GEOMETRY-32: refactoring BSP and related classes; also addresses issues GEOMETRY-32 (simplify Transform interface), GEOMETRY-33 (Region API), and GEOMETRY-34 (SubHyperplane optimized implementations)
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/Embedding.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/Embedding.java
new file mode 100644
index 0000000..cf258c8
--- /dev/null
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/Embedding.java
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/** This interface defines mappings between a space and one of its subspaces.
+
+ * <p>Subspaces are the lower-dimension subsets of a space. For example,
+ * in an n-dimension space, the subspaces are the (n-1) dimension space,
+ * the (n-2) dimension space, and so on. This interface can be used regardless
+ * of the difference in number of dimensions between the space and the target
+ * subspace. For example, a line in 3D Euclidean space can use this interface
+ * to map directly from 3D Euclidean space to 1D Euclidean space (ie, the location
+ * along the line).</p>
+ *
+ * @param <P> Point type defining the embedding space.
+ * @param <S> Point type defining the embedded subspace.
+ */
+public interface Embedding<P extends Point<P>, S extends Point<S>> {
+
+    /** Transform a space point into a subspace point.
+     * @param point n-dimension point of the space
+     * @return lower-dimension point of the subspace corresponding to
+     *      the specified space point
+     * @see #toSpace
+     */
+    S toSubspace(P point);
+
+    /** Transform a collection of space points into subspace points.
+     * @param points collection of n-dimension points to transform
+     * @return collection of transformed lower-dimension points.
+     * @see #toSubspace(Point)
+     */
+    default List<S> toSubspace(final Collection<P> points) {
+        return points.stream().map(this::toSubspace).collect(Collectors.toList());
+    }
+
+    /** Transform a subspace point into a space point.
+     * @param point lower-dimension point of the subspace
+     * @return n-dimension point of the space corresponding to the
+     *      specified subspace point
+     * @see #toSubspace(Point)
+     */
+    P toSpace(S point);
+
+    /** Transform a collection of subspace points into space points.
+     * @param points collection of lower-dimension points to transform
+     * @return collection of transformed n-dimension points.
+     * @see #toSpace(Point)
+     */
+    default List<P> toSpace(final Collection<S> points) {
+        return points.stream().map(this::toSpace).collect(Collectors.toList());
+    }
+}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/Geometry.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/Geometry.java
index 819a9e5..37c40aa 100644
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/Geometry.java
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/Geometry.java
@@ -23,8 +23,8 @@
     /** Alias for {@link Math#PI}, placed here for completeness. */
     public static final double PI = Math.PI;
 
-    /** Constant value for {@code -pi} */
-    public static final double MINUS_PI = - Math.PI;
+    /** Constant value for {@code -pi}. */
+    public static final double MINUS_PI = -Math.PI;
 
     /** Constant value for {@code 2*pi}. */
     public static final double TWO_PI = 2.0 * Math.PI;
@@ -36,7 +36,7 @@
     public static final double HALF_PI = 0.5 * Math.PI;
 
     /** Constant value for {@code - pi/2}. */
-    public static final double MINUS_HALF_PI = - 0.5 * Math.PI;
+    public static final double MINUS_HALF_PI = -0.5 * Math.PI;
 
     /** Constant value for {@code  3*pi/2}. */
     public static final double THREE_HALVES_PI = 1.5 * Math.PI;
@@ -46,6 +46,6 @@
      */
     public static final double ZERO_PI = 0.0;
 
-    /** Private constructor */
+    /** Private constructor. */
     private Geometry() {}
 }
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/Region.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/Region.java
new file mode 100644
index 0000000..233cd4b
--- /dev/null
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/Region.java
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core;
+
+/** Interface representing a region in a space. A region partitions a space
+ * into sets of points lying on the inside, outside, and boundary.
+ * @param <P> Point implementation type
+ */
+public interface Region<P extends Point<P>> {
+
+    /** Return true if the region spans the entire space. In other words,
+     * a region is full if no points in the space are classified as
+     * {@link RegionLocation#OUTSIDE outside}.
+     * @return true if the region spans the entire space
+     */
+    boolean isFull();
+
+    /** Return true if the region is completely empty, ie all points in
+     * the space are classified as {@link RegionLocation#OUTSIDE outside}.
+     * @return true if the region is empty
+     */
+    boolean isEmpty();
+
+    /** Get the size of the region. The meaning of this will vary depending on
+     * the space and dimension of the region. For example, in Euclidean space,
+     * this will be a length in 1D, an area in 2D, and a volume in 3D.
+     * @return the size of the region
+     */
+    double getSize();
+
+    /** Get the size of the boundary of the region. The size is a value in
+     * the {@code d-1} dimension space. For example, in Euclidean space,
+     * this will be a length in 2D and an area in 3D.
+     * @return the size of the boundary of the region
+     */
+    double getBoundarySize();
+
+    /** Get the barycenter of the region or null if none exists. A barycenter
+     * will not exist for empty or infinite regions.
+     * @return the barycenter of the region or null if none exists
+     */
+    P getBarycenter();
+
+    /** Classify the given point with respect to the region.
+     * @param pt the point to classify
+     * @return the location of the point with respect to the region
+     */
+    RegionLocation classify(P pt);
+
+    /** Return true if the given point is on the inside or boundary
+     * of the region.
+     * @param pt the point to test
+     * @return true if the point is on the inside or boundary of the region
+     */
+    default boolean contains(P pt) {
+        final RegionLocation location = classify(pt);
+        return location != null && location != RegionLocation.OUTSIDE;
+    }
+
+    /** Project a point onto the boundary of the region. Null is returned if
+     * the region contains no boundaries (ie, is either {@link #isFull() full}
+     * or {@link #isEmpty() empty}).
+     * @param pt pt to project
+     * @return projection of the point on the boundary of the region or null
+     *      if the region does not contain any boundaries
+     */
+    P project(P pt);
+}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Side.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/RegionLocation.java
similarity index 62%
copy from commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Side.java
copy to commons-geometry-core/src/main/java/org/apache/commons/geometry/core/RegionLocation.java
index 046defe..19f5068 100644
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Side.java
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/RegionLocation.java
@@ -14,23 +14,26 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.commons.geometry.core.partitioning;
+package org.apache.commons.geometry.core;
 
-/** Enumerate representing the location of an element with respect to an
- * {@link Hyperplane hyperplane} of a space.
+/** Enumeration containing the possible locations of a point with
+ * respect to a region.
+ * @see Region
  */
-public enum Side {
+public enum RegionLocation {
 
-    /** Code for the plus side of the hyperplane. */
-    PLUS,
+    /** Value indicating that a point lies on the inside of
+     * a region.
+     */
+    INSIDE,
 
-    /** Code for the minus side of the hyperplane. */
-    MINUS,
+    /** Value indicating that a point lies on the outside of
+     * a region.
+     */
+    OUTSIDE,
 
-    /** Code for elements crossing the hyperplane from plus to minus side. */
-    BOTH,
-
-    /** Code for the hyperplane itself. */
-    HYPER;
-
+    /** Value indicating that a point lies on the boundary of
+     * a region.
+     */
+    BOUNDARY
 }
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/Spatial.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/Spatial.java
index c6f76c1..ae5e277 100644
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/Spatial.java
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/Spatial.java
@@ -38,4 +38,10 @@
      *      are NaN
      */
     boolean isInfinite();
+
+    /** Returns true if all values in this element are finite, meaning
+     * they are not NaN or infinite.
+     * @return true if all values in this element are finite
+     */
+    boolean isFinite();
 }
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/Transform.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/Transform.java
new file mode 100644
index 0000000..7eedd67
--- /dev/null
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/Transform.java
@@ -0,0 +1,56 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core;
+
+import java.util.function.Function;
+
+/** This interface represents an <em>inversible affine transform</em> in a space.
+ * Common examples of this type of transform in Euclidean space include
+ * scalings, translations, and rotations.
+ *
+ * <h2>Implementation Note</h2>
+ * <p>Implementations are responsible for ensuring that they meet the geometric
+ * requirements outlined above. These are:
+ * <ol>
+ *      <li>The transform must be <a href="https://en.wikipedia.org/wiki/Affine_transformation">affine</a>.
+ *      This means that points and parallel lines must be preserved by the transformation. For example,
+ *      a translation or rotation in Euclidean 3D space meets this requirement because a mapping exists for
+ *      all points and lines that are parallel before the transform remain parallel afterwards.
+ *      However, a projective transform that causes parallel lines to meet at a point in infinity does not.
+ *      </li>
+ *      <li>The transform must be <em>inversible</em>. An inverse transform must exist that will return
+ *      the original point if given the transformed point. In other words, for a transform {@code t}, there
+ *      must exist an inverse {@code inv} such that {@code inv.apply(t.apply(pt))} returns a point equal to
+ *      the input point {@code pt}.
+ *      </li>
+ * </ol>
+ * Implementations that do not meet these requirements cannot be expected to produce correct results in
+ * algorithms that use this interface.
+ *
+ * @param <P> Point implementation type
+ * @see <a href="https://en.wikipedia.org/wiki/Affine_transformation">Affine Space</a>
+ */
+public interface Transform<P extends Point<P>> extends Function<P, P> {
+
+    /** Return true if the transform preserves the orientation of the space.
+     * For example, in Euclidean 2D space, this will be true for translations,
+     * rotations, and scalings but will be false for reflections.
+     * @return true if the transform preserves the orientation of the space
+     * @see <a href="https://en.wikipedia.org/wiki/Orientation_(vector_space)">Orientation</a>
+     */
+    boolean preservesOrientation();
+}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/Vector.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/Vector.java
index 873e24e..c62abe7 100644
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/Vector.java
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/Vector.java
@@ -16,8 +16,6 @@
  */
 package org.apache.commons.geometry.core;
 
-import org.apache.commons.geometry.core.exception.IllegalNormException;
-
 /** Interface representing a vector in a vector space or displacement vectors
  * in an affine space.
  *
@@ -96,7 +94,8 @@
     /** Get a normalized vector aligned with the instance. The returned
      * vector has a magnitude of 1.
      * @return a new normalized vector
-     * @exception IllegalNormException if the norm is zero, NaN, or infinite
+     * @throws org.apache.commons.geometry.core.exception.IllegalNormException if the norm is
+     *      zero, NaN, or infinite
      */
     V normalize();
 
@@ -131,7 +130,8 @@
     /** Compute the angular separation between two vectors in radians.
      * @param v other vector
      * @return angular separation between this instance and v in radians
-     * @exception IllegalNormException if either vector has a zero, NaN, or infinite norm
+     * @throws org.apache.commons.geometry.core.exception.IllegalNormException if either
+     *      vector has a zero, NaN, or infinite norm
      */
     double angle(V v);
 }
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/exception/GeometryException.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/exception/GeometryException.java
index 57462be..d37bef8 100644
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/exception/GeometryException.java
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/exception/GeometryException.java
@@ -22,7 +22,7 @@
  */
 public class GeometryException extends RuntimeException {
 
-    /** Serializable version identifier */
+    /** Serializable version identifier. */
     private static final long serialVersionUID = 20180909L;
 
     /** Simple constructor with error message.
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/exception/GeometryValueException.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/exception/GeometryValueException.java
index a5f7d3f..592045b 100644
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/exception/GeometryValueException.java
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/exception/GeometryValueException.java
@@ -21,7 +21,7 @@
  */
 public class GeometryValueException extends GeometryException {
 
-    /** Serializable version identifier */
+    /** Serializable version identifier. */
     private static final long serialVersionUID = 20190210L;
 
     /** Simple constructor with error message.
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/exception/IllegalNormException.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/exception/IllegalNormException.java
index 6993704..ad65322 100644
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/exception/IllegalNormException.java
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/exception/IllegalNormException.java
@@ -21,7 +21,7 @@
  */
 public class IllegalNormException extends GeometryValueException {
 
-    /** Serializable version identifier */
+    /** Serializable version identifier. */
     private static final long serialVersionUID = 20180909L;
 
     /** Simple constructor accepting the illegal norm value.
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/Equivalency.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/Equivalency.java
new file mode 100644
index 0000000..cd52a73
--- /dev/null
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/Equivalency.java
@@ -0,0 +1,33 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.internal;
+
+/** Interface for determining equivalency, not exact equality, between
+ * two objects. This is performs a function similar to {@link Object#equals(Object)}
+ * but allows fuzzy comparisons to occur instead of strict equality. This is
+ * especially useful when comparing objects with floating point values that
+ * may not be exact but are operationally equivalent.
+ * @param <T> The type being compared
+ */
+public interface Equivalency<T> {
+
+    /** Determine if this object is equivalent (effectively equal) to the argument.
+     * @param other the object to compare for equivalency
+     * @return true if this object is equivalent to the argument; false otherwise
+     */
+    boolean eq(T other);
+}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/GeometryInternalError.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/GeometryInternalError.java
index 219ccdb..bc52f62 100644
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/GeometryInternalError.java
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/GeometryInternalError.java
@@ -27,7 +27,7 @@
             "error in the algorithm implementation than in the calling code or data. Please file a bug report " +
             "with the developers.";
 
-    /** Serializable version identifier */
+    /** Serializable version identifier. */
     private static final long serialVersionUID = 20180913L;
 
     /** Simple constructor with a default error message.
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/IteratorTransform.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/IteratorTransform.java
new file mode 100644
index 0000000..b60d40d
--- /dev/null
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/IteratorTransform.java
@@ -0,0 +1,92 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.internal;
+
+import java.util.Collection;
+import java.util.Deque;
+import java.util.Iterator;
+import java.util.LinkedList;
+import java.util.NoSuchElementException;
+
+/** Class that wraps another iterator, converting each input iterator value into
+ * one or more output iterator values.
+ * @param <I> Input iterator type
+ * @param <T> Output iterator type
+ */
+public abstract class IteratorTransform<I, T> implements Iterator<T> {
+
+    /** Input iterator instance that supplies the input values for this instance. */
+    private final Iterator<I> inputIterator;
+
+    /** Output value queue. */
+    private final Deque<T> outputQueue = new LinkedList<>();
+
+    /** Create a new instance that uses the given iterator as the input source.
+     * @param inputIterator iterator supplying input values
+     */
+    public IteratorTransform(final Iterator<I> inputIterator) {
+        this.inputIterator = inputIterator;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean hasNext() {
+        return loadNextOutput();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public T next() {
+        if (outputQueue.isEmpty()) {
+            throw new NoSuchElementException();
+        }
+
+        return outputQueue.removeFirst();
+    }
+
+    /** Load the next output values into the output queue. Returns true if the output queue
+     * contains more entries.
+     * @return true if more output values are available
+     */
+    private boolean loadNextOutput() {
+        while (outputQueue.isEmpty() && inputIterator.hasNext()) {
+            acceptInput(inputIterator.next());
+        }
+
+        return !outputQueue.isEmpty();
+    }
+
+    /** Add a value to the output queue.
+     * @param value value to add to the output queue
+     */
+    protected void addOutput(final T value) {
+        outputQueue.add(value);
+    }
+
+    /** Add multiple values to the output queue.
+     * @param values values to add to the output queue
+     */
+    protected void addAllOutput(final Collection<T> values) {
+        outputQueue.addAll(values);
+    }
+
+    /** Accept a value from the input iterator. This method should take
+     * the input value and add one or more values to the output queue.
+     * @param input value from the input iterator
+     */
+    protected abstract void acceptInput(I input);
+}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/SimpleTupleFormat.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/SimpleTupleFormat.java
index 15637e0..ce0c9ac 100644
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/SimpleTupleFormat.java
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/SimpleTupleFormat.java
@@ -25,7 +25,7 @@
     /** Default value separator string. */
     private static final String DEFAULT_SEPARATOR = ",";
 
-    /** Space character */
+    /** Space character. */
     private static final String SPACE = " ";
 
     /** Static instance configured with default values. Tuples in this format
@@ -34,13 +34,13 @@
     private static final SimpleTupleFormat DEFAULT_INSTANCE =
             new SimpleTupleFormat(",", "(", ")");
 
-    /** String separating tuple values */
+    /** String separating tuple values. */
     private final String separator;
 
-    /** String used to signal the start of a tuple; may be null */
+    /** String used to signal the start of a tuple; may be null. */
     private final String prefix;
 
-    /** String used to signal the end of a tuple; may be null */
+    /** String used to signal the end of a tuple; may be null. */
     private final String suffix;
 
     /** Constructs a new instance with the default string separator (a comma)
@@ -298,8 +298,7 @@
             matchSequence(str, separator, pos);
 
             return value;
-        }
-        catch (NumberFormatException exc) {
+        } catch (NumberFormatException exc) {
             fail(String.format("unable to parse number from string \"%s\"", substr), str, pos, exc);
             return 0.0; // for the compiler
         }
@@ -342,7 +341,7 @@
         int idx = pos.getIndex();
         final int len = str.length();
 
-        for (; idx<len; ++idx) {
+        for (; idx < len; ++idx) {
             if (!Character.isWhitespace(str.codePointAt(idx))) {
                 break;
             }
@@ -369,7 +368,7 @@
 
         int i = idx;
         int s = 0;
-        for (; i<inputLength && s<seqLength; ++i, ++s) {
+        for (; i < inputLength && s < seqLength; ++i, ++s) {
             if (str.codePointAt(i) != seq.codePointAt(s)) {
                 break;
             }
@@ -444,7 +443,7 @@
      */
     private static class TupleParseException extends IllegalArgumentException {
 
-        /** Serializable version identifier */
+        /** Serializable version identifier. */
         private static final long serialVersionUID = 20180629;
 
         /** Simple constructor.
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/AbstractConvexHyperplaneBoundedRegion.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/AbstractConvexHyperplaneBoundedRegion.java
new file mode 100644
index 0000000..d920335
--- /dev/null
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/AbstractConvexHyperplaneBoundedRegion.java
@@ -0,0 +1,379 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.function.Function;
+
+import org.apache.commons.geometry.core.Point;
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.exception.GeometryException;
+
+/** Base class for convex hyperplane-bounded regions. This class provides generic implementations of many
+ * algorithms related to convex regions.
+ * @param <P> Point implementation type
+ * @param <S> Convex subhyperplane implementation type
+ */
+public abstract class AbstractConvexHyperplaneBoundedRegion<P extends Point<P>, S extends ConvexSubHyperplane<P>>
+    implements HyperplaneBoundedRegion<P>, Serializable {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20190812L;
+
+    /** List of boundaries for the region. */
+    private final List<S> boundaries;
+
+    /** Simple constructor. Callers are responsible for ensuring that the given list of subhyperplanes
+     * represent a valid convex region boundary. No validation is performed.
+     * @param boundaries the boundaries of the convex region
+     */
+    protected AbstractConvexHyperplaneBoundedRegion(final List<S> boundaries) {
+        this.boundaries = Collections.unmodifiableList(boundaries);
+    }
+
+    /** Get the boundaries of the convex region. The exact ordering of the boundaries
+     * is not guaranteed.
+     * @return the boundaries of the convex region
+     */
+    public List<S> getBoundaries() {
+        return boundaries;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isFull() {
+        // no boundaries => no outside
+        return boundaries.isEmpty();
+    }
+
+    /** {@inheritDoc}
+     *
+     * <p>This method always returns false.</p>
+     */
+    @Override
+    public boolean isEmpty() {
+        return false;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public double getBoundarySize() {
+        double sum = 0.0;
+        for (final S boundary : boundaries) {
+            sum += boundary.getSize();
+        }
+
+        return sum;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public RegionLocation classify(P pt) {
+        boolean isOn = false;
+
+        HyperplaneLocation loc;
+        for (final S boundary : boundaries) {
+            loc = boundary.getHyperplane().classify(pt);
+
+            if (loc == HyperplaneLocation.PLUS) {
+                return RegionLocation.OUTSIDE;
+            } else if (loc == HyperplaneLocation.ON) {
+                isOn = true;
+            }
+        }
+
+        return isOn ? RegionLocation.BOUNDARY : RegionLocation.INSIDE;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public P project(P pt) {
+
+        P projected;
+        double dist;
+
+        P closestPt = null;
+        double closestDist = Double.POSITIVE_INFINITY;
+
+        for (final S boundary : boundaries) {
+            projected = boundary.closest(pt);
+            dist = pt.distance(projected);
+
+            if (projected != null && (closestPt == null || dist < closestDist)) {
+                closestPt = projected;
+                closestDist = dist;
+            }
+        }
+
+        return closestPt;
+    }
+
+    /** Trim the given convex subhyperplane to the portion contained inside this instance.
+     * @param convexSubHyperplane convex subhyperplane to trim. Null is returned if the subhyperplane
+     * does not intersect the instance.
+     * @return portion of the argument that lies entirely inside the region represented by
+     *      this instance, or null if it does not intersect.
+     */
+    public ConvexSubHyperplane<P> trim(final ConvexSubHyperplane<P> convexSubHyperplane) {
+        ConvexSubHyperplane<P> remaining = convexSubHyperplane;
+        for (final S boundary : boundaries) {
+            remaining = remaining.split(boundary.getHyperplane()).getMinus();
+            if (remaining == null) {
+                break;
+            }
+        }
+
+        return remaining;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String toString() {
+        final StringBuilder sb = new StringBuilder();
+        sb.append(this.getClass().getSimpleName())
+            .append("[boundaries= ")
+            .append(boundaries);
+
+        return sb.toString();
+    }
+
+    /** Generic, internal transform method. Subclasses should use this to implement their own transform methods.
+     * @param transform the transform to apply to the instance
+     * @param thisInstance a reference to the current instance; this is passed as
+     *      an argument in order to allow it to be a generic type
+     * @param subhpType the type used for the boundary subhyperplanes
+     * @param factory function used to create new convex region instances
+     * @param <R> Region implementation type
+     * @return the result of the transform operation
+     */
+    protected <R extends AbstractConvexHyperplaneBoundedRegion<P, S>> R transformInternal(
+            final Transform<P> transform, final R thisInstance, final Class<S> subhpType,
+            final Function<List<S>, R> factory) {
+
+        if (isFull()) {
+            return thisInstance;
+        }
+
+        final List<S> origBoundaries = getBoundaries();
+
+        final int size = origBoundaries.size();
+        final List<S> tBoundaries = new ArrayList<>(size);
+
+        // determine if the hyperplanes should be reversed
+        final S boundary = origBoundaries.get(0);
+        ConvexSubHyperplane<P> tBoundary = boundary.transform(transform);
+
+        final boolean reverseDirection = swapsInsideOutside(transform);
+
+        // transform all of the segments
+        if (reverseDirection) {
+            tBoundary = tBoundary.reverse();
+        }
+        tBoundaries.add(subhpType.cast(tBoundary));
+
+        for (int i = 1; i < origBoundaries.size(); ++i) {
+            tBoundary = origBoundaries.get(i).transform(transform);
+
+            if (reverseDirection) {
+                tBoundary = tBoundary.reverse();
+            }
+
+            tBoundaries.add(subhpType.cast(tBoundary));
+        }
+
+        return factory.apply(tBoundaries);
+    }
+
+    /** Return true if the given transform swaps the inside and outside of
+     * the region.
+     *
+     * <p>The default behavior of this method is to return true if the transform
+     * does not preserve spatial orientation (ie, {@link Transform#preservesOrientation()}
+     * is false). Subclasses may need to override this method to implement the correct
+     * behavior for their space and dimension.</p>
+     * @param transform transform to check
+     * @return true if the given transform swaps the interior and exterior of
+     *      the region
+     */
+    protected boolean swapsInsideOutside(final Transform<P> transform) {
+        return !transform.preservesOrientation();
+    }
+
+    /** Generic, internal split method. Subclasses should call this from their {@link #split(Hyperplane)} methods.
+     * @param splitter splitting hyperplane
+     * @param thisInstance a reference to the current instance; this is passed as
+     *      an argument in order to allow it to be a generic type
+     * @param subhpType the type used for the boundary subhyperplanes
+     * @param factory function used to create new convex region instances
+     * @param <R> Region implementation type
+     * @return the result of the split operation
+     */
+    protected <R extends AbstractConvexHyperplaneBoundedRegion<P, S>> Split<R> splitInternal(
+            final Hyperplane<P> splitter, final R thisInstance, final Class<S> subhpType,
+            final Function<List<S>, R> factory) {
+
+        if (isFull()) {
+            final R minus = factory.apply(Arrays.asList(subhpType.cast(splitter.span())));
+            final R plus = factory.apply(Arrays.asList(subhpType.cast(splitter.reverse().span())));
+
+            return new Split<>(minus, plus);
+        } else {
+            final ConvexSubHyperplane<P> trimmedSplitter = trim(splitter.span());
+
+            if (trimmedSplitter == null) {
+                // The splitter lies entirely outside of the region; we need
+                // to determine whether we lie on the plus or minus side of the splitter.
+                // We can use the first boundary to determine this. If the boundary is entirely
+                // on the minus side of the splitter or lies directly on the splitter and has
+                // the same orientation, then the area lies on the minus side of the splitter.
+                // Otherwise, it lies on the plus side.
+                final ConvexSubHyperplane<P> testSegment = boundaries.get(0);
+                final SplitLocation testLocation = testSegment.split(splitter).getLocation();
+
+                if (SplitLocation.MINUS == testLocation ||
+                        (SplitLocation.NEITHER == testLocation &&
+                            splitter.similarOrientation(testSegment.getHyperplane()))) {
+                    return new Split<>(thisInstance, null);
+                }
+
+                return new Split<>(null, thisInstance);
+            }
+
+            final List<S> minusBoundaries = new ArrayList<>();
+            final List<S> plusBoundaries = new ArrayList<>();
+
+            Split<? extends ConvexSubHyperplane<P>> split;
+            ConvexSubHyperplane<P> minusBoundary;
+            ConvexSubHyperplane<P> plusBoundary;
+
+            for (final S boundary : boundaries) {
+                split = boundary.split(splitter);
+
+                minusBoundary = split.getMinus();
+                plusBoundary = split.getPlus();
+
+                if (minusBoundary != null) {
+                    minusBoundaries.add(subhpType.cast(minusBoundary));
+                }
+
+                if (plusBoundary != null) {
+                    plusBoundaries.add(subhpType.cast(plusBoundary));
+                }
+            }
+
+            minusBoundaries.add(subhpType.cast(trimmedSplitter));
+            plusBoundaries.add(subhpType.cast(trimmedSplitter.reverse()));
+
+            return new Split<>(factory.apply(minusBoundaries), factory.apply(plusBoundaries));
+        }
+    }
+
+    /** Internal class encapsulating the logic for building convex region boundaries from collections of
+     * hyperplanes.
+     * @param <P> Point implementation type
+     * @param <S> ConvexSubHyperplane implementation type
+     */
+    protected static class ConvexRegionBoundaryBuilder<P extends Point<P>, S extends ConvexSubHyperplane<P>> {
+
+        /** Convex subhyperplane implementation type. */
+        private final Class<S> subhyperplaneType;
+
+        /** Construct a new instance for building convex region boundaries with the given convex subhyperplane
+         * implementation type.
+         * @param subhyperplaneType Convex subhyperplane implementation type
+         */
+        public ConvexRegionBoundaryBuilder(final Class<S> subhyperplaneType) {
+            this.subhyperplaneType = subhyperplaneType;
+        }
+
+        /** Compute a list of convex subhyperplanes representing the boundaries of the convex region
+         * bounded by the given collection of hyperplanes.
+         * @param bounds hyperplanes defining the convex region
+         * @return a list of convex subhyperplanes representing the boundaries of the convex region
+         * @throws GeometryException if the given hyperplanes do not form a convex region
+         */
+        public List<S> build(final Iterable<? extends Hyperplane<P>> bounds) {
+
+            final List<S> boundaries = new ArrayList<>();
+
+            // cut each hyperplane by every other hyperplane in order to get the subplane boundaries
+            boolean notConvex = false;
+            int outerIdx = 0;
+            ConvexSubHyperplane<P> subhp;
+
+            for (final Hyperplane<P> hp : bounds) {
+                ++outerIdx;
+                subhp = hp.span();
+
+                int innerIdx = 0;
+                for (final Hyperplane<P> splitter : bounds) {
+                    ++innerIdx;
+
+                    if (hp != splitter) {
+                        final Split<? extends ConvexSubHyperplane<P>> split = subhp.split(splitter);
+
+                        if (split.getLocation() == SplitLocation.NEITHER) {
+                            if (hp.similarOrientation(splitter)) {
+                                // two or more splitters are the equivalent; only
+                                // use the segment from the first one
+                                if (outerIdx > innerIdx) {
+                                    subhp = null;
+                                }
+                            } else {
+                                // two or more splitters are coincident and have opposite
+                                // orientations, meaning that no area is on the minus side
+                                // of both
+                                notConvex = true;
+                                break;
+                            }
+                        } else {
+                            subhp = subhp.split(splitter).getMinus();
+                        }
+
+                        if (subhp == null) {
+                            break;
+                        }
+                    } else if (outerIdx > innerIdx) {
+                        // this hyperplane is duplicated in the list; skip all but the
+                        // first insertion of its subhyperplane
+                        subhp = null;
+                        break;
+                    }
+                }
+
+                if (notConvex) {
+                    break;
+                }
+
+                if (subhp != null) {
+                    boundaries.add(subhyperplaneType.cast(subhp));
+                }
+            }
+
+            if (notConvex || (outerIdx > 0 && boundaries.isEmpty())) {
+                throw new GeometryException("Bounding hyperplanes do not produce a convex region: " + bounds);
+            }
+
+            return boundaries;
+        }
+    }
+}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/AbstractEmbeddingSubHyperplane.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/AbstractEmbeddingSubHyperplane.java
new file mode 100644
index 0000000..6bfb2b8
--- /dev/null
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/AbstractEmbeddingSubHyperplane.java
@@ -0,0 +1,110 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning;
+
+import java.io.Serializable;
+
+import org.apache.commons.geometry.core.Point;
+import org.apache.commons.geometry.core.Region;
+import org.apache.commons.geometry.core.RegionLocation;
+
+/** Abstract base class for subhyperplane implementations that embed a lower-dimension region through
+ * an embedding hyperplane.
+ * @param <P> Point implementation type
+ * @param <S> Subspace point implementation type
+ * @param <H> Hyperplane containing the embedded subspace
+ */
+public abstract class AbstractEmbeddingSubHyperplane<
+    P extends Point<P>,
+    S extends Point<S>,
+    H extends EmbeddingHyperplane<P, S>> implements SubHyperplane<P>, Serializable {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20190729L;
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isFull() {
+        return getSubspaceRegion().isFull();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isEmpty() {
+        return getSubspaceRegion().isEmpty();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isInfinite() {
+        return Double.isInfinite(getSize());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isFinite() {
+        return !isInfinite();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public double getSize() {
+        return getSubspaceRegion().getSize();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public RegionLocation classify(P point) {
+        final H hyperplane = getHyperplane();
+
+        if (hyperplane.contains(point)) {
+            final S subPoint = hyperplane.toSubspace(point);
+
+            return getSubspaceRegion().classify(subPoint);
+        }
+        return RegionLocation.OUTSIDE;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public P closest(P point) {
+        final H hyperplane = getHyperplane();
+
+        final P projected = hyperplane.project(point);
+        final S subProjected = hyperplane.toSubspace(projected);
+
+        final Region<S> region = getSubspaceRegion();
+        if (region.contains(subProjected)) {
+            return projected;
+        }
+
+        final S subRegionBoundary = region.project(subProjected);
+        if (subRegionBoundary != null) {
+            return hyperplane.toSpace(subRegionBoundary);
+        }
+        return null;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public abstract H getHyperplane();
+
+    /** Return the embedded subspace region for this instance.
+     * @return the embedded subspace region for this instance
+     */
+    public abstract HyperplaneBoundedRegion<S> getSubspaceRegion();
+}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/AbstractHyperplane.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/AbstractHyperplane.java
new file mode 100644
index 0000000..03d1723
--- /dev/null
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/AbstractHyperplane.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning;
+
+import java.io.Serializable;
+
+import org.apache.commons.geometry.core.Point;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+
+/** Base class for hyperplane implementations.
+ * @param <P> Point implementation type
+ */
+public abstract class AbstractHyperplane<P extends Point<P>> implements Hyperplane<P>, Serializable {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 1L;
+
+    /** Precision object used to perform floating point comparisons. */
+    private final DoublePrecisionContext precision;
+
+    /** Construct an instance using the given precision context.
+     * @param precision object used to perform floating point comparisons
+     */
+    protected AbstractHyperplane(final DoublePrecisionContext precision) {
+        this.precision = precision;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public HyperplaneLocation classify(final P point) {
+        final double offsetValue = offset(point);
+
+        final int cmp = precision.sign(offsetValue);
+        if (cmp > 0) {
+            return HyperplaneLocation.PLUS;
+        } else if (cmp < 0) {
+            return HyperplaneLocation.MINUS;
+        }
+        return HyperplaneLocation.ON;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean contains(final P point) {
+        final HyperplaneLocation loc = classify(point);
+        return loc == HyperplaneLocation.ON;
+    }
+
+    /** Get the precision object used to perform floating point
+     * comparisons for this instance.
+     * @return the precision object for this instance
+     */
+    public DoublePrecisionContext getPrecision() {
+        return precision;
+    }
+}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/AbstractRegion.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/AbstractRegion.java
deleted file mode 100644
index d3047c8..0000000
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/AbstractRegion.java
+++ /dev/null
@@ -1,516 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning;
-
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Comparator;
-import java.util.HashMap;
-import java.util.Iterator;
-import java.util.Map;
-import java.util.TreeSet;
-
-import org.apache.commons.geometry.core.Point;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-
-/** Abstract class for all regions, independent of geometry type or dimension.
-
- * @param <P> Point type defining the space
- * @param <S> Point type defining the sub-space
- */
-public abstract class AbstractRegion<P extends Point<P>, S extends Point<S>> implements Region<P> {
-
-    /** Inside/Outside BSP tree. */
-    private BSPTree<P> tree;
-
-    /** Precision context used to determine floating point equality. */
-    private final DoublePrecisionContext precision;
-
-    /** Size of the instance. */
-    private double size;
-
-    /** Barycenter. */
-    private P barycenter;
-
-    /** Build a region representing the whole space.
-     * @param precision precision context used to compare floating point numbers
-     */
-    protected AbstractRegion(final DoublePrecisionContext precision) {
-        this.tree      = new BSPTree<>(Boolean.TRUE);
-        this.precision = precision;
-    }
-
-    /** Build a region from an inside/outside BSP tree.
-     * <p>The leaf nodes of the BSP tree <em>must</em> have a
-     * {@code Boolean} attribute representing the inside status of
-     * the corresponding cell (true for inside cells, false for outside
-     * cells). In order to avoid building too many small objects, it is
-     * recommended to use the predefined constants
-     * {@code Boolean.TRUE} and {@code Boolean.FALSE}. The
-     * tree also <em>must</em> have either null internal nodes or
-     * internal nodes representing the boundary as specified in the
-     * {@link #getTree getTree} method).</p>
-     * @param tree inside/outside BSP tree representing the region
-     * @param precision precision context used to compare floating point values
-     */
-    protected AbstractRegion(final BSPTree<P> tree, final DoublePrecisionContext precision) {
-        this.tree      = tree;
-        this.precision = precision;
-    }
-
-    /** Build a Region from a Boundary REPresentation (B-rep).
-     * <p>The boundary is provided as a collection of {@link
-     * SubHyperplane sub-hyperplanes}. Each sub-hyperplane has the
-     * interior part of the region on its minus side and the exterior on
-     * its plus side.</p>
-     * <p>The boundary elements can be in any order, and can form
-     * several non-connected sets (like for example polygons with holes
-     * or a set of disjoints polyhedrons considered as a whole). In
-     * fact, the elements do not even need to be connected together
-     * (their topological connections are not used here). However, if the
-     * boundary does not really separate an inside open from an outside
-     * open (open having here its topological meaning), then subsequent
-     * calls to the {@link #checkPoint(Point) checkPoint} method will not be
-     * meaningful anymore.</p>
-     * <p>If the boundary is empty, the region will represent the whole
-     * space.</p>
-     * @param boundary collection of boundary elements, as a
-     * collection of {@link SubHyperplane SubHyperplane} objects
-     * @param precision precision context used to compare floating point values
-     */
-    protected AbstractRegion(final Collection<SubHyperplane<P>> boundary, final DoublePrecisionContext precision) {
-
-        this.precision = precision;
-
-        if (boundary.size() == 0) {
-
-            // the tree represents the whole space
-            tree = new BSPTree<>(Boolean.TRUE);
-
-        } else {
-
-            // sort the boundary elements in decreasing size order
-            // (we don't want equal size elements to be removed, so
-            // we use a trick to fool the TreeSet)
-            final TreeSet<SubHyperplane<P>> ordered = new TreeSet<>(new Comparator<SubHyperplane<P>>() {
-                /** {@inheritDoc} */
-                @Override
-                public int compare(final SubHyperplane<P> o1, final SubHyperplane<P> o2) {
-                    final double size1 = o1.getSize();
-                    final double size2 = o2.getSize();
-                    return (size2 < size1) ? -1 : ((o1 == o2) ? 0 : +1);
-                }
-            });
-            ordered.addAll(boundary);
-
-            // build the tree top-down
-            tree = new BSPTree<>();
-            insertCuts(tree, ordered);
-
-            // set up the inside/outside flags
-            tree.visit(new BSPTreeVisitor<P>() {
-
-                /** {@inheritDoc} */
-                @Override
-                public Order visitOrder(final BSPTree<P> node) {
-                    return Order.PLUS_SUB_MINUS;
-                }
-
-                /** {@inheritDoc} */
-                @Override
-                public void visitInternalNode(final BSPTree<P> node) {
-                }
-
-                /** {@inheritDoc} */
-                @Override
-                public void visitLeafNode(final BSPTree<P> node) {
-                    if (node.getParent() == null || node == node.getParent().getMinus()) {
-                        node.setAttribute(Boolean.TRUE);
-                    } else {
-                        node.setAttribute(Boolean.FALSE);
-                    }
-                }
-            });
-
-        }
-
-    }
-
-    /** Build a convex region from an array of bounding hyperplanes.
-     * @param hyperplanes array of bounding hyperplanes (if null, an
-     * empty region will be built)
-     * @param precision precision context used to compare floating point values
-     */
-    public AbstractRegion(final Hyperplane<P>[] hyperplanes, final DoublePrecisionContext precision) {
-        this.precision = precision;
-        if ((hyperplanes == null) || (hyperplanes.length == 0)) {
-            tree = new BSPTree<>(Boolean.FALSE);
-        } else {
-
-            // use the first hyperplane to build the right class
-            tree = hyperplanes[0].wholeSpace().getTree(false);
-
-            // chop off parts of the space
-            BSPTree<P> node = tree;
-            node.setAttribute(Boolean.TRUE);
-            for (final Hyperplane<P> hyperplane : hyperplanes) {
-                if (node.insertCut(hyperplane)) {
-                    node.setAttribute(null);
-                    node.getPlus().setAttribute(Boolean.FALSE);
-                    node = node.getMinus();
-                    node.setAttribute(Boolean.TRUE);
-                }
-            }
-
-        }
-
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public abstract AbstractRegion<P, S> buildNew(BSPTree<P> newTree);
-
-    /** Get the object used to determine floating point equality for this region.
-     * @return the floating point precision context for the instance
-     */
-    public DoublePrecisionContext getPrecision() {
-        return precision;
-    }
-
-    /** Recursively build a tree by inserting cut sub-hyperplanes.
-     * @param node current tree node (it is a leaf node at the beginning
-     * of the call)
-     * @param boundary collection of edges belonging to the cell defined
-     * by the node
-     */
-    private void insertCuts(final BSPTree<P> node, final Collection<SubHyperplane<P>> boundary) {
-
-        final Iterator<SubHyperplane<P>> iterator = boundary.iterator();
-
-        // build the current level
-        Hyperplane<P> inserted = null;
-        while ((inserted == null) && iterator.hasNext()) {
-            inserted = iterator.next().getHyperplane();
-            if (!node.insertCut(inserted.copySelf())) {
-                inserted = null;
-            }
-        }
-
-        if (!iterator.hasNext()) {
-            return;
-        }
-
-        // distribute the remaining edges in the two sub-trees
-        final ArrayList<SubHyperplane<P>> plusList  = new ArrayList<>();
-        final ArrayList<SubHyperplane<P>> minusList = new ArrayList<>();
-        while (iterator.hasNext()) {
-            final SubHyperplane<P> other = iterator.next();
-            final SubHyperplane.SplitSubHyperplane<P> split = other.split(inserted);
-            switch (split.getSide()) {
-            case PLUS:
-                plusList.add(other);
-                break;
-            case MINUS:
-                minusList.add(other);
-                break;
-            case BOTH:
-                plusList.add(split.getPlus());
-                minusList.add(split.getMinus());
-                break;
-            default:
-                // ignore the sub-hyperplanes belonging to the cut hyperplane
-            }
-        }
-
-        // recurse through lower levels
-        insertCuts(node.getPlus(),  plusList);
-        insertCuts(node.getMinus(), minusList);
-
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public AbstractRegion<P, S> copySelf() {
-        return buildNew(tree.copySelf());
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public boolean isEmpty() {
-        return isEmpty(tree);
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public boolean isEmpty(final BSPTree<P> node) {
-
-        // we use a recursive function rather than the BSPTreeVisitor
-        // interface because we can stop visiting the tree as soon as we
-        // have found an inside cell
-
-        if (node.getCut() == null) {
-            // if we find an inside node, the region is not empty
-            return !((Boolean) node.getAttribute());
-        }
-
-        // check both sides of the sub-tree
-        return isEmpty(node.getMinus()) && isEmpty(node.getPlus());
-
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public boolean isFull() {
-        return isFull(tree);
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public boolean isFull(final BSPTree<P> node) {
-
-        // we use a recursive function rather than the BSPTreeVisitor
-        // interface because we can stop visiting the tree as soon as we
-        // have found an outside cell
-
-        if (node.getCut() == null) {
-            // if we find an outside node, the region does not cover full space
-            return (Boolean) node.getAttribute();
-        }
-
-        // check both sides of the sub-tree
-        return isFull(node.getMinus()) && isFull(node.getPlus());
-
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public boolean contains(final Region<P> region) {
-        return new RegionFactory<P>().difference(region, this).isEmpty();
-    }
-
-    /** {@inheritDoc}
-     */
-    @Override
-    public BoundaryProjection<P> projectToBoundary(final P point) {
-        final BoundaryProjector<P, S> projector = new BoundaryProjector<>(point);
-        getTree(true).visit(projector);
-        return projector.getProjection();
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public Location checkPoint(final P point) {
-        return checkPoint(tree, point);
-    }
-
-    /** Check a point with respect to the region starting at a given node.
-     * @param node root node of the region
-     * @param point point to check
-     * @return a code representing the point status: either {@link
-     * Region.Location#INSIDE INSIDE}, {@link Region.Location#OUTSIDE
-     * OUTSIDE} or {@link Region.Location#BOUNDARY BOUNDARY}
-     */
-    protected Location checkPoint(final BSPTree<P> node, final P point) {
-        final BSPTree<P> cell = node.getCell(point, precision);
-        if (cell.getCut() == null) {
-            // the point is in the interior of a cell, just check the attribute
-            return ((Boolean) cell.getAttribute()) ? Location.INSIDE : Location.OUTSIDE;
-        }
-
-        // the point is on a cut-sub-hyperplane, is it on a boundary ?
-        final Location minusCode = checkPoint(cell.getMinus(), point);
-        final Location plusCode  = checkPoint(cell.getPlus(),  point);
-        return (minusCode == plusCode) ? minusCode : Location.BOUNDARY;
-
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public BSPTree<P> getTree(final boolean includeBoundaryAttributes) {
-        if (includeBoundaryAttributes && (tree.getCut() != null) && (tree.getAttribute() == null)) {
-            // compute the boundary attributes
-            tree.visit(new BoundaryBuilder<P>());
-        }
-        return tree;
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public double getBoundarySize() {
-        final BoundarySizeVisitor<P> visitor = new BoundarySizeVisitor<>();
-        getTree(true).visit(visitor);
-        return visitor.getSize();
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public double getSize() {
-        if (barycenter == null) {
-            computeGeometricalProperties();
-        }
-        return size;
-    }
-
-    /** Set the size of the instance.
-     * @param size size of the instance
-     */
-    protected void setSize(final double size) {
-        this.size = size;
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public P getBarycenter() {
-        if (barycenter == null) {
-            computeGeometricalProperties();
-        }
-        return barycenter;
-    }
-
-    /** Set the barycenter of the instance.
-     * @param barycenter barycenter of the instance
-     */
-    protected void setBarycenter(final P barycenter) {
-        this.barycenter = barycenter;
-    }
-
-    /** Compute some geometrical properties.
-     * <p>The properties to compute are the barycenter and the size.</p>
-     */
-    protected abstract void computeGeometricalProperties();
-
-    /** {@inheritDoc} */
-    @Override
-    public SubHyperplane<P> intersection(final SubHyperplane<P> sub) {
-        return recurseIntersection(tree, sub);
-    }
-
-    /** Recursively compute the parts of a sub-hyperplane that are
-     * contained in the region.
-     * @param node current BSP tree node
-     * @param sub sub-hyperplane traversing the region
-     * @return filtered sub-hyperplane
-     */
-    private SubHyperplane<P> recurseIntersection(final BSPTree<P> node, final SubHyperplane<P> sub) {
-
-        if (node.getCut() == null) {
-            return (Boolean) node.getAttribute() ? sub.copySelf() : null;
-        }
-
-        final Hyperplane<P> hyperplane = node.getCut().getHyperplane();
-        final SubHyperplane.SplitSubHyperplane<P> split = sub.split(hyperplane);
-        if (split.getPlus() != null) {
-            if (split.getMinus() != null) {
-                // both sides
-                final SubHyperplane<P> plus  = recurseIntersection(node.getPlus(),  split.getPlus());
-                final SubHyperplane<P> minus = recurseIntersection(node.getMinus(), split.getMinus());
-                if (plus == null) {
-                    return minus;
-                } else if (minus == null) {
-                    return plus;
-                } else {
-                    return plus.reunite(minus);
-                }
-            } else {
-                // only on plus side
-                return recurseIntersection(node.getPlus(), sub);
-            }
-        } else if (split.getMinus() != null) {
-            // only on minus side
-            return recurseIntersection(node.getMinus(), sub);
-        } else {
-            // on hyperplane
-            return recurseIntersection(node.getPlus(),
-                                       recurseIntersection(node.getMinus(), sub));
-        }
-
-    }
-
-    /** Transform a region.
-     * <p>Applying a transform to a region consist in applying the
-     * transform to all the hyperplanes of the underlying BSP tree and
-     * of the boundary (and also to the sub-hyperplanes embedded in
-     * these hyperplanes) and to the barycenter. The instance is not
-     * modified, a new instance is built.</p>
-     * @param transform transform to apply
-     * @return a new region, resulting from the application of the
-     * transform to the instance
-     */
-    public AbstractRegion<P, S> applyTransform(final Transform<P, S> transform) {
-
-        // transform the tree, except for boundary attribute splitters
-        final Map<BSPTree<P>, BSPTree<P>> map = new HashMap<>();
-        final BSPTree<P> transformedTree = recurseTransform(getTree(false), transform, map);
-
-        // set up the boundary attributes splitters
-        for (final Map.Entry<BSPTree<P>, BSPTree<P>> entry : map.entrySet()) {
-            if (entry.getKey().getCut() != null) {
-                @SuppressWarnings("unchecked")
-                BoundaryAttribute<P> original = (BoundaryAttribute<P>) entry.getKey().getAttribute();
-                if (original != null) {
-                    @SuppressWarnings("unchecked")
-                    BoundaryAttribute<P> transformed = (BoundaryAttribute<P>) entry.getValue().getAttribute();
-                    for (final BSPTree<P> splitter : original.getSplitters()) {
-                        transformed.getSplitters().add(map.get(splitter));
-                    }
-                }
-            }
-        }
-
-        return buildNew(transformedTree);
-
-    }
-
-    /** Recursively transform an inside/outside BSP-tree.
-     * @param node current BSP tree node
-     * @param transform transform to apply
-     * @param map transformed nodes map
-     * @return a new tree
-     */
-    @SuppressWarnings("unchecked")
-    private BSPTree<P> recurseTransform(final BSPTree<P> node, final Transform<P, S> transform,
-                                        final Map<BSPTree<P>, BSPTree<P>> map) {
-
-        final BSPTree<P> transformedNode;
-        if (node.getCut() == null) {
-            transformedNode = new BSPTree<>(node.getAttribute());
-        } else {
-
-            final SubHyperplane<P>  sub = node.getCut();
-            final SubHyperplane<P> tSub = ((AbstractSubHyperplane<P, S>) sub).applyTransform(transform);
-            BoundaryAttribute<P> attribute = (BoundaryAttribute<P>) node.getAttribute();
-            if (attribute != null) {
-                final SubHyperplane<P> tPO = (attribute.getPlusOutside() == null) ?
-                    null : ((AbstractSubHyperplane<P, S>) attribute.getPlusOutside()).applyTransform(transform);
-                final SubHyperplane<P> tPI = (attribute.getPlusInside()  == null) ?
-                    null  : ((AbstractSubHyperplane<P, S>) attribute.getPlusInside()).applyTransform(transform);
-                // we start with an empty list of splitters, it will be filled in out of recursion
-                attribute = new BoundaryAttribute<>(tPO, tPI, new NodesSet<P>());
-            }
-
-            transformedNode = new BSPTree<>(tSub,
-                                             recurseTransform(node.getPlus(),  transform, map),
-                                             recurseTransform(node.getMinus(), transform, map),
-                                             attribute);
-        }
-
-        map.put(node, transformedNode);
-        return transformedNode;
-
-    }
-
-}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/AbstractSubHyperplane.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/AbstractSubHyperplane.java
deleted file mode 100644
index c8dfad1..0000000
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/AbstractSubHyperplane.java
+++ /dev/null
@@ -1,189 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning;
-
-import java.util.HashMap;
-import java.util.Map;
-
-import org.apache.commons.geometry.core.Point;
-
-/** This class implements the dimension-independent parts of {@link SubHyperplane}.
-
- * <p>sub-hyperplanes are obtained when parts of an {@link
- * Hyperplane hyperplane} are chopped off by other hyperplanes that
- * intersect it. The remaining part is a convex region. Such objects
- * appear in {@link BSPTree BSP trees} as the intersection of a cut
- * hyperplane with the convex region which it splits, the chopping
- * hyperplanes are the cut hyperplanes closer to the tree root.</p>
-
- * @param <P> Point type defining the space
- * @param <S> Point type defining the sub-space
- */
-public abstract class AbstractSubHyperplane<P extends Point<P>, S extends Point<S>>
-    implements SubHyperplane<P> {
-
-    /** Underlying hyperplane. */
-    private final Hyperplane<P> hyperplane;
-
-    /** Remaining region of the hyperplane. */
-    private final Region<S> remainingRegion;
-
-    /** Build a sub-hyperplane from an hyperplane and a region.
-     * @param hyperplane underlying hyperplane
-     * @param remainingRegion remaining region of the hyperplane
-     */
-    protected AbstractSubHyperplane(final Hyperplane<P> hyperplane,
-                                    final Region<S> remainingRegion) {
-        this.hyperplane      = hyperplane;
-        this.remainingRegion = remainingRegion;
-    }
-
-    /** Build a sub-hyperplane from an hyperplane and a region.
-     * @param hyper underlying hyperplane
-     * @param remaining remaining region of the hyperplane
-     * @return a new sub-hyperplane
-     */
-    protected abstract AbstractSubHyperplane<P, S> buildNew(final Hyperplane<P> hyper,
-                                                            final Region<S> remaining);
-
-    /** {@inheritDoc} */
-    @Override
-    public AbstractSubHyperplane<P, S> copySelf() {
-        return buildNew(hyperplane.copySelf(), remainingRegion);
-    }
-
-    /** Get the underlying hyperplane.
-     * @return underlying hyperplane
-     */
-    @Override
-    public Hyperplane<P> getHyperplane() {
-        return hyperplane;
-    }
-
-    /** Get the remaining region of the hyperplane.
-     * <p>The returned region is expressed in the canonical hyperplane
-     * frame and has the hyperplane dimension. For example a chopped
-     * hyperplane in the 3D Euclidean is a 2D plane and the
-     * corresponding region is a convex 2D polygon.</p>
-     * @return remaining region of the hyperplane
-     */
-    public Region<S> getRemainingRegion() {
-        return remainingRegion;
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public double getSize() {
-        return remainingRegion.getSize();
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public AbstractSubHyperplane<P, S> reunite(final SubHyperplane<P> other) {
-        @SuppressWarnings("unchecked")
-        AbstractSubHyperplane<P, S> o = (AbstractSubHyperplane<P, S>) other;
-        return buildNew(hyperplane,
-                        new RegionFactory<S>().union(remainingRegion, o.remainingRegion));
-    }
-
-    /** Apply a transform to the instance.
-     * <p>The instance must be a (D-1)-dimension sub-hyperplane with
-     * respect to the transform <em>not</em> a (D-2)-dimension
-     * sub-hyperplane the transform knows how to transform by
-     * itself. The transform will consist in transforming first the
-     * hyperplane and then the all region using the various methods
-     * provided by the transform.</p>
-     * @param transform D-dimension transform to apply
-     * @return the transformed instance
-     */
-    public AbstractSubHyperplane<P, S> applyTransform(final Transform<P, S> transform) {
-        final Hyperplane<P> tHyperplane = transform.apply(hyperplane);
-
-        // transform the tree, except for boundary attribute splitters
-        final Map<BSPTree<S>, BSPTree<S>> map = new HashMap<>();
-        final BSPTree<S> tTree =
-            recurseTransform(remainingRegion.getTree(false), tHyperplane, transform, map);
-
-        // set up the boundary attributes splitters
-        for (final Map.Entry<BSPTree<S>, BSPTree<S>> entry : map.entrySet()) {
-            if (entry.getKey().getCut() != null) {
-                @SuppressWarnings("unchecked")
-                BoundaryAttribute<S> original = (BoundaryAttribute<S>) entry.getKey().getAttribute();
-                if (original != null) {
-                    @SuppressWarnings("unchecked")
-                    BoundaryAttribute<S> transformed = (BoundaryAttribute<S>) entry.getValue().getAttribute();
-                    for (final BSPTree<S> splitter : original.getSplitters()) {
-                        transformed.getSplitters().add(map.get(splitter));
-                    }
-                }
-            }
-        }
-
-        return buildNew(tHyperplane, remainingRegion.buildNew(tTree));
-
-    }
-
-    /** Recursively transform a BSP-tree from a sub-hyperplane.
-     * @param node current BSP tree node
-     * @param transformed image of the instance hyperplane by the transform
-     * @param transform transform to apply
-     * @param map transformed nodes map
-     * @return a new tree
-     */
-    private BSPTree<S> recurseTransform(final BSPTree<S> node,
-                                        final Hyperplane<P> transformed,
-                                        final Transform<P, S> transform,
-                                        final Map<BSPTree<S>, BSPTree<S>> map) {
-
-        final BSPTree<S> transformedNode;
-        if (node.getCut() == null) {
-            transformedNode = new BSPTree<>(node.getAttribute());
-        } else {
-
-            @SuppressWarnings("unchecked")
-            BoundaryAttribute<S> attribute = (BoundaryAttribute<S>) node.getAttribute();
-            if (attribute != null) {
-                final SubHyperplane<S> tPO = (attribute.getPlusOutside() == null) ?
-                    null : transform.apply(attribute.getPlusOutside(), hyperplane, transformed);
-                final SubHyperplane<S> tPI = (attribute.getPlusInside() == null) ?
-                    null : transform.apply(attribute.getPlusInside(), hyperplane, transformed);
-                // we start with an empty list of splitters, it will be filled in out of recursion
-                attribute = new BoundaryAttribute<>(tPO, tPI, new NodesSet<S>());
-            }
-
-            transformedNode = new BSPTree<>(transform.apply(node.getCut(), hyperplane, transformed),
-                    recurseTransform(node.getPlus(),  transformed, transform, map),
-                    recurseTransform(node.getMinus(), transformed, transform, map),
-                    attribute);
-        }
-
-        map.put(node, transformedNode);
-        return transformedNode;
-
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public abstract SplitSubHyperplane<P> split(Hyperplane<P> hyper);
-
-    /** {@inheritDoc} */
-    @Override
-    public boolean isEmpty() {
-        return remainingRegion.isEmpty();
-    }
-
-}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/BSPTree.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/BSPTree.java
deleted file mode 100644
index 9a00a89..0000000
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/BSPTree.java
+++ /dev/null
@@ -1,781 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning;
-
-import java.util.ArrayList;
-import java.util.List;
-
-import org.apache.commons.geometry.core.Point;
-import org.apache.commons.geometry.core.partitioning.BSPTreeVisitor.Order;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-
-/** This class represent a Binary Space Partition tree.
-
- * <p>BSP trees are an efficient way to represent space partitions and
- * to associate attributes with each cell. Each node in a BSP tree
- * represents a convex region which is partitioned in two convex
- * sub-regions at each side of a cut hyperplane. The root tree
- * contains the complete space.</p>
-
- * <p>The main use of such partitions is to use a boolean attribute to
- * define an inside/outside property, hence representing arbitrary
- * polytopes (line segments in 1D, polygons in 2D and polyhedrons in
- * 3D) and to operate on them.</p>
-
- * <p>Another example would be to represent Voronoi tesselations, the
- * attribute of each cell holding the defining point of the cell.</p>
-
- * <p>The application-defined attributes are shared among copied
- * instances and propagated to split parts. These attributes are not
- * used by the BSP-tree algorithms themselves, so the application can
- * use them for any purpose. Since the tree visiting method holds
- * internal and leaf nodes differently, it is possible to use
- * different classes for internal nodes attributes and leaf nodes
- * attributes. This should be used with care, though, because if the
- * tree is modified in any way after attributes have been set, some
- * internal nodes may become leaf nodes and some leaf nodes may become
- * internal nodes.</p>
-
- * <p>One of the main sources for the development of this package was
- * Bruce Naylor, John Amanatides and William Thibault paper <a
- * href="http://www.cs.yorku.ca/~amana/research/bsptSetOp.pdf">Merging
- * BSP Trees Yields Polyhedral Set Operations</a> Proc. Siggraph '90,
- * Computer Graphics 24(4), August 1990, pp 115-124, published by the
- * Association for Computing Machinery (ACM).</p>
-
- * @param <P> Point type defining the space
- */
-public class BSPTree<P extends Point<P>> {
-
-    /** Cut sub-hyperplane. */
-    private SubHyperplane<P> cut;
-
-    /** Tree at the plus side of the cut hyperplane. */
-    private BSPTree<P> plus;
-
-    /** Tree at the minus side of the cut hyperplane. */
-    private BSPTree<P> minus;
-
-    /** Parent tree. */
-    private BSPTree<P> parent;
-
-    /** Application-defined attribute. */
-    private Object attribute;
-
-    /** Build a tree having only one root cell representing the whole space.
-     */
-    public BSPTree() {
-        cut       = null;
-        plus      = null;
-        minus     = null;
-        parent    = null;
-        attribute = null;
-    }
-
-    /** Build a tree having only one root cell representing the whole space.
-     * @param attribute attribute of the tree (may be null)
-     */
-    public BSPTree(final Object attribute) {
-        cut    = null;
-        plus   = null;
-        minus  = null;
-        parent = null;
-        this.attribute = attribute;
-    }
-
-    /** Build a BSPTree from its underlying elements.
-     * <p>This method does <em>not</em> perform any verification on
-     * consistency of its arguments, it should therefore be used only
-     * when then caller knows what it is doing.</p>
-     * <p>This method is mainly useful to build trees
-     * bottom-up. Building trees top-down is realized with the help of
-     * method {@link #insertCut insertCut}.</p>
-     * @param cut cut sub-hyperplane for the tree
-     * @param plus plus side sub-tree
-     * @param minus minus side sub-tree
-     * @param attribute attribute associated with the node (may be null)
-     * @see #insertCut
-     */
-    public BSPTree(final SubHyperplane<P> cut, final BSPTree<P> plus, final BSPTree<P> minus,
-                   final Object attribute) {
-        this.cut       = cut;
-        this.plus      = plus;
-        this.minus     = minus;
-        this.parent    = null;
-        this.attribute = attribute;
-        plus.parent    = this;
-        minus.parent   = this;
-    }
-
-    /** Insert a cut sub-hyperplane in a node.
-     * <p>The sub-tree starting at this node will be completely
-     * overwritten. The new cut sub-hyperplane will be built from the
-     * intersection of the provided hyperplane with the cell. If the
-     * hyperplane does intersect the cell, the cell will have two
-     * children cells with {@code null} attributes on each side of
-     * the inserted cut sub-hyperplane. If the hyperplane does not
-     * intersect the cell then <em>no</em> cut hyperplane will be
-     * inserted and the cell will be changed to a leaf cell. The
-     * attribute of the node is never changed.</p>
-     * <p>This method is mainly useful when called on leaf nodes
-     * (i.e. nodes for which {@link #getCut getCut} returns
-     * {@code null}), in this case it provides a way to build a
-     * tree top-down (whereas the {@link #BSPTree(SubHyperplane,
-     * BSPTree, BSPTree, Object) 4 arguments constructor} is devoted to
-     * build trees bottom-up).</p>
-     * @param hyperplane hyperplane to insert, it will be chopped in
-     * order to fit in the cell defined by the parent nodes of the
-     * instance
-     * @return true if a cut sub-hyperplane has been inserted (i.e. if
-     * the cell now has two leaf child nodes)
-     * @see #BSPTree(SubHyperplane, BSPTree, BSPTree, Object)
-     */
-    public boolean insertCut(final Hyperplane<P> hyperplane) {
-
-        if (cut != null) {
-            plus.parent  = null;
-            minus.parent = null;
-        }
-
-        final SubHyperplane<P> chopped = fitToCell(hyperplane.wholeHyperplane());
-        if (chopped == null || chopped.isEmpty()) {
-            cut          = null;
-            plus         = null;
-            minus        = null;
-            return false;
-        }
-
-        cut          = chopped;
-        plus         = new BSPTree<>();
-        plus.parent  = this;
-        minus        = new BSPTree<>();
-        minus.parent = this;
-        return true;
-
-    }
-
-    /** Copy the instance.
-     * <p>The instance created is completely independent of the original
-     * one. A deep copy is used, none of the underlying objects are
-     * shared (except for the nodes attributes and immutable
-     * objects).</p>
-     * @return a new tree, copy of the instance
-     */
-    public BSPTree<P> copySelf() {
-
-        if (cut == null) {
-            return new BSPTree<>(attribute);
-        }
-
-        return new BSPTree<>(cut.copySelf(), plus.copySelf(), minus.copySelf(),
-                           attribute);
-
-    }
-
-    /** Get the cut sub-hyperplane.
-     * @return cut sub-hyperplane, null if this is a leaf tree
-     */
-    public SubHyperplane<P> getCut() {
-        return cut;
-    }
-
-    /** Get the tree on the plus side of the cut hyperplane.
-     * @return tree on the plus side of the cut hyperplane, null if this
-     * is a leaf tree
-     */
-    public BSPTree<P> getPlus() {
-        return plus;
-    }
-
-    /** Get the tree on the minus side of the cut hyperplane.
-     * @return tree on the minus side of the cut hyperplane, null if this
-     * is a leaf tree
-     */
-    public BSPTree<P> getMinus() {
-        return minus;
-    }
-
-    /** Get the parent node.
-     * @return parent node, null if the node has no parents
-     */
-    public BSPTree<P> getParent() {
-        return parent;
-    }
-
-    /** Associate an attribute with the instance.
-     * @param attribute attribute to associate with the node
-     * @see #getAttribute
-     */
-    public void setAttribute(final Object attribute) {
-        this.attribute = attribute;
-    }
-
-    /** Get the attribute associated with the instance.
-     * @return attribute associated with the node or null if no
-     * attribute has been explicitly set using the {@link #setAttribute
-     * setAttribute} method
-     * @see #setAttribute
-     */
-    public Object getAttribute() {
-        return attribute;
-    }
-
-    /** Visit the BSP tree nodes.
-     * @param visitor object visiting the tree nodes
-     */
-    public void visit(final BSPTreeVisitor<P> visitor) {
-        if (cut == null) {
-            visitor.visitLeafNode(this);
-        } else {
-            Order order = visitor.visitOrder(this);
-            switch (order) {
-            case PLUS_MINUS_SUB:
-                plus.visit(visitor);
-                minus.visit(visitor);
-                visitor.visitInternalNode(this);
-                break;
-            case PLUS_SUB_MINUS:
-                plus.visit(visitor);
-                visitor.visitInternalNode(this);
-                minus.visit(visitor);
-                break;
-            case MINUS_PLUS_SUB:
-                minus.visit(visitor);
-                plus.visit(visitor);
-                visitor.visitInternalNode(this);
-                break;
-            case MINUS_SUB_PLUS:
-                minus.visit(visitor);
-                visitor.visitInternalNode(this);
-                plus.visit(visitor);
-                break;
-            case SUB_PLUS_MINUS:
-                visitor.visitInternalNode(this);
-                plus.visit(visitor);
-                minus.visit(visitor);
-                break;
-            case SUB_MINUS_PLUS:
-                visitor.visitInternalNode(this);
-                minus.visit(visitor);
-                plus.visit(visitor);
-                break;
-            default:
-                // we shouldn't end up here since all possibilities are
-                // covered above
-                throw new IllegalStateException("Invalid node visit order: " + order);
-            }
-        }
-    }
-
-    /** Fit a sub-hyperplane inside the cell defined by the instance.
-     * <p>Fitting is done by chopping off the parts of the
-     * sub-hyperplane that lie outside of the cell using the
-     * cut-hyperplanes of the parent nodes of the instance.</p>
-     * @param sub sub-hyperplane to fit
-     * @return a new sub-hyperplane, guaranteed to have no part outside
-     * of the instance cell
-     */
-    private SubHyperplane<P> fitToCell(final SubHyperplane<P> sub) {
-        SubHyperplane<P> s = sub;
-        for (BSPTree<P> tree = this; tree.parent != null && s != null; tree = tree.parent) {
-            if (tree == tree.parent.plus) {
-                s = s.split(tree.parent.cut.getHyperplane()).getPlus();
-            } else {
-                s = s.split(tree.parent.cut.getHyperplane()).getMinus();
-            }
-        }
-        return s;
-    }
-
-    /** Get the cell to which a point belongs.
-     * <p>If the returned cell is a leaf node the points belongs to the
-     * interior of the node, if the cell is an internal node the points
-     * belongs to the node cut sub-hyperplane.</p>
-     * @param point point to check
-     * @param precision precision context used to determine which points
-     * close to a cut hyperplane are considered to belong to the hyperplane itself
-     * @return the tree cell to which the point belongs
-     */
-    public BSPTree<P> getCell(final P point, final DoublePrecisionContext precision) {
-
-        if (cut == null) {
-            return this;
-        }
-
-        // position of the point with respect to the cut hyperplane
-        final double offset = cut.getHyperplane().getOffset(point);
-
-        final int comparison = precision.compare(offset, 0.0);
-        if (comparison == 0) {
-            return this;
-        } else if (comparison < 0) {
-            // point is on the minus side of the cut hyperplane
-            return minus.getCell(point, precision);
-        } else {
-            // point is on the plus side of the cut hyperplane
-            return plus.getCell(point, precision);
-        }
-
-    }
-
-    /** Get the cells whose cut sub-hyperplanes are close to the point.
-     * @param point point to check
-     * @param maxOffset offset below which a cut sub-hyperplane is considered
-     * close to the point (in absolute value)
-     * @return close cells (may be empty if all cut sub-hyperplanes are farther
-     * than maxOffset from the point)
-     */
-    public List<BSPTree<P>> getCloseCuts(final P point, final double maxOffset) {
-        final List<BSPTree<P>> close = new ArrayList<>();
-        recurseCloseCuts(point, maxOffset, close);
-        return close;
-    }
-
-    /** Get the cells whose cut sub-hyperplanes are close to the point.
-     * @param point point to check
-     * @param maxOffset offset below which a cut sub-hyperplane is considered
-     * close to the point (in absolute value)
-     * @param close list to fill
-     */
-    private void recurseCloseCuts(final P point, final double maxOffset,
-                                  final List<BSPTree<P>> close) {
-        if (cut != null) {
-
-            // position of the point with respect to the cut hyperplane
-            final double offset = cut.getHyperplane().getOffset(point);
-
-            if (offset < -maxOffset) {
-                // point is on the minus side of the cut hyperplane
-                minus.recurseCloseCuts(point, maxOffset, close);
-            } else if (offset > maxOffset) {
-                // point is on the plus side of the cut hyperplane
-                plus.recurseCloseCuts(point, maxOffset, close);
-            } else {
-                // point is close to the cut hyperplane
-                close.add(this);
-                minus.recurseCloseCuts(point, maxOffset, close);
-                plus.recurseCloseCuts(point, maxOffset, close);
-            }
-
-        }
-    }
-
-    /** Perform condensation on a tree.
-     * <p>The condensation operation is not recursive, it must be called
-     * explicitly from leaves to root.</p>
-     */
-    private void condense() {
-        if ((cut != null) && (plus.cut == null) && (minus.cut == null) &&
-            (((plus.attribute == null) && (minus.attribute == null)) ||
-             ((plus.attribute != null) && plus.attribute.equals(minus.attribute)))) {
-            attribute = (plus.attribute == null) ? minus.attribute : plus.attribute;
-            cut       = null;
-            plus      = null;
-            minus     = null;
-        }
-    }
-
-    /** Merge a BSP tree with the instance.
-     * <p>All trees are modified (parts of them are reused in the new
-     * tree), it is the responsibility of the caller to ensure a copy
-     * has been done before if any of the former tree should be
-     * preserved, <em>no</em> such copy is done here!</p>
-     * <p>The algorithm used here is directly derived from the one
-     * described in the Naylor, Amanatides and Thibault paper (section
-     * III, Binary Partitioning of a BSP Tree).</p>
-     * @param tree other tree to merge with the instance (will be
-     * <em>unusable</em> after the operation, as well as the
-     * instance itself)
-     * @param leafMerger object implementing the final merging phase
-     * (this is where the semantic of the operation occurs, generally
-     * depending on the attribute of the leaf node)
-     * @return a new tree, result of <code>instance &lt;op&gt;
-     * tree</code>, this value can be ignored if parentTree is not null
-     * since all connections have already been established
-     */
-    public BSPTree<P> merge(final BSPTree<P> tree, final LeafMerger<P> leafMerger) {
-        return merge(tree, leafMerger, null, false);
-    }
-
-    /** Merge a BSP tree with the instance.
-     * @param tree other tree to merge with the instance (will be
-     * <em>unusable</em> after the operation, as well as the
-     * instance itself)
-     * @param leafMerger object implementing the final merging phase
-     * (this is where the semantic of the operation occurs, generally
-     * depending on the attribute of the leaf node)
-     * @param parentTree parent tree to connect to (may be null)
-     * @param isPlusChild if true and if parentTree is not null, the
-     * resulting tree should be the plus child of its parent, ignored if
-     * parentTree is null
-     * @return a new tree, result of <code>instance &lt;op&gt;
-     * tree</code>, this value can be ignored if parentTree is not null
-     * since all connections have already been established
-     */
-    private BSPTree<P> merge(final BSPTree<P> tree, final LeafMerger<P> leafMerger,
-                             final BSPTree<P> parentTree, final boolean isPlusChild) {
-        if (cut == null) {
-            // cell/tree operation
-            return leafMerger.merge(this, tree, parentTree, isPlusChild, true);
-        } else if (tree.cut == null) {
-            // tree/cell operation
-            return leafMerger.merge(tree, this, parentTree, isPlusChild, false);
-        } else {
-            // tree/tree operation
-            final BSPTree<P> merged = tree.split(cut);
-            if (parentTree != null) {
-                merged.parent = parentTree;
-                if (isPlusChild) {
-                    parentTree.plus = merged;
-                } else {
-                    parentTree.minus = merged;
-                }
-            }
-
-            // merging phase
-            plus.merge(merged.plus, leafMerger, merged, true);
-            minus.merge(merged.minus, leafMerger, merged, false);
-            merged.condense();
-            if (merged.cut != null) {
-                merged.cut = merged.fitToCell(merged.cut.getHyperplane().wholeHyperplane());
-            }
-
-            return merged;
-
-        }
-    }
-
-    /** This interface gather the merging operations between a BSP tree
-     * leaf and another BSP tree.
-     * <p>As explained in Bruce Naylor, John Amanatides and William
-     * Thibault paper <a
-     * href="http://www.cs.yorku.ca/~amana/research/bsptSetOp.pdf">Merging
-     * BSP Trees Yields Polyhedral Set Operations</a>,
-     * the operations on {@link BSPTree BSP trees} can be expressed as a
-     * generic recursive merging operation where only the final part,
-     * when one of the operand is a leaf, is specific to the real
-     * operation semantics. For example, a tree representing a region
-     * using a boolean attribute to identify inside cells and outside
-     * cells would use four different objects to implement the final
-     * merging phase of the four set operations union, intersection,
-     * difference and symmetric difference (exclusive or).</p>
-     * @param <S> Type of the space.
-     */
-    public interface LeafMerger<S extends Point<S>> {
-
-        /** Merge a leaf node and a tree node.
-         * <p>This method is called at the end of a recursive merging
-         * resulting from a {@code tree1.merge(tree2, leafMerger)}
-         * call, when one of the sub-trees involved is a leaf (i.e. when
-         * its cut-hyperplane is null). This is the only place where the
-         * precise semantics of the operation are required. For all upper
-         * level nodes in the tree, the merging operation is only a
-         * generic partitioning algorithm.</p>
-         * <p>Since the final operation may be non-commutative, it is
-         * important to know if the leaf node comes from the instance tree
-         * ({@code tree1}) or the argument tree
-         * ({@code tree2}). The third argument of the method is
-         * devoted to this. It can be ignored for commutative
-         * operations.</p>
-         * <p>The {@link BSPTree#insertInTree BSPTree.insertInTree} method
-         * may be useful to implement this method.</p>
-         * @param leaf leaf node (its cut hyperplane is guaranteed to be
-         * null)
-         * @param tree tree node (its cut hyperplane may be null or not)
-         * @param parentTree parent tree to connect to (may be null)
-         * @param isPlusChild if true and if parentTree is not null, the
-         * resulting tree should be the plus child of its parent, ignored if
-         * parentTree is null
-         * @param leafFromInstance if true, the leaf node comes from the
-         * instance tree ({@code tree1}) and the tree node comes from
-         * the argument tree ({@code tree2})
-         * @return the BSP tree resulting from the merging (may be one of
-         * the arguments)
-         */
-        BSPTree<S> merge(BSPTree<S> leaf, BSPTree<S> tree, BSPTree<S> parentTree,
-                         boolean isPlusChild, boolean leafFromInstance);
-
-    }
-
-    /** This interface handles the corner cases when an internal node cut sub-hyperplane vanishes.
-     * <p>
-     * Such cases happens for example when a cut sub-hyperplane is inserted into
-     * another tree (during a merge operation), and is split in several parts,
-     * some of which becomes smaller than the tolerance. The corresponding node
-     * as then no cut sub-hyperplane anymore, but does have children. This interface
-     * specifies how to handle this situation.
-     * setting
-     * </p>
-     * @param <S> Type of the space.
-     */
-    public interface VanishingCutHandler<S extends Point<S>> {
-
-        /** Fix a node with both vanished cut and children.
-         * @param node node to fix
-         * @return fixed node
-         */
-        BSPTree<S> fixNode(BSPTree<S> node);
-
-    }
-
-    /** Split a BSP tree by an external sub-hyperplane.
-     * <p>Split a tree in two halves, on each side of the
-     * sub-hyperplane. The instance is not modified.</p>
-     * <p>The tree returned is not upward-consistent: despite all of its
-     * sub-trees cut sub-hyperplanes (including its own cut
-     * sub-hyperplane) are bounded to the current cell, it is <em>not</em>
-     * attached to any parent tree yet. This tree is intended to be
-     * later inserted into an higher level tree.</p>
-     * <p>The algorithm used here is the one given in Naylor, Amanatides
-     * and Thibault paper (section III, Binary Partitioning of a BSP
-     * Tree).</p>
-     * @param sub partitioning sub-hyperplane, must be already clipped
-     * to the convex region represented by the instance, will be used as
-     * the cut sub-hyperplane of the returned tree
-     * @return a tree having the specified sub-hyperplane as its cut
-     * sub-hyperplane, the two parts of the split instance as its two
-     * sub-trees and a null parent
-     */
-    public BSPTree<P> split(final SubHyperplane<P> sub) {
-
-        if (cut == null) {
-            return new BSPTree<>(sub, copySelf(), new BSPTree<P>(attribute), null);
-        }
-
-        final Hyperplane<P> cHyperplane = cut.getHyperplane();
-        final Hyperplane<P> sHyperplane = sub.getHyperplane();
-        final SubHyperplane.SplitSubHyperplane<P> subParts = sub.split(cHyperplane);
-        switch (subParts.getSide()) {
-        case PLUS :
-        { // the partitioning sub-hyperplane is entirely in the plus sub-tree
-            final BSPTree<P> split = plus.split(sub);
-            if (cut.split(sHyperplane).getSide() == Side.PLUS) {
-                split.plus =
-                    new BSPTree<>(cut.copySelf(), split.plus, minus.copySelf(), attribute);
-                split.plus.condense();
-                split.plus.parent = split;
-            } else {
-                split.minus =
-                    new BSPTree<>(cut.copySelf(), split.minus, minus.copySelf(), attribute);
-                split.minus.condense();
-                split.minus.parent = split;
-            }
-            return split;
-        }
-        case MINUS :
-        { // the partitioning sub-hyperplane is entirely in the minus sub-tree
-            final BSPTree<P> split = minus.split(sub);
-            if (cut.split(sHyperplane).getSide() == Side.PLUS) {
-                split.plus =
-                    new BSPTree<>(cut.copySelf(), plus.copySelf(), split.plus, attribute);
-                split.plus.condense();
-                split.plus.parent = split;
-            } else {
-                split.minus =
-                    new BSPTree<>(cut.copySelf(), plus.copySelf(), split.minus, attribute);
-                split.minus.condense();
-                split.minus.parent = split;
-            }
-            return split;
-        }
-        case BOTH :
-        {
-            final SubHyperplane.SplitSubHyperplane<P> cutParts = cut.split(sHyperplane);
-            final BSPTree<P> split =
-                new BSPTree<>(sub, plus.split(subParts.getPlus()), minus.split(subParts.getMinus()),
-                               null);
-            split.plus.cut          = cutParts.getPlus();
-            split.minus.cut         = cutParts.getMinus();
-            final BSPTree<P> tmp    = split.plus.minus;
-            split.plus.minus        = split.minus.plus;
-            split.plus.minus.parent = split.plus;
-            split.minus.plus        = tmp;
-            split.minus.plus.parent = split.minus;
-            split.plus.condense();
-            split.minus.condense();
-            return split;
-        }
-        default :
-            return cHyperplane.sameOrientationAs(sHyperplane) ?
-                   new BSPTree<>(sub, plus.copySelf(),  minus.copySelf(), attribute) :
-                   new BSPTree<>(sub, minus.copySelf(), plus.copySelf(),  attribute);
-        }
-
-    }
-
-    /** Insert the instance into another tree.
-     * <p>The instance itself is modified so its former parent should
-     * not be used anymore.</p>
-     * @param parentTree parent tree to connect to (may be null)
-     * @param isPlusChild if true and if parentTree is not null, the
-     * resulting tree should be the plus child of its parent, ignored if
-     * parentTree is null
-     * @param vanishingHandler handler to use for handling very rare corner
-     * cases of vanishing cut sub-hyperplanes in internal nodes during merging
-     * @see LeafMerger
-     */
-    public void insertInTree(final BSPTree<P> parentTree, final boolean isPlusChild,
-                             final VanishingCutHandler<P> vanishingHandler) {
-
-        // set up parent/child links
-        parent = parentTree;
-        if (parentTree != null) {
-            if (isPlusChild) {
-                parentTree.plus = this;
-            } else {
-                parentTree.minus = this;
-            }
-        }
-
-        // make sure the inserted tree lies in the cell defined by its parent nodes
-        if (cut != null) {
-
-            // explore the parent nodes from here towards tree root
-            for (BSPTree<P> tree = this; tree.parent != null; tree = tree.parent) {
-
-                // this is an hyperplane of some parent node
-                final Hyperplane<P> hyperplane = tree.parent.cut.getHyperplane();
-
-                // chop off the parts of the inserted tree that extend
-                // on the wrong side of this parent hyperplane
-                if (tree == tree.parent.plus) {
-                    cut = cut.split(hyperplane).getPlus();
-                    plus.chopOffMinus(hyperplane, vanishingHandler);
-                    minus.chopOffMinus(hyperplane, vanishingHandler);
-                } else {
-                    cut = cut.split(hyperplane).getMinus();
-                    plus.chopOffPlus(hyperplane, vanishingHandler);
-                    minus.chopOffPlus(hyperplane, vanishingHandler);
-                }
-
-                if (cut == null) {
-                    // the cut sub-hyperplane has vanished
-                    final BSPTree<P> fixed = vanishingHandler.fixNode(this);
-                    cut       = fixed.cut;
-                    plus      = fixed.plus;
-                    minus     = fixed.minus;
-                    attribute = fixed.attribute;
-                    if (cut == null) {
-                        break;
-                    }
-                }
-
-            }
-
-            // since we may have drop some parts of the inserted tree,
-            // perform a condensation pass to keep the tree structure simple
-            condense();
-
-        }
-
-    }
-
-    /** Prune a tree around a cell.
-     * <p>
-     * This method can be used to extract a convex cell from a tree.
-     * The original cell may either be a leaf node or an internal node.
-     * If it is an internal node, it's subtree will be ignored (i.e. the
-     * extracted cell will be a leaf node in all cases). The original
-     * tree to which the original cell belongs is not touched at all,
-     * a new independent tree will be built.
-     * </p>
-     * @param cellAttribute attribute to set for the leaf node
-     * corresponding to the initial instance cell
-     * @param otherLeafsAttributes attribute to set for the other leaf
-     * nodes
-     * @param internalAttributes attribute to set for the internal nodes
-     * @return a new tree (the original tree is left untouched) containing
-     * a single branch with the cell as a leaf node, and other leaf nodes
-     * as the remnants of the pruned branches
-     */
-    public BSPTree<P> pruneAroundConvexCell(final Object cellAttribute,
-                                            final Object otherLeafsAttributes,
-                                            final Object internalAttributes) {
-
-        // build the current cell leaf
-        BSPTree<P> tree = new BSPTree<>(cellAttribute);
-
-        // build the pruned tree bottom-up
-        for (BSPTree<P> current = this; current.parent != null; current = current.parent) {
-            final SubHyperplane<P> parentCut = current.parent.cut.copySelf();
-            final BSPTree<P>       sibling   = new BSPTree<>(otherLeafsAttributes);
-            if (current == current.parent.plus) {
-                tree = new BSPTree<>(parentCut, tree, sibling, internalAttributes);
-            } else {
-                tree = new BSPTree<>(parentCut, sibling, tree, internalAttributes);
-            }
-        }
-
-        return tree;
-
-    }
-
-    /** Chop off parts of the tree.
-     * <p>The instance is modified in place, all the parts that are on
-     * the minus side of the chopping hyperplane are discarded, only the
-     * parts on the plus side remain.</p>
-     * @param hyperplane chopping hyperplane
-     * @param vanishingHandler handler to use for handling very rare corner
-     * cases of vanishing cut sub-hyperplanes in internal nodes during merging
-     */
-    private void chopOffMinus(final Hyperplane<P> hyperplane, final VanishingCutHandler<P> vanishingHandler) {
-        if (cut != null) {
-
-            cut = cut.split(hyperplane).getPlus();
-            plus.chopOffMinus(hyperplane, vanishingHandler);
-            minus.chopOffMinus(hyperplane, vanishingHandler);
-
-            if (cut == null) {
-                // the cut sub-hyperplane has vanished
-                final BSPTree<P> fixed = vanishingHandler.fixNode(this);
-                cut       = fixed.cut;
-                plus      = fixed.plus;
-                minus     = fixed.minus;
-                attribute = fixed.attribute;
-            }
-
-        }
-    }
-
-    /** Chop off parts of the tree.
-     * <p>The instance is modified in place, all the parts that are on
-     * the plus side of the chopping hyperplane are discarded, only the
-     * parts on the minus side remain.</p>
-     * @param hyperplane chopping hyperplane
-     * @param vanishingHandler handler to use for handling very rare corner
-     * cases of vanishing cut sub-hyperplanes in internal nodes during merging
-     */
-    private void chopOffPlus(final Hyperplane<P> hyperplane, final VanishingCutHandler<P> vanishingHandler) {
-        if (cut != null) {
-
-            cut = cut.split(hyperplane).getMinus();
-            plus.chopOffPlus(hyperplane, vanishingHandler);
-            minus.chopOffPlus(hyperplane, vanishingHandler);
-
-            if (cut == null) {
-                // the cut sub-hyperplane has vanished
-                final BSPTree<P> fixed = vanishingHandler.fixNode(this);
-                cut       = fixed.cut;
-                plus      = fixed.plus;
-                minus     = fixed.minus;
-                attribute = fixed.attribute;
-            }
-
-        }
-    }
-
-}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/BSPTreeVisitor.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/BSPTreeVisitor.java
deleted file mode 100644
index 52d0eee..0000000
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/BSPTreeVisitor.java
+++ /dev/null
@@ -1,112 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning;
-
-import org.apache.commons.geometry.core.Point;
-
-/** This interface is used to visit {@link BSPTree BSP tree} nodes.
-
- * <p>Navigation through {@link BSPTree BSP trees} can be done using
- * two different point of views:</p>
- * <ul>
- *   <li>
- *     the first one is in a node-oriented way using the {@link
- *     BSPTree#getPlus}, {@link BSPTree#getMinus} and {@link
- *     BSPTree#getParent} methods. Terminal nodes without associated
- *     {@link SubHyperplane sub-hyperplanes} can be visited this way,
- *     there is no constraint in the visit order, and it is possible
- *     to visit either all nodes or only a subset of the nodes
- *   </li>
- *   <li>
- *     the second one is in a sub-hyperplane-oriented way using
- *     classes implementing this interface which obeys the visitor
- *     design pattern. The visit order is provided by the visitor as
- *     each node is first encountered. Each node is visited exactly
- *     once.
- *   </li>
- * </ul>
-
- * @param <P> Point type defining the space
-
- * @see BSPTree
- * @see SubHyperplane
- */
-public interface BSPTreeVisitor<P extends Point<P>> {
-
-    /** Enumerate for visit order with respect to plus sub-tree, minus sub-tree and cut sub-hyperplane. */
-    enum Order {
-        /** Indicator for visit order plus sub-tree, then minus sub-tree,
-         * and last cut sub-hyperplane.
-         */
-        PLUS_MINUS_SUB,
-
-        /** Indicator for visit order plus sub-tree, then cut sub-hyperplane,
-         * and last minus sub-tree.
-         */
-        PLUS_SUB_MINUS,
-
-        /** Indicator for visit order minus sub-tree, then plus sub-tree,
-         * and last cut sub-hyperplane.
-         */
-        MINUS_PLUS_SUB,
-
-        /** Indicator for visit order minus sub-tree, then cut sub-hyperplane,
-         * and last plus sub-tree.
-         */
-        MINUS_SUB_PLUS,
-
-        /** Indicator for visit order cut sub-hyperplane, then plus sub-tree,
-         * and last minus sub-tree.
-         */
-        SUB_PLUS_MINUS,
-
-        /** Indicator for visit order cut sub-hyperplane, then minus sub-tree,
-         * and last plus sub-tree.
-         */
-        SUB_MINUS_PLUS;
-    }
-
-    /** Determine the visit order for this node.
-     * <p>Before attempting to visit an internal node, this method is
-     * called to determine the desired ordering of the visit. It is
-     * guaranteed that this method will be called before {@link
-     * #visitInternalNode visitInternalNode} for a given node, it will be
-     * called exactly once for each internal node.</p>
-     * @param node BSP node guaranteed to have a non null cut sub-hyperplane
-     * @return desired visit order, must be one of
-     * {@link Order#PLUS_MINUS_SUB}, {@link Order#PLUS_SUB_MINUS},
-     * {@link Order#MINUS_PLUS_SUB}, {@link Order#MINUS_SUB_PLUS},
-     * {@link Order#SUB_PLUS_MINUS}, {@link Order#SUB_MINUS_PLUS}
-     */
-    Order visitOrder(BSPTree<P> node);
-
-    /** Visit a BSP tree node node having a non-null sub-hyperplane.
-     * <p>It is guaranteed that this method will be called after {@link
-     * #visitOrder visitOrder} has been called for a given node,
-     * it wil be called exactly once for each internal node.</p>
-     * @param node BSP node guaranteed to have a non null cut sub-hyperplane
-     * @see #visitLeafNode
-     */
-    void visitInternalNode(BSPTree<P> node);
-
-    /** Visit a leaf BSP tree node node having a null sub-hyperplane.
-     * @param node leaf BSP node having a null sub-hyperplane
-     * @see #visitInternalNode
-     */
-    void visitLeafNode(BSPTree<P> node);
-
-}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/BoundaryAttribute.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/BoundaryAttribute.java
deleted file mode 100644
index 0476c34..0000000
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/BoundaryAttribute.java
+++ /dev/null
@@ -1,100 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning;
-
-import org.apache.commons.geometry.core.Point;
-
-/** Class holding boundary attributes.
- *
- * <p>This class is used for the attributes associated with the
- * nodes of region boundary shell trees returned by the {@link
- * Region#getTree(boolean) Region.getTree(includeBoundaryAttributes)}
- * when the boolean {@code includeBoundaryAttributes} parameter is
- * set to {@code true}. It contains the parts of the node cut
- * sub-hyperplane that belong to the boundary.</p>
- *
- * <p>This class is a simple placeholder, it does not provide any
- * processing methods.</p>
- *
- * @param <P> Point type defining the space
- * @see Region#getTree
- */
-public class BoundaryAttribute<P extends Point<P>> {
-
-    /** Part of the node cut sub-hyperplane that belongs to the
-     * boundary and has the outside of the region on the plus side of
-     * its underlying hyperplane (may be null).
-     */
-    private final SubHyperplane<P> plusOutside;
-
-    /** Part of the node cut sub-hyperplane that belongs to the
-     * boundary and has the inside of the region on the plus side of
-     * its underlying hyperplane (may be null).
-     */
-    private final SubHyperplane<P> plusInside;
-
-    /** Sub-hyperplanes that were used to split the boundary part. */
-    private final NodesSet<P> splitters;
-
-    /** Simple constructor.
-     * @param plusOutside part of the node cut sub-hyperplane that
-     * belongs to the boundary and has the outside of the region on
-     * the plus side of its underlying hyperplane (may be null)
-     * @param plusInside part of the node cut sub-hyperplane that
-     * belongs to the boundary and has the inside of the region on the
-     * plus side of its underlying hyperplane (may be null)
-     * @param splitters sub-hyperplanes that were used to
-     * split the boundary part (may be null)
-     */
-    BoundaryAttribute(final SubHyperplane<P> plusOutside,
-                      final SubHyperplane<P> plusInside,
-                      final NodesSet<P> splitters) {
-        this.plusOutside = plusOutside;
-        this.plusInside  = plusInside;
-        this.splitters   = splitters;
-    }
-
-    /** Get the part of the node cut sub-hyperplane that belongs to the
-     * boundary and has the outside of the region on the plus side of
-     * its underlying hyperplane.
-     * @return part of the node cut sub-hyperplane that belongs to the
-     * boundary and has the outside of the region on the plus side of
-     * its underlying hyperplane
-     */
-    public SubHyperplane<P> getPlusOutside() {
-        return plusOutside;
-    }
-
-    /** Get the part of the node cut sub-hyperplane that belongs to the
-     * boundary and has the inside of the region on the plus side of
-     * its underlying hyperplane.
-     * @return part of the node cut sub-hyperplane that belongs to the
-     * boundary and has the inside of the region on the plus side of
-     * its underlying hyperplane
-     */
-    public SubHyperplane<P> getPlusInside() {
-        return plusInside;
-    }
-
-    /** Get the sub-hyperplanes that were used to split the boundary part.
-     * @return sub-hyperplanes that were used to split the boundary part
-     */
-    public NodesSet<P> getSplitters() {
-        return splitters;
-    }
-
-}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/BoundaryBuilder.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/BoundaryBuilder.java
deleted file mode 100644
index 63d19b8..0000000
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/BoundaryBuilder.java
+++ /dev/null
@@ -1,99 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning;
-
-import org.apache.commons.geometry.core.Point;
-
-/** Visitor building boundary shell tree.
- *
- * <p>
- * The boundary shell is represented as {@link BoundaryAttribute boundary attributes}
- * at each internal node.
- * </p>
- *
- * @param <P> Point type defining the space.
- */
-class BoundaryBuilder<P extends Point<P>> implements BSPTreeVisitor<P> {
-
-    /** {@inheritDoc} */
-    @Override
-    public Order visitOrder(BSPTree<P> node) {
-        return Order.PLUS_MINUS_SUB;
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public void visitInternalNode(BSPTree<P> node) {
-
-        SubHyperplane<P> plusOutside = null;
-        SubHyperplane<P> plusInside  = null;
-        NodesSet<P>      splitters   = null;
-
-        // characterize the cut sub-hyperplane,
-        // first with respect to the plus sub-tree
-        final Characterization<P> plusChar = new Characterization<>(node.getPlus(), node.getCut().copySelf());
-
-        if (plusChar.touchOutside()) {
-            // plusChar.outsideTouching() corresponds to a subset of the cut sub-hyperplane
-            // known to have outside cells on its plus side, we want to check if parts
-            // of this subset do have inside cells on their minus side
-            final Characterization<P> minusChar = new Characterization<>(node.getMinus(), plusChar.outsideTouching());
-            if (minusChar.touchInside()) {
-                // this part belongs to the boundary,
-                // it has the outside on its plus side and the inside on its minus side
-                plusOutside = minusChar.insideTouching();
-                splitters = new NodesSet<>();
-                splitters.addAll(minusChar.getInsideSplitters());
-                splitters.addAll(plusChar.getOutsideSplitters());
-            }
-        }
-
-        if (plusChar.touchInside()) {
-            // plusChar.insideTouching() corresponds to a subset of the cut sub-hyperplane
-            // known to have inside cells on its plus side, we want to check if parts
-            // of this subset do have outside cells on their minus side
-            final Characterization<P> minusChar = new Characterization<>(node.getMinus(), plusChar.insideTouching());
-            if (minusChar.touchOutside()) {
-                // this part belongs to the boundary,
-                // it has the inside on its plus side and the outside on its minus side
-                plusInside = minusChar.outsideTouching();
-                if (splitters == null) {
-                    splitters = new NodesSet<>();
-                }
-                splitters.addAll(minusChar.getOutsideSplitters());
-                splitters.addAll(plusChar.getInsideSplitters());
-            }
-        }
-
-        if (splitters != null) {
-            // the parent nodes are natural splitters for boundary sub-hyperplanes
-            for (BSPTree<P> up = node.getParent(); up != null; up = up.getParent()) {
-                splitters.add(up);
-            }
-        }
-
-        // set the boundary attribute at non-leaf nodes
-        node.setAttribute(new BoundaryAttribute<>(plusOutside, plusInside, splitters));
-
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public void visitLeafNode(BSPTree<P> node) {
-    }
-
-}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/BoundaryProjection.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/BoundaryProjection.java
deleted file mode 100644
index 27709c2..0000000
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/BoundaryProjection.java
+++ /dev/null
@@ -1,84 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning;
-
-import org.apache.commons.geometry.core.Point;
-
-/** Class holding the result of point projection on region boundary.
- *
- * <p>This class is a simple placeholder, it does not provide any
- * processing methods.</p>
- *
- * <p>Instances of this class are guaranteed to be immutable</p>
- *
- * @param <P> Point type defining the space
- * @see AbstractRegion#projectToBoundary(Point)
- */
-public class BoundaryProjection<P extends Point<P>> {
-
-    /** Original point. */
-    private final P original;
-
-    /** Projected point. */
-    private final P projected;
-
-    /** Offset of the point with respect to the boundary it is projected on. */
-    private final double offset;
-
-    /** Constructor from raw elements.
-     * @param original original point
-     * @param projected projected point
-     * @param offset offset of the point with respect to the boundary it is projected on
-     */
-    public BoundaryProjection(final P original, final P projected, final double offset) {
-        this.original  = original;
-        this.projected = projected;
-        this.offset    = offset;
-    }
-
-    /** Get the original point.
-     * @return original point
-     */
-    public P getOriginal() {
-        return original;
-    }
-
-    /** Projected point.
-     * @return projected point, or null if there are no boundary
-     */
-    public P getProjected() {
-        return projected;
-    }
-
-    /** Offset of the point with respect to the boundary it is projected on.
-     * <p>
-     * The offset with respect to the boundary is negative if the {@link
-     * #getOriginal() original point} is inside the region, and positive otherwise.
-     * </p>
-     * <p>
-     * If there are no boundary, the value is set to either {@code
-     * Double.POSITIVE_INFINITY} if the region is empty (i.e. all points are
-     * outside of the region) or {@code Double.NEGATIVE_INFINITY} if the region
-     * covers the whole space (i.e. all points are inside of the region).
-     * </p>
-     * @return offset of the point with respect to the boundary it is projected on
-     */
-    public double getOffset() {
-        return offset;
-    }
-
-}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/BoundaryProjector.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/BoundaryProjector.java
deleted file mode 100644
index f694a89..0000000
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/BoundaryProjector.java
+++ /dev/null
@@ -1,200 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning;
-
-import java.util.ArrayList;
-import java.util.List;
-
-import org.apache.commons.geometry.core.Point;
-import org.apache.commons.geometry.core.partitioning.Region.Location;
-
-/** Local tree visitor to compute projection on boundary.
- * @param <P> Point type defining the space
- * @param <S> Point type defining the sub-space
- */
-class BoundaryProjector<P extends Point<P>, S extends Point<S>> implements BSPTreeVisitor<P> {
-
-    /** Original point. */
-    private final P original;
-
-    /** Current best projected point. */
-    private P projected;
-
-    /** Leaf node closest to the test point. */
-    private BSPTree<P> leaf;
-
-    /** Current offset. */
-    private double offset;
-
-    /** Simple constructor.
-     * @param original original point
-     */
-    BoundaryProjector(final P original) {
-        this.original  = original;
-        this.projected = null;
-        this.leaf      = null;
-        this.offset    = Double.POSITIVE_INFINITY;
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public Order visitOrder(final BSPTree<P> node) {
-        // we want to visit the tree so that the first encountered
-        // leaf is the one closest to the test point
-        if (node.getCut().getHyperplane().getOffset(original) <= 0) {
-            return Order.MINUS_SUB_PLUS;
-        } else {
-            return Order.PLUS_SUB_MINUS;
-        }
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public void visitInternalNode(final BSPTree<P> node) {
-
-        // project the point on the cut sub-hyperplane
-        final Hyperplane<P> hyperplane = node.getCut().getHyperplane();
-        final double signedOffset = hyperplane.getOffset(original);
-        if (Math.abs(signedOffset) < offset) {
-
-            // project point
-            final P regular = hyperplane.project(original);
-
-            // get boundary parts
-            final List<Region<S>> boundaryParts = boundaryRegions(node);
-
-            // check if regular projection really belongs to the boundary
-            boolean regularFound = false;
-            for (final Region<S> part : boundaryParts) {
-                if (!regularFound && belongsToPart(regular, hyperplane, part)) {
-                    // the projected point lies in the boundary
-                    projected    = regular;
-                    offset       = Math.abs(signedOffset);
-                    regularFound = true;
-                }
-            }
-
-            if (!regularFound) {
-                // the regular projected point is not on boundary,
-                // so we have to check further if a singular point
-                // (i.e. a vertex in 2D case) is a possible projection
-                for (final Region<S> part : boundaryParts) {
-                    final P spI = singularProjection(regular, hyperplane, part);
-                    if (spI != null) {
-                        final double distance = original.distance(spI);
-                        if (distance < offset) {
-                            projected = spI;
-                            offset    = distance;
-                        }
-                    }
-                }
-
-            }
-
-        }
-
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public void visitLeafNode(final BSPTree<P> node) {
-        if (leaf == null) {
-            // this is the first leaf we visit,
-            // it is the closest one to the original point
-            leaf = node;
-        }
-    }
-
-    /** Get the projection.
-     * @return projection
-     */
-    public BoundaryProjection<P> getProjection() {
-
-        // fix offset sign
-        offset = Math.copySign(offset, (Boolean) leaf.getAttribute() ? -1 : +1);
-
-        return new BoundaryProjection<>(original, projected, offset);
-
-    }
-
-    /** Extract the regions of the boundary on an internal node.
-     * @param node internal node
-     * @return regions in the node sub-hyperplane
-     */
-    private List<Region<S>> boundaryRegions(final BSPTree<P> node) {
-
-        final List<Region<S>> regions = new ArrayList<>(2);
-
-        @SuppressWarnings("unchecked")
-        final BoundaryAttribute<P> ba = (BoundaryAttribute<P>) node.getAttribute();
-        addRegion(ba.getPlusInside(),  regions);
-        addRegion(ba.getPlusOutside(), regions);
-
-        return regions;
-
-    }
-
-    /** Add a boundary region to a list.
-     * @param sub sub-hyperplane defining the region
-     * @param list to fill up
-     */
-    private void addRegion(final SubHyperplane<P> sub, final List<Region<S>> list) {
-        if (sub != null) {
-            @SuppressWarnings("unchecked")
-            final Region<S> region = ((AbstractSubHyperplane<P, S>) sub).getRemainingRegion();
-            if (region != null) {
-                list.add(region);
-            }
-        }
-    }
-
-    /** Check if a projected point lies on a boundary part.
-     * @param point projected point to check
-     * @param hyperplane hyperplane into which the point was projected
-     * @param part boundary part
-     * @return true if point lies on the boundary part
-     */
-    private boolean belongsToPart(final P point, final Hyperplane<P> hyperplane,
-                                  final Region<S> part) {
-
-        // there is a non-null sub-space, we can dive into smaller dimensions
-        @SuppressWarnings("unchecked")
-        final Embedding<P, S> embedding = (Embedding<P, S>) hyperplane;
-        return part.checkPoint(embedding.toSubSpace(point)) != Location.OUTSIDE;
-
-    }
-
-    /** Get the projection to the closest boundary singular point.
-     * @param point projected point to check
-     * @param hyperplane hyperplane into which the point was projected
-     * @param part boundary part
-     * @return projection to a singular point of boundary part (may be null)
-     */
-    private P singularProjection(final P point, final Hyperplane<P> hyperplane,
-                                        final Region<S> part) {
-
-        // there is a non-null sub-space, we can dive into smaller dimensions
-        @SuppressWarnings("unchecked")
-        final Embedding<P, S> embedding = (Embedding<P, S>) hyperplane;
-        final BoundaryProjection<S> bp = part.projectToBoundary(embedding.toSubSpace(point));
-
-        // back to initial dimension
-        return (bp.getProjected() == null) ? null : embedding.toSpace(bp.getProjected());
-
-    }
-
-}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/BoundarySizeVisitor.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/BoundarySizeVisitor.java
deleted file mode 100644
index e84c70a..0000000
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/BoundarySizeVisitor.java
+++ /dev/null
@@ -1,67 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning;
-
-import org.apache.commons.geometry.core.Point;
-
-/** Visitor computing the boundary size.
- * @param <P> Point type defining the space
- */
-class BoundarySizeVisitor<P extends Point<P>> implements BSPTreeVisitor<P> {
-
-    /** Size of the boundary. */
-    private double boundarySize;
-
-    /** Simple constructor.
-     */
-    BoundarySizeVisitor() {
-        boundarySize = 0;
-    }
-
-    /** {@inheritDoc}*/
-    @Override
-    public Order visitOrder(final BSPTree<P> node) {
-        return Order.MINUS_SUB_PLUS;
-    }
-
-    /** {@inheritDoc}*/
-    @Override
-    public void visitInternalNode(final BSPTree<P> node) {
-        @SuppressWarnings("unchecked")
-        final BoundaryAttribute<P> attribute =
-            (BoundaryAttribute<P>) node.getAttribute();
-        if (attribute.getPlusOutside() != null) {
-            boundarySize += attribute.getPlusOutside().getSize();
-        }
-        if (attribute.getPlusInside() != null) {
-            boundarySize += attribute.getPlusInside().getSize();
-        }
-    }
-
-    /** {@inheritDoc}*/
-    @Override
-    public void visitLeafNode(final BSPTree<P> node) {
-    }
-
-    /** Get the size of the boundary.
-     * @return size of the boundary
-     */
-    public double getSize() {
-        return boundarySize;
-    }
-
-}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Characterization.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Characterization.java
deleted file mode 100644
index d9ec6e7..0000000
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Characterization.java
+++ /dev/null
@@ -1,196 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning;
-
-import java.util.ArrayList;
-import java.util.List;
-
-import org.apache.commons.geometry.core.Point;
-
-/** Cut sub-hyperplanes characterization with respect to inside/outside cells.
- * @see BoundaryBuilder
- * @param <P> Point type defining the space
- */
-class Characterization<P extends Point<P>> {
-
-    /** Part of the cut sub-hyperplane that touch outside cells. */
-    private SubHyperplane<P> outsideTouching;
-
-    /** Part of the cut sub-hyperplane that touch inside cells. */
-    private SubHyperplane<P> insideTouching;
-
-    /** Nodes that were used to split the outside touching part. */
-    private final NodesSet<P> outsideSplitters;
-
-    /** Nodes that were used to split the outside touching part. */
-    private final NodesSet<P> insideSplitters;
-
-    /** Simple constructor.
-     * <p>Characterization consists in splitting the specified
-     * sub-hyperplane into several parts lying in inside and outside
-     * cells of the tree. The principle is to compute characterization
-     * twice for each cut sub-hyperplane in the tree, once on the plus
-     * node and once on the minus node. The parts that have the same flag
-     * (inside/inside or outside/outside) do not belong to the boundary
-     * while parts that have different flags (inside/outside or
-     * outside/inside) do belong to the boundary.</p>
-     * @param node current BSP tree node
-     * @param sub sub-hyperplane to characterize
-     */
-    Characterization(final BSPTree<P> node, final SubHyperplane<P> sub) {
-        outsideTouching  = null;
-        insideTouching   = null;
-        outsideSplitters = new NodesSet<>();
-        insideSplitters  = new NodesSet<>();
-        characterize(node, sub, new ArrayList<BSPTree<P>>());
-    }
-
-    /** Filter the parts of an hyperplane belonging to the boundary.
-     * <p>The filtering consist in splitting the specified
-     * sub-hyperplane into several parts lying in inside and outside
-     * cells of the tree. The principle is to call this method twice for
-     * each cut sub-hyperplane in the tree, once on the plus node and
-     * once on the minus node. The parts that have the same flag
-     * (inside/inside or outside/outside) do not belong to the boundary
-     * while parts that have different flags (inside/outside or
-     * outside/inside) do belong to the boundary.</p>
-     * @param node current BSP tree node
-     * @param sub sub-hyperplane to characterize
-     * @param splitters nodes that did split the current one
-     */
-    private void characterize(final BSPTree<P> node, final SubHyperplane<P> sub,
-                              final List<BSPTree<P>> splitters) {
-        if (node.getCut() == null) {
-            // we have reached a leaf node
-            final boolean inside = (Boolean) node.getAttribute();
-            if (inside) {
-                addInsideTouching(sub, splitters);
-            } else {
-                addOutsideTouching(sub, splitters);
-            }
-        } else {
-            final Hyperplane<P> hyperplane = node.getCut().getHyperplane();
-            final SubHyperplane.SplitSubHyperplane<P> split = sub.split(hyperplane);
-            switch (split.getSide()) {
-            case PLUS:
-                characterize(node.getPlus(),  sub, splitters);
-                break;
-            case MINUS:
-                characterize(node.getMinus(), sub, splitters);
-                break;
-            case BOTH:
-                splitters.add(node);
-                characterize(node.getPlus(),  split.getPlus(),  splitters);
-                characterize(node.getMinus(), split.getMinus(), splitters);
-                splitters.remove(splitters.size() - 1);
-                break;
-            default:
-                // If we reach this point, then the sub-hyperplane we're
-                // testing lies directly on this node's hyperplane. In theory,
-                // this shouldn't ever happen with correctly-formed trees. However,
-                // this does actually occur in practice, especially with manually
-                // built trees or very complex models. Rather than throwing an
-                // exception, we'll attempt to handle this situation gracefully
-                // by treating these sub-hyperplanes as if they lie on the minus
-                // side of the cut hyperplane.
-                characterize(node.getMinus(), sub, splitters);
-                break;
-            }
-        }
-    }
-
-    /** Add a part of the cut sub-hyperplane known to touch an outside cell.
-     * @param sub part of the cut sub-hyperplane known to touch an outside cell
-     * @param splitters sub-hyperplanes that did split the current one
-     */
-    private void addOutsideTouching(final SubHyperplane<P> sub,
-                                    final List<BSPTree<P>> splitters) {
-        if (outsideTouching == null) {
-            outsideTouching = sub;
-        } else {
-            outsideTouching = outsideTouching.reunite(sub);
-        }
-        outsideSplitters.addAll(splitters);
-    }
-
-    /** Add a part of the cut sub-hyperplane known to touch an inside cell.
-     * @param sub part of the cut sub-hyperplane known to touch an inside cell
-     * @param splitters sub-hyperplanes that did split the current one
-     */
-    private void addInsideTouching(final SubHyperplane<P> sub,
-                                   final List<BSPTree<P>> splitters) {
-        if (insideTouching == null) {
-            insideTouching = sub;
-        } else {
-            insideTouching = insideTouching.reunite(sub);
-        }
-        insideSplitters.addAll(splitters);
-    }
-
-    /** Check if the cut sub-hyperplane touches outside cells.
-     * @return true if the cut sub-hyperplane touches outside cells
-     */
-    public boolean touchOutside() {
-        return outsideTouching != null && !outsideTouching.isEmpty();
-    }
-
-    /** Get all the parts of the cut sub-hyperplane known to touch outside cells.
-     * @return parts of the cut sub-hyperplane known to touch outside cells
-     * (may be null or empty)
-     */
-    public SubHyperplane<P> outsideTouching() {
-        return outsideTouching;
-    }
-
-    /** Get the nodes that were used to split the outside touching part.
-     * <p>
-     * Splitting nodes are internal nodes (i.e. they have a non-null
-     * cut sub-hyperplane).
-     * </p>
-     * @return nodes that were used to split the outside touching part
-     */
-    public NodesSet<P> getOutsideSplitters() {
-        return outsideSplitters;
-    }
-
-    /** Check if the cut sub-hyperplane touches inside cells.
-     * @return true if the cut sub-hyperplane touches inside cells
-     */
-    public boolean touchInside() {
-        return insideTouching != null && !insideTouching.isEmpty();
-    }
-
-    /** Get all the parts of the cut sub-hyperplane known to touch inside cells.
-     * @return parts of the cut sub-hyperplane known to touch inside cells
-     * (may be null or empty)
-     */
-    public SubHyperplane<P> insideTouching() {
-        return insideTouching;
-    }
-
-    /** Get the nodes that were used to split the inside touching part.
-     * <p>
-     * Splitting nodes are internal nodes (i.e. they have a non-null
-     * cut sub-hyperplane).
-     * </p>
-     * @return nodes that were used to split the inside touching part
-     */
-    public NodesSet<P> getInsideSplitters() {
-        return insideSplitters;
-    }
-
-}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/ConvexSubHyperplane.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/ConvexSubHyperplane.java
new file mode 100644
index 0000000..606e233
--- /dev/null
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/ConvexSubHyperplane.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning;
+
+import org.apache.commons.geometry.core.Point;
+import org.apache.commons.geometry.core.Transform;
+
+/** Extension of the {@link SubHyperplane} interface with the additional restriction
+ * that instances represent convex regions of space.
+ * @param <P> Point implementation type
+ */
+public interface ConvexSubHyperplane<P extends Point<P>> extends SubHyperplane<P> {
+
+    /** Reverse the orientation of the hyperplane for this instance. The subhyperplane
+     * occupies the same locations in space but with a reversed orientation.
+     * @return a convex subhyperplane representing the same region but with the
+     *      opposite orientation.
+     */
+    ConvexSubHyperplane<P> reverse();
+
+    /** {@inheritDoc}
+     *
+     * <p>The parts resulting from a split operation with a convex subhyperplane
+     * are guaranteed to also be convex.</p>
+     */
+    @Override
+    Split<? extends ConvexSubHyperplane<P>> split(Hyperplane<P> splitter);
+
+    /** {@inheritDoc}
+     *
+     * <p>Convex subhyperplanes subjected to affine transformations remain
+     * convex.</p>
+     */
+    @Override
+    ConvexSubHyperplane<P> transform(Transform<P> transform);
+}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Embedding.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Embedding.java
deleted file mode 100644
index dfc40bf..0000000
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Embedding.java
+++ /dev/null
@@ -1,62 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning;
-
-import org.apache.commons.geometry.core.Point;
-
-/** This interface defines mappers between a space and one of its sub-spaces.
-
- * <p>Sub-spaces are the lower dimensions subsets of a n-dimensions
- * space. The (n-1)-dimension sub-spaces are specific sub-spaces known
- * as {@link Hyperplane hyperplanes}. This interface can be used regardless
- * of the dimensions differences. For example, a line in 3D Euclidean space
- * can map directly from 3 dimensions to 1.</p>
-
- * <p>In the 3D Euclidean space, hyperplanes are 2D planes, and the 1D
- * sub-spaces are lines.</p>
-
- * <p>
- * Note that this interface is <em>not</em> intended to be implemented
- * by Apache Commons Geometry users, it is only intended to be implemented
- * within the library itself. New methods may be added even for minor
- * versions, which breaks compatibility for external implementations.
- * </p>
-
- * @param <P> Point type defining the embedding space.
- * @param <S> Point type defining the embedded sub-space.
-
- * @see Hyperplane
- */
-public interface Embedding<P extends Point<P>, S extends Point<S>> {
-
-    /** Transform a space point into a sub-space point.
-     * @param point n-dimension point of the space
-     * @return (n-1)-dimension point of the sub-space corresponding to
-     * the specified space point
-     * @see #toSpace
-     */
-    S toSubSpace(P point);
-
-    /** Transform a sub-space point into a space point.
-     * @param point (n-1)-dimension point of the sub-space
-     * @return n-dimension point of the space corresponding to the
-     * specified sub-space point
-     * @see #toSubSpace
-     */
-    P toSpace(S point);
-
-}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Side.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/EmbeddingHyperplane.java
similarity index 67%
copy from commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Side.java
copy to commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/EmbeddingHyperplane.java
index 046defe..71b1297 100644
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Side.java
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/EmbeddingHyperplane.java
@@ -16,21 +16,15 @@
  */
 package org.apache.commons.geometry.core.partitioning;
 
-/** Enumerate representing the location of an element with respect to an
- * {@link Hyperplane hyperplane} of a space.
+import org.apache.commons.geometry.core.Embedding;
+import org.apache.commons.geometry.core.Point;
+
+/** Hyperplane that also embeds a subspace.
+ * @param <P> Point implementation type
+ * @param <S> Subspace point implementation type
+ * @see Hyperplane
+ * @see Embedding
  */
-public enum Side {
-
-    /** Code for the plus side of the hyperplane. */
-    PLUS,
-
-    /** Code for the minus side of the hyperplane. */
-    MINUS,
-
-    /** Code for elements crossing the hyperplane from plus to minus side. */
-    BOTH,
-
-    /** Code for the hyperplane itself. */
-    HYPER;
-
+public interface EmbeddingHyperplane<P extends Point<P>, S extends Point<S>>
+    extends Hyperplane<P>, Embedding<P, S> {
 }
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Hyperplane.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Hyperplane.java
index 28d9d12..d7bd09f 100644
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Hyperplane.java
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Hyperplane.java
@@ -17,80 +17,69 @@
 package org.apache.commons.geometry.core.partitioning;
 
 import org.apache.commons.geometry.core.Point;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.core.Transform;
 
-/** This interface represents an hyperplane of a space.
-
- * <p>The most prominent place where hyperplane appears in space
- * partitioning is as cutters. Each partitioning node in a {@link
- * BSPTree BSP tree} has a cut {@link SubHyperplane sub-hyperplane}
- * which is either an hyperplane or a part of an hyperplane. In an
- * n-dimensions Euclidean space, an hyperplane is an (n-1)-dimensions
- * hyperplane (for example a traditional plane in the 3D Euclidean
- * space). They can be more exotic objects in specific fields, for
- * example a circle on the surface of the unit sphere.</p>
-
- * <p>
- * Note that this interface is <em>not</em> intended to be implemented
- * by Apache Commons Geometry users, it is only intended to be implemented
- * within the library itself. New methods may be added even for minor
- * versions, which breaks compatibility for external implementations.
- * </p>
-
- * @param <P> Point type defining the space
+/** Interface representing a hyperplane, which is a subspace of degree
+ * one less than the space it is embedded in.
+ * @param <P> Point implementation type
  */
 public interface Hyperplane<P extends Point<P>> {
 
-    /** Copy the instance.
-     * <p>The instance created is completely independant of the original
-     * one. A deep copy is used, none of the underlying objects are
-     * shared (except for immutable objects).</p>
-     * @return a new hyperplane, copy of the instance
+    /** Get the offset (oriented distance) of a point with respect
+     * to this instance. Points with an offset of zero lie on the
+     * hyperplane itself.
+     * @param point the point to compute the offset for
+     * @return the offset of the point
      */
-    Hyperplane<P> copySelf();
+    double offset(P point);
 
-    /** Get the offset (oriented distance) of a point.
-     * <p>The offset is 0 if the point is on the underlying hyperplane,
-     * it is positive if the point is on one particular side of the
-     * hyperplane, and it is negative if the point is on the other side,
-     * according to the hyperplane natural orientation.</p>
-     * @param point point to check
-     * @return offset of the point
+    /** Classify a point with respect to this hyperplane.
+     * @param point the point to classify
+     * @return the relative location of the point with
+     *      respect to this instance
      */
-    double getOffset(P point);
+    HyperplaneLocation classify(P point);
 
-    /** Project a point to the hyperplane.
-     * @param point point to project
-     * @return projected point
+    /** Return true if the given point lies on the hyperplane.
+     * @param point the point to test
+     * @return true if the point lies on the hyperplane
+     */
+    boolean contains(P point);
+
+    /** Project a point onto this instance.
+     * @param point the point to project
+     * @return the projection of the point onto this instance. The returned
+     *      point lies on the hyperplane.
      */
     P project(P point);
 
-    /** Get the object used to determine floating point equality for this hyperplane.
-     * This determines which points belong to the hyperplane and which do not, or pictured
-     * another way, the "thickness" of the hyperplane.
-     * @return the floating point precision context for the instance
+    /** Return a hyperplane that has the opposite orientation as this instance.
+     * That is, the plus side of this instance is the minus side of the returned
+     * instance and vice versa.
+     * @return a hyperplane with the opposite orientation
      */
-    DoublePrecisionContext getPrecision();
+    Hyperplane<P> reverse();
 
-    /** Check if the instance has the same orientation as another hyperplane.
-     * <p>This method is expected to be called on parallel hyperplanes. The
-     * method should <em>not</em> re-check for parallelism, only for
-     * orientation, typically by testing something like the sign of the
-     * dot-products of normals.</p>
-     * @param other other hyperplane to check against the instance
-     * @return true if the instance and the other hyperplane have
-     * the same orientation
+    /** Transform this instance using the given {@link Transform}.
+     * @param transform object to transform this instance with
+     * @return a new, transformed hyperplane
      */
-    boolean sameOrientationAs(Hyperplane<P> other);
+    Hyperplane<P> transform(Transform<P> transform);
 
-    /** Build a sub-hyperplane covering the whole hyperplane.
-     * @return a sub-hyperplane covering the whole hyperplane
+    /** Return true if this instance has a similar orientation to the given hyperplane,
+     * meaning that they point in generally the same direction. This method is not
+     * used to determine exact equality of hyperplanes, but rather to determine whether
+     * two hyperplanes that contain the same points are parallel (point in the same direction)
+     * or anti-parallel (point in opposite directions).
+     * @param other the hyperplane to compare with
+     * @return true if the hyperplanes point in generally the same direction and could
+     *      possibly be parallel
      */
-    SubHyperplane<P> wholeHyperplane();
+    boolean similarOrientation(Hyperplane<P> other);
 
-    /** Build a region covering the whole space.
-     * @return a region containing the instance
+    /** Return a {@link ConvexSubHyperplane} spanning this entire hyperplane. The returned
+     * subhyperplane contains all points lying in this hyperplane and no more.
+     * @return a {@link ConvexSubHyperplane} containing all points lying in this hyperplane
      */
-    Region<P> wholeSpace();
-
+    ConvexSubHyperplane<P> span();
 }
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Side.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/HyperplaneBoundedRegion.java
similarity index 60%
copy from commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Side.java
copy to commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/HyperplaneBoundedRegion.java
index 046defe..0718d09 100644
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Side.java
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/HyperplaneBoundedRegion.java
@@ -16,21 +16,15 @@
  */
 package org.apache.commons.geometry.core.partitioning;
 
-/** Enumerate representing the location of an element with respect to an
- * {@link Hyperplane hyperplane} of a space.
+import org.apache.commons.geometry.core.Point;
+import org.apache.commons.geometry.core.Region;
+
+/** Interface representing regions with boundaries defined by hyperplanes or
+ * portions of hyperplanes. This interface is intended to represent closed regions
+ * with finite sizes as well as infinite and empty spaces. Regions of this type
+ * can be recursively split by hyperplanes into similar regions.
+ * @param <P> Point implementation type
  */
-public enum Side {
-
-    /** Code for the plus side of the hyperplane. */
-    PLUS,
-
-    /** Code for the minus side of the hyperplane. */
-    MINUS,
-
-    /** Code for elements crossing the hyperplane from plus to minus side. */
-    BOTH,
-
-    /** Code for the hyperplane itself. */
-    HYPER;
-
+public interface HyperplaneBoundedRegion<P extends Point<P>>
+    extends Region<P>, Splittable<P, HyperplaneBoundedRegion<P>> {
 }
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Side.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/HyperplaneLocation.java
similarity index 68%
rename from commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Side.java
rename to commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/HyperplaneLocation.java
index 046defe..ce45f0f 100644
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Side.java
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/HyperplaneLocation.java
@@ -16,21 +16,23 @@
  */
 package org.apache.commons.geometry.core.partitioning;
 
-/** Enumerate representing the location of an element with respect to an
- * {@link Hyperplane hyperplane} of a space.
+/** Enumeration containing possible locations of a point with respect to
+ * a hyperplane.
+ * @see Hyperplane
  */
-public enum Side {
+public enum HyperplaneLocation {
 
-    /** Code for the plus side of the hyperplane. */
-    PLUS,
-
-    /** Code for the minus side of the hyperplane. */
+    /** Value indicating that a point lies on the minus side of
+     * a hyperplane.
+     */
     MINUS,
 
-    /** Code for elements crossing the hyperplane from plus to minus side. */
-    BOTH,
+    /** Value indicating that a point lies on the plus side of
+     * a hyperplane.
+     */
+    PLUS,
 
-    /** Code for the hyperplane itself. */
-    HYPER;
-
+    /** Value indicating that a point lies directly on a hyperplane.
+     */
+    ON
 }
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/InsideFinder.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/InsideFinder.java
deleted file mode 100644
index cb384d9..0000000
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/InsideFinder.java
+++ /dev/null
@@ -1,149 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning;
-
-import org.apache.commons.geometry.core.Point;
-
-/** Utility class checking if inside nodes can be found
- * on the plus and minus sides of an hyperplane.
- * @param <P> Point type defining the space
- */
-class InsideFinder<P extends Point<P>> {
-
-    /** Region on which to operate. */
-    private final Region<P> region;
-
-    /** Indicator of inside leaf nodes found on the plus side. */
-    private boolean plusFound;
-
-    /** Indicator of inside leaf nodes found on the plus side. */
-    private boolean minusFound;
-
-    /** Simple constructor.
-     * @param region region on which to operate
-     */
-    InsideFinder(final Region<P> region) {
-        this.region = region;
-        plusFound  = false;
-        minusFound = false;
-    }
-
-    /** Search recursively for inside leaf nodes on each side of the given hyperplane.
-
-     * <p>The algorithm used here is directly derived from the one
-     * described in section III (<i>Binary Partitioning of a BSP
-     * Tree</i>) of the Bruce Naylor, John Amanatides and William
-     * Thibault paper <a
-     * href="http://www.cs.yorku.ca/~amana/research/bsptSetOp.pdf">Merging
-     * BSP Trees Yields Polyhedral Set Operations</a> Proc. Siggraph
-     * '90, Computer Graphics 24(4), August 1990, pp 115-124, published
-     * by the Association for Computing Machinery (ACM)..</p>
-
-     * @param node current BSP tree node
-     * @param sub sub-hyperplane
-     */
-    public void recurseSides(final BSPTree<P> node, final SubHyperplane<P> sub) {
-
-        if (node.getCut() == null) {
-            if ((Boolean) node.getAttribute()) {
-                // this is an inside cell expanding across the hyperplane
-                plusFound  = true;
-                minusFound = true;
-            }
-            return;
-        }
-
-        final Hyperplane<P> hyperplane = node.getCut().getHyperplane();
-        final SubHyperplane.SplitSubHyperplane<P> split = sub.split(hyperplane);
-        switch (split.getSide()) {
-        case PLUS :
-            // the sub-hyperplane is entirely in the plus sub-tree
-            if (node.getCut().split(sub.getHyperplane()).getSide() == Side.PLUS) {
-                if (!region.isEmpty(node.getMinus())) {
-                    plusFound  = true;
-                }
-            } else {
-                if (!region.isEmpty(node.getMinus())) {
-                    minusFound = true;
-                }
-            }
-            if (!(plusFound && minusFound)) {
-                recurseSides(node.getPlus(), sub);
-            }
-            break;
-        case MINUS :
-            // the sub-hyperplane is entirely in the minus sub-tree
-            if (node.getCut().split(sub.getHyperplane()).getSide() == Side.PLUS) {
-                if (!region.isEmpty(node.getPlus())) {
-                    plusFound  = true;
-                }
-            } else {
-                if (!region.isEmpty(node.getPlus())) {
-                    minusFound = true;
-                }
-            }
-            if (!(plusFound && minusFound)) {
-                recurseSides(node.getMinus(), sub);
-            }
-            break;
-        case BOTH :
-            // the sub-hyperplane extends in both sub-trees
-
-            // explore first the plus sub-tree
-            recurseSides(node.getPlus(), split.getPlus());
-
-            // if needed, explore the minus sub-tree
-            if (!(plusFound && minusFound)) {
-                recurseSides(node.getMinus(), split.getMinus());
-            }
-            break;
-        default :
-            // the sub-hyperplane and the cut sub-hyperplane share the same hyperplane
-            if (node.getCut().getHyperplane().sameOrientationAs(sub.getHyperplane())) {
-                if ((node.getPlus().getCut() != null) || ((Boolean) node.getPlus().getAttribute())) {
-                    plusFound  = true;
-                }
-                if ((node.getMinus().getCut() != null) || ((Boolean) node.getMinus().getAttribute())) {
-                    minusFound = true;
-                }
-            } else {
-                if ((node.getPlus().getCut() != null) || ((Boolean) node.getPlus().getAttribute())) {
-                    minusFound = true;
-                }
-                if ((node.getMinus().getCut() != null) || ((Boolean) node.getMinus().getAttribute())) {
-                    plusFound  = true;
-                }
-            }
-        }
-
-    }
-
-    /** Check if inside leaf nodes have been found on the plus side.
-     * @return true if inside leaf nodes have been found on the plus side
-     */
-    public boolean plusFound() {
-        return plusFound;
-    }
-
-    /** Check if inside leaf nodes have been found on the minus side.
-     * @return true if inside leaf nodes have been found on the minus side
-     */
-    public boolean minusFound() {
-        return minusFound;
-    }
-
-}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/NodesSet.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/NodesSet.java
deleted file mode 100644
index 54e0c3d..0000000
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/NodesSet.java
+++ /dev/null
@@ -1,72 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning;
-
-import java.util.ArrayList;
-import java.util.Iterator;
-import java.util.List;
-
-import org.apache.commons.geometry.core.Point;
-
-/** Set of {@link BSPTree BSP tree} nodes.
- * @see BoundaryAttribute
- * @param <P> Point type defining the space
- */
-public class NodesSet<P extends Point<P>> implements Iterable<BSPTree<P>> {
-
-    /** List of sub-hyperplanes. */
-    private final List<BSPTree<P>> list;
-
-    /** Simple constructor.
-     */
-    public NodesSet() {
-        list = new ArrayList<>();
-    }
-
-    /** Add a node if not already known.
-     * @param node node to add
-     */
-    public void add(final BSPTree<P> node) {
-
-        for (final BSPTree<P> existing : list) {
-            if (node == existing) {
-                // the node is already known, don't add it
-                return;
-            }
-        }
-
-        // the node was not known, add it
-        list.add(node);
-
-    }
-
-    /** Add nodes if they are not already known.
-     * @param iterator nodes iterator
-     */
-    public void addAll(final Iterable<BSPTree<P>> iterator) {
-        for (final BSPTree<P> node : iterator) {
-            add(node);
-        }
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public Iterator<BSPTree<P>> iterator() {
-        return list.iterator();
-    }
-
-}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Region.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Region.java
deleted file mode 100644
index 4fef7e3..0000000
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Region.java
+++ /dev/null
@@ -1,204 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning;
-
-import org.apache.commons.geometry.core.Point;
-
-/** This interface represents a region of a space as a partition.
-
- * <p>Region are subsets of a space, they can be infinite (whole
- * space, half space, infinite stripe ...) or finite (polygons in 2D,
- * polyhedrons in 3D ...). Their main characteristic is to separate
- * points that are considered to be <em>inside</em> the region from
- * points considered to be <em>outside</em> of it. In between, there
- * may be points on the <em>boundary</em> of the region.</p>
-
- * <p>This implementation is limited to regions for which the boundary
- * is composed of several {@link SubHyperplane sub-hyperplanes},
- * including regions with no boundary at all: the whole space and the
- * empty region. They are not necessarily finite and not necessarily
- * path-connected. They can contain holes.</p>
-
- * <p>Regions can be combined using the traditional sets operations :
- * union, intersection, difference and symetric difference (exclusive
- * or) for the binary operations, complement for the unary
- * operation.</p>
-
- * <p>
- * Note that this interface is <em>not</em> intended to be implemented
- * by Apache Commons Math users, it is only intended to be implemented
- * within the library itself. New methods may be added even for minor
- * versions, which breaks compatibility for external implementations.
- * </p>
-
- * @param <P> Point type defining the space
- */
-public interface Region<P extends Point<P>> {
-
-    /** Enumerate for the location of a point with respect to the region. */
-    enum Location {
-        /** Code for points inside the partition. */
-        INSIDE,
-
-        /** Code for points outside of the partition. */
-        OUTSIDE,
-
-        /** Code for points on the partition boundary. */
-        BOUNDARY;
-    }
-
-    /** Build a region using the instance as a prototype.
-     * <p>This method allow to create new instances without knowing
-     * exactly the type of the region. It is an application of the
-     * prototype design pattern.</p>
-     * <p>The leaf nodes of the BSP tree <em>must</em> have a
-     * {@code Boolean} attribute representing the inside status of
-     * the corresponding cell (true for inside cells, false for outside
-     * cells). In order to avoid building too many small objects, it is
-     * recommended to use the predefined constants
-     * {@code Boolean.TRUE} and {@code Boolean.FALSE}. The
-     * tree also <em>must</em> have either null internal nodes or
-     * internal nodes representing the boundary as specified in the
-     * {@link #getTree getTree} method).</p>
-     * @param newTree inside/outside BSP tree representing the new region
-     * @return the built region
-     */
-    Region<P> buildNew(BSPTree<P> newTree);
-
-    /** Copy the instance.
-     * <p>The instance created is completely independant of the original
-     * one. A deep copy is used, none of the underlying objects are
-     * shared (except for the underlying tree {@code Boolean}
-     * attributes and immutable objects).</p>
-     * @return a new region, copy of the instance
-     */
-    Region<P> copySelf();
-
-    /** Check if the instance is empty.
-     * @return true if the instance is empty
-     */
-    boolean isEmpty();
-
-    /** Check if the sub-tree starting at a given node is empty.
-     * @param node root node of the sub-tree (<em>must</em> have {@link
-     * Region Region} tree semantics, i.e. the leaf nodes must have
-     * {@code Boolean} attributes representing an inside/outside
-     * property)
-     * @return true if the sub-tree starting at the given node is empty
-     */
-    boolean isEmpty(final BSPTree<P> node);
-
-    /** Check if the instance covers the full space.
-     * @return true if the instance covers the full space
-     */
-    boolean isFull();
-
-    /** Check if the sub-tree starting at a given node covers the full space.
-     * @param node root node of the sub-tree (<em>must</em> have {@link
-     * Region Region} tree semantics, i.e. the leaf nodes must have
-     * {@code Boolean} attributes representing an inside/outside
-     * property)
-     * @return true if the sub-tree starting at the given node covers the full space
-     */
-    boolean isFull(final BSPTree<P> node);
-
-    /** Check if the instance entirely contains another region.
-     * @param region region to check against the instance
-     * @return true if the instance contains the specified tree
-     */
-    boolean contains(final Region<P> region);
-
-    /** Check a point with respect to the region.
-     * @param point point to check
-     * @return a code representing the point status: either {@link
-     * Location#INSIDE}, {@link Location#OUTSIDE} or {@link Location#BOUNDARY}
-     */
-    Location checkPoint(final P point);
-
-    /** Project a point on the boundary of the region.
-     * @param point point to check
-     * @return projection of the point on the boundary
-     */
-    BoundaryProjection<P> projectToBoundary(final P point);
-
-    /** Get the underlying BSP tree.
-
-     * <p>Regions are represented by an underlying inside/outside BSP
-     * tree whose leaf attributes are {@code Boolean} instances
-     * representing inside leaf cells if the attribute value is
-     * {@code true} and outside leaf cells if the attribute is
-     * {@code false}. These leaf attributes are always present and
-     * guaranteed to be non null.</p>
-
-     * <p>In addition to the leaf attributes, the internal nodes which
-     * correspond to cells split by cut sub-hyperplanes may contain
-     * {@link BoundaryAttribute BoundaryAttribute} objects representing
-     * the parts of the corresponding cut sub-hyperplane that belong to
-     * the boundary. When the boundary attributes have been computed,
-     * all internal nodes are guaranteed to have non-null
-     * attributes, however some {@link BoundaryAttribute
-     * BoundaryAttribute} instances may have their {@link
-     * BoundaryAttribute#getPlusInside() getPlusInside} and {@link
-     * BoundaryAttribute#getPlusOutside() getPlusOutside} methods both
-     * returning null if the corresponding cut sub-hyperplane does not
-     * have any parts belonging to the boundary.</p>
-
-     * <p>Since computing the boundary is not always required and can be
-     * time-consuming for large trees, these internal nodes attributes
-     * are computed using lazy evaluation only when required by setting
-     * the {@code includeBoundaryAttributes} argument to
-     * {@code true}. Once computed, these attributes remain in the
-     * tree, which implies that in this case, further calls to the
-     * method for the same region will always include these attributes
-     * regardless of the value of the
-     * {@code includeBoundaryAttributes} argument.</p>
-
-     * @param includeBoundaryAttributes if true, the boundary attributes
-     * at internal nodes are guaranteed to be included (they may be
-     * included even if the argument is false, if they have already been
-     * computed due to a previous call)
-     * @return underlying BSP tree
-     * @see BoundaryAttribute
-     */
-    BSPTree<P> getTree(final boolean includeBoundaryAttributes);
-
-    /** Get the size of the boundary.
-     * @return the size of the boundary (this is 0 in 1D, a length in
-     * 2D, an area in 3D ...)
-     */
-    double getBoundarySize();
-
-    /** Get the size of the instance.
-     * @return the size of the instance (this is a length in 1D, an area
-     * in 2D, a volume in 3D ...)
-     */
-    double getSize();
-
-    /** Get the barycenter of the instance.
-     * @return an object representing the barycenter
-     */
-    P getBarycenter();
-
-    /** Get the parts of a sub-hyperplane that are contained in the region.
-     * <p>The parts of the sub-hyperplane that belong to the boundary are
-     * <em>not</em> included in the resulting parts.</p>
-     * @param sub sub-hyperplane traversing the region
-     * @return filtered sub-hyperplane
-     */
-    SubHyperplane<P> intersection(final SubHyperplane<P> sub);
-
-}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/RegionFactory.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/RegionFactory.java
deleted file mode 100644
index c15676c..0000000
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/RegionFactory.java
+++ /dev/null
@@ -1,383 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning;
-
-import java.util.HashMap;
-import java.util.Map;
-
-import org.apache.commons.geometry.core.Point;
-import org.apache.commons.geometry.core.partitioning.BSPTree.VanishingCutHandler;
-import org.apache.commons.geometry.core.partitioning.Region.Location;
-import org.apache.commons.geometry.core.partitioning.SubHyperplane.SplitSubHyperplane;
-
-/** This class is a factory for {@link Region}.
-
- * @param <P> Point type defining the space
- */
-public class RegionFactory<P extends Point<P>> {
-
-    /** Visitor removing internal nodes attributes. */
-    private final NodesCleaner nodeCleaner;
-
-    /** Simple constructor.
-     */
-    public RegionFactory() {
-        nodeCleaner = new NodesCleaner();
-    }
-
-    /** Build a convex region from a collection of bounding hyperplanes.
-     * @param hyperplanes collection of bounding hyperplanes
-     * @return a new convex region, or null if the collection is empty
-     */
-    @SafeVarargs
-    public final Region<P> buildConvex(final Hyperplane<P> ... hyperplanes) {
-        if ((hyperplanes == null) || (hyperplanes.length == 0)) {
-            return null;
-        }
-
-        // use the first hyperplane to build the right class
-        final Region<P> region = hyperplanes[0].wholeSpace();
-
-        // chop off parts of the space
-        BSPTree<P> node = region.getTree(false);
-        node.setAttribute(Boolean.TRUE);
-        for (final Hyperplane<P> hyperplane : hyperplanes) {
-            if (node.insertCut(hyperplane)) {
-                node.setAttribute(null);
-                node.getPlus().setAttribute(Boolean.FALSE);
-                node = node.getMinus();
-                node.setAttribute(Boolean.TRUE);
-            } else {
-                // the hyperplane could not be inserted in the current leaf node
-                // either it is completely outside (which means the input hyperplanes
-                // are wrong), or it is parallel to a previous hyperplane
-                SubHyperplane<P> s = hyperplane.wholeHyperplane();
-                for (BSPTree<P> tree = node; tree.getParent() != null && s != null; tree = tree.getParent()) {
-                    final Hyperplane<P>         other = tree.getParent().getCut().getHyperplane();
-                    final SplitSubHyperplane<P> split = s.split(other);
-                    switch (split.getSide()) {
-                        case HYPER :
-                            // the hyperplane is parallel to a previous hyperplane
-                            if (!hyperplane.sameOrientationAs(other)) {
-                                // this hyperplane is opposite to the other one,
-                                // the region is thinner than the tolerance, we consider it empty
-                                return getComplement(hyperplanes[0].wholeSpace());
-                            }
-                            // the hyperplane is an extension of an already known hyperplane, we just ignore it
-                            break;
-                        case PLUS :
-                            // the hyperplane is outside of the current convex zone,
-                            // the input hyperplanes are inconsistent
-                            throw new IllegalArgumentException("Hyperplanes do not define a convex region");
-                        default :
-                            s = split.getMinus();
-                    }
-                }
-            }
-        }
-
-        return region;
-
-    }
-
-    /** Compute the union of two regions.
-     * @param region1 first region (will be unusable after the operation as
-     * parts of it will be reused in the new region)
-     * @param region2 second region (will be unusable after the operation as
-     * parts of it will be reused in the new region)
-     * @return a new region, result of {@code region1 union region2}
-     */
-    public Region<P> union(final Region<P> region1, final Region<P> region2) {
-        final BSPTree<P> tree =
-            region1.getTree(false).merge(region2.getTree(false), new UnionMerger());
-        tree.visit(nodeCleaner);
-        return region1.buildNew(tree);
-    }
-
-    /** Compute the intersection of two regions.
-     * @param region1 first region (will be unusable after the operation as
-     * parts of it will be reused in the new region)
-     * @param region2 second region (will be unusable after the operation as
-     * parts of it will be reused in the new region)
-     * @return a new region, result of {@code region1 intersection region2}
-     */
-    public Region<P> intersection(final Region<P> region1, final Region<P> region2) {
-        final BSPTree<P> tree =
-            region1.getTree(false).merge(region2.getTree(false), new IntersectionMerger());
-        tree.visit(nodeCleaner);
-        return region1.buildNew(tree);
-    }
-
-    /** Compute the symmetric difference (exclusive or) of two regions.
-     * @param region1 first region (will be unusable after the operation as
-     * parts of it will be reused in the new region)
-     * @param region2 second region (will be unusable after the operation as
-     * parts of it will be reused in the new region)
-     * @return a new region, result of {@code region1 xor region2}
-     */
-    public Region<P> xor(final Region<P> region1, final Region<P> region2) {
-        final BSPTree<P> tree =
-            region1.getTree(false).merge(region2.getTree(false), new XorMerger());
-        tree.visit(nodeCleaner);
-        return region1.buildNew(tree);
-    }
-
-    /** Compute the difference of two regions.
-     * @param region1 first region (will be unusable after the operation as
-     * parts of it will be reused in the new region)
-     * @param region2 second region (will be unusable after the operation as
-     * parts of it will be reused in the new region)
-     * @return a new region, result of {@code region1 minus region2}
-     */
-    public Region<P> difference(final Region<P> region1, final Region<P> region2) {
-        final BSPTree<P> tree =
-            region1.getTree(false).merge(region2.getTree(false), new DifferenceMerger(region1, region2));
-        tree.visit(nodeCleaner);
-        return region1.buildNew(tree);
-    }
-
-    /** Get the complement of the region (exchanged interior/exterior).
-     * @param region region to complement, it will not modified, a new
-     * region independent region will be built
-     * @return a new region, complement of the specified one
-     */
-    /** Get the complement of the region (exchanged interior/exterior).
-     * @param region region to complement, it will not modified, a new
-     * region independent region will be built
-     * @return a new region, complement of the specified one
-     */
-    public Region<P> getComplement(final Region<P> region) {
-        return region.buildNew(recurseComplement(region.getTree(false)));
-    }
-
-    /** Recursively build the complement of a BSP tree.
-     * @param node current node of the original tree
-     * @return new tree, complement of the node
-     */
-    private BSPTree<P> recurseComplement(final BSPTree<P> node) {
-
-        // transform the tree, except for boundary attribute splitters
-        final Map<BSPTree<P>, BSPTree<P>> map = new HashMap<>();
-        final BSPTree<P> transformedTree = recurseComplement(node, map);
-
-        // set up the boundary attributes splitters
-        for (final Map.Entry<BSPTree<P>, BSPTree<P>> entry : map.entrySet()) {
-            if (entry.getKey().getCut() != null) {
-                @SuppressWarnings("unchecked")
-                BoundaryAttribute<P> original = (BoundaryAttribute<P>) entry.getKey().getAttribute();
-                if (original != null) {
-                    @SuppressWarnings("unchecked")
-                    BoundaryAttribute<P> transformed = (BoundaryAttribute<P>) entry.getValue().getAttribute();
-                    for (final BSPTree<P> splitter : original.getSplitters()) {
-                        transformed.getSplitters().add(map.get(splitter));
-                    }
-                }
-            }
-        }
-
-        return transformedTree;
-
-    }
-
-    /** Recursively build the complement of a BSP tree.
-     * @param node current node of the original tree
-     * @param map transformed nodes map
-     * @return new tree, complement of the node
-     */
-    private BSPTree<P> recurseComplement(final BSPTree<P> node,
-                                         final Map<BSPTree<P>, BSPTree<P>> map) {
-
-        final BSPTree<P> transformedNode;
-        if (node.getCut() == null) {
-            transformedNode = new BSPTree<>(((Boolean) node.getAttribute()) ? Boolean.FALSE : Boolean.TRUE);
-        } else {
-
-            @SuppressWarnings("unchecked")
-            BoundaryAttribute<P> attribute = (BoundaryAttribute<P>) node.getAttribute();
-            if (attribute != null) {
-                final SubHyperplane<P> plusOutside =
-                        (attribute.getPlusInside() == null) ? null : attribute.getPlusInside().copySelf();
-                final SubHyperplane<P> plusInside  =
-                        (attribute.getPlusOutside() == null) ? null : attribute.getPlusOutside().copySelf();
-                // we start with an empty list of splitters, it will be filled in out of recursion
-                attribute = new BoundaryAttribute<>(plusOutside, plusInside, new NodesSet<P>());
-            }
-
-            transformedNode = new BSPTree<>(node.getCut().copySelf(),
-                                             recurseComplement(node.getPlus(),  map),
-                                             recurseComplement(node.getMinus(), map),
-                                             attribute);
-        }
-
-        map.put(node, transformedNode);
-        return transformedNode;
-
-    }
-
-    /** BSP tree leaf merger computing union of two regions. */
-    private class UnionMerger implements BSPTree.LeafMerger<P> {
-        /** {@inheritDoc} */
-        @Override
-        public BSPTree<P> merge(final BSPTree<P> leaf, final BSPTree<P> tree,
-                                final BSPTree<P> parentTree,
-                                final boolean isPlusChild, final boolean leafFromInstance) {
-            if ((Boolean) leaf.getAttribute()) {
-                // the leaf node represents an inside cell
-                leaf.insertInTree(parentTree, isPlusChild, new VanishingToLeaf(true));
-                return leaf;
-            }
-            // the leaf node represents an outside cell
-            tree.insertInTree(parentTree, isPlusChild, new VanishingToLeaf(false));
-            return tree;
-        }
-    }
-
-    /** BSP tree leaf merger computing intersection of two regions. */
-    private class IntersectionMerger implements BSPTree.LeafMerger<P> {
-        /** {@inheritDoc} */
-        @Override
-        public BSPTree<P> merge(final BSPTree<P> leaf, final BSPTree<P> tree,
-                                final BSPTree<P> parentTree,
-                                final boolean isPlusChild, final boolean leafFromInstance) {
-            if ((Boolean) leaf.getAttribute()) {
-                // the leaf node represents an inside cell
-                tree.insertInTree(parentTree, isPlusChild, new VanishingToLeaf(true));
-                return tree;
-            }
-            // the leaf node represents an outside cell
-            leaf.insertInTree(parentTree, isPlusChild, new VanishingToLeaf(false));
-            return leaf;
-        }
-    }
-
-    /** BSP tree leaf merger computing symmetric difference (exclusive or) of two regions. */
-    private class XorMerger implements BSPTree.LeafMerger<P> {
-        /** {@inheritDoc} */
-        @Override
-        public BSPTree<P> merge(final BSPTree<P> leaf, final BSPTree<P> tree,
-                                final BSPTree<P> parentTree, final boolean isPlusChild,
-                                final boolean leafFromInstance) {
-            BSPTree<P> t = tree;
-            if ((Boolean) leaf.getAttribute()) {
-                // the leaf node represents an inside cell
-                t = recurseComplement(t);
-            }
-            t.insertInTree(parentTree, isPlusChild, new VanishingToLeaf(true));
-            return t;
-        }
-    }
-
-    /** BSP tree leaf merger computing difference of two regions. */
-    private class DifferenceMerger implements BSPTree.LeafMerger<P>, VanishingCutHandler<P> {
-
-        /** Region to subtract from. */
-        private final Region<P> region1;
-
-        /** Region to subtract. */
-        private final Region<P> region2;
-
-        /** Simple constructor.
-         * @param region1 region to subtract from
-         * @param region2 region to subtract
-         */
-        DifferenceMerger(final Region<P> region1, final Region<P> region2) {
-            this.region1 = region1.copySelf();
-            this.region2 = region2.copySelf();
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public BSPTree<P> merge(final BSPTree<P> leaf, final BSPTree<P> tree,
-                                final BSPTree<P> parentTree, final boolean isPlusChild,
-                                final boolean leafFromInstance) {
-            if ((Boolean) leaf.getAttribute()) {
-                // the leaf node represents an inside cell
-                final BSPTree<P> argTree =
-                    recurseComplement(leafFromInstance ? tree : leaf);
-                argTree.insertInTree(parentTree, isPlusChild, this);
-                return argTree;
-            }
-            // the leaf node represents an outside cell
-            final BSPTree<P> instanceTree =
-                leafFromInstance ? leaf : tree;
-            instanceTree.insertInTree(parentTree, isPlusChild, this);
-            return instanceTree;
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public BSPTree<P> fixNode(final BSPTree<P> node) {
-            // get a representative point in the degenerate cell
-            final BSPTree<P> cell = node.pruneAroundConvexCell(Boolean.TRUE, Boolean.FALSE, null);
-            final Region<P> r = region1.buildNew(cell);
-            final P p = r.getBarycenter();
-            return new BSPTree<>(region1.checkPoint(p) == Location.INSIDE &&
-                                  region2.checkPoint(p) == Location.OUTSIDE);
-        }
-
-    }
-
-    /** Visitor removing internal nodes attributes. */
-    private class NodesCleaner implements  BSPTreeVisitor<P> {
-
-        /** {@inheritDoc} */
-        @Override
-        public Order visitOrder(final BSPTree<P> node) {
-            return Order.PLUS_SUB_MINUS;
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public void visitInternalNode(final BSPTree<P> node) {
-            node.setAttribute(null);
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public void visitLeafNode(final BSPTree<P> node) {
-        }
-
-    }
-
-    /** Handler replacing nodes with vanishing cuts with leaf nodes. */
-    private class VanishingToLeaf implements VanishingCutHandler<P> {
-
-        /** Inside/outside indocator to use for ambiguous nodes. */
-        private final boolean inside;
-
-        /** Simple constructor.
-         * @param inside inside/outside indicator to use for ambiguous nodes
-         */
-        VanishingToLeaf(final boolean inside) {
-            this.inside = inside;
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public BSPTree<P> fixNode(final BSPTree<P> node) {
-            if (node.getPlus().getAttribute().equals(node.getMinus().getAttribute())) {
-                // no ambiguity
-                return new BSPTree<>(node.getPlus().getAttribute());
-            } else {
-                // ambiguous node
-                return new BSPTree<>(inside);
-            }
-        }
-
-    }
-
-}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Split.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Split.java
new file mode 100644
index 0000000..20307e3
--- /dev/null
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Split.java
@@ -0,0 +1,97 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning;
+
+/** Class containing the result of splitting an object with a hyperplane.
+ * @param <T> Split type
+ */
+public final class Split<T> {
+
+    /** Part of the object lying on the minus side of the splitting hyperplane.
+     */
+    private final T minus;
+
+    /** Part of the object lying on the plus side of the splitting hyperplane.
+     */
+    private final T plus;
+
+    /** Build a new instance from its parts.
+     * @param minus part of the object lying on the minus side of the
+     *      splitting hyperplane or null if no such part exists
+     * @param plus part of the object lying on the plus side of the
+     *      splitting hyperplane or null if no such part exists.
+     */
+    public Split(final T minus, final T plus) {
+        this.minus = minus;
+        this.plus = plus;
+    }
+
+    /** Get the part of the object lying on the minus side of the splitting
+     * hyperplane or null if no such part exists.
+     * @return part of the object lying on the minus side of the splitting
+     *      hyperplane
+     */
+    public T getMinus() {
+        return minus;
+    }
+
+    /** Get the part of the object lying on the plus side of the splitting
+     * hyperplane or null if no such part exists.
+     * @return part of the object lying on the plus side of the splitting
+     *      hyperplane
+     */
+    public T getPlus() {
+        return plus;
+    }
+
+    /** Get the location of the object with respect to its splitting
+     * hyperplane.
+     * @return
+     *  <ul>
+     *      <li>{@link SplitLocation#PLUS} - if only {@link #getPlus()} is not null</li>
+     *      <li>{@link SplitLocation#MINUS} - if only {@link #getMinus()} is not null</li>
+     *      <li>{@link SplitLocation#BOTH} - if both {@link #getPlus()} and {@link #getMinus()}
+     *          are not null</li>
+     *      <li>{@link SplitLocation#NEITHER} - if both {@link #getPlus()} and {@link #getMinus()}
+     *          are null</li>
+     *  </ul>
+     */
+    public SplitLocation getLocation() {
+        if (minus != null) {
+            return plus != null ? SplitLocation.BOTH : SplitLocation.MINUS;
+        } else if (plus != null) {
+            return SplitLocation.PLUS;
+        }
+        return SplitLocation.NEITHER;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String toString() {
+        final StringBuilder sb = new StringBuilder();
+        sb.append(this.getClass().getSimpleName())
+            .append("[location= ")
+            .append(getLocation())
+            .append(", minus= ")
+            .append(minus)
+            .append(", plus= ")
+            .append(plus)
+            .append(']');
+
+        return sb.toString();
+    }
+}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/SplitLocation.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/SplitLocation.java
new file mode 100644
index 0000000..cbf2445
--- /dev/null
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/SplitLocation.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning;
+
+/** Enumeration representing the location of a split object with respect
+ * to its splitting {@link Hyperplane hyperplane}.
+ */
+public enum SplitLocation {
+
+    /** Value indicating that the split object lies entirely on the
+     * plus side of the splitting hyperplane.
+     */
+    PLUS,
+
+    /** Value indicating that the split object lies entirely on the
+     * minus side of the splitting hyperplane.
+     */
+    MINUS,
+
+    /** Value indicating that the split object lies in both the plus
+     * and minus sides of the splitting hyperplane.
+     */
+    BOTH,
+
+    /** Value indicating that the split object lies neither on the plus
+     * or minus sides of the splitting hyperplane. This is the case when
+     * the object lies entirely on the hyperplane or is empty (and
+     * therefore "lies" nowhere).
+     */
+    NEITHER;
+}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Side.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Splittable.java
similarity index 64%
copy from commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Side.java
copy to commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Splittable.java
index 046defe..60cef06 100644
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Side.java
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Splittable.java
@@ -16,21 +16,17 @@
  */
 package org.apache.commons.geometry.core.partitioning;
 
-/** Enumerate representing the location of an element with respect to an
- * {@link Hyperplane hyperplane} of a space.
+import org.apache.commons.geometry.core.Point;
+
+/** Interface representing objects that can be split by hyperplanes.
+ * @param <P> Point implementation type
+ * @param <S> Split type
  */
-public enum Side {
+public interface Splittable<P extends Point<P>, S extends Splittable<P, S>> {
 
-    /** Code for the plus side of the hyperplane. */
-    PLUS,
-
-    /** Code for the minus side of the hyperplane. */
-    MINUS,
-
-    /** Code for elements crossing the hyperplane from plus to minus side. */
-    BOTH,
-
-    /** Code for the hyperplane itself. */
-    HYPER;
-
+    /** Split this instance with the given hyperplane.
+     * @param splitter the hyperplane to split this object with.
+     * @return result of the split operation
+     */
+    Split<? extends S> split(Hyperplane<P> splitter);
 }
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/SubHyperplane.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/SubHyperplane.java
index 7237bb7..887f2a8 100644
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/SubHyperplane.java
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/SubHyperplane.java
@@ -16,127 +16,129 @@
  */
 package org.apache.commons.geometry.core.partitioning;
 
+import java.util.List;
+
 import org.apache.commons.geometry.core.Point;
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.Transform;
 
-/** This interface represents the remaining parts of an hyperplane after
- * other parts have been chopped off.
+/** Interface representing subhyperplanes, which are regions
+ * embedded in a hyperplane.
 
- * <p>sub-hyperplanes are obtained when parts of an {@link
- * Hyperplane hyperplane} are chopped off by other hyperplanes that
- * intersect it. The remaining part is a convex region. Such objects
- * appear in {@link BSPTree BSP trees} as the intersection of a cut
- * hyperplane with the convex region which it splits, the chopping
- * hyperplanes are the cut hyperplanes closer to the tree root.</p>
-
- * <p>
- * Note that this interface is <em>not</em> intended to be implemented
- * by Apache Commons Math users, it is only intended to be implemented
- * within the library itself. New methods may be added even for minor
- * versions, which breaks compatibility for external implementations.
- * </p>
-
- * @param <P> Point type defining the embedding space.
+ * @param <P> Point implementation type
  */
-public interface SubHyperplane<P extends Point<P>> {
+public interface SubHyperplane<P extends Point<P>> extends Splittable<P, SubHyperplane<P>> {
 
-    /** Copy the instance.
-     * <p>The instance created is completely independent of the original
-     * one. A deep copy is used, none of the underlying objects are
-     * shared (except for the nodes attributes and immutable
-     * objects).</p>
-     * @return a new sub-hyperplane, copy of the instance
-     */
-    SubHyperplane<P> copySelf();
-
-    /** Get the underlying hyperplane.
-     * @return underlying hyperplane
+    /** Get the hyperplane that this instance is embedded in.
+     * @return the hyperplane that this instance is embedded in.
      */
     Hyperplane<P> getHyperplane();
 
-    /** Check if the instance is empty.
-     * @return true if the instance is empty
+    /** Return true if this instance contains all points in the
+     * hyperplane.
+     * @return true if this instance contains all points in the
+     *      hyperplane
+     */
+    boolean isFull();
+
+    /** Return true if this instance does not contain any points.
+     * @return true if this instance does not contain any points
      */
     boolean isEmpty();
 
-    /** Get the size of the instance.
-     * @return the size of the instance (this is a length in 1D, an area
-     * in 2D, a volume in 3D ...)
+    /** Return true if this instance has infinite size.
+     * @return true if this instance has infinite size
+     */
+    boolean isInfinite();
+
+    /** Return true if this instance has finite size.
+     * @return true if this instance has finite size
+     */
+    boolean isFinite();
+
+    /** Return the size of this instance. This will have different
+     * meanings in different spaces and dimensions. For example, in
+     * Euclidean space, this will be length in 2D and area in 3D.
+     * @return the size of this instance
      */
     double getSize();
 
-    /** Split the instance in two parts by an hyperplane.
-     * @param hyperplane splitting hyperplane
-     * @return an object containing both the part of the instance
-     * on the plus side of the hyperplane and the part of the
-     * instance on the minus side of the hyperplane
+    /** Classify a point with respect to the subhyperplane's region. The point is
+     * classified as follows:
+     * <ul>
+     *  <li>{@link RegionLocation#INSIDE INSIDE} - The point lies on the hyperplane
+     *      and inside of the subhyperplane's region.</li>
+     *  <li>{@link RegionLocation#BOUNDARY BOUNDARY} - The point lies on the hyperplane
+     *      and is on the boundary of the subhyperplane's region.</li>
+     *  <li>{@link RegionLocation#OUTSIDE OUTSIDE} - The point does not lie on
+     *      the hyperplane or it does lie on the hyperplane but is outside of the
+     *      subhyperplane's region.</li>
+     * </ul>
+     * @param point the point to classify
+     * @return classification of the point with respect to the subhyperplane's hyperplane
+     *      and region
      */
-    SplitSubHyperplane<P> split(Hyperplane<P> hyperplane);
+    RegionLocation classify(P point);
 
-    /** Compute the union of the instance and another sub-hyperplane.
-     * @param other other sub-hyperplane to union (<em>must</em> be in the
-     * same hyperplane as the instance)
-     * @return a new sub-hyperplane, union of the instance and other
+    /** Return true if the subhyperplane contains the given point, meaning that the point
+     * lies on the hyperplane and is not on the outside of the subhyperplane's region.
+     * @param point the point to check
+     * @return true if the point is contained in the subhyperplane
      */
-    SubHyperplane<P> reunite(SubHyperplane<P> other);
-
-    /** Class holding the results of the {@link #split split} method.
-     * @param <U> Type of the embedding space.
-     */
-    class SplitSubHyperplane<U extends Point<U>> {
-
-        /** Part of the sub-hyperplane on the plus side of the splitting hyperplane. */
-        private final SubHyperplane<U> plus;
-
-        /** Part of the sub-hyperplane on the minus side of the splitting hyperplane. */
-        private final SubHyperplane<U> minus;
-
-        /** Build a SplitSubHyperplane from its parts.
-         * @param plus part of the sub-hyperplane on the plus side of the
-         * splitting hyperplane
-         * @param minus part of the sub-hyperplane on the minus side of the
-         * splitting hyperplane
-         */
-        public SplitSubHyperplane(final SubHyperplane<U> plus,
-                                  final SubHyperplane<U> minus) {
-            this.plus  = plus;
-            this.minus = minus;
-        }
-
-        /** Get the part of the sub-hyperplane on the plus side of the splitting hyperplane.
-         * @return part of the sub-hyperplane on the plus side of the splitting hyperplane
-         */
-        public SubHyperplane<U> getPlus() {
-            return plus;
-        }
-
-        /** Get the part of the sub-hyperplane on the minus side of the splitting hyperplane.
-         * @return part of the sub-hyperplane on the minus side of the splitting hyperplane
-         */
-        public SubHyperplane<U> getMinus() {
-            return minus;
-        }
-
-        /** Get the side of the split sub-hyperplane with respect to its splitter.
-         * @return {@link Side#PLUS} if only {@link #getPlus()} is neither null nor empty,
-         * {@link Side#MINUS} if only {@link #getMinus()} is neither null nor empty,
-         * {@link Side#BOTH} if both {@link #getPlus()} and {@link #getMinus()}
-         * are neither null nor empty or {@link Side#HYPER} if both {@link #getPlus()} and
-         * {@link #getMinus()} are either null or empty
-         */
-        public Side getSide() {
-            if (plus != null && !plus.isEmpty()) {
-                if (minus != null && !minus.isEmpty()) {
-                    return Side.BOTH;
-                } else {
-                    return Side.PLUS;
-                }
-            } else if (minus != null && !minus.isEmpty()) {
-                return Side.MINUS;
-            } else {
-                return Side.HYPER;
-            }
-        }
-
+    default boolean contains(P point) {
+        final RegionLocation loc = classify(point);
+        return loc != null && loc != RegionLocation.OUTSIDE;
     }
 
+    /** Return the closest point to the argument that is contained in the subhyperplane
+     * (ie, not classified as {@link RegionLocation#OUTSIDE outside}), or null if no
+     * such point exists.
+     * @param point the reference point
+     * @return the closest point to the reference point that is contained in the subhyperplane,
+     *      or null if no such point exists
+     */
+    P closest(P point);
+
+    /** Return a {@link Builder} instance for joining multiple
+     * subhyperplanes together.
+     * @return a new builder instance
+     */
+    Builder<P> builder();
+
+    /** Return a new subhyperplane instance resulting from the application
+     * of the given transform. The current instance is not modified.
+     * @param transform the transform instance to apply
+     * @return new transformed subhyperplane instance
+     */
+    SubHyperplane<P> transform(Transform<P> transform);
+
+    /** Convert this instance into a list of convex child subhyperplanes.
+     * @return a list of convex subhyperplanes representing the same subspace
+     *      region as this instance
+     */
+    List<? extends ConvexSubHyperplane<P>> toConvex();
+
+    /** Interface for joining multiple {@link SubHyperplane}s into a single
+     * instance.
+     * @param <P> Point implementation type
+     */
+    interface Builder<P extends Point<P>> {
+
+        /** Add a {@link SubHyperplane} instance to the builder.
+         * @param sub subhyperplane to add to this instance
+         */
+        void add(SubHyperplane<P> sub);
+
+        /** Add a {@link ConvexSubHyperplane} instance to the builder.
+         * @param sub convex subhyperplane to add to this instance
+         */
+        void add(ConvexSubHyperplane<P> sub);
+
+        /** Get a {@link SubHyperplane} representing the union
+         * of all input subhyperplanes.
+         * @return subhyperplane representing the union of all input
+         *      subhyperplanes
+         */
+        SubHyperplane<P> build();
+    }
 }
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Transform.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Transform.java
deleted file mode 100644
index 53cb056..0000000
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Transform.java
+++ /dev/null
@@ -1,77 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning;
-
-import org.apache.commons.geometry.core.Point;
-
-
-/** This interface represents an inversible affine transform in a space.
- * <p>Inversible affine transform include for example scalings,
- * translations, rotations.</p>
-
- * <p>Transforms are dimension-specific. The consistency rules between
- * the three {@code apply} methods are the following ones for a
- * transformed defined for dimension D:</p>
- * <ul>
- *   <li>
- *     the transform can be applied to a point in the
- *     D-dimension space using its {@link #apply(Point)}
- *     method
- *   </li>
- *   <li>
- *     the transform can be applied to a (D-1)-dimension
- *     hyperplane in the D-dimension space using its
- *     {@link #apply(Hyperplane)} method
- *   </li>
- *   <li>
- *     the transform can be applied to a (D-2)-dimension
- *     sub-hyperplane in a (D-1)-dimension hyperplane using
- *     its {@link #apply(SubHyperplane, Hyperplane, Hyperplane)}
- *     method
- *   </li>
- * </ul>
-
- * @param <P> Point type defining the embedding space.
- * @param <S> Point type defining the embedded sub-space.
- */
-public interface Transform<P extends Point<P>, S extends Point<S>> {
-
-    /** Transform a point of a space.
-     * @param point point to transform
-     * @return a new object representing the transformed point
-     */
-    P apply(P point);
-
-    /** Transform an hyperplane of a space.
-     * @param hyperplane hyperplane to transform
-     * @return a new object representing the transformed hyperplane
-     */
-    Hyperplane<P> apply(Hyperplane<P> hyperplane);
-
-    /** Transform a sub-hyperplane embedded in an hyperplane.
-     * @param sub sub-hyperplane to transform
-     * @param original hyperplane in which the sub-hyperplane is
-     * defined (this is the original hyperplane, the transform has
-     * <em>not</em> been applied to it)
-     * @param transformed hyperplane in which the sub-hyperplane is
-     * defined (this is the transformed hyperplane, the transform
-     * <em>has</em> been applied to it)
-     * @return a new object representing the transformed sub-hyperplane
-     */
-    SubHyperplane<S> apply(SubHyperplane<S> sub, Hyperplane<P> original, Hyperplane<P> transformed);
-
-}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/bsp/AbstractBSPTree.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/bsp/AbstractBSPTree.java
new file mode 100644
index 0000000..157eb16
--- /dev/null
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/bsp/AbstractBSPTree.java
@@ -0,0 +1,1108 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning.bsp;
+
+import java.io.Serializable;
+import java.util.Deque;
+import java.util.Iterator;
+import java.util.LinkedList;
+import java.util.NoSuchElementException;
+
+import org.apache.commons.geometry.core.Point;
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.partitioning.ConvexSubHyperplane;
+import org.apache.commons.geometry.core.partitioning.Hyperplane;
+import org.apache.commons.geometry.core.partitioning.HyperplaneLocation;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.SplitLocation;
+import org.apache.commons.geometry.core.partitioning.SubHyperplane;
+import org.apache.commons.geometry.core.partitioning.bsp.BSPTreeVisitor.Order;
+
+/** Abstract class for Binary Space Partitioning (BSP) tree implementations.
+ * @param <P> Point implementation type
+ * @param <N> BSP tree node implementation type
+ */
+public abstract class AbstractBSPTree<P extends Point<P>, N extends AbstractBSPTree.AbstractNode<P, N>>
+    implements BSPTree<P, N>, Serializable {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20190330L;
+
+    /** The default number of levels to print when creating a string representation of the tree. */
+    private static final int DEFAULT_TREE_STRING_MAX_DEPTH = 8;
+
+    /** Integer value set on various node fields when a value is unknown. */
+    private static final int UNKNOWN_VALUE = -1;
+
+    /** The root node for the tree. */
+    private N root;
+
+    /** The current modification version for the tree structure. This is incremented each time
+     * a structural change occurs in the tree and is used to determine when cached values
+     * must be recomputed.
+     */
+    private int version = 0;
+
+    /** {@inheritDoc} */
+    @Override
+    public N getRoot() {
+        if (root == null) {
+            setRoot(createNode());
+        }
+        return root;
+    }
+
+    /** Set the root node for the tree. Cached tree properties are invalidated
+     * with {@link #invalidate()}.
+     * @param root new root node for the tree
+     */
+    protected void setRoot(final N root) {
+        this.root = root;
+
+        this.root.makeRoot();
+
+        invalidate();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public int count() {
+        return getRoot().count();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public int height() {
+        return getRoot().height();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void accept(final BSPTreeVisitor<P, N> visitor) {
+        acceptVisitor(getRoot(), visitor);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public N findNode(final P pt, final NodeCutRule cutBehavior) {
+        return findNode(getRoot(), pt, cutBehavior);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void insert(final SubHyperplane<P> sub) {
+        insert(sub.toConvex());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void insert(final ConvexSubHyperplane<P> convexSub) {
+        insertRecursive(getRoot(), convexSub,
+                convexSub.getHyperplane().span());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void insert(final Iterable<? extends ConvexSubHyperplane<P>> convexSubs) {
+        for (final ConvexSubHyperplane<P> convexSub : convexSubs) {
+            insert(convexSub);
+        }
+    }
+
+    /** Return an iterator over the nodes in the tree. */
+    @Override
+    public Iterator<N> iterator() {
+        return new NodeIterator<>(getRoot());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void copy(final BSPTree<P, N> src) {
+        copySubtree(src.getRoot(), getRoot());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void extract(final N node) {
+        // copy downward
+        final N extracted = importSubtree(node);
+
+        // extract upward
+        final N newRoot = extractParentPath(node, extracted);
+
+        // set the root of this tree
+        setRoot(newRoot);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void transform(final Transform<P> transform) {
+        final boolean swapChildren = swapsInsideOutside(transform);
+        transformRecursive(getRoot(), transform, swapChildren);
+
+        invalidate();
+    }
+
+    /** Get a simple string representation of the tree structure. The returned string contains
+     * the tree structure down to the default max depth of {@value #DEFAULT_TREE_STRING_MAX_DEPTH}.
+     * @return a string representation of the tree
+     */
+    public String treeString() {
+        return treeString(DEFAULT_TREE_STRING_MAX_DEPTH);
+    }
+
+    /** Get a simple string representation of the tree structure. The returned string contains
+     * the tree structure down to {@code maxDepth}.
+     * @param maxDepth the maximum depth in the tree to print; nodes below this depth are skipped
+     * @return a string representation of the tree
+     */
+    public String treeString(final int maxDepth) {
+        BSPTreePrinter<P, N> printer = new BSPTreePrinter<>(maxDepth);
+        accept(printer);
+
+        return printer.toString();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String toString() {
+        return new StringBuilder()
+                .append(getClass().getSimpleName())
+                .append("[count= ")
+                .append(count())
+                .append(", height= ")
+                .append(height())
+                .append("]")
+                .toString();
+    }
+
+    /** Create a new node for this tree.
+     * @return a new node for this tree
+     */
+    protected abstract N createNode();
+
+    /** Copy non-structural node properties from {@code src} to {@code dst}.
+     * Non-structural properties are those properties not directly related
+     * to the structure of the BSP tree, i.e. properties other than parent/child
+     * connections and cut subhyperplanes. Subclasses should override this method
+     * when additional properties are stored on nodes.
+     * @param src source node
+     * @param dst destination node
+     */
+    protected void copyNodeProperties(final N src, final N dst) {
+        // no-op
+    }
+
+    /** Method called to initialize a new child node. Subclasses can use this method to
+     * set initial attributes on the node.
+     * @param parent the parent node
+     * @param child the new child node
+     * @param isPlus true if the child will be assigned as the parent's plus child;
+     *      false if it will be the parent's minus child
+     */
+    protected void initChildNode(final N parent, final N child, final boolean isPlus) {
+        // no-op
+    }
+
+    /** Create a non-structural copy of the given node. Properties such as parent/child
+     * connections and cut subhyperplanes are <em>not</em> copied.
+     * @param src the node to copy; does not need to belong to the current tree
+     * @return the copied node
+     * @see AbstractBSPTree#copyNodeProperties(AbstractNode, AbstractNode)
+     */
+    protected N copyNode(final N src) {
+        final N copy = createNode();
+        copyNodeProperties(src, copy);
+
+        return copy;
+    }
+
+    /** Recursively copy a subtree. The returned node is not attached to the current tree.
+     * Structural <em>and</em> non-structural properties are copied from the source subtree
+     * to the destination subtree. This method does nothing if {@code src} and {@code dst}
+     * reference the same node.
+     * @param src the node representing the source subtree; does not need to belong to the
+     *      current tree
+     * @param dst the node representing the destination subtree
+     * @return the copied node, ie {@code dst}
+     */
+    protected N copySubtree(final N src, final N dst) {
+        // only copy if we're actually switching nodes
+        if (src != dst) {
+            // copy non-structural properties
+            copyNodeProperties(src, dst);
+
+            // copy the subtree structure
+            ConvexSubHyperplane<P> cut = null;
+            N minus = null;
+            N plus = null;
+
+            if (!src.isLeaf()) {
+                final AbstractBSPTree<P, N> dstTree = dst.getTree();
+
+                cut = src.getCut();
+                minus = copySubtree(src.getMinus(), dstTree.createNode());
+                plus = copySubtree(src.getPlus(), dstTree.createNode());
+            }
+
+            dst.setSubtree(cut, minus, plus);
+        }
+
+        return dst;
+    }
+
+    /** Import the subtree represented by the given node into this tree. If the given node
+     * already belongs to this tree, then the node is returned directly without modification.
+     * If the node does <em>not</em> belong to this tree, a new node is created and the src node
+     * subtree is copied into it.
+     *
+     * <p>This method does not modify the current structure of the tree.</p>
+     * @param src node to import
+     * @return the given node if it belongs to this tree, otherwise a new node containing
+     *      a copy of the given node's subtree
+     * @see #copySubtree(AbstractNode, AbstractNode)
+     */
+    protected N importSubtree(final N src) {
+        // create a copy of the node if it's not already in this tree
+        if (src.getTree() != this) {
+            return copySubtree(src, createNode());
+        }
+
+        return src;
+    }
+
+    /** Extract the path from {@code src} to the root of its tree and
+     * set it as the parent path of {@code dst}. Leaf nodes created during
+     * the extraction are given the same node properties as their counterparts
+     * in the source tree but without the cuts and child nodes. The properties
+     * of {@code dst} are not modified, with the exception of its parent node
+     * reference.
+     * @param src the source node to copy the parent path from
+     * @param dst the destination node to place under the extracted path
+     * @return the root node of the extracted path
+     */
+    protected N extractParentPath(final N src, final N dst) {
+        N dstParent = dst;
+        N dstChild;
+
+        N srcChild = src;
+        N srcParent = srcChild.getParent();
+
+        while (srcParent != null) {
+            dstChild = dstParent;
+            dstParent = copyNode(srcParent);
+
+            if (srcChild.isMinus()) {
+                dstParent.setSubtree(
+                        srcParent.getCut(),
+                        dstChild,
+                        copyNode(srcParent.getPlus()));
+            } else {
+                dstParent.setSubtree(
+                        srcParent.getCut(),
+                        copyNode(srcParent.getMinus()),
+                        dstChild);
+            }
+
+            srcChild = srcParent;
+            srcParent = srcChild.getParent();
+        }
+
+        return dstParent;
+    }
+
+    /** Find the smallest node in the tree containing the point, starting
+     * at the given node.
+     * @param start the node to begin the search with
+     * @param pt the point to check
+     * @param cutBehavior value determining the search behavior when the test point
+     *      lies directly on the cut subhyperplane of an internal node
+     * @return the smallest node in the tree containing the point
+     */
+    protected N findNode(final N start, final P pt, final NodeCutRule cutBehavior) {
+        final Hyperplane<P> cutHyper = start.getCutHyperplane();
+        if (cutHyper != null) {
+            final HyperplaneLocation cutLoc = cutHyper.classify(pt);
+
+            final boolean onPlusSide = cutLoc == HyperplaneLocation.PLUS;
+            final boolean onMinusSide = cutLoc == HyperplaneLocation.MINUS;
+            final boolean onCut = !onPlusSide && !onMinusSide;
+
+            if (onMinusSide || (onCut && cutBehavior == NodeCutRule.MINUS)) {
+                return findNode(start.getMinus(), pt, cutBehavior);
+            } else if (onPlusSide || (onCut && cutBehavior == NodeCutRule.PLUS)) {
+                return findNode(start.getPlus(), pt, cutBehavior);
+            }
+        }
+        return start;
+    }
+
+    /** Visit the nodes in a subtree.
+     * @param node the node to begin the visit process
+     * @param visitor the visitor to pass nodes to
+     */
+    protected void acceptVisitor(final N node, BSPTreeVisitor<P, N> visitor) {
+        if (node.isLeaf()) {
+            visitor.visit(node);
+        } else {
+            final Order order = visitor.visitOrder(node);
+
+            if (order != null) {
+
+                switch (order) {
+                case PLUS_MINUS_NODE:
+                    acceptVisitor(node.getPlus(), visitor);
+                    acceptVisitor(node.getMinus(), visitor);
+                    visitor.visit(node);
+                    break;
+                case PLUS_NODE_MINUS:
+                    acceptVisitor(node.getPlus(), visitor);
+                    visitor.visit(node);
+                    acceptVisitor(node.getMinus(), visitor);
+                    break;
+                case MINUS_PLUS_NODE:
+                    acceptVisitor(node.getMinus(), visitor);
+                    acceptVisitor(node.getPlus(), visitor);
+                    visitor.visit(node);
+                    break;
+                case MINUS_NODE_PLUS:
+                    acceptVisitor(node.getMinus(), visitor);
+                    visitor.visit(node);
+                    acceptVisitor(node.getPlus(), visitor);
+                    break;
+                case NODE_PLUS_MINUS:
+                    visitor.visit(node);
+                    acceptVisitor(node.getPlus(), visitor);
+                    acceptVisitor(node.getMinus(), visitor);
+                    break;
+                default: // NODE_MINUS_PLUS:
+                    visitor.visit(node);
+                    acceptVisitor(node.getMinus(), visitor);
+                    acceptVisitor(node.getPlus(), visitor);
+                    break;
+                }
+            }
+        }
+    }
+
+    /** Cut a node with a hyperplane. The algorithm proceeds are follows:
+     * <ol>
+     *      <li>The hyperplane is trimmed by splitting it with each cut hyperplane on the
+     *      path from the given node to the root of the tree.</li>
+     *      <li>If the remaining portion of the hyperplane is <em>not</em> empty, then
+     *          <ul>
+     *              <li>the remaining portion becomes the cut subhyperplane for the node,</li>
+     *              <li>two new child nodes are created and initialized with
+     *              {@link #initChildNode(AbstractNode, AbstractNode, boolean)}, and</li>
+     *              <li>true is returned.</li>
+     *          </ul>
+ *          </li>
+     *      <li>If the remaining portion of the hyperplane <em>is</em> empty (ie, the
+     *      cutting hyperplane does not intersect the node's region), then
+     *          <ul>
+     *              <li>the node is converted to a leaf node (meaning that previous
+     *              child nodes are lost), and</li>
+     *              <li>false is returned.</li>
+     *          </ul>
+     *      </li>
+     * </ol>
+     *
+     * <p>It is important to note that since this method uses the path from given node
+     * to the tree root, it must only be used on nodes that are already inserted into
+     * the tree.</p>
+     *
+     * <p>This method always calls {@link #invalidate()} to invalidate cached tree properties.</p>
+     *
+     * @param node the node to cut
+     * @param cutter the hyperplane to cut the node with
+     * @return true if the node was cut and two new child nodes were created;
+     *      otherwise false
+     * @see #trimToNode(AbstractNode, ConvexSubHyperplane)
+     * @see #cutNode(AbstractNode, ConvexSubHyperplane)
+     * @see #invalidate()
+     */
+    protected boolean insertNodeCut(final N node, final Hyperplane<P> cutter) {
+        // cut the hyperplane using all hyperplanes from this node up
+        // to the root
+        final ConvexSubHyperplane<P> cut = trimToNode(node, cutter.span());
+        if (cut == null || cut.isEmpty()) {
+            // insertion failed; the node was not cut
+            cutNode(node, null);
+            return false;
+        }
+
+        cutNode(node, cut);
+        return true;
+    }
+
+    /** Trim the given subhyperplane to the region defined by the given node. This method cuts the
+     * subhyperplane with the cut hyperplanes (binary partitioners) of all parent nodes up to
+     * the root and returns the trimmed subhyperplane or {@code null} if the subhyperplane lies
+     * outside of the region defined by the node.
+     *
+     * <p>If the subhyperplane is directly coincident with a binary partitioner of a parent node,
+     * then the relative orientations of the associated hyperplanes are used to determine the behavior,
+     * as described below.
+     * <ul>
+     *      <li>If the orientations are <strong>similar</strong>, then the subhyperplane is determined to
+     *      lie <em>outside</em> of the node's region and {@code null} is returned.</li>
+     *      <li>If the orientations are <strong>different</strong> (ie, opposite), then the subhyperplane
+     *      is determined to lie <em>inside</em> of the node's region and the fit operation continues
+     *      with the remaining parent nodes.</li>
+     * </ul>
+     * These rules are designed to allow the creation of trees with node regions that are the thickness
+     * of a single hyperplane. For example, in two dimensions, a tree could be constructed with an internal
+     * node containing a cut along the x-axis in the positive direction and with a child node containing a
+     * cut along the x-axis in the opposite direction. If the nodes in the tree are given inside and outside
+     * attributes, then this tree could be used to represent a region consisting of a single line or a region
+     * consisting of the entire space except for the single line. This would not be possible if nodes were not
+     * able to have cut hyperplanes that were coincident with parent cuts but in opposite directions.
+     *
+     * <p>
+     * Another way of looking at the rules above is that inserting a hyperplane into the tree that exactly
+     * matches the hyperplane of a parent node does not add any information to the tree. However, adding a
+     * hyperplane to the tree that is coincident with a parent node but with the opposite orientation,
+     * <em>does</em> add information to the tree.
+     *
+     * @param node the node representing the region to fit the subhyperplane to
+     * @param sub the subhyperplane to trim to the node's region
+     * @return the trimmed subhyperplane or null if the given subhyperplane does not intersect
+     *      the node's region
+     */
+    protected ConvexSubHyperplane<P> trimToNode(final N node, final ConvexSubHyperplane<P> sub) {
+
+        ConvexSubHyperplane<P> result = sub;
+
+        N parentNode = node.getParent();
+        N currentNode = node;
+
+        while (parentNode != null && result != null) {
+            final Split<? extends ConvexSubHyperplane<P>> split = result.split(parentNode.getCutHyperplane());
+
+            if (split.getLocation() == SplitLocation.NEITHER) {
+                // if we're directly on the splitter and have the same orientation, then
+                // we say the subhyperplane does not lie in the node's region (no new information
+                // is added to the tree in this case)
+                if (result.getHyperplane().similarOrientation(parentNode.getCutHyperplane())) {
+                    result = null;
+                }
+            } else {
+                result = currentNode.isPlus() ? split.getPlus() : split.getMinus();
+            }
+
+            currentNode = parentNode;
+            parentNode = parentNode.getParent();
+        }
+
+        return result;
+    }
+
+    /** Remove the cut from the given node. Returns true if the node had a cut before
+     * the call to this method. Any previous child nodes are lost.
+     * @param node the node to remove the cut from
+     * @return true if the node previously had a cut
+     */
+    protected boolean removeNodeCut(final N node) {
+        boolean hadCut = node.getCut() != null;
+        cutNode(node, null);
+
+        return hadCut;
+    }
+
+    /** Set the cut subhyperplane for the given node. If {@code cut} is {@code null} then any
+     * existing child nodes are removed. If {@code cut} is not {@code null}, two new child
+     * nodes are created and initialized with
+     * {@link AbstractBSPTree#initChildNode(AbstractNode, AbstractNode, boolean)}.
+     *
+     * <p>This method performs absolutely <em>no</em> validation on the given cut
+     * subhyperplane. It is the responsibility of the caller to ensure that the
+     * subhyperplane fits the region represented by the node.</p>
+     *
+     * <p>This method always calls {@link #invalidate()} to invalidate cached tree properties.</p>
+     * @param node the node to cut
+     * @param cut the convex subhyperplane to set as the node cut
+     */
+    protected void cutNode(final N node, final ConvexSubHyperplane<P> cut) {
+        N plus = null;
+        N minus = null;
+
+        if (cut != null) {
+            minus = createNode();
+            initChildNode(node, minus, false);
+
+            plus = createNode();
+            initChildNode(node, plus, true);
+        }
+
+        node.setSubtree(cut, minus, plus);
+
+        invalidate();
+    }
+
+    /** Return true if the given transform swaps the inside and outside of
+     * the region.
+     *
+     * <p>The default behavior of this method is to return true if the transform
+     * does not preserve spatial orientation (ie, {@link Transform#preservesOrientation()}
+     * is false). Subclasses may need to override this method to implement the correct
+     * behavior for their space and dimension.</p>
+     * @param transform transform to check
+     * @return true if the given transform swaps the interior and exterior of
+     *      the region
+     */
+    protected boolean swapsInsideOutside(final Transform<P> transform) {
+        return !transform.preservesOrientation();
+    }
+
+    /** Recursively insert a subhyperplane into the tree at the given node.
+     * @param node the node to begin insertion with
+     * @param insert the subhyperplane to insert
+     * @param trimmed subhyperplane containing the result of splitting the entire
+     *      space with each hyperplane from this node to the root
+     */
+    private void insertRecursive(final N node, final ConvexSubHyperplane<P> insert,
+            final ConvexSubHyperplane<P> trimmed) {
+        if (node.isLeaf()) {
+            cutNode(node, trimmed);
+        } else {
+            final Split<? extends ConvexSubHyperplane<P>> insertSplit = insert.split(node.getCutHyperplane());
+
+            final ConvexSubHyperplane<P> minus = insertSplit.getMinus();
+            final ConvexSubHyperplane<P> plus = insertSplit.getPlus();
+
+            if (minus != null || plus != null) {
+                final Split<? extends ConvexSubHyperplane<P>> trimmedSplit = trimmed.split(node.getCutHyperplane());
+
+                if (minus != null) {
+                    insertRecursive(node.getMinus(), minus, trimmedSplit.getMinus());
+                }
+                if (plus != null) {
+                    insertRecursive(node.getPlus(), plus, trimmedSplit.getPlus());
+                }
+            }
+        }
+    }
+
+    /** Transform the subtree rooted as {@code node} recursively.
+     * @param node the root node of the subtree to transform
+     * @param t the transform to apply
+     * @param swapChildren if true, the plus and minus child nodes of each internal node
+     *      will be swapped; this should be the case when the transform is a reflection
+     */
+    private void transformRecursive(final N node, final Transform<P> t, final boolean swapChildren) {
+        if (node.isInternal()) {
+            // transform our cut
+            final ConvexSubHyperplane<P> transformedCut = node.getCut().transform(t);
+
+            // transform our children
+            transformRecursive(node.getMinus(), t, swapChildren);
+            transformRecursive(node.getPlus(), t, swapChildren);
+
+            final N transformedMinus = swapChildren ? node.getPlus() : node.getMinus();
+            final N transformedPlus = swapChildren ? node.getMinus() : node.getPlus();
+
+            // set our new state
+            node.setSubtree(transformedCut, transformedMinus, transformedPlus);
+        }
+    }
+
+    /** Split this tree with the given hyperplane, placing the split contents into the given
+     * target trees. One of the given trees may be null, in which case that portion of the split
+     * will not be exported. The current tree is not modified.
+     * @param splitter splitting hyperplane
+     * @param minus tree that will contain the portion of the tree on the minus side of the splitter
+     * @param plus tree that will contain the portion of the tree on the plus side of the splitter
+     */
+    protected void splitIntoTrees(final Hyperplane<P> splitter,
+            final AbstractBSPTree<P, N> minus, final AbstractBSPTree<P, N> plus) {
+
+        AbstractBSPTree<P, N> temp = (minus != null) ? minus : plus;
+
+        N splitRoot = temp.splitSubtree(this.getRoot(), splitter.span());
+
+        if (minus != null) {
+            if (plus != null) {
+                plus.extract(splitRoot.getPlus());
+            }
+            minus.extract(splitRoot.getMinus());
+        } else {
+            plus.extract(splitRoot.getPlus());
+        }
+    }
+
+    /** Split the subtree rooted at the given node by a partitioning convex subhyperplane defined
+     * on the same region as the node. The subtree rooted at {@code node} is imported into
+     * this tree, meaning that if it comes from a different tree, the other tree is not
+     * modified.
+     * @param node the root node of the subtree to split; may come from a different tree,
+     *      in which case the other tree is not modified
+     * @param partitioner partitioning convex subhyperplane
+     * @return node containing the split subtree
+     */
+    protected N splitSubtree(final N node, final ConvexSubHyperplane<P> partitioner) {
+        if (node.isLeaf()) {
+            return splitLeafNode(node, partitioner);
+        }
+        return splitInternalNode(node, partitioner);
+    }
+
+    /** Split the given leaf node by a partitioning convex subhyperplane defined on the
+     * same region and import it into this tree.
+     * @param node the leaf node to split
+     * @param partitioner partitioning convex subhyperplane
+     * @return node containing the split subtree
+     */
+    private N splitLeafNode(final N node, final ConvexSubHyperplane<P> partitioner) {
+        // in this case, we just create a new parent node with the partitioner as its
+        // cut and two copies of the original node as children
+        final N parent = createNode();
+        parent.setSubtree(partitioner, copyNode(node), copyNode(node));
+
+        return parent;
+    }
+
+    /** Split the given internal node by a partitioning convex subhyperplane defined on the same region
+     * as the node and import it into this tree.
+     * @param node the internal node to split
+     * @param partitioner partitioning convex subhyperplane
+     * @return node containing the split subtree
+     */
+    private N splitInternalNode(final N node, final ConvexSubHyperplane<P> partitioner) {
+        // split the partitioner and node cut with each other's hyperplanes to determine their relative positions
+        final Split<? extends ConvexSubHyperplane<P>> partitionerSplit = partitioner.split(node.getCutHyperplane());
+        final Split<? extends ConvexSubHyperplane<P>> nodeCutSplit = node.getCut().split(partitioner.getHyperplane());
+
+        final SplitLocation partitionerSplitSide = partitionerSplit.getLocation();
+        final SplitLocation nodeCutSplitSide = nodeCutSplit.getLocation();
+
+        final N result = createNode();
+
+        N resultMinus;
+        N resultPlus;
+
+        if (partitionerSplitSide == SplitLocation.PLUS) {
+            if (nodeCutSplitSide == SplitLocation.PLUS) {
+                // partitioner is on node cut plus side, node cut is on partitioner plus side
+                final N nodePlusSplit = splitSubtree(node.getPlus(), partitioner);
+
+                resultMinus = nodePlusSplit.getMinus();
+
+                resultPlus = copyNode(node);
+                resultPlus.setSubtree(node.getCut(), importSubtree(node.getMinus()), nodePlusSplit.getPlus());
+            } else {
+                // partitioner is on node cut plus side, node cut is on partitioner minus side
+                final N nodePlusSplit = splitSubtree(node.getPlus(), partitioner);
+
+                resultMinus = copyNode(node);
+                resultMinus.setSubtree(node.getCut(), importSubtree(node.getMinus()), nodePlusSplit.getMinus());
+
+                resultPlus = nodePlusSplit.getPlus();
+            }
+        } else if (partitionerSplitSide == SplitLocation.MINUS) {
+            if (nodeCutSplitSide == SplitLocation.MINUS) {
+                // partitioner is on node cut minus side, node cut is on partitioner minus side
+                final N nodeMinusSplit = splitSubtree(node.getMinus(), partitioner);
+
+                resultMinus = copyNode(node);
+                resultMinus.setSubtree(node.getCut(), nodeMinusSplit.getMinus(), importSubtree(node.getPlus()));
+
+                resultPlus = nodeMinusSplit.getPlus();
+            } else {
+                // partitioner is on node cut minus side, node cut is on partitioner plus side
+                final N nodeMinusSplit = splitSubtree(node.getMinus(), partitioner);
+
+                resultMinus = nodeMinusSplit.getMinus();
+
+                resultPlus = copyNode(node);
+                resultPlus.setSubtree(node.getCut(), nodeMinusSplit.getPlus(), importSubtree(node.getPlus()));
+            }
+        } else if (partitionerSplitSide == SplitLocation.BOTH) {
+            // partitioner and node cut split each other
+            final N nodeMinusSplit = splitSubtree(node.getMinus(), partitionerSplit.getMinus());
+            final N nodePlusSplit = splitSubtree(node.getPlus(), partitionerSplit.getPlus());
+
+            resultMinus = copyNode(node);
+            resultMinus.setSubtree(nodeCutSplit.getMinus(), nodeMinusSplit.getMinus(), nodePlusSplit.getMinus());
+
+            resultPlus = copyNode(node);
+            resultPlus.setSubtree(nodeCutSplit.getPlus(), nodeMinusSplit.getPlus(), nodePlusSplit.getPlus());
+        } else {
+            // partitioner and node cut are parallel or anti-parallel
+            final boolean sameOrientation = partitioner.getHyperplane().similarOrientation(node.getCutHyperplane());
+
+            resultMinus = importSubtree(sameOrientation ? node.getMinus() : node.getPlus());
+            resultPlus = importSubtree(sameOrientation ? node.getPlus() : node.getMinus());
+        }
+
+        result.setSubtree(partitioner, resultMinus, resultPlus);
+
+        return result;
+    }
+
+    /** Invalidate any previously computed properties that rely on the internal structure of the tree.
+     * This method must be called any time the tree's internal structure changes in order to force cacheable
+     * tree and node properties to be recomputed the next time they are requested.
+     *
+     * <p>This method increments the tree's {@link #version} property.</p>
+     * @see #getVersion()
+     */
+    protected void invalidate() {
+        version = Math.max(0, version + 1); // positive values only
+    }
+
+    /** Get the current structural version of the tree. This is incremented each time the
+     * tree structure is changes and can be used by nodes to allow caching of computed values.
+     * @return the current version of the tree structure
+     * @see #invalidate()
+     */
+    protected int getVersion() {
+        return version;
+    }
+
+    /** Abstract implementation of {@link BSPTree.Node}. This class is intended for use with
+     * {@link AbstractBSPTree} and delegates tree mutation methods back to the parent tree object.
+     * @param <P> Point implementation type
+     * @param <N> BSP tree node implementation type
+     */
+    public abstract static class AbstractNode<P extends Point<P>, N extends AbstractNode<P, N>>
+        implements BSPTree.Node<P, N>, Serializable {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20190225L;
+
+        /** The owning tree instance. */
+        private final AbstractBSPTree<P, N> tree;
+
+        /** The parent node; this will be null for the tree root node. */
+        private N parent;
+
+        /** The subhyperplane cutting the node's region; this will be null for leaf nodes. */
+        private ConvexSubHyperplane<P> cut;
+
+        /** The node lying on the minus side of the cut subhyperplane; this will be null
+         * for leaf nodes.
+         */
+        private N minus;
+
+        /** The node lying on the plus side of the cut subhyperplane; this will be null
+         * for leaf nodes.
+         */
+        private N plus;
+
+        /** The current version of the node. This is set to track the tree's version
+         * and is used to detect when certain values need to be recomputed due to
+         * structural changes in the tree.
+         */
+        private int nodeVersion = -1;
+
+        /** The depth of this node in the tree. This will be zero for the root node and
+         * {@link AbstractBSPTree#UNKNOWN_VALUE} when the value needs to be computed.
+         */
+        private int depth = UNKNOWN_VALUE;
+
+        /** The total number of nodes in the subtree rooted at this node. This will be
+         * set to {@link AbstractBSPTree#UNKNOWN_VALUE} when the value needs
+         * to be computed.
+         */
+        private int count = UNKNOWN_VALUE;
+
+        /** The height of the subtree rooted at this node. This will
+         * be set to {@link AbstractBSPTree#UNKNOWN_VALUE} when the value needs
+         * to be computed.
+         */
+        private int height = UNKNOWN_VALUE;
+
+        /** Simple constructor.
+         * @param tree the tree instance that owns this node
+         */
+        protected AbstractNode(final AbstractBSPTree<P, N> tree) {
+            this.tree = tree;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public AbstractBSPTree<P, N> getTree() {
+            return tree;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public int depth() {
+            if (depth == UNKNOWN_VALUE) {
+                // calculate our depth based on our parent's depth, if
+                // possible
+                if (parent != null) {
+                    final int parentDepth = parent.depth();
+                    if (parentDepth != UNKNOWN_VALUE) {
+                        depth = parentDepth + 1;
+                    }
+                }
+            }
+            return depth;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public int height() {
+            checkValid();
+
+            if (height == UNKNOWN_VALUE) {
+                if (isLeaf()) {
+                    height = 0;
+                } else {
+                    height = Math.max(getMinus().height(), getPlus().height()) + 1;
+                }
+            }
+
+            return height;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public int count() {
+            checkValid();
+
+            if (count == UNKNOWN_VALUE) {
+                count = 1;
+
+                if (!isLeaf()) {
+                    count += minus.count() + plus.count();
+                }
+            }
+
+            return count;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public Iterator<N> iterator() {
+            return new NodeIterator<P, N>(getSelf());
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void accept(final BSPTreeVisitor<P, N> visitor) {
+            tree.acceptVisitor(getSelf(), visitor);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public N getParent() {
+            return parent;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean isLeaf() {
+            return cut == null;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean isInternal() {
+            return cut != null;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean isPlus() {
+            return parent != null && parent.getPlus() == this;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean isMinus() {
+            return parent != null && parent.getMinus() == this;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public ConvexSubHyperplane<P> getCut() {
+            return cut;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public Hyperplane<P> getCutHyperplane() {
+            return (cut != null) ? cut.getHyperplane() : null;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public N getPlus() {
+            return plus;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public N getMinus() {
+            return minus;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean insertCut(final Hyperplane<P> cutter) {
+            return tree.insertNodeCut(getSelf(), cutter);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean clearCut() {
+            return tree.removeNodeCut(getSelf());
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public N cut(final Hyperplane<P> cutter) {
+            this.insertCut(cutter);
+
+            return getSelf();
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public String toString() {
+            final StringBuilder sb = new StringBuilder();
+            sb.append(this.getClass().getSimpleName())
+                .append("[cut= ")
+                .append(getCut())
+                .append(']');
+
+            return sb.toString();
+        }
+
+        /** Set the parameters for the subtree rooted at this node. The arguments should either be
+         * all null (representing a leaf node) or all non-null (representing an internal node).
+         *
+         * <p>Absolutely no validation is performed on the arguments. Callers are responsible for
+         * ensuring that any given subhyperplane fits the region defined by the node and that
+         * any child nodes belong to this tree and are correctly initialized.</p>
+         *
+         * @param newCut the new cut subhyperplane for the node
+         * @param newMinus the new minus child for the node
+         * @param newPlus the new plus child for the node
+         */
+        protected void setSubtree(final ConvexSubHyperplane<P> newCut, final N newMinus, final N newPlus) {
+            this.cut = newCut;
+
+            final N self = getSelf();
+
+            // cast for access to private member
+            AbstractNode<P, N> minusNode = newMinus;
+            AbstractNode<P, N> plusNode = newPlus;
+
+            // get the child depth now if we know it offhand, otherwise set it to the unknown value
+            // and have the child pull it when needed
+            final int childDepth = (depth != UNKNOWN_VALUE) ? depth + 1 : UNKNOWN_VALUE;
+
+            if (newMinus != null) {
+                minusNode.parent = self;
+                minusNode.depth = childDepth;
+            }
+            this.minus = newMinus;
+
+            if (newPlus != null) {
+                plusNode.parent = self;
+                plusNode.depth = childDepth;
+            }
+            this.plus = newPlus;
+        }
+
+        /**
+         * Make this node a root node, detaching it from its parent and settings its depth to zero.
+         * Any previous parent node will be left in an invalid state since one of its children now
+         * does not have a reference back to it.
+         */
+        protected void makeRoot() {
+            parent = null;
+            depth = 0;
+        }
+
+        /** Check if cached node properties are valid, meaning that no structural updates have
+         * occurred in the tree since the last call to this method. If updates have occurred, the
+         * {@link #nodeInvalidated()} method is called to clear the cached properties. This method
+         * should be called at the beginning of any method that fetches cacheable properties
+         * to ensure that no stale values are returned.
+         */
+        protected void checkValid() {
+            final int treeVersion = tree.getVersion();
+
+            if (nodeVersion != treeVersion) {
+                // the tree structure changed somewhere
+                nodeInvalidated();
+
+                // store the current version
+                nodeVersion = treeVersion;
+            }
+        }
+
+        /** Method called from {@link #checkValid()} when updates
+         * are detected in the tree. This method should clear out any
+         * computed properties that rely on the structure of the tree
+         * and prepare them for recalculation.
+         */
+        protected void nodeInvalidated() {
+            count = UNKNOWN_VALUE;
+            height = UNKNOWN_VALUE;
+        }
+
+        /** Get a reference to the current instance, cast to type N.
+         * @return a reference to the current instance, as type N.
+         */
+        protected abstract N getSelf();
+    }
+
+    /** Class for iterating through the nodes in a BSP subtree.
+     * @param <P> Point implementation type
+     * @param <N> Node implementation type
+     */
+    public static class NodeIterator<P extends Point<P>, N extends AbstractNode<P, N>> implements Iterator<N> {
+
+        /** The current node stack. */
+        private final Deque<N> stack = new LinkedList<>();
+
+        /** Create a new instance for iterating over the nodes in the given subtree.
+         * @param subtreeRoot the root node of the subtree to iterate
+         */
+        public NodeIterator(final N subtreeRoot) {
+            stack.push(subtreeRoot);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean hasNext() {
+            return !stack.isEmpty();
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public N next() {
+            if (stack.isEmpty()) {
+                throw new NoSuchElementException();
+            }
+
+            final N result = stack.pop();
+
+            if (result != null && !result.isLeaf()) {
+                stack.push(result.getPlus());
+                stack.push(result.getMinus());
+            }
+
+            return result;
+        }
+    }
+}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/bsp/AbstractBSPTreeMergeOperator.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/bsp/AbstractBSPTreeMergeOperator.java
new file mode 100644
index 0000000..8cfaa46
--- /dev/null
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/bsp/AbstractBSPTreeMergeOperator.java
@@ -0,0 +1,147 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning.bsp;
+
+import org.apache.commons.geometry.core.Point;
+import org.apache.commons.geometry.core.partitioning.bsp.AbstractBSPTree.AbstractNode;
+
+/** Class containing the basic algorithm for merging two {@link AbstractBSPTree}
+ * instances. Subclasses must override the
+ * {@link #mergeLeaf(AbstractBSPTree.AbstractNode, AbstractBSPTree.AbstractNode)} method
+ * to implement the merging logic for their particular use case. The remainder of the
+ * algorithm is independent of the use case.
+ *
+ * <p>This class does not expose any public methods so that subclasses can present their own
+ * public API, tailored to the specific types being worked with. In particular, most subclasses
+ * will want to restrict the tree types used with the algorithm, which is difficult to implement
+ * cleanly at this level.</p>
+ *
+ * <p>This class maintains state during the merging process and is therefore
+ * <em>not</em> thread-safe.</p>
+ * @param <P> Point implementation type
+ * @param <N> BSP tree node implementation type
+ */
+public abstract class AbstractBSPTreeMergeOperator<P extends Point<P>, N extends AbstractNode<P, N>> {
+
+    /** The tree that the merge operation output will be written to. All existing content
+     * in this tree is overwritten.
+     */
+    private AbstractBSPTree<P, N> outputTree;
+
+    /** Set the tree used as output for this instance.
+     * @param outputTree the tree used as output for this instance
+     */
+    protected void setOutputTree(final AbstractBSPTree<P, N> outputTree) {
+        this.outputTree = outputTree;
+    }
+
+    /** Get the tree used as output for this instance.
+     * @return the tree used as output for this instance
+     */
+    protected AbstractBSPTree<P, N> getOutputTree() {
+        return outputTree;
+    }
+
+    /** Perform a merge operation with the two input trees and store the result in the output tree. The
+     * output tree may be one of the input trees, in which case, the tree is modified in place.
+     * @param input1 first input tree
+     * @param input2 second input tree
+     * @param output output tree all previous content in this tree is overwritten
+     */
+    protected void performMerge(final AbstractBSPTree<P, N> input1, final AbstractBSPTree<P, N> input2,
+            final AbstractBSPTree<P, N> output) {
+
+        setOutputTree(output);
+
+        final N root1 = input1.getRoot();
+        final N root2 = input2.getRoot();
+
+        final N outputRoot = performMergeRecursive(root1, root2);
+
+        getOutputTree().setRoot(outputRoot);
+    }
+
+    /** Recursively merge two nodes.
+     * @param node1 node from the first input tree
+     * @param node2 node from the second input tree
+     * @return a merged node
+     */
+    private N performMergeRecursive(final N node1, final N node2) {
+
+        if (node1.isLeaf() || node2.isLeaf()) {
+            // delegate to the mergeLeaf method if we can no longer continue
+            // merging recursively
+            final N merged = mergeLeaf(node1, node2);
+
+            // copy the merged node to the output if needed (in case mergeLeaf
+            // returned one of the input nodes directly)
+            return outputTree.importSubtree(merged);
+        } else {
+            final N partitioned = outputTree.splitSubtree(node2, node1.getCut());
+
+            final N minus = performMergeRecursive(node1.getMinus(), partitioned.getMinus());
+
+            final N plus = performMergeRecursive(node1.getPlus(), partitioned.getPlus());
+
+            final N outputNode = outputTree.copyNode(node1);
+            outputNode.setSubtree(node1.getCut(), minus, plus);
+
+            return outputNode;
+        }
+    }
+
+    /** Create a new node in the output tree. The node is associated with the output tree but
+     * is not attached to a parent node.
+     * @return a new node associated with the output tree but not yet attached to a parent
+     */
+    protected N outputNode() {
+        return outputTree.createNode();
+    }
+
+    /** Create a new node in the output tree with the same non-structural properties as the given
+     * node. Non-structural properties are properties other than parent, children, or cut. The
+     * returned node is associated with the output tree but is not attached to a parent node.
+     * Note that this method only copies the given node and <strong>not</strong> any of its children.
+     * @param node the input node to copy properties from
+     * @return a new node in the output tree
+     */
+    protected N outputNode(final N node) {
+        return outputTree.copyNode(node);
+    }
+
+    /** Place the subtree rooted at the given input node into the output tree. The subtree
+     * is copied if needed.
+     * @param node the root of the subtree to copy
+     * @return a subtree in the output tree
+     */
+    protected N outputSubtree(final N node) {
+        return outputTree.importSubtree(node);
+    }
+
+    /** Merge a leaf node from one input with a subtree from another.
+     * <p>When this method is called, one or both of the given nodes will be a leaf node.
+     * This method is expected to return a node representing the merger of the two given
+     * nodes. The way that the returned node is determined defines the overall behavior of
+     * the merge operation.
+     * </p>
+     * <p>The return value can be one of the two input nodes or a completely different one.</p>
+     * @param node1 node from the first input tree
+     * @param node2 node from the second input tree
+     * @return node representing the merger of the two input nodes
+     */
+    protected abstract N mergeLeaf(N node1, N node2);
+}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/bsp/AbstractRegionBSPTree.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/bsp/AbstractRegionBSPTree.java
new file mode 100644
index 0000000..9a867a8
--- /dev/null
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/bsp/AbstractRegionBSPTree.java
@@ -0,0 +1,966 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning.bsp;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.function.Function;
+
+import org.apache.commons.geometry.core.Point;
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.internal.IteratorTransform;
+import org.apache.commons.geometry.core.partitioning.ConvexSubHyperplane;
+import org.apache.commons.geometry.core.partitioning.Hyperplane;
+import org.apache.commons.geometry.core.partitioning.HyperplaneBoundedRegion;
+import org.apache.commons.geometry.core.partitioning.HyperplaneLocation;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.SplitLocation;
+import org.apache.commons.geometry.core.partitioning.SubHyperplane;
+import org.apache.commons.geometry.core.partitioning.bsp.BSPTreeVisitor.ClosestFirstVisitor;
+
+/** {@link BSPTree} specialized for representing regions of space. For example, this
+ * class can be used to represent polygons in Euclidean 2D space and polyhedrons
+ * in Euclidean 3D space.
+ * @param <P> Point implementation type
+ * @param <N> BSP tree node implementation type
+ */
+public abstract class AbstractRegionBSPTree<
+        P extends Point<P>,
+        N extends AbstractRegionBSPTree.AbstractRegionNode<P, N>>
+    extends AbstractBSPTree<P, N> implements HyperplaneBoundedRegion<P> {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 1L;
+
+    /** Value used to indicate an unknown size. */
+    private static final double UNKNOWN_SIZE = -1.0;
+
+    /** The region boundary size; this is computed when requested and then cached. */
+    private double boundarySize = UNKNOWN_SIZE;
+
+    /** The current size properties for the region. */
+    private RegionSizeProperties<P> regionSizeProperties;
+
+    /** Construct a new region will the given boolean determining whether or not the
+     * region will be full (including the entire space) or empty (excluding the entire
+     * space).
+     * @param full if true, the region will cover the entire space, otherwise it will
+     *      be empty
+     */
+    protected AbstractRegionBSPTree(final boolean full) {
+        getRoot().setLocation(full ? RegionLocation.INSIDE : RegionLocation.OUTSIDE);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isEmpty() {
+        return !hasNodeWithLocationRecursive(getRoot(), RegionLocation.INSIDE);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isFull() {
+        return !hasNodeWithLocationRecursive(getRoot(), RegionLocation.OUTSIDE);
+    }
+
+    /** Return true if any node in the subtree rooted at the given node has a location with the
+     * given value.
+     * @param node the node at the root of the subtree to search
+     * @param location the location to find
+     * @return true if any node in the subtree has the given location
+     */
+    private boolean hasNodeWithLocationRecursive(final AbstractRegionNode<P, N> node, final RegionLocation location) {
+        if (node == null) {
+            return false;
+        }
+
+        return node.getLocation() == location ||
+                hasNodeWithLocationRecursive(node.getMinus(), location) ||
+                hasNodeWithLocationRecursive(node.getPlus(), location);
+    }
+
+    /** Modify this instance so that it contains the entire space.
+     * @see #isFull()
+     */
+    public void setFull() {
+        final N root = getRoot();
+
+        root.clearCut();
+        root.setLocation(RegionLocation.INSIDE);
+    }
+
+    /** Modify this instance so that is is completely empty.
+     * @see #isEmpty()
+     */
+    public void setEmpty() {
+        final N root = getRoot();
+
+        root.clearCut();
+        root.setLocation(RegionLocation.OUTSIDE);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public double getSize() {
+        return getRegionSizeProperties().getSize();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public double getBoundarySize() {
+        if (boundarySize < 0.0) {
+            double sum = 0.0;
+
+            RegionCutBoundary<P> boundary;
+            for (final AbstractRegionNode<P, N> node : this) {
+                boundary = node.getCutBoundary();
+                if (boundary != null) {
+                    sum += boundary.getInsideFacing().getSize();
+                    sum += boundary.getOutsideFacing().getSize();
+                }
+            }
+
+            boundarySize = sum;
+        }
+
+        return boundarySize;
+    }
+
+    /** Return an {@link Iterable} for iterating over the boundaries of the region.
+     * Each boundary is oriented such that its plus side points to the outside of the
+     * region. The exact ordering of the boundaries is determined by the internal structure
+     * of the tree.
+     * @return an {@link Iterable} for iterating over the boundaries of the region
+     * @see #getBoundaries()
+     */
+    public Iterable<? extends ConvexSubHyperplane<P>> boundaries() {
+        return createBoundaryIterable(Function.identity());
+    }
+
+    /** Internal method for creating the iterable instances used to iterate the region boundaries.
+     * @param typeConverter function to convert the generic convex subhyperplane type into
+     *      the type specific for this tree
+     * @param <C> ConvexSubhyperplane implementation type
+     * @return an iterable to iterating the region boundaries
+     */
+    protected <C extends ConvexSubHyperplane<P>> Iterable<C> createBoundaryIterable(
+            final Function<ConvexSubHyperplane<P>, C> typeConverter) {
+
+        return new Iterable<C>() {
+
+            @Override
+            public Iterator<C> iterator() {
+                final NodeIterator<P, N> nodeIterator = new NodeIterator<>(getRoot());
+                return new RegionBoundaryIterator<>(nodeIterator, typeConverter);
+            }
+        };
+    }
+
+    /** Return a list containing the boundaries of the region. Each boundary is oriented such
+     * that its plus side points to the outside of the region. The exact ordering of
+     * the boundaries is determined by the internal structure of the tree.
+     * @return a list of the boundaries of the region
+     */
+    public List<? extends ConvexSubHyperplane<P>> getBoundaries() {
+        return createBoundaryList(Function.identity());
+    }
+
+    /** Iternal method for creating a list of the region boundaries.
+     * @param typeConverter function to convert the generic convex subhyperplane type into
+     *      the type specific for this tree
+     * @param <C> ConvexSubhyperplane implementation type
+     * @return a list of the region boundaries
+     */
+    protected <C extends ConvexSubHyperplane<P>> List<C> createBoundaryList(
+            final Function<ConvexSubHyperplane<P>, C> typeConverter) {
+
+        final List<C> result = new ArrayList<>();
+
+        final RegionBoundaryIterator<P, C, N> it = new RegionBoundaryIterator<>(iterator(), typeConverter);
+        it.forEachRemaining(result::add);
+
+        return result;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public P project(P pt) {
+        final BoundaryProjector<P, N> projector = new BoundaryProjector<>(pt);
+        accept(projector);
+
+        return projector.getProjected();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public P getBarycenter() {
+        return getRegionSizeProperties().getBarycenter();
+    }
+
+    /** Helper method implementing the algorithm for splitting a tree by a hyperplane. Subclasses
+     * should call this method with two instantiated trees of the correct type.
+     * @param splitter splitting hyperplane
+     * @param minus tree that will contain the minus side of the split result
+     * @param plus tree that will contain the plus side of the split result
+     * @param <T> Tree implementation type
+     * @return result of splitting this tree with the given hyperplane
+     */
+    protected <T extends AbstractRegionBSPTree<P, N>> Split<T> split(final Hyperplane<P> splitter,
+            final T minus, final T plus) {
+
+        splitIntoTrees(splitter, minus, plus);
+
+        T splitMinus = null;
+        T splitPlus = null;
+
+        if (minus != null) {
+            minus.getRoot().getPlus().setLocation(RegionLocation.OUTSIDE);
+            minus.condense();
+
+            splitMinus = minus.isEmpty() ? null : minus;
+        }
+        if (plus != null) {
+            plus.getRoot().getMinus().setLocation(RegionLocation.OUTSIDE);
+            plus.condense();
+
+            splitPlus = plus.isEmpty() ? null : plus;
+        }
+
+        return new Split<T>(splitMinus, splitPlus);
+    }
+
+    /** Get the size-related properties for the region. The value is computed
+     * lazily and cached.
+     * @return the size-related properties for the region
+     */
+    protected RegionSizeProperties<P> getRegionSizeProperties() {
+        if (regionSizeProperties == null) {
+            regionSizeProperties = computeRegionSizeProperties();
+        }
+
+        return regionSizeProperties;
+    }
+
+    /** Compute the size-related properties of the region.
+     * @return object containing size properties for the region
+     */
+    protected abstract RegionSizeProperties<P> computeRegionSizeProperties();
+
+    /** {@inheritDoc}
+     *
+     * <p>If the point is {@link org.apache.commons.geometry.core.Spatial#isNaN() NaN}, then
+     * {@link RegionLocation#OUTSIDE} is returned.</p>
+     */
+    @Override
+    public RegionLocation classify(final P point) {
+        if (point.isNaN()) {
+            return RegionLocation.OUTSIDE;
+        }
+
+        return classifyRecursive(getRoot(), point);
+    }
+
+    /** Recursively classify a point with respect to the region.
+     * @param node the node to classify against
+     * @param point the point to classify
+     * @return the classification of the point with respect to the region rooted
+     *      at the given node
+     */
+    private RegionLocation classifyRecursive(final AbstractRegionNode<P, N> node, final P point) {
+        if (node.isLeaf()) {
+            // the point is in a leaf, so the classification is just the leaf location
+            return node.getLocation();
+        } else {
+            final HyperplaneLocation cutLoc = node.getCutHyperplane().classify(point);
+
+            if (cutLoc == HyperplaneLocation.MINUS) {
+                return classifyRecursive(node.getMinus(), point);
+            } else if (cutLoc == HyperplaneLocation.PLUS) {
+                return classifyRecursive(node.getPlus(), point);
+            } else {
+                // the point is on the cut boundary; classify against both child
+                // subtrees and see if we end up with the same result or not
+                RegionLocation minusLoc = classifyRecursive(node.getMinus(), point);
+                RegionLocation plusLoc = classifyRecursive(node.getPlus(), point);
+
+                if (minusLoc == plusLoc) {
+                    return minusLoc;
+                }
+                return RegionLocation.BOUNDARY;
+            }
+        }
+    }
+
+    /** Change this region into its complement. All inside nodes become outside
+     * nodes and vice versa. The orientation of the cut subhyperplanes is not modified.
+     */
+    public void complement() {
+        complementRecursive(getRoot());
+    }
+
+    /** Set this instance to be the complement of the given tree. The argument
+     * is not modified.
+     * @param tree the tree to become the complement of
+     */
+    public void complement(final AbstractRegionBSPTree<P, N> tree) {
+        copySubtree(tree.getRoot(), getRoot());
+        complementRecursive(getRoot());
+    }
+
+    /** Recursively switch all inside nodes to outside nodes and vice versa.
+     * @param node the node at the root of the subtree to switch
+     */
+    private void complementRecursive(final AbstractRegionNode<P, N> node) {
+        if (node != null) {
+            final RegionLocation newLoc = (node.getLocationValue() == RegionLocation.INSIDE) ?
+                    RegionLocation.OUTSIDE :
+                    RegionLocation.INSIDE;
+
+            node.setLocation(newLoc);
+
+            complementRecursive(node.getMinus());
+            complementRecursive(node.getPlus());
+        }
+    }
+
+    /** Compute the union of this instance and the given region, storing the result back in
+     * this instance. The argument is not modified.
+     * @param other the tree to compute the union with
+     */
+    public void union(final AbstractRegionBSPTree<P, N> other) {
+        new UnionOperator<P, N>().apply(this, other, this);
+    }
+
+    /** Compute the union of the two regions passed as arguments and store the result in
+     * this instance. Any nodes currently existing in this instance are removed.
+     * @param a first argument to the union operation
+     * @param b second argument to the union operation
+     */
+    public void union(final AbstractRegionBSPTree<P, N> a, final AbstractRegionBSPTree<P, N> b) {
+        new UnionOperator<P, N>().apply(a, b, this);
+    }
+
+    /** Compute the intersection of this instance and the given region, storing the result back in
+     * this instance. The argument is not modified.
+     * @param other the tree to compute the intersection with
+     */
+    public void intersection(final AbstractRegionBSPTree<P, N> other) {
+        new IntersectionOperator<P, N>().apply(this, other, this);
+    }
+
+    /** Compute the intersection of the two regions passed as arguments and store the result in
+     * this instance. Any nodes currently existing in this instance are removed.
+     * @param a first argument to the intersection operation
+     * @param b second argument to the intersection operation
+     */
+    public void intersection(final AbstractRegionBSPTree<P, N> a, final AbstractRegionBSPTree<P, N> b) {
+        new IntersectionOperator<P, N>().apply(a, b, this);
+    }
+
+    /** Compute the difference of this instance and the given region, storing the result back in
+     * this instance. The argument is not modified.
+     * @param other the tree to compute the difference with
+     */
+    public void difference(final AbstractRegionBSPTree<P, N> other) {
+        new DifferenceOperator<P, N>().apply(this, other, this);
+    }
+
+    /** Compute the difference of the two regions passed as arguments and store the result in
+     * this instance. Any nodes currently existing in this instance are removed.
+     * @param a first argument to the difference operation
+     * @param b second argument to the difference operation
+     */
+    public void difference(final AbstractRegionBSPTree<P, N> a, final AbstractRegionBSPTree<P, N> b) {
+        new DifferenceOperator<P, N>().apply(a, b, this);
+    }
+
+    /** Compute the symmetric difference (xor) of this instance and the given region, storing the result back in
+     * this instance. The argument is not modified.
+     * @param other the tree to compute the symmetric difference with
+     */
+    public void xor(final AbstractRegionBSPTree<P, N> other) {
+        new XorOperator<P, N>().apply(this, other, this);
+    }
+
+    /** Compute the symmetric difference (xor) of the two regions passed as arguments and store the result in
+     * this instance. Any nodes currently existing in this instance are removed.
+     * @param a first argument to the symmetric difference operation
+     * @param b second argument to the symmetric difference operation
+     */
+    public void xor(final AbstractRegionBSPTree<P, N> a, final AbstractRegionBSPTree<P, N> b) {
+        new XorOperator<P, N>().apply(a, b, this);
+    }
+
+    /** Condense this tree by removing redundant subtrees.
+     *
+     * <p>This operation can be used to reduce the total number of nodes in the
+     * tree after performing node manipulations. For example, if two sibling leaf
+     * nodes both represent the same {@link RegionLocation}, then there is no reason
+     * from the perspective of the geometric region to retain both nodes. They are
+     * therefore both merged into their parent node. This method performs this
+     * simplification process.
+     * </p>
+     */
+    protected void condense() {
+        condenseRecursive(getRoot());
+    }
+
+    /** Recursively condense nodes that have children with homogenous location attributes
+     * (eg, both inside, both outside) into single nodes.
+     * @param node the root of the subtree to condense
+     * @return the location of the successfully condensed subtree or null if no condensing was
+     *      able to be performed
+     */
+    private RegionLocation condenseRecursive(final N node) {
+        if (node.isLeaf()) {
+            return node.getLocation();
+        }
+
+        final RegionLocation minusLocation = condenseRecursive(node.getMinus());
+        final RegionLocation plusLocation = condenseRecursive(node.getPlus());
+
+        if (minusLocation != null && plusLocation != null && minusLocation == plusLocation) {
+            node.setLocation(minusLocation);
+            node.clearCut();
+
+            return minusLocation;
+        }
+
+        return null;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void copyNodeProperties(final N src, final N dst) {
+        dst.setLocation(src.getLocationValue());
+    }
+
+    /** Compute the portion of the node's cut subhyperplane that lies on the boundary of
+     * the region.
+     * @param node the node to compute the cut subhyperplane boundary of
+     * @return object representing the portions of the node's cut subhyperplane that lie
+     *      on the region's boundary
+     */
+    private RegionCutBoundary<P> computeBoundary(final N node) {
+        if (node.isLeaf()) {
+            // no boundary for leaf nodes; they are either entirely in or
+            // entirely out
+            return null;
+        }
+
+        ConvexSubHyperplane<P> sub = node.getCut();
+
+        // find the portions of the node cut sub-hyperplane that touch inside and
+        // outside cells in the minus sub-tree
+        SubHyperplane.Builder<P> minusInBuilder = sub.builder();
+        SubHyperplane.Builder<P> minusOutBuilder = sub.builder();
+
+        characterizeSubHyperplane(sub, node.getMinus(), minusInBuilder, minusOutBuilder);
+
+        List<? extends ConvexSubHyperplane<P>> minusIn = minusInBuilder.build().toConvex();
+        List<? extends ConvexSubHyperplane<P>> minusOut = minusOutBuilder.build().toConvex();
+
+        // create the result boundary builders
+        SubHyperplane.Builder<P> insideFacing = sub.builder();
+        SubHyperplane.Builder<P> outsideFacing = sub.builder();
+
+        if (!minusIn.isEmpty()) {
+            // Add to the boundary anything that touches an inside cell in the minus sub-tree
+            // and an outside cell in the plus sub-tree. These portions are oriented with their
+            // plus side pointing to the outside of the region.
+            for (ConvexSubHyperplane<P> minusInFragment : minusIn) {
+                characterizeSubHyperplane(minusInFragment, node.getPlus(), null, outsideFacing);
+            }
+        }
+
+        if (!minusOut.isEmpty()) {
+            // Add to the boundary anything that touches an outside cell in the minus sub-tree
+            // and an inside cell in the plus sub-tree. These portions are oriented with their
+            // plus side pointing to the inside of the region.
+            for (ConvexSubHyperplane<P> minusOutFragment : minusOut) {
+                characterizeSubHyperplane(minusOutFragment, node.getPlus(), insideFacing, null);
+            }
+        }
+
+        return new RegionCutBoundary<P>(insideFacing.build(), outsideFacing.build());
+    }
+
+    /** Recursive method to characterize a convex subhyperplane with respect to the region's
+     * boundaries.
+     * @param sub the subhyperplane to characterize
+     * @param node the node to characterize the subhyperplane against
+     * @param in the builder that will receive the portions of the subhyperplane that lie in the inside
+     *      of the region; may be null
+     * @param out the builder that will receive the portions of the subhyperplane that lie on the outside
+     *      of the region; may be null
+     */
+    private void characterizeSubHyperplane(final ConvexSubHyperplane<P> sub, final AbstractRegionNode<P, N> node,
+            final SubHyperplane.Builder<P> in, final SubHyperplane.Builder<P> out) {
+
+        if (sub != null) {
+            if (node.isLeaf()) {
+                if (node.isInside() && in != null) {
+                    in.add(sub);
+                } else if (node.isOutside() && out != null) {
+                    out.add(sub);
+                }
+            } else {
+                final Split<? extends ConvexSubHyperplane<P>> split = sub.split(node.getCutHyperplane());
+
+                // Continue further on down the subtree with the same subhyperplane if the
+                // subhyperplane lies directly on the current node's cut
+                if (split.getLocation() == SplitLocation.NEITHER) {
+                    characterizeSubHyperplane(sub, node.getPlus(), in, out);
+                    characterizeSubHyperplane(sub, node.getMinus(), in, out);
+                } else {
+                    characterizeSubHyperplane(split.getPlus(), node.getPlus(), in, out);
+                    characterizeSubHyperplane(split.getMinus(), node.getMinus(), in, out);
+                }
+            }
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void initChildNode(final N parent, final N child, final boolean isPlus) {
+        super.initChildNode(parent, child, isPlus);
+
+        child.setLocation(isPlus ? RegionLocation.OUTSIDE : RegionLocation.INSIDE);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void invalidate() {
+        super.invalidate();
+
+        // clear cached region properties
+        boundarySize = UNKNOWN_SIZE;
+        regionSizeProperties = null;
+    }
+
+    /** {@link BSPTree.Node} implementation for use with {@link AbstractRegionBSPTree}s.
+     * @param <P> Point implementation type
+     * @param <N> BSP tree node implementation type
+     */
+    public abstract static class AbstractRegionNode<P extends Point<P>, N extends AbstractRegionNode<P, N>>
+        extends AbstractBSPTree.AbstractNode<P, N> {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 1L;
+
+        /** The location for the node. This will only be set on leaf nodes. */
+        private RegionLocation location;
+
+        /** Object representing the part of the node cut subhyperplane that lies on the
+         * region boundary. This is calculated lazily and is only present on internal nodes.
+         */
+        private RegionCutBoundary<P> cutBoundary;
+
+        /** Simple constructor.
+         * @param tree owning tree instance
+         */
+        protected AbstractRegionNode(AbstractBSPTree<P, N> tree) {
+            super(tree);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public AbstractRegionBSPTree<P, N> getTree() {
+            // cast to our parent tree type
+            return (AbstractRegionBSPTree<P, N>) super.getTree();
+        }
+
+        /** Get the location of the node. This value will only be non-null for
+         * leaf nodes.
+         * @return the location of the node; will be null for internal nodes
+         */
+        public RegionLocation getLocation() {
+            return isLeaf() ? location : null;
+        }
+
+        /** True if the node is a leaf node and has a location of {@link RegionLocation#INSIDE}.
+         * @return true if the node is a leaf node and has a location of
+         *      {@link RegionLocation#INSIDE}
+         */
+        public boolean isInside() {
+            return getLocation() == RegionLocation.INSIDE;
+        }
+
+        /** True if the node is a leaf node and has a location of {@link RegionLocation#OUTSIDE}.
+         * @return true if the node is a leaf node and has a location of
+         *      {@link RegionLocation#OUTSIDE}
+         */
+        public boolean isOutside() {
+            return getLocation() == RegionLocation.OUTSIDE;
+        }
+
+        /** Get the portion of the node's cut subhyperplane that lies on the boundary of the
+         * region.
+         * @return the portion of the node's cut subhyperplane that lies on the boundary of
+         *      the region
+         */
+        public RegionCutBoundary<P> getCutBoundary() {
+            if (!isLeaf()) {
+                checkValid();
+
+                if (cutBoundary == null) {
+                    cutBoundary = getTree().computeBoundary(getSelf());
+                }
+            }
+
+            return cutBoundary;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public String toString() {
+            final StringBuilder sb = new StringBuilder();
+            sb.append(this.getClass().getSimpleName())
+                .append("[cut= ")
+                .append(getCut())
+                .append(", location= ")
+                .append(getLocation())
+                .append("]");
+
+            return sb.toString();
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected void nodeInvalidated() {
+            super.nodeInvalidated();
+
+            // null any computed boundary value since it is no longer valid
+            cutBoundary = null;
+        }
+
+        /** Set the location attribute for the node.
+         * @param location the location attribute for the node
+         */
+        protected void setLocation(final RegionLocation location) {
+            this.location = location;
+        }
+
+        /** Get the value of the location property, unmodified based on the
+         * node's leaf state.
+         * @return the value of the location property
+         */
+        protected RegionLocation getLocationValue() {
+            return location;
+        }
+    }
+
+    /** Class containing the basic algorithm for merging region BSP trees.
+     * @param <P> Point implementation type
+     * @param <N> BSP tree node implementation type
+     */
+    public abstract static class RegionMergeOperator<P extends Point<P>, N extends AbstractRegionNode<P, N>>
+        extends AbstractBSPTreeMergeOperator<P, N> {
+
+        /** Merge two input trees, storing the output in the third. The output tree can be one of the
+         * input trees. The output tree is condensed before the method returns.
+         * @param inputTree1 first input tree
+         * @param inputTree2 second input tree
+         * @param outputTree the tree that will contain the result of the merge; may be one
+         *      of the input trees
+         */
+        public void apply(final AbstractRegionBSPTree<P, N> inputTree1, final AbstractRegionBSPTree<P, N> inputTree2,
+                final AbstractRegionBSPTree<P, N> outputTree) {
+
+            this.performMerge(inputTree1, inputTree2, outputTree);
+
+            outputTree.condense();
+        }
+    }
+
+    /** Class for performing boolean union operations on region trees.
+     * @param <P> Point implementation type
+     * @param <N> BSP tree node implementation type
+     */
+    public static class UnionOperator<P extends Point<P>, N extends AbstractRegionNode<P, N>>
+        extends RegionMergeOperator<P, N> {
+
+        /** {@inheritDoc} */
+        @Override
+        protected N mergeLeaf(final N node1, final N node2) {
+            if (node1.isLeaf()) {
+                return node1.isInside() ? node1 : node2;
+            }
+
+            // call again with flipped arguments
+            return mergeLeaf(node2, node1);
+        }
+    }
+
+    /** Class for performing boolean intersection operations on region trees.
+     * @param <P> Point implementation type
+     * @param <N> BSP tree node implementation type
+     */
+    public static class IntersectionOperator<P extends Point<P>, N extends AbstractRegionNode<P, N>>
+        extends RegionMergeOperator<P, N> {
+
+        /** {@inheritDoc} */
+        @Override
+        protected N mergeLeaf(final N node1, final N node2) {
+            if (node1.isLeaf()) {
+                return node1.isInside() ? node2 : node1;
+            }
+
+            // call again with flipped arguments
+            return mergeLeaf(node2, node1);
+        }
+    }
+
+    /** Class for performing boolean difference operations on region trees.
+     * @param <P> Point implementation type
+     * @param <N> BSP tree node implementation type
+     */
+    public static class DifferenceOperator<P extends Point<P>, N extends AbstractRegionNode<P, N>>
+        extends RegionMergeOperator<P, N> {
+
+        /** {@inheritDoc} */
+        @Override
+        protected N mergeLeaf(final N node1, final N node2) {
+            // a region is included if it belongs in tree1 and is not in tree2
+
+            if (node1.isInside()) {
+                // this region is inside of tree1, so only include subregions that are
+                // not in tree2, ie include everything in node2's complement
+                final N output = outputSubtree(node2);
+                output.getTree().complementRecursive(output);
+
+                return output;
+            } else if (node2.isInside()) {
+                // this region is inside of tree2 and so cannot be in the result region
+                final N output = outputNode();
+                output.setLocation(RegionLocation.OUTSIDE);
+
+                return output;
+            }
+
+            // this region is not in tree2, so we can include everything in tree1
+            return node1;
+        }
+    }
+
+    /** Class for performing boolean symmetric difference (xor) operations on region trees.
+     * @param <P> Point implementation type
+     * @param <N> BSP tree node implementation type
+     */
+    public static class XorOperator<P extends Point<P>, N extends AbstractRegionNode<P, N>>
+        extends RegionMergeOperator<P, N> {
+
+        /** {@inheritDoc} */
+        @Override
+        protected N mergeLeaf(final N node1, final N node2) {
+            // a region is included if it belongs in tree1 and is not in tree2 OR
+            // it belongs in tree2 and is not in tree1
+
+            if (node1.isLeaf()) {
+                if (node1.isInside()) {
+                    // this region is inside node1, so only include subregions that are
+                    // not in node2, ie include everything in node2's complement
+                    final N output = outputSubtree(node2);
+                    output.getTree().complementRecursive(output);
+
+                    return output;
+                } else {
+                    // this region is not in node1, so only include subregions that
+                    // in node2
+                    return node2;
+                }
+            }
+
+            // the operation is symmetric, so perform the same operation but with the
+            // nodes flipped
+            return mergeLeaf(node2, node1);
+        }
+    }
+
+    /** Class used to compute the point on the region's boundary that is closest to a target point.
+     * @param <P> Point implementation type
+     * @param <N> BSP tree node implementation type
+     */
+    protected static class BoundaryProjector<P extends Point<P>, N extends AbstractRegionNode<P, N>>
+        extends ClosestFirstVisitor<P, N> {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20190504L;
+
+        /** The projected point. */
+        private P projected;
+
+        /** The current closest distance to the boundary found. */
+        private double minDist = -1.0;
+
+        /** Simple constructor.
+         * @param point the point to project onto the region's boundary
+         */
+        public BoundaryProjector(final P point) {
+            super(point);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void visit(final N node) {
+            final P point = getTarget();
+
+            if (node.isInternal() && (minDist < 0.0 || isPossibleClosestCut(node.getCut(), point, minDist))) {
+                final RegionCutBoundary<P> boundary = node.getCutBoundary();
+                final P boundaryPt = boundary.closest(point);
+
+                final double dist = boundaryPt.distance(point);
+                final int cmp = Double.compare(dist, minDist);
+
+                if (minDist < 0.0 || cmp < 0) {
+                    projected = boundaryPt;
+                    minDist = dist;
+                } else if (cmp == 0) {
+                    // the two points are the _exact_ same distance from the reference point, so use
+                    // a separate method to disambiguate them
+                    projected = disambiguateClosestPoint(point, projected, boundaryPt);
+                }
+            }
+        }
+
+        /** Return true if the given node cut subhyperplane is a possible candidate for containing the
+         * closest region boundary point to the target.
+         * @param cut the node cut subhyperplane to test
+         * @param target the target point being projected
+         * @param currentMinDist the smallest distance found so far to a region boundary; this value is guaranteed
+         *      to be non-negative
+         * @return true if the subhyperplane is a possible candidate for containing the closest region
+         *      boundary point to the target
+         */
+        protected boolean isPossibleClosestCut(final SubHyperplane<P> cut, final P target,
+                final double currentMinDist) {
+            return Math.abs(cut.getHyperplane().offset(target)) <= currentMinDist;
+        }
+
+        /** Method used to determine which of points {@code a} and {@code b} should be considered
+         * as the "closest" point to {@code target} when the points are exactly equidistant.
+         * @param target the target point
+         * @param a first point to consider
+         * @param b second point to consider
+         * @return which of {@code a} or {@code b} should be considered as the one closest to
+         *      {@code target}
+         */
+        protected P disambiguateClosestPoint(final P target, final P a, final P b) {
+            return a;
+        }
+
+        /** Get the projected point on the region's boundary, or null if no point could be found.
+         * @return the projected point on the region's boundary
+         */
+        public P getProjected() {
+            return projected;
+        }
+    }
+
+    /** Class containing the primary size-related properties of a region. These properties
+     * are typically computed at the same time, so this class serves to encapsulate the result
+     * of the combined computation.
+     * @param <P> Point implementation type
+     */
+    protected static class RegionSizeProperties<P extends Point<P>> implements Serializable {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20190428L;
+
+        /** The size of the region. */
+        private final double size;
+
+        /** The barycenter of the region. */
+        private final P barycenter;
+
+        /** Simple constructor.
+         * @param size the region size
+         * @param barycenter the region barycenter
+         */
+        public RegionSizeProperties(final double size, final P barycenter) {
+            this.size = size;
+            this.barycenter = barycenter;
+        }
+
+        /** Get the size of the region.
+         * @return the size of the region
+         */
+        public double getSize() {
+            return size;
+        }
+
+        /** Get the barycenter of the region.
+         * @return the barycenter of the region
+         */
+        public P getBarycenter() {
+            return barycenter;
+        }
+    }
+
+    /** Class that iterates over the boundary convex subhyperplanes from a set of region nodes.
+     * @param <P> Point implementation type
+     * @param <C> Boundary convex subhyperplane implementation type
+     * @param <N> BSP tree node implementation type
+     */
+    protected static final class RegionBoundaryIterator<
+            P extends Point<P>,
+            C extends ConvexSubHyperplane<P>,
+            N extends AbstractRegionNode<P, N>>
+        extends IteratorTransform<N, C> {
+
+        /** Function that converts from the convex subhyperplane type to the output type. */
+        private final Function<ConvexSubHyperplane<P>, C> typeConverter;
+
+        /** Simple constructor.
+         * @param inputIterator iterator that will provide all nodes in the tree
+         * @param typeConverter function that converts from the convex subhyperplane type to the output type
+         */
+        private RegionBoundaryIterator(final Iterator<N> inputIterator,
+                final Function<ConvexSubHyperplane<P>, C> typeConverter) {
+            super(inputIterator);
+
+            this.typeConverter = typeConverter;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected void acceptInput(final N input) {
+            if (input.isInternal()) {
+                final RegionCutBoundary<P> cutBoundary = input.getCutBoundary();
+
+                final SubHyperplane<P> outsideFacing = cutBoundary.getOutsideFacing();
+                final SubHyperplane<P> insideFacing = cutBoundary.getInsideFacing();
+
+                if (outsideFacing != null && !outsideFacing.isEmpty()) {
+                    for (ConvexSubHyperplane<P> boundary : outsideFacing.toConvex()) {
+
+                        addOutput(typeConverter.apply(boundary));
+                    }
+                }
+                if (insideFacing != null && !insideFacing.isEmpty()) {
+                    for (ConvexSubHyperplane<P> boundary : insideFacing.toConvex()) {
+                        ConvexSubHyperplane<P> reversed = boundary.reverse();
+
+                        addOutput(typeConverter.apply(reversed));
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/bsp/AttributeBSPTree.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/bsp/AttributeBSPTree.java
new file mode 100644
index 0000000..d7fecd6
--- /dev/null
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/bsp/AttributeBSPTree.java
@@ -0,0 +1,144 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning.bsp;
+
+import org.apache.commons.geometry.core.Point;
+
+/** Simple {@link BSPTree} implementation allowing arbitrary values to be
+ * associated with each node.
+ * @param <P> Point implementation type
+ * @param <T> Tree node attribute type
+ */
+public class AttributeBSPTree<P extends Point<P>, T>
+    extends AbstractBSPTree<P, AttributeBSPTree.AttributeNode<P, T>> {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20190306L;
+
+    /** The initial attribute value to use for newly created nodes. */
+    private final T initialNodeAttribute;
+
+    /** Create a new tree instance. New nodes in the tree are given an attribute
+     * of null.
+     */
+    public AttributeBSPTree() {
+        this(null);
+    }
+
+    /** Create a new tree instance. New nodes in the tree are assigned the given
+     * initial attribute value.
+     * @param initialNodeAttribute The attribute value to assign to newly created nodes.
+     */
+    public AttributeBSPTree(T initialNodeAttribute) {
+        this.initialNodeAttribute = initialNodeAttribute;
+
+        this.getRoot().setAttribute(initialNodeAttribute);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected AttributeNode<P, T> createNode() {
+        return new AttributeNode<P, T>(this);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void initChildNode(final AttributeNode<P, T> parent, final AttributeNode<P, T> child,
+            final boolean isPlus) {
+        super.initChildNode(parent, child, isPlus);
+
+        child.setAttribute(initialNodeAttribute);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void copyNodeProperties(final AttributeNode<P, T> src, final AttributeNode<P, T> dst) {
+        dst.setAttribute(src.getAttribute());
+    }
+
+    /** {@link BSPTree.Node} implementation for use with {@link AttributeBSPTree}s.
+     * @param <P> Point implementation type
+     * @param <T> Tree node attribute type
+     */
+    public static class AttributeNode<P extends Point<P>, T>
+        extends AbstractBSPTree.AbstractNode<P, AttributeNode<P, T>> {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 1L;
+
+        /** The node attribute. */
+        private T attribute;
+
+        /** Simple constructor.
+         * @param tree the owning tree; this must be an instance of {@link AttributeBSPTree}
+         */
+        protected AttributeNode(AbstractBSPTree<P, AttributeNode<P, T>> tree) {
+            super(tree);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public AttributeBSPTree<P, T> getTree() {
+            // cast to our parent tree type
+            return (AttributeBSPTree<P, T>) super.getTree();
+        }
+
+        /** Get the attribute associated with this node.
+         * @return the attribute associated with this node
+         */
+        public T getAttribute() {
+            return attribute;
+        }
+
+        /** Set the attribute associated with this node.
+         * @param attribute the attribute to associate with this node
+         */
+        public void setAttribute(T attribute) {
+            this.attribute = attribute;
+        }
+
+        /** Set the attribute for this node. The node is returned.
+         * @param attributeValue attribute to set for the node
+         * @return the node instance
+         */
+        public AttributeNode<P, T> attr(final T attributeValue) {
+            setAttribute(attributeValue);
+
+            return this;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public String toString() {
+            final StringBuilder sb = new StringBuilder();
+            sb.append(this.getClass().getSimpleName())
+                .append("[cut= ")
+                .append(getCut())
+                .append(", attribute= ")
+                .append(attribute)
+                .append("]");
+
+            return sb.toString();
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected AttributeNode<P, T> getSelf() {
+            return this;
+        }
+    }
+}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/bsp/BSPSubtree.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/bsp/BSPSubtree.java
new file mode 100644
index 0000000..5943765
--- /dev/null
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/bsp/BSPSubtree.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning.bsp;
+
+import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
+
+import org.apache.commons.geometry.core.Point;
+
+/** Interface for types that form the root of BSP subtrees. This includes trees
+ * themselves as well as each node in a tree.
+ * @param <P> Point implementation type
+ * @param <N> Node implementation type
+ */
+public interface BSPSubtree<P extends Point<P>, N extends BSPTree.Node<P, N>> extends Iterable<N> {
+
+    /** Return the total number of nodes in the subtree.
+     * @return the total number of nodes in the subtree.
+     */
+    int count();
+
+    /** The height of the subtree, ie the length of the longest downward path from
+     * the subtree root to a leaf node. A leaf node has a height of 0.
+     * @return the height of the subtree.
+     */
+    int height();
+
+    /** Accept a visitor instance, calling it with each node from the subtree.
+     * @param visitor visitor called with each subtree node
+     */
+    void accept(BSPTreeVisitor<P, N> visitor);
+
+    /** Create a stream over the nodes in this subtree.
+     * @return a stream for accessing the nodes in this subtree
+     */
+    default Stream<N> stream() {
+        return StreamSupport.stream(spliterator(), false);
+    }
+}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/bsp/BSPTree.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/bsp/BSPTree.java
new file mode 100644
index 0000000..758ca90
--- /dev/null
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/bsp/BSPTree.java
@@ -0,0 +1,225 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning.bsp;
+
+import org.apache.commons.geometry.core.Point;
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.partitioning.ConvexSubHyperplane;
+import org.apache.commons.geometry.core.partitioning.Hyperplane;
+import org.apache.commons.geometry.core.partitioning.SubHyperplane;
+
+/** Interface for Binary Space Partitioning (BSP) trees.
+ * @param <P> Point implementation type
+ * @param <N> Node implementation type
+ */
+public interface BSPTree<P extends Point<P>, N extends BSPTree.Node<P, N>>
+    extends BSPSubtree<P, N> {
+
+    /** Enum specifying possible behaviors when a point used to locate a node
+     * falls directly on the cut subhyperplane of an internal node.
+     */
+    enum NodeCutRule {
+
+        /** Choose the minus child of the internal node and continue searching.
+         * This behavior will result in a leaf node always being returned by the
+         * node search.
+         */
+        MINUS,
+
+        /** Choose the plus child of the internal node and continue searching.
+         * This behavior will result in a leaf node always being returned by the
+         * node search.
+         */
+        PLUS,
+
+        /** Choose the internal node and stop searching. This behavior may result
+         * in non-leaf nodes being returned by the node search.
+         */
+        NODE
+    }
+
+    /** Get the root node of the tree.
+     * @return the root node of the tree
+     */
+    N getRoot();
+
+    /** Find a node in this subtree containing the given point or its interior or boundary.
+     * When a point lies directly on the cut of an internal node, the minus child of the
+     * cut is chosen. This is equivalent to {@code subtree.findNode(pt, NodeCutRule.MINUS)}
+     * and always returns a leaf node.
+     * @param pt test point used to locate a node in the tree
+     * @return leaf node containing the point on its interior or boundary
+     * @see #findNode(Point, NodeCutRule)
+     */
+    default N findNode(P pt) {
+        return findNode(pt, NodeCutRule.MINUS);
+    }
+
+    /** Find a node in this subtree containing the given point on it interior or boundary. The
+     * search should always return a leaf node except in the cases where the given point lies
+     * exactly on the cut subhyperplane of an internal node. In this case, it is unclear whether
+     * the search should continue with the minus child, the plus child, or end with the internal
+     * node. The {@code cutRule} argument specifies what should happen in this case.
+     * <ul>
+     *      <li>{@link NodeCutRule#MINUS} - continue the search in the minus subtree</li>
+     *      <li>{@link NodeCutRule#PLUS} - continue the search in the plus subtree</li>
+     *      <li>{@link NodeCutRule#NODE} - stop the search and return the internal node</li>
+     * </ul>
+     * @param pt test point used to locate a node in the tree
+     * @param cutRule value used to determine the search behavior when the test point lies
+     *      exactly on the cut subhyperplane of an internal node
+     * @return node containing the point on its interior or boundary
+     * @see #findNode(Point)
+     */
+    N findNode(P pt, NodeCutRule cutRule);
+
+    /** Insert a subhyperplane into the tree.
+     * @param sub the subhyperplane to insert into the tree
+     */
+    void insert(SubHyperplane<P> sub);
+
+    /** Insert a convex subhyperplane into the tree.
+     * @param convexSub the convex subhyperplane to insert into the tree
+     */
+    void insert(ConvexSubHyperplane<P> convexSub);
+
+    /** Insert a set of convex subhyperplanes into the tree.
+     * @param convexSubs iterable containing a collection of subhyperplanes
+     *      to insert into the tree
+     */
+    void insert(Iterable<? extends ConvexSubHyperplane<P>> convexSubs);
+
+    /** Make the current instance a deep copy of the argument.
+     * @param src the tree to copy
+     */
+    void copy(BSPTree<P, N> src);
+
+    /** Set this instance to the region represented by the given node. The
+     * given node could have come from this tree or a different tree.
+     * @param node the node to extract
+     */
+    void extract(N node);
+
+    /** Transform this tree. Each cut subhyperplane in the tree is transformed in place using
+     * the given {@link Transform}.
+     * @param transform the transform to apply
+     */
+    void transform(Transform<P> transform);
+
+    /** Interface for Binary Space Partitioning (BSP) tree nodes.
+     * @param <P> Point type
+     * @param <N> BSP tree node implementation type
+     */
+    interface Node<P extends Point<P>, N extends Node<P, N>> extends BSPSubtree<P, N> {
+
+        /** Get the {@link BSPTree} that owns the node.
+         * @return the owning tree
+         */
+        BSPTree<P, N> getTree();
+
+        /** Get the depth of the node in the tree. The root node of the tree
+         * has a depth of 0.
+         * @return the depth of the node in the tree
+         */
+        int depth();
+
+        /** Get the parent of the node. This will be null if the node is the
+         * root of the tree.
+         * @return the parent node for this instance
+         */
+        N getParent();
+
+        /** Get the cut for the node. This is a convex subhyperplane that splits
+         * the region for the cell into two disjoint regions, namely the plus and
+         * minus regions. This will be null for leaf nodes.
+         * @see #getPlus()
+         * @see #getMinus()
+         * @return the cut subhyperplane for the cell
+         */
+        ConvexSubHyperplane<P> getCut();
+
+        /** Get the hyperplane belonging to the node cut, if it exists.
+         * @return the hyperplane belonging to the node cut, or null if
+         *      the node does not have a cut
+         * @see #getCut()
+         */
+        Hyperplane<P> getCutHyperplane();
+
+        /** Get the node for the minus region of the cell. This will be null if the
+         * node has not been cut, ie if it is a leaf node.
+         * @return the node for the minus region of the cell
+         */
+        N getMinus();
+
+        /** Get the node for the plus region of the cell. This will be null if the
+         * node has not been cut, ie if it is a leaf node.
+         * @return the node for the plus region of the cell
+         */
+        N getPlus();
+
+        /** Return true if the node is a leaf node, meaning that it has no
+         * binary partitioner (aka "cut") and therefore no child nodes.
+         * @return true if the node is a leaf node
+         */
+        boolean isLeaf();
+
+        /** Return true if the node is an internal node, meaning that is
+         * has a binary partitioner (aka "cut") and therefore two child nodes.
+         * @return true if the node is an internal node
+         */
+        boolean isInternal();
+
+        /** Return true if the node has a parent and is the parent's minus
+         * child.
+         * @return true if the node is the minus child of its parent
+         */
+        boolean isMinus();
+
+        /** Return true if the node has a parent and is the parent's plus
+         * child.
+         * @return true if the node is the plus child of its parent
+         */
+        boolean isPlus();
+
+        /** Insert a cut into this node. If the given hyperplane intersects
+         * this node's region, then the node's cut is set to the {@link ConvexSubHyperplane}
+         * representing the intersection, new plus and minus child leaf nodes
+         * are assigned, and true is returned. If the hyperplane does not intersect
+         * the node's region, then the node's cut and plus and minus child references
+         * are all set to null (ie, it becomes a leaf node) and false is returned. In
+         * either case, any existing cut and/or child nodes are removed by this method.
+         * @param cutter the hyperplane to cut the node's region with
+         * @return true if the cutting hyperplane intersected the node's region, resulting
+         *      in the creation of new child nodes
+         */
+        boolean insertCut(Hyperplane<P> cutter);
+
+        /** Remove the cut from this node. Returns true if the node previously had a cut.
+         * @return true if the node had a cut before the call to this method
+         */
+        boolean clearCut();
+
+        /** Cut this node with the given hyperplane. The same node is returned, regardless of
+         * the outcome of the cut operation. If the operation succeeded, then the node will
+         * have plus and minus child nodes.
+         * @param cutter the hyperplane to cut the node's region with
+         * @return this node
+         * @see #insertCut(Hyperplane)
+         */
+        N cut(Hyperplane<P> cutter);
+    }
+}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/bsp/BSPTreePrinter.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/bsp/BSPTreePrinter.java
new file mode 100644
index 0000000..b5df252
--- /dev/null
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/bsp/BSPTreePrinter.java
@@ -0,0 +1,118 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning.bsp;
+
+import org.apache.commons.geometry.core.Point;
+import org.apache.commons.geometry.core.partitioning.bsp.BSPTree.Node;
+
+/** Internal class for creating simple string representations of BSP trees.
+ * @param <P> Point implementation type
+ * @param <N> Node implementation type
+ */
+final class BSPTreePrinter<P extends Point<P>, N extends Node<P, N>>
+    implements BSPTreeVisitor<P, N> {
+
+    /** Line indent string. */
+    private static final String INDENT = "    ";
+
+    /** New line character. */
+    private static final String NEW_LINE = "\n";
+
+    /** Entry prefix for nodes on the minus side of their parent. */
+    private static final String MINUS_CHILD = "[-] ";
+
+    /** Entry prefix for nodes on the plus side of their parent. */
+    private static final String PLUS_CHILD = "[+] ";
+
+    /** Ellipsis for truncated representations. */
+    private static final String ELLIPSIS = "...";
+
+    /** Maximum depth of nodes that will be printed. */
+    private final int maxDepth;
+
+    /** Contains the string output. */
+    private final StringBuilder output = new StringBuilder();
+
+    /** Simple constructor.
+     * @param maxDepth maximum depth of nodes to be printed
+     */
+    BSPTreePrinter(final int maxDepth) {
+        this.maxDepth = maxDepth;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void visit(final N node) {
+        final int depth = node.depth();
+
+        if (depth <= maxDepth) {
+            startLine(node);
+            writeNode(node);
+        } else if (depth == maxDepth + 1 && node.isPlus()) {
+            startLine(node);
+            write(ELLIPSIS);
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Order visitOrder(final N node) {
+        return Order.NODE_MINUS_PLUS;
+    }
+
+    /** Start a line for the given node.
+     * @param node the node to begin a line for
+     */
+    private void startLine(final N node) {
+        if (node.getParent() != null) {
+            write(NEW_LINE);
+        }
+
+        final int depth = node.depth();
+        for (int i = 0; i < depth; ++i) {
+            write(INDENT);
+        }
+    }
+
+    /** Writes the given node to the output.
+     * @param node the node to write
+     */
+    private void writeNode(final N node) {
+        if (node.getParent() != null) {
+            if (node.isMinus()) {
+                write(MINUS_CHILD);
+            } else {
+                write(PLUS_CHILD);
+            }
+        }
+
+        write(node.toString());
+    }
+
+    /** Add the given string to the output.
+     * @param str the string to add
+     */
+    private void write(String str) {
+        output.append(str);
+    }
+
+    /** Return the string representation of the visited tree. */
+    @Override
+    public String toString() {
+        return output.toString();
+    }
+}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/bsp/BSPTreeVisitor.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/bsp/BSPTreeVisitor.java
new file mode 100644
index 0000000..8fc0ffd
--- /dev/null
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/bsp/BSPTreeVisitor.java
@@ -0,0 +1,173 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning.bsp;
+
+import java.io.Serializable;
+
+import org.apache.commons.geometry.core.Point;
+
+/** Interface for visiting the nodes in a {@link BSPTree} or {@link BSPSubtree}.
+ * @param <P> Point implementation type
+ * @param <N> BSP tree node implementation type
+ */
+@FunctionalInterface
+public interface BSPTreeVisitor<P extends Point<P>, N extends BSPTree.Node<P, N>> {
+
+    /** Enum used to specify the order in which visitors should visit the nodes
+     * in the tree.
+     */
+    enum Order {
+
+        /** Indicates that the visitor should first visit the plus sub-tree, then
+         * the minus sub-tree and then the current node.
+         */
+        PLUS_MINUS_NODE,
+
+        /** Indicates that the visitor should first visit the plus sub-tree, then
+         * the current node, and then the minus sub-tree.
+         */
+        PLUS_NODE_MINUS,
+
+        /** Indicates that the visitor should first visit the minus sub-tree, then
+         * the plus sub-tree, and then the current node.
+         */
+        MINUS_PLUS_NODE,
+
+        /** Indicates that the visitor should first visit the minus sub-tree, then the
+         * current node, and then the plus sub-tree.
+         */
+        MINUS_NODE_PLUS,
+
+        /** Indicates that the visitor should first visit the current node, then the
+         * plus sub-tree, and then the minus sub-tree.
+         */
+        NODE_PLUS_MINUS,
+
+        /** Indicates that the visitor should first visit the current node, then the
+         * minus sub-tree, and then the plus sub-tree.
+         */
+        NODE_MINUS_PLUS;
+    }
+
+    /** Visit a node in a BSP tree. This method is called for both internal nodes and
+     * leaf nodes.
+     * @param node the node being visited
+     */
+    void visit(N node);
+
+    /** Determine the visit order for the given internal node. This is called for each
+     * internal node before {@link #visit(BSPTree.Node)} is called. Returning null from
+     * this method skips the subtree rooted at the given node. This method is not called
+     * on leaf nodes.
+     * @param internalNode the internal node to determine the visit order for
+     * @return the order that the subtree rooted at the given node should be visited
+     */
+    default Order visitOrder(final N internalNode) {
+        return Order.NODE_MINUS_PLUS;
+    }
+
+    /** Abstract class for {@link BSPTreeVisitor} implementations that base their visit
+     * ordering on a target point.
+     * @param <P> Point implementation type
+     * @param <N> BSP tree node implementation type
+     */
+    abstract class TargetPointVisitor<P extends Point<P>, N extends BSPTree.Node<P, N>>
+        implements BSPTreeVisitor<P, N>, Serializable {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20190504L;
+
+        /** Point serving as the target of the traversal. */
+        private final P target;
+
+        /** Simple constructor.
+         * @param target the point serving as the target for the tree traversal
+         */
+        public TargetPointVisitor(final P target) {
+            this.target = target;
+        }
+
+        /** Get the target point for the tree traversal.
+         * @return the target point for the tree traversal
+         */
+        public P getTarget() {
+            return target;
+        }
+    }
+
+    /** {@link BSPTreeVisitor} base class that orders tree nodes so that nodes closest to the target point are
+     * visited first. This is done by choosing {@link Order#MINUS_NODE_PLUS}
+     * when the target point lies on the minus side of the node's cut hyperplane and {@link Order#PLUS_NODE_MINUS}
+     * when it lies on the plus side. The order {@link Order#MINUS_NODE_PLUS} order is used when
+     * the target point lies directly on the node's cut hyerplane and no child node is closer than the other.
+     * @param <P> Point implementation type
+     * @param <N> BSP tree node implementation type
+     */
+    abstract class ClosestFirstVisitor<P extends Point<P>, N extends BSPTree.Node<P, N>>
+        extends TargetPointVisitor<P, N> {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20190504L;
+
+        /** Simple constructor.
+         * @param target the point serving as the target for the traversal
+         */
+        public ClosestFirstVisitor(final P target) {
+            super(target);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public Order visitOrder(N node) {
+            if (node.getCutHyperplane().offset(getTarget()) > 0.0) {
+                return Order.PLUS_NODE_MINUS;
+            }
+            return Order.MINUS_NODE_PLUS;
+        }
+    }
+
+    /** {@link BSPTreeVisitor} base class that orders tree nodes so that nodes farthest from the target point
+     * are traversed first. This is done by choosing {@link Order#PLUS_NODE_MINUS}
+     * when the target point lies on the minus side of the node's cut hyperplane and {@link Order#MINUS_NODE_PLUS}
+     * when it lies on the plus side. The order {@link Order#MINUS_NODE_PLUS} order is used when
+     * the target point lies directly on the node's cut hyerplane and no child node is closer than the other.
+     * @param <P> Point implementation type
+     * @param <N> BSP tree node implementation type
+     */
+    abstract class FarthestFirstVisitor<P extends Point<P>, N extends BSPTree.Node<P, N>>
+        extends TargetPointVisitor<P, N> {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20190504L;
+
+        /** Simple constructor.
+         * @param target the point serving as the target for the traversal
+         */
+        public FarthestFirstVisitor(final P target) {
+            super(target);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public Order visitOrder(N node) {
+            if (node.getCutHyperplane().offset(getTarget()) < 0.0) {
+                return Order.PLUS_NODE_MINUS;
+            }
+            return Order.MINUS_NODE_PLUS;
+        }
+    }
+}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/bsp/RegionCutBoundary.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/bsp/RegionCutBoundary.java
new file mode 100644
index 0000000..a165381
--- /dev/null
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/bsp/RegionCutBoundary.java
@@ -0,0 +1,109 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning.bsp;
+
+import java.io.Serializable;
+
+import org.apache.commons.geometry.core.Point;
+import org.apache.commons.geometry.core.partitioning.SubHyperplane;
+
+/** Class representing the portion of an
+ * {@link AbstractRegionBSPTree.AbstractRegionNode AbstractRegionNode}'s cut subhyperplane that
+ * lies on the boundary of the region. Portions of this subhyperplane
+ * may be oriented so that the plus side of the subhyperplane points toward
+ * the outside of the region ({@link #getOutsideFacing()}) and other portions
+ * of the same subhyperplane may be oriented so that the plus side points
+ * toward the inside of the region ({@link #getInsideFacing()}).
+ *
+ * @param <P> Point implementation type
+ */
+public final class RegionCutBoundary<P extends Point<P>> implements Serializable {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20190310L;
+
+    /** Portion of the region cut subhyperplane with its plus side facing the
+     * inside of the region.
+     */
+    private final SubHyperplane<P> insideFacing;
+
+    /** Portion of the region cut subhyperplane with its plus side facing the
+     * outside of the region.
+     */
+    private final SubHyperplane<P> outsideFacing;
+
+    /** Simple constructor.
+     * @param insideFacing portion of the region cut subhyperplane with its plus side facing the
+     *      inside of the region
+     * @param outsideFacing portion of the region cut subhyperplane with its plus side facing the
+     *      outside of the region
+     */
+    public RegionCutBoundary(final SubHyperplane<P> insideFacing, final SubHyperplane<P> outsideFacing) {
+        this.insideFacing = insideFacing;
+        this.outsideFacing = outsideFacing;
+    }
+
+    /** Get the portion of the region cut subhyperplane with its plus side facing the
+     * inside of the region.
+     * @return the portion of the region cut subhyperplane with its plus side facing the
+     *      inside of the region
+     */
+    public SubHyperplane<P> getInsideFacing() {
+        return insideFacing;
+    }
+
+    /** Get the portion of the region cut subhyperplane with its plus side facing the
+     * outside of the region.
+     * @return the portion of the region cut subhyperplane with its plus side facing the
+     *      outside of the region
+     */
+    public SubHyperplane<P> getOutsideFacing() {
+        return outsideFacing;
+    }
+
+    /** Return the closest point to the argument in the inside and outside facing
+     * portions of the cut boundary.
+     * @param pt the reference point
+     * @return the point in the cut boundary closest to the reference point
+     * @see SubHyperplane#closest(Point)
+     */
+    public P closest(final P pt) {
+        final P insideFacingPt = (insideFacing != null) ? insideFacing.closest(pt) : null;
+        final P outsideFacingPt = (outsideFacing != null) ? outsideFacing.closest(pt) : null;
+
+        if (insideFacingPt != null && outsideFacingPt != null) {
+            if (pt.distance(insideFacingPt) < pt.distance(outsideFacingPt)) {
+                return insideFacingPt;
+            }
+            return outsideFacingPt;
+        } else if (insideFacingPt != null) {
+            return insideFacingPt;
+        }
+        return outsideFacingPt;
+    }
+
+    /** Return true if the given point is contained in the boundary, in either the
+     * inside facing portion or the outside facing portion.
+     * @param pt point to test
+     * @return true if the point is contained in the boundary
+     * @see SubHyperplane#contains(Point)
+     */
+    public boolean contains(final P pt) {
+        return (insideFacing != null && insideFacing.contains(pt)) ||
+                (outsideFacing != null && outsideFacing.contains(pt));
+    }
+}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Side.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/bsp/package-info.java
similarity index 62%
copy from commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Side.java
copy to commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/bsp/package-info.java
index 046defe..b94442a 100644
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Side.java
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/bsp/package-info.java
@@ -14,23 +14,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.commons.geometry.core.partitioning;
-
-/** Enumerate representing the location of an element with respect to an
- * {@link Hyperplane hyperplane} of a space.
+/**
+ *
+ * <p>
+ * This package contains classes related to Binary Space Partitioning (BSP) trees. BSP
+ * tree are data structures that allow arbitrary partitioning of spaces using hyperplanes.
+ * </p>
  */
-public enum Side {
-
-    /** Code for the plus side of the hyperplane. */
-    PLUS,
-
-    /** Code for the minus side of the hyperplane. */
-    MINUS,
-
-    /** Code for elements crossing the hyperplane from plus to minus side. */
-    BOTH,
-
-    /** Code for the hyperplane itself. */
-    HYPER;
-
-}
+package org.apache.commons.geometry.core.partitioning.bsp;
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/package-info.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/package-info.java
index cfb55c0..b0e4967 100644
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/package-info.java
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/package-info.java
@@ -16,99 +16,8 @@
  */
 /**
  *
- * This package provides classes to implement Binary Space Partition trees.
- *
  * <p>
- * {@link org.apache.commons.geometry.core.partitioning.BSPTree BSP trees}
- * are an efficient way to represent parts of space and in particular
- * polytopes (line segments in 1D, polygons in 2D and polyhedrons in 3D)
- * and to operate on them. The main principle is to recursively subdivide
- * the space using simple hyperplanes (points in 1D, lines in 2D, planes
- * in 3D).
+ * This package contains code related to partitioning of spaces by hyperplanes.
  * </p>
- *
- * <p>
- * We start with a tree composed of a single node without any cut
- * hyperplane: it represents the complete space, which is a convex
- * part. If we add a cut hyperplane to this node, this represents a
- * partition with the hyperplane at the node level and two half spaces at
- * each side of the cut hyperplane. These half-spaces are represented by
- * two child nodes without any cut hyperplanes associated, the plus child
- * which represents the half space on the plus side of the cut hyperplane
- * and the minus child on the other side. Continuing the subdivisions, we
- * end up with a tree having internal nodes that are associated with a
- * cut hyperplane and leaf nodes without any hyperplane which correspond
- * to convex parts.
- * </p>
- *
- * <p>
- * When BSP trees are used to represent polytopes, the convex parts are
- * known to be completely inside or outside the polytope as long as there
- * is no facet in the part (which is obviously the case if the cut
- * hyperplanes have been chosen as the underlying hyperplanes of the
- * facets (this is called an autopartition) and if the subdivision
- * process has been continued until all facets have been processed. It is
- * important to note that the polytope is <em>not</em> defined by a
- * single part, but by several convex ones. This is the property that
- * allows BSP-trees to represent non-convex polytopes despites all parts
- * are convex. The {@link
- * org.apache.commons.geometry.core.partitioning.Region Region} class is
- * devoted to this representation, it is build on top of the {@link
- * org.apache.commons.geometry.core.partitioning.BSPTree BSPTree} class using
- * boolean objects as the leaf nodes attributes to represent the
- * inside/outside property of each leaf part, and also adds various
- * methods dealing with boundaries (i.e. the separation between the
- * inside and the outside parts).
- * </p>
- *
- * <p>
- * Rather than simply associating the internal nodes with an hyperplane,
- * we consider <em>sub-hyperplanes</em> which correspond to the part of
- * the hyperplane that is inside the convex part defined by all the
- * parent nodes (this implies that the sub-hyperplane at root node is in
- * fact a complete hyperplane, because there is no parent to bound
- * it). Since the parts are convex, the sub-hyperplanes are convex, in
- * 3D the convex parts are convex polyhedrons, and the sub-hyperplanes
- * are convex polygons that cut these polyhedrons in two
- * sub-polyhedrons. Using this definition, a BSP tree completely
- * partitions the space. Each point either belongs to one of the
- * sub-hyperplanes in an internal node or belongs to one of the leaf
- * convex parts.
- * </p>
- *
- * <p>
- * In order to determine where a point is, it is sufficient to check its
- * position with respect to the root cut hyperplane, to select the
- * corresponding child tree and to repeat the procedure recursively,
- * until either the point appears to be exactly on one of the hyperplanes
- * in the middle of the tree or to be in one of the leaf parts. For
- * this operation, it is sufficient to consider the complete hyperplanes,
- * there is no need to check the points with the boundary of the
- * sub-hyperplanes, because this check has in fact already been realized
- * by the recursive descent in the tree. This is very easy to do and very
- * efficient, especially if the tree is well balanced (the cost is
- * <code>O(log(n))</code> where <code>n</code> is the number of facets)
- * or if the first tree levels close to the root discriminate large parts
- * of the total space.
- * </p>
- *
- * <p>
- * One of the main sources for the development of this package was Bruce
- * Naylor, John Amanatides and William Thibault paper <a
- * href="http://www.cs.yorku.ca/~amana/research/bsptSetOp.pdf">Merging
- * BSP Trees Yields Polyhedral Set Operations</a> Proc. Siggraph '90,
- * Computer Graphics 24(4), August 1990, pp 115-124, published by the
- * Association for Computing Machinery (ACM). The same paper can also be
- * found <a
- * href="http://www.cs.utexas.edu/users/fussell/courses/cs384g/bsp_treemerge.pdf">here</a>.
- * </p>
- *
- * <p>
- * Note that the interfaces defined in this package are <em>not</em> intended to
- * be implemented by Apache Commons Math users, they are only intended to be
- * implemented within the library itself. New methods may be added even for
- * minor versions, which breaks compatibility for external implementations.
- * </p>
- *
  */
 package org.apache.commons.geometry.core.partitioning;
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/precision/DoublePrecisionContext.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/precision/DoublePrecisionContext.java
index 6773837..7ca33ab 100644
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/precision/DoublePrecisionContext.java
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/precision/DoublePrecisionContext.java
@@ -23,7 +23,7 @@
  */
 public abstract class DoublePrecisionContext implements Comparator<Double>, Serializable {
 
-    /** Serializable identifier */
+    /** Serializable identifier. */
     private static final long serialVersionUID = 20190121L;
 
     /** Return true if the given values are considered equal to each other.
@@ -85,6 +85,22 @@
         return compare(a, b) >= 0;
     }
 
+    /** Return the sign of the argument: 0 if the value is considered equal to
+     * zero, -1 if less than 0, and +1 if greater than 0.
+     * @param a number to determine the sign of
+     * @return 0 if the number is considered equal to 0, -1 if less than
+     *      0, and +1 if greater than 0
+     */
+    public int sign(final double a) {
+        final int cmp = compare(a, 0.0);
+        if (cmp < 0) {
+            return -1;
+        } else if (cmp > 0) {
+            return 1;
+        }
+        return 0;
+    }
+
     /** {@inheritDoc} */
     @Override
     public int compare(final Double a, final Double b) {
@@ -110,7 +126,7 @@
      *      first is smaller than the second, {@code 1} is the first is larger
      *      than the second or either value is NaN.
      */
-    public abstract int compare(final double a, final double b);
+    public abstract int compare(double a, double b);
 
     /** Get the largest positive double value that is still considered equal
      * to zero by this instance.
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/precision/EpsilonDoublePrecisionContext.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/precision/EpsilonDoublePrecisionContext.java
index c26f502..0d69c8b 100644
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/precision/EpsilonDoublePrecisionContext.java
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/precision/EpsilonDoublePrecisionContext.java
@@ -30,7 +30,7 @@
  */
 public class EpsilonDoublePrecisionContext extends DoublePrecisionContext {
 
-    /** Serializable identifier */
+    /** Serializable identifier. */
     private static final long serialVersionUID = 20190119L;
 
     /** Epsilon value. */
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/EmbeddingTest.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/EmbeddingTest.java
new file mode 100644
index 0000000..f15fcc8
--- /dev/null
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/EmbeddingTest.java
@@ -0,0 +1,98 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.commons.geometry.core.partition.test.PartitionTestUtils;
+import org.apache.commons.geometry.core.partition.test.TestLine;
+import org.apache.commons.geometry.core.partition.test.TestPoint1D;
+import org.apache.commons.geometry.core.partition.test.TestPoint2D;
+import org.junit.Test;
+
+import org.junit.Assert;
+
+public class EmbeddingTest {
+
+    @Test
+    public void testToSubspace_collection_emptyInput() {
+        // arrange
+        TestLine line = TestLine.Y_AXIS;
+
+        // act
+        List<TestPoint1D> result = line.toSubspace(new ArrayList<>());
+
+        // assert
+        Assert.assertEquals(0, result.size());
+    }
+
+    @Test
+    public void testToSubspace_collection() {
+        // arrange
+        List<TestPoint2D> pts = Arrays.asList(
+                    new TestPoint2D(0, 0),
+                    new TestPoint2D(1, 0.25),
+                    new TestPoint2D(0.5, 1)
+                );
+
+        TestLine line = TestLine.Y_AXIS;
+
+        // act
+        List<TestPoint1D> result = line.toSubspace(pts);
+
+        // assert
+        Assert.assertEquals(3, result.size());
+        Assert.assertEquals(0, result.get(0).getX(), PartitionTestUtils.EPS);
+        Assert.assertEquals(0.25, result.get(1).getX(), PartitionTestUtils.EPS);
+        Assert.assertEquals(1, result.get(2).getX(), PartitionTestUtils.EPS);
+    }
+
+    @Test
+    public void testToSpace_collection_emptyInput() {
+        // arrange
+        TestLine line = TestLine.Y_AXIS;
+
+        // act
+        List<TestPoint2D> result = line.toSpace(new ArrayList<>());
+
+        // assert
+        Assert.assertEquals(0, result.size());
+    }
+
+    @Test
+    public void testToSpace_collection() {
+        // arrange
+        List<TestPoint1D> pts = Arrays.asList(
+                    new TestPoint1D(0),
+                    new TestPoint1D(1),
+                    new TestPoint1D(0.5)
+                );
+
+        TestLine line = TestLine.Y_AXIS;
+
+        // act
+        List<TestPoint2D> result = line.toSpace(pts);
+
+        // assert
+        Assert.assertEquals(3, result.size());
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(0, 0), result.get(0));
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(0, 1), result.get(1));
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(0, 0.5), result.get(2));
+    }
+}
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/GeometryTestUtils.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/GeometryTestUtils.java
index 60bdeea..242e2d9 100644
--- a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/GeometryTestUtils.java
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/GeometryTestUtils.java
@@ -20,6 +20,7 @@
 import java.io.ByteArrayOutputStream;
 import java.io.ObjectInputStream;
 import java.io.ObjectOutputStream;
+import java.util.regex.Pattern;
 
 import org.junit.Assert;
 
@@ -50,7 +51,7 @@
      * @param exceptionType the expected exception type
      */
     public static void assertThrows(Runnable r, Class<?> exceptionType) {
-        assertThrows(r, exceptionType, null);
+        assertThrows(r, exceptionType, (String) null);
     }
 
     /** Asserts that the given Runnable throws an exception of the given type. If
@@ -77,6 +78,42 @@
         }
     }
 
+    /** Asserts that the given Runnable throws an exception of the given type. If
+     * {@code pattern} is not null, the exception message is asserted to match the
+     * given regex.
+     * @param r the Runnable instance
+     * @param exceptionType the expected exception type
+     * @param pattern regex pattern to match; ignored if null
+     */
+    public static void assertThrows(Runnable r, Class<?> exceptionType, Pattern pattern) {
+        try {
+            r.run();
+            Assert.fail("Operation should have thrown an exception");
+        }
+        catch (Exception exc) {
+            Class<?> actualType = exc.getClass();
+
+            Assert.assertTrue("Expected exception of type " + exceptionType.getName() + " but was " + actualType.getName(),
+                    exceptionType.isAssignableFrom(actualType));
+
+            if (pattern != null) {
+                String message = exc.getMessage();
+
+                String err = "Expected exception message to match /" + pattern + "/ but was [" + message + "]";
+                Assert.assertTrue(err, pattern.matcher(message).matches());
+            }
+        }
+    }
+
+    /** Assert that a string contains a given substring value.
+     * @param substr
+     * @param actual
+     */
+    public static void assertContains(String substr, String actual) {
+        String msg = "Expected string to contain [" + substr + "] but was [" + actual + "]";
+        Assert.assertTrue(msg, actual.contains(substr));
+    }
+
     /**
      * Serializes and then recovers an object from a byte array. Returns the deserialized object.
      *
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/internal/IteratorTransformTest.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/internal/IteratorTransformTest.java
new file mode 100644
index 0000000..8eaab17
--- /dev/null
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/internal/IteratorTransformTest.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.internal;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+import java.util.NoSuchElementException;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+public class IteratorTransformTest {
+
+    @Test
+    public void testIteration() {
+        // arrange
+        List<Integer> input = Arrays.asList(1, 2, 3, 4, 12, 13);
+
+        // act
+        List<String> result = toList(new EvenCharIterator(input.iterator()));
+
+        // assert
+        Assert.assertEquals(Arrays.asList("2", "4", "1", "2"), result);
+    }
+
+    @Test(expected = NoSuchElementException.class)
+    public void testThrowsNoSuchElement() {
+        // arrange
+        List<Integer> input = Arrays.asList();
+        EvenCharIterator it = new EvenCharIterator(input.iterator());
+
+        // act/assert
+        Assert.assertFalse(it.hasNext());
+        it.next();
+    }
+
+    private static <T> List<T> toList(Iterator<T> it) {
+        List<T> result = new ArrayList<>();
+        while (it.hasNext()) {
+            result.add(it.next());
+        }
+
+        return result;
+    }
+
+    private static class EvenCharIterator extends IteratorTransform<Integer, String>{
+
+        public EvenCharIterator(final Iterator<Integer> inputIterator) {
+            super(inputIterator);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected void acceptInput(Integer input) {
+            // filter out odd integers
+            int value = input.intValue();
+            if (value % 2 == 0) {
+                char[] chars = (value + "").toCharArray();
+
+                if (chars.length > 1) {
+                    List<String> strs = new ArrayList<>();
+                    for (char ch : chars) {
+                        strs.add(ch + "");
+                    }
+
+                    addAllOutput(strs);
+                }
+                else if (chars.length == 1) {
+                    addOutput(chars[0] + "");
+                }
+            }
+        }
+    }
+}
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/PartitionTestUtils.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/PartitionTestUtils.java
new file mode 100644
index 0000000..107e5a7
--- /dev/null
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/PartitionTestUtils.java
@@ -0,0 +1,115 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partition.test;
+
+import org.apache.commons.geometry.core.Point;
+import org.apache.commons.geometry.core.partitioning.bsp.BSPTree;
+import org.apache.commons.geometry.core.partitioning.bsp.BSPTree.Node;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
+import org.junit.Assert;
+
+/** Class containing utility methods for tests related to the
+ * partition package.
+ */
+public class PartitionTestUtils {
+
+    public static final double EPS = 1e-6;
+
+    public static final DoublePrecisionContext PRECISION =
+            new EpsilonDoublePrecisionContext(EPS);
+
+    /**
+     * Asserts that corresponding values in the given points are equal.
+     * @param expected
+     * @param actual
+     */
+    public static void assertPointsEqual(TestPoint2D expected, TestPoint2D actual) {
+        String msg = "Expected points to equal " + expected + " but was " + actual + ";";
+        Assert.assertEquals(msg, expected.getX(), actual.getX(), EPS);
+        Assert.assertEquals(msg, expected.getY(), actual.getY(), EPS);
+    }
+
+    public static void assertSegmentsEqual(TestLineSegment expected, TestLineSegment actual) {
+        String msg = "Expected line segment to equal " + expected + " but was " + actual;
+
+        Assert.assertEquals(msg, expected.getStartPoint().getX(),
+                actual.getStartPoint().getX(), EPS);
+        Assert.assertEquals(msg, expected.getStartPoint().getY(),
+                actual.getStartPoint().getY(), EPS);
+
+        Assert.assertEquals(msg, expected.getEndPoint().getX(),
+                actual.getEndPoint().getX(), EPS);
+        Assert.assertEquals(msg, expected.getEndPoint().getY(),
+                actual.getEndPoint().getY(), EPS);
+    }
+
+    public static void assertIsInternalNode(Node<?, ?> node) {
+        Assert.assertNotNull(node.getCut());
+        Assert.assertNotNull(node.getMinus());
+        Assert.assertNotNull(node.getPlus());
+
+        Assert.assertTrue(node.isInternal());
+        Assert.assertFalse(node.isLeaf());
+    }
+
+    public static void assertIsLeafNode(Node<?, ?> node) {
+        Assert.assertNull(node.getCut());
+        Assert.assertNull(node.getMinus());
+        Assert.assertNull(node.getPlus());
+
+        Assert.assertFalse(node.isInternal());
+        Assert.assertTrue(node.isLeaf());
+    }
+
+    /** Assert that the given tree for has a valid, consistent internal structure. This checks that all nodes
+     * in the tree are owned by the tree, that the node depth values are correct, and the cut nodes have children
+     * and non-cut nodes do not.
+     * @param tree tree to check
+     */
+    public static <P extends Point<P>, N extends BSPTree.Node<P, N>> void assertTreeStructure(final BSPTree<P, N> tree) {
+        assertTreeStructureRecursive(tree, tree.getRoot(), 0);
+    }
+
+    /** Recursive method to assert that a tree has a valid internal structure.
+     * @param tree tree to check
+     * @param node node to check
+     * @param expectedDepth the expected depth of the node in the tree
+     */
+    private static <P extends Point<P>, N extends BSPTree.Node<P, N>> void assertTreeStructureRecursive(
+            final BSPTree<P, N> tree, final BSPTree.Node<P, N> node, final int expectedDepth) {
+
+        Assert.assertSame("Node has an incorrect owning tree", tree, node.getTree());
+        Assert.assertEquals("Node has an incorrect depth property", node.depth(), expectedDepth);
+
+        if (node.getCut() == null) {
+            String msg = "Node without cut cannot have children";
+
+            Assert.assertNull(msg, node.getMinus());
+            Assert.assertNull(msg, node.getPlus());
+        }
+        else {
+            String msg = "Node with cut must have children";
+
+            Assert.assertNotNull(msg, node.getMinus());
+            Assert.assertNotNull(msg, node.getPlus());
+
+            assertTreeStructureRecursive(tree, node.getPlus(), expectedDepth + 1);
+            assertTreeStructureRecursive(tree, node.getMinus(), expectedDepth + 1);
+        }
+    }
+}
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestBSPTree.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestBSPTree.java
new file mode 100644
index 0000000..b04ca59
--- /dev/null
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestBSPTree.java
@@ -0,0 +1,64 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partition.test;
+
+import org.apache.commons.geometry.core.partitioning.Hyperplane;
+import org.apache.commons.geometry.core.partitioning.bsp.AbstractBSPTree;
+
+/** BSP Tree implementation class for testing purposes.
+ */
+public class TestBSPTree extends AbstractBSPTree<TestPoint2D, TestBSPTree.TestNode> {
+
+    /** Serializable UID */
+    private static final long serialVersionUID = 20190225L;
+
+    /** {@inheritDoc} */
+    @Override
+    protected TestNode createNode() {
+        return new TestNode(this);
+    }
+
+    /** {@inheritDoc}
+     *
+     * <p>Exposed as public for testing.</p>
+     */
+    @Override
+    public void splitIntoTrees(Hyperplane<TestPoint2D> splitter,
+            final AbstractBSPTree<TestPoint2D, TestBSPTree.TestNode> minus,
+            final AbstractBSPTree<TestPoint2D, TestBSPTree.TestNode> plus) {
+
+        super.splitIntoTrees(splitter, minus, plus);
+    }
+
+    /** BSP Tree node class for {@link TestBSPTree}.
+     */
+    public static class TestNode extends AbstractBSPTree.AbstractNode<TestPoint2D,TestNode> {
+
+        /** Serializable UID */
+        private static final long serialVersionUID = 20190225L;
+
+        public TestNode(AbstractBSPTree<TestPoint2D, TestNode> tree) {
+            super(tree);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected TestNode getSelf() {
+            return this;
+        }
+    }
+}
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestLine.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestLine.java
new file mode 100644
index 0000000..8a19ed5
--- /dev/null
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestLine.java
@@ -0,0 +1,280 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partition.test;
+
+import java.io.Serializable;
+
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.partitioning.EmbeddingHyperplane;
+import org.apache.commons.geometry.core.partitioning.Hyperplane;
+import org.apache.commons.geometry.core.partitioning.HyperplaneLocation;
+
+/** Class representing a line in two dimensional Euclidean space. This
+ * class should only be used for testing purposes.
+ */
+public class TestLine implements EmbeddingHyperplane<TestPoint2D, TestPoint1D>, Serializable {
+
+    /** Line pointing along the positive x-axis. */
+    public static final TestLine X_AXIS = new TestLine(0, 0, 1, 0);
+
+    /** Line pointing along the positive y-axis. */
+    public static final TestLine Y_AXIS = new TestLine(0, 0, 0, 1);
+
+    /** Serializable UID */
+    private static final long serialVersionUID = 20190224L;
+
+    /** X value of the normalized line direction. */
+    private final double directionX;
+
+    /** Y value of the normalized line direction. */
+    private final double directionY;
+
+    /** The distance between the origin and the line. */
+    private final double originOffset;
+
+    /** Construct a line from two points. The line points in the direction from
+     * {@code p1} to {@code p2}.
+     * @param p1 first point
+     * @param p2 second point
+     */
+    public TestLine(final TestPoint2D p1, final TestPoint2D p2) {
+        this(p1.getX(), p1.getY(), p2.getX(), p2.getY());
+    }
+
+    /** Construct a line from two point, given by their components.
+     * @param x1 x coordinate of first point
+     * @param y1 y coordinate of first point
+     * @param x2 x coordinate of second point
+     * @param y2 y coordinate of second point
+     */
+    public TestLine(final double x1, final double y1, final double x2, final double y2) {
+        double vecX = x2 - x1;
+        double vecY = y2 - y1;
+
+        double norm = norm(vecX, vecY);
+
+        vecX /= norm;
+        vecY /= norm;
+
+        if (!Double.isFinite(vecX) || !Double.isFinite(vecY)) {
+            throw new IllegalStateException("Unable to create line between points: (" +
+                    x1 + ", " + y1 + "), (" + x2 + ", " + y2 + ")");
+        }
+
+        this.directionX = vecX;
+        this.directionY = vecY;
+
+        this.originOffset = signedArea(vecX, vecY, x1, y1);
+    }
+
+    /** Get the line origin, meaning the projection of the 2D origin onto the line.
+     * @return line origin
+     */
+    public TestPoint2D getOrigin() {
+        return toSpace(0);
+    }
+
+    /** Get the x component of the line direction.
+     * @return x component of the line direction.
+     */
+    public double getDirectionX() {
+        return directionX;
+    }
+
+    /** Get the y component of the line direction.
+     * @return y component of the line direction.
+     */
+    public double getDirectionY() {
+        return directionY;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public double offset(TestPoint2D point) {
+        return originOffset - signedArea(directionX, directionY, point.getX(), point.getY());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public HyperplaneLocation classify(TestPoint2D point) {
+        final double offset = offset(point);
+        final double cmp = PartitionTestUtils.PRECISION.compare(offset, 0.0);
+        if (cmp == 0) {
+            return HyperplaneLocation.ON;
+        }
+        return cmp < 0 ? HyperplaneLocation.MINUS : HyperplaneLocation.PLUS;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean contains(TestPoint2D point) {
+        return classify(point) == HyperplaneLocation.ON;
+    }
+
+    /** Get the location of the given 2D point in the 1D space of the line.
+     * @param point point to project into the line's 1D space
+     * @return location of the point in the line's 1D space
+     */
+    public double toSubspaceValue(TestPoint2D point) {
+        return (directionX * point.getX()) + (directionY * point.getY());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public TestPoint1D toSubspace(TestPoint2D point) {
+        return new TestPoint1D(toSubspaceValue(point));
+    }
+
+    /** Get the 2D location of the given 1D location in the line's 1D space.
+     * @param abscissa location in the line's 1D space.
+     * @return the location of the given 1D point in 2D space
+     */
+    public TestPoint2D toSpace(final double abscissa) {
+        if (Double.isInfinite(abscissa)) {
+            int dirXCmp = PartitionTestUtils.PRECISION.sign(directionX);
+            int dirYCmp = PartitionTestUtils.PRECISION.sign(directionY);
+
+            double x;
+            if (dirXCmp == 0) {
+                // vertical line
+                x = getOrigin().getX();
+            }
+            else {
+                x = (dirXCmp < 0 ^ abscissa < 0) ? Double.NEGATIVE_INFINITY : Double.POSITIVE_INFINITY;
+            }
+
+            double y;
+            if (dirYCmp == 0) {
+                // horizontal line
+                y = getOrigin().getY();
+            }
+            else {
+                y = (dirYCmp < 0 ^ abscissa < 0) ? Double.NEGATIVE_INFINITY : Double.POSITIVE_INFINITY;
+            }
+
+            return new TestPoint2D(x, y);
+        }
+
+        final double ptX = (abscissa * directionX) + (-originOffset * directionY);
+        final double ptY = (abscissa * directionY) + (originOffset * directionX);
+
+        return new TestPoint2D(ptX, ptY);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public TestPoint2D toSpace(TestPoint1D point) {
+        return toSpace(point.getX());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public TestPoint2D project(final TestPoint2D point) {
+        return toSpace(toSubspaceValue(point));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public TestLine reverse() {
+        TestPoint2D pt = getOrigin();
+        return new TestLine(pt.getX(), pt.getY(), pt.getX() - directionX, pt.getY() - directionY);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public TestLine transform(Transform<TestPoint2D> transform) {
+        TestPoint2D p1 = transform.apply(toSpace(0));
+        TestPoint2D p2 = transform.apply(toSpace(1));
+
+        return new TestLine(p1, p2);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean similarOrientation(Hyperplane<TestPoint2D> other) {
+        final TestLine otherLine = (TestLine) other;
+        final double dot = (directionX * otherLine.directionX) + (directionY * otherLine.directionY);
+        return dot >= 0.0;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public TestLineSegment span() {
+        return new TestLineSegment(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, this);
+    }
+
+    /** Get the intersection point of the instance and another line.
+     * @param other other line
+     * @return intersection point of the instance and the other line
+     *      or null if there is no unique intersection point (ie, the lines
+     *      are parallel or coincident)
+     */
+    public TestPoint2D intersection(final TestLine other) {
+        final double area = signedArea(directionX, directionY, other.directionX, other.directionY);
+        if (PartitionTestUtils.PRECISION.eqZero(area)) {
+            // lines are parallel
+            return null;
+        }
+
+        final double x = ((other.directionX * originOffset) +
+                (-directionX * other.originOffset)) / area;
+
+        final double y = ((other.directionY * originOffset) +
+                (-directionY * other.originOffset)) / area;
+
+        return new TestPoint2D(x, y);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String toString() {
+        final StringBuilder sb = new StringBuilder();
+        sb.append(this.getClass().getSimpleName())
+            .append("[origin= ")
+            .append(getOrigin())
+            .append(", direction= (")
+            .append(directionX)
+            .append(", ")
+            .append(directionY)
+            .append(")]");
+
+        return sb.toString();
+    }
+
+    /** Compute the signed area of the parallelogram with sides defined by the given
+     * vectors.
+     * @param x1 x coordinate of first vector
+     * @param y1 y coordinate of first vector
+     * @param x2 x coordinate of second vector
+     * @param y2 y coordinate of second vector
+     * @return the signed are of the parallelogram with side defined by the given
+     *      vectors
+     */
+    private static double signedArea(final double x1, final double y1,
+            final double x2, final double y2) {
+        return (x1 * y2) + (-y1 * x2);
+    }
+
+    /** Compute the Euclidean norm.
+     * @param x x coordinate value
+     * @param y y coordinate value
+     * @return Euclidean norm
+     */
+    public static double norm(final double x, final double y) {
+        return Math.sqrt((x * x) + (y * y));
+    }
+}
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestLineSegment.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestLineSegment.java
new file mode 100644
index 0000000..aa20dd7
--- /dev/null
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestLineSegment.java
@@ -0,0 +1,340 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partition.test;
+
+import java.io.Serializable;
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.partitioning.ConvexSubHyperplane;
+import org.apache.commons.geometry.core.partitioning.Hyperplane;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.SubHyperplane;
+
+/** Class representing a line segment in two dimensional Euclidean space. This
+ * class should only be used for testing purposes.
+ */
+public class TestLineSegment implements ConvexSubHyperplane<TestPoint2D>, Serializable {
+
+    /** Serializable UID */
+    private static final long serialVersionUID = 20190224L;
+
+    /** Abscissa of the line segment start point. */
+    private final double start;
+
+    /** Abscissa of the line segment end point. */
+    private final double end;
+
+    /** The underlying line for the line segment. */
+    private final TestLine line;
+
+    /** Construct a line segment between two points.
+     * @param start start point
+     * @param end end point
+     */
+    public TestLineSegment(final TestPoint2D start, final TestPoint2D end) {
+        this.line = new TestLine(start, end);
+
+        final double startValue = line.toSubspaceValue(start);
+        final double endValue = line.toSubspaceValue(end);
+
+        this.start = Math.min(startValue, endValue);
+        this.end = Math.max(startValue, endValue);
+    }
+
+    /** Construct a line segment between two points.
+     * @param x1 x coordinate of first point
+     * @param y1 y coordinate of first point
+     * @param x2 x coordinate of second point
+     * @param y2 y coordinate of second point
+     */
+    public TestLineSegment(final double x1, final double y1, final double x2, final double y2) {
+        this(new TestPoint2D(x1, y1), new TestPoint2D(x2, y2));
+    }
+
+    /** Construct a line segment based on an existing line.
+     * @param start abscissa of the line segment start point
+     * @param end abscissa of the line segment end point
+     * @param line the underyling line
+     */
+    public TestLineSegment(final double start, final double end, final TestLine line) {
+        this.start = Math.min(start, end);
+        this.end = Math.max(start, end);
+        this.line = line;
+    }
+
+    /** Get the start abscissa value.
+     * @return
+     */
+    public double getStart() {
+        return start;
+    }
+
+    /** Get the end abscissa value.
+     * @return
+     */
+    public double getEnd() {
+        return end;
+    }
+
+    /** Get the start point of the line segment.
+     * @return the start point of the line segment
+     */
+    public TestPoint2D getStartPoint() {
+        return line.toSpace(start);
+    }
+
+    /** Get the end point of the line segment.
+     * @return the end point of the line segment
+     */
+    public TestPoint2D getEndPoint() {
+        return line.toSpace(end);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public TestLine getHyperplane() {
+        return line;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isFull() {
+        return start < end && Double.isInfinite(start) && Double.isInfinite(end);
+
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isEmpty() {
+        return PartitionTestUtils.PRECISION.eqZero(getSize());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isInfinite() {
+        return Double.isInfinite(getSize());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isFinite() {
+        return !isInfinite();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public double getSize() {
+        return Math.abs(start - end);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public RegionLocation classify(TestPoint2D point) {
+        if (line.contains(point)) {
+            final double value = line.toSubspaceValue(point);
+
+            final int startCmp = PartitionTestUtils.PRECISION.compare(value, start);
+            final int endCmp = PartitionTestUtils.PRECISION.compare(value, end);
+
+            if (startCmp == 0 || endCmp == 0) {
+                return RegionLocation.BOUNDARY;
+            }
+            else if (startCmp > 0 && endCmp < 0) {
+                return RegionLocation.INSIDE;
+            }
+        }
+
+        return RegionLocation.OUTSIDE;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public TestPoint2D closest(TestPoint2D point) {
+        double value = line.toSubspaceValue(point);
+        value = Math.max(Math.min(value, end), start);
+
+        return line.toSpace(value);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public List<ConvexSubHyperplane<TestPoint2D>> toConvex() {
+        return Arrays.asList(this);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public TestLineSegment reverse() {
+        TestLine rLine = line.reverse();
+        return new TestLineSegment(-end, -start, rLine);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Split<TestLineSegment> split(Hyperplane<TestPoint2D> splitter) {
+        final TestLine splitterLine = (TestLine) splitter;
+
+        if (isInfinite()) {
+            return splitInfinite(splitterLine);
+        }
+        return splitFinite(splitterLine);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public SubHyperplane.Builder<TestPoint2D> builder() {
+        return new TestLineSegmentCollectionBuilder(line);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public ConvexSubHyperplane<TestPoint2D> transform(Transform<TestPoint2D> transform) {
+        if (!isInfinite()) {
+            // simple case; just transform the points directly
+            TestPoint2D p1 = transform.apply(getStartPoint());
+            TestPoint2D p2 = transform.apply(getEndPoint());
+
+            return new TestLineSegment(p1, p2);
+        }
+
+        // determine how the line has transformed
+        TestPoint2D p0 = transform.apply(line.toSpace(0));
+        TestPoint2D p1 = transform.apply(line.toSpace(1));
+
+        TestLine tLine = new TestLine(p0, p1);
+        double translation = tLine.toSubspaceValue(p0);
+        double scale = tLine.toSubspaceValue(p1);
+
+        double tStart = (start * scale) + translation;
+        double tEnd = (end * scale) + translation;
+
+        return new TestLineSegment(tStart, tEnd, tLine);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String toString() {
+        final StringBuilder sb = new StringBuilder();
+        sb.append(this.getClass().getSimpleName())
+            .append("[start= ")
+            .append(getStartPoint())
+            .append(", end= ")
+            .append(getEndPoint())
+            .append("]");
+
+        return sb.toString();
+    }
+
+    /** Method used to split the instance with the given line when the instance has
+     * infinite size.
+     * @param splitter the splitter line
+     * @return the split convex subhyperplane
+     */
+    private Split<TestLineSegment> splitInfinite(TestLine splitter) {
+        final TestPoint2D intersection = splitter.intersection(line);
+
+        if (intersection == null) {
+            // the lines are parallel
+            final double originOffset = splitter.offset(line.getOrigin());
+
+            final int sign = PartitionTestUtils.PRECISION.sign(originOffset);
+            if (sign < 0) {
+                return new Split<TestLineSegment>(this, null);
+            }
+            else if (sign > 0) {
+                return new Split<TestLineSegment>(null, this);
+            }
+            return new Split<TestLineSegment>(null, null);
+        }
+        else {
+            // the lines intersect
+            final double intersectionAbscissa = line.toSubspaceValue(intersection);
+
+            TestLineSegment startSegment = null;
+            TestLineSegment endSegment = null;
+
+            if (start < intersectionAbscissa) {
+                startSegment = new TestLineSegment(start, intersectionAbscissa, line);
+            }
+            if (intersectionAbscissa < end) {
+                endSegment = new TestLineSegment(intersectionAbscissa, end, line);
+            }
+
+            final double startOffset = splitter.offset(line.toSpace(intersectionAbscissa - 1));
+            final double startCmp = PartitionTestUtils.PRECISION.sign(startOffset);
+
+            final TestLineSegment minus = (startCmp > 0) ? endSegment: startSegment;
+            final TestLineSegment plus = (startCmp > 0) ? startSegment : endSegment;
+
+            return new Split<TestLineSegment>(minus, plus);
+        }
+    }
+
+    /** Method used to split the instance with the given line when the instance has
+     * finite size.
+     * @param splitter the splitter line
+     * @return the split convex subhyperplane
+     */
+    private Split<TestLineSegment> splitFinite(TestLine splitter) {
+
+        final double startOffset = splitter.offset(line.toSpace(start));
+        final double endOffset = splitter.offset(line.toSpace(end));
+
+        final int startCmp = PartitionTestUtils.PRECISION.sign(startOffset);
+        final int endCmp = PartitionTestUtils.PRECISION.sign(endOffset);
+
+        // startCmp |   endCmp  |   result
+        // --------------------------------
+        // 0        |   0       |   hyper
+        // 0        |   < 0     |   minus
+        // 0        |   > 0     |   plus
+        // < 0      |   0       |   minus
+        // < 0      |   < 0     |   minus
+        // < 0      |   > 0     |   SPLIT
+        // > 0      |   0       |   plus
+        // > 0      |   < 0     |   SPLIT
+        // > 0      |   > 0     |   plus
+
+        if (startCmp == 0 && endCmp == 0) {
+            // the entire line segment is directly on the splitter line
+            return new Split<TestLineSegment>(null, null);
+        }
+        else if (startCmp < 1 && endCmp < 1) {
+            // the entire line segment is on the minus side
+            return new Split<TestLineSegment>(this, null);
+        }
+        else if (startCmp > -1 && endCmp > -1) {
+            // the entire line segment is on the plus side
+            return new Split<TestLineSegment>(null, this);
+        }
+
+        // we need to split the line
+        final TestPoint2D intersection = splitter.intersection(line);
+        final double intersectionAbscissa = line.toSubspaceValue(intersection);
+
+        final TestLineSegment startSegment = new TestLineSegment(start, intersectionAbscissa, line);
+        final TestLineSegment endSegment = new TestLineSegment(intersectionAbscissa, end, line);
+
+        final TestLineSegment minus = (startCmp > 0) ? endSegment: startSegment;
+        final TestLineSegment plus = (startCmp > 0) ? startSegment : endSegment;
+
+        return new Split<TestLineSegment>(minus, plus);
+    }
+}
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestLineSegmentCollection.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestLineSegmentCollection.java
new file mode 100644
index 0000000..a541a5d
--- /dev/null
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestLineSegmentCollection.java
@@ -0,0 +1,197 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partition.test;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.partitioning.ConvexSubHyperplane;
+import org.apache.commons.geometry.core.partitioning.Hyperplane;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.SubHyperplane;
+
+/** Class containing a collection line segments. This class should only be used for
+ * testing purposes.
+ */
+public class TestLineSegmentCollection implements SubHyperplane<TestPoint2D>, Serializable {
+
+    /** Serializable UID */
+    private static final long serialVersionUID = 20190303L;
+
+    /** The collection of line-segments making up the subhyperplane.
+     */
+    private final List<TestLineSegment> segments;
+
+    /** Create a new instance with the given line segments. The segments
+     * are all assumed to lie on the same hyperplane.
+     * @param segments the segments to use in the collection
+     * @throws IllegalArgumentException if the collection is null or empty
+     */
+    public TestLineSegmentCollection(List<TestLineSegment> segments) {
+        this.segments = Collections.unmodifiableList(new ArrayList<>(segments));
+    }
+
+    /** Get the list of line segments comprising the collection.
+     * @return the list of line segments in the collection
+     */
+    public List<TestLineSegment> getLineSegments() {
+        return segments;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Hyperplane<TestPoint2D> getHyperplane() {
+        throw new UnsupportedOperationException();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isFull() {
+        for (TestLineSegment seg : segments) {
+            if (seg.isFull()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isEmpty() {
+        for (TestLineSegment seg : segments) {
+            if (!seg.isEmpty()) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isInfinite() {
+        for (TestLineSegment seg : segments) {
+            if (seg.isInfinite()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isFinite() {
+        return !isInfinite();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public double getSize() {
+        double size = 0.0;
+
+        for (TestLineSegment seg : segments) {
+            size += seg.getSize();
+        }
+
+        return size;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Split<TestLineSegmentCollection> split(Hyperplane<TestPoint2D> splitter) {
+        final List<TestLineSegment> minusList = new ArrayList<>();
+        final List<TestLineSegment> plusList = new ArrayList<>();
+
+        for (TestLineSegment segment : segments) {
+            Split<TestLineSegment> split = segment.split(splitter);
+
+            if (split.getMinus() != null) {
+                minusList.add(split.getMinus());
+            }
+
+            if (split.getPlus() != null) {
+                plusList.add(split.getPlus());
+            }
+        }
+
+        final TestLineSegmentCollection minus = minusList.isEmpty() ?
+                null :
+                new TestLineSegmentCollection(minusList);
+
+        final TestLineSegmentCollection plus = plusList.isEmpty() ?
+                null :
+                new TestLineSegmentCollection(plusList);
+
+        return new Split<TestLineSegmentCollection>(minus, plus);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public RegionLocation classify(TestPoint2D point) {
+
+        // simply return the first value that is not outside;
+        // this is decidedly not robust but should work for testing purposes
+        for (TestLineSegment seg : segments) {
+            final RegionLocation loc = seg.classify(point);
+            if (loc != RegionLocation.OUTSIDE) {
+                return loc;
+            }
+        }
+
+        return RegionLocation.OUTSIDE;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public TestPoint2D closest(TestPoint2D point) {
+        TestPoint2D closest = null;
+        double minDist = -1;
+
+        for (TestLineSegment seg : segments) {
+            TestPoint2D pt = seg.closest(point);
+            double dist = pt.distance(point);
+            if (minDist < 0 || dist < minDist) {
+                minDist = dist;
+                closest = pt;
+            }
+        }
+
+        return closest;
+
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public List<ConvexSubHyperplane<TestPoint2D>> toConvex() {
+        return new ArrayList<>(segments);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public SubHyperplane<TestPoint2D> transform(Transform<TestPoint2D> transform) {
+        throw new UnsupportedOperationException();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public SubHyperplane.Builder<TestPoint2D> builder() {
+        return new TestLineSegmentCollectionBuilder(segments.get(0).getHyperplane());
+    }
+}
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestLineSegmentCollectionBuilder.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestLineSegmentCollectionBuilder.java
new file mode 100644
index 0000000..8ab0d73
--- /dev/null
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestLineSegmentCollectionBuilder.java
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partition.test;
+
+import java.util.ArrayList;
+import java.util.LinkedList;
+import java.util.List;
+
+import org.apache.commons.geometry.core.partitioning.ConvexSubHyperplane;
+import org.apache.commons.geometry.core.partitioning.SubHyperplane;
+
+public class TestLineSegmentCollectionBuilder implements SubHyperplane.Builder<TestPoint2D> {
+
+    private final TestLine line;
+
+    private final List<SegmentInterval> intervals = new LinkedList<>();
+
+    public TestLineSegmentCollectionBuilder(final TestLine line) {
+        this.line = line;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void add(SubHyperplane<TestPoint2D> sub) {
+        for (ConvexSubHyperplane<TestPoint2D> convex : sub.toConvex()) {
+            add(convex);
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void add(ConvexSubHyperplane<TestPoint2D> convex) {
+        TestLineSegment seg = (TestLineSegment) convex;
+        addSegment(seg.getStart(), seg.getEnd());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public SubHyperplane<TestPoint2D> build() {
+        List<TestLineSegment> segments = new ArrayList<>();
+
+        for (SegmentInterval interval : intervals) {
+            segments.add(new TestLineSegment(interval.start, interval.end, line));
+        }
+
+        return new TestLineSegmentCollection(segments);
+    }
+
+    private void addSegment(final double start, final double end) {
+        if (intervals.isEmpty()) {
+            intervals.add(new SegmentInterval(start, end));
+        }
+        else {
+            boolean added = false;
+            SegmentInterval current;
+            for (int i=0; i<intervals.size() && !added; ++i) {
+                current = intervals.get(i);
+
+                if (end < current.start) {
+                    intervals.add(i, new SegmentInterval(start, end));
+
+                    added = true;
+                }
+                else if (start <= current.end) {
+                    current.start = Math.min(current.start, start);
+                    current.end = Math.max(current.end, end);
+
+                    added = true;
+                }
+            }
+
+            if (!added) {
+                intervals.add(new SegmentInterval(start, end));
+            }
+        }
+    }
+
+    private static class SegmentInterval {
+        public double start;
+        public double end;
+
+        SegmentInterval(final double start, final double end) {
+            this.start = start;
+            this.end = end;
+        }
+    }
+}
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestPoint1D.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestPoint1D.java
new file mode 100644
index 0000000..7d353f0
--- /dev/null
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestPoint1D.java
@@ -0,0 +1,83 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partition.test;
+
+import java.io.Serializable;
+
+import org.apache.commons.geometry.core.Point;
+
+/** Class representing a point in one dimensional Euclidean space. This
+ * class should only be used for testing purposes.
+ */
+public class TestPoint1D implements Point<TestPoint1D>, Serializable {
+
+    /** Serializable UID */
+    private static final long serialVersionUID = 1L;
+
+    /** X coordinate */
+    private final double x;
+
+    /** Simple constructor.
+     * @param x x coordinate
+     */
+    public TestPoint1D(final double x) {
+        this.x = x;
+    }
+
+    /** Get the x coordinate of the point.
+     * @return the x coordinate of the point
+     */
+    public double getX() {
+        return x;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public int getDimension() {
+        return 1;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isNaN() {
+        return Double.isNaN(x);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isInfinite() {
+        return Double.isInfinite(x);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isFinite() {
+        return Double.isFinite(x);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public double distance(final TestPoint1D p) {
+        return Math.abs(this.x - p.x);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String toString() {
+        return "(" + x + ")";
+    }
+}
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestPoint2D.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestPoint2D.java
new file mode 100644
index 0000000..e5e9b58
--- /dev/null
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestPoint2D.java
@@ -0,0 +1,107 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partition.test;
+
+import java.io.Serializable;
+
+import org.apache.commons.geometry.core.Point;
+
+/** Class representing a point in two dimensional Euclidean space. This
+ * class should only be used for testing purposes.
+ */
+public class TestPoint2D implements Point<TestPoint2D>, Serializable {
+
+    /** Instance representing the coordinates {@code (0, 0)} */
+    public static final TestPoint2D ZERO = new TestPoint2D(0, 0);
+
+    /** Instance representing the coordinates {@code (1, 0)} */
+    public static final TestPoint2D PLUS_X = new TestPoint2D(1, 0);
+
+    /** Instance representing the coordinates {@code (0, 1)} */
+    public static final TestPoint2D PLUS_Y = new TestPoint2D(0, 1);
+
+    /** Serializable UID */
+    private static final long serialVersionUID = 20190224L;
+
+    /** X coordinate */
+    private final double x;
+
+    /** Y coordinate */
+    private final double y;
+
+    /** Simple constructor.
+     * @param x x coordinate
+     * @param y y coordinate
+     */
+    public TestPoint2D(final double x, final double y) {
+        this.x = x;
+        this.y = y;
+    }
+
+    /** Get the x coordinate value.
+     * @return x coordinate value
+     */
+    public double getX() {
+        return x;
+    }
+
+    /** Get the y coordinate value.
+     * @return y coordinate value
+     */
+    public double getY() {
+        return y;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public int getDimension() {
+        return 2;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isNaN() {
+        return Double.isNaN(x) || Double.isNaN(y);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isInfinite() {
+        return Double.isInfinite(x) || Double.isInfinite(y);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isFinite() {
+        return Double.isFinite(x) && Double.isFinite(y);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public double distance(TestPoint2D p) {
+        final double dx = x - p.x;
+        final double dy = y - p.y;
+
+        return Math.sqrt((dx * dx) + (dy * dy));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String toString() {
+        return "(" + x + ", " + y + ")";
+    }
+}
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestTransform2D.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestTransform2D.java
new file mode 100644
index 0000000..2caa2be
--- /dev/null
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestTransform2D.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partition.test;
+
+import java.util.function.Function;
+
+import org.apache.commons.geometry.core.Transform;
+
+/** Implementation class for 2D {@link Transform}s. This
+ * class should only be used for testing purposes.
+ */
+public class TestTransform2D implements Transform<TestPoint2D> {
+
+    /** Underlying transform function. */
+    private final Function<TestPoint2D, TestPoint2D> fn;
+
+    /** True if the transform preserves the handedness of the space. */
+    private final boolean preservesHandedness;
+
+    /** Create a new instance using the given transform function.
+     * @param fn transform function
+     */
+    public TestTransform2D(final Function<TestPoint2D, TestPoint2D> fn) {
+        this.fn = fn;
+
+        final TestPoint2D tx = fn.apply(TestPoint2D.PLUS_X);
+        final TestPoint2D ty = fn.apply(TestPoint2D.PLUS_Y);
+
+        final double signedArea = (tx.getX() * ty.getY()) -
+                (tx.getY() * ty.getX());
+
+        this.preservesHandedness = signedArea > 0;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public TestPoint2D apply(final TestPoint2D pt) {
+        return fn.apply(pt);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean preservesOrientation() {
+        return preservesHandedness;
+    }
+}
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/AbstractConvexHyperplaneBoundedRegionTest.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/AbstractConvexHyperplaneBoundedRegionTest.java
new file mode 100644
index 0000000..f957acb
--- /dev/null
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/AbstractConvexHyperplaneBoundedRegionTest.java
@@ -0,0 +1,546 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.commons.geometry.core.GeometryTestUtils;
+import org.apache.commons.geometry.core.Region;
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.exception.GeometryException;
+import org.apache.commons.geometry.core.partition.test.PartitionTestUtils;
+import org.apache.commons.geometry.core.partition.test.TestLine;
+import org.apache.commons.geometry.core.partition.test.TestLineSegment;
+import org.apache.commons.geometry.core.partition.test.TestPoint2D;
+import org.apache.commons.geometry.core.partition.test.TestTransform2D;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class AbstractConvexHyperplaneBoundedRegionTest {
+
+    @Test
+    public void testBoundaries_areUnmodifiable() {
+        // arrange
+        StubRegion region = new StubRegion(new ArrayList<>());
+
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            region.getBoundaries().add(TestLine.X_AXIS.span());
+        }, UnsupportedOperationException.class);
+    }
+
+    @Test
+    public void testFull() {
+        // act
+        StubRegion region = new StubRegion(Collections.emptyList());
+
+        // assert
+        Assert.assertTrue(region.isFull());
+        Assert.assertFalse(region.isEmpty());
+    }
+
+    @Test
+    public void testGetBoundarySize() {
+        // arrange
+        TestPoint2D p1 = new TestPoint2D(1, 0);
+        TestPoint2D p2 = new TestPoint2D(2, 0);
+        TestPoint2D p3 = new TestPoint2D(1, 1);
+
+        // act/assert
+        Assert.assertEquals(0, new StubRegion(Collections.emptyList()).getBoundarySize(), PartitionTestUtils.EPS);
+        GeometryTestUtils.assertPositiveInfinity(new StubRegion(Arrays.asList(TestLine.X_AXIS.span())).getBoundarySize());
+        Assert.assertEquals(2 + Math.sqrt(2), new StubRegion(Arrays.asList(
+                    new TestLineSegment(p1, p2),
+                    new TestLineSegment(p2, p3),
+                    new TestLineSegment(p3, p1)
+                )).getBoundarySize(), PartitionTestUtils.EPS);
+    }
+
+    @Test
+    public void testClassify() {
+        // arrange
+        TestPoint2D p1 = new TestPoint2D(1, 0);
+        TestPoint2D p2 = new TestPoint2D(2, 0);
+        TestPoint2D p3 = new TestPoint2D(1, 1);
+
+        StubRegion full = new StubRegion(Collections.emptyList());
+        StubRegion halfSpace = new StubRegion(Arrays.asList(TestLine.X_AXIS.span()));
+        StubRegion triangle = new StubRegion(Arrays.asList(
+                new TestLineSegment(p1, p2),
+                new TestLineSegment(p2, p3),
+                new TestLineSegment(p3, p1)
+            ));
+
+        // act/assert
+        checkClassify(full, RegionLocation.INSIDE, TestPoint2D.ZERO, p1, p2, p3);
+
+        checkClassify(halfSpace, RegionLocation.INSIDE, new TestPoint2D(0, 1));
+        checkClassify(halfSpace, RegionLocation.OUTSIDE, new TestPoint2D(0, -1));
+        checkClassify(halfSpace, RegionLocation.BOUNDARY,
+                new TestPoint2D(-1, 0), new TestPoint2D(0, 0), new TestPoint2D(1, 0));
+
+        checkClassify(triangle, RegionLocation.INSIDE, new TestPoint2D(1.25, 0.25));
+        checkClassify(triangle, RegionLocation.OUTSIDE, new TestPoint2D(-1, 0), new TestPoint2D(0, 0), new TestPoint2D(3, 0));
+        checkClassify(triangle, RegionLocation.BOUNDARY, p1, p2, p3);
+    }
+
+    @Test
+    public void testProject() {
+        // arrange
+        TestPoint2D p1 = new TestPoint2D(1, 0);
+        TestPoint2D p2 = new TestPoint2D(2, 0);
+        TestPoint2D p3 = new TestPoint2D(1, 1);
+
+        StubRegion full = new StubRegion(Collections.emptyList());
+        StubRegion halfSpace = new StubRegion(Arrays.asList(TestLine.X_AXIS.span()));
+        StubRegion triangle = new StubRegion(Arrays.asList(
+                new TestLineSegment(p1, p2),
+                new TestLineSegment(p2, p3),
+                new TestLineSegment(p3, p1)
+            ));
+
+        // act/assert
+        Assert.assertNull(full.project(TestPoint2D.ZERO));
+        Assert.assertNull(full.project(new TestPoint2D(1, 1)));
+
+        PartitionTestUtils.assertPointsEqual(TestPoint2D.ZERO, halfSpace.project(new TestPoint2D(0, 1)));
+        PartitionTestUtils.assertPointsEqual(TestPoint2D.ZERO, halfSpace.project(new TestPoint2D(0, 0)));
+        PartitionTestUtils.assertPointsEqual(TestPoint2D.ZERO, halfSpace.project(new TestPoint2D(0, -1)));
+
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(1.25, 0), triangle.project(new TestPoint2D(1.25, 0.1)));
+        PartitionTestUtils.assertPointsEqual(p1, triangle.project(TestPoint2D.ZERO));
+        PartitionTestUtils.assertPointsEqual(p3, triangle.project(new TestPoint2D(0, 10)));
+    }
+
+    @Test
+    public void testTrim() {
+        // arrange
+        TestPoint2D p1 = new TestPoint2D(1, 0);
+        TestPoint2D p2 = new TestPoint2D(2, 0);
+        TestPoint2D p3 = new TestPoint2D(2, 1);
+        TestPoint2D p4 = new TestPoint2D(1, 1);
+
+        StubRegion full = new StubRegion(Collections.emptyList());
+        StubRegion halfSpace = new StubRegion(Arrays.asList(TestLine.Y_AXIS.span()));
+        StubRegion square = new StubRegion(Arrays.asList(
+                new TestLineSegment(p1, p2),
+                new TestLineSegment(p2, p3),
+                new TestLineSegment(p3, p4),
+                new TestLineSegment(p4, p1)
+            ));
+
+        TestLineSegment segment = new TestLineSegment(new TestPoint2D(-1, 0.5), new TestPoint2D(4, 0.5));
+
+        // act/assert
+        Assert.assertSame(segment, full.trim(segment));
+
+        TestLineSegment trimmedA = halfSpace.trim(segment);
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(-1, 0.5), trimmedA.getStartPoint());
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(0, 0.5), trimmedA.getEndPoint());
+
+        TestLineSegment trimmedB = square.trim(segment);
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(1, 0.5), trimmedB.getStartPoint());
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(2, 0.5), trimmedB.getEndPoint());
+    }
+
+    @Test
+    public void testSplit_full() {
+        // arrange
+        StubRegion region = new StubRegion(Collections.emptyList());
+
+        TestLine splitter = TestLine.X_AXIS;
+
+        // act
+        Split<StubRegion> split = region.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        StubRegion minus = split.getMinus();
+        Assert.assertEquals(1, minus.getBoundaries().size());
+        checkClassify(minus, RegionLocation.INSIDE, new TestPoint2D(0, 1));
+        checkClassify(minus, RegionLocation.BOUNDARY, new TestPoint2D(0, 0));
+        checkClassify(minus, RegionLocation.OUTSIDE, new TestPoint2D(0, -1));
+
+        StubRegion plus = split.getPlus();
+        Assert.assertEquals(1, plus.getBoundaries().size());
+        checkClassify(plus, RegionLocation.OUTSIDE, new TestPoint2D(0, 1));
+        checkClassify(plus, RegionLocation.BOUNDARY, new TestPoint2D(0, 0));
+        checkClassify(plus, RegionLocation.INSIDE, new TestPoint2D(0, -1));
+    }
+
+    @Test
+    public void testSplit_parallel_plusOnly() {
+     // arrange
+        StubRegion region = new StubRegion(
+                Arrays.asList(new TestLineSegment(new TestPoint2D(0, 1), new TestPoint2D(1, 1))));
+
+        TestLine splitter = TestLine.X_AXIS.reverse();
+
+        // act
+        Split<StubRegion> split = region.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.PLUS, split.getLocation());
+
+        Assert.assertNull(split.getMinus());
+        Assert.assertSame(region, split.getPlus());
+    }
+
+    @Test
+    public void testSplit_parallel_minusOnly() {
+     // arrange
+        StubRegion region = new StubRegion(
+                Arrays.asList(new TestLineSegment(new TestPoint2D(0, 1), new TestPoint2D(1, 1))));
+
+        TestLine splitter = TestLine.X_AXIS;
+
+        // act
+        Split<StubRegion> split = region.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.MINUS, split.getLocation());
+
+        Assert.assertSame(region, split.getMinus());
+        Assert.assertNull(split.getPlus());
+    }
+
+    @Test
+    public void testSplit_coincident_sameOrientation() {
+     // arrange
+        StubRegion region = new StubRegion(Arrays.asList(TestLine.X_AXIS.span()));
+
+        TestLine splitter = TestLine.X_AXIS;
+
+        // act
+        Split<StubRegion> split = region.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.MINUS, split.getLocation());
+
+        Assert.assertSame(region, split.getMinus());
+        Assert.assertNull(split.getPlus());
+    }
+
+    @Test
+    public void testSplit_coincident_oppositeOrientation() {
+     // arrange
+        StubRegion region = new StubRegion(Arrays.asList(TestLine.X_AXIS.span()));
+
+        TestLine splitter = TestLine.X_AXIS.reverse();
+
+        // act
+        Split<StubRegion> split = region.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.PLUS, split.getLocation());
+
+        Assert.assertNull(split.getMinus());
+        Assert.assertSame(region, split.getPlus());
+    }
+
+    @Test
+    public void testSplit_finite_both() {
+        // arrange
+        TestPoint2D p1 = new TestPoint2D(1, -0.5);
+        TestPoint2D p2 = new TestPoint2D(2, -0.5);
+        TestPoint2D p3 = new TestPoint2D(2, 0.5);
+        TestPoint2D p4 = new TestPoint2D(1, 0.5);
+
+        StubRegion region = new StubRegion(Arrays.asList(
+                    new TestLineSegment(p1, p2),
+                    new TestLineSegment(p2, p3),
+                    new TestLineSegment(p3, p4),
+                    new TestLineSegment(p4, p1)
+                ));
+
+        TestLine splitter = TestLine.X_AXIS;
+
+        // act
+        Split<StubRegion> split = region.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        StubRegion minus = split.getMinus();
+        Assert.assertEquals(4, minus.getBoundaries().size());
+        checkClassify(minus, RegionLocation.INSIDE, new TestPoint2D(1.5, 0.25));
+        checkClassify(minus, RegionLocation.BOUNDARY, new TestPoint2D(1.5, 0));
+        checkClassify(minus, RegionLocation.OUTSIDE, new TestPoint2D(1.5, -0.25));
+
+        StubRegion plus = split.getPlus();
+        Assert.assertEquals(4, plus.getBoundaries().size());
+        checkClassify(plus, RegionLocation.OUTSIDE, new TestPoint2D(1.5, 0.25));
+        checkClassify(plus, RegionLocation.BOUNDARY, new TestPoint2D(1.5, 0));
+        checkClassify(plus, RegionLocation.INSIDE, new TestPoint2D(1.5, -0.25));
+    }
+
+    @Test
+    public void testTransform_full() {
+        // arrange
+        StubRegion region = new StubRegion(Collections.emptyList());
+
+        Transform<TestPoint2D> transform = new TestTransform2D(p -> new TestPoint2D(p.getX() + 1, p.getY() + 2));
+
+        // act
+        StubRegion transformed = region.transform(transform);
+
+        // assert
+        Assert.assertTrue(transformed.isFull());
+        Assert.assertFalse(transformed.isEmpty());
+    }
+
+    @Test
+    public void testTransform_infinite() {
+        // arrange
+        TestLine line = TestLine.Y_AXIS;
+
+        StubRegion region = new StubRegion(Arrays.asList(
+                line.span()
+            ));
+
+        Transform<TestPoint2D> transform = new TestTransform2D(p -> new TestPoint2D(p.getX() + 1, p.getY() + 2));
+
+        // act
+        StubRegion transformed = region.transform(transform);
+
+        // assert
+        List<TestLineSegment> boundaries = transformed.getBoundaries();
+
+        Assert.assertEquals(1, boundaries.size());
+
+        TestLineSegment a = boundaries.get(0);
+        TestLine aLine = a.getHyperplane();
+        PartitionTestUtils.assertPointsEqual(aLine.getOrigin(), new TestPoint2D(1, 0));
+        Assert.assertEquals(0.0, aLine.getDirectionX(), PartitionTestUtils.EPS);
+        Assert.assertEquals(1.0, aLine.getDirectionY(), PartitionTestUtils.EPS);
+
+        GeometryTestUtils.assertNegativeInfinity(a.getStart());
+        GeometryTestUtils.assertPositiveInfinity(a.getEnd());
+    }
+
+    @Test
+    public void testTransform_finite() {
+        // arrange
+        TestPoint2D p1 = new TestPoint2D(1, 0);
+        TestPoint2D p2 = new TestPoint2D(2, 0);
+        TestPoint2D p3 = new TestPoint2D(1, 1);
+
+        StubRegion region = new StubRegion(Arrays.asList(
+                new TestLineSegment(p1, p2),
+                new TestLineSegment(p2, p3),
+                new TestLineSegment(p3, p1)
+            ));
+
+        Transform<TestPoint2D> transform = new TestTransform2D(p -> new TestPoint2D(p.getX() + 1, p.getY() + 2));
+
+        // act
+        StubRegion transformed = region.transform(transform);
+
+        // assert
+        List<TestLineSegment> boundaries = transformed.getBoundaries();
+
+        Assert.assertEquals(3, boundaries.size());
+
+        TestLineSegment a = boundaries.get(0);
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(2, 2), a.getStartPoint());
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(3, 2), a.getEndPoint());
+
+        TestLineSegment b = boundaries.get(1);
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(3, 2), b.getStartPoint());
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(2, 3), b.getEndPoint());
+
+        TestLineSegment c = boundaries.get(2);
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(2, 3), c.getStartPoint());
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(2, 2), c.getEndPoint());
+    }
+
+    @Test
+    public void testTransform_reflection() {
+        // arrange
+        TestPoint2D p1 = new TestPoint2D(1, 0);
+        TestPoint2D p2 = new TestPoint2D(2, 0);
+        TestPoint2D p3 = new TestPoint2D(1, 1);
+
+        StubRegion region = new StubRegion(Arrays.asList(
+                new TestLineSegment(p1, p2),
+                new TestLineSegment(p2, p3),
+                new TestLineSegment(p3, p1)
+            ));
+
+        Transform<TestPoint2D> transform = new TestTransform2D(p -> new TestPoint2D(-p.getX(), p.getY()));
+
+        // act
+        StubRegion transformed = region.transform(transform);
+
+        // assert
+        List<TestLineSegment> boundaries = transformed.getBoundaries();
+
+        Assert.assertEquals(3, boundaries.size());
+
+        TestLineSegment a = boundaries.get(0);
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(-2, 0), a.getStartPoint());
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(-1, 0), a.getEndPoint());
+
+        TestLineSegment b = boundaries.get(1);
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(-1, 1), b.getStartPoint());
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(-2, 0), b.getEndPoint());
+
+        TestLineSegment c = boundaries.get(2);
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(-1, 0), c.getStartPoint());
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(-1, 1), c.getEndPoint());
+    }
+
+    @Test
+    public void testConvexRegionBoundaryBuilder_full() {
+        // act
+        StubRegion region = StubRegion.fromBounds(Collections.emptyList());
+
+        // assert
+        Assert.assertSame(StubRegion.FULL, region);
+    }
+
+    @Test
+    public void testConvexRegionBoundaryBuilder_singleLine() {
+        // act
+        StubRegion region = StubRegion.fromBounds(Arrays.asList(TestLine.Y_AXIS));
+
+        // assert
+        Assert.assertEquals(1, region.getBoundaries().size());
+
+        checkClassify(region, RegionLocation.INSIDE, new TestPoint2D(-1, 0));
+        checkClassify(region, RegionLocation.BOUNDARY, new TestPoint2D(0, 0));
+        checkClassify(region, RegionLocation.OUTSIDE, new TestPoint2D(1, 0));
+    }
+
+    @Test
+    public void testConvexRegionBoundaryBuilder_multipleLines() {
+        // act
+        StubRegion region = StubRegion.fromBounds(Arrays.asList(
+                    TestLine.X_AXIS,
+                    new TestLine(new TestPoint2D(1, 0), new TestPoint2D(0, 1)),
+                    TestLine.Y_AXIS.reverse()
+                ));
+
+        // assert
+        Assert.assertEquals(3, region.getBoundaries().size());
+
+        checkClassify(region, RegionLocation.INSIDE, new TestPoint2D(0.25, 0.25));
+
+        checkClassify(region, RegionLocation.BOUNDARY,
+                TestPoint2D.ZERO, new TestPoint2D(1, 0), new TestPoint2D(1, 0), new TestPoint2D(0.5, 0.5));
+
+        checkClassify(region, RegionLocation.OUTSIDE,
+                new TestPoint2D(-1, 0.5), new TestPoint2D(1, 0.5),
+                new TestPoint2D(0.5, 1), new TestPoint2D(0.5, -1));
+    }
+
+    @Test
+    public void testConvexRegionBoundaryBuilder_duplicateLines() {
+        // act
+        StubRegion region = StubRegion.fromBounds(Arrays.asList(
+                TestLine.Y_AXIS,
+                TestLine.Y_AXIS,
+                new TestLine(new TestPoint2D(0, 0), new TestPoint2D(0, 1)),
+                TestLine.Y_AXIS));
+
+        // assert
+        Assert.assertEquals(1, region.getBoundaries().size());
+
+        checkClassify(region, RegionLocation.INSIDE, new TestPoint2D(-1, 0));
+        checkClassify(region, RegionLocation.BOUNDARY, new TestPoint2D(0, 0));
+        checkClassify(region, RegionLocation.OUTSIDE, new TestPoint2D(1, 0));
+    }
+
+    @Test
+    public void testConvexRegionBoundaryBuilder() {
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            StubRegion.fromBounds(Arrays.asList(TestLine.X_AXIS, TestLine.X_AXIS.reverse()));
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            StubRegion.fromBounds(Arrays.asList(
+                    TestLine.X_AXIS,
+                    TestLine.Y_AXIS,
+                    new TestLine(new TestPoint2D(1, 0), new TestPoint2D(0, -1))));
+        }, GeometryException.class);
+    }
+
+    @Test
+    public void testToString() {
+        // arrange
+        StubRegion region = new StubRegion(Collections.emptyList());
+
+        // act
+        String str = region.toString();
+
+        // assert
+        Assert.assertTrue(str.contains("StubRegion"));
+        Assert.assertTrue(str.contains("boundaries= "));
+    }
+
+    private static void checkClassify(Region<TestPoint2D> region, RegionLocation loc, TestPoint2D ... pts) {
+        for (TestPoint2D pt : pts) {
+            Assert.assertEquals("Unexpected location for point " + pt, loc, region.classify(pt));
+        }
+    }
+
+    private static final class StubRegion extends AbstractConvexHyperplaneBoundedRegion<TestPoint2D, TestLineSegment>{
+
+        private static final long serialVersionUID = 1L;
+
+        private static final StubRegion FULL = new StubRegion(Collections.emptyList());
+
+        StubRegion(List<TestLineSegment> boundaries) {
+            super(boundaries);
+        }
+
+        public StubRegion transform(Transform<TestPoint2D> transform) {
+            return transformInternal(transform, this, TestLineSegment.class, StubRegion::new);
+        }
+
+        @Override
+        public Split<StubRegion> split(Hyperplane<TestPoint2D> splitter) {
+            return splitInternal(splitter, this, TestLineSegment.class, StubRegion::new);
+        }
+
+        @Override
+        public TestLineSegment trim(ConvexSubHyperplane<TestPoint2D> convexSubHyperplane) {
+            return (TestLineSegment) super.trim(convexSubHyperplane);
+        }
+
+        @Override
+        public double getSize() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public TestPoint2D getBarycenter() {
+            throw new UnsupportedOperationException();
+        }
+
+        public static StubRegion fromBounds(Iterable<TestLine> boundingLines) {
+            final List<TestLineSegment> segments = new ConvexRegionBoundaryBuilder<>(TestLineSegment.class).build(boundingLines);
+            return segments.isEmpty() ? FULL : new StubRegion(segments);
+        }
+    }
+}
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/AbstractEmbeddingSubHyperplaneTest.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/AbstractEmbeddingSubHyperplaneTest.java
new file mode 100644
index 0000000..eda55fa
--- /dev/null
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/AbstractEmbeddingSubHyperplaneTest.java
@@ -0,0 +1,187 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning;
+
+import java.util.List;
+
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.partition.test.PartitionTestUtils;
+import org.apache.commons.geometry.core.partition.test.TestLine;
+import org.apache.commons.geometry.core.partition.test.TestPoint1D;
+import org.apache.commons.geometry.core.partition.test.TestPoint2D;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class AbstractEmbeddingSubHyperplaneTest {
+
+    @Test
+    public void testSimpleProperties() {
+        // arrange
+        StubSubHyperplane sub = new StubSubHyperplane();
+
+        // act/assert
+        Assert.assertTrue(sub.isFull());
+        Assert.assertTrue(sub.isEmpty());
+        Assert.assertEquals(1.0, sub.getSize(), PartitionTestUtils.EPS);
+    }
+
+    @Test
+    public void testClassify() {
+        // arrange
+        StubSubHyperplane sub = new StubSubHyperplane();
+
+        // act/assert
+        Assert.assertEquals(RegionLocation.INSIDE, sub.classify(new TestPoint2D(-1, 0)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, sub.classify(new TestPoint2D(0, 0)));
+
+        Assert.assertEquals(RegionLocation.OUTSIDE, sub.classify(new TestPoint2D(0, 1)));
+        Assert.assertEquals(RegionLocation.OUTSIDE, sub.classify(new TestPoint2D(-1, 1)));
+        Assert.assertEquals(RegionLocation.OUTSIDE, sub.classify(new TestPoint2D(-1, -1)));
+    }
+
+    @Test
+    public void testClosest() {
+        // arrange
+        StubSubHyperplane sub = new StubSubHyperplane();
+
+        // act/assert
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(-1, 0), sub.closest(new TestPoint2D(-1, 0)));
+
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(0, 0), sub.closest(new TestPoint2D(0, 0)));
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(0, 0), sub.closest(new TestPoint2D(1, 0)));
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(0, 0), sub.closest(new TestPoint2D(1, 1)));
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(0, 0), sub.closest(new TestPoint2D(1, -1)));
+
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(-1, 0), sub.closest(new TestPoint2D(-1, 1)));
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(-1, 0), sub.closest(new TestPoint2D(-1, -1)));
+    }
+
+    @Test
+    public void testClosest_nullSubspaceRegionProjection() {
+        // arrange
+        StubSubHyperplane sub = new StubSubHyperplane();
+        sub.region.projected = null;
+
+        // act/assert
+        Assert.assertNull(sub.closest(new TestPoint2D(1, 1)));
+    }
+
+    private static class StubSubHyperplane extends AbstractEmbeddingSubHyperplane<TestPoint2D, TestPoint1D, TestLine> {
+
+        /** Serializable UID */
+        private static final long serialVersionUID = 20190729L;
+
+        private StubRegion1D region = new StubRegion1D();
+
+        @Override
+        public boolean isInfinite() {
+            return false;
+        }
+
+        @Override
+        public boolean isFinite() {
+            return true;
+        }
+
+        @Override
+        public Builder<TestPoint2D> builder() {
+            return null;
+        }
+
+        @Override
+        public List<? extends ConvexSubHyperplane<TestPoint2D>> toConvex() {
+            return null;
+        }
+
+        @Override
+        public TestLine getHyperplane() {
+            return TestLine.X_AXIS;
+        }
+
+        @Override
+        public HyperplaneBoundedRegion<TestPoint1D> getSubspaceRegion() {
+            return region;
+        }
+
+        @Override
+        public Split<StubSubHyperplane> split(Hyperplane<TestPoint2D> splitter) {
+            return null;
+        }
+
+        @Override
+        public SubHyperplane<TestPoint2D> transform(Transform<TestPoint2D> transform) {
+            return null;
+        }
+    }
+
+    /** Stub region implementation with some hard-coded values. Negative numbers are
+     * on the inside of the region.
+     */
+    private static class StubRegion1D implements HyperplaneBoundedRegion<TestPoint1D> {
+
+        private TestPoint1D projected = new TestPoint1D(0);
+
+        @Override
+        public boolean isFull() {
+            return true;
+        }
+
+        @Override
+        public boolean isEmpty() {
+            return true;
+        }
+
+        @Override
+        public double getSize() {
+            return 1;
+        }
+
+        @Override
+        public double getBoundarySize() {
+            return 0;
+        }
+
+        @Override
+        public TestPoint1D getBarycenter() {
+            return null;
+        }
+
+        @Override
+        public RegionLocation classify(TestPoint1D pt) {
+            int sign = PartitionTestUtils.PRECISION.sign(pt.getX());
+
+            if (sign < 0) {
+                return RegionLocation.INSIDE;
+            }
+            else if (sign == 0) {
+                return RegionLocation.BOUNDARY;
+            }
+            return RegionLocation.OUTSIDE;
+        }
+
+        @Override
+        public TestPoint1D project(TestPoint1D pt) {
+            return projected;
+        }
+
+        @Override
+        public Split<? extends HyperplaneBoundedRegion<TestPoint1D>> split(Hyperplane<TestPoint1D> splitter) {
+            throw new UnsupportedOperationException();
+        }
+    }
+}
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/AbstractHyperplaneTest.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/AbstractHyperplaneTest.java
new file mode 100644
index 0000000..5855448
--- /dev/null
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/AbstractHyperplaneTest.java
@@ -0,0 +1,113 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning;
+
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.partition.test.TestLine;
+import org.apache.commons.geometry.core.partition.test.TestPoint2D;
+import org.apache.commons.geometry.core.partitioning.AbstractHyperplane;
+import org.apache.commons.geometry.core.partitioning.ConvexSubHyperplane;
+import org.apache.commons.geometry.core.partitioning.Hyperplane;
+import org.apache.commons.geometry.core.partitioning.HyperplaneLocation;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class AbstractHyperplaneTest {
+
+    @Test
+    public void testGetPrecision() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-1);
+        StubHyperplane hyper = new StubHyperplane(precision);
+
+        // act/assert
+        Assert.assertSame(precision, hyper.getPrecision());
+    }
+
+    @Test
+    public void testClassify() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-1);
+        StubHyperplane hyper = new StubHyperplane(precision);
+
+        // act/assert
+        Assert.assertEquals(HyperplaneLocation.MINUS, hyper.classify(new TestPoint2D(1, 1)));
+
+        Assert.assertEquals(HyperplaneLocation.ON, hyper.classify(new TestPoint2D(1, 0.09)));
+        Assert.assertEquals(HyperplaneLocation.ON, hyper.classify(new TestPoint2D(1, 0)));
+        Assert.assertEquals(HyperplaneLocation.ON, hyper.classify(new TestPoint2D(1, -0.09)));
+
+        Assert.assertEquals(HyperplaneLocation.PLUS, hyper.classify(new TestPoint2D(1, -1)));
+    }
+
+    @Test
+    public void testContains() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-1);
+        StubHyperplane hyper = new StubHyperplane(precision);
+
+        // act/assert
+        Assert.assertFalse(hyper.contains(new TestPoint2D(1, 1)));
+
+        Assert.assertTrue(hyper.contains(new TestPoint2D(1, 0.09)));
+        Assert.assertTrue(hyper.contains(new TestPoint2D(1, 0)));
+        Assert.assertTrue(hyper.contains(new TestPoint2D(1, -0.09)));
+
+        Assert.assertFalse(hyper.contains(new TestPoint2D(1, -1)));
+    }
+
+    public static class StubHyperplane extends AbstractHyperplane<TestPoint2D> {
+
+        private static final long serialVersionUID = 1L;
+
+        public StubHyperplane(DoublePrecisionContext precision) {
+            super(precision);
+        }
+
+        @Override
+        public double offset(TestPoint2D point) {
+            return TestLine.X_AXIS.offset(point);
+        }
+
+        @Override
+        public TestPoint2D project(TestPoint2D point) {
+            return null;
+        }
+
+        @Override
+        public Hyperplane<TestPoint2D> reverse() {
+            return null;
+        }
+
+        @Override
+        public Hyperplane<TestPoint2D> transform(Transform<TestPoint2D> transform) {
+            return null;
+        }
+
+        @Override
+        public boolean similarOrientation(Hyperplane<TestPoint2D> other) {
+            return false;
+        }
+
+        @Override
+        public ConvexSubHyperplane<TestPoint2D> span() {
+            return null;
+        }
+    }
+}
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/SplitTest.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/SplitTest.java
new file mode 100644
index 0000000..5b4ec57
--- /dev/null
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/SplitTest.java
@@ -0,0 +1,64 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning;
+
+import org.junit.Test;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.SplitLocation;
+import org.junit.Assert;
+
+public class SplitTest {
+
+    @Test
+    public void testProperties() {
+        // arrange
+        Object a = new Object();
+        Object b = new Object();
+
+        // act
+        Split<Object> split = new Split<>(a, b);
+
+        // assert
+        Assert.assertSame(a, split.getMinus());
+        Assert.assertSame(b,  split.getPlus());
+    }
+
+    @Test
+    public void testGetLocation() {
+        // arrange
+        Object a = new Object();
+        Object b = new Object();
+
+        // act/assert
+        Assert.assertEquals(SplitLocation.NEITHER, new Split<Object>(null, null).getLocation());
+        Assert.assertEquals(SplitLocation.MINUS, new Split<Object>(a, null).getLocation());
+        Assert.assertEquals(SplitLocation.PLUS, new Split<Object>(null, b).getLocation());
+        Assert.assertEquals(SplitLocation.BOTH, new Split<Object>(a, b).getLocation());
+    }
+
+    @Test
+    public void testToString() {
+        // arrange
+        Split<String> split = new Split<>("a", "b");
+
+        // act
+        String str = split.toString();
+
+        // assert
+        Assert.assertEquals("Split[location= BOTH, minus= a, plus= b]", str);
+    }
+}
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/TreeBuilder.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/TreeBuilder.java
deleted file mode 100644
index 81d03af..0000000
--- a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/TreeBuilder.java
+++ /dev/null
@@ -1,168 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning;
-
-import java.text.ParseException;
-import java.util.StringTokenizer;
-
-import org.apache.commons.geometry.core.Point;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
-
-/** Local class for building an {@link AbstractRegion} tree.
- * @param <P> Point type defining the space
- */
-public abstract class TreeBuilder<P extends Point<P>> {
-
-    /** Default epsilon value for use when no value is specified
-     * in the constructor.
-     */
-    private static final double DEFAULT_EPS = 1e-10;
-
-    /** Keyword for internal nodes. */
-    private static final String INTERNAL  = "internal";
-
-    /** Keyword for leaf nodes. */
-    private static final String LEAF      = "leaf";
-
-    /** Keyword for plus children trees. */
-    private static final String PLUS      = "plus";
-
-    /** Keyword for minus children trees. */
-    private static final String MINUS     = "minus";
-
-    /** Keyword for true flags. */
-    private static final String TRUE      = "true";
-
-    /** Keyword for false flags. */
-    private static final String FALSE     = "false";
-
-    /** Tree root. */
-    private BSPTree<P> root;
-
-    /** Precision. */
-    private final DoublePrecisionContext precision;
-
-    /** Tokenizer parsing string representation. */
-    private final StringTokenizer tokenizer;
-
-    /** Constructor using a default precision context.
-     * @param type type of the expected representation
-     * @param str the tree string representation
-     * @exception ParseException if the string cannot be parsed
-     */
-    public TreeBuilder(final String type, final String str) throws ParseException {
-        this(type, str, new EpsilonDoublePrecisionContext(DEFAULT_EPS));
-    }
-
-    /** Simple constructor.
-     * @param type type of the expected representation
-     * @param str the tree string representation
-     * @param precision precision context for determining floating point equality
-     * @exception ParseException if the string cannot be parsed
-     */
-    public TreeBuilder(final String type, final String str, final DoublePrecisionContext precision)
-        throws ParseException {
-        this.precision = precision;
-
-        root = null;
-        tokenizer = new StringTokenizer(str);
-        getWord(type);
-        getWord(PLUS);
-        root = new BSPTree<>();
-        parseTree(root);
-        if (tokenizer.hasMoreTokens()) {
-            throw new ParseException("unexpected " + tokenizer.nextToken(), 0);
-        }
-    }
-
-    /** Parse a tree.
-     * @param node start node
-     * @exception ParseException if the string cannot be parsed
-     */
-    private void parseTree(final BSPTree<P> node)
-        throws ParseException {
-        if (INTERNAL.equals(getWord(INTERNAL, LEAF))) {
-            // this is an internal node, it has a cut sub-hyperplane (stored as a whole hyperplane)
-            // then a minus tree, then a plus tree
-            node.insertCut(parseHyperplane());
-            getWord(MINUS);
-            parseTree(node.getMinus());
-            getWord(PLUS);
-            parseTree(node.getPlus());
-        } else {
-            // this is a leaf node, it has only an inside/outside flag
-            node.setAttribute(getBoolean());
-        }
-    }
-
-    /** Get next word.
-     * @param allowed allowed values
-     * @return parsed word
-     * @exception ParseException if the string cannot be parsed
-     */
-    protected String getWord(final String ... allowed)
-        throws ParseException {
-        final String token = tokenizer.nextToken();
-        for (final String a : allowed) {
-            if (a.equals(token)) {
-                return token;
-            }
-        }
-        throw new ParseException(token + " != " + allowed[0], 0);
-    }
-
-    /** Get next number.
-     * @return parsed number
-     * @exception NumberFormatException if the string cannot be parsed
-     */
-    protected double getNumber()
-        throws NumberFormatException {
-        return Double.parseDouble(tokenizer.nextToken());
-    }
-
-    /** Get next boolean.
-     * @return parsed boolean
-     * @exception ParseException if the string cannot be parsed
-     */
-    protected boolean getBoolean()
-        throws ParseException {
-        return getWord(TRUE, FALSE).equals(TRUE);
-    }
-
-    /** Get the built tree.
-     * @return built tree
-     */
-    public BSPTree<P> getTree() {
-        return root;
-    }
-
-    /** Get the precision.
-     * @return precision
-     */
-    public DoublePrecisionContext getPrecision() {
-        return precision;
-    }
-
-    /** Parse an hyperplane.
-     * @return next hyperplane from the stream
-     * @exception ParseException if the string cannot be parsed
-     */
-    protected abstract Hyperplane<P> parseHyperplane()
-        throws ParseException;
-
-}
\ No newline at end of file
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/TreeDumper.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/TreeDumper.java
deleted file mode 100644
index db94a9c..0000000
--- a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/TreeDumper.java
+++ /dev/null
@@ -1,101 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning;
-
-import java.util.Formatter;
-import java.util.Locale;
-
-import org.apache.commons.geometry.core.Point;
-
-/** Dumping visitor.
- * @param <P> Point type defining the space
- */
-public abstract class TreeDumper<P extends Point<P>> implements BSPTreeVisitor<P> {
-    /** Builder for the string representation of the dumped tree. */
-    private final StringBuilder dump;
-
-    /** Formatter for strings. */
-    private final Formatter formatter;
-
-    /** Current indentation prefix. */
-    private String prefix;
-
-    /** Simple constructor.
-     * @param type type of the region to dump
-     */
-    public TreeDumper(final String type) {
-        this.dump      = new StringBuilder();
-        this.formatter = new Formatter(dump, Locale.US);
-        this.prefix    = "";
-        formatter.format("%s%n", type);
-    }
-
-    /** Get the string representation of the tree.
-     * @return string representation of the tree.
-     */
-    public String getDump() {
-        return dump.toString();
-    }
-
-    /** Get the formatter to use.
-     * @return formatter to use
-     */
-    protected Formatter getFormatter() {
-        return formatter;
-    }
-
-    /** Format a string representation of the hyperplane underlying a cut sub-hyperplane.
-     * @param hyperplane hyperplane to format
-     */
-    protected abstract void formatHyperplane(Hyperplane<P> hyperplane);
-
-    /** {@inheritDoc} */
-    @Override
-    public Order visitOrder(final BSPTree<P> node) {
-        return Order.SUB_MINUS_PLUS;
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public void visitInternalNode(final BSPTree<P> node) {
-        formatter.format("%s %s internal ", prefix, type(node));
-        formatHyperplane(node.getCut().getHyperplane());
-        formatter.format("%n");
-        prefix = prefix + "  ";
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public void visitLeafNode(final BSPTree<P> node) {
-        formatter.format("%s %s leaf %s%n",
-                         prefix, type(node), node.getAttribute());
-        for (BSPTree<P> n = node;
-             n.getParent() != null && n == n.getParent().getPlus();
-             n = n.getParent()) {
-            prefix = prefix.substring(0, prefix.length() - 2);
-        }
-    }
-
-    /** Get the type of the node.
-     * @param node node to check
-     * @return "plus " or "minus" depending on the node being the plus or minus
-     * child of its parent ("plus " is arbitrarily returned for the root node)
-     */
-    private String type(final BSPTree<P> node) {
-        return (node.getParent() != null && node == node.getParent().getMinus()) ? "minus" : "plus ";
-    }
-}
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/TreePrinter.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/TreePrinter.java
deleted file mode 100644
index 75acd92..0000000
--- a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/TreePrinter.java
+++ /dev/null
@@ -1,135 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning;
-
-import java.util.Objects;
-
-import org.apache.commons.geometry.core.Point;
-
-/** Base for classes that create string representations of {@link BSPTree}s.
- * @param <P> Point type defining the space
- */
-public abstract class TreePrinter<P extends Point<P>> implements BSPTreeVisitor<P> {
-
-    /** Indent per tree level */
-    protected static final String INDENT = "    ";
-
-    /** Current depth in the tree */
-    protected int depth;
-
-    /** Contains the string output */
-    protected StringBuilder output = new StringBuilder();
-
-    /** Returns a string representation of the given {@link BSPTree}.
-     * @param tree
-     * @return
-     */
-    public String writeAsString(BSPTree<P> tree) {
-        output.delete(0, output.length());
-
-        tree.visit(this);
-
-        return output.toString();
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public Order visitOrder(BSPTree<P> node) {
-        return Order.SUB_MINUS_PLUS;
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public void visitInternalNode(BSPTree<P> node) {
-        writeLinePrefix(node);
-        writeInternalNode(node);
-
-        write("\n");
-
-        ++depth;
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public void visitLeafNode(BSPTree<P> node) {
-        writeLinePrefix(node);
-        writeLeafNode(node);
-
-        write("\n");
-
-        BSPTree<P> cur = node;
-        while (cur.getParent() != null && cur.getParent().getPlus() == cur) {
-            --depth;
-            cur = cur.getParent();
-        }
-    }
-
-    /** Writes the prefix for the current line in the output. This includes
-     * the line indent, the plus/minus node indicator, and a string identifier
-     * for the node itself.
-     * @param node
-     */
-    protected void writeLinePrefix(BSPTree<P> node) {
-        for (int i=0; i<depth; ++i) {
-            write(INDENT);
-        }
-
-        if (node.getParent() != null) {
-            if (node.getParent().getMinus() == node) {
-                write("[-] ");
-            }
-            else {
-                write("[+] ");
-            }
-        }
-
-        write(nodeIdString(node) + " | ");
-    }
-
-    /** Returns a short string identifier for the given node.
-     * @param node
-     * @return
-     */
-    protected String nodeIdString(BSPTree<P> node) {
-        String str = Objects.toString(node);
-        int idx = str.lastIndexOf('.');
-        if (idx > -1) {
-            return str.substring(idx + 1, str.length());
-        }
-        return str;
-    }
-
-    /** Adds the given string to the output.
-     * @param str
-     */
-    protected void write(String str) {
-        output.append(str);
-    }
-
-    /** Method for subclasses to provide their own string representation
-     * of the given internal node.
-     */
-    protected abstract void writeInternalNode(BSPTree<P> node);
-
-    /** Writes a leaf node. The default implementation here simply writes
-     * the node attribute as a string.
-     * @param node
-     */
-    protected void writeLeafNode(BSPTree<P> node) {
-        write(String.valueOf(node.getAttribute()));
-    }
-}
\ No newline at end of file
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/bsp/AbstractBSPTreeMergeOperatorTest.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/bsp/AbstractBSPTreeMergeOperatorTest.java
new file mode 100644
index 0000000..4f1a5fc
--- /dev/null
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/bsp/AbstractBSPTreeMergeOperatorTest.java
@@ -0,0 +1,561 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning.bsp;
+
+import org.apache.commons.geometry.core.partition.test.PartitionTestUtils;
+import org.apache.commons.geometry.core.partition.test.TestLine;
+import org.apache.commons.geometry.core.partition.test.TestPoint2D;
+import org.apache.commons.geometry.core.partitioning.bsp.AbstractBSPTreeMergeOperator;
+import org.apache.commons.geometry.core.partitioning.bsp.AttributeBSPTree;
+import org.apache.commons.geometry.core.partitioning.bsp.BSPTree;
+import org.apache.commons.geometry.core.partitioning.bsp.AttributeBSPTree.AttributeNode;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class AbstractBSPTreeMergeOperatorTest {
+
+    @Test
+    public void testMerge_singleNodeTreeWithSingleNodeTree() {
+        // arrange
+        AttributeBSPTree<TestPoint2D, String> a = new AttributeBSPTree<>();
+        a.getRoot().setAttribute("A");
+
+        AttributeBSPTree<TestPoint2D, String> b = new AttributeBSPTree<>();
+        b.getRoot().setAttribute("B");
+
+        AttributeBSPTree<TestPoint2D, String> c = new AttributeBSPTree<>();
+
+        TestMergeOperator mergeOp = new TestMergeOperator();
+
+        // act
+        mergeOp.apply(a, b, c);
+
+        // assert
+        Assert.assertEquals(1, a.count());
+        Assert.assertEquals(1, b.count());
+        Assert.assertEquals(1, c.count());
+
+        Assert.assertEquals("A", a.findNode(new TestPoint2D(0, 1)).getAttribute());
+        Assert.assertEquals("A", a.findNode(new TestPoint2D(0, -1)).getAttribute());
+
+        Assert.assertEquals("B", b.findNode(new TestPoint2D(0, 1)).getAttribute());
+        Assert.assertEquals("B", b.findNode(new TestPoint2D(0, -1)).getAttribute());
+
+        Assert.assertEquals("AB", c.findNode(new TestPoint2D(1, 1)).getAttribute());
+        Assert.assertEquals("AB", c.findNode(new TestPoint2D(-1, 1)).getAttribute());
+        Assert.assertEquals("AB", c.findNode(new TestPoint2D(-1, -1)).getAttribute());
+        Assert.assertEquals("AB", c.findNode(new TestPoint2D(1, -1)).getAttribute());
+
+        PartitionTestUtils.assertTreeStructure(a);
+        PartitionTestUtils.assertTreeStructure(b);
+        PartitionTestUtils.assertTreeStructure(c);
+    }
+
+    @Test
+    public void testMerge_singleNodeTreeWithMultiNodeTree() {
+        // arrange
+        AttributeBSPTree<TestPoint2D, String> a = new AttributeBSPTree<>();
+        a.getRoot().cut(TestLine.X_AXIS)
+            .getPlus().attr("A")
+            .getParent()
+            .getMinus().attr("a");
+
+        AttributeBSPTree<TestPoint2D, String> b = new AttributeBSPTree<>();
+        b.getRoot().setAttribute("B");
+
+        AttributeBSPTree<TestPoint2D, String> c = new AttributeBSPTree<>();
+
+        TestMergeOperator mergeOp = new TestMergeOperator();
+
+        // act
+        mergeOp.apply(a, b, c);
+
+        // assert
+        Assert.assertEquals(3, a.count());
+        Assert.assertEquals(1, b.count());
+        Assert.assertEquals(3, c.count());
+
+        Assert.assertEquals("a", a.findNode(new TestPoint2D(0, 1)).getAttribute());
+        Assert.assertEquals("A", a.findNode(new TestPoint2D(0, -1)).getAttribute());
+
+        Assert.assertEquals("B", b.findNode(new TestPoint2D(0, 1)).getAttribute());
+        Assert.assertEquals("B", b.findNode(new TestPoint2D(0, -1)).getAttribute());
+
+        Assert.assertEquals("Ba", c.findNode(new TestPoint2D(1, 1)).getAttribute());
+        Assert.assertEquals("Ba", c.findNode(new TestPoint2D(-1, 1)).getAttribute());
+        Assert.assertEquals("BA", c.findNode(new TestPoint2D(-1, -1)).getAttribute());
+        Assert.assertEquals("BA", c.findNode(new TestPoint2D(1, -1)).getAttribute());
+
+        PartitionTestUtils.assertTreeStructure(a);
+        PartitionTestUtils.assertTreeStructure(b);
+        PartitionTestUtils.assertTreeStructure(c);
+    }
+
+    @Test
+    public void testMerge_multiNodeTreeWithSingleNodeTree() {
+        // arrange
+        AttributeBSPTree<TestPoint2D, String> a = new AttributeBSPTree<>();
+        a.getRoot().setAttribute("A");
+
+        AttributeBSPTree<TestPoint2D, String> b = new AttributeBSPTree<>();
+        b.getRoot().cut(TestLine.X_AXIS)
+            .getPlus().attr("B")
+            .getParent()
+            .getMinus().attr("b");
+
+        AttributeBSPTree<TestPoint2D, String> c = new AttributeBSPTree<>();
+
+        TestMergeOperator mergeOp = new TestMergeOperator();
+
+        // act
+        mergeOp.apply(a, b, c);
+
+        // assert
+        Assert.assertEquals(1, a.count());
+        Assert.assertEquals(3, b.count());
+        Assert.assertEquals(3, c.count());
+
+        Assert.assertEquals("A", a.findNode(new TestPoint2D(0, 1)).getAttribute());
+        Assert.assertEquals("A", a.findNode(new TestPoint2D(0, -1)).getAttribute());
+
+        Assert.assertEquals("b", b.findNode(new TestPoint2D(0, 1)).getAttribute());
+        Assert.assertEquals("B", b.findNode(new TestPoint2D(0, -1)).getAttribute());
+
+        Assert.assertEquals("Ab", c.findNode(new TestPoint2D(1, 1)).getAttribute());
+        Assert.assertEquals("Ab", c.findNode(new TestPoint2D(-1, 1)).getAttribute());
+        Assert.assertEquals("AB", c.findNode(new TestPoint2D(-1, -1)).getAttribute());
+        Assert.assertEquals("AB", c.findNode(new TestPoint2D(1, -1)).getAttribute());
+
+        PartitionTestUtils.assertTreeStructure(a);
+        PartitionTestUtils.assertTreeStructure(b);
+        PartitionTestUtils.assertTreeStructure(c);
+    }
+
+    @Test
+    public void testMerge_cutsIntersect() {
+        // arrange
+        AttributeBSPTree<TestPoint2D, String> a = new AttributeBSPTree<>();
+        a.getRoot().cut(TestLine.X_AXIS)
+            .getPlus().attr("A")
+            .getParent()
+            .getMinus().attr("a");
+
+        AttributeBSPTree<TestPoint2D, String> b = new AttributeBSPTree<>();
+        b.getRoot().cut(TestLine.Y_AXIS)
+            .getPlus().attr("B")
+            .getParent()
+            .getMinus().attr("b");
+
+        AttributeBSPTree<TestPoint2D, String> c = new AttributeBSPTree<>();
+
+        TestMergeOperator mergeOp = new TestMergeOperator();
+
+        // act
+        mergeOp.apply(a, b, c);
+
+        // assert
+        Assert.assertEquals(3, a.count());
+        Assert.assertEquals(3, b.count());
+        Assert.assertEquals(7, c.count());
+
+        Assert.assertEquals("a", a.findNode(new TestPoint2D(0, 1)).getAttribute());
+        Assert.assertEquals("A", a.findNode(new TestPoint2D(0, -1)).getAttribute());
+
+        Assert.assertEquals("B", b.findNode(new TestPoint2D(1, 0)).getAttribute());
+        Assert.assertEquals("b", b.findNode(new TestPoint2D(-1, 0)).getAttribute());
+
+        Assert.assertEquals("aB", c.findNode(new TestPoint2D(1, 1)).getAttribute());
+        Assert.assertEquals("ab", c.findNode(new TestPoint2D(-1, 1)).getAttribute());
+        Assert.assertEquals("Ab", c.findNode(new TestPoint2D(-1, -1)).getAttribute());
+        Assert.assertEquals("AB", c.findNode(new TestPoint2D(1, -1)).getAttribute());
+
+        PartitionTestUtils.assertTreeStructure(a);
+        PartitionTestUtils.assertTreeStructure(b);
+        PartitionTestUtils.assertTreeStructure(c);
+    }
+
+    @Test
+    public void testMerge_cutsParallel() {
+        // arrange
+        AttributeBSPTree<TestPoint2D, String> a = new AttributeBSPTree<>();
+        a.getRoot().cut(TestLine.X_AXIS)
+            .getPlus().attr("A")
+            .getParent()
+            .getMinus().attr("a");
+
+        AttributeBSPTree<TestPoint2D, String> b = new AttributeBSPTree<>();
+        b.getRoot().cut(TestLine.X_AXIS)
+            .getPlus().attr("B")
+            .getParent()
+            .getMinus().attr("b");
+
+        AttributeBSPTree<TestPoint2D, String> c = new AttributeBSPTree<>();
+
+        TestMergeOperator mergeOp = new TestMergeOperator();
+
+        // act
+        mergeOp.apply(a, b, c);
+
+        // assert
+        Assert.assertEquals(3, a.count());
+        Assert.assertEquals(3, b.count());
+        Assert.assertEquals(3, c.count());
+
+        Assert.assertEquals("a", a.findNode(new TestPoint2D(0, 1)).getAttribute());
+        Assert.assertEquals("A", a.findNode(new TestPoint2D(0, -1)).getAttribute());
+
+        Assert.assertEquals("b", b.findNode(new TestPoint2D(0, 1)).getAttribute());
+        Assert.assertEquals("B", b.findNode(new TestPoint2D(0, -1)).getAttribute());
+
+        Assert.assertEquals("ab", c.findNode(new TestPoint2D(1, 1)).getAttribute());
+        Assert.assertEquals("ab", c.findNode(new TestPoint2D(-1, 1)).getAttribute());
+        Assert.assertEquals("AB", c.findNode(new TestPoint2D(-1, -1)).getAttribute());
+        Assert.assertEquals("AB", c.findNode(new TestPoint2D(1, -1)).getAttribute());
+
+        PartitionTestUtils.assertTreeStructure(a);
+        PartitionTestUtils.assertTreeStructure(b);
+        PartitionTestUtils.assertTreeStructure(c);
+    }
+
+    @Test
+    public void testMerge_cutsAntiParallel() {
+        // arrange
+        AttributeBSPTree<TestPoint2D, String> a = new AttributeBSPTree<>();
+        a.getRoot().cut(TestLine.X_AXIS)
+            .getPlus().attr("A")
+            .getParent()
+            .getMinus().attr("a");
+
+        AttributeBSPTree<TestPoint2D, String> b = new AttributeBSPTree<>();
+        b.getRoot().cut(new TestLine(new TestPoint2D(1, 0), TestPoint2D.ZERO))
+            .getPlus().attr("B")
+            .getParent()
+            .getMinus().attr("b");
+
+        AttributeBSPTree<TestPoint2D, String> c = new AttributeBSPTree<>();
+
+        TestMergeOperator mergeOp = new TestMergeOperator();
+
+        // act
+        mergeOp.apply(a, b, c);
+
+        // assert
+        Assert.assertEquals(3, a.count());
+        Assert.assertEquals(3, b.count());
+        Assert.assertEquals(3, c.count());
+
+        Assert.assertEquals("a", a.findNode(new TestPoint2D(0, 1)).getAttribute());
+        Assert.assertEquals("A", a.findNode(new TestPoint2D(0, -1)).getAttribute());
+
+        Assert.assertEquals("B", b.findNode(new TestPoint2D(0, 1)).getAttribute());
+        Assert.assertEquals("b", b.findNode(new TestPoint2D(0, -1)).getAttribute());
+
+        Assert.assertEquals("aB", c.findNode(new TestPoint2D(1, 1)).getAttribute());
+        Assert.assertEquals("aB", c.findNode(new TestPoint2D(-1, 1)).getAttribute());
+        Assert.assertEquals("Ab", c.findNode(new TestPoint2D(-1, -1)).getAttribute());
+        Assert.assertEquals("Ab", c.findNode(new TestPoint2D(1, -1)).getAttribute());
+
+        PartitionTestUtils.assertTreeStructure(a);
+        PartitionTestUtils.assertTreeStructure(b);
+        PartitionTestUtils.assertTreeStructure(c);
+    }
+
+    @Test
+    public void testMerge_cutOnPlusSide_parallel() {
+        // arrange
+        AttributeBSPTree<TestPoint2D, String> a = new AttributeBSPTree<>();
+        a.getRoot().cut(TestLine.X_AXIS)
+            .getPlus().attr("A")
+            .getParent()
+            .getMinus().attr("a");
+
+        AttributeBSPTree<TestPoint2D, String> b = new AttributeBSPTree<>();
+        b.getRoot().cut(new TestLine(new TestPoint2D(0, -2), new TestPoint2D(1, -2)))
+            .getPlus().attr("B")
+            .getParent()
+            .getMinus().attr("b");
+
+        AttributeBSPTree<TestPoint2D, String> c = new AttributeBSPTree<>();
+
+        TestMergeOperator mergeOp = new TestMergeOperator();
+
+        // act
+        mergeOp.apply(a, b, c);
+
+        // assert
+        Assert.assertEquals(3, a.count());
+        Assert.assertEquals(3, b.count());
+        Assert.assertEquals(5, c.count());
+
+        Assert.assertEquals("a", a.findNode(new TestPoint2D(0, 1)).getAttribute());
+        Assert.assertEquals("A", a.findNode(new TestPoint2D(0, -1)).getAttribute());
+
+        Assert.assertEquals("b", b.findNode(new TestPoint2D(0, -1)).getAttribute());
+        Assert.assertEquals("B", b.findNode(new TestPoint2D(0, -3)).getAttribute());
+
+        Assert.assertEquals("ab", c.findNode(new TestPoint2D(1, 1)).getAttribute());
+        Assert.assertEquals("ab", c.findNode(new TestPoint2D(-1, 1)).getAttribute());
+        Assert.assertEquals("Ab", c.findNode(new TestPoint2D(-1, -1)).getAttribute());
+        Assert.assertEquals("Ab", c.findNode(new TestPoint2D(1, -1)).getAttribute());
+
+        Assert.assertEquals("AB", c.findNode(new TestPoint2D(-1, -3)).getAttribute());
+        Assert.assertEquals("AB", c.findNode(new TestPoint2D(1, -3)).getAttribute());
+
+        PartitionTestUtils.assertTreeStructure(a);
+        PartitionTestUtils.assertTreeStructure(b);
+        PartitionTestUtils.assertTreeStructure(c);
+    }
+
+    @Test
+    public void testMerge_cutOnPlusSide_antiParallel() {
+        // arrange
+        AttributeBSPTree<TestPoint2D, String> a = new AttributeBSPTree<>();
+        a.getRoot().cut(TestLine.X_AXIS)
+            .getPlus().attr("A")
+            .getParent()
+            .getMinus().attr("a");
+
+        AttributeBSPTree<TestPoint2D, String> b = new AttributeBSPTree<>();
+        b.getRoot().cut(new TestLine(new TestPoint2D(1, -2), new TestPoint2D(0, -2)))
+            .getPlus().attr("B")
+            .getParent()
+            .getMinus().attr("b");
+
+        AttributeBSPTree<TestPoint2D, String> c = new AttributeBSPTree<>();
+
+        TestMergeOperator mergeOp = new TestMergeOperator();
+
+        // act
+        mergeOp.apply(a, b, c);
+
+        // assert
+        Assert.assertEquals(3, a.count());
+        Assert.assertEquals(3, b.count());
+        Assert.assertEquals(5, c.count());
+
+        Assert.assertEquals("a", a.findNode(new TestPoint2D(0, 1)).getAttribute());
+        Assert.assertEquals("A", a.findNode(new TestPoint2D(0, -1)).getAttribute());
+
+        Assert.assertEquals("B", b.findNode(new TestPoint2D(0, -1)).getAttribute());
+        Assert.assertEquals("b", b.findNode(new TestPoint2D(0, -3)).getAttribute());
+
+        Assert.assertEquals("aB", c.findNode(new TestPoint2D(1, 1)).getAttribute());
+        Assert.assertEquals("aB", c.findNode(new TestPoint2D(-1, 1)).getAttribute());
+        Assert.assertEquals("AB", c.findNode(new TestPoint2D(-1, -1)).getAttribute());
+        Assert.assertEquals("AB", c.findNode(new TestPoint2D(1, -1)).getAttribute());
+
+        Assert.assertEquals("Ab", c.findNode(new TestPoint2D(-1, -3)).getAttribute());
+        Assert.assertEquals("Ab", c.findNode(new TestPoint2D(1, -3)).getAttribute());
+
+        PartitionTestUtils.assertTreeStructure(a);
+        PartitionTestUtils.assertTreeStructure(b);
+        PartitionTestUtils.assertTreeStructure(c);
+    }
+
+    @Test
+    public void testMerge_cutOnMinusSide_parallel() {
+        // arrange
+        AttributeBSPTree<TestPoint2D, String> a = new AttributeBSPTree<>();
+        a.getRoot().cut(TestLine.X_AXIS)
+            .getPlus().attr("A")
+            .getParent()
+            .getMinus().attr("a");
+
+        AttributeBSPTree<TestPoint2D, String> b = new AttributeBSPTree<>();
+        b.getRoot().cut(new TestLine(new TestPoint2D(0, 2), new TestPoint2D(1, 2)))
+            .getPlus().attr("B")
+            .getParent()
+            .getMinus().attr("b");
+
+        AttributeBSPTree<TestPoint2D, String> c = new AttributeBSPTree<>();
+
+        TestMergeOperator mergeOp = new TestMergeOperator();
+
+        // act
+        mergeOp.apply(a, b, c);
+
+        // assert
+        Assert.assertEquals(3, a.count());
+        Assert.assertEquals(3, b.count());
+        Assert.assertEquals(5, c.count());
+
+        Assert.assertEquals("a", a.findNode(new TestPoint2D(0, 1)).getAttribute());
+        Assert.assertEquals("A", a.findNode(new TestPoint2D(0, -1)).getAttribute());
+
+        Assert.assertEquals("B", b.findNode(new TestPoint2D(0, 1)).getAttribute());
+        Assert.assertEquals("b", b.findNode(new TestPoint2D(0, 3)).getAttribute());
+
+        Assert.assertEquals("aB", c.findNode(new TestPoint2D(1, 1)).getAttribute());
+        Assert.assertEquals("aB", c.findNode(new TestPoint2D(-1, 1)).getAttribute());
+        Assert.assertEquals("AB", c.findNode(new TestPoint2D(-1, -1)).getAttribute());
+        Assert.assertEquals("AB", c.findNode(new TestPoint2D(1, -1)).getAttribute());
+
+        Assert.assertEquals("ab", c.findNode(new TestPoint2D(-1, 3)).getAttribute());
+        Assert.assertEquals("ab", c.findNode(new TestPoint2D(1, 3)).getAttribute());
+
+        PartitionTestUtils.assertTreeStructure(a);
+        PartitionTestUtils.assertTreeStructure(b);
+        PartitionTestUtils.assertTreeStructure(c);
+    }
+
+    @Test
+    public void testMerge_cutOnMinusSide_antiParallel() {
+        // arrange
+        AttributeBSPTree<TestPoint2D, String> a = new AttributeBSPTree<>();
+        a.getRoot().cut(TestLine.X_AXIS)
+            .getPlus().attr("A")
+            .getParent()
+            .getMinus().attr("a");
+
+        AttributeBSPTree<TestPoint2D, String> b = new AttributeBSPTree<>();
+        b.getRoot().cut(new TestLine(new TestPoint2D(1, 2), new TestPoint2D(0, 2)))
+            .getPlus().attr("B")
+            .getParent()
+            .getMinus().attr("b");
+
+        AttributeBSPTree<TestPoint2D, String> c = new AttributeBSPTree<>();
+
+        TestMergeOperator mergeOp = new TestMergeOperator();
+
+        // act
+        mergeOp.apply(a, b, c);
+
+        // assert
+        Assert.assertEquals(3, a.count());
+        Assert.assertEquals(3, b.count());
+        Assert.assertEquals(5, c.count());
+
+        Assert.assertEquals("a", a.findNode(new TestPoint2D(0, 1)).getAttribute());
+        Assert.assertEquals("A", a.findNode(new TestPoint2D(0, -1)).getAttribute());
+
+        Assert.assertEquals("b", b.findNode(new TestPoint2D(0, 1)).getAttribute());
+        Assert.assertEquals("B", b.findNode(new TestPoint2D(0, 3)).getAttribute());
+
+        Assert.assertEquals("ab", c.findNode(new TestPoint2D(1, 1)).getAttribute());
+        Assert.assertEquals("ab", c.findNode(new TestPoint2D(-1, 1)).getAttribute());
+        Assert.assertEquals("Ab", c.findNode(new TestPoint2D(-1, -1)).getAttribute());
+        Assert.assertEquals("Ab", c.findNode(new TestPoint2D(1, -1)).getAttribute());
+
+        Assert.assertEquals("aB", c.findNode(new TestPoint2D(-1, 3)).getAttribute());
+        Assert.assertEquals("aB", c.findNode(new TestPoint2D(1, 3)).getAttribute());
+
+        PartitionTestUtils.assertTreeStructure(a);
+        PartitionTestUtils.assertTreeStructure(b);
+        PartitionTestUtils.assertTreeStructure(c);
+    }
+
+    @Test
+    public void testMerge_outputIsFirstInput() {
+        // arrange
+        AttributeBSPTree<TestPoint2D, String> a = new AttributeBSPTree<>();
+        a.getRoot().cut(TestLine.X_AXIS)
+            .getPlus().attr("A")
+            .getParent()
+            .getMinus().attr("a");
+
+        AttributeBSPTree<TestPoint2D, String> b = new AttributeBSPTree<>();
+        b.getRoot().cut(TestLine.Y_AXIS)
+            .getPlus().attr("B")
+            .getParent()
+            .getMinus().attr("b");
+
+        TestMergeOperator mergeOp = new TestMergeOperator();
+
+        // act
+        mergeOp.apply(a, b, a);
+
+        // assert
+        Assert.assertEquals(7, a.count());
+        Assert.assertEquals(3, b.count());
+
+        Assert.assertEquals("B", b.findNode(new TestPoint2D(1, 0)).getAttribute());
+        Assert.assertEquals("b", b.findNode(new TestPoint2D(-1, 0)).getAttribute());
+
+        Assert.assertEquals("aB", a.findNode(new TestPoint2D(1, 1)).getAttribute());
+        Assert.assertEquals("ab", a.findNode(new TestPoint2D(-1, 1)).getAttribute());
+        Assert.assertEquals("Ab", a.findNode(new TestPoint2D(-1, -1)).getAttribute());
+        Assert.assertEquals("AB", a.findNode(new TestPoint2D(1, -1)).getAttribute());
+
+        PartitionTestUtils.assertTreeStructure(a);
+        PartitionTestUtils.assertTreeStructure(b);
+    }
+
+    @Test
+    public void testMerge_outputIsSecondInput() {
+        // arrange
+        AttributeBSPTree<TestPoint2D, String> a = new AttributeBSPTree<>();
+        a.getRoot().cut(TestLine.X_AXIS)
+            .getPlus().attr("A")
+            .getParent()
+            .getMinus().attr("a");
+
+        AttributeBSPTree<TestPoint2D, String> b = new AttributeBSPTree<>();
+        b.getRoot().cut(TestLine.Y_AXIS)
+            .getPlus().attr("B")
+            .getParent()
+            .getMinus().attr("b");
+
+        TestMergeOperator mergeOp = new TestMergeOperator();
+
+        // act
+        mergeOp.apply(a, b, b);
+
+        // assert
+        Assert.assertEquals(3, a.count());
+        Assert.assertEquals(7, b.count());
+
+        Assert.assertEquals("a", a.findNode(new TestPoint2D(0, 1)).getAttribute());
+        Assert.assertEquals("A", a.findNode(new TestPoint2D(0, -1)).getAttribute());
+
+        Assert.assertEquals("aB", b.findNode(new TestPoint2D(1, 1)).getAttribute());
+        Assert.assertEquals("ab", b.findNode(new TestPoint2D(-1, 1)).getAttribute());
+        Assert.assertEquals("Ab", b.findNode(new TestPoint2D(-1, -1)).getAttribute());
+        Assert.assertEquals("AB", b.findNode(new TestPoint2D(1, -1)).getAttribute());
+
+        PartitionTestUtils.assertTreeStructure(a);
+        PartitionTestUtils.assertTreeStructure(b);
+    }
+
+    private static class TestMergeOperator extends AbstractBSPTreeMergeOperator<TestPoint2D, AttributeNode<TestPoint2D, String>> {
+
+        /** Perform the test merge operation with the given arguments.
+         * @param input1
+         * @param input2
+         * @param output
+         */
+        public void apply(AttributeBSPTree<TestPoint2D, String> input1, AttributeBSPTree<TestPoint2D, String> input2,
+                AttributeBSPTree<TestPoint2D, String> output) {
+            performMerge(input1, input2, output);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected AttributeNode<TestPoint2D, String> mergeLeaf(AttributeNode<TestPoint2D, String> node1,
+                AttributeNode<TestPoint2D, String> node2) {
+
+            final AttributeNode<TestPoint2D, String> leaf = node1.isLeaf() ? node1 : node2;
+            final AttributeNode<TestPoint2D, String> subtree = node1.isInternal() ? node1 : node2;
+
+            String attr = leaf.getAttribute();
+
+            AttributeNode<TestPoint2D, String> output = outputSubtree(subtree);
+            output.stream().filter(BSPTree.Node::isLeaf).forEach(n -> n.setAttribute(attr + n.getAttribute()));
+
+            return output;
+        }
+    }
+}
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/bsp/AbstractBSPTreeTest.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/bsp/AbstractBSPTreeTest.java
new file mode 100644
index 0000000..46a88da
--- /dev/null
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/bsp/AbstractBSPTreeTest.java
@@ -0,0 +1,1806 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning.bsp;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.partition.test.PartitionTestUtils;
+import org.apache.commons.geometry.core.partition.test.TestBSPTree;
+import org.apache.commons.geometry.core.partition.test.TestBSPTree.TestNode;
+import org.apache.commons.geometry.core.partition.test.TestLine;
+import org.apache.commons.geometry.core.partition.test.TestLineSegment;
+import org.apache.commons.geometry.core.partition.test.TestLineSegmentCollection;
+import org.apache.commons.geometry.core.partition.test.TestPoint2D;
+import org.apache.commons.geometry.core.partition.test.TestTransform2D;
+import org.apache.commons.geometry.core.partitioning.bsp.BSPTree.NodeCutRule;
+import org.apache.commons.geometry.core.partitioning.bsp.BSPTreeVisitor.Order;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class AbstractBSPTreeTest {
+
+    @Test
+    public void testInitialization() {
+        // act
+        TestBSPTree tree = new TestBSPTree();
+
+        // assert
+        TestNode root = tree.getRoot();
+
+        Assert.assertNotNull(root);
+        Assert.assertNull(root.getParent());
+
+        PartitionTestUtils.assertIsLeafNode(root);
+        Assert.assertFalse(root.isPlus());
+        Assert.assertFalse(root.isMinus());
+
+        Assert.assertSame(tree, root.getTree());
+    }
+
+    @Test
+    public void testNodeStateGetters() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+
+        TestNode root = tree.getRoot();
+        root.cut(TestLine.X_AXIS);
+
+        TestNode plus = root.getPlus();
+        TestNode minus = root.getMinus();
+
+        // act/assert
+        Assert.assertFalse(root.isLeaf());
+        Assert.assertTrue(root.isInternal());
+        Assert.assertFalse(root.isPlus());
+        Assert.assertFalse(root.isMinus());
+
+        Assert.assertTrue(plus.isLeaf());
+        Assert.assertFalse(plus.isInternal());
+        Assert.assertTrue(plus.isPlus());
+        Assert.assertFalse(plus.isMinus());
+
+        Assert.assertTrue(minus.isLeaf());
+        Assert.assertFalse(minus.isInternal());
+        Assert.assertFalse(minus.isPlus());
+        Assert.assertTrue(minus.isMinus());
+    }
+
+    @Test
+    public void testInsertCut() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        TestLine line = TestLine.X_AXIS;
+
+        // act
+        boolean result = tree.getRoot().insertCut(line);
+
+        // assert
+        Assert.assertTrue(result);
+
+        TestNode root = tree.getRoot();
+        PartitionTestUtils.assertIsInternalNode(root);
+
+        Assert.assertSame(line, root.getCut().getHyperplane());
+
+        PartitionTestUtils.assertIsLeafNode(root.getMinus());
+        PartitionTestUtils.assertIsLeafNode(root.getPlus());
+    }
+
+    @Test
+    public void testInsertCut_fitsCutterToCell() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+
+        TestNode node = tree.getRoot()
+            .cut(TestLine.X_AXIS)
+            .getMinus()
+                .cut(TestLine.Y_AXIS)
+                .getPlus();
+
+        // act
+        boolean result = node.insertCut(new TestLine(0.5, 1.5, 1.5, 0.5));
+
+        // assert
+        Assert.assertTrue(result);
+        PartitionTestUtils.assertIsInternalNode(node);
+
+        TestLineSegment segment = (TestLineSegment) node.getCut();
+
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(0, 2), segment.getStartPoint());
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(2, 0), segment.getEndPoint());
+    }
+
+    @Test
+    public void testInsertCut_doesNotPassThroughCell_intersects() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+
+        TestNode node = tree.getRoot()
+            .cut(TestLine.X_AXIS)
+                .getMinus()
+                    .cut(TestLine.Y_AXIS)
+                    .getPlus();
+
+        // act
+        boolean result = node.insertCut(new TestLine(-2, 0, 0, -2));
+
+        // assert
+        Assert.assertFalse(result);
+        PartitionTestUtils.assertIsLeafNode(node);
+    }
+
+    @Test
+    public void testInsertCut_doesNotPassThroughCell_parallel() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+
+        TestNode node = tree.getRoot()
+            .cut(TestLine.X_AXIS)
+                .getMinus();
+
+        // act
+        boolean result = node.insertCut(new TestLine(0, -1, 1, -1));
+
+        // assert
+        Assert.assertFalse(result);
+        PartitionTestUtils.assertIsLeafNode(node);
+    }
+
+    @Test
+    public void testInsertCut_doesNotPassThroughCell_removesExistingChildren() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+
+        TestNode node = tree.getRoot()
+            .cut(TestLine.X_AXIS)
+                .getMinus()
+                    .cut(TestLine.Y_AXIS)
+                    .getPlus()
+                        .cut(new TestLine(0, 2, 2, 0));
+
+        // act
+        boolean result = node.insertCut(new TestLine(-2, 0, 0, -2));
+
+        // assert
+        Assert.assertFalse(result);
+        PartitionTestUtils.assertIsLeafNode(node);
+    }
+
+    @Test
+    public void testInsertCut_cutExistsInTree_sameOrientation() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+
+        TestNode node = tree.getRoot()
+                .cut(TestLine.X_AXIS)
+                    .getMinus()
+                        .cut(TestLine.Y_AXIS)
+                        .getPlus()
+                            .cut(new TestLine(0, 2, 2, 0));
+
+        // act
+        boolean result = node.insertCut(new TestLine(0, 2, 0, 3));
+
+        // assert
+        Assert.assertFalse(result);
+        PartitionTestUtils.assertIsLeafNode(node);
+    }
+
+    @Test
+    public void testInsertCut_cutExistsInTree_oppositeOrientation() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+
+        TestNode node = tree.getRoot()
+                .cut(TestLine.X_AXIS)
+                    .getMinus()
+                        .cut(TestLine.Y_AXIS)
+                        .getPlus()
+                            .cut(new TestLine(0, 2, 2, 0));
+
+        // act
+        boolean result = node.insertCut(new TestLine(0, 3, 0, 2));
+
+        // assert
+        Assert.assertTrue(result);
+        PartitionTestUtils.assertIsInternalNode(node);
+    }
+
+    @Test
+    public void testInsertCut_createRegionWithThicknessOfHyperplane() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+
+        TestNode node = tree.getRoot()
+                .cut(TestLine.X_AXIS)
+                    .getMinus();
+
+        // act
+        boolean result = node.insertCut(new TestLine(0, 0, -1, 0));
+
+        // assert
+        Assert.assertTrue(result);
+
+        Assert.assertSame(tree.getRoot().getPlus(), tree.findNode(new TestPoint2D(0, -1e-2)));
+        Assert.assertSame(node.getMinus(), tree.findNode(new TestPoint2D(0, 0)));
+        Assert.assertSame(node.getPlus(), tree.findNode(new TestPoint2D(0, 1e-2)));
+    }
+
+    @Test
+    public void testClearCut_cutExists() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+
+        TestNode node = tree.getRoot()
+            .cut(TestLine.X_AXIS)
+                .getMinus()
+                .cut(TestLine.Y_AXIS);
+
+        // act
+        boolean result = node.clearCut();
+
+        // assert
+        Assert.assertTrue(result);
+        Assert.assertTrue(node.isLeaf());
+        Assert.assertNull(node.getPlus());
+        Assert.assertNull(node.getMinus());
+    }
+
+    @Test
+    public void testClearCut_cutDoesNotExist() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+
+        TestNode node = tree.getRoot()
+            .cut(TestLine.X_AXIS)
+                .getMinus()
+                .cut(TestLine.Y_AXIS)
+                .getMinus();
+
+        // act
+        boolean result = node.clearCut();
+
+        // assert
+        Assert.assertFalse(result);
+        Assert.assertTrue(node.isLeaf());
+        Assert.assertNull(node.getPlus());
+        Assert.assertNull(node.getMinus());
+    }
+
+    @Test
+    public void testClearCut_root_fullTree() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+
+        TestNode node = tree.getRoot()
+            .cut(TestLine.X_AXIS)
+                .getMinus()
+                .cut(TestLine.Y_AXIS)
+                .getMinus();
+
+        // act
+        boolean result = tree.getRoot().clearCut();
+
+        // assert
+        Assert.assertTrue(result);
+        Assert.assertTrue(node.isLeaf());
+        Assert.assertNull(node.getPlus());
+        Assert.assertNull(node.getMinus());
+
+        Assert.assertEquals(1, tree.count());
+    }
+
+    @Test
+    public void testClearCut_root_emptyTree() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        TestNode node = tree.getRoot();
+
+        // act
+        boolean result = node.clearCut();
+
+        // assert
+        Assert.assertFalse(result);
+        Assert.assertTrue(node.isLeaf());
+        Assert.assertNull(node.getPlus());
+        Assert.assertNull(node.getMinus());
+
+        Assert.assertEquals(1, tree.count());
+    }
+
+    @Test
+    public void testFindNode_emptyTree() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        TestNode root = tree.getRoot();
+
+        List<TestPoint2D> testPoints = Arrays.asList(
+                    new TestPoint2D(0, 0),
+                    new TestPoint2D(1, 0),
+                    new TestPoint2D(1, 1),
+                    new TestPoint2D(0, 1),
+                    new TestPoint2D(-1, 1),
+                    new TestPoint2D(-1, 0),
+                    new TestPoint2D(-1, -1),
+                    new TestPoint2D(0, -1),
+                    new TestPoint2D(1, -1)
+                );
+
+        // act/assert
+        for (TestPoint2D pt : testPoints) {
+            Assert.assertSame(root, tree.findNode(pt));
+        }
+
+        for (TestPoint2D pt : testPoints) {
+            Assert.assertSame(root, tree.findNode(pt, NodeCutRule.NODE));
+        }
+
+        for (TestPoint2D pt : testPoints) {
+            Assert.assertSame(root, tree.findNode(pt, NodeCutRule.MINUS));
+        }
+
+        for (TestPoint2D pt : testPoints) {
+            Assert.assertSame(root, tree.findNode(pt, NodeCutRule.PLUS));
+        }
+    }
+
+    @Test
+    public void testFindNode_singleArg() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+
+        tree.getRoot()
+                .cut(TestLine.X_AXIS)
+                .getMinus()
+                    .cut(TestLine.Y_AXIS)
+                    .getPlus()
+                        .cut(new TestLine(0, 2, 2, 0));
+
+        TestNode root = tree.getRoot();
+        TestNode minusY = root.getPlus();
+
+        TestNode yCut = root.getMinus();
+        TestNode minusXPlusY = yCut.getMinus();
+
+        TestNode diagonalCut = yCut.getPlus();
+        TestNode underDiagonal = diagonalCut.getPlus();
+        TestNode aboveDiagonal = diagonalCut.getMinus();
+
+        // act/assert
+        Assert.assertSame(minusXPlusY, tree.findNode(new TestPoint2D(0, 0)));
+
+        Assert.assertSame(underDiagonal, tree.findNode(new TestPoint2D(1, 0)));
+        Assert.assertSame(aboveDiagonal, tree.findNode(new TestPoint2D(1, 1)));
+        Assert.assertSame(minusXPlusY, tree.findNode(new TestPoint2D(0, 1)));
+        Assert.assertSame(minusXPlusY, tree.findNode(new TestPoint2D(-1, 1)));
+        Assert.assertSame(minusXPlusY, tree.findNode(new TestPoint2D(-1, 0)));
+        Assert.assertSame(minusY, tree.findNode(new TestPoint2D(-1, -1)));
+        Assert.assertSame(minusY, tree.findNode(new TestPoint2D(0, -1)));
+        Assert.assertSame(minusY, tree.findNode(new TestPoint2D(1, -1)));
+
+        Assert.assertSame(underDiagonal, tree.findNode(new TestPoint2D(0.5, 0.5)));
+        Assert.assertSame(aboveDiagonal, tree.findNode(new TestPoint2D(3, 3)));
+    }
+
+    @Test
+    public void testFindNode_nodeCutBehavior() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+
+        tree.getRoot()
+                .cut(TestLine.X_AXIS)
+                .getMinus()
+                    .cut(TestLine.Y_AXIS)
+                    .getPlus()
+                        .cut(new TestLine(0, 2, 2, 0));
+
+        TestNode root = tree.getRoot();
+        TestNode minusY = root.getPlus();
+
+        TestNode yCut = root.getMinus();
+        TestNode minusXPlusY = yCut.getMinus();
+
+        TestNode diagonalCut = yCut.getPlus();
+        TestNode underDiagonal = diagonalCut.getPlus();
+        TestNode aboveDiagonal = diagonalCut.getMinus();
+
+        NodeCutRule cutBehavior = NodeCutRule.NODE;
+
+        // act/assert
+        Assert.assertSame(root, tree.findNode(new TestPoint2D(0, 0), cutBehavior));
+
+        Assert.assertSame(root, tree.findNode(new TestPoint2D(1, 0), cutBehavior));
+        Assert.assertSame(diagonalCut, tree.findNode(new TestPoint2D(1, 1), cutBehavior));
+        Assert.assertSame(yCut, tree.findNode(new TestPoint2D(0, 1), cutBehavior));
+        Assert.assertSame(minusXPlusY, tree.findNode(new TestPoint2D(-1, 1), cutBehavior));
+        Assert.assertSame(root, tree.findNode(new TestPoint2D(-1, 0), cutBehavior));
+        Assert.assertSame(minusY, tree.findNode(new TestPoint2D(-1, -1), cutBehavior));
+        Assert.assertSame(minusY, tree.findNode(new TestPoint2D(0, -1), cutBehavior));
+        Assert.assertSame(minusY, tree.findNode(new TestPoint2D(1, -1), cutBehavior));
+
+        Assert.assertSame(underDiagonal, tree.findNode(new TestPoint2D(0.5, 0.5), cutBehavior));
+        Assert.assertSame(aboveDiagonal, tree.findNode(new TestPoint2D(3, 3), cutBehavior));
+    }
+
+    @Test
+    public void testFindNode_minusCutBehavior() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+
+        tree.getRoot()
+                .cut(TestLine.X_AXIS)
+                .getMinus()
+                    .cut(TestLine.Y_AXIS)
+                    .getPlus()
+                        .cut(new TestLine(0, 2, 2, 0));
+
+        TestNode root = tree.getRoot();
+        TestNode minusY = root.getPlus();
+
+        TestNode yCut = root.getMinus();
+        TestNode minusXPlusY = yCut.getMinus();
+
+        TestNode diagonalCut = yCut.getPlus();
+        TestNode underDiagonal = diagonalCut.getPlus();
+        TestNode aboveDiagonal = diagonalCut.getMinus();
+
+        NodeCutRule cutBehavior = NodeCutRule.MINUS;
+
+        // act/assert
+        Assert.assertSame(minusXPlusY, tree.findNode(new TestPoint2D(0, 0), cutBehavior));
+
+        Assert.assertSame(underDiagonal, tree.findNode(new TestPoint2D(1, 0), cutBehavior));
+        Assert.assertSame(aboveDiagonal, tree.findNode(new TestPoint2D(1, 1), cutBehavior));
+        Assert.assertSame(minusXPlusY, tree.findNode(new TestPoint2D(0, 1), cutBehavior));
+        Assert.assertSame(minusXPlusY, tree.findNode(new TestPoint2D(-1, 1), cutBehavior));
+        Assert.assertSame(minusXPlusY, tree.findNode(new TestPoint2D(-1, 0), cutBehavior));
+        Assert.assertSame(minusY, tree.findNode(new TestPoint2D(-1, -1), cutBehavior));
+        Assert.assertSame(minusY, tree.findNode(new TestPoint2D(0, -1), cutBehavior));
+        Assert.assertSame(minusY, tree.findNode(new TestPoint2D(1, -1), cutBehavior));
+
+        Assert.assertSame(underDiagonal, tree.findNode(new TestPoint2D(0.5, 0.5), cutBehavior));
+        Assert.assertSame(aboveDiagonal, tree.findNode(new TestPoint2D(3, 3), cutBehavior));
+    }
+
+    @Test
+    public void testFindNode_plusCutBehavior() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+
+        tree.getRoot()
+                .cut(TestLine.X_AXIS)
+                .getMinus()
+                    .cut(TestLine.Y_AXIS)
+                    .getPlus()
+                        .cut(new TestLine(0, 2, 2, 0));
+
+        TestNode root = tree.getRoot();
+        TestNode minusY = root.getPlus();
+
+        TestNode yCut = root.getMinus();
+        TestNode minusXPlusY = yCut.getMinus();
+
+        TestNode diagonalCut = yCut.getPlus();
+        TestNode underDiagonal = diagonalCut.getPlus();
+        TestNode aboveDiagonal = diagonalCut.getMinus();
+
+        NodeCutRule cutBehavior = NodeCutRule.PLUS;
+
+        // act/assert
+        Assert.assertSame(minusY, tree.findNode(new TestPoint2D(0, 0), cutBehavior));
+
+        Assert.assertSame(minusY, tree.findNode(new TestPoint2D(1, 0), cutBehavior));
+        Assert.assertSame(underDiagonal, tree.findNode(new TestPoint2D(1, 1), cutBehavior));
+        Assert.assertSame(underDiagonal, tree.findNode(new TestPoint2D(0, 1), cutBehavior));
+        Assert.assertSame(minusXPlusY, tree.findNode(new TestPoint2D(-1, 1), cutBehavior));
+        Assert.assertSame(minusY, tree.findNode(new TestPoint2D(-1, 0), cutBehavior));
+        Assert.assertSame(minusY, tree.findNode(new TestPoint2D(-1, -1), cutBehavior));
+        Assert.assertSame(minusY, tree.findNode(new TestPoint2D(0, -1), cutBehavior));
+        Assert.assertSame(minusY, tree.findNode(new TestPoint2D(1, -1), cutBehavior));
+
+        Assert.assertSame(underDiagonal, tree.findNode(new TestPoint2D(0.5, 0.5), cutBehavior));
+        Assert.assertSame(aboveDiagonal, tree.findNode(new TestPoint2D(3, 3), cutBehavior));
+    }
+
+    @Test
+    public void testInsert_convex_emptyTree() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+
+        // act
+        tree.insert(new TestLineSegment(1, 0, 1, 1));
+
+        // assert
+        TestNode root = tree.getRoot();
+        Assert.assertFalse(root.isLeaf());
+        Assert.assertTrue(root.getMinus().isLeaf());
+        Assert.assertTrue(root.getPlus().isLeaf());
+
+        TestLineSegment seg = (TestLineSegment) root.getCut();
+        PartitionTestUtils.assertPointsEqual(
+                new TestPoint2D(1, Double.NEGATIVE_INFINITY),
+                seg.getStartPoint());
+        PartitionTestUtils.assertPointsEqual(
+                new TestPoint2D(1, Double.POSITIVE_INFINITY),
+                seg.getEndPoint());
+    }
+
+    @Test
+    public void testInsert_convex_noSplit() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        tree.getRoot()
+            .cut(TestLine.X_AXIS)
+            .getMinus()
+                .cut(TestLine.Y_AXIS);
+
+        // act
+        tree.insert(new TestLineSegment(0.5, 1.5, 1.5, 0.5));
+
+        // assert
+        TestNode root = tree.getRoot();
+        Assert.assertFalse(root.isLeaf());
+
+        TestNode node = tree.findNode(new TestPoint2D(0.5, 0.5));
+        TestLineSegment seg = (TestLineSegment) node.getParent().getCut();
+
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(0, 2), seg.getStartPoint());
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(2, 0), seg.getEndPoint());
+
+        Assert.assertTrue(tree.getRoot().getPlus().isLeaf());
+        Assert.assertTrue(tree.getRoot().getMinus().getMinus().isLeaf());
+    }
+
+    @Test
+    public void testInsert_convex_split() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        tree.getRoot()
+            .cut(TestLine.X_AXIS)
+            .getMinus()
+                .cut(TestLine.Y_AXIS);
+
+        // act
+        tree.insert(new TestLineSegment(-0.5, 2.5, 2.5, -0.5));
+
+        // assert
+        TestNode root = tree.getRoot();
+        Assert.assertFalse(root.isLeaf());
+
+        TestNode plusXPlusY = tree.getRoot().getMinus().getPlus();
+        TestLineSegment plusXPlusYSeg = (TestLineSegment) plusXPlusY.getCut();
+
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(0, 2), plusXPlusYSeg.getStartPoint());
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(2, 0), plusXPlusYSeg.getEndPoint());
+
+        TestNode minusY = tree.getRoot().getPlus();
+        TestLineSegment minusYSeg = (TestLineSegment) minusY.getCut();
+
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(2, 0), minusYSeg.getStartPoint());
+        PartitionTestUtils.assertPointsEqual(
+                new TestPoint2D(Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY), minusYSeg.getEndPoint());
+
+        TestNode minusXPlusY = tree.getRoot().getMinus().getMinus();
+        TestLineSegment minusXPlusYSeg = (TestLineSegment) minusXPlusY.getCut();
+
+        PartitionTestUtils.assertPointsEqual(
+                new TestPoint2D(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY), minusXPlusYSeg.getStartPoint());
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(0, 2), minusXPlusYSeg.getEndPoint());
+    }
+
+    @Test
+    public void testInsert_convexList_convexRegion() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+
+        TestLineSegment a = new TestLineSegment(0, 0, 1, 0);
+        TestLineSegment b = new TestLineSegment(1, 0, 0, 1);
+        TestLineSegment c = new TestLineSegment(0, 1, 0, 0);
+
+        // act
+        tree.insert(Arrays.asList(a, b, c));
+
+        // assert
+        List<TestLineSegment> segments = getLineSegments(tree);
+
+        Assert.assertEquals(3, segments.size());
+
+        PartitionTestUtils.assertSegmentsEqual(
+                new TestLineSegment(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, TestLine.X_AXIS),
+                segments.get(0));
+        PartitionTestUtils.assertSegmentsEqual(
+                new TestLineSegment(-Math.sqrt(0.5), Double.POSITIVE_INFINITY, new TestLine(1, 0, 0, 1)),
+                segments.get(1));
+        PartitionTestUtils.assertSegmentsEqual(c, segments.get(2));
+    }
+
+    @Test
+    public void testInsert_convexList_concaveRegion() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+
+        TestLineSegment a = new TestLineSegment(-1, -1, 1, -1);
+        TestLineSegment b = new TestLineSegment(1, -1, 0, 0);
+        TestLineSegment c = new TestLineSegment(0, 0, 1, 1);
+        TestLineSegment d = new TestLineSegment(1, 1, -1, 1);
+        TestLineSegment e = new TestLineSegment(-1, 1, -1, -1);
+
+        // act
+        tree.insert(Arrays.asList(a, b, c, d, e));
+
+        // assert
+        List<TestLineSegment> segments = getLineSegments(tree);
+
+        Assert.assertEquals(5, segments.size());
+
+        PartitionTestUtils.assertSegmentsEqual(
+                new TestLineSegment(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, new TestLine(-1, -1, 1, -1)),
+                segments.get(0));
+        PartitionTestUtils.assertSegmentsEqual(
+                new TestLineSegment(-Math.sqrt(2), Double.POSITIVE_INFINITY, new TestLine(1, -1, 0, 0)),
+                segments.get(1));
+        PartitionTestUtils.assertSegmentsEqual(
+                new TestLineSegment(-1, 1, -1, -1),
+                segments.get(2));
+
+        PartitionTestUtils.assertSegmentsEqual(
+                new TestLineSegment(0, Double.POSITIVE_INFINITY, new TestLine(0, 0, 1, 1)),
+                segments.get(3));
+        PartitionTestUtils.assertSegmentsEqual(
+                new TestLineSegment(1, 1, -1, 1),
+                segments.get(4));
+    }
+
+    @Test
+    public void testInsert_subhyperplane_concaveRegion() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+
+        TestLineSegment a = new TestLineSegment(-1, -1, 1, -1);
+        TestLineSegment b = new TestLineSegment(1, -1, 0, 0);
+        TestLineSegment c = new TestLineSegment(0, 0, 1, 1);
+        TestLineSegment d = new TestLineSegment(1, 1, -1, 1);
+        TestLineSegment e = new TestLineSegment(-1, 1, -1, -1);
+
+        TestLineSegmentCollection coll = new TestLineSegmentCollection(
+                Arrays.asList(a, b, c, d, e));
+
+        // act
+        tree.insert(coll);
+
+        // assert
+        List<TestLineSegment> segments = getLineSegments(tree);
+
+        Assert.assertEquals(5, segments.size());
+
+        PartitionTestUtils.assertSegmentsEqual(
+                new TestLineSegment(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, new TestLine(-1, -1, 1, -1)),
+                segments.get(0));
+        PartitionTestUtils.assertSegmentsEqual(
+                new TestLineSegment(-Math.sqrt(2), Double.POSITIVE_INFINITY, new TestLine(1, -1, 0, 0)),
+                segments.get(1));
+        PartitionTestUtils.assertSegmentsEqual(
+                new TestLineSegment(-1, 1, -1, -1),
+                segments.get(2));
+
+        PartitionTestUtils.assertSegmentsEqual(
+                new TestLineSegment(0, Double.POSITIVE_INFINITY, new TestLine(0, 0, 1, 1)),
+                segments.get(3));
+        PartitionTestUtils.assertSegmentsEqual(
+                new TestLineSegment(1, 1, -1, 1),
+                segments.get(4));
+    }
+
+    @Test
+    public void testCount() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+
+        // act/assert
+        Assert.assertEquals(1, tree.count());
+        Assert.assertEquals(1, tree.getRoot().count());
+
+        tree.getRoot().insertCut(TestLine.X_AXIS);
+        Assert.assertEquals(1, tree.getRoot().getMinus().count());
+        Assert.assertEquals(1, tree.getRoot().getPlus().count());
+        Assert.assertEquals(3, tree.count());
+
+        tree.getRoot().getPlus().insertCut(TestLine.Y_AXIS);
+        Assert.assertEquals(1, tree.getRoot().getMinus().count());
+        Assert.assertEquals(3, tree.getRoot().getPlus().count());
+        Assert.assertEquals(5, tree.count());
+
+        tree.getRoot().getMinus().insertCut(TestLine.Y_AXIS);
+        Assert.assertEquals(3, tree.getRoot().getMinus().count());
+        Assert.assertEquals(3, tree.getRoot().getPlus().count());
+        Assert.assertEquals(7, tree.count());
+
+        tree.getRoot().getMinus().insertCut(new TestLine(new TestPoint2D(-1, -1), new TestPoint2D(1, -1)));
+        Assert.assertEquals(1, tree.getRoot().getMinus().count());
+        Assert.assertEquals(3, tree.getRoot().getPlus().count());
+        Assert.assertEquals(5, tree.count());
+    }
+
+    @Test
+    public void testHeight() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+
+        // act/assert
+        Assert.assertEquals(0, tree.height());
+        Assert.assertEquals(0, tree.getRoot().height());
+
+        tree.getRoot().insertCut(TestLine.X_AXIS);
+        Assert.assertEquals(0, tree.getRoot().getMinus().height());
+        Assert.assertEquals(0, tree.getRoot().getPlus().height());
+        Assert.assertEquals(1, tree.height());
+
+        tree.getRoot().getPlus().insertCut(TestLine.Y_AXIS);
+        Assert.assertEquals(0, tree.getRoot().getMinus().height());
+        Assert.assertEquals(1, tree.getRoot().getPlus().height());
+        Assert.assertEquals(2, tree.height());
+
+        tree.getRoot().getMinus().insertCut(TestLine.Y_AXIS);
+        Assert.assertEquals(1, tree.getRoot().getMinus().height());
+        Assert.assertEquals(1, tree.getRoot().getPlus().height());
+        Assert.assertEquals(2, tree.height());
+
+        tree.getRoot().getMinus().clearCut();
+        Assert.assertEquals(0, tree.getRoot().getMinus().height());
+        Assert.assertEquals(1, tree.getRoot().getPlus().height());
+        Assert.assertEquals(2, tree.height());
+
+        tree.getRoot().getPlus().getPlus()
+            .insertCut(new TestLine(new TestPoint2D(0, -1), new TestPoint2D(1, -1)));
+
+        Assert.assertEquals(0, tree.getRoot().getMinus().height());
+        Assert.assertEquals(2, tree.getRoot().getPlus().height());
+        Assert.assertEquals(3, tree.height());
+    }
+
+    @Test
+    public void testDepth() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+
+        TestNode root = tree.getRoot();
+        root.cut(TestLine.X_AXIS)
+                .getMinus()
+                    .cut(TestLine.Y_AXIS);
+
+        // act/assert
+        Assert.assertEquals(0, root.depth());
+
+        Assert.assertEquals(1, root.getPlus().depth());
+
+        Assert.assertEquals(1, root.getMinus().depth());
+        Assert.assertEquals(2, root.getMinus().getPlus().depth());
+        Assert.assertEquals(2, root.getMinus().getMinus().depth());
+    }
+
+    @Test
+    public void testVisit_defaultOrder() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        tree.getRoot().cut(TestLine.X_AXIS)
+            .getMinus().cut(TestLine.Y_AXIS);
+
+        TestNode root = tree.getRoot();
+        TestNode minus = root.getMinus();
+        TestNode plus = root.getPlus();
+        TestNode minusMinus = minus.getMinus();
+        TestNode minusPlus = minus.getPlus();
+
+        List<TestNode> nodes = new ArrayList<>();
+
+        // act
+        tree.accept(node -> nodes.add(node));
+
+        // assert
+        Assert.assertEquals(
+                Arrays.asList(root, minus, minusMinus, minusPlus, plus),
+                nodes);
+    }
+
+    @Test
+    public void testVisit_specifiedOrder() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        tree.getRoot().cut(TestLine.X_AXIS)
+            .getMinus().cut(TestLine.Y_AXIS);
+
+        TestNode root = tree.getRoot();
+        TestNode minus = root.getMinus();
+        TestNode plus = root.getPlus();
+        TestNode minusMinus = minus.getMinus();
+        TestNode minusPlus = minus.getPlus();
+
+        // act/assert
+        TestVisitor plusMinusNode = new TestVisitor(Order.PLUS_MINUS_NODE);
+        tree.accept(plusMinusNode);
+        Assert.assertEquals(
+                Arrays.asList(plus, minusPlus, minusMinus, minus, root),
+                plusMinusNode.getVisited());
+
+        TestVisitor plusNodeMinus = new TestVisitor(Order.PLUS_NODE_MINUS);
+        tree.accept(plusNodeMinus);
+        Assert.assertEquals(
+                Arrays.asList(plus, root, minusPlus, minus, minusMinus),
+                plusNodeMinus.getVisited());
+
+        TestVisitor minusPlusNode = new TestVisitor(Order.MINUS_PLUS_NODE);
+        tree.accept(minusPlusNode);
+        Assert.assertEquals(
+                Arrays.asList(minusMinus, minusPlus, minus, plus, root),
+                minusPlusNode.getVisited());
+
+        TestVisitor minusNodePlus = new TestVisitor(Order.MINUS_NODE_PLUS);
+        tree.accept(minusNodePlus);
+        Assert.assertEquals(
+                Arrays.asList(minusMinus, minus, minusPlus, root, plus),
+                minusNodePlus.getVisited());
+
+        TestVisitor nodeMinusPlus = new TestVisitor(Order.NODE_MINUS_PLUS);
+        tree.accept(nodeMinusPlus);
+        Assert.assertEquals(
+                Arrays.asList(root, minus, minusMinus, minusPlus, plus),
+                nodeMinusPlus.getVisited());
+
+        TestVisitor nodePlusMinus = new TestVisitor(Order.NODE_PLUS_MINUS);
+        tree.accept(nodePlusMinus);
+        Assert.assertEquals(
+                Arrays.asList(root, plus, minus, minusPlus, minusMinus),
+                nodePlusMinus.getVisited());
+    }
+
+    @Test
+    public void testVisit_nullVisitOrderSkipsSubtree() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        tree.getRoot().cut(TestLine.X_AXIS)
+            .getMinus().cut(TestLine.Y_AXIS);
+
+        TestNode root = tree.getRoot();
+        TestNode minus = root.getMinus();
+        TestNode minusMinus = minus.getMinus();
+        TestNode minusPlus = minus.getPlus();
+
+        TestVisitor visitor = new TestVisitor(Order.NODE_MINUS_PLUS);
+
+        // act
+        minus.accept(visitor);
+
+        // assert
+        Assert.assertEquals(
+                Arrays.asList(minus, minusMinus, minusPlus),
+                visitor.getVisited());
+    }
+
+    @Test
+    public void testVisit_visitNode() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        tree.getRoot().cut(TestLine.X_AXIS)
+            .getMinus().cut(TestLine.Y_AXIS);
+
+        TestNode root = tree.getRoot();
+        TestNode minus = root.getMinus();
+        TestNode plus = root.getPlus();
+        TestNode minusMinus = minus.getMinus();
+        TestNode minusPlus = minus.getPlus();
+
+        List<TestNode> nodes = new ArrayList<>();
+
+        // act
+        tree.accept(node -> nodes.add(node));
+
+        // assert
+        Assert.assertEquals(
+                Arrays.asList(root, minus, minusMinus, minusPlus, plus),
+                nodes);
+    }
+
+    @Test
+    public void testIterable_emptyTree() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        List<TestNode> nodes = new ArrayList<>();
+
+        // act
+        for (TestNode node : tree)
+        {
+            nodes.add(node);
+        }
+
+        // assert
+        Assert.assertEquals(1, nodes.size());
+        Assert.assertSame(tree.getRoot(), nodes.get(0));
+    }
+
+    @Test
+    public void testIterable_multipleNodes() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+
+        TestNode root = tree.getRoot();
+        root.cut(TestLine.X_AXIS)
+                .getMinus()
+                    .cut(TestLine.Y_AXIS)
+                 .getParent()
+                 .getPlus()
+                     .cut(TestLine.Y_AXIS);
+
+        List<TestNode> nodes = new ArrayList<>();
+
+        // act
+        for (TestNode node : tree)
+        {
+            nodes.add(node);
+        }
+
+        // assert
+        Assert.assertEquals(7, nodes.size());
+        Assert.assertSame(root, nodes.get(0));
+
+        Assert.assertSame(root.getMinus(), nodes.get(1));
+        Assert.assertSame(root.getMinus().getMinus(), nodes.get(2));
+        Assert.assertSame(root.getMinus().getPlus(), nodes.get(3));
+
+        Assert.assertSame(root.getPlus(), nodes.get(4));
+        Assert.assertSame(root.getPlus().getMinus(), nodes.get(5));
+        Assert.assertSame(root.getPlus().getPlus(), nodes.get(6));
+    }
+
+    @Test
+    public void testStream_emptyTree() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+
+        // act
+        List<TestNode> nodes = tree.stream().collect(Collectors.toList());
+
+        // assert
+        Assert.assertEquals(1, nodes.size());
+        Assert.assertSame(tree.getRoot(), nodes.get(0));
+    }
+
+    @Test
+    public void testStream_multipleNodes() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+
+        TestNode root = tree.getRoot();
+        root.cut(TestLine.X_AXIS)
+                .getMinus()
+                    .cut(TestLine.Y_AXIS)
+                 .getParent()
+                 .getPlus()
+                     .cut(TestLine.Y_AXIS);
+
+        // act
+        List<TestNode> nodes = tree.stream().collect(Collectors.toList());
+
+        // assert
+        Assert.assertEquals(7, nodes.size());
+        Assert.assertSame(root, nodes.get(0));
+
+        Assert.assertSame(root.getMinus(), nodes.get(1));
+        Assert.assertSame(root.getMinus().getMinus(), nodes.get(2));
+        Assert.assertSame(root.getMinus().getPlus(), nodes.get(3));
+
+        Assert.assertSame(root.getPlus(), nodes.get(4));
+        Assert.assertSame(root.getPlus().getMinus(), nodes.get(5));
+        Assert.assertSame(root.getPlus().getPlus(), nodes.get(6));
+    }
+
+    @Test
+    public void testNodeIterable_singleNodeSubtree() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        TestNode node = tree.getRoot().cut(TestLine.X_AXIS)
+            .getMinus()
+                .cut(TestLine.Y_AXIS)
+                .getMinus();
+
+        List<TestNode> nodes = new ArrayList<>();
+        // act
+        for (TestNode n : node)
+        {
+            nodes.add(n);
+        }
+
+        // assert
+        Assert.assertEquals(1, nodes.size());
+        Assert.assertSame(node, nodes.get(0));
+    }
+
+    @Test
+    public void testNodeIterable_multipleNodeSubtree() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        TestNode node = tree.getRoot().cut(TestLine.X_AXIS)
+            .getMinus()
+                .cut(TestLine.Y_AXIS);
+
+        List<TestNode> nodes = new ArrayList<>();
+        // act
+        for (TestNode n : node)
+        {
+            nodes.add(n);
+        }
+
+        // assert
+        Assert.assertEquals(3, nodes.size());
+        Assert.assertSame(node, nodes.get(0));
+        Assert.assertSame(node.getMinus(), nodes.get(1));
+        Assert.assertSame(node.getPlus(), nodes.get(2));
+    }
+
+    @Test
+    public void testNodeStream_singleNodeSubtree() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        TestNode node = tree.getRoot().cut(TestLine.X_AXIS)
+            .getMinus()
+                .cut(TestLine.Y_AXIS)
+                .getMinus();
+
+        // act
+        List<TestNode> nodes = node.stream().collect(Collectors.toList());
+
+        // assert
+        Assert.assertEquals(1, nodes.size());
+        Assert.assertSame(node, nodes.get(0));
+    }
+
+    @Test
+    public void testNodeStream_multipleNodeSubtree() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        TestNode node = tree.getRoot().cut(TestLine.X_AXIS)
+            .getMinus()
+                .cut(TestLine.Y_AXIS);
+
+        // act
+        List<TestNode> nodes = node.stream().collect(Collectors.toList());
+
+        // assert
+        Assert.assertEquals(3, nodes.size());
+        Assert.assertSame(node, nodes.get(0));
+        Assert.assertSame(node.getMinus(), nodes.get(1));
+        Assert.assertSame(node.getPlus(), nodes.get(2));
+    }
+
+    @Test
+    public void testCopy_rootOnly() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+
+        // act
+        TestBSPTree copy = new TestBSPTree();
+        copy.copy(tree);
+
+        // assert
+        Assert.assertNotSame(tree, copy);
+        Assert.assertNotSame(tree.getRoot(), copy.getRoot());
+
+        Assert.assertEquals(tree.count(), copy.count());
+    }
+
+    @Test
+    public void testCopy_withCuts() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        tree.getRoot()
+            .cut(TestLine.X_AXIS)
+            .getMinus()
+                .cut(TestLine.Y_AXIS);
+
+        // act
+        TestBSPTree copy = new TestBSPTree();
+        copy.copy(tree);
+
+        // assert
+        Assert.assertNotSame(tree, copy);
+
+        assertNodesCopiedRecursive(tree.getRoot(), copy.getRoot());
+
+        Assert.assertEquals(tree.count(), copy.count());
+    }
+
+    @Test
+    public void testCopy_changesToOneTreeDoNotAffectCopy() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        tree.getRoot()
+            .cut(TestLine.X_AXIS)
+            .getMinus()
+                .cut(TestLine.Y_AXIS);
+
+        // act
+        TestBSPTree copy = new TestBSPTree();
+        copy.copy(tree);
+        tree.getRoot().clearCut();
+
+        // assert
+        Assert.assertEquals(1, tree.count());
+        Assert.assertEquals(5, copy.count());
+    }
+
+    @Test
+    public void testCopy_instancePassedAsArgument() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        tree.getRoot()
+            .cut(TestLine.X_AXIS)
+            .getMinus()
+                .cut(TestLine.Y_AXIS);
+
+        // act
+        tree.copy(tree);
+
+        // assert
+        Assert.assertEquals(5, tree.count());
+    }
+
+    @Test
+    public void testExtract_singleNodeTree() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+
+        TestBSPTree result = new TestBSPTree();
+        result.getRoot().insertCut(TestLine.X_AXIS);
+
+        // act
+        result.extract(tree.getRoot());
+
+        // assert
+        Assert.assertNotSame(tree.getRoot(), result.getRoot());
+        Assert.assertEquals(1, tree.count());
+        Assert.assertEquals(1, result.count());
+
+        PartitionTestUtils.assertTreeStructure(tree);
+        PartitionTestUtils.assertTreeStructure(result);
+    }
+
+    @Test
+    public void testExtract_clearsExistingNodesInCallingTree() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+
+        TestBSPTree result = new TestBSPTree();
+        result.getRoot().cut(TestLine.X_AXIS)
+            .getMinus().cut(TestLine.Y_AXIS);
+
+        // act
+        result.extract(tree.getRoot());
+
+        // assert
+        Assert.assertNotSame(tree.getRoot(), result.getRoot());
+        Assert.assertEquals(1, tree.count());
+        Assert.assertEquals(1, result.count());
+
+        PartitionTestUtils.assertTreeStructure(tree);
+        PartitionTestUtils.assertTreeStructure(result);
+    }
+
+    @Test
+    public void testExtract_internalNode() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        tree.insert(Arrays.asList(
+                    new TestLineSegment(TestPoint2D.ZERO, new TestPoint2D(1, 0)),
+                    new TestLineSegment(new TestPoint2D(0, -1), new TestPoint2D(0, 1)),
+                    new TestLineSegment(new TestPoint2D(1, 2), new TestPoint2D(2, 1)),
+                    new TestLineSegment(new TestPoint2D(-1, 2), new TestPoint2D(-2, 1)),
+                    new TestLineSegment(new TestPoint2D(0, -2), new TestPoint2D(1, -2))
+                ));
+
+        TestBSPTree result = new TestBSPTree();
+
+        // act
+        result.extract(tree.getRoot().getPlus());
+
+        // assert
+        Assert.assertEquals(7, result.count());
+
+        List<TestLineSegment> resultSegments = getLineSegments(result);
+        Assert.assertEquals(3, resultSegments.size());
+
+        PartitionTestUtils.assertSegmentsEqual(
+                new TestLineSegment(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, TestLine.X_AXIS),
+                resultSegments.get(0));
+        PartitionTestUtils.assertSegmentsEqual(
+                new TestLineSegment(Double.NEGATIVE_INFINITY, 0, TestLine.Y_AXIS),
+                resultSegments.get(1));
+        PartitionTestUtils.assertSegmentsEqual(
+                new TestLineSegment(0, Double.POSITIVE_INFINITY, new TestLine(new TestPoint2D(0, -2), new TestPoint2D(1, -2))),
+                resultSegments.get(2));
+
+        Assert.assertEquals(13, tree.count());
+
+        List<TestLineSegment> inputSegment = getLineSegments(tree);
+        Assert.assertEquals(6, inputSegment.size());
+
+        PartitionTestUtils.assertTreeStructure(tree);
+        PartitionTestUtils.assertTreeStructure(result);
+    }
+
+    @Test
+    public void testExtract_leafNode() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        tree.insert(Arrays.asList(
+                    new TestLineSegment(TestPoint2D.ZERO, new TestPoint2D(1, 0)),
+                    new TestLineSegment(new TestPoint2D(0, -1), new TestPoint2D(0, 1)),
+                    new TestLineSegment(new TestPoint2D(1, 2), new TestPoint2D(2, 1)),
+                    new TestLineSegment(new TestPoint2D(-1, 2), new TestPoint2D(-2, 1)),
+                    new TestLineSegment(new TestPoint2D(0, -2), new TestPoint2D(1, -2))
+                ));
+
+        TestPoint2D pt = new TestPoint2D(1, 1);
+
+        TestNode node = tree.findNode(pt);
+        TestBSPTree result = new TestBSPTree();
+
+        // act
+        result.extract(node);
+
+        // assert
+        TestNode resultNode = result.findNode(pt);
+        Assert.assertNotNull(resultNode);
+        Assert.assertNotSame(node, resultNode);
+
+        Assert.assertEquals(7, result.count());
+
+        List<TestLineSegment> resultSegments = getLineSegments(result);
+        Assert.assertEquals(3, resultSegments.size());
+
+        PartitionTestUtils.assertSegmentsEqual(
+                new TestLineSegment(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, TestLine.X_AXIS),
+                resultSegments.get(0));
+        PartitionTestUtils.assertSegmentsEqual(
+                new TestLineSegment(0, Double.POSITIVE_INFINITY, TestLine.Y_AXIS),
+                resultSegments.get(1));
+        PartitionTestUtils.assertSegmentsEqual(
+                new TestLineSegment(new TestPoint2D(0, 3), new TestPoint2D(3, 0)),
+                resultSegments.get(2));
+
+        Assert.assertEquals(13, tree.count());
+
+        List<TestLineSegment> inputSegment = getLineSegments(tree);
+        Assert.assertEquals(6, inputSegment.size());
+
+        PartitionTestUtils.assertTreeStructure(tree);
+        PartitionTestUtils.assertTreeStructure(result);
+    }
+
+    @Test
+    public void testExtract_extractFromSameTree() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        tree.insert(Arrays.asList(
+                    new TestLineSegment(TestPoint2D.ZERO, new TestPoint2D(1, 0)),
+                    new TestLineSegment(new TestPoint2D(0, -1), new TestPoint2D(0, 1)),
+                    new TestLineSegment(new TestPoint2D(1, 2), new TestPoint2D(2, 1)),
+                    new TestLineSegment(new TestPoint2D(-1, 2), new TestPoint2D(-2, 1)),
+                    new TestLineSegment(new TestPoint2D(0, -2), new TestPoint2D(1, -2))
+                ));
+
+        TestPoint2D pt = new TestPoint2D(1, 1);
+
+        TestNode node = tree.findNode(pt);
+
+        // act
+        tree.extract(node);
+
+        // assert
+        TestNode resultNode = tree.findNode(pt);
+        Assert.assertNotNull(resultNode);
+        Assert.assertSame(node, resultNode);
+
+        Assert.assertEquals(7, tree.count());
+
+        List<TestLineSegment> resultSegments = getLineSegments(tree);
+        Assert.assertEquals(3, resultSegments.size());
+
+        PartitionTestUtils.assertSegmentsEqual(
+                new TestLineSegment(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, TestLine.X_AXIS),
+                resultSegments.get(0));
+        PartitionTestUtils.assertSegmentsEqual(
+                new TestLineSegment(0, Double.POSITIVE_INFINITY, TestLine.Y_AXIS),
+                resultSegments.get(1));
+        PartitionTestUtils.assertSegmentsEqual(
+                new TestLineSegment(new TestPoint2D(0, 3), new TestPoint2D(3, 0)),
+                resultSegments.get(2));
+
+        PartitionTestUtils.assertTreeStructure(tree);
+    }
+
+    @Test
+    public void testTransform_singleNodeTree() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+
+        Transform<TestPoint2D> t = new TestTransform2D((p) -> new TestPoint2D(p.getX(), p.getY() + 2));
+
+        // act
+        tree.transform(t);
+
+        // assert
+        Assert.assertEquals(1, tree.count());
+        Assert.assertTrue(tree.getRoot().isLeaf());
+    }
+
+    @Test
+    public void testTransform_singleCut() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        tree.getRoot().insertCut(TestLine.X_AXIS);
+
+        Transform<TestPoint2D> t = new TestTransform2D((p) -> new TestPoint2D(p.getX(), p.getY() + 2));
+
+        // act
+        tree.transform(t);
+
+        // assert
+        Assert.assertEquals(3, tree.count());
+
+        List<TestLineSegment> segments = getLineSegments(tree);
+        Assert.assertEquals(1, segments.size());
+
+        TestLineSegment seg = segments.get(0);
+
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(Double.NEGATIVE_INFINITY, 2), seg.getStartPoint());
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(Double.POSITIVE_INFINITY, 2), seg.getEndPoint());
+    }
+
+    @Test
+    public void testTransform_multipleCuts() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        tree.insert(Arrays.asList(
+                    new TestLineSegment(new TestPoint2D(-1, 0), new TestPoint2D(1, 0)),
+                    new TestLineSegment(new TestPoint2D(-1, -1), new TestPoint2D(1, 1)),
+                    new TestLineSegment(new TestPoint2D(3, 1), new TestPoint2D(3, 2))
+                ));
+
+        Transform<TestPoint2D> t = new TestTransform2D((p) -> new TestPoint2D(0.5 * p.getX(), p.getY() + 2));
+
+        // act
+        tree.transform(t);
+
+        // assert
+        Assert.assertEquals(9, tree.count());
+
+        List<TestLineSegment> segments = getLineSegments(tree);
+        Assert.assertEquals(4, segments.size());
+
+        TestLineSegment segment1 = segments.get(0);
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(Double.NEGATIVE_INFINITY, 2), segment1.getStartPoint());
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(Double.POSITIVE_INFINITY, 2), segment1.getEndPoint());
+
+        TestLineSegment segment2 = segments.get(1);
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(0, 2), segment2.getStartPoint());
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY), segment2.getEndPoint());
+
+        TestLineSegment segment3 = segments.get(2);
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(1.5, 2), segment3.getStartPoint());
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(1.5, 5), segment3.getEndPoint());
+
+        TestLineSegment segment4 = segments.get(3);
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(Double.NEGATIVE_INFINITY, Double.NEGATIVE_INFINITY), segment4.getStartPoint());
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(0, 2), segment4.getEndPoint());
+    }
+
+    @Test
+    public void testTransform_xAxisReflection() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        tree.insert(Arrays.asList(
+                    new TestLineSegment(new TestPoint2D(-1, 0), new TestPoint2D(1, 0)),
+                    new TestLineSegment(new TestPoint2D(0, -1), new TestPoint2D(0, 1)),
+                    new TestLineSegment(new TestPoint2D(0, 3), new TestPoint2D(3, 0))
+                ));
+
+        Transform<TestPoint2D> t = new TestTransform2D((p) -> new TestPoint2D(-p.getX(), p.getY()));
+
+        Map<TestPoint2D, TestNode> pointNodeMap = createPointNodeMap(tree, -5, 5);
+
+        // act
+        tree.transform(t);
+
+        // assert
+        checkTransformedPointNodeMap(tree, t, pointNodeMap);
+
+        List<TestLineSegment> segments = getLineSegments(tree);
+        Assert.assertEquals(4, segments.size());
+    }
+
+    @Test
+    public void testTransform_yAxisReflection() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        tree.insert(Arrays.asList(
+                    new TestLineSegment(new TestPoint2D(-1, 0), new TestPoint2D(1, 0)),
+                    new TestLineSegment(new TestPoint2D(0, -1), new TestPoint2D(0, 1)),
+                    new TestLineSegment(new TestPoint2D(0, 3), new TestPoint2D(3, 0))
+                ));
+
+        Transform<TestPoint2D> t = new TestTransform2D((p) -> new TestPoint2D(p.getX(), -p.getY()));
+
+        Map<TestPoint2D, TestNode> pointNodeMap = createPointNodeMap(tree, -5, 5);
+
+        // act
+        tree.transform(t);
+
+        // assert
+        checkTransformedPointNodeMap(tree, t, pointNodeMap);
+
+        List<TestLineSegment> segments = getLineSegments(tree);
+        Assert.assertEquals(4, segments.size());
+    }
+
+    @Test
+    public void testTransform_xAndYAxisReflection() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        tree.insert(Arrays.asList(
+                    new TestLineSegment(new TestPoint2D(-1, 0), new TestPoint2D(1, 0)),
+                    new TestLineSegment(new TestPoint2D(0, -1), new TestPoint2D(0, 1)),
+                    new TestLineSegment(new TestPoint2D(0, 3), new TestPoint2D(3, 0))
+                ));
+
+        Transform<TestPoint2D> t = new TestTransform2D((p) -> new TestPoint2D(-p.getX(), -p.getY()));
+
+        Map<TestPoint2D, TestNode> pointNodeMap = createPointNodeMap(tree, -5, 5);
+
+        // act
+        tree.transform(t);
+
+        // assert
+        checkTransformedPointNodeMap(tree, t, pointNodeMap);
+
+        List<TestLineSegment> segments = getLineSegments(tree);
+        Assert.assertEquals(4, segments.size());
+    }
+
+    @Test
+    public void testTreeString() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        tree.getRoot().cut(TestLine.X_AXIS)
+            .getMinus().cut(TestLine.Y_AXIS);
+
+        // act
+        String str = tree.treeString();
+
+        // assert
+        String[] lines = str.split("\n");
+        Assert.assertEquals(5, lines.length);
+        Assert.assertTrue(lines[0].startsWith("TestNode[cut= TestLineSegment"));
+        Assert.assertTrue(lines[1].startsWith("    [-] TestNode[cut= TestLineSegment"));
+        Assert.assertEquals("        [-] TestNode[cut= null]", lines[2]);
+        Assert.assertEquals("        [+] TestNode[cut= null]", lines[3]);
+        Assert.assertEquals("    [+] TestNode[cut= null]", lines[4]);
+    }
+
+    @Test
+    public void testTreeString_emptyTree() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+
+        // act
+        String str = tree.treeString();
+
+        // assert
+        Assert.assertEquals("TestNode[cut= null]", str);
+    }
+
+    @Test
+    public void testTreeString_reachesMaxDepth() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        tree.getRoot().cut(TestLine.X_AXIS)
+            .getMinus().cut(TestLine.Y_AXIS)
+            .getMinus().cut(new TestLine(new TestPoint2D(-2, 1), new TestPoint2D(0, 1)));
+
+        // act
+        String str = tree.treeString(1);
+
+        // assert
+        String[] lines = str.split("\n");
+        Assert.assertEquals(4, lines.length);
+        Assert.assertTrue(lines[0].startsWith("TestNode[cut= TestLineSegment"));
+        Assert.assertTrue(lines[1].startsWith("    [-] TestNode[cut= TestLineSegment"));
+        Assert.assertEquals("        ...", lines[2]);
+        Assert.assertEquals("    [+] TestNode[cut= null]", lines[3]);
+    }
+
+    @Test
+    public void testTreeString_zeroMaxDepth() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        tree.getRoot().cut(TestLine.X_AXIS)
+            .getMinus().cut(TestLine.Y_AXIS)
+            .getMinus().cut(new TestLine(new TestPoint2D(-2, 1), new TestPoint2D(0, 1)));
+
+        // act
+        String str = tree.treeString(0);
+
+        // assert
+        String[] lines = str.split("\n");
+        Assert.assertEquals(2, lines.length);
+        Assert.assertTrue(lines[0].startsWith("TestNode[cut= TestLineSegment"));
+        Assert.assertTrue(lines[1].startsWith("    ..."));
+    }
+
+    @Test
+    public void testTreeString_negativeMaxDepth() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        tree.getRoot().cut(TestLine.X_AXIS)
+            .getMinus().cut(TestLine.Y_AXIS)
+            .getMinus().cut(new TestLine(new TestPoint2D(-2, 1), new TestPoint2D(0, 1)));
+
+        // act
+        String str = tree.treeString(-1);
+
+        // assert
+        Assert.assertEquals("", str);
+    }
+
+    @Test
+    public void testToString() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        tree.getRoot().insertCut(TestLine.Y_AXIS);
+
+        // act
+        String str = tree.toString();
+
+        // assert
+        String msg = "Unexpected toString() representation: " + str;
+
+        Assert.assertTrue(msg, str.contains("TestBSPTree"));
+        Assert.assertTrue(msg, str.contains("count= 3"));
+        Assert.assertTrue(msg, str.contains("height= 1"));
+    }
+
+    @Test
+    public void testNodeToString() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        tree.getRoot().cut(TestLine.X_AXIS);
+
+        // act
+        String str = tree.getRoot().toString();
+
+        // assert
+        Assert.assertTrue(str.contains("TestNode"));
+        Assert.assertTrue(str.contains("cut= TestLineSegment"));
+    }
+
+    @Test
+    public void testSplitIntoTree() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        tree.getRoot()
+            .cut(TestLine.X_AXIS)
+            .getMinus()
+                .cut(TestLine.Y_AXIS);
+
+        TestBSPTree minus = new TestBSPTree();
+        TestBSPTree plus = new TestBSPTree();
+
+        TestLine splitter = new TestLine(new TestPoint2D(0,  0), new TestPoint2D(-1, 1));
+
+        // act
+        tree.splitIntoTrees(splitter, minus, plus);
+
+        // assert
+        TestLineSegment splitSegment = new TestLineSegment(Double.NEGATIVE_INFINITY,
+                Double.POSITIVE_INFINITY, splitter);
+
+        Assert.assertEquals(5, tree.count());
+        Assert.assertEquals(2, tree.height());
+
+        Assert.assertEquals(5, minus.count());
+        Assert.assertEquals(2, minus.height());
+
+        List<TestLineSegment> minusSegments = getLineSegments(minus);
+        Assert.assertEquals(2, minusSegments.size());
+        PartitionTestUtils.assertSegmentsEqual(splitSegment, minusSegments.get(0));
+        PartitionTestUtils.assertSegmentsEqual(new TestLineSegment(Double.NEGATIVE_INFINITY, 0, TestLine.X_AXIS),
+                minusSegments.get(1));
+
+        Assert.assertEquals(7, plus.count());
+        Assert.assertEquals(3, plus.height());
+
+        List<TestLineSegment> plusSegments = getLineSegments(plus);
+        Assert.assertEquals(3, plusSegments.size());
+        PartitionTestUtils.assertSegmentsEqual(splitSegment, plusSegments.get(0));
+        PartitionTestUtils.assertSegmentsEqual(new TestLineSegment(0, Double.POSITIVE_INFINITY, TestLine.X_AXIS),
+                plusSegments.get(1));
+        PartitionTestUtils.assertSegmentsEqual(new TestLineSegment(0, Double.POSITIVE_INFINITY, TestLine.Y_AXIS),
+                plusSegments.get(2));
+    }
+
+    @Test
+    public void testSplitIntoTree_minusOnly() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        tree.getRoot()
+            .cut(TestLine.X_AXIS)
+            .getMinus()
+                .cut(TestLine.Y_AXIS);
+
+        TestBSPTree minus = new TestBSPTree();
+
+        TestLine splitter = new TestLine(new TestPoint2D(0,  0), new TestPoint2D(-1, 1));
+
+        // act
+        tree.splitIntoTrees(splitter, minus, null);
+
+        // assert
+        TestLineSegment splitSegment = new TestLineSegment(Double.NEGATIVE_INFINITY,
+                Double.POSITIVE_INFINITY, splitter);
+
+        Assert.assertEquals(5, tree.count());
+        Assert.assertEquals(2, tree.height());
+
+        Assert.assertEquals(5, minus.count());
+        Assert.assertEquals(2, minus.height());
+
+        List<TestLineSegment> minusSegments = getLineSegments(minus);
+        Assert.assertEquals(2, minusSegments.size());
+        PartitionTestUtils.assertSegmentsEqual(splitSegment, minusSegments.get(0));
+        PartitionTestUtils.assertSegmentsEqual(new TestLineSegment(Double.NEGATIVE_INFINITY, 0, TestLine.X_AXIS),
+                minusSegments.get(1));
+    }
+
+    @Test
+    public void testSplitIntoTree_plusOnly() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        tree.getRoot()
+            .cut(TestLine.X_AXIS)
+            .getMinus()
+                .cut(TestLine.Y_AXIS);
+
+        TestBSPTree plus = new TestBSPTree();
+
+        TestLine splitter = new TestLine(new TestPoint2D(0,  0), new TestPoint2D(-1, 1));
+
+        // act
+        tree.splitIntoTrees(splitter, null, plus);
+
+        // assert
+        TestLineSegment splitSegment = new TestLineSegment(Double.NEGATIVE_INFINITY,
+                Double.POSITIVE_INFINITY, splitter);
+
+        Assert.assertEquals(5, tree.count());
+        Assert.assertEquals(2, tree.height());
+
+        Assert.assertEquals(7, plus.count());
+        Assert.assertEquals(3, plus.height());
+
+        List<TestLineSegment> plusSegments = getLineSegments(plus);
+        Assert.assertEquals(3, plusSegments.size());
+        PartitionTestUtils.assertSegmentsEqual(splitSegment, plusSegments.get(0));
+        PartitionTestUtils.assertSegmentsEqual(new TestLineSegment(0, Double.POSITIVE_INFINITY, TestLine.X_AXIS),
+                plusSegments.get(1));
+        PartitionTestUtils.assertSegmentsEqual(new TestLineSegment(0, Double.POSITIVE_INFINITY, TestLine.Y_AXIS),
+                plusSegments.get(2));
+    }
+
+    private void assertNodesCopiedRecursive(final TestNode orig, final TestNode copy) {
+        Assert.assertNotSame(orig, copy);
+
+        Assert.assertEquals(orig.getCut(), copy.getCut());
+
+        if (!orig.isLeaf())
+        {
+            Assert.assertNotSame(orig.getMinus(), copy.getMinus());
+            Assert.assertNotSame(orig.getPlus(), copy.getPlus());
+
+            assertNodesCopiedRecursive(orig.getMinus(), copy.getMinus());
+            assertNodesCopiedRecursive(orig.getPlus(), copy.getPlus());
+        }
+        else {
+            Assert.assertNull(copy.getMinus());
+            Assert.assertNull(copy.getPlus());
+        }
+
+        Assert.assertEquals(orig.depth(), copy.depth());
+        Assert.assertEquals(orig.count(), copy.count());
+    }
+
+    private static List<TestLineSegment> getLineSegments(TestBSPTree tree) {
+        return tree.stream()
+            .filter(BSPTree.Node::isInternal)
+            .map(n -> (TestLineSegment) n.getCut())
+            .collect(Collectors.toList());
+    }
+
+    /** Create a map of points to the nodes that they resolve to in the
+     * given tree.
+     */
+    private static Map<TestPoint2D, TestNode> createPointNodeMap(TestBSPTree tree, int min, int max) {
+        Map<TestPoint2D, TestNode> map = new HashMap<>();
+
+        for (int x = min; x <= max; ++x) {
+            for (int y = min; y <= max; ++y) {
+                TestPoint2D pt = new TestPoint2D(x, y);
+                TestNode node = tree.findNode(pt, NodeCutRule.NODE);
+
+                map.put(pt, node);
+            }
+        }
+
+        return map;
+    }
+
+    /** Check that transformed points resolve to the same tree nodes that were found when the original
+     * points were resolved in the untransformed tree.
+     * @param transformed
+     * @param transform
+     * @param pointNodeMap
+     */
+    private static void checkTransformedPointNodeMap(TestBSPTree transformedTree, Transform<TestPoint2D> transform,
+            Map<TestPoint2D, TestNode> pointNodeMap) {
+
+        for (TestPoint2D pt : pointNodeMap.keySet()) {
+            TestNode expectedNode = pointNodeMap.get(pt);
+            TestPoint2D transformedPt = transform.apply(pt);
+
+            String msg = "Expected transformed point " + transformedPt + " to resolve to node " + expectedNode;
+            Assert.assertSame(msg, expectedNode, transformedTree.findNode(transformedPt, NodeCutRule.NODE));
+        }
+    }
+
+    private static class TestVisitor implements BSPTreeVisitor<TestPoint2D, TestNode> {
+
+        private final Order order;
+
+        private final List<TestNode> visited = new ArrayList<>();
+
+        public TestVisitor(Order order) {
+            this.order = order;
+        }
+
+        @Override
+        public void visit(TestNode node) {
+            visited.add(node);
+        }
+
+        @Override
+        public Order visitOrder(TestNode node) {
+            return order;
+        }
+
+        public List<TestNode> getVisited() {
+            return visited;
+        }
+    }
+}
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/bsp/AbstractRegionBSPTreeTest.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/bsp/AbstractRegionBSPTreeTest.java
new file mode 100644
index 0000000..32471b7
--- /dev/null
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/bsp/AbstractRegionBSPTreeTest.java
@@ -0,0 +1,2257 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning.bsp;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.partition.test.PartitionTestUtils;
+import org.apache.commons.geometry.core.partition.test.TestLine;
+import org.apache.commons.geometry.core.partition.test.TestLineSegment;
+import org.apache.commons.geometry.core.partition.test.TestLineSegmentCollection;
+import org.apache.commons.geometry.core.partition.test.TestPoint2D;
+import org.apache.commons.geometry.core.partition.test.TestTransform2D;
+import org.apache.commons.geometry.core.partitioning.ConvexSubHyperplane;
+import org.apache.commons.geometry.core.partitioning.Hyperplane;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.SplitLocation;
+import org.apache.commons.geometry.core.partitioning.SubHyperplane;
+import org.apache.commons.geometry.core.partitioning.bsp.AbstractRegionBSPTree.AbstractRegionNode;
+import org.apache.commons.geometry.core.partitioning.bsp.AbstractRegionBSPTree.RegionSizeProperties;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+public class AbstractRegionBSPTreeTest {
+
+    private TestRegionBSPTree tree;
+
+    private TestRegionNode root;
+
+    @Before
+    public void setup() {
+        tree = new TestRegionBSPTree();
+        root = tree.getRoot();
+    }
+
+    @Test
+    public void testDefaultConstructor() {
+        // assert
+        Assert.assertNotNull(root);
+        Assert.assertNull(root.getParent());
+
+        PartitionTestUtils.assertIsLeafNode(root);
+        Assert.assertFalse(root.isPlus());
+        Assert.assertFalse(root.isMinus());
+
+        Assert.assertSame(tree, root.getTree());
+
+        Assert.assertEquals(RegionLocation.INSIDE, root.getLocation());
+    }
+
+    @Test
+    public void testParameterizedConstructor_true() {
+        // act
+        tree = new TestRegionBSPTree(true);
+        root = tree.getRoot();
+
+        // assert
+        Assert.assertNotNull(root);
+        Assert.assertNull(root.getParent());
+
+        PartitionTestUtils.assertIsLeafNode(root);
+        Assert.assertFalse(root.isPlus());
+        Assert.assertFalse(root.isMinus());
+
+        Assert.assertSame(tree, root.getTree());
+
+        Assert.assertEquals(RegionLocation.INSIDE, root.getLocation());
+    }
+
+    @Test
+    public void testParameterizedConstructor_false() {
+        // act
+        tree = new TestRegionBSPTree(false);
+        root = tree.getRoot();
+
+        // assert
+        Assert.assertNotNull(root);
+        Assert.assertNull(root.getParent());
+
+        PartitionTestUtils.assertIsLeafNode(root);
+        Assert.assertFalse(root.isPlus());
+        Assert.assertFalse(root.isMinus());
+
+        Assert.assertSame(tree, root.getTree());
+
+        Assert.assertEquals(RegionLocation.OUTSIDE, root.getLocation());
+    }
+
+    @Test
+    public void testGetLocation_emptyRoot() {
+        // act/assert
+        Assert.assertEquals(RegionLocation.INSIDE, root.getLocation());
+    }
+
+    @Test
+    public void testGetLocation_singleCut() {
+        // arrange
+        root.insertCut(TestLine.X_AXIS);
+
+        // act/assert
+        Assert.assertNull(root.getLocation());
+        Assert.assertEquals(RegionLocation.INSIDE, root.getMinus().getLocation());
+        Assert.assertEquals(RegionLocation.OUTSIDE, root.getPlus().getLocation());
+    }
+
+    @Test
+    public void testGetLocation_multipleCuts() {
+        // arrange
+        tree.insert(Arrays.asList(
+                new TestLineSegment(TestPoint2D.ZERO, new TestPoint2D(1, 0)),
+                new TestLineSegment(TestPoint2D.ZERO, new TestPoint2D(0, 1)),
+                new TestLineSegment(TestPoint2D.ZERO, new TestPoint2D(0, -1))));
+
+        // act/assert
+        Assert.assertNull(root.getLocation());
+
+        TestRegionNode plus = root.getPlus();
+        Assert.assertNull(plus.getLocation());
+
+        TestRegionNode plusPlus = plus.getPlus();
+        Assert.assertEquals(RegionLocation.OUTSIDE, plusPlus.getLocation());
+
+        TestRegionNode plusMinus = plus.getMinus();
+        Assert.assertEquals(RegionLocation.INSIDE, plusMinus.getLocation());
+
+        TestRegionNode minus = root.getMinus();
+        Assert.assertNull(minus.getLocation());
+
+        TestRegionNode minusPlus = minus.getPlus();
+        Assert.assertEquals(RegionLocation.OUTSIDE, minusPlus.getLocation());
+
+        TestRegionNode minusMinus = minus.getMinus();
+        Assert.assertEquals(RegionLocation.INSIDE, minusMinus.getLocation());
+    }
+
+    @Test
+    public void testGetLocation_resetsLocationWhenNodeCleared() {
+        // arrange
+        tree.insert(Arrays.asList(
+                new TestLineSegment(TestPoint2D.ZERO, new TestPoint2D(1, 0)),
+                new TestLineSegment(TestPoint2D.ZERO, new TestPoint2D(0, 1)),
+                new TestLineSegment(TestPoint2D.ZERO, new TestPoint2D(0, -1))));
+
+        // act
+        root.getPlus().clearCut();
+        root.getMinus().clearCut();
+
+        // assert
+        Assert.assertNull(root.getLocation());
+
+        Assert.assertEquals(RegionLocation.INSIDE, root.getMinus().getLocation());
+        Assert.assertEquals(RegionLocation.OUTSIDE, root.getPlus().getLocation());
+    }
+
+    @Test
+    public void testGetLocation_resetRoot() {
+        // arrange
+        tree.insert(Arrays.asList(
+                new TestLineSegment(TestPoint2D.ZERO, new TestPoint2D(1, 0)),
+                new TestLineSegment(TestPoint2D.ZERO, new TestPoint2D(0, 1)),
+                new TestLineSegment(TestPoint2D.ZERO, new TestPoint2D(0, -1))));
+
+        TestRegionNode root = tree.getRoot();
+
+        // act
+        root.clearCut();
+
+        // assert
+        Assert.assertEquals(RegionLocation.INSIDE, root.getLocation());
+    }
+
+    @Test
+    public void testBoundaries_fullAndEmpty() {
+        // act/assert
+        tree.setFull();
+        Assert.assertFalse(tree.boundaries().iterator().hasNext());
+
+        tree.setEmpty();
+        Assert.assertFalse(tree.boundaries().iterator().hasNext());
+    }
+
+    @Test
+    public void testBoundaries_finite() {
+        // arrange
+        insertBox(tree, new TestPoint2D(0, 1), new TestPoint2D(1, 0));
+
+        // act
+        List<TestLineSegment> segments = new ArrayList<>();
+        for (ConvexSubHyperplane<TestPoint2D> sub : tree.boundaries()) {
+            segments.add((TestLineSegment) sub);
+        }
+
+        // assert
+        Assert.assertEquals(4, segments.size());
+
+        assertContainsSegment(segments, new TestPoint2D(0, 0), new TestPoint2D(1, 0));
+        assertContainsSegment(segments, new TestPoint2D(1, 0), new TestPoint2D(1, 1));
+        assertContainsSegment(segments, new TestPoint2D(1, 1), new TestPoint2D(0, 1));
+        assertContainsSegment(segments, new TestPoint2D(0, 1), new TestPoint2D(0, 0));
+    }
+
+    @Test
+    public void testBoundaries_finite_inverted() {
+        // arrange
+        insertBox(tree, new TestPoint2D(0, 1), new TestPoint2D(1, 0));
+        tree.complement();
+
+        // act
+        List<TestLineSegment> segments = new ArrayList<>();
+        for (ConvexSubHyperplane<TestPoint2D> sub : tree.boundaries()) {
+            segments.add((TestLineSegment) sub);
+        }
+
+        // assert
+        Assert.assertEquals(4, segments.size());
+
+        assertContainsSegment(segments, new TestPoint2D(0, 0), new TestPoint2D(0, 1));
+        assertContainsSegment(segments, new TestPoint2D(0, 1), new TestPoint2D(1, 1));
+        assertContainsSegment(segments, new TestPoint2D(1, 1), new TestPoint2D(1, 0));
+        assertContainsSegment(segments, new TestPoint2D(1, 0), new TestPoint2D(0, 0));
+    }
+
+    @Test
+    public void testGetBoundaries_fullAndEmpty() {
+        // act/assert
+        tree.setFull();
+        Assert.assertEquals(0, tree.getBoundaries().size());
+
+        tree.setEmpty();
+        Assert.assertEquals(0, tree.getBoundaries().size());
+    }
+
+    @Test
+    public void testGetBoundaries_finite() {
+        // arrange
+        insertBox(tree, new TestPoint2D(0, 1), new TestPoint2D(1, 0));
+
+        // act
+        List<TestLineSegment> segments = new ArrayList<>();
+        for (ConvexSubHyperplane<TestPoint2D> sub : tree.getBoundaries()) {
+            segments.add((TestLineSegment) sub);
+        }
+
+        // assert
+        Assert.assertEquals(4, segments.size());
+
+        assertContainsSegment(segments, new TestPoint2D(0, 0), new TestPoint2D(1, 0));
+        assertContainsSegment(segments, new TestPoint2D(1, 0), new TestPoint2D(1, 1));
+        assertContainsSegment(segments, new TestPoint2D(1, 1), new TestPoint2D(0, 1));
+        assertContainsSegment(segments, new TestPoint2D(0, 1), new TestPoint2D(0, 0));
+    }
+
+    @Test
+    public void testGetBoundaries_finite_inverted() {
+        // arrange
+        insertBox(tree, new TestPoint2D(0, 1), new TestPoint2D(1, 0));
+        tree.complement();
+
+        // act
+        List<TestLineSegment> segments = new ArrayList<>();
+        for (ConvexSubHyperplane<TestPoint2D> sub : tree.getBoundaries()) {
+            segments.add((TestLineSegment) sub);
+        }
+
+        // assert
+        Assert.assertEquals(4, segments.size());
+
+        assertContainsSegment(segments, new TestPoint2D(0, 0), new TestPoint2D(0, 1));
+        assertContainsSegment(segments, new TestPoint2D(0, 1), new TestPoint2D(1, 1));
+        assertContainsSegment(segments, new TestPoint2D(1, 1), new TestPoint2D(1, 0));
+        assertContainsSegment(segments, new TestPoint2D(1, 0), new TestPoint2D(0, 0));
+    }
+
+    @Test
+    public void testClassify() {
+        // arrange
+        insertSkewedBowtie(tree);
+
+        // act/assert
+        Assert.assertEquals(RegionLocation.INSIDE, tree.classify(new TestPoint2D(3, 1)));
+        Assert.assertEquals(RegionLocation.INSIDE, tree.classify(new TestPoint2D(-3, -1)));
+
+        Assert.assertEquals(RegionLocation.OUTSIDE, tree.classify(new TestPoint2D(-3, 1)));
+        Assert.assertEquals(RegionLocation.OUTSIDE, tree.classify(new TestPoint2D(3, -1)));
+
+        Assert.assertEquals(RegionLocation.BOUNDARY, tree.classify(new TestPoint2D(4, 5)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, tree.classify(new TestPoint2D(-4, -5)));
+
+        Assert.assertEquals(RegionLocation.OUTSIDE, tree.classify(new TestPoint2D(5, 0)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, tree.classify(new TestPoint2D(4, 0)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, tree.classify(new TestPoint2D(3, 0)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, tree.classify(new TestPoint2D(2, 0)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, tree.classify(new TestPoint2D(1, 0)));
+        Assert.assertEquals(RegionLocation.INSIDE, tree.classify(new TestPoint2D(0, 0)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, tree.classify(new TestPoint2D(-1, 0)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, tree.classify(new TestPoint2D(-2, 0)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, tree.classify(new TestPoint2D(-3, 0)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, tree.classify(new TestPoint2D(-4, 0)));
+        Assert.assertEquals(RegionLocation.OUTSIDE, tree.classify(new TestPoint2D(-5, 0)));
+    }
+
+    @Test
+    public void testClassify_emptyTree() {
+        // act/assert
+        Assert.assertEquals(RegionLocation.INSIDE, tree.classify(TestPoint2D.ZERO));
+    }
+
+    @Test
+    public void testClassify_NaN() {
+        // act/assert
+        Assert.assertEquals(RegionLocation.OUTSIDE, tree.classify(new TestPoint2D(0, Double.NaN)));
+    }
+
+    @Test
+    public void testContains() {
+        // arrange
+        insertSkewedBowtie(tree);
+
+        // act/assert
+        Assert.assertTrue(tree.contains(new TestPoint2D(3, 1)));
+        Assert.assertTrue(tree.contains(new TestPoint2D(-3, -1)));
+
+        Assert.assertFalse(tree.contains(new TestPoint2D(-3, 1)));
+        Assert.assertFalse(tree.contains(new TestPoint2D(3, -1)));
+
+        Assert.assertTrue(tree.contains(new TestPoint2D(4, 5)));
+        Assert.assertTrue(tree.contains(new TestPoint2D(-4, -5)));
+
+        Assert.assertFalse(tree.contains(new TestPoint2D(5, 0)));
+
+        Assert.assertTrue(tree.contains(new TestPoint2D(4, 0)));
+        Assert.assertTrue(tree.contains(new TestPoint2D(3, 0)));
+        Assert.assertTrue(tree.contains(new TestPoint2D(2, 0)));
+        Assert.assertTrue(tree.contains(new TestPoint2D(1, 0)));
+        Assert.assertTrue(tree.contains(new TestPoint2D(0, 0)));
+        Assert.assertTrue(tree.contains(new TestPoint2D(-1, 0)));
+        Assert.assertTrue(tree.contains(new TestPoint2D(-2, 0)));
+        Assert.assertTrue(tree.contains(new TestPoint2D(-3, 0)));
+        Assert.assertTrue(tree.contains(new TestPoint2D(-4, 0)));
+
+        Assert.assertFalse(tree.contains(new TestPoint2D(-5, 0)));
+    }
+
+    @Test
+    public void testSetFull() {
+        // arrange
+        insertSkewedBowtie(tree);
+
+        // act
+        tree.setFull();
+
+        // assert
+        Assert.assertTrue(tree.isFull());
+        Assert.assertFalse(tree.isEmpty());
+
+        Assert.assertEquals(RegionLocation.INSIDE, tree.classify(TestPoint2D.ZERO));
+        Assert.assertTrue(tree.contains(TestPoint2D.ZERO));
+    }
+
+    @Test
+    public void testSetEmpty() {
+        // arrange
+        insertSkewedBowtie(tree);
+
+        // act
+        tree.setEmpty();
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertTrue(tree.isEmpty());
+
+        Assert.assertEquals(RegionLocation.OUTSIDE, tree.classify(TestPoint2D.ZERO));
+        Assert.assertFalse(tree.contains(TestPoint2D.ZERO));
+    }
+
+    @Test
+    public void testGetRegionSizeProperties_cachesValueBasedOnVersion() {
+        // act
+        RegionSizeProperties<TestPoint2D> first = tree.getRegionSizeProperties();
+        RegionSizeProperties<TestPoint2D> second = tree.getRegionSizeProperties();
+        tree.getRoot().cut(TestLine.X_AXIS);
+        RegionSizeProperties<TestPoint2D> third = tree.getRegionSizeProperties();
+
+        // assert
+        Assert.assertSame(first, second);
+        Assert.assertNotSame(second, third);
+
+        Assert.assertEquals(1, first.getSize(), PartitionTestUtils.EPS);
+        Assert.assertSame(TestPoint2D.ZERO, first.getBarycenter());
+    }
+
+    @Test
+    public void testGetSize() {
+        // act/assert
+        // make sure our stub value is pulled
+        Assert.assertEquals(1, tree.getSize(), PartitionTestUtils.EPS);
+    }
+
+    @Test
+    public void testGetBarycenter() {
+        // act/assert
+        // make sure our stub value is pulled
+        Assert.assertSame(TestPoint2D.ZERO, tree.getBarycenter());
+    }
+
+    @Test
+    public void testGetBoundarySize_fullAndEmpty() {
+        // act/assert
+        Assert.assertEquals(0.0, fullTree().getBoundarySize(), PartitionTestUtils.EPS);
+        Assert.assertEquals(0.0, emptyTree().getBoundarySize(), PartitionTestUtils.EPS);
+    }
+
+    @Test
+    public void testGetBoundarySize_infinite() {
+        // arrange
+        TestRegionBSPTree halfPos = new TestRegionBSPTree(true);
+        halfPos.getRoot().cut(TestLine.X_AXIS);
+
+        TestRegionBSPTree halfPosComplement = new TestRegionBSPTree(true);
+        halfPosComplement.complement(halfPos);
+
+        // act/assert
+        Assert.assertEquals(Double.POSITIVE_INFINITY, halfPos.getBoundarySize(), PartitionTestUtils.EPS);
+        Assert.assertEquals(Double.POSITIVE_INFINITY, halfPosComplement.getBoundarySize(), PartitionTestUtils.EPS);
+    }
+
+    @Test
+    public void testGetBoundarySize_alignedCuts() {
+        // arrange
+        TestPoint2D p0 = TestPoint2D.ZERO;
+        TestPoint2D p1 = new TestPoint2D(0, 3);
+
+        TestRegionNode node = tree.getRoot();
+
+        tree.cutNode(node, new TestLineSegment(p0, p1));
+        node = node.getMinus();
+
+        tree.cutNode(node, new TestLineSegment(0, 0, new TestLine(p1, new TestPoint2D(-1, 3))));
+        node = node.getMinus();
+
+        tree.cutNode(node, new TestLineSegment(p1, p0));
+        node = node.getMinus();
+
+        tree.cutNode(node, new TestLineSegment(0, 0, new TestLine(p0, new TestPoint2D(1, 3))));
+
+        // act/assert
+        Assert.assertEquals(6, tree.getBoundarySize(), PartitionTestUtils.EPS);
+    }
+
+    @Test
+    public void testGetBoundarySize_box() {
+        // arrange
+        insertBox(tree, new TestPoint2D(2, 2), new TestPoint2D(4, 1));
+
+        // act/assert
+        Assert.assertEquals(6.0, tree.getBoundarySize(), PartitionTestUtils.EPS);
+    }
+
+    @Test
+    public void testGetBoundarySize_boxComplement() {
+        // arrange
+        insertBox(tree, new TestPoint2D(2, 2), new TestPoint2D(4, 1));
+        tree.complement();
+
+        // act/assert
+        Assert.assertEquals(6.0, tree.getBoundarySize(), PartitionTestUtils.EPS);
+    }
+
+    @Test
+    public void testGetBoundarySize_recomputesAfterChange() {
+        // arrange
+        insertBox(tree, new TestPoint2D(2, 2), new TestPoint2D(4, 1));
+
+        // act
+        double first = tree.getBoundarySize();
+        tree.insert(new TestLineSegment(new TestPoint2D(3, 1), new TestPoint2D(3, 2)));
+
+        double second = tree.getBoundarySize();
+        double third = tree.getBoundarySize();
+
+        // assert
+        Assert.assertEquals(6.0, first, PartitionTestUtils.EPS);
+        Assert.assertEquals(4.0, second, PartitionTestUtils.EPS);
+        Assert.assertEquals(4.0, third, PartitionTestUtils.EPS);
+    }
+
+    @Test
+    public void testGetCutBoundary_emptyTree() {
+        // act
+        RegionCutBoundary<TestPoint2D> boundary = root.getCutBoundary();
+
+        // assert
+        Assert.assertNull(boundary);
+    }
+
+    @Test
+    public void tetsGetCutBoundary_singleCut() {
+        // arrange
+        tree.insert(new TestLineSegment(new TestPoint2D(0, 0), new TestPoint2D(1, 0)));
+
+        // act
+        RegionCutBoundary<TestPoint2D> boundary = root.getCutBoundary();
+
+        // assert
+        Assert.assertTrue(boundary.getInsideFacing().isEmpty());
+
+        assertCutBoundarySegment(boundary.getOutsideFacing(),
+                new TestPoint2D(Double.NEGATIVE_INFINITY, 0.0), new TestPoint2D(Double.POSITIVE_INFINITY, 0.0));
+    }
+
+    @Test
+    public void tetsGetCutBoundary_singleCut_leafNode() {
+        // arrange
+        tree.insert(new TestLineSegment(new TestPoint2D(0, 0), new TestPoint2D(1, 0)));
+
+        // act
+        RegionCutBoundary<TestPoint2D> boundary = root.getMinus().getCutBoundary();
+
+        // assert
+        Assert.assertNull(boundary);
+    }
+
+    @Test
+    public void tetsGetCutBoundary_singleCorner() {
+        // arrange
+        tree.insert(new TestLineSegment(new TestPoint2D(-1, 0), new TestPoint2D(1, 0)));
+        tree.insert(new TestLineSegment(TestPoint2D.ZERO, new TestPoint2D(0, 1)));
+
+        // act/assert
+        RegionCutBoundary<TestPoint2D> rootBoundary = root.getCutBoundary();
+
+        Assert.assertTrue(rootBoundary.getInsideFacing().isEmpty());
+        assertCutBoundarySegment(rootBoundary.getOutsideFacing(),
+                new TestPoint2D(Double.NEGATIVE_INFINITY, 0.0), TestPoint2D.ZERO);
+
+        RegionCutBoundary<TestPoint2D> childBoundary = tree.getRoot().getMinus().getCutBoundary();
+        Assert.assertTrue(childBoundary.getInsideFacing().isEmpty());
+        assertCutBoundarySegment(childBoundary.getOutsideFacing(),
+                TestPoint2D.ZERO, new TestPoint2D(0.0, Double.POSITIVE_INFINITY));
+    }
+
+    @Test
+    public void tetsGetCutBoundary_leafNode() {
+        // arrange
+        tree.insert(new TestLineSegment(new TestPoint2D(-1, 0), new TestPoint2D(1, 0)));
+        tree.insert(new TestLineSegment(TestPoint2D.ZERO, new TestPoint2D(0, 1)));
+
+        // act/assert
+        Assert.assertNull(root.getPlus().getCutBoundary());
+        Assert.assertNull(root.getMinus().getMinus().getCutHyperplane());
+        Assert.assertNull(root.getMinus().getPlus().getCutHyperplane());
+    }
+
+    @Test
+    public void testFullEmpty_fullTree() {
+        // act/assert
+        Assert.assertTrue(tree.isFull());
+        Assert.assertFalse(tree.isEmpty());
+        Assert.assertEquals(RegionLocation.INSIDE, tree.getRoot().getLocation());
+    }
+
+    @Test
+    public void testFullEmpty_emptyTree() {
+        // arrange
+        tree.complement();
+
+        // act/assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertTrue(tree.isEmpty());
+        Assert.assertEquals(RegionLocation.OUTSIDE, tree.getRoot().getLocation());
+    }
+
+    @Test
+    public void testTransform_noCuts() {
+        // arrange
+        Transform<TestPoint2D> t = new TestTransform2D(p -> new TestPoint2D(p.getX(), p.getY() + 2));
+
+        // act
+        tree.transform(t);
+
+        // assert
+        Assert.assertTrue(tree.isFull());
+        Assert.assertFalse(tree.isEmpty());
+
+        assertPointLocations(tree, RegionLocation.INSIDE, TestPoint2D.ZERO);
+    }
+
+    @Test
+    public void testTransform_singleCut() {
+        // arrange
+        tree.getRoot().insertCut(TestLine.X_AXIS);
+
+        Transform<TestPoint2D> t = new TestTransform2D(p -> new TestPoint2D(p.getX(), p.getY() + 2));
+
+        // act
+        tree.transform(t);
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertFalse(tree.isEmpty());
+
+        assertPointLocations(tree, RegionLocation.OUTSIDE,
+                new TestPoint2D(0, -1), TestPoint2D.ZERO, new TestPoint2D(0, 1));
+
+        assertPointLocations(tree, RegionLocation.BOUNDARY, new TestPoint2D(0, 2));
+
+        assertPointLocations(tree, RegionLocation.INSIDE,
+                new TestPoint2D(0, 3), new TestPoint2D(0, 4));
+    }
+
+    @Test
+    public void testTransform_multipleCuts() {
+        // arrange
+        insertSkewedBowtie(tree);
+
+        Transform<TestPoint2D> t = new TestTransform2D(p -> new TestPoint2D(0.5 * p.getX(), p.getY() + 5));
+
+        // act
+        tree.transform(t);
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertFalse(tree.isEmpty());
+
+        assertPointLocations(tree, RegionLocation.INSIDE,
+                new TestPoint2D(0, 5), new TestPoint2D(-1, 4), new TestPoint2D(1, 6));
+
+        assertPointLocations(tree, RegionLocation.BOUNDARY,
+                new TestPoint2D(-2, 4), new TestPoint2D(2, 6));
+
+        assertPointLocations(tree, RegionLocation.OUTSIDE,
+                new TestPoint2D(-3, 5), new TestPoint2D(3, 5));
+    }
+
+    @Test
+    public void testTransform_xAxisReflection() {
+        // arrange
+        insertSkewedBowtie(tree);
+
+        Transform<TestPoint2D> t = new TestTransform2D(p -> new TestPoint2D(-p.getX(), p.getY()));
+
+        // act
+        tree.transform(t);
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertFalse(tree.isEmpty());
+
+        assertPointLocations(tree, RegionLocation.INSIDE,
+                TestPoint2D.ZERO, new TestPoint2D(-1, 1), new TestPoint2D(1, -1));
+
+        assertPointLocations(tree, RegionLocation.BOUNDARY,
+                new TestPoint2D(0, 1), new TestPoint2D(0, -1),
+                new TestPoint2D(-4, 0), new TestPoint2D(4, 0));
+
+        assertPointLocations(tree, RegionLocation.OUTSIDE,
+                new TestPoint2D(1, 1), new TestPoint2D(-1, -1));
+    }
+
+    @Test
+    public void testTransform_yAxisReflection() {
+        // arrange
+        insertSkewedBowtie(tree);
+
+        Transform<TestPoint2D> t = new TestTransform2D(p -> new TestPoint2D(p.getX(), -p.getY()));
+
+        // act
+        tree.transform(t);
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertFalse(tree.isEmpty());
+
+        assertPointLocations(tree, RegionLocation.INSIDE,
+                TestPoint2D.ZERO, new TestPoint2D(1, -1), new TestPoint2D(-1, 1));
+
+        assertPointLocations(tree, RegionLocation.BOUNDARY,
+                new TestPoint2D(0, 1), new TestPoint2D(0, -1),
+                new TestPoint2D(-4, 0), new TestPoint2D(4, 0));
+
+        assertPointLocations(tree, RegionLocation.OUTSIDE,
+                new TestPoint2D(-1, -1), new TestPoint2D(1, 1));
+    }
+
+    @Test
+    public void testTransform_xAndYAxisReflection() {
+        // arrange
+        insertSkewedBowtie(tree);
+
+        Transform<TestPoint2D> t = new TestTransform2D(p -> new TestPoint2D(-p.getX(), -p.getY()));
+
+        // act
+        tree.transform(t);
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertFalse(tree.isEmpty());
+
+        assertPointLocations(tree, RegionLocation.INSIDE,
+                TestPoint2D.ZERO, new TestPoint2D(1, 1), new TestPoint2D(-1, -1));
+
+        assertPointLocations(tree, RegionLocation.BOUNDARY,
+                new TestPoint2D(0, 1), new TestPoint2D(0, -1),
+                new TestPoint2D(-4, 0), new TestPoint2D(4, 0));
+
+        assertPointLocations(tree, RegionLocation.OUTSIDE,
+                new TestPoint2D(-1, 1), new TestPoint2D(1, -1));
+    }
+
+    @Test
+    public void testTransform_resetsCutBoundary() {
+        // arrange
+        insertSkewedBowtie(tree);
+
+        TestRegionNode node = tree.findNode(new TestPoint2D(1, 1)).getParent();
+
+
+        Transform<TestPoint2D> t = new TestTransform2D(p -> new TestPoint2D(0.5 * p.getX(), p.getY() + 5));
+
+        // act
+        RegionCutBoundary<TestPoint2D> origBoundary = node.getCutBoundary();
+
+        tree.transform(t);
+
+        RegionCutBoundary<TestPoint2D> resultBoundary = node.getCutBoundary();
+
+        // assert
+        Assert.assertNotSame(origBoundary, resultBoundary);
+
+        assertCutBoundarySegment(origBoundary.getOutsideFacing(), new TestPoint2D(4, 5), new TestPoint2D(-1, 0));
+
+        assertCutBoundarySegment(resultBoundary.getOutsideFacing(), new TestPoint2D(2, 10), new TestPoint2D(-0.5, 5));
+    }
+
+    @Test
+    public void testComplement_rootOnly() {
+        // act
+        tree.complement();
+
+        // assert
+        Assert.assertTrue(tree.isEmpty());
+        Assert.assertFalse(tree.isFull());
+
+        Assert.assertEquals(RegionLocation.OUTSIDE, root.getLocation());
+        Assert.assertEquals(RegionLocation.OUTSIDE, tree.classify(TestPoint2D.ZERO));
+    }
+
+    @Test
+    public void testComplement_singleCut() {
+        // arrange
+        root.insertCut(TestLine.X_AXIS);
+
+        // act
+        tree.complement();
+
+        // assert
+        Assert.assertFalse(tree.isEmpty());
+        Assert.assertFalse(tree.isFull());
+
+        Assert.assertEquals(RegionLocation.OUTSIDE, root.getMinus().getLocation());
+        Assert.assertEquals(RegionLocation.INSIDE, root.getPlus().getLocation());
+
+        Assert.assertEquals(RegionLocation.OUTSIDE, tree.classify(new TestPoint2D(0, 1)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, tree.classify(TestPoint2D.ZERO));
+        Assert.assertEquals(RegionLocation.INSIDE, tree.classify(new TestPoint2D(0, -1)));
+    }
+
+    @Test
+    public void testComplement_skewedBowtie() {
+        // arrange
+        insertSkewedBowtie(tree);
+
+        // act
+        tree.complement();
+
+        // assert
+        Assert.assertFalse(tree.isEmpty());
+        Assert.assertFalse(tree.isFull());
+
+        Assert.assertEquals(RegionLocation.OUTSIDE, tree.classify(new TestPoint2D(3, 1)));
+        Assert.assertEquals(RegionLocation.OUTSIDE, tree.classify(new TestPoint2D(-3, -1)));
+
+        Assert.assertEquals(RegionLocation.INSIDE, tree.classify(new TestPoint2D(-3, 1)));
+        Assert.assertEquals(RegionLocation.INSIDE, tree.classify(new TestPoint2D(3, -1)));
+
+        Assert.assertEquals(RegionLocation.BOUNDARY, tree.classify(new TestPoint2D(4, 5)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, tree.classify(new TestPoint2D(-4, -5)));
+
+        Assert.assertEquals(RegionLocation.INSIDE, tree.classify(new TestPoint2D(5, 0)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, tree.classify(new TestPoint2D(4, 0)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, tree.classify(new TestPoint2D(3, 0)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, tree.classify(new TestPoint2D(2, 0)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, tree.classify(new TestPoint2D(1, 0)));
+        Assert.assertEquals(RegionLocation.OUTSIDE, tree.classify(new TestPoint2D(0, 0)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, tree.classify(new TestPoint2D(-1, 0)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, tree.classify(new TestPoint2D(-2, 0)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, tree.classify(new TestPoint2D(-3, 0)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, tree.classify(new TestPoint2D(-4, 0)));
+        Assert.assertEquals(RegionLocation.INSIDE, tree.classify(new TestPoint2D(-5, 0)));
+    }
+
+    @Test
+    public void testComplement_addCutAfterComplement() {
+        // arrange
+        tree.insert(new TestLineSegment(TestPoint2D.ZERO, new TestPoint2D(1, 0)));
+        tree.complement();
+
+        // act
+        tree.insert(new TestLineSegment(TestPoint2D.ZERO, new TestPoint2D(0, 1)));
+
+        // assert
+        Assert.assertFalse(tree.isEmpty());
+        Assert.assertFalse(tree.isFull());
+
+        Assert.assertEquals(RegionLocation.BOUNDARY, tree.classify(TestPoint2D.ZERO));
+
+        Assert.assertEquals(RegionLocation.OUTSIDE, tree.classify(new TestPoint2D(1, 1)));
+        Assert.assertEquals(RegionLocation.INSIDE, tree.classify(new TestPoint2D(-1, 1)));
+        Assert.assertEquals(RegionLocation.INSIDE, tree.classify(new TestPoint2D(1, -1)));
+        Assert.assertEquals(RegionLocation.INSIDE, tree.classify(new TestPoint2D(-1, -1)));
+    }
+
+    @Test
+    public void testComplement_clearCutAfterComplement() {
+        // arrange
+        tree.insert(Arrays.asList(
+                    new TestLineSegment(TestPoint2D.ZERO, new TestPoint2D(1, 0)),
+                    new TestLineSegment(TestPoint2D.ZERO, new TestPoint2D(0, 1))
+                ));
+        tree.complement();
+
+        // act
+        root.getMinus().clearCut();
+
+        // assert
+        Assert.assertFalse(tree.isEmpty());
+        Assert.assertFalse(tree.isFull());
+
+        Assert.assertEquals(RegionLocation.BOUNDARY, tree.classify(TestPoint2D.ZERO));
+
+        Assert.assertEquals(RegionLocation.OUTSIDE, tree.classify(new TestPoint2D(1, 1)));
+        Assert.assertEquals(RegionLocation.OUTSIDE, tree.classify(new TestPoint2D(-1, 1)));
+        Assert.assertEquals(RegionLocation.INSIDE, tree.classify(new TestPoint2D(1, -1)));
+        Assert.assertEquals(RegionLocation.INSIDE, tree.classify(new TestPoint2D(-1, -1)));
+    }
+
+    @Test
+    public void testComplement_clearRootAfterComplement() {
+        // arrange
+        tree.insert(Arrays.asList(
+                    new TestLineSegment(TestPoint2D.ZERO, new TestPoint2D(1, 0)),
+                    new TestLineSegment(TestPoint2D.ZERO, new TestPoint2D(0, 1))
+                ));
+        tree.complement();
+
+        // act
+        root.clearCut();
+
+        // assert
+        Assert.assertTrue(tree.isEmpty());
+        Assert.assertFalse(tree.isFull());
+
+        Assert.assertEquals(RegionLocation.OUTSIDE, tree.classify(TestPoint2D.ZERO));
+    }
+
+    @Test
+    public void testComplement_isReversible_root() {
+        // act
+        tree.complement();
+        tree.complement();
+
+        // assert
+        Assert.assertFalse(tree.isEmpty());
+        Assert.assertTrue(tree.isFull());
+
+        Assert.assertEquals(RegionLocation.INSIDE, root.getLocation());
+        Assert.assertEquals(RegionLocation.INSIDE, tree.classify(TestPoint2D.ZERO));
+    }
+
+    @Test
+    public void testComplement_isReversible_skewedBowtie() {
+        // arrange
+        insertSkewedBowtie(tree);
+
+        // act
+        tree.complement();
+        tree.complement();
+
+        // assert
+        Assert.assertEquals(RegionLocation.INSIDE, tree.classify(new TestPoint2D(3, 1)));
+        Assert.assertEquals(RegionLocation.INSIDE, tree.classify(new TestPoint2D(-3, -1)));
+
+        Assert.assertEquals(RegionLocation.OUTSIDE, tree.classify(new TestPoint2D(-3, 1)));
+        Assert.assertEquals(RegionLocation.OUTSIDE, tree.classify(new TestPoint2D(3, -1)));
+
+        Assert.assertEquals(RegionLocation.BOUNDARY, tree.classify(new TestPoint2D(4, 5)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, tree.classify(new TestPoint2D(-4, -5)));
+
+        Assert.assertEquals(RegionLocation.OUTSIDE, tree.classify(new TestPoint2D(5, 0)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, tree.classify(new TestPoint2D(4, 0)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, tree.classify(new TestPoint2D(3, 0)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, tree.classify(new TestPoint2D(2, 0)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, tree.classify(new TestPoint2D(1, 0)));
+        Assert.assertEquals(RegionLocation.INSIDE, tree.classify(new TestPoint2D(0, 0)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, tree.classify(new TestPoint2D(-1, 0)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, tree.classify(new TestPoint2D(-2, 0)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, tree.classify(new TestPoint2D(-3, 0)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, tree.classify(new TestPoint2D(-4, 0)));
+        Assert.assertEquals(RegionLocation.OUTSIDE, tree.classify(new TestPoint2D(-5, 0)));
+    }
+
+    @Test
+    public void testComplement_getCutBoundary() {
+        // arrange
+        tree.insert(Arrays.asList(
+                new TestLineSegment(TestPoint2D.ZERO, new TestPoint2D(1, 0)),
+                new TestLineSegment(TestPoint2D.ZERO, new TestPoint2D(0, 1))));
+        tree.complement();
+
+        // act
+        RegionCutBoundary<TestPoint2D> xAxisBoundary = root.getCutBoundary();
+        RegionCutBoundary<TestPoint2D> yAxisBoundary = root.getMinus().getCutBoundary();
+
+        // assert
+        Assert.assertTrue(xAxisBoundary.getOutsideFacing().isEmpty());
+        Assert.assertFalse(xAxisBoundary.getInsideFacing().isEmpty());
+
+        TestLineSegmentCollection xAxisInsideFacing = (TestLineSegmentCollection) xAxisBoundary.getInsideFacing();
+        Assert.assertEquals(1, xAxisInsideFacing.getLineSegments().size());
+
+        TestLineSegment xAxisSeg = xAxisInsideFacing.getLineSegments().get(0);
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(Double.NEGATIVE_INFINITY, 0), xAxisSeg.getStartPoint());
+        PartitionTestUtils.assertPointsEqual(TestPoint2D.ZERO, xAxisSeg.getEndPoint());
+
+        Assert.assertTrue(yAxisBoundary.getOutsideFacing().isEmpty());
+        Assert.assertFalse(yAxisBoundary.getInsideFacing().isEmpty());
+
+        TestLineSegmentCollection yAxisInsideFacing = (TestLineSegmentCollection) yAxisBoundary.getInsideFacing();
+        Assert.assertEquals(1, yAxisInsideFacing.getLineSegments().size());
+
+        TestLineSegment yAxisSeg = yAxisInsideFacing.getLineSegments().get(0);
+        PartitionTestUtils.assertPointsEqual(TestPoint2D.ZERO, yAxisSeg.getStartPoint());
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(0, Double.POSITIVE_INFINITY), yAxisSeg.getEndPoint());
+    }
+
+    @Test
+    public void testComplementOf_rootOnly() {
+        // arrange
+        TestRegionBSPTree other = fullTree();
+        insertSkewedBowtie(other);
+
+        // act
+        other.complement(tree);
+
+        // assert
+        Assert.assertFalse(tree.isEmpty());
+        Assert.assertTrue(tree.isFull());
+
+        Assert.assertEquals(RegionLocation.INSIDE, root.getLocation());
+        Assert.assertEquals(RegionLocation.INSIDE, tree.classify(TestPoint2D.ZERO));
+
+        Assert.assertTrue(other.isEmpty());
+        Assert.assertFalse(other.isFull());
+
+        Assert.assertEquals(RegionLocation.OUTSIDE, other.getRoot().getLocation());
+        Assert.assertEquals(RegionLocation.OUTSIDE, other.classify(TestPoint2D.ZERO));
+    }
+
+    @Test
+    public void testComplementOf_skewedBowtie() {
+        // arrange
+        insertSkewedBowtie(tree);
+
+        TestRegionBSPTree other = fullTree();
+
+        // act
+        other.complement(tree);
+
+        // assert
+        Assert.assertEquals(RegionLocation.INSIDE, tree.classify(new TestPoint2D(3, 1)));
+        Assert.assertEquals(RegionLocation.INSIDE, tree.classify(new TestPoint2D(-3, -1)));
+
+        Assert.assertFalse(other.isEmpty());
+        Assert.assertFalse(other.isFull());
+
+        Assert.assertEquals(RegionLocation.OUTSIDE, other.classify(new TestPoint2D(3, 1)));
+        Assert.assertEquals(RegionLocation.OUTSIDE, other.classify(new TestPoint2D(-3, -1)));
+
+        Assert.assertEquals(RegionLocation.INSIDE, other.classify(new TestPoint2D(-3, 1)));
+        Assert.assertEquals(RegionLocation.INSIDE, other.classify(new TestPoint2D(3, -1)));
+
+        Assert.assertEquals(RegionLocation.BOUNDARY, other.classify(new TestPoint2D(4, 5)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, other.classify(new TestPoint2D(-4, -5)));
+
+        Assert.assertEquals(RegionLocation.INSIDE, other.classify(new TestPoint2D(5, 0)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, other.classify(new TestPoint2D(4, 0)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, other.classify(new TestPoint2D(3, 0)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, other.classify(new TestPoint2D(2, 0)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, other.classify(new TestPoint2D(1, 0)));
+        Assert.assertEquals(RegionLocation.OUTSIDE, other.classify(new TestPoint2D(0, 0)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, other.classify(new TestPoint2D(-1, 0)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, other.classify(new TestPoint2D(-2, 0)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, other.classify(new TestPoint2D(-3, 0)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, other.classify(new TestPoint2D(-4, 0)));
+        Assert.assertEquals(RegionLocation.INSIDE, other.classify(new TestPoint2D(-5, 0)));
+    }
+
+    @Test
+    public void testCopy() {
+        // arrange
+        insertSkewedBowtie(tree);
+
+        // act
+        TestRegionBSPTree copy = fullTree();
+        copy.copy(tree);
+
+        // assert
+        Assert.assertNotSame(tree, copy);
+        Assert.assertEquals(tree.count(), copy.count());
+
+        List<RegionLocation> origLocations = new ArrayList<>();
+        tree.forEach(n -> origLocations.add(n.getLocationValue()));
+
+        List<RegionLocation> copyLocations = new ArrayList<>();
+        copy.forEach(n -> copyLocations.add(n.getLocationValue()));
+
+        Assert.assertEquals(origLocations, copyLocations);
+    }
+
+    @Test
+    public void testExtract() {
+        // arrange
+        insertSkewedBowtie(tree);
+
+        TestRegionBSPTree result = fullTree();
+
+        TestPoint2D pt = new TestPoint2D(2, 2);
+
+        // act
+        result.extract(tree.findNode(pt));
+
+        // assert
+        assertPointLocations(result, RegionLocation.INSIDE,
+                new TestPoint2D(0, 0.5), new TestPoint2D(2, 2));
+        assertPointLocations(result, RegionLocation.OUTSIDE,
+                new TestPoint2D(-2, 2),
+                new TestPoint2D(-2, -2), new TestPoint2D(0, -0.5), new TestPoint2D(-2, 2));
+
+        assertPointLocations(tree, RegionLocation.INSIDE,
+                new TestPoint2D(0, 0.5), new TestPoint2D(2, 2),
+                new TestPoint2D(-2, -2), new TestPoint2D(0, -0.5));
+        assertPointLocations(tree, RegionLocation.OUTSIDE,
+                new TestPoint2D(2, -2), new TestPoint2D(-2, 2));
+    }
+
+    @Test
+    public void testExtract_complementedTree() {
+        // arrange
+        insertSkewedBowtie(tree);
+        tree.complement();
+
+        TestRegionBSPTree result = fullTree();
+
+        TestPoint2D pt = new TestPoint2D(2, 2);
+
+        // act
+        result.extract(tree.findNode(pt));
+
+        // assert
+        assertPointLocations(result, RegionLocation.OUTSIDE,
+                new TestPoint2D(0, 0.5), new TestPoint2D(2, 2));
+        assertPointLocations(result, RegionLocation.INSIDE,
+                new TestPoint2D(-2, 2),
+                new TestPoint2D(-2, -2), new TestPoint2D(0, -0.5), new TestPoint2D(-2, 2));
+
+        assertPointLocations(tree, RegionLocation.OUTSIDE,
+                new TestPoint2D(0, 0.5), new TestPoint2D(2, 2),
+                new TestPoint2D(-2, -2), new TestPoint2D(0, -0.5));
+        assertPointLocations(tree, RegionLocation.INSIDE,
+                new TestPoint2D(2, -2), new TestPoint2D(-2, 2));
+    }
+
+
+    @Test
+    public void testUnion_singleNodeTrees() {
+        // act/assert
+        unionChecker(AbstractRegionBSPTreeTest::emptyTree, AbstractRegionBSPTreeTest::emptyTree)
+            .full(false)
+            .empty(true)
+            .count(1)
+            .check();
+
+        unionChecker(AbstractRegionBSPTreeTest::fullTree, AbstractRegionBSPTreeTest::emptyTree)
+            .full(true)
+            .empty(false)
+            .count(1)
+            .check();
+
+        unionChecker(AbstractRegionBSPTreeTest::emptyTree, AbstractRegionBSPTreeTest::fullTree)
+            .full(true)
+            .empty(false)
+            .count(1)
+            .check();
+
+        unionChecker(AbstractRegionBSPTreeTest::fullTree, AbstractRegionBSPTreeTest::fullTree)
+            .full(true)
+            .empty(false)
+            .count(1)
+            .check();
+    }
+
+    @Test
+    public void testUnion_simpleCrossingCuts() {
+        // act/assert
+        unionChecker(AbstractRegionBSPTreeTest::xAxisTree, AbstractRegionBSPTreeTest::emptyTree)
+            .full(false)
+            .empty(false)
+            .count(3)
+            .check();
+
+        unionChecker(AbstractRegionBSPTreeTest::emptyTree, AbstractRegionBSPTreeTest::xAxisTree)
+            .full(false)
+            .empty(false)
+            .count(3)
+            .check();
+
+        unionChecker(AbstractRegionBSPTreeTest::yAxisTree, AbstractRegionBSPTreeTest::fullTree)
+            .full(true)
+            .empty(false)
+            .count(1)
+            .check();
+
+        unionChecker(AbstractRegionBSPTreeTest::fullTree, AbstractRegionBSPTreeTest::yAxisTree)
+            .full(true)
+            .empty(false)
+            .count(1)
+            .check();
+
+        unionChecker(AbstractRegionBSPTreeTest::xAxisTree, AbstractRegionBSPTreeTest::yAxisTree)
+            .full(false)
+            .empty(false)
+            .inside(new TestPoint2D(1, 1), new TestPoint2D(-1, 1), new TestPoint2D(-1, -1))
+            .outside(new TestPoint2D(1, -1))
+            .boundary(TestPoint2D.ZERO)
+            .count(5)
+            .check(tree -> {
+                TestLineSegment seg = (TestLineSegment) tree.getRoot().getPlus().getCut();
+
+                PartitionTestUtils.assertPointsEqual(new TestPoint2D(0.0, Double.NEGATIVE_INFINITY), seg.getStartPoint());
+                PartitionTestUtils.assertPointsEqual(TestPoint2D.ZERO, seg.getEndPoint());
+            });
+    }
+
+    @Test
+    public void testUnion_boxTreeWithSingleCutTree() {
+        // arrange
+        Supplier<TestRegionBSPTree> boxFactory = () -> {
+            TestRegionBSPTree box = fullTree();
+            insertBox(box, TestPoint2D.ZERO, new TestPoint2D(2, -4));
+            return box;
+        };
+
+        Supplier<TestRegionBSPTree> horizonalFactory = () -> {
+            TestRegionBSPTree horizontal = fullTree();
+            horizontal.getRoot().insertCut(new TestLine(new TestPoint2D(2, 2), new TestPoint2D(0, 2)));
+
+            return horizontal;
+        };
+
+        // act/assert
+        unionChecker(horizonalFactory, boxFactory)
+            .count(3)
+            .inside(TestPoint2D.ZERO, new TestPoint2D(1, 1), new TestPoint2D(1, -1))
+            .outside(new TestPoint2D(0, 3), new TestPoint2D(3, 3))
+            .boundary(new TestPoint2D(-1, 2), new TestPoint2D(3, 2))
+            .check();
+    }
+
+    @Test
+    public void testUnion_treeWithComplement() {
+        // arrange
+        Supplier<TestRegionBSPTree> treeFactory = () -> {
+            TestRegionBSPTree tree = fullTree();
+            insertSkewedBowtie(tree);
+
+            return tree;
+        };
+        Supplier<TestRegionBSPTree> complementFactory = () -> {
+            TestRegionBSPTree tree = treeFactory.get();
+            tree.complement();
+
+            return tree;
+        };
+
+        // act/assert
+        unionChecker(treeFactory, complementFactory)
+            .full(true)
+            .empty(false)
+            .count(1)
+            .check();
+    }
+
+    @Test
+    public void testIntersection_singleNodeTrees() {
+        // act/assert
+        intersectionChecker(AbstractRegionBSPTreeTest::emptyTree, AbstractRegionBSPTreeTest::emptyTree)
+            .full(false)
+            .empty(true)
+            .count(1)
+            .check();
+
+        intersectionChecker(AbstractRegionBSPTreeTest::fullTree, AbstractRegionBSPTreeTest::emptyTree)
+            .full(false)
+            .empty(true)
+            .count(1)
+            .check();
+
+        intersectionChecker(AbstractRegionBSPTreeTest::emptyTree, AbstractRegionBSPTreeTest::fullTree)
+            .full(false)
+            .empty(true)
+            .count(1)
+            .check();
+
+        intersectionChecker(AbstractRegionBSPTreeTest::fullTree, AbstractRegionBSPTreeTest::fullTree)
+            .full(true)
+            .empty(false)
+            .count(1)
+            .check();
+    }
+
+    @Test
+    public void testIntersection_simpleCrossingCuts() {
+        // act/assert
+        intersectionChecker(AbstractRegionBSPTreeTest::xAxisTree, AbstractRegionBSPTreeTest::emptyTree)
+            .full(false)
+            .empty(true)
+            .count(1)
+            .check();
+
+        intersectionChecker(AbstractRegionBSPTreeTest::emptyTree, AbstractRegionBSPTreeTest::xAxisTree)
+            .full(false)
+            .empty(true)
+            .count(1)
+            .check();
+
+        intersectionChecker(AbstractRegionBSPTreeTest::yAxisTree, AbstractRegionBSPTreeTest::fullTree)
+            .full(false)
+            .empty(false)
+            .inside(new TestPoint2D(-1, 1), new TestPoint2D(-1, -1))
+            .outside(new TestPoint2D(1, 1), new TestPoint2D(1, -1))
+            .boundary(new TestPoint2D(0, 1), new TestPoint2D(0, -1))
+            .count(3)
+            .check();
+
+        intersectionChecker(AbstractRegionBSPTreeTest::fullTree, AbstractRegionBSPTreeTest::yAxisTree)
+            .full(false)
+            .empty(false)
+            .inside(new TestPoint2D(-1, 1), new TestPoint2D(-1, -1))
+            .outside(new TestPoint2D(1, 1), new TestPoint2D(1, -1))
+            .boundary(new TestPoint2D(0, 1), new TestPoint2D(0, -1))
+            .count(3)
+            .check();
+
+        intersectionChecker(AbstractRegionBSPTreeTest::xAxisTree, AbstractRegionBSPTreeTest::yAxisTree)
+            .full(false)
+            .empty(false)
+            .inside(new TestPoint2D(-1, 1))
+            .outside(new TestPoint2D(1, 1), new TestPoint2D(1, -1), new TestPoint2D(-1, -1))
+            .boundary(TestPoint2D.ZERO)
+            .count(5)
+            .check(tree -> {
+                TestLineSegment seg = (TestLineSegment) tree.getRoot().getMinus().getCut();
+
+                PartitionTestUtils.assertPointsEqual(TestPoint2D.ZERO, seg.getStartPoint());
+                PartitionTestUtils.assertPointsEqual(new TestPoint2D(0.0, Double.POSITIVE_INFINITY), seg.getEndPoint());
+            });
+    }
+
+    @Test
+    public void testIntersection_boxTreeWithSingleCutTree() {
+        // arrange
+        Supplier<TestRegionBSPTree> boxFactory = () -> {
+            TestRegionBSPTree box = fullTree();
+            insertBox(box, TestPoint2D.ZERO, new TestPoint2D(2, -4));
+            return box;
+        };
+
+        Supplier<TestRegionBSPTree> horizonalFactory = () -> {
+            TestRegionBSPTree horizontal = fullTree();
+            horizontal.getRoot().insertCut(new TestLine(new TestPoint2D(2, -2), new TestPoint2D(0, -2)));
+
+            return horizontal;
+        };
+
+        // act/assert
+        intersectionChecker(horizonalFactory, boxFactory)
+            .inside(new TestPoint2D(1, -3))
+            .outside(new TestPoint2D(1, -1), new TestPoint2D(-1, -3),
+                    new TestPoint2D(1, -5), new TestPoint2D(3, -3))
+            .boundary(new TestPoint2D(0, -2), new TestPoint2D(2, -2),
+                    new TestPoint2D(0, -4), new TestPoint2D(2, -4))
+            .count(9)
+            .check();
+    }
+
+    @Test
+    public void testIntersection_treeWithComplement() {
+        // arrange
+        Supplier<TestRegionBSPTree> treeFactory = () -> {
+            TestRegionBSPTree tree = fullTree();
+            insertSkewedBowtie(tree);
+
+            return tree;
+        };
+        Supplier<TestRegionBSPTree> complementFactory = () -> {
+            TestRegionBSPTree tree = treeFactory.get();
+            tree.complement();
+
+            return tree;
+        };
+
+        // act/assert
+        intersectionChecker(treeFactory, complementFactory)
+            .full(false)
+            .empty(true)
+            .count(1)
+            .check();
+    }
+
+    @Test
+    public void testDifference_singleNodeTrees() {
+        // act/assert
+        differenceChecker(AbstractRegionBSPTreeTest::emptyTree, AbstractRegionBSPTreeTest::emptyTree)
+            .full(false)
+            .empty(true)
+            .count(1)
+            .check();
+
+        differenceChecker(AbstractRegionBSPTreeTest::fullTree, AbstractRegionBSPTreeTest::emptyTree)
+            .full(true)
+            .empty(false)
+            .count(1)
+            .check();
+
+        differenceChecker(AbstractRegionBSPTreeTest::emptyTree, AbstractRegionBSPTreeTest::fullTree)
+            .full(false)
+            .empty(true)
+            .count(1)
+            .check();
+
+        differenceChecker(AbstractRegionBSPTreeTest::fullTree, AbstractRegionBSPTreeTest::fullTree)
+            .full(false)
+            .empty(true)
+            .count(1)
+            .check();
+    }
+
+    @Test
+    public void testDifference_simpleCrossingCuts() {
+        // act/assert
+        differenceChecker(AbstractRegionBSPTreeTest::xAxisTree, AbstractRegionBSPTreeTest::emptyTree)
+            .full(false)
+            .empty(false)
+            .inside(new TestPoint2D(0, 1))
+            .outside(new TestPoint2D(0, -1))
+            .boundary(TestPoint2D.ZERO)
+            .count(3)
+            .check();
+
+        differenceChecker(AbstractRegionBSPTreeTest::emptyTree, AbstractRegionBSPTreeTest::xAxisTree)
+            .full(false)
+            .empty(true)
+            .count(1)
+            .check();
+
+        differenceChecker(AbstractRegionBSPTreeTest::yAxisTree, AbstractRegionBSPTreeTest::fullTree)
+            .full(false)
+            .empty(true)
+            .count(1)
+            .check();
+
+        differenceChecker(AbstractRegionBSPTreeTest::fullTree, AbstractRegionBSPTreeTest::yAxisTree)
+            .full(false)
+            .empty(false)
+            .inside(new TestPoint2D(1, 1), new TestPoint2D(1, -1))
+            .outside(new TestPoint2D(-1, 1), new TestPoint2D(-1, -1))
+            .boundary(new TestPoint2D(0, 1), new TestPoint2D(0, -1))
+            .count(3)
+            .check();
+
+        differenceChecker(AbstractRegionBSPTreeTest::xAxisTree, AbstractRegionBSPTreeTest::yAxisTree)
+            .full(false)
+            .empty(false)
+            .inside(new TestPoint2D(1, 1))
+            .outside(new TestPoint2D(-1, 1), new TestPoint2D(1, -1), new TestPoint2D(-1, -1))
+            .boundary(TestPoint2D.ZERO)
+            .count(5)
+            .check(tree -> {
+                TestLineSegment seg = (TestLineSegment) tree.getRoot().getMinus().getCut();
+
+                PartitionTestUtils.assertPointsEqual(TestPoint2D.ZERO, seg.getStartPoint());
+                PartitionTestUtils.assertPointsEqual(new TestPoint2D(0.0, Double.POSITIVE_INFINITY), seg.getEndPoint());
+            });
+    }
+
+    @Test
+    public void testDifference_boxTreeWithSingleCutTree() {
+        // arrange
+        Supplier<TestRegionBSPTree> boxFactory = () -> {
+            TestRegionBSPTree box = fullTree();
+            insertBox(box, TestPoint2D.ZERO, new TestPoint2D(2, -4));
+            return box;
+        };
+
+        Supplier<TestRegionBSPTree> horizonalFactory = () -> {
+            TestRegionBSPTree horizontal = fullTree();
+            horizontal.getRoot().insertCut(new TestLine(new TestPoint2D(2, -2), new TestPoint2D(0, -2)));
+
+            return horizontal;
+        };
+
+        // act/assert
+        differenceChecker(horizonalFactory, boxFactory)
+            .inside(new TestPoint2D(-1, -3), new TestPoint2D(-1, -5),
+                    new TestPoint2D(1, -5), new TestPoint2D(3, -5),
+                    new TestPoint2D(4, -3))
+            .outside(new TestPoint2D(1, -1), new TestPoint2D(1, -1),
+                    new TestPoint2D(3, -1), new TestPoint2D(1, -3))
+            .boundary(new TestPoint2D(0, -2), new TestPoint2D(0, -4),
+                    new TestPoint2D(2, -4), new TestPoint2D(2, -2))
+            .count(9)
+            .check();
+    }
+
+    @Test
+    public void testDifference_treeWithCopy() {
+        // arrange
+        Supplier<TestRegionBSPTree> treeFactory = () -> {
+            TestRegionBSPTree tree = fullTree();
+            insertSkewedBowtie(tree);
+
+            return tree;
+        };
+
+        // act/assert
+        differenceChecker(treeFactory, treeFactory)
+            .full(false)
+            .empty(true)
+            .count(1)
+            .check();
+    }
+
+    @Test
+    public void testXor_singleNodeTrees() {
+        // act/assert
+        xorChecker(AbstractRegionBSPTreeTest::emptyTree, AbstractRegionBSPTreeTest::emptyTree)
+            .full(false)
+            .empty(true)
+            .count(1)
+            .check();
+
+        xorChecker(AbstractRegionBSPTreeTest::fullTree, AbstractRegionBSPTreeTest::emptyTree)
+            .full(true)
+            .empty(false)
+            .count(1)
+            .check();
+
+        xorChecker(AbstractRegionBSPTreeTest::emptyTree, AbstractRegionBSPTreeTest::fullTree)
+            .full(true)
+            .empty(false)
+            .count(1)
+            .check();
+
+        xorChecker(AbstractRegionBSPTreeTest::fullTree, AbstractRegionBSPTreeTest::fullTree)
+            .full(false)
+            .empty(true)
+            .count(1)
+            .check();
+    }
+
+    @Test
+    public void testXor_simpleCrossingCuts() {
+        // act/assert
+        xorChecker(AbstractRegionBSPTreeTest::xAxisTree, AbstractRegionBSPTreeTest::emptyTree)
+            .full(false)
+            .empty(false)
+            .inside(new TestPoint2D(0, 1))
+            .outside(new TestPoint2D(0, -1))
+            .boundary(TestPoint2D.ZERO)
+            .count(3)
+            .check();
+
+        xorChecker(AbstractRegionBSPTreeTest::emptyTree, AbstractRegionBSPTreeTest::xAxisTree)
+            .full(false)
+            .empty(false)
+            .inside(new TestPoint2D(0, 1))
+            .outside(new TestPoint2D(0, -1))
+            .boundary(TestPoint2D.ZERO)
+            .count(3)
+            .check();
+
+        xorChecker(AbstractRegionBSPTreeTest::yAxisTree, AbstractRegionBSPTreeTest::fullTree)
+            .full(false)
+            .empty(false)
+            .inside(new TestPoint2D(1, 1), new TestPoint2D(1, -1))
+            .outside(new TestPoint2D(-1, 1), new TestPoint2D(-1, -1))
+            .boundary(new TestPoint2D(0, 1), new TestPoint2D(0, -1))
+            .count(3)
+            .check();
+
+        xorChecker(AbstractRegionBSPTreeTest::fullTree, AbstractRegionBSPTreeTest::yAxisTree)
+            .full(false)
+            .empty(false)
+            .inside(new TestPoint2D(1, 1), new TestPoint2D(1, -1))
+            .outside(new TestPoint2D(-1, 1), new TestPoint2D(-1, -1))
+            .boundary(new TestPoint2D(0, 1), new TestPoint2D(0, -1))
+            .count(3)
+            .check();
+
+        xorChecker(AbstractRegionBSPTreeTest::xAxisTree, AbstractRegionBSPTreeTest::yAxisTree)
+            .full(false)
+            .empty(false)
+            .inside(new TestPoint2D(1, 1), new TestPoint2D(-1, -1))
+            .outside(new TestPoint2D(-1, 1), new TestPoint2D(1, -1))
+            .boundary(TestPoint2D.ZERO)
+            .count(7)
+            .check(tree -> {
+                TestLineSegment minusSeg = (TestLineSegment) tree.getRoot().getMinus().getCut();
+
+                PartitionTestUtils.assertPointsEqual(TestPoint2D.ZERO, minusSeg.getStartPoint());
+                PartitionTestUtils.assertPointsEqual(new TestPoint2D(0.0, Double.POSITIVE_INFINITY), minusSeg.getEndPoint());
+
+                TestLineSegment plusSeg = (TestLineSegment) tree.getRoot().getPlus().getCut();
+
+                PartitionTestUtils.assertPointsEqual(new TestPoint2D(0.0, Double.NEGATIVE_INFINITY), plusSeg.getStartPoint());
+                PartitionTestUtils.assertPointsEqual(TestPoint2D.ZERO, plusSeg.getEndPoint());
+            });
+    }
+
+    @Test
+    public void testXor_boxTreeWithSingleCutTree() {
+        // arrange
+        Supplier<TestRegionBSPTree> boxFactory = () -> {
+            TestRegionBSPTree box = fullTree();
+            insertBox(box, TestPoint2D.ZERO, new TestPoint2D(2, -4));
+            return box;
+        };
+
+        Supplier<TestRegionBSPTree> horizonalFactory = () -> {
+            TestRegionBSPTree horizontal = fullTree();
+            horizontal.getRoot().insertCut(new TestLine(new TestPoint2D(2, -2), new TestPoint2D(0, -2)));
+
+            return horizontal;
+        };
+
+        // act/assert
+        xorChecker(horizonalFactory, boxFactory)
+            .inside(new TestPoint2D(-1, -3), new TestPoint2D(-1, -5),
+                    new TestPoint2D(1, -5), new TestPoint2D(3, -5),
+                    new TestPoint2D(4, -3), new TestPoint2D(1, -1))
+            .outside(new TestPoint2D(3, -1), new TestPoint2D(1, -3),
+                    new TestPoint2D(1, 1), new TestPoint2D(5, -1))
+            .boundary(new TestPoint2D(0, -2), new TestPoint2D(0, -4),
+                    new TestPoint2D(2, -4), new TestPoint2D(2, -2),
+                    TestPoint2D.ZERO, new TestPoint2D(2, 0))
+            .count(15)
+            .check();
+    }
+
+    @Test
+    public void testXor_treeWithComplement() {
+        // arrange
+        Supplier<TestRegionBSPTree> treeFactory = () -> {
+            TestRegionBSPTree tree = fullTree();
+            insertSkewedBowtie(tree);
+
+            return tree;
+        };
+        Supplier<TestRegionBSPTree> complementFactory = () -> {
+            TestRegionBSPTree tree = treeFactory.get();
+            tree.complement();
+
+            return tree;
+        };
+
+        // act/assert
+        xorChecker(treeFactory, complementFactory)
+            .full(true)
+            .empty(false)
+            .count(1)
+            .check();
+    }
+
+    @Test
+    public void testProject_emptyAndFull() {
+        // arrange
+        TestRegionBSPTree full = fullTree();
+        TestRegionBSPTree empty = emptyTree();
+
+        // act/assert
+        Assert.assertNull(full.project(new TestPoint2D(0, 0)));
+        Assert.assertNull(empty.project(new TestPoint2D(-1, 1)));
+    }
+
+    @Test
+    public void testProject_halfSpace() {
+        // arrange
+        TestRegionBSPTree tree = fullTree();
+        tree.getRoot().cut(TestLine.X_AXIS);
+
+        // act/assert
+        PartitionTestUtils.assertPointsEqual(TestPoint2D.ZERO, tree.project(TestPoint2D.ZERO));
+
+        PartitionTestUtils.assertPointsEqual(TestPoint2D.ZERO, tree.project(new TestPoint2D(0, 7)));
+        PartitionTestUtils.assertPointsEqual(TestPoint2D.ZERO, tree.project(new TestPoint2D(0, -7)));
+
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(4, 0), tree.project(new TestPoint2D(4, 10)));
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(-5, 0), tree.project(new TestPoint2D(-5, -2)));
+    }
+
+    @Test
+    public void testProject_box() {
+        // arrange
+        TestRegionBSPTree tree = fullTree();
+        insertBox(tree, new TestPoint2D(0, 1), new TestPoint2D(1, 0));
+
+        // act/assert
+        PartitionTestUtils.assertPointsEqual(TestPoint2D.ZERO, tree.project(TestPoint2D.ZERO));
+        PartitionTestUtils.assertPointsEqual(TestPoint2D.ZERO, tree.project(new TestPoint2D(-1, -4)));
+
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(1, 1), tree.project(new TestPoint2D(2, 9)));
+
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(0.5, 1), tree.project(new TestPoint2D(0.5, 3)));
+    }
+
+    @Test
+    public void testSplit_empty() {
+        // arrange
+        TestRegionBSPTree tree = emptyTree();
+
+        // act
+        Split<TestRegionBSPTree> split = tree.split(TestLine.X_AXIS);
+
+        // assert
+        Assert.assertEquals(SplitLocation.NEITHER, split.getLocation());
+
+        Assert.assertNull(split.getMinus());
+        Assert.assertNull(split.getPlus());
+    }
+
+    @Test
+    public void testSplit_full() {
+        // arrange
+        TestRegionBSPTree tree = fullTree();
+
+        // act
+        Split<TestRegionBSPTree> split = tree.split(TestLine.X_AXIS);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        TestRegionBSPTree minus = split.getMinus();
+        assertPointLocations(minus, RegionLocation.INSIDE,
+                new TestPoint2D(-1, 1), new TestPoint2D(0, 1), new TestPoint2D(1, 1));
+        assertPointLocations(minus, RegionLocation.BOUNDARY,
+                new TestPoint2D(-1, 0), new TestPoint2D(0, 0), new TestPoint2D(1, 0));
+        assertPointLocations(minus, RegionLocation.OUTSIDE,
+                new TestPoint2D(-1, -1), new TestPoint2D(0, -1), new TestPoint2D(1, -1));
+
+        TestRegionBSPTree plus = split.getPlus();
+        assertPointLocations(plus, RegionLocation.OUTSIDE,
+                new TestPoint2D(-1, 1), new TestPoint2D(0, 1), new TestPoint2D(1, 1));
+        assertPointLocations(plus, RegionLocation.BOUNDARY,
+                new TestPoint2D(-1, 0), new TestPoint2D(0, 0), new TestPoint2D(1, 0));
+        assertPointLocations(plus, RegionLocation.INSIDE,
+                new TestPoint2D(-1, -1), new TestPoint2D(0, -1), new TestPoint2D(1, -1));
+    }
+
+    @Test
+    public void testSplit_halfSpace() {
+        // arrange
+        TestRegionBSPTree tree = fullTree();
+        tree.getRoot().insertCut(TestLine.X_AXIS);
+
+        TestLine splitter = TestLine.Y_AXIS;
+
+        // act
+        Split<TestRegionBSPTree> split = tree.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        TestRegionBSPTree minus = split.getMinus();
+        assertPointLocations(minus, RegionLocation.INSIDE, new TestPoint2D(-1, 1));
+        assertPointLocations(minus, RegionLocation.OUTSIDE,
+                new TestPoint2D(1, 1), new TestPoint2D(-1, -1), new TestPoint2D(1, -1));
+
+        TestRegionBSPTree plus = split.getPlus();
+        assertPointLocations(plus, RegionLocation.INSIDE, new TestPoint2D(1, 1));
+        assertPointLocations(plus, RegionLocation.OUTSIDE,
+                new TestPoint2D(-1, 1), new TestPoint2D(-1, -1), new TestPoint2D(1, -1));
+    }
+
+    @Test
+    public void testSplit_box() {
+        // arrange
+        TestRegionBSPTree tree = fullTree();
+        insertBox(tree, new TestPoint2D(0, 1), new TestPoint2D(1, 0));
+
+        TestLine splitter = new TestLine(new TestPoint2D(1, 0), new TestPoint2D(0, 1));
+
+        // act
+        Split<TestRegionBSPTree> split = tree.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        TestRegionBSPTree minus = split.getMinus();
+        assertPointLocations(minus, RegionLocation.INSIDE, new TestPoint2D(0.25, 0.25));
+        assertPointLocations(minus, RegionLocation.BOUNDARY,
+                new TestPoint2D(0.5, 0), new TestPoint2D(0, 0.5));
+        assertPointLocations(minus, RegionLocation.OUTSIDE,
+                new TestPoint2D(1, 0.5), new TestPoint2D(0.5, 1), new TestPoint2D(0.75, 0.75));
+
+        TestRegionBSPTree plus = split.getPlus();
+        assertPointLocations(plus, RegionLocation.INSIDE, new TestPoint2D(0.75, 0.75));
+        assertPointLocations(plus, RegionLocation.OUTSIDE,
+                new TestPoint2D(0.5, 0), new TestPoint2D(0, 0.5), new TestPoint2D(0.25, 0.25));
+        assertPointLocations(plus, RegionLocation.BOUNDARY,
+                new TestPoint2D(1, 0.5), new TestPoint2D(0.5, 1));
+    }
+
+    @Test
+    public void testSplit_box_onMinusOnly() {
+        // arrange
+        TestRegionBSPTree tree = fullTree();
+        insertBox(tree, new TestPoint2D(0, 1), new TestPoint2D(1, 0));
+
+        TestLine splitter = new TestLine(new TestPoint2D(2, 0), new TestPoint2D(1, 1));
+
+        // act
+        Split<TestRegionBSPTree> split = tree.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.MINUS, split.getLocation());
+
+        TestRegionBSPTree minus = split.getMinus();
+        assertPointLocations(minus, RegionLocation.INSIDE, new TestPoint2D(0.5, 0.5));
+        assertPointLocations(minus, RegionLocation.BOUNDARY,
+                new TestPoint2D(0.5, 0), new TestPoint2D(0, 0.5),
+                new TestPoint2D(1, 0.5), new TestPoint2D(0.5, 1));
+
+        Assert.assertNull(split.getPlus());
+    }
+
+    @Test
+    public void testSplit_box_onPlusOnly() {
+        // arrange
+        TestRegionBSPTree tree = fullTree();
+        insertBox(tree, new TestPoint2D(0, 1), new TestPoint2D(1, 0));
+
+        TestLine splitter = new TestLine(new TestPoint2D(0, 0), new TestPoint2D(-1, 1));
+
+        // act
+        Split<TestRegionBSPTree> split = tree.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.PLUS, split.getLocation());
+
+        Assert.assertNull(split.getMinus());
+
+        TestRegionBSPTree plus = split.getPlus();
+        assertPointLocations(plus, RegionLocation.INSIDE, new TestPoint2D(0.5, 0.5));
+        assertPointLocations(plus, RegionLocation.BOUNDARY,
+                new TestPoint2D(0.5, 0), new TestPoint2D(0, 0.5),
+                new TestPoint2D(1, 0.5), new TestPoint2D(0.5, 1));
+    }
+
+    @Test
+    public void testToString() {
+        // arrange
+        TestRegionBSPTree tree = fullTree();
+        tree.getRoot().cut(TestLine.X_AXIS);
+
+        // act
+        String str = tree.toString();
+
+        // assert
+        Assert.assertEquals("TestRegionBSPTree[count= 3, height= 1]", str);
+        Assert.assertTrue(tree.getRoot().toString().contains("TestRegionNode"));
+    }
+
+    /** Assert that all given points lie in the expected location of the region.
+     * @param tree region tree
+     * @param points points to test
+     * @param location expected location of all points
+     */
+    private static void assertPointLocations(final TestRegionBSPTree tree, final RegionLocation location,
+            final TestPoint2D ... points) {
+        assertPointLocations(tree, location, Arrays.asList(points));
+    }
+
+    /** Assert that all given points lie in the expected location of the region.
+     * @param tree region tree
+     * @param points points to test
+     * @param location expected location of all points
+     */
+    private static void assertPointLocations(final TestRegionBSPTree tree, final RegionLocation location,
+            final List<TestPoint2D> points) {
+
+        for (TestPoint2D p : points) {
+            Assert.assertEquals("Unexpected location for point " + p, location, tree.classify(p));
+        }
+    }
+
+    private static MergeChecker unionChecker(
+            final Supplier<TestRegionBSPTree> r1,
+            final Supplier<TestRegionBSPTree> r2) {
+
+        MergeOperation constOperation = (a, b) -> {
+            TestRegionBSPTree result = fullTree();
+            result.union(a, b);
+            return result;
+        };
+
+        MergeOperation inPlaceOperation = (a, b) -> {
+            a.union(b);
+            return a;
+        };
+
+        return new MergeChecker(r1, r2, constOperation, inPlaceOperation);
+    }
+
+    private static MergeChecker intersectionChecker(
+            final Supplier<TestRegionBSPTree> tree1Factory,
+            final Supplier<TestRegionBSPTree> tree2Factory) {
+
+        MergeOperation constOperation = (a, b) -> {
+            TestRegionBSPTree result = fullTree();
+            result.intersection(a, b);
+            return result;
+        };
+
+        MergeOperation inPlaceOperation = (a, b) -> {
+            a.intersection(b);
+            return a;
+        };
+
+        return new MergeChecker(tree1Factory, tree2Factory, constOperation, inPlaceOperation);
+    }
+
+    private static MergeChecker differenceChecker(
+            final Supplier<TestRegionBSPTree> tree1Factory,
+            final Supplier<TestRegionBSPTree> tree2Factory) {
+
+        MergeOperation constOperation = (a, b) -> {
+            TestRegionBSPTree result = fullTree();
+            result.difference(a, b);
+            return result;
+        };
+
+        MergeOperation inPlaceOperation = (a, b) -> {
+            a.difference(b);
+            return a;
+        };
+
+        return new MergeChecker(tree1Factory, tree2Factory, constOperation, inPlaceOperation);
+    }
+
+    private static MergeChecker xorChecker(
+            final Supplier<TestRegionBSPTree> tree1Factory,
+            final Supplier<TestRegionBSPTree> tree2Factory) {
+
+        MergeOperation constOperation = (a, b) -> {
+            TestRegionBSPTree result = fullTree();
+            result.xor(a, b);
+            return result;
+        };
+
+        MergeOperation inPlaceOperation = (a, b) -> {
+            a.xor(b);
+            return a;
+        };
+
+        return new MergeChecker(tree1Factory, tree2Factory, constOperation, inPlaceOperation);
+    }
+
+    private static void insertBox(final TestRegionBSPTree tree, final TestPoint2D upperLeft, final TestPoint2D lowerRight) {
+        final TestPoint2D upperRight = new TestPoint2D(lowerRight.getX(), upperLeft.getY());
+        final TestPoint2D lowerLeft = new TestPoint2D(upperLeft.getX(), lowerRight.getY());
+
+        tree.insert(Arrays.asList(
+                    new TestLineSegment(lowerRight, upperRight),
+                    new TestLineSegment(upperRight, upperLeft),
+                    new TestLineSegment(upperLeft, lowerLeft),
+                    new TestLineSegment(lowerLeft, lowerRight)
+                ));
+    }
+
+    private static void insertSkewedBowtie(final TestRegionBSPTree tree) {
+        tree.insert(Arrays.asList(
+                new TestLineSegment(TestPoint2D.ZERO, new TestPoint2D(1, 0)),
+
+                new TestLineSegment(new TestPoint2D(4, 0), new TestPoint2D(4, 1)),
+                new TestLineSegment(new TestPoint2D(-4, 0), new TestPoint2D(-4, -1)),
+
+                new TestLineSegment(new TestPoint2D(4, 5), new TestPoint2D(-1, 0)),
+                new TestLineSegment(new TestPoint2D(-4, -5), new TestPoint2D(1, 0))));
+    }
+
+    private static void assertCutBoundarySegment(final SubHyperplane<TestPoint2D> boundary, final TestPoint2D start, final TestPoint2D end) {
+        Assert.assertFalse("Expected boundary to not be empty", boundary.isEmpty());
+
+        TestLineSegmentCollection segmentCollection = (TestLineSegmentCollection) boundary;
+        Assert.assertEquals(1, segmentCollection.getLineSegments().size());
+
+        TestLineSegment segment = segmentCollection.getLineSegments().get(0);
+        PartitionTestUtils.assertPointsEqual(start, segment.getStartPoint());
+        PartitionTestUtils.assertPointsEqual(end, segment.getEndPoint());
+    }
+
+    private static void assertContainsSegment(final List<TestLineSegment> boundaries, final TestPoint2D start, final TestPoint2D end) {
+        boolean found = false;
+        for (TestLineSegment seg : boundaries) {
+            TestPoint2D startPt = seg.getStartPoint();
+            TestPoint2D endPt = seg.getEndPoint();
+
+            if (PartitionTestUtils.PRECISION.eq(start.getX(), startPt.getX()) &&
+                    PartitionTestUtils.PRECISION.eq(start.getY(), startPt.getY()) &&
+                    PartitionTestUtils.PRECISION.eq(end.getX(), endPt.getX()) &&
+                    PartitionTestUtils.PRECISION.eq(end.getY(), endPt.getY())) {
+                found = true;
+                break;
+            }
+        }
+
+        Assert.assertTrue("Expected to find segment start= " + start + ", end= " + end , found);
+    }
+
+    private static TestRegionBSPTree emptyTree() {
+        return new TestRegionBSPTree(false);
+    }
+
+    private static TestRegionBSPTree fullTree() {
+        return new TestRegionBSPTree(true);
+    }
+
+    private static TestRegionBSPTree xAxisTree() {
+        TestRegionBSPTree tree = fullTree();
+        tree.getRoot().cut(TestLine.X_AXIS);
+
+        return tree;
+    }
+
+    private static TestRegionBSPTree yAxisTree() {
+        TestRegionBSPTree tree = fullTree();
+        tree.getRoot().cut(TestLine.Y_AXIS);
+
+        return tree;
+    }
+
+    /** Helper interface used when testing tree merge operations.
+     */
+    @FunctionalInterface
+    private static interface MergeOperation {
+        TestRegionBSPTree apply(TestRegionBSPTree tree1, TestRegionBSPTree tree2);
+    }
+
+    /** Helper class with a fluent API used to construct assert conditions on tree merge operations.
+     */
+    private static class MergeChecker {
+
+        /** First tree in the merge operation */
+        private final Supplier<TestRegionBSPTree> tree1Factory;
+
+        /** Second tree in the merge operation */
+        private final Supplier<TestRegionBSPTree> tree2Factory;
+
+        /** Merge operation that does not modify either input tree */
+        private final MergeOperation constOperation;
+
+        /** Merge operation that stores the result in the first input tree
+         * and leaves the second one unmodified.
+         */
+        private final MergeOperation inPlaceOperation;
+
+        /** If true, the resulting tree will be printed to stdout to help with
+         * debugging.
+         */
+        private boolean print;
+
+        /** The expected node count of the merged tree */
+        private int expectedCount = -1;
+
+        /** The expected full state of the merged tree */
+        private boolean expectedFull = false;
+
+        /** The expected empty state of the merged tree */
+        private boolean expectedEmpty = false;
+
+        /** Points expected to lie in the inside of the region */
+        private List<TestPoint2D> insidePoints = new ArrayList<>();
+
+        /** Points expected to lie on the outside of the region */
+        private List<TestPoint2D> outsidePoints = new ArrayList<>();
+
+        /** Points expected to lie on the  boundary of the region */
+        private List<TestPoint2D> boundaryPoints = new ArrayList<>();
+
+        /** Construct a new instance that will verify the output of performing the given merge operation
+         * on the input trees.
+         * @param tree1 first tree in the merge operation
+         * @param tree2 second tree in the merge operation
+         * @param constOperation object that performs the merge operation in a form that
+         *      leaves both argument unmodified
+         * @param inPlaceOperation object that performs the merge operation in a form
+         *      that stores the result in the first input tree and leaves the second
+         *      input unchanged.
+         */
+        public MergeChecker(
+                final Supplier<TestRegionBSPTree> tree1Factory,
+                final Supplier<TestRegionBSPTree> tree2Factory,
+                final MergeOperation constOperation,
+                final MergeOperation inPlaceOperation) {
+
+            this.tree1Factory = tree1Factory;
+            this.tree2Factory = tree2Factory;
+            this.constOperation = constOperation;
+            this.inPlaceOperation = inPlaceOperation;
+        }
+
+        /** Set the expected node count of the merged tree
+         * @param expectedCount the expected node count of the merged tree
+         * @return this instance
+         */
+        public MergeChecker count(final int expectedCount) {
+            this.expectedCount = expectedCount;
+            return this;
+        }
+
+        /** Set the expected full state of the merged tree.
+         * @param expectedFull the expected full state of the merged tree.
+         * @return this instance
+         */
+        public MergeChecker full(final boolean expectedFull) {
+            this.expectedFull = expectedFull;
+            return this;
+        }
+
+        /** Set the expected empty state of the merged tree.
+         * @param expectedEmpty the expected empty state of the merged tree.
+         * @return this instance
+         */
+        public MergeChecker empty(final boolean expectedEmpty) {
+            this.expectedEmpty = expectedEmpty;
+            return this;
+        }
+
+        /** Add points expected to be on the inside of the merged region.
+         * @param points point expected to be on the inside of the merged
+         *      region
+         * @return this instance
+         */
+        public MergeChecker inside(TestPoint2D ... points) {
+            insidePoints.addAll(Arrays.asList(points));
+            return this;
+        }
+
+        /** Add points expected to be on the outside of the merged region.
+         * @param points point expected to be on the outside of the merged
+         *      region
+         * @return this instance
+         */
+        public MergeChecker outside(TestPoint2D ... points) {
+            outsidePoints.addAll(Arrays.asList(points));
+            return this;
+        }
+
+        /** Add points expected to be on the boundary of the merged region.
+         * @param points point expected to be on the boundary of the merged
+         *      region
+         * @return this instance
+         */
+        public MergeChecker boundary(TestPoint2D ... points) {
+            boundaryPoints.addAll(Arrays.asList(points));
+            return this;
+        }
+
+        /** Set the flag for printing the merged tree to stdout before performing assertions.
+         * This can be useful for debugging tests.
+         * @param print if set to true, the merged tree will be printed to stdout
+         * @return this instance
+         */
+        @SuppressWarnings("unused")
+        public MergeChecker print(final boolean print) {
+            this.print = print;
+            return this;
+        }
+
+        /** Perform the merge operation and verify the output.
+         */
+        public void check() {
+            check(null);
+        }
+
+        /** Perform the merge operation and verify the output. The given consumer
+         * is passed the merge result and can be used to perform extra assertions.
+         * @param assertions consumer that will be passed the merge result; may
+         *      be null
+         */
+        public void check(final Consumer<TestRegionBSPTree> assertions) {
+            checkConst(assertions);
+            checkInPlace(assertions);
+        }
+
+        private void checkConst(final Consumer<TestRegionBSPTree> assertions) {
+            checkInternal(false, constOperation, assertions);
+        }
+
+        private void checkInPlace(final Consumer<TestRegionBSPTree> assertions) {
+            checkInternal(true, inPlaceOperation, assertions);
+        }
+
+        private void checkInternal(final boolean inPlace, final MergeOperation operation,
+                final Consumer<TestRegionBSPTree> assertions) {
+
+            final TestRegionBSPTree tree1 = tree1Factory.get();
+            final TestRegionBSPTree tree2 = tree2Factory.get();
+
+            // store the number of nodes in each tree before the operation
+            final int tree1BeforeCount = tree1.count();
+            final int tree2BeforeCount = tree2.count();
+
+            // perform the operation
+            final TestRegionBSPTree result = operation.apply(tree1, tree2);
+
+            if (print) {
+                System.out.println((inPlace ? "In Place" : "Const") + " Result:");
+                System.out.println(result.treeString());
+            }
+
+            // verify the internal consistency of all of the involved trees
+            PartitionTestUtils.assertTreeStructure(tree1);
+            PartitionTestUtils.assertTreeStructure(tree2);
+            PartitionTestUtils.assertTreeStructure(result);
+
+            // check full/empty status
+            Assert.assertEquals("Unexpected tree 'full' property", expectedFull, result.isFull());
+            Assert.assertEquals("Unexpected tree 'empty' property", expectedEmpty, result.isEmpty());
+
+            // check the node count
+            if (expectedCount > -1) {
+                Assert.assertEquals("Unexpected node count", expectedCount, result.count());
+            }
+
+            // check in place or not
+            if (inPlace) {
+                Assert.assertSame("Expected merge operation to be in place", tree1, result);
+            }
+            else {
+                Assert.assertNotSame("Expected merge operation to return a new instance", tree1, result);
+
+                // make sure that tree1 wasn't modified
+                Assert.assertEquals("Tree 1 node count should not have changed", tree1BeforeCount, tree1.count());
+            }
+
+            // make sure that tree2 wasn't modified
+            Assert.assertEquals("Tree 2 node count should not have changed", tree2BeforeCount, tree2.count());
+
+            // check region point locations
+            assertPointLocations(result, RegionLocation.INSIDE, insidePoints);
+            assertPointLocations(result, RegionLocation.OUTSIDE, outsidePoints);
+            assertPointLocations(result, RegionLocation.BOUNDARY, boundaryPoints);
+
+            // pass the result to the given function for any additional assertions
+            if (assertions != null) {
+                assertions.accept(result);
+            }
+        }
+    }
+
+    private static class TestRegionBSPTree extends AbstractRegionBSPTree<TestPoint2D, TestRegionNode> {
+
+        private static final long serialVersionUID = 20190405L;
+
+        TestRegionBSPTree() {
+            this(true);
+        }
+
+        TestRegionBSPTree(final boolean full) {
+            super(full);
+        }
+
+        /**
+         * Expose the direct node cut method for easier creation of test tree structures.
+         */
+        @Override
+        public void cutNode(final TestRegionNode node, final ConvexSubHyperplane<TestPoint2D> cut) {
+            super.cutNode(node, cut);
+        }
+
+        @Override
+        protected TestRegionNode createNode() {
+            return new TestRegionNode(this);
+        }
+
+        @Override
+        protected RegionSizeProperties<TestPoint2D> computeRegionSizeProperties() {
+            // return a set of stub values
+            return new RegionSizeProperties<>(1, TestPoint2D.ZERO);
+        }
+
+        @Override
+        public boolean contains(TestPoint2D pt) {
+            return classify(pt) != RegionLocation.OUTSIDE;
+        }
+
+        @Override
+        public Split<TestRegionBSPTree> split(Hyperplane<TestPoint2D> splitter) {
+            return split(splitter, new TestRegionBSPTree(), new TestRegionBSPTree());
+        }
+    }
+
+    private static class TestRegionNode extends AbstractRegionNode<TestPoint2D, TestRegionNode> {
+
+        private static final long serialVersionUID = 20190405L;
+
+        protected TestRegionNode(AbstractBSPTree<TestPoint2D, TestRegionNode> tree) {
+            super(tree);
+        }
+
+        @Override
+        protected TestRegionNode getSelf() {
+            return this;
+        }
+    }
+}
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/bsp/AttributeBSPTreeTest.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/bsp/AttributeBSPTreeTest.java
new file mode 100644
index 0000000..e69425a
--- /dev/null
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/bsp/AttributeBSPTreeTest.java
@@ -0,0 +1,220 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning.bsp;
+
+import java.util.Arrays;
+
+import org.apache.commons.geometry.core.partition.test.PartitionTestUtils;
+import org.apache.commons.geometry.core.partition.test.TestLine;
+import org.apache.commons.geometry.core.partition.test.TestLineSegment;
+import org.apache.commons.geometry.core.partition.test.TestPoint2D;
+import org.apache.commons.geometry.core.partitioning.bsp.AttributeBSPTree;
+import org.apache.commons.geometry.core.partitioning.bsp.AttributeBSPTree.AttributeNode;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class AttributeBSPTreeTest {
+
+    @Test
+    public void testInitialization() {
+        // act
+        AttributeBSPTree<TestPoint2D, String> tree = new AttributeBSPTree<TestPoint2D, String>();
+
+        // assert
+        AttributeNode<TestPoint2D, String> root = tree.getRoot();
+
+        Assert.assertNotNull(root);
+        Assert.assertNull(root.getParent());
+        Assert.assertNull(root.getAttribute());
+
+        PartitionTestUtils.assertIsLeafNode(root);
+        Assert.assertFalse(root.isPlus());
+        Assert.assertFalse(root.isMinus());
+
+        Assert.assertSame(tree, root.getTree());
+    }
+
+    @Test
+    public void testInitialNodeValue_null() {
+        // arrange
+        AttributeBSPTree<TestPoint2D, String> tree = new AttributeBSPTree<TestPoint2D, String>();
+        tree.getRoot().cut(TestLine.X_AXIS);
+
+        // act/assert
+        Assert.assertNull(tree.getRoot().getAttribute());
+        Assert.assertNull(tree.getRoot().getPlus().getAttribute());
+        Assert.assertNull(tree.getRoot().getMinus().getAttribute());
+    }
+
+    @Test
+    public void testInitialNodeValue_givenValue() {
+        // arrange
+        AttributeBSPTree<TestPoint2D, String> tree = new AttributeBSPTree<TestPoint2D, String>("a");
+        tree.getRoot().cut(TestLine.X_AXIS);
+
+        // act/assert
+        Assert.assertEquals("a", tree.getRoot().getAttribute());
+        Assert.assertEquals("a", tree.getRoot().getPlus().getAttribute());
+        Assert.assertEquals("a", tree.getRoot().getMinus().getAttribute());
+    }
+
+    @Test
+    public void testSetAttribute_node() {
+        // arrange
+        AttributeBSPTree<TestPoint2D, String> tree = new AttributeBSPTree<TestPoint2D, String>();
+        AttributeNode<TestPoint2D, String> root = tree.getRoot();
+
+        // act
+        root.setAttribute("a");
+
+        // assert
+        Assert.assertEquals("a", root.getAttribute());
+    }
+
+    @Test
+    public void testAttr_node() {
+        // arrange
+        AttributeBSPTree<TestPoint2D, String> tree = new AttributeBSPTree<TestPoint2D, String>();
+        AttributeNode<TestPoint2D, String> root = tree.getRoot();
+
+        // act
+        AttributeNode<TestPoint2D, String> result = root.attr("a");
+
+        // assert
+        Assert.assertSame(root, result);
+        Assert.assertEquals("a", root.getAttribute());
+    }
+
+    @Test
+    public void testCopy_rootOnly() {
+        // arrange
+        AttributeBSPTree<TestPoint2D, String> tree = new AttributeBSPTree<TestPoint2D, String>();
+        tree.getRoot().attr("abc");
+
+        // act
+        AttributeBSPTree<TestPoint2D, String> copy = new AttributeBSPTree<>();
+        copy.copy(tree);
+
+        // assert
+        Assert.assertEquals(1, copy.count());
+        Assert.assertEquals("abc", copy.getRoot().getAttribute());
+    }
+
+    @Test
+    public void testCopy_withCuts() {
+        // arrange
+        AttributeBSPTree<TestPoint2D, String> tree = new AttributeBSPTree<TestPoint2D, String>();
+        tree.insert(Arrays.asList(
+                new TestLineSegment(TestPoint2D.ZERO, new TestPoint2D(1, 0)),
+                new TestLineSegment(TestPoint2D.ZERO, new TestPoint2D(0, 1))
+                ));
+
+        tree.findNode(new TestPoint2D(1, 1)).attr("a");
+        tree.findNode(new TestPoint2D(-1, 1)).attr("b");
+        tree.findNode(new TestPoint2D(0, -1)).attr("c");
+
+        // act
+        AttributeBSPTree<TestPoint2D, String> copy = new AttributeBSPTree<>();
+        copy.copy(tree);
+
+        // assert
+        Assert.assertEquals(5, copy.count());
+        Assert.assertEquals("a", copy.findNode(new TestPoint2D(1, 1)).getAttribute());
+        Assert.assertEquals("b", copy.findNode(new TestPoint2D(-1, 1)).getAttribute());
+        Assert.assertEquals("c", copy.findNode(new TestPoint2D(0, -1)).getAttribute());
+    }
+
+    @Test
+    public void testExtract() {
+        // arrange
+        AttributeBSPTree<TestPoint2D, String> tree = new AttributeBSPTree<TestPoint2D, String>();
+        tree.insert(Arrays.asList(
+                new TestLineSegment(new TestPoint2D(-1, -1), new TestPoint2D(1, 1)),
+                new TestLineSegment(new TestPoint2D(-1, 1), new TestPoint2D(1, -1)),
+
+                new TestLineSegment(new TestPoint2D(-1, 3), new TestPoint2D(1, 3)),
+                new TestLineSegment(new TestPoint2D(3, 1), new TestPoint2D(3, -1)),
+                new TestLineSegment(new TestPoint2D(1, -3), new TestPoint2D(-1, -3)),
+                new TestLineSegment(new TestPoint2D(-3, -1), new TestPoint2D(-3, 1))
+                ));
+
+        AttributeNode<TestPoint2D, String> root = tree.getRoot();
+
+        root.attr("R");
+        root.getMinus().attr("A");
+        root.getPlus().attr("B");
+
+        root.getMinus().getMinus().forEach(n -> n.attr("a"));
+        root.getMinus().getPlus().forEach(n -> n.attr("b"));
+
+        root.getPlus().getPlus().forEach(n -> n.attr("c"));
+        root.getPlus().getMinus().forEach(n -> n.attr("d"));
+
+        AttributeBSPTree<TestPoint2D, String> result = new AttributeBSPTree<>();
+
+        // act
+        result.extract(tree.findNode(new TestPoint2D(0, 1)));
+
+        // assert
+        Assert.assertEquals(7, result.count());
+        Assert.assertEquals(15, tree.count());
+
+        // check result tree attributes
+        AttributeNode<TestPoint2D, String> resultRoot = result.getRoot();
+        Assert.assertEquals("R", resultRoot.getAttribute());
+        Assert.assertEquals("A", resultRoot.getMinus().getAttribute());
+        Assert.assertEquals("B", resultRoot.getPlus().getAttribute());
+
+        Assert.assertEquals("a", resultRoot.getMinus().getMinus().getAttribute());
+        Assert.assertEquals("b", resultRoot.getMinus().getPlus().getAttribute());
+
+        Assert.assertEquals(2, resultRoot.getMinus().height());
+        Assert.assertEquals(0, resultRoot.getPlus().height());
+
+        PartitionTestUtils.assertTreeStructure(result);
+
+        // check original tree attributes
+        Assert.assertEquals("R", root.getAttribute());
+        Assert.assertEquals("A", root.getMinus().getAttribute());
+        Assert.assertEquals("B", root.getPlus().getAttribute());
+
+        Assert.assertEquals("a", root.getMinus().getMinus().getAttribute());
+        Assert.assertEquals("b", root.getMinus().getPlus().getAttribute());
+        Assert.assertEquals("c", root.getPlus().getPlus().getAttribute());
+        Assert.assertEquals("d", root.getPlus().getMinus().getAttribute());
+
+        Assert.assertEquals(2, root.getMinus().height());
+        Assert.assertEquals(2, root.getPlus().height());
+
+        PartitionTestUtils.assertTreeStructure(tree);
+    }
+
+    @Test
+    public void testNodeToString() {
+        // arrange
+        AttributeBSPTree<TestPoint2D, String> tree = new AttributeBSPTree<TestPoint2D, String>();
+        tree.getRoot().cut(TestLine.X_AXIS).attr("abc");
+
+        // act
+        String str = tree.getRoot().toString();
+
+        // assert
+        Assert.assertTrue(str.contains("AttributeNode"));
+        Assert.assertTrue(str.contains("cut= TestLineSegment"));
+        Assert.assertTrue(str.contains("attribute= abc"));
+    }
+}
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/bsp/BSPTreeVisitorTest.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/bsp/BSPTreeVisitorTest.java
new file mode 100644
index 0000000..2e692ae
--- /dev/null
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/bsp/BSPTreeVisitorTest.java
@@ -0,0 +1,142 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning.bsp;
+
+import org.apache.commons.geometry.core.partition.test.TestBSPTree;
+import org.apache.commons.geometry.core.partition.test.TestBSPTree.TestNode;
+import org.apache.commons.geometry.core.partitioning.bsp.BSPTreeVisitor;
+import org.apache.commons.geometry.core.partitioning.bsp.BSPTreeVisitor.ClosestFirstVisitor;
+import org.apache.commons.geometry.core.partitioning.bsp.BSPTreeVisitor.FarthestFirstVisitor;
+import org.apache.commons.geometry.core.partitioning.bsp.BSPTreeVisitor.Order;
+import org.apache.commons.geometry.core.partition.test.TestLine;
+import org.apache.commons.geometry.core.partition.test.TestPoint2D;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class BSPTreeVisitorTest {
+
+    @Test
+    public void testDefaultVisitOrder() {
+        // arrange
+        BSPTreeVisitor<TestPoint2D, TestNode> visitor = n -> {};
+
+        // act/assert
+        Assert.assertEquals(Order.NODE_MINUS_PLUS, visitor.visitOrder(null));
+    }
+
+    @Test
+    public void testClosestFirst() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        TestNode root = tree.getRoot();
+        root.cut(TestLine.X_AXIS);
+        root.getMinus().cut(TestLine.Y_AXIS);
+        root.getPlus().cut(TestLine.Y_AXIS);
+
+        // act
+        checkClosestFirst(new TestPoint2D(1, 1), root, Order.MINUS_NODE_PLUS);
+        checkClosestFirst(new TestPoint2D(1, 1), root.getMinus(), Order.PLUS_NODE_MINUS);
+        checkClosestFirst(new TestPoint2D(1, 1), root.getPlus(), Order.PLUS_NODE_MINUS);
+
+        checkClosestFirst(new TestPoint2D(-1, 1), root, Order.MINUS_NODE_PLUS);
+        checkClosestFirst(new TestPoint2D(-1, 1), root.getMinus(), Order.MINUS_NODE_PLUS);
+        checkClosestFirst(new TestPoint2D(-1, 1), root.getPlus(), Order.MINUS_NODE_PLUS);
+
+        checkClosestFirst(new TestPoint2D(-1, -1), root, Order.PLUS_NODE_MINUS);
+        checkClosestFirst(new TestPoint2D(-1, -1), root.getMinus(), Order.MINUS_NODE_PLUS);
+        checkClosestFirst(new TestPoint2D(-1, -1), root.getPlus(), Order.MINUS_NODE_PLUS);
+
+        checkClosestFirst(new TestPoint2D(1, -1), root, Order.PLUS_NODE_MINUS);
+        checkClosestFirst(new TestPoint2D(1, -1), root.getMinus(), Order.PLUS_NODE_MINUS);
+        checkClosestFirst(new TestPoint2D(1, -1), root.getPlus(), Order.PLUS_NODE_MINUS);
+
+        checkClosestFirst(TestPoint2D.ZERO, root.getPlus(), Order.MINUS_NODE_PLUS);
+        checkClosestFirst(TestPoint2D.ZERO, root.getPlus(), Order.MINUS_NODE_PLUS);
+        checkClosestFirst(TestPoint2D.ZERO, root.getPlus(), Order.MINUS_NODE_PLUS);
+    }
+
+    @Test
+    public void testFarthestFirst() {
+        // arrange
+        TestBSPTree tree = new TestBSPTree();
+        TestNode root = tree.getRoot();
+        root.cut(TestLine.X_AXIS);
+        root.getMinus().cut(TestLine.Y_AXIS);
+        root.getPlus().cut(TestLine.Y_AXIS);
+
+        // act
+        checkFarthestFirst(new TestPoint2D(1, 1), root, Order.PLUS_NODE_MINUS);
+        checkFarthestFirst(new TestPoint2D(1, 1), root.getMinus(), Order.MINUS_NODE_PLUS);
+        checkFarthestFirst(new TestPoint2D(1, 1), root.getPlus(), Order.MINUS_NODE_PLUS);
+
+        checkFarthestFirst(new TestPoint2D(-1, 1), root, Order.PLUS_NODE_MINUS);
+        checkFarthestFirst(new TestPoint2D(-1, 1), root.getMinus(), Order.PLUS_NODE_MINUS);
+        checkFarthestFirst(new TestPoint2D(-1, 1), root.getPlus(), Order.PLUS_NODE_MINUS);
+
+        checkFarthestFirst(new TestPoint2D(-1, -1), root, Order.MINUS_NODE_PLUS);
+        checkFarthestFirst(new TestPoint2D(-1, -1), root.getMinus(), Order.PLUS_NODE_MINUS);
+        checkFarthestFirst(new TestPoint2D(-1, -1), root.getPlus(), Order.PLUS_NODE_MINUS);
+
+        checkFarthestFirst(new TestPoint2D(1, -1), root, Order.MINUS_NODE_PLUS);
+        checkFarthestFirst(new TestPoint2D(1, -1), root.getMinus(), Order.MINUS_NODE_PLUS);
+        checkFarthestFirst(new TestPoint2D(1, -1), root.getPlus(), Order.MINUS_NODE_PLUS);
+
+        checkFarthestFirst(TestPoint2D.ZERO, root.getPlus(), Order.MINUS_NODE_PLUS);
+        checkFarthestFirst(TestPoint2D.ZERO, root.getPlus(), Order.MINUS_NODE_PLUS);
+        checkFarthestFirst(TestPoint2D.ZERO, root.getPlus(), Order.MINUS_NODE_PLUS);
+    }
+
+    private static void checkClosestFirst(TestPoint2D target, TestNode node, Order order) {
+        ClosestFirstStubVisitor visitor = new ClosestFirstStubVisitor(target);
+
+        Assert.assertSame(target, visitor.getTarget());
+        Assert.assertEquals(order, visitor.visitOrder(node));
+    }
+
+    private static void checkFarthestFirst(TestPoint2D target, TestNode node, Order order) {
+        FarthestFirstStubVisitor visitor = new FarthestFirstStubVisitor(target);
+
+        Assert.assertSame(target, visitor.getTarget());
+        Assert.assertEquals(order, visitor.visitOrder(node));
+    }
+
+    private static class ClosestFirstStubVisitor extends ClosestFirstVisitor<TestPoint2D, TestNode> {
+
+        private static final long serialVersionUID = 1L;
+
+        public ClosestFirstStubVisitor(TestPoint2D target) {
+            super(target);
+        }
+
+        @Override
+        public void visit(TestNode node) {
+        }
+    }
+
+    private static class FarthestFirstStubVisitor extends FarthestFirstVisitor<TestPoint2D, TestNode> {
+
+        private static final long serialVersionUID = 1L;
+
+        public FarthestFirstStubVisitor(TestPoint2D target) {
+            super(target);
+        }
+
+        @Override
+        public void visit(TestNode node) {
+        }
+    }
+}
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/bsp/RegionCutBoundaryTest.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/bsp/RegionCutBoundaryTest.java
new file mode 100644
index 0000000..524ec3b
--- /dev/null
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/bsp/RegionCutBoundaryTest.java
@@ -0,0 +1,146 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning.bsp;
+
+import org.apache.commons.geometry.core.partition.test.PartitionTestUtils;
+import org.apache.commons.geometry.core.partition.test.TestLineSegment;
+import org.apache.commons.geometry.core.partition.test.TestPoint2D;
+import org.apache.commons.geometry.core.partitioning.bsp.RegionCutBoundary;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class RegionCutBoundaryTest {
+
+    @Test
+    public void testProperties() {
+        // arrange
+        TestLineSegment insideFacing = new TestLineSegment(TestPoint2D.ZERO, new TestPoint2D(1, 0));
+        TestLineSegment outsideFacing = new TestLineSegment(new TestPoint2D(-1, 0), TestPoint2D.ZERO);
+
+        // act
+        RegionCutBoundary<TestPoint2D> boundary = new RegionCutBoundary<>(insideFacing, outsideFacing);
+
+        // assert
+        Assert.assertSame(insideFacing, boundary.getInsideFacing());
+        Assert.assertSame(outsideFacing, boundary.getOutsideFacing());
+    }
+
+    @Test
+    public void testClosest() {
+        // arrange
+        TestPoint2D a = new TestPoint2D(-1, 0);
+        TestPoint2D b = TestPoint2D.ZERO;
+        TestPoint2D c = new TestPoint2D(1, 0);
+
+        TestLineSegment insideFacing = new TestLineSegment(a, b);
+        TestLineSegment outsideFacing = new TestLineSegment(b, c);
+
+        RegionCutBoundary<TestPoint2D> boundary = new RegionCutBoundary<>(insideFacing, outsideFacing);
+
+        // act/assert
+        PartitionTestUtils.assertPointsEqual(a, boundary.closest(new TestPoint2D(-2, 1)));
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(-0.5, 0), boundary.closest(new TestPoint2D(-0.5, -1)));
+        PartitionTestUtils.assertPointsEqual(b, boundary.closest(TestPoint2D.ZERO));
+        PartitionTestUtils.assertPointsEqual(b, boundary.closest(new TestPoint2D(0, 2)));
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(0.5, 0), boundary.closest(new TestPoint2D(0.5, 3)));
+        PartitionTestUtils.assertPointsEqual(c, boundary.closest(new TestPoint2D(1, -4)));
+        PartitionTestUtils.assertPointsEqual(c, boundary.closest(new TestPoint2D(3, -5)));
+    }
+
+    @Test
+    public void testClosest_nullInsideFacing() {
+        // arrange
+        TestPoint2D a = new TestPoint2D(-1, 0);
+        TestPoint2D b = TestPoint2D.ZERO;
+
+        TestLineSegment outsideFacing = new TestLineSegment(a, b);
+
+        RegionCutBoundary<TestPoint2D> boundary = new RegionCutBoundary<>(null, outsideFacing);
+
+        // act/assert
+        PartitionTestUtils.assertPointsEqual(a, boundary.closest(new TestPoint2D(-2, 1)));
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(-0.5, 0), boundary.closest(new TestPoint2D(-0.5, -1)));
+        PartitionTestUtils.assertPointsEqual(b, boundary.closest(TestPoint2D.ZERO));
+        PartitionTestUtils.assertPointsEqual(b, boundary.closest(new TestPoint2D(1, 2)));
+    }
+
+    @Test
+    public void testClosest_nullOutsideFacing() {
+        // arrange
+        TestPoint2D a = new TestPoint2D(-1, 0);
+        TestPoint2D b = TestPoint2D.ZERO;
+
+        TestLineSegment insideFacing = new TestLineSegment(a, b);
+
+        RegionCutBoundary<TestPoint2D> boundary = new RegionCutBoundary<>(insideFacing, null);
+
+        // act/assert
+        PartitionTestUtils.assertPointsEqual(a, boundary.closest(new TestPoint2D(-2, 1)));
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(-0.5, 0), boundary.closest(new TestPoint2D(-0.5, -1)));
+        PartitionTestUtils.assertPointsEqual(b, boundary.closest(TestPoint2D.ZERO));
+        PartitionTestUtils.assertPointsEqual(b, boundary.closest(new TestPoint2D(1, 2)));
+    }
+
+    @Test
+    public void testClosest_nullInsideAndOutsideFacing() {
+        // arrange
+        RegionCutBoundary<TestPoint2D> boundary = new RegionCutBoundary<>(null, null);
+
+        // act/assert
+        Assert.assertNull(boundary.closest(TestPoint2D.ZERO));
+        Assert.assertNull(boundary.closest(new TestPoint2D(1, 1)));
+    }
+
+    @Test
+    public void testContains() {
+        // arrange
+        TestPoint2D a = new TestPoint2D(-1, 0);
+        TestPoint2D b = TestPoint2D.ZERO;
+        TestPoint2D c = new TestPoint2D(1, 0);
+
+        TestLineSegment insideFacing = new TestLineSegment(a, b);
+        TestLineSegment outsideFacing = new TestLineSegment(b, c);
+
+        RegionCutBoundary<TestPoint2D> boundary = new RegionCutBoundary<>(insideFacing, outsideFacing);
+
+        // act/assert
+        Assert.assertFalse(boundary.contains(new TestPoint2D(-2, 0)));
+
+        Assert.assertTrue(boundary.contains(new TestPoint2D(-1, 0)));
+        Assert.assertTrue(boundary.contains(new TestPoint2D(-0.5, 0)));
+        Assert.assertTrue(boundary.contains(new TestPoint2D(0, 0)));
+        Assert.assertTrue(boundary.contains(new TestPoint2D(0.5, 0)));
+        Assert.assertTrue(boundary.contains(new TestPoint2D(1, 0)));
+
+        Assert.assertFalse(boundary.contains(new TestPoint2D(2, 0)));
+
+        Assert.assertFalse(boundary.contains(new TestPoint2D(-1, 1)));
+        Assert.assertFalse(boundary.contains(new TestPoint2D(0, -1)));
+        Assert.assertFalse(boundary.contains(new TestPoint2D(1, 1)));
+    }
+
+    @Test
+    public void testContains_nullSubHyperplanes() {
+        // arrange
+        RegionCutBoundary<TestPoint2D> boundary = new RegionCutBoundary<>(null, null);
+
+        // act/assert
+        Assert.assertFalse(boundary.contains(new TestPoint2D(-1, 0)));
+        Assert.assertFalse(boundary.contains(new TestPoint2D(0, 0)));
+        Assert.assertFalse(boundary.contains(new TestPoint2D(1, 0)));
+    }
+}
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/precision/DoublePrecisionContextTest.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/precision/DoublePrecisionContextTest.java
index 80d9f8d..17a95a1 100644
--- a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/precision/DoublePrecisionContextTest.java
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/precision/DoublePrecisionContextTest.java
@@ -94,6 +94,19 @@
     }
 
     @Test
+    public void testSign() {
+        // act/assert
+        Assert.assertEquals(0, ctx.sign(0.0));
+
+        Assert.assertEquals(1, ctx.sign(1e-3));
+        Assert.assertEquals(-1, ctx.sign(-1e-3));
+
+        Assert.assertEquals(1, ctx.sign(Double.NaN));
+        Assert.assertEquals(1, ctx.sign(Double.POSITIVE_INFINITY));
+        Assert.assertEquals(-1, ctx.sign(Double.NEGATIVE_INFINITY));
+    }
+
+    @Test
     public void testCompare() {
         // act/assert
         Assert.assertEquals(0, ctx.compare(1, 1));
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/precision/EpsilonDoublePrecisionContextTest.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/precision/EpsilonDoublePrecisionContextTest.java
index 8bbe10d..ff75ee8 100644
--- a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/precision/EpsilonDoublePrecisionContextTest.java
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/precision/EpsilonDoublePrecisionContextTest.java
@@ -36,6 +36,28 @@
     }
 
     @Test
+    public void testSign() {
+        // arrange
+        double eps = 1e-2;
+
+        EpsilonDoublePrecisionContext ctx = new EpsilonDoublePrecisionContext(eps);
+
+        // act/assert
+        Assert.assertEquals(0, ctx.sign(0.0));
+        Assert.assertEquals(0, ctx.sign(-0.0));
+
+        Assert.assertEquals(0, ctx.sign(1e-2));
+        Assert.assertEquals(0, ctx.sign(-1e-2));
+
+        Assert.assertEquals(1, ctx.sign(1e-1));
+        Assert.assertEquals(-1, ctx.sign(-1e-1));
+
+        Assert.assertEquals(1, ctx.sign(Double.NaN));
+        Assert.assertEquals(1, ctx.sign(Double.POSITIVE_INFINITY));
+        Assert.assertEquals(-1, ctx.sign(Double.NEGATIVE_INFINITY));
+    }
+
+    @Test
     public void testCompare_compareToZero() {
         // arrange
         double eps = 1e-2;
diff --git a/commons-geometry-enclosing/src/main/java/org/apache/commons/geometry/enclosing/EnclosingBall.java b/commons-geometry-enclosing/src/main/java/org/apache/commons/geometry/enclosing/EnclosingBall.java
index 0fb9224..4186454 100644
--- a/commons-geometry-enclosing/src/main/java/org/apache/commons/geometry/enclosing/EnclosingBall.java
+++ b/commons-geometry-enclosing/src/main/java/org/apache/commons/geometry/enclosing/EnclosingBall.java
@@ -45,7 +45,7 @@
      * @param support support points used to define the ball
      */
     @SafeVarargs
-    public EnclosingBall(final P center, final double radius, final P ... support) {
+    public EnclosingBall(final P center, final double radius, final P... support) {
         this.center  = center;
         this.radius  = radius;
         this.support = support.clone();
diff --git a/commons-geometry-enclosing/src/main/java/org/apache/commons/geometry/euclidean/threed/enclosing/SphereGenerator.java b/commons-geometry-enclosing/src/main/java/org/apache/commons/geometry/euclidean/threed/enclosing/SphereGenerator.java
index 87a7c87..29ce8ee 100644
--- a/commons-geometry-enclosing/src/main/java/org/apache/commons/geometry/euclidean/threed/enclosing/SphereGenerator.java
+++ b/commons-geometry-enclosing/src/main/java/org/apache/commons/geometry/euclidean/threed/enclosing/SphereGenerator.java
@@ -33,7 +33,7 @@
  */
 public class SphereGenerator implements SupportBallGenerator<Vector3D> {
 
-    /** Base epsilon value */
+    /** Base epsilon value. */
     private static final double BASE_EPS = 1e-10;
 
     /** {@inheritDoc} */
@@ -57,12 +57,13 @@
                     if (support.size() < 4) {
 
                         // delegate to 2D disk generator
-                        final DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(BASE_EPS * (norm1(vA) + norm1(vB) + norm1(vC)));
+                        final DoublePrecisionContext precision =
+                                new EpsilonDoublePrecisionContext(BASE_EPS * (norm1(vA) + norm1(vB) + norm1(vC)));
                         final Plane p = Plane.fromPoints(vA, vB, vC, precision);
                         final EnclosingBall<Vector2D> disk =
-                                new DiskGenerator().ballOnSupport(Arrays.asList(p.toSubSpace(vA),
-                                                                                p.toSubSpace(vB),
-                                                                                p.toSubSpace(vC)));
+                                new DiskGenerator().ballOnSupport(Arrays.asList(p.toSubspace(vA),
+                                                                                p.toSubspace(vB),
+                                                                                p.toSubspace(vC)));
 
                         // convert back to 3D
                         return new EnclosingBall<>(p.toSpace(disk.getCenter()),
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/AbstractAffineTransformMatrix.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/AbstractAffineTransformMatrix.java
new file mode 100644
index 0000000..8def150
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/AbstractAffineTransformMatrix.java
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean;
+
+/** Base class for affine transform matrices in Euclidean space.
+ *
+ * @param <V> Vector/point implementation type defining the space.
+ */
+public abstract class AbstractAffineTransformMatrix<V extends EuclideanVector<V>>
+    implements EuclideanTransform<V> {
+
+    /** Apply this transform to the given vector, ignoring translations and normalizing the
+     * result. This is equivalent to {@code transform.applyVector(vec).normalize()} but without
+     * the intermediate vector instance.
+     *
+     * @param vec the vector to transform
+     * @return the new, transformed unit vector
+     * @throws org.apache.commons.geometry.core.exception.IllegalNormException if the transformed vector coordinates
+     *      cannot be normalized
+     * @see #applyVector(EuclideanVector)
+     */
+    public abstract V applyDirection(V vec);
+
+    /** Get the determinant of the matrix.
+     * @return the determinant of the matrix
+     */
+    public abstract double determinant();
+
+    /** {@inheritDoc}
+     *
+     * <p>This method returns true if the determinant of the matrix is positive.</p>
+     */
+    @Override
+    public boolean preservesOrientation() {
+        return determinant() > 0.0;
+    }
+}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/AffineTransformMatrix.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/AffineTransformMatrix.java
deleted file mode 100644
index 7209c3d..0000000
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/AffineTransformMatrix.java
+++ /dev/null
@@ -1,75 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean;
-
-import org.apache.commons.geometry.core.Point;
-import org.apache.commons.geometry.core.exception.IllegalNormException;
-import org.apache.commons.geometry.core.partitioning.Hyperplane;
-import org.apache.commons.geometry.core.partitioning.SubHyperplane;
-import org.apache.commons.geometry.core.partitioning.Transform;
-
-/** Interface representing an affine transform matrix in Euclidean space.
- * Rotation, scaling, and translation are examples of affine transformations.
- *
- * @param <V> Vector/point implementation type defining the space.
- * @param <S> Point type defining the embedded sub-space.
- * @see <a href="https://en.wikipedia.org/wiki/Affine_transformation">Affine transformation</a>
- */
-public interface AffineTransformMatrix<V extends EuclideanVector<V>, S extends Point<S>> extends Transform<V, S> {
-
-    /** Apply this transform to the given vector, ignoring translations.
-    *
-    * <p>This method can be used to transform vector instances representing displacements between points.
-    * For example, if {@code v} represents the difference between points {@code p1} and {@code p2},
-    * then {@code transform.applyVector(v)} will represent the difference between {@code p1} and {@code p2}
-    * after {@code transform} is applied.
-    * </p>
-    *
-    * @param vec the vector to transform
-    * @return the new, transformed vector
-    * @see #applyDirection(EuclideanVector)
-    */
-    V applyVector(V vec);
-
-    /** Apply this transform to the given vector, ignoring translations and normalizing the
-     * result. This is equivalent to {@code transform.applyVector(vec).normalize()} but without
-     * the intermediate vector instance.
-     *
-     * @param vec the vector to transform
-     * @return the new, transformed unit vector
-     * @throws IllegalNormException if the transformed vector coordinates cannot be normalized
-     * @see #applyVector(EuclideanVector)
-     */
-    V applyDirection(V vec);
-
-    /** {@inheritDoc}
-     * This operation is not supported. See GEOMETRY-24.
-     */
-    @Override
-    default Hyperplane<V> apply(Hyperplane<V> hyperplane) {
-        throw new UnsupportedOperationException("Transforming hyperplanes is not supported");
-    }
-
-    /** {@inheritDoc}
-     * This operation is not supported. See GEOMETRY-24.
-     */
-    @Override
-    default SubHyperplane<S> apply(SubHyperplane<S> sub, Hyperplane<V> original,
-            Hyperplane<V> transformed) {
-        throw new UnsupportedOperationException("Transforming sub-hyperplanes is not supported");
-    }
-}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/EuclideanTransform.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/EuclideanTransform.java
new file mode 100644
index 0000000..0a76f37
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/EuclideanTransform.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean;
+
+import org.apache.commons.geometry.core.Transform;
+
+/** Extension transform interface for Euclidean space. This interface provides an additional method
+ * for transforming vectors as opposed to points.
+ *
+ * @param <V> Vector implementation type
+ */
+public interface EuclideanTransform<V extends EuclideanVector<V>> extends Transform<V> {
+
+    /** Apply this transform to the given vector, ignoring translations.
+    *
+    * <p>This method can be used to transform vector instances representing displacements between points.
+    * For example, if {@code v} represents the difference between points {@code p1} and {@code p2},
+    * then {@code transform.applyVector(v)} will represent the difference between {@code p1} and {@code p2}
+    * after {@code transform} is applied.
+    * </p>
+    *
+    * @param vec the vector to transform
+    * @return the new, transformed vector
+    */
+    V applyVector(V vec);
+}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/EuclideanVector.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/EuclideanVector.java
index f767f97..ccd810f 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/EuclideanVector.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/EuclideanVector.java
@@ -20,7 +20,6 @@
 
 import org.apache.commons.geometry.core.Point;
 import org.apache.commons.geometry.core.Vector;
-import org.apache.commons.geometry.core.exception.IllegalNormException;
 import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
 import org.apache.commons.geometry.euclidean.internal.Vectors;
 
@@ -34,7 +33,7 @@
 public abstract class EuclideanVector<V extends EuclideanVector<V>>
     implements Vector<V>, Point<V>, Serializable {
 
-    /** Serializable version identifer */
+    /** Serializable version identifier. */
     private static final long serialVersionUID = 20181017L;
 
     /** Return the vector representing the displacement from this vector
@@ -51,8 +50,8 @@
      * @param v the vector that the returned vector will be directed toward
      * @return unit vector representing the direction of displacement <em>from</em> this vector
      *      <em>to</em> the given vector
-     * @throws IllegalNormException if the norm of the vector pointing from this instance to {@code v}
-     *      is zero, NaN, or infinite
+     * @throws org.apache.commons.geometry.core.exception.IllegalNormException if the norm of the vector pointing
+     *      from this instance to {@code v} is zero, NaN, or infinite
      */
     public abstract V directionTo(V v);
 
@@ -82,7 +81,7 @@
      * @return true if the current instance is considered equal to the given vector when using
      *      the given precision context; otherwise false
      */
-    public abstract boolean equals(V v, DoublePrecisionContext precision);
+    public abstract boolean eq(V v, DoublePrecisionContext precision);
 
     /** Return true if the current instance is considered equal to the zero vector as evaluated by the
      * given precision context. This is a convenience method equivalent to
@@ -91,16 +90,18 @@
      * @param precision precision context used to determine floating point equality
      * @return true if the current instance is considered equal to the zero vector when using
      *      the given precision context; otherwise false
-     * @see #equals(EuclideanVector, DoublePrecisionContext)
+     * @see #eq(EuclideanVector, DoublePrecisionContext)
      */
     public boolean isZero(final DoublePrecisionContext precision) {
-        return equals(getZero(), precision);
+        return eq(getZero(), precision);
     }
 
-    /** Return the vector norm value, throwing an {@link IllegalNormException} if the value
-     * is not real (ie, NaN or infinite) or zero.
+    /** Return the vector norm value, throwing an
+     * {@link org.apache.commons.geometry.core.exception.IllegalNormException} if the value is not real
+     * (ie, NaN or infinite) or zero.
      * @return the vector norm value, guaranteed to be real and non-zero
-     * @throws IllegalNormException if the vector norm is zero, NaN, or infinite
+     * @throws org.apache.commons.geometry.core.exception.IllegalNormException if the vector norm is
+     *      zero, NaN, or infinite
      */
     protected double getCheckedNorm() {
         return Vectors.checkedNorm(this);
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/MultiDimensionalEuclideanVector.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/MultiDimensionalEuclideanVector.java
index 05f6c2e..3cd6365 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/MultiDimensionalEuclideanVector.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/MultiDimensionalEuclideanVector.java
@@ -16,8 +16,6 @@
  */
 package org.apache.commons.geometry.euclidean;
 
-import org.apache.commons.geometry.core.exception.IllegalNormException;
-
 /**
  * Abstract base class for Euclidean vectors with two or more dimensions.
  *
@@ -26,7 +24,7 @@
 public abstract class MultiDimensionalEuclideanVector<V extends MultiDimensionalEuclideanVector<V>>
         extends EuclideanVector<V> {
 
-    /** Serializable version identifer */
+    /** Serializable version identifier. */
     private static final long serialVersionUID = 20181017L;
 
     /** Get the projection of the instance onto the given base vector. The returned
@@ -37,7 +35,8 @@
      * </code>
      * @param base base vector
      * @return the vector projection of the instance onto {@code base}
-     * @exception IllegalNormException if the norm of the base vector is zero, NaN, or infinite
+     * @exception org.apache.commons.geometry.core.exception.IllegalNormException if the norm of the base vector is
+     *      zero, NaN, or infinite
      * @see #reject(MultiDimensionalEuclideanVector)
      */
     public abstract V project(V base);
@@ -52,15 +51,16 @@
      * </code>
      * @param base base vector
      * @return the vector rejection of the instance from {@code base}
-     * @exception IllegalNormException if the norm of the base vector is zero, NaN, or infinite
+     * @exception org.apache.commons.geometry.core.exception.IllegalNormException if the norm of the base vector is
+     *      zero, NaN, or infinite
      * @see #project(MultiDimensionalEuclideanVector)
      */
     public abstract V reject(V base);
 
     /** Get a unit vector orthogonal to the instance.
      * @return a unit vector orthogonal to the current instance
-     * @throws IllegalNormException if the norm of the current instance is zero, NaN,
-     *  or infinite
+     * @throws org.apache.commons.geometry.core.exception.IllegalNormException if the norm of the current instance
+     *      is zero, NaN, or infinite
      */
     public abstract V orthogonal();
 
@@ -70,8 +70,8 @@
      * @param dir the direction to use for generating the orthogonal vector
      * @return unit vector orthogonal to the current vector and pointing in the direction of
      *      {@code dir} that does not lie along the current vector
-     * @throws IllegalNormException if either vector norm is zero, NaN or infinite,
-     *      or the given vector is collinear with this vector.
+     * @throws org.apache.commons.geometry.core.exception.IllegalNormException if either vector norm is
+     *      zero, NaN or infinite, or the given vector is collinear with this vector.
      */
     public abstract V orthogonal(V dir);
 }
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/exception/NonInvertibleTransformException.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/exception/NonInvertibleTransformException.java
index 88748dd..916c75c 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/exception/NonInvertibleTransformException.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/exception/NonInvertibleTransformException.java
@@ -23,7 +23,7 @@
  */
 public class NonInvertibleTransformException extends GeometryException {
 
-    /** Serializable version identifier */
+    /** Serializable version identifier. */
     private static final long serialVersionUID = 20180927L;
 
     /** Simple constructor accepting an error message.
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/internal/AbstractPathConnector.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/internal/AbstractPathConnector.java
new file mode 100644
index 0000000..08b3a2f
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/internal/AbstractPathConnector.java
@@ -0,0 +1,460 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.internal;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.NavigableSet;
+import java.util.TreeSet;
+
+/** Abstract base class for joining unconnected path elements into connected, directional
+ * paths. The connection algorithm is exposed as a set of protected methods, allowing subclasses
+ * to define their own public API. Implementations must supply their own subclass of {@link ConnectableElement}
+ * specific for the objects being connected.
+ *
+ * <p>The connection algorithm proceeds as follows:
+ * <ul>
+ *      <li>Create a sorted list of {@link ConnectableElement}s.</li>
+ *      <li>For each element, attempt to find other elements with start points next the
+ *      first instance's end point by calling {@link ConnectableElement#getConnectionSearchKey()} and
+ *      using the returned instance to locate a search start location in the sorted element list.</li>
+ *      <li>Search up through the sorted list from the start location, testing each element for possible connectivity
+ *      with {@link ConnectableElement#canConnectTo(AbstractPathConnector.ConnectableElement)}. Collect possible
+ *      connections in a list. Terminate the search when
+ *      {@link ConnectableElement#shouldContinueConnectionSearch(AbstractPathConnector.ConnectableElement, boolean)}
+ *      returns false.
+ *      <li>Repeat the previous step searching downward through the list from the start location.</li>
+ *      <li>Select the best connection option from the list of possible connections, using
+ *      {@link #selectPointConnection(AbstractPathConnector.ConnectableElement, List)}
+ *      and/or {@link #selectConnection(AbstractPathConnector.ConnectableElement, List)} when multiple possibilities
+ *      are found.</li>
+ *      <li>Repeat the above steps for each element. When done, the elements represent a linked list
+ *      of connected paths.</li>
+ * </ul>
+ *
+ * <p>This class is not thread-safe.</p>
+ *
+ * @param <E> Element type
+ * @see ConnectableElement
+ */
+public abstract class AbstractPathConnector<E extends AbstractPathConnector.ConnectableElement<E>>
+    implements Serializable {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20191106L;
+
+    /** List of path elements. */
+    private final NavigableSet<E> pathElements = new TreeSet<>();
+
+    /** View of the path element set in descending order. */
+    private final NavigableSet<E> pathElementsDescending = pathElements.descendingSet();
+
+    /** List used to store possible connections for the current element. */
+    private final List<E> possibleConnections = new ArrayList<>();
+
+    /** List used to store possible point-like (zero-length) connections for the current element. */
+    private final List<E> possiblePointConnections = new ArrayList<>();
+
+    /** Add a collection of path elements to the connector and attempt to connect each new element
+     * with previously added ones.
+     * @param elements path elements to connect
+     */
+    protected void connectPathElements(final Iterable<E> elements) {
+        elements.forEach(this::addPathElement);
+
+        for (final E element : elements) {
+            makeForwardConnection(element);
+        }
+    }
+
+    /** Add a single path element to the connector, leaving it unconnected until a later call to
+     * to {@link #connectPathElements(Iterable)} or {@link #computePathRoots()}.
+     * @param element value to add to the connector
+     * @see #connectPathElements(Iterable)
+     * @see #computePathRoots()
+     */
+    protected void addPathElement(final E element) {
+        pathElements.add(element);
+    }
+
+    /** Compute all connected paths and return a list of path elements representing
+     * the roots (start locations) of each. Each returned element is the head of a
+     * (possibly circular) linked list that follows a connected path.
+     *
+     * <p>The connector is reset after this call. Further calls to add elements
+     * will result in new paths being generated.</p>
+     * @return a list of root elements for the computed connected paths
+     */
+    protected List<E> computePathRoots() {
+        for (final E segment : pathElements) {
+            followForwardConnections(segment);
+        }
+
+        List<E> rootEntries = new ArrayList<>();
+        E root;
+        for (final E segment : pathElements) {
+            root = segment.exportPath();
+            if (root != null) {
+                rootEntries.add(root);
+            }
+        }
+
+        pathElements.clear();
+        possibleConnections.clear();
+        possiblePointConnections.clear();
+
+        return rootEntries;
+    }
+
+    /** Find and follow forward connections from the given start element.
+     * @param start element to begin the connection operation with
+     */
+    private void followForwardConnections(final E start) {
+        E current = start;
+
+        while (current != null && current.hasEnd() && !current.hasNext()) {
+            current = makeForwardConnection(current);
+        }
+    }
+
+    /** Connect the end point of the given element to the start point of another element. Returns
+     * the newly connected element or null if no forward connection was made.
+     * @param element element to connect
+     * @return the next element in the path or null if no connection was made
+     */
+    private E makeForwardConnection(final E element) {
+        findPossibleConnections(element);
+
+        E next = null;
+
+        // select from all available connections, handling point-like segments first
+        if (!possiblePointConnections.isEmpty()) {
+            next = (possiblePointConnections.size() == 1) ?
+                    possiblePointConnections.get(0) :
+                    selectPointConnection(element, possiblePointConnections);
+        } else if (!possibleConnections.isEmpty()) {
+
+            next = (possibleConnections.size() == 1) ?
+                    possibleConnections.get(0) :
+                    selectConnection(element, possibleConnections);
+        }
+
+        if (next != null) {
+            element.connectTo(next);
+        }
+
+        return next;
+    }
+
+    /** Find possible connections for the given element and place them in the
+     * {@link #possibleConnections} and {@link #possiblePointConnections} lists.
+     * @param element the element to find connections for
+     */
+    private void findPossibleConnections(final E element) {
+        possibleConnections.clear();
+        possiblePointConnections.clear();
+
+        if (element.hasEnd()) {
+            final E searchKey = element.getConnectionSearchKey();
+
+            // search up
+            for (E candidate : pathElements.tailSet(searchKey)) {
+                if (!addPossibleConnection(element, candidate) &&
+                        !element.shouldContinueConnectionSearch(candidate, true)) {
+                    break;
+                }
+            }
+
+            // search down
+            for (E candidate : pathElementsDescending.tailSet(searchKey, false)) {
+                if (!addPossibleConnection(element, candidate) &&
+                        !element.shouldContinueConnectionSearch(candidate, false)) {
+                    break;
+                }
+            }
+        }
+    }
+
+    /** Add the candidate to one of the connection lists if it represents a possible connection. Returns
+     * true if the candidate was added, otherwise false.
+     * @param element element to check for connections with
+     * @param candidate candidate connection element
+     * @return true if the candidate is a possible connection
+     */
+    private boolean addPossibleConnection(final E element, final E candidate) {
+        if (element != candidate &&
+                !candidate.hasPrevious() &&
+                candidate.hasStart() &&
+                element.canConnectTo(candidate)) {
+
+            if (element.endPointsEq(candidate)) {
+                possiblePointConnections.add(candidate);
+            } else {
+                possibleConnections.add(candidate);
+            }
+
+            return true;
+        }
+
+        return false;
+    }
+
+    /** Method called to select a connection to use for a given element when multiple zero-length connections are
+     * available. The algorithm here attempts to choose the point most likely to produce a logical path by selecting
+     * the outgoing element with the smallest relative angle with the incoming element, with unconnected element
+     * preferred over ones that are already connected (thereby allowing other connections to occur in the path).
+     * @param incoming the incoming element
+     * @param outgoingList list of available outgoing point-like connections
+     * @return the connection to use
+     */
+    protected E selectPointConnection(final E incoming, final List<E> outgoingList) {
+
+        double angle;
+        boolean isUnconnected;
+
+        double smallestAngle = 0.0;
+        E bestElement = null;
+        boolean bestIsUnconnected = false;
+
+        for (final E outgoing : outgoingList) {
+            angle = Math.abs(incoming.getRelativeAngle(outgoing));
+            isUnconnected = !outgoing.hasNext();
+
+            if (bestElement == null || (!bestIsUnconnected && isUnconnected) ||
+                    (bestIsUnconnected == isUnconnected && angle < smallestAngle)) {
+
+                smallestAngle = angle;
+                bestElement = outgoing;
+                bestIsUnconnected = isUnconnected;
+            }
+        }
+
+        return bestElement;
+    }
+
+    /** Method called to select a connection to use for a given segment when multiple non-length-zero
+     * connections are available. In this case, the selection of the outgoing connection depends only
+     * on the desired characteristics of the connected path.
+     * @param incoming the incoming segment
+     * @param outgoing list of available outgoing connections; will always contain at least
+     *      two elements
+     * @return the connection to use
+     */
+    protected abstract E selectConnection(E incoming, List<E> outgoing);
+
+    /** Class used to represent connectable path elements for use with {@link AbstractPathConnector}.
+     * Subclasses must fulfill the following requirements in order for path connection operations
+     * to work correctly:
+     * <ul>
+     *      <li>Implement {@link #compareTo(Object)} such that elements are sorted by their start
+     *      point locations. Other criteria may be used as well but elements with start points in close
+     *      proximity must be grouped together.</li>
+     *      <li>Implement {@link #getConnectionSearchKey()} such that it returns an instance that will be placed
+     *      next to elements with start points close to the current instance's end point when sorted with
+     *      {@link #compareTo(Object)}.</li>
+     *      <li>Implement {@link #shouldContinueConnectionSearch(AbstractPathConnector.ConnectableElement, boolean)}
+     *      such that it returns false when the search for possible connections through a sorted list of elements
+     *      may terminate.</li>
+     * </ul>
+     *
+     * @param <E> Element type
+     * @see AbstractPathConnector
+     */
+    public abstract static class ConnectableElement<E extends ConnectableElement<E>>
+        implements Comparable<E>, Serializable {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20191107L;
+
+        /** Next connected element. */
+        private E next;
+
+        /** Previous connected element. */
+        private E previous;
+
+        /** Flag set to true when this element has exported its value to a path. */
+        private boolean exported = false;
+
+        /** Return true if the instance is connected to another element's start point.
+         * @return true if the instance has a next element
+         */
+        public boolean hasNext() {
+            return next != null;
+        }
+
+        /** Get the next connected element in the path, if any.
+         * @return the next connected segment in the path; may be null
+         */
+        public E getNext() {
+            return next;
+        }
+
+        /** Set the next connected element for this path. This is intended for
+         * internal use only. Callers should use the {@link #connectTo(AbstractPathConnector.ConnectableElement)}
+         * method instead.
+         * @param next next path element
+         */
+        protected void setNext(final E next) {
+            this.next = next;
+        }
+
+        /** Return true if another element is connected to this instance's start point.
+         * @return true if the instance has a previous element
+         */
+        public boolean hasPrevious() {
+            return previous != null;
+        }
+
+        /** Get the previous connected element in the path, if any.
+         * @return the previous connected element in the path; may be null
+         */
+        public E getPrevious() {
+            return previous;
+        }
+
+        /** Set the previous connected element for this path. This is intended for
+         * internal use only. Callers should use the {@link #connectTo(AbstractPathConnector.ConnectableElement)}
+         * method instead.
+         * @param previous previous path element
+         */
+        protected void setPrevious(final E previous) {
+            this.previous = previous;
+        }
+
+        /** Connect this instance's end point to the given element's start point. No validation
+         * is performed in this method. The {@link #canConnectTo(AbstractPathConnector.ConnectableElement)}
+         * method must have been called previously.
+         * @param nextElement the next element in the path
+         */
+        public void connectTo(final E nextElement) {
+            setNext(nextElement);
+            nextElement.setPrevious(getSelf());
+        }
+
+        /** Export the path that this element belongs to, returning the root
+         * segment. This method traverses all connected element, sets their
+         * exported flags to true, and returns the root element of the path
+         * (or this element in the case of a loop). Each path can only be
+         * exported once. Later calls to this method on this instance or any of its
+         * connected elements will return null.
+         * @return the root of the path or null if the path that this element
+         *      belongs to has already been exported
+         */
+        public E exportPath() {
+            if (markExported()) {
+
+                // export the connected portions of the path, moving both
+                // forward and backward
+                E current;
+                E root = getSelf();
+
+                // forward
+                current = next;
+                while (current != null && current.markExported()) {
+                    current = current.getNext();
+                }
+
+                // backward
+                current = previous;
+                while (current != null && current.markExported()) {
+                    root = current;
+                    current = current.getPrevious();
+                }
+
+                return root;
+            }
+
+            return null;
+        }
+
+        /** Set the export flag for this instance to true. Returns true
+         * if the flag was changed and false otherwise.
+         * @return true if the flag was changed and false if it was
+         *      already set to true
+         */
+        protected boolean markExported() {
+            if (!exported) {
+                exported = true;
+                return true;
+            }
+            return false;
+        }
+
+        /** Return true if this instance has a start point that can be
+         * connected to another element's end point.
+         * @return true if this instance has a start point that can be
+         *      connected to another element's end point
+         */
+        public abstract boolean hasStart();
+
+        /** Return true if this instance has an end point that can be
+         * connected to another element's start point.
+         * @return true if this instance has an end point that can be
+         *      connected to another element's start point
+         */
+        public abstract boolean hasEnd();
+
+        /** Return true if the end point of this instance should be considered
+         * equivalent to the end point of the argument.
+         * @param other element to compare end points with
+         * @return true if this instance has an end point equivalent to that
+         *      of the argument
+         */
+        public abstract boolean endPointsEq(E other);
+
+        /** Return true if this instance's end point can be connected to
+         * the argument's start point.
+         * @param nextElement candidate for the next element in the path; this value
+         *      is guaranteed to not be null and to contain a start point
+         * @return true if this instance's end point can be connected to
+         *      the argument's start point
+         */
+        public abstract boolean canConnectTo(E nextElement);
+
+        /** Return the relative angle between this element and the argument.
+         * @param other element to compute the angle with
+         * @return the relative angle between this element and the argument
+         */
+        public abstract double getRelativeAngle(E other);
+
+        /** Get a new instance used as a search key to help locate other elements
+         * with start points matching this instance's end point. The only restriction
+         * on the returned instance is that it be compatible with the implementation
+         * class' {@link #compareTo(Object)} method.
+         * @return a new instance used to help locate other path elements with start
+         *      points equivalent to this instance's end point
+         */
+        public abstract E getConnectionSearchKey();
+
+        /** Return true if the search for possible connections should continue through
+         * the sorted set of possible path elements given the current candidate element
+         * and search direction. The search operation stops for the given direction
+         * when this method returns false.
+         * @param candidate last tested candidate connection element
+         * @param ascending true if the search is proceeding in an ascending direction;
+         *      false otherwise
+         * @return true if the connection search should continue
+         */
+        public abstract boolean shouldContinueConnectionSearch(E candidate, boolean ascending);
+
+        /** Return the current instance as the generic type.
+         * @return the current instance as the generic type.
+         */
+        protected abstract E getSelf();
+    }
+}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/internal/Matrices.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/internal/Matrices.java
index ec710ba..51ff088 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/internal/Matrices.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/internal/Matrices.java
@@ -20,7 +20,7 @@
  */
 public final class Matrices {
 
-    /** Private constructor */
+    /** Private constructor. */
     private Matrices() {}
 
     /** Compute the determinant of the 2x2 matrix represented by the given values.
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/AffineTransformMatrix1D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/AffineTransformMatrix1D.java
index 045c893..25a0de0 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/AffineTransformMatrix1D.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/AffineTransformMatrix1D.java
@@ -19,12 +19,12 @@
 import java.io.Serializable;
 
 import org.apache.commons.geometry.core.internal.DoubleFunction1N;
-import org.apache.commons.geometry.euclidean.AffineTransformMatrix;
+import org.apache.commons.geometry.euclidean.AbstractAffineTransformMatrix;
 import org.apache.commons.geometry.euclidean.exception.NonInvertibleTransformException;
 import org.apache.commons.geometry.euclidean.internal.Vectors;
 import org.apache.commons.numbers.core.Precision;
 
-/** Class using a matrix to represent a affine transformations in 1 dimensional Euclidean space.
+/** Class using a matrix to represent affine transformations in 1 dimensional Euclidean space.
 *
 * <p>Instances of this class use a 2x2 matrix for all transform operations.
 * The last row of this matrix is always set to the values <code>[0 1]</code> and so
@@ -32,29 +32,30 @@
 * use arrays containing 2 elements, instead of 4.
 * </p>
 */
-public final class AffineTransformMatrix1D implements AffineTransformMatrix<Vector1D, Vector1D>, Serializable {
+public final class AffineTransformMatrix1D extends AbstractAffineTransformMatrix<Vector1D>
+    implements Transform1D, Serializable {
 
-    /** Serializable version identifier */
+    /** Serializable version identifier. */
     private static final long serialVersionUID = 20181006L;
 
-    /** The number of internal matrix elements */
+    /** The number of internal matrix elements. */
     private static final int NUM_ELEMENTS = 2;
 
-    /** String used to start the transform matrix string representation */
+    /** String used to start the transform matrix string representation. */
     private static final String MATRIX_START = "[ ";
 
-    /** String used to end the transform matrix string representation */
+    /** String used to end the transform matrix string representation. */
     private static final String MATRIX_END = " ]";
 
-    /** String used to separate elements in the matrix string representation */
+    /** String used to separate elements in the matrix string representation. */
     private static final String ELEMENT_SEPARATOR = ", ";
 
     /** Shared transform set to the identity matrix. */
     private static final AffineTransformMatrix1D IDENTITY_INSTANCE = new AffineTransformMatrix1D(1, 0);
 
-    /** Transform matrix entry <code>m<sub>0,0</sub></code> */
+    /** Transform matrix entry <code>m<sub>0,0</sub></code>. */
     private final double m00;
-    /** Transform matrix entry <code>m<sub>0,1</sub></code> */
+    /** Transform matrix entry <code>m<sub>0,1</sub></code>. */
     private final double m01;
 
     /**
@@ -81,7 +82,7 @@
      */
     public double[] toArray() {
         return new double[] {
-                m00, m01
+            m00, m01
         };
     }
 
@@ -107,10 +108,25 @@
      * @see #applyVector(Vector1D)
      */
     @Override
-    public Vector1D applyDirection(final Vector1D vec) {
+    public Vector1D.Unit applyDirection(final Vector1D vec) {
         return applyVector(vec, Vector1D.Unit::from);
     }
 
+    /** {@inheritDoc} */
+    @Override
+    public double determinant() {
+        return m00;
+    }
+
+    /** {@inheritDoc}
+     *
+     * <p>This simply returns the current instance.</p>
+     */
+    @Override
+    public AffineTransformMatrix1D toMatrix() {
+        return this;
+    }
+
     /** Get a new transform containing the result of applying a translation logically after
      * the transformation represented by the current instance. This is achieved by
      * creating a new translation transform and pre-multiplying it with the current
@@ -214,7 +230,7 @@
         final double invDet = 1.0 / det;
 
         final double c00 = invDet;
-        final double c01 = - (this.m01 * invDet);
+        final double c01 = -(this.m01 * invDet);
 
         return new AffineTransformMatrix1D(c00, c01);
     }
@@ -288,7 +304,7 @@
      * @return a new transform initialized with the given matrix values
      * @throws IllegalArgumentException if the array does not have 2 elements
      */
-    public static AffineTransformMatrix1D of(final double ... arr) {
+    public static AffineTransformMatrix1D of(final double... arr) {
         if (arr.length != NUM_ELEMENTS) {
             throw new IllegalArgumentException("Dimension mismatch: " + arr.length + " != " + NUM_ELEMENTS);
         }
@@ -341,7 +357,8 @@
      * @param b second transform
      * @return the transform computed as {@code a x b}
      */
-    private static AffineTransformMatrix1D multiply(final AffineTransformMatrix1D a, final AffineTransformMatrix1D b) {
+    private static AffineTransformMatrix1D multiply(final AffineTransformMatrix1D a,
+            final AffineTransformMatrix1D b) {
 
         // calculate the matrix elements
         final double c00 = a.m00 * b.m00;
@@ -358,7 +375,8 @@
      */
     private static void validateElementForInverse(final double element) {
         if (!Double.isFinite(element)) {
-            throw new NonInvertibleTransformException("Transform is not invertible; invalid matrix element: " + element);
+            throw new NonInvertibleTransformException("Transform is not invertible; invalid matrix element: " +
+                    element);
         }
     }
 }
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/FunctionTransform1D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/FunctionTransform1D.java
new file mode 100644
index 0000000..5c0ca23
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/FunctionTransform1D.java
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.oned;
+
+import java.util.function.Function;
+
+/** Class that wraps a {@link Function} with the {@link Transform1D} interface.
+ */
+public final class FunctionTransform1D implements Transform1D {
+
+    /** Static instance representing the identity transform. */
+    private static final FunctionTransform1D IDENTITY =
+            new FunctionTransform1D(Function.identity(), true, Vector1D.ZERO);
+
+    /** The underlying function for the transform. */
+    private final Function<Vector1D, Vector1D> fn;
+
+    /** True if the transform preserves spatial orientation. */
+    private final boolean preservesOrientation;
+
+    /** The translation component of the transform. */
+    private final Vector1D translation;
+
+    /** Construct a new instance from its component parts. No validation of the input is performed.
+     * @param fn the underlying function for the transform
+     * @param preservesOrientation true if the transform preserves spatial orientation
+     * @param translation the translation component of the transform
+     */
+    private FunctionTransform1D(final Function<Vector1D, Vector1D> fn, final boolean preservesOrientation,
+            final Vector1D translation) {
+        this.fn = fn;
+        this.preservesOrientation = preservesOrientation;
+        this.translation = translation;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Vector1D apply(final Vector1D pt) {
+        return fn.apply(pt);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Vector1D applyVector(final Vector1D vec) {
+        return apply(vec).subtract(translation);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean preservesOrientation() {
+        return preservesOrientation;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public AffineTransformMatrix1D toMatrix() {
+        final Vector1D tOne = applyVector(Vector1D.Unit.PLUS);
+
+        return AffineTransformMatrix1D.of(tOne.getX(), translation.getX());
+    }
+
+    /** Return an instance representing the identity transform.
+     * @return an instance representing the identity transform
+     */
+    public static FunctionTransform1D identity() {
+        return IDENTITY;
+    }
+
+    /** Construct a new transform instance from the given function.
+     * @param fn the function to use for the transform
+     * @return a new transform instance using the given function
+     */
+    public static FunctionTransform1D from(final Function<Vector1D, Vector1D> fn) {
+        final Vector1D tOne = fn.apply(Vector1D.Unit.PLUS);
+        final Vector1D tZero = fn.apply(Vector1D.ZERO);
+
+        final boolean preservesOrientation = tOne.getX() > 0.0;
+
+        return new FunctionTransform1D(fn, preservesOrientation, tZero);
+    }
+}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/Interval.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/Interval.java
index c4c39af..2003ddc 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/Interval.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/Interval.java
@@ -16,74 +16,501 @@
  */
 package org.apache.commons.geometry.euclidean.oned;
 
-import org.apache.commons.geometry.core.partitioning.Region.Location;
+import java.io.Serializable;
+import java.text.MessageFormat;
 
-/** This class represents a 1D interval.
- * @see IntervalsSet
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.exception.GeometryException;
+import org.apache.commons.geometry.core.partitioning.Hyperplane;
+import org.apache.commons.geometry.core.partitioning.HyperplaneBoundedRegion;
+import org.apache.commons.geometry.core.partitioning.HyperplaneLocation;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+
+/** Class representing an interval in one dimension. The interval is defined
+ * by minimum and maximum values. One or both of these values may be infinite
+ * although not with the same sign.
+ *
+ * <p>Instances of this class are guaranteed to be immutable.</p>
  */
-public class Interval {
+public final class Interval implements HyperplaneBoundedRegion<Vector1D>, Serializable {
 
-    /** The lower bound of the interval. */
-    private final double lower;
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20190210L;
 
-    /** The upper bound of the interval. */
-    private final double upper;
+    /** Interval instance representing the entire real number line. */
+    private static final Interval FULL = new Interval(null, null);
 
-    /** Simple constructor.
-     * @param lower lower bound of the interval
-     * @param upper upper bound of the interval
+    /** {@link OrientedPoint} instance representing the min boundary of the interval,
+     * or null if no min boundary exists. If present, this instance will be negative-facing.
+     * Infinite values are allowed but not NaN.
      */
-    public Interval(final double lower, final double upper) {
-        if (upper < lower) {
-            throw new IllegalArgumentException("Endpoints do not specify an interval: [{" + upper + "}, {" + lower + "}]");
+    private final OrientedPoint minBoundary;
+
+    /** {@link OrientedPoint} instance representing the max boundary of the interval,
+     * or null if no max boundary exists. If present, this instance will be negative-facing.
+     * Infinite values are allowed but not NaN.
+     */
+    private final OrientedPoint maxBoundary;
+
+    /** Create an instance from min and max bounding hyperplanes. No validation is performed.
+     * Callers are responsible for ensuring that the given hyperplanes represent a valid
+     * interval.
+     * @param minBoundary the min (negative-facing) hyperplane
+     * @param maxBoundary the max (positive-facing) hyperplane
+     */
+    private Interval(final OrientedPoint minBoundary, final OrientedPoint maxBoundary) {
+        this.minBoundary = minBoundary;
+        this.maxBoundary = maxBoundary;
+    }
+
+    /** Get the minimum value for the interval or {@link Double#NEGATIVE_INFINITY}
+     * if no minimum value exists.
+     * @return the minimum value for the interval or {@link Double#NEGATIVE_INFINITY}
+     *      if no minimum value exists.
+     */
+    public double getMin() {
+        return (minBoundary != null) ? minBoundary.getLocation() : Double.NEGATIVE_INFINITY;
+    }
+
+    /** Get the maximum value for the interval or {@link Double#POSITIVE_INFINITY}
+     * if no maximum value exists.
+     * @return the maximum value for the interval or {@link Double#POSITIVE_INFINITY}
+     *      if no maximum value exists.
+     */
+    public double getMax() {
+        return (maxBoundary != null) ? maxBoundary.getLocation() : Double.POSITIVE_INFINITY;
+    }
+
+    /**
+     * Get the {@link OrientedPoint} forming the minimum bounding hyperplane
+     * of the interval, or null if none exists. If present, This hyperplane
+     * is oriented to point in the negative direction.
+     * @return the hyperplane forming the minimum boundary of the interval or
+     *      null if no minimum boundary exists
+     */
+    public OrientedPoint getMinBoundary() {
+        return minBoundary;
+    }
+
+    /**
+     * Get the {@link OrientedPoint} forming the maximum bounding hyperplane
+     * of the interval, or null if none exists. If present, this hyperplane
+     * is oriented to point in the positive direction.
+     * @return the hyperplane forming the maximum boundary of the interval or
+     *      null if no maximum boundary exists
+     */
+    public OrientedPoint getMaxBoundary() {
+        return maxBoundary;
+    }
+
+    /** Return true if the interval has a minimum (lower) boundary.
+     * @return true if the interval has minimum (lower) boundary
+     */
+    public boolean hasMinBoundary() {
+        return minBoundary != null;
+    }
+
+    /** Return true if the interval has a maximum (upper) boundary.
+     * @return true if the interval has maximum (upper) boundary
+     */
+    public boolean hasMaxBoundary() {
+        return maxBoundary != null;
+    }
+
+    /** True if the region is infinite, meaning that at least one of the boundaries
+     * does not exist.
+     * @return true if the region is infinite
+     */
+    public boolean isInfinite() {
+        return minBoundary == null || maxBoundary == null;
+    }
+
+    /** True if the region is finite, meaning that both the minimum and maximum
+     * boundaries exist and the region size is finite.
+     * @return true if the region is finite
+     */
+    public boolean isFinite() {
+        return !isInfinite();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public RegionLocation classify(final Vector1D pt) {
+        return classify(pt.getX());
+    }
+
+    /** Classify a point with respect to the interval.
+     * @param location the location to classify
+     * @return the classification of the point with respect to the interval
+     * @see #classify(Vector1D)
+     */
+    public RegionLocation classify(final double location) {
+        final RegionLocation minLoc = classifyWithBoundary(location, minBoundary);
+        final RegionLocation maxLoc = classifyWithBoundary(location, maxBoundary);
+
+        if (minLoc == RegionLocation.BOUNDARY || maxLoc == RegionLocation.BOUNDARY) {
+            return RegionLocation.BOUNDARY;
+        } else if (minLoc == RegionLocation.INSIDE && maxLoc == RegionLocation.INSIDE) {
+            return RegionLocation.INSIDE;
         }
-        this.lower = lower;
-        this.upper = upper;
+        return RegionLocation.OUTSIDE;
     }
 
-    /** Get the lower bound of the interval.
-     * @return lower bound of the interval
+    /** Classify the location using the given interval boundary, which may be null.
+     * @param location the location to classify
+     * @param boundary interval boundary to classify against
+     * @return the location of the point relative to the boundary
      */
-    public double getInf() {
-        return lower;
-    }
-
-    /** Get the upper bound of the interval.
-     * @return upper bound of the interval
-     */
-    public double getSup() {
-        return upper;
-    }
-
-    /** Get the size of the interval.
-     * @return size of the interval
-     */
-    public double getSize() {
-        return upper - lower;
-    }
-
-    /** Get the barycenter of the interval.
-     * @return barycenter of the interval
-     */
-    public double getBarycenter() {
-        return 0.5 * (lower + upper);
-    }
-
-    /** Check a point with respect to the interval.
-     * @param point point to check
-     * @param tolerance tolerance below which points are considered to
-     * belong to the boundary
-     * @return a code representing the point status: either {@link
-     * Location#INSIDE}, {@link Location#OUTSIDE} or {@link Location#BOUNDARY}
-     */
-    public Location checkPoint(final double point, final double tolerance) {
-        if (point < lower - tolerance || point > upper + tolerance) {
-            return Location.OUTSIDE;
-        } else if (point > lower + tolerance && point < upper - tolerance) {
-            return Location.INSIDE;
+    private RegionLocation classifyWithBoundary(final double location, final OrientedPoint boundary) {
+        if (Double.isNaN(location)) {
+            return RegionLocation.OUTSIDE;
+        } else if (boundary == null) {
+            return RegionLocation.INSIDE;
         } else {
-            return Location.BOUNDARY;
+            final HyperplaneLocation hyperLoc = boundary.classify(location);
+
+            if (hyperLoc == HyperplaneLocation.ON) {
+                return RegionLocation.BOUNDARY;
+            } else if (hyperLoc == HyperplaneLocation.PLUS) {
+                return RegionLocation.OUTSIDE;
+            }
+            return RegionLocation.INSIDE;
         }
     }
 
+    /** Return true if the given point location is on the inside or boundary
+     * of the region.
+     * @param x the location to test
+     * @return true if the location is on the inside or boundary of the region
+     * @see #contains(Point)
+     */
+    public boolean contains(final double x) {
+        return classify(x) != RegionLocation.OUTSIDE;
+    }
+
+    /** {@inheritDoc}
+     *
+     * <p>The point is projected onto the nearest interval boundary. When a point
+     * is on the inside of the interval and is equidistant from both boundaries,
+     * then the minimum boundary is selected. when a point is on the outside of the
+     * interval and is equidistant from both boundaries (as is the case for intervals
+     * representing a single point), then the boundary facing the point is returned,
+     * ensuring that the returned offset is positive.
+     * </p>
+     */
+    @Override
+    public Vector1D project(Vector1D pt) {
+
+        OrientedPoint boundary = null;
+
+        if (minBoundary != null && maxBoundary != null) {
+            // both boundaries are present; use the closest
+            final double minOffset = minBoundary.offset(pt.getX());
+            final double maxOffset = maxBoundary.offset(pt.getX());
+
+            final double minDist = Math.abs(minOffset);
+            final double maxDist = Math.abs(maxOffset);
+
+            // Project onto the max boundary if it's the closest or the point is on its plus side.
+            // Otherwise, project onto the min boundary.
+            if (maxDist < minDist || maxOffset > 0) {
+                boundary = maxBoundary;
+            } else {
+                boundary = minBoundary;
+            }
+        } else if (minBoundary != null) {
+            // only the min boundary is present
+            boundary = minBoundary;
+        } else if (maxBoundary != null) {
+            // only the max boundary is present
+            boundary = maxBoundary;
+        }
+
+        return (boundary != null) ? boundary.project(pt) : null;
+    }
+
+    /** Return a new instance transformed by the argument.
+     * @param transform transform to apply
+     * @return a new instance transformed by the argument
+     */
+    public Interval transform(final Transform<Vector1D> transform) {
+        final OrientedPoint transformedMin = (minBoundary != null) ?
+                minBoundary.transform(transform) :
+                null;
+        final OrientedPoint transformedMax = (maxBoundary != null) ?
+                maxBoundary.transform(transform) :
+                null;
+
+        return of(transformedMin, transformedMax);
+    }
+
+    /** {@inheritDoc}
+     *
+     *  <p>This method always returns false since there is always at least
+     *  one point that can be classified as not being on the outside of
+     *  the region.</p>
+     */
+    @Override
+    public boolean isEmpty() {
+        return false;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isFull() {
+        return minBoundary == null && maxBoundary == null;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public double getSize() {
+        if (isInfinite()) {
+            return Double.POSITIVE_INFINITY;
+        }
+
+        return getMax() - getMin();
+    }
+
+    /** {@inheritDoc}
+     *
+     *  <p>This method simply returns 0 because boundaries in one dimension do not
+     *  have any size.</p>
+     */
+    @Override
+    public double getBoundarySize() {
+        return 0;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Vector1D getBarycenter() {
+        if (isInfinite()) {
+            return null;
+        }
+
+        final double min = getMin();
+        final double max = getMax();
+
+        return Vector1D.of((0.5 * (max - min)) + min);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Split<Interval> split(final Hyperplane<Vector1D> splitter) {
+        final OrientedPoint splitOrientedPoint = (OrientedPoint) splitter;
+        final Vector1D splitPoint = splitOrientedPoint.getPoint();
+
+        final HyperplaneLocation splitterMinLoc = (minBoundary != null) ? minBoundary.classify(splitPoint) : null;
+        final HyperplaneLocation splitterMaxLoc = (maxBoundary != null) ? maxBoundary.classify(splitPoint) : null;
+
+        Interval low = null;
+        Interval high = null;
+
+        if (splitterMinLoc != HyperplaneLocation.ON || splitterMaxLoc != HyperplaneLocation.ON) {
+
+            if (splitterMinLoc != null && splitterMinLoc != HyperplaneLocation.MINUS) {
+                // splitter is on or below min boundary
+                high = this;
+            } else if (splitterMaxLoc != null && splitterMaxLoc != HyperplaneLocation.MINUS) {
+                // splitter is on or above max boundary
+                low = this;
+            } else {
+                // the interval is split in two
+                low = new Interval(minBoundary, OrientedPoint.createPositiveFacing(
+                        splitPoint, splitOrientedPoint.getPrecision()));
+                high = new Interval(OrientedPoint.createNegativeFacing(
+                        splitPoint, splitOrientedPoint.getPrecision()), maxBoundary);
+            }
+        }
+
+        // assign minus/plus based on the orientation of the splitter
+        final boolean lowIsMinus = splitOrientedPoint.isPositiveFacing();
+        final Interval minus = lowIsMinus ? low : high;
+        final Interval plus = lowIsMinus ? high : low;
+
+        return new Split<>(minus, plus);
+    }
+
+    /** Return a {@link RegionBSPTree1D} representing the same region as this instance.
+     * @return a BSP tree representing the same region
+     * @see RegionBSPTree1D#from(Interval, Interval...)
+     */
+    public RegionBSPTree1D toTree() {
+        return RegionBSPTree1D.from(this);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String toString() {
+        final StringBuilder sb = new StringBuilder();
+        sb.append(this.getClass().getSimpleName())
+            .append("[min= ")
+            .append(getMin())
+            .append(", max= ")
+            .append(getMax())
+            .append(']');
+
+        return sb.toString();
+    }
+
+    /** Create a new interval from the given point locations. The returned interval represents
+     * the region between the points, regardless of the order they are given as arguments.
+     * @param a first point location
+     * @param b second point location
+     * @param precision precision context used to compare floating point numbers
+     * @return a new interval representing the region between the given point locations
+     * @throws GeometryException if either number is {@link Double#NaN NaN} or the numbers
+     *      are both infinite and have the same sign
+     */
+    public static Interval of(final double a, final double b, final DoublePrecisionContext precision) {
+        validateIntervalValues(a, b);
+
+        final double min = Math.min(a, b);
+        final double max = Math.max(a, b);
+
+        final OrientedPoint minBoundary = Double.isFinite(min) ?
+                OrientedPoint.fromLocationAndDirection(min, false, precision) :
+                null;
+
+        final OrientedPoint maxBoundary = Double.isFinite(max) ?
+                OrientedPoint.fromLocationAndDirection(max, true, precision) :
+                null;
+
+        if (minBoundary == null && maxBoundary == null) {
+            return FULL;
+        }
+
+        return new Interval(minBoundary, maxBoundary);
+    }
+
+    /** Create a new interval from the given points. The returned interval represents
+     * the region between the points, regardless of the order they are given as arguments.
+     * @param a first point
+     * @param b second point
+     * @param precision precision context used to compare floating point numbers
+     * @return a new interval representing the region between the given points
+     * @throws GeometryException if either point is {@link Vector1D#isNaN() NaN} or the points
+     *      are both {@link Vector1D#isInfinite() infinite} and have the same sign
+     */
+    public static Interval of(final Vector1D a, final Vector1D b, final DoublePrecisionContext precision) {
+        return of(a.getX(), b.getX(), precision);
+    }
+
+    /** Create a new interval from the given hyperplanes. The hyperplanes may be given in
+     * any order but one must be positive-facing and the other negative-facing, with the positive-facing
+     * hyperplane located above the negative-facing hyperplane. Either or both argument may be null,
+     * in which case the returned interval will extend to infinity in the appropriate direction. If both
+     * arguments are null, an interval representing the full space is returned.
+     * @param a first hyperplane; may be null
+     * @param b second hyperplane; may be null
+     * @return a new interval representing the region between the given hyperplanes
+     * @throws GeometryException if the hyperplanes have the same orientation or
+     *      do not form an interval (for example, if the positive-facing hyperplane is below
+     *      the negative-facing hyperplane)
+     */
+    public static Interval of(final OrientedPoint a, final OrientedPoint b) {
+        // determine the ordering of the hyperplanes
+        OrientedPoint minBoundary = null;
+        OrientedPoint maxBoundary = null;
+
+        if (a != null && b != null) {
+            // both hyperplanes are present, so validate then against each other
+            if (a.isPositiveFacing() == b.isPositiveFacing()) {
+                throw new GeometryException(
+                        MessageFormat.format("Invalid interval: hyperplanes have same orientation: {0}, {1}", a, b));
+            }
+
+            if (a.classify(b.getPoint()) == HyperplaneLocation.PLUS ||
+                    b.classify(a.getPoint()) == HyperplaneLocation.PLUS) {
+                throw new GeometryException(
+                        MessageFormat.format("Invalid interval: hyperplanes do not form interval: {0}, {1}", a, b));
+            }
+
+            // min boundary faces -infinity, max boundary faces +infinity
+            minBoundary = a.isPositiveFacing() ? b : a;
+            maxBoundary = a.isPositiveFacing() ? a : b;
+        } else if (a == null) {
+            if (b == null) {
+                // no boundaries; return the full number line
+                return FULL;
+            }
+
+            if (b.isPositiveFacing()) {
+                maxBoundary = b;
+            } else {
+                minBoundary = b;
+            }
+        } else {
+            if (a.isPositiveFacing()) {
+                maxBoundary = a;
+            } else {
+                minBoundary = a;
+            }
+        }
+
+        // validate the boundary locations
+        final double minLoc = (minBoundary != null) ? minBoundary.getLocation() : Double.NEGATIVE_INFINITY;
+        final double maxLoc = (maxBoundary != null) ? maxBoundary.getLocation() : Double.POSITIVE_INFINITY;
+
+        validateIntervalValues(minLoc, maxLoc);
+
+        // create the interval, replacing infinites with nulls
+        return new Interval(
+                Double.isFinite(minLoc) ? minBoundary : null,
+                Double.isFinite(maxLoc) ? maxBoundary : null);
+    }
+
+    /** Return an interval with the given min value and no max.
+     * @param min min value for the interval
+     * @param precision precision context used to compare floating point numbers
+     * @return an interval with the given min value and no max.
+     */
+    public static Interval min(final double min, final DoublePrecisionContext precision) {
+        return of(min, Double.POSITIVE_INFINITY, precision);
+    }
+
+    /** Return an interval with the given max value and no min.
+     * @param max max value for the interval
+     * @param precision precision context used to compare floating point numbers
+     * @return an interval with the given max value and no min.
+     */
+    public static Interval max(final double max, final DoublePrecisionContext precision) {
+        return of(Double.NEGATIVE_INFINITY, max, precision);
+    }
+
+    /** Return an interval representing a single point at the given location.
+     * @param location the location of the interval
+     * @param precision precision context used to compare floating point numbers
+     * @return an interval representing a single point
+     */
+    public static Interval point(final double location, final DoublePrecisionContext precision) {
+        return of(location, location, precision);
+    }
+
+    /** Return an interval representing the entire real number line. The {@link #isFull()}
+     * method of the instance will return true.
+     * @return an interval representing the entire real number line
+     * @see #isFull()
+     */
+    public static Interval full() {
+        return FULL;
+    }
+
+    /** Validate that the given value can be used to construct an interval. The values
+     * must not be NaN and if infinite, must have opposite signs.
+     * @param a first value
+     * @param b second value
+     * @throws GeometryException if either value is NaN or if both values are infinite
+     *      and have the same sign
+     */
+    private static void validateIntervalValues(final double a, final double b) {
+        if (Double.isNaN(a) || Double.isNaN(b) ||
+                (Double.isInfinite(a) && Double.compare(a, b) == 0)) {
+
+            throw new GeometryException(
+                    MessageFormat.format("Invalid interval values: [{0}, {1}]", a, b));
+        }
+    }
 }
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/IntervalsSet.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/IntervalsSet.java
deleted file mode 100644
index b7f1dbc..0000000
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/IntervalsSet.java
+++ /dev/null
@@ -1,619 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.oned;
-
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Iterator;
-import java.util.List;
-import java.util.NoSuchElementException;
-
-import org.apache.commons.geometry.core.partitioning.AbstractRegion;
-import org.apache.commons.geometry.core.partitioning.BSPTree;
-import org.apache.commons.geometry.core.partitioning.BoundaryProjection;
-import org.apache.commons.geometry.core.partitioning.SubHyperplane;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-
-/** This class represents a 1D region: a set of intervals.
- */
-public class IntervalsSet extends AbstractRegion<Vector1D, Vector1D> implements Iterable<double[]> {
-
-    /** Build an intervals set representing the whole real line.
-     * @param precision precision context used to compare floating point values
-     */
-    public IntervalsSet(final DoublePrecisionContext precision) {
-        super(precision);
-    }
-
-    /** Build an intervals set corresponding to a single interval.
-     * @param lower lower bound of the interval, must be lesser or equal
-     * to {@code upper} (may be {@code Double.NEGATIVE_INFINITY})
-     * @param upper upper bound of the interval, must be greater or equal
-     * to {@code lower} (may be {@code Double.POSITIVE_INFINITY})
-     * @param precision precision context used to compare floating point values
-     */
-    public IntervalsSet(final double lower, final double upper, final DoublePrecisionContext precision) {
-        super(buildTree(lower, upper, precision), precision);
-    }
-
-    /** Build an intervals set from an inside/outside BSP tree.
-     * <p>The leaf nodes of the BSP tree <em>must</em> have a
-     * {@code Boolean} attribute representing the inside status of
-     * the corresponding cell (true for inside cells, false for outside
-     * cells). In order to avoid building too many small objects, it is
-     * recommended to use the predefined constants
-     * {@code Boolean.TRUE} and {@code Boolean.FALSE}</p>
-     * @param tree inside/outside BSP tree representing the intervals set
-     * @param precision precision context used to compare floating point values
-     */
-    public IntervalsSet(final BSPTree<Vector1D> tree, final DoublePrecisionContext precision) {
-        super(tree, precision);
-    }
-
-    /** Build an intervals set from a Boundary REPresentation (B-rep).
-     * <p>The boundary is provided as a collection of {@link
-     * SubHyperplane sub-hyperplanes}. Each sub-hyperplane has the
-     * interior part of the region on its minus side and the exterior on
-     * its plus side.</p>
-     * <p>The boundary elements can be in any order, and can form
-     * several non-connected sets (like for example polygons with holes
-     * or a set of disjoints polyhedrons considered as a whole). In
-     * fact, the elements do not even need to be connected together
-     * (their topological connections are not used here). However, if the
-     * boundary does not really separate an inside open from an outside
-     * open (open having here its topological meaning), then subsequent
-     * calls to the {@link
-     * org.apache.commons.geometry.core.partitioning.Region#checkPoint(org.apache.commons.geometry.core.Point)
-     * checkPoint} method will not be meaningful anymore.</p>
-     * <p>If the boundary is empty, the region will represent the whole
-     * space.</p>
-     * @param boundary collection of boundary elements
-     * @param precision precision context used to compare floating point values
-     */
-    public IntervalsSet(final Collection<SubHyperplane<Vector1D>> boundary,
-                        final DoublePrecisionContext precision) {
-        super(boundary, precision);
-    }
-
-    /** Build an inside/outside tree representing a single interval.
-     * @param lower lower bound of the interval, must be lesser or equal
-     * to {@code upper} (may be {@code Double.NEGATIVE_INFINITY})
-     * @param upper upper bound of the interval, must be greater or equal
-     * to {@code lower} (may be {@code Double.POSITIVE_INFINITY})
-     * @param precision precision context used to compare floating point values
-     * @return the built tree
-     */
-    private static BSPTree<Vector1D> buildTree(final double lower, final double upper,
-                                                  final DoublePrecisionContext precision) {
-        if (Double.isInfinite(lower) && (lower < 0)) {
-            if (Double.isInfinite(upper) && (upper > 0)) {
-                // the tree must cover the whole real line
-                return new BSPTree<>(Boolean.TRUE);
-            }
-            // the tree must be open on the negative infinity side
-            final SubHyperplane<Vector1D> upperCut =
-                OrientedPoint.createPositiveFacing(Vector1D.of(upper), precision).wholeHyperplane();
-            return new BSPTree<>(upperCut,
-                               new BSPTree<Vector1D>(Boolean.FALSE),
-                               new BSPTree<Vector1D>(Boolean.TRUE),
-                               null);
-        }
-        final SubHyperplane<Vector1D> lowerCut =
-            OrientedPoint.createNegativeFacing(Vector1D.of(lower), precision).wholeHyperplane();
-        if (Double.isInfinite(upper) && (upper > 0)) {
-            // the tree must be open on the positive infinity side
-            return new BSPTree<>(lowerCut,
-                                            new BSPTree<Vector1D>(Boolean.FALSE),
-                                            new BSPTree<Vector1D>(Boolean.TRUE),
-                                            null);
-        }
-
-        // the tree must be bounded on the two sides
-        final SubHyperplane<Vector1D> upperCut =
-            OrientedPoint.createPositiveFacing(Vector1D.of(upper), precision).wholeHyperplane();
-        return new BSPTree<>(lowerCut,
-                                        new BSPTree<Vector1D>(Boolean.FALSE),
-                                        new BSPTree<>(upperCut,
-                                                                 new BSPTree<Vector1D>(Boolean.FALSE),
-                                                                 new BSPTree<Vector1D>(Boolean.TRUE),
-                                                                 null),
-                                        null);
-
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public IntervalsSet buildNew(final BSPTree<Vector1D> tree) {
-        return new IntervalsSet(tree, getPrecision());
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    protected void computeGeometricalProperties() {
-        if (getTree(false).getCut() == null) {
-            setBarycenter(Vector1D.NaN);
-            setSize(((Boolean) getTree(false).getAttribute()) ? Double.POSITIVE_INFINITY : 0);
-        } else {
-            double size = 0.0;
-            double sum = 0.0;
-            for (final Interval interval : asList()) {
-                size += interval.getSize();
-                sum  += interval.getSize() * interval.getBarycenter();
-            }
-            setSize(size);
-            if (Double.isInfinite(size)) {
-                setBarycenter(Vector1D.NaN);
-            } else if (size > 0) {
-                setBarycenter(Vector1D.of(sum / size));
-            } else {
-                setBarycenter(((OrientedPoint) getTree(false).getCut().getHyperplane()).getLocation());
-            }
-        }
-    }
-
-    /** Get the lowest value belonging to the instance.
-     * @return lowest value belonging to the instance
-     * ({@code Double.NEGATIVE_INFINITY} if the instance doesn't
-     * have any low bound, {@code Double.POSITIVE_INFINITY} if the
-     * instance is empty)
-     */
-    public double getInf() {
-        BSPTree<Vector1D> node = getTree(false);
-        double  inf  = Double.POSITIVE_INFINITY;
-        while (node.getCut() != null) {
-            final OrientedPoint op = (OrientedPoint) node.getCut().getHyperplane();
-            inf  = op.getLocation().getX();
-            node = op.isPositiveFacing() ? node.getMinus() : node.getPlus();
-        }
-        return ((Boolean) node.getAttribute()) ? Double.NEGATIVE_INFINITY : inf;
-    }
-
-    /** Get the highest value belonging to the instance.
-     * @return highest value belonging to the instance
-     * ({@code Double.POSITIVE_INFINITY} if the instance doesn't
-     * have any high bound, {@code Double.NEGATIVE_INFINITY} if the
-     * instance is empty)
-     */
-    public double getSup() {
-        BSPTree<Vector1D> node = getTree(false);
-        double  sup  = Double.NEGATIVE_INFINITY;
-        while (node.getCut() != null) {
-            final OrientedPoint op = (OrientedPoint) node.getCut().getHyperplane();
-            sup  = op.getLocation().getX();
-            node = op.isPositiveFacing() ? node.getPlus() : node.getMinus();
-        }
-        return ((Boolean) node.getAttribute()) ? Double.POSITIVE_INFINITY : sup;
-    }
-
-    /** {@inheritDoc}
-     */
-    @Override
-    public BoundaryProjection<Vector1D> projectToBoundary(final Vector1D point) {
-
-        // get position of test point
-        final double x = point.getX();
-
-        double previous = Double.NEGATIVE_INFINITY;
-        for (final double[] a : this) {
-            if (x < a[0]) {
-                // the test point lies between the previous and the current intervals
-                // offset will be positive
-                final double previousOffset = x - previous;
-                final double currentOffset  = a[0] - x;
-                if (previousOffset < currentOffset) {
-                    return new BoundaryProjection<>(point, finiteOrNullPoint(previous), previousOffset);
-                } else {
-                    return new BoundaryProjection<>(point, finiteOrNullPoint(a[0]), currentOffset);
-                }
-            } else if (x <= a[1]) {
-                // the test point lies within the current interval
-                // offset will be negative
-                final double offset0 = a[0] - x;
-                final double offset1 = x - a[1];
-                if (offset0 < offset1) {
-                    return new BoundaryProjection<>(point, finiteOrNullPoint(a[1]), offset1);
-                } else {
-                    return new BoundaryProjection<>(point, finiteOrNullPoint(a[0]), offset0);
-                }
-            }
-            previous = a[1];
-        }
-
-        // the test point if past the last sub-interval
-        return new BoundaryProjection<>(point, finiteOrNullPoint(previous), x - previous);
-
-    }
-
-    /** Build a finite point.
-     * @param x abscissa of the point
-     * @return a new point for finite abscissa, null otherwise
-     */
-    private Vector1D finiteOrNullPoint(final double x) {
-        return Double.isInfinite(x) ? null : Vector1D.of(x);
-    }
-
-    /** Build an ordered list of intervals representing the instance.
-     * <p>This method builds this intervals set as an ordered list of
-     * {@link Interval Interval} elements. If the intervals set has no
-     * lower limit, the first interval will have its low bound equal to
-     * {@code Double.NEGATIVE_INFINITY}. If the intervals set has
-     * no upper limit, the last interval will have its upper bound equal
-     * to {@code Double.POSITIVE_INFINITY}. An empty tree will
-     * build an empty list while a tree representing the whole real line
-     * will build a one element list with both bounds being
-     * infinite.</p>
-     * @return a new ordered list containing {@link Interval Interval}
-     * elements
-     */
-    public List<Interval> asList() {
-        final List<Interval> list = new ArrayList<>();
-        for (final double[] a : this) {
-            list.add(new Interval(a[0], a[1]));
-        }
-        return list;
-    }
-
-    /** Get the first leaf node of a tree.
-     * @param root tree root
-     * @return first leaf node
-     */
-    private BSPTree<Vector1D> getFirstLeaf(final BSPTree<Vector1D> root) {
-
-        if (root.getCut() == null) {
-            return root;
-        }
-
-        // find the smallest internal node
-        BSPTree<Vector1D> smallest = null;
-        for (BSPTree<Vector1D> n = root; n != null; n = previousInternalNode(n)) {
-            smallest = n;
-        }
-
-        return leafBefore(smallest);
-
-    }
-
-    /** Get the node corresponding to the first interval boundary.
-     * @return smallest internal node,
-     * or null if there are no internal nodes (i.e. the set is either empty or covers the real line)
-     */
-    private BSPTree<Vector1D> getFirstIntervalBoundary() {
-
-        // start search at the tree root
-        BSPTree<Vector1D> node = getTree(false);
-        if (node.getCut() == null) {
-            return null;
-        }
-
-        // walk tree until we find the smallest internal node
-        node = getFirstLeaf(node).getParent();
-
-        // walk tree until we find an interval boundary
-        while (node != null && !(isIntervalStart(node) || isIntervalEnd(node))) {
-            node = nextInternalNode(node);
-        }
-
-        return node;
-
-    }
-
-    /** Check if an internal node corresponds to the start abscissa of an interval.
-     * @param node internal node to check
-     * @return true if the node corresponds to the start abscissa of an interval
-     */
-    private boolean isIntervalStart(final BSPTree<Vector1D> node) {
-
-        if ((Boolean) leafBefore(node).getAttribute()) {
-            // it has an inside cell before it, it may end an interval but not start it
-            return false;
-        }
-
-        if (!(Boolean) leafAfter(node).getAttribute()) {
-            // it has an outside cell after it, it is a dummy cut away from real intervals
-            return false;
-        }
-
-        // the cell has an outside before and an inside after it
-        // it is the start of an interval
-        return true;
-
-    }
-
-    /** Check if an internal node corresponds to the end abscissa of an interval.
-     * @param node internal node to check
-     * @return true if the node corresponds to the end abscissa of an interval
-     */
-    private boolean isIntervalEnd(final BSPTree<Vector1D> node) {
-
-        if (!(Boolean) leafBefore(node).getAttribute()) {
-            // it has an outside cell before it, it may start an interval but not end it
-            return false;
-        }
-
-        if ((Boolean) leafAfter(node).getAttribute()) {
-            // it has an inside cell after it, it is a dummy cut in the middle of an interval
-            return false;
-        }
-
-        // the cell has an inside before and an outside after it
-        // it is the end of an interval
-        return true;
-
-    }
-
-    /** Get the next internal node.
-     * @param node current internal node
-     * @return next internal node in ascending order, or null
-     * if this is the last internal node
-     */
-    private BSPTree<Vector1D> nextInternalNode(BSPTree<Vector1D> node) {
-
-        if (childAfter(node).getCut() != null) {
-            // the next node is in the sub-tree
-            return leafAfter(node).getParent();
-        }
-
-        // there is nothing left deeper in the tree, we backtrack
-        while (isAfterParent(node)) {
-            node = node.getParent();
-        }
-        return node.getParent();
-
-    }
-
-    /** Get the previous internal node.
-     * @param node current internal node
-     * @return previous internal node in ascending order, or null
-     * if this is the first internal node
-     */
-    private BSPTree<Vector1D> previousInternalNode(BSPTree<Vector1D> node) {
-
-        if (childBefore(node).getCut() != null) {
-            // the next node is in the sub-tree
-            return leafBefore(node).getParent();
-        }
-
-        // there is nothing left deeper in the tree, we backtrack
-        while (isBeforeParent(node)) {
-            node = node.getParent();
-        }
-        return node.getParent();
-
-    }
-
-    /** Find the leaf node just before an internal node.
-     * @param node internal node at which the sub-tree starts
-     * @return leaf node just before the internal node
-     */
-    private BSPTree<Vector1D> leafBefore(BSPTree<Vector1D> node) {
-
-        node = childBefore(node);
-        while (node.getCut() != null) {
-            node = childAfter(node);
-        }
-
-        return node;
-
-    }
-
-    /** Find the leaf node just after an internal node.
-     * @param node internal node at which the sub-tree starts
-     * @return leaf node just after the internal node
-     */
-    private BSPTree<Vector1D> leafAfter(BSPTree<Vector1D> node) {
-
-        node = childAfter(node);
-        while (node.getCut() != null) {
-            node = childBefore(node);
-        }
-
-        return node;
-
-    }
-
-    /** Check if a node is the child before its parent in ascending order.
-     * @param node child node considered
-     * @return true is the node has a parent end is before it in ascending order
-     */
-    private boolean isBeforeParent(final BSPTree<Vector1D> node) {
-        final BSPTree<Vector1D> parent = node.getParent();
-        if (parent == null) {
-            return false;
-        } else {
-            return node == childBefore(parent);
-        }
-    }
-
-    /** Check if a node is the child after its parent in ascending order.
-     * @param node child node considered
-     * @return true is the node has a parent end is after it in ascending order
-     */
-    private boolean isAfterParent(final BSPTree<Vector1D> node) {
-        final BSPTree<Vector1D> parent = node.getParent();
-        if (parent == null) {
-            return false;
-        } else {
-            return node == childAfter(parent);
-        }
-    }
-
-    /** Find the child node just before an internal node.
-     * @param node internal node at which the sub-tree starts
-     * @return child node just before the internal node
-     */
-    private BSPTree<Vector1D> childBefore(BSPTree<Vector1D> node) {
-        if (isDirect(node)) {
-            // smaller abscissas are on minus side, larger abscissas are on plus side
-            return node.getMinus();
-        } else {
-            // smaller abscissas are on plus side, larger abscissas are on minus side
-            return node.getPlus();
-        }
-    }
-
-    /** Find the child node just after an internal node.
-     * @param node internal node at which the sub-tree starts
-     * @return child node just after the internal node
-     */
-    private BSPTree<Vector1D> childAfter(BSPTree<Vector1D> node) {
-        if (isDirect(node)) {
-            // smaller abscissas are on minus side, larger abscissas are on plus side
-            return node.getPlus();
-        } else {
-            // smaller abscissas are on plus side, larger abscissas are on minus side
-            return node.getMinus();
-        }
-    }
-
-    /** Check if an internal node has a direct oriented point.
-     * @param node internal node to check
-     * @return true if the oriented point is direct
-     */
-    private boolean isDirect(final BSPTree<Vector1D> node) {
-        return ((OrientedPoint) node.getCut().getHyperplane()).isPositiveFacing();
-    }
-
-    /** Get the abscissa of an internal node.
-     * @param node internal node to check
-     * @return abscissa
-     */
-    private double getAngle(final BSPTree<Vector1D> node) {
-        return ((OrientedPoint) node.getCut().getHyperplane()).getLocation().getX();
-    }
-
-    /** {@inheritDoc}
-     * <p>
-     * The iterator returns the limit values of sub-intervals in ascending order.
-     * </p>
-     * <p>
-     * The iterator does <em>not</em> support the optional {@code remove} operation.
-     * </p>
-     */
-    @Override
-    public Iterator<double[]> iterator() {
-        return new SubIntervalsIterator();
-    }
-
-    /** Local iterator for sub-intervals. */
-    private class SubIntervalsIterator implements Iterator<double[]> {
-
-        /** Current node. */
-        private BSPTree<Vector1D> current;
-
-        /** Sub-interval no yet returned. */
-        private double[] pending;
-
-        /** Simple constructor.
-         */
-        SubIntervalsIterator() {
-
-            current = getFirstIntervalBoundary();
-
-            if (current == null) {
-                // all the leaf tree nodes share the same inside/outside status
-                if ((Boolean) getFirstLeaf(getTree(false)).getAttribute()) {
-                    // it is an inside node, it represents the full real line
-                    pending = new double[] {
-                        Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY
-                    };
-                } else {
-                    pending = null;
-                }
-            } else if (isIntervalEnd(current)) {
-                // the first boundary is an interval end,
-                // so the first interval starts at infinity
-                pending = new double[] {
-                    Double.NEGATIVE_INFINITY, getAngle(current)
-                };
-            } else {
-                selectPending();
-            }
-        }
-
-        /** Walk the tree to select the pending sub-interval.
-         */
-        private void selectPending() {
-
-            // look for the start of the interval
-            BSPTree<Vector1D> start = current;
-            while (start != null && !isIntervalStart(start)) {
-                start = nextInternalNode(start);
-            }
-
-            if (start == null) {
-                // we have exhausted the iterator
-                current = null;
-                pending = null;
-                return;
-            }
-
-            // look for the end of the interval
-            BSPTree<Vector1D> end = start;
-            while (end != null && !isIntervalEnd(end)) {
-                end = nextInternalNode(end);
-            }
-
-            if (end != null) {
-
-                // we have identified the interval
-                pending = new double[] {
-                    getAngle(start), getAngle(end)
-                };
-
-                // prepare search for next interval
-                current = end;
-
-            } else {
-
-                // the final interval is open toward infinity
-                pending = new double[] {
-                    getAngle(start), Double.POSITIVE_INFINITY
-                };
-
-                // there won't be any other intervals
-                current = null;
-
-            }
-
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public boolean hasNext() {
-            return pending != null;
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public double[] next() {
-            if (pending == null) {
-                throw new NoSuchElementException();
-            }
-            final double[] next = pending;
-            selectPending();
-            return next;
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public void remove() {
-            throw new UnsupportedOperationException();
-        }
-
-    }
-
-}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/OrientedPoint.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/OrientedPoint.java
index 8888eb9..51c993c 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/OrientedPoint.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/OrientedPoint.java
@@ -17,11 +17,21 @@
 package org.apache.commons.geometry.euclidean.oned;
 
 import java.io.Serializable;
+import java.util.Arrays;
+import java.util.List;
 import java.util.Objects;
 
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.exception.GeometryException;
 import org.apache.commons.geometry.core.exception.GeometryValueException;
+import org.apache.commons.geometry.core.internal.Equivalency;
+import org.apache.commons.geometry.core.partitioning.AbstractHyperplane;
+import org.apache.commons.geometry.core.partitioning.ConvexSubHyperplane;
 import org.apache.commons.geometry.core.partitioning.Hyperplane;
-import org.apache.commons.geometry.core.partitioning.Transform;
+import org.apache.commons.geometry.core.partitioning.HyperplaneLocation;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.SubHyperplane;
 import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
 
 /** This class represents a 1D oriented hyperplane.
@@ -31,20 +41,18 @@
  *
  * <p>Instances of this class are guaranteed to be immutable.</p>
  */
-public final class OrientedPoint implements Hyperplane<Vector1D>, Serializable {
+public final class OrientedPoint extends AbstractHyperplane<Vector1D>
+    implements Hyperplane<Vector1D>, Equivalency<OrientedPoint>, Serializable {
 
     /** Serializable UID. */
     private static final long serialVersionUID = 20190210L;
 
-    /** Hyperplane location. */
-    private final Vector1D location;
+    /** Hyperplane location as a point. */
+    private final Vector1D point;
 
     /** Hyperplane direction. */
     private final boolean positiveFacing;
 
-    /** Precision context used to compare floating point numbers. */
-    private final DoublePrecisionContext precision;
-
     /** Simple constructor.
      * @param point location of the hyperplane
      * @param positiveFacing if true, the hyperplane will face toward positive infinity;
@@ -52,16 +60,28 @@
      * @param precision precision context used to compare floating point values
      */
     private OrientedPoint(final Vector1D point, final boolean positiveFacing, final DoublePrecisionContext precision) {
-        this.location = point;
+        super(precision);
+
+        this.point = point;
         this.positiveFacing = positiveFacing;
-        this.precision = precision;
     }
 
-    /** Get the point representing the hyperplane's location on the real line.
-     * @return the hyperplane location
+    /** Get the location of the hyperplane as a point.
+     * @return the hyperplane location as a point
+     * @see #getLocation()
      */
-    public Vector1D getLocation() {
-        return location;
+    public Vector1D getPoint() {
+        return point;
+    }
+
+    /**
+     * Get the location of the hyperplane as a single value. This is
+     * equivalent to {@code pt.getPoint().getX()}.
+     * @return the location of the hyperplane as a single value.
+     * @see #getPoint()
+     */
+    public double getLocation() {
+        return point.getX();
     }
 
     /** Get the direction of the hyperplane's plus side.
@@ -83,85 +103,116 @@
 
     /** {@inheritDoc} */
     @Override
-    public DoublePrecisionContext getPrecision() {
-        return precision;
-    }
-
-    /** Get an instance with the same location and precision but the opposite
-     * direction.
-     * @return a copy of this instance with the opposite direction
-     */
     public OrientedPoint reverse() {
-        return new OrientedPoint(location, !positiveFacing, precision);
+        return new OrientedPoint(point, !positiveFacing, getPrecision());
     }
 
-    /** Return a new instance transformed by the given {@link Transform}.
-     * @param transform transform object
-     * @return a transformed instance
-     */
-    public OrientedPoint transform(final Transform<Vector1D, Vector1D> transform) {
-        Vector1D transformedLocation = transform.apply(location);
-        Vector1D transformedPlusDirPt = transform.apply(location.add(getDirection()));
+    /** {@inheritDoc} */
+    @Override
+    public OrientedPoint transform(final Transform<Vector1D> transform) {
+        final Vector1D transformedPoint = transform.apply(point);
+
+        Vector1D transformedDir;
+        if (point.isInfinite()) {
+            // use a test point to determine if the direction switches or not
+            final Vector1D transformedZero = transform.apply(Vector1D.ZERO);
+            final Vector1D transformedZeroDir = transform.apply(getDirection());
+
+            transformedDir = transformedZero.vectorTo(transformedZeroDir);
+        } else {
+            final Vector1D transformedPointPlusDir = transform.apply(point.add(getDirection()));
+            transformedDir = transformedPoint.vectorTo(transformedPointPlusDir);
+        }
 
         return OrientedPoint.fromPointAndDirection(
-                    transformedLocation,
-                    transformedLocation.vectorTo(transformedPlusDirPt),
-                    precision
+                    transformedPoint,
+                    transformedDir,
+                    getPrecision()
                 );
     }
 
-    /** Copy the instance.
-     * <p>Since instances are immutable, this method directly returns
-     * the instance.</p>
-     * @return the instance itself
-     */
-    @Override
-    public OrientedPoint copySelf() {
-        return this;
-    }
-
     /** {@inheritDoc} */
     @Override
-    public double getOffset(final Vector1D point) {
-        final double delta = point.getX() - location.getX();
+    public double offset(final Vector1D pt) {
+        return offset(pt.getX());
+    }
+
+    /** Compute the offset of the given number line location. This is
+     * a convenience overload of {@link #offset(Vector1D)} for use in
+     * one dimension.
+     * @param location the number line location to compute the offset for
+     * @return the offset of the location from the instance
+     */
+    public double offset(final double location) {
+        final double delta = location - point.getX();
         return positiveFacing ? delta : -delta;
     }
 
-    /** Build a region covering the whole hyperplane.
-     * <p>Since this class represent zero dimension spaces which does
-     * not have lower dimension sub-spaces, this method returns a dummy
-     * implementation of a {@link
-     * org.apache.commons.geometry.core.partitioning.SubHyperplane SubHyperplane}.
-     * This implementation is only used to allow the {@link
-     * org.apache.commons.geometry.core.partitioning.SubHyperplane
-     * SubHyperplane} class implementation to work properly, it should
-     * <em>not</em> be used otherwise.</p>
-     * @return a dummy sub hyperplane
-     */
+    /** {@inheritDoc} */
     @Override
-    public SubOrientedPoint wholeHyperplane() {
-        return new SubOrientedPoint(this, null);
+    public HyperplaneLocation classify(final Vector1D pt) {
+        return classify(pt.getX());
     }
 
-    /** Build a region covering the whole space.
-     * @return a region containing the instance (really an {@link
-     * IntervalsSet IntervalsSet} instance)
+    /** Classify the number line location with respect to the instance.
+     * This is a convenience overload of {@link #classify(Vector1D)} for
+     * use in one dimension.
+     * @param location the number line location to classify
+     * @return the classification of the number line location with respect
+     *      to this instance
      */
-    @Override
-    public IntervalsSet wholeSpace() {
-        return new IntervalsSet(precision);
+    public HyperplaneLocation classify(final double location) {
+        final double offsetValue = offset(location);
+
+        final int cmp = getPrecision().sign(offsetValue);
+        if (cmp > 0) {
+            return HyperplaneLocation.PLUS;
+        } else if (cmp < 0) {
+            return HyperplaneLocation.MINUS;
+        }
+        return HyperplaneLocation.ON;
     }
 
     /** {@inheritDoc} */
     @Override
-    public boolean sameOrientationAs(final Hyperplane<Vector1D> other) {
+    public boolean similarOrientation(final Hyperplane<Vector1D> other) {
         return positiveFacing == ((OrientedPoint) other).positiveFacing;
     }
 
     /** {@inheritDoc} */
     @Override
-    public Vector1D project(final Vector1D point) {
-        return location;
+    public Vector1D project(final Vector1D pt) {
+        return this.point;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public SubOrientedPoint span() {
+        return new SubOrientedPoint(this);
+    }
+
+    /** {@inheritDoc}
+     *
+     * <p>Instances are considered equivalent if they
+     * <ul>
+     *  <li>contain equal {@link DoublePrecisionContext precision contexts},</li>
+     *  <li>have equivalent locations as evaluated by the precision context, and</li>
+     *  <li>point in the same direction</li>
+     * </ul>
+     * @param other the point to compare with
+     * @return true if this instance should be considered equivalent to the argument
+     */
+    @Override
+    public boolean eq(final OrientedPoint other) {
+        if (this == other) {
+            return true;
+        }
+
+        final DoublePrecisionContext precision = getPrecision();
+
+        return precision.equals(other.getPrecision()) &&
+                point.eq(other.point, precision) &&
+                positiveFacing == other.positiveFacing;
     }
 
     /** {@inheritDoc} */
@@ -170,9 +221,9 @@
         final int prime = 31;
 
         int result = 1;
-        result = (prime * result) + Objects.hashCode(location);
+        result = (prime * result) + Objects.hashCode(point);
         result = (prime * result) + Boolean.hashCode(positiveFacing);
-        result = (prime * result) + Objects.hashCode(precision);
+        result = (prime * result) + Objects.hashCode(getPrecision());
 
         return result;
     }
@@ -182,16 +233,15 @@
     public boolean equals(Object obj) {
         if (this == obj) {
             return true;
-        }
-        else if (!(obj instanceof OrientedPoint)) {
+        } else if (!(obj instanceof OrientedPoint)) {
             return false;
         }
 
         OrientedPoint other = (OrientedPoint) obj;
 
-        return Objects.equals(this.location, other.location) &&
+        return Objects.equals(this.point, other.point) &&
                 this.positiveFacing == other.positiveFacing &&
-                Objects.equals(this.precision, other.precision);
+                Objects.equals(this.getPrecision(), other.getPrecision());
     }
 
     /** {@inheritDoc} */
@@ -199,8 +249,8 @@
     public String toString() {
         final StringBuilder sb = new StringBuilder();
         sb.append(this.getClass().getSimpleName())
-            .append("[location= ")
-            .append(location)
+            .append("[point= ")
+            .append(point)
             .append(", direction= ")
             .append(getDirection())
             .append(']');
@@ -208,6 +258,18 @@
         return sb.toString();
     }
 
+    /** Create a new instance from the given location and boolean direction value.
+     * @param location the location of the hyperplane
+     * @param positiveFacing if true, the hyperplane will face toward positive infinity;
+     *      otherwise, it will point toward negative infinity.
+     * @param precision precision context used to compare floating point values
+     * @return a new instance
+     */
+    public static OrientedPoint fromLocationAndDirection(final double location, final boolean positiveFacing,
+            final DoublePrecisionContext precision) {
+        return fromPointAndDirection(Vector1D.of(location), positiveFacing, precision);
+    }
+
     /** Create a new instance from the given point and boolean direction value.
      * @param point the location of the hyperplane
      * @param positiveFacing if true, the hyperplane will face toward positive infinity;
@@ -248,6 +310,15 @@
         return new OrientedPoint(point, true, precision);
     }
 
+    /** Create a new instance at the given location, oriented so that it is facing positive infinity.
+     * @param location the location of the hyperplane
+     * @param precision precision context used to compare floating point values
+     * @return a new instance oriented toward positive infinity
+     */
+    public static OrientedPoint createPositiveFacing(final double location, final DoublePrecisionContext precision) {
+        return new OrientedPoint(Vector1D.of(location), true, precision);
+    }
+
     /** Create a new instance at the given point, oriented so that it is facing negative infinity.
      * @param point the location of the hyperplane
      * @param precision precision context used to compare floating point values
@@ -256,4 +327,222 @@
     public static OrientedPoint createNegativeFacing(final Vector1D point, final DoublePrecisionContext precision) {
         return new OrientedPoint(point, false, precision);
     }
+
+    /** Create a new instance at the given location, oriented so that it is facing negative infinity.
+     * @param location the location of the hyperplane
+     * @param precision precision context used to compare floating point values
+     * @return a new instance oriented toward negative infinity
+     */
+    public static OrientedPoint createNegativeFacing(final double location, final DoublePrecisionContext precision) {
+        return new OrientedPoint(Vector1D.of(location), false, precision);
+    }
+
+    /** {@link ConvexSubHyperplane} implementation for Euclidean 1D space. Since there are no subspaces in 1D,
+     * this is effectively a stub implementation, its main use being to allow for the correct functioning of
+     * partitioning code.
+     */
+    public static class SubOrientedPoint implements ConvexSubHyperplane<Vector1D>, Serializable {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20190405L;
+
+        /** The underlying hyperplane for this instance. */
+        private final OrientedPoint hyperplane;
+
+        /** Simple constructor.
+         * @param hyperplane underlying hyperplane instance
+         */
+        public SubOrientedPoint(final OrientedPoint hyperplane) {
+            this.hyperplane = hyperplane;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public OrientedPoint getHyperplane() {
+            return hyperplane;
+        }
+
+        /** {@inheritDoc}
+        *
+        * <p>This method simply returns false.</p>
+        */
+        @Override
+        public boolean isFull() {
+            return false;
+        }
+
+        /** {@inheritDoc}
+        *
+        * <p>This method simply returns false.</p>
+        */
+        @Override
+        public boolean isEmpty() {
+            return false;
+        }
+
+        /** {@inheritDoc}
+         *
+         * <p>This method simply returns false.</p>
+         */
+        @Override
+        public boolean isInfinite() {
+            return false;
+        }
+
+        /** {@inheritDoc}
+        *
+        * <p>This method simply returns true.</p>
+        */
+        @Override
+        public boolean isFinite() {
+            return true;
+        }
+
+        /** {@inheritDoc}
+         *
+         *  <p>This method simply returns {@code 0}.</p>
+         */
+        @Override
+        public double getSize() {
+            return 0;
+        }
+
+        /** {@inheritDoc}
+         *
+         * <p>This method returns {@link RegionLocation#BOUNDARY} if the
+         * point is on the hyperplane and {@link RegionLocation#OUTSIDE}
+         * otherwise.</p>
+         */
+        @Override
+        public RegionLocation classify(final Vector1D point) {
+            if (hyperplane.contains(point)) {
+                return RegionLocation.BOUNDARY;
+            }
+
+            return RegionLocation.OUTSIDE;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public Vector1D closest(Vector1D point) {
+            return hyperplane.project(point);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public Split<SubOrientedPoint> split(final Hyperplane<Vector1D> splitter) {
+            final HyperplaneLocation side = splitter.classify(hyperplane.getPoint());
+
+            SubOrientedPoint minus = null;
+            SubOrientedPoint plus = null;
+
+            if (side == HyperplaneLocation.MINUS) {
+                minus = this;
+            } else if (side == HyperplaneLocation.PLUS) {
+                plus = this;
+            }
+
+            return new Split<>(minus, plus);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public List<SubOrientedPoint> toConvex() {
+            return Arrays.asList(this);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public SubOrientedPoint transform(final Transform<Vector1D> transform) {
+            return getHyperplane().transform(transform).span();
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public SubOrientedPointBuilder builder() {
+            return new SubOrientedPointBuilder(this);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public SubOrientedPoint reverse() {
+            return new SubOrientedPoint(hyperplane.reverse());
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public String toString() {
+            final StringBuilder sb = new StringBuilder();
+            sb.append(this.getClass().getSimpleName())
+                .append("[hyperplane= ")
+                .append(hyperplane)
+                .append(']');
+
+            return sb.toString();
+        }
+    }
+
+    /** {@link SubHyperplane.Builder} implementation for Euclidean 1D space. Similar to {@link SubOrientedPoint},
+     * this is effectively a stub implementation since there are no subspaces of 1D space. Its primary use is to allow
+     * for the correct functioning of partitioning code.
+     */
+    public static final class SubOrientedPointBuilder implements SubHyperplane.Builder<Vector1D>, Serializable {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20190405L;
+
+        /** Base subhyperplane for the builder. */
+        private final SubOrientedPoint base;
+
+        /** Construct a new instance using the given base subhyperplane.
+         * @param base base subhyperplane for the instance
+         */
+        private SubOrientedPointBuilder(final SubOrientedPoint base) {
+            this.base = base;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void add(final SubHyperplane<Vector1D> sub) {
+            validateHyperplane(sub);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void add(final ConvexSubHyperplane<Vector1D> sub) {
+            validateHyperplane(sub);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public SubOrientedPoint build() {
+            return base;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public String toString() {
+            final StringBuilder sb = new StringBuilder();
+            sb.append(this.getClass().getSimpleName())
+                .append("[base= ")
+                .append(base)
+                .append(']');
+
+            return sb.toString();
+        }
+
+        /** Validate the given subhyperplane lies on the same hyperplane.
+         * @param sub subhyperplane to validate
+         */
+        private void validateHyperplane(final SubHyperplane<Vector1D> sub) {
+            final OrientedPoint baseHyper = base.getHyperplane();
+            final OrientedPoint inputHyper = (OrientedPoint) sub.getHyperplane();
+
+            if (!baseHyper.eq(inputHyper)) {
+                throw new GeometryException("Argument is not on the same " +
+                        "hyperplane. Expected " + baseHyper + " but was " +
+                        inputHyper);
+            }
+        }
+    }
 }
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/RegionBSPTree1D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/RegionBSPTree1D.java
new file mode 100644
index 0000000..8b5a539
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/RegionBSPTree1D.java
@@ -0,0 +1,578 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.oned;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Objects;
+import java.util.function.BiConsumer;
+
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.partitioning.Hyperplane;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.bsp.AbstractBSPTree;
+import org.apache.commons.geometry.core.partitioning.bsp.AbstractRegionBSPTree;
+
+/** Binary space partitioning (BSP) tree representing a region in one dimensional
+ * Euclidean space.
+ */
+public final class RegionBSPTree1D extends AbstractRegionBSPTree<Vector1D, RegionBSPTree1D.RegionNode1D> {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20190405L;
+
+    /** Comparator used to sort BoundaryPairs by ascending location.  */
+    private static final Comparator<BoundaryPair> BOUNDARY_PAIR_COMPARATOR = (BoundaryPair a, BoundaryPair b) -> {
+        return Double.compare(a.getMinValue(), b.getMinValue());
+    };
+
+    /** Create a new, empty region.
+     */
+    public RegionBSPTree1D() {
+        this(false);
+    }
+
+    /** Create a new region. If {@code full} is true, then the region will
+     * represent the entire number line. Otherwise, it will be empty.
+     * @param full whether or not the region should contain the entire
+     *      number line or be empty
+     */
+    public RegionBSPTree1D(boolean full) {
+        super(full);
+    }
+
+    /** Return a deep copy of this instance.
+     * @return a deep copy of this instance.
+     * @see #copy(org.apache.commons.geometry.core.partitioning.bsp.BSPTree)
+     */
+    public RegionBSPTree1D copy() {
+        RegionBSPTree1D result = RegionBSPTree1D.empty();
+        result.copy(this);
+
+        return result;
+    }
+
+    /** Add an interval to this region. The resulting region will be the
+     * union of the interval and the region represented by this instance.
+     * @param interval the interval to add
+     */
+    public void add(final Interval interval) {
+        union(intervalToTree(interval));
+    }
+
+    /** Classify a point location with respect to the region.
+     * @param x the point to classify
+     * @return the location of the point with respect to the region
+     * @see #classify(Point)
+     */
+    public RegionLocation classify(final double x) {
+        return classify(Vector1D.of(x));
+    }
+
+    /** Return true if the given point location is on the inside or boundary
+     * of the region.
+     * @param x the location to test
+     * @return true if the location is on the inside or boundary of the region
+     * @see #contains(Point)
+     */
+    public boolean contains(final double x) {
+        return contains(Vector1D.of(x));
+    }
+
+    /** {@inheritDoc}
+    *
+    *  <p>This method simply returns 0 because boundaries in one dimension do not
+    *  have any size.</p>
+    */
+    @Override
+    public double getBoundarySize() {
+        return 0;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Vector1D project(final Vector1D pt) {
+        // use our custom projector so that we can disambiguate points that are
+        // actually equidistant from the target point
+        final BoundaryProjector1D projector = new BoundaryProjector1D(pt);
+        accept(projector);
+
+        return projector.getProjected();
+    }
+
+    /** {@inheritDoc}
+     *
+     * <p>When splitting trees representing single points with a splitter lying directly
+     * on the point, the result point is placed on one side of the splitter based on its
+     * orientation: if the splitter is positive-facing, the point is placed on the plus
+     * side of the split; if the splitter is negative-facing, the point is placed on the
+     * minus side of the split.</p>
+     */
+    @Override
+    public Split<RegionBSPTree1D> split(final Hyperplane<Vector1D> splitter) {
+        return split(splitter, RegionBSPTree1D.empty(), RegionBSPTree1D.empty());
+    }
+
+    /** Get the minimum value on the inside of the region; returns {@link Double#NEGATIVE_INFINITY}
+     * if the region does not have a minimum value and {@link Double#POSITIVE_INFINITY} if
+     * the region is empty.
+     * @return the minimum value on the inside of the region
+     */
+    public double getMin() {
+        double min = Double.POSITIVE_INFINITY;
+
+        RegionNode1D node = getRoot();
+        OrientedPoint pt;
+
+        while (!node.isLeaf()) {
+            pt = (OrientedPoint) node.getCutHyperplane();
+
+            min = pt.getLocation();
+            node = pt.isPositiveFacing() ? node.getMinus() : node.getPlus();
+        }
+
+        return node.isInside() ? Double.NEGATIVE_INFINITY : min;
+    }
+
+    /** Get the maximum value on the inside of the region; returns {@link Double#POSITIVE_INFINITY}
+     * if the region does not have a maximum value and {@link Double#NEGATIVE_INFINITY} if
+     * the region is empty.
+     * @return the maximum value on the inside of the region
+     */
+    public double getMax() {
+        double max = Double.NEGATIVE_INFINITY;
+
+        RegionNode1D node = getRoot();
+        OrientedPoint pt;
+
+        while (!node.isLeaf()) {
+            pt = (OrientedPoint) node.getCutHyperplane();
+
+            max = pt.getLocation();
+            node = pt.isPositiveFacing() ? node.getPlus() : node.getMinus();
+        }
+
+        return node.isInside() ? Double.POSITIVE_INFINITY : max;
+    }
+
+    /** Convert the region represented by this tree into a list of separate
+     * {@link Interval}s, arranged in order of ascending min value.
+     * @return list of {@link Interval}s representing this region in order of
+     *      ascending min value
+     */
+    public List<Interval> toIntervals() {
+
+        final List<BoundaryPair> boundaryPairs = new ArrayList<>();
+
+        visitInsideIntervals((min, max) -> {
+            boundaryPairs.add(new BoundaryPair(min, max));
+        });
+
+        boundaryPairs.sort(BOUNDARY_PAIR_COMPARATOR);
+
+        final List<Interval> intervals = new ArrayList<>();
+
+        BoundaryPair start = null;
+        BoundaryPair end = null;
+
+        for (BoundaryPair current : boundaryPairs) {
+            if (start == null) {
+                start = current;
+                end = current;
+            } else if (Objects.equals(end.getMax(), current.getMin())) {
+                // these intervals should be merged
+                end = current;
+            } else {
+                // these intervals should not be merged
+                intervals.add(createInterval(start, end));
+
+                // queue up the next pair
+                start = current;
+                end = current;
+            }
+        }
+
+        if (start != null && end != null) {
+            intervals.add(createInterval(start, end));
+        }
+
+        return intervals;
+    }
+
+    /** Create an interval instance from the min boundary from the start boundary pair and
+     * the max boundary from the end boundary pair. The hyperplane directions are adjusted
+     * as needed.
+     * @param start starting boundary pair
+     * @param end ending boundary pair
+     * @return an interval created from the min boundary of the given start pair and the
+     *      max boundary from the given end pair
+     */
+    private Interval createInterval(final BoundaryPair start, final BoundaryPair end) {
+        OrientedPoint min = start.getMin();
+        OrientedPoint max = end.getMax();
+
+        // flip the hyperplanes if needed since there's no
+        // guarantee that the inside will be on the minus side
+        // of the hyperplane (for example, if the region is complemented)
+
+        if (min != null && min.isPositiveFacing()) {
+            min = min.reverse();
+        }
+        if (max != null && !max.isPositiveFacing()) {
+            max = max.reverse();
+        }
+
+        return Interval.of(min, max);
+    }
+
+    /** Compute the min/max intervals for all interior convex regions in the tree and
+     * pass the values to the given visitor function.
+     * @param visitor the object that will receive the calculated min and max boundary for each
+     *      insides node's convex region
+     */
+    private void visitInsideIntervals(final BiConsumer<OrientedPoint, OrientedPoint> visitor) {
+        for (RegionNode1D node : this) {
+            if (node.isInside()) {
+                visitNodeInterval(node, visitor);
+            }
+        }
+    }
+
+    /** Determine the min/max boundaries for the convex region represented by the given node and pass
+     * the values to the visitor function.
+     * @param node the node to compute the interval for
+     * @param visitor the object that will receive the min and max boundaries for the node's
+     *      convex region
+     */
+    private void visitNodeInterval(final RegionNode1D node, final BiConsumer<OrientedPoint, OrientedPoint> visitor) {
+        OrientedPoint min = null;
+        OrientedPoint max = null;
+
+        OrientedPoint pt;
+        RegionNode1D child = node;
+        RegionNode1D parent;
+
+        while ((min == null || max == null) && (parent = child.getParent()) != null) {
+            pt = (OrientedPoint) parent.getCutHyperplane();
+
+            if ((pt.isPositiveFacing() && child.isMinus()) ||
+                    (!pt.isPositiveFacing() && child.isPlus())) {
+
+                if (max == null) {
+                    max = pt;
+                }
+            } else if (min == null) {
+                min = pt;
+            }
+
+            child = parent;
+        }
+
+        visitor.accept(min, max);
+    }
+
+    /** Compute the region represented by the given node.
+     * @param node the node to compute the region for
+     * @return the region represented by the given node
+     */
+    private Interval computeNodeRegion(final RegionNode1D node) {
+        final NodeRegionVisitor visitor = new NodeRegionVisitor();
+        visitNodeInterval(node, visitor);
+
+        return visitor.getInterval();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected RegionNode1D createNode() {
+        return new RegionNode1D(this);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected RegionSizeProperties<Vector1D> computeRegionSizeProperties() {
+        RegionSizePropertiesVisitor visitor = new RegionSizePropertiesVisitor();
+
+        visitInsideIntervals(visitor);
+
+        return visitor.getRegionSizeProperties();
+    }
+
+    /** Returns true if the given transform would result in a swapping of the interior
+     * and exterior of the region if applied.
+     *
+     * <p>This method always returns false since no swapping of this kind occurs in
+     * 1D.</p>
+     */
+    @Override
+    protected boolean swapsInsideOutside(final Transform<Vector1D> transform) {
+        return false;
+    }
+
+    /** Return a new {@link RegionBSPTree1D} instance containing the entire space.
+     * @return a new {@link RegionBSPTree1D} instance containing the entire space
+     */
+    public static RegionBSPTree1D full() {
+        return new RegionBSPTree1D(true);
+    }
+
+    /** Return a new, empty {@link RegionBSPTree1D} instance.
+     * @return a new, empty {@link RegionBSPTree1D} instance
+     */
+    public static RegionBSPTree1D empty() {
+        return new RegionBSPTree1D(false);
+    }
+
+    /** Construct a new instance from one or more intervals. The returned tree
+     * represents the same region as the union of all of the input intervals.
+     * @param interval the input interval
+     * @param more additional intervals to add to the region
+     * @return a new instance representing the same region as the union
+     *      of all of the given intervals
+     */
+    public static RegionBSPTree1D from(final Interval interval, final Interval... more) {
+        final RegionBSPTree1D tree = intervalToTree(interval);
+
+        for (final Interval additional : more) {
+            tree.add(additional);
+        }
+
+        return tree;
+    }
+
+    /** Construct a new instance from the given collection of intervals.
+     * @param intervals the intervals to populate the region with
+     * @return a new instance constructed from the given collection of intervals
+     */
+    public static RegionBSPTree1D from(final Iterable<Interval> intervals) {
+        RegionBSPTree1D tree = new RegionBSPTree1D(false);
+
+        for (final Interval interval : intervals) {
+            tree.add(interval);
+        }
+
+        return tree;
+    }
+
+    /** Return a tree representing the same region as the given interval.
+     * @param interval interval to create a tree from
+     * @return a tree representing the same region as the given interval
+     */
+    private static RegionBSPTree1D intervalToTree(final Interval interval) {
+        final OrientedPoint minBoundary = interval.getMinBoundary();
+        final OrientedPoint maxBoundary = interval.getMaxBoundary();
+
+        final RegionBSPTree1D tree = full();
+
+        RegionNode1D node = tree.getRoot();
+
+        if (minBoundary != null) {
+            tree.cutNode(node, minBoundary.span());
+
+            node = node.getMinus();
+        }
+
+        if (maxBoundary != null) {
+            tree.cutNode(node, maxBoundary.span());
+        }
+
+        return tree;
+    }
+
+    /** BSP tree node for one dimensional Euclidean space.
+     */
+    public static final class RegionNode1D extends AbstractRegionBSPTree.AbstractRegionNode<Vector1D, RegionNode1D> {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20190405L;
+
+        /** Simple constructor.
+         * @param tree the owning tree instance
+         */
+        private RegionNode1D(AbstractBSPTree<Vector1D, RegionNode1D> tree) {
+            super(tree);
+        }
+
+        /** Get the region represented by this node. The returned region contains
+         * the entire area contained in this node, regardless of the attributes of
+         * any child nodes.
+         * @return the region represented by this node
+         */
+        public Interval getNodeRegion() {
+            return ((RegionBSPTree1D) getTree()).computeNodeRegion(this);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected RegionNode1D getSelf() {
+            return this;
+        }
+    }
+
+    /** Internal class containing pairs of interval boundaries.
+     */
+    private static final class BoundaryPair {
+
+        /** The min boundary. */
+        private final OrientedPoint min;
+
+        /** The max boundary. */
+        private final OrientedPoint max;
+
+        /** Simple constructor.
+         * @param min min boundary hyperplane
+         * @param max max boundary hyperplane
+         */
+        BoundaryPair(final OrientedPoint min, final OrientedPoint max) {
+            this.min = min;
+            this.max = max;
+        }
+
+        /** Get the minimum boundary hyperplane.
+         * @return the minimum boundary hyperplane.
+         */
+        public OrientedPoint getMin() {
+            return min;
+        }
+
+        /** Get the maximum boundary hyperplane.
+         * @return the maximum boundary hyperplane.
+         */
+        public OrientedPoint getMax() {
+            return max;
+        }
+
+        /** Get the minumum value of the interval or {@link Double#NEGATIVE_INFINITY}
+         * if no minimum value exists.
+         * @return the minumum value of the interval or {@link Double#NEGATIVE_INFINITY}
+         *      if no minimum value exists.
+         */
+        public double getMinValue() {
+            return (min != null) ? min.getLocation() : Double.NEGATIVE_INFINITY;
+        }
+    }
+
+    /** Class used to project points onto the region boundary.
+     */
+    private static final class BoundaryProjector1D extends BoundaryProjector<Vector1D, RegionNode1D> {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20190405L;
+
+        /** Simple constructor.
+         * @param point the point to project onto the region's boundary
+         */
+        BoundaryProjector1D(Vector1D point) {
+            super(point);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected Vector1D disambiguateClosestPoint(final Vector1D target, final Vector1D a, final Vector1D b) {
+            final int cmp = Vector1D.COORDINATE_ASCENDING_ORDER.compare(a, b);
+
+            if (target.isInfinite() && target.getX() > 0) {
+                // return the largest value (closest to +Infinity)
+                return cmp < 0 ? b : a;
+            }
+
+            // return the smallest value
+            return cmp < 0 ? a : b;
+        }
+    }
+
+    /** Internal class for calculating the region of a single tree node.
+     */
+    private static final class NodeRegionVisitor implements BiConsumer<OrientedPoint, OrientedPoint> {
+
+        /** The min boundary for the region. */
+        private OrientedPoint min;
+
+        /** The max boundary for the region. */
+        private OrientedPoint max;
+
+        /** {@inheritDoc} */
+        @Override
+        public void accept(final OrientedPoint minBoundary, final OrientedPoint maxBoundary) {
+            // reverse the oriented point directions if needed
+            this.min = (minBoundary != null && minBoundary.isPositiveFacing()) ? minBoundary.reverse() : minBoundary;
+            this.max = (maxBoundary != null && !maxBoundary.isPositiveFacing()) ? maxBoundary.reverse() : maxBoundary;
+        }
+
+        /** Return the computed interval.
+         * @return the computed interval.
+         */
+        public Interval getInterval() {
+            return Interval.of(min, max);
+        }
+    }
+
+    /** Internal class for calculating size-related properties for a {@link RegionBSPTree1D}.
+     */
+    private static final class RegionSizePropertiesVisitor implements BiConsumer<OrientedPoint, OrientedPoint> {
+        /** Number of inside intervals visited. */
+        private int count = 0;
+
+        /** Total computed size of all inside regions. */
+        private double size = 0;
+
+        /** Raw sum of the barycenters of each inside interval. */
+        private double rawBarycenterSum = 0;
+
+        /** The sum of the barycenter of each inside interval, scaled by the size of the interval. */
+        private double scaledBarycenterSum = 0;
+
+        /** {@inheritDoc} */
+        @Override
+        public void accept(OrientedPoint min, OrientedPoint max) {
+            ++count;
+
+            final double minLoc = (min != null) ? min.getLocation() : Double.NEGATIVE_INFINITY;
+            final double maxLoc = (max != null) ? max.getLocation() : Double.POSITIVE_INFINITY;
+
+            final double intervalSize = maxLoc - minLoc;
+            final double intervalBarycenter = 0.5 * (maxLoc + minLoc);
+
+            size += intervalSize;
+            rawBarycenterSum += intervalBarycenter;
+            scaledBarycenterSum += intervalSize * intervalBarycenter;
+        }
+
+        /** Get the computed properties for the region. This must only be called after
+         * every inside interval has been visited.
+         * @return properties for the region
+         */
+        public RegionSizeProperties<Vector1D> getRegionSizeProperties() {
+            Vector1D barycenter = null;
+
+            if (count > 0 && Double.isFinite(size)) {
+                if (size > 0.0) {
+                    // use the scaled sum if we have a non-zero size
+                    barycenter = Vector1D.of(scaledBarycenterSum / size);
+                } else {
+                    // use the raw sum if we don't have a size; this will be
+                    // the case if the region only contains points with zero size
+                    barycenter = Vector1D.of(rawBarycenterSum / count);
+                }
+            }
+
+            return new RegionSizeProperties<>(size, barycenter);
+        }
+    }
+}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/SubOrientedPoint.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/SubOrientedPoint.java
deleted file mode 100644
index e228fb0..0000000
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/SubOrientedPoint.java
+++ /dev/null
@@ -1,79 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.oned;
-
-import org.apache.commons.geometry.core.partitioning.AbstractSubHyperplane;
-import org.apache.commons.geometry.core.partitioning.Hyperplane;
-import org.apache.commons.geometry.core.partitioning.Region;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-
-/** This class represents sub-hyperplane for {@link OrientedPoint}.
- * <p>An hyperplane in 1D is a simple point, its orientation being a
- * boolean.</p>
- * <p>Instances of this class are guaranteed to be immutable.</p>
- */
-public class SubOrientedPoint extends AbstractSubHyperplane<Vector1D, Vector1D> {
-
-    /** Simple constructor.
-     * @param hyperplane underlying hyperplane
-     * @param remainingRegion remaining region of the hyperplane
-     */
-    public SubOrientedPoint(final Hyperplane<Vector1D> hyperplane,
-                            final Region<Vector1D> remainingRegion) {
-        super(hyperplane, remainingRegion);
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public double getSize() {
-        return 0;
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public boolean isEmpty() {
-        return false;
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    protected AbstractSubHyperplane<Vector1D, Vector1D> buildNew(final Hyperplane<Vector1D> hyperplane,
-                                                                       final Region<Vector1D> remainingRegion) {
-        return new SubOrientedPoint(hyperplane, remainingRegion);
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public SplitSubHyperplane<Vector1D> split(final Hyperplane<Vector1D> hyperplane) {
-        final OrientedPoint thisHyperplane = (OrientedPoint) getHyperplane();
-        final double global = hyperplane.getOffset(thisHyperplane.getLocation());
-
-        // use the precision context from our parent hyperplane to determine equality
-        final DoublePrecisionContext precision = thisHyperplane.getPrecision();
-
-        int comparison = precision.compare(global, 0.0);
-
-        if (comparison < 0) {
-            return new SplitSubHyperplane<Vector1D>(null, this);
-        } else if (comparison > 0) {
-            return new SplitSubHyperplane<Vector1D>(this, null);
-        } else {
-            return new SplitSubHyperplane<Vector1D>(null, null);
-        }
-    }
-
-}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Side.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/Transform1D.java
similarity index 61%
copy from commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Side.java
copy to commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/Transform1D.java
index 046defe..1f0eac1 100644
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Side.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/Transform1D.java
@@ -14,23 +14,18 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.commons.geometry.core.partitioning;
+package org.apache.commons.geometry.euclidean.oned;
 
-/** Enumerate representing the location of an element with respect to an
- * {@link Hyperplane hyperplane} of a space.
+import org.apache.commons.geometry.euclidean.EuclideanTransform;
+
+/** Extension of the {@link EuclideanTransform} interface for 1D space.
  */
-public enum Side {
+public interface Transform1D extends EuclideanTransform<Vector1D> {
 
-    /** Code for the plus side of the hyperplane. */
-    PLUS,
-
-    /** Code for the minus side of the hyperplane. */
-    MINUS,
-
-    /** Code for elements crossing the hyperplane from plus to minus side. */
-    BOTH,
-
-    /** Code for the hyperplane itself. */
-    HYPER;
-
+    /** Return an affine transform matrix representing the same transform
+     * as this instance.
+     * @return an affine tranform matrix representing the same transform
+     *      as this instance
+     */
+    AffineTransformMatrix1D toMatrix();
 }
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/Vector1D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/Vector1D.java
index 48a3c5f..57ff97d 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/Vector1D.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/oned/Vector1D.java
@@ -16,8 +16,10 @@
  */
 package org.apache.commons.geometry.euclidean.oned;
 
+import java.util.Comparator;
+import java.util.function.Function;
+
 import org.apache.commons.geometry.core.Geometry;
-import org.apache.commons.geometry.core.exception.IllegalNormException;
 import org.apache.commons.geometry.core.internal.SimpleTupleFormat;
 import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
 import org.apache.commons.geometry.euclidean.EuclideanVector;
@@ -45,6 +47,24 @@
     public static final Vector1D NEGATIVE_INFINITY =
         new Vector1D(Double.NEGATIVE_INFINITY);
 
+    /** Comparator that sorts vectors in component-wise ascending order.
+     * Vectors are only considered equal if their coordinates match exactly.
+     * Null arguments are evaluated as being greater than non-null arguments.
+     */
+    public static final Comparator<Vector1D> COORDINATE_ASCENDING_ORDER = (a, b) -> {
+        int cmp = 0;
+
+        if (a != null && b != null) {
+            cmp = Double.compare(a.getX(), b.getX());
+        } else if (a != null) {
+            cmp = -1;
+        } else if (b != null) {
+            cmp = 1;
+        }
+
+        return cmp;
+    };
+
     /** Serializable UID. */
     private static final long serialVersionUID = 20180710L;
 
@@ -54,7 +74,7 @@
     /** Simple constructor.
      * @param x abscissa (coordinate value)
      */
-    private Vector1D(double x) {
+    private Vector1D(final double x) {
         this.x = x;
     }
 
@@ -86,19 +106,25 @@
 
     /** {@inheritDoc} */
     @Override
-    public Vector1D vectorTo(Vector1D v) {
+    public boolean isFinite() {
+        return Double.isFinite(x);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Vector1D vectorTo(final Vector1D v) {
         return v.subtract(this);
     }
 
     /** {@inheritDoc} */
     @Override
-    public Unit directionTo(Vector1D v) {
+    public Unit directionTo(final Vector1D v) {
         return vectorTo(v).normalize();
     }
 
     /** {@inheritDoc} */
     @Override
-    public Vector1D lerp(Vector1D p, double t) {
+    public Vector1D lerp(final Vector1D p, final double t) {
         return linearCombination(1.0 - t, this, t, p);
     }
 
@@ -122,32 +148,32 @@
 
     /** {@inheritDoc} */
     @Override
-    public Vector1D withNorm(double magnitude) {
+    public Vector1D withNorm(final double magnitude) {
         getCheckedNorm(); // validate our norm value
-        return (x > 0.0)? new Vector1D(magnitude) : new Vector1D(-magnitude);
+        return (x > 0.0) ? new Vector1D(magnitude) : new Vector1D(-magnitude);
     }
 
     /** {@inheritDoc} */
     @Override
-    public Vector1D add(Vector1D v) {
+    public Vector1D add(final Vector1D v) {
         return new Vector1D(x + v.x);
     }
 
     /** {@inheritDoc} */
     @Override
-    public Vector1D add(double factor, Vector1D v) {
+    public Vector1D add(final double factor, final Vector1D v) {
         return new Vector1D(x + (factor * v.x));
     }
 
     /** {@inheritDoc} */
     @Override
-    public Vector1D subtract(Vector1D v) {
+    public Vector1D subtract(final Vector1D v) {
         return new Vector1D(x - v.x);
     }
 
     /** {@inheritDoc} */
     @Override
-    public Vector1D subtract(double factor, Vector1D v) {
+    public Vector1D subtract(final double factor, final Vector1D v) {
         return new Vector1D(x - (factor * v.x));
     }
 
@@ -165,25 +191,25 @@
 
     /** {@inheritDoc} */
     @Override
-    public Vector1D multiply(double a) {
+    public Vector1D multiply(final double a) {
         return new Vector1D(a * x);
     }
 
     /** {@inheritDoc} */
     @Override
-    public double distance(Vector1D v) {
+    public double distance(final Vector1D v) {
         return Vectors.norm(x - v.x);
     }
 
     /** {@inheritDoc} */
     @Override
-    public double distanceSq(Vector1D v) {
+    public double distanceSq(final Vector1D v) {
         return Vectors.normSq(x - v.x);
     }
 
     /** {@inheritDoc} */
     @Override
-    public double dot(Vector1D v) {
+    public double dot(final Vector1D v) {
         return x * v.x;
     }
 
@@ -204,19 +230,18 @@
         return (sig1 == sig2) ? 0.0 : Geometry.PI;
     }
 
-    /** Apply the given transform to this vector, returning the result as a
-     * new vector instance.
-     * @param transform the transform to apply
-     * @return a new, transformed vector
-     * @see AffineTransformMatrix1D#apply(Vector1D)
+    /** Convenience method to apply a function to this vector. This
+     * can be used to transform the vector inline with other methods.
+     * @param fn the function to apply
+     * @return the transformed vector
      */
-    public Vector1D transform(AffineTransformMatrix1D transform) {
-        return transform.apply(this);
+    public Vector1D transform(final Function<Vector1D, Vector1D> fn) {
+        return fn.apply(this);
     }
 
     /** {@inheritDoc} */
     @Override
-    public boolean equals(final Vector1D vec, final DoublePrecisionContext precision) {
+    public boolean eq(final Vector1D vec, final DoublePrecisionContext precision) {
         return precision.eq(x, vec.x);
     }
 
@@ -254,7 +279,7 @@
      *
      */
     @Override
-    public boolean equals(Object other) {
+    public boolean equals(final Object other) {
 
         if (this == other) {
             return true;
@@ -281,7 +306,7 @@
      * @param x vector coordinate
      * @return vector instance
      */
-    public static Vector1D of(double x) {
+    public static Vector1D of(final double x) {
         return new Vector1D(x);
     }
 
@@ -291,7 +316,7 @@
      * @return vector instance represented by the string
      * @throws IllegalArgumentException if the given string has an invalid format
      */
-    public static Vector1D parse(String str) {
+    public static Vector1D parse(final String str) {
         return SimpleTupleFormat.getDefault().parse(str, Vector1D::new);
     }
 
@@ -305,7 +330,7 @@
      * @param c first coordinate
      * @return vector with coordinates calculated by {@code a * c}
      */
-    public static Vector1D linearCombination(double a, Vector1D c) {
+    public static Vector1D linearCombination(final double a, final Vector1D c) {
         return new Vector1D(a * c.x);
     }
 
@@ -321,7 +346,9 @@
      * @param v2 second coordinate
      * @return vector with coordinates calculated by {@code (a1 * v1) + (a2 * v2)}
      */
-    public static Vector1D linearCombination(double a1, Vector1D v1, double a2, Vector1D v2) {
+    public static Vector1D linearCombination(final double a1, final Vector1D v1,
+            final double a2, final Vector1D v2) {
+
         return new Vector1D(
                 LinearCombination.value(a1, v1.x, a2, v2.x));
     }
@@ -340,8 +367,10 @@
      * @param v3 third coordinate
      * @return vector with coordinates calculated by {@code (a1 * v1) + (a2 * v2) + (a3 * v3)}
      */
-    public static Vector1D linearCombination(double a1, Vector1D v1, double a2, Vector1D v2,
-            double a3, Vector1D v3) {
+    public static Vector1D linearCombination(final double a1, final Vector1D v1,
+            final double a2, final Vector1D v2,
+            final double a3, final Vector1D v3) {
+
         return new Vector1D(
                 LinearCombination.value(a1, v1.x, a2, v2.x, a3, v3.x));
     }
@@ -362,8 +391,11 @@
      * @param v4 fourth coordinate
      * @return point with coordinates calculated by {@code (a1 * v1) + (a2 * v2) + (a3 * v3) + (a4 * v4)}
      */
-    public static Vector1D linearCombination(double a1, Vector1D v1, double a2, Vector1D v2,
-            double a3, Vector1D v3, double a4, Vector1D v4) {
+    public static Vector1D linearCombination(final double a1, final Vector1D v1,
+            final double a2, final Vector1D v2,
+            final double a3, final Vector1D v3,
+            final double a4, final Vector1D v4) {
+
         return new Vector1D(
                 LinearCombination.value(a1, v1.x, a2, v2.x, a3, v3.x, a4, v4.x));
     }
@@ -373,12 +405,13 @@
      * This allows optimizations to be performed for certain operations.
      */
     public static final class Unit extends Vector1D {
+
         /** Unit vector (coordinates: 1). */
         public static final Unit PLUS  = new Unit(1d);
         /** Negation of unit vector (coordinates: -1). */
         public static final Unit MINUS = new Unit(-1d);
 
-        /** Serializable version identifier */
+        /** Serializable version identifier. */
         private static final long serialVersionUID = 20180903L;
 
         /** Simple constructor. Callers are responsible for ensuring that the given
@@ -394,7 +427,8 @@
          *
          * @param x Vector coordinate.
          * @return a vector whose norm is 1.
-         * @throws IllegalNormException if the norm of the given value is zero, NaN, or infinite
+         * @throws org.apache.commons.geometry.core.exception.IllegalNormException if the norm of the given value is
+         *      zero, NaN, or infinite
          */
         public static Unit from(double x) {
             Vectors.checkedNorm(Vectors.norm(x));
@@ -406,7 +440,8 @@
          *
          * @param v Vector.
          * @return a vector whose norm is 1.
-         * @throws IllegalNormException if the norm of the given value is zero, NaN, or infinite
+         * @throws org.apache.commons.geometry.core.exception.IllegalNormException if the norm of the given value is
+         *      zero, NaN, or infinite
          */
         public static Unit from(Vector1D v) {
             return v instanceof Unit ?
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/AbstractSubLine3D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/AbstractSubLine3D.java
new file mode 100644
index 0000000..1ab7654
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/AbstractSubLine3D.java
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.threed;
+
+import java.io.Serializable;
+
+import org.apache.commons.geometry.core.Region;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.oned.Vector1D;
+
+/** Internal base class for 3 dimensional subline implementations.
+ * @param <R> 1D subspace region type
+ */
+abstract class AbstractSubLine3D<R extends Region<Vector1D>> implements Serializable {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20190812L;
+
+    /** The line that this instance belongs to. */
+    private final Line3D line;
+
+    /** Construct a new instance belonging to the given line.
+     * @param line line the instance belongs to
+     */
+    protected AbstractSubLine3D(final Line3D line) {
+        this.line = line;
+    }
+
+    /** Get the line that this subline belongs to.
+     * @return the line that this subline belongs to.
+     */
+    public Line3D getLine() {
+        return line;
+    }
+
+    /** Get the precision object used to perform floating point
+     * comparisons for this instance.
+     * @return the precision object for this instance
+     */
+    public DoublePrecisionContext getPrecision() {
+        return line.getPrecision();
+    }
+
+    /** Get the subspace region for the subline.
+     * @return the subspace region for the subline
+     */
+    public abstract R getSubspaceRegion();
+}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/AbstractSubPlane.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/AbstractSubPlane.java
new file mode 100644
index 0000000..a8ad65e
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/AbstractSubPlane.java
@@ -0,0 +1,147 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.threed;
+
+import java.util.function.BiFunction;
+
+import org.apache.commons.geometry.core.partitioning.AbstractEmbeddingSubHyperplane;
+import org.apache.commons.geometry.core.partitioning.Hyperplane;
+import org.apache.commons.geometry.core.partitioning.HyperplaneBoundedRegion;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.SplitLocation;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.threed.SubPlane.SubPlaneBuilder;
+import org.apache.commons.geometry.euclidean.twod.Line;
+import org.apache.commons.geometry.euclidean.twod.Vector2D;
+
+/** Internal base class for subplane implementations.
+ * @param <R> Subspace region type
+ */
+abstract class AbstractSubPlane<R extends HyperplaneBoundedRegion<Vector2D>>
+    extends AbstractEmbeddingSubHyperplane<Vector3D, Vector2D, Plane> {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20190729L;
+
+    /** The plane defining this instance. */
+    private final Plane plane;
+
+    /** Construct a new instance based on the given plane.
+     * @param plane the plane defining the subplane
+     */
+    AbstractSubPlane(final Plane plane) {
+        this.plane = plane;
+    }
+
+    /** Get the plane that this subplane lies on. This method is an alias
+     * for {@link #getHyperplane()}.
+     * @return the plane that this subplane lies on
+     * @see #getHyperplane()
+     */
+    public Plane getPlane() {
+        return getHyperplane();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Plane getHyperplane() {
+        return plane;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public SubPlaneBuilder builder() {
+        return new SubPlaneBuilder(plane);
+    }
+
+    /** Return the object used to perform floating point comparisons, which is the
+     * same object used by the underlying {@link Plane}).
+     * @return precision object used to perform floating point comparisons.
+     */
+    public DoublePrecisionContext getPrecision() {
+        return plane.getPrecision();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String toString() {
+        final StringBuilder sb = new StringBuilder();
+        sb.append(getClass().getSimpleName())
+            .append("[plane= ")
+            .append(getPlane())
+            .append(", subspaceRegion= ")
+            .append(getSubspaceRegion());
+
+
+        return sb.toString();
+    }
+
+    /** Generic, internal split method. Subclasses should call this from their
+     * {@link #split(Hyperplane)} methods.
+     * @param splitter splitting hyperplane
+     * @param thisInstance a reference to the current instance; this is passed as
+     *      an argument in order to allow it to be a generic type
+     * @param factory function used to create new subhyperplane instances
+     * @param <T> Subplane implementation type
+     * @return the result of the split operation
+     */
+    protected <T extends AbstractSubPlane<R>> Split<T> splitInternal(final Hyperplane<Vector3D> splitter,
+            final T thisInstance, final BiFunction<Plane, HyperplaneBoundedRegion<Vector2D>, T> factory) {
+
+        final Plane thisPlane = thisInstance.getPlane();
+        final Plane splitterPlane = (Plane) splitter;
+        final DoublePrecisionContext precision = thisInstance.getPrecision();
+
+        final Line3D intersection = thisPlane.intersection(splitterPlane);
+        if (intersection == null) {
+            // the planes are parallel or coincident; check which side of
+            // the splitter we lie on
+            final double offset = splitterPlane.offset(thisPlane);
+            final int comp = precision.compare(offset, 0.0);
+
+            if (comp < 0) {
+                return new Split<>(thisInstance, null);
+            } else if (comp > 0) {
+                return new Split<>(null, thisInstance);
+            } else {
+                return new Split<>(null, null);
+            }
+        } else {
+            // the lines intersect; split the subregion
+            final Vector3D intersectionOrigin = intersection.getOrigin();
+            final Vector2D subspaceP1 = thisPlane.toSubspace(intersectionOrigin);
+            final Vector2D subspaceP2 = thisPlane.toSubspace(intersectionOrigin.add(intersection.getDirection()));
+
+            final Line subspaceSplitter = Line.fromPoints(subspaceP1, subspaceP2, getPrecision());
+
+            final Split<? extends HyperplaneBoundedRegion<Vector2D>> split =
+                    thisInstance.getSubspaceRegion().split(subspaceSplitter);
+            final SplitLocation subspaceSplitLoc = split.getLocation();
+
+            if (SplitLocation.MINUS == subspaceSplitLoc) {
+                return new Split<>(thisInstance, null);
+            } else if (SplitLocation.PLUS == subspaceSplitLoc) {
+                return new Split<>(null, thisInstance);
+            }
+
+            final T minus = (split.getMinus() != null) ? factory.apply(getPlane(), split.getMinus()) : null;
+            final T plus = (split.getPlus() != null) ? factory.apply(getPlane(), split.getPlus()) : null;
+
+            return new Split<>(minus, plus);
+        }
+    }
+}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/AffineTransformMatrix3D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/AffineTransformMatrix3D.java
index 92d2b2a..e88f2c3 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/AffineTransformMatrix3D.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/AffineTransformMatrix3D.java
@@ -19,12 +19,11 @@
 import java.io.Serializable;
 
 import org.apache.commons.geometry.core.internal.DoubleFunction3N;
-import org.apache.commons.geometry.euclidean.AffineTransformMatrix;
+import org.apache.commons.geometry.euclidean.AbstractAffineTransformMatrix;
 import org.apache.commons.geometry.euclidean.exception.NonInvertibleTransformException;
 import org.apache.commons.geometry.euclidean.internal.Matrices;
 import org.apache.commons.geometry.euclidean.internal.Vectors;
 import org.apache.commons.geometry.euclidean.threed.rotation.QuaternionRotation;
-import org.apache.commons.geometry.euclidean.twod.Vector2D;
 import org.apache.commons.numbers.arrays.LinearCombination;
 import org.apache.commons.numbers.core.Precision;
 
@@ -36,24 +35,25 @@
  * use arrays containing 12 elements, instead of 16.
  * </p>
  */
-public final class AffineTransformMatrix3D implements AffineTransformMatrix<Vector3D, Vector2D>, Serializable {
+public final class AffineTransformMatrix3D extends AbstractAffineTransformMatrix<Vector3D>
+    implements Transform3D, Serializable {
 
-    /** Serializable version identifier */
+    /** Serializable version identifier. */
     private static final long serialVersionUID = 20180923L;
 
-    /** The number of internal matrix elements */
+    /** The number of internal matrix elements. */
     private static final int NUM_ELEMENTS = 12;
 
-    /** String used to start the transform matrix string representation */
+    /** String used to start the transform matrix string representation. */
     private static final String MATRIX_START = "[ ";
 
-    /** String used to end the transform matrix string representation */
+    /** String used to end the transform matrix string representation. */
     private static final String MATRIX_END = " ]";
 
-    /** String used to separate elements in the matrix string representation */
+    /** String used to separate elements in the matrix string representation. */
     private static final String ELEMENT_SEPARATOR = ", ";
 
-    /** String used to separate rows in the matrix string representation */
+    /** String used to separate rows in the matrix string representation. */
     private static final String ROW_SEPARATOR = "; ";
 
     /** Shared transform set to the identity matrix. */
@@ -63,31 +63,31 @@
                 0, 0, 1, 0
             );
 
-    /** Transform matrix entry <code>m<sub>0,0</sub></code> */
+    /** Transform matrix entry <code>m<sub>0,0</sub></code>. */
     private final double m00;
-    /** Transform matrix entry <code>m<sub>0,1</sub></code> */
+    /** Transform matrix entry <code>m<sub>0,1</sub></code>. */
     private final double m01;
-    /** Transform matrix entry <code>m<sub>0,2</sub></code> */
+    /** Transform matrix entry <code>m<sub>0,2</sub></code>. */
     private final double m02;
-    /** Transform matrix entry <code>m<sub>0,3</sub></code> */
+    /** Transform matrix entry <code>m<sub>0,3</sub></code>. */
     private final double m03;
 
-    /** Transform matrix entry <code>m<sub>1,0</sub></code> */
+    /** Transform matrix entry <code>m<sub>1,0</sub></code>. */
     private final double m10;
-    /** Transform matrix entry <code>m<sub>1,1</sub></code> */
+    /** Transform matrix entry <code>m<sub>1,1</sub></code>. */
     private final double m11;
-    /** Transform matrix entry <code>m<sub>1,2</sub></code> */
+    /** Transform matrix entry <code>m<sub>1,2</sub></code>. */
     private final double m12;
-    /** Transform matrix entry <code>m<sub>1,3</sub></code> */
+    /** Transform matrix entry <code>m<sub>1,3</sub></code>. */
     private final double m13;
 
-    /** Transform matrix entry <code>m<sub>2,0</sub></code> */
+    /** Transform matrix entry <code>m<sub>2,0</sub></code>. */
     private final double m20;
-    /** Transform matrix entry <code>m<sub>2,1</sub></code> */
+    /** Transform matrix entry <code>m<sub>2,1</sub></code>. */
     private final double m21;
-    /** Transform matrix entry <code>m<sub>2,2</sub></code> */
+    /** Transform matrix entry <code>m<sub>2,2</sub></code>. */
     private final double m22;
-    /** Transform matrix entry <code>m<sub>2,3</sub></code> */
+    /** Transform matrix entry <code>m<sub>2,3</sub></code>. */
     private final double m23;
 
     /**
@@ -142,9 +142,9 @@
      */
     public double[] toArray() {
         return new double[] {
-                m00, m01, m02, m03,
-                m10, m11, m12, m13,
-                m20, m21, m22, m23
+            m00, m01, m02, m03,
+            m10, m11, m12, m13,
+            m20, m21, m22, m23
         };
     }
 
@@ -198,10 +198,29 @@
      * @see #applyVector(Vector3D)
      */
     @Override
-    public Vector3D applyDirection(final Vector3D vec) {
+    public Vector3D.Unit applyDirection(final Vector3D vec) {
         return applyVector(vec, Vector3D.Unit::from);
     }
 
+    /** {@inheritDoc} */
+    @Override
+    public double determinant() {
+        return Matrices.determinant(
+                m00, m01, m02,
+                m10, m11, m12,
+                m20, m21, m22
+            );
+    }
+
+    /** {@inheritDoc}
+    *
+    * <p>This simply returns the current instance.</p>
+    */
+    @Override
+    public AffineTransformMatrix3D toMatrix() {
+        return this;
+    }
+
     /** Apply a translation to the current instance, returning the result as a new transform.
      * @param translation vector containing the translation values for each axis
      * @return a new transform containing the result of applying a translation to
@@ -263,10 +282,10 @@
      * @param rotation the rotation to apply
      * @return a new transform containing the result of applying a rotation to the
      *      current instance
-     * @see QuaternionRotation#toTransformMatrix()
+     * @see QuaternionRotation#toMatrix()
      */
     public AffineTransformMatrix3D rotate(final QuaternionRotation rotation) {
-        return multiply(rotation.toTransformMatrix(), this);
+        return multiply(rotation.toMatrix(), this);
     }
 
     /** Apply a rotation around the given center point to the current instance, returning the result
@@ -276,7 +295,7 @@
      * @param rotation the rotation to apply
      * @return a new transform containing the result of applying a rotation about the given center
      *      point to the current instance
-     * @see QuaternionRotation#toTransformMatrix()
+     * @see QuaternionRotation#toMatrix()
      */
     public AffineTransformMatrix3D rotate(final Vector3D center, final QuaternionRotation rotation) {
         return multiply(createRotation(center, rotation), this);
@@ -321,12 +340,7 @@
         // Our full matrix is 4x4 but we can significantly reduce the amount of computations
         // needed here since we know that our last row is [0 0 0 1].
 
-        // compute the determinant of the matrix
-        final double det = Matrices.determinant(
-                    m00, m01, m02,
-                    m10, m11, m12,
-                    m20, m21, m22
-                );
+        final double det = determinant();
 
         if (!Vectors.isRealNonZero(det)) {
             throw new NonInvertibleTransformException("Transform is not invertible; matrix determinant is " + det);
@@ -343,18 +357,18 @@
         final double invDet = 1.0 / det;
 
         final double c00 = invDet * Matrices.determinant(m11, m12, m21, m22);
-        final double c01 = - invDet * Matrices.determinant(m10, m12, m20, m22);
+        final double c01 = -invDet * Matrices.determinant(m10, m12, m20, m22);
         final double c02 = invDet * Matrices.determinant(m10, m11, m20, m21);
 
-        final double c10 = - invDet * Matrices.determinant(m01, m02, m21, m22);
+        final double c10 = -invDet * Matrices.determinant(m01, m02, m21, m22);
         final double c11 = invDet * Matrices.determinant(m00, m02, m20, m22);
-        final double c12 = - invDet * Matrices.determinant(m00, m01, m20, m21);
+        final double c12 = -invDet * Matrices.determinant(m00, m01, m20, m21);
 
         final double c20 = invDet * Matrices.determinant(m01, m02, m11, m12);
-        final double c21 = - invDet * Matrices.determinant(m00, m02, m10, m12);
+        final double c21 = -invDet * Matrices.determinant(m00, m02, m10, m12);
         final double c22 = invDet * Matrices.determinant(m00, m01, m10, m11);
 
-        final double c30 = - invDet * Matrices.determinant(
+        final double c30 = -invDet * Matrices.determinant(
                     m01, m02, m03,
                     m11, m12, m13,
                     m21, m22, m23
@@ -364,7 +378,7 @@
                     m10, m12, m13,
                     m20, m22, m23
                 );
-        final double c32 = - invDet * Matrices.determinant(
+        final double c32 = -invDet * Matrices.determinant(
                     m00, m01, m03,
                     m10, m11, m13,
                     m20, m21, m23
@@ -383,9 +397,12 @@
         final int prime = 31;
         int result = 1;
 
-        result = (result * prime) + (Double.hashCode(m00) - Double.hashCode(m01) + Double.hashCode(m02) - Double.hashCode(m03));
-        result = (result * prime) + (Double.hashCode(m10) - Double.hashCode(m11) + Double.hashCode(m12) - Double.hashCode(m13));
-        result = (result * prime) + (Double.hashCode(m20) - Double.hashCode(m21) + Double.hashCode(m22) - Double.hashCode(m23));
+        result = (result * prime) + (Double.hashCode(m00) - Double.hashCode(m01) +
+                Double.hashCode(m02) - Double.hashCode(m03));
+        result = (result * prime) + (Double.hashCode(m10) - Double.hashCode(m11) +
+                Double.hashCode(m12) - Double.hashCode(m13));
+        result = (result * prime) + (Double.hashCode(m20) - Double.hashCode(m21) +
+                Double.hashCode(m22) - Double.hashCode(m23));
 
         return result;
     }
@@ -397,7 +414,7 @@
      * @return true if all transform matrix elements are exactly equal; otherwise false
      */
     @Override
-    public boolean equals(Object obj) {
+    public boolean equals(final Object obj) {
         if (this == obj) {
             return true;
         }
@@ -488,7 +505,7 @@
      * @return a new transform initialized with the given matrix values
      * @throws IllegalArgumentException if the array does not have 12 elements
      */
-    public static AffineTransformMatrix3D of(final double ... arr) {
+    public static AffineTransformMatrix3D of(final double... arr) {
         if (arr.length != NUM_ELEMENTS) {
             throw new IllegalArgumentException("Dimension mismatch: " + arr.length + " != " + NUM_ELEMENTS);
         }
@@ -500,6 +517,40 @@
                 );
     }
 
+    /** Get a new transform create from the given column vectors. The returned transform
+     * does not include any translation component.
+     * @param u first column vector; this corresponds to the first basis vector
+     *      in the coordinate frame
+     * @param v second column vector; this corresponds to the second basis vector
+     *      in the coordinate frame
+     * @param w third column vector; this corresponds to the third basis vector
+     *      in the coordinate frame
+     * @return a new transform with the given column vectors
+     */
+    public static AffineTransformMatrix3D fromColumnVectors(final Vector3D u, final Vector3D v, final Vector3D w) {
+        return fromColumnVectors(u, v, w, Vector3D.ZERO);
+    }
+
+    /** Get a new transform created from the given column vectors.
+     * @param u first column vector; this corresponds to the first basis vector
+     *      in the coordinate frame
+     * @param v second column vector; this corresponds to the second basis vector
+     *      in the coordinate frame
+     * @param w third column vector; this corresponds to the third basis vector
+     *      in the coordinate frame
+     * @param t fourth column vector; this corresponds to the translation of the transform
+     * @return a new transform with the given column vectors
+     */
+    public static AffineTransformMatrix3D fromColumnVectors(final Vector3D u, final Vector3D v,
+            final Vector3D w, final Vector3D t) {
+
+        return new AffineTransformMatrix3D(
+                    u.getX(), v.getX(), w.getX(), t.getX(),
+                    u.getY(), v.getY(), w.getY(), t.getY(),
+                    u.getZ(), v.getZ(), w.getZ(), t.getZ()
+                );
+    }
+
     /** Get the transform representing the identity matrix. This transform does not
      * modify point or vector values when applied.
      * @return transform representing the identity matrix
@@ -565,7 +616,7 @@
      * @param center the center of rotation
      * @param rotation the rotation to apply
      * @return a new transform representing a rotation about the given center point
-     * @see QuaternionRotation#toTransformMatrix()
+     * @see QuaternionRotation#toMatrix()
      */
     public static AffineTransformMatrix3D createRotation(final Vector3D center, final QuaternionRotation rotation) {
         return createTranslation(center.negate())
@@ -578,7 +629,8 @@
      * @param b second transform
      * @return the transform computed as {@code a x b}
      */
-    private static AffineTransformMatrix3D multiply(final AffineTransformMatrix3D a, final AffineTransformMatrix3D b) {
+    private static AffineTransformMatrix3D multiply(final AffineTransformMatrix3D a,
+            final AffineTransformMatrix3D b) {
 
         // calculate the matrix elements
         final double c00 = LinearCombination.value(a.m00, b.m00, a.m01, b.m10, a.m02, b.m20);
@@ -593,8 +645,8 @@
 
         final double c20 = LinearCombination.value(a.m20, b.m00, a.m21, b.m10, a.m22, b.m20);
         final double c21 = LinearCombination.value(a.m20, b.m01, a.m21, b.m11, a.m22, b.m21);
-        final double c22 = LinearCombination.value(a.m20, b.m02, a.m21 , b.m12, a.m22, b.m22);
-        final double c23 = LinearCombination.value(a.m20, b.m03, a.m21 , b.m13, a.m22, b.m23) + a.m23;
+        final double c22 = LinearCombination.value(a.m20, b.m02, a.m21, b.m12, a.m22, b.m22);
+        final double c23 = LinearCombination.value(a.m20, b.m03, a.m21, b.m13, a.m22, b.m23) + a.m23;
 
         return new AffineTransformMatrix3D(
                     c00, c01, c02, c03,
@@ -611,7 +663,8 @@
      */
     private static void validateElementForInverse(final double element) {
         if (!Double.isFinite(element)) {
-            throw new NonInvertibleTransformException("Transform is not invertible; invalid matrix element: " + element);
+            throw new NonInvertibleTransformException("Transform is not invertible; invalid matrix element: " +
+                    element);
         }
     }
 }
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/ConvexSubPlane.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/ConvexSubPlane.java
new file mode 100644
index 0000000..085bf79
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/ConvexSubPlane.java
@@ -0,0 +1,172 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.threed;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.partitioning.ConvexSubHyperplane;
+import org.apache.commons.geometry.core.partitioning.Hyperplane;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.threed.Plane.SubspaceTransform;
+import org.apache.commons.geometry.euclidean.twod.AffineTransformMatrix2D;
+import org.apache.commons.geometry.euclidean.twod.ConvexArea;
+import org.apache.commons.geometry.euclidean.twod.Vector2D;
+
+/** Class representing a convex subhyperplane in 3 dimensional Euclidean space, meaning
+ * a 2D convex area embedded in a plane.
+ */
+public final class ConvexSubPlane extends AbstractSubPlane<ConvexArea>
+    implements ConvexSubHyperplane<Vector3D>  {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20190729L;
+
+    /** The embedded 2D area. */
+    private final ConvexArea area;
+
+    /** Create a new instance from its component parts.
+     * @param plane plane the the convex area is embedded in
+     * @param area the embedded convex area
+     */
+    private ConvexSubPlane(final Plane plane, final ConvexArea area) {
+        super(plane);
+
+        this.area = area;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public List<ConvexSubPlane> toConvex() {
+        return Arrays.asList(this);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public ConvexSubPlane reverse() {
+        final Plane plane = getPlane();
+        final Plane rPlane = plane.reverse();
+
+        final Vector2D rU = rPlane.toSubspace(plane.toSpace(Vector2D.Unit.PLUS_X));
+        final Vector2D rV = rPlane.toSubspace(plane.toSpace(Vector2D.Unit.PLUS_Y));
+
+        final AffineTransformMatrix2D transform =
+                AffineTransformMatrix2D.fromColumnVectors(rU, rV);
+
+        return new ConvexSubPlane(rPlane, area.transform(transform));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public ConvexSubPlane transform(final Transform<Vector3D> transform) {
+        final SubspaceTransform st = getPlane().subspaceTransform(transform);
+        final ConvexArea tArea = area.transform(st.getTransform());
+
+        return fromConvexArea(st.getPlane(), tArea);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public ConvexArea getSubspaceRegion() {
+        return area;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Split<ConvexSubPlane> split(Hyperplane<Vector3D> splitter) {
+        return splitInternal(splitter, this, (p, r) -> new ConvexSubPlane(p, (ConvexArea) r));
+    }
+
+    /** Get the vertices for the subplane. The vertices lie at the intersections of the
+     * 2D area bounding lines.
+     * @return the vertices for the subplane
+     */
+    public List<Vector3D> getVertices() {
+        return getPlane().toSpace(area.getVertices());
+    }
+
+    /** Create a new instance from a plane and an embedded convex subspace area.
+     * @param plane embedding plane for the area
+     * @param area area embedded in the plane
+     * @return a new convex sub plane instance
+     */
+    public static ConvexSubPlane fromConvexArea(final Plane plane, final ConvexArea area) {
+        return new ConvexSubPlane(plane, area);
+    }
+
+    /** Create a new instance from the given sequence of points. The points must define a unique plane, meaning that
+    * at least 3 unique vertices must be given. In contrast with the
+    * {@link #fromVertices(Collection, DoublePrecisionContext)} method, the first point in the sequence is included
+    * at the end if needed, in order to form a closed loop.
+    * @param pts collection of points defining the convex subplane
+    * @param precision precision context used to compare floating point values
+    * @return a new instance defined by the given sequence of vertices
+    * @throws IllegalArgumentException if fewer than 3 vertices are given
+    * @throws org.apache.commons.geometry.core.exception.GeometryException if the vertices do not define a
+    *       unique plane
+    * @see #fromVertices(Collection, DoublePrecisionContext)
+    * @see #fromVertices(Collection, boolean, DoublePrecisionContext)
+    * @see Plane#fromPoints(Collection, DoublePrecisionContext)
+    */
+    public static ConvexSubPlane fromVertexLoop(final Collection<Vector3D> pts,
+            final DoublePrecisionContext precision) {
+        return fromVertices(pts, true, precision);
+    }
+
+    /** Create a new instance from the given sequence of points. The points must define a unique plane, meaning that
+     * at least 3 unique vertices must be given.
+     * @param pts collection of points defining the convex subplane
+     * @param precision precision context used to compare floating point values
+     * @return a new instance defined by the given sequence of vertices
+     * @throws IllegalArgumentException if fewer than 3 vertices are given
+     * @throws org.apache.commons.geometry.core.exception.GeometryException if the vertices do not define a
+     *      unique plane
+     * @see #fromVertexLoop(Collection, DoublePrecisionContext)
+     * @see #fromVertices(Collection, boolean, DoublePrecisionContext)
+     * @see Plane#fromPoints(Collection, DoublePrecisionContext)
+     */
+    public static ConvexSubPlane fromVertices(final Collection<Vector3D> pts,
+            final DoublePrecisionContext precision) {
+        return fromVertices(pts, false, precision);
+    }
+
+    /** Create a new instance from the given sequence of points. The points must define a unique plane, meaning that
+     * at least 3 unique vertices must be given. If {@code close} is true, the vertices are made into a closed loop
+     * by including the start point at the end if needed.
+     * @param pts collection of points
+     * @param close if true, the point sequence will implicitly include the start point again at the end; otherwise
+     *      the vertex sequence is taken as-is
+     * @param precision precision context used to compare floating point values
+     * @return a new convex subplane instance
+     * @see #fromVertexLoop(Collection, DoublePrecisionContext)
+     * @see #fromVertices(Collection, DoublePrecisionContext)
+     * @see Plane#fromPoints(Collection, DoublePrecisionContext)
+     */
+    public static ConvexSubPlane fromVertices(final Collection<Vector3D> pts, final boolean close,
+            final DoublePrecisionContext precision) {
+
+        final Plane plane = Plane.fromPoints(pts, precision);
+
+        final List<Vector2D> subspacePts = plane.toSubspace(pts);
+        final ConvexArea area = ConvexArea.fromVertices(subspacePts, close, precision);
+
+        return new ConvexSubPlane(plane, area);
+    }
+}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/ConvexVolume.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/ConvexVolume.java
new file mode 100644
index 0000000..e772060
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/ConvexVolume.java
@@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.threed;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.partitioning.AbstractConvexHyperplaneBoundedRegion;
+import org.apache.commons.geometry.core.partitioning.ConvexSubHyperplane;
+import org.apache.commons.geometry.core.partitioning.Hyperplane;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.euclidean.twod.ConvexArea;
+
+/** Class representing a finite or infinite convex volume in Euclidean 3D space.
+ * The boundaries of this area, if any, are composed of convex subplanes.
+ */
+public final class ConvexVolume extends AbstractConvexHyperplaneBoundedRegion<Vector3D, ConvexSubPlane> {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20190811L;
+
+    /** Instance representing the full 3D volume. */
+    private static final ConvexVolume FULL = new ConvexVolume(Collections.emptyList());
+
+    /** Simple constructor. Callers are responsible for ensuring that the given path
+     * represents the boundary of a convex area. No validation is performed.
+     * @param boundaries the boundaries of the convex area
+     */
+    private ConvexVolume(final List<ConvexSubPlane> boundaries) {
+        super(boundaries);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public double getSize() {
+        if (isFull()) {
+            return Double.POSITIVE_INFINITY;
+        }
+
+        double volumeSum = 0.0;
+
+        for (ConvexSubPlane subplane : getBoundaries()) {
+            if (subplane.isInfinite()) {
+                return Double.POSITIVE_INFINITY;
+            }
+
+            final Plane plane = subplane.getPlane();
+            final ConvexArea subarea = subplane.getSubspaceRegion();
+
+            final Vector3D facetBarycenter = subplane.getHyperplane().toSpace(
+                    subarea.getBarycenter());
+
+
+            volumeSum += subarea.getSize() * facetBarycenter.dot(plane.getNormal());
+        }
+
+        return volumeSum / 3.0;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Vector3D getBarycenter() {
+        if (isFull()) {
+            return null;
+        }
+
+        double volumeSum = 0.0;
+
+        double sumX = 0.0;
+        double sumY = 0.0;
+        double sumZ = 0.0;
+
+        for (ConvexSubPlane subplane : getBoundaries()) {
+            if (subplane.isInfinite()) {
+                return null;
+            }
+
+            final Plane plane = subplane.getPlane();
+            final ConvexArea subarea = subplane.getSubspaceRegion();
+
+            final Vector3D facetBarycenter = subplane.getHyperplane().toSpace(
+                    subarea.getBarycenter());
+
+            double scaledVolume = subarea.getSize() * facetBarycenter.dot(plane.getNormal());
+
+            volumeSum += scaledVolume;
+
+            sumX += scaledVolume * facetBarycenter.getX();
+            sumY += scaledVolume * facetBarycenter.getY();
+            sumZ += scaledVolume * facetBarycenter.getZ();
+        }
+
+        double size = volumeSum / 3.0;
+
+        // Since the volume we used when adding together the facet contributions
+        // was 3x the actual pyramid size, we'll multiply by 1/4 here instead
+        // of 3/4 to adjust for the actual barycenter position in each pyramid.
+        final double barycenterScale = 1.0 / (4 * size);
+        return Vector3D.of(
+                sumX * barycenterScale,
+                sumY * barycenterScale,
+                sumZ * barycenterScale);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Split<ConvexVolume> split(final Hyperplane<Vector3D> splitter) {
+        return splitInternal(splitter, this, ConvexSubPlane.class, ConvexVolume::new);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public ConvexSubPlane trim(final ConvexSubHyperplane<Vector3D> convexSubHyperplane) {
+        return (ConvexSubPlane) super.trim(convexSubHyperplane);
+    }
+
+    /** Return a new instance transformed by the argument.
+     * @param transform transform to apply
+     * @return a new instance transformed by the argument
+     */
+    public ConvexVolume transform(final Transform<Vector3D> transform) {
+        return transformInternal(transform, this, ConvexSubPlane.class, ConvexVolume::new);
+    }
+
+    /** Return a BSP tree instance representing the same region as the current instance.
+     * @return a BSP tree instance representing the same region as the current instance
+     */
+    public RegionBSPTree3D toTree() {
+        return RegionBSPTree3D.from(this);
+    }
+
+    /** Return an instance representing the full 3D volume.
+     * @return an instance representing the full 3D volume.
+     */
+    public static ConvexVolume full() {
+        return FULL;
+    }
+
+    /** Create a convex volume formed by the intersection of the negative half-spaces of the
+     * given bounding planes. The returned instance represents the volume that is on the
+     * minus side of all of the given plane. Note that this method does not support volumes
+     * of zero size (ie, infinitely thin volumes or points.)
+     * @param planes planes used to define the convex area
+     * @return a new convex volume instance representing the volume on the minus side of all
+     *      of the bounding plane or an instance representing the full space if the collection
+     *      is empty
+     * @throws org.apache.commons.geometry.core.exception.GeometryException if the given set of bounding
+     *      planes do not form a convex volume, meaning that there is no region that is on the minus side
+     *      of all of the bounding planes.
+     */
+    public static ConvexVolume fromBounds(final Plane... planes) {
+        return fromBounds(Arrays.asList(planes));
+    }
+
+    /** Create a convex volume formed by the intersection of the negative half-spaces of the
+     * given bounding planes. The returned instance represents the volume that is on the
+     * minus side of all of the given plane. Note that this method does not support volumes
+     * of zero size (ie, infinitely thin volumes or points.)
+     * @param boundingPlanes planes used to define the convex area
+     * @return a new convex volume instance representing the volume on the minus side of all
+     *      of the bounding plane or an instance representing the full space if the collection
+     *      is empty
+     * @throws org.apache.commons.geometry.core.exception.GeometryException if the given set of bounding planes
+     *      do not form a convex volume, meaning that there is no region that is on the minus side of all of
+ *          the bounding planes.
+     */
+    public static ConvexVolume fromBounds(final Iterable<Plane> boundingPlanes) {
+        final List<ConvexSubPlane> subplanes = new ConvexRegionBoundaryBuilder<>(ConvexSubPlane.class)
+                .build(boundingPlanes);
+        return subplanes.isEmpty() ? full() : new ConvexVolume(subplanes);
+    }
+}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/FunctionTransform3D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/FunctionTransform3D.java
new file mode 100644
index 0000000..15dca12
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/FunctionTransform3D.java
@@ -0,0 +1,107 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.threed;
+
+import java.util.function.Function;
+
+import org.apache.commons.geometry.euclidean.internal.Matrices;
+
+/** Class that wraps a {@link Function} with the {@link Transform3D} interface.
+ */
+public final class FunctionTransform3D implements Transform3D {
+
+    /** Static instance representing the identity transform. */
+    private static final FunctionTransform3D IDENTITY =
+            new FunctionTransform3D(Function.identity(), true, Vector3D.ZERO);
+
+    /** The underlying function for the transform. */
+    private final Function<Vector3D, Vector3D> fn;
+
+    /** True if the transform preserves spatial orientation. */
+    private final boolean preservesOrientation;
+
+    /** The translation component of the transform. */
+    private final Vector3D translation;
+
+    /** Construct a new instance from its component parts. No validation of the input is performed.
+     * @param fn the underlying function for the transform
+     * @param preservesOrientation true if the transform preserves spatial orientation
+     * @param translation the translation component of the transform
+     */
+    private FunctionTransform3D(final Function<Vector3D, Vector3D> fn, final boolean preservesOrientation,
+            final Vector3D translation) {
+        this.fn = fn;
+        this.preservesOrientation = preservesOrientation;
+        this.translation = translation;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Vector3D apply(final Vector3D pt) {
+        return fn.apply(pt);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Vector3D applyVector(final Vector3D vec) {
+        return apply(vec).subtract(translation);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean preservesOrientation() {
+        return preservesOrientation;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public AffineTransformMatrix3D toMatrix() {
+        final Vector3D u = applyVector(Vector3D.Unit.PLUS_X);
+        final Vector3D v = applyVector(Vector3D.Unit.PLUS_Y);
+        final Vector3D w = applyVector(Vector3D.Unit.PLUS_Z);
+
+        return AffineTransformMatrix3D.fromColumnVectors(u, v, w, translation);
+    }
+
+    /** Return an instance representing the identity transform.
+     * @return an instance representing the identity transform
+     */
+    public static FunctionTransform3D identity() {
+        return IDENTITY;
+    }
+
+    /** Construct a new transform instance from the given function.
+     * @param fn the function to use for the transform
+     * @return a new transform instance using the given function
+     */
+    public static FunctionTransform3D from(final Function<Vector3D, Vector3D> fn) {
+        final Vector3D tPlusX = fn.apply(Vector3D.Unit.PLUS_X);
+        final Vector3D tPlusY = fn.apply(Vector3D.Unit.PLUS_Y);
+        final Vector3D tPlusZ = fn.apply(Vector3D.Unit.PLUS_Z);
+
+        final Vector3D tZero = fn.apply(Vector3D.ZERO);
+
+        final double det = Matrices.determinant(
+                tPlusX.getX(), tPlusY.getX(), tPlusZ.getX(),
+                tPlusX.getY(), tPlusY.getY(), tPlusZ.getY(),
+                tPlusX.getZ(), tPlusY.getZ(), tPlusZ.getZ()
+            );
+        final boolean preservesOrientation = det > 0;
+
+        return new FunctionTransform3D(fn, preservesOrientation, tZero);
+    }
+}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Line.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Line.java
deleted file mode 100644
index 9f859e7..0000000
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Line.java
+++ /dev/null
@@ -1,269 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.threed;
-
-import java.util.Objects;
-
-import org.apache.commons.geometry.core.partitioning.Embedding;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.geometry.euclidean.oned.IntervalsSet;
-import org.apache.commons.geometry.euclidean.oned.Vector1D;
-import org.apache.commons.numbers.core.Precision;
-
-/** The class represent lines in a three dimensional space.
-
- * <p>Each oriented line is intrinsically associated with an abscissa
- * which is a coordinate on the line. The point at abscissa 0 is the
- * orthogonal projection of the origin on the line, another equivalent
- * way to express this is to say that it is the point of the line
- * which is closest to the origin. Abscissa increases in the line
- * direction.</p>0
- */
-public class Line implements Embedding<Vector3D, Vector1D> {
-
-    /** Line direction. */
-    private Vector3D direction;
-
-    /** Line point closest to the origin. */
-    private Vector3D zero;
-
-    /** Precision context used to compare floating point numbers. */
-    private final DoublePrecisionContext precision;
-
-    /** Build a line from two points.
-     * @param p1 first point belonging to the line (this can be any point)
-     * @param p2 second point belonging to the line (this can be any point, different from p1)
-     * @param precision precision context used to compare floating point values
-     * @exception IllegalArgumentException if the points are equal
-     */
-    public Line(final Vector3D p1, final Vector3D p2, final DoublePrecisionContext precision)
-        throws IllegalArgumentException {
-        reset(p1, p2);
-        this.precision = precision;
-    }
-
-    /** Copy constructor.
-     * <p>The created instance is completely independent from the
-     * original instance, it is a deep copy.</p>
-     * @param line line to copy
-     */
-    public Line(final Line line) {
-        this.direction = line.direction;
-        this.zero      = line.zero;
-        this.precision = line.precision;
-    }
-
-    /** Reset the instance as if built from two points.
-     * @param p1 first point belonging to the line (this can be any point)
-     * @param p2 second point belonging to the line (this can be any point, different from p1)
-     * @exception IllegalArgumentException if the points are equal
-     */
-    public void reset(final Vector3D p1, final Vector3D p2) {
-        final Vector3D delta = p2.subtract(p1);
-        final double norm2 = delta.normSq();
-        if (norm2 == 0.0) {
-            throw new IllegalArgumentException("Points are equal");
-        }
-        this.direction = Vector3D.linearCombination(1.0 / Math.sqrt(norm2), delta);
-        this.zero = Vector3D.linearCombination(1.0, p1, -p1.dot(delta) / norm2, delta);
-    }
-
-    /** Get the object used to determine floating point equality for this instance.
-     * @return the floating point precision context for the instance
-     */
-    public DoublePrecisionContext getPrecision() {
-        return precision;
-    }
-
-    /** Get a line with reversed direction.
-     * @return a new instance, with reversed direction
-     */
-    public Line revert() {
-        final Line reverted = new Line(this);
-        reverted.direction = reverted.direction.negate();
-        return reverted;
-    }
-
-    /** Get the normalized direction vector.
-     * @return normalized direction vector
-     */
-    public Vector3D getDirection() {
-        return direction;
-    }
-
-    /** Get the line point closest to the origin.
-     * @return line point closest to the origin
-     */
-    public Vector3D getOrigin() {
-        return zero;
-    }
-
-    /** Get the abscissa of a point with respect to the line.
-     * <p>The abscissa is 0 if the projection of the point and the
-     * projection of the frame origin on the line are the same
-     * point.</p>
-     * @param point point to check
-     * @return abscissa of the point
-     */
-    public double getAbscissa(final Vector3D point) {
-        return point.subtract(zero).dot(direction);
-    }
-
-    /** Get one point from the line.
-     * @param abscissa desired abscissa for the point
-     * @return one point belonging to the line, at specified abscissa
-     */
-    public Vector3D pointAt(final double abscissa) {
-        return Vector3D.linearCombination(1.0, zero, abscissa, direction);
-    }
-
-    /** Transform a space point into a sub-space point.
-     * @param point n-dimension point of the space
-     * @return (n-1)-dimension point of the sub-space corresponding to
-     * the specified space point
-     */
-    @Override
-    public Vector1D toSubSpace(final Vector3D point) {
-        return Vector1D.of(getAbscissa(point));
-    }
-
-    /** Transform a sub-space point into a space point.
-     * @param point (n-1)-dimension point of the sub-space
-     * @return n-dimension point of the space corresponding to the
-     * specified sub-space point
-     */
-    @Override
-    public Vector3D toSpace(final Vector1D point) {
-        return pointAt(point.getX());
-    }
-
-    /** Check if the instance is similar to another line.
-     * <p>Lines are considered similar if they contain the same
-     * points. This does not mean they are equal since they can have
-     * opposite directions.</p>
-     * @param line line to which instance should be compared
-     * @return true if the lines are similar
-     */
-    public boolean isSimilarTo(final Line line) {
-        final double angle = direction.angle(line.direction);
-        return (precision.eqZero(angle) || precision.eq(angle, Math.PI)) && contains(line.zero);
-    }
-
-    /** Check if the instance contains a point.
-     * @param p point to check
-     * @return true if p belongs to the line
-     */
-    public boolean contains(final Vector3D p) {
-        return precision.eqZero(distance(p));
-    }
-
-    /** Compute the distance between the instance and a point.
-     * @param p to check
-     * @return distance between the instance and the point
-     */
-    public double distance(final Vector3D p) {
-        final Vector3D d = p.subtract(zero);
-        final Vector3D n = Vector3D.linearCombination(1.0, d, -d.dot(direction), direction);
-        return n.norm();
-    }
-
-    /** Compute the shortest distance between the instance and another line.
-     * @param line line to check against the instance
-     * @return shortest distance between the instance and the line
-     */
-    public double distance(final Line line) {
-
-        final Vector3D normal = direction.cross(line.direction);
-        final double n = normal.norm();
-        if (n < Precision.SAFE_MIN) {
-            // lines are parallel
-            return distance(line.zero);
-        }
-
-        // signed separation of the two parallel planes that contains the lines
-        final double offset = line.zero.subtract(zero).dot(normal) / n;
-
-        return Math.abs(offset);
-
-    }
-
-    /** Compute the point of the instance closest to another line.
-     * @param line line to check against the instance
-     * @return point of the instance closest to another line
-     */
-    public Vector3D closestPoint(final Line line) {
-
-        final double cos = direction.dot(line.direction);
-        final double n = 1 - cos * cos;
-        if (n < Precision.EPSILON) {
-            // the lines are parallel
-            return zero;
-        }
-
-        final Vector3D delta0 = line.zero.subtract(zero);
-        final double a        = delta0.dot(direction);
-        final double b        = delta0.dot(line.direction);
-
-        return Vector3D.linearCombination(1, zero, (a - b * cos) / n, direction);
-
-    }
-
-    /** Get the intersection point of the instance and another line.
-     * @param line other line
-     * @return intersection point of the instance and the other line
-     * or null if there are no intersection points
-     */
-    public Vector3D intersection(final Line line) {
-        final Vector3D closest = closestPoint(line);
-        return line.contains(closest) ? closest : null;
-    }
-
-    /** Build a sub-line covering the whole line.
-     * @return a sub-line covering the whole line
-     */
-    public SubLine wholeLine() {
-        return new SubLine(this, new IntervalsSet(precision));
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public int hashCode() {
-        return Objects.hash(direction, precision, zero);
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public boolean equals(Object obj) {
-        if (this == obj) {
-            return true;
-        }
-        if (obj == null) {
-            return false;
-        }
-        if (getClass() != obj.getClass()) {
-            return false;
-        }
-        Line other = (Line) obj;
-        return this.direction.equals(other.direction, precision) && this.zero.equals(other.zero, precision);
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public String toString() {
-        return "Line [direction=" + direction + ", zero=" + zero + "]";
-    }
-}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Line3D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Line3D.java
new file mode 100644
index 0000000..883b63e
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Line3D.java
@@ -0,0 +1,432 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.threed;
+
+import java.io.Serializable;
+import java.util.Objects;
+
+import org.apache.commons.geometry.core.Embedding;
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.oned.AffineTransformMatrix1D;
+import org.apache.commons.geometry.euclidean.oned.Interval;
+import org.apache.commons.geometry.euclidean.oned.Vector1D;
+
+/** Class representing a line in 3D space.
+ *
+ * <p>Instances of this class are guaranteed to be immutable.</p>
+ */
+public final class Line3D implements Embedding<Vector3D, Vector1D>, Serializable {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20190704L;
+
+    /** Line point closest to the origin. */
+    private final Vector3D origin;
+
+    /** Line direction. */
+    private final Vector3D direction;
+
+    /** Precision context used to compare floating point numbers. */
+    private final DoublePrecisionContext precision;
+
+    /** Simple constructor.
+     * @param origin the origin of the line, meaning the point on the line closest to the origin of the
+     *      3D space
+     * @param direction the direction of the line
+     * @param precision precision context used to compare floating point numbers
+     */
+    private Line3D(final Vector3D origin, final Vector3D direction, final DoublePrecisionContext precision) {
+        this.origin = origin;
+        this.direction = direction;
+        this.precision = precision;
+    }
+
+    /** Get the line point closest to the origin.
+     * @return line point closest to the origin
+     */
+    public Vector3D getOrigin() {
+        return origin;
+    }
+
+    /** Get the normalized direction vector.
+     * @return normalized direction vector
+     */
+    public Vector3D getDirection() {
+        return direction;
+    }
+
+    /** Get the object used to determine floating point equality for this instance.
+     * @return the floating point precision context for the instance
+     */
+    public DoublePrecisionContext getPrecision() {
+        return precision;
+    }
+
+    /** Return a line containing the same points as this instance but pointing
+     * in the opposite direction.
+     * @return an instance containing the same points but pointing in the opposite
+     *      direction
+     */
+    public Line3D reverse() {
+        return new Line3D(origin, direction.negate(), precision);
+    }
+
+    /** Transform this instance.
+     * @param transform object used to transform the instance
+     * @return a transformed instance
+     */
+    public Line3D transform(final Transform<Vector3D> transform) {
+        final Vector3D p1 = transform.apply(origin);
+        final Vector3D p2 = transform.apply(origin.add(direction));
+
+        return fromPoints(p1, p2, precision);
+    }
+
+    /** Get an object containing the current line transformed by the argument along with a
+     * 1D transform that can be applied to subspace points. The subspace transform transforms
+     * subspace points such that their 3D location in the transformed line is the same as their
+     * 3D location in the original line after the 3D transform is applied. For example, consider
+     * the code below:
+     * <pre>
+     *      SubspaceTransform st = line.subspaceTransform(transform);
+     *
+     *      Vector1D subPt = Vector1D.of(1);
+     *
+     *      Vector3D a = transform.apply(line.toSpace(subPt)); // transform in 3D space
+     *      Vector3D b = st.getLine().toSpace(st.getTransform().apply(subPt)); // transform in 1D space
+     * </pre>
+     * At the end of execution, the points {@code a} (which was transformed using the original
+     * 3D transform) and {@code b} (which was transformed in 1D using the subspace transform)
+     * are equivalent.
+     *
+     * @param transform the transform to apply to this instance
+     * @return an object containing the transformed line along with a transform that can be applied
+     *      to subspace points
+     * @see #transform(Transform)
+     */
+    public SubspaceTransform subspaceTransform(final Transform<Vector3D> transform) {
+        final Vector3D p1 = transform.apply(origin);
+        final Vector3D p2 = transform.apply(origin.add(direction));
+
+        final Line3D tLine = fromPoints(p1, p2, precision);
+
+        final Vector1D tSubspaceOrigin = tLine.toSubspace(p1);
+        final Vector1D tSubspaceDirection = tSubspaceOrigin.vectorTo(tLine.toSubspace(p2));
+
+        final double translation = tSubspaceOrigin.getX();
+        final double scale = tSubspaceDirection.getX();
+
+        final AffineTransformMatrix1D subspaceTransform = AffineTransformMatrix1D.of(scale, translation);
+
+        return new SubspaceTransform(tLine, subspaceTransform);
+    }
+
+    /** Get the abscissa of the given point on the line. The abscissa represents
+     * the distance the projection of the point on the line is from the line's
+     * origin point (the point on the line closest to the origin of the
+     * 2D space). Abscissa values increase in the direction of the line. This method
+     * is exactly equivalent to {@link #toSubspace(Vector3D)} except that this method
+     * returns a double instead of a {@link Vector1D}.
+     * @param point point to compute the abscissa for
+     * @return abscissa value of the point
+     * @see #toSubspace(Vector3D)
+     */
+    public double abscissa(final Vector3D point) {
+        return point.subtract(origin).dot(direction);
+    }
+
+    /** Get one point from the line.
+     * @param abscissa desired abscissa for the point
+     * @return one point belonging to the line, at specified abscissa
+     */
+    public Vector3D pointAt(final double abscissa) {
+        return Vector3D.linearCombination(1.0, origin, abscissa, direction);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Vector1D toSubspace(Vector3D pt) {
+        return Vector1D.of(abscissa(pt));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Vector3D toSpace(Vector1D pt) {
+        return toSpace(pt.getX());
+    }
+
+    /** Get the 3 dimensional point at the given abscissa position
+     * on the line.
+     * @param abscissa location on the line
+     * @return the 3 dimensional point at the given abscissa position
+     *      on the line
+     */
+    public Vector3D toSpace(final double abscissa) {
+        return pointAt(abscissa);
+    }
+
+    /** Check if the instance is similar to another line.
+     * <p>Lines are considered similar if they contain the same
+     * points. This does not mean they are equal since they can have
+     * opposite directions.</p>
+     * @param line line to which instance should be compared
+     * @return true if the lines are similar
+     */
+    public boolean isSimilarTo(final Line3D line) {
+        final double angle = direction.angle(line.direction);
+        return (precision.eqZero(angle) || precision.eq(Math.abs(angle), Math.PI)) &&
+                contains(line.origin);
+    }
+
+    /** Check if the instance contains a point.
+     * @param pt point to check
+     * @return true if p belongs to the line
+     */
+    public boolean contains(final Vector3D pt) {
+        return precision.eqZero(distance(pt));
+    }
+
+    /** Compute the distance between the instance and a point.
+     * @param pt to check
+     * @return distance between the instance and the point
+     */
+    public double distance(final Vector3D pt) {
+        final Vector3D delta = pt.subtract(origin);
+        final Vector3D orthogonal = delta.reject(direction);
+
+        return orthogonal.norm();
+    }
+
+    /** Compute the shortest distance between the instance and another line.
+     * @param line line to check against the instance
+     * @return shortest distance between the instance and the line
+     */
+    public double distance(final Line3D line) {
+
+        final Vector3D normal = direction.cross(line.direction);
+        final double norm = normal.norm();
+
+        if (precision.eqZero(norm)) {
+            // the lines are parallel
+            return distance(line.origin);
+        }
+
+        // signed separation of the two parallel planes that contains the lines
+        final double offset = line.origin.subtract(origin).dot(normal) / norm;
+
+        return Math.abs(offset);
+    }
+
+    /** Compute the point of the instance closest to another line.
+     * @param line line to check against the instance
+     * @return point of the instance closest to another line
+     */
+    public Vector3D closest(final Line3D line) {
+
+        final double cos = direction.dot(line.direction);
+        final double n = 1 - cos * cos;
+
+        if (precision.eqZero(n)) {
+            // the lines are parallel
+            return origin;
+        }
+
+        final Vector3D delta = line.origin.subtract(origin);
+        final double a = delta.dot(direction);
+        final double b = delta.dot(line.direction);
+
+        return Vector3D.linearCombination(1, origin, (a - (b * cos)) / n, direction);
+    }
+
+    /** Get the intersection point of the instance and another line.
+     * @param line other line
+     * @return intersection point of the instance and the other line
+     * or null if there are no intersection points
+     */
+    public Vector3D intersection(final Line3D line) {
+        final Vector3D closestPt = closest(line);
+        return line.contains(closestPt) ? closestPt : null;
+    }
+
+    /** Return a new infinite segment representing the entire line.
+     * @return a new infinite segment representing the entire line
+     */
+    public Segment3D span() {
+        return Segment3D.fromInterval(this, Interval.full());
+    }
+
+    /** Create a new line segment from the given interval.
+     * @param interval interval representing the 1D region for the line segment
+     * @return a new line segment on this line
+     */
+    public Segment3D segment(final Interval interval) {
+        return Segment3D.fromInterval(this, interval);
+    }
+
+    /** Create a new line segment from the given interval.
+     * @param a first 1D location for the interval
+     * @param b second 1D location for the interval
+     * @return a new line segment on this line
+     */
+    public Segment3D segment(final double a, final double b) {
+        return Segment3D.fromInterval(this, a, b);
+    }
+
+    /** Create a new line segment between the projections of the two
+     * given points onto this line.
+     * @param a first point
+     * @param b second point
+     * @return a new line segment on this line
+     */
+    public Segment3D segment(final Vector3D a, final Vector3D b) {
+        return Segment3D.fromInterval(this, toSubspace(a), toSubspace(b));
+    }
+
+    /** Create a new line segment that starts at infinity and continues along
+     * the line up to the projection of the given point.
+     * @param pt point defining the end point of the line segment; the end point
+     *      is equal to the projection of this point onto the line
+     * @return a new, half-open line segment
+     */
+    public Segment3D segmentTo(final Vector3D pt) {
+        return segment(Double.NEGATIVE_INFINITY, toSubspace(pt).getX());
+    }
+
+    /** Create a new line segment that starts at the projection of the given point
+     * and continues in the direction of the line to infinity, similar to a ray.
+     * @param pt point defining the start point of the line segment; the start point
+     *      is equal to the projection of this point onto the line
+     * @return a new, half-open line segment
+     */
+    public Segment3D segmentFrom(final Vector3D pt) {
+        return segment(toSubspace(pt).getX(), Double.POSITIVE_INFINITY);
+    }
+
+    /** Create a new, empty subline based on this line.
+     * @return a new, empty subline based on this line
+     */
+    public SubLine3D subline() {
+        return new SubLine3D(this);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public int hashCode() {
+        return Objects.hash(origin, direction, precision);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (!(obj instanceof Line3D)) {
+            return false;
+        }
+        Line3D other = (Line3D) obj;
+        return this.origin.equals(other.origin) &&
+                this.direction.equals(other.direction) &&
+                this.precision.equals(other.precision);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String toString() {
+        final StringBuilder sb = new StringBuilder();
+        sb.append(getClass().getSimpleName())
+            .append("[origin= ")
+            .append(origin)
+            .append(", direction= ")
+            .append(direction)
+            .append("]");
+
+        return sb.toString();
+    }
+
+    /** Create a new line instance from two points that lie on the line. The line
+     * direction points from the first point to the second point.
+     * @param p1 first point on the line
+     * @param p2 second point on the line
+     * @param precision floating point precision context
+     * @return a new line instance that contains both of the given point and that has
+     *      a direction going from the first point to the second point
+     * @throws org.apache.commons.geometry.core.exception.IllegalNormException if the points lie too close to
+     *      create a non-zero direction vector
+     */
+    public static Line3D fromPoints(final Vector3D p1, final Vector3D p2,
+            final DoublePrecisionContext precision) {
+        return fromPointAndDirection(p1, p1.directionTo(p2), precision);
+    }
+
+    /** Create a new line instance from a point and a direction.
+     * @param pt a point lying on the line
+     * @param direction the direction of the line
+     * @param precision floating point precision context
+     * @return a new line instance that contains the given point and points in the
+     *      given direction
+     * @throws org.apache.commons.geometry.core.exception.IllegalNormException if the direction cannot be normalized
+     */
+    public static Line3D fromPointAndDirection(final Vector3D pt, final Vector3D direction,
+            final DoublePrecisionContext precision) {
+
+        final Vector3D normDirection = direction.normalize();
+        final Vector3D origin = pt.reject(normDirection);
+
+        return new Line3D(origin, normDirection, precision);
+    }
+
+    /** Class containing a transformed line instance along with a subspace (1D) transform. The subspace
+     * transform produces the equivalent of the 3D transform in 1D.
+     */
+    public static final class SubspaceTransform implements Serializable {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20190809L;
+
+        /** The transformed line. */
+        private final Line3D line;
+
+        /** The subspace transform instance. */
+        private final AffineTransformMatrix1D transform;
+
+        /** Simple constructor.
+         * @param line the transformed line
+         * @param transform 1D transform that can be applied to subspace points
+         */
+        public SubspaceTransform(final Line3D line, final AffineTransformMatrix1D transform) {
+            this.line = line;
+            this.transform = transform;
+        }
+
+        /** Get the transformed line instance.
+         * @return the transformed line instance
+         */
+        public Line3D getLine() {
+            return line;
+        }
+
+        /** Get the 1D transform that can be applied to subspace points. This transform can be used
+         * to perform the equivalent of the 3D transform in 1D space.
+         * @return the subspace transform instance
+         */
+        public AffineTransformMatrix1D getTransform() {
+            return transform;
+        }
+    }
+}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/OutlineExtractor.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/OutlineExtractor.java
deleted file mode 100644
index 48c8181..0000000
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/OutlineExtractor.java
+++ /dev/null
@@ -1,263 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.threed;
-
-import java.util.ArrayList;
-
-import org.apache.commons.geometry.core.partitioning.AbstractSubHyperplane;
-import org.apache.commons.geometry.core.partitioning.BSPTree;
-import org.apache.commons.geometry.core.partitioning.BSPTreeVisitor;
-import org.apache.commons.geometry.core.partitioning.BoundaryAttribute;
-import org.apache.commons.geometry.core.partitioning.RegionFactory;
-import org.apache.commons.geometry.core.partitioning.SubHyperplane;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.geometry.euclidean.twod.PolygonsSet;
-import org.apache.commons.geometry.euclidean.twod.Vector2D;
-
-/** Extractor for {@link PolygonsSet polyhedrons sets} outlines.
- * <p>This class extracts the 2D outlines from {{@link PolygonsSet
- * polyhedrons sets} in a specified projection plane.</p>
- */
-public class OutlineExtractor {
-
-    /** Abscissa axis of the projection plane. */
-    private final Vector3D u;
-
-    /** Ordinate axis of the projection plane. */
-    private final Vector3D v;
-
-    /** Normal of the projection plane (viewing direction). */
-    private final Vector3D w;
-
-    /** Build an extractor for a specific projection plane.
-     * @param u abscissa axis of the projection point
-     * @param v ordinate axis of the projection point
-     */
-    public OutlineExtractor(final Vector3D u, final Vector3D v) {
-        this.u = u;
-        this.v = v;
-        this.w = u.cross(v);
-    }
-
-    /** Extract the outline of a polyhedrons set.
-     * @param polyhedronsSet polyhedrons set whose outline must be extracted
-     * @return an outline, as an array of loops.
-     */
-    public Vector2D[][] getOutline(final PolyhedronsSet polyhedronsSet) {
-
-        // project all boundary facets into one polygons set
-        final BoundaryProjector projector = new BoundaryProjector(polyhedronsSet.getPrecision());
-        polyhedronsSet.getTree(true).visit(projector);
-        final PolygonsSet projected = projector.getProjected();
-
-        // Remove the spurious intermediate vertices from the outline
-        final Vector2D[][] outline = projected.getVertices();
-        for (int i = 0; i < outline.length; ++i) {
-            final Vector2D[] rawLoop = outline[i];
-            int end = rawLoop.length;
-            int j = 0;
-            while (j < end) {
-                if (pointIsBetween(rawLoop, end, j)) {
-                    // the point should be removed
-                    for (int k = j; k < (end - 1); ++k) {
-                        rawLoop[k] = rawLoop[k + 1];
-                    }
-                    --end;
-                } else {
-                    // the point remains in the loop
-                    ++j;
-                }
-            }
-            if (end != rawLoop.length) {
-                // resize the array
-                outline[i] = new Vector2D[end];
-                System.arraycopy(rawLoop, 0, outline[i], 0, end);
-            }
-        }
-
-        return outline;
-
-    }
-
-    /** Check if a point is geometrically between its neighbor in an array.
-     * <p>The neighbors are computed considering the array is a loop
-     * (i.e. point at index (n-1) is before point at index 0)</p>
-     * @param loop points array
-     * @param n number of points to consider in the array
-     * @param i index of the point to check (must be between 0 and n-1)
-     * @return true if the point is exactly between its neighbors
-     */
-    private boolean pointIsBetween(final Vector2D[] loop, final int n, final int i) {
-        final Vector2D previous = loop[(i + n - 1) % n];
-        final Vector2D current  = loop[i];
-        final Vector2D next     = loop[(i + 1) % n];
-        final double dx1       = current.getX() - previous.getX();
-        final double dy1       = current.getY() - previous.getY();
-        final double dx2       = next.getX()    - current.getX();
-        final double dy2       = next.getY()    - current.getY();
-        final double cross     = dx1 * dy2 - dx2 * dy1;
-        final double dot       = dx1 * dx2 + dy1 * dy2;
-        final double d1d2      = Math.sqrt((dx1 * dx1 + dy1 * dy1) * (dx2 * dx2 + dy2 * dy2));
-        return (Math.abs(cross) <= (1.0e-6 * d1d2)) && (dot >= 0.0);
-    }
-
-    /** Visitor projecting the boundary facets on a plane. */
-    private class BoundaryProjector implements BSPTreeVisitor<Vector3D> {
-
-        /** Projection of the polyhedrons set on the plane. */
-        private PolygonsSet projected;
-
-        /** Precision context used to compare floating point numbers. */
-        private final DoublePrecisionContext precision;
-
-        /** Simple constructor.
-         * @param precision precision context used to compare floating point values
-         */
-        BoundaryProjector(final DoublePrecisionContext precision) {
-            this.projected = new PolygonsSet(new BSPTree<Vector2D>(Boolean.FALSE), precision);
-            this.precision = precision;
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public Order visitOrder(final BSPTree<Vector3D> node) {
-            return Order.MINUS_SUB_PLUS;
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public void visitInternalNode(final BSPTree<Vector3D> node) {
-            @SuppressWarnings("unchecked")
-            final BoundaryAttribute<Vector3D> attribute =
-                (BoundaryAttribute<Vector3D>) node.getAttribute();
-            if (attribute.getPlusOutside() != null) {
-                addContribution(attribute.getPlusOutside(), false);
-            }
-            if (attribute.getPlusInside() != null) {
-                addContribution(attribute.getPlusInside(), true);
-            }
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public void visitLeafNode(final BSPTree<Vector3D> node) {
-        }
-
-        /** Add he contribution of a boundary facet.
-         * @param facet boundary facet
-         * @param reversed if true, the facet has the inside on its plus side
-         */
-        private void addContribution(final SubHyperplane<Vector3D> facet, final boolean reversed) {
-
-            // extract the vertices of the facet
-            @SuppressWarnings("unchecked")
-            final AbstractSubHyperplane<Vector3D, Vector2D> absFacet =
-                (AbstractSubHyperplane<Vector3D, Vector2D>) facet;
-            final Plane plane    = (Plane) facet.getHyperplane();
-
-            final double scal = plane.getNormal().dot(w);
-            if (Math.abs(scal) > 1.0e-3) {
-                Vector2D[][] vertices =
-                    ((PolygonsSet) absFacet.getRemainingRegion()).getVertices();
-
-                if ((scal < 0) ^ reversed) {
-                    // the facet is seen from the inside,
-                    // we need to invert its boundary orientation
-                    final Vector2D[][] newVertices = new Vector2D[vertices.length][];
-                    for (int i = 0; i < vertices.length; ++i) {
-                        final Vector2D[] loop = vertices[i];
-                        final Vector2D[] newLoop = new Vector2D[loop.length];
-                        if (loop[0] == null) {
-                            newLoop[0] = null;
-                            for (int j = 1; j < loop.length; ++j) {
-                                newLoop[j] = loop[loop.length - j];
-                            }
-                        } else {
-                            for (int j = 0; j < loop.length; ++j) {
-                                newLoop[j] = loop[loop.length - (j + 1)];
-                            }
-                        }
-                        newVertices[i] = newLoop;
-                    }
-
-                    // use the reverted vertices
-                    vertices = newVertices;
-
-                }
-
-                // compute the projection of the facet in the outline plane
-                final ArrayList<SubHyperplane<Vector2D>> edges = new ArrayList<>();
-                for (Vector2D[] loop : vertices) {
-                    final boolean closed = loop[0] != null;
-                    int previous         = closed ? (loop.length - 1) : 1;
-                    Vector3D previous3D  = plane.toSpace(loop[previous]);
-                    int current          = (previous + 1) % loop.length;
-                    Vector2D pPoint       = Vector2D.of(previous3D.dot(u),
-                                                         previous3D.dot(v));
-                    while (current < loop.length) {
-
-                        final Vector3D current3D = plane.toSpace(loop[current]);
-                        final Vector2D  cPoint    = Vector2D.of(current3D.dot(u),
-                                                                 current3D.dot(v));
-                        final org.apache.commons.geometry.euclidean.twod.Line line =
-                            org.apache.commons.geometry.euclidean.twod.Line.fromPoints(pPoint, cPoint, precision);
-                        SubHyperplane<Vector2D> edge = line.wholeHyperplane();
-
-                        if (closed || (previous != 1)) {
-                            // the previous point is a real vertex
-                            // it defines one bounding point of the edge
-                            final double angle = line.getAngle() + 0.5 * Math.PI;
-                            final org.apache.commons.geometry.euclidean.twod.Line l =
-                                org.apache.commons.geometry.euclidean.twod.Line.fromPointAndAngle(pPoint, angle, precision);
-                            edge = edge.split(l).getPlus();
-                        }
-
-                        if (closed || (current != (loop.length - 1))) {
-                            // the current point is a real vertex
-                            // it defines one bounding point of the edge
-                            final double angle = line.getAngle() + 0.5 * Math.PI;
-                            final org.apache.commons.geometry.euclidean.twod.Line l =
-                                org.apache.commons.geometry.euclidean.twod.Line.fromPointAndAngle(cPoint, angle, precision);
-                            edge = edge.split(l).getMinus();
-                        }
-
-                        edges.add(edge);
-
-                        previous   = current++;
-                        previous3D = current3D;
-                        pPoint     = cPoint;
-
-                    }
-                }
-                final PolygonsSet projectedFacet = new PolygonsSet(edges, precision);
-
-                // add the contribution of the facet to the global outline
-                projected = (PolygonsSet) new RegionFactory<Vector2D>().union(projected, projectedFacet);
-
-            }
-        }
-
-        /** Get the projection of the polyhedrons set on the plane.
-         * @return projection of the polyhedrons set on the plane
-         */
-        public PolygonsSet getProjected() {
-            return projected;
-        }
-
-    }
-
-}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Plane.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Plane.java
index a253d9b..48597ae 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Plane.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Plane.java
@@ -16,25 +16,32 @@
  */
 package org.apache.commons.geometry.euclidean.threed;
 
+import java.io.Serializable;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Iterator;
 import java.util.Objects;
 
+import org.apache.commons.geometry.core.Transform;
 import org.apache.commons.geometry.core.exception.GeometryException;
-import org.apache.commons.geometry.core.exception.IllegalNormException;
-import org.apache.commons.geometry.core.partitioning.Embedding;
+import org.apache.commons.geometry.core.internal.Equivalency;
+import org.apache.commons.geometry.core.partitioning.AbstractHyperplane;
+import org.apache.commons.geometry.core.partitioning.EmbeddingHyperplane;
 import org.apache.commons.geometry.core.partitioning.Hyperplane;
 import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
 import org.apache.commons.geometry.euclidean.oned.Vector1D;
 import org.apache.commons.geometry.euclidean.threed.rotation.QuaternionRotation;
-import org.apache.commons.geometry.euclidean.twod.PolygonsSet;
+import org.apache.commons.geometry.euclidean.twod.AffineTransformMatrix2D;
+import org.apache.commons.geometry.euclidean.twod.ConvexArea;
 import org.apache.commons.geometry.euclidean.twod.Vector2D;
 
-/**
- * The class represents a plane in a three dimensional space.
+/** Class representing a plane in 3 dimensional Euclidean space.
  */
-public final class Plane implements Hyperplane<Vector3D>, Embedding<Vector3D, Vector2D> {
+public final class Plane extends AbstractHyperplane<Vector3D>
+    implements EmbeddingHyperplane<Vector3D, Vector2D>, Equivalency<Plane> {
+
+    /** Serializable version UID. */
+    private static final long serialVersionUID = 20190702L;
 
     /** First normalized vector of the plane frame (in plane). */
     private final Vector3D u;
@@ -48,9 +55,6 @@
     /** Offset of the origin with respect to the plane. */
     private final double originOffset;
 
-    /** Precision context used to compare floating point numbers. */
-    private final DoublePrecisionContext precision;
-
     /**
      * Constructor to build a new plane with the given values.
      * Made private to prevent inheritance.
@@ -59,196 +63,17 @@
      * @param w unit normal vector
      * @param originOffset offset of the origin with respect to the plane.
      * @param precision precision context used to compare floating point values
-     * @throws IllegalArgumentException if the provided vectors are coplanar or not normalized
      */
     private Plane(final Vector3D u, final Vector3D v, final Vector3D w, double originOffset,
             final DoublePrecisionContext precision) {
+
+        super(precision);
+
         this.u = u;
         this.v = v;
         this.w = w;
-        if (areCoplanar(u, v, w, precision)) {
-            throw new IllegalArgumentException("Provided vectors must not be coplanar.");
-        }
+
         this.originOffset = originOffset;
-        this.precision = precision;
-    }
-
-    /**
-     * Build a plane from a point and two (on plane) vectors.
-     * @param p the provided point (on plane)
-     * @param u u vector (on plane)
-     * @param v v vector (on plane)
-     * @param precision precision context used to compare floating point values
-     * @return a new plane
-     * @throws IllegalNormException if the norm of the given values is zero, NaN, or infinite.
-     * @throws IllegalArgumentException if the provided vectors are collinear
-     */
-    public static Plane fromPointAndPlaneVectors (final Vector3D p, final Vector3D u, final Vector3D v, final DoublePrecisionContext precision) {
-        final Vector3D uNorm = u.normalize();
-        final Vector3D vNorm = uNorm.orthogonal(v);
-        final Vector3D wNorm = uNorm.cross(vNorm).normalize();
-        final double originOffset = -p.dot(wNorm);
-
-        return new Plane(uNorm, vNorm, wNorm, originOffset, precision);
-    }
-
-    /**
-     * Build a plane from a normal.
-     * Chooses origin as point on plane.
-     * @param normal normal direction to the plane
-     * @param precision precision context used to compare floating point values
-     * @return a new plane
-     * @throws IllegalNormException if the norm of the given values is zero, NaN, or infinite.
-     */
-    public static Plane fromNormal(final Vector3D normal, final DoublePrecisionContext precision) {
-        return fromPointAndNormal(Vector3D.ZERO, normal, precision);
-    }
-
-    /**
-     * Build a plane from a point and a normal.
-     *
-     * @param p point belonging to the plane
-     * @param normal normal direction to the plane
-     * @param precision precision context used to compare floating point values
-     * @return a new plane
-     * @throws IllegalNormException if the norm of the given values is zero, NaN, or infinite.
-     */
-    public static Plane fromPointAndNormal(final Vector3D p, final Vector3D normal, final DoublePrecisionContext precision) {
-        final Vector3D w = normal.normalize();
-        final double originOffset = -p.dot(w);
-        final Vector3D u = w.orthogonal();
-        final Vector3D v = w.cross(u);
-
-        return new Plane(u, v, w, originOffset, precision);
-    }
-
-    /**
-     * Build a plane from three points.
-     * <p>
-     * The plane is oriented in the direction of {@code (p2-p1) ^ (p3-p1)}
-     * </p>
-     *
-     * @param p1 first point belonging to the plane
-     * @param p2 second point belonging to the plane
-     * @param p3 third point belonging to the plane
-     * @param precision precision context used to compare floating point values
-     * @return a new plane
-     * @throws GeometryException if the points do not define a unique plane
-     */
-    public static Plane fromPoints(final Vector3D p1, final Vector3D p2, final Vector3D p3,
-            final DoublePrecisionContext precision) {
-        return Plane.fromPoints(Arrays.asList(p1, p2, p3), precision);
-    }
-
-    /** Construct a plane from a collection of points lying on the plane. The plane orientation is
-     * determined by the overall orientation of the point sequence. For example, if the points wind
-     * around the z-axis in a counter-clockwise direction, then the plane normal will point up the
-     * +z axis. If the points wind in the opposite direction, then the plane normal will point down
-     * the -z axis. The {@code u} vector for the plane is set to the first non-zero vector between
-     * points in the sequence (ie, the first direction in the path).
-     *
-     * @param pts collection of sequenced points lying on the plane
-     * @param precision precision context used to compare floating point values
-     * @return a new plane containing the given points
-     * @throws IllegalArgumentException if the given collection does not contain at least 3 points
-     * @throws GeometryException if the points do not define a unique plane
-     */
-    public static Plane fromPoints(final Collection<Vector3D> pts, final DoublePrecisionContext precision) {
-
-        if (pts.size() < 3) {
-            throw new IllegalArgumentException("At least 3 points are required to define a plane; " +
-                    "argument contains only " + pts.size() + ".");
-        }
-
-        final Iterator<Vector3D> it = pts.iterator();
-
-        Vector3D startPt = it.next();
-
-        Vector3D u = null;
-        Vector3D w = null;
-
-        Vector3D currentPt;
-
-        Vector3D currentVector = null;
-        Vector3D prevVector = null;
-
-        Vector3D cross = null;
-        double crossNorm;
-        double crossSumX = 0;
-        double crossSumY = 0;
-        double crossSumZ = 0;
-
-        boolean nonPlanar = false;
-
-        while (it.hasNext()) {
-            currentPt = it.next();
-            currentVector = startPt.vectorTo(currentPt);
-
-            if (!precision.eqZero(currentVector.norm())) {
-
-                if (u == null) {
-                    // save the first non-zero vector as our u vector
-                    u = currentVector.normalize();
-                }
-                if (prevVector != null) {
-                    cross = prevVector.cross(currentVector);
-
-                    crossSumX += cross.getX();
-                    crossSumY += cross.getY();
-                    crossSumZ += cross.getZ();
-
-                    crossNorm = cross.norm();
-
-                    if (!precision.eqZero(crossNorm)) {
-                        // the cross product has non-zero magnitude
-                        if (w == null) {
-                            // save the first non-zero cross product as our normal
-                            w = cross.normalize();
-                        }
-                        else if (!precision.eq(1.0, Math.abs(w.dot(cross) / crossNorm))) {
-                            // if the normalized dot product is not either +1 or -1, then
-                            // the points are not coplanar
-                            nonPlanar = true;
-                            break;
-                        }
-                    }
-                }
-
-                prevVector = currentVector;
-            }
-        }
-
-        if (u == null ||
-            w == null ||
-            nonPlanar) {
-            throw new GeometryException("Points do not define a plane: " + pts);
-        }
-
-        if (w.dot(Vector3D.of(crossSumX, crossSumY, crossSumZ)) < 0) {
-            w = w.negate();
-        }
-
-        final Vector3D v = w.cross(u);
-        final double originOffset = -startPt.dot(w);
-
-        return new Plane(u, v, w, originOffset, precision);
-    }
-
-    // This should be removed after GEOMETRY-32 is done.
-    /**
-     * Copy the instance.
-     * <p>
-     * The instance created is completely independent of the original one. A deep
-     * copy is used, none of the underlying objects are shared (except for immutable
-     * objects).
-     * </p>
-     *
-     * @return a new hyperplane, copy of the instance
-     */
-    @Deprecated
-    @Override
-    public Plane copySelf() {
-        return new Plane(this.u, this.v, this.w, this.originOffset, this.precision);
     }
 
     /**
@@ -261,7 +86,7 @@
     }
 
     /**
-     *  Get the offset of the origin with respect to the plane.
+     *  Get the offset of the spatial origin ({@code 0, 0, 0}) with respect to the plane.
      *
      *  @return the offset of the origin with respect to the plane.
      */
@@ -300,30 +125,33 @@
     }
 
     /**
-     * Get the normalized normal vector, alias for getNormal().
+     * Get the normalized normal vector.
      * <p>
-     * The frame defined by ({@link #getU getU}, {@link #getV getV},
-     * {@link #getNormal getNormal}) is a right-handed orthonormalized frame).
+     * The frame defined by {@link #getU()}, {@link #getV()},
+     * {@link #getW()} is a right-handed orthonormalized frame.
      * </p>
      *
      * @return normalized normal vector
-     * @see #getU
-     * @see #getV
+     * @see #getU()
+     * @see #getV()
+     * @see #getNormal()
      */
     public Vector3D getW() {
         return w;
     }
 
     /**
-     * Get the normalized normal vector.
+     * Get the normalized normal vector. This method is an alias
+     * for {@link #getW()}.
      * <p>
-     * The frame defined by ({@link #getU getU}, {@link #getV getV},
-     * {@link #getNormal getNormal}) is a right-handed orthonormalized frame).
+     * The frame defined by {@link #getU()}, {@link #getV()},
+     * {@link #getW()} is a right-handed orthonormalized frame.
      * </p>
      *
      * @return normalized normal vector
-     * @see #getU
-     * @see #getV
+     * @see #getU()
+     * @see #getV()
+     * @see #getW()
      */
     public Vector3D getNormal() {
         return w;
@@ -332,28 +160,23 @@
     /** {@inheritDoc} */
     @Override
     public Vector3D project(final Vector3D point) {
-        return toSpace(toSubSpace(point));
+        return toSpace(toSubspace(point));
     }
 
     /**
-     * 3D line projected onto plane
+     * Project a 3D line onto the plane.
      * @param line the line to project
      * @return the projection of the given line onto the plane.
      */
-    public Line project(final Line line) {
+    public Line3D project(final Line3D line) {
         Vector3D direction = line.getDirection();
-        Vector3D projection = w.multiply(direction.dot(w) * (1/w.normSq()));
+        Vector3D projection = w.multiply(direction.dot(w) * (1 / w.normSq()));
+
         Vector3D projectedLineDirection = direction.subtract(projection);
         Vector3D p1 = project(line.getOrigin());
         Vector3D p2 = p1.add(projectedLineDirection);
 
-        return new Line(p1,p2, precision);
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public DoublePrecisionContext getPrecision() {
-        return precision;
+        return Line3D.fromPoints(p1, p2, getPrecision());
     }
 
     /**
@@ -369,14 +192,9 @@
      * </p>
      * @return a new reversed plane
      */
+    @Override
     public Plane reverse() {
-        final Vector3D tmp = u;
-        Vector3D uTmp = v;
-        Vector3D vTmp = tmp;
-        Vector3D wTmp = this.w.negate();
-        double originOffsetTmp = -this.originOffset;
-
-        return new Plane(uTmp, vTmp, wTmp, originOffsetTmp, this.precision);
+        return new Plane(v, u, w.negate(), -originOffset, getPrecision());
     }
 
     /**
@@ -387,7 +205,7 @@
      * @see #toSpace
      */
     @Override
-    public Vector2D toSubSpace(final Vector3D point) {
+    public Vector2D toSubspace(final Vector3D point) {
         return Vector2D.of(point.dot(u), point.dot(v));
     }
 
@@ -396,13 +214,107 @@
      *
      * @param point in-plane point (must be a {@link Vector2D} instance)
      * @return 3D space point
-     * @see #toSubSpace
+     * @see #toSubspace(Vector3D)
      */
     @Override
     public Vector3D toSpace(final Vector2D point) {
         return Vector3D.linearCombination(point.getX(), u, point.getY(), v, -originOffset, w);
     }
 
+    /** {@inheritDoc} */
+    @Override
+    public Plane transform(final Transform<Vector3D> transform) {
+        final Vector3D origin = getOrigin();
+
+        final Vector3D p1 = transform.apply(origin);
+        final Vector3D p2 = transform.apply(origin.add(u));
+        final Vector3D p3 = transform.apply(origin.add(v));
+
+        return fromPoints(p1, p2, p3, getPrecision());
+    }
+
+    /** Get an object containing the current plane transformed by the argument along with a
+     * 2D transform that can be applied to subspace points. The subspace transform transforms
+     * subspace points such that their 3D location in the transformed plane is the same as their
+     * 3D location in the original plane after the 3D transform is applied. For example, consider
+     * the code below:
+     * <pre>
+     *      SubspaceTransform st = plane.subspaceTransform(transform);
+     *
+     *      Vector2D subPt = Vector2D.of(1, 1);
+     *
+     *      Vector3D a = transform.apply(plane.toSpace(subPt)); // transform in 3D space
+     *      Vector3D b = st.getPlane().toSpace(st.getTransform().apply(subPt)); // transform in 2D space
+     * </pre>
+     * At the end of execution, the points {@code a} (which was transformed using the original
+     * 3D transform) and {@code b} (which was transformed in 2D using the subspace transform)
+     * are equivalent.
+     *
+     * @param transform the transform to apply to this instance
+     * @return an object containing the transformed plane along with a transform that can be applied
+     *      to subspace points
+     * @see #transform(Transform)
+     */
+    public SubspaceTransform subspaceTransform(final Transform<Vector3D> transform) {
+        final Vector3D origin = getOrigin();
+
+        final Vector3D p1 = transform.apply(origin);
+        final Vector3D p2 = transform.apply(origin.add(u));
+        final Vector3D p3 = transform.apply(origin.add(v));
+
+        final Plane tPlane = fromPoints(p1, p2, p3, getPrecision());
+
+        final Vector2D tSubspaceOrigin = tPlane.toSubspace(p1);
+        final Vector2D tSubspaceU = tSubspaceOrigin.vectorTo(tPlane.toSubspace(p2));
+        final Vector2D tSubspaceV = tSubspaceOrigin.vectorTo(tPlane.toSubspace(p3));
+
+        final AffineTransformMatrix2D subspaceTransform =
+                AffineTransformMatrix2D.fromColumnVectors(tSubspaceU, tSubspaceV, tSubspaceOrigin);
+
+        return new SubspaceTransform(tPlane, subspaceTransform);
+    }
+
+    /**
+     * Rotate the plane around the specified point.
+     * <p>
+     * The instance is not modified, a new instance is created.
+     * </p>
+     *
+     * @param center   rotation center
+     * @param rotation 3-dimensional rotation
+     * @return a new plane
+     */
+    public Plane rotate(final Vector3D center, final QuaternionRotation rotation) {
+        final Vector3D delta = getOrigin().subtract(center);
+        final Vector3D p = center.add(rotation.apply(delta));
+        final Vector3D normal = rotation.apply(this.w);
+        final Vector3D wTmp = normal.normalize();
+
+        final double originOffsetTmp = -p.dot(wTmp);
+        final Vector3D uTmp = rotation.apply(this.u);
+        final Vector3D vTmp = rotation.apply(this.v);
+
+        return new Plane(uTmp, vTmp, wTmp, originOffsetTmp, getPrecision());
+    }
+
+    /**
+     * Translate the plane by the specified amount.
+     * <p>
+     * The instance is not modified, a new instance is created.
+     * </p>
+     *
+     * @param translation translation to apply
+     * @return a new plane
+     */
+    public Plane translate(final Vector3D translation) {
+        Vector3D p = getOrigin().add(translation);
+        Vector3D normal = this.w;
+        Vector3D wTmp = normal.normalize();
+        double originOffsetTmp = -p.dot(wTmp);
+
+        return new Plane(this.u, this.v, wTmp, originOffsetTmp, getPrecision());
+    }
+
     /**
      * Get one point from the 3D-space.
      *
@@ -427,53 +339,12 @@
      */
     public boolean contains(final Plane plane) {
         final double angle = w.angle(plane.w);
+        final DoublePrecisionContext precision = getPrecision();
 
         return ((precision.eqZero(angle)) && precision.eq(originOffset, plane.originOffset)) ||
                 ((precision.eq(angle, Math.PI)) && precision.eq(originOffset, -plane.originOffset));
     }
 
-
-
-    /**
-     * Rotate the plane around the specified point.
-     * <p>
-     * The instance is not modified, a new instance is created.
-     * </p>
-     *
-     * @param center   rotation center
-     * @param rotation 3-dimensional rotation
-     * @return a new plane
-     */
-    public Plane rotate(final Vector3D center, final QuaternionRotation rotation) {
-        final Vector3D delta = getOrigin().subtract(center);
-        Vector3D p = center.add(rotation.apply(delta));
-        Vector3D normal = rotation.apply(this.w);
-        Vector3D wTmp = normal.normalize();
-        double originOffsetTmp = -p.dot(wTmp);
-        Vector3D uTmp = rotation.apply(this.u);
-        Vector3D vTmp = rotation.apply(this.v);
-
-        return new Plane(uTmp, vTmp, wTmp, originOffsetTmp, this.precision);
-    }
-
-    /**
-     * Translate the plane by the specified amount.
-     * <p>
-     * The instance is not modified, a new instance is created.
-     * </p>
-     *
-     * @param translation translation to apply
-     * @return a new plane
-     */
-    public Plane translate(final Vector3D translation) {
-        Vector3D p = getOrigin().add(translation);
-        Vector3D normal = this.w;
-        Vector3D wTmp = normal.normalize();
-        double originOffsetTmp = -p.dot(wTmp);
-
-        return new Plane(this.u, this.v, wTmp, originOffsetTmp, this.precision);
-    }
-
     /**
      * Get the intersection of a line with the instance.
      *
@@ -481,42 +352,47 @@
      * @return intersection point between between the line and the instance (null if
      *         the line is parallel to the instance)
      */
-    public Vector3D intersection(final Line line) {
+    public Vector3D intersection(final Line3D line) {
         final Vector3D direction = line.getDirection();
         final double dot = w.dot(direction);
-        if (precision.eqZero(dot)) {
+        if (getPrecision().eqZero(dot)) {
             return null;
         }
         final Vector3D point = line.toSpace(Vector1D.ZERO);
         final double k = -(originOffset + w.dot(point)) / dot;
-
         return Vector3D.linearCombination(1.0, point, k, direction);
     }
 
     /**
-     * Build the line shared by the instance and another plane.
+     * Get the line formed by the intersection of this instance with the given plane.
+     * The returned line lies in both planes and points in the direction of
+     * the cross product <code>n<sub>1</sub> x n<sub>2</sub></code>, where <code>n<sub>1</sub></code>
+     * is the normal of the current instance and <code>n<sub>2</sub></code> is the normal
+     * of the argument.
+     *
+     * <p>Null is returned if the planes are parallel.</p>
      *
      * @param other other plane
-     * @return line at the intersection of the instance and the other plane (really
-     *         a {@link Line Line} instance)
+     * @return line at the intersection of the instance and the other plane, or null
+     *      if no such line exists
      */
-    public Line intersection(final Plane other) {
+    public Line3D intersection(final Plane other) {
         final Vector3D direction = w.cross(other.w);
-        if (precision.eqZero(direction.norm())) {
+        if (getPrecision().eqZero(direction.norm())) {
             return null;
         }
-        final Vector3D point = intersection(this, other, Plane.fromNormal(direction, precision));
-
-        return new Line(point, point.add(direction), precision);
+        final Vector3D point = intersection(this, other, Plane.fromNormal(direction, getPrecision()));
+        return Line3D.fromPointAndDirection(point, direction, getPrecision());
     }
 
     /**
-     * Get the intersection point of three planes.
+     * Get the intersection point of three planes. Returns null if no unique intersection point
+     * exists (ie, there are no intersection points or an infinite number).
      *
      * @param plane1 first plane1
      * @param plane2 second plane2
      * @param plane3 third plane2
-     * @return intersection point of three planes, null if some planes are parallel
+     * @return intersection point of the three planes or null if no unique intersection point exists
      */
     public static Vector3D intersection(final Plane plane1, final Plane plane2, final Plane plane3) {
 
@@ -538,40 +414,27 @@
 
         // direct Cramer resolution of the linear system
         // (this is still feasible for a 3x3 system)
-        final double a23 = b2 * c3 - b3 * c2;
-        final double b23 = c2 * a3 - c3 * a2;
-        final double c23 = a2 * b3 - a3 * b2;
-        final double determinant = a1 * a23 + b1 * b23 + c1 * c23;
-        if (Math.abs(determinant) < 1.0e-10) {
+        final double a23 = (b2 * c3) - (b3 * c2);
+        final double b23 = (c2 * a3) - (c3 * a2);
+        final double c23 = (a2 * b3) - (a3 * b2);
+        final double determinant = (a1 * a23) + (b1 * b23) + (c1 * c23);
+
+        // use the precision context of the first plane to determine equality
+        if (plane1.getPrecision().eqZero(determinant)) {
             return null;
         }
 
         final double r = 1.0 / determinant;
-
         return Vector3D.of((-a23 * d1 - (c1 * b3 - c3 * b1) * d2 - (c2 * b1 - c1 * b2) * d3) * r,
                 (-b23 * d1 - (c3 * a1 - c1 * a3) * d2 - (c1 * a2 - c2 * a1) * d3) * r,
                 (-c23 * d1 - (b1 * a3 - b3 * a1) * d2 - (b2 * a1 - b1 * a2) * d3) * r);
+
     }
 
-    /**
-     * Build a region covering the whole hyperplane.
-     *
-     * @return a region covering the whole hyperplane
-     */
+    /** {@inheritDoc} */
     @Override
-    public SubPlane wholeHyperplane() {
-        return new SubPlane(this, new PolygonsSet(precision));
-    }
-
-    /**
-     * Build a region covering the whole space.
-     *
-     * @return a region containing the instance (really a {@link PolyhedronsSet
-     *         PolyhedronsSet} instance)
-     */
-    @Override
-    public PolyhedronsSet wholeSpace() {
-        return new PolyhedronsSet(precision);
+    public ConvexSubPlane span() {
+        return ConvexSubPlane.fromConvexArea(this, ConvexArea.full());
     }
 
     /**
@@ -580,33 +443,28 @@
      * @param p point to check
      * @return true if p belongs to the plane
      */
+    @Override
     public boolean contains(final Vector3D p) {
-        return precision.eqZero(getOffset(p));
+        return getPrecision().eqZero(offset(p));
     }
 
     /**
-     * Check if the instance contains a line
+     * Check if the instance contains a line.
      * @param line line to check
      * @return true if line is contained in this plane
      */
-    public boolean contains(final Line line) {
-        Vector3D origin = line.getOrigin();
-        Vector3D direction = line.getDirection();
-        return contains(origin) &&
-            areCoplanar(u, v, direction, precision);
+    public boolean contains(final Line3D line) {
+        return isParallel(line) && contains(line.getOrigin());
     }
 
-    /** Check, if the line is parallel to the instance.
+    /** Check if the line is parallel to the instance.
      * @param line line to check.
      * @return true if the line is parallel to the instance, false otherwise.
      */
-    public boolean isParallel(final Line line) {
-        final Vector3D direction = line.getDirection();
-        final double dot = w.dot(direction);
-        if (precision.eqZero(dot)) {
-            return true;
-        }
-        return false;
+    public boolean isParallel(final Line3D line) {
+        final double dot = w.dot(line.getDirection());
+
+        return getPrecision().eqZero(dot);
     }
 
     /** Check, if the plane is parallel to the instance.
@@ -614,82 +472,79 @@
      * @return true if the plane is parallel to the instance, false otherwise.
      */
     public boolean isParallel(final Plane plane) {
-        return precision.eqZero(w.cross(plane.w).norm());
-    }
-
-
-    /**
-     * Get the offset (oriented distance) of a parallel plane.
-     * <p>
-     * This method should be called only for parallel planes otherwise the result is
-     * not meaningful.
-     * </p>
-     * <p>
-     * The offset is 0 if both planes are the same, it is positive if the plane is
-     * on the plus side of the instance and negative if it is on the minus side,
-     * according to its natural orientation.
-     * </p>
-     *
-     * @param plane plane to check
-     * @return offset of the plane
-     */
-    public double getOffset(final Plane plane) {
-        return originOffset + (sameOrientationAs(plane) ?
-                               -plane.originOffset :
-                               plane.originOffset);
+        return getPrecision().eqZero(w.cross(plane.w).norm());
     }
 
     /**
-     *  Returns the distance of the given line to the plane instance.
-     *  Returns 0.0, if the line is not parallel to the plane instance.
-     * @param line to calculate the distance to the plane instance
-     * @return the distance or 0.0, if the line is not parallel to the plane instance.
+     * Get the offset (oriented distance) of the given plane with respect to this instance. The value
+     * closest to zero is returned, which will always be zero if the planes are not parallel.
+     * @param plane plane to calculate the offset of
+     * @return the offset of the plane with respect to this instance or 0.0 if the planes
+     *      are not parallel.
      */
-    public double getOffset(final Line line) {
-        if (!isParallel(line)) {
-            return 0;
+    public double offset(final Plane plane) {
+        if (!isParallel(plane)) {
+            return 0.0;
         }
-        return getOffset(line.getOrigin());
+        return originOffset + (similarOrientation(plane) ? -plane.originOffset : plane.originOffset);
     }
 
     /**
-     * Get the offset (oriented distance) of a point.
-     * <p>
-     * The offset is 0 if the point is on the underlying hyperplane, it is positive
-     * if the point is on one particular side of the hyperplane, and it is negative
-     * if the point is on the other side, according to the hyperplane natural
-     * orientation.
-     * </p>
-     *
-     * @param point point to check
-     * @return offset of the point
+     * Get the offset (oriented distance) of the given line with respect to the plane. The value
+     * closest to zero is returned, which will always be zero if the line is not parallel to the plane.
+     * @param line line to calculate the offset of
+     * @return the offset of the line with respect to the plane or 0.0 if the line
+     *      is not parallel to the plane.
      */
+    public double offset(final Line3D line) {
+        if (!isParallel(line)) {
+            return 0.0;
+        }
+        return offset(line.getOrigin());
+    }
+
+    /** {@inheritDoc} */
     @Override
-    public double getOffset(final Vector3D point) {
+    public double offset(final Vector3D point) {
         return point.dot(w) + originOffset;
     }
 
-    /**
-     * Check if the instance has the same orientation as another hyperplane.
-     *
-     * @param other other hyperplane to check against the instance
-     * @return true if the instance and the other hyperplane have the same
-     *         orientation
-     */
+    /** {@inheritDoc} */
     @Override
-    public boolean sameOrientationAs(final Hyperplane<Vector3D> other) {
+    public boolean similarOrientation(final Hyperplane<Vector3D> other) {
         return (((Plane) other).w).dot(w) > 0;
     }
 
+
+    /** {@inheritDoc}
+    *
+    * <p>Instances are considered equivalent if they
+    * <ul>
+    *   <li>contain equal {@link DoublePrecisionContext precision contexts},</li>
+    *   <li>have equivalent origins (as evaluated by the precision context), and</li>
+    *   <li>have equivalent {@code u} and {@code v} vectors (as evaluated by the precision context)</li>
+    * </ul>
+    * @param other the point to compare with
+    * @return true if this instance should be considered equivalent to the argument
+    */
     @Override
-    public String toString() {
-        return "Plane [u=" + u + ", v=" + v + ", w=" + w  + "]";
+    public boolean eq(Plane other) {
+        if (this == other) {
+            return true;
+        }
+
+        final DoublePrecisionContext precision = getPrecision();
+
+        return precision.equals(other.getPrecision()) &&
+                getOrigin().eq(other.getOrigin(), precision) &&
+                u.eq(other.u, precision) &&
+                v.eq(other.v, precision);
     }
 
     /** {@inheritDoc} */
     @Override
     public int hashCode() {
-        return Objects.hash(originOffset, u, v, w);
+        return Objects.hash(u, v, w, originOffset, getPrecision());
     }
 
     /** {@inheritDoc} */
@@ -697,27 +552,238 @@
     public boolean equals(Object obj) {
         if (this == obj) {
             return true;
-        }
-        if (obj == null) {
+        } else if (!(obj instanceof Plane)) {
             return false;
         }
-        if (getClass() != obj.getClass()) {
-            return false;
-        }
+
         Plane other = (Plane) obj;
-        return  Double.doubleToLongBits(originOffset) == Double.doubleToLongBits(other.originOffset) &&
-                Objects.equals(u, other.u) && Objects.equals(v, other.v) && Objects.equals(w, other.w);
+
+        return Objects.equals(this.u, other.u) &&
+                Objects.equals(this.v, other.v) &&
+                Objects.equals(this.w, other.w) &&
+                Double.compare(this.originOffset, other.originOffset) == 0 &&
+                Objects.equals(this.getPrecision(), other.getPrecision());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String toString() {
+        final StringBuilder sb = new StringBuilder();
+        sb.append(getClass().getSimpleName())
+            .append("[origin= ")
+            .append(getOrigin())
+            .append(", u= ")
+            .append(u)
+            .append(", v= ")
+            .append(v)
+            .append(", w= ")
+            .append(w)
+            .append(']');
+
+        return sb.toString();
     }
 
     /**
-     * Check if provided vectors are coplanar.
-     * @param u first vector
-     * @param v second vector
-     * @param w third vector
+     * Build a plane from a point and two (on plane) vectors.
+     * @param p the provided point (on plane)
+     * @param u u vector (on plane)
+     * @param v v vector (on plane)
      * @param precision precision context used to compare floating point values
-     * @return true if vectors are coplanar, false otherwise.
+     * @return a new plane
+     * @throws org.apache.commons.geometry.core.exception.IllegalNormException if the norm of the given
+     *      values is zero, NaN, or infinite.
      */
-    private static boolean areCoplanar(final Vector3D u, final Vector3D v, final Vector3D w, final DoublePrecisionContext precision) {
-        return precision.eqZero(u.dot(v.cross(w)));
+    public static Plane fromPointAndPlaneVectors(final Vector3D p, final Vector3D u, final Vector3D v,
+            final DoublePrecisionContext precision) {
+        Vector3D uNorm = u.normalize();
+        Vector3D vNorm = uNorm.orthogonal(v);
+        Vector3D wNorm = uNorm.cross(vNorm).normalize();
+        double originOffset = -p.dot(wNorm);
+
+        return new Plane(uNorm, vNorm, wNorm, originOffset, precision);
+    }
+
+    /**
+     * Build a plane from a normal.
+     * Chooses origin as point on plane.
+     * @param normal    normal direction to the plane
+     * @param precision precision context used to compare floating point values
+     * @return a new plane
+     * @throws org.apache.commons.geometry.core.exception.IllegalNormException if the norm of the given
+     *      values is zero, NaN, or infinite.
+     */
+    public static Plane fromNormal(final Vector3D normal, final DoublePrecisionContext precision) {
+        return fromPointAndNormal(Vector3D.ZERO, normal, precision);
+    }
+
+    /**
+     * Build a plane from a point and a normal.
+     *
+     * @param p         point belonging to the plane
+     * @param normal    normal direction to the plane
+     * @param precision precision context used to compare floating point values
+     * @return a new plane
+     * @throws org.apache.commons.geometry.core.exception.IllegalNormException if the norm of the given
+     *      values is zero, NaN, or infinite.
+     */
+    public static Plane fromPointAndNormal(final Vector3D p, final Vector3D normal,
+            final DoublePrecisionContext precision) {
+        Vector3D w = normal.normalize();
+        double originOffset = -p.dot(w);
+
+        Vector3D u = w.orthogonal();
+        Vector3D v = w.cross(u);
+
+        return new Plane(u, v, w, originOffset, precision);
+    }
+
+    /**
+     * Build a plane from three points.
+     * <p>
+     * The plane is oriented in the direction of {@code (p2-p1) ^ (p3-p1)}
+     * </p>
+     *
+     * @param p1        first point belonging to the plane
+     * @param p2        second point belonging to the plane
+     * @param p3        third point belonging to the plane
+     * @param precision precision context used to compare floating point values
+     * @return a new plane
+     * @throws GeometryException if the points do not define a unique plane
+     */
+    public static Plane fromPoints(final Vector3D p1, final Vector3D p2, final Vector3D p3,
+            final DoublePrecisionContext precision) {
+        return Plane.fromPoints(Arrays.asList(p1, p2, p3), precision);
+    }
+
+    /** Construct a plane from a collection of points lying on the plane. The plane orientation is
+     * determined by the overall orientation of the point sequence. For example, if the points wind
+     * around the z-axis in a counter-clockwise direction, then the plane normal will point up the
+     * +z axis. If the points wind in the opposite direction, then the plane normal will point down
+     * the -z axis. The {@code u} vector for the plane is set to the first non-zero vector between
+     * points in the sequence (ie, the first direction in the path).
+     *
+     * @param pts collection of sequenced points lying on the plane
+     * @param precision precision context used to compare floating point values
+     * @return a new plane containing the given points
+     * @throws IllegalArgumentException if the given collection does not contain at least 3 points
+     * @throws GeometryException if the points do not define a unique plane
+     */
+    public static Plane fromPoints(final Collection<Vector3D> pts, final DoublePrecisionContext precision) {
+
+        if (pts.size() < 3) {
+            throw new IllegalArgumentException("At least 3 points are required to define a plane; " +
+                    "argument contains only " + pts.size() + ".");
+        }
+
+        final Iterator<Vector3D> it = pts.iterator();
+
+        Vector3D startPt = it.next();
+
+        Vector3D u = null;
+        Vector3D w = null;
+
+        Vector3D currentPt;
+        Vector3D prevPt = startPt;
+
+        Vector3D currentVector = null;
+        Vector3D prevVector = null;
+
+        Vector3D cross = null;
+        double crossNorm;
+        double crossSumX = 0.0;
+        double crossSumY = 0.0;
+        double crossSumZ = 0.0;
+
+        boolean nonPlanar = false;
+
+        while (it.hasNext()) {
+            currentPt = it.next();
+
+            if (!currentPt.eq(prevPt, precision)) {
+                currentVector = startPt.vectorTo(currentPt);
+
+                if (u == null) {
+                    // save the first non-zero vector as our u vector
+                    u = currentVector.normalize();
+                }
+                if (prevVector != null) {
+                    cross = prevVector.cross(currentVector);
+
+                    crossSumX += cross.getX();
+                    crossSumY += cross.getY();
+                    crossSumZ += cross.getZ();
+
+                    crossNorm = cross.norm();
+
+                    if (!precision.eqZero(crossNorm)) {
+                        // the cross product has non-zero magnitude
+                        if (w == null) {
+                            // save the first non-zero cross product as our normal
+                            w = cross.normalize();
+                        } else if (!precision.eq(1.0, Math.abs(w.dot(cross) / crossNorm))) {
+                            // if the normalized dot product is not either +1 or -1, then
+                            // the points are not coplanar
+                            nonPlanar = true;
+                            break;
+                        }
+                    }
+                }
+
+                prevVector = currentVector;
+                prevPt = currentPt;
+            }
+        }
+
+        if (u == null || w == null || nonPlanar) {
+            throw new GeometryException("Points do not define a plane: " + pts);
+        }
+
+        if (w.dot(Vector3D.of(crossSumX, crossSumY, crossSumZ)) < 0) {
+            w = w.negate();
+        }
+
+        final Vector3D v = w.cross(u);
+        final double originOffset = -startPt.dot(w);
+
+        return new Plane(u, v, w, originOffset, precision);
+    }
+
+    /** Class containing a transformed plane instance along with a subspace (2D) transform. The subspace
+     * transform produces the equivalent of the 3D transform in 2D.
+     */
+    public static final class SubspaceTransform implements Serializable {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20190807L;
+
+        /** The transformed plane. */
+        private final Plane plane;
+
+        /** The subspace transform instance. */
+        private final AffineTransformMatrix2D transform;
+
+        /** Simple constructor.
+         * @param plane the transformed plane
+         * @param transform 2D transform that can be applied to subspace points
+         */
+        public SubspaceTransform(final Plane plane, final AffineTransformMatrix2D transform) {
+            this.plane = plane;
+            this.transform = transform;
+        }
+
+        /** Get the transformed plane instance.
+         * @return the transformed plane instance
+         */
+        public Plane getPlane() {
+            return plane;
+        }
+
+        /** Get the 2D transform that can be applied to subspace points. This transform can be used
+         * to perform the equivalent of the 3D transform in 2D space.
+         * @return the subspace transform instance
+         */
+        public AffineTransformMatrix2D getTransform() {
+            return transform;
+        }
     }
 }
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/PolyhedronsSet.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/PolyhedronsSet.java
deleted file mode 100644
index bea93e8..0000000
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/PolyhedronsSet.java
+++ /dev/null
@@ -1,704 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.threed;
-
-import java.text.MessageFormat;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.List;
-
-import org.apache.commons.geometry.core.Point;
-import org.apache.commons.geometry.core.partitioning.AbstractRegion;
-import org.apache.commons.geometry.core.partitioning.BSPTree;
-import org.apache.commons.geometry.core.partitioning.BSPTreeVisitor;
-import org.apache.commons.geometry.core.partitioning.BoundaryAttribute;
-import org.apache.commons.geometry.core.partitioning.Hyperplane;
-import org.apache.commons.geometry.core.partitioning.Region;
-import org.apache.commons.geometry.core.partitioning.RegionFactory;
-import org.apache.commons.geometry.core.partitioning.SubHyperplane;
-import org.apache.commons.geometry.core.partitioning.Transform;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.geometry.euclidean.oned.Vector1D;
-import org.apache.commons.geometry.euclidean.threed.rotation.QuaternionRotation;
-import org.apache.commons.geometry.euclidean.twod.PolygonsSet;
-import org.apache.commons.geometry.euclidean.twod.SubLine;
-import org.apache.commons.geometry.euclidean.twod.Vector2D;
-
-/** This class represents a 3D region: a set of polyhedrons.
- */
-public class PolyhedronsSet extends AbstractRegion<Vector3D, Vector2D> {
-
-    /** Build a polyhedrons set representing the whole real line.
-     * @param precision precision context used to compare floating point values
-     */
-    public PolyhedronsSet(final DoublePrecisionContext precision) {
-        super(precision);
-    }
-
-    /** Build a polyhedrons set from a BSP tree.
-     * <p>The leaf nodes of the BSP tree <em>must</em> have a
-     * {@code Boolean} attribute representing the inside status of
-     * the corresponding cell (true for inside cells, false for outside
-     * cells). In order to avoid building too many small objects, it is
-     * recommended to use the predefined constants
-     * {@code Boolean.TRUE} and {@code Boolean.FALSE}</p>
-     * <p>
-     * This constructor is aimed at expert use, as building the tree may
-     * be a difficult task. It is not intended for general use and for
-     * performances reasons does not check thoroughly its input, as this would
-     * require walking the full tree each time. Failing to provide a tree with
-     * the proper attributes, <em>will</em> therefore generate problems like
-     * {@link NullPointerException} or {@link ClassCastException} only later on.
-     * This limitation is known and explains why this constructor is for expert
-     * use only. The caller does have the responsibility to provided correct arguments.
-     * </p>
-     * @param tree inside/outside BSP tree representing the region
-     * @param precision precision context used to compare floating point values
-     */
-    public PolyhedronsSet(final BSPTree<Vector3D> tree, final DoublePrecisionContext precision) {
-        super(tree, precision);
-    }
-
-    /** Build a polyhedrons set from a Boundary REPresentation (B-rep) specified by sub-hyperplanes.
-     * <p>The boundary is provided as a collection of {@link
-     * SubHyperplane sub-hyperplanes}. Each sub-hyperplane has the
-     * interior part of the region on its minus side and the exterior on
-     * its plus side.</p>
-     * <p>The boundary elements can be in any order, and can form
-     * several non-connected sets (like for example polyhedrons with holes
-     * or a set of disjoint polyhedrons considered as a whole). In
-     * fact, the elements do not even need to be connected together
-     * (their topological connections are not used here). However, if the
-     * boundary does not really separate an inside open from an outside
-     * open (open having here its topological meaning), then subsequent
-     * calls to the {@link Region#checkPoint(Point) checkPoint} method will
-     * not be meaningful anymore.</p>
-     * <p>If the boundary is empty, the region will represent the whole
-     * space.</p>
-     * @param boundary collection of boundary elements, as a
-     * collection of {@link SubHyperplane SubHyperplane} objects
-     * @param precision precision context used to compare floating point values
-     */
-    public PolyhedronsSet(final Collection<SubHyperplane<Vector3D>> boundary,
-                          final DoublePrecisionContext precision) {
-        super(boundary, precision);
-    }
-
-    /** Build a polyhedrons set from a Boundary REPresentation (B-rep) specified by connected vertices.
-     * <p>
-     * The boundary is provided as a list of vertices and a list of facets.
-     * Each facet is specified as an integer array containing the arrays vertices
-     * indices in the vertices list. Each facet normal is oriented by right hand
-     * rule to the facet vertices list.
-     * </p>
-     * <p>
-     * Some basic sanity checks are performed but not everything is thoroughly
-     * assessed, so it remains under caller responsibility to ensure the vertices
-     * and facets are consistent and properly define a polyhedrons set.
-     * </p>
-     * @param vertices list of polyhedrons set vertices
-     * @param facets list of facets, as vertices indices in the vertices list
-     * @param precision precision context used to compare floating point values
-     * @exception IllegalArgumentException if some basic sanity checks fail
-     */
-    public PolyhedronsSet(final List<Vector3D> vertices, final List<int[]> facets,
-                          final DoublePrecisionContext precision) {
-        super(buildBoundary(vertices, facets, precision), precision);
-    }
-
-    /** Build a parallellepipedic box.
-     * @param xMin low bound along the x direction
-     * @param xMax high bound along the x direction
-     * @param yMin low bound along the y direction
-     * @param yMax high bound along the y direction
-     * @param zMin low bound along the z direction
-     * @param zMax high bound along the z direction
-     * @param precision precision context used to compare floating point values
-     */
-    public PolyhedronsSet(final double xMin, final double xMax,
-                          final double yMin, final double yMax,
-                          final double zMin, final double zMax,
-                          final DoublePrecisionContext precision) {
-        super(buildBoundary(xMin, xMax, yMin, yMax, zMin, zMax, precision), precision);
-    }
-
-    /** Build a parallellepipedic box boundary.
-     * @param xMin low bound along the x direction
-     * @param xMax high bound along the x direction
-     * @param yMin low bound along the y direction
-     * @param yMax high bound along the y direction
-     * @param zMin low bound along the z direction
-     * @param zMax high bound along the z direction
-     * @param precision precision context used to compare floating point values
-     * @return boundary tree
-     */
-    private static BSPTree<Vector3D> buildBoundary(final double xMin, final double xMax,
-                                                      final double yMin, final double yMax,
-                                                      final double zMin, final double zMax,
-                                                      final DoublePrecisionContext precision) {
-        if (precision.eq(xMin, xMax) ||
-            precision.eq(yMin, yMax) ||
-            precision.eq(zMin, zMax)) {
-            // too thin box, build an empty polygons set
-            return new BSPTree<>(Boolean.FALSE);
-        }
-        final Plane pxMin = Plane.fromPointAndNormal(Vector3D.of(xMin, 0, 0), Vector3D.Unit.MINUS_X, precision);
-        final Plane pxMax = Plane.fromPointAndNormal(Vector3D.of(xMax, 0, 0), Vector3D.Unit.PLUS_X,  precision);
-        final Plane pyMin = Plane.fromPointAndNormal(Vector3D.of(0, yMin, 0), Vector3D.Unit.MINUS_Y, precision);
-        final Plane pyMax = Plane.fromPointAndNormal(Vector3D.of(0, yMax, 0), Vector3D.Unit.PLUS_Y,  precision);
-        final Plane pzMin = Plane.fromPointAndNormal(Vector3D.of(0, 0, zMin), Vector3D.Unit.MINUS_Z, precision);
-        final Plane pzMax = Plane.fromPointAndNormal(Vector3D.of(0, 0, zMax), Vector3D.Unit.PLUS_Z,  precision);
-        final Region<Vector3D> boundary =
-            new RegionFactory<Vector3D>().buildConvex(pxMin, pxMax, pyMin, pyMax, pzMin, pzMax);
-        return boundary.getTree(false);
-    }
-
-    /** Build boundary from vertices and facets.
-     * @param vertices list of polyhedrons set vertices
-     * @param facets list of facets, as vertices indices in the vertices list
-     * @param precision precision context used to compare floating point values
-     * @return boundary as a list of sub-hyperplanes
-     * @exception IllegalArgumentException if some basic sanity checks fail
-     */
-    private static List<SubHyperplane<Vector3D>> buildBoundary(final List<Vector3D> vertices,
-                                                               final List<int[]> facets,
-                                                               final DoublePrecisionContext precision) {
-
-        // check vertices distances
-        for (int i = 0; i < vertices.size() - 1; ++i) {
-            final Vector3D vi = vertices.get(i);
-            for (int j = i + 1; j < vertices.size(); ++j) {
-                if (precision.eqZero(vi.distance(vertices.get(j)))) {
-                    throw new IllegalArgumentException("Vertices are too close near point " + vi);
-                }
-            }
-        }
-
-        // find how vertices are referenced by facets
-        final int[][] references = findReferences(vertices, facets);
-
-        // find how vertices are linked together by edges along the facets they belong to
-        final int[][] successors = successors(vertices, facets, references);
-
-        // check edges orientations
-        for (int vA = 0; vA < vertices.size(); ++vA) {
-            for (final int vB : successors[vA]) {
-
-                if (vB >= 0) {
-                    // when facets are properly oriented, if vB is the successor of vA on facet f1,
-                    // then there must be an adjacent facet f2 where vA is the successor of vB
-                    boolean found = false;
-                    for (final int v : successors[vB]) {
-                        found = found || (v == vA);
-                    }
-                    if (!found) {
-                        final Vector3D start = vertices.get(vA);
-                        final Vector3D end   = vertices.get(vB);
-                        throw new IllegalArgumentException(MessageFormat.format("Edge joining points {0} and {1} is connected to one facet only", start, end));
-                    }
-                }
-            }
-        }
-
-        final List<SubHyperplane<Vector3D>> boundary = new ArrayList<>();
-        final List<Vector3D> facetVertices = new ArrayList<>();
-
-        for (final int[] facet : facets) {
-
-            for (int i = 0; i < facet.length; ++i) {
-                facetVertices.add(vertices.get(facet[i]));
-            }
-
-            Plane plane = Plane.fromPoints(facetVertices, precision);
-
-            // convert to subspace points
-            final Vector2D[] two2Points = new Vector2D[facet.length];
-            for (int i = 0 ; i < facet.length; ++i) {
-                two2Points[i] = plane.toSubSpace(facetVertices.get(i));
-            }
-
-            // create the polygonal facet
-            boundary.add(new SubPlane(plane, new PolygonsSet(precision, two2Points)));
-
-            facetVertices.clear();
-        }
-
-        return boundary;
-    }
-
-    /** Find the facets that reference each edges.
-     * @param vertices list of polyhedrons set vertices
-     * @param facets list of facets, as vertices indices in the vertices list
-     * @return references array such that r[v][k] = f for some k if facet f contains vertex v
-     * @exception IllegalArgumentException if some facets have fewer than 3 vertices
-     */
-    private static int[][] findReferences(final List<Vector3D> vertices, final List<int[]> facets) {
-
-        // find the maximum number of facets a vertex belongs to
-        final int[] nbFacets = new int[vertices.size()];
-        int maxFacets  = 0;
-        for (final int[] facet : facets) {
-            if (facet.length < 3) {
-                throw new IllegalArgumentException("3 points are required, got only " + facet.length);
-            }
-            for (final int index : facet) {
-                maxFacets = Math.max(maxFacets, ++nbFacets[index]);
-            }
-        }
-
-        // set up the references array
-        final int[][] references = new int[vertices.size()][maxFacets];
-        for (int[] r : references) {
-            Arrays.fill(r, -1);
-        }
-        for (int f = 0; f < facets.size(); ++f) {
-            for (final int v : facets.get(f)) {
-                // vertex v is referenced by facet f
-                int k = 0;
-                while (k < maxFacets &&
-                       references[v][k] >= 0) {
-                    ++k;
-                }
-                references[v][k] = f;
-            }
-        }
-
-        return references;
-    }
-
-    /** Find the successors of all vertices among all facets they belong to.
-     * @param vertices list of polyhedrons set vertices
-     * @param facets list of facets, as vertices indices in the vertices list
-     * @param references facets references array
-     * @return indices of vertices that follow vertex v in some facet (the array
-     * may contain extra entries at the end, set to negative indices)
-     * @exception IllegalArgumentException if the same vertex appears more than
-     * once in the successors list (which means one facet orientation is wrong)
-
-     */
-    private static int[][] successors(final List<Vector3D> vertices, final List<int[]> facets,
-                                      final int[][] references) {
-
-        // create an array large enough
-        final int[][] successors = new int[vertices.size()][references[0].length];
-        for (final int[] s : successors) {
-            Arrays.fill(s, -1);
-        }
-
-        for (int v = 0; v < vertices.size(); ++v) {
-            for (int k = 0; k < successors[v].length && references[v][k] >= 0; ++k) {
-
-                // look for vertex v
-                final int[] facet = facets.get(references[v][k]);
-                int i = 0;
-                while (i < facet.length &&
-                       facet[i] != v) {
-                    ++i;
-                }
-
-                // we have found vertex v, we deduce its successor on current facet
-                successors[v][k] = facet[(i + 1) % facet.length];
-                for (int l = 0; l < k; ++l) {
-                    if (successors[v][l] == successors[v][k]) {
-                        final Vector3D start = vertices.get(v);
-                        final Vector3D end   = vertices.get(successors[v][k]);
-                        throw new IllegalArgumentException(MessageFormat.format("Facet orientation mismatch around edge joining points {0} and {1}", start, end));
-                    }
-                }
-            }
-        }
-
-        return successors;
-
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public PolyhedronsSet buildNew(final BSPTree<Vector3D> tree) {
-        return new PolyhedronsSet(tree, getPrecision());
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    protected void computeGeometricalProperties() {
-        // check simple cases first
-        if (isEmpty()) {
-            setSize(0);
-            setBarycenter(Vector3D.NaN);
-        }
-        else if (isFull()) {
-            setSize(Double.POSITIVE_INFINITY);
-            setBarycenter(Vector3D.NaN);
-        }
-        else {
-            // not empty or full; compute the contribution of all boundary facets
-            final FacetsContributionVisitor contributionVisitor = new FacetsContributionVisitor();
-            getTree(true).visit(contributionVisitor);
-
-            final double size = contributionVisitor.getSize();
-            final Vector3D barycenter = contributionVisitor.getBarycenter();
-
-            if (size < 0) {
-                // the polyhedrons set is a finite outside surrounded by an infinite inside
-                setSize(Double.POSITIVE_INFINITY);
-                setBarycenter(Vector3D.NaN);
-            } else {
-                // the polyhedrons set is finite
-                setSize(size);
-                setBarycenter(barycenter);
-            }
-        }
-    }
-
-    /** Visitor computing polyhedron geometrical properties.
-     *  The volume of the polyhedron is computed using the equation
-     *  <code>V = (1/3)*&Sigma;<sub>F</sub>[(C<sub>F</sub>&sdot;N<sub>F</sub>)*area(F)]</code>,
-     *  where <code>F</code> represents each face in the polyhedron, <code>C<sub>F</sub></code>
-     *  represents the barycenter of the face, and <code>N<sub>F</sub></code> represents the
-     *  normal of the face. (More details can be found in the article
-     *  <a href="https://en.wikipedia.org/wiki/Polyhedron#Volume">here</a>.)
-     *  This essentially splits up the polyhedron into pyramids with a polyhedron
-     *  face forming the base of each pyramid.
-     *  The barycenter is computed in a similar way. The barycenter of each pyramid
-     *  is calculated using the fact that it is located 3/4 of the way along the
-     *  line from the apex to the base. The polyhedron barycenter then becomes
-     *  the volume-weighted average of these pyramid centers.
-     */
-    private static class FacetsContributionVisitor implements BSPTreeVisitor<Vector3D> {
-
-        /** Accumulator for facet volume contributions. */
-        private double volumeSum;
-
-        /** Accumulator for barycenter contributions. */
-        private Vector3D barycenterSum = Vector3D.ZERO;
-
-        /** Returns the total computed size (ie, volume) of the polyhedron.
-         * This value will be negative if the polyhedron is "inside-out", meaning
-         * that it has a finite outside surrounded by an infinite inside.
-         * @return the volume.
-         */
-        public double getSize() {
-            // apply the 1/3 pyramid volume scaling factor
-            return volumeSum / 3;
-        }
-
-        /** Returns the computed barycenter. This is the volume-weighted average
-         * of contributions from all facets. All coordinates will be NaN if the
-         * region is infinite.
-         * @return the barycenter.
-         */
-        public Vector3D getBarycenter() {
-            // Since the volume we used when adding together the facet contributions
-            // was 3x the actual pyramid size, we'll multiply by 1/4 here instead
-            // of 3/4 to adjust for the actual barycenter position in each pyramid.
-            return Vector3D.linearCombination(1.0 / (4 * getSize()), barycenterSum);
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public Order visitOrder(final BSPTree<Vector3D> node) {
-            return Order.MINUS_SUB_PLUS;
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public void visitInternalNode(final BSPTree<Vector3D> node) {
-            @SuppressWarnings("unchecked")
-            final BoundaryAttribute<Vector3D> attribute =
-                (BoundaryAttribute<Vector3D>) node.getAttribute();
-            if (attribute.getPlusOutside() != null) {
-                addContribution(attribute.getPlusOutside(), false);
-            }
-            if (attribute.getPlusInside() != null) {
-                addContribution(attribute.getPlusInside(), true);
-            }
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public void visitLeafNode(final BSPTree<Vector3D> node) {}
-
-        /** Add the contribution of a boundary facet.
-         * @param facet boundary facet
-         * @param reversed if true, the facet has the inside on its plus side
-         */
-        private void addContribution(final SubHyperplane<Vector3D> facet, final boolean reversed) {
-
-            final Region<Vector2D> polygon = ((SubPlane) facet).getRemainingRegion();
-            final double area = polygon.getSize();
-
-            if (Double.isInfinite(area)) {
-                volumeSum = Double.POSITIVE_INFINITY;
-                barycenterSum = Vector3D.NaN;
-            } else {
-                final Plane plane = (Plane) facet.getHyperplane();
-                final Vector3D facetBarycenter = plane.toSpace(polygon.getBarycenter());
-
-                // the volume here is actually 3x the actual pyramid volume; we'll apply
-                // the final scaling all at once at the end
-                double scaledVolume = area * facetBarycenter.dot(plane.getNormal());
-                if (reversed) {
-                    scaledVolume = -scaledVolume;
-                }
-
-                volumeSum += scaledVolume;
-                barycenterSum = Vector3D.linearCombination(1.0, barycenterSum, scaledVolume, facetBarycenter);
-            }
-        }
-    }
-
-    /** Get the first sub-hyperplane crossed by a semi-infinite line.
-     * @param point start point of the part of the line considered
-     * @param line line to consider (contains point)
-     * @return the first sub-hyperplane crossed by the line after the
-     * given point, or null if the line does not intersect any
-     * sub-hyperplane
-     */
-    public SubHyperplane<Vector3D> firstIntersection(final Vector3D point, final Line line) {
-        return recurseFirstIntersection(getTree(true), point, line);
-    }
-
-    /** Get the first sub-hyperplane crossed by a semi-infinite line.
-     * @param node current node
-     * @param point start point of the part of the line considered
-     * @param line line to consider (contains point)
-     * @return the first sub-hyperplane crossed by the line after the
-     * given point, or null if the line does not intersect any
-     * sub-hyperplane
-     */
-    private SubHyperplane<Vector3D> recurseFirstIntersection(final BSPTree<Vector3D> node,
-                                                                final Vector3D point,
-                                                                final Line line) {
-
-        final SubHyperplane<Vector3D> cut = node.getCut();
-        if (cut == null) {
-            return null;
-        }
-        final BSPTree<Vector3D> minus = node.getMinus();
-        final BSPTree<Vector3D> plus  = node.getPlus();
-        final Plane plane = (Plane) cut.getHyperplane();
-
-        // establish search order
-        final double offset = plane.getOffset(point);
-        final boolean in = getPrecision().eqZero(Math.abs(offset));
-        final BSPTree<Vector3D> near;
-        final BSPTree<Vector3D> far;
-        if (offset < 0) {
-            near = minus;
-            far  = plus;
-        } else {
-            near = plus;
-            far  = minus;
-        }
-
-        if (in) {
-            // search in the cut hyperplane
-            final SubHyperplane<Vector3D> facet = boundaryFacet(point, node);
-
-            // only return the facet here if it exists and intersects the plane
-            // (ie, is not parallel it)
-            if (facet != null &&
-                plane.intersection(line) != null) {
-                return facet;
-            }
-        }
-
-        // search in the near branch
-        final SubHyperplane<Vector3D> crossed = recurseFirstIntersection(near, point, line);
-        if (crossed != null) {
-            return crossed;
-        }
-
-        if (!in) {
-            // search in the cut hyperplane
-            final Vector3D hit3D = plane.intersection(line);
-            if (hit3D != null &&
-                line.getAbscissa(hit3D) > line.getAbscissa(point)) {
-                final SubHyperplane<Vector3D> facet = boundaryFacet(hit3D, node);
-                if (facet != null) {
-                    return facet;
-                }
-            }
-        }
-
-        // search in the far branch
-        return recurseFirstIntersection(far, point, line);
-
-    }
-
-    /** Check if a point belongs to the boundary part of a node.
-     * @param point point to check
-     * @param node node containing the boundary facet to check
-     * @return the boundary facet this points belongs to (or null if it
-     * does not belong to any boundary facet)
-     */
-    private SubHyperplane<Vector3D> boundaryFacet(final Vector3D point,
-                                                     final BSPTree<Vector3D> node) {
-        final Vector2D Vector2D = ((Plane) node.getCut().getHyperplane()).toSubSpace(point);
-        @SuppressWarnings("unchecked")
-        final BoundaryAttribute<Vector3D> attribute =
-            (BoundaryAttribute<Vector3D>) node.getAttribute();
-        if ((attribute.getPlusOutside() != null) &&
-            (((SubPlane) attribute.getPlusOutside()).getRemainingRegion().checkPoint(Vector2D) != Location.OUTSIDE)) {
-            return attribute.getPlusOutside();
-        }
-        if ((attribute.getPlusInside() != null) &&
-            (((SubPlane) attribute.getPlusInside()).getRemainingRegion().checkPoint(Vector2D) != Location.OUTSIDE)) {
-            return attribute.getPlusInside();
-        }
-        return null;
-    }
-
-    /** Rotate the region around the specified point.
-     * <p>The instance is not modified, a new instance is created.</p>
-     * @param center rotation center
-     * @param rotation 3-dimensional rotation
-     * @return a new instance representing the rotated region
-     */
-    public PolyhedronsSet rotate(final Vector3D center, final QuaternionRotation rotation) {
-        return (PolyhedronsSet) applyTransform(new RotationTransform(center, rotation));
-    }
-
-    /** 3D rotation as a Transform. */
-    private static class RotationTransform implements Transform<Vector3D, Vector2D> {
-
-        /** Center point of the rotation. */
-        private final Vector3D   center;
-
-        /** Quaternion rotation. */
-        private final QuaternionRotation   rotation;
-
-        /** Cached original hyperplane. */
-        private Plane cachedOriginal;
-
-        /** Cached 2D transform valid inside the cached original hyperplane. */
-        private Transform<Vector2D, Vector1D>  cachedTransform;
-
-        /** Build a rotation transform.
-         * @param center center point of the rotation
-         * @param rotation vectorial rotation
-         */
-        RotationTransform(final Vector3D center, final QuaternionRotation rotation) {
-            this.center = center;
-            this.rotation = rotation;
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public Vector3D apply(final Vector3D point) {
-            final Vector3D delta = point.subtract(center);
-            return Vector3D.linearCombination(1.0, center, 1.0, rotation.apply(delta));
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public Plane apply(final Hyperplane<Vector3D> hyperplane) {
-            return ((Plane) hyperplane).rotate(center, rotation);
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public SubHyperplane<Vector2D> apply(final SubHyperplane<Vector2D> sub,
-                                             final Hyperplane<Vector3D> original,
-                                             final Hyperplane<Vector3D> transformed) {
-            if (original != cachedOriginal) {
-                // we have changed hyperplane, reset the in-hyperplane transform
-
-                final Plane    oPlane = (Plane) original;
-                final Plane    tPlane = (Plane) transformed;
-                final Vector3D p00 = oPlane.getOrigin();
-                final Vector3D p10 = oPlane.toSpace(Vector2D.of(1.0, 0.0));
-                final Vector3D p01 = oPlane.toSpace(Vector2D.of(0.0, 1.0));
-                final Vector2D tP00 = tPlane.toSubSpace(apply(p00));
-                final Vector2D tP10 = tPlane.toSubSpace(apply(p10));
-                final Vector2D tP01 = tPlane.toSubSpace(apply(p01));
-
-                cachedOriginal  = (Plane) original;
-                cachedTransform =
-                        org.apache.commons.geometry.euclidean.twod.Line.getTransform(tP00.vectorTo(tP10),
-                                                                                     tP00.vectorTo(tP01),
-                                                                                     tP00);
-            }
-            return ((SubLine) sub).applyTransform(cachedTransform);
-        }
-
-    }
-
-    /** Translate the region by the specified amount.
-     * <p>The instance is not modified, a new instance is created.</p>
-     * @param translation translation to apply
-     * @return a new instance representing the translated region
-     */
-    public PolyhedronsSet translate(final Vector3D translation) {
-        return (PolyhedronsSet) applyTransform(new TranslationTransform(translation));
-    }
-
-    /** 3D translation as a transform. */
-    private static class TranslationTransform implements Transform<Vector3D, Vector2D> {
-
-        /** Translation vector. */
-        private final Vector3D   translation;
-
-        /** Cached original hyperplane. */
-        private Plane cachedOriginal;
-
-        /** Cached 2D transform valid inside the cached original hyperplane. */
-        private Transform<Vector2D, Vector1D>  cachedTransform;
-
-        /** Build a translation transform.
-         * @param translation translation vector
-         */
-        TranslationTransform(final Vector3D translation) {
-            this.translation = translation;
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public Vector3D apply(final Vector3D point) {
-            return Vector3D.linearCombination(1.0, point, 1.0, translation);
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public Plane apply(final Hyperplane<Vector3D> hyperplane) {
-            return ((Plane) hyperplane).translate(translation);
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public SubHyperplane<Vector2D> apply(final SubHyperplane<Vector2D> sub,
-                                             final Hyperplane<Vector3D> original,
-                                             final Hyperplane<Vector3D> transformed) {
-            if (original != cachedOriginal) {
-                // we have changed hyperplane, reset the in-hyperplane transform
-
-                final Plane oPlane = (Plane) original;
-                final Plane tPlane = (Plane) transformed;
-                final Vector2D shift = tPlane.toSubSpace(apply(oPlane.getOrigin()));
-
-                cachedOriginal = (Plane) original;
-                cachedTransform =
-                    org.apache.commons.geometry.euclidean.twod.Line.getTransform(Vector2D.Unit.PLUS_X,
-                                                                                 Vector2D.Unit.PLUS_Y,
-                                                                                 shift);
-            }
-
-            return ((SubLine) sub).applyTransform(cachedTransform);
-        }
-    }
-}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/RegionBSPTree3D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/RegionBSPTree3D.java
new file mode 100644
index 0000000..23e5792
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/RegionBSPTree3D.java
@@ -0,0 +1,698 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.threed;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.apache.commons.geometry.core.exception.GeometryValueException;
+import org.apache.commons.geometry.core.partitioning.Hyperplane;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.SubHyperplane;
+import org.apache.commons.geometry.core.partitioning.bsp.AbstractBSPTree;
+import org.apache.commons.geometry.core.partitioning.bsp.AbstractRegionBSPTree;
+import org.apache.commons.geometry.core.partitioning.bsp.BSPTreeVisitor;
+import org.apache.commons.geometry.core.partitioning.bsp.RegionCutBoundary;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.twod.RegionBSPTree2D;
+import org.apache.commons.geometry.euclidean.twod.Polyline;
+import org.apache.commons.geometry.euclidean.twod.Vector2D;
+
+/** Binary space partitioning (BSP) tree representing a region in three dimensional
+ * Euclidean space.
+ */
+public final class RegionBSPTree3D extends AbstractRegionBSPTree<Vector3D, RegionBSPTree3D.RegionNode3D> {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20190702L;
+
+    /** Create a new, empty region. */
+    public RegionBSPTree3D() {
+        this(false);
+    }
+
+    /** Create a new region. If {@code full} is true, then the region will
+     * represent the entire 3D space. Otherwise, it will be empty.
+     * @param full whether or not the region should contain the entire
+     *      3D space or be empty
+     */
+    public RegionBSPTree3D(boolean full) {
+        super(full);
+    }
+
+    /** Return a deep copy of this instance.
+     * @return a deep copy of this instance.
+     * @see #copy(org.apache.commons.geometry.core.partitioning.bsp.BSPTree)
+     */
+    public RegionBSPTree3D copy() {
+        RegionBSPTree3D result = RegionBSPTree3D.empty();
+        result.copy(this);
+
+        return result;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Iterable<ConvexSubPlane> boundaries() {
+        return createBoundaryIterable(b -> (ConvexSubPlane) b);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public List<ConvexSubPlane> getBoundaries() {
+        return createBoundaryList(b -> (ConvexSubPlane) b);
+    }
+
+    /** Return a list of {@link ConvexVolume}s representing the same region
+     * as this instance. One convex volume is returned for each interior leaf
+     * node in the tree.
+     * @return a list of convex volumes representing the same region as this
+     *      instance
+     */
+    public List<ConvexVolume> toConvex() {
+        final List<ConvexVolume> result = new ArrayList<>();
+
+        toConvexRecursive(getRoot(), ConvexVolume.full(), result);
+
+        return result;
+    }
+
+    /** Recursive method to compute the convex volumes of all inside leaf nodes in the subtree rooted at the given
+     * node. The computed convex volumes are added to the given list.
+     * @param node root of the subtree to compute the convex volumes for
+     * @param nodeVolume the convex volume for the current node; this will be split by the node's cut hyperplane to
+     *      form the convex volumes for any child nodes
+     * @param result list containing the results of the computation
+     */
+    private void toConvexRecursive(final RegionNode3D node, final ConvexVolume nodeVolume,
+            final List<ConvexVolume> result) {
+
+        if (node.isLeaf()) {
+            // base case; only add to the result list if the node is inside
+            if (node.isInside()) {
+                result.add(nodeVolume);
+            }
+        } else {
+            // recurse
+            Split<ConvexVolume> split = nodeVolume.split(node.getCutHyperplane());
+
+            toConvexRecursive(node.getMinus(), split.getMinus(), result);
+            toConvexRecursive(node.getPlus(), split.getPlus(), result);
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Split<RegionBSPTree3D> split(final Hyperplane<Vector3D> splitter) {
+        return split(splitter, RegionBSPTree3D.empty(), RegionBSPTree3D.empty());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Vector3D project(Vector3D pt) {
+        // use our custom projector so that we can disambiguate points that are
+        // actually equidistant from the target point
+        final BoundaryProjector3D projector = new BoundaryProjector3D(pt);
+        accept(projector);
+
+        return projector.getProjected();
+    }
+
+    /** Find the first intersection of the given ray/line segment with the boundary of
+     * the region. The return value is the cut subhyperplane of the node containing the
+     * intersection. Null is returned if no intersection exists.
+     * @param ray ray to intersect with the region
+     * @return the node cut subhyperplane containing the intersection or null if no
+     *      intersection exists
+     */
+    public ConvexSubPlane raycastFirst(final Segment3D ray) {
+        return raycastFirstRecursive(getRoot(), ray);
+    }
+
+    /** Recursive method used to find the first intersection of the given ray/line segment
+     * with the boundary of the region.
+     * @param node current BSP tree node
+     * @param ray the ray used for the raycast operation
+     * @return the node cut subhyperplane containing the intersection or null if no
+     *      intersection exists
+     */
+    private ConvexSubPlane raycastFirstRecursive(final RegionNode3D node, final Segment3D ray) {
+        if (node.isLeaf()) {
+            // no boundary to intersect with on leaf nodes
+            return null;
+        }
+
+        // establish search order
+        final Plane cut = (Plane) node.getCutHyperplane();
+        final Line3D line = ray.getLine();
+
+        final boolean plusIsNear = line.getDirection().dot(cut.getNormal()) < 0;
+
+        final RegionNode3D nearNode = plusIsNear ? node.getPlus() : node.getMinus();
+        final RegionNode3D farNode = plusIsNear ? node.getMinus() : node.getPlus();
+
+        // check the near node
+        final ConvexSubPlane nearResult = raycastFirstRecursive(nearNode, ray);
+        if (nearResult != null) {
+            return nearResult;
+        }
+
+        // check ourselves
+        final Vector3D intersection = computeRegionCutBoundaryIntersection(node, ray);
+        if (intersection != null) {
+            // we intersect, so our cut is the answer
+            return (ConvexSubPlane) node.getCut();
+        }
+
+        // check the far node
+        final ConvexSubPlane farResult = raycastFirstRecursive(farNode, ray);
+        if (farResult != null) {
+            return farResult;
+        }
+
+        return null;
+    }
+
+    /** Compute the intersection point between the region cut boundary and the given line segment.
+     * @param node BSP tree node to compute the region cut boundary intersection for
+     * @param segment line segment to compute the intersection for
+     * @return the intersection point between the region cut boundary and the given line segment or
+     *      null if one does not exist.
+     */
+    private Vector3D computeRegionCutBoundaryIntersection(final RegionNode3D node, final Segment3D segment) {
+        if (node.isInternal()) {
+            final Line3D line = segment.getLine();
+            final Vector3D intersection = ((Plane) node.getCutHyperplane()).intersection(line);
+
+            if (intersection != null && segment.contains(intersection)) {
+
+                final RegionCutBoundary<Vector3D> boundary = node.getCutBoundary();
+
+                if ((boundary.getInsideFacing() != null && boundary.getInsideFacing().contains(intersection)) ||
+                        boundary.getOutsideFacing() != null && boundary.getOutsideFacing().contains(intersection)) {
+
+                    return intersection;
+                }
+            }
+        }
+
+        return null;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected RegionSizeProperties<Vector3D> computeRegionSizeProperties() {
+        // handle simple cases
+        if (isFull()) {
+            return new RegionSizeProperties<>(Double.POSITIVE_INFINITY, null);
+        } else if (isEmpty()) {
+            return new RegionSizeProperties<>(0, null);
+        }
+
+        RegionSizePropertiesVisitor visitor = new RegionSizePropertiesVisitor();
+        accept(visitor);
+
+        return visitor.getRegionSizeProperties();
+    }
+
+    /** Compute the region represented by the given node.
+     * @param node the node to compute the region for
+     * @return the region represented by the given node
+     */
+    private ConvexVolume computeNodeRegion(final RegionNode3D node) {
+        ConvexVolume volume = ConvexVolume.full();
+
+        RegionNode3D child = node;
+        RegionNode3D parent;
+
+        while ((parent = child.getParent()) != null) {
+            Split<ConvexVolume> split = volume.split(parent.getCutHyperplane());
+
+            volume = child.isMinus() ? split.getMinus() : split.getPlus();
+
+            child = parent;
+        }
+
+        return volume;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected RegionNode3D createNode() {
+        return new RegionNode3D(this);
+    }
+
+    /** Return a new instance containing all of 3D space.
+     * @return a new instance containing all of 3D space.
+     */
+    public static RegionBSPTree3D full() {
+        return new RegionBSPTree3D(true);
+    }
+
+    /** Return a new, empty instance. The represented region is completely empty.
+     * @return a new, empty instance.
+     */
+    public static RegionBSPTree3D empty() {
+        return new RegionBSPTree3D(false);
+    }
+
+    /** Create a new BSP tree instance representing the same region as the argument.
+     * @param volume convex volume instance
+     * @return a new BSP tree instance representing the same region as the argument
+     */
+    public static RegionBSPTree3D from(final ConvexVolume volume) {
+        RegionBSPTree3D tree = RegionBSPTree3D.full();
+        tree.insert(volume.getBoundaries());
+
+        return tree;
+    }
+
+    /** Create a new {@link RegionBSPTree3D.Builder} instance for creating BSP
+     * trees from boundary representations.
+     * @param precision precision context to use for floating point comparisons.
+     * @return a new builder instance
+     */
+    public static Builder builder(final DoublePrecisionContext precision) {
+        return new Builder(precision);
+    }
+
+    /** BSP tree node for three dimensional Euclidean space.
+     */
+    public static final class RegionNode3D extends AbstractRegionBSPTree.AbstractRegionNode<Vector3D, RegionNode3D> {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20190702L;
+
+        /** Simple constructor.
+         * @param tree the owning tree instance
+         */
+        protected RegionNode3D(AbstractBSPTree<Vector3D, RegionNode3D> tree) {
+            super(tree);
+        }
+
+        /** Get the region represented by this node. The returned region contains
+         * the entire area contained in this node, regardless of the attributes of
+         * any child nodes.
+         * @return the region represented by this node
+         */
+        public ConvexVolume getNodeRegion() {
+            return ((RegionBSPTree3D) getTree()).computeNodeRegion(this);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected RegionNode3D getSelf() {
+            return this;
+        }
+    }
+
+    /** Class used to construct {@link RegionBSPTree3D} instances from boundary representations.
+     */
+    public static final class Builder {
+
+        /** Precision object used to perform floating point comparisons. This object is
+         * used when constructing geometric types.
+         */
+        private final DoublePrecisionContext precision;
+
+        /** The BSP tree being constructed. */
+        private final RegionBSPTree3D tree = RegionBSPTree3D.empty();
+
+        /** List of vertices to use for indexed facet operations. */
+        private List<Vector3D> vertexList;
+
+        /** Create a new builder instance. The given precision context will be used when
+         * constructing geometric types.
+         * @param precision precision object used to perform floating point comparisons
+         */
+        public Builder(final DoublePrecisionContext precision) {
+            this.precision = precision;
+        }
+
+        /** Set the list of vertices to use for indexed facet operations.
+         * @param vertices array of vertices
+         * @return this builder instance
+         * @see #addIndexedFacet(int...)
+         * @see #addIndexedFacet(List)
+         * @see #addIndexedFacets(int[][])
+         */
+        public Builder withVertexList(final Vector3D... vertices) {
+            return withVertexList(Arrays.asList(vertices));
+        }
+
+        /** Set the list of vertices to use for indexed facet operations.
+         * @param vertices list of vertices
+         * @return this builder instance
+         * @see #addIndexedFacet(int...)
+         * @see #addIndexedFacet(List)
+         * @see #addIndexedFacets(int[][])
+         */
+        public Builder withVertexList(final List<Vector3D> vertices) {
+            this.vertexList = vertices;
+            return this;
+        }
+
+        /** Add a subplane to the tree.
+         * @param subplane subplane to add
+         * @return this builder instance
+         */
+        public Builder add(final SubPlane subplane) {
+            tree.insert(subplane);
+            return this;
+        }
+
+        /** Add a convex subplane to the tree.
+         * @param convex convex subplane to add
+         * @return this builder instance
+         */
+        public Builder add(final ConvexSubPlane convex) {
+            tree.insert(convex);
+            return this;
+        }
+
+        /** Add a facet defined by the given array of vertices. The vertices
+         * are considered to form a loop, even if the first vertex is not included
+         * again at the end of the array.
+         * @param vertices array of vertices defining the facet
+         * @return this builder instance
+         */
+        public Builder addFacet(final Vector3D... vertices) {
+            return addFacet(Arrays.asList(vertices));
+        }
+
+        /** Add a facet defined by the given list of vertices. The vertices
+         * are considered to form a loop, even if the first vertex is not included
+         * again at the end of the list.
+         * @param vertices list of vertices defining the facet
+         * @return this builder instance
+         */
+        public Builder addFacet(final List<Vector3D> vertices) {
+            // if there are only 3 vertices, then we know for certain that the area is convex
+            if (vertices.size() < 4) {
+                return add(ConvexSubPlane.fromVertexLoop(vertices, precision));
+            }
+
+            final Plane plane = Plane.fromPoints(vertices, precision);
+            final List<Vector2D> subspaceVertices = plane.toSubspace(vertices);
+
+            final Polyline path = Polyline.fromVertexLoop(subspaceVertices, precision);
+            return add(new SubPlane(plane, path.toTree()));
+        }
+
+        /** Add multiple facets, each one defined by an array of indices into the current
+         * vertex list.
+         * @param facets array of facet definitions, where each definition consists of an
+         *      array of indices into the current vertex list
+         * @return this builder instance
+         * @see #withVertexList(List)
+         */
+        public Builder addIndexedFacets(int[][] facets) {
+            for (int[] facet : facets) {
+                addIndexedFacet(facet);
+            }
+
+            return this;
+        }
+
+        /** Add a facet defined by an array of indices into the current vertex list.
+         * @param vertexIndices indices into the current vertex list, defining the vertices
+         *      for the facet
+         * @return this builder instance
+         * @see #withVertexList(List)
+         */
+        public Builder addIndexedFacet(int... vertexIndices) {
+            final Vector3D[] vertices = new Vector3D[vertexIndices.length];
+            for (int i = 0; i < vertexIndices.length; ++i) {
+                vertices[i] = vertexList.get(vertexIndices[i]);
+            }
+
+            return addFacet(vertices);
+        }
+
+        /** Add a facet defined by a list of indices into the current vertex list.
+         * @param vertexIndices indices into the current vertex list, defining the vertices
+         *      for the facet
+         * @return this builder instance
+         * @see #withVertexList(List)
+         */
+        public Builder addIndexedFacet(final List<Integer> vertexIndices) {
+            final List<Vector3D> vertices = vertexIndices.stream()
+                    .map(idx -> vertexList.get(idx)).collect(Collectors.toList());
+
+            return addFacet(vertices);
+        }
+
+        /** Add an axis-oriented cube with the given dimensions to this instance.
+        * @param center the cube center point
+        * @param size the size of the cube
+        * @return this builder instance
+        * @throws GeometryValueException if the width, height, or depth of the defined region is zero
+        *      as evaluated by the precision context.
+        */
+        public Builder addCenteredCube(final Vector3D center, final double size) {
+            return addCenteredRect(center, size, size, size);
+        }
+
+        /** Add an axis-oriented cube with the given dimensions to this instance.
+         * @param corner a corner of the cube
+         * @param size the size of the cube
+         * @return this builder instance
+         * @throws GeometryValueException if the width, height, or depth of the defined region is zero
+         *      as evaluated by the precision context.
+         */
+        public Builder addCube(final Vector3D corner, final double size) {
+            return addRect(corner, size, size, size);
+        }
+
+        /** Add an axis-oriented rectangular prism to this instance. The prism is centered at the given point and
+         * has the specified dimensions.
+         * @param center center point for the rectangular prism
+         * @param xSize size of the prism along the x-axis
+         * @param ySize size of the prism along the y-axis
+         * @param zSize size of the prism along the z-axis
+         * @return this builder instance
+         * @throws GeometryValueException if the width, height, or depth of the defined region is zero
+         *      as evaluated by the precision context.
+         */
+        public Builder addCenteredRect(final Vector3D center, final double xSize, final double ySize,
+                final double zSize) {
+
+            return addRect(Vector3D.of(
+                        center.getX() - (xSize * 0.5),
+                        center.getY() - (ySize * 0.5),
+                        center.getZ() - (zSize * 0.5)
+                    ), xSize, ySize, zSize);
+        }
+
+        /** Add an axis-oriented rectangular prism to this instance. The prism
+         * is constructed by taking {@code pt} as one corner of the region and adding {@code xDelta},
+         * {@code yDelta}, and {@code zDelta} to its components to create the opposite corner.
+         * @param pt point lying in a corner of the region
+         * @param xDelta distance to move along the x axis to place the other points in the
+         *      prism; this value may be negative.
+         * @param yDelta distance to move along the y axis to place the other points in the
+         *      prism; this value may be negative.
+         * @param zDelta distance to move along the z axis to place the other points in the
+         *      prism; this value may be negative.
+         * @return this builder instance
+         * @throws GeometryValueException if the width, height, or depth of the defined region is zero
+         *      as evaluated by the precision context.
+         */
+        public Builder addRect(final Vector3D pt, final double xDelta, final double yDelta, final double zDelta) {
+            return addRect(pt, Vector3D.of(
+                    pt.getX() + xDelta,
+                    pt.getY() + yDelta,
+                    pt.getZ() + zDelta));
+        }
+
+        /** Add an axis-oriented rectangular prism to this instance. The points {@code a} and {@code b}
+         * are taken to represent opposite corner points in the prism and may be specified in any order.
+         * @param a first corner point in the rectangular prism (opposite of {@code b})
+         * @param b second corner point in the rectangular prism (opposite of {@code a})
+         * @return this builder instance
+         * @throws GeometryValueException if the width, height, or depth of the defined region is zero
+         *      as evaluated by the precision context.
+         */
+        public Builder addRect(final Vector3D a, final Vector3D b) {
+            final double minX = Math.min(a.getX(), b.getX());
+            final double maxX = Math.max(a.getX(), b.getX());
+
+            final double minY = Math.min(a.getY(), b.getY());
+            final double maxY = Math.max(a.getY(), b.getY());
+
+            final double minZ = Math.min(a.getZ(), b.getZ());
+            final double maxZ = Math.max(a.getZ(), b.getZ());
+
+            if (precision.eq(minX, maxX) || precision.eq(minY, maxY) || precision.eq(minZ, maxZ)) {
+                throw new GeometryValueException("Rectangular prism has zero size: " + a + ", " + b + ".");
+            }
+
+            final Vector3D[] vertices = {
+                Vector3D.of(minX, minY, minZ),
+                Vector3D.of(maxX, minY, minZ),
+                Vector3D.of(maxX, maxY, minZ),
+                Vector3D.of(minX, maxY, minZ),
+
+                Vector3D.of(minX, minY, maxZ),
+                Vector3D.of(maxX, minY, maxZ),
+                Vector3D.of(maxX, maxY, maxZ),
+                Vector3D.of(minX, maxY, maxZ)
+            };
+
+            addFacet(vertices[0], vertices[3], vertices[2], vertices[1]);
+            addFacet(vertices[4], vertices[5], vertices[6], vertices[7]);
+
+            addFacet(vertices[5], vertices[1], vertices[2], vertices[6]);
+            addFacet(vertices[0], vertices[4], vertices[7], vertices[3]);
+
+            addFacet(vertices[0], vertices[1], vertices[5], vertices[4]);
+            addFacet(vertices[3], vertices[7], vertices[6], vertices[2]);
+
+            return this;
+        }
+
+        /** Get the created BSP tree.
+         * @return the created BSP tree
+         */
+        public RegionBSPTree3D build() {
+            return tree;
+        }
+    }
+
+    /** Class used to project points onto the 3D region boundary.
+     */
+    private static final class BoundaryProjector3D extends BoundaryProjector<Vector3D, RegionNode3D> {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20190811L;
+
+        /** Simple constructor.
+         * @param point the point to project onto the region's boundary
+         */
+        private BoundaryProjector3D(Vector3D point) {
+            super(point);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected Vector3D disambiguateClosestPoint(final Vector3D target, final Vector3D a, final Vector3D b) {
+            // return the point with the smallest coordinate values
+            final int cmp = Vector3D.COORDINATE_ASCENDING_ORDER.compare(a, b);
+            return cmp < 0 ? a : b;
+        }
+    }
+
+    /** Visitor for computing geometric properties for 3D BSP tree instances.
+     *  The volume of the region is computed using the equation
+     *  <code>V = (1/3)*&Sigma;<sub>F</sub>[(C<sub>F</sub>&sdot;N<sub>F</sub>)*area(F)]</code>,
+     *  where <code>F</code> represents each face in the region, <code>C<sub>F</sub></code>
+     *  represents the barycenter of the face, and <code>N<sub>F</sub></code> represents the
+     *  normal of the face. (More details can be found in the article
+     *  <a href="https://en.wikipedia.org/wiki/Polyhedron#Volume">here</a>.)
+     *  This essentially splits up the region into pyramids with a 2D face forming
+     *  the base of each pyramid. The barycenter is computed in a similar way. The barycenter
+     *  of each pyramid is calculated using the fact that it is located 3/4 of the way along the
+     *  line from the apex to the base. The region barycenter then becomes the volume-weighted
+     *  average of these pyramid centers.
+     *  @see https://en.wikipedia.org/wiki/Polyhedron#Volume
+     */
+    private static final class RegionSizePropertiesVisitor implements BSPTreeVisitor<Vector3D, RegionNode3D> {
+
+        /** Accumulator for facet volume contributions. */
+        private double volumeSum;
+
+        /** Barycenter contribution x coordinate accumulator. */
+        private double sumX;
+
+        /** Barycenter contribution y coordinate accumulator. */
+        private double sumY;
+
+        /** Barycenter contribution z coordinate accumulator. */
+        private double sumZ;
+
+        /** {@inheritDoc} */
+        @Override
+        public void visit(final RegionNode3D node) {
+            if (node.isInternal()) {
+                RegionCutBoundary<Vector3D> boundary = node.getCutBoundary();
+                addFacetContribution(boundary.getOutsideFacing(), false);
+                addFacetContribution(boundary.getInsideFacing(), true);
+            }
+        }
+
+        /** Return the computed size properties for the visited region.
+         * @return the computed size properties for the visited region.
+         */
+        public RegionSizeProperties<Vector3D> getRegionSizeProperties() {
+            double size = Double.POSITIVE_INFINITY;
+            Vector3D barycenter = null;
+
+            // we only have a finite size if the volume sum is finite and positive
+            // (negative indicates a finite outside surrounded by an infinite inside)
+            if (Double.isFinite(volumeSum) && volumeSum > 0.0) {
+                // apply the 1/3 pyramid volume scaling factor
+                size = volumeSum / 3.0;
+
+                // Since the volume we used when adding together the facet contributions
+                // was 3x the actual pyramid size, we'll multiply by 1/4 here instead
+                // of 3/4 to adjust for the actual barycenter position in each pyramid.
+                final double barycenterScale = 1.0 / (4 * size);
+                barycenter =  Vector3D.of(
+                        sumX * barycenterScale,
+                        sumY * barycenterScale,
+                        sumZ * barycenterScale);
+            }
+
+            return new RegionSizeProperties<Vector3D>(size, barycenter);
+        }
+
+        /** Add the facet contribution of the given node cut boundary. If {@code reverse} is true,
+         * the volume of the facet contribution is reversed before being added to the total.
+         * @param boundary node cut boundary
+         * @param reverse if true, the facet contribution is reversed before being added to the total.
+         */
+        private void addFacetContribution(final SubHyperplane<Vector3D> boundary, boolean reverse) {
+            SubPlane subplane = (SubPlane) boundary;
+            RegionBSPTree2D base = subplane.getSubspaceRegion();
+
+            double area = base.getSize();
+            Vector2D baseBarycenter = base.getBarycenter();
+
+            if (Double.isInfinite(area)) {
+                volumeSum = Double.POSITIVE_INFINITY;
+            } else if (baseBarycenter != null) {
+                Plane plane = subplane.getPlane();
+                Vector3D facetBarycenter = plane.toSpace(base.getBarycenter());
+
+                // the volume here is actually 3x the actual pyramid volume; we'll apply
+                // the final scaling all at once at the end
+                double scaledVolume = area * facetBarycenter.dot(plane.getNormal());
+                if (reverse) {
+                    scaledVolume = -scaledVolume;
+                }
+
+                volumeSum += scaledVolume;
+
+                sumX += scaledVolume * facetBarycenter.getX();
+                sumY += scaledVolume * facetBarycenter.getY();
+                sumZ += scaledVolume * facetBarycenter.getZ();
+            }
+        }
+    }
+}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Segment.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Segment.java
deleted file mode 100644
index 8823278..0000000
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Segment.java
+++ /dev/null
@@ -1,65 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.threed;
-
-
-/** Simple container for a two-points segment.
- */
-public class Segment {
-
-    /** Start point of the segment. */
-    private final Vector3D start;
-
-    /** End point of the segments. */
-    private final Vector3D end;
-
-    /** Line containing the segment. */
-    private final Line     line;
-
-    /** Build a segment.
-     * @param start start point of the segment
-     * @param end end point of the segment
-     * @param line line containing the segment
-     */
-    public Segment(final Vector3D start, final Vector3D end, final Line line) {
-        this.start  = start;
-        this.end    = end;
-        this.line   = line;
-    }
-
-    /** Get the start point of the segment.
-     * @return start point of the segment
-     */
-    public Vector3D getStart() {
-        return start;
-    }
-
-    /** Get the end point of the segment.
-     * @return end point of the segment
-     */
-    public Vector3D getEnd() {
-        return end;
-    }
-
-    /** Get the line containing the segment.
-     * @return line containing the segment
-     */
-    public Line getLine() {
-        return line;
-    }
-
-}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Segment3D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Segment3D.java
new file mode 100644
index 0000000..e330f1b
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Segment3D.java
@@ -0,0 +1,271 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.threed;
+
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.oned.Interval;
+import org.apache.commons.geometry.euclidean.oned.Vector1D;
+import org.apache.commons.geometry.euclidean.threed.Line3D.SubspaceTransform;
+
+/** Class representing a line segment in 3 dimensional Euclidean space.
+ *
+ * <p>This class is guaranteed to be immutable.</p>
+ */
+public final class Segment3D extends AbstractSubLine3D<Interval> {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20190702L;
+
+    /** String used to indicate the start point of the segment in the toString() representation. */
+    private static final String START_STR = "start= ";
+
+    /** String used to indicate the direction the segment in the toString() representation. */
+    private static final String DIR_STR = "direction= ";
+
+    /** String used to indicate the end point of the segment in the toString() representation. */
+    private static final String END_STR = "end= ";
+
+    /** String used as a separator value in the toString() representation. */
+    private static final String SEP_STR = ", ";
+
+    /** The interval representing the region of the line contained in
+     * the line segment.
+     */
+    private final Interval interval;
+
+    /** Construct a line segment from an underlying line and a 1D interval
+     * on it.
+     * @param line the underlying line
+     * @param interval 1D interval on the line defining the line segment
+     */
+    private Segment3D(final Line3D line, final Interval interval) {
+        super(line);
+
+        this.interval = interval;
+    }
+
+    /** Get the start value in the 1D subspace of the line.
+     * @return the start value in the 1D subspace of the line.
+     */
+    public double getSubspaceStart() {
+        return interval.getMin();
+    }
+
+    /** Get the end value in the 1D subspace of the line.
+     * @return the end value in the 1D subspace of the line
+     */
+    public double getSubspaceEnd() {
+        return interval.getMax();
+    }
+
+    /** Get the start point of the line segment or null if no start point
+     * exists (ie, the segment is infinite).
+     * @return the start point of the line segment or null if no start point
+     *      exists
+     */
+    public Vector3D getStartPoint() {
+        return interval.hasMinBoundary() ? getLine().toSpace(interval.getMin()) : null;
+    }
+
+    /** Get the end point of the line segment or null if no end point
+     * exists (ie, the segment is infinite).
+     * @return the end point of the line segment or null if no end point
+     *      exists
+     */
+    public Vector3D getEndPoint() {
+        return interval.hasMaxBoundary() ? getLine().toSpace(interval.getMax()) : null;
+    }
+
+    /** Return true if the segment is infinite.
+     * @return true if the segment is infinite.
+     */
+    public boolean isInfinite() {
+        return interval.isInfinite();
+    }
+
+    /** Return true if the segment is finite.
+     * @return true if the segment is finite.
+     */
+    public boolean isFinite() {
+        return interval.isFinite();
+    }
+
+    /** Return the 1D interval for the line segment.
+     * @return the 1D interval for the line segment
+     * @see #getSubspaceRegion()
+     */
+    public Interval getInterval() {
+        return interval;
+    }
+
+    /** {@inheritDoc}
+     *
+     * <p>This is an alias for {@link #getInterval()}.</p>
+     */
+    @Override
+    public Interval getSubspaceRegion() {
+        return getInterval();
+    }
+
+    /** Return true if the given point lies in the segment.
+     * @param pt point to check
+     * @return true if the point lies in the segment
+     */
+    public boolean contains(final Vector3D pt) {
+        final Line3D line = getLine();
+        return line.contains(pt) && interval.contains(line.toSubspace(pt));
+    }
+
+    /** Transform this instance.
+     * @param transform the transform to apply
+     * @return a new, transformed instance
+     */
+    public Segment3D transform(final Transform<Vector3D> transform) {
+        final SubspaceTransform st = getLine().subspaceTransform(transform);
+
+        return new Segment3D(st.getLine(), interval.transform(st.getTransform()));
+    }
+
+    /** Return a string representation of the segment.
+     *
+     * <p>In order to keep the representation short but informative, the exact format used
+     * depends on the properties of the instance, as demonstrated in the examples
+     * below.
+     * <ul>
+     *      <li>Infinite segment -
+     *          {@code "Segment3D[lineOrigin= (0.0, 0.0, 0.0), lineDirection= (1.0, 0.0, 0.0)]}"</li>
+     *      <li>Start point but no end point -
+     *          {@code "Segment3D[start= (0.0, 0.0, 0.0), direction= (1.0, 0.0, 0.0)]}"</li>
+     *      <li>End point but no start point -
+     *          {@code "Segment3D[direction= (1.0, 0.0, 0.0), end= (0.0, 0.0, 0.0)]}"</li>
+     *      <li>Start point and end point -
+     *          {@code "Segment3D[start= (0.0, 0.0, 0.0), end= (1.0, 0.0, 0.0)]}"</li>
+     * </ul>
+     */
+    @Override
+    public String toString() {
+        final Vector3D startPoint = getStartPoint();
+        final Vector3D endPoint = getEndPoint();
+
+        final StringBuilder sb = new StringBuilder();
+        sb.append(this.getClass().getSimpleName())
+            .append('[');
+
+        if (startPoint != null && endPoint != null) {
+            sb.append(START_STR)
+                .append(startPoint)
+                .append(SEP_STR)
+                .append(END_STR)
+                .append(endPoint);
+        } else if (startPoint != null) {
+            sb.append(START_STR)
+                .append(startPoint)
+                .append(SEP_STR)
+                .append(DIR_STR)
+                .append(getLine().getDirection());
+        } else if (endPoint != null) {
+            sb.append(DIR_STR)
+                .append(getLine().getDirection())
+                .append(SEP_STR)
+                .append(END_STR)
+                .append(endPoint);
+        } else {
+            final Line3D line = getLine();
+
+            sb.append("lineOrigin= ")
+                .append(line.getOrigin())
+                .append(SEP_STR)
+                .append("lineDirection= ")
+                .append(line.getDirection());
+        }
+
+        sb.append(']');
+
+        return sb.toString();
+    }
+
+    /** Create a line segment between two points. The underlying line points in the direction from {@code start}
+     * to {@code end}.
+     * @param start start point for the line segment
+     * @param end end point for the line segment
+     * @param precision precision context used to determine floating point equality
+     * @return a new line segment between {@code start} and {@code end}.
+     */
+    public static Segment3D fromPoints(final Vector3D start, final Vector3D end,
+            final DoublePrecisionContext precision) {
+
+        final Line3D line = Line3D.fromPoints(start, end, precision);
+        return fromPointsOnLine(line, start, end);
+    }
+
+    /** Construct a line segment from a starting point and a direction that the line should extend to
+     * infinity from. This is equivalent to constructing a ray.
+     * @param start start point for the segment
+     * @param direction direction that the line should extend from the segment
+     * @param precision precision context used to determine floating point equality
+     * @return a new line segment starting from the given point and extending to infinity in the
+     *      specified direction
+     */
+    public static Segment3D fromPointAndDirection(final Vector3D start, final Vector3D direction,
+            final DoublePrecisionContext precision) {
+        final Line3D line = Line3D.fromPointAndDirection(start, direction, precision);
+        return fromInterval(line, Interval.min(line.toSubspace(start).getX(), precision));
+    }
+
+    /** Create a line segment from an underlying line and a 1D interval on the line.
+     * @param line the line that the line segment will belong to
+     * @param interval 1D interval on the line
+     * @return a line segment defined by the given line and interval
+     */
+    public static Segment3D fromInterval(final Line3D line, final Interval interval) {
+        return new Segment3D(line, interval);
+    }
+
+    /** Create a line segment from an underlying line and a 1D interval on the line.
+     * @param line the line that the line segment will belong to
+     * @param a first 1D location on the line
+     * @param b second 1D location on the line
+     * @return a line segment defined by the given line and interval
+     */
+    public static Segment3D fromInterval(final Line3D line, final double a, final double b) {
+        return fromInterval(line, Interval.of(a, b, line.getPrecision()));
+    }
+
+    /** Create a line segment from an underlying line and a 1D interval on the line.
+     * @param line the line that the line segment will belong to
+     * @param a first 1D point on the line; must not be null
+     * @param b second 1D point on the line; must not be null
+     * @return a line segment defined by the given line and interval
+     */
+    public static Segment3D fromInterval(final Line3D line, final Vector1D a, final Vector1D b) {
+        return fromInterval(line, a.getX(), b.getX());
+    }
+
+    /** Create a new line segment from a line and points known to lie on the line.
+     * @param line the line that the line segment will belong to
+     * @param start line segment start point known to lie on the line
+     * @param end line segment end poitn known to lie on the line
+     * @return a new line segment created from the line and points
+     */
+    private static Segment3D fromPointsOnLine(final Line3D line, final Vector3D start, final Vector3D end) {
+        final double subspaceStart = line.toSubspace(start).getX();
+        final double subspaceEnd = line.toSubspace(end).getX();
+
+        return fromInterval(line, subspaceStart, subspaceEnd);
+    }
+}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/SphericalCoordinates.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/SphericalCoordinates.java
index b863ec5..9759606 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/SphericalCoordinates.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/SphericalCoordinates.java
@@ -21,6 +21,7 @@
 import org.apache.commons.geometry.core.Geometry;
 import org.apache.commons.geometry.core.Spatial;
 import org.apache.commons.geometry.core.internal.SimpleTupleFormat;
+import org.apache.commons.geometry.euclidean.internal.Vectors;
 import org.apache.commons.geometry.euclidean.twod.PolarCoordinates;
 import org.apache.commons.numbers.angle.PlaneAngleRadians;
 
@@ -137,6 +138,12 @@
         return !isNaN() && (Double.isInfinite(radius) || Double.isInfinite(azimuth) || Double.isInfinite(polar));
     }
 
+    /** {@inheritDoc} */
+    @Override
+    public boolean isFinite() {
+        return Double.isFinite(radius) && Double.isFinite(azimuth) && Double.isFinite(polar);
+    }
+
     /** Convert this set of spherical coordinates to a Cartesian form.
      * @return A 3-dimensional vector with an equivalent set of
      *      Cartesian coordinates.
@@ -221,7 +228,7 @@
      * @return a set of spherical coordinates equivalent to the given Cartesian coordinates
      */
     public static SphericalCoordinates fromCartesian(final double x, final double y, final double z) {
-        final double radius = Math.sqrt((x*x) + (y*y) + (z*z));
+        final double radius = Vectors.norm(x, y, z);
         final double azimuth = Math.atan2(y, x);
 
         // default the polar angle to 0 when the radius is 0
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/SubLine.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/SubLine.java
deleted file mode 100644
index adb0194..0000000
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/SubLine.java
+++ /dev/null
@@ -1,147 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.threed;
-
-import java.util.ArrayList;
-import java.util.List;
-
-import org.apache.commons.geometry.core.partitioning.Region.Location;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.geometry.euclidean.oned.Interval;
-import org.apache.commons.geometry.euclidean.oned.IntervalsSet;
-import org.apache.commons.geometry.euclidean.oned.Vector1D;
-
-/** This class represents a subset of a {@link Line}.
- */
-public class SubLine {
-
-    /** Underlying line. */
-    private final Line line;
-
-    /** Remaining region of the hyperplane. */
-    private final IntervalsSet remainingRegion;
-
-    /** Simple constructor.
-     * @param line underlying line
-     * @param remainingRegion remaining region of the line
-     */
-    public SubLine(final Line line, final IntervalsSet remainingRegion) {
-        this.line            = line;
-        this.remainingRegion = remainingRegion;
-    }
-
-    /** Create a sub-line from two endpoints.
-     * @param start start point
-     * @param end end point
-     * @param precision precision context used to compare floating point values
-     * @exception IllegalArgumentException if the points are equal
-     */
-    public SubLine(final Vector3D start, final Vector3D end, final DoublePrecisionContext precision)
-        throws IllegalArgumentException {
-        this(new Line(start, end, precision), buildIntervalSet(start, end, precision));
-    }
-
-    /** Create a sub-line from a segment.
-     * @param segment single segment forming the sub-line
-     * @exception IllegalArgumentException if the segment endpoints are equal
-     */
-    public SubLine(final Segment segment) {
-        this(segment.getLine(),
-             buildIntervalSet(segment.getStart(), segment.getEnd(), segment.getLine().getPrecision()));
-    }
-
-    /** Get the endpoints of the sub-line.
-     * <p>
-     * A subline may be any arbitrary number of disjoints segments, so the endpoints
-     * are provided as a list of endpoint pairs. Each element of the list represents
-     * one segment, and each segment contains a start point at index 0 and an end point
-     * at index 1. If the sub-line is unbounded in the negative infinity direction,
-     * the start point of the first segment will have infinite coordinates. If the
-     * sub-line is unbounded in the positive infinity direction, the end point of the
-     * last segment will have infinite coordinates. So a sub-line covering the whole
-     * line will contain just one row and both elements of this row will have infinite
-     * coordinates. If the sub-line is empty, the returned list will contain 0 segments.
-     * </p>
-     * @return list of segments endpoints
-     */
-    public List<Segment> getSegments() {
-
-        final List<Interval> list = remainingRegion.asList();
-        final List<Segment> segments = new ArrayList<>(list.size());
-
-        for (final Interval interval : list) {
-            final Vector3D start = line.toSpace(Vector1D.of(interval.getInf()));
-            final Vector3D end   = line.toSpace(Vector1D.of(interval.getSup()));
-            segments.add(new Segment(start, end, line));
-        }
-
-        return segments;
-
-    }
-
-    /** Get the intersection of the instance and another sub-line.
-     * <p>
-     * This method is related to the {@link Line#intersection(Line)
-     * intersection} method in the {@link Line Line} class, but in addition
-     * to compute the point along infinite lines, it also checks the point
-     * lies on both sub-line ranges.
-     * </p>
-     * @param subLine other sub-line which may intersect instance
-     * @param includeEndPoints if true, endpoints are considered to belong to
-     * instance (i.e. they are closed sets) and may be returned, otherwise endpoints
-     * are considered to not belong to instance (i.e. they are open sets) and intersection
-     * occurring on endpoints lead to null being returned
-     * @return the intersection point if there is one, null if the sub-lines don't intersect
-     */
-    public Vector3D intersection(final SubLine subLine, final boolean includeEndPoints) {
-
-        // compute the intersection on infinite line
-        Vector3D v1D = line.intersection(subLine.line);
-        if (v1D == null) {
-            return null;
-        }
-
-        // check location of point with respect to first sub-line
-        Location loc1 = remainingRegion.checkPoint(line.toSubSpace(v1D));
-
-        // check location of point with respect to second sub-line
-        Location loc2 = subLine.remainingRegion.checkPoint(subLine.line.toSubSpace(v1D));
-
-        if (includeEndPoints) {
-            return ((loc1 != Location.OUTSIDE) && (loc2 != Location.OUTSIDE)) ? v1D : null;
-        } else {
-            return ((loc1 == Location.INSIDE) && (loc2 == Location.INSIDE)) ? v1D : null;
-        }
-
-    }
-
-    /** Build an interval set from two points.
-     * @param start start point
-     * @param end end point
-     * @return an interval set
-     * @param precision precision context used to compare floating point values
-     * @exception IllegalArgumentException if the points are equal
-     */
-    private static IntervalsSet buildIntervalSet(final Vector3D start, final Vector3D end, final DoublePrecisionContext precision)
-        throws IllegalArgumentException {
-        final Line line = new Line(start, end, precision);
-        return new IntervalsSet(line.toSubSpace(start).getX(),
-                                line.toSubSpace(end).getX(),
-                                precision);
-    }
-
-}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/SubLine3D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/SubLine3D.java
new file mode 100644
index 0000000..b847a96
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/SubLine3D.java
@@ -0,0 +1,124 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.threed;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.euclidean.oned.Interval;
+import org.apache.commons.geometry.euclidean.oned.RegionBSPTree1D;
+import org.apache.commons.geometry.euclidean.threed.Line3D.SubspaceTransform;
+
+/** Class representing an arbitrary region of a 3 dimensional line. This class can represent
+ * both convex and non-convex regions of its underlying line.
+ *
+ * <p>This class is mutable and <em>not</em> thread safe.</p>
+ */
+public final class SubLine3D extends AbstractSubLine3D<RegionBSPTree1D> {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20190702L;
+
+    /** The 1D region representing the area on the line. */
+    private final RegionBSPTree1D region;
+
+    /** Construct a new, empty subline for the given line.
+     * @param line line defining the subline
+     */
+    public SubLine3D(final Line3D line) {
+        this(line, false);
+    }
+
+    /** Construct a new subline for the given line. If {@code full}
+     * is true, then the subline will cover the entire line; otherwise,
+     * it will be empty.
+     * @param line line defining the subline
+     * @param full if true, the subline will cover the entire space;
+     *      otherwise it will be empty
+     */
+    public SubLine3D(final Line3D line, boolean full) {
+        this(line, new RegionBSPTree1D(full));
+    }
+
+    /** Construct a new instance from its defining line and subspace region.
+     * @param line line defining the subline
+     * @param region subspace region for the subline
+     */
+    public SubLine3D(final Line3D line, final RegionBSPTree1D region) {
+        super(line);
+
+        this.region = region;
+    }
+
+    /** Transform this instance.
+     * @param transform the transform to apply
+     * @return a new, transformed instance
+     */
+    public SubLine3D transform(final Transform<Vector3D> transform) {
+        final SubspaceTransform st = getLine().subspaceTransform(transform);
+
+        final RegionBSPTree1D tRegion = RegionBSPTree1D.empty();
+        tRegion.copy(region);
+        tRegion.transform(st.getTransform());
+
+        return new SubLine3D(st.getLine(), tRegion);
+    }
+
+    /** Return a list of {@link Segment3D} instances representing the same region
+     * as this subline.
+     * @return a list of {@link Segment3D} instances representing the same region
+     *      as this instance.
+     */
+    public List<Segment3D> toConvex() {
+        final List<Interval> intervals = region.toIntervals();
+
+        final Line3D line = getLine();
+        final List<Segment3D> segments = new ArrayList<>(intervals.size());
+
+        for (Interval interval : intervals) {
+            segments.add(Segment3D.fromInterval(line, interval));
+        }
+
+        return segments;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public RegionBSPTree1D getSubspaceRegion() {
+        return region;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String toString() {
+        final Line3D line = getLine();
+
+        final StringBuilder sb = new StringBuilder();
+        sb.append(this.getClass().getSimpleName())
+            .append('[')
+            .append("lineOrigin= ")
+            .append(line.getOrigin())
+            .append(", lineDirection= ")
+            .append(line.getDirection())
+            .append(", region= ")
+            .append(region)
+            .append(']');
+
+        return sb.toString();
+    }
+}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/SubPlane.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/SubPlane.java
index 0520658..4a22af0 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/SubPlane.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/SubPlane.java
@@ -16,92 +16,189 @@
  */
 package org.apache.commons.geometry.euclidean.threed;
 
-import org.apache.commons.geometry.core.partitioning.AbstractSubHyperplane;
-import org.apache.commons.geometry.core.partitioning.BSPTree;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.exception.GeometryException;
+import org.apache.commons.geometry.core.partitioning.ConvexSubHyperplane;
 import org.apache.commons.geometry.core.partitioning.Hyperplane;
-import org.apache.commons.geometry.core.partitioning.Region;
+import org.apache.commons.geometry.core.partitioning.Split;
 import org.apache.commons.geometry.core.partitioning.SubHyperplane;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.geometry.euclidean.oned.Vector1D;
-import org.apache.commons.geometry.euclidean.twod.PolygonsSet;
-import org.apache.commons.geometry.euclidean.twod.Vector2D;
+import org.apache.commons.geometry.euclidean.twod.ConvexArea;
+import org.apache.commons.geometry.euclidean.twod.RegionBSPTree2D;
 
-/** This class represents a sub-hyperplane for {@link Plane}.
+/** Class representing an arbitrary region of a plane. This class can represent
+ * both convex and non-convex regions of its underlying plane.
+ *
+ * <p>This class is mutable and <em>not</em> thread safe.</p>
  */
-public class SubPlane extends AbstractSubHyperplane<Vector3D, Vector2D> {
+public final class SubPlane extends AbstractSubPlane<RegionBSPTree2D> implements Serializable {
 
-    /** Simple constructor.
-     * @param hyperplane underlying hyperplane
-     * @param remainingRegion remaining region of the hyperplane
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20190717L;
+
+    /** The 2D region representing the area on the plane. */
+    private final RegionBSPTree2D region;
+
+    /** Construct a new, empty subplane for the given plane.
+     * @param plane plane defining the subplane
      */
-    public SubPlane(final Hyperplane<Vector3D> hyperplane,
-                    final Region<Vector2D> remainingRegion) {
-        super(hyperplane, remainingRegion);
+    public SubPlane(final Plane plane) {
+        this(plane, false);
+    }
+
+    /** Construct a new subplane for the given plane. If {@code full}
+     * is true, then the subplane will cover the entire plane; otherwise,
+     * it will be empty.
+     * @param plane plane defining the subplane
+     * @param full if true, the subplane will cover the entire space;
+     *      otherwise it will be empty
+     */
+    public SubPlane(final Plane plane, boolean full) {
+        this(plane, new RegionBSPTree2D(full));
+    }
+
+    /** Construct a new instance from its defining plane and subspace region.
+     * @param plane plane defining the subplane
+     * @param region subspace region for the subplane
+     */
+    public SubPlane(final Plane plane, final RegionBSPTree2D region) {
+        super(plane);
+
+        this.region = region;
     }
 
     /** {@inheritDoc} */
     @Override
-    protected AbstractSubHyperplane<Vector3D, Vector2D> buildNew(final Hyperplane<Vector3D> hyperplane,
-                                                                       final Region<Vector2D> remainingRegion) {
-        return new SubPlane(hyperplane, remainingRegion);
+    public List<ConvexSubPlane> toConvex() {
+        final List<ConvexArea> areas = region.toConvex();
+
+        final Plane plane = getPlane();
+        final List<ConvexSubPlane> subplanes = new ArrayList<>(areas.size());
+
+        for (final ConvexArea area : areas) {
+            subplanes.add(ConvexSubPlane.fromConvexArea(plane, area));
+        }
+
+        return subplanes;
     }
 
-    /** Split the instance in two parts by an hyperplane.
-     * @param hyperplane splitting hyperplane
-     * @return an object containing both the part of the instance
-     * on the plus side of the instance and the part of the
-     * instance on the minus side of the instance
+    /** {@inheritDoc}
+     *
+     * <p>In all cases, the current instance is not modified. However, In order to avoid
+     * unnecessary copying, this method will use the current instance as the split value when
+     * the instance lies entirely on the plus or minus side of the splitter. For example, if
+     * this instance lies entirely on the minus side of the splitter, the subplane
+     * returned by {@link Split#getMinus()} will be this instance. Similarly, {@link Split#getPlus()}
+     * will return the current instance if it lies entirely on the plus side. Callers need to make
+     * special note of this, since this class is mutable.</p>
      */
     @Override
-    public SplitSubHyperplane<Vector3D> split(Hyperplane<Vector3D> hyperplane) {
-
-        final Plane otherPlane = (Plane) hyperplane;
-        final Plane thisPlane  = (Plane) getHyperplane();
-        final Line  inter      = otherPlane.intersection(thisPlane);
-        final DoublePrecisionContext precision = thisPlane.getPrecision();
-
-        if (inter == null) {
-            // the hyperplanes are parallel
-            final double global = otherPlane.getOffset(thisPlane);
-            final int comparison = precision.compare(global, 0.0);
-
-            if (comparison < 0) {
-                return new SplitSubHyperplane<>(null, this);
-            } else if (comparison > 0) {
-                return new SplitSubHyperplane<>(this, null);
-            } else {
-                return new SplitSubHyperplane<>(null, null);
-            }
-        }
-
-        // the hyperplanes do intersect
-        Vector2D p = thisPlane.toSubSpace(inter.toSpace(Vector1D.ZERO));
-        Vector2D q = thisPlane.toSubSpace(inter.toSpace(Vector1D.Unit.PLUS));
-        Vector3D crossP = inter.getDirection().cross(thisPlane.getNormal());
-        if (crossP.dot(otherPlane.getNormal()) < 0) {
-            final Vector2D tmp = p;
-            p           = q;
-            q           = tmp;
-        }
-        final SubHyperplane<Vector2D> l2DMinus =
-            org.apache.commons.geometry.euclidean.twod.Line.fromPoints(p, q, precision).wholeHyperplane();
-        final SubHyperplane<Vector2D> l2DPlus =
-            org.apache.commons.geometry.euclidean.twod.Line.fromPoints(q, p, precision).wholeHyperplane();
-
-        final BSPTree<Vector2D> splitTree = getRemainingRegion().getTree(false).split(l2DMinus);
-        final BSPTree<Vector2D> plusTree  = getRemainingRegion().isEmpty(splitTree.getPlus()) ?
-                                               new BSPTree<Vector2D>(Boolean.FALSE) :
-                                               new BSPTree<>(l2DPlus, new BSPTree<Vector2D>(Boolean.FALSE),
-                                                                        splitTree.getPlus(), null);
-
-        final BSPTree<Vector2D> minusTree = getRemainingRegion().isEmpty(splitTree.getMinus()) ?
-                                               new BSPTree<Vector2D>(Boolean.FALSE) :
-                                                   new BSPTree<>(l2DMinus, new BSPTree<Vector2D>(Boolean.FALSE),
-                                                                            splitTree.getMinus(), null);
-
-        return new SplitSubHyperplane<>(new SubPlane(thisPlane.copySelf(), new PolygonsSet(plusTree, precision)),
-                                                   new SubPlane(thisPlane.copySelf(), new PolygonsSet(minusTree, precision)));
-
+    public Split<SubPlane> split(final Hyperplane<Vector3D> splitter) {
+        return splitInternal(splitter, this, (p, r) -> new SubPlane(p, (RegionBSPTree2D) r));
     }
 
+    /** {@inheritDoc} */
+    @Override
+    public RegionBSPTree2D getSubspaceRegion() {
+        return region;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public SubPlane transform(final Transform<Vector3D> transform) {
+        final Plane.SubspaceTransform subTransform = getPlane().subspaceTransform(transform);
+
+        final RegionBSPTree2D tRegion = RegionBSPTree2D.empty();
+        tRegion.copy(region);
+        tRegion.transform(subTransform.getTransform());
+
+        return new SubPlane(subTransform.getPlane(), tRegion);
+    }
+
+    /** Add a convex subplane to this instance.
+     * @param subplane convex subplane to add
+     * @throws GeometryException if the given convex subplane is not from
+     *      a plane equivalent to this instance
+     */
+    public void add(final ConvexSubPlane subplane) {
+        validatePlane(subplane.getPlane());
+
+        region.add(subplane.getSubspaceRegion());
+    }
+
+    /** Add a subplane to this instance.
+     * @param subplane subplane to add
+     * @throws GeometryException if the given convex subplane is not from
+     *      a plane equivalent to this instance
+     */
+    public void add(final SubPlane subplane) {
+        validatePlane(subplane.getPlane());
+
+        region.union(subplane.getSubspaceRegion());
+    }
+
+    /** Validate that the given plane is equivalent to the plane
+     * defining this subplane.
+     * @param inputPlane plane to validate
+     * @throws GeometryException if the given plane is not equivalent
+     *      to the plane for this instance
+     */
+    private void validatePlane(final Plane inputPlane) {
+        final Plane plane = getPlane();
+
+        if (!plane.eq(inputPlane)) {
+            throw new GeometryException("Argument is not on the same " +
+                    "plane. Expected " + plane + " but was " +
+                    inputPlane);
+        }
+    }
+
+    /** {@link Builder} implementation for sublines.
+     */
+    public static class SubPlaneBuilder implements SubHyperplane.Builder<Vector3D> {
+
+        /** Subplane instance created by this builder. */
+        private final SubPlane subplane;
+
+        /** Construct a new instance for building subplane region for the given plane.
+         * @param plane the underlying plane for the subplane region
+         */
+        public SubPlaneBuilder(final Plane plane) {
+            this.subplane = new SubPlane(plane);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void add(final SubHyperplane<Vector3D> sub) {
+            addInternal(sub);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void add(final ConvexSubHyperplane<Vector3D> sub) {
+            addInternal(sub);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public SubPlane build() {
+            return subplane;
+        }
+
+        /** Internal method for adding subhyperplanes to this builder.
+         * @param sub the subhyperplane to add; either convex or non-convex
+         */
+        private void addInternal(final SubHyperplane<Vector3D> sub) {
+            if (sub instanceof ConvexSubPlane) {
+                subplane.add((ConvexSubPlane) sub);
+            } else if (sub instanceof SubPlane) {
+                subplane.add((SubPlane) sub);
+            } else {
+                throw new IllegalArgumentException("Unsupported subhyperplane type: " + sub.getClass().getName());
+            }
+        }
+    }
 }
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Side.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Transform3D.java
similarity index 61%
copy from commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Side.java
copy to commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Transform3D.java
index 046defe..493a70a 100644
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Side.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Transform3D.java
@@ -14,23 +14,18 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.commons.geometry.core.partitioning;
+package org.apache.commons.geometry.euclidean.threed;
 
-/** Enumerate representing the location of an element with respect to an
- * {@link Hyperplane hyperplane} of a space.
+import org.apache.commons.geometry.euclidean.EuclideanTransform;
+
+/** Extension of the {@link EuclideanTransform} interface for 3D points.
  */
-public enum Side {
+public interface Transform3D extends EuclideanTransform<Vector3D> {
 
-    /** Code for the plus side of the hyperplane. */
-    PLUS,
-
-    /** Code for the minus side of the hyperplane. */
-    MINUS,
-
-    /** Code for elements crossing the hyperplane from plus to minus side. */
-    BOTH,
-
-    /** Code for the hyperplane itself. */
-    HYPER;
-
+    /** Return an affine transform matrix representing the same transform
+     * as this instance.
+     * @return an affine tranform matrix representing the same transform
+     *      as this instance
+     */
+    AffineTransformMatrix3D toMatrix();
 }
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Vector3D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Vector3D.java
index 1434841..a8b8d47 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Vector3D.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Vector3D.java
@@ -16,8 +16,9 @@
  */
 package org.apache.commons.geometry.euclidean.threed;
 
+import java.util.Comparator;
+import java.util.function.Function;
 
-import org.apache.commons.geometry.core.exception.IllegalNormException;
 import org.apache.commons.geometry.core.internal.DoubleFunction3N;
 import org.apache.commons.geometry.core.internal.SimpleTupleFormat;
 import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
@@ -46,16 +47,40 @@
     public static final Vector3D NEGATIVE_INFINITY =
         new Vector3D(Double.NEGATIVE_INFINITY, Double.NEGATIVE_INFINITY, Double.NEGATIVE_INFINITY);
 
-    /** Serializable version identifier */
+    /** Comparator that sorts vectors in component-wise ascending order.
+     * Vectors are only considered equal if their coordinates match exactly.
+     * Null arguments are evaluated as being greater than non-null arguments.
+     */
+    public static final Comparator<Vector3D> COORDINATE_ASCENDING_ORDER = (a, b) -> {
+        int cmp = 0;
+
+        if (a != null && b != null) {
+            cmp = Double.compare(a.getX(), b.getX());
+            if (cmp == 0) {
+                cmp = Double.compare(a.getY(), b.getY());
+                if (cmp == 0) {
+                    cmp = Double.compare(a.getZ(), b.getZ());
+                }
+            }
+        } else if (a != null) {
+            cmp = -1;
+        } else if (b != null) {
+            cmp = 1;
+        }
+
+        return cmp;
+    };
+
+    /** Serializable version identifier. */
     private static final long serialVersionUID = 20180903L;
 
-    /** Abscissa (first coordinate value) */
+    /** Abscissa (first coordinate value). */
     private final double x;
 
-    /** Ordinate (second coordinate value) */
+    /** Ordinate (second coordinate value). */
     private final double y;
 
-    /** Height (third coordinate value)*/
+    /** Height (third coordinate value). */
     private final double z;
 
     /** Simple constructor.
@@ -95,7 +120,7 @@
      * @return the coordinates for this instance
      */
     public double[] toArray() {
-        return new double[] { x, y, z };
+        return new double[]{x, y, z};
     }
 
     /** {@inheritDoc} */
@@ -118,25 +143,31 @@
 
     /** {@inheritDoc} */
     @Override
+    public boolean isFinite() {
+        return Double.isFinite(x) && Double.isFinite(y) && Double.isFinite(z);
+    }
+
+    /** {@inheritDoc} */
+    @Override
     public Vector3D getZero() {
         return ZERO;
     }
 
     /** {@inheritDoc} */
     @Override
-    public Vector3D vectorTo(Vector3D v) {
+    public Vector3D vectorTo(final Vector3D v) {
         return v.subtract(this);
     }
 
     /** {@inheritDoc} */
     @Override
-    public Unit directionTo(Vector3D v) {
+    public Unit directionTo(final Vector3D v) {
         return vectorTo(v).normalize();
     }
 
     /** {@inheritDoc} */
     @Override
-    public Vector3D lerp(Vector3D p, double t) {
+    public Vector3D lerp(final Vector3D p, final double t) {
         return linearCombination(1.0 - t, this, t, p);
     }
 
@@ -154,7 +185,7 @@
 
     /** {@inheritDoc} */
     @Override
-    public Vector3D withNorm(double magnitude) {
+    public Vector3D withNorm(final double magnitude) {
         final double m = magnitude / getCheckedNorm();
 
         return new Vector3D(
@@ -166,7 +197,7 @@
 
     /** {@inheritDoc} */
     @Override
-    public Vector3D add(Vector3D v) {
+    public Vector3D add(final Vector3D v) {
         return new Vector3D(
                     x + v.x,
                     y + v.y,
@@ -176,7 +207,7 @@
 
     /** {@inheritDoc} */
     @Override
-    public Vector3D add(double factor, Vector3D v) {
+    public Vector3D add(final double factor, final Vector3D v) {
         return new Vector3D(
                     x + (factor * v.x),
                     y + (factor * v.y),
@@ -186,7 +217,7 @@
 
     /** {@inheritDoc} */
     @Override
-    public Vector3D subtract(Vector3D v) {
+    public Vector3D subtract(final Vector3D v) {
         return new Vector3D(
                     x - v.x,
                     y - v.y,
@@ -196,7 +227,7 @@
 
     /** {@inheritDoc} */
     @Override
-    public Vector3D subtract(double factor, Vector3D v) {
+    public Vector3D subtract(final double factor, final Vector3D v) {
         return new Vector3D(
                     x - (factor * v.x),
                     y - (factor * v.y),
@@ -218,13 +249,13 @@
 
     /** {@inheritDoc} */
     @Override
-    public Vector3D multiply(double a) {
+    public Vector3D multiply(final double a) {
         return new Vector3D(a * x, a * y, a * z);
     }
 
     /** {@inheritDoc} */
     @Override
-    public double distance(Vector3D v) {
+    public double distance(final Vector3D v) {
         return Vectors.norm(
                 x - v.x,
                 y - v.y,
@@ -234,7 +265,7 @@
 
     /** {@inheritDoc} */
     @Override
-    public double distanceSq(Vector3D v) {
+    public double distanceSq(final Vector3D v) {
         return Vectors.normSq(
                 x - v.x,
                 y - v.y,
@@ -251,7 +282,7 @@
      * @see LinearCombination#value(double, double, double, double, double, double)
      */
     @Override
-    public double dot(Vector3D v) {
+    public double dot(final Vector3D v) {
         return LinearCombination.value(x, v.x, y, v.y, z, v.z);
     }
 
@@ -263,7 +294,7 @@
      * other.</p>
      */
     @Override
-    public double angle(Vector3D v) {
+    public double angle(final Vector3D v) {
         double normProduct = getCheckedNorm() * v.getCheckedNorm();
 
         double dot = dot(v);
@@ -283,13 +314,13 @@
 
     /** {@inheritDoc} */
     @Override
-    public Vector3D project(Vector3D base) {
+    public Vector3D project(final Vector3D base) {
         return getComponent(base, false, Vector3D::new);
     }
 
     /** {@inheritDoc} */
     @Override
-    public Vector3D reject(Vector3D base) {
+    public Vector3D reject(final Vector3D base) {
         return getComponent(base, true, Vector3D::new);
     }
 
@@ -303,30 +334,31 @@
      * <pre><code>
      *   Vector3D k = u.normalize();
      *   Vector3D i = k.orthogonal();
-     *   Vector3D j = k.crossProduct(i);
+     *   Vector3D j = k.cross(i);
      * </code></pre>
      * @return a unit vector orthogonal to the instance
-     * @throws IllegalNormException if the norm of the instance is zero, NaN,
-     *  or infinite
+     * @throws org.apache.commons.geometry.core.exception.IllegalNormException if the norm of the instance
+     *      is zero, NaN, or infinite
      */
     @Override
-    public Vector3D orthogonal() {
+    public Vector3D.Unit orthogonal() {
         double threshold = 0.6 * getCheckedNorm();
 
+        double inverse;
         if (Math.abs(x) <= threshold) {
-            double inverse  = 1 / Math.sqrt(y * y + z * z);
-            return new Vector3D(0, inverse * z, -inverse * y);
+            inverse  = 1 / Vectors.norm(y, z);
+            return new Unit(0, inverse * z, -inverse * y);
         } else if (Math.abs(y) <= threshold) {
-            double inverse  = 1 / Math.sqrt(x * x + z * z);
-            return new Vector3D(-inverse * z, 0, inverse * x);
+            inverse  = 1 / Vectors.norm(x, z);
+            return new Unit(-inverse * z, 0, inverse * x);
         }
-        double inverse  = 1 / Math.sqrt(x * x + y * y);
-        return new Vector3D(inverse * y, -inverse * x, 0);
+        inverse  = 1 / Vectors.norm(x, y);
+        return new Unit(inverse * y, -inverse * x, 0);
     }
 
     /** {@inheritDoc} */
     @Override
-    public Vector3D orthogonal(Vector3D dir) {
+    public Vector3D.Unit orthogonal(Vector3D dir) {
         return dir.getComponent(this, true, Vector3D.Unit::from);
     }
 
@@ -340,19 +372,18 @@
                             LinearCombination.value(x, v.y, -y, v.x));
     }
 
-    /** Apply the given transform to this vector, returning the result as a
-     * new vector instance.
-     * @param transform the transform to apply
-     * @return a new, transformed vector
-     * @see AffineTransformMatrix3D#apply(Vector3D)
+    /** Convenience method to apply a function to this vector. This
+     * can be used to transform the vector inline with other methods.
+     * @param fn the function to apply
+     * @return the transformed vector
      */
-    public Vector3D transform(AffineTransformMatrix3D transform) {
-        return transform.apply(this);
+    public Vector3D transform(final Function<Vector3D, Vector3D> fn) {
+        return fn.apply(this);
     }
 
     /** {@inheritDoc} */
     @Override
-    public boolean equals(final Vector3D vec, final DoublePrecisionContext precision) {
+    public boolean eq(final Vector3D vec, final DoublePrecisionContext precision) {
         return precision.eq(x, vec.x) &&
                 precision.eq(y, vec.y) &&
                 precision.eq(z, vec.z);
@@ -422,11 +453,13 @@
      *      returned. If false, the projection of this instance onto {@code base}
      *      is returned.
      * @param factory factory function used to build the final vector
+     * @param <V> Vector implementation type
      * @return The projection or rejection of this instance relative to {@code base},
      *      depending on the value of {@code reject}.
-     * @throws IllegalNormException if {@code base} has a zero, NaN, or infinite norm
+     * @throws org.apache.commons.geometry.core.exception.IllegalNormException if {@code base} has a zero, NaN,
+     *      or infinite norm
      */
-    private Vector3D getComponent(Vector3D base, boolean reject, DoubleFunction3N<Vector3D> factory) {
+    private <V extends Vector3D> V getComponent(Vector3D base, boolean reject, DoubleFunction3N<V> factory) {
         final double aDotB = dot(base);
 
         // We need to check the norm value here to ensure that it's legal. However, we don't
@@ -456,7 +489,7 @@
      * @param z height (third coordinate value)
      * @return vector instance
      */
-    public static Vector3D of(double x, double y, double z) {
+    public static Vector3D of(final double x, final double y, final double z) {
         return new Vector3D(x, y, z);
     }
 
@@ -465,7 +498,7 @@
      * @return new vector
      * @exception IllegalArgumentException if the array does not have 3 elements
      */
-    public static Vector3D of(double[] v) {
+    public static Vector3D of(final double[] v) {
         if (v.length != 3) {
             throw new IllegalArgumentException("Dimension mismatch: " + v.length + " != 3");
         }
@@ -478,7 +511,7 @@
      * @return vector instance represented by the string
      * @throws IllegalArgumentException if the given string has an invalid format
      */
-    public static Vector3D parse(String str) {
+    public static Vector3D parse(final String str) {
         return SimpleTupleFormat.getDefault().parse(str, Vector3D::new);
     }
 
@@ -492,7 +525,7 @@
      * @param c first coordinate
      * @return vector with coordinates calculated by {@code a * c}
      */
-    public static Vector3D linearCombination(double a, Vector3D c) {
+    public static Vector3D linearCombination(final double a, final Vector3D c) {
         return c.multiply(a);
     }
 
@@ -508,7 +541,8 @@
      * @param v2 second coordinate
      * @return vector with coordinates calculated by {@code (a1 * v1) + (a2 * v2)}
      */
-    public static Vector3D linearCombination(double a1, Vector3D v1, double a2, Vector3D v2) {
+    public static Vector3D linearCombination(final double a1, final Vector3D v1,
+            final double a2, final Vector3D v2) {
         return new Vector3D(
                 LinearCombination.value(a1, v1.x, a2, v2.x),
                 LinearCombination.value(a1, v1.y, a2, v2.y),
@@ -529,8 +563,9 @@
      * @param v3 third coordinate
      * @return vector with coordinates calculated by {@code (a1 * v1) + (a2 * v2) + (a3 * v3)}
      */
-    public static Vector3D linearCombination(double a1, Vector3D v1, double a2, Vector3D v2,
-            double a3, Vector3D v3) {
+    public static Vector3D linearCombination(final double a1, final Vector3D v1,
+            final double a2, final Vector3D v2,
+            final double a3, final Vector3D v3) {
         return new Vector3D(
                 LinearCombination.value(a1, v1.x, a2, v2.x, a3, v3.x),
                 LinearCombination.value(a1, v1.y, a2, v2.y, a3, v3.y),
@@ -553,8 +588,10 @@
      * @param v4 fourth coordinate
      * @return point with coordinates calculated by {@code (a1 * v1) + (a2 * v2) + (a3 * v3) + (a4 * v4)}
      */
-    public static Vector3D linearCombination(double a1, Vector3D v1, double a2, Vector3D v2,
-            double a3, Vector3D v3, double a4, Vector3D v4) {
+    public static Vector3D linearCombination(final double a1, final Vector3D v1,
+            final double a2, final Vector3D v2,
+            final double a3, final Vector3D v3,
+            final double a4, final Vector3D v4) {
         return new Vector3D(
                 LinearCombination.value(a1, v1.x, a2, v2.x, a3, v3.x, a4, v4.x),
                 LinearCombination.value(a1, v1.y, a2, v2.y, a3, v3.y, a4, v4.y),
@@ -579,7 +616,7 @@
         /** Negation of unit vector (coordinates: 0, 0, -1). */
         public static final Unit MINUS_Z = new Unit(0d, 0d, -1d);
 
-        /** Serializable version identifier */
+        /** Serializable version identifier. */
         private static final long serialVersionUID = 20180903L;
 
         /** Simple constructor. Callers are responsible for ensuring that the given
@@ -599,9 +636,10 @@
          * @param y Vector coordinate.
          * @param z Vector coordinate.
          * @return a vector whose norm is 1.
-         * @throws IllegalNormException if the norm of the given value is zero, NaN, or infinite
+         * @throws org.apache.commons.geometry.core.exception.IllegalNormException if the norm of the given value
+         *      is zero, NaN, or infinite
          */
-        public static Unit from(double x, double y, double z) {
+        public static Unit from(final double x, final double y, final double z) {
             final double invNorm = 1 / Vectors.checkedNorm(Vectors.norm(x, y, z));
             return new Unit(x * invNorm, y * invNorm, z * invNorm);
         }
@@ -611,9 +649,10 @@
          *
          * @param v Vector.
          * @return a vector whose norm is 1.
-         * @throws IllegalNormException if the norm of the given value is zero, NaN, or infinite
+         * @throws org.apache.commons.geometry.core.exception.IllegalNormException if the norm of the given
+         *      value is zero, NaN, or infinite
          */
-        public static Unit from(Vector3D v) {
+        public static Unit from(final Vector3D v) {
             return v instanceof Unit ?
                 (Unit) v :
                 from(v.getX(), v.getY(), v.getZ());
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/rotation/AxisAngleSequence.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/rotation/AxisAngleSequence.java
index f59a76f..a4c701b 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/rotation/AxisAngleSequence.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/rotation/AxisAngleSequence.java
@@ -63,7 +63,7 @@
  */
 public final class AxisAngleSequence implements Serializable {
 
-    /** Serializable identifier*/
+    /** Serializable identifier. */
     private static final long serialVersionUID = 20181125L;
 
     /** Reference frame for defining axis positions. */
@@ -88,8 +88,8 @@
      * @param angle2 angle around the second axis in radians
      * @param angle3 angle around the third axis in radians
      */
-    public AxisAngleSequence(final AxisReferenceFrame referenceFrame, final AxisSequence axisSequence, final double angle1,
-            final double angle2, final double angle3) {
+    public AxisAngleSequence(final AxisReferenceFrame referenceFrame, final AxisSequence axisSequence,
+            final double angle1, final double angle2, final double angle3) {
         this.referenceFrame = referenceFrame;
         this.axisSequence = axisSequence;
 
@@ -137,7 +137,7 @@
      * @return an array containing the 3 rotation angles
      */
     public double[] getAngles() {
-        return new double[] { angle1, angle2, angle3 };
+        return new double[]{angle1, angle2, angle3};
     }
 
     /** {@inheritDoc} */
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/rotation/AxisSequence.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/rotation/AxisSequence.java
index 813d6c5..6db6243 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/rotation/AxisSequence.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/rotation/AxisSequence.java
@@ -95,7 +95,7 @@
     /** Axis of the third rotation. */
     private final Vector3D axis3;
 
-    /** Simple constructor
+    /** Simple constructor.
      * @param type the axis sequence type
      * @param axis1 first rotation axis
      * @param axis2 second rotation axis
@@ -141,6 +141,6 @@
      * @return a 3-element array containing the rotation axes in order
      */
     public Vector3D[] toArray() {
-        return new Vector3D[] { axis1, axis2, axis3 };
+        return new Vector3D[]{axis1, axis2, axis3};
     }
 }
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 b32762f..0c6f423 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
@@ -20,6 +20,7 @@
 import java.util.Objects;
 
 import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.exception.GeometryValueException;
 import org.apache.commons.geometry.core.exception.IllegalNormException;
 import org.apache.commons.geometry.core.internal.GeometryInternalError;
 import org.apache.commons.geometry.euclidean.internal.Vectors;
@@ -40,7 +41,7 @@
  */
 public final class QuaternionRotation implements Rotation3D, Serializable {
 
-    /** Serializable version identifier */
+    /** Serializable version identifier. */
     private static final long serialVersionUID = 20181018L;
 
     /** Threshold value for the dot product of antiparallel vectors. If the dot product of two vectors is
@@ -92,8 +93,7 @@
         // vector is to just try to normalize it and see if we fail
         try {
             return Vector3D.Unit.from(quat.getX(), quat.getY(), quat.getZ());
-        }
-        catch (IllegalNormException exc) {
+        } catch (IllegalNormException exc) {
             return Vector3D.Unit.PLUS_X;
         }
     }
@@ -155,6 +155,70 @@
                 );
     }
 
+    /** {@inheritDoc}
+     *
+     * <p>This method simply calls {@code apply(vec)} since rotations treat
+     * points and vectors similarly.</p>
+     */
+    @Override
+    public Vector3D applyVector(final Vector3D vec) {
+        return apply(vec);
+    }
+
+    /** {@inheritDoc}
+     *
+     * <p>This method simply returns true since rotations always preserve the orientation
+     * of the space.</p>
+     */
+    @Override
+    public boolean preservesOrientation() {
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public AffineTransformMatrix3D toMatrix() {
+
+        final double qw = quat.getW();
+        final double qx = quat.getX();
+        final double qy = quat.getY();
+        final double qz = quat.getZ();
+
+        // pre-calculate products that we'll need
+        final double xx = qx * qx;
+        final double xy = qx * qy;
+        final double xz = qx * qz;
+        final double xw = qx * qw;
+
+        final double yy = qy * qy;
+        final double yz = qy * qz;
+        final double yw = qy * qw;
+
+        final double zz = qz * qz;
+        final double zw = qz * qw;
+
+        final double m00 = 1.0 - (2.0 * (yy + zz));
+        final double m01 = 2.0 * (xy - zw);
+        final double m02 = 2.0 * (xz + yw);
+        final double m03 = 0.0;
+
+        final double m10 = 2.0 * (xy + zw);
+        final double m11 = 1.0 - (2.0 * (xx + zz));
+        final double m12 = 2.0 * (yz - xw);
+        final double m13 = 0.0;
+
+        final double m20 = 2.0 * (xz - yw);
+        final double m21 = 2.0 * (yz + xw);
+        final double m22 = 1.0 - (2.0 * (xx + yy));
+        final double m23 = 0.0;
+
+        return AffineTransformMatrix3D.of(
+                    m00, m01, m02, m03,
+                    m10, m11, m12, m13,
+                    m20, m21, m22, m23
+                );
+    }
+
     /**
      * Multiply this instance by the given argument, returning the result as
      * a new instance. This is equivalent to the expression {@code t * q} where
@@ -209,55 +273,6 @@
         return new Slerp(quat, end.quat);
     }
 
-    /**
-     * Return a {@link AffineTransformMatrix3D} that performs the rotation
-     * represented by this instance.
-     *
-     * @return an {@link AffineTransformMatrix3D} instance that performs the rotation
-     *         represented by this instance
-     */
-    public AffineTransformMatrix3D toTransformMatrix() {
-
-        final double qw = quat.getW();
-        final double qx = quat.getX();
-        final double qy = quat.getY();
-        final double qz = quat.getZ();
-
-        // pre-calculate products that we'll need
-        final double xx = qx * qx;
-        final double xy = qx * qy;
-        final double xz = qx * qz;
-        final double xw = qx * qw;
-
-        final double yy = qy * qy;
-        final double yz = qy * qz;
-        final double yw = qy * qw;
-
-        final double zz = qz * qz;
-        final double zw = qz * qw;
-
-        final double m00 = 1.0 - (2.0 * (yy + zz));
-        final double m01 = 2.0 * (xy - zw);
-        final double m02 = 2.0 * (xz + yw);
-        final double m03 = 0.0;
-
-        final double m10 = 2.0 * (xy + zw);
-        final double m11 = 1.0 - (2.0 * (xx + zz));
-        final double m12 = 2.0 * (yz - xw);
-        final double m13 = 0.0;
-
-        final double m20 = 2.0 * (xz - yw);
-        final double m21 = 2.0 * (yz + xw);
-        final double m22 = 1.0 - (2.0 * (xx + yy));
-        final double m23 = 0.0;
-
-        return AffineTransformMatrix3D.of(
-                    m00, m01, m02, m03,
-                    m10, m11, m12, m13,
-                    m20, m21, m22, m23
-                );
-    }
-
     /** Get a sequence of axis-angle rotations that produce an overall rotation equivalent to this instance.
      *
      * <p>
@@ -349,16 +364,13 @@
         if (frame == AxisReferenceFrame.RELATIVE) {
             if (sequenceType == AxisSequenceType.TAIT_BRYAN) {
                 return getRelativeTaitBryanAngles(axis1, axis2, axis3);
-            }
-            else if (sequenceType == AxisSequenceType.EULER) {
+            } else if (sequenceType == AxisSequenceType.EULER) {
                 return getRelativeEulerAngles(axis1, axis2, axis3);
             }
-        }
-        else if (frame == AxisReferenceFrame.ABSOLUTE) {
+        } else if (frame == AxisReferenceFrame.ABSOLUTE) {
             if (sequenceType == AxisSequenceType.TAIT_BRYAN) {
                 return getAbsoluteTaitBryanAngles(axis1, axis2, axis3);
-            }
-            else if (sequenceType == AxisSequenceType.EULER) {
+            } else if (sequenceType == AxisSequenceType.EULER) {
                 return getAbsoluteEulerAngles(axis1, axis2, axis3);
             }
         }
@@ -394,7 +406,9 @@
             final double angle1TanY = vec2.dot(axis1.cross(axis2));
             final double angle1TanX = vec2.dot(axis2);
 
-            final double angle2 = angle2Sin > AXIS_ANGLE_SINGULARITY_THRESHOLD ? Geometry.HALF_PI : Geometry.MINUS_HALF_PI;
+            final double angle2 = angle2Sin > AXIS_ANGLE_SINGULARITY_THRESHOLD ?
+                    Geometry.HALF_PI :
+                    Geometry.MINUS_HALF_PI;
 
             return new double[] {
                 Math.atan2(angle1TanY, angle1TanX),
@@ -560,7 +574,7 @@
      * @return a new instance representing the defined rotation
      *
      * @throws IllegalNormException if the given axis cannot be normalized
-     * @throws IllegalArgumentException if the angle is NaN or infinite
+     * @throws GeometryValueException if the angle is NaN or infinite
      */
     public static QuaternionRotation fromAxisAngle(final Vector3D axis, final double angle) {
         // reference formula:
@@ -568,7 +582,7 @@
         final Vector3D normAxis = axis.normalize();
 
         if (!Double.isFinite(angle)) {
-            throw new IllegalArgumentException("Invalid angle: " + angle);
+            throw new GeometryValueException("Invalid angle: " + angle);
         }
 
         final double halfAngle = 0.5 * angle;
@@ -766,8 +780,7 @@
             y = (m02 - m20) * sinv;
             z = (m10 - m01) * sinv;
             w = 0.25 * s;
-        }
-        else if ((m00 > m11) && (m00 > m22)) {
+        } else if ((m00 > m11) && (m00 > m22)) {
             // let s = 4*x
             final double s = 2.0 * Math.sqrt(1.0 + m00 - m11 - m22);
             final double sinv = 1.0 / s;
@@ -776,8 +789,7 @@
             y = (m01 + m10) * sinv;
             z = (m02 + m20) * sinv;
             w = (m21 - m12) * sinv;
-        }
-        else if (m11 > m22) {
+        } else if (m11 > m22) {
             // let s = 4*y
             final double s = 2.0 * Math.sqrt(1.0 + m11 - m00 - m22);
             final double sinv = 1.0 / s;
@@ -786,8 +798,7 @@
             y = 0.25 * s;
             z = (m21 + m12) * sinv;
             w = (m02 - m20) * sinv;
-        }
-        else {
+        } else {
             // let s = 4*z
             final double s = 2.0 * Math.sqrt(1.0 + m22 - m00 - m11);
             final double sinv = 1.0 / s;
@@ -813,7 +824,7 @@
         int i;
         int j;
 
-        for (i=0, j=len-1; i < len / 2; ++i, --j) {
+        for (i = 0, j = len - 1; i < len / 2; ++i, --j) {
             temp = arr[i];
             arr[i] = arr[j];
             arr[j] = temp;
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/rotation/Rotation3D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/rotation/Rotation3D.java
index 9b96ee8..395f9f0 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/rotation/Rotation3D.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/rotation/Rotation3D.java
@@ -16,16 +16,13 @@
  */
 package org.apache.commons.geometry.euclidean.threed.rotation;
 
-import org.apache.commons.geometry.core.partitioning.Hyperplane;
-import org.apache.commons.geometry.core.partitioning.SubHyperplane;
-import org.apache.commons.geometry.core.partitioning.Transform;
+import org.apache.commons.geometry.euclidean.threed.Transform3D;
 import org.apache.commons.geometry.euclidean.threed.Vector3D;
-import org.apache.commons.geometry.euclidean.twod.Vector2D;
 
 /** Interface representing a generic rotation in 3-dimensional Euclidean
  * space.
  */
-public interface Rotation3D extends Transform<Vector3D, Vector2D> {
+public interface Rotation3D extends Transform3D {
 
     /** Apply this rotation to the given argument. Since rotations do
      * not affect vector magnitudes, this method can be applied to
@@ -61,21 +58,4 @@
      * @return the inverse rotation.
      */
     Rotation3D inverse();
-
-    /** {@inheritDoc}
-     * This operation is not supported. See GEOMETRY-24.
-     */
-    @Override
-    default Hyperplane<Vector3D> apply(Hyperplane<Vector3D> hyperplane) {
-        throw new UnsupportedOperationException("Transforming hyperplanes is not supported");
-    }
-
-    /** {@inheritDoc}
-     * This operation is not supported. See GEOMETRY-24.
-     */
-    @Override
-    default SubHyperplane<Vector2D> apply(SubHyperplane<Vector2D> sub, Hyperplane<Vector3D> original,
-            Hyperplane<Vector3D> transformed) {
-        throw new UnsupportedOperationException("Transforming sub-hyperplanes is not supported");
-    }
 }
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/AbstractSegmentConnector.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/AbstractSegmentConnector.java
new file mode 100644
index 0000000..e7cbef2
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/AbstractSegmentConnector.java
@@ -0,0 +1,306 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.twod;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+import org.apache.commons.geometry.euclidean.internal.AbstractPathConnector;
+import org.apache.commons.numbers.angle.PlaneAngleRadians;
+
+/** Abstract class for joining collections of line segments into connected
+ * paths. This class is not thread-safe.
+ */
+public abstract class AbstractSegmentConnector
+    extends AbstractPathConnector<AbstractSegmentConnector.ConnectableSegment> {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20190528L;
+
+    /** Add a line segment to the connector, leaving it unconnected until a later call to
+     * to {@link #connect(Iterable)} or {@link #connectAll()}.
+     * @param segment line segment to add
+     * @see #connect(Iterable)
+     * @see #connectAll()
+     */
+    public void add(final Segment segment) {
+        addPathElement(new ConnectableSegment(segment));
+    }
+
+    /** Add a collection of line segments to the connector, leaving them unconnected
+     * until a later call to {@link #connect(Iterable)} or
+     * {@link #connectAll()}.
+     * @param segments line segments to add
+     * @see #connect(Iterable)
+     * @see #connectAll()
+     * @see #add(Segment)
+     */
+    public void add(final Iterable<Segment> segments) {
+        for (Segment segment : segments) {
+            add(segment);
+        }
+    }
+
+    /** Add a collection of line segments to the connector and attempt to connect each new
+     * segment with existing segments. Connections made at this time will not be
+     * overwritten by subsequent calls to this or other connection methods.
+     * (eg, {@link #connectAll()}).
+     *
+     * <p>The connector is not reset by this call. Additional segments can still be added
+     * to the current set of paths.</p>
+     * @param segments line segments to connect
+     * @see #connectAll()
+     */
+    public void connect(final Iterable<Segment> segments) {
+        List<ConnectableSegment> newEntries = new ArrayList<>();
+
+        for (Segment segment : segments) {
+            newEntries.add(new ConnectableSegment(segment));
+        }
+
+        connectPathElements(newEntries);
+    }
+
+    /** Add the given line segments to this instance and connect all current
+     * segments into polylines (ie, line segment paths). This call is equivalent to
+     * <pre>
+     *      connector.add(segments);
+     *      List&lt;Polyline&gt; result = connector.connectAll();
+     * </pre>
+     *
+     * <p>The connector is reset after this call. Further calls to
+     * add or connect line segments will result in new paths being
+     * generated.</p>
+     * @param segments line segments to add
+     * @return the connected line segment paths
+     * @see #add(Iterable)
+     * @see #connectAll()
+     */
+    public List<Polyline> connectAll(final Iterable<Segment> segments) {
+        add(segments);
+        return connectAll();
+    }
+
+    /** Connect all current segments into connected paths, returning the result as a
+     * list of polylines.
+     *
+     * <p>The connector is reset after this call. Further calls to
+     * add or connect line segments will result in new paths being
+     * generated.</p>
+     * @return the connected line segments paths
+     */
+    public List<Polyline> connectAll() {
+        final List<ConnectableSegment> roots = computePathRoots();
+        final List<Polyline> paths = new ArrayList<>(roots.size());
+
+        for (ConnectableSegment root : roots) {
+            paths.add(toPolyline(root));
+        }
+
+        return paths;
+    }
+
+    /** Convert the linked list of path elements starting at the argument
+     * into a {@link Polyline}.
+     * @param root root of a connected path linked list
+     * @return a polyline representing the linked list path
+     */
+    private Polyline toPolyline(final ConnectableSegment root) {
+        final Polyline.Builder builder = Polyline.builder(null);
+
+        builder.append(root.getSegment());
+
+        ConnectableSegment current = root.getNext();
+
+        while (current != null && current != root) {
+            builder.append(current.getSegment());
+            current = current.getNext();
+        }
+
+        return builder.build();
+    }
+
+    /** Internal class used to connect line segments together.
+     */
+    protected static class ConnectableSegment extends AbstractPathConnector.ConnectableElement<ConnectableSegment> {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20191107L;
+
+        /** Segment start point. This will be used to connect to other path elements. */
+        private final Vector2D start;
+
+        /** Line segment for the entry. */
+        private final Segment segment;
+
+        /** Create a new instance with the given start point. This constructor is
+         * intended only for performing searches for other path elements.
+         * @param start start point
+         */
+        public ConnectableSegment(final Vector2D start) {
+            this(start, null);
+        }
+
+        /** Create a new instance from the given line segment.
+         * @param segment line segment
+         */
+        public ConnectableSegment(final Segment segment) {
+            this(segment.getStartPoint(), segment);
+        }
+
+        /** Create a new instance with the given start point and line segment.
+         * @param start start point
+         * @param segment line segment
+         */
+        private ConnectableSegment(final Vector2D start, final Segment segment) {
+            this.start = start;
+            this.segment = segment;
+        }
+
+        /** Get the line segment for this instance.
+         * @return the line segment for this instance
+         */
+        public Segment getSegment() {
+            return segment;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean hasStart() {
+            return start != null;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean hasEnd() {
+            return segment != null && segment.getEndPoint() != null;
+        }
+
+        /** Return true if this instance has a size equivalent to zero.
+         * @return true if this instance has a size equivalent to zero.
+         */
+        public boolean hasZeroSize() {
+            return segment != null && segment.getPrecision().eqZero(segment.getSize());
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean endPointsEq(final ConnectableSegment other) {
+            if (hasEnd() && other.hasEnd()) {
+                return segment.getEndPoint()
+                        .eq(other.segment.getEndPoint(), segment.getPrecision());
+            }
+
+            return false;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean canConnectTo(final ConnectableSegment next) {
+            final Vector2D end = segment.getEndPoint();
+            final Vector2D nextStart = next.start;
+
+            return end != null && nextStart != null &&
+                    end.eq(nextStart, segment.getPrecision());
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public double getRelativeAngle(final ConnectableSegment next) {
+            return segment.getLine().angle(next.getSegment().getLine());
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public ConnectableSegment getConnectionSearchKey() {
+            return new ConnectableSegment(segment.getEndPoint());
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean shouldContinueConnectionSearch(final ConnectableSegment candidate, final boolean ascending) {
+
+            if (candidate.hasStart()) {
+                final double candidateX = candidate.getSegment().getStartPoint().getX();
+                final double thisX = segment.getEndPoint().getX();
+                final int cmp = segment.getPrecision().compare(candidateX, thisX);
+
+                return ascending ? cmp <= 0 : cmp >= 0;
+            }
+
+            return true;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public int compareTo(ConnectableSegment other) {
+            // sort by coordinates
+            int cmp = Vector2D.COORDINATE_ASCENDING_ORDER.compare(start, other.start);
+            if (cmp == 0) {
+                // sort entries without segments before ones with segments
+                final boolean thisHasSegment = segment != null;
+                final boolean otherHasSegment = other.segment != null;
+
+                cmp = Boolean.compare(thisHasSegment, otherHasSegment);
+
+                if (cmp == 0 && thisHasSegment) {
+                    // place point-like segments before ones with non-zero length
+                    cmp = Boolean.compare(this.hasZeroSize(), other.hasZeroSize());
+
+                    if (cmp == 0) {
+                        // sort by line angle
+                        final double aAngle = PlaneAngleRadians.normalizeBetweenMinusPiAndPi(
+                                this.getSegment().getLine().getAngle());
+                        final double bAngle = PlaneAngleRadians.normalizeBetweenMinusPiAndPi(
+                                other.getSegment().getLine().getAngle());
+
+                        cmp = Double.compare(aAngle, bAngle);
+                    }
+                }
+            }
+            return cmp;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public int hashCode() {
+            return Objects.hash(start, segment);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean equals(final Object obj) {
+            if (this == obj) {
+                return true;
+            }
+            if (obj == null || !this.getClass().equals(obj.getClass())) {
+                return false;
+            }
+
+            final ConnectableSegment other = (ConnectableSegment) obj;
+            return Objects.equals(this.start, other.start) &&
+                    Objects.equals(this.segment, other.segment);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected ConnectableSegment getSelf() {
+            return this;
+        }
+    }
+}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/AbstractSubLine.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/AbstractSubLine.java
new file mode 100644
index 0000000..f039905
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/AbstractSubLine.java
@@ -0,0 +1,131 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.twod;
+
+import java.util.function.BiFunction;
+
+import org.apache.commons.geometry.core.partitioning.AbstractEmbeddingSubHyperplane;
+import org.apache.commons.geometry.core.partitioning.Hyperplane;
+import org.apache.commons.geometry.core.partitioning.HyperplaneBoundedRegion;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.SplitLocation;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.oned.OrientedPoint;
+import org.apache.commons.geometry.euclidean.oned.Vector1D;
+import org.apache.commons.geometry.euclidean.twod.SubLine.SubLineBuilder;
+
+/** Internal base class for subline implementations.
+ */
+abstract class AbstractSubLine extends AbstractEmbeddingSubHyperplane<Vector2D, Vector1D, Line> {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20190729L;
+
+    /** The line defining this instance. */
+    private final Line line;
+
+    /** Construct a new instance based on the given line.
+     * @param line line forming the base of the instance
+     */
+    AbstractSubLine(final Line line) {
+        this.line = line;
+    }
+
+    /** Get the line that this segment lies on. This method is an alias
+     * for {@link #getHyperplane()}.
+     * @return the line that this segment lies on
+     * @see #getHyperplane()
+     */
+    public Line getLine() {
+        return getHyperplane();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Line getHyperplane() {
+        return line;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public SubLineBuilder builder() {
+        return new SubLineBuilder(line);
+    }
+
+    /** Return the object used to perform floating point comparisons, which is the
+     * same object used by the underlying {@link Line}).
+     * @return precision object used to perform floating point comparisons.
+     */
+    public DoublePrecisionContext getPrecision() {
+        return line.getPrecision();
+    }
+
+    /** Generic, internal split method. Subclasses should call this from their
+     * {@link #split(Hyperplane)} methods.
+     * @param splitter splitting hyperplane
+     * @param thisInstance a reference to the current instance; this is passed as
+     *      an argument in order to allow it to be a generic type
+     * @param factory function used to create new subhyperplane instances
+     * @param <T> Subline implementation type
+     * @return the result of the split operation
+     */
+    protected <T extends AbstractSubLine> Split<T> splitInternal(final Hyperplane<Vector2D> splitter,
+            final T thisInstance, final BiFunction<Line, HyperplaneBoundedRegion<Vector1D>, T> factory) {
+
+        final Line thisLine = getLine();
+        final Line splitterLine = (Line) splitter;
+        final DoublePrecisionContext precision = getPrecision();
+
+        final Vector2D intersection = splitterLine.intersection(thisLine);
+        if (intersection == null) {
+            // the lines are parallel or coincident; check which side of
+            // the splitter we lie on
+            final double offset = splitterLine.offset(thisLine);
+            final int comp = precision.compare(offset, 0.0);
+
+            if (comp < 0) {
+                return new Split<>(thisInstance, null);
+            } else if (comp > 0) {
+                return new Split<>(null, thisInstance);
+            } else {
+                return new Split<>(null, null);
+            }
+        } else {
+            // the lines intersect; split the subregion
+            final Vector1D splitPt = thisLine.toSubspace(intersection);
+            final boolean positiveFacing = thisLine.angle(splitterLine) > 0.0;
+
+            final OrientedPoint subspaceSplitter = OrientedPoint.fromPointAndDirection(splitPt,
+                    positiveFacing, getPrecision());
+
+            final Split<? extends HyperplaneBoundedRegion<Vector1D>> split =
+                    thisInstance.getSubspaceRegion().split(subspaceSplitter);
+            final SplitLocation subspaceSplitLoc = split.getLocation();
+
+            if (SplitLocation.MINUS == subspaceSplitLoc) {
+                return new Split<>(thisInstance, null);
+            } else if (SplitLocation.PLUS == subspaceSplitLoc) {
+                return new Split<>(null, thisInstance);
+            }
+
+            final T minus = (split.getMinus() != null) ? factory.apply(thisLine, split.getMinus()) : null;
+            final T plus = (split.getPlus() != null) ? factory.apply(thisLine, split.getPlus()) : null;
+
+            return new Split<>(minus, plus);
+        }
+    }
+}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/AffineTransformMatrix2D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/AffineTransformMatrix2D.java
index ae80335..e32bfe2 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/AffineTransformMatrix2D.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/AffineTransformMatrix2D.java
@@ -19,11 +19,10 @@
 import java.io.Serializable;
 
 import org.apache.commons.geometry.core.internal.DoubleFunction2N;
-import org.apache.commons.geometry.euclidean.AffineTransformMatrix;
+import org.apache.commons.geometry.euclidean.AbstractAffineTransformMatrix;
 import org.apache.commons.geometry.euclidean.exception.NonInvertibleTransformException;
 import org.apache.commons.geometry.euclidean.internal.Matrices;
 import org.apache.commons.geometry.euclidean.internal.Vectors;
-import org.apache.commons.geometry.euclidean.oned.Vector1D;
 import org.apache.commons.numbers.arrays.LinearCombination;
 import org.apache.commons.numbers.core.Precision;
 
@@ -35,24 +34,25 @@
 * use arrays containing 6 elements, instead of 9.
 * </p>
 */
-public final class AffineTransformMatrix2D implements AffineTransformMatrix<Vector2D, Vector1D>, Serializable {
+public final class AffineTransformMatrix2D extends AbstractAffineTransformMatrix<Vector2D>
+    implements Transform2D, Serializable {
 
-    /** Serializable version identifier */
+    /** Serializable version identifier. */
     private static final long serialVersionUID = 20181005L;
 
-    /** The number of internal matrix elements */
+    /** The number of internal matrix elements. */
     private static final int NUM_ELEMENTS = 6;
 
-    /** String used to start the transform matrix string representation */
+    /** String used to start the transform matrix string representation. */
     private static final String MATRIX_START = "[ ";
 
-    /** String used to end the transform matrix string representation */
+    /** String used to end the transform matrix string representation. */
     private static final String MATRIX_END = " ]";
 
-    /** String used to separate elements in the matrix string representation */
+    /** String used to separate elements in the matrix string representation. */
     private static final String ELEMENT_SEPARATOR = ", ";
 
-    /** String used to separate rows in the matrix string representation */
+    /** String used to separate rows in the matrix string representation. */
     private static final String ROW_SEPARATOR = "; ";
 
     /** Shared transform set to the identity matrix. */
@@ -61,18 +61,18 @@
                 0, 1, 0
             );
 
-    /** Transform matrix entry <code>m<sub>0,0</sub></code> */
+    /** Transform matrix entry <code>m<sub>0,0</sub></code>. */
     private final double m00;
-    /** Transform matrix entry <code>m<sub>0,1</sub></code> */
+    /** Transform matrix entry <code>m<sub>0,1</sub></code>. */
     private final double m01;
-    /** Transform matrix entry <code>m<sub>0,2</sub></code> */
+    /** Transform matrix entry <code>m<sub>0,2</sub></code>. */
     private final double m02;
 
-    /** Transform matrix entry <code>m<sub>1,0</sub></code> */
+    /** Transform matrix entry <code>m<sub>1,0</sub></code>. */
     private final double m10;
-    /** Transform matrix entry <code>m<sub>1,1</sub></code> */
+    /** Transform matrix entry <code>m<sub>1,1</sub></code>. */
     private final double m11;
-    /** Transform matrix entry <code>m<sub>1,2</sub></code> */
+    /** Transform matrix entry <code>m<sub>1,2</sub></code>. */
     private final double m12;
 
     /**
@@ -112,8 +112,8 @@
      */
     public double[] toArray() {
         return new double[] {
-                m00, m01, m02,
-                m10, m11, m12
+            m00, m01, m02,
+            m10, m11, m12
         };
     }
 
@@ -163,10 +163,28 @@
      * @see #applyVector(Vector2D)
      */
     @Override
-    public Vector2D applyDirection(final Vector2D vec) {
+    public Vector2D.Unit applyDirection(final Vector2D vec) {
         return applyVector(vec, Vector2D.Unit::from);
     }
 
+    /** {@inheritDoc} */
+    @Override
+    public double determinant() {
+        return Matrices.determinant(
+                m00, m01,
+                m10, m11
+            );
+    }
+
+    /** {@inheritDoc}
+    *
+    * <p>This simply returns the current instance.</p>
+    */
+    @Override
+    public AffineTransformMatrix2D toMatrix() {
+        return this;
+    }
+
     /** Apply a translation to the current instance, returning the result as a new transform.
      * @param translation vector containing the translation values for each axis
      * @return a new transform containing the result of applying a translation to
@@ -281,11 +299,7 @@
         // Our full matrix is 3x3 but we can significantly reduce the amount of computations
         // needed here since we know that our last row is [0 0 1].
 
-        // compute the determinant of the matrix
-        final double det = Matrices.determinant(
-                    m00, m01,
-                    m10, m11
-                );
+        final double det = determinant();
 
         if (!Vectors.isRealNonZero(det)) {
             throw new NonInvertibleTransformException("Transform is not invertible; matrix determinant is " + det);
@@ -301,13 +315,13 @@
         final double invDet = 1.0 / det;
 
         final double c00 = invDet * m11;
-        final double c01 = - invDet * m10;
+        final double c01 = -invDet * m10;
 
-        final double c10 = - invDet * m01;
+        final double c10 = -invDet * m01;
         final double c11 = invDet * m00;
 
         final double c20 = invDet * Matrices.determinant(m01, m02, m11, m12);
-        final double c21 = - invDet * Matrices.determinant(m00, m02, m10, m12);
+        final double c21 = -invDet * Matrices.determinant(m00, m02, m10, m12);
 
         return new AffineTransformMatrix2D(
                     c00, c10, c20,
@@ -403,7 +417,7 @@
      * @return a new transform initialized with the given matrix values
      * @throws IllegalArgumentException if the array does not have 6 elements
      */
-    public static AffineTransformMatrix2D of(final double ... arr) {
+    public static AffineTransformMatrix2D of(final double... arr) {
         if (arr.length != NUM_ELEMENTS) {
             throw new IllegalArgumentException("Dimension mismatch: " + arr.length + " != " + NUM_ELEMENTS);
         }
@@ -537,7 +551,8 @@
      * @param b second transform
      * @return the transform computed as {@code a x b}
      */
-    private static AffineTransformMatrix2D multiply(final AffineTransformMatrix2D a, final AffineTransformMatrix2D b) {
+    private static AffineTransformMatrix2D multiply(final AffineTransformMatrix2D a,
+            final AffineTransformMatrix2D b) {
 
         final double c00 = LinearCombination.value(a.m00, b.m00, a.m01, b.m10);
         final double c01 = LinearCombination.value(a.m00, b.m01, a.m01, b.m11);
@@ -561,7 +576,8 @@
      */
     private static void validateElementForInverse(final double element) {
         if (!Double.isFinite(element)) {
-            throw new NonInvertibleTransformException("Transform is not invertible; invalid matrix element: " + element);
+            throw new NonInvertibleTransformException(
+                    "Transform is not invertible; invalid matrix element: " + element);
         }
     }
 }
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/ConvexArea.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/ConvexArea.java
new file mode 100644
index 0000000..b152b05
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/ConvexArea.java
@@ -0,0 +1,294 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.twod;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.partitioning.AbstractConvexHyperplaneBoundedRegion;
+import org.apache.commons.geometry.core.partitioning.ConvexSubHyperplane;
+import org.apache.commons.geometry.core.partitioning.Hyperplane;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+
+/** Class representing a finite or infinite convex area in Euclidean 2D space.
+ * The boundaries of this area, if any, are composed of line segments.
+ */
+public final class ConvexArea extends AbstractConvexHyperplaneBoundedRegion<Vector2D, Segment> {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20190619L;
+
+    /** Instance representing the full 2D plane. */
+    private static final ConvexArea FULL = new ConvexArea(Collections.emptyList());
+
+    /** Simple constructor. Callers are responsible for ensuring that the given path
+     * represents the boundary of a convex area. No validation is performed.
+     * @param boundaries the boundaries of the convex area
+     */
+    private ConvexArea(final List<Segment> boundaries) {
+        super(boundaries);
+    }
+
+    /** Get the connected line segment paths comprising the boundary of the area. The
+     * segments are oriented so that their minus sides point toward the interior of the
+     * region. The size of the returned list is
+     * <ul>
+     *      <li><strong>0</strong> if the convex area is full,</li>
+     *      <li><strong>1</strong> if at least one boundary is present and
+     *          a single path can connect all segments (this will be the case
+     *          for most instances), and</li>
+     *      <li><strong>2</strong> if only two boundaries exist and they are
+     *          parallel to each other (in which case they cannot be connected
+     *          as a single path).</li>
+     * </ul>
+     * @return the line segment paths comprising the boundary of the area.
+     */
+    public List<Polyline> getBoundaryPaths() {
+        return InteriorAngleSegmentConnector.connectMinimized(getBoundaries());
+    }
+
+    /** Get the vertices for the area. The vertices lie at the intersections of the
+     * area bounding lines.
+     * @return the vertices for the area
+     */
+    public List<Vector2D> getVertices() {
+        final List<Polyline> path = getBoundaryPaths();
+
+        // we will only have vertices if we have a single path; otherwise, we have a full
+        // area or two non-intersecting infinite segments
+        if (path.size() == 1) {
+            return path.get(0).getVertices();
+        }
+
+        return Collections.emptyList();
+    }
+
+    /** Return a new instance transformed by the argument.
+     * @param transform transform to apply
+     * @return a new instance transformed by the argument
+     */
+    public ConvexArea transform(final Transform<Vector2D> transform) {
+        return transformInternal(transform, this, Segment.class, ConvexArea::new);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Segment trim(final ConvexSubHyperplane<Vector2D> convexSubHyperplane) {
+        return (Segment) super.trim(convexSubHyperplane);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public double getSize() {
+        if (isFull()) {
+            return Double.POSITIVE_INFINITY;
+        }
+
+        double quadrilateralAreaSum = 0.0;
+
+        for (Segment segment : getBoundaries()) {
+            if (segment.isInfinite()) {
+                return Double.POSITIVE_INFINITY;
+            }
+
+            quadrilateralAreaSum += segment.getStartPoint().signedArea(segment.getEndPoint());
+        }
+
+        return 0.5 * quadrilateralAreaSum;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Vector2D getBarycenter() {
+        List<Segment> boundaries = getBoundaries();
+
+        if (!boundaries.isEmpty()) {
+            double quadrilateralAreaSum = 0.0;
+            double scaledSumX = 0.0;
+            double scaledSumY = 0.0;
+
+            double signedArea;
+            Vector2D startPoint;
+            Vector2D endPoint;
+
+            for (Segment seg : boundaries) {
+                if (seg.isInfinite()) {
+                    // infinite => no barycenter
+                    return null;
+                }
+
+                startPoint = seg.getStartPoint();
+                endPoint = seg.getEndPoint();
+
+                signedArea = startPoint.signedArea(endPoint);
+
+                quadrilateralAreaSum += signedArea;
+
+                scaledSumX += signedArea * (startPoint.getX() + endPoint.getX());
+                scaledSumY += signedArea * (startPoint.getY() + endPoint.getY());
+            }
+
+            return Vector2D.of(scaledSumX, scaledSumY).multiply(1.0 / (3.0 * quadrilateralAreaSum));
+        }
+
+        return null;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Split<ConvexArea> split(final Hyperplane<Vector2D> splitter) {
+        return splitInternal(splitter, this, Segment.class, ConvexArea::new);
+    }
+
+    /** Return a BSP tree instance representing the same region as the current instance.
+     * @return a BSP tree instance representing the same region as the current instance
+     */
+    public RegionBSPTree2D toTree() {
+        return RegionBSPTree2D.from(this);
+    }
+
+    /** Return an instance representing the full 2D area.
+     * @return an instance representing the full 2D area.
+     */
+    public static ConvexArea full() {
+        return FULL;
+    }
+
+    /** Construct a convex area by creating lines between adjacent vertices. The vertices must be given in a
+     * counter-clockwise around order the interior of the shape. If the area is intended to be closed, the
+     * beginning point must be repeated at the end of the path.
+     * @param vertices vertices to use to construct the area
+     * @param precision precision context used to create new line instances
+     * @return a convex area constructed using lines between adjacent vertices
+     * @see #fromVertexLoop(Collection, DoublePrecisionContext)
+     * @see #fromVertices(Collection, boolean, DoublePrecisionContext)
+     */
+    public static ConvexArea fromVertices(final Collection<Vector2D> vertices,
+            final DoublePrecisionContext precision) {
+        return fromVertices(vertices, false, precision);
+    }
+
+    /** Construct a convex area by creating lines between adjacent vertices. An implicit line is created between the
+     * last vertex given and the first one. The vertices must be given in a counter-clockwise around order the interior
+     * of the shape.
+     * @param vertices vertices to use to construct the area
+     * @param precision precision context used to create new line instances
+     * @return a convex area constructed using lines between adjacent vertices
+     * @see #fromVertices(Collection, DoublePrecisionContext)
+     * @see #fromVertices(Collection, boolean, DoublePrecisionContext)
+     */
+    public static ConvexArea fromVertexLoop(final Collection<Vector2D> vertices,
+            final DoublePrecisionContext precision) {
+        return fromVertices(vertices, true, precision);
+    }
+
+    /** Construct a convex area from lines between adjacent vertices.
+     * @param vertices vertices to use to construct the area
+     * @param close if true, an additional line will be created between the last and first vertex
+     * @param precision precision context used to create new line instances
+     * @return a convex area constructed using lines between adjacent vertices
+     */
+    public static ConvexArea fromVertices(final Collection<Vector2D> vertices, boolean close,
+            final DoublePrecisionContext precision) {
+        if (vertices.isEmpty()) {
+            return full();
+        }
+
+        final List<Line> lines = new ArrayList<>();
+
+        Vector2D first = null;
+        Vector2D prev = null;
+        Vector2D cur = null;
+
+        for (Vector2D vertex : vertices) {
+            cur = vertex;
+
+            if (first == null) {
+                first = cur;
+            }
+
+            if (prev != null && !cur.eq(prev, precision)) {
+                lines.add(Line.fromPoints(prev, cur, precision));
+            }
+
+            prev = cur;
+        }
+
+        if (close && cur != null && !cur.eq(first, precision)) {
+            lines.add(Line.fromPoints(cur, first, precision));
+        }
+
+        if (!vertices.isEmpty() && lines.isEmpty()) {
+            throw new IllegalStateException("Unable to create convex area: only a single unique vertex provided");
+        }
+
+        return fromBounds(lines);
+    }
+
+    /** Construct a convex area from a line segment path. The area represents the intersection of all of the negative
+     * half-spaces of the lines in the path. The boundaries of the returned area may therefore not match the line
+     * segments in the path.
+     * @param path path to construct the area from
+     * @return a convex area constructed from the lines in the given path
+     */
+    public static ConvexArea fromPath(final Polyline path) {
+        final List<Line> lines = new ArrayList<>();
+        for (Segment segment : path) {
+            lines.add(segment.getLine());
+        }
+
+        return fromBounds(lines);
+    }
+
+    /** Create a convex area formed by the intersection of the negative half-spaces of the
+     * given bounding lines. The returned instance represents the area that is on the
+     * minus side of all of the given lines. Note that this method does not support areas
+     * of zero size (ie, infinitely thin areas or points.)
+     * @param bounds lines used to define the convex area
+     * @return a new convex area instance representing the area on the minus side of all
+     *      of the bounding lines or an instance representing the full area if no lines are
+     *      given
+     * @throws org.apache.commons.geometry.core.exception.GeometryException if the given set of bounding lines do
+     *      not form a convex area, meaning that there is no region that is on the minus side of all of the bounding
+     *      lines.
+     */
+    public static ConvexArea fromBounds(final Line... bounds) {
+        return fromBounds(Arrays.asList(bounds));
+    }
+
+    /** Create a convex area formed by the intersection of the negative half-spaces of the
+     * given bounding lines. The returned instance represents the area that is on the
+     * minus side of all of the given lines. Note that this method does not support areas
+     * of zero size (ie, infinitely thin areas or points.)
+     * @param bounds lines used to define the convex area
+     * @return a new convex area instance representing the area on the minus side of all
+     *      of the bounding lines or an instance representing the full area if the collection
+     *      is empty
+     * @throws org.apache.commons.geometry.core.exception.GeometryException if the given set of bounding lines do
+     *      not form a convex area, meaning that there is no region that is on the minus side of all of the bounding
+     *      lines.
+     */
+    public static ConvexArea fromBounds(final Iterable<Line> bounds) {
+        final List<Segment> segments = new ConvexRegionBoundaryBuilder<>(Segment.class).build(bounds);
+        return segments.isEmpty() ? full() : new ConvexArea(segments);
+    }
+}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/FunctionTransform2D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/FunctionTransform2D.java
new file mode 100644
index 0000000..7062f1c
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/FunctionTransform2D.java
@@ -0,0 +1,103 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.twod;
+
+import java.util.function.Function;
+
+import org.apache.commons.geometry.euclidean.internal.Matrices;
+
+/** Class that wraps a {@link Function} with the {@link Transform2D} interface.
+ */
+public final class FunctionTransform2D implements Transform2D {
+
+    /** Static instance representing the identity transform. */
+    private static final FunctionTransform2D IDENTITY =
+            new FunctionTransform2D(Function.identity(), true, Vector2D.ZERO);
+
+    /** The underlying function for the transform. */
+    private final Function<Vector2D, Vector2D> fn;
+
+    /** True if the transform preserves spatial orientation. */
+    private final boolean preservesOrientation;
+
+    /** The translation component of the transform. */
+    private final Vector2D translation;
+
+    /** Construct a new instance from its component parts. No validation of the input is performed.
+     * @param fn the underlying function for the transform
+     * @param preservesOrientation true if the transform preserves spatial orientation
+     * @param translation the translation component of the transform
+     */
+    private FunctionTransform2D(final Function<Vector2D, Vector2D> fn, final boolean preservesOrientation,
+            final Vector2D translation) {
+        this.fn = fn;
+        this.preservesOrientation = preservesOrientation;
+        this.translation = translation;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Vector2D apply(final Vector2D pt) {
+        return fn.apply(pt);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Vector2D applyVector(final Vector2D vec) {
+        return apply(vec).subtract(translation);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean preservesOrientation() {
+        return preservesOrientation;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public AffineTransformMatrix2D toMatrix() {
+        final Vector2D u = applyVector(Vector2D.Unit.PLUS_X);
+        final Vector2D v = applyVector(Vector2D.Unit.PLUS_Y);
+
+        return AffineTransformMatrix2D.fromColumnVectors(u, v, translation);
+    }
+
+    /** Return an instance representing the identity transform.
+     * @return an instance representing the identity transform
+     */
+    public static FunctionTransform2D identity() {
+        return IDENTITY;
+    }
+
+    /** Construct a new transform instance from the given function.
+     * @param fn the function to use for the transform
+     * @return a new transform instance using the given function
+     */
+    public static FunctionTransform2D from(final Function<Vector2D, Vector2D> fn) {
+        final Vector2D tPlusX = fn.apply(Vector2D.Unit.PLUS_X);
+        final Vector2D tPlusY = fn.apply(Vector2D.Unit.PLUS_Y);
+        final Vector2D tZero = fn.apply(Vector2D.ZERO);
+
+        final double det = Matrices.determinant(
+                tPlusX.getX(), tPlusY.getX(),
+                tPlusX.getY(), tPlusY.getY()
+            );
+        final boolean preservesOrientation = det > 0;
+
+        return new FunctionTransform2D(fn, preservesOrientation, tZero);
+    }
+}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/InteriorAngleSegmentConnector.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/InteriorAngleSegmentConnector.java
new file mode 100644
index 0000000..65eff62
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/InteriorAngleSegmentConnector.java
@@ -0,0 +1,125 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.twod;
+
+import java.util.Collection;
+import java.util.List;
+
+import org.apache.commons.geometry.core.Geometry;
+
+/** Line segment connector that selects between multiple connection options
+ * based on the resulting interior angle. An interior angle in this
+ * case is the angle created between an incoming segment and an outgoing segment
+ * as measured on the minus (interior) side of the incoming line. If looking
+ * along the direction of the incoming line segment, smaller interior angles
+ * point more to the left and larger ones point more to the right.
+ *
+ * <p>This class provides two concrete implementations: {@link Maximize} and
+ * {@link Minimize}, which choose connections with the largest or smallest interior
+ * angles respectively.
+ * </p>
+ */
+public abstract class InteriorAngleSegmentConnector extends AbstractSegmentConnector {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20190530L;
+
+    /** {@inheritDoc} */
+    @Override
+    protected ConnectableSegment selectConnection(ConnectableSegment incoming, List<ConnectableSegment> outgoing) {
+
+        // search for the best connection
+        final Line segmentLine = incoming.getSegment().getLine();
+
+        double selectedInteriorAngle = Double.POSITIVE_INFINITY;
+        ConnectableSegment selected = null;
+
+        for (ConnectableSegment candidate : outgoing) {
+            double interiorAngle = Geometry.PI - segmentLine.angle(candidate.getSegment().getLine());
+
+            if (selected == null || isBetterAngle(interiorAngle, selectedInteriorAngle)) {
+                selectedInteriorAngle = interiorAngle;
+                selected = candidate;
+            }
+        }
+
+        return selected;
+    }
+
+    /** Return true if {@code newAngle} represents a better interior angle than {@code previousAngle}.
+     * @param newAngle the new angle under consideration
+     * @param previousAngle the previous best angle
+     * @return true if {@code newAngle} represents a better interior angle than {@code previousAngle}
+     */
+    protected abstract boolean isBetterAngle(double newAngle, double previousAngle);
+
+    /** Convenience method for connecting a set of line segments with interior angles maximized
+     * when possible. This method is equivalent to {@code new Maximize().connect(segments)}.
+     * @param segments line segments to connect
+     * @return a list of connected line segment paths
+     * @see Maximize
+     */
+    public static List<Polyline> connectMaximized(final Collection<Segment> segments) {
+        return new Maximize().connectAll(segments);
+    }
+
+    /** Convenience method for connecting a set of line segments with interior angles minimized
+     * when possible. This method is equivalent to {@code new Minimize().connect(segments)}.
+     * @param segments line segments to connect
+     * @return a list of connected line segment paths
+     * @see Minimize
+     */
+    public static List<Polyline> connectMinimized(final Collection<Segment> segments) {
+        return new Minimize().connectAll(segments);
+    }
+
+    /** Implementation of {@link InteriorAngleSegmentConnector} that chooses line segment
+     * connections that produce the largest interior angles. Another way to visualize this is
+     * that when presented multiple connection options for a given line segment, this class will
+     * choose the option that points most to the right when viewed in the direction of the incoming
+     * line segment.
+     */
+    public static class Maximize extends InteriorAngleSegmentConnector {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20190530L;
+
+        /** {@inheritDoc} */
+        @Override
+        protected boolean isBetterAngle(double newAngle, double previousAngle) {
+            return newAngle > previousAngle;
+        }
+    }
+
+    /** Implementation of {@link InteriorAngleSegmentConnector} that chooses line segment
+     * connections that produce the smallest interior angles. Another way to visualize this is
+     * that when presented multiple connection options for a given line segment, this class will
+     * choose the option that points most to the left when viewed in the direction of the incoming
+     * line segment.
+     */
+    public static class Minimize extends InteriorAngleSegmentConnector {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20190530L;
+
+        /** {@inheritDoc} */
+        @Override
+        protected boolean isBetterAngle(double newAngle, double previousAngle) {
+            return newAngle < previousAngle;
+        }
+    }
+}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/Line.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/Line.java
index 3ac9d9d..16c370f 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/Line.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/Line.java
@@ -19,14 +19,15 @@
 import java.io.Serializable;
 import java.util.Objects;
 
+import org.apache.commons.geometry.core.Transform;
 import org.apache.commons.geometry.core.exception.GeometryValueException;
-import org.apache.commons.geometry.core.partitioning.Embedding;
+import org.apache.commons.geometry.core.internal.Equivalency;
+import org.apache.commons.geometry.core.partitioning.AbstractHyperplane;
+import org.apache.commons.geometry.core.partitioning.EmbeddingHyperplane;
 import org.apache.commons.geometry.core.partitioning.Hyperplane;
-import org.apache.commons.geometry.core.partitioning.SubHyperplane;
-import org.apache.commons.geometry.core.partitioning.Transform;
 import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.geometry.euclidean.oned.IntervalsSet;
-import org.apache.commons.geometry.euclidean.oned.OrientedPoint;
+import org.apache.commons.geometry.euclidean.oned.AffineTransformMatrix1D;
+import org.apache.commons.geometry.euclidean.oned.Interval;
 import org.apache.commons.geometry.euclidean.oned.Vector1D;
 import org.apache.commons.numbers.angle.PlaneAngleRadians;
 import org.apache.commons.numbers.arrays.LinearCombination;
@@ -57,7 +58,8 @@
  * points with negative offsets and the right half plane is the set of
  * points with positive offsets.</p>
  */
-public final class Line implements Hyperplane<Vector2D>, Embedding<Vector2D, Vector1D>, Serializable {
+public final class Line extends AbstractHyperplane<Vector2D>
+    implements EmbeddingHyperplane<Vector2D, Vector1D>, Equivalency<Line> {
 
     /** Serializable UID. */
     private static final long serialVersionUID = 20190120L;
@@ -68,18 +70,16 @@
     /** The distance between the origin and the line. */
     private final double originOffset;
 
-    /** Precision context used to compare floating point numbers. */
-    private final DoublePrecisionContext precision;
-
     /** Simple constructor.
      * @param direction The direction of the line.
      * @param originOffset The signed distance between the line and the origin.
      * @param precision Precision context used to compare floating point numbers.
      */
     private Line(final Vector2D direction, final double originOffset, final DoublePrecisionContext precision) {
+        super(precision);
+
         this.direction = direction;
         this.originOffset = originOffset;
-        this.precision = precision;
     }
 
     /** Get the angle of the line in radians with respect to the abscissa (+x) axis. The
@@ -127,35 +127,154 @@
 
     /** {@inheritDoc} */
     @Override
-    public DoublePrecisionContext getPrecision() {
-        return precision;
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public Line copySelf() {
-        return this;
-    }
-
-    /** Get the reverse of the instance, meaning a line containing the same
-     * points but with the opposite orientation.
-     * @return a new line, with orientation opposite to the instance orientation
-     */
     public Line reverse() {
-        return new Line(direction.negate(), -originOffset, precision);
+        return new Line(direction.negate(), -originOffset, getPrecision());
     }
 
     /** {@inheritDoc} */
     @Override
-    public Vector1D toSubSpace(final Vector2D point) {
-        return Vector1D.of(direction.dot(point));
+    public Line transform(final Transform<Vector2D> transform) {
+        final Vector2D origin = getOrigin();
+
+        final Vector2D tOrigin = transform.apply(origin);
+        final Vector2D tOriginPlusDir = transform.apply(origin.add(getDirection()));
+
+        return fromPoints(tOrigin, tOriginPlusDir, getPrecision());
+    }
+
+    /** Get an object containing the current line transformed by the argument along with a
+     * 1D transform that can be applied to subspace points. The subspace transform transforms
+     * subspace points such that their 2D location in the transformed line is the same as their
+     * 2D location in the original line after the 2D transform is applied. For example, consider
+     * the code below:
+     * <pre>
+     *      SubspaceTransform st = line.subspaceTransform(transform);
+     *
+     *      Vector1D subPt = Vector1D.of(1);
+     *
+     *      Vector2D a = transform.apply(line.toSpace(subPt)); // transform in 2D space
+     *      Vector2D b = st.getLine().toSpace(st.getTransform().apply(subPt)); // transform in 1D space
+     * </pre>
+     * At the end of execution, the points {@code a} (which was transformed using the original
+     * 2D transform) and {@code b} (which was transformed in 1D using the subspace transform)
+     * are equivalent.
+     *
+     * @param transform the transform to apply to this instance
+     * @return an object containing the transformed line along with a transform that can be applied
+     *      to subspace points
+     * @see #transform(Transform)
+     */
+    public SubspaceTransform subspaceTransform(final Transform<Vector2D> transform) {
+        final Vector2D origin = getOrigin();
+
+        final Vector2D p1 = transform.apply(origin);
+        final Vector2D p2 = transform.apply(origin.add(direction));
+
+        final Line tLine = Line.fromPoints(p1, p2, getPrecision());
+
+        final Vector1D tSubspaceOrigin = tLine.toSubspace(p1);
+        final Vector1D tSubspaceDirection = tSubspaceOrigin.vectorTo(tLine.toSubspace(p2));
+
+        final double translation = tSubspaceOrigin.getX();
+        final double scale = tSubspaceDirection.getX();
+
+        final AffineTransformMatrix1D subspaceTransform = AffineTransformMatrix1D.of(scale, translation);
+
+        return new SubspaceTransform(tLine, subspaceTransform);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Segment span() {
+        return segment(Interval.full());
+    }
+
+    /** Create a new line segment from the given interval.
+     * @param interval interval representing the 1D region for the line segment
+     * @return a new line segment on this line
+     */
+    public Segment segment(final Interval interval) {
+        return Segment.fromInterval(this, interval);
+    }
+
+    /** Create a new line segment from the given interval.
+     * @param a first 1D location for the interval
+     * @param b second 1D location for the interval
+     * @return a new line segment on this line
+     */
+    public Segment segment(final double a, final double b) {
+        return Segment.fromInterval(this, a, b);
+    }
+
+    /** Create a new line segment between the projections of the two
+     * given points onto this line.
+     * @param a first point
+     * @param b second point
+     * @return a new line segment on this line
+     */
+    public Segment segment(final Vector2D a, final Vector2D b) {
+        return Segment.fromInterval(this, toSubspace(a), toSubspace(b));
+    }
+
+    /** Create a new line segment that starts at infinity and continues along
+     * the line up to the projection of the given point.
+     * @param pt point defining the end point of the line segment; the end point
+     *      is equal to the projection of this point onto the line
+     * @return a new, half-open line segment
+     */
+    public Segment segmentTo(final Vector2D pt) {
+        return segment(Double.NEGATIVE_INFINITY, toSubspace(pt).getX());
+    }
+
+    /** Create a new line segment that starts at the projection of the given point
+     * and continues in the direction of the line to infinity, similar to a ray.
+     * @param pt point defining the start point of the line segment; the start point
+     *      is equal to the projection of this point onto the line
+     * @return a new, half-open line segment
+     */
+    public Segment segmentFrom(final Vector2D pt) {
+        return segment(toSubspace(pt).getX(), Double.POSITIVE_INFINITY);
+    }
+
+    /** Create a new, empty subline based on this line.
+     * @return a new, empty subline based on this line
+     */
+    public SubLine subline() {
+        return new SubLine(this);
+    }
+
+    /** Get the abscissa of the given point on the line. The abscissa represents
+     * the distance the projection of the point on the line is from the line's
+     * origin point (the point on the line closest to the origin of the
+     * 2D space). Abscissa values increase in the direction of the line. This method
+     * is exactly equivalent to {@link #toSubspace(Vector2D)} except that this method
+     * returns a double instead of a {@link Vector1D}.
+     * @param point point to compute the abscissa for
+     * @return abscissa value of the point
+     * @see #toSubspace(Vector2D)
+     */
+    public double abscissa(final Vector2D point) {
+        return direction.dot(point);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Vector1D toSubspace(final Vector2D point) {
+        return Vector1D.of(abscissa(point));
     }
 
     /** {@inheritDoc} */
     @Override
     public Vector2D toSpace(final Vector1D point) {
-        final double abscissa = point.getX();
+        return toSpace(point.getX());
+    }
 
+    /** Convert the given abscissa value (1D location on the line)
+     * into a 2D point.
+     * @param abscissa value to convert
+     * @return 2D point corresponding to the line abscissa value
+     */
+    public Vector2D toSpace(final double abscissa) {
         // The 2D coordinate is equal to the projection of the
         // 2D origin onto the line plus the direction multiplied
         // by the abscissa. We can combine everything into a single
@@ -175,7 +294,7 @@
      */
     public Vector2D intersection(final Line other) {
         final double area = this.direction.signedArea(other.direction);
-        if (precision.eqZero(area)) {
+        if (getPrecision().eqZero(area)) {
             // lines are parallel
             return null;
         }
@@ -191,36 +310,35 @@
         return Vector2D.of(x, y);
     }
 
+    /** Compute the angle in radians between this instance's direction and the direction
+     * of the given line. The return value is in the range {@code [-pi, +pi)}. This method
+     * always returns a value, even for parallel or coincident lines.
+     * @param other other line
+     * @return the angle required to rotate this line to point in the direction of
+     *      the given line
+     */
+    public double angle(final Line other) {
+        final double thisAngle = Math.atan2(direction.getY(), direction.getX());
+        final double otherAngle = Math.atan2(other.direction.getY(), other.direction.getX());
+
+        return PlaneAngleRadians.normalizeBetweenMinusPiAndPi(otherAngle - thisAngle);
+    }
+
     /** {@inheritDoc} */
     @Override
     public Vector2D project(final Vector2D point) {
-        return toSpace(toSubSpace(point));
+        return toSpace(toSubspace(point));
     }
 
     /** {@inheritDoc} */
     @Override
-    public SubLine wholeHyperplane() {
-        return new SubLine(this, new IntervalsSet(precision));
-    }
-
-    /** Build a region covering the whole space.
-     * @return a region containing the instance (really a {@link
-     * PolygonsSet PolygonsSet} instance)
-     */
-    @Override
-    public PolygonsSet wholeSpace() {
-        return new PolygonsSet(precision);
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public double getOffset(final Vector2D point) {
+    public double offset(final Vector2D point) {
         return originOffset - direction.signedArea(point);
     }
 
-    /** Get the offset (oriented distance) of a line. Since an infinite
-     * number of distances can be calculated between points on two different
-     * lines, this methods returns the value closest to zero. For intersecting
+    /** Get the offset (oriented distance) of the given line relative to this instance.
+     * Since an infinite number of distances can be calculated between points on two
+     * different lines, this method returns the value closest to zero. For intersecting
      * lines, this will simply be zero. For parallel lines, this will be the
      * perpendicular distance between the two lines, as a signed value.
      *
@@ -232,7 +350,7 @@
      * @return offset of the line
      * @see #distance(Line)
      */
-    public double getOffset(final Line line) {
+    public double offset(final Line line) {
         if (isParallel(line)) {
             // since the lines are parallel, the offset between
             // them is simply the difference between their origin offsets,
@@ -248,7 +366,7 @@
 
     /** {@inheritDoc} */
     @Override
-    public boolean sameOrientationAs(final Hyperplane<Vector2D> other) {
+    public boolean similarOrientation(final Hyperplane<Vector2D> other) {
         final Line otherLine = (Line) other;
         return direction.dot(otherLine.direction) >= 0.0;
     }
@@ -273,8 +391,9 @@
      * @param p point to check
      * @return true if p belongs to the line
      */
+    @Override
     public boolean contains(final Vector2D p) {
-        return precision.eqZero(getOffset(p));
+        return getPrecision().eqZero(offset(p));
     }
 
     /** Check if this instance completely contains the other line.
@@ -284,7 +403,7 @@
      * @return true if this instance contains all points in the given line
      */
     public boolean contains(final Line line) {
-        return isParallel(line) && precision.eqZero(getOffset(line));
+        return isParallel(line) && getPrecision().eqZero(offset(line));
     }
 
     /** Compute the distance between the instance and a point.
@@ -296,7 +415,7 @@
      * @return distance between the instance and the point
      */
     public double distance(final Vector2D p) {
-        return Math.abs(getOffset(p));
+        return Math.abs(offset(p));
     }
 
     /** Compute the shortest distance between this instance and
@@ -305,10 +424,10 @@
      * @param line line to compute the closest distance to
      * @return the shortest distance between this instance and the
      *      given line
-     * @see #getOffset(Line)
+     * @see #offset(Line)
      */
     public double distance(final Line line) {
-        return Math.abs(getOffset(line));
+        return Math.abs(offset(line));
     }
 
     /** Check if the instance is parallel to another line.
@@ -318,20 +437,31 @@
      */
     public boolean isParallel(final Line line) {
         final double area = direction.signedArea(line.direction);
-        return precision.eqZero(area);
+        return getPrecision().eqZero(area);
     }
 
-    /** Transform this instance with the given transform.
-     * @param transform transform to apply to this instance
-     * @return a new transformed line
-     */
-    public Line transform(final Transform<Vector2D, Vector1D> transform) {
-        final Vector2D origin = getOrigin();
+    /** {@inheritDoc}
+    *
+    * <p>Instances are considered equivalent if they
+    * <ul>
+    *   <li>contain equal {@link DoublePrecisionContext precision contexts},</li>
+    *   <li>have equivalent locations (as evaluated by the precision context), and</li>
+    *   <li>point in the same direction (as evaluated by the precision context)</li>
+    * </ul>
+    * @param other the point to compare with
+    * @return true if this instance should be considered equivalent to the argument
+    */
+    @Override
+    public boolean eq(final Line other) {
+        if (this == other) {
+            return true;
+        }
 
-        final Vector2D p1 = transform.apply(origin);
-        final Vector2D p2 = transform.apply(origin.add(direction));
+        final DoublePrecisionContext precision = getPrecision();
 
-        return Line.fromPoints(p1, p2, precision);
+        return precision.equals(other.getPrecision()) &&
+                getOrigin().eq(other.getOrigin(), precision) &&
+                precision.eq(getAngle(), other.getAngle());
     }
 
     /** {@inheritDoc} */
@@ -342,7 +472,7 @@
         int result = 1;
         result = (prime * result) + Objects.hashCode(direction);
         result = (prime * result) + Double.hashCode(originOffset);
-        result = (prime * result) + Objects.hashCode(precision);
+        result = (prime * result) + Objects.hashCode(getPrecision());
 
         return result;
     }
@@ -352,8 +482,7 @@
     public boolean equals(Object obj) {
         if (this == obj) {
             return true;
-        }
-        else if (!(obj instanceof Line)) {
+        } else if (!(obj instanceof Line)) {
             return false;
         }
 
@@ -361,7 +490,7 @@
 
         return Objects.equals(this.direction, other.direction) &&
                 Double.compare(this.originOffset, other.originOffset) == 0 &&
-                Objects.equals(this.precision, other.precision);
+                Objects.equals(this.getPrecision(), other.getPrecision());
     }
 
     /** {@inheritDoc} */
@@ -400,7 +529,8 @@
      * @throws GeometryValueException If {@code dir} has zero length, as evaluated by the
      *      given precision context
      */
-    public static Line fromPointAndDirection(final Vector2D pt, final Vector2D dir, final DoublePrecisionContext precision) {
+    public static Line fromPointAndDirection(final Vector2D pt, final Vector2D dir,
+            final DoublePrecisionContext precision) {
         if (dir.isZero(precision)) {
             throw new GeometryValueException("Line direction cannot be zero");
         }
@@ -419,67 +549,48 @@
      * @return new line containing {@code pt} and forming the given angle with the
      *      abscissa (x) axis.
      */
-    public static Line fromPointAndAngle(final Vector2D pt, final double angle, final DoublePrecisionContext precision) {
+    public static Line fromPointAndAngle(final Vector2D pt, final double angle,
+            final DoublePrecisionContext precision) {
         final Vector2D.Unit dir = Vector2D.Unit.from(Math.cos(angle), Math.sin(angle));
         return fromPointAndDirection(pt, dir, precision);
     }
 
-    // TODO: Remove this method and associated class after the Transform interface has been simplified.
-    // See GEOMETRY-24.
-
-    /** Create a {@link Transform} instance from a set of column vectors. The returned object can be used
-     * to transform {@link SubLine} instances.
-     * @param u first column vector; this corresponds to the first basis vector
-     *      in the coordinate frame
-     * @param v second column vector; this corresponds to the second basis vector
-     *      in the coordinate frame
-     * @param t third column vector; this corresponds to the translation of the transform
-     * @return a new transform instance
+    /** Class containing a transformed line instance along with a subspace (1D) transform. The subspace
+     * transform produces the equivalent of the 2D transform in 1D.
      */
-    public static Transform<Vector2D, Vector1D> getTransform(final Vector2D u, final Vector2D v, final Vector2D t) {
-        final AffineTransformMatrix2D matrix = AffineTransformMatrix2D.fromColumnVectors(u, v, t);
-        return new LineTransform(matrix);
-    }
+    public static final class SubspaceTransform implements Serializable {
 
-    /** Class wrapping an {@link AffineTransformMatrix2D} with the methods necessary to fulfill the full
-     * {@link Transform} interface.
-     */
-    private static class LineTransform implements Transform<Vector2D, Vector1D> {
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20190809L;
 
-        /** Transform matrix */
-        private final AffineTransformMatrix2D matrix;
+        /** The transformed line. */
+        private final Line line;
+
+        /** The subspace transform instance. */
+        private final AffineTransformMatrix1D transform;
 
         /** Simple constructor.
-         * @param matrix transform matrix
+         * @param line the transformed line
+         * @param transform 1D transform that can be applied to subspace points
          */
-        LineTransform(final AffineTransformMatrix2D matrix) {
-            this.matrix = matrix;
+        public SubspaceTransform(final Line line, final AffineTransformMatrix1D transform) {
+            this.line = line;
+            this.transform = transform;
         }
 
-        /** {@inheritDoc} */
-        @Override
-        public Vector2D apply(final Vector2D point) {
-            return matrix.apply(point);
+        /** Get the transformed line instance.
+         * @return the transformed line instance
+         */
+        public Line getLine() {
+            return line;
         }
 
-        /** {@inheritDoc} */
-        @Override
-        public Line apply(final Hyperplane<Vector2D> hyperplane) {
-            final Line line = (Line) hyperplane;
-            return line.transform(matrix);
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public SubHyperplane<Vector1D> apply(final SubHyperplane<Vector1D> sub,
-                                                final Hyperplane<Vector2D> original,
-                                                final Hyperplane<Vector2D> transformed) {
-            final OrientedPoint op = (OrientedPoint) sub.getHyperplane();
-            final Line originalLine  = (Line) original;
-            final Line transformedLine = (Line) transformed;
-            final Vector1D newLoc =
-                transformedLine.toSubSpace(apply(originalLine.toSpace(op.getLocation())));
-            return OrientedPoint.fromPointAndDirection(newLoc, op.getDirection(), originalLine.precision).wholeHyperplane();
+        /** Get the 1D transform that can be applied to subspace points. This transform can be used
+         * to perform the equivalent of the 2D transform in 1D space.
+         * @return the subspace transform instance
+         */
+        public AffineTransformMatrix1D getTransform() {
+            return transform;
         }
     }
 }
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/NestedLoops.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/NestedLoops.java
deleted file mode 100644
index d6dbbf9..0000000
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/NestedLoops.java
+++ /dev/null
@@ -1,195 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.twod;
-
-import java.util.ArrayList;
-import java.util.Iterator;
-import java.util.List;
-
-import org.apache.commons.geometry.core.partitioning.Region;
-import org.apache.commons.geometry.core.partitioning.RegionFactory;
-import org.apache.commons.geometry.core.partitioning.SubHyperplane;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.geometry.euclidean.oned.IntervalsSet;
-
-/** This class represent a tree of nested 2D boundary loops.
-
- * <p>This class is used for piecewise polygons construction.
- * Polygons are built using the outline edges as
- * representative of boundaries, the orientation of these lines are
- * meaningful. However, we want to allow the user to specify its
- * outline loops without having to take care of this orientation. This
- * class is devoted to correct mis-oriented loops.<p>
-
- * <p>Orientation is computed assuming the piecewise polygon is finite,
- * i.e. the outermost loops have their exterior side facing points at
- * infinity, and hence are oriented counter-clockwise. The orientation of
- * internal loops is computed as the reverse of the orientation of
- * their immediate surrounding loop.</p>
- */
-class NestedLoops {
-
-    /** Boundary loop. */
-    private Vector2D[] loop;
-
-    /** Surrounded loops. */
-    private List<NestedLoops> surrounded;
-
-    /** Polygon enclosing a finite region. */
-    private Region<Vector2D> polygon;
-
-    /** Indicator for original loop orientation. */
-    private boolean originalIsClockwise;
-
-    /** Precision context used to compare floating point numbers. */
-    private final DoublePrecisionContext precision;
-
-    /** Simple Constructor.
-     * <p>Build an empty tree of nested loops. This instance will become
-     * the root node of a complete tree, it is not associated with any
-     * loop by itself, the outermost loops are in the root tree child
-     * nodes.</p>
-     * @param precision precision context used to compare floating point values
-     */
-    NestedLoops(final DoublePrecisionContext precision) {
-        this.surrounded = new ArrayList<>();
-        this.precision  = precision;
-    }
-
-    /** Constructor.
-     * <p>Build a tree node with neither parent nor children</p>
-     * @param loop boundary loop (will be reversed in place if needed)
-     * @param precision precision context used to compare floating point values
-     * @exception IllegalArgumentException if an outline has an open boundary loop
-     */
-    private NestedLoops(final Vector2D[] loop, DoublePrecisionContext precision)
-        throws IllegalArgumentException {
-
-        if (loop[0] == null) {
-            throw new IllegalArgumentException("An outline boundary loop is open");
-        }
-
-        this.loop       = loop;
-        this.surrounded = new ArrayList<>();
-        this.precision  = precision;
-
-        // build the polygon defined by the loop
-        final ArrayList<SubHyperplane<Vector2D>> edges = new ArrayList<>();
-        Vector2D current = loop[loop.length - 1];
-        for (int i = 0; i < loop.length; ++i) {
-            final Vector2D previous = current;
-            current = loop[i];
-            final Line   line   = Line.fromPoints(previous, current, precision);
-            final IntervalsSet region =
-                new IntervalsSet(line.toSubSpace(previous).getX(),
-                                 line.toSubSpace(current).getX(),
-                                 precision);
-            edges.add(new SubLine(line, region));
-        }
-        polygon = new PolygonsSet(edges, precision);
-
-        // ensure the polygon encloses a finite region of the plane
-        if (Double.isInfinite(polygon.getSize())) {
-            polygon = new RegionFactory<Vector2D>().getComplement(polygon);
-            originalIsClockwise = false;
-        } else {
-            originalIsClockwise = true;
-        }
-
-    }
-
-    /** Add a loop in a tree.
-     * @param bLoop boundary loop (will be reversed in place if needed)
-     * @exception IllegalArgumentException if an outline has crossing
-     * boundary loops or open boundary loops
-     */
-    public void add(final Vector2D[] bLoop) {
-        add(new NestedLoops(bLoop, precision));
-    }
-
-    /** Add a loop in a tree.
-     * @param node boundary loop (will be reversed in place if needed)
-     * @exception IllegalArgumentException if an outline has boundary
-     * loops that cross each other
-     */
-    private void add(final NestedLoops node) {
-
-        // check if we can go deeper in the tree
-        for (final NestedLoops child : surrounded) {
-            if (child.polygon.contains(node.polygon)) {
-                child.add(node);
-                return;
-            }
-        }
-
-        // check if we can absorb some of the instance children
-        for (final Iterator<NestedLoops> iterator = surrounded.iterator(); iterator.hasNext();) {
-            final NestedLoops child = iterator.next();
-            if (node.polygon.contains(child.polygon)) {
-                node.surrounded.add(child);
-                iterator.remove();
-            }
-        }
-
-        // we should be separate from the remaining children
-        RegionFactory<Vector2D> factory = new RegionFactory<>();
-        for (final NestedLoops child : surrounded) {
-            if (!factory.intersection(node.polygon, child.polygon).isEmpty()) {
-                throw new IllegalArgumentException("Some outline boundary loops cross each other");
-            }
-        }
-
-        surrounded.add(node);
-
-    }
-
-    /** Correct the orientation of the loops contained in the tree.
-     * <p>This is this method that really inverts the loops that where
-     * provided through the {@link #add(Vector2D[]) add} method if
-     * they are mis-oriented</p>
-     */
-    public void correctOrientation() {
-        for (NestedLoops child : surrounded) {
-            child.setClockWise(true);
-        }
-    }
-
-    /** Set the loop orientation.
-     * @param clockwise if true, the loop should be set to clockwise
-     * orientation
-     */
-    private void setClockWise(final boolean clockwise) {
-
-        if (originalIsClockwise ^ clockwise) {
-            // we need to inverse the original loop
-            int min = -1;
-            int max = loop.length;
-            while (++min < --max) {
-                final Vector2D tmp = loop[min];
-                loop[min] = loop[max];
-                loop[max] = tmp;
-            }
-        }
-
-        // go deeper in the tree
-        for (final NestedLoops child : surrounded) {
-            child.setClockWise(!clockwise);
-        }
-
-    }
-
-}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/PolarCoordinates.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/PolarCoordinates.java
index adffb8d..5f679f9 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/PolarCoordinates.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/PolarCoordinates.java
@@ -51,13 +51,13 @@
  */
 public final class PolarCoordinates implements Spatial, Serializable {
 
-    /** Serializable version UID */
+    /** Serializable version UID. */
     private static final long serialVersionUID = 20180630L;
 
-    /** Radius value */
+    /** Radius value. */
     private final double radius;
 
-    /** Azimuth angle in radians */
+    /** Azimuth angle in radians. */
     private final double azimuth;
 
     /** Simple constructor. Input values are normalized.
@@ -72,7 +72,7 @@
         }
 
         this.radius = radius;
-        this.azimuth = normalizeAzimuth(azimuth);;
+        this.azimuth = normalizeAzimuth(azimuth);
     }
 
     /** Return the radius value. The value will be greater than or equal to 0.
@@ -108,6 +108,12 @@
         return !isNaN() && (Double.isInfinite(radius) || Double.isInfinite(azimuth));
     }
 
+    /** {@inheritDoc} */
+    @Override
+    public boolean isFinite() {
+        return Double.isFinite(radius) && Double.isFinite(azimuth);
+    }
+
     /** Convert this set of polar coordinates to Cartesian coordinates.
      * @return A 2-dimensional vector with an equivalent set of
      *      coordinates in Cartesian form
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/PolygonsSet.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/PolygonsSet.java
deleted file mode 100644
index ee76ff9..0000000
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/PolygonsSet.java
+++ /dev/null
@@ -1,1101 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.twod;
-
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-
-import org.apache.commons.geometry.core.partitioning.AbstractRegion;
-import org.apache.commons.geometry.core.partitioning.AbstractSubHyperplane;
-import org.apache.commons.geometry.core.partitioning.BSPTree;
-import org.apache.commons.geometry.core.partitioning.BSPTreeVisitor;
-import org.apache.commons.geometry.core.partitioning.BoundaryAttribute;
-import org.apache.commons.geometry.core.partitioning.Hyperplane;
-import org.apache.commons.geometry.core.partitioning.Side;
-import org.apache.commons.geometry.core.partitioning.SubHyperplane;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.geometry.euclidean.oned.Interval;
-import org.apache.commons.geometry.euclidean.oned.IntervalsSet;
-import org.apache.commons.geometry.euclidean.oned.Vector1D;
-import org.apache.commons.numbers.core.Precision;
-
-/** This class represents a 2D region: a set of polygons.
- */
-public class PolygonsSet extends AbstractRegion<Vector2D, Vector1D> {
-
-    /** Vertices organized as boundary loops. */
-    private Vector2D[][] vertices;
-
-    /** Build a polygons set representing the whole plane.
-     * @param precision precision context used to compare floating point values
-     */
-    public PolygonsSet(final DoublePrecisionContext precision) {
-        super(precision);
-    }
-
-    /** Build a polygons set from a BSP tree.
-     * <p>The leaf nodes of the BSP tree <em>must</em> have a
-     * {@code Boolean} attribute representing the inside status of
-     * the corresponding cell (true for inside cells, false for outside
-     * cells). In order to avoid building too many small objects, it is
-     * recommended to use the predefined constants
-     * {@code Boolean.TRUE} and {@code Boolean.FALSE}</p>
-     * <p>
-     * This constructor is aimed at expert use, as building the tree may
-     * be a difficult task. It is not intended for general use and for
-     * performances reasons does not check thoroughly its input, as this would
-     * require walking the full tree each time. Failing to provide a tree with
-     * the proper attributes, <em>will</em> therefore generate problems like
-     * {@link NullPointerException} or {@link ClassCastException} only later on.
-     * This limitation is known and explains why this constructor is for expert
-     * use only. The caller does have the responsibility to provided correct arguments.
-     * </p>
-     * @param tree inside/outside BSP tree representing the region
-     * @param precision precision context used to compare floating point values
-     */
-    public PolygonsSet(final BSPTree<Vector2D> tree, final DoublePrecisionContext precision) {
-        super(tree, precision);
-    }
-
-    /** Build a polygons set from a Boundary REPresentation (B-rep).
-     * <p>The boundary is provided as a collection of {@link
-     * SubHyperplane sub-hyperplanes}. Each sub-hyperplane has the
-     * interior part of the region on its minus side and the exterior on
-     * its plus side.</p>
-     * <p>The boundary elements can be in any order, and can form
-     * several non-connected sets (like for example polygons with holes
-     * or a set of disjoint polygons considered as a whole). In
-     * fact, the elements do not even need to be connected together
-     * (their topological connections are not used here). However, if the
-     * boundary does not really separate an inside open from an outside
-     * open (open having here its topological meaning), then subsequent
-     * calls to the {@link
-     * org.apache.commons.geometry.core.partitioning.Region#checkPoint(org.apache.commons.geometry.core.Point)
-     * checkPoint} method will not be meaningful anymore.</p>
-     * <p>If the boundary is empty, the region will represent the whole
-     * space.</p>
-     * @param boundary collection of boundary elements, as a
-     * collection of {@link SubHyperplane SubHyperplane} objects
-     * @param precision precision context used to compare floating point values
-     */
-    public PolygonsSet(final Collection<SubHyperplane<Vector2D>> boundary, final DoublePrecisionContext precision) {
-        super(boundary, precision);
-    }
-
-    /** Build a parallellepipedic box.
-     * @param xMin low bound along the x direction
-     * @param xMax high bound along the x direction
-     * @param yMin low bound along the y direction
-     * @param yMax high bound along the y direction
-     * @param precision precision context used to compare floating point values
-     */
-    public PolygonsSet(final double xMin, final double xMax,
-                       final double yMin, final double yMax,
-                       final DoublePrecisionContext precision) {
-        super(boxBoundary(xMin, xMax, yMin, yMax, precision), precision);
-    }
-
-    /** Build a polygon from a simple list of vertices.
-     * <p>The boundary is provided as a list of points considering to
-     * represent the vertices of a simple loop. The interior part of the
-     * region is on the left side of this path and the exterior is on its
-     * right side.</p>
-     * <p>This constructor does not handle polygons with a boundary
-     * forming several disconnected paths (such as polygons with holes).</p>
-     * <p>For cases where this simple constructor applies, it is expected to
-     * be numerically more robust than the {@link #PolygonsSet(Collection, DoublePrecisionContext) general
-     * constructor} using {@link SubHyperplane subhyperplanes}.</p>
-     * <p>If the list is empty, the region will represent the whole
-     * space.</p>
-     * <p>
-     * Polygons with thin pikes or dents are inherently difficult to handle because
-     * they involve lines with almost opposite directions at some vertices. Polygons
-     * whose vertices come from some physical measurement with noise are also
-     * difficult because an edge that should be straight may be broken in lots of
-     * different pieces with almost equal directions. In both cases, computing the
-     * lines intersections is not numerically robust due to the almost 0 or almost
-     * &pi; angle. Such cases need to carefully adjust the {@code hyperplaneThickness}
-     * parameter. A too small value would often lead to completely wrong polygons
-     * with large area wrongly identified as inside or outside. Large values are
-     * often much safer. As a rule of thumb, a value slightly below the size of the
-     * most accurate detail needed is a good value for the {@code hyperplaneThickness}
-     * parameter.
-     * </p>
-     * @param precision precision context used to compare floating point values
-     * @param vertices vertices of the simple loop boundary
-     */
-    public PolygonsSet(final DoublePrecisionContext precision, final Vector2D ... vertices) {
-        super(verticesToTree(precision, vertices), precision);
-    }
-
-    /** Create a list of hyperplanes representing the boundary of a box.
-     * @param xMin low bound along the x direction
-     * @param xMax high bound along the x direction
-     * @param yMin low bound along the y direction
-     * @param yMax high bound along the y direction
-     * @param precision precision context used to compare floating point values
-     * @return boundary of the box
-     */
-    private static Line[] boxBoundary(final double xMin, final double xMax,
-                                      final double yMin, final double yMax,
-                                      final DoublePrecisionContext precision) {
-        if (precision.eq(xMin, xMax) || precision.eq(yMin, yMax)) {
-            // too thin box, build an empty polygons set
-            return null;
-        }
-        final Vector2D minMin = Vector2D.of(xMin, yMin);
-        final Vector2D minMax = Vector2D.of(xMin, yMax);
-        final Vector2D maxMin = Vector2D.of(xMax, yMin);
-        final Vector2D maxMax = Vector2D.of(xMax, yMax);
-        return new Line[] {
-            Line.fromPoints(minMin, maxMin, precision),
-            Line.fromPoints(maxMin, maxMax, precision),
-            Line.fromPoints(maxMax, minMax, precision),
-            Line.fromPoints(minMax, minMin, precision)
-        };
-    }
-
-    /** Build the BSP tree of a polygons set from a simple list of vertices.
-     * <p>The boundary is provided as a list of points considering to
-     * represent the vertices of a simple loop. The interior part of the
-     * region is on the left side of this path and the exterior is on its
-     * right side.</p>
-     * <p>This constructor does not handle polygons with a boundary
-     * forming several disconnected paths (such as polygons with holes).</p>
-     * <p>For cases where this simple constructor applies, it is expected to
-     * be numerically more robust than the {@link #PolygonsSet(Collection,double) general
-     * constructor} using {@link SubHyperplane subhyperplanes}.</p>
-     * @param precision precision context used to compare floating point values
-     * @param vertices vertices of the simple loop boundary
-     * @return the BSP tree of the input vertices
-     */
-    private static BSPTree<Vector2D> verticesToTree(final DoublePrecisionContext precision,
-                                                       final Vector2D ... vertices) {
-
-        final int n = vertices.length;
-        if (n == 0) {
-            // the tree represents the whole space
-            return new BSPTree<>(Boolean.TRUE);
-        }
-
-        // build the vertices
-        final Vertex[] vArray = new Vertex[n];
-        for (int i = 0; i < n; ++i) {
-            vArray[i] = new Vertex(vertices[i]);
-        }
-
-        // build the edges
-        List<Edge> edges = new ArrayList<>(n);
-        for (int i = 0; i < n; ++i) {
-
-            // get the endpoints of the edge
-            final Vertex start = vArray[i];
-            final Vertex end   = vArray[(i + 1) % n];
-
-            // get the line supporting the edge, taking care not to recreate it
-            // if it was already created earlier due to another edge being aligned
-            // with the current one
-            Line line = start.sharedLineWith(end);
-            if (line == null) {
-                line = Line.fromPoints(start.getLocation(), end.getLocation(), precision);
-            }
-
-            // create the edge and store it
-            edges.add(new Edge(start, end, line));
-
-            // check if another vertex also happens to be on this line
-            for (final Vertex vertex : vArray) {
-                if (vertex != start && vertex != end &&
-                    precision.eqZero(line.getOffset(vertex.getLocation()))) {
-                    vertex.bindWith(line);
-                }
-            }
-
-        }
-
-        // build the tree top-down
-        final BSPTree<Vector2D> tree = new BSPTree<>();
-        insertEdges(precision, tree, edges);
-
-        return tree;
-
-    }
-
-    /** Recursively build a tree by inserting cut sub-hyperplanes.
-     * @param precision precision context used to compare floating point values
-     * @param node current tree node (it is a leaf node at the beginning
-     * of the call)
-     * @param edges list of edges to insert in the cell defined by this node
-     * (excluding edges not belonging to the cell defined by this node)
-     */
-    private static void insertEdges(final DoublePrecisionContext precision,
-                                    final BSPTree<Vector2D> node,
-                                    final List<Edge> edges) {
-
-        // find an edge with an hyperplane that can be inserted in the node
-        int index = 0;
-        Edge inserted =null;
-        while (inserted == null && index < edges.size()) {
-            inserted = edges.get(index++);
-            if (inserted.getNode() == null) {
-                if (node.insertCut(inserted.getLine())) {
-                    inserted.setNode(node);
-                } else {
-                    inserted = null;
-                }
-            } else {
-                inserted = null;
-            }
-        }
-
-        if (inserted == null) {
-            // no suitable edge was found, the node remains a leaf node
-            // we need to set its inside/outside boolean indicator
-            final BSPTree<Vector2D> parent = node.getParent();
-            if (parent == null || node == parent.getMinus()) {
-                node.setAttribute(Boolean.TRUE);
-            } else {
-                node.setAttribute(Boolean.FALSE);
-            }
-            return;
-        }
-
-        // we have split the node by inserting an edge as a cut sub-hyperplane
-        // distribute the remaining edges in the two sub-trees
-        final List<Edge> plusList  = new ArrayList<>();
-        final List<Edge> minusList = new ArrayList<>();
-        for (final Edge edge : edges) {
-            if (edge != inserted) {
-                final double startOffset = inserted.getLine().getOffset(edge.getStart().getLocation());
-                final double endOffset   = inserted.getLine().getOffset(edge.getEnd().getLocation());
-                Side startSide = precision.eqZero(Math.abs(startOffset)) ?
-                                 Side.HYPER : ((startOffset < 0) ? Side.MINUS : Side.PLUS);
-                Side endSide   = precision.eqZero(endOffset) ?
-                                 Side.HYPER : ((endOffset < 0) ? Side.MINUS : Side.PLUS);
-                switch (startSide) {
-                    case PLUS:
-                        if (endSide == Side.MINUS) {
-                            // we need to insert a split point on the hyperplane
-                            final Vertex splitPoint = edge.split(inserted.getLine());
-                            minusList.add(splitPoint.getOutgoing());
-                            plusList.add(splitPoint.getIncoming());
-                        } else {
-                            plusList.add(edge);
-                        }
-                        break;
-                    case MINUS:
-                        if (endSide == Side.PLUS) {
-                            // we need to insert a split point on the hyperplane
-                            final Vertex splitPoint = edge.split(inserted.getLine());
-                            minusList.add(splitPoint.getIncoming());
-                            plusList.add(splitPoint.getOutgoing());
-                        } else {
-                            minusList.add(edge);
-                        }
-                        break;
-                    default:
-                        if (endSide == Side.PLUS) {
-                            plusList.add(edge);
-                        } else if (endSide == Side.MINUS) {
-                            minusList.add(edge);
-                        }
-                        break;
-                }
-            }
-        }
-
-        // recurse through lower levels
-        if (!plusList.isEmpty()) {
-            insertEdges(precision, node.getPlus(),  plusList);
-        } else {
-            node.getPlus().setAttribute(Boolean.FALSE);
-        }
-        if (!minusList.isEmpty()) {
-            insertEdges(precision, node.getMinus(), minusList);
-        } else {
-            node.getMinus().setAttribute(Boolean.TRUE);
-        }
-
-    }
-
-    /** Internal class for holding vertices while they are processed to build a BSP tree. */
-    private static class Vertex {
-
-        /** Vertex location. */
-        private final Vector2D location;
-
-        /** Incoming edge. */
-        private Edge incoming;
-
-        /** Outgoing edge. */
-        private Edge outgoing;
-
-        /** Lines bound with this vertex. */
-        private final List<Line> lines;
-
-        /** Build a non-processed vertex not owned by any node yet.
-         * @param location vertex location
-         */
-        Vertex(final Vector2D location) {
-            this.location = location;
-            this.incoming = null;
-            this.outgoing = null;
-            this.lines    = new ArrayList<>();
-        }
-
-        /** Get Vertex location.
-         * @return vertex location
-         */
-        public Vector2D getLocation() {
-            return location;
-        }
-
-        /** Bind a line considered to contain this vertex.
-         * @param line line to bind with this vertex
-         */
-        public void bindWith(final Line line) {
-            lines.add(line);
-        }
-
-        /** Get the common line bound with both the instance and another vertex, if any.
-         * <p>
-         * When two vertices are both bound to the same line, this means they are
-         * already handled by node associated with this line, so there is no need
-         * to create a cut hyperplane for them.
-         * </p>
-         * @param vertex other vertex to check instance against
-         * @return line bound with both the instance and another vertex, or null if the
-         * two vertices do not share a line yet
-         */
-        public Line sharedLineWith(final Vertex vertex) {
-            for (final Line line1 : lines) {
-                for (final Line line2 : vertex.lines) {
-                    if (line1 == line2) {
-                        return line1;
-                    }
-                }
-            }
-            return null;
-        }
-
-        /** Set incoming edge.
-         * <p>
-         * The line supporting the incoming edge is automatically bound
-         * with the instance.
-         * </p>
-         * @param incoming incoming edge
-         */
-        public void setIncoming(final Edge incoming) {
-            this.incoming = incoming;
-            bindWith(incoming.getLine());
-        }
-
-        /** Get incoming edge.
-         * @return incoming edge
-         */
-        public Edge getIncoming() {
-            return incoming;
-        }
-
-        /** Set outgoing edge.
-         * <p>
-         * The line supporting the outgoing edge is automatically bound
-         * with the instance.
-         * </p>
-         * @param outgoing outgoing edge
-         */
-        public void setOutgoing(final Edge outgoing) {
-            this.outgoing = outgoing;
-            bindWith(outgoing.getLine());
-        }
-
-        /** Get outgoing edge.
-         * @return outgoing edge
-         */
-        public Edge getOutgoing() {
-            return outgoing;
-        }
-
-    }
-
-    /** Internal class for holding edges while they are processed to build a BSP tree. */
-    private static class Edge {
-
-        /** Start vertex. */
-        private final Vertex start;
-
-        /** End vertex. */
-        private final Vertex end;
-
-        /** Line supporting the edge. */
-        private final Line line;
-
-        /** Node whose cut hyperplane contains this edge. */
-        private BSPTree<Vector2D> node;
-
-        /** Build an edge not contained in any node yet.
-         * @param start start vertex
-         * @param end end vertex
-         * @param line line supporting the edge
-         */
-        Edge(final Vertex start, final Vertex end, final Line line) {
-
-            this.start = start;
-            this.end   = end;
-            this.line  = line;
-            this.node  = null;
-
-            // connect the vertices back to the edge
-            start.setOutgoing(this);
-            end.setIncoming(this);
-
-        }
-
-        /** Get start vertex.
-         * @return start vertex
-         */
-        public Vertex getStart() {
-            return start;
-        }
-
-        /** Get end vertex.
-         * @return end vertex
-         */
-        public Vertex getEnd() {
-            return end;
-        }
-
-        /** Get the line supporting this edge.
-         * @return line supporting this edge
-         */
-        public Line getLine() {
-            return line;
-        }
-
-        /** Set the node whose cut hyperplane contains this edge.
-         * @param node node whose cut hyperplane contains this edge
-         */
-        public void setNode(final BSPTree<Vector2D> node) {
-            this.node = node;
-        }
-
-        /** Get the node whose cut hyperplane contains this edge.
-         * @return node whose cut hyperplane contains this edge
-         * (null if edge has not yet been inserted into the BSP tree)
-         */
-        public BSPTree<Vector2D> getNode() {
-            return node;
-        }
-
-        /** Split the edge.
-         * <p>
-         * Once split, this edge is not referenced anymore by the vertices,
-         * it is replaced by the two half-edges and an intermediate splitting
-         * vertex is introduced to connect these two halves.
-         * </p>
-         * @param splitLine line splitting the edge in two halves
-         * @return split vertex (its incoming and outgoing edges are the two halves)
-         */
-        public Vertex split(final Line splitLine) {
-            final Vertex splitVertex = new Vertex(line.intersection(splitLine));
-            splitVertex.bindWith(splitLine);
-            final Edge startHalf = new Edge(start, splitVertex, line);
-            final Edge endHalf   = new Edge(splitVertex, end, line);
-            startHalf.node = node;
-            endHalf.node   = node;
-            return splitVertex;
-        }
-
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public PolygonsSet buildNew(final BSPTree<Vector2D> tree) {
-        return new PolygonsSet(tree, getPrecision());
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    protected void computeGeometricalProperties() {
-
-        final Vector2D[][] v = getVertices();
-
-        if (v.length == 0) {
-            final BSPTree<Vector2D> tree = getTree(false);
-            if (tree.getCut() == null && (Boolean) tree.getAttribute()) {
-                // the instance covers the whole space
-                setSize(Double.POSITIVE_INFINITY);
-                setBarycenter(Vector2D.NaN);
-            } else {
-                setSize(0);
-                setBarycenter(Vector2D.NaN);
-            }
-        } else if (v[0][0] == null) {
-            // there is at least one open-loop: the polygon is infinite
-            setSize(Double.POSITIVE_INFINITY);
-            setBarycenter(Vector2D.NaN);
-        } else {
-            // all loops are closed, we compute some integrals around the shape
-
-            double sum  = 0;
-            double sumX = 0;
-            double sumY = 0;
-
-            for (Vector2D[] loop : v) {
-                double x1 = loop[loop.length - 1].getX();
-                double y1 = loop[loop.length - 1].getY();
-                for (final Vector2D point : loop) {
-                    final double x0 = x1;
-                    final double y0 = y1;
-                    x1 = point.getX();
-                    y1 = point.getY();
-                    final double factor = x0 * y1 - y0 * x1;
-                    sum  += factor;
-                    sumX += factor * (x0 + x1);
-                    sumY += factor * (y0 + y1);
-                }
-            }
-
-            if (sum < 0) {
-                // the polygon as a finite outside surrounded by an infinite inside
-                setSize(Double.POSITIVE_INFINITY);
-                setBarycenter(Vector2D.NaN);
-            } else {
-                setSize(sum / 2);
-                setBarycenter(Vector2D.of(sumX / (3 * sum), sumY / (3 * sum)));
-            }
-
-        }
-
-    }
-
-    /** Get the vertices of the polygon.
-     * <p>The polygon boundary can be represented as an array of loops,
-     * each loop being itself an array of vertices.</p>
-     * <p>In order to identify open loops which start and end by
-     * infinite edges, the open loops arrays start with a null point. In
-     * this case, the first non null point and the last point of the
-     * array do not represent real vertices, they are dummy points
-     * intended only to get the direction of the first and last edge. An
-     * open loop consisting of a single infinite line will therefore be
-     * represented by a three elements array with one null point
-     * followed by two dummy points. The open loops are always the first
-     * ones in the loops array.</p>
-     * <p>If the polygon has no boundary at all, a zero length loop
-     * array will be returned.</p>
-     * <p>All line segments in the various loops have the inside of the
-     * region on their left side and the outside on their right side
-     * when moving in the underlying line direction. This means that
-     * closed loops surrounding finite areas obey the direct
-     * trigonometric orientation.</p>
-     * @return vertices of the polygon, organized as oriented boundary
-     * loops with the open loops first (the returned value is guaranteed
-     * to be non-null)
-     */
-    public Vector2D[][] getVertices() {
-        if (vertices == null) {
-            if (getTree(false).getCut() == null) {
-                vertices = new Vector2D[0][];
-            } else {
-
-                // build the unconnected segments
-                final SegmentsBuilder visitor = new SegmentsBuilder(getPrecision());
-                getTree(true).visit(visitor);
-                final List<ConnectableSegment> segments = visitor.getSegments();
-
-                // connect all segments, using topological criteria first
-                // and using Euclidean distance only as a last resort
-                int pending = segments.size();
-                pending -= naturalFollowerConnections(segments);
-                if (pending > 0) {
-                    pending -= splitEdgeConnections(segments);
-                }
-                if (pending > 0) {
-                    pending -= closeVerticesConnections(segments);
-                }
-
-                // create the segment loops
-                final ArrayList<List<Segment>> loops = new ArrayList<>();
-                for (ConnectableSegment s = getUnprocessed(segments); s != null; s = getUnprocessed(segments)) {
-                    final List<Segment> loop = followLoop(s);
-                    if (loop != null) {
-                        // an open loop is one that has fewer than two segments or has a null
-                        // start point; the case where we have two segments in a closed loop
-                        // (ie, an infinitely thin, degenerate loop) will result in null being
-                        // returned from the followLoops method
-                        if (loop.size() < 2 || loop.get(0).getStart() == null) {
-                            // this is an open loop, we put it on the front
-                            loops.add(0, loop);
-                        } else {
-                            // this is a closed loop, we put it on the back
-                            loops.add(loop);
-                        }
-                    }
-                }
-
-                // transform the loops in an array of arrays of points
-                vertices = new Vector2D[loops.size()][];
-                int i = 0;
-
-                for (final List<Segment> loop : loops) {
-                    if (loop.size() < 2 ||
-                        (loop.size() == 2 && loop.get(0).getStart() == null && loop.get(1).getEnd() == null)) {
-                        // single infinite line
-                        final Line line = loop.get(0).getLine();
-                        vertices[i++] = new Vector2D[] {
-                            null,
-                            line.toSpace(Vector1D.of(-Float.MAX_VALUE)),
-                            line.toSpace(Vector1D.of(+Float.MAX_VALUE))
-                        };
-                    } else if (loop.get(0).getStart() == null) {
-                        // open loop with at least one real point
-                        final Vector2D[] array = new Vector2D[loop.size() + 2];
-                        int j = 0;
-                        for (Segment segment : loop) {
-
-                            if (j == 0) {
-                                // null point and first dummy point
-                                double x = segment.getLine().toSubSpace(segment.getEnd()).getX();
-                                x -= Math.max(1.0, Math.abs(x / 2));
-                                array[j++] = null;
-                                array[j++] = segment.getLine().toSpace(Vector1D.of(x));
-                            }
-
-                            if (j < (array.length - 1)) {
-                                // current point
-                                array[j++] = segment.getEnd();
-                            } else if (j == (array.length - 1)) {
-                                // last dummy point
-                                double x = segment.getLine().toSubSpace(segment.getStart()).getX();
-                                x += Math.max(1.0, Math.abs(x / 2));
-                                array[j++] = segment.getLine().toSpace(Vector1D.of(x));
-                            }
-
-                        }
-                        vertices[i++] = array;
-                    } else {
-                        final Vector2D[] array = new Vector2D[loop.size()];
-                        int j = 0;
-                        for (Segment segment : loop) {
-                            array[j++] = segment.getStart();
-                        }
-                        vertices[i++] = array;
-                    }
-                }
-
-            }
-        }
-
-        return vertices.clone();
-
-    }
-
-    /** Connect the segments using only natural follower information.
-     * @param segments segments complete segments list
-     * @return number of connections performed
-     */
-    private int naturalFollowerConnections(final List<ConnectableSegment> segments) {
-        int connected = 0;
-        for (final ConnectableSegment segment : segments) {
-            if (segment.getNext() == null) {
-                final BSPTree<Vector2D> node = segment.getNode();
-                final BSPTree<Vector2D> end  = segment.getEndNode();
-                for (final ConnectableSegment candidateNext : segments) {
-                    if (candidateNext.getPrevious()  == null &&
-                        candidateNext.getNode()      == end &&
-                        candidateNext.getStartNode() == node) {
-                        // connect the two segments
-                        segment.setNext(candidateNext);
-                        candidateNext.setPrevious(segment);
-                        ++connected;
-                        break;
-                    }
-                }
-            }
-        }
-        return connected;
-    }
-
-    /** Connect the segments resulting from a line splitting a straight edge.
-     * @param segments segments complete segments list
-     * @return number of connections performed
-     */
-    private int splitEdgeConnections(final List<ConnectableSegment> segments) {
-        int connected = 0;
-        for (final ConnectableSegment segment : segments) {
-            if (segment.getNext() == null && segment.getEndNode() != null) {
-                final Hyperplane<Vector2D> hyperplane = segment.getNode().getCut().getHyperplane();
-                final BSPTree<Vector2D> end  = segment.getEndNode();
-                for (final ConnectableSegment candidateNext : segments) {
-                    if (candidateNext.getPrevious()                      == null &&
-                        candidateNext.getNode().getCut().getHyperplane().equals(hyperplane) &&
-                        candidateNext.getStartNode()                     == end) {
-                        // connect the two segments
-                        segment.setNext(candidateNext);
-                        candidateNext.setPrevious(segment);
-                        ++connected;
-                        break;
-                    }
-                }
-            }
-        }
-        return connected;
-    }
-
-    /** Connect the segments using Euclidean distance.
-     * <p>
-     * This connection heuristic should be used last, as it relies
-     * only on a fuzzy distance criterion.
-     * </p>
-     * @param segments segments complete segments list
-     * @return number of connections performed
-     */
-    private int closeVerticesConnections(final List<ConnectableSegment> segments) {
-        int connected = 0;
-        for (final ConnectableSegment segment : segments) {
-            if (segment.getNext() == null && segment.getEnd() != null) {
-                final Vector2D end = segment.getEnd();
-                ConnectableSegment selectedNext = null;
-                double min = Double.POSITIVE_INFINITY;
-                for (final ConnectableSegment candidateNext : segments) {
-                    if (candidateNext.getPrevious() == null && candidateNext.getStart() != null) {
-                        final double distance = end.distance(candidateNext.getStart());
-                        if (distance < min) {
-                            selectedNext = candidateNext;
-                            min          = distance;
-                        }
-                    }
-                }
-                if (getPrecision().eqZero(min)) {
-                    // connect the two segments
-                    segment.setNext(selectedNext);
-                    selectedNext.setPrevious(segment);
-                    ++connected;
-                }
-            }
-        }
-        return connected;
-    }
-
-    /** Get first unprocessed segment from a list.
-     * @param segments segments list
-     * @return first segment that has not been processed yet
-     * or null if all segments have been processed
-     */
-    private ConnectableSegment getUnprocessed(final List<ConnectableSegment> segments) {
-        for (final ConnectableSegment segment : segments) {
-            if (!segment.isProcessed()) {
-                return segment;
-            }
-        }
-        return null;
-    }
-
-    /** Build the loop containing a segment.
-     * <p>
-     * The segment put in the loop will be marked as processed.
-     * </p>
-     * @param defining segment used to define the loop
-     * @return loop containing the segment (may be null if the loop is a
-     * degenerated infinitely thin 2 points loop
-     */
-    private List<Segment> followLoop(final ConnectableSegment defining) {
-
-        final List<Segment> loop = new ArrayList<>();
-        loop.add(defining);
-        defining.setProcessed(true);
-
-        // add segments in connection order
-        ConnectableSegment next = defining.getNext();
-        while (next != defining && next != null) {
-            loop.add(next);
-            next.setProcessed(true);
-            next = next.getNext();
-        }
-
-        if (next == null) {
-            // the loop is open and we have found its end,
-            // we need to find its start too
-            ConnectableSegment previous = defining.getPrevious();
-            while (previous != null) {
-                loop.add(0, previous);
-                previous.setProcessed(true);
-                previous = previous.getPrevious();
-            }
-        }
-
-        // filter out spurious vertices
-        filterSpuriousVertices(loop);
-
-        if (loop.size() == 2 && loop.get(0).getStart() != null) {
-            // this is a degenerated infinitely thin closed loop, we simply ignore it
-            return null;
-        } else {
-            return loop;
-        }
-
-    }
-
-    /** Filter out spurious vertices on straight lines (at machine precision).
-     * @param loop segments loop to filter (will be modified in-place)
-     */
-    private void filterSpuriousVertices(final List<Segment> loop) {
-        // we need at least 2 segments in order for one of the contained vertices
-        // to be unnecessary
-        if (loop.size() > 1) {
-            // Go through the list and compare each segment with the next
-            // one in line. We can remove the shared vertex if the segments
-            // are not infinite and they lie on the same line.
-            for (int i = 0; i < loop.size(); ++i) {
-                final Segment previous = loop.get(i);
-                int j = (i + 1) % loop.size();
-                final Segment next = loop.get(j);
-                if (next != null &&
-                    previous.getStart() != null && next.getEnd() != null &&
-                    Precision.equals(previous.getLine().getAngle(), next.getLine().getAngle(), Precision.EPSILON)) {
-                    // the vertex between the two edges is a spurious one
-                    // replace the two segments by a single one
-                    loop.set(j, new Segment(previous.getStart(), next.getEnd(), previous.getLine()));
-                    loop.remove(i--);
-                }
-            }
-        }
-    }
-
-    /** Private extension of Segment allowing connection. */
-    private static class ConnectableSegment extends Segment {
-
-        /** Node containing segment. */
-        private final BSPTree<Vector2D> node;
-
-        /** Node whose intersection with current node defines start point. */
-        private final BSPTree<Vector2D> startNode;
-
-        /** Node whose intersection with current node defines end point. */
-        private final BSPTree<Vector2D> endNode;
-
-        /** Previous segment. */
-        private ConnectableSegment previous;
-
-        /** Next segment. */
-        private ConnectableSegment next;
-
-        /** Indicator for completely processed segments. */
-        private boolean processed;
-
-        /** Build a segment.
-         * @param start start point of the segment
-         * @param end end point of the segment
-         * @param line line containing the segment
-         * @param node node containing the segment
-         * @param startNode node whose intersection with current node defines start point
-         * @param endNode node whose intersection with current node defines end point
-         */
-        ConnectableSegment(final Vector2D start, final Vector2D end, final Line line,
-                           final BSPTree<Vector2D> node,
-                           final BSPTree<Vector2D> startNode,
-                           final BSPTree<Vector2D> endNode) {
-            super(start, end, line);
-            this.node      = node;
-            this.startNode = startNode;
-            this.endNode   = endNode;
-            this.previous  = null;
-            this.next      = null;
-            this.processed = false;
-        }
-
-        /** Get the node containing segment.
-         * @return node containing segment
-         */
-        public BSPTree<Vector2D> getNode() {
-            return node;
-        }
-
-        /** Get the node whose intersection with current node defines start point.
-         * @return node whose intersection with current node defines start point
-         */
-        public BSPTree<Vector2D> getStartNode() {
-            return startNode;
-        }
-
-        /** Get the node whose intersection with current node defines end point.
-         * @return node whose intersection with current node defines end point
-         */
-        public BSPTree<Vector2D> getEndNode() {
-            return endNode;
-        }
-
-        /** Get the previous segment.
-         * @return previous segment
-         */
-        public ConnectableSegment getPrevious() {
-            return previous;
-        }
-
-        /** Set the previous segment.
-         * @param previous previous segment
-         */
-        public void setPrevious(final ConnectableSegment previous) {
-            this.previous = previous;
-        }
-
-        /** Get the next segment.
-         * @return next segment
-         */
-        public ConnectableSegment getNext() {
-            return next;
-        }
-
-        /** Set the next segment.
-         * @param next previous segment
-         */
-        public void setNext(final ConnectableSegment next) {
-            this.next = next;
-        }
-
-        /** Set the processed flag.
-         * @param processed processed flag to set
-         */
-        public void setProcessed(final boolean processed) {
-            this.processed = processed;
-        }
-
-        /** Check if the segment has been processed.
-         * @return true if the segment has been processed
-         */
-        public boolean isProcessed() {
-            return processed;
-        }
-
-    }
-
-    /** Visitor building segments. */
-    private static class SegmentsBuilder implements BSPTreeVisitor<Vector2D> {
-
-        /** Object used to determine floating point equality */
-        private final DoublePrecisionContext precision;
-
-        /** Built segments. */
-        private final List<ConnectableSegment> segments;
-
-        /** Simple constructor.
-         * @param precision precision context used to compare floating point values
-         */
-        SegmentsBuilder(final DoublePrecisionContext precision) {
-            this.precision = precision;
-            this.segments  = new ArrayList<>();
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public Order visitOrder(final BSPTree<Vector2D> node) {
-            return Order.MINUS_SUB_PLUS;
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public void visitInternalNode(final BSPTree<Vector2D> node) {
-            @SuppressWarnings("unchecked")
-            final BoundaryAttribute<Vector2D> attribute = (BoundaryAttribute<Vector2D>) node.getAttribute();
-            final Iterable<BSPTree<Vector2D>> splitters = attribute.getSplitters();
-            if (attribute.getPlusOutside() != null) {
-                addContribution(attribute.getPlusOutside(), node, splitters, false);
-            }
-            if (attribute.getPlusInside() != null) {
-                addContribution(attribute.getPlusInside(), node, splitters, true);
-            }
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public void visitLeafNode(final BSPTree<Vector2D> node) {
-        }
-
-        /** Add the contribution of a boundary facet.
-         * @param sub boundary facet
-         * @param node node containing segment
-         * @param splitters splitters for the boundary facet
-         * @param reversed if true, the facet has the inside on its plus side
-         */
-        private void addContribution(final SubHyperplane<Vector2D> sub,
-                                     final BSPTree<Vector2D> node,
-                                     final Iterable<BSPTree<Vector2D>> splitters,
-                                     final boolean reversed) {
-            @SuppressWarnings("unchecked")
-            final AbstractSubHyperplane<Vector2D, Vector1D> absSub =
-                (AbstractSubHyperplane<Vector2D, Vector1D>) sub;
-            final Line line      = (Line) sub.getHyperplane();
-            final List<Interval> intervals = ((IntervalsSet) absSub.getRemainingRegion()).asList();
-            for (final Interval i : intervals) {
-
-                // find the 2D points
-                final Vector2D startV = Double.isInfinite(i.getInf()) ?
-                                        null : line.toSpace(Vector1D.of(i.getInf()));
-                final Vector2D endV   = Double.isInfinite(i.getSup()) ?
-                                        null : line.toSpace(Vector1D.of(i.getSup()));
-
-                // recover the connectivity information
-                final BSPTree<Vector2D> startN = selectClosest(startV, splitters);
-                final BSPTree<Vector2D> endN   = selectClosest(endV, splitters);
-
-                if (reversed) {
-                    segments.add(new ConnectableSegment(endV, startV, line.reverse(),
-                                                        node, endN, startN));
-                } else {
-                    segments.add(new ConnectableSegment(startV, endV, line,
-                                                        node, startN, endN));
-                }
-
-            }
-        }
-
-        /** Select the node whose cut sub-hyperplane is closest to specified point.
-         * @param point reference point
-         * @param candidates candidate nodes
-         * @return node closest to point, or null if point is null or no node is closer than tolerance
-         */
-        private BSPTree<Vector2D> selectClosest(final Vector2D point, final Iterable<BSPTree<Vector2D>> candidates) {
-            if (point != null) {
-                BSPTree<Vector2D> selected = null;
-                double min = Double.POSITIVE_INFINITY;
-
-                for (final BSPTree<Vector2D> node : candidates) {
-                    final double distance = Math.abs(node.getCut().getHyperplane().getOffset(point));
-                    if (distance < min) {
-                        selected = node;
-                        min      = distance;
-                    }
-                }
-
-                if (precision.eqZero(min)) {
-                    return selected;
-                }
-            }
-            return null;
-        }
-
-        /** Get the segments.
-         * @return built segments
-         */
-        public List<ConnectableSegment> getSegments() {
-            return segments;
-        }
-
-    }
-
-}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/Polyline.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/Polyline.java
new file mode 100644
index 0000000..1722934
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/Polyline.java
@@ -0,0 +1,861 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.twod;
+
+import java.io.Serializable;
+import java.text.MessageFormat;
+import java.util.ArrayList;
+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 org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+
+/** Class representing a polyline, ie, a connected series of line segments. The line
+ * segments in the polyline are connected end to end, with the end vertex of the previous
+ * line segment equivalent to the start vertex of the next line segment. The first segment,
+ * the last segment, or both may be infinite.
+ *
+ * <p>Instances of this class are guaranteed to be immutable.</p>
+ * @see <a href="https://en.wikipedia.org/wiki/Polygonal_chain">Polygonal chain</a>
+ */
+public class Polyline implements Iterable<Segment>, Serializable {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20190522L;
+
+    /** Polyline instance containing no segments. */
+    private static final Polyline EMPTY = new Polyline(Collections.emptyList());
+
+    /** List of line segments comprising the instance. */
+    private final List<Segment> segments;
+
+    /** Simple constructor. No validation is performed on the input segments.
+     * @param segments line segments comprising the instance
+     */
+    private Polyline(final List<Segment> segments) {
+        this.segments = Collections.unmodifiableList(segments);
+    }
+
+    /** Get the line segments comprising the polyline.
+     * @return the line segments comprising the polyline
+     */
+    public List<Segment> getSegments() {
+        return segments;
+    }
+
+    /** Get the start segment for the polyline or null if the polyline is empty.
+     * @return the start segment for the polyline or null if the polyline is empty
+     */
+    public Segment getStartSegment() {
+        if (!isEmpty()) {
+            return segments.get(0);
+        }
+        return null;
+    }
+
+    /** Get the end segment for the polyline or null if the polyline is empty.
+     * @return the end segment for the polyline or null if the polyline is empty
+     */
+    public Segment getEndSegment() {
+        if (!isEmpty()) {
+            return segments.get(segments.size() - 1);
+        }
+        return null;
+    }
+
+    /** Get the start vertex for the polyline or null if the polyline is empty
+     * or has an infinite start segment.
+     * @return the start vertex for the polyline
+     */
+    public Vector2D getStartVertex() {
+        final Segment seg = getStartSegment();
+        return (seg != null) ? seg.getStartPoint() : null;
+    }
+
+    /** Get the end vertex for the polyline or null if the polyline is empty
+     * or has an infinite end segment.
+     * @return the end vertex for the polyline
+     */
+    public Vector2D getEndVertex() {
+        final Segment seg = getEndSegment();
+        return (seg != null) ? seg.getEndPoint() : null;
+    }
+
+    /** Get the vertices contained in the polyline in the order they appear.
+     * Closed polyline contain the start point at the beginning of the list
+     * as well as the end.
+     * @return the vertices contained in the polyline in order they appear
+     */
+    public List<Vector2D> getVertices() {
+        final List<Vector2D> vertices = new ArrayList<>();
+
+        Vector2D pt;
+
+        // add the start point, if present
+        pt = getStartVertex();
+        if (pt != null) {
+            vertices.add(pt);
+        }
+
+        // add end points
+        for (Segment seg : segments) {
+            pt = seg.getEndPoint();
+            if (pt != null) {
+                vertices.add(pt);
+            }
+        }
+
+        return vertices;
+    }
+
+    /** Return true if the polyline has a start of end line segment that
+     * extends to infinity.
+     * @return true if the polyline is infinite
+     */
+    public boolean isInfinite() {
+        return !isEmpty() && (getStartVertex() == null || getEndVertex() == null);
+    }
+
+    /** Return true if the polyline has a finite length. This will be true if there are
+     * no segments in the polyline or if all segments have a finite length.
+     * @return true if the polyline is finite
+     */
+    public boolean isFinite() {
+        return !isInfinite();
+    }
+
+    /** Return true if the polyline does not contain any line segments.
+     * @return true if the polyline does not contain any line segments
+     */
+    public boolean isEmpty() {
+        return segments.isEmpty();
+    }
+
+    /** Return true if the polyline is closed, meaning that the end
+     * point for the last line segment is equal to the start point
+     * for the polyline.
+     * @return true if the end point for the last line segment is
+     *      equal to the start point for the polyline.
+     */
+    public boolean isClosed() {
+        final Segment endSegment = getEndSegment();
+
+        if (endSegment != null) {
+            final Vector2D start = getStartVertex();
+            final Vector2D end = endSegment.getEndPoint();
+
+            return start != null && end != null && start.eq(end, endSegment.getPrecision());
+        }
+
+        return false;
+    }
+
+    /** Transform this instance with the argument, returning the result in a new instance.
+     * @param transform the transform to apply
+     * @return a new instance, transformed by the argument
+     */
+    public Polyline transform(final Transform<Vector2D> transform) {
+        if (!isEmpty()) {
+            final List<Segment> transformed = segments.stream()
+                    .map(s -> s.transform(transform))
+                    .collect(Collectors.toCollection(() -> new ArrayList<>()));
+
+            return new Polyline(transformed);
+        }
+
+        return this;
+    }
+
+    /** Return a new instance with all line segment directions, and their order,
+     * reversed. The last segment in this instance will be the first in the
+     * returned instance.
+     * @return a new instance with the polyline reversed
+     */
+    public Polyline reverse() {
+        if (!isEmpty()) {
+            final List<Segment> reversed = segments.stream()
+                    .map(s -> s.reverse())
+                    .collect(Collectors.toCollection(() -> new ArrayList<>()));
+            Collections.reverse(reversed);
+
+            return new Polyline(reversed);
+        }
+
+        return this;
+    }
+
+    /** Construct a {@link RegionBSPTree2D} from the line segments in this instance.
+     * @return a bsp tree constructed from the line segments in this instance
+     */
+    public RegionBSPTree2D toTree() {
+        RegionBSPTree2D tree = RegionBSPTree2D.empty();
+        tree.insert(this);
+
+        return tree;
+    }
+
+    /** Simplify this polyline, if possible, by combining adjacent segments that lie on the
+     * same line (as determined by {@link Line#equals(Object)}).
+     * @return a simplified instance
+     */
+    public Polyline simplify() {
+        final List<Segment> simplified = new ArrayList<>();
+
+        final int size = segments.size();
+
+        Segment current;
+        Line currentLine;
+        double end;
+
+        int idx = 0;
+        int testIdx;
+        while (idx < size) {
+            current = segments.get(idx);
+            currentLine = current.getLine();
+            end = current.getSubspaceEnd();
+
+            // try to combine with forward neighbors
+            testIdx = idx + 1;
+            while (testIdx < size && currentLine.equals(segments.get(testIdx).getLine())) {
+                end = Math.max(end, segments.get(testIdx).getSubspaceEnd());
+                ++testIdx;
+            }
+
+            if (testIdx > idx + 1) {
+                // we found something to merge
+                simplified.add(currentLine.segment(current.getSubspaceStart(), end));
+            } else {
+                simplified.add(current);
+            }
+
+            idx = testIdx;
+        }
+
+        // combine the first and last items if needed
+        if (isClosed() && simplified.size() > 2 && simplified.get(0).getLine().equals(
+                simplified.get(simplified.size() - 1).getLine())) {
+
+            final Segment startSegment = simplified.get(0);
+            final Segment endSegment = simplified.remove(simplified.size() - 1);
+
+            final Segment combined = endSegment.getLine().segment(endSegment.getSubspaceStart(),
+                    startSegment.getSubspaceEnd());
+
+            simplified.set(0, combined);
+        }
+
+        return new SimplifiedPolyline(simplified);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Iterator<Segment> iterator() {
+        return segments.iterator();
+    }
+
+    /** Return a string representation of the segment polyline.
+     *
+     * <p>In order to keep the string representation short but useful, the exact format of the return
+     * value depends on the properties of the polyline. See below for examples.
+     *
+     * <ul>
+     *      <li>Empty path
+     *          <ul>
+     *              <li>{@code Polyline[empty= true]}</li>
+     *          </ul>
+     *      </li>
+     *      <li>Single segment
+     *          <ul>
+     *              <li>{@code Polyline[segment= Segment[lineOrigin= (0.0, 0.0), lineDirection= (1.0, 0.0)]]}</li>
+     *              <li>{@code Polyline[segment= Segment[start= (0.0, 0.0), end= (1.0, 0.0)]]}</li>
+     *          </ul>
+     *      </li>
+     *      <li>Path with infinite start segment
+     *          <ul>
+     *              <li>{@code Polyline[startDirection= (1.0, 0.0), vertices= [(1.0, 0.0), (1.0, 1.0)]]}</li>
+     *          </ul>
+     *      </li>
+     *      <li>Path with infinite end segment
+     *          <ul>
+     *              <li>{@code Polyline[vertices= [(0.0, 1.0), (0.0, 0.0)], endDirection= (1.0, 0.0)]}</li>
+     *          </ul>
+     *      </li>
+     *      <li>Path with infinite start and end segments
+     *          <ul>
+     *              <li>{@code Polyline[startDirection= (0.0, 1.0), vertices= [(0.0, 0.0)], endDirection= (1.0, 0.0)]}</li>
+     *          </ul>
+     *      </li>
+     *      <li>Path with no infinite segments
+     *          <ul>
+     *              <li>{@code Polyline[vertices= [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)]]}</li>
+     *          </ul>
+     *      </li>
+     * </ul>
+     */
+    @Override
+    public String toString() {
+        final StringBuilder sb = new StringBuilder();
+        sb.append(this.getClass().getSimpleName())
+            .append('[');
+
+        if (segments.isEmpty()) {
+            sb.append("empty= true");
+        } else if (segments.size() == 1) {
+            sb.append("segment= ")
+                .append(segments.get(0));
+        } else {
+            final Segment startSegment = getStartSegment();
+            if (startSegment.getStartPoint() == null) {
+                sb.append("startDirection= ")
+                    .append(startSegment.getLine().getDirection())
+                    .append(", ");
+            }
+
+            sb.append("vertices= ")
+                .append(getVertices());
+
+            final Segment endSegment = getEndSegment();
+            if (endSegment.getEndPoint() == null) {
+                sb.append(", endDirection= ")
+                    .append(endSegment.getLine().getDirection());
+            }
+        }
+
+        sb.append(']');
+
+        return sb.toString();
+    }
+
+    /** Return a {@link Builder} instance configured with the given precision
+     * context. The precision context is used when building line segments from
+     * vertices and may be omitted if raw vertices are not used.
+     * @param precision precision context to use when building line segments from
+     *      raw vertices; may be null if raw vertices are not used.
+     * @return a new {@link Builder} instance
+     */
+    public static Builder builder(final DoublePrecisionContext precision) {
+        return new Builder(precision);
+    }
+
+    /** Build a new polyline from the given segments.
+     * @param segments the segment to comprise the polyline
+     * @return new polyline containing the given line segment in order
+     * @throws IllegalStateException if the segments do not form a connected polyline
+     */
+    public static Polyline fromSegments(final Segment... segments) {
+        return fromSegments(Arrays.asList(segments));
+    }
+
+    /** Build a new polyline from the given segments.
+     * @param segments the segment to comprise the path
+     * @return new polyline containing the given line segments in order
+     * @throws IllegalStateException if the segments do not form a connected polyline
+     */
+    public static Polyline fromSegments(final Collection<Segment> segments) {
+        Builder builder = builder(null);
+
+        for (Segment segment : segments) {
+            builder.append(segment);
+        }
+
+        return builder.build();
+    }
+
+    /** Build a new polyline from the given vertices. A line segment is created
+     * from the last vertex to the first one, if the two vertices are not already
+     * considered equal using the given precision context. This method is equivalent to
+     * calling {@link #fromVertices(Collection, boolean, DoublePrecisionContext)
+     * fromVertices(vertices, true, precision)}
+     * @param vertices the vertices to construct the closed path from
+     * @param precision precision context used to construct the line segment
+     *      instances for the path
+     * @return new closed polyline constructed from the given vertices
+     * @see #fromVertices(Collection, boolean, DoublePrecisionContext)
+     */
+    public static Polyline fromVertexLoop(final Collection<Vector2D> vertices,
+            final DoublePrecisionContext precision) {
+
+        return fromVertices(vertices, true, precision);
+    }
+
+    /** Build a new polyline from the given vertices. No additional segment is added
+     * from the last vertex to the first. This method is equivalent to calling
+     * {@link #fromVertices(Collection, boolean, DoublePrecisionContext)
+     * fromVertices(vertices, false, precision)}.
+     * @param vertices the vertices to construct the path from
+     * @param precision precision context used to construct the line segment
+     *      instances for the path
+     * @return new polyline constructed from the given vertices
+     * @see #fromVertices(Collection, boolean, DoublePrecisionContext)
+     */
+    public static Polyline fromVertices(final Collection<Vector2D> vertices,
+            final DoublePrecisionContext precision) {
+
+        return fromVertices(vertices, false, precision);
+    }
+
+    /** Build a new polyline from the given vertices.
+     * @param vertices the vertices to construct the path from
+     * @param close if true, a line segment is created from the last vertex
+     *      given to the first one, if the two vertices are not already considered
+     *      equal using the given precision context.
+     * @param precision precision context used to construct the line segment
+     *      instances for the path
+     * @return new polyline constructed from the given vertices
+     */
+    public static Polyline fromVertices(final Collection<Vector2D> vertices,
+            final boolean close, final DoublePrecisionContext precision) {
+
+        return builder(precision)
+                .appendVertices(vertices)
+                .build(close);
+    }
+
+    /** Return a line segment path containing no segments.
+     * @return a line segment path containing no segments.
+     */
+    public static Polyline empty() {
+        return EMPTY;
+    }
+
+    /** Class used to build polylines.
+     */
+    public static final class Builder implements Serializable {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20190522L;
+
+        /** Line segments appended to the polyline. */
+        private List<Segment> appendedSegments = null;
+
+        /** Line segments prepended to the polyline. */
+        private List<Segment> prependedSegments = null;
+
+        /** Precision context used when creating line segments directly from vertices. */
+        private DoublePrecisionContext precision;
+
+        /** The current vertex at the start of the polyline. */
+        private Vector2D startVertex;
+
+        /** The current vertex at the end of the polyline. */
+        private Vector2D endVertex;
+
+        /** The precision context used when performing comparisons involving the current
+         * end vertex.
+         */
+        private DoublePrecisionContext endVertexPrecision;
+
+        /** Construct a new instance configured with the given precision context. The
+         * precision context is used when building line segments from vertices and
+         * may be omitted if raw vertices are not used.
+         * @param precision precision context to use when creating line segments
+         *      from vertices
+         */
+        private Builder(final DoublePrecisionContext precision) {
+            setPrecision(precision);
+        }
+
+        /** Set the precision context. This context is used only when creating line segments
+         * from appended or prepended vertices. It is not used when adding existing
+         * {@link Segment} instances since those contain their own precision contexts.
+         * @param builderPrecision precision context to use when creating line segments
+         *      from vertices
+         * @return this instance
+         */
+        public Builder setPrecision(final DoublePrecisionContext builderPrecision) {
+            this.precision = builderPrecision;
+
+            return this;
+        }
+
+        /** Get the line segment at the start of the polyline or null if
+         * it does not exist.
+         * @return the line segment at the start of the polyline
+         */
+        public Segment getStartSegment() {
+            Segment start = getLast(prependedSegments);
+            if (start == null) {
+                start = getFirst(appendedSegments);
+            }
+            return start;
+        }
+
+        /** Get the line segment at the end of the polyline or null if
+         * it does not exist.
+         * @return the line segment at the end of the polyline
+         */
+        public Segment getEndSegment() {
+            Segment end = getLast(appendedSegments);
+            if (end == null) {
+                end = getFirst(prependedSegments);
+            }
+            return end;
+        }
+
+        /** Append a line segment to the end of the polyline.
+         * @param segment line segment to append to the polyline
+         * @return the current builder instance
+         * @throws IllegalStateException if the polyline contains a previous segment
+         *      and the end vertex of the previous segment is not equivalent to the
+         *      start vertex of the given segment.
+         */
+        public Builder append(final Segment segment) {
+            validateSegmentsConnected(getEndSegment(), segment);
+            appendInternal(segment);
+
+            return this;
+        }
+
+        /** Add a vertex to the end of this polyline. If the polyline already has an end vertex,
+         * then a line segment is added between the previous end vertex and this vertex,
+         * using the configured precision context.
+         * @param vertex the vertex to add
+         * @return this instance
+         * @see #setPrecision(DoublePrecisionContext)
+         */
+        public Builder append(final Vector2D vertex) {
+            final DoublePrecisionContext vertexPrecision = getAddVertexPrecision();
+
+            if (endVertex == null) {
+                // make sure that we're not adding to an infinite segment
+                final Segment end = getEndSegment();
+                if (end != null) {
+                    throw new IllegalStateException(
+                            MessageFormat.format("Cannot add vertex {0} after infinite line segment: {1}",
+                                    vertex, end));
+                }
+
+                // this is the first vertex added
+                startVertex = vertex;
+                endVertex = vertex;
+                endVertexPrecision = vertexPrecision;
+            } else if (!endVertex.eq(vertex, endVertexPrecision)) {
+                // only add the vertex if its not equal to the end point
+                // of the last segment
+                appendInternal(Segment.fromPoints(endVertex, vertex, endVertexPrecision));
+            }
+
+            return this;
+        }
+
+        /** Convenience method for appending a collection of vertices to the polyline in a single method call.
+         * @param vertices the vertices to append
+         * @return this instance
+         * @see #append(Vector2D)
+         */
+        public Builder appendVertices(final Collection<Vector2D> vertices) {
+            for (Vector2D vertex : vertices) {
+                append(vertex);
+            }
+
+            return this;
+        }
+
+        /** Convenience method for appending multiple vertices to the polyline at once.
+         * @param vertices the vertices to append
+         * @return this instance
+         * @see #append(Vector2D)
+         */
+        public Builder appendVertices(final Vector2D... vertices) {
+            return appendVertices(Arrays.asList(vertices));
+        }
+
+        /** Prepend a line segment to the beginning of the polyline.
+         * @param segment line segment to prepend to the polyline
+         * @return the current builder instance
+         * @throws IllegalStateException if the polyline contains a start segment
+         *      and the end vertex of the given segment is not equivalent to the
+         *      start vertex of the start segment.
+         */
+        public Builder prepend(final Segment segment) {
+            validateSegmentsConnected(segment, getStartSegment());
+            prependInternal(segment);
+
+            return this;
+        }
+
+        /** Add a vertex to the front of this polyline. If the polyline already has a start vertex,
+         * then a line segment is added between this vertex and the previous start vertex,
+         * using the configured precision context.
+         * @param vertex the vertex to add
+         * @return this instance
+         * @see #setPrecision(DoublePrecisionContext)
+         */
+        public Builder prepend(final Vector2D vertex) {
+            final DoublePrecisionContext vertexPrecision = getAddVertexPrecision();
+
+            if (startVertex == null) {
+                // make sure that we're not adding to an infinite segment
+                final Segment start = getStartSegment();
+                if (start != null) {
+                    throw new IllegalStateException(
+                            MessageFormat.format("Cannot add vertex {0} before infinite line segment: {1}",
+                                    vertex, start));
+                }
+
+                // this is the first vertex added
+                startVertex = vertex;
+                endVertex = vertex;
+                endVertexPrecision = vertexPrecision;
+            } else if (!vertex.eq(startVertex, vertexPrecision)) {
+                // only add if the vertex is not equal to the start
+                // point of the first segment
+                prependInternal(Segment.fromPoints(vertex, startVertex, vertexPrecision));
+            }
+
+            return this;
+        }
+
+        /** Convenience method for prepending a collection of vertices to the polyline in a single method call.
+         * The vertices are logically prepended as a single group, meaning that the first vertex
+         * in the given collection appears as the first vertex in the polyline after this method call.
+         * Internally, this means that the vertices are actually passed to the {@link #prepend(Vector2D)}
+         * method in reverse order.
+         * @param vertices the vertices to prepend
+         * @return this instance
+         * @see #prepend(Vector2D)
+         */
+        public Builder prependVertices(final Collection<Vector2D> vertices) {
+            return prependVertices(vertices.toArray(new Vector2D[0]));
+        }
+
+        /** Convenience method for prepending multiple vertices to the polyline in a single method call.
+         * The vertices are logically prepended as a single group, meaning that the first vertex
+         * in the given collection appears as the first vertex in the polyline after this method call.
+         * Internally, this means that the vertices are actually passed to the {@link #prepend(Vector2D)}
+         * method in reverse order.
+         * @param vertices the vertices to prepend
+         * @return this instance
+         * @see #prepend(Vector2D)
+         */
+        public Builder prependVertices(final Vector2D... vertices) {
+            for (int i = vertices.length - 1; i >= 0; --i) {
+                prepend(vertices[i]);
+            }
+
+            return this;
+        }
+
+        /** Close the current polyline and build a new {@link Polyline} instance.  This method is equivalent
+         * to {@code builder.build(true)}.
+         * @return new closed polyline instance
+         */
+        public Polyline close() {
+            return build(true);
+        }
+
+        /** Build a {@link Polyline} instance from the configured polyline. This method is equivalent
+         * to {@code builder.build(false)}.
+         * @return new polyline instance
+         */
+        public Polyline build() {
+            return build(false);
+        }
+
+        /** Build a {@link Polyline} instance from the configured polyline.
+         * @param close if true, the path will be closed by adding an end point equivalent to the
+         *      start point
+         * @return new polyline instance
+         */
+        public Polyline build(final boolean close) {
+            if (close) {
+                closePath();
+            }
+
+            // combine all of the segments
+            List<Segment> result = null;
+
+            if (prependedSegments != null) {
+                result = prependedSegments;
+                Collections.reverse(result);
+            }
+
+            if (appendedSegments != null) {
+                if (result == null) {
+                    result = appendedSegments;
+                } else {
+                    result.addAll(appendedSegments);
+                }
+            }
+
+            if (result == null) {
+                result = Collections.emptyList();
+            }
+
+            if (result.isEmpty() && startVertex != null) {
+                throw new IllegalStateException(
+                        MessageFormat.format("Unable to create polyline; only a single vertex provided: {0} ",
+                                startVertex));
+            }
+
+            // clear internal state
+            appendedSegments = null;
+            prependedSegments = null;
+
+            // build the final polyline instance, using the shared empty instance if
+            // no segments are present
+            return result.isEmpty() ? empty() : new Polyline(result);
+        }
+
+        /** Close the path by adding an end point equivalent to the path start point.
+         * @throws IllegalStateException if the path cannot be closed
+         */
+        private void closePath() {
+            final Segment end = getEndSegment();
+
+            if (end != null) {
+                if (startVertex != null && endVertex != null) {
+                    if (!endVertex.eq(startVertex, endVertexPrecision)) {
+                        appendInternal(Segment.fromPoints(endVertex, startVertex, endVertexPrecision));
+                    }
+                } else {
+                    throw new IllegalStateException("Unable to close polyline: polyline is infinite");
+                }
+            }
+        }
+
+        /** Validate that the given segments are connected, meaning that the end vertex of {@code previous}
+         * is equivalent to the start vertex of {@code next}. The segments are considered valid if either
+         * segment is null.
+         * @param previous previous segment
+         * @param next next segment
+         * @throws IllegalStateException if previous and next are not null and the end vertex of previous
+         *      is not equivalent the start vertex of next
+         */
+        private void validateSegmentsConnected(final Segment previous, final Segment next) {
+            if (previous != null && next != null) {
+                final Vector2D nextStartVertex = next.getStartPoint();
+                final Vector2D previousEndVertex = previous.getEndPoint();
+                final DoublePrecisionContext previousPrecision = previous.getPrecision();
+
+                if (nextStartVertex == null || previousEndVertex == null ||
+                        !(nextStartVertex.eq(previousEndVertex, previousPrecision))) {
+
+                    throw new IllegalStateException(
+                            MessageFormat.format("Polyline segments are not connected: previous= {0}, next= {1}",
+                                    previous, next));
+                }
+            }
+        }
+
+        /** Get the precision context used when adding raw vertices to the polyline. An exception is thrown
+         * if no precision has been specified.
+         * @return the precision context used when creating working with raw vertices
+         * @throws IllegalStateException if no precision context is configured
+         */
+        private DoublePrecisionContext getAddVertexPrecision() {
+            if (precision == null) {
+                throw new IllegalStateException("Unable to create line segment: no vertex precision specified");
+            }
+
+            return precision;
+        }
+
+        /** Append the given, validated segment to the polyline.
+         * @param segment validated segment to append
+         */
+        private void appendInternal(final Segment segment) {
+            if (appendedSegments == null) {
+                appendedSegments = new ArrayList<>();
+            }
+
+            if (appendedSegments.isEmpty() &&
+                    (prependedSegments == null || prependedSegments.isEmpty())) {
+                startVertex = segment.getStartPoint();
+            }
+
+            endVertex = segment.getEndPoint();
+            endVertexPrecision = segment.getPrecision();
+
+            appendedSegments.add(segment);
+        }
+
+        /** Prepend the given, validated segment to the polyline.
+         * @param segment validated segment to prepend
+         */
+        private void prependInternal(final Segment segment) {
+            if (prependedSegments == null) {
+                prependedSegments = new ArrayList<>();
+            }
+
+            startVertex = segment.getStartPoint();
+
+            if (prependedSegments.isEmpty() &&
+                    (appendedSegments == null || appendedSegments.isEmpty())) {
+                endVertex = segment.getEndPoint();
+                endVertexPrecision = segment.getPrecision();
+            }
+
+            prependedSegments.add(segment);
+        }
+
+        /** Get the first element in the list or null if the list is null
+         * or empty.
+         * @param list the list to return the first item from
+         * @return the first item from the given list or null if it does not exist
+         */
+        private Segment getFirst(final List<Segment> list) {
+            if (list != null && list.size() > 0) {
+                return list.get(0);
+            }
+            return null;
+        }
+
+        /** Get the last element in the list or null if the list is null
+         * or empty.
+         * @param list the list to return the last item from
+         * @return the last item from the given list or null if it does not exist
+         */
+        private Segment getLast(final List<Segment> list) {
+            if (list != null && list.size() > 0) {
+                return list.get(list.size() - 1);
+            }
+            return null;
+        }
+    }
+
+    /** Internal class returned when a polyline is simplified to remove
+     * unecessary line segments divisions. The {@link #simplify()} method on this
+     * class simply returns the same instance.
+     */
+    private static final class SimplifiedPolyline extends Polyline {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20190619;
+
+        /** Create a new instance containing the given line segments. No validation is
+         * performed on the inputs. Caller must ensure that the given segments represent
+         * a valid, simplified path.
+         * @param segments line segments comprising the path
+         */
+        private SimplifiedPolyline(final List<Segment> segments) {
+            super(segments);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public Polyline simplify() {
+            // already simplified
+            return this;
+        }
+    }
+}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/RegionBSPTree2D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/RegionBSPTree2D.java
new file mode 100644
index 0000000..c577eb3
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/RegionBSPTree2D.java
@@ -0,0 +1,506 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.twod;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.apache.commons.geometry.core.exception.GeometryValueException;
+import org.apache.commons.geometry.core.partitioning.Hyperplane;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.bsp.AbstractBSPTree;
+import org.apache.commons.geometry.core.partitioning.bsp.AbstractRegionBSPTree;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+
+/** Binary space partitioning (BSP) tree representing a region in two dimensional
+ * Euclidean space.
+ */
+public final class RegionBSPTree2D extends AbstractRegionBSPTree<Vector2D, RegionBSPTree2D.RegionNode2D> {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20190519L;
+
+    /** List of line segment paths comprising the region boundary. */
+    private List<Polyline> boundaryPaths;
+
+    /** Create a new, empty region.
+     */
+    public RegionBSPTree2D() {
+        this(false);
+    }
+
+    /** Create a new region. If {@code full} is true, then the region will
+     * represent the entire 2D space. Otherwise, it will be empty.
+     * @param full whether or not the region should contain the entire
+     *      2D space or be empty
+     */
+    public RegionBSPTree2D(boolean full) {
+        super(full);
+    }
+
+    /** Return a deep copy of this instance.
+     * @return a deep copy of this instance.
+     * @see #copy(org.apache.commons.geometry.core.partitioning.bsp.BSPTree)
+     */
+    public RegionBSPTree2D copy() {
+        RegionBSPTree2D result = RegionBSPTree2D.empty();
+        result.copy(this);
+
+        return result;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Iterable<Segment> boundaries() {
+        return createBoundaryIterable(b -> (Segment) b);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public List<Segment> getBoundaries() {
+        return createBoundaryList(b -> (Segment) b);
+    }
+
+    /** Get the boundary of the region as a list of connected line segment paths. The
+     * line segments are oriented such that their minus (left) side lies on the
+     * interior of the region.
+     * @return line segment paths representing the region boundary
+     */
+    public List<Polyline> getBoundaryPaths() {
+        if (boundaryPaths == null) {
+            boundaryPaths = Collections.unmodifiableList(computeBoundaryPaths());
+        }
+        return boundaryPaths;
+    }
+
+    /** Add a convex area to this region. The resulting region will be the
+     * union of the convex area and the region represented by this instance.
+     * @param area the convex area to add
+     */
+    public void add(final ConvexArea area) {
+        union(from(area));
+    }
+
+    /** Return a list of {@link ConvexArea}s representing the same region
+     * as this instance. One convex area is returned for each interior leaf
+     * node in the tree.
+     * @return a list of convex areas representing the same region as this
+     *      instance
+     */
+    public List<ConvexArea> toConvex() {
+        final List<ConvexArea> result = new ArrayList<>();
+
+        toConvexRecursive(getRoot(), ConvexArea.full(), result);
+
+        return result;
+    }
+
+    /** Recursive method to compute the convex areas of all inside leaf nodes in the subtree rooted at the given
+     * node. The computed convex areas are added to the given list.
+     * @param node root of the subtree to compute the convex areas for
+     * @param nodeArea the convex area for the current node; this will be split by the node's cut hyperplane to
+     *      form the convex areas for any child nodes
+     * @param result list containing the results of the computation
+     */
+    private void toConvexRecursive(final RegionNode2D node, final ConvexArea nodeArea, final List<ConvexArea> result) {
+        if (node.isLeaf()) {
+            // base case; only add to the result list if the node is inside
+            if (node.isInside()) {
+                result.add(nodeArea);
+            }
+        } else {
+            // recurse
+            Split<ConvexArea> split = nodeArea.split(node.getCutHyperplane());
+
+            toConvexRecursive(node.getMinus(), split.getMinus(), result);
+            toConvexRecursive(node.getPlus(), split.getPlus(), result);
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Split<RegionBSPTree2D> split(final Hyperplane<Vector2D> splitter) {
+        return split(splitter, RegionBSPTree2D.empty(), RegionBSPTree2D.empty());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Vector2D project(final Vector2D pt) {
+        // use our custom projector so that we can disambiguate points that are
+        // actually equidistant from the target point
+        final BoundaryProjector2D projector = new BoundaryProjector2D(pt);
+        accept(projector);
+
+        return projector.getProjected();
+    }
+
+    /** Compute the line segment paths comprising the region boundary.
+     * @return the line segment paths comprising the region boundary
+     */
+    private List<Polyline> computeBoundaryPaths() {
+        final InteriorAngleSegmentConnector connector = new InteriorAngleSegmentConnector.Minimize();
+        connector.connect(boundaries());
+
+        return connector.connectAll().stream()
+                .map(Polyline::simplify).collect(Collectors.toList());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected RegionSizeProperties<Vector2D> computeRegionSizeProperties() {
+        // handle simple cases
+        if (isFull()) {
+            return new RegionSizeProperties<>(Double.POSITIVE_INFINITY, null);
+        } else if (isEmpty()) {
+            return new RegionSizeProperties<>(0, null);
+        }
+
+        // compute the size based on the boundary segments
+        double quadrilateralAreaSum = 0.0;
+
+        double scaledSumX = 0.0;
+        double scaledSumY = 0.0;
+
+        Vector2D startPoint;
+        Vector2D endPoint;
+        double signedArea;
+
+        for (Segment segment : boundaries()) {
+
+            if (segment.isInfinite()) {
+                // at least on boundary is infinite, meaning that
+                // the size is also infinite
+                quadrilateralAreaSum = Double.POSITIVE_INFINITY;
+
+                break;
+            }
+
+            startPoint = segment.getStartPoint();
+            endPoint = segment.getEndPoint();
+
+            // compute the area
+            signedArea = startPoint.signedArea(endPoint);
+
+            quadrilateralAreaSum += signedArea;
+
+            // compute scaled coordinate values for the barycenter
+            scaledSumX += signedArea * (startPoint.getX() + endPoint.getX());
+            scaledSumY += signedArea * (startPoint.getY() + endPoint.getY());
+        }
+
+        double size = Double.POSITIVE_INFINITY;
+        Vector2D barycenter = null;
+
+        // The area is finite only if the computed quadrilateral area is finite and non-negative.
+        // Negative areas indicate that the region is inside-out, with a finite outside surrounded
+        // by an infinite inside.
+        if (quadrilateralAreaSum >= 0.0 && Double.isFinite(quadrilateralAreaSum)) {
+            size = 0.5 * quadrilateralAreaSum;
+
+            if (quadrilateralAreaSum > 0.0) {
+                barycenter = Vector2D.of(scaledSumX, scaledSumY).multiply(1.0 / (3.0 * quadrilateralAreaSum));
+            }
+        }
+
+        return new RegionSizeProperties<>(size, barycenter);
+    }
+
+    /** Compute the region represented by the given node.
+     * @param node the node to compute the region for
+     * @return the region represented by the given node
+     */
+    private ConvexArea computeNodeRegion(final RegionNode2D node) {
+        ConvexArea area = ConvexArea.full();
+
+        RegionNode2D child = node;
+        RegionNode2D parent;
+
+        while ((parent = child.getParent()) != null) {
+            Split<ConvexArea> split = area.split(parent.getCutHyperplane());
+
+            area = child.isMinus() ? split.getMinus() : split.getPlus();
+
+            child = parent;
+        }
+
+        return area;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void invalidate() {
+        super.invalidate();
+
+        boundaryPaths = null;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected RegionNode2D createNode() {
+        return new RegionNode2D(this);
+    }
+
+    /** Return a new {@link RegionBSPTree2D} instance containing the entire space.
+     * @return a new {@link RegionBSPTree2D} instance containing the entire space
+     */
+    public static RegionBSPTree2D full() {
+        return new RegionBSPTree2D(true);
+    }
+
+    /** Return a new, empty {@link RegionBSPTree2D} instance.
+     * @return a new, empty {@link RegionBSPTree2D} instance
+     */
+    public static RegionBSPTree2D empty() {
+        return new RegionBSPTree2D(false);
+    }
+
+    /** Construct a tree from a convex area.
+     * @param area the area to construct a tree from
+     * @return tree instance representing the same area as the given
+     *      convex area
+     */
+    public static RegionBSPTree2D from(final ConvexArea area) {
+        final RegionBSPTree2D tree = RegionBSPTree2D.full();
+        tree.insert(area.getBoundaries());
+
+        return tree;
+    }
+
+    /** Create a new {@link RegionBSPTree2D.Builder} instance for creating BSP
+     * trees from boundary representations.
+     * @param precision precision context to use for floating point comparisons.
+     * @return a new builder instance
+     */
+    public static Builder builder(final DoublePrecisionContext precision) {
+        return new Builder(precision);
+    }
+
+    /** BSP tree node for two dimensional Euclidean space.
+     */
+    public static final class RegionNode2D extends AbstractRegionBSPTree.AbstractRegionNode<Vector2D, RegionNode2D> {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20190519L;
+
+        /** Simple constructor.
+         * @param tree the owning tree instance
+         */
+        private RegionNode2D(AbstractBSPTree<Vector2D, RegionNode2D> tree) {
+            super(tree);
+        }
+
+        /** Get the region represented by this node. The returned region contains
+         * the entire area contained in this node, regardless of the attributes of
+         * any child nodes.
+         * @return the region represented by this node
+         */
+        public ConvexArea getNodeRegion() {
+            return ((RegionBSPTree2D) getTree()).computeNodeRegion(this);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected RegionNode2D getSelf() {
+            return this;
+        }
+    }
+
+    /** Class used to construct {@link RegionBSPTree2D} instances from boundary representations.
+     */
+    public static final class Builder {
+
+        /** Precision object used to perform floating point comparisons. This object is
+         * used when constructing geometric types.
+         */
+        private final DoublePrecisionContext precision;
+
+        /** The BSP tree being constructed. */
+        private final RegionBSPTree2D tree = RegionBSPTree2D.empty();
+
+        /** Create a new builder instance. The given precision context will be used when
+         * constructing geometric types.
+         * @param precision precision object used to perform floating point comparisons
+         */
+        public Builder(final DoublePrecisionContext precision) {
+            this.precision = precision;
+        }
+
+        /** Add a subline to the tree.
+         * @param subline subline to add
+         * @return this builder instance
+         */
+        public Builder add(final SubLine subline) {
+            tree.insert(subline);
+            return this;
+        }
+
+        /** Add a segment to the tree.
+         * @param segment segment to add
+         * @return this builder instance
+         */
+        public Builder add(final Segment segment) {
+            tree.insert(segment);
+            return this;
+        }
+
+        /** Add the line segments defined in the given segment path.
+         * @param path path containing line segments to add
+         * @return this builder instance
+         */
+        public Builder add(final Polyline path) {
+            for (Segment segment : path) {
+                add(segment);
+            }
+
+            return this;
+        }
+
+        /** Add a segment defined by the given points.
+         * @param start start point
+         * @param end end point
+         * @return this builder instance
+         */
+        public Builder addSegment(final Vector2D start, final Vector2D end) {
+            return add(Segment.fromPoints(start, end, precision));
+        }
+
+        /** Add segments defining an axis-oriented square with the given corner point and size.
+         * @param center center point of the square
+         * @param size the size of the square
+         * @return this builder instance
+         * @throws GeometryValueException if the width or height of the defined rectangle is zero
+         *      as evaluated by the precision context.
+         */
+        public Builder addCenteredSquare(final Vector2D center, final double size) {
+            return addCenteredRect(center, size, size);
+        }
+
+        /** Add segments defining an axis-oriented square with the given corner point and size.
+         * @param corner point in the corner of the square
+         * @param size the size of the square
+         * @return this builder instance
+         * @throws GeometryValueException if the width or height of the defined rectangle is zero
+         *      as evaluated by the precision context.
+         */
+        public Builder addSquare(final Vector2D corner, final double size) {
+            return addRect(corner, size, size);
+        }
+
+        /** Add segments defining an axis-oriented rectangular region with the given center point and size.
+         * @param center center point for the region
+         * @param xSize size along the x-axis
+         * @param ySize size along the y-axis
+         * @return this builder instance
+         * @throws GeometryValueException if the width or height of the defined rectangle is zero
+         *      as evaluated by the precision context.
+         */
+        public Builder addCenteredRect(final Vector2D center, final double xSize, final double ySize) {
+            return addRect(Vector2D.of(
+                        center.getX() - (0.5 * xSize),
+                        center.getY() - (0.5 * ySize)
+                    ), xSize, ySize);
+        }
+
+        /** Add segments defining an axis-oriented rectangular region. The region
+         * is constructed by taking {@code pt} as one corner of the region and adding {@code xDelta}
+         * and {@code yDelta} to its components to create the opposite corner. If {@code xDelta}
+         * and {@code yDelta} are both positive, then the constructed rectangle will have {@code pt}
+         * as its lower-left corner and will have a width and height of {@code xDelta} and {@code yDelta}
+         * respectively.
+         * @param pt point lying in a corner of the region
+         * @param xDelta distance to move along the x axis to place the other points in the
+         *      rectangle; this value may be negative, in which case {@code pt} will lie
+         *      on the right side of the constructed rectangle
+         * @param yDelta distance to move along the y axis to place the other points in the
+         *      rectangle; this value may be negative, in which case {@code pt} will lie
+         *      on the top of the rectangle
+         * @return this builder instance
+         * @throws GeometryValueException if the width or height of the defined rectangle is zero
+         *      as evaluated by the precision context.
+         */
+        public Builder addRect(final Vector2D pt, final double xDelta, final double yDelta) {
+            return addRect(pt, Vector2D.of(
+                    pt.getX() + xDelta,
+                    pt.getY() + yDelta));
+        }
+
+        /** Add segments defining an axis-oriented rectangular region. The points {@code a} and {@code b}
+         * are taken to represent opposite corner points in the rectangle and may be specified in any order.
+         * @param a first corner point in the rectangle (opposite of {@code b})
+         * @param b second corner point in the rectangle (opposite of {@code a})
+         * @return this builder instance
+         * @throws GeometryValueException if the width or height of the defined rectangle is zero
+         *      as evaluated by the precision context.
+         */
+        public Builder addRect(final Vector2D a, final Vector2D b) {
+            final double minX = Math.min(a.getX(), b.getX());
+            final double maxX = Math.max(a.getX(), b.getX());
+
+            final double minY = Math.min(a.getY(), b.getY());
+            final double maxY = Math.max(a.getY(), b.getY());
+
+            if (precision.eq(minX, maxX) || precision.eq(minY, maxY)) {
+                throw new GeometryValueException("Rectangle has zero size: " + a + ", " + b + ".");
+            }
+
+            final Vector2D lowerLeft = Vector2D.of(minX, minY);
+            final Vector2D upperLeft = Vector2D.of(minX, maxY);
+
+            final Vector2D upperRight = Vector2D.of(maxX, maxY);
+            final Vector2D lowerRight = Vector2D.of(maxX, minY);
+
+            addSegment(lowerLeft, lowerRight);
+            addSegment(upperRight, upperLeft);
+            addSegment(lowerRight, upperRight);
+            addSegment(upperLeft, lowerLeft);
+
+            return this;
+        }
+
+        /** Get the created BSP tree.
+         * @return the created BSP tree
+         */
+        public RegionBSPTree2D build() {
+            return tree;
+        }
+    }
+
+    /** Class used to project points onto the 2D region boundary.
+     */
+    private static final class BoundaryProjector2D extends BoundaryProjector<Vector2D, RegionNode2D> {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20190811L;
+
+        /** Simple constructor.
+         * @param point the point to project onto the region's boundary
+         */
+        BoundaryProjector2D(final Vector2D point) {
+            super(point);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected Vector2D disambiguateClosestPoint(final Vector2D target, final Vector2D a, final Vector2D b) {
+            // return the point with the smallest coordinate values
+            final int cmp = Vector2D.COORDINATE_ASCENDING_ORDER.compare(a, b);
+            return cmp < 0 ? a : b;
+        }
+    }
+}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/Segment.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/Segment.java
index b2f82b3..2f959fc 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/Segment.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/Segment.java
@@ -16,92 +16,294 @@
  */
 package org.apache.commons.geometry.euclidean.twod;
 
-/** Simple container for a two-points segment.
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.partitioning.ConvexSubHyperplane;
+import org.apache.commons.geometry.core.partitioning.Hyperplane;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.oned.FunctionTransform1D;
+import org.apache.commons.geometry.euclidean.oned.Interval;
+import org.apache.commons.geometry.euclidean.oned.Vector1D;
+import org.apache.commons.geometry.euclidean.twod.Line.SubspaceTransform;
+
+/** <p>Class representing a line segment in 2D Euclidean space. Segments
+ * need not be finite, in which case the start or end point (or both)
+ * will be null.</p>
+ *
+ * <p>Instances of this class are guaranteed to be immutable.</p>
  */
-public class Segment {
+public final class Segment extends AbstractSubLine
+    implements ConvexSubHyperplane<Vector2D> {
 
-    /** Start point of the segment. */
-    private final Vector2D start;
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20190729L;
 
-    /** End point of the segment. */
-    private final Vector2D end;
+    /** String used to indicate the start point of the segment in the toString() representation. */
+    private static final String START_STR = "start= ";
 
-    /** Line containing the segment. */
-    private final Line     line;
+    /** String used to indicate the direction the segment in the toString() representation. */
+    private static final String DIR_STR = "direction= ";
 
-    /** Build a segment.
-     * @param start start point of the segment
-     * @param end end point of the segment
-     * @param line line containing the segment
+    /** String used to indicate the end point of the segment in the toString() representation. */
+    private static final String END_STR = "end= ";
+
+    /** String used as a separator value in the toString() representation. */
+    private static final String SEP_STR = ", ";
+
+    /** The interval representing the region of the line contained in
+     * the line segment.
      */
-    public Segment(final Vector2D start, final Vector2D end, final Line line) {
-        this.start  = start;
-        this.end    = end;
-        this.line   = line;
+    private final Interval interval;
+
+    /** Construct a line segment from an underlying line and a 1D interval
+     * on it.
+     * @param line the underlying line
+     * @param interval 1D interval on the line defining the line segment
+     */
+    private Segment(final Line line, final Interval interval) {
+        super(line);
+
+        this.interval = interval;
     }
 
-    /** Get the start point of the segment.
-     * @return start point of the segment
+    /** Get the start value in the 1D subspace of the line.
+     * @return the start value in the 1D subspace of the line.
      */
-    public Vector2D getStart() {
-        return start;
+    public double getSubspaceStart() {
+        return interval.getMin();
     }
 
-    /** Get the end point of the segment.
-     * @return end point of the segment
+    /** Get the end value in the 1D subspace of the line.
+     * @return the end value in the 1D subspace of the line
      */
-    public Vector2D getEnd() {
-        return end;
+    public double getSubspaceEnd() {
+        return interval.getMax();
     }
 
-    /** Get the line containing the segment.
-     * @return line containing the segment
+    /** Get the start point of the line segment or null if no start point
+     * exists (ie, the segment is infinite).
+     * @return the start point of the line segment or null if no start point
+     *      exists
      */
-    public Line getLine() {
-        return line;
+    public Vector2D getStartPoint() {
+        return interval.hasMinBoundary() ? getLine().toSpace(interval.getMin()) : null;
     }
 
-    /** Calculates the shortest distance from a point to this line segment.
-     * <p>
-     * If the perpendicular extension from the point to the line does not
-     * cross in the bounds of the line segment, the shortest distance to
-     * the two end points will be returned.
-     * </p>
-     *
-     * Algorithm adapted from:
-     * <a href="http://www.codeguru.com/forum/printthread.php?s=cc8cf0596231f9a7dba4da6e77c29db3&t=194400&pp=15&page=1">
-     * Thread @ Codeguru</a>
-     *
-     * @param p to check
-     * @return distance between the instance and the point
+    /** Get the end point of the line segment or null if no end point
+     * exists (ie, the segment is infinite).
+     * @return the end point of the line segment or null if no end point
+     *      exists
      */
-    public double distance(final Vector2D p) {
-        final double deltaX = end.getX() - start.getX();
-        final double deltaY = end.getY() - start.getY();
+    public Vector2D getEndPoint() {
+        return interval.hasMaxBoundary() ? getLine().toSpace(interval.getMax()) : null;
+    }
 
-        final double r = ((p.getX() - start.getX()) * deltaX + (p.getY() - start.getY()) * deltaY) /
-                         (deltaX * deltaX + deltaY * deltaY);
+    /** Return the 1D interval for the line segment.
+     * @return the 1D interval for the line segment
+     * @see #getSubspaceRegion()
+     */
+    public Interval getInterval() {
+        return interval;
+    }
 
-        // r == 0 => P = startPt
-        // r == 1 => P = endPt
-        // r < 0 => P is on the backward extension of the segment
-        // r > 1 => P is on the forward extension of the segment
-        // 0 < r < 1 => P is on the segment
+    /** {@inheritDoc} */
+    @Override
+    public boolean isInfinite() {
+        return interval.isInfinite();
+    }
 
-        // if point isn't on the line segment, just return the shortest distance to the end points
-        if (r < 0 || r > 1) {
-            final double dist1 = getStart().distance(p);
-            final double dist2 = getEnd().distance(p);
+    /** {@inheritDoc} */
+    @Override
+    public boolean isFinite() {
+        return interval.isFinite();
+    }
 
-            return Math.min(dist1, dist2);
+    /** {@inheritDoc} */
+    @Override
+    public Interval getSubspaceRegion() {
+        return getInterval();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public List<Segment> toConvex() {
+        return Arrays.asList(this);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Split<Segment> split(final Hyperplane<Vector2D> splitter) {
+        return splitInternal(splitter, this, (line, region) -> new Segment(line, (Interval) region));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Segment transform(Transform<Vector2D> transform) {
+        final Line line = getLine();
+        final SubspaceTransform st = line.subspaceTransform(transform);
+
+        return fromInterval(st.getLine(), interval.transform(st.getTransform()));
+    }
+
+    /** Get the unique intersection of this segment with the given line. Null is
+     * returned if no unique intersection point exists (ie, the lines are
+     * parallel or coincident) or the line does not intersect the segment.
+     * @param line line to intersect with this segment
+     * @return the unique intersection point between the line and this segment
+     *      or null if no such point exists.
+     * @see Line#intersection(Line)
+     */
+    public Vector2D intersection(final Line line) {
+        final Vector2D pt = getLine().intersection(line);
+        return (pt != null && contains(pt)) ? pt : null;
+    }
+
+    /** Get the unique intersection of this instance with the given segment. Null
+     * is returned if the lines containing the segments do not have a unique intersection
+     * point (ie, they are parallel or coincident) or the intersection point is unique
+     * but in not contained in both segments.
+     * @param segment segment to intersect with
+     * @return the unique intersection point between this segment and the argument or
+     *      null if no such point exists.
+     * @see Line#intersection(Line)
+     */
+    public Vector2D intersection(final Segment segment) {
+        final Vector2D pt = intersection(segment.getLine());
+        return (pt != null && segment.contains(pt)) ? pt : null;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Segment reverse() {
+        final Interval reversedInterval = interval.transform(FunctionTransform1D.from(Vector1D::negate));
+        return fromInterval(getLine().reverse(), reversedInterval);
+    }
+
+    /** Return a string representation of the segment.
+    *
+    * <p>In order to keep the representation short but informative, the exact format used
+    * depends on the properties of the instance, as demonstrated in the examples
+    * below.
+    * <ul>
+    *      <li>Infinite segment -
+    *          {@code "Segment[lineOrigin= (0.0, 0.0), lineDirection= (1.0, 0.0)]"}</li>
+    *      <li>Start point but no end point -
+    *          {@code "Segment[start= (0.0, 0.0), direction= (1.0, 0.0)]"}</li>
+    *      <li>End point but no start point -
+    *          {@code "Segment[direction= (1.0, 0.0), end= (0.0, 0.0)]"}</li>
+    *      <li>Start point and end point -
+    *          {@code "Segment[start= (0.0, 0.0), end= (1.0, 0.0)]"}</li>
+    * </ul>
+    */
+    @Override
+    public String toString() {
+        final Vector2D startPoint = getStartPoint();
+        final Vector2D endPoint = getEndPoint();
+
+        final StringBuilder sb = new StringBuilder();
+        sb.append(this.getClass().getSimpleName())
+            .append('[');
+
+        if (startPoint != null && endPoint != null) {
+            sb.append(START_STR)
+                .append(startPoint)
+                .append(SEP_STR)
+                .append(END_STR)
+                .append(endPoint);
+        } else if (startPoint != null) {
+            sb.append(START_STR)
+                .append(startPoint)
+                .append(SEP_STR)
+                .append(DIR_STR)
+                .append(getLine().getDirection());
+        } else if (endPoint != null) {
+            sb.append(DIR_STR)
+                .append(getLine().getDirection())
+                .append(SEP_STR)
+                .append(END_STR)
+                .append(endPoint);
+        } else {
+            final Line line = getLine();
+
+            sb.append("lineOrigin= ")
+                .append(line.getOrigin())
+                .append(", lineDirection= ")
+                .append(line.getDirection());
         }
-        else {
-            // find point on line and see if it is in the line segment
-            final double px = start.getX() + r * deltaX;
-            final double py = start.getY() + r * deltaY;
 
-            final Vector2D interPt = Vector2D.of(px, py);
-            return interPt.distance(p);
-        }
+        sb.append(']');
+
+        return sb.toString();
+    }
+
+    /** Create a line segment between two points. The underlying line points in the direction from {@code start}
+     * to {@code end}.
+     * @param start start point for the line segment
+     * @param end end point for the line segment
+     * @param precision precision context used to determine floating point equality
+     * @return a new line segment between {@code start} and {@code end}.
+     */
+    public static Segment fromPoints(final Vector2D start, final Vector2D end, final DoublePrecisionContext precision) {
+        final Line line = Line.fromPoints(start, end, precision);
+        return fromPointsOnLine(line, start, end);
+    }
+
+    /** Construct a line segment from a starting point and a direction that the line should extend to
+     * infinity from. This is equivalent to constructing a ray.
+     * @param start start point for the segment
+     * @param direction direction that the line should extend from the segment
+     * @param precision precision context used to determine floating point equality
+     * @return a new line segment starting from the given point and extending to infinity in the
+     *      specified direction
+     */
+    public static Segment fromPointAndDirection(final Vector2D start, final Vector2D direction,
+            final DoublePrecisionContext precision) {
+        final Line line = Line.fromPointAndDirection(start, direction, precision);
+        return fromInterval(line, Interval.min(line.toSubspace(start).getX(), precision));
+    }
+
+    /** Create a line segment from an underlying line and a 1D interval on the line.
+     * @param line the line that the line segment will belong to
+     * @param interval 1D interval on the line
+     * @return a line segment defined by the given line and interval
+     */
+    public static Segment fromInterval(final Line line, final Interval interval) {
+        return new Segment(line, interval);
+    }
+
+    /** Create a line segment from an underlying line and a 1D interval on the line.
+     * @param line the line that the line segment will belong to
+     * @param a first 1D location on the line
+     * @param b second 1D location on the line
+     * @return a line segment defined by the given line and interval
+     */
+    public static Segment fromInterval(final Line line, final double a, final double b) {
+        return fromInterval(line, Interval.of(a, b, line.getPrecision()));
+    }
+
+    /** Create a line segment from an underlying line and a 1D interval on the line.
+     * @param line the line that the line segment will belong to
+     * @param a first 1D point on the line; must not be null
+     * @param b second 1D point on the line; must not be null
+     * @return a line segment defined by the given line and interval
+     */
+    public static Segment fromInterval(final Line line, final Vector1D a, final Vector1D b) {
+        return fromInterval(line, a.getX(), b.getX());
+    }
+
+    /** Create a new line segment from a line and points known to lie on the line.
+     * @param line the line that the line segment will belong to
+     * @param start line segment start point known to lie on the line
+     * @param end line segment end poitn known to lie on the line
+     * @return a new line segment created from the line and points
+     */
+    private static Segment fromPointsOnLine(final Line line, final Vector2D start, final Vector2D end) {
+        final double subspaceStart = line.toSubspace(start).getX();
+        final double subspaceEnd = line.toSubspace(end).getX();
+
+        return fromInterval(line, subspaceStart, subspaceEnd);
     }
 }
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/SubLine.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/SubLine.java
index 3e07ffa..db234a4 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/SubLine.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/SubLine.java
@@ -16,184 +16,210 @@
  */
 package org.apache.commons.geometry.euclidean.twod;
 
+import java.io.Serializable;
 import java.util.ArrayList;
 import java.util.List;
 
-import org.apache.commons.geometry.core.partitioning.AbstractSubHyperplane;
-import org.apache.commons.geometry.core.partitioning.BSPTree;
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.exception.GeometryException;
+import org.apache.commons.geometry.core.partitioning.ConvexSubHyperplane;
 import org.apache.commons.geometry.core.partitioning.Hyperplane;
-import org.apache.commons.geometry.core.partitioning.Region;
-import org.apache.commons.geometry.core.partitioning.Region.Location;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.core.partitioning.Split;
 import org.apache.commons.geometry.core.partitioning.SubHyperplane;
 import org.apache.commons.geometry.euclidean.oned.Interval;
-import org.apache.commons.geometry.euclidean.oned.IntervalsSet;
-import org.apache.commons.geometry.euclidean.oned.OrientedPoint;
-import org.apache.commons.geometry.euclidean.oned.Vector1D;
+import org.apache.commons.geometry.euclidean.oned.RegionBSPTree1D;
+import org.apache.commons.geometry.euclidean.twod.Line.SubspaceTransform;
 
-/** This class represents a sub-hyperplane for {@link Line}.
+/** Class representing an arbitrary region of a line. This class can represent
+ * both convex and non-convex regions of its underlying line.
+ *
+ * <p>This class is mutable and <em>not</em> thread safe.</p>
  */
-public class SubLine extends AbstractSubHyperplane<Vector2D, Vector1D> {
+public final class SubLine extends AbstractSubLine implements Serializable {
 
-    /** Simple constructor.
-     * @param hyperplane underlying hyperplane
-     * @param remainingRegion remaining region of the hyperplane
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20190717L;
+
+    /** The 1D region representing the area on the line. */
+    private final RegionBSPTree1D region;
+
+    /** Construct a new, empty subline for the given line.
+     * @param line line defining the subline
      */
-    public SubLine(final Hyperplane<Vector2D> hyperplane,
-                   final Region<Vector1D> remainingRegion) {
-        super(hyperplane, remainingRegion);
+    public SubLine(final Line line) {
+        this(line, false);
     }
 
-    /** Create a sub-line from two endpoints.
-     * @param start start point
-     * @param end end point
-     * @param precision precision context used to compare floating point values
+    /** Construct a new subline for the given line. If {@code full}
+     * is true, then the subline will cover the entire line; otherwise,
+     * it will be empty.
+     * @param line line defining the subline
+     * @param full if true, the subline will cover the entire space;
+     *      otherwise it will be empty
      */
-    public SubLine(final Vector2D start, final Vector2D end, final DoublePrecisionContext precision) {
-        super(Line.fromPoints(start, end, precision), buildIntervalSet(start, end, precision));
+    public SubLine(final Line line, boolean full) {
+        this(line, new RegionBSPTree1D(full));
     }
 
-    /** Create a sub-line from a segment.
-     * @param segment single segment forming the sub-line
+    /** Construct a new instance from its defining line and subspace region.
+     * @param line line defining the subline
+     * @param region subspace region for the subline
      */
-    public SubLine(final Segment segment) {
-        super(segment.getLine(),
-              buildIntervalSet(segment.getStart(), segment.getEnd(), segment.getLine().getPrecision()));
+    public SubLine(final Line line, final RegionBSPTree1D region) {
+        super(line);
+
+        this.region = region;
     }
 
-    /** Get the endpoints of the sub-line.
-     * <p>
-     * A subline may be any arbitrary number of disjoints segments, so the endpoints
-     * are provided as a list of endpoint pairs. Each element of the list represents
-     * one segment, and each segment contains a start point at index 0 and an end point
-     * at index 1. If the sub-line is unbounded in the negative infinity direction,
-     * the start point of the first segment will have infinite coordinates. If the
-     * sub-line is unbounded in the positive infinity direction, the end point of the
-     * last segment will have infinite coordinates. So a sub-line covering the whole
-     * line will contain just one row and both elements of this row will have infinite
-     * coordinates. If the sub-line is empty, the returned list will contain 0 segments.
-     * </p>
-     * @return list of segments endpoints
-     */
-    public List<Segment> getSegments() {
+    /** {@inheritDoc} */
+    @Override
+    public SubLine transform(final Transform<Vector2D> transform) {
+        final SubspaceTransform st = getLine().subspaceTransform(transform);
 
-        final Line line = (Line) getHyperplane();
-        final List<Interval> list = ((IntervalsSet) getRemainingRegion()).asList();
-        final List<Segment> segments = new ArrayList<>(list.size());
+        final RegionBSPTree1D tRegion = RegionBSPTree1D.empty();
+        tRegion.copy(region);
+        tRegion.transform(st.getTransform());
 
-        for (final Interval interval : list) {
-            final Vector2D start = line.toSpace(Vector1D.of(interval.getInf()));
-            final Vector2D end   = line.toSpace(Vector1D.of(interval.getSup()));
-            segments.add(new Segment(start, end, line));
+        return new SubLine(st.getLine(), tRegion);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public List<Segment> toConvex() {
+        final List<Interval> intervals = region.toIntervals();
+
+        final Line line = getLine();
+        final List<Segment> segments = new ArrayList<>(intervals.size());
+
+        for (final Interval interval : intervals) {
+            segments.add(Segment.fromInterval(line, interval));
         }
 
         return segments;
-
-    }
-
-    /** Get the intersection of the instance and another sub-line.
-     * <p>
-     * This method is related to the {@link Line#intersection(Line)
-     * intersection} method in the {@link Line Line} class, but in addition
-     * to compute the point along infinite lines, it also checks the point
-     * lies on both sub-line ranges.
-     * </p>
-     * @param subLine other sub-line which may intersect instance
-     * @param includeEndPoints if true, endpoints are considered to belong to
-     * instance (i.e. they are closed sets) and may be returned, otherwise endpoints
-     * are considered to not belong to instance (i.e. they are open sets) and intersection
-     * occurring on endpoints lead to null being returned
-     * @return the intersection point if there is one, null if the sub-lines don't intersect
-     */
-    public Vector2D intersection(final SubLine subLine, final boolean includeEndPoints) {
-
-        // retrieve the underlying lines
-        Line line1 = (Line) getHyperplane();
-        Line line2 = (Line) subLine.getHyperplane();
-
-        // compute the intersection on infinite line
-        Vector2D v2D = line1.intersection(line2);
-        if (v2D == null) {
-            return null;
-        }
-
-        // check location of point with respect to first sub-line
-        Location loc1 = getRemainingRegion().checkPoint(line1.toSubSpace(v2D));
-
-        // check location of point with respect to second sub-line
-        Location loc2 = subLine.getRemainingRegion().checkPoint(line2.toSubSpace(v2D));
-
-        if (includeEndPoints) {
-            return ((loc1 != Location.OUTSIDE) && (loc2 != Location.OUTSIDE)) ? v2D : null;
-        } else {
-            return ((loc1 == Location.INSIDE) && (loc2 == Location.INSIDE)) ? v2D : null;
-        }
-
-    }
-
-    /** Build an interval set from two points.
-     * @param start start point
-     * @param end end point
-     * @param precision precision context used to compare floating point values
-     * @return an interval set
-     */
-    private static IntervalsSet buildIntervalSet(final Vector2D start, final Vector2D end, final DoublePrecisionContext precision) {
-        final Line line = Line.fromPoints(start, end, precision);
-        return new IntervalsSet(line.toSubSpace(start).getX(),
-                                line.toSubSpace(end).getX(),
-                                precision);
     }
 
     /** {@inheritDoc} */
     @Override
-    protected AbstractSubHyperplane<Vector2D, Vector1D> buildNew(final Hyperplane<Vector2D> hyperplane,
-                                                                       final Region<Vector1D> remainingRegion) {
-        return new SubLine(hyperplane, remainingRegion);
+    public RegionBSPTree1D getSubspaceRegion() {
+        return region;
+    }
+
+    /** {@inheritDoc}
+     *
+     * <p>In all cases, the current instance is not modified. However, In order to avoid
+     * unnecessary copying, this method will use the current instance as the split value when
+     * the instance lies entirely on the plus or minus side of the splitter. For example, if
+     * this instance lies entirely on the minus side of the splitter, the subplane
+     * returned by {@link Split#getMinus()} will be this instance. Similarly, {@link Split#getPlus()}
+     * will return the current instance if it lies entirely on the plus side. Callers need to make
+     * special note of this, since this class is mutable.</p>
+     */
+    @Override
+    public Split<SubLine> split(final Hyperplane<Vector2D> splitter) {
+        return splitInternal(splitter, this, (line, reg) -> new SubLine(line, (RegionBSPTree1D) reg));
+    }
+
+    /** Add a line segment to this instance..
+     * @param segment line segment to add
+     * @throws GeometryException if the given line segment is not from
+     *      a line equivalent to this instance
+     */
+    public void add(final Segment segment) {
+        validateLine(segment.getLine());
+
+        region.add(segment.getSubspaceRegion());
+    }
+
+    /** Add the region represented by the given subline to this instance.
+     * The argument is not modified.
+     * @param subline subline to add
+     * @throws GeometryException if the given subline is not from
+     *      a line equivalent to this instance
+     */
+    public void add(final SubLine subline) {
+        validateLine(subline.getLine());
+
+        region.union(subline.getSubspaceRegion());
     }
 
     /** {@inheritDoc} */
     @Override
-    public SplitSubHyperplane<Vector2D> split(final Hyperplane<Vector2D> hyperplane) {
+    public String toString() {
+        final Line line = getLine();
 
-        final Line    thisLine  = (Line) getHyperplane();
-        final Line    otherLine = (Line) hyperplane;
-        final Vector2D crossing = thisLine.intersection(otherLine);
-        final DoublePrecisionContext precision = thisLine.getPrecision();
+        final StringBuilder sb = new StringBuilder();
+        sb.append(this.getClass().getSimpleName())
+            .append('[')
+            .append("lineOrigin= ")
+            .append(line.getOrigin())
+            .append(", lineDirection= ")
+            .append(line.getDirection())
+            .append(", region= ")
+            .append(region)
+            .append(']');
 
-        if (crossing == null) {
-            // the lines are parallel
-            final double global = otherLine.getOffset(thisLine);
-            final int comparison = precision.compare(global, 0.0);
+        return sb.toString();
+    }
 
-            if (comparison < 0) {
-                return new SplitSubHyperplane<>(null, this);
-            } else if (comparison > 0) {
-                return new SplitSubHyperplane<>(this, null);
+    /** Validate that the given line is equivalent to the line
+     * defining this subline.
+     * @param inputLine the line to validate
+     * @throws GeometryException if the given line is not equivalent
+     *      to the line for this instance
+     */
+    private void validateLine(final Line inputLine) {
+        final Line line = getLine();
+
+        if (!line.eq(inputLine)) {
+            throw new GeometryException("Argument is not on the same " +
+                    "line. Expected " + line + " but was " +
+                    inputLine);
+        }
+    }
+
+    /** {@link Builder} implementation for sublines.
+     */
+    public static final class SubLineBuilder implements SubHyperplane.Builder<Vector2D> {
+
+        /** SubLine instance created by this builder. */
+        private final SubLine subline;
+
+        /** Construct a new instance for building subline region for the given line.
+         * @param line the underlying line for the subline region
+         */
+        public SubLineBuilder(final Line line) {
+            this.subline = new SubLine(line);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void add(final SubHyperplane<Vector2D> sub) {
+            addInternal(sub);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void add(final ConvexSubHyperplane<Vector2D> sub) {
+            addInternal(sub);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public SubLine build() {
+            return subline;
+        }
+
+        /** Internal method for adding subhyperplanes to this builder.
+         * @param sub the subhyperplane to add; either convex or non-convex
+         */
+        private void addInternal(final SubHyperplane<Vector2D> sub) {
+            if (sub instanceof Segment) {
+                subline.add((Segment) sub);
+            } else if (sub instanceof SubLine) {
+                subline.add((SubLine) sub);
             } else {
-                return new SplitSubHyperplane<>(null, null);
+                throw new IllegalArgumentException("Unsupported subhyperplane type: " + sub.getClass().getName());
             }
         }
-
-        // the lines do intersect
-        final boolean direct = Math.sin(thisLine.getAngle() - otherLine.getAngle()) < 0;
-        final Vector1D x      = thisLine.toSubSpace(crossing);
-        final SubHyperplane<Vector1D> subPlus  =
-                OrientedPoint.fromPointAndDirection(x, !direct, precision).wholeHyperplane();
-        final SubHyperplane<Vector1D> subMinus =
-                OrientedPoint.fromPointAndDirection(x,  direct, precision).wholeHyperplane();
-
-        final BSPTree<Vector1D> splitTree = getRemainingRegion().getTree(false).split(subMinus);
-        final BSPTree<Vector1D> plusTree  = getRemainingRegion().isEmpty(splitTree.getPlus()) ?
-                                               new BSPTree<Vector1D>(Boolean.FALSE) :
-                                               new BSPTree<>(subPlus, new BSPTree<Vector1D>(Boolean.FALSE),
-                                                                        splitTree.getPlus(), null);
-        final BSPTree<Vector1D> minusTree = getRemainingRegion().isEmpty(splitTree.getMinus()) ?
-                                               new BSPTree<Vector1D>(Boolean.FALSE) :
-                                               new BSPTree<>(subMinus, new BSPTree<Vector1D>(Boolean.FALSE),
-                                                                        splitTree.getMinus(), null);
-        return new SplitSubHyperplane<>(new SubLine(thisLine.copySelf(), new IntervalsSet(plusTree, precision)),
-                                                   new SubLine(thisLine.copySelf(), new IntervalsSet(minusTree, precision)));
-
     }
-
 }
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Side.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/Transform2D.java
similarity index 61%
copy from commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Side.java
copy to commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/Transform2D.java
index 046defe..94f8a4e 100644
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/Side.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/Transform2D.java
@@ -14,23 +14,18 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.commons.geometry.core.partitioning;
+package org.apache.commons.geometry.euclidean.twod;
 
-/** Enumerate representing the location of an element with respect to an
- * {@link Hyperplane hyperplane} of a space.
+import org.apache.commons.geometry.euclidean.EuclideanTransform;
+
+/** Extension of the {@link EuclideanTransform} interface for 2D space.
  */
-public enum Side {
+public interface Transform2D extends EuclideanTransform<Vector2D> {
 
-    /** Code for the plus side of the hyperplane. */
-    PLUS,
-
-    /** Code for the minus side of the hyperplane. */
-    MINUS,
-
-    /** Code for elements crossing the hyperplane from plus to minus side. */
-    BOTH,
-
-    /** Code for the hyperplane itself. */
-    HYPER;
-
+    /** Return an affine transform matrix representing the same transform
+     * as this instance.
+     * @return an affine tranform matrix representing the same transform
+     *      as this instance
+     */
+    AffineTransformMatrix2D toMatrix();
 }
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/Vector2D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/Vector2D.java
index d6ba2de..15ab254 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/Vector2D.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/Vector2D.java
@@ -16,7 +16,9 @@
  */
 package org.apache.commons.geometry.euclidean.twod;
 
-import org.apache.commons.geometry.core.exception.IllegalNormException;
+import java.util.Comparator;
+import java.util.function.Function;
+
 import org.apache.commons.geometry.core.internal.DoubleFunction2N;
 import org.apache.commons.geometry.core.internal.SimpleTupleFormat;
 import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
@@ -45,20 +47,41 @@
     public static final Vector2D NEGATIVE_INFINITY =
         new Vector2D(Double.NEGATIVE_INFINITY, Double.NEGATIVE_INFINITY);
 
-    /** Serializable UID */
+    /** Comparator that sorts vectors in component-wise ascending order.
+     * Vectors are only considered equal if their coordinates match exactly.
+     * Null arguments are evaluated as being greater than non-null arguments.
+     */
+    public static final Comparator<Vector2D> COORDINATE_ASCENDING_ORDER = (a, b) -> {
+        int cmp = 0;
+
+        if (a != null && b != null) {
+            cmp = Double.compare(a.getX(), b.getX());
+            if (cmp == 0) {
+                cmp = Double.compare(a.getY(), b.getY());
+            }
+        } else if (a != null) {
+            cmp = -1;
+        } else if (b != null) {
+            cmp = 1;
+        }
+
+        return cmp;
+    };
+
+    /** Serializable UID. */
     private static final long serialVersionUID = 20180710L;
 
-    /** Abscissa (first coordinate) */
+    /** Abscissa (first coordinate). */
     private final double x;
 
-    /** Ordinate (second coordinate) */
+    /** Ordinate (second coordinate). */
     private final double y;
 
     /** Simple constructor.
      * @param x abscissa (first coordinate)
      * @param y ordinate (second coordinate)
      */
-    private Vector2D(double x, double y) {
+    private Vector2D(final double x, final double y) {
         this.x = x;
         this.y = y;
     }
@@ -81,7 +104,7 @@
      * @return coordinates for this instance
      */
     public double[] toArray() {
-        return new double[] { x, y };
+        return new double[]{x, y};
     }
 
     /** {@inheritDoc} */
@@ -104,19 +127,25 @@
 
     /** {@inheritDoc} */
     @Override
-    public Vector2D vectorTo(Vector2D v) {
+    public boolean isFinite() {
+        return Double.isFinite(x) && Double.isFinite(y);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Vector2D vectorTo(final Vector2D v) {
         return v.subtract(this);
     }
 
     /** {@inheritDoc} */
     @Override
-    public Unit directionTo(Vector2D v) {
+    public Unit directionTo(final Vector2D v) {
         return vectorTo(v).normalize();
     }
 
     /** {@inheritDoc} */
     @Override
-    public Vector2D lerp(Vector2D p, double t) {
+    public Vector2D lerp(final Vector2D p, final double t) {
         return linearCombination(1.0 - t, this, t, p);
     }
 
@@ -140,7 +169,7 @@
 
     /** {@inheritDoc} */
     @Override
-    public Vector2D withNorm(double magnitude) {
+    public Vector2D withNorm(final double magnitude) {
         final double invNorm = 1.0 / getCheckedNorm();
 
         return new Vector2D(
@@ -151,25 +180,25 @@
 
     /** {@inheritDoc} */
     @Override
-    public Vector2D add(Vector2D v) {
+    public Vector2D add(final Vector2D v) {
         return new Vector2D(x + v.x, y + v.y);
     }
 
     /** {@inheritDoc} */
     @Override
-    public Vector2D add(double factor, Vector2D v) {
+    public Vector2D add(final double factor, final Vector2D v) {
         return new Vector2D(x + (factor * v.x), y + (factor * v.y));
     }
 
     /** {@inheritDoc} */
     @Override
-    public Vector2D subtract(Vector2D v) {
+    public Vector2D subtract(final Vector2D v) {
         return new Vector2D(x - v.x, y - v.y);
     }
 
     /** {@inheritDoc} */
     @Override
-    public Vector2D subtract(double factor, Vector2D v) {
+    public Vector2D subtract(final double factor, final Vector2D v) {
         return new Vector2D(x - (factor * v.x), y - (factor * v.y));
     }
 
@@ -187,25 +216,25 @@
 
     /** {@inheritDoc} */
     @Override
-    public Vector2D multiply(double a) {
+    public Vector2D multiply(final double a) {
         return new Vector2D(a * x, a * y);
     }
 
     /** {@inheritDoc} */
     @Override
-    public double distance(Vector2D v) {
+    public double distance(final Vector2D v) {
         return Vectors.norm(x - v.x, y - v.y);
     }
 
     /** {@inheritDoc} */
     @Override
-    public double distanceSq(Vector2D v) {
+    public double distanceSq(final Vector2D v) {
         return Vectors.normSq(x - v.x, y - v.y);
     }
 
     /** {@inheritDoc} */
     @Override
-    public double dot(Vector2D v) {
+    public double dot(final Vector2D v) {
         return LinearCombination.value(x, v.x, y, v.y);
     }
 
@@ -217,7 +246,7 @@
      * other.</p>
      */
     @Override
-    public double angle(Vector2D v) {
+    public double angle(final Vector2D v) {
         double normProduct = getCheckedNorm() * v.getCheckedNorm();
 
         double dot = dot(v);
@@ -237,13 +266,13 @@
 
     /** {@inheritDoc} */
     @Override
-    public Vector2D project(Vector2D base) {
+    public Vector2D project(final Vector2D base) {
         return getComponent(base, false, Vector2D::new);
     }
 
     /** {@inheritDoc} */
     @Override
-    public Vector2D reject(Vector2D base) {
+    public Vector2D reject(final Vector2D base) {
         return getComponent(base, true, Vector2D::new);
     }
 
@@ -253,17 +282,17 @@
      * called on a vector pointing along the positive x-axis, then a unit vector representing
      * the positive y-axis is returned.
      * @return a unit vector orthogonal to the current instance
-     * @throws IllegalNormException if the norm of the current instance is zero, NaN,
-     *  or infinite
+     * @throws org.apache.commons.geometry.core.exception.IllegalNormException if the norm of the current instance
+     *      is zero, NaN, or infinite
      */
     @Override
-    public Vector2D orthogonal() {
+    public Vector2D.Unit orthogonal() {
         return Unit.from(-y, x);
     }
 
     /** {@inheritDoc} */
     @Override
-    public Vector2D orthogonal(Vector2D dir) {
+    public Vector2D.Unit orthogonal(final Vector2D dir) {
         return dir.getComponent(this, true, Vector2D.Unit::from);
     }
 
@@ -287,19 +316,18 @@
                 -y, v.x);
     }
 
-    /** Apply the given transform to this vector, returning the result as a
-     * new vector instance.
-     * @param transform the transform to apply
-     * @return a new, transformed vector
-     * @see AffineTransformMatrix2D#apply(Vector2D)
+    /** Convenience method to apply a function to this vector. This
+     * can be used to transform the vector inline with other methods.
+     * @param fn the function to apply
+     * @return the transformed vector
      */
-    public Vector2D transform(AffineTransformMatrix2D transform) {
-        return transform.apply(this);
+    public Vector2D transform(final Function<Vector2D, Vector2D> fn) {
+        return fn.apply(this);
     }
 
     /** {@inheritDoc} */
     @Override
-    public boolean equals(final Vector2D vec, final DoublePrecisionContext precision) {
+    public boolean eq(final Vector2D vec, final DoublePrecisionContext precision) {
         return precision.eq(x, vec.x) &&
                 precision.eq(y, vec.y);
     }
@@ -339,7 +367,7 @@
      *
      */
     @Override
-    public boolean equals(Object other) {
+    public boolean equals(final Object other) {
         if (this == other) {
             return true;
         }
@@ -369,11 +397,14 @@
      *      returned. If false, the projection of this instance onto {@code base}
      *      is returned.
      * @param factory factory function used to build the final vector
+     * @param <T> Vector implementation type
      * @return The projection or rejection of this instance relative to {@code base},
      *      depending on the value of {@code reject}.
-     * @throws IllegalNormException if {@code base} has a zero, NaN, or infinite norm
+     * @throws org.apache.commons.geometry.core.exception.IllegalNormException if {@code base} has a
+     *      zero, NaN, or infinite norm
      */
-    private Vector2D getComponent(Vector2D base, boolean reject, DoubleFunction2N<Vector2D> factory) {
+    private <T extends Vector2D> T getComponent(final Vector2D base, final boolean reject,
+            final DoubleFunction2N<T> factory) {
         final double aDotB = dot(base);
 
         // We need to check the norm value here to ensure that it's legal. However, we don't
@@ -401,7 +432,7 @@
      * @param y abscissa (second coordinate value)
      * @return vector instance
      */
-    public static Vector2D of(double x, double y) {
+    public static Vector2D of(final double x, final double y) {
         return new Vector2D(x, y);
     }
 
@@ -410,7 +441,7 @@
      * @return new vector
      * @exception IllegalArgumentException if the array does not have 2 elements
      */
-    public static Vector2D of(double[] v) {
+    public static Vector2D of(final double[] v) {
         if (v.length != 2) {
             throw new IllegalArgumentException("Dimension mismatch: " + v.length + " != 2");
         }
@@ -423,7 +454,7 @@
      * @return vector instance represented by the string
      * @throws IllegalArgumentException if the given string has an invalid format
      */
-    public static Vector2D parse(String str) {
+    public static Vector2D parse(final String str) {
         return SimpleTupleFormat.getDefault().parse(str, Vector2D::new);
     }
 
@@ -437,7 +468,7 @@
      * @param c first coordinate
      * @return vector with coordinates calculated by {@code a * c}
      */
-    public static Vector2D linearCombination(double a, Vector2D c) {
+    public static Vector2D linearCombination(final double a, final Vector2D c) {
         return new Vector2D(a * c.x, a * c.y);
     }
 
@@ -453,7 +484,8 @@
      * @param v2 second coordinate
      * @return vector with coordinates calculated by {@code (a1 * v1) + (a2 * v2)}
      */
-    public static Vector2D linearCombination(double a1, Vector2D v1, double a2, Vector2D v2) {
+    public static Vector2D linearCombination(final double a1, final Vector2D v1,
+            final double a2, final Vector2D v2) {
         return new Vector2D(
                 LinearCombination.value(a1, v1.x, a2, v2.x),
                 LinearCombination.value(a1, v1.y, a2, v2.y));
@@ -473,8 +505,9 @@
      * @param v3 third coordinate
      * @return vector with coordinates calculated by {@code (a1 * v1) + (a2 * v2) + (a3 * v3)}
      */
-    public static Vector2D linearCombination(double a1, Vector2D v1, double a2, Vector2D v2,
-            double a3, Vector2D v3) {
+    public static Vector2D linearCombination(final double a1, final Vector2D v1,
+            final double a2, final Vector2D v2,
+            final double a3, final Vector2D v3) {
         return new Vector2D(
                 LinearCombination.value(a1, v1.x, a2, v2.x, a3, v3.x),
                 LinearCombination.value(a1, v1.y, a2, v2.y, a3, v3.y));
@@ -496,8 +529,10 @@
      * @param v4 fourth coordinate
      * @return point with coordinates calculated by {@code (a1 * v1) + (a2 * v2) + (a3 * v3) + (a4 * v4)}
      */
-    public static Vector2D linearCombination(double a1, Vector2D v1, double a2, Vector2D v2,
-            double a3, Vector2D v3, double a4, Vector2D v4) {
+    public static Vector2D linearCombination(final double a1, Vector2D v1,
+            final double a2, final Vector2D v2,
+            final double a3, final Vector2D v3,
+            final double a4, final Vector2D v4) {
         return new Vector2D(
                 LinearCombination.value(a1, v1.x, a2, v2.x, a3, v3.x, a4, v4.x),
                 LinearCombination.value(a1, v1.y, a2, v2.y, a3, v3.y, a4, v4.y));
@@ -517,7 +552,7 @@
         /** Negation of unit vector (coordinates: 0, -1). */
         public static final Unit MINUS_Y = new Unit(0d, -1d);
 
-        /** Serializable version identifier */
+        /** Serializable version identifier. */
         private static final long serialVersionUID = 20180903L;
 
         /** Simple constructor. Callers are responsible for ensuring that the given
@@ -535,9 +570,10 @@
          * @param x Vector coordinate.
          * @param y Vector coordinate.
          * @return a vector whose norm is 1.
-         * @throws IllegalNormException if the norm of the given value is zero, NaN, or infinite
+         * @throws org.apache.commons.geometry.core.exception.IllegalNormException if the norm of the given value
+         *      is zero, NaN, or infinite
          */
-        public static Unit from(double x, double y) {
+        public static Unit from(final double x, final double y) {
             final double invNorm = 1 / Vectors.checkedNorm(Vectors.norm(x, y));
             return new Unit(x * invNorm, y * invNorm);
         }
@@ -547,9 +583,10 @@
          *
          * @param v Vector.
          * @return a vector whose norm is 1.
-         * @throws IllegalNormException if the norm of the given value is zero, NaN, or infinite
+         * @throws org.apache.commons.geometry.core.exception.IllegalNormException if the norm of the given
+         *      value is zero, NaN, or infinite
          */
-        public static Unit from(Vector2D v) {
+        public static Unit from(final Vector2D v) {
             return v instanceof Unit ?
                 (Unit) v :
                 from(v.getX(), v.getY());
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/core/partitioning/CharacterizationTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/core/partitioning/CharacterizationTest.java
deleted file mode 100644
index f172467..0000000
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/core/partitioning/CharacterizationTest.java
+++ /dev/null
@@ -1,427 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.core.partitioning;
-
-import java.util.Iterator;
-
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
-import org.apache.commons.geometry.euclidean.oned.IntervalsSet;
-import org.apache.commons.geometry.euclidean.twod.Line;
-import org.apache.commons.geometry.euclidean.twod.PolygonsSet;
-import org.apache.commons.geometry.euclidean.twod.SubLine;
-import org.apache.commons.geometry.euclidean.twod.Vector2D;
-import org.junit.Assert;
-import org.junit.Test;
-
-/** Tests for partitioning characterization. This is designed to test code
- * in commons-geometry-core but is placed here to allow access to the euclidean
- * spatial primitives.
- */
-public class CharacterizationTest {
-
-    private static final double TEST_EPS = 1e-10;
-
-    private static final DoublePrecisionContext TEST_PRECISION = new EpsilonDoublePrecisionContext(TEST_EPS);
-
-    @Test
-    public void testCharacterize_insideLeaf() {
-        // arrange
-        BSPTree<Vector2D> tree = new BSPTree<>(Boolean.TRUE);
-        SubLine sub = buildSubLine(Vector2D.of(0, -1), Vector2D.of(0, 1));
-
-        // act
-        Characterization<Vector2D> ch = new Characterization<>(tree, sub);
-
-        // assert
-        Assert.assertEquals(true, ch.touchInside());
-        Assert.assertSame(sub, ch.insideTouching());
-        Assert.assertEquals(0, size(ch.getInsideSplitters()));
-
-        Assert.assertEquals(false, ch.touchOutside());
-        Assert.assertEquals(null,  ch.outsideTouching());
-        Assert.assertEquals(0, size(ch.getOutsideSplitters()));
-    }
-
-    @Test
-    public void testCharacterize_outsideLeaf() {
-        // arrange
-        BSPTree<Vector2D> tree = new BSPTree<>(Boolean.FALSE);
-        SubLine sub = buildSubLine(Vector2D.of(0, -1), Vector2D.of(0, 1));
-
-        // act
-        Characterization<Vector2D> ch = new Characterization<>(tree, sub);
-
-        // assert
-        Assert.assertEquals(false, ch.touchInside());
-        Assert.assertSame(null, ch.insideTouching());
-        Assert.assertEquals(0, size(ch.getInsideSplitters()));
-
-        Assert.assertEquals(true, ch.touchOutside());
-        Assert.assertEquals(sub,  ch.outsideTouching());
-        Assert.assertEquals(0, size(ch.getOutsideSplitters()));
-    }
-
-    @Test
-    public void testCharacterize_onPlusSide() {
-        // arrange
-        BSPTree<Vector2D> tree = new BSPTree<>(Boolean.TRUE);
-        cut(tree, buildLine(Vector2D.of(0, 0), Vector2D.of(1, 0)));
-
-        SubLine sub = buildSubLine(Vector2D.of(0, -1), Vector2D.of(0, -2));
-
-        // act
-        Characterization<Vector2D> ch = new Characterization<>(tree, sub);
-
-        // assert
-        Assert.assertEquals(false, ch.touchInside());
-        Assert.assertSame(null, ch.insideTouching());
-        Assert.assertEquals(0, size(ch.getInsideSplitters()));
-
-        Assert.assertEquals(true, ch.touchOutside());
-        Assert.assertEquals(sub,  ch.outsideTouching());
-        Assert.assertEquals(0, size(ch.getOutsideSplitters()));
-    }
-
-    @Test
-    public void testCharacterize_onMinusSide() {
-        // arrange
-        BSPTree<Vector2D> tree = new BSPTree<>(Boolean.TRUE);
-        cut(tree, buildLine(Vector2D.of(0, 0), Vector2D.of(1, 0)));
-
-        SubLine sub = buildSubLine(Vector2D.of(0, 1), Vector2D.of(0, 2));
-
-        // act
-        Characterization<Vector2D> ch = new Characterization<>(tree, sub);
-
-        // assert
-        Assert.assertEquals(true, ch.touchInside());
-        Assert.assertSame(sub, ch.insideTouching());
-        Assert.assertEquals(0, size(ch.getInsideSplitters()));
-
-        Assert.assertEquals(false, ch.touchOutside());
-        Assert.assertEquals(null,  ch.outsideTouching());
-        Assert.assertEquals(0, size(ch.getOutsideSplitters()));
-    }
-
-    @Test
-    public void testCharacterize_onBothSides() {
-        // arrange
-        BSPTree<Vector2D> tree = new BSPTree<>(Boolean.TRUE);
-        cut(tree, buildLine(Vector2D.of(0, 0), Vector2D.of(1, 0)));
-
-        SubLine sub = buildSubLine(Vector2D.of(0, -1), Vector2D.of(0, 1));
-
-        // act
-        Characterization<Vector2D> ch = new Characterization<>(tree, sub);
-
-        // assert
-        Assert.assertEquals(true, ch.touchInside());
-        Assert.assertNotSame(sub, ch.insideTouching());
-
-        SubLine inside = (SubLine) ch.insideTouching();
-        Assert.assertEquals(1, inside.getSegments().size());
-        assertVectorEquals(Vector2D.of(0, 0), inside.getSegments().get(0).getStart());
-        assertVectorEquals(Vector2D.of(0, 1), inside.getSegments().get(0).getEnd());
-
-        Assert.assertEquals(1, size(ch.getInsideSplitters()));
-        Iterator<BSPTree<Vector2D>> insideSplitterIter = ch.getInsideSplitters().iterator();
-        Assert.assertSame(tree, insideSplitterIter.next());
-
-        Assert.assertEquals(true, ch.touchOutside());
-        Assert.assertNotSame(sub, ch.insideTouching());
-
-        SubLine outside = (SubLine) ch.outsideTouching();
-        Assert.assertEquals(1, outside.getSegments().size());
-        assertVectorEquals(Vector2D.of(0, -1), outside.getSegments().get(0).getStart());
-        assertVectorEquals(Vector2D.of(0, 0), outside.getSegments().get(0).getEnd());
-
-        Assert.assertEquals(1, size(ch.getOutsideSplitters()));
-        Iterator<BSPTree<Vector2D>> outsideSplitterIter = ch.getOutsideSplitters().iterator();
-        Assert.assertSame(tree, outsideSplitterIter.next());
-    }
-
-    @Test
-    public void testCharacterize_multipleSplits_reunitedOnPlusSide() {
-        // arrange
-        BSPTree<Vector2D> tree = new BSPTree<>(Boolean.TRUE);
-        cut(tree, buildLine(Vector2D.of(0, 0), Vector2D.of(1, 0)));
-        cut(tree.getMinus(), buildLine(Vector2D.of(-1, 0), Vector2D.of(0, 1)));
-
-        SubLine sub = buildSubLine(Vector2D.of(0, -2), Vector2D.of(0, 2));
-
-        // act
-        Characterization<Vector2D> ch = new Characterization<>(tree, sub);
-
-        // assert
-        Assert.assertEquals(true, ch.touchInside());
-        Assert.assertNotSame(sub, ch.insideTouching());
-
-        SubLine inside = (SubLine) ch.insideTouching();
-        Assert.assertEquals(1, inside.getSegments().size());
-        assertVectorEquals(Vector2D.of(0, 1), inside.getSegments().get(0).getStart());
-        assertVectorEquals(Vector2D.of(0, 2), inside.getSegments().get(0).getEnd());
-
-        Assert.assertEquals(2, size(ch.getInsideSplitters()));
-        Iterator<BSPTree<Vector2D>> insideSplitterIter = ch.getInsideSplitters().iterator();
-        Assert.assertSame(tree, insideSplitterIter.next());
-        Assert.assertSame(tree.getMinus(), insideSplitterIter.next());
-
-        Assert.assertEquals(true, ch.touchOutside());
-        Assert.assertNotSame(sub, ch.insideTouching());
-
-        SubLine outside = (SubLine) ch.outsideTouching();
-        Assert.assertEquals(1, outside.getSegments().size());
-        assertVectorEquals(Vector2D.of(0, -2), outside.getSegments().get(0).getStart());
-        assertVectorEquals(Vector2D.of(0, 1), outside.getSegments().get(0).getEnd());
-
-        Assert.assertEquals(2, size(ch.getOutsideSplitters()));
-        Iterator<BSPTree<Vector2D>> outsideSplitterIter = ch.getOutsideSplitters().iterator();
-        Assert.assertSame(tree, outsideSplitterIter.next());
-        Assert.assertSame(tree.getMinus(), outsideSplitterIter.next());
-    }
-
-    @Test
-    public void testCharacterize_multipleSplits_reunitedOnMinusSide() {
-        // arrange
-        BSPTree<Vector2D> tree = new BSPTree<>(Boolean.TRUE);
-        cut(tree, buildLine(Vector2D.of(0, 0), Vector2D.of(1, 0)));
-        cut(tree.getMinus(), buildLine(Vector2D.of(-1, 0), Vector2D.of(0, 1)));
-        cut(tree.getMinus().getPlus(), buildLine(Vector2D.of(-0.5, 0.5), Vector2D.of(0, 0)));
-
-        SubLine sub = buildSubLine(Vector2D.of(0, -2), Vector2D.of(0, 2));
-
-        // act
-        Characterization<Vector2D> ch = new Characterization<>(tree, sub);
-
-        // assert
-        Assert.assertEquals(true, ch.touchInside());
-        Assert.assertNotSame(sub, ch.insideTouching());
-
-        SubLine inside = (SubLine) ch.insideTouching();
-        Assert.assertEquals(1, inside.getSegments().size());
-        assertVectorEquals(Vector2D.of(0, 0), inside.getSegments().get(0).getStart());
-        assertVectorEquals(Vector2D.of(0, 2), inside.getSegments().get(0).getEnd());
-
-        Assert.assertEquals(2, size(ch.getInsideSplitters()));
-        Iterator<BSPTree<Vector2D>> insideSplitterIter = ch.getInsideSplitters().iterator();
-        Assert.assertSame(tree, insideSplitterIter.next());
-        Assert.assertSame(tree.getMinus(), insideSplitterIter.next());
-
-        Assert.assertEquals(true, ch.touchOutside());
-        Assert.assertNotSame(sub, ch.insideTouching());
-
-        SubLine outside = (SubLine) ch.outsideTouching();
-        Assert.assertEquals(1, outside.getSegments().size());
-        assertVectorEquals(Vector2D.of(0, -2), outside.getSegments().get(0).getStart());
-        assertVectorEquals(Vector2D.of(0, 0), outside.getSegments().get(0).getEnd());
-
-        Assert.assertEquals(1, size(ch.getOutsideSplitters()));
-        Iterator<BSPTree<Vector2D>> outsideSplitterIter = ch.getOutsideSplitters().iterator();
-        Assert.assertSame(tree, outsideSplitterIter.next());
-    }
-
-    @Test
-    public void testCharacterize_onHyperplane_sameOrientation() {
-        // arrange
-        BSPTree<Vector2D> tree = new BSPTree<>(Boolean.TRUE);
-        cut(tree, buildLine(Vector2D.of(0, 0), Vector2D.of(1, 0)));
-
-        SubLine sub = buildSubLine(Vector2D.of(0, 0), Vector2D.of(1, 0));
-
-        // act
-        Characterization<Vector2D> ch = new Characterization<>(tree, sub);
-
-        // assert
-        Assert.assertEquals(true, ch.touchInside());
-        Assert.assertSame(sub, ch.insideTouching());
-        Assert.assertEquals(0, size(ch.getInsideSplitters()));
-
-        Assert.assertEquals(false, ch.touchOutside());
-        Assert.assertEquals(null,  ch.outsideTouching());
-        Assert.assertEquals(0, size(ch.getOutsideSplitters()));
-    }
-
-    @Test
-    public void testCharacterize_onHyperplane_oppositeOrientation() {
-        // arrange
-        BSPTree<Vector2D> tree = new BSPTree<>(Boolean.TRUE);
-        cut(tree, buildLine(Vector2D.of(0, 0), Vector2D.of(1, 0)));
-
-        SubLine sub = buildSubLine(Vector2D.of(1, 0), Vector2D.of(0, 0));
-
-        // act
-        Characterization<Vector2D> ch = new Characterization<>(tree, sub);
-
-        // assert
-        Assert.assertEquals(true, ch.touchInside());
-        Assert.assertSame(sub, ch.insideTouching());
-        Assert.assertEquals(0, size(ch.getInsideSplitters()));
-
-        Assert.assertEquals(false, ch.touchOutside());
-        Assert.assertEquals(null,  ch.outsideTouching());
-        Assert.assertEquals(0, size(ch.getOutsideSplitters()));
-    }
-
-    @Test
-    public void testCharacterize_onHyperplane_multipleSplits_sameOrientation() {
-        // arrange
-        BSPTree<Vector2D> tree = new BSPTree<>(Boolean.TRUE);
-        cut(tree, buildLine(Vector2D.of(0, 0), Vector2D.of(1, 0)));
-        cut(tree.getMinus(), buildLine(Vector2D.of(-1, 0), Vector2D.of(0, 1)));
-
-        SubLine sub = buildSubLine(Vector2D.of(-2, 0), Vector2D.of(2, 0));
-
-        // act
-        Characterization<Vector2D> ch = new Characterization<>(tree, sub);
-
-        // assert
-        Assert.assertEquals(true, ch.touchInside());
-        Assert.assertNotSame(sub, ch.insideTouching());
-
-        SubLine inside = (SubLine) ch.insideTouching();
-        Assert.assertEquals(1, inside.getSegments().size());
-        assertVectorEquals(Vector2D.of(-2, 0), inside.getSegments().get(0).getStart());
-        assertVectorEquals(Vector2D.of(-1, 0), inside.getSegments().get(0).getEnd());
-
-        Assert.assertEquals(1, size(ch.getInsideSplitters()));
-        Iterator<BSPTree<Vector2D>> insideSplitterIter = ch.getInsideSplitters().iterator();
-        Assert.assertSame(tree.getMinus(), insideSplitterIter.next());
-
-        Assert.assertEquals(true, ch.touchOutside());
-        Assert.assertNotSame(sub, ch.insideTouching());
-
-        SubLine outside = (SubLine) ch.outsideTouching();
-        Assert.assertEquals(1, outside.getSegments().size());
-        assertVectorEquals(Vector2D.of(-1, 0), outside.getSegments().get(0).getStart());
-        assertVectorEquals(Vector2D.of(2, 0), outside.getSegments().get(0).getEnd());
-
-        Assert.assertEquals(1, size(ch.getOutsideSplitters()));
-        Iterator<BSPTree<Vector2D>> outsideSplitterIter = ch.getOutsideSplitters().iterator();
-        Assert.assertSame(tree.getMinus(), outsideSplitterIter.next());
-    }
-
-    @Test
-    public void testCharacterize_onHyperplane_multipleSplits_oppositeOrientation() {
-        // arrange
-        BSPTree<Vector2D> tree = new BSPTree<>(Boolean.TRUE);
-        cut(tree, buildLine(Vector2D.of(0, 0), Vector2D.of(1, 0)));
-        cut(tree.getMinus(), buildLine(Vector2D.of(-1, 0), Vector2D.of(0, 1)));
-
-        SubLine sub = buildSubLine(Vector2D.of(2, 0), Vector2D.of(-2, 0));
-
-        // act
-        Characterization<Vector2D> ch = new Characterization<>(tree, sub);
-
-        // assert
-        Assert.assertEquals(true, ch.touchInside());
-        Assert.assertNotSame(sub, ch.insideTouching());
-
-        SubLine inside = (SubLine) ch.insideTouching();
-        Assert.assertEquals(1, inside.getSegments().size());
-        assertVectorEquals(Vector2D.of(-1, 0), inside.getSegments().get(0).getStart());
-        assertVectorEquals(Vector2D.of(-2, 0), inside.getSegments().get(0).getEnd());
-
-        Assert.assertEquals(1, size(ch.getInsideSplitters()));
-        Iterator<BSPTree<Vector2D>> insideSplitterIter = ch.getInsideSplitters().iterator();
-        Assert.assertSame(tree.getMinus(), insideSplitterIter.next());
-
-        Assert.assertEquals(true, ch.touchOutside());
-        Assert.assertNotSame(sub, ch.insideTouching());
-
-        SubLine outside = (SubLine) ch.outsideTouching();
-        Assert.assertEquals(1, outside.getSegments().size());
-        assertVectorEquals(Vector2D.of(2, 0), outside.getSegments().get(0).getStart());
-        assertVectorEquals(Vector2D.of(-1, 0), outside.getSegments().get(0).getEnd());
-
-        Assert.assertEquals(1, size(ch.getOutsideSplitters()));
-        Iterator<BSPTree<Vector2D>> outsideSplitterIter = ch.getOutsideSplitters().iterator();
-        Assert.assertSame(tree.getMinus(), outsideSplitterIter.next());
-    }
-
-    @Test
-    public void testCharacterize_onHyperplane_box() {
-        // arrange
-        PolygonsSet poly = new PolygonsSet(0, 1, 0, 1, TEST_PRECISION);
-        BSPTree<Vector2D> tree = poly.getTree(false);
-
-        SubLine sub = buildSubLine(Vector2D.of(2, 0), Vector2D.of(-2, 0));
-
-        // act
-        Characterization<Vector2D> ch = new Characterization<>(tree, sub);
-
-        // assert
-        Assert.assertEquals(true, ch.touchInside());
-        Assert.assertNotSame(sub, ch.insideTouching());
-
-        SubLine inside = (SubLine) ch.insideTouching();
-        Assert.assertEquals(1, inside.getSegments().size());
-        assertVectorEquals(Vector2D.of(1, 0), inside.getSegments().get(0).getStart());
-        assertVectorEquals(Vector2D.of(0, 0), inside.getSegments().get(0).getEnd());
-
-        Assert.assertEquals(2, size(ch.getInsideSplitters()));
-
-        Assert.assertEquals(true, ch.touchOutside());
-        Assert.assertNotSame(sub, ch.insideTouching());
-
-        SubLine outside = (SubLine) ch.outsideTouching();
-        Assert.assertEquals(2, outside.getSegments().size());
-        assertVectorEquals(Vector2D.of(2, 0), outside.getSegments().get(0).getStart());
-        assertVectorEquals(Vector2D.of(1, 0), outside.getSegments().get(0).getEnd());
-        assertVectorEquals(Vector2D.of(0, 0), outside.getSegments().get(1).getStart());
-        assertVectorEquals(Vector2D.of(-2, 0), outside.getSegments().get(1).getEnd());
-
-        Assert.assertEquals(2, size(ch.getOutsideSplitters()));
-    }
-
-    private void cut(BSPTree<Vector2D> tree, Line line) {
-        if (tree.insertCut(line)) {
-            tree.setAttribute(null);
-            tree.getPlus().setAttribute(Boolean.FALSE);
-            tree.getMinus().setAttribute(Boolean.TRUE);
-        }
-    }
-
-    private int size(NodesSet<Vector2D> nodes) {
-        Iterator<BSPTree<Vector2D>> it = nodes.iterator();
-
-        int size = 0;
-        while (it.hasNext()) {
-            it.next();
-            ++size;
-        }
-
-        return size;
-    }
-
-    private Line buildLine(Vector2D p1, Vector2D p2) {
-        return Line.fromPoints(p1, p2, TEST_PRECISION);
-    }
-
-    private SubLine buildSubLine(Vector2D start, Vector2D end) {
-        Line line = Line.fromPoints(start, end, TEST_PRECISION);
-        double lower = (line.toSubSpace(start)).getX();
-        double upper = (line.toSubSpace(end)).getX();
-        return new SubLine(line, new IntervalsSet(lower, upper, TEST_PRECISION));
-    }
-
-    private void assertVectorEquals(Vector2D expected, Vector2D actual) {
-        String msg = "Expected vector to equal " + expected + " but was " + actual + ";";
-        Assert.assertEquals(msg, expected.getX(), actual.getX(), TEST_EPS);
-        Assert.assertEquals(msg, expected.getY(), actual.getY(), TEST_EPS);
-    }
-}
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/EuclideanTestUtils.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/EuclideanTestUtils.java
index 5b7b0db..f74aaf8 100644
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/EuclideanTestUtils.java
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/EuclideanTestUtils.java
@@ -16,28 +16,10 @@
  */
 package org.apache.commons.geometry.euclidean;
 
-import java.text.ParseException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-
-import org.apache.commons.geometry.core.partitioning.BSPTree;
-import org.apache.commons.geometry.core.partitioning.Hyperplane;
-import org.apache.commons.geometry.core.partitioning.TreeBuilder;
-import org.apache.commons.geometry.core.partitioning.TreeDumper;
-import org.apache.commons.geometry.core.partitioning.TreePrinter;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.geometry.euclidean.oned.IntervalsSet;
-import org.apache.commons.geometry.euclidean.oned.OrientedPoint;
-import org.apache.commons.geometry.euclidean.oned.SubOrientedPoint;
+import org.apache.commons.geometry.core.Region;
+import org.apache.commons.geometry.core.RegionLocation;
 import org.apache.commons.geometry.euclidean.oned.Vector1D;
-import org.apache.commons.geometry.euclidean.threed.Plane;
-import org.apache.commons.geometry.euclidean.threed.PolyhedronsSet;
-import org.apache.commons.geometry.euclidean.threed.SubPlane;
 import org.apache.commons.geometry.euclidean.threed.Vector3D;
-import org.apache.commons.geometry.euclidean.twod.Line;
-import org.apache.commons.geometry.euclidean.twod.PolygonsSet;
-import org.apache.commons.geometry.euclidean.twod.SubLine;
 import org.apache.commons.geometry.euclidean.twod.Vector2D;
 import org.junit.Assert;
 
@@ -210,283 +192,39 @@
         Assert.assertTrue(msg, value < 0);
     }
 
-    /**
-     * Get a string representation of an {@link IntervalsSet}.
-     *
-     * @param intervalsSet region to dump
-     * @return string representation of the region
+    /** Assert that all of the given points lie within the specified location relative to
+     * {@code region}.
+     * @param region
+     * @param loc
+     * @param pts
      */
-    public static String dump(final IntervalsSet intervalsSet) {
-        final TreeDumper<Vector1D> visitor = new TreeDumper<Vector1D>("IntervalsSet") {
-
-            /** {@inheritDoc} */
-            @Override
-            protected void formatHyperplane(final Hyperplane<Vector1D> hyperplane) {
-                final OrientedPoint h = (OrientedPoint) hyperplane;
-                getFormatter().format("%22.15e %b", h.getLocation().getX(), h.isPositiveFacing());
-            }
-
-        };
-        intervalsSet.getTree(false).visit(visitor);
-        return visitor.getDump();
-    }
-
-    /**
-     * Get a string representation of a {@link PolygonsSet}.
-     *
-     * @param polygonsSet region to dump
-     * @return string representation of the region
-     */
-    public static String dump(final PolygonsSet polygonsSet) {
-        final TreeDumper<Vector2D> visitor = new TreeDumper<Vector2D>("PolygonsSet") {
-
-            /** {@inheritDoc} */
-            @Override
-            protected void formatHyperplane(final Hyperplane<Vector2D> hyperplane) {
-                final Line h = (Line) hyperplane;
-                final Vector2D p = h.toSpace(Vector1D.ZERO);
-                getFormatter().format("%22.15e %22.15e %22.15e",
-                                      p.getX(), p.getY(), h.getAngle());
-            }
-
-        };
-        polygonsSet.getTree(false).visit(visitor);
-        return visitor.getDump();
-    }
-
-    /**
-     * Get a string representation of a {@link PolyhedronsSet}.
-     *
-     * @param polyhedronsSet region to dump
-     * @return string representation of the region
-     */
-    public static String dump(final PolyhedronsSet polyhedronsSet) {
-        final TreeDumper<Vector3D> visitor = new TreeDumper<Vector3D>("PolyhedronsSet") {
-
-            /** {@inheritDoc} */
-            @Override
-            protected void formatHyperplane(final Hyperplane<Vector3D> hyperplane) {
-                final Plane h = (Plane) hyperplane;
-                final Vector3D p = h.toSpace(Vector2D.ZERO);
-                getFormatter().format("%22.15e %22.15e %22.15e %22.15e %22.15e %22.15e",
-                                      p.getX(), p.getY(), p.getZ(),
-                                      h.getNormal().getX(), h.getNormal().getY(), h.getNormal().getZ());
-            }
-
-        };
-        polyhedronsSet.getTree(false).visit(visitor);
-        return visitor.getDump();
-    }
-
-    /**
-     * Parse a string representation of an {@link IntervalsSet}.
-     *
-     * @param str string to parse
-     * @param precision precision context to use for the region
-     * @return parsed region
-     * @exception ParseException if the string cannot be parsed
-     */
-    public static IntervalsSet parseIntervalsSet(final String str, final DoublePrecisionContext precision)
-        throws ParseException {
-        final TreeBuilder<Vector1D> builder = new TreeBuilder<Vector1D>("IntervalsSet", str, precision) {
-
-            /** {@inheritDoc} */
-            @Override
-            public OrientedPoint parseHyperplane()
-                throws ParseException {
-                return OrientedPoint.fromPointAndDirection(Vector1D.of(getNumber()), getBoolean(), getPrecision());
-            }
-
-        };
-        return new IntervalsSet(builder.getTree(), builder.getPrecision());
-    }
-
-    /**
-     * Parse a string representation of a {@link PolygonsSet}.
-     *
-     * @param str string to parse
-     * @param precision precision context to use for the region
-     * @return parsed region
-     * @exception ParseException if the string cannot be parsed
-     */
-    public static PolygonsSet parsePolygonsSet(final String str, final DoublePrecisionContext precision)
-        throws ParseException {
-        final TreeBuilder<Vector2D> builder = new TreeBuilder<Vector2D>("PolygonsSet", str, precision) {
-
-            /** {@inheritDoc} */
-            @Override
-            public Line parseHyperplane()
-                throws ParseException {
-                return Line.fromPointAndAngle(Vector2D.of(getNumber(), getNumber()), getNumber(), getPrecision());
-            }
-
-        };
-        return new PolygonsSet(builder.getTree(), builder.getPrecision());
-    }
-
-    /**
-     * Parse a string representation of a {@link PolyhedronsSet}.
-     *
-     * @param str string to parse
-     * @param precision precision context to use for the region
-     * @return parsed region
-     * @exception ParseException if the string cannot be parsed
-     */
-    public static PolyhedronsSet parsePolyhedronsSet(final String str, final DoublePrecisionContext precision)
-        throws ParseException {
-        final TreeBuilder<Vector3D> builder = new TreeBuilder<Vector3D>("PolyhedronsSet", str, precision) {
-
-            /** {@inheritDoc} */
-            @Override
-            public Plane parseHyperplane()
-                throws ParseException {
-                return Plane.fromPointAndNormal(Vector3D.of(getNumber(), getNumber(), getNumber()),
-                                 Vector3D.of(getNumber(), getNumber(), getNumber()),
-                                 getPrecision());
-            }
-
-        };
-        return new PolyhedronsSet(builder.getTree(), builder.getPrecision());
-    }
-
-    /**
-     * Prints a string representation of the given 1D {@link BSPTree} to the
-     * console. This is intended for quick debugging of small trees.
-     *
-     * @param tree
-     */
-    public static void printTree1D(BSPTree<Vector1D> tree) {
-        TreePrinter1D printer = new TreePrinter1D();
-        System.out.println(printer.writeAsString(tree));
-    }
-
-    /**
-     * Prints a string representation of the given 2D {@link BSPTree} to the
-     * console. This is intended for quick debugging of small trees.
-     *
-     * @param tree
-     */
-    public static void printTree2D(BSPTree<Vector2D> tree) {
-        TreePrinter2D printer = new TreePrinter2D();
-        System.out.println(printer.writeAsString(tree));
-    }
-
-    /**
-     * Prints a string representation of the given 3D {@link BSPTree} to the
-     * console. This is intended for quick debugging of small trees.
-     *
-     * @param tree
-     */
-    public static void printTree3D(BSPTree<Vector3D> tree) {
-        TreePrinter3D printer = new TreePrinter3D();
-        System.out.println(printer.writeAsString(tree));
-    }
-
-    /**
-     * Class for creating string representations of 1D {@link BSPTree}s.
-     */
-    public static class TreePrinter1D extends TreePrinter<Vector1D> {
-
-        /** {@inheritDoc} */
-        @Override
-        protected void writeInternalNode(BSPTree<Vector1D> node) {
-            SubOrientedPoint cut = (SubOrientedPoint) node.getCut();
-
-            OrientedPoint hyper = (OrientedPoint) cut.getHyperplane();
-            write("cut = { hyperplane: ");
-            if (hyper.isPositiveFacing()) {
-                write("[" + hyper.getLocation().getX() + ", inf)");
-            } else {
-                write("(-inf, " + hyper.getLocation().getX() + "]");
-            }
-
-            IntervalsSet remainingRegion = (IntervalsSet) cut.getRemainingRegion();
-            if (remainingRegion != null) {
-                write(", remainingRegion: [");
-
-                boolean isFirst = true;
-                for (double[] interval : remainingRegion) {
-                    if (isFirst) {
-                        isFirst = false;
-                    } else {
-                        write(", ");
-                    }
-                    write(Arrays.toString(interval));
-                }
-
-                write("]");
-            }
-
-            write("}");
+    public static void assertRegionLocation(Region<Vector1D> region, RegionLocation loc, Vector1D ... pts) {
+        for (Vector1D pt : pts) {
+            Assert.assertEquals("Unexpected region location for point " + pt, loc, region.classify(pt));
         }
     }
 
-    /**
-     * Class for creating string representations of 2D {@link BSPTree}s.
+    /** Assert that all of the given points lie within the specified location relative to
+     * {@code region}.
+     * @param region
+     * @param loc
+     * @param pts
      */
-    public static class TreePrinter2D extends TreePrinter<Vector2D> {
-
-        /** {@inheritDoc} */
-        @Override
-        protected void writeInternalNode(BSPTree<Vector2D> node) {
-            SubLine cut = (SubLine) node.getCut();
-            Line line = (Line) cut.getHyperplane();
-            IntervalsSet remainingRegion = (IntervalsSet) cut.getRemainingRegion();
-
-            write("cut = { angle: " + Math.toDegrees(line.getAngle()) + ", origin: " + line.toSpace(Vector1D.ZERO) + "}");
-            write(", remainingRegion: [");
-
-            boolean isFirst = true;
-            for (double[] interval : remainingRegion) {
-                if (isFirst) {
-                    isFirst = false;
-                } else {
-                    write(", ");
-                }
-                write(Arrays.toString(interval));
-            }
-
-            write("]");
+    public static void assertRegionLocation(Region<Vector2D> region, RegionLocation loc, Vector2D ... pts) {
+        for (Vector2D pt : pts) {
+            Assert.assertEquals("Unexpected region location for point " + pt, loc, region.classify(pt));
         }
     }
 
-    /**
-     * Class for creating string representations of 3D {@link BSPTree}s.
+    /** Assert that all of the given points lie within the specified location relative to
+     * {@code region}.
+     * @param region
+     * @param loc
+     * @param pts
      */
-    public static class TreePrinter3D extends TreePrinter<Vector3D> {
-
-        /** {@inheritDoc} */
-        @Override
-        protected void writeInternalNode(BSPTree<Vector3D> node) {
-            SubPlane cut = (SubPlane) node.getCut();
-            Plane plane = (Plane) cut.getHyperplane();
-            PolygonsSet polygon = (PolygonsSet) cut.getRemainingRegion();
-
-            write("cut = { normal: " + plane.getNormal() + ", origin: " + plane.getOrigin() + "}");
-            write(", remainingRegion = [");
-
-            boolean isFirst = true;
-            for (Vector2D[] loop : polygon.getVertices()) {
-                // convert to 3-space for easier debugging
-                List<Vector3D> loop3 = new ArrayList<>();
-                for (Vector2D vertex : loop) {
-                    if (vertex != null) {
-                        loop3.add(plane.toSpace(vertex));
-                    } else {
-                        loop3.add(null);
-                    }
-                }
-
-                if (isFirst) {
-                    isFirst = false;
-                } else {
-                    write(", ");
-                }
-
-                write(loop3.toString());
-            }
-
-            write("]");
+    public static void assertRegionLocation(Region<Vector3D> region, RegionLocation loc, Vector3D ... pts) {
+        for (Vector3D pt : pts) {
+            Assert.assertEquals("Unexpected region location for point " + pt, loc, region.classify(pt));
         }
     }
 }
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/AffineTransformMatrix1DTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/AffineTransformMatrix1DTest.java
index 87cc71f..7b27f91 100644
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/AffineTransformMatrix1DTest.java
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/AffineTransformMatrix1DTest.java
@@ -34,6 +34,8 @@
         AffineTransformMatrix1D transform = AffineTransformMatrix1D.of(1, 2);
 
         // assert
+        Assert.assertTrue(transform.preservesOrientation());
+
         double[] result = transform.toArray();
         Assert.assertArrayEquals(new double[] { 1, 2 }, result, 0.0);
     }
@@ -52,6 +54,8 @@
         AffineTransformMatrix1D transform = AffineTransformMatrix1D.identity();
 
         // assert
+        Assert.assertTrue(transform.preservesOrientation());
+
         double[] expected = { 1, 0 };
         Assert.assertArrayEquals(expected, transform.toArray(), 0.0);
     }
@@ -62,6 +66,8 @@
         AffineTransformMatrix1D transform = AffineTransformMatrix1D.createTranslation(2);
 
         // assert
+        Assert.assertTrue(transform.preservesOrientation());
+
         double[] expected = { 1, 2 };
         Assert.assertArrayEquals(expected, transform.toArray(), 0.0);
     }
@@ -72,6 +78,8 @@
         AffineTransformMatrix1D transform = AffineTransformMatrix1D.createTranslation(Vector1D.of(5));
 
         // assert
+        Assert.assertTrue(transform.preservesOrientation());
+
         double[] expected = { 1, 5 };
         Assert.assertArrayEquals(expected, transform.toArray(), 0.0);
     }
@@ -85,6 +93,8 @@
         AffineTransformMatrix1D result = a.translate(4);
 
         // assert
+        Assert.assertTrue(result.preservesOrientation());
+
         double[] expected = { 2, 14 };
         Assert.assertArrayEquals(expected, result.toArray(), 0.0);
     }
@@ -98,6 +108,8 @@
         AffineTransformMatrix1D result = a.translate(Vector1D.of(7));
 
         // assert
+        Assert.assertTrue(result.preservesOrientation());
+
         double[] expected = { 2, 17 };
         Assert.assertArrayEquals(expected, result.toArray(), 0.0);
     }
@@ -108,6 +120,8 @@
         AffineTransformMatrix1D transform = AffineTransformMatrix1D.createScale(Vector1D.of(4));
 
         // assert
+        Assert.assertTrue(transform.preservesOrientation());
+
         double[] expected = { 4, 0 };
         Assert.assertArrayEquals(expected, transform.toArray(), 0.0);
     }
@@ -118,6 +132,8 @@
         AffineTransformMatrix1D transform = AffineTransformMatrix1D.createScale(7);
 
         // assert
+        Assert.assertTrue(transform.preservesOrientation());
+
         double[] expected = { 7, 0 };
         Assert.assertArrayEquals(expected, transform.toArray(), 0.0);
     }
@@ -131,6 +147,8 @@
         AffineTransformMatrix1D result = a.scale(4);
 
         // assert
+        Assert.assertTrue(result.preservesOrientation());
+
         double[] expected = { 8, 40 };
         Assert.assertArrayEquals(expected, result.toArray(), 0.0);
     }
@@ -144,6 +162,8 @@
         AffineTransformMatrix1D result = a.scale(Vector1D.of(7));
 
         // assert
+        Assert.assertTrue(result.preservesOrientation());
+
         double[] expected = { 14, 70 };
         Assert.assertArrayEquals(expected, result.toArray(), 0.0);
     }
@@ -393,6 +413,31 @@
     }
 
     @Test
+    public void testDeterminant() {
+        // act/assert
+        Assert.assertEquals(0.0, AffineTransformMatrix1D.of(0, 1).determinant(), EPS);
+        Assert.assertEquals(1.0, AffineTransformMatrix1D.of(1, 0).determinant(), EPS);
+        Assert.assertEquals(-1.0, AffineTransformMatrix1D.of(-1, 2).determinant(), EPS);
+    }
+
+    @Test
+    public void testPreservesOrientation() {
+        // act/assert
+        Assert.assertFalse(AffineTransformMatrix1D.of(0, 1).preservesOrientation());
+        Assert.assertTrue(AffineTransformMatrix1D.of(1, 0).preservesOrientation());
+        Assert.assertFalse(AffineTransformMatrix1D.of(-1, 2).preservesOrientation());
+    }
+
+    @Test
+    public void testToMatrix() {
+        // arrange
+        AffineTransformMatrix1D t = AffineTransformMatrix1D.of(1, 1);
+
+        // act/assert
+        Assert.assertSame(t, t.toMatrix());
+    }
+
+    @Test
     public void testMultiply() {
         // arrange
         AffineTransformMatrix1D a = AffineTransformMatrix1D.of(2, 3);
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/FunctionTransform1DTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/FunctionTransform1DTest.java
new file mode 100644
index 0000000..af6b378
--- /dev/null
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/FunctionTransform1DTest.java
@@ -0,0 +1,177 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.oned;
+
+import java.util.function.Function;
+
+import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class FunctionTransform1DTest {
+
+    private static final double TEST_EPS = 1e-15;
+
+    @Test
+    public void testIdentity() {
+        // arrange
+        Vector1D p0 = Vector1D.of(0);
+        Vector1D p1 = Vector1D.of(1);
+        Vector1D p2 = Vector1D.of(-1);
+
+        // act
+        Transform1D t = FunctionTransform1D.identity();
+
+        // assert
+        Assert.assertTrue(t.preservesOrientation());
+
+        EuclideanTestUtils.assertCoordinatesEqual(p0, t.apply(p0), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(p1, t.apply(p1), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(p2, t.apply(p2), TEST_EPS);
+    }
+
+    @Test
+    public void testFrom_identity() {
+        // arrange
+        Vector1D p0 = Vector1D.of(0);
+        Vector1D p1 = Vector1D.of(1);
+        Vector1D p2 = Vector1D.of(-1);
+
+        // act
+        Transform1D t = FunctionTransform1D.from(Function.identity());
+
+        // assert
+        Assert.assertTrue(t.preservesOrientation());
+
+        EuclideanTestUtils.assertCoordinatesEqual(p0, t.apply(p0), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(p1, t.apply(p1), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(p2, t.apply(p2), TEST_EPS);
+    }
+
+    @Test
+    public void testFrom_scaleAndTranslate() {
+        // arrange
+        Vector1D p0 = Vector1D.of(0);
+        Vector1D p1 = Vector1D.of(1);
+        Vector1D p2 = Vector1D.of(-1);
+
+        // act
+        Transform1D t = FunctionTransform1D.from(v -> Vector1D.of((v.getX() + 2) * 3));
+
+        // assert
+        Assert.assertTrue(t.preservesOrientation());
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.of(6), t.apply(p0), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.of(9), t.apply(p1), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.of(3), t.apply(p2), TEST_EPS);
+    }
+
+    @Test
+    public void testFrom_reflection() {
+        // arrange
+        Vector1D p0 = Vector1D.of(0);
+        Vector1D p1 = Vector1D.of(1);
+        Vector1D p2 = Vector1D.of(-1);
+
+        // act
+        Transform1D t = FunctionTransform1D.from(Vector1D::negate);
+
+        // assert
+        Assert.assertFalse(t.preservesOrientation());
+
+        EuclideanTestUtils.assertCoordinatesEqual(p0, t.apply(p0), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(p2, t.apply(p1), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(p1, t.apply(p2), TEST_EPS);
+    }
+
+    @Test
+    public void testApply() {
+        // arrange
+        Transform1D t = FunctionTransform1D.from(v -> {
+            double x = v.getX();
+            return Vector1D.of((-2 * x) + 1);
+        });
+
+        // act/assert
+        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.of(1), t.apply(Vector1D.ZERO), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.of(-1), t.apply(Vector1D.Unit.PLUS), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.of(-3), t.apply(Vector1D.of(2)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.of(3), t.apply(Vector1D.of(-1)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.of(5), t.apply(Vector1D.of(-2)), TEST_EPS);
+    }
+
+    @Test
+    public void testApplyVector() {
+        // arrange
+        Transform1D t = FunctionTransform1D.from(v -> {
+            double x = v.getX();
+            return Vector1D.of((-2 * x) + 1);
+        });
+
+        // act/assert
+        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.ZERO, t.applyVector(Vector1D.ZERO), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.of(-2), t.applyVector(Vector1D.Unit.PLUS), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.of(-4), t.applyVector(Vector1D.of(2)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.of(2), t.applyVector(Vector1D.of(-1)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.of(4), t.applyVector(Vector1D.of(-2)), TEST_EPS);
+    }
+
+    @Test
+    public void testToMatrix() {
+        // act/assert
+        Assert.assertArrayEquals(new double[] { 1, 0 },
+                FunctionTransform1D.identity().toMatrix().toArray(), TEST_EPS);
+        Assert.assertArrayEquals(new double[] { 1, 2 },
+                FunctionTransform1D.from(v -> v.add(Vector1D.of(2))).toMatrix().toArray(), TEST_EPS);
+        Assert.assertArrayEquals(new double[] { 3, 0 },
+                FunctionTransform1D.from(v -> v.multiply(3)).toMatrix().toArray(), TEST_EPS);
+        Assert.assertArrayEquals(new double[] { 3, 6 },
+                FunctionTransform1D.from(v -> v.add(Vector1D.of(2)).multiply(3)).toMatrix().toArray(), TEST_EPS);
+    }
+
+    @Test
+    public void testTransformRoundTrip() {
+        // arrange
+        double eps = 1e-8;
+        double delta = 0.11;
+
+        Vector1D p1 = Vector1D.of(1.1);
+        Vector1D p2 = Vector1D.of(-5);
+        Vector1D vec = p1.vectorTo(p2);
+
+        EuclideanTestUtils.permuteSkipZero(-2, 2, delta, (translate, scale) -> {
+
+            FunctionTransform1D t = FunctionTransform1D.from(v -> {
+                return v.multiply(scale * 0.5)
+                    .add(Vector1D.of(translate))
+                    .multiply(scale * 1.5);
+            });
+
+            // act
+            Vector1D t1 = t.apply(p1);
+            Vector1D t2 = t.apply(p2);
+            Vector1D tvec = t.applyVector(vec);
+
+            Transform1D inverse = t.toMatrix().inverse();
+
+            // assert
+            EuclideanTestUtils.assertCoordinatesEqual(tvec, t1.vectorTo(t2), eps);
+            EuclideanTestUtils.assertCoordinatesEqual(p1, inverse.apply(t1), eps);
+            EuclideanTestUtils.assertCoordinatesEqual(p2, inverse.apply(t2), eps);
+        });
+    }
+}
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/IntervalTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/IntervalTest.java
index ac808a6..dd79284 100644
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/IntervalTest.java
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/IntervalTest.java
@@ -16,166 +16,930 @@
  */
 package org.apache.commons.geometry.euclidean.oned;
 
-import org.apache.commons.geometry.core.partitioning.Region;
+import org.apache.commons.geometry.core.GeometryTestUtils;
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.exception.GeometryException;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.SplitLocation;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
 import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
-import org.apache.commons.numbers.core.Precision;
 import org.junit.Assert;
 import org.junit.Test;
 
 public class IntervalTest {
 
-    private static final double TEST_TOLERANCE = 1e-10;
+    private static final double TEST_EPS = 1e-15;
+
+    private static final DoublePrecisionContext TEST_PRECISION =
+            new EpsilonDoublePrecisionContext(TEST_EPS);
 
     @Test
-    public void testBasicProperties() {
+    public void testOf_doubles() {
+        // act/assert
+        checkInterval(Interval.of(0, 0, TEST_PRECISION), 0, 0);
+
+        checkInterval(Interval.of(1, 2, TEST_PRECISION), 1, 2);
+        checkInterval(Interval.of(2, 1, TEST_PRECISION), 1, 2);
+
+        checkInterval(Interval.of(-2, -1, TEST_PRECISION), -2, -1);
+        checkInterval(Interval.of(-1, -2, TEST_PRECISION), -2, -1);
+
+        checkInterval(Interval.of(1, Double.POSITIVE_INFINITY, TEST_PRECISION),
+                1, Double.POSITIVE_INFINITY);
+        checkInterval(Interval.of(Double.POSITIVE_INFINITY, 1, TEST_PRECISION),
+                1, Double.POSITIVE_INFINITY);
+
+        checkInterval(Interval.of(Double.NEGATIVE_INFINITY, 1, TEST_PRECISION),
+                Double.NEGATIVE_INFINITY, 1);
+        checkInterval(Interval.of(1, Double.NEGATIVE_INFINITY, TEST_PRECISION),
+                Double.NEGATIVE_INFINITY, 1);
+
+        checkInterval(Interval.of(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, TEST_PRECISION),
+                Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY);
+
+        checkInterval(Interval.of(Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY, TEST_PRECISION),
+                Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY);
+    }
+
+    @Test
+    public void testOf_doubles_invalidIntervals() {
         // arrange
-        Interval interval = new Interval(2.3, 5.7);
+        Class<?> excType = GeometryException.class;
 
         // act/assert
-        Assert.assertEquals(3.4, interval.getSize(), TEST_TOLERANCE);
-        Assert.assertEquals(4.0, interval.getBarycenter(), TEST_TOLERANCE);
-        Assert.assertEquals(2.3, interval.getInf(), TEST_TOLERANCE);
-        Assert.assertEquals(5.7, interval.getSup(), TEST_TOLERANCE);
+        GeometryTestUtils.assertThrows(() -> Interval.of(1, Double.NaN, TEST_PRECISION), excType);
+        GeometryTestUtils.assertThrows(() -> Interval.of(Double.NaN, 1, TEST_PRECISION), excType);
+        GeometryTestUtils.assertThrows(() -> Interval.of(Double.NaN, Double.NaN, TEST_PRECISION), excType);
+
+        GeometryTestUtils.assertThrows(
+                () -> Interval.of(Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY, TEST_PRECISION), excType);
+        GeometryTestUtils.assertThrows(
+                () -> Interval.of(Double.NEGATIVE_INFINITY, Double.NEGATIVE_INFINITY, TEST_PRECISION), excType);
     }
 
     @Test
-    public void testBasicProperties_negativeValues() {
+    public void testOf_points() {
+        // act/assert
+        checkInterval(Interval.of(Vector1D.of(1), Vector1D.of(2), TEST_PRECISION), 1, 2);
+        checkInterval(Interval.of(Vector1D.of(Double.POSITIVE_INFINITY), Vector1D.of(Double.NEGATIVE_INFINITY), TEST_PRECISION),
+                Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY);
+    }
+
+    @Test
+    public void testOf_points_invalidIntervals() {
         // arrange
-        Interval interval = new Interval(-5.7, -2.3);
+        Class<?> excType = GeometryException.class;
 
         // act/assert
-        Assert.assertEquals(3.4, interval.getSize(), TEST_TOLERANCE);
-        Assert.assertEquals(-4.0, interval.getBarycenter(), TEST_TOLERANCE);
-        Assert.assertEquals(-5.7, interval.getInf(), TEST_TOLERANCE);
-        Assert.assertEquals(-2.3, interval.getSup(), TEST_TOLERANCE);
-    }
-
-    // MATH-1256
-    @Test(expected = IllegalArgumentException.class)
-    public void testStrictOrdering() {
-        new Interval(0, -1);
+        GeometryTestUtils.assertThrows(
+                () -> Interval.of(Vector1D.of(1), Vector1D.of(Double.NaN), TEST_PRECISION), excType);
+        GeometryTestUtils.assertThrows(
+                () -> Interval.of(Vector1D.of(Double.POSITIVE_INFINITY), Vector1D.of(Double.POSITIVE_INFINITY), TEST_PRECISION), excType);
     }
 
     @Test
-    public void testCheckPoint() {
+    public void testOf_hyperplanes() {
+        // act/assert
+        checkInterval(Interval.of(
+                OrientedPoint.fromLocationAndDirection(1, true, TEST_PRECISION),
+                OrientedPoint.fromLocationAndDirection(1, false, TEST_PRECISION)), 1, 1);
+        checkInterval(Interval.of(
+                OrientedPoint.fromLocationAndDirection(1, false, TEST_PRECISION),
+                OrientedPoint.fromLocationAndDirection(1, true, TEST_PRECISION)), 1, 1);
+
+        checkInterval(Interval.of(
+                OrientedPoint.fromLocationAndDirection(-2, false, TEST_PRECISION),
+                OrientedPoint.fromLocationAndDirection(5, true, TEST_PRECISION)), -2, 5);
+        checkInterval(Interval.of(
+                OrientedPoint.fromLocationAndDirection(5, true, TEST_PRECISION),
+                OrientedPoint.fromLocationAndDirection(-2, false, TEST_PRECISION)), -2, 5);
+
+        checkInterval(Interval.of(
+                null,
+                OrientedPoint.fromLocationAndDirection(5, true, TEST_PRECISION)), Double.NEGATIVE_INFINITY, 5);
+        checkInterval(Interval.of(
+                OrientedPoint.fromLocationAndDirection(5, true, TEST_PRECISION),
+                null), Double.NEGATIVE_INFINITY, 5);
+        checkInterval(Interval.of(
+                OrientedPoint.fromLocationAndDirection(Double.NEGATIVE_INFINITY, false, TEST_PRECISION),
+                OrientedPoint.fromLocationAndDirection(5, true, TEST_PRECISION)), Double.NEGATIVE_INFINITY, 5);
+
+        checkInterval(Interval.of(
+                null,
+                OrientedPoint.fromLocationAndDirection(5, false, TEST_PRECISION)), 5, Double.POSITIVE_INFINITY);
+        checkInterval(Interval.of(
+                OrientedPoint.fromLocationAndDirection(5, false, TEST_PRECISION),
+                null), 5, Double.POSITIVE_INFINITY);
+        checkInterval(Interval.of(
+                OrientedPoint.fromLocationAndDirection(Double.POSITIVE_INFINITY, true, TEST_PRECISION),
+                OrientedPoint.fromLocationAndDirection(5, false, TEST_PRECISION)), 5, Double.POSITIVE_INFINITY);
+    }
+
+    @Test
+    public void testOf_hyperplanes_invalidArgs() {
         // arrange
-        Interval interval = new Interval(2.3, 5.7);
+        Class<?> excType = GeometryException.class;
 
         // act/assert
-        Assert.assertEquals(Region.Location.OUTSIDE,  interval.checkPoint(1.2, TEST_TOLERANCE));
+        GeometryTestUtils.assertThrows(
+                () -> Interval.of(
+                        OrientedPoint.fromLocationAndDirection(1, false, TEST_PRECISION),
+                        OrientedPoint.fromLocationAndDirection(1, false, TEST_PRECISION)), excType);
 
-        Assert.assertEquals(Region.Location.OUTSIDE, interval.checkPoint(2.2, TEST_TOLERANCE));
-        Assert.assertEquals(Region.Location.BOUNDARY, interval.checkPoint(2.3, TEST_TOLERANCE));
-        Assert.assertEquals(Region.Location.INSIDE, interval.checkPoint(2.4, TEST_TOLERANCE));
+        GeometryTestUtils.assertThrows(
+                () -> Interval.of(
+                        OrientedPoint.fromLocationAndDirection(2, false, TEST_PRECISION),
+                        OrientedPoint.fromLocationAndDirection(1, true, TEST_PRECISION)), excType);
 
-        Assert.assertEquals(Region.Location.INSIDE,   interval.checkPoint(3.0, TEST_TOLERANCE));
+        GeometryTestUtils.assertThrows(
+                () -> Interval.of(
+                        OrientedPoint.fromLocationAndDirection(Double.POSITIVE_INFINITY, false, TEST_PRECISION),
+                        OrientedPoint.fromLocationAndDirection(Double.POSITIVE_INFINITY, true, TEST_PRECISION)), excType);
 
-        Assert.assertEquals(Region.Location.INSIDE, interval.checkPoint(5.6, TEST_TOLERANCE));
-        Assert.assertEquals(Region.Location.BOUNDARY, interval.checkPoint(5.7, TEST_TOLERANCE));
-        Assert.assertEquals(Region.Location.OUTSIDE, interval.checkPoint(5.8, TEST_TOLERANCE));
+        GeometryTestUtils.assertThrows(
+                () -> Interval.of(
+                        OrientedPoint.fromLocationAndDirection(Double.NaN, false, TEST_PRECISION),
+                        OrientedPoint.fromLocationAndDirection(1, true, TEST_PRECISION)), excType);
 
-        Assert.assertEquals(Region.Location.OUTSIDE,  interval.checkPoint(8.7, TEST_TOLERANCE));
+        GeometryTestUtils.assertThrows(
+                () -> Interval.of(
+                        OrientedPoint.fromLocationAndDirection(1, false, TEST_PRECISION),
+                        OrientedPoint.fromLocationAndDirection(Double.NaN, true, TEST_PRECISION)), excType);
 
-        Assert.assertEquals(Region.Location.OUTSIDE, interval.checkPoint(Double.NEGATIVE_INFINITY, TEST_TOLERANCE));
-        Assert.assertEquals(Region.Location.OUTSIDE, interval.checkPoint(Double.POSITIVE_INFINITY, TEST_TOLERANCE));
-        Assert.assertEquals(Region.Location.BOUNDARY, interval.checkPoint(Double.NaN, TEST_TOLERANCE));
+        GeometryTestUtils.assertThrows(
+                () -> Interval.of(
+                        OrientedPoint.fromLocationAndDirection(Double.NaN, false, TEST_PRECISION),
+                        OrientedPoint.fromLocationAndDirection(Double.NaN, true, TEST_PRECISION)), excType);
     }
 
     @Test
-    public void testCheckPoint_tolerance() {
+    public void testPoint() {
+        // act/assert
+        checkInterval(Interval.point(0, TEST_PRECISION), 0, 0);
+        checkInterval(Interval.point(1, TEST_PRECISION), 1, 1);
+        checkInterval(Interval.point(-1, TEST_PRECISION), -1, -1);
+    }
+
+    @Test
+    public void testPoint_invalidArgs() {
         // arrange
-        Interval interval = new Interval(2.3, 5.7);
+        Class<?> excType = GeometryException.class;
 
         // act/assert
-        Assert.assertEquals(Region.Location.OUTSIDE, interval.checkPoint(2.29, 1e-3));
-        Assert.assertEquals(Region.Location.BOUNDARY, interval.checkPoint(2.29, 1e-2));
-        Assert.assertEquals(Region.Location.BOUNDARY, interval.checkPoint(2.29, 1e-1));
-        Assert.assertEquals(Region.Location.BOUNDARY, interval.checkPoint(2.29, 1));
-        Assert.assertEquals(Region.Location.BOUNDARY, interval.checkPoint(2.29, 2));
-
-        Assert.assertEquals(Region.Location.INSIDE, interval.checkPoint(4.0, 1e-3));
-        Assert.assertEquals(Region.Location.INSIDE, interval.checkPoint(4.0, 1e-2));
-        Assert.assertEquals(Region.Location.INSIDE, interval.checkPoint(4.0, 1e-1));
-        Assert.assertEquals(Region.Location.INSIDE, interval.checkPoint(4.0, 1));
-        Assert.assertEquals(Region.Location.BOUNDARY, interval.checkPoint(4.0, 2));
+        GeometryTestUtils.assertThrows(
+                () -> Interval.point(Double.NEGATIVE_INFINITY, TEST_PRECISION), excType);
+        GeometryTestUtils.assertThrows(
+                () -> Interval.point(Double.POSITIVE_INFINITY, TEST_PRECISION), excType);
+        GeometryTestUtils.assertThrows(
+                () -> Interval.point(Double.NaN, TEST_PRECISION), excType);
     }
 
     @Test
-    public void testInfinite_inf() {
+    public void testMin() {
+        // act/assert
+        checkInterval(Interval.min(Double.NEGATIVE_INFINITY, TEST_PRECISION),
+                Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY);
+
+        checkInterval(Interval.min(0, TEST_PRECISION), 0, Double.POSITIVE_INFINITY);
+        checkInterval(Interval.min(1, TEST_PRECISION), 1, Double.POSITIVE_INFINITY);
+        checkInterval(Interval.min(-1, TEST_PRECISION), -1, Double.POSITIVE_INFINITY);
+    }
+
+    @Test
+    public void testMin_invalidArgs() {
+        // arrange
+        Class<?> excType = GeometryException.class;
+
+        // act/assert
+        GeometryTestUtils.assertThrows(
+                () -> Interval.min(Double.POSITIVE_INFINITY, TEST_PRECISION), excType);
+        GeometryTestUtils.assertThrows(
+                () -> Interval.min(Double.NaN, TEST_PRECISION), excType);
+    }
+
+    @Test
+    public void testMax() {
+        // act/assert
+        checkInterval(Interval.max(Double.POSITIVE_INFINITY, TEST_PRECISION),
+                Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY);
+
+        checkInterval(Interval.max(0, TEST_PRECISION), Double.NEGATIVE_INFINITY, 0);
+        checkInterval(Interval.max(1, TEST_PRECISION), Double.NEGATIVE_INFINITY, 1);
+        checkInterval(Interval.max(-1, TEST_PRECISION), Double.NEGATIVE_INFINITY, -1);
+    }
+
+    @Test
+    public void testMax_invalidArgs() {
+        // arrange
+        Class<?> excType = GeometryException.class;
+
+        // act/assert
+        GeometryTestUtils.assertThrows(
+                () -> Interval.max(Double.NEGATIVE_INFINITY, TEST_PRECISION), excType);
+        GeometryTestUtils.assertThrows(
+                () -> Interval.max(Double.NaN, TEST_PRECISION), excType);
+    }
+
+    @Test
+    public void testIsInfinite() {
+        // act/assert
+        Assert.assertFalse(Interval.of(1, 2, TEST_PRECISION).isInfinite());
+
+        Assert.assertTrue(Interval.of(Double.NEGATIVE_INFINITY, 2, TEST_PRECISION).isInfinite());
+        Assert.assertTrue(Interval.of(2, Double.POSITIVE_INFINITY, TEST_PRECISION).isInfinite());
+        Assert.assertTrue(Interval.of(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, TEST_PRECISION).isInfinite());
+    }
+
+    @Test
+    public void testIsFinite() {
+        // act/assert
+        Assert.assertTrue(Interval.of(1, 2, TEST_PRECISION).isFinite());
+
+        Assert.assertFalse(Interval.of(Double.NEGATIVE_INFINITY, 2, TEST_PRECISION).isFinite());
+        Assert.assertFalse(Interval.of(2, Double.POSITIVE_INFINITY, TEST_PRECISION).isFinite());
+        Assert.assertFalse(Interval.of(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, TEST_PRECISION).isFinite());
+    }
+
+    @Test
+    public void testClassify_finite() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-2);
+        Interval interval = Interval.of(-1, 1, precision);
+
+        // act/assert
+        checkClassify(interval, RegionLocation.OUTSIDE,
+                Double.NEGATIVE_INFINITY, -2, -1.1,
+                1.1, 2, Double.POSITIVE_INFINITY);
+
+        checkClassify(interval, RegionLocation.BOUNDARY,
+                -1.001, -1, -0.999,
+                0.999, 1, 1.001);
+
+        checkClassify(interval, RegionLocation.INSIDE, -0.9, 0, 0.9);
+
+        checkClassify(interval, RegionLocation.OUTSIDE, Double.NaN);
+    }
+
+    @Test
+    public void testClassify_singlePoint() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-2);
+        Interval interval = Interval.of(1, 1, precision);
+
+        // act/assert
+        checkClassify(interval, RegionLocation.OUTSIDE,
+                Double.NEGATIVE_INFINITY, 0, 0.9, 1.1, 2, Double.POSITIVE_INFINITY);
+
+        checkClassify(interval, RegionLocation.BOUNDARY,
+                0.999, 1, 1.0001);
+
+        checkClassify(interval, RegionLocation.OUTSIDE, Double.NaN);
+    }
+
+    @Test
+    public void testClassify_maxInfinite() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-2);
+        Interval interval = Interval.of(-1, Double.POSITIVE_INFINITY, precision);
+
+        // act/assert
+        checkClassify(interval, RegionLocation.OUTSIDE,
+                Double.NEGATIVE_INFINITY, -2, -1.1);
+
+        checkClassify(interval, RegionLocation.BOUNDARY,
+                -1.001, -1, -0.999);
+
+        checkClassify(interval, RegionLocation.INSIDE,
+                -0.9, 0, 1.0, Double.POSITIVE_INFINITY);
+
+        checkClassify(interval, RegionLocation.OUTSIDE, Double.NaN);
+    }
+
+    @Test
+    public void testClassify_minInfinite() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-2);
+        Interval interval = Interval.of(Double.NEGATIVE_INFINITY, 1, precision);
+
+        // act/assert
+        checkClassify(interval, RegionLocation.INSIDE,
+                Double.NEGATIVE_INFINITY, 0, 0.9);
+
+        checkClassify(interval, RegionLocation.BOUNDARY,
+                0.999, 1, 1.001);
+
+        checkClassify(interval, RegionLocation.OUTSIDE,
+                1.1, 2, Double.POSITIVE_INFINITY);
+
+        checkClassify(interval, RegionLocation.OUTSIDE, Double.NaN);
+    }
+
+    @Test
+    public void testClassify_minMaxInfinite() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-2);
+        Interval interval = Interval.of(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, precision);
+
+        // act/assert
+        checkClassify(interval, RegionLocation.INSIDE,
+                Double.NEGATIVE_INFINITY, -1, 0, 1, Double.POSITIVE_INFINITY);
+
+        checkClassify(interval, RegionLocation.OUTSIDE, Double.NaN);
+    }
+
+    @Test
+    public void testContains_finite() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-2);
+        Interval interval = Interval.of(-1, 1, precision);
+
+        // act/assert
+        checkContains(interval, true,
+                -1.001, -1, -0.999,
+                0.999, 1, 1.001,
+
+                -0.9, 0, 0.9);
+
+        checkContains(interval, false,
+                Double.NEGATIVE_INFINITY, -2, -1.1,
+                1.1, 2, Double.POSITIVE_INFINITY);
+
+        checkContains(interval, false, Double.NaN);
+    }
+
+    @Test
+    public void testIsFull() {
+        // act/assert
+        Assert.assertFalse(Interval.of(1, 1, TEST_PRECISION).isFull());
+        Assert.assertFalse(Interval.of(-2, 2, TEST_PRECISION).isFull());
+
+        Assert.assertFalse(Interval.of(1, Double.POSITIVE_INFINITY, TEST_PRECISION).isFull());
+        Assert.assertFalse(Interval.of(Double.NEGATIVE_INFINITY, 1, TEST_PRECISION).isFull());
+
+        Assert.assertTrue(Interval.of(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, TEST_PRECISION).isFull());
+    }
+
+    @Test
+    public void testGetSize() {
+        // act/assert
+        Assert.assertEquals(0, Interval.of(1, 1, TEST_PRECISION).getSize(), TEST_EPS);
+
+        Assert.assertEquals(4, Interval.of(-2, 2, TEST_PRECISION).getSize(), TEST_EPS);
+        Assert.assertEquals(5, Interval.of(2, -3, TEST_PRECISION).getSize(), TEST_EPS);
+
+        Assert.assertEquals(Double.POSITIVE_INFINITY,
+                Interval.of(1, Double.POSITIVE_INFINITY, TEST_PRECISION).getSize(), TEST_EPS);
+        Assert.assertEquals(Double.POSITIVE_INFINITY,
+                Interval.of(Double.NEGATIVE_INFINITY, 1, TEST_PRECISION).getSize(), TEST_EPS);
+
+        Assert.assertEquals(Double.POSITIVE_INFINITY,
+                Interval.of(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, TEST_PRECISION).getSize(), TEST_EPS);
+    }
+
+    @Test
+    public void testGetBoundarySize() {
+        // act/assert
+        Assert.assertEquals(0, Interval.of(1, 1, TEST_PRECISION).getBoundarySize(), TEST_EPS);
+        Assert.assertEquals(0, Interval.of(-2, 5, TEST_PRECISION).getBoundarySize(), TEST_EPS);
+        Assert.assertEquals(0, Interval.full().getBoundarySize(), TEST_EPS);
+    }
+
+    @Test
+    public void testGetBarycenter() {
+        // act/assert
+        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.ZERO,
+                Interval.of(-1, 1, TEST_PRECISION).getBarycenter(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.of(10),
+                Interval.of(10, 10, TEST_PRECISION).getBarycenter(), TEST_EPS);
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.of(2),
+                Interval.of(1, 3, TEST_PRECISION).getBarycenter(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.of(-1),
+                Interval.of(-2, 0, TEST_PRECISION).getBarycenter(), TEST_EPS);
+
+        Assert.assertNull(Interval.of(1, Double.POSITIVE_INFINITY, TEST_PRECISION).getBarycenter());
+        Assert.assertNull(Interval.of(Double.NEGATIVE_INFINITY, 1, TEST_PRECISION).getBarycenter());
+        Assert.assertNull(Interval.of(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, TEST_PRECISION).getBarycenter());
+    }
+
+    @Test
+    public void checkToTree_finite() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-2);
+        Interval interval = Interval.of(-1, 1, precision);
+
         // act
-        Interval interval = new Interval(Double.NEGATIVE_INFINITY, 9);
+        RegionBSPTree1D tree = interval.toTree();
 
         // assert
-        Assert.assertEquals(Region.Location.BOUNDARY, interval.checkPoint(9.0, TEST_TOLERANCE));
-        Assert.assertEquals(Region.Location.OUTSIDE,  interval.checkPoint(9.4, TEST_TOLERANCE));
-        for (double e = 1.0; e <= 6.0; e += 1.0) {
-            Assert.assertEquals(Region.Location.INSIDE,
-                                interval.checkPoint(-1 * Math.pow(10.0, e), TEST_TOLERANCE));
+        Assert.assertEquals(5, tree.count());
+
+        checkClassify(tree, RegionLocation.OUTSIDE,
+                Double.NEGATIVE_INFINITY, -2, -1.1,
+                1.1, 2, Double.POSITIVE_INFINITY);
+
+        checkClassify(tree, RegionLocation.BOUNDARY,
+                -1.001, -1, -0.999,
+                0.999, 1, 1.001);
+
+        checkClassify(tree, RegionLocation.INSIDE, -0.9, 0, 0.9);
+
+        checkClassify(tree, RegionLocation.OUTSIDE, Double.NaN);
+    }
+
+    @Test
+    public void checkToTree_singlePoint() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-2);
+        Interval interval = Interval.of(1, 1, precision);
+
+        // act
+        RegionBSPTree1D tree = interval.toTree();
+
+        // assert
+        Assert.assertEquals(5, tree.count());
+
+        checkClassify(tree, RegionLocation.OUTSIDE,
+                Double.NEGATIVE_INFINITY, 0, 0.9, 1.1, 2, Double.POSITIVE_INFINITY);
+
+        checkClassify(tree, RegionLocation.BOUNDARY,
+                0.999, 1, 1.0001);
+
+        checkClassify(tree, RegionLocation.OUTSIDE, Double.NaN);
+    }
+
+    @Test
+    public void checkToTree_maxInfinite() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-2);
+        Interval interval = Interval.of(-1, Double.POSITIVE_INFINITY, precision);
+
+        // act
+        RegionBSPTree1D tree = interval.toTree();
+
+        // assert
+        Assert.assertEquals(3, tree.count());
+
+        checkClassify(tree, RegionLocation.OUTSIDE,
+                Double.NEGATIVE_INFINITY, -2, -1.1);
+
+        checkClassify(tree, RegionLocation.BOUNDARY,
+                -1.001, -1, -0.999);
+
+        checkClassify(tree, RegionLocation.INSIDE,
+                -0.9, 0, 1.0, Double.POSITIVE_INFINITY);
+
+        checkClassify(interval, RegionLocation.OUTSIDE, Double.NaN);
+    }
+
+    @Test
+    public void checkToTree_minInfinite() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-2);
+        Interval interval = Interval.of(Double.NEGATIVE_INFINITY, 1, precision);
+
+        // act
+        RegionBSPTree1D tree = interval.toTree();
+
+        // assert
+        Assert.assertEquals(3, tree.count());
+
+        checkClassify(tree, RegionLocation.INSIDE,
+                Double.NEGATIVE_INFINITY, 0, 0.9);
+
+        checkClassify(tree, RegionLocation.BOUNDARY,
+                0.999, 1, 1.001);
+
+        checkClassify(tree, RegionLocation.OUTSIDE,
+                1.1, 2, Double.POSITIVE_INFINITY);
+
+        checkClassify(tree, RegionLocation.OUTSIDE, Double.NaN);
+    }
+
+    @Test
+    public void checkToTree_minMaxInfinite() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-2);
+        Interval interval = Interval.of(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, precision);
+
+        // act
+        RegionBSPTree1D tree = interval.toTree();
+
+        // assert
+        Assert.assertEquals(1, tree.count());
+
+        checkClassify(tree, RegionLocation.INSIDE,
+                Double.NEGATIVE_INFINITY, -1, 0, 1, Double.POSITIVE_INFINITY);
+
+        checkClassify(tree, RegionLocation.OUTSIDE, Double.NaN);
+    }
+
+    @Test
+    public void testProjectToBoundary_full() {
+        // arrange
+        Interval full = Interval.full();
+
+
+        // act/assert
+        Assert.assertNull(full.project(Vector1D.of(Double.NEGATIVE_INFINITY)));
+        Assert.assertNull(full.project(Vector1D.of(0)));
+        Assert.assertNull(full.project(Vector1D.of(Double.POSITIVE_INFINITY)));
+    }
+
+    @Test
+    public void testProjectToBoundary_singlePoint() {
+        // arrange
+        Interval interval = Interval.point(1, TEST_PRECISION);
+
+        // act/assert
+        checkBoundaryProjection(interval, -1, 1);
+        checkBoundaryProjection(interval, 0, 1);
+
+        checkBoundaryProjection(interval, 1, 1);
+
+        checkBoundaryProjection(interval, 2, 1);
+        checkBoundaryProjection(interval, 3, 1);
+
+        checkBoundaryProjection(interval, Double.NEGATIVE_INFINITY, 1);
+        checkBoundaryProjection(interval, Double.POSITIVE_INFINITY, 1);
+    }
+
+    @Test
+    public void testProjectToBoundary_closedInterval() {
+        // arrange
+        Interval interval = Interval.of(1, 3, TEST_PRECISION);
+
+        // act/assert
+        checkBoundaryProjection(interval, -1, 1);
+        checkBoundaryProjection(interval, 0, 1);
+        checkBoundaryProjection(interval, 1, 1);
+
+        checkBoundaryProjection(interval, 1.9, 1);
+        checkBoundaryProjection(interval, 2, 1);
+        checkBoundaryProjection(interval, 2.1, 3);
+
+        checkBoundaryProjection(interval, 3, 3);
+        checkBoundaryProjection(interval, 4, 3);
+        checkBoundaryProjection(interval, 5, 3);
+
+        checkBoundaryProjection(interval, Double.NEGATIVE_INFINITY, 1);
+        checkBoundaryProjection(interval, Double.POSITIVE_INFINITY, 3);
+    }
+
+    @Test
+    public void testProjectToBoundary_noMinBoundary() {
+        // arrange
+        Interval interval = Interval.of(Double.NEGATIVE_INFINITY, 1, TEST_PRECISION);
+
+        // act/assert
+        checkBoundaryProjection(interval, -1, 1);
+        checkBoundaryProjection(interval, 0, 1);
+        checkBoundaryProjection(interval, 1, 1);
+        checkBoundaryProjection(interval, 2, 1);
+        checkBoundaryProjection(interval, 3, 1);
+
+        checkBoundaryProjection(interval, Double.NEGATIVE_INFINITY, 1);
+        checkBoundaryProjection(interval, Double.POSITIVE_INFINITY, 1);
+    }
+
+    @Test
+    public void testProjectToBoundary_noMaxBoundary() {
+        // arrange
+        Interval interval = Interval.of(1, Double.POSITIVE_INFINITY, TEST_PRECISION);
+
+        // act/assert
+        checkBoundaryProjection(interval, -1, 1);
+        checkBoundaryProjection(interval, 0, 1);
+        checkBoundaryProjection(interval, 1, 1);
+        checkBoundaryProjection(interval, 2, 1);
+        checkBoundaryProjection(interval, 3, 1);
+
+        checkBoundaryProjection(interval, Double.NEGATIVE_INFINITY, 1);
+        checkBoundaryProjection(interval, Double.POSITIVE_INFINITY, 1);
+    }
+
+    @Test
+    public void testTransform() {
+        // arrange
+        Transform1D transform = FunctionTransform1D.from((p) -> Vector1D.of(2.0 * p.getX()));
+
+        // act/assert
+        checkInterval(Interval.of(-1, 2, TEST_PRECISION).transform(transform), -2, 4);
+
+        checkInterval(Interval.of(Double.NEGATIVE_INFINITY, 2, TEST_PRECISION).transform(transform),
+                Double.NEGATIVE_INFINITY, 4);
+
+        checkInterval(Interval.of(-1, Double.POSITIVE_INFINITY, TEST_PRECISION).transform(transform), -2,
+                Double.POSITIVE_INFINITY);
+
+        checkInterval(Interval.of(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, TEST_PRECISION).transform(transform),
+                Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY);
+    }
+
+    @Test
+    public void testTransform_reflection() {
+        // arrange
+        Transform1D transform = FunctionTransform1D.from(Vector1D::negate);
+
+        // act/assert
+        checkInterval(Interval.of(-1, 2, TEST_PRECISION).transform(transform), -2, 1);
+
+        checkInterval(Interval.of(Double.NEGATIVE_INFINITY, 2, TEST_PRECISION).transform(transform),
+                -2, Double.POSITIVE_INFINITY);
+
+        checkInterval(Interval.of(-1, Double.POSITIVE_INFINITY, TEST_PRECISION).transform(transform),
+                Double.NEGATIVE_INFINITY, 1);
+    }
+
+    @Test
+    public void testSplit_full_positiveFacingSplitter() {
+        // arrange
+        Interval interval = Interval.full();
+        OrientedPoint splitter = OrientedPoint.fromPointAndDirection(
+                Vector1D.of(1), true, TEST_PRECISION);
+
+        // act
+        Split<Interval> split = interval.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        checkInterval(split.getMinus(), Double.NEGATIVE_INFINITY, 1);
+        checkInterval(split.getPlus(), 1, Double.POSITIVE_INFINITY);
+    }
+
+    @Test
+    public void testSplit_full_negativeFacingSplitter() {
+        // arrange
+        Interval interval = Interval.full();
+        OrientedPoint splitter = OrientedPoint.fromPointAndDirection(
+                Vector1D.of(1), true, TEST_PRECISION);
+
+        // act
+        Split<Interval> split = interval.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        checkInterval(split.getMinus(), Double.NEGATIVE_INFINITY, 1);
+        checkInterval(split.getPlus(), 1, Double.POSITIVE_INFINITY);
+    }
+
+    @Test
+    public void testSplit_halfSpace_positiveFacingSplitter() {
+        // arrange
+        Interval interval = Interval.min(-1, TEST_PRECISION);
+        OrientedPoint splitter = OrientedPoint.fromPointAndDirection(
+                Vector1D.of(1), false, TEST_PRECISION);
+
+        // act
+        Split<Interval> split = interval.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        checkInterval(split.getMinus(), 1, Double.POSITIVE_INFINITY);
+        checkInterval(split.getPlus(), -1, 1);
+    }
+
+
+    @Test
+    public void testSplit_halfSpace_negativeFacingSplitter() {
+        // arrange
+        Interval interval = Interval.min(-1, TEST_PRECISION);
+        OrientedPoint splitter = OrientedPoint.fromPointAndDirection(
+                Vector1D.of(1), false, TEST_PRECISION);
+
+        // act
+        Split<Interval> split = interval.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        checkInterval(split.getMinus(), 1, Double.POSITIVE_INFINITY);
+        checkInterval(split.getPlus(), -1, 1);
+    }
+
+    @Test
+    public void testSplit_splitterBelowInterval() {
+        // arrange
+        Interval interval = Interval.of(5, 10, TEST_PRECISION);
+        OrientedPoint splitter = OrientedPoint.fromPointAndDirection(
+                Vector1D.of(1), true, TEST_PRECISION);
+
+        // act
+        Split<Interval> split = interval.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.PLUS, split.getLocation());
+
+        Assert.assertSame(interval, split.getPlus());
+    }
+
+    @Test
+    public void testSplit_splitterOnMinBoundary() {
+        // arrange
+        Interval interval = Interval.of(5, 10, TEST_PRECISION);
+        OrientedPoint splitter = OrientedPoint.fromPointAndDirection(
+                Vector1D.of(5), false, TEST_PRECISION);
+
+        // act
+        Split<Interval> split = interval.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.MINUS, split.getLocation());
+
+        Assert.assertSame(interval, split.getMinus());
+    }
+
+    @Test
+    public void testSplit_splitterAboveInterval() {
+        // arrange
+        Interval interval = Interval.of(5, 10, TEST_PRECISION);
+        OrientedPoint splitter = OrientedPoint.fromPointAndDirection(
+                Vector1D.of(11), true, TEST_PRECISION);
+
+        // act
+        Split<Interval> split = interval.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.MINUS, split.getLocation());
+
+        Assert.assertSame(interval, split.getMinus());
+    }
+
+    @Test
+    public void testSplit_splitterOnMaxBoundary() {
+        // arrange
+        Interval interval = Interval.of(5, 10, TEST_PRECISION);
+        OrientedPoint splitter = OrientedPoint.fromPointAndDirection(
+                Vector1D.of(10), false, TEST_PRECISION);
+
+        // act
+        Split<Interval> split = interval.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.PLUS, split.getLocation());
+
+        Assert.assertSame(interval, split.getPlus());
+    }
+
+    @Test
+    public void testSplit_point_minusOnly() {
+        // arrange
+        Interval interval = Interval.point(2, TEST_PRECISION);
+        OrientedPoint splitter = OrientedPoint.fromPointAndDirection(
+                Vector1D.of(1), false, TEST_PRECISION);
+
+        // act
+        Split<Interval> split = interval.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.MINUS, split.getLocation());
+
+        checkInterval(split.getMinus(), 2, 2);
+        Assert.assertNull(split.getPlus());
+    }
+
+    @Test
+    public void testSplit_point_plusOnly() {
+        // arrange
+        Interval interval = Interval.point(2, TEST_PRECISION);
+        OrientedPoint splitter = OrientedPoint.fromPointAndDirection(
+                Vector1D.of(1), true, TEST_PRECISION);
+
+        // act
+        Split<Interval> split = interval.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.PLUS, split.getLocation());
+
+        Assert.assertNull(split.getMinus());
+        checkInterval(split.getPlus(), 2, 2);
+    }
+
+    @Test
+    public void testSplit_point_onPoint() {
+        // arrange
+        Interval interval = Interval.point(1, TEST_PRECISION);
+        OrientedPoint splitter = OrientedPoint.fromPointAndDirection(
+                Vector1D.of(1), true, TEST_PRECISION);
+
+        // act
+        Split<Interval> split = interval.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.NEITHER, split.getLocation());
+
+        Assert.assertNull(split.getMinus());
+        Assert.assertNull(split.getPlus());
+    }
+
+    @Test
+    public void testToString() {
+        // arrange
+        Interval interval = Interval.of(2, 1, TEST_PRECISION);
+
+        // act
+        String str = interval.toString();
+
+        // assert
+        Assert.assertTrue(str.contains("Interval"));
+        Assert.assertTrue(str.contains("min= 1.0"));
+        Assert.assertTrue(str.contains("max= 2.0"));
+    }
+
+    @Test
+    public void testFull() {
+        // act
+        Interval full = Interval.full();
+
+        // assert
+        Assert.assertTrue(full.isFull());
+        Assert.assertFalse(full.isEmpty());
+        Assert.assertFalse(full.hasMinBoundary());
+        Assert.assertFalse(full.hasMaxBoundary());
+        Assert.assertTrue(full.isInfinite());
+
+        Assert.assertEquals(RegionLocation.INSIDE, full.classify(Double.NEGATIVE_INFINITY));
+        Assert.assertEquals(RegionLocation.INSIDE, full.classify(Double.POSITIVE_INFINITY));
+    }
+
+    private static void checkContains(Interval interval, boolean contains, double ... points) {
+        for (double x : points) {
+            String msg = "Unexpected contains status for point " + x;
+
+            Assert.assertEquals(msg, contains, interval.contains(x));
+            Assert.assertEquals(msg, contains, interval.contains(Vector1D.of(x)));
         }
-        EuclideanTestUtils.assertPositiveInfinity(interval.getSize());
-        EuclideanTestUtils.assertNegativeInfinity(interval.getInf());
-        Assert.assertEquals(9.0, interval.getSup(), TEST_TOLERANCE);
     }
 
-    @Test
-    public void testInfinite_sup() {
-        // act
-        Interval interval = new Interval(9.0, Double.POSITIVE_INFINITY);
+    private static void checkClassify(Interval interval, RegionLocation loc, double ... points) {
+        for (double x : points) {
+            String msg = "Unexpected location for point " + x;
 
-        // assert
-        Assert.assertEquals(Region.Location.BOUNDARY, interval.checkPoint(9.0, TEST_TOLERANCE));
-        Assert.assertEquals(Region.Location.OUTSIDE,  interval.checkPoint(8.4, TEST_TOLERANCE));
-        for (double e = 1.0; e <= 6.0; e += 1.0) {
-            Assert.assertEquals(Region.Location.INSIDE,
-                                interval.checkPoint(Math.pow(10.0, e), TEST_TOLERANCE));
+            Assert.assertEquals(msg, loc, interval.classify(x));
+            Assert.assertEquals(msg, loc, interval.classify(Vector1D.of(x)));
         }
-        EuclideanTestUtils.assertPositiveInfinity(interval.getSize());
-        Assert.assertEquals(9.0, interval.getInf(), TEST_TOLERANCE);
-        EuclideanTestUtils.assertPositiveInfinity(interval.getSup());
     }
 
-    @Test
-    public void testInfinite_infAndSup() {
-        // act
-        Interval interval = new Interval(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY);
+    private static void checkClassify(RegionBSPTree1D tree, RegionLocation loc, double ... points) {
+        for (double x : points) {
+            String msg = "Unexpected location for point " + x;
 
-        // assert
-        for (double e = 1.0; e <= 6.0; e += 1.0) {
-            Assert.assertEquals(Region.Location.INSIDE,
-                                interval.checkPoint(Math.pow(10.0, e), TEST_TOLERANCE));
+            Assert.assertEquals(msg, loc, tree.classify(x));
+            Assert.assertEquals(msg, loc, tree.classify(Vector1D.of(x)));
         }
-        EuclideanTestUtils.assertPositiveInfinity(interval.getSize());
-        EuclideanTestUtils.assertNegativeInfinity(interval.getInf());
-        EuclideanTestUtils.assertPositiveInfinity(interval.getSup());
     }
 
-    @Test
-    public void testSinglePoint() {
-        // act
-        Interval interval = new Interval(1.0, 1.0);
+    private static void checkBoundaryProjection(Interval interval, double location, double projectedLocation) {
+        Vector1D pt = Vector1D.of(location);
 
-        // assert
-        Assert.assertEquals(0.0, interval.getSize(), Precision.SAFE_MIN);
-        Assert.assertEquals(1.0, interval.getBarycenter(), Precision.EPSILON);
+        Vector1D proj = interval.project(pt);
+
+        Assert.assertEquals(projectedLocation, proj.getX(), TEST_EPS);
     }
 
-    @Test
-    public void testSingleInfinitePoint_positive() {
-        // act
-        Interval interval = new Interval(Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY);
-
-        // assert
-        Assert.assertTrue(Double.isNaN(interval.getSize())); // inf - inf = NaN according to floating point spec
-        EuclideanTestUtils.assertPositiveInfinity(interval.getBarycenter());
+    /** Check that the given interval matches the arguments and is internally consistent.
+     * @param interval
+     * @param min
+     * @param max
+     */
+    private static void checkInterval(Interval interval, double min, double max) {
+        checkInterval(interval, min, max, TEST_PRECISION);
     }
 
-    @Test
-    public void testSingleInfinitePoint_negative() {
-        // act
-        Interval interval = new Interval(Double.NEGATIVE_INFINITY, Double.NEGATIVE_INFINITY);
+    /** Check that the given interval matches the arguments and is internally consistent.
+     * @param interval
+     * @param min
+     * @param max
+     * @param precision
+     */
+    private static void checkInterval(Interval interval, double min, double max, DoublePrecisionContext precision) {
+        Assert.assertEquals(min, interval.getMin(), TEST_EPS);
+        Assert.assertEquals(max, interval.getMax(), TEST_EPS);
 
-        // assert
-        Assert.assertTrue(Double.isNaN(interval.getSize())); // inf - inf = NaN according to floating point spec
-        EuclideanTestUtils.assertNegativeInfinity(interval.getBarycenter());
+        boolean finiteMin = Double.isFinite(min);
+        boolean finiteMax = Double.isFinite(max);
+
+        Assert.assertEquals(finiteMin, interval.hasMinBoundary());
+        Assert.assertEquals(finiteMax, interval.hasMaxBoundary());
+
+        if (finiteMin) {
+            Assert.assertEquals(min, interval.getMinBoundary().getLocation(), TEST_EPS);
+        }
+        else {
+            Assert.assertNull(interval.getMinBoundary());
+        }
+
+        if (finiteMax) {
+            Assert.assertEquals(max, interval.getMaxBoundary().getLocation(), TEST_EPS);
+        }
+        else {
+            Assert.assertNull(interval.getMaxBoundary());
+        }
+
+        Assert.assertFalse(interval.isEmpty()); // always false
     }
 }
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/IntervalsSetTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/IntervalsSetTest.java
deleted file mode 100644
index 85f27e4..0000000
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/IntervalsSetTest.java
+++ /dev/null
@@ -1,592 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.oned;
-
-import java.util.ArrayList;
-import java.util.List;
-
-import org.apache.commons.geometry.core.partitioning.BSPTree;
-import org.apache.commons.geometry.core.partitioning.BoundaryProjection;
-import org.apache.commons.geometry.core.partitioning.Region;
-import org.apache.commons.geometry.core.partitioning.RegionFactory;
-import org.apache.commons.geometry.core.partitioning.SubHyperplane;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
-import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
-import org.junit.Assert;
-import org.junit.Test;
-
-public class IntervalsSetTest {
-
-    private static final double TEST_EPS = 1e-15;
-
-    private static final DoublePrecisionContext TEST_PRECISION =
-            new EpsilonDoublePrecisionContext(TEST_EPS);
-
-    @Test
-    public void testInterval_wholeNumberLine() {
-        // act
-        IntervalsSet set = new IntervalsSet(TEST_PRECISION);
-
-        // assert
-        Assert.assertSame(TEST_PRECISION, set.getPrecision());
-        EuclideanTestUtils.assertNegativeInfinity(set.getInf());
-        EuclideanTestUtils.assertPositiveInfinity(set.getSup());
-        EuclideanTestUtils.assertPositiveInfinity(set.getSize());
-        Assert.assertEquals(0.0, set.getBoundarySize(), TEST_EPS);
-        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.NaN, set.getBarycenter(), TEST_EPS);
-
-        BSPTree<Vector1D> tree = set.getTree(true);
-        Assert.assertEquals(Boolean.TRUE, tree.getAttribute());
-        Assert.assertNull(tree.getCut());
-        Assert.assertNull(tree.getMinus());
-        Assert.assertNull(tree.getPlus());
-
-        List<Interval> intervals = set.asList();
-        Assert.assertEquals(1, intervals.size());
-        assertInterval(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, intervals.get(0), TEST_EPS);
-
-        assertLocation(Region.Location.INSIDE, set, Double.NEGATIVE_INFINITY);
-        assertLocation(Region.Location.INSIDE, set, 0.0);
-        assertLocation(Region.Location.INSIDE, set, Double.POSITIVE_INFINITY);
-    }
-
-    @Test
-    public void testInterval_doubleOpenInterval() {
-        // act
-        IntervalsSet set = new IntervalsSet(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, TEST_PRECISION);
-
-        // assert
-        Assert.assertSame(TEST_PRECISION, set.getPrecision());
-        EuclideanTestUtils.assertNegativeInfinity(set.getInf());
-        EuclideanTestUtils.assertPositiveInfinity(set.getSup());
-        EuclideanTestUtils.assertPositiveInfinity(set.getSize());
-        Assert.assertEquals(0.0, set.getBoundarySize(), TEST_EPS);
-        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.NaN, set.getBarycenter(), TEST_EPS);
-
-        BSPTree<Vector1D> tree = set.getTree(true);
-        Assert.assertEquals(Boolean.TRUE, tree.getAttribute());
-        Assert.assertNull(tree.getCut());
-        Assert.assertNull(tree.getMinus());
-        Assert.assertNull(tree.getPlus());
-
-        List<Interval> intervals = set.asList();
-        Assert.assertEquals(1, intervals.size());
-        assertInterval(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, intervals.get(0), TEST_EPS);
-
-        assertLocation(Region.Location.INSIDE, set, Double.NEGATIVE_INFINITY);
-        assertLocation(Region.Location.INSIDE, set, 0.0);
-        assertLocation(Region.Location.INSIDE, set, Double.POSITIVE_INFINITY);
-    }
-
-    @Test
-    public void testInterval_openInterval_positive() {
-        // act
-        IntervalsSet set = new IntervalsSet(9.0, Double.POSITIVE_INFINITY, TEST_PRECISION);
-
-        // assert
-        Assert.assertSame(TEST_PRECISION, set.getPrecision());
-        Assert.assertEquals(9.0, set.getInf(), TEST_EPS);
-        EuclideanTestUtils.assertPositiveInfinity(set.getSup());
-        EuclideanTestUtils.assertPositiveInfinity(set.getSize());
-        Assert.assertEquals(0.0, set.getBoundarySize(), TEST_EPS);
-        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.NaN, set.getBarycenter(), TEST_EPS);
-
-        List<Interval> intervals = set.asList();
-        Assert.assertEquals(1, intervals.size());
-        assertInterval(9.0, Double.POSITIVE_INFINITY, intervals.get(0), TEST_EPS);
-
-        assertLocation(Region.Location.OUTSIDE, set, Double.NEGATIVE_INFINITY);
-        assertLocation(Region.Location.OUTSIDE, set, 0.0);
-        assertLocation(Region.Location.BOUNDARY, set, 9.0 - 1e-16);
-        assertLocation(Region.Location.BOUNDARY, set, 9.0 + 1e-16);
-        assertLocation(Region.Location.INSIDE, set, 10.0);
-        assertLocation(Region.Location.INSIDE, set, Double.POSITIVE_INFINITY);
-    }
-
-    @Test
-    public void testInterval_openInterval_negative() {
-        // act
-        IntervalsSet set = new IntervalsSet(Double.NEGATIVE_INFINITY, 9.0, TEST_PRECISION);
-
-        // assert
-        Assert.assertSame(TEST_PRECISION, set.getPrecision());
-        EuclideanTestUtils.assertNegativeInfinity(set.getInf());
-        Assert.assertEquals(9.0, set.getSup(), TEST_EPS);
-        EuclideanTestUtils.assertPositiveInfinity(set.getSize());
-        Assert.assertEquals(0.0, set.getBoundarySize(), TEST_EPS);
-        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.NaN, set.getBarycenter(), TEST_EPS);
-
-        List<Interval> intervals = set.asList();
-        Assert.assertEquals(1, intervals.size());
-        assertInterval(Double.NEGATIVE_INFINITY, 9.0, intervals.get(0), TEST_EPS);
-
-        assertLocation(Region.Location.INSIDE, set, Double.NEGATIVE_INFINITY);
-        assertLocation(Region.Location.INSIDE, set, 0.0);
-        assertLocation(Region.Location.BOUNDARY, set, 9.0 - 1e-16);
-        assertLocation(Region.Location.BOUNDARY, set, 9.0 + 1e-16);
-        assertLocation(Region.Location.OUTSIDE, set, 10.0);
-        assertLocation(Region.Location.OUTSIDE, set, Double.POSITIVE_INFINITY);
-    }
-
-    @Test
-    public void testInterval_singleClosedInterval() {
-        // act
-        IntervalsSet set = new IntervalsSet(-1.0, 9.0, TEST_PRECISION);
-
-        // assert
-        Assert.assertSame(TEST_PRECISION, set.getPrecision());
-        Assert.assertEquals(-1.0, set.getInf(), TEST_EPS);
-        Assert.assertEquals(9.0, set.getSup(), TEST_EPS);
-        Assert.assertEquals(10.0, set.getSize(), TEST_EPS);
-        Assert.assertEquals(0.0, set.getBoundarySize(), TEST_EPS);
-        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.of(4.0), set.getBarycenter(), TEST_EPS);
-
-        List<Interval> intervals = set.asList();
-        Assert.assertEquals(1, intervals.size());
-        assertInterval(-1.0, 9.0, intervals.get(0), TEST_EPS);
-
-        assertLocation(Region.Location.OUTSIDE, set, Double.NEGATIVE_INFINITY);
-        assertLocation(Region.Location.OUTSIDE, set, -2.0);
-        assertLocation(Region.Location.INSIDE, set, 0.0);
-        assertLocation(Region.Location.BOUNDARY, set, 9.0 - 1e-16);
-        assertLocation(Region.Location.BOUNDARY, set, 9.0 + 1e-16);
-        assertLocation(Region.Location.OUTSIDE, set, 10.0);
-        assertLocation(Region.Location.OUTSIDE, set, Double.POSITIVE_INFINITY);
-    }
-
-    @Test
-    public void testInterval_singlePoint() {
-        // act
-        IntervalsSet set = new IntervalsSet(1.0, 1.0, TEST_PRECISION);
-
-        // assert
-        Assert.assertSame(TEST_PRECISION, set.getPrecision());
-        Assert.assertEquals(1.0, set.getInf(), TEST_EPS);
-        Assert.assertEquals(1.0, set.getSup(), TEST_EPS);
-        Assert.assertEquals(0.0, set.getSize(), TEST_EPS);
-        Assert.assertEquals(0.0, set.getBoundarySize(), TEST_EPS);
-        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.of(1.0), set.getBarycenter(), TEST_EPS);
-
-        List<Interval> intervals = set.asList();
-        Assert.assertEquals(1, intervals.size());
-        assertInterval(1.0, 1.0, intervals.get(0), TEST_EPS);
-
-        assertLocation(Region.Location.OUTSIDE, set, Double.NEGATIVE_INFINITY);
-        assertLocation(Region.Location.OUTSIDE, set, 0.0);
-        assertLocation(Region.Location.BOUNDARY, set, 1.0);
-        assertLocation(Region.Location.OUTSIDE, set, 2.0);
-        assertLocation(Region.Location.OUTSIDE, set, Double.POSITIVE_INFINITY);
-    }
-
-    @Test
-    public void testFromBoundaries_wholeNumberLine() {
-        // arrange
-        List<SubHyperplane<Vector1D>> boundaries = new ArrayList<>();
-
-        // act
-        IntervalsSet set = new IntervalsSet(boundaries, TEST_PRECISION);
-
-        // assert
-        Assert.assertSame(TEST_PRECISION, set.getPrecision());
-        EuclideanTestUtils.assertNegativeInfinity(set.getInf());
-        EuclideanTestUtils.assertPositiveInfinity(set.getSup());
-        EuclideanTestUtils.assertPositiveInfinity(set.getSize());
-        Assert.assertEquals(0.0, set.getBoundarySize(), TEST_EPS);
-        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.NaN, set.getBarycenter(), TEST_EPS);
-
-        BSPTree<Vector1D> tree = set.getTree(true);
-        Assert.assertEquals(Boolean.TRUE, tree.getAttribute());
-        Assert.assertNull(tree.getCut());
-        Assert.assertNull(tree.getMinus());
-        Assert.assertNull(tree.getPlus());
-
-        List<Interval> intervals = set.asList();
-        Assert.assertEquals(1, intervals.size());
-        assertInterval(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, intervals.get(0), TEST_EPS);
-
-        assertLocation(Region.Location.INSIDE, set, Double.NEGATIVE_INFINITY);
-        assertLocation(Region.Location.INSIDE, set, 0.0);
-        assertLocation(Region.Location.INSIDE, set, Double.POSITIVE_INFINITY);
-    }
-
-    @Test
-    public void testFromBoundaries_openInterval_positive() {
-        // arrange
-        List<SubHyperplane<Vector1D>> boundaries = new ArrayList<>();
-        boundaries.add(subOrientedPoint(9.0, false));
-
-        // act
-        IntervalsSet set = new IntervalsSet(boundaries, TEST_PRECISION);
-
-        // assert
-        Assert.assertSame(TEST_PRECISION, set.getPrecision());
-        Assert.assertEquals(9.0, set.getInf(), TEST_EPS);
-        EuclideanTestUtils.assertPositiveInfinity(set.getSup());
-        EuclideanTestUtils.assertPositiveInfinity(set.getSize());
-        Assert.assertEquals(0.0, set.getBoundarySize(), TEST_EPS);
-        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.NaN, set.getBarycenter(), TEST_EPS);
-
-        List<Interval> intervals = set.asList();
-        Assert.assertEquals(1, intervals.size());
-        assertInterval(9.0, Double.POSITIVE_INFINITY, intervals.get(0), TEST_EPS);
-
-        assertLocation(Region.Location.OUTSIDE, set, Double.NEGATIVE_INFINITY);
-        assertLocation(Region.Location.OUTSIDE, set, 0.0);
-        assertLocation(Region.Location.BOUNDARY, set, 9.0 - 1e-16);
-        assertLocation(Region.Location.BOUNDARY, set, 9.0 + 1e-16);
-        assertLocation(Region.Location.INSIDE, set, 10.0);
-        assertLocation(Region.Location.INSIDE, set, Double.POSITIVE_INFINITY);
-    }
-
-    @Test
-    public void testFromBoundaries_openInterval_negative() {
-        // arrange
-        List<SubHyperplane<Vector1D>> boundaries = new ArrayList<>();
-        boundaries.add(subOrientedPoint(9.0, true));
-
-        // act
-        IntervalsSet set = new IntervalsSet(boundaries, TEST_PRECISION);
-
-        // assert
-        Assert.assertSame(TEST_PRECISION, set.getPrecision());
-        EuclideanTestUtils.assertNegativeInfinity(set.getInf());
-        Assert.assertEquals(9.0, set.getSup(), TEST_EPS);
-        EuclideanTestUtils.assertPositiveInfinity(set.getSize());
-        Assert.assertEquals(0.0, set.getBoundarySize(), TEST_EPS);
-        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.NaN, set.getBarycenter(), TEST_EPS);
-
-        List<Interval> intervals = set.asList();
-        Assert.assertEquals(1, intervals.size());
-        assertInterval(Double.NEGATIVE_INFINITY, 9.0, intervals.get(0), TEST_EPS);
-
-        assertLocation(Region.Location.INSIDE, set, Double.NEGATIVE_INFINITY);
-        assertLocation(Region.Location.INSIDE, set, 0.0);
-        assertLocation(Region.Location.BOUNDARY, set, 9.0 - 1e-16);
-        assertLocation(Region.Location.BOUNDARY, set, 9.0 + 1e-16);
-        assertLocation(Region.Location.OUTSIDE, set, 10.0);
-        assertLocation(Region.Location.OUTSIDE, set, Double.POSITIVE_INFINITY);
-    }
-
-    @Test
-    public void testFromBoundaries_singleClosedInterval() {
-        // arrange
-        List<SubHyperplane<Vector1D>> boundaries = new ArrayList<>();
-        boundaries.add(subOrientedPoint(-1.0, false));
-        boundaries.add(subOrientedPoint(9.0, true));
-
-        // act
-        IntervalsSet set = new IntervalsSet(boundaries, TEST_PRECISION);
-
-        // assert
-        Assert.assertSame(TEST_PRECISION, set.getPrecision());
-        Assert.assertEquals(-1.0, set.getInf(), TEST_EPS);
-        Assert.assertEquals(9.0, set.getSup(), TEST_EPS);
-        Assert.assertEquals(10.0, set.getSize(), TEST_EPS);
-        Assert.assertEquals(0.0, set.getBoundarySize(), TEST_EPS);
-        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.of(4.0), set.getBarycenter(), TEST_EPS);
-
-        List<Interval> intervals = set.asList();
-        Assert.assertEquals(1, intervals.size());
-        assertInterval(-1.0, 9.0, intervals.get(0), TEST_EPS);
-
-        assertLocation(Region.Location.OUTSIDE, set, Double.NEGATIVE_INFINITY);
-        assertLocation(Region.Location.OUTSIDE, set, -2.0);
-        assertLocation(Region.Location.INSIDE, set, 0.0);
-        assertLocation(Region.Location.BOUNDARY, set, 9.0 - 1e-16);
-        assertLocation(Region.Location.BOUNDARY, set, 9.0 + 1e-16);
-        assertLocation(Region.Location.OUTSIDE, set, 10.0);
-        assertLocation(Region.Location.OUTSIDE, set, Double.POSITIVE_INFINITY);
-    }
-
-    @Test
-    public void testFromBoundaries_multipleClosedIntervals() {
-        // arrange
-        List<SubHyperplane<Vector1D>> boundaries = new ArrayList<>();
-        boundaries.add(subOrientedPoint(-1.0, false));
-        boundaries.add(subOrientedPoint(2.0, true));
-        boundaries.add(subOrientedPoint(5.0, false));
-        boundaries.add(subOrientedPoint(9.0, true));
-
-        // act
-        IntervalsSet set = new IntervalsSet(boundaries, TEST_PRECISION);
-
-        // assert
-        Assert.assertSame(TEST_PRECISION, set.getPrecision());
-        Assert.assertEquals(-1.0, set.getInf(), TEST_EPS);
-        Assert.assertEquals(9.0, set.getSup(), TEST_EPS);
-        Assert.assertEquals(7.0, set.getSize(), TEST_EPS);
-        Assert.assertEquals(0.0, set.getBoundarySize(), TEST_EPS);
-        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.of(29.5 / 7.0), set.getBarycenter(), TEST_EPS);
-
-        List<Interval> intervals = set.asList();
-        Assert.assertEquals(2, intervals.size());
-        assertInterval(-1.0, 2.0, intervals.get(0), TEST_EPS);
-        assertInterval(5.0, 9.0, intervals.get(1), TEST_EPS);
-
-        assertLocation(Region.Location.OUTSIDE, set, Double.NEGATIVE_INFINITY);
-        assertLocation(Region.Location.OUTSIDE, set, -2.0);
-        assertLocation(Region.Location.INSIDE, set, 0.0);
-        assertLocation(Region.Location.OUTSIDE, set, 3.0);
-        assertLocation(Region.Location.INSIDE, set, 6.0);
-        assertLocation(Region.Location.BOUNDARY, set, 9.0 - 1e-16);
-        assertLocation(Region.Location.BOUNDARY, set, 9.0 + 1e-16);
-        assertLocation(Region.Location.OUTSIDE, set, 10.0);
-        assertLocation(Region.Location.OUTSIDE, set, Double.POSITIVE_INFINITY);
-    }
-
-    @Test
-    public void testFromBoundaries_mixedOpenAndClosedIntervals() {
-        // arrange
-        List<SubHyperplane<Vector1D>> boundaries = new ArrayList<>();
-        boundaries.add(subOrientedPoint(-2.0, true));
-        boundaries.add(subOrientedPoint(-1.0, false));
-        boundaries.add(subOrientedPoint(2.0, true));
-        boundaries.add(subOrientedPoint(5.0, false));
-        boundaries.add(subOrientedPoint(9.0, true));
-        boundaries.add(subOrientedPoint(10.0, false));
-
-        // act
-        IntervalsSet set = new IntervalsSet(boundaries, TEST_PRECISION);
-
-        // assert
-        Assert.assertSame(TEST_PRECISION, set.getPrecision());
-        EuclideanTestUtils.assertNegativeInfinity(set.getInf());
-        EuclideanTestUtils.assertPositiveInfinity(set.getSup());
-        EuclideanTestUtils.assertPositiveInfinity(set.getSize());
-        Assert.assertEquals(0.0, set.getBoundarySize(), TEST_EPS);
-        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.of(Double.NaN), set.getBarycenter(), TEST_EPS);
-
-        List<Interval> intervals = set.asList();
-        Assert.assertEquals(4, intervals.size());
-        assertInterval(Double.NEGATIVE_INFINITY, -2.0, intervals.get(0), TEST_EPS);
-        assertInterval(-1.0, 2.0, intervals.get(1), TEST_EPS);
-        assertInterval(5.0, 9.0, intervals.get(2), TEST_EPS);
-        assertInterval(10.0, Double.POSITIVE_INFINITY, intervals.get(3), TEST_EPS);
-
-        assertLocation(Region.Location.INSIDE, set, Double.NEGATIVE_INFINITY);
-        assertLocation(Region.Location.INSIDE, set, -3);
-        assertLocation(Region.Location.OUTSIDE, set, -1.5);
-        assertLocation(Region.Location.INSIDE, set, 0.0);
-        assertLocation(Region.Location.OUTSIDE, set, 3.0);
-        assertLocation(Region.Location.INSIDE, set, 6.0);
-        assertLocation(Region.Location.BOUNDARY, set, 9.0 - 1e-16);
-        assertLocation(Region.Location.BOUNDARY, set, 9.0 + 1e-16);
-        assertLocation(Region.Location.OUTSIDE, set, 9.5);
-        assertLocation(Region.Location.INSIDE, set, 11.0);
-        assertLocation(Region.Location.INSIDE, set, Double.POSITIVE_INFINITY);
-    }
-
-    @Test
-    public void testFromBoundaries_intervalEqualToEpsilon_onlyFirstBoundaryUsed() {
-        // arrange
-        double eps = 1e-3;
-        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(eps);
-        double first = 1.0;
-        double second = 1.0 + eps;
-        List<SubHyperplane<Vector1D>> boundaries = new ArrayList<>();
-        boundaries.add(subOrientedPoint(first, true, precision));
-        boundaries.add(subOrientedPoint(second, false, precision));
-
-        // act
-        IntervalsSet set = new IntervalsSet(boundaries, precision);
-
-        // assert
-        Assert.assertSame(precision, set.getPrecision());
-        EuclideanTestUtils.assertNegativeInfinity(set.getInf());
-        Assert.assertEquals(first, set.getSup(), TEST_EPS);
-        EuclideanTestUtils.assertPositiveInfinity(set.getSize());
-        Assert.assertEquals(0.0, set.getBoundarySize(), TEST_EPS);
-        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.NaN, set.getBarycenter(), TEST_EPS);
-
-        List<Interval> intervals = set.asList();
-        Assert.assertEquals(1, intervals.size());
-        assertInterval(Double.NEGATIVE_INFINITY, first, intervals.get(0), TEST_EPS);
-
-        assertLocation(Region.Location.INSIDE, set, 0.0);
-        assertLocation(Region.Location.BOUNDARY, set, 1.0);
-        assertLocation(Region.Location.OUTSIDE, set, 2.0);
-    }
-
-    @Test
-    public void testFromBoundaries_intervalSmallerThanTolerance_onlyFirstBoundaryUsed() {
-        // arrange
-        double eps = 1e-3;
-        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(eps);
-        double first = 1.0;
-        double second = 1.0 - 1e-4;
-        List<SubHyperplane<Vector1D>> boundaries = new ArrayList<>();
-        boundaries.add(subOrientedPoint(first, false, precision));
-        boundaries.add(subOrientedPoint(second, true, precision));
-
-        // act
-        IntervalsSet set = new IntervalsSet(boundaries, precision);
-
-        // assert
-        Assert.assertSame(precision, set.getPrecision());
-        Assert.assertEquals(first, set.getInf(), TEST_EPS);
-        EuclideanTestUtils.assertPositiveInfinity(set.getSup());
-        EuclideanTestUtils.assertPositiveInfinity(set.getSize());
-        Assert.assertEquals(0.0, set.getBoundarySize(), TEST_EPS);
-        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.NaN, set.getBarycenter(), TEST_EPS);
-
-        List<Interval> intervals = set.asList();
-        Assert.assertEquals(1, intervals.size());
-        assertInterval(first, Double.POSITIVE_INFINITY, intervals.get(0), TEST_EPS);
-
-        assertLocation(Region.Location.OUTSIDE, set, 0.0);
-        assertLocation(Region.Location.BOUNDARY, set, 1.0);
-        assertLocation(Region.Location.INSIDE, set, 2.0);
-    }
-
-    @Test
-    public void testProjectToBoundary() {
-        // arrange
-        List<SubHyperplane<Vector1D>> boundaries = new ArrayList<>();
-        boundaries.add(subOrientedPoint(-2.0, true));
-        boundaries.add(subOrientedPoint(-1.0, false));
-        boundaries.add(subOrientedPoint(2.0, true));
-        boundaries.add(subOrientedPoint(5.0, false));
-        boundaries.add(subOrientedPoint(9.0, true));
-        boundaries.add(subOrientedPoint(10.0, false));
-
-        IntervalsSet set = new IntervalsSet(boundaries, TEST_PRECISION);
-
-        // act/assert
-        assertProjection(Vector1D.of(-2), -1, set, Vector1D.of(-3));
-        assertProjection(Vector1D.of(-2), 0, set, Vector1D.of(-2));
-        assertProjection(Vector1D.of(-2), 0.1, set, Vector1D.of(-1.9));
-
-        assertProjection(Vector1D.of(-1), 0.5, set, Vector1D.of(-1.5));
-        assertProjection(Vector1D.of(-1), 0.1, set, Vector1D.of(-1.1));
-        assertProjection(Vector1D.of(-1), 0, set, Vector1D.of(-1));
-        assertProjection(Vector1D.of(-1), -1, set, Vector1D.of(0));
-
-        assertProjection(Vector1D.of(2), -1, set, Vector1D.of(1));
-        assertProjection(Vector1D.of(2), 0, set, Vector1D.of(2));
-        assertProjection(Vector1D.of(2), 1, set, Vector1D.of(3));
-
-        assertProjection(Vector1D.of(5), 1, set, Vector1D.of(4));
-        assertProjection(Vector1D.of(5), 0, set, Vector1D.of(5));
-
-        assertProjection(Vector1D.of(5), -1, set, Vector1D.of(6));
-        assertProjection(Vector1D.of(5), -2, set, Vector1D.of(7));
-
-        assertProjection(Vector1D.of(9), -1, set, Vector1D.of(8));
-        assertProjection(Vector1D.of(9), 0, set, Vector1D.of(9));
-        assertProjection(Vector1D.of(9), 0.1, set, Vector1D.of(9.1));
-
-        assertProjection(Vector1D.of(10), 0, set, Vector1D.of(10));
-        assertProjection(Vector1D.of(10), -1, set, Vector1D.of(11));
-    }
-
-    @Test
-    public void testInterval() {
-        IntervalsSet set = new IntervalsSet(2.3, 5.7, TEST_PRECISION);
-        Assert.assertEquals(3.4, set.getSize(), 1.0e-10);
-        Assert.assertEquals(4.0, set.getBarycenter().getX(), 1.0e-10);
-        Assert.assertEquals(Region.Location.BOUNDARY, set.checkPoint(Vector1D.of(2.3)));
-        Assert.assertEquals(Region.Location.BOUNDARY, set.checkPoint(Vector1D.of(5.7)));
-        Assert.assertEquals(Region.Location.OUTSIDE,  set.checkPoint(Vector1D.of(1.2)));
-        Assert.assertEquals(Region.Location.OUTSIDE,  set.checkPoint(Vector1D.of(8.7)));
-        Assert.assertEquals(Region.Location.INSIDE,   set.checkPoint(Vector1D.of(3.0)));
-        Assert.assertEquals(2.3, set.getInf(), 1.0e-10);
-        Assert.assertEquals(5.7, set.getSup(), 1.0e-10);
-    }
-
-    @Test
-    public void testInfinite() {
-        IntervalsSet set = new IntervalsSet(9.0, Double.POSITIVE_INFINITY, TEST_PRECISION);
-        Assert.assertEquals(Region.Location.BOUNDARY, set.checkPoint(Vector1D.of(9.0)));
-        Assert.assertEquals(Region.Location.OUTSIDE,  set.checkPoint(Vector1D.of(8.4)));
-        for (double e = 1.0; e <= 6.0; e += 1.0) {
-            Assert.assertEquals(Region.Location.INSIDE,
-                                set.checkPoint(Vector1D.of(Math.pow(10.0, e))));
-        }
-        Assert.assertTrue(Double.isInfinite(set.getSize()));
-        Assert.assertEquals(9.0, set.getInf(), 1.0e-10);
-        Assert.assertTrue(Double.isInfinite(set.getSup()));
-
-        set = (IntervalsSet) new RegionFactory<Vector1D>().getComplement(set);
-        Assert.assertEquals(9.0, set.getSup(), 1.0e-10);
-        Assert.assertTrue(Double.isInfinite(set.getInf()));
-
-    }
-
-    @Test
-    public void testBooleanOperations() {
-        // arrange
-        RegionFactory<Vector1D> factory = new RegionFactory<>();
-
-        // act
-        IntervalsSet set = (IntervalsSet)
-        factory.intersection(factory.union(factory.difference(new IntervalsSet(1.0, 6.0, TEST_PRECISION),
-                                                              new IntervalsSet(3.0, 5.0, TEST_PRECISION)),
-                                                              new IntervalsSet(9.0, Double.POSITIVE_INFINITY, TEST_PRECISION)),
-                                                              new IntervalsSet(Double.NEGATIVE_INFINITY, 11.0, TEST_PRECISION));
-
-        // arrange
-        Assert.assertEquals(1.0, set.getInf(), TEST_EPS);
-        Assert.assertEquals(11.0, set.getSup(), TEST_EPS);
-
-        Assert.assertEquals(5.0, set.getSize(), TEST_EPS);
-        Assert.assertEquals(5.9, set.getBarycenter().getX(), TEST_EPS);
-
-        assertLocation(Region.Location.OUTSIDE, set, 0.0);
-        assertLocation(Region.Location.OUTSIDE, set, 4.0);
-        assertLocation(Region.Location.OUTSIDE, set, 8.0);
-        assertLocation(Region.Location.OUTSIDE, set, 12.0);
-        assertLocation(Region.Location.INSIDE, set, 1.2);
-        assertLocation(Region.Location.INSIDE, set, 5.9);
-        assertLocation(Region.Location.INSIDE, set, 9.01);
-        assertLocation(Region.Location.BOUNDARY, set, 5.0);
-        assertLocation(Region.Location.BOUNDARY, set, 11.0);
-
-        List<Interval> list = set.asList();
-        Assert.assertEquals(3, list.size());
-        assertInterval(1.0, 3.0, list.get(0), TEST_EPS);
-        assertInterval(5.0, 6.0, list.get(1), TEST_EPS);
-        assertInterval(9.0, 11.0, list.get(2), TEST_EPS);
-    }
-
-    private void assertLocation(Region.Location location, IntervalsSet set, double pt) {
-        Assert.assertEquals(location, set.checkPoint(Vector1D.of(pt)));
-    }
-
-    private void assertInterval(double expectedInf, double expectedSup, Interval actual, double tolerance) {
-        Assert.assertEquals(expectedInf, actual.getInf(), tolerance);
-        Assert.assertEquals(expectedSup, actual.getSup(), tolerance);
-    }
-
-    private void assertProjection(Vector1D expectedProjection, double expectedOffset,
-            IntervalsSet set, Vector1D toProject) {
-        BoundaryProjection<Vector1D> proj = set.projectToBoundary(toProject);
-
-        EuclideanTestUtils.assertCoordinatesEqual(toProject, proj.getOriginal(), TEST_EPS);
-        EuclideanTestUtils.assertCoordinatesEqual(expectedProjection, proj.getProjected(), TEST_EPS);
-        Assert.assertEquals(expectedOffset, proj.getOffset(), TEST_EPS);
-    }
-
-    private SubOrientedPoint subOrientedPoint(double location, boolean direct) {
-        return subOrientedPoint(location, direct, TEST_PRECISION);
-    }
-
-    private SubOrientedPoint subOrientedPoint(double location, boolean direct, DoublePrecisionContext precision) {
-        // the remaining region isn't necessary for creating 1D boundaries so we can set it to null here
-        return new SubOrientedPoint(OrientedPoint.fromPointAndDirection(Vector1D.of(location), direct, precision), null);
-    }
-}
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/OrientedPointTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/OrientedPointTest.java
index f0d2dff..d322ea9 100644
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/OrientedPointTest.java
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/OrientedPointTest.java
@@ -16,11 +16,22 @@
  */
 package org.apache.commons.geometry.euclidean.oned;
 
+import java.util.List;
+
 import org.apache.commons.geometry.core.GeometryTestUtils;
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.exception.GeometryException;
 import org.apache.commons.geometry.core.exception.GeometryValueException;
+import org.apache.commons.geometry.core.partitioning.HyperplaneLocation;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.SubHyperplane;
+import org.apache.commons.geometry.core.partitioning.SubHyperplane.Builder;
 import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
 import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
 import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
+import org.apache.commons.geometry.euclidean.oned.OrientedPoint.SubOrientedPoint;
+import org.apache.commons.geometry.euclidean.oned.OrientedPoint.SubOrientedPointBuilder;
 import org.apache.commons.numbers.core.Precision;
 import org.junit.Assert;
 import org.junit.Test;
@@ -82,6 +93,23 @@
     }
 
     @Test
+    public void testTransform_locationAtInfinity() {
+        // arrange
+        OrientedPoint pos = OrientedPoint.createNegativeFacing(Double.POSITIVE_INFINITY, TEST_PRECISION);
+        OrientedPoint neg = OrientedPoint.createPositiveFacing(Double.NEGATIVE_INFINITY, TEST_PRECISION);
+
+        Transform<Vector1D> scaleAndTranslate = AffineTransformMatrix1D.identity().scale(10.0).translate(5.0);
+        Transform<Vector1D> negate = FunctionTransform1D.from(Vector1D::negate);
+
+        // act/assert
+        assertOrientedPoint(pos.transform(scaleAndTranslate), Double.POSITIVE_INFINITY, false, TEST_PRECISION);
+        assertOrientedPoint(neg.transform(scaleAndTranslate), Double.NEGATIVE_INFINITY, true, TEST_PRECISION);
+
+        assertOrientedPoint(pos.transform(negate), Double.NEGATIVE_INFINITY, true, TEST_PRECISION);
+        assertOrientedPoint(neg.transform(negate), Double.POSITIVE_INFINITY, false, TEST_PRECISION);
+    }
+
+    @Test
     public void testTransform_zeroScale() {
         // arrange
         AffineTransformMatrix1D zeroScale = AffineTransformMatrix1D.createScale(0.0);
@@ -95,78 +123,98 @@
     }
 
     @Test
-    public void testCopySelf() {
-        // arrange
-        OrientedPoint orig = OrientedPoint.fromPointAndDirection(Vector1D.of(2.0), true, TEST_PRECISION);
-
-        // act
-        OrientedPoint copy = orig.copySelf();
-
-        // assert
-        Assert.assertSame(orig, copy);
-        assertOrientedPoint(copy, 2.0, true, TEST_PRECISION);
-    }
-
-    @Test
-    public void testGetOffset_positiveFacing() {
+    public void testOffset_positiveFacing() {
         // arrange
         OrientedPoint pt = OrientedPoint.fromPointAndDirection(Vector1D.of(-2.0), true, TEST_PRECISION);
 
         // act/assert
-        Assert.assertEquals(-98.0, pt.getOffset(Vector1D.of(-100)), Precision.EPSILON);
-        Assert.assertEquals(-0.1, pt.getOffset(Vector1D.of(-2.1)), Precision.EPSILON);
-        Assert.assertEquals(0.0, pt.getOffset(Vector1D.of(-2)), Precision.EPSILON);
-        Assert.assertEquals(0.99, pt.getOffset(Vector1D.of(-1.01)), Precision.EPSILON);
-        Assert.assertEquals(1.0, pt.getOffset(Vector1D.of(-1.0)), Precision.EPSILON);
-        Assert.assertEquals(1.01, pt.getOffset(Vector1D.of(-0.99)), Precision.EPSILON);
-        Assert.assertEquals(2.0, pt.getOffset(Vector1D.of(0)), Precision.EPSILON);
-        Assert.assertEquals(102, pt.getOffset(Vector1D.of(100)), Precision.EPSILON);
+        Assert.assertEquals(-98.0, pt.offset(Vector1D.of(-100)), Precision.EPSILON);
+        Assert.assertEquals(-0.1, pt.offset(Vector1D.of(-2.1)), Precision.EPSILON);
+        Assert.assertEquals(0.0, pt.offset(Vector1D.of(-2)), Precision.EPSILON);
+        Assert.assertEquals(0.99, pt.offset(Vector1D.of(-1.01)), Precision.EPSILON);
+        Assert.assertEquals(1.0, pt.offset(Vector1D.of(-1.0)), Precision.EPSILON);
+        Assert.assertEquals(1.01, pt.offset(Vector1D.of(-0.99)), Precision.EPSILON);
+        Assert.assertEquals(2.0, pt.offset(Vector1D.of(0)), Precision.EPSILON);
+        Assert.assertEquals(102, pt.offset(Vector1D.of(100)), Precision.EPSILON);
     }
 
     @Test
-    public void testGetOffset_negativeFacing() {
+    public void testOffset_negativeFacing() {
         // arrange
         OrientedPoint pt = OrientedPoint.fromPointAndDirection(Vector1D.of(-2.0), false, TEST_PRECISION);
 
         // act/assert
-        Assert.assertEquals(98.0, pt.getOffset(Vector1D.of(-100)), Precision.EPSILON);
-        Assert.assertEquals(0.1, pt.getOffset(Vector1D.of(-2.1)), Precision.EPSILON);
-        Assert.assertEquals(0.0, pt.getOffset(Vector1D.of(-2)), Precision.EPSILON);
-        Assert.assertEquals(-0.99, pt.getOffset(Vector1D.of(-1.01)), Precision.EPSILON);
-        Assert.assertEquals(-1.0, pt.getOffset(Vector1D.of(-1.0)), Precision.EPSILON);
-        Assert.assertEquals(-1.01, pt.getOffset(Vector1D.of(-0.99)), Precision.EPSILON);
-        Assert.assertEquals(-2, pt.getOffset(Vector1D.of(0)), Precision.EPSILON);
-        Assert.assertEquals(-102, pt.getOffset(Vector1D.of(100)), Precision.EPSILON);
+        Assert.assertEquals(98.0, pt.offset(Vector1D.of(-100)), Precision.EPSILON);
+        Assert.assertEquals(0.1, pt.offset(Vector1D.of(-2.1)), Precision.EPSILON);
+        Assert.assertEquals(0.0, pt.offset(Vector1D.of(-2)), Precision.EPSILON);
+        Assert.assertEquals(-0.99, pt.offset(Vector1D.of(-1.01)), Precision.EPSILON);
+        Assert.assertEquals(-1.0, pt.offset(Vector1D.of(-1.0)), Precision.EPSILON);
+        Assert.assertEquals(-1.01, pt.offset(Vector1D.of(-0.99)), Precision.EPSILON);
+        Assert.assertEquals(-2, pt.offset(Vector1D.of(0)), Precision.EPSILON);
+        Assert.assertEquals(-102, pt.offset(Vector1D.of(100)), Precision.EPSILON);
     }
 
     @Test
-    public void testWholeHyperplane() {
+    public void testOffset_infinityArguments() {
+        // arrange
+        OrientedPoint pt = OrientedPoint.fromPointAndDirection(Vector1D.of(-2.0), true, TEST_PRECISION);
+
+        // act/assert
+        GeometryTestUtils.assertPositiveInfinity(pt.offset(Vector1D.of(Double.POSITIVE_INFINITY)));
+        GeometryTestUtils.assertNegativeInfinity(pt.offset(Vector1D.of(Double.NEGATIVE_INFINITY)));
+    }
+
+    @Test
+    public void testOffset_infinityLocation() {
+        // arrange
+        OrientedPoint pt = OrientedPoint.fromPointAndDirection(Vector1D.of(Double.POSITIVE_INFINITY), true, TEST_PRECISION);
+
+        // act/assert
+        Assert.assertTrue(Double.isNaN(pt.offset(Vector1D.of(Double.POSITIVE_INFINITY))));
+        GeometryTestUtils.assertNegativeInfinity(pt.offset(Vector1D.of(Double.NEGATIVE_INFINITY)));
+
+        GeometryTestUtils.assertNegativeInfinity(pt.offset(Vector1D.of(0)));
+    }
+
+    @Test
+    public void testClassify() {
+        // arrange
+        DoublePrecisionContext smallPrecision = new EpsilonDoublePrecisionContext(1e-10);
+        DoublePrecisionContext largePrecision = new EpsilonDoublePrecisionContext(1e-1);
+
+        OrientedPoint smallPosFacing = OrientedPoint.fromLocationAndDirection(1.0, true, smallPrecision);
+        OrientedPoint largeNegFacing = OrientedPoint.fromLocationAndDirection(1.0, false, largePrecision);
+
+        // act/assert
+        assertClassify(HyperplaneLocation.MINUS, smallPosFacing,
+                Double.NEGATIVE_INFINITY, -10, 0, 0.9, 0.99999, 1 - 1e-9);
+        assertClassify(HyperplaneLocation.ON, smallPosFacing,
+                1 - 1e-11, 1, 1 + 1e-11);
+        assertClassify(HyperplaneLocation.PLUS, smallPosFacing,
+                1 + 1e-9, 2, 10, Double.POSITIVE_INFINITY);
+
+        assertClassify(HyperplaneLocation.PLUS, largeNegFacing,
+                Double.NEGATIVE_INFINITY, -10, 0, 0.89);
+        assertClassify(HyperplaneLocation.ON, largeNegFacing,
+                0.91, 0.9999, 1, 1.001, 1.09);
+        assertClassify(HyperplaneLocation.MINUS, largeNegFacing,
+                1.11, 2, 10, Double.POSITIVE_INFINITY);
+    }
+
+    @Test
+    public void testSpan() {
         // arrange
         OrientedPoint pt = OrientedPoint.fromPointAndDirection(Vector1D.of(1.0), false, TEST_PRECISION);
 
         // act
-        SubOrientedPoint subPt = pt.wholeHyperplane();
+        SubOrientedPoint result = pt.span();
 
         // assert
-        Assert.assertSame(pt, subPt.getHyperplane());
-        Assert.assertNull(subPt.getRemainingRegion());
+        Assert.assertSame(pt, result.getHyperplane());
     }
 
     @Test
-    public void testWholeSpace() {
-        // arrange
-        OrientedPoint pt = OrientedPoint.fromPointAndDirection(Vector1D.of(1.0), false, TEST_PRECISION);
-
-        // act
-        IntervalsSet set = pt.wholeSpace();
-
-        // assert
-        EuclideanTestUtils.assertNegativeInfinity(set.getInf());
-        EuclideanTestUtils.assertPositiveInfinity(set.getSup());
-    }
-
-    @Test
-    public void testSameOrientationAs() {
+    public void testSimilarOrientation() {
         // arrange
         OrientedPoint negativeDir1 = OrientedPoint.fromPointAndDirection(Vector1D.of(1.0), false, TEST_PRECISION);
         OrientedPoint negativeDir2 = OrientedPoint.fromPointAndDirection(Vector1D.of(-1.0), false, TEST_PRECISION);
@@ -174,16 +222,16 @@
         OrientedPoint positiveDir2 = OrientedPoint.fromPointAndDirection(Vector1D.of(-2.0), true, TEST_PRECISION);
 
         // act/assert
-        Assert.assertTrue(negativeDir1.sameOrientationAs(negativeDir1));
-        Assert.assertTrue(negativeDir1.sameOrientationAs(negativeDir2));
-        Assert.assertTrue(negativeDir2.sameOrientationAs(negativeDir1));
+        Assert.assertTrue(negativeDir1.similarOrientation(negativeDir1));
+        Assert.assertTrue(negativeDir1.similarOrientation(negativeDir2));
+        Assert.assertTrue(negativeDir2.similarOrientation(negativeDir1));
 
-        Assert.assertTrue(positiveDir1.sameOrientationAs(positiveDir1));
-        Assert.assertTrue(positiveDir1.sameOrientationAs(positiveDir2));
-        Assert.assertTrue(positiveDir2.sameOrientationAs(positiveDir1));
+        Assert.assertTrue(positiveDir1.similarOrientation(positiveDir1));
+        Assert.assertTrue(positiveDir1.similarOrientation(positiveDir2));
+        Assert.assertTrue(positiveDir2.similarOrientation(positiveDir1));
 
-        Assert.assertFalse(negativeDir1.sameOrientationAs(positiveDir1));
-        Assert.assertFalse(positiveDir1.sameOrientationAs(negativeDir1));
+        Assert.assertFalse(negativeDir1.similarOrientation(positiveDir1));
+        Assert.assertFalse(positiveDir1.similarOrientation(negativeDir1));
     }
 
     @Test
@@ -198,6 +246,31 @@
         Assert.assertEquals(1.0, pt.project(Vector1D.of(100.0)).getX(), Precision.EPSILON);
     }
 
+
+    @Test
+    public void testEq() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-3);
+
+        OrientedPoint a = OrientedPoint.createPositiveFacing(0, precision);
+
+        OrientedPoint b = OrientedPoint.createPositiveFacing(0, TEST_PRECISION);
+        OrientedPoint c = OrientedPoint.createNegativeFacing(0, precision);
+        OrientedPoint d = OrientedPoint.createPositiveFacing(2e-3, precision);
+
+        OrientedPoint e = OrientedPoint.createPositiveFacing(1e-4, precision);
+
+        // act/assert
+        Assert.assertTrue(a.eq(a));
+
+        Assert.assertFalse(a.eq(b));
+        Assert.assertFalse(a.eq(c));
+        Assert.assertFalse(a.eq(d));
+
+        Assert.assertTrue(a.eq(e));
+        Assert.assertTrue(e.eq(a));
+    }
+
     @Test
     public void testHashCode() {
         // arrange
@@ -262,50 +335,35 @@
 
         // assert
         Assert.assertTrue(str.contains("OrientedPoint"));
-        Assert.assertTrue(str.contains("location= (2.0)"));
+        Assert.assertTrue(str.contains("point= (2.0)"));
         Assert.assertTrue(str.contains("direction= (1.0)"));
     }
 
     @Test
-    public void testFromPointAndDirection_trueBooleanArg() {
-        // act
-        OrientedPoint pt = OrientedPoint.fromPointAndDirection(Vector1D.of(2.0), true, TEST_PRECISION);
-
-        // assert
-        assertOrientedPoint(pt, 2.0, true, TEST_PRECISION);
-        Assert.assertEquals(1.0, pt.getDirection().getX(), Precision.EPSILON);
+    public void testFromLocationAndDirection() {
+        // act/assert
+        assertOrientedPoint(OrientedPoint.fromLocationAndDirection(3.0, true, TEST_PRECISION),
+                3.0, true, TEST_PRECISION);
+        assertOrientedPoint(OrientedPoint.fromLocationAndDirection(2.0, false, TEST_PRECISION),
+                2.0, false, TEST_PRECISION);
     }
 
     @Test
-    public void testFromPointAndDirection_falseBooleanArg() {
-        // act
-        OrientedPoint pt = OrientedPoint.fromPointAndDirection(Vector1D.of(2.0), false, TEST_PRECISION);
-
-        // assert
-        assertOrientedPoint(pt, 2.0, false, TEST_PRECISION);
-        Assert.assertEquals(-1.0, pt.getDirection().getX(), Precision.EPSILON);
+    public void testFromPointAndDirection_pointAndBooleanArgs() {
+        // act/assert
+        assertOrientedPoint(OrientedPoint.fromPointAndDirection(Vector1D.of(3.0), true, TEST_PRECISION),
+                3.0, true, TEST_PRECISION);
+        assertOrientedPoint(OrientedPoint.fromPointAndDirection(Vector1D.of(2.0), false, TEST_PRECISION),
+                2.0, false, TEST_PRECISION);
     }
 
     @Test
-    public void testFromPointAndDirection_positiveVectorArg() {
-        // act
-        OrientedPoint pt = OrientedPoint.fromPointAndDirection(
-                Vector1D.of(-2.0), Vector1D.of(0.1), TEST_PRECISION);
-
-        // assert
-        assertOrientedPoint(pt, -2.0, true, TEST_PRECISION);
-        Assert.assertEquals(1.0, pt.getDirection().getX(), Precision.EPSILON);
-    }
-
-    @Test
-    public void testFromPointAndDirection_negativeVectorArg() {
-        // act
-        OrientedPoint pt = OrientedPoint.fromPointAndDirection(
-                Vector1D.of(2.0), Vector1D.of(-10.1), TEST_PRECISION);
-
-        // assert
-        assertOrientedPoint(pt, 2.0, false, TEST_PRECISION);
-        Assert.assertEquals(-1.0, pt.getDirection().getX(), Precision.EPSILON);
+    public void testFromPointAndDirection_pointAndVectorArgs() {
+        // act/assert
+        assertOrientedPoint(OrientedPoint.fromPointAndDirection(Vector1D.of(-2.0), Vector1D.of(0.1), TEST_PRECISION),
+                -2.0, true, TEST_PRECISION);
+        assertOrientedPoint(OrientedPoint.fromPointAndDirection(Vector1D.of(2.0), Vector1D.of(-10.1), TEST_PRECISION),
+                2.0, false, TEST_PRECISION);
     }
 
     @Test
@@ -320,35 +378,257 @@
         GeometryTestUtils.assertThrows(
                 () -> OrientedPoint.fromPointAndDirection(Vector1D.of(2.0), Vector1D.of(-0.09), precision),
                 GeometryValueException.class, "Oriented point direction cannot be zero");
-        ;
     }
 
     @Test
     public void testCreatePositiveFacing() {
-        // act
-        OrientedPoint pt = OrientedPoint.createPositiveFacing(
-                Vector1D.of(-2.0), TEST_PRECISION);
-
-        // assert
-        assertOrientedPoint(pt, -2.0, true, TEST_PRECISION);
-        Assert.assertEquals(1.0, pt.getDirection().getX(), Precision.EPSILON);
+        // act/assert
+        assertOrientedPoint(OrientedPoint.createPositiveFacing(Vector1D.of(-2.0), TEST_PRECISION),
+                -2.0, true, TEST_PRECISION);
+        assertOrientedPoint(OrientedPoint.createPositiveFacing(-4.0, TEST_PRECISION),
+                -4.0, true, TEST_PRECISION);
     }
 
     @Test
     public void testCreateNegativeFacing() {
-        // act
-        OrientedPoint pt = OrientedPoint.createNegativeFacing(
-                Vector1D.of(2.0), TEST_PRECISION);
-
-        // assert
-        assertOrientedPoint(pt, 2.0, false, TEST_PRECISION);
-        Assert.assertEquals(-1.0, pt.getDirection().getX(), Precision.EPSILON);
+        // act/assert
+        assertOrientedPoint(OrientedPoint.createNegativeFacing(Vector1D.of(2.0), TEST_PRECISION),
+                2.0, false, TEST_PRECISION);
+        assertOrientedPoint(OrientedPoint.createNegativeFacing(4, TEST_PRECISION),
+                4.0, false, TEST_PRECISION);
     }
 
-    private static void assertOrientedPoint(OrientedPoint pt, double location,
-            boolean positiveFacing, DoublePrecisionContext precision) {
-        Assert.assertEquals(location, pt.getLocation().getX(), TEST_EPS);
+    @Test
+    public void testSubHyperplane_split() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-3);
+
+        OrientedPoint pt = OrientedPoint.createPositiveFacing(-1.5, precision);
+        SubOrientedPoint sub = pt.span();
+
+        // act/assert
+        checkSplit(sub, OrientedPoint.createPositiveFacing(1.0, precision), true, false);
+        checkSplit(sub, OrientedPoint.createPositiveFacing(-1.5 + 1e-2, precision), true, false);
+
+        checkSplit(sub, OrientedPoint.createNegativeFacing(1.0, precision), false, true);
+        checkSplit(sub, OrientedPoint.createNegativeFacing(-1.5 + 1e-2, precision), false, true);
+
+        checkSplit(sub, OrientedPoint.createNegativeFacing(-1.5, precision), false, false);
+        checkSplit(sub, OrientedPoint.createNegativeFacing(-1.5 + 1e-4, precision), false, false);
+        checkSplit(sub, OrientedPoint.createNegativeFacing(-1.5 - 1e-4, precision), false, false);
+    }
+
+    private void checkSplit(SubOrientedPoint sub, OrientedPoint splitter, boolean minus, boolean plus) {
+        Split<SubOrientedPoint> split = sub.split(splitter);
+
+        Assert.assertSame(minus ? sub : null, split.getMinus());
+        Assert.assertSame(plus ? sub : null, split.getPlus());
+    }
+
+    @Test
+    public void testSubHyperplane_simpleMethods() {
+        // arrange
+        OrientedPoint pt = OrientedPoint.createPositiveFacing(0, TEST_PRECISION);
+        SubOrientedPoint sub = pt.span();
+
+        // act/assert
+        Assert.assertSame(pt, sub.getHyperplane());
+        Assert.assertFalse(sub.isFull());
+        Assert.assertFalse(sub.isEmpty());
+        Assert.assertFalse(sub.isInfinite());
+        Assert.assertTrue(sub.isFinite());
+        Assert.assertEquals(0.0, sub.getSize(), TEST_EPS);
+
+        List<SubOrientedPoint> list = sub.toConvex();
+        Assert.assertEquals(1, list.size());
+        Assert.assertSame(sub, list.get(0));
+    }
+
+    @Test
+    public void testSubHyperplane_classify() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-1);
+        OrientedPoint pt = OrientedPoint.createPositiveFacing(1, precision);
+        SubOrientedPoint sub = pt.span();
+
+        // act/assert
+        Assert.assertEquals(RegionLocation.BOUNDARY, sub.classify(Vector1D.of(0.95)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, sub.classify(Vector1D.of(1)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, sub.classify(Vector1D.of(1.05)));
+
+        Assert.assertEquals(RegionLocation.OUTSIDE, sub.classify(Vector1D.of(1.11)));
+        Assert.assertEquals(RegionLocation.OUTSIDE, sub.classify(Vector1D.of(0.89)));
+
+        Assert.assertEquals(RegionLocation.OUTSIDE, sub.classify(Vector1D.of(-3)));
+        Assert.assertEquals(RegionLocation.OUTSIDE, sub.classify(Vector1D.of(10)));
+
+        Assert.assertEquals(RegionLocation.OUTSIDE, sub.classify(Vector1D.NEGATIVE_INFINITY));
+        Assert.assertEquals(RegionLocation.OUTSIDE, sub.classify(Vector1D.POSITIVE_INFINITY));
+    }
+
+    @Test
+    public void testSubHyperplane_contains() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-1);
+        OrientedPoint pt = OrientedPoint.createPositiveFacing(1, precision);
+        SubOrientedPoint sub = pt.span();
+
+        // act/assert
+        Assert.assertTrue(sub.contains(Vector1D.of(0.95)));
+        Assert.assertTrue(sub.contains(Vector1D.of(1)));
+        Assert.assertTrue(sub.contains(Vector1D.of(1.05)));
+
+        Assert.assertFalse(sub.contains(Vector1D.of(1.11)));
+        Assert.assertFalse(sub.contains(Vector1D.of(0.89)));
+
+        Assert.assertFalse(sub.contains(Vector1D.of(-3)));
+        Assert.assertFalse(sub.contains(Vector1D.of(10)));
+
+        Assert.assertFalse(sub.contains(Vector1D.NEGATIVE_INFINITY));
+        Assert.assertFalse(sub.contains(Vector1D.POSITIVE_INFINITY));
+    }
+
+    @Test
+    public void testSubHyperplane_closestContained() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-1);
+        OrientedPoint pt = OrientedPoint.createPositiveFacing(1, precision);
+        SubOrientedPoint sub = pt.span();
+
+        // act/assert
+        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.of(1), sub.closest(Vector1D.NEGATIVE_INFINITY), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.of(1), sub.closest(Vector1D.of(0)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.of(1), sub.closest(Vector1D.of(1)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.of(1), sub.closest(Vector1D.of(2)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.of(1), sub.closest(Vector1D.POSITIVE_INFINITY), TEST_EPS);
+    }
+
+    @Test
+    public void testSubHyperplane_transform() {
+        // arrange
+        AffineTransformMatrix1D scaleAndTranslate = AffineTransformMatrix1D
+                .createScale(0.5)
+                .translate(-10);
+
+        AffineTransformMatrix1D reflect = AffineTransformMatrix1D.createScale(-2);
+
+        SubOrientedPoint a = OrientedPoint.createPositiveFacing(Vector1D.of(2.0), TEST_PRECISION).span();
+        SubOrientedPoint b = OrientedPoint.createNegativeFacing(Vector1D.of(-3.0), TEST_PRECISION).span();
+
+        // act/assert
+        assertOrientedPoint(a.transform(scaleAndTranslate).getHyperplane(), -9.0, true, TEST_PRECISION);
+        assertOrientedPoint(b.transform(scaleAndTranslate).getHyperplane(), -11.5, false, TEST_PRECISION);
+
+        assertOrientedPoint(a.transform(reflect).getHyperplane(), -4.0, false, TEST_PRECISION);
+        assertOrientedPoint(b.transform(reflect).getHyperplane(), 6.0, true, TEST_PRECISION);
+    }
+
+    @Test
+    public void testSubHyperplane_reverse() {
+        // arrange
+        OrientedPoint pt = OrientedPoint.createPositiveFacing(2.0, TEST_PRECISION);
+        SubOrientedPoint sub = pt.span();
+
+        // act
+        SubOrientedPoint result = sub.reverse();
+
+        // assert
+        Assert.assertEquals(2.0, result.getHyperplane().getLocation(), TEST_EPS);
+        Assert.assertFalse(result.getHyperplane().isPositiveFacing());
+
+        Assert.assertEquals(sub.getHyperplane(), result.reverse().getHyperplane());
+    }
+
+    @Test
+    public void testSubHyperplane_toString() {
+        // arrange
+        OrientedPoint pt = OrientedPoint.createPositiveFacing(2, TEST_PRECISION);
+        SubOrientedPoint sub = pt.span();
+
+        // act
+        String str = sub.toString();
+
+        //assert
+        Assert.assertTrue(str.contains("SubOrientedPoint"));
+        Assert.assertTrue(str.contains("point= (2.0)"));
+        Assert.assertTrue(str.contains("direction= (1.0)"));
+    }
+
+    @Test
+    public void testBuilder() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-3);
+
+        OrientedPoint pt = OrientedPoint.createPositiveFacing(0, precision);
+        SubOrientedPoint sub = pt.span();
+
+        // act
+        Builder<Vector1D> builder = sub.builder();
+
+        builder.add(sub);
+        builder.add(OrientedPoint.createPositiveFacing(1e-4, precision).span());
+        builder.add((SubHyperplane<Vector1D>) sub);
+
+        SubHyperplane<Vector1D> result = builder.build();
+
+        // assert
+        Assert.assertSame(sub, result);
+    }
+
+    @Test
+    public void testBuilder_invalidArgs() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-3);
+
+        OrientedPoint pt = OrientedPoint.createPositiveFacing(0, precision);
+        SubOrientedPoint sub = pt.span();
+
+        Builder<Vector1D> builder = sub.builder();
+
+        // act/assert
+        GeometryTestUtils.assertThrows(
+                () -> builder.add(OrientedPoint.createPositiveFacing(2e-3, precision).span()),
+                GeometryException.class);
+        GeometryTestUtils.assertThrows(
+                () -> builder.add(OrientedPoint.createNegativeFacing(2e-3, precision).span()),
+                GeometryException.class);
+
+        GeometryTestUtils.assertThrows(
+                () -> builder.add((SubHyperplane<Vector1D>) OrientedPoint.createPositiveFacing(2e-3, precision).span()),
+                GeometryException.class);
+    }
+
+    @Test
+    public void testBuilder_toString() {
+        // arrange
+        OrientedPoint pt = OrientedPoint.createPositiveFacing(2, TEST_PRECISION);
+        SubOrientedPointBuilder builder = pt.span().builder();
+
+        // act
+        String str = builder.toString();
+
+        //assert
+        Assert.assertTrue(str.contains("SubOrientedPointBuilder"));
+        Assert.assertTrue(str.contains("base= SubOrientedPoint"));
+        Assert.assertTrue(str.contains("point= (2.0)"));
+        Assert.assertTrue(str.contains("direction= (1.0)"));
+    }
+
+    private static void assertOrientedPoint(OrientedPoint pt, double location, boolean positiveFacing,
+            DoublePrecisionContext precision) {
+        Assert.assertEquals(location, pt.getPoint().getX(), TEST_EPS);
+        Assert.assertEquals(location, pt.getLocation(), TEST_EPS);
+        Assert.assertEquals(positiveFacing ? 1.0 : -1.0, pt.getDirection().getX(), TEST_EPS);
         Assert.assertEquals(positiveFacing, pt.isPositiveFacing());
         Assert.assertSame(precision, pt.getPrecision());
     }
+
+    private static void assertClassify(HyperplaneLocation expected, OrientedPoint pt, double ... locations) {
+        for (double location : locations) {
+            String msg = "Unexpected classification for location " + location;
+
+            Assert.assertEquals(msg, expected, pt.classify(location));
+            Assert.assertEquals(msg, expected, pt.classify(Vector1D.of(location)));
+        }
+    }
 }
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/RegionBSPTree1DTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/RegionBSPTree1DTest.java
new file mode 100644
index 0000000..25804c4
--- /dev/null
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/RegionBSPTree1DTest.java
@@ -0,0 +1,1231 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.oned;
+
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.commons.geometry.core.GeometryTestUtils;
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.SplitLocation;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
+import org.apache.commons.geometry.euclidean.oned.RegionBSPTree1D.RegionNode1D;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class RegionBSPTree1DTest {
+
+    private static final double TEST_EPS = 1e-10;
+
+    private static final DoublePrecisionContext TEST_PRECISION =
+            new EpsilonDoublePrecisionContext(TEST_EPS);
+
+    @Test
+    public void testCopy() {
+        // arrange
+        RegionBSPTree1D tree = new RegionBSPTree1D(true);
+        tree.getRoot().cut(OrientedPoint.createPositiveFacing(1.0, TEST_PRECISION));
+
+        // act
+        RegionBSPTree1D copy = tree.copy();
+
+        // assert
+        Assert.assertNotSame(tree, copy);
+        Assert.assertEquals(3, copy.count());
+    }
+
+    @Test
+    public void testClassify_fullRegion() {
+        // arrange
+        RegionBSPTree1D tree = new RegionBSPTree1D(true);
+
+        // act/assert
+        checkClassify(tree, RegionLocation.INSIDE,
+                Double.NEGATIVE_INFINITY, -1, 0, 1, Double.POSITIVE_INFINITY);
+
+        checkClassify(tree, RegionLocation.OUTSIDE, Double.NaN);
+    }
+
+    @Test
+    public void testClassify_emptyRegion() {
+        // arrange
+        RegionBSPTree1D tree = new RegionBSPTree1D(false);
+
+        // act/assert
+        checkClassify(tree, RegionLocation.OUTSIDE,
+                Double.NEGATIVE_INFINITY, -1, 0, 1, Double.POSITIVE_INFINITY);
+
+        checkClassify(tree, RegionLocation.OUTSIDE, Double.NaN);
+    }
+
+    @Test
+    public void testClassify_singleClosedInterval() {
+        // arrange
+        RegionBSPTree1D tree = new RegionBSPTree1D();
+        tree.insert(Arrays.asList(
+                    OrientedPoint.createNegativeFacing(Vector1D.of(-1), TEST_PRECISION).span(),
+                    OrientedPoint.createPositiveFacing(Vector1D.of(9), TEST_PRECISION).span()
+                ));
+
+        // act/assert
+        checkClassify(tree, RegionLocation.OUTSIDE, Double.NEGATIVE_INFINITY);
+        checkClassify(tree, RegionLocation.OUTSIDE,-2.0);
+        checkClassify(tree, RegionLocation.INSIDE, 0.0);
+        checkClassify(tree, RegionLocation.BOUNDARY, 9.0 - 1e-16);
+        checkClassify(tree, RegionLocation.BOUNDARY, 9.0 + 1e-16);
+        checkClassify(tree, RegionLocation.OUTSIDE, 10.0);
+        checkClassify(tree, RegionLocation.OUTSIDE, Double.POSITIVE_INFINITY);
+    }
+
+    @Test
+    public void testContains_fullRegion() {
+        // arrange
+        RegionBSPTree1D tree = new RegionBSPTree1D(true);
+
+        // act/assert
+        checkContains(tree, true,
+                Double.NEGATIVE_INFINITY, -1, 0, 1, Double.POSITIVE_INFINITY);
+
+        checkContains(tree, false, Double.NaN);
+    }
+
+    @Test
+    public void testContains_emptyRegion() {
+        // arrange
+        RegionBSPTree1D tree = new RegionBSPTree1D(false);
+
+        // act/assert
+        checkContains(tree, false,
+                Double.NEGATIVE_INFINITY, -1, 0, 1, Double.POSITIVE_INFINITY);
+
+        checkContains(tree, false, Double.NaN);
+    }
+
+    @Test
+    public void testContains_singleClosedInterval() {
+        // arrange
+        RegionBSPTree1D tree = new RegionBSPTree1D();
+        tree.insert(Arrays.asList(
+                    OrientedPoint.createNegativeFacing(Vector1D.of(-1), TEST_PRECISION).span(),
+                    OrientedPoint.createPositiveFacing(Vector1D.of(9), TEST_PRECISION).span()
+                ));
+
+        // act/assert
+        checkContains(tree, false, Double.NEGATIVE_INFINITY);
+        checkContains(tree, false,-2.0);
+        checkContains(tree, true, 0.0);
+        checkContains(tree, true, 9.0 - 1e-16);
+        checkContains(tree, true, 9.0 + 1e-16);
+        checkContains(tree, false, 10.0);
+        checkContains(tree, false, Double.POSITIVE_INFINITY);
+    }
+
+    @Test
+    public void testGetBoundarySize_alwaysReturnsZero() {
+        // act/assert
+        Assert.assertEquals(0.0, RegionBSPTree1D.full().getBoundarySize(), TEST_EPS);
+        Assert.assertEquals(0.0, RegionBSPTree1D.empty().getBoundarySize(), TEST_EPS);
+        Assert.assertEquals(0.0, RegionBSPTree1D.from(
+                    Interval.of(1, 2, TEST_PRECISION),
+                    Interval.of(4, 5, TEST_PRECISION)
+                ).getBoundarySize(), TEST_EPS);
+    }
+
+    @Test
+    public void testProject_full() {
+        // arrange
+        RegionBSPTree1D full = RegionBSPTree1D.full();
+
+        // act/assert
+        Assert.assertNull(full.project(Vector1D.of(Double.NEGATIVE_INFINITY)));
+        Assert.assertNull(full.project(Vector1D.of(0)));
+        Assert.assertNull(full.project(Vector1D.of(Double.POSITIVE_INFINITY)));
+    }
+
+    @Test
+    public void testProject_empty() {
+        // arrange
+        RegionBSPTree1D empty = RegionBSPTree1D.empty();
+
+        // act/assert
+        Assert.assertNull(empty.project(Vector1D.of(Double.NEGATIVE_INFINITY)));
+        Assert.assertNull(empty.project(Vector1D.of(0)));
+        Assert.assertNull(empty.project(Vector1D.of(Double.POSITIVE_INFINITY)));
+    }
+
+    @Test
+    public void testProject_singlePoint() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.from(Interval.point(1, TEST_PRECISION));
+
+        // act/assert
+        checkBoundaryProjection(tree, -1, 1);
+        checkBoundaryProjection(tree, 0, 1);
+
+        checkBoundaryProjection(tree, 1, 1);
+
+        checkBoundaryProjection(tree, 2, 1);
+        checkBoundaryProjection(tree, 3, 1);
+
+        checkBoundaryProjection(tree, Double.NEGATIVE_INFINITY, 1);
+        checkBoundaryProjection(tree, Double.POSITIVE_INFINITY, 1);
+    }
+
+    @Test
+    public void testProject_noMinBoundary() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.from(Interval.of(Double.NEGATIVE_INFINITY, 1, TEST_PRECISION));
+
+        // act/assert
+        checkBoundaryProjection(tree, -1, 1);
+        checkBoundaryProjection(tree, 0, 1);
+        checkBoundaryProjection(tree, 1, 1);
+        checkBoundaryProjection(tree, 2, 1);
+        checkBoundaryProjection(tree, 3, 1);
+
+        checkBoundaryProjection(tree, Double.NEGATIVE_INFINITY, 1);
+        checkBoundaryProjection(tree, Double.POSITIVE_INFINITY, 1);
+    }
+
+    @Test
+    public void testProject_noMaxBoundary() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.from(Interval.of(1, Double.POSITIVE_INFINITY, TEST_PRECISION));
+
+        // act/assert
+        checkBoundaryProjection(tree, -1, 1);
+        checkBoundaryProjection(tree, 0, 1);
+        checkBoundaryProjection(tree, 1, 1);
+        checkBoundaryProjection(tree, 2, 1);
+        checkBoundaryProjection(tree, 3, 1);
+
+        checkBoundaryProjection(tree, Double.NEGATIVE_INFINITY, 1);
+        checkBoundaryProjection(tree, Double.POSITIVE_INFINITY, 1);
+    }
+
+    @Test
+    public void testProject_closedInterval() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.from(Interval.of(1, 3, TEST_PRECISION));
+
+        // act/assert
+        checkBoundaryProjection(tree, -1, 1);
+        checkBoundaryProjection(tree, 0, 1);
+        checkBoundaryProjection(tree, 1, 1);
+
+        checkBoundaryProjection(tree, 1.9, 1);
+        checkBoundaryProjection(tree, 2, 1);
+        checkBoundaryProjection(tree, 2.1, 3);
+
+        checkBoundaryProjection(tree, 3, 3);
+        checkBoundaryProjection(tree, 4, 3);
+        checkBoundaryProjection(tree, 5, 3);
+
+        checkBoundaryProjection(tree, Double.NEGATIVE_INFINITY, 1);
+        checkBoundaryProjection(tree, Double.POSITIVE_INFINITY, 3);
+    }
+
+    @Test
+    public void testProject_multipleIntervals() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.from(
+                    Interval.max(-1, TEST_PRECISION),
+                    Interval.point(1, TEST_PRECISION),
+                    Interval.of(2, 3, TEST_PRECISION),
+                    Interval.of(5, 6, TEST_PRECISION)
+                );
+
+        // act/assert
+        checkBoundaryProjection(tree, Double.NEGATIVE_INFINITY, -1);
+        checkBoundaryProjection(tree, -2, -1);
+        checkBoundaryProjection(tree, -1, -1);
+
+        checkBoundaryProjection(tree, -0.5, -1);
+        checkBoundaryProjection(tree, 0, -1);
+        checkBoundaryProjection(tree, 0.5, 1);
+
+        checkBoundaryProjection(tree, 0.9, 1);
+        checkBoundaryProjection(tree, 1, 1);
+        checkBoundaryProjection(tree, 1.1, 1);
+
+        checkBoundaryProjection(tree, 0.5, 1);
+
+        checkBoundaryProjection(tree, 1.9, 2);
+        checkBoundaryProjection(tree, 2, 2);
+        checkBoundaryProjection(tree, 2.1, 2);
+        checkBoundaryProjection(tree, 2.5, 2);
+        checkBoundaryProjection(tree, 2.9, 3);
+        checkBoundaryProjection(tree, 3, 3);
+        checkBoundaryProjection(tree, 3.1, 3);
+
+        checkBoundaryProjection(tree, 3.9, 3);
+        checkBoundaryProjection(tree, 4, 3);
+        checkBoundaryProjection(tree, 4.1, 5);
+
+        checkBoundaryProjection(tree, 4.9, 5);
+        checkBoundaryProjection(tree, 5, 5);
+        checkBoundaryProjection(tree, 5.1, 5);
+        checkBoundaryProjection(tree, 5.49, 5);
+        checkBoundaryProjection(tree, 5.5, 5);
+        checkBoundaryProjection(tree, 5.51, 6);
+        checkBoundaryProjection(tree, 5.9, 6);
+        checkBoundaryProjection(tree, 6, 6);
+        checkBoundaryProjection(tree, 6.1, 6);
+
+        checkBoundaryProjection(tree, 7, 6);
+
+        checkBoundaryProjection(tree, Double.POSITIVE_INFINITY, 6);
+    }
+
+    @Test
+    public void testAdd_interval() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.empty();
+
+        // act
+        tree.add(Interval.of(Double.NEGATIVE_INFINITY, -10, TEST_PRECISION));
+        tree.add(Interval.of(-1, 1, TEST_PRECISION));
+        tree.add(Interval.of(10, Double.POSITIVE_INFINITY, TEST_PRECISION));
+
+        // assert
+        checkClassify(tree, RegionLocation.INSIDE,
+                Double.NEGATIVE_INFINITY, -11, 0, 11, Double.POSITIVE_INFINITY);
+
+        checkClassify(tree, RegionLocation.BOUNDARY, -10, -1, 1, 10);
+
+        checkClassify(tree, RegionLocation.OUTSIDE, -9, -2, 2, 9);
+    }
+
+    @Test
+    public void testAdd_adjacentIntervals() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.empty();
+
+        // act
+        tree.add(Interval.of(1, 2, TEST_PRECISION));
+        tree.add(Interval.of(2, 3, TEST_PRECISION));
+
+        // assert
+        checkClassify(tree, RegionLocation.INSIDE, 1.1, 2, 2.9);
+
+        checkClassify(tree, RegionLocation.BOUNDARY, 1, 3);
+
+        checkClassify(tree, RegionLocation.OUTSIDE,
+                Double.NEGATIVE_INFINITY, 0, 0.9, 3.1, 4, Double.POSITIVE_INFINITY);
+    }
+
+    @Test
+    public void testAdd_addFullInterval() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.empty();
+
+        // act
+        tree.add(Interval.of(-1, 1, TEST_PRECISION));
+        tree.add(Interval.of(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, TEST_PRECISION));
+
+        // assert
+        Assert.assertTrue(tree.isFull());
+        Assert.assertEquals(1, tree.count());
+    }
+
+    @Test
+    public void testAdd_interval_duplicateBoundaryPoint() {
+        // arrange
+        RegionBSPTree1D tree = new RegionBSPTree1D(false);
+
+        // act
+        tree.add(Interval.of(1, 2, TEST_PRECISION));
+        tree.add(Interval.of(2, 3, TEST_PRECISION));
+        tree.add(Interval.of(1, 2, TEST_PRECISION));
+        tree.add(Interval.of(0, 1, TEST_PRECISION));
+
+        // assert
+        checkClassify(tree, RegionLocation.INSIDE, 0.1, 1, 2, 2.9);
+
+        checkClassify(tree, RegionLocation.BOUNDARY, 0, 3);
+
+        checkClassify(tree, RegionLocation.OUTSIDE, -1, -0.1, 3.1, 4);
+    }
+
+    @Test
+    public void testToIntervals_fullRegion() {
+        // arrange
+        RegionBSPTree1D tree = new RegionBSPTree1D(true);
+
+        // act
+        List<Interval> intervals = tree.toIntervals();
+
+        // assert
+        Assert.assertEquals(1, intervals.size());
+        checkInterval(intervals.get(0), Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY);
+    }
+
+    @Test
+    public void testToIntervals_emptyRegion() {
+        // arrange
+        RegionBSPTree1D tree = new RegionBSPTree1D(false);
+
+        // act
+        List<Interval> intervals = tree.toIntervals();
+
+        // assert
+        Assert.assertEquals(0, intervals.size());
+    }
+
+    @Test
+    public void testToIntervals_halfOpen_negative() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-2);
+
+        RegionBSPTree1D tree = new RegionBSPTree1D();
+        tree.getRoot().cut(OrientedPoint.fromLocationAndDirection(1.0, true, precision));
+
+        // act
+        List<Interval> intervals = tree.toIntervals();
+
+        // assert
+        Assert.assertEquals(1, intervals.size());
+        checkInterval(intervals.get(0), Double.NEGATIVE_INFINITY, 1);
+    }
+
+    @Test
+    public void testToIntervals_halfOpen_positive() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-2);
+
+        RegionBSPTree1D tree = new RegionBSPTree1D();
+        tree.getRoot().cut(OrientedPoint.fromLocationAndDirection(-1.0, false, precision));
+
+        // act
+        List<Interval> intervals = tree.toIntervals();
+
+        // assert
+        Assert.assertEquals(1, intervals.size());
+        checkInterval(intervals.get(0), -1, Double.POSITIVE_INFINITY);
+    }
+
+    @Test
+    public void testToIntervals_singleClosedInterval() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-2);
+
+        RegionBSPTree1D tree = new RegionBSPTree1D(false);
+        tree.add(Interval.of(-1, 1, precision));
+
+        // act
+        List<Interval> intervals = tree.toIntervals();
+
+        // assert
+        Assert.assertEquals(1, intervals.size());
+        checkInterval(intervals.get(0), -1, 1);
+    }
+
+    @Test
+    public void testToIntervals_singleClosedInterval_complement() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-2);
+
+        RegionBSPTree1D tree = new RegionBSPTree1D(false);
+        tree.add(Interval.of(-1, 1, precision));
+        tree.complement();
+
+        // act
+        List<Interval> intervals = tree.toIntervals();
+
+        // assert
+        Assert.assertEquals(2, intervals.size());
+        checkInterval(intervals.get(0), Double.NEGATIVE_INFINITY, -1);
+        checkInterval(intervals.get(1), 1, Double.POSITIVE_INFINITY);
+    }
+
+    @Test
+    public void testToIntervals_openAndClosedIntervals() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-2);
+
+        RegionBSPTree1D tree = new RegionBSPTree1D(false);
+        tree.add(Interval.of(Double.NEGATIVE_INFINITY, -10, precision));
+        tree.add(Interval.of(-1, 1, precision));
+        tree.add(Interval.of(10, Double.POSITIVE_INFINITY, precision));
+
+        // act
+        List<Interval> intervals = tree.toIntervals();
+
+        // assert
+        Assert.assertEquals(3, intervals.size());
+        checkInterval(intervals.get(0), Double.NEGATIVE_INFINITY, -10);
+        checkInterval(intervals.get(1), -1, 1);
+        checkInterval(intervals.get(2), 10, Double.POSITIVE_INFINITY);
+    }
+
+    @Test
+    public void testToIntervals_singlePoint() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-2);
+
+        RegionBSPTree1D tree = new RegionBSPTree1D(false);
+        tree.add(Interval.of(1, 1, precision));
+
+        // act
+        List<Interval> intervals = tree.toIntervals();
+
+        // assert
+        Assert.assertEquals(1, intervals.size());
+        checkInterval(intervals.get(0), 1, 1);
+    }
+
+    @Test
+    public void testToIntervals_singlePoint_complement() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-2);
+
+        RegionBSPTree1D tree = new RegionBSPTree1D(false);
+        tree.add(Interval.of(1, 1, precision));
+        tree.complement();
+
+        // act
+        List<Interval> intervals = tree.toIntervals();
+
+        // assert
+        Assert.assertEquals(2, intervals.size());
+        checkInterval(intervals.get(0), Double.NEGATIVE_INFINITY, 1);
+        checkInterval(intervals.get(1), 1, Double.POSITIVE_INFINITY);
+    }
+
+    @Test
+    public void testToIntervals_multiplePoints() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-2);
+
+        RegionBSPTree1D tree = new RegionBSPTree1D(false);
+        tree.add(Interval.of(1, 1, precision));
+        tree.add(Interval.of(2, 2, precision));
+
+        // act
+        List<Interval> intervals = tree.toIntervals();
+
+        // assert
+        Assert.assertEquals(2, intervals.size());
+        checkInterval(intervals.get(0), 1, 1);
+        checkInterval(intervals.get(1), 2, 2);
+    }
+
+    @Test
+    public void testToIntervals_multiplePoints_complement() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-2);
+
+        RegionBSPTree1D tree = new RegionBSPTree1D(false);
+        tree.add(Interval.of(1, 1, precision));
+        tree.add(Interval.of(2, 2, precision));
+        tree.complement();
+
+        // act
+        List<Interval> intervals = tree.toIntervals();
+
+        // assert
+        Assert.assertEquals(3, intervals.size());
+        checkInterval(intervals.get(0), Double.NEGATIVE_INFINITY, 1);
+        checkInterval(intervals.get(1), 1, 2);
+        checkInterval(intervals.get(2), 2, Double.POSITIVE_INFINITY);
+    }
+
+    @Test
+    public void testToIntervals_adjacentIntervals() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.empty();
+
+        tree.add(Interval.of(1, 2, TEST_PRECISION));
+        tree.add(Interval.of(2, 3, TEST_PRECISION));
+
+        // act
+        List<Interval> intervals = tree.toIntervals();
+
+        // assert
+        Assert.assertEquals(1, intervals.size());
+        checkInterval(intervals.get(0), 1, 3);
+    }
+
+    @Test
+    public void testToIntervals_multipleAdjacentIntervals() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.empty();
+
+        tree.add(Interval.of(1, 2, TEST_PRECISION));
+        tree.add(Interval.of(2, 3, TEST_PRECISION));
+        tree.add(Interval.of(3, 4, TEST_PRECISION));
+
+        tree.add(Interval.of(-2, -1, TEST_PRECISION));
+        tree.add(Interval.of(5, 6, TEST_PRECISION));
+
+        // act
+        List<Interval> intervals = tree.toIntervals();
+
+        // assert
+        Assert.assertEquals(3, intervals.size());
+        checkInterval(intervals.get(0), -2, -1);
+        checkInterval(intervals.get(1), 1, 4);
+        checkInterval(intervals.get(2), 5, 6);
+    }
+
+    @Test
+    public void testToIntervals() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-2);
+
+        RegionBSPTree1D tree = new RegionBSPTree1D(false);
+        tree.add(Interval.of(-1, 6, precision));
+
+        // act
+        List<Interval> intervals = tree.toIntervals();
+
+        // assert
+        Assert.assertEquals(1, intervals.size());
+        checkInterval(intervals.get(0), -1, 6);
+    }
+
+    @Test
+    public void testGetNodeRegion() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.empty();
+
+        RegionNode1D root = tree.getRoot();
+        root.cut(OrientedPoint.createPositiveFacing(1.0, TEST_PRECISION));
+
+        RegionNode1D minus = root.getMinus();
+        minus.cut(OrientedPoint.createNegativeFacing(0.0, TEST_PRECISION));
+
+        // act/assert
+        checkInterval(root.getNodeRegion(), Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY);
+
+        checkInterval(minus.getNodeRegion(), Double.NEGATIVE_INFINITY, 1.0);
+        checkInterval(root.getPlus().getNodeRegion(), 1.0, Double.POSITIVE_INFINITY);
+
+        checkInterval(minus.getPlus().getNodeRegion(), Double.NEGATIVE_INFINITY, 0.0);
+        checkInterval(minus.getMinus().getNodeRegion(), 0.0, 1.0);
+    }
+
+    @Test
+    public void testTransform_full() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.full();
+
+        Transform1D transform = AffineTransformMatrix1D.createScale(2);
+
+        // act
+        tree.transform(transform);
+
+        // assert
+        Assert.assertTrue(tree.isFull());
+    }
+
+    @Test
+    public void testTransform_noReflection() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.from(
+                    Interval.of(1, 2, TEST_PRECISION),
+                    Interval.min(3, TEST_PRECISION)
+                );
+
+        Transform1D transform = AffineTransformMatrix1D.createScale(2)
+                .translate(3);
+
+        // act
+        tree.transform(transform);
+
+        // assert
+        List<Interval> intervals = tree.toIntervals();
+
+        Assert.assertEquals(2, intervals.size());
+        checkInterval(intervals.get(0), 5, 7);
+        checkInterval(intervals.get(1), 9, Double.POSITIVE_INFINITY);
+    }
+
+    @Test
+    public void testTransform_withReflection() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.from(
+                    Interval.of(1, 2, TEST_PRECISION),
+                    Interval.min(3, TEST_PRECISION)
+                );
+
+        Transform1D transform = AffineTransformMatrix1D.createScale(-2)
+                .translate(3);
+
+        // act
+        tree.transform(transform);
+
+        // assert
+        List<Interval> intervals = tree.toIntervals();
+
+        Assert.assertEquals(2, intervals.size());
+        checkInterval(intervals.get(0), Double.NEGATIVE_INFINITY, -3);
+        checkInterval(intervals.get(1), -1, 1);
+    }
+
+    @Test
+    public void testTransform_withReflection_functionBasedTransform() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.from(
+                    Interval.of(1, 2, TEST_PRECISION),
+                    Interval.min(3, TEST_PRECISION)
+                );
+
+        Transform1D transform = FunctionTransform1D.from(Vector1D::negate);
+
+        // act
+        tree.transform(transform);
+
+        // assert
+        List<Interval> intervals = tree.toIntervals();
+
+        Assert.assertEquals(2, intervals.size());
+        checkInterval(intervals.get(0), Double.NEGATIVE_INFINITY, -3);
+        checkInterval(intervals.get(1), -2, -1);
+    }
+
+    @Test
+    public void testSplit_full() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.full();
+        OrientedPoint splitter = OrientedPoint.fromLocationAndDirection(2, true, TEST_PRECISION);
+
+        // act
+        Split<RegionBSPTree1D> split = tree.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        List<Interval> minusIntervals = split.getMinus().toIntervals();
+        Assert.assertEquals(1, minusIntervals.size());
+        checkInterval(minusIntervals.get(0), Double.NEGATIVE_INFINITY, 2);
+
+        List<Interval> plusIntervals = split.getPlus().toIntervals();
+        Assert.assertEquals(1, plusIntervals.size());
+        checkInterval(plusIntervals.get(0), 2, Double.POSITIVE_INFINITY);
+    }
+
+    @Test
+    public void testSplit_empty() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.empty();
+        OrientedPoint splitter = OrientedPoint.fromLocationAndDirection(2, true, TEST_PRECISION);
+
+        // act
+        Split<RegionBSPTree1D> split = tree.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.NEITHER, split.getLocation());
+
+        Assert.assertNull(split.getMinus());
+        Assert.assertNull(split.getPlus());
+    }
+
+    @Test
+    public void testSplit_bothSides() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.empty();
+        tree.add(Interval.max(-2, TEST_PRECISION));
+        tree.add(Interval.of(1, 4, TEST_PRECISION));
+
+        OrientedPoint splitter = OrientedPoint.fromLocationAndDirection(2, false, TEST_PRECISION);
+
+        // act
+        Split<RegionBSPTree1D> split = tree.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        List<Interval> minusIntervals = split.getMinus().toIntervals();
+        Assert.assertEquals(1, minusIntervals.size());
+        checkInterval(minusIntervals.get(0), 2, 4);
+
+        List<Interval> plusIntervals = split.getPlus().toIntervals();
+        Assert.assertEquals(2, plusIntervals.size());
+        checkInterval(plusIntervals.get(0), Double.NEGATIVE_INFINITY, -2);
+        checkInterval(plusIntervals.get(1), 1, 2);
+    }
+
+    @Test
+    public void testSplit_splitterOnBoundary_minus() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.empty();
+        tree.add(Interval.of(1, 4, TEST_PRECISION));
+
+        OrientedPoint splitter = OrientedPoint.fromLocationAndDirection(1, false, TEST_PRECISION);
+
+        // act
+        Split<RegionBSPTree1D> split = tree.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.MINUS, split.getLocation());
+
+        List<Interval> minusIntervals = split.getMinus().toIntervals();
+        Assert.assertEquals(1, minusIntervals.size());
+        checkInterval(minusIntervals.get(0), 1, 4);
+
+        Assert.assertNull(split.getPlus());
+    }
+
+    @Test
+    public void testSplit_splitterOnBoundary_plus() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.empty();
+        tree.add(Interval.of(1, 4, TEST_PRECISION));
+
+        OrientedPoint splitter = OrientedPoint.fromLocationAndDirection(4, false, TEST_PRECISION);
+
+        // act
+        Split<RegionBSPTree1D> split = tree.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.PLUS, split.getLocation());
+
+        Assert.assertNull(split.getMinus());
+
+        List<Interval> plusIntervals = split.getPlus().toIntervals();
+        Assert.assertEquals(1, plusIntervals.size());
+        checkInterval(plusIntervals.get(0), 1, 4);
+    }
+
+    @Test
+    public void testSplit_point() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.from(Interval.point(1.0, TEST_PRECISION));
+
+        OrientedPoint splitter = OrientedPoint.fromLocationAndDirection(2, false, TEST_PRECISION);
+
+        // act
+        Split<RegionBSPTree1D> split = tree.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.PLUS, split.getLocation());
+
+        Assert.assertNull(split.getMinus());
+
+        List<Interval> plusIntervals = split.getPlus().toIntervals();
+        Assert.assertEquals(1, plusIntervals.size());
+        checkInterval(plusIntervals.get(0), 1, 1);
+    }
+
+    @Test
+    public void testSplit_point_splitOnPoint_positiveFacingSplitter() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.from(Interval.point(1, TEST_PRECISION));
+
+        OrientedPoint splitter = OrientedPoint.fromLocationAndDirection(1, true, TEST_PRECISION);
+
+        // act
+        Split<RegionBSPTree1D> split = tree.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.PLUS, split.getLocation());
+
+        Assert.assertNull(split.getMinus());
+
+        List<Interval> plusIntervals = split.getPlus().toIntervals();
+        Assert.assertEquals(1, plusIntervals.size());
+        checkInterval(plusIntervals.get(0), 1, 1);
+    }
+
+    @Test
+    public void testSplit_point_splitOnPoint_negativeFacingSplitter() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.from(Interval.point(1, TEST_PRECISION));
+
+        OrientedPoint splitter = OrientedPoint.fromLocationAndDirection(1, false, TEST_PRECISION);
+
+        // act
+        Split<RegionBSPTree1D> split = tree.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.MINUS, split.getLocation());
+
+        List<Interval> minusIntervals = split.getMinus().toIntervals();
+        Assert.assertEquals(1, minusIntervals.size());
+        checkInterval(minusIntervals.get(0), 1, 1);
+
+        Assert.assertNull(split.getPlus());
+    }
+
+    @Test
+    public void testGetSize_infinite() {
+        // arrange
+        RegionBSPTree1D full = RegionBSPTree1D.full();
+
+        RegionBSPTree1D posHalfSpace = RegionBSPTree1D.empty();
+        posHalfSpace.getRoot().cut(OrientedPoint.createNegativeFacing(-2.0, TEST_PRECISION));
+
+        RegionBSPTree1D negHalfSpace = RegionBSPTree1D.empty();
+        negHalfSpace.getRoot().cut(OrientedPoint.createPositiveFacing(3.0, TEST_PRECISION));
+
+        // act/assert
+        Assert.assertEquals(Double.POSITIVE_INFINITY, full.getSize(), TEST_EPS);
+        Assert.assertEquals(Double.POSITIVE_INFINITY, posHalfSpace.getSize(), TEST_EPS);
+        Assert.assertEquals(Double.POSITIVE_INFINITY, negHalfSpace.getSize(), TEST_EPS);
+    }
+
+    @Test
+    public void testGetSize_empty() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.empty();
+
+        // act/assert
+        Assert.assertEquals(0, tree.getSize(), TEST_EPS);
+    }
+
+    @Test
+    public void testGetSize_exactPoints() {
+        // arrange
+        RegionBSPTree1D singlePoint = RegionBSPTree1D.empty();
+        singlePoint.add(Interval.of(1, 1, TEST_PRECISION));
+
+        RegionBSPTree1D multiplePoints = RegionBSPTree1D.empty();
+        multiplePoints.add(Interval.of(1, 1, TEST_PRECISION));
+        multiplePoints.add(Interval.of(-1, -1, TEST_PRECISION));
+        multiplePoints.add(Interval.of(2, 2, TEST_PRECISION));
+
+        // act/assert
+        Assert.assertEquals(0, singlePoint.getSize(), TEST_EPS);
+        Assert.assertEquals(0, multiplePoints.getSize(), TEST_EPS);
+    }
+
+    @Test
+    public void testGetSize_pointsWithinPrecision() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-1);
+
+        RegionBSPTree1D singlePoint = RegionBSPTree1D.empty();
+        singlePoint.add(Interval.of(1, 1.02, precision));
+
+        RegionBSPTree1D multiplePoints = RegionBSPTree1D.empty();
+        multiplePoints.add(Interval.of(1, 1.02, precision));
+        multiplePoints.add(Interval.of(-1.02, -1, precision));
+        multiplePoints.add(Interval.of(2, 2.02, precision));
+
+        // act/assert
+        Assert.assertEquals(0.02, singlePoint.getSize(), TEST_EPS);
+        Assert.assertEquals(0.06, multiplePoints.getSize(), TEST_EPS);
+    }
+
+    @Test
+    public void testGetSize_nonEmptyIntervals() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.empty();
+        tree.add(Interval.of(1, 2, TEST_PRECISION));
+        tree.add(Interval.of(3, 5, TEST_PRECISION));
+
+        // act/assert
+        Assert.assertEquals(3, tree.getSize(), TEST_EPS);
+    }
+
+    @Test
+    public void testGetSize_intervalWithPoints() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.empty();
+        tree.add(Interval.of(1, 2, TEST_PRECISION));
+        tree.add(Interval.of(3, 3, TEST_PRECISION));
+        tree.add(Interval.of(5, 5, TEST_PRECISION));
+
+        // act/assert
+        Assert.assertEquals(1, tree.getSize(), TEST_EPS);
+    }
+
+    @Test
+    public void testGetSize_complementedRegion() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.empty();
+        tree.add(Interval.of(Double.NEGATIVE_INFINITY, 2, TEST_PRECISION));
+        tree.add(Interval.of(4, Double.POSITIVE_INFINITY, TEST_PRECISION));
+
+        tree.complement();
+
+        // act/assert
+        Assert.assertEquals(2, tree.getSize(), TEST_EPS);
+    }
+
+    @Test
+    public void testGetBarycenter_infinite() {
+        // arrange
+        RegionBSPTree1D full = RegionBSPTree1D.full();
+
+        RegionBSPTree1D posHalfSpace = RegionBSPTree1D.empty();
+        posHalfSpace.getRoot().cut(OrientedPoint.createNegativeFacing(-2.0, TEST_PRECISION));
+
+        RegionBSPTree1D negHalfSpace = RegionBSPTree1D.empty();
+        negHalfSpace.getRoot().cut(OrientedPoint.createPositiveFacing(3.0, TEST_PRECISION));
+
+        // act/assert
+        Assert.assertNull(full.getBarycenter());
+        Assert.assertNull(posHalfSpace.getBarycenter());
+        Assert.assertNull(negHalfSpace.getBarycenter());
+    }
+
+    @Test
+    public void testGetBarycenter_empty() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.empty();
+
+        // act/assert
+        Assert.assertNull(tree.getBarycenter());
+    }
+
+    @Test
+    public void testGetBarycenter_exactPoints() {
+        // arrange
+        RegionBSPTree1D singlePoint = RegionBSPTree1D.empty();
+        singlePoint.add(Interval.of(1, 1, TEST_PRECISION));
+
+        RegionBSPTree1D multiplePoints = RegionBSPTree1D.empty();
+        multiplePoints.add(Interval.of(1, 1, TEST_PRECISION));
+        multiplePoints.add(Interval.of(-1, -1, TEST_PRECISION));
+        multiplePoints.add(Interval.of(6, 6, TEST_PRECISION));
+
+        // act/assert
+        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.of(1), singlePoint.getBarycenter(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.of(2), multiplePoints.getBarycenter(), TEST_EPS);
+    }
+
+    @Test
+    public void testGetBarycenter_pointsWithinPrecision() {
+     // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-1);
+
+        RegionBSPTree1D singlePoint = RegionBSPTree1D.empty();
+        singlePoint.add(Interval.of(1, 1.02, precision));
+
+        RegionBSPTree1D multiplePoints = RegionBSPTree1D.empty();
+        multiplePoints.add(Interval.of(1, 1.02, precision));
+        multiplePoints.add(Interval.of(-1.02, -1, precision));
+        multiplePoints.add(Interval.of(6, 6.02, precision));
+
+        // act/assert
+        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.of(1.01), singlePoint.getBarycenter(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.of(6.01 / 3), multiplePoints.getBarycenter(), TEST_EPS);
+    }
+
+    @Test
+    public void testGetBarycenter_nonEmptyIntervals() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.empty();
+        tree.add(Interval.of(1, 2, TEST_PRECISION));
+        tree.add(Interval.of(3, 5, TEST_PRECISION));
+
+        // act/assert
+        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.of(9.5 / 3), tree.getBarycenter(), TEST_EPS);
+    }
+
+    @Test
+    public void testGetBarycenter_complementedRegion() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.empty();
+        tree.add(Interval.of(Double.NEGATIVE_INFINITY, 2, TEST_PRECISION));
+        tree.add(Interval.of(4, Double.POSITIVE_INFINITY, TEST_PRECISION));
+
+        tree.complement();
+
+        // act/assert
+        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.of(3), tree.getBarycenter(), TEST_EPS);
+    }
+
+    @Test
+    public void testGetBarycenter_intervalWithPoints() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.empty();
+        tree.add(Interval.of(1, 2, TEST_PRECISION));
+        tree.add(Interval.of(3, 3, TEST_PRECISION));
+        tree.add(Interval.of(5, 5, TEST_PRECISION));
+
+        // act/assert
+        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.of(1.5), tree.getBarycenter(), TEST_EPS);
+    }
+
+    @Test
+    public void testGetMinMax_full() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.empty();
+
+        // act/assert
+        GeometryTestUtils.assertPositiveInfinity(tree.getMin());
+        GeometryTestUtils.assertNegativeInfinity(tree.getMax());
+    }
+
+    @Test
+    public void testGetMinMax_empty() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.empty();
+
+        // act/assert
+        GeometryTestUtils.assertPositiveInfinity(tree.getMin());
+        GeometryTestUtils.assertNegativeInfinity(tree.getMax());
+    }
+
+    @Test
+    public void testGetMinMax_halfSpaces() {
+        // arrange
+        RegionBSPTree1D posHalfSpace = RegionBSPTree1D.empty();
+        posHalfSpace.getRoot().cut(OrientedPoint.createNegativeFacing(-2.0, TEST_PRECISION));
+
+        RegionBSPTree1D negHalfSpace = RegionBSPTree1D.empty();
+        negHalfSpace.getRoot().cut(OrientedPoint.createPositiveFacing(3.0, TEST_PRECISION));
+
+        // act/assert
+        Assert.assertEquals(-2, posHalfSpace.getMin(), TEST_EPS);
+        GeometryTestUtils.assertPositiveInfinity(posHalfSpace.getMax());
+
+        GeometryTestUtils.assertNegativeInfinity(negHalfSpace.getMin());
+        Assert.assertEquals(3, negHalfSpace.getMax(), TEST_EPS);
+    }
+
+    @Test
+    public void testGetMinMax_multipleIntervals() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.from(Arrays.asList(
+                    Interval.of(3, 5, TEST_PRECISION),
+                    Interval.of(-4, -2, TEST_PRECISION),
+                    Interval.of(0, 0, TEST_PRECISION)
+                ));
+
+        // act/assert
+        Assert.assertEquals(-4, tree.getMin(), TEST_EPS);
+        Assert.assertEquals(5, tree.getMax(), TEST_EPS);
+    }
+
+    @Test
+    public void testGetMinMax_pointsAtMinAndMax() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.from(Arrays.asList(
+                    Interval.of(5, 5, TEST_PRECISION),
+                    Interval.of(-4, -4, TEST_PRECISION),
+                    Interval.of(0, 0, TEST_PRECISION)
+                ));
+
+        // act/assert
+        Assert.assertEquals(-4, tree.getMin(), TEST_EPS);
+        Assert.assertEquals(5, tree.getMax(), TEST_EPS);
+    }
+
+    @Test
+    public void testFull_factoryMethod() {
+        // act
+        RegionBSPTree1D tree = RegionBSPTree1D.full();
+
+        // assert
+        Assert.assertTrue(tree.isFull());
+        Assert.assertFalse(tree.isEmpty());
+        Assert.assertNotSame(tree, RegionBSPTree1D.full());
+    }
+
+    @Test
+    public void testEmpty_factoryMethod() {
+        // act
+        RegionBSPTree1D tree = RegionBSPTree1D.empty();
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertTrue(tree.isEmpty());
+        Assert.assertNotSame(tree, RegionBSPTree1D.full());
+    }
+
+    @Test
+    public void testFromIntervals_iterable() {
+        // act
+        RegionBSPTree1D tree = RegionBSPTree1D.from(Arrays.asList(
+                    Interval.of(1, 2, TEST_PRECISION),
+                    Interval.of(3, 4, TEST_PRECISION)
+                ));
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertFalse(tree.isEmpty());
+
+        checkClassify(tree, RegionLocation.INSIDE, 1.5, 3.5);
+        checkClassify(tree, RegionLocation.BOUNDARY, 1, 2, 3, 4);
+        checkClassify(tree, RegionLocation.OUTSIDE, 0, 2.5, 5);
+
+        Assert.assertEquals(2, tree.toIntervals().size());
+    }
+
+    @Test
+    public void testFromIntervals_iterable_noItervals() {
+        // act
+        RegionBSPTree1D tree = RegionBSPTree1D.from(Arrays.asList());
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertTrue(tree.isEmpty());
+
+        Assert.assertEquals(0, tree.toIntervals().size());
+    }
+
+    @Test
+    public void testFromIntervals_varargs() {
+        // act
+        RegionBSPTree1D tree = RegionBSPTree1D.from(
+                    Interval.of(1, 2, TEST_PRECISION),
+                    Interval.of(3, 4, TEST_PRECISION)
+                );
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertFalse(tree.isEmpty());
+
+        checkClassify(tree, RegionLocation.INSIDE, 1.5, 3.5);
+        checkClassify(tree, RegionLocation.BOUNDARY, 1, 2, 3, 4);
+        checkClassify(tree, RegionLocation.OUTSIDE, 0, 2.5, 5);
+
+        Assert.assertEquals(2, tree.toIntervals().size());
+    }
+
+    private static void checkClassify(RegionBSPTree1D tree, RegionLocation loc, double ... points) {
+        for (double x : points) {
+            String msg = "Unexpected location for point " + x;
+
+            Assert.assertEquals(msg, loc, tree.classify(x));
+            Assert.assertEquals(msg, loc, tree.classify(Vector1D.of(x)));
+        }
+    }
+
+    private static void checkContains(RegionBSPTree1D tree, boolean contains, double ... points) {
+        for (double x : points) {
+            String msg = "Unexpected contains status for point " + x;
+
+            Assert.assertEquals(msg, contains, tree.contains(x));
+            Assert.assertEquals(msg, contains, tree.contains(Vector1D.of(x)));
+        }
+    }
+
+    private static void checkBoundaryProjection(RegionBSPTree1D tree, double location, double projectedLocation) {
+        Vector1D pt = Vector1D.of(location);
+
+        Vector1D proj = tree.project(pt);
+
+        Assert.assertEquals(projectedLocation, proj.getX(), TEST_EPS);
+    }
+
+    private static void checkInterval(Interval interval, double min, double max) {
+        checkInterval(interval, min, max, TEST_PRECISION);
+    }
+
+    private static void checkInterval(Interval interval, double min, double max, DoublePrecisionContext precision) {
+        Assert.assertEquals(min, interval.getMin(), TEST_EPS);
+        Assert.assertEquals(max, interval.getMax(), TEST_EPS);
+    }
+}
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/SubOrientedPointTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/SubOrientedPointTest.java
deleted file mode 100644
index 5935309..0000000
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/SubOrientedPointTest.java
+++ /dev/null
@@ -1,169 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.oned;
-
-import org.apache.commons.geometry.core.partitioning.Side;
-import org.apache.commons.geometry.core.partitioning.SubHyperplane;
-import org.apache.commons.geometry.core.partitioning.SubHyperplane.SplitSubHyperplane;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
-import org.junit.Assert;
-import org.junit.Test;
-
-public class SubOrientedPointTest {
-
-    private static final double TEST_EPS = 1e-15;
-
-    private static final DoublePrecisionContext TEST_PRECISION =
-            new EpsilonDoublePrecisionContext(TEST_EPS);
-
-    @Test
-    public void testGetSize() {
-        // arrange
-        OrientedPoint hyperplane = OrientedPoint.fromPointAndDirection(Vector1D.of(1), true, TEST_PRECISION);
-        SubOrientedPoint pt = hyperplane.wholeHyperplane();
-
-        // act/assert
-        Assert.assertEquals(0.0, pt.getSize(), TEST_EPS);
-    }
-
-    @Test
-    public void testIsEmpty() {
-        // arrange
-        OrientedPoint hyperplane = OrientedPoint.fromPointAndDirection(Vector1D.of(1), true, TEST_PRECISION);
-        SubOrientedPoint pt = hyperplane.wholeHyperplane();
-
-        // act/assert
-        Assert.assertFalse(pt.isEmpty());
-    }
-
-    @Test
-    public void testBuildNew() {
-        // arrange
-        OrientedPoint originalHyperplane = OrientedPoint.fromPointAndDirection(Vector1D.of(1), true, TEST_PRECISION);
-        SubOrientedPoint pt = originalHyperplane.wholeHyperplane();
-
-        OrientedPoint hyperplane = OrientedPoint.fromPointAndDirection(Vector1D.of(2), true, TEST_PRECISION);
-        IntervalsSet intervals = new IntervalsSet(2, 3, TEST_PRECISION);
-
-        // act
-        SubHyperplane<Vector1D> result = pt.buildNew(hyperplane, intervals);
-
-        // assert
-        Assert.assertTrue(result instanceof SubOrientedPoint);
-        Assert.assertSame(hyperplane, result.getHyperplane());
-        Assert.assertSame(intervals, ((SubOrientedPoint) result).getRemainingRegion());
-    }
-
-    @Test
-    public void testSplit_resultOnMinusSide() {
-        // arrange
-        OrientedPoint hyperplane = OrientedPoint.fromPointAndDirection(Vector1D.of(1), true, TEST_PRECISION);
-        IntervalsSet interval = new IntervalsSet(TEST_PRECISION);
-        SubOrientedPoint pt = new SubOrientedPoint(hyperplane, interval);
-
-        OrientedPoint splitter = OrientedPoint.fromPointAndDirection(Vector1D.of(2), true, TEST_PRECISION);
-
-        // act
-        SplitSubHyperplane<Vector1D> split = pt.split(splitter);
-
-        // assert
-        Assert.assertEquals(Side.MINUS, split.getSide());
-
-        SubOrientedPoint minusSub = ((SubOrientedPoint) split.getMinus());
-        Assert.assertNotNull(minusSub);
-
-        OrientedPoint minusHyper = (OrientedPoint) minusSub.getHyperplane();
-        Assert.assertEquals(1, minusHyper.getLocation().getX(), TEST_EPS);
-
-        Assert.assertSame(interval, minusSub.getRemainingRegion());
-
-        Assert.assertNull(split.getPlus());
-    }
-
-    @Test
-    public void testSplit_resultOnPlusSide() {
-        // arrange
-        OrientedPoint hyperplane = OrientedPoint.fromPointAndDirection(Vector1D.of(1), true, TEST_PRECISION);
-        IntervalsSet interval = new IntervalsSet(TEST_PRECISION);
-        SubOrientedPoint pt = new SubOrientedPoint(hyperplane, interval);
-
-        OrientedPoint splitter = OrientedPoint.fromPointAndDirection(Vector1D.of(0), true, TEST_PRECISION);
-
-        // act
-        SplitSubHyperplane<Vector1D> split = pt.split(splitter);
-
-        // assert
-        Assert.assertEquals(Side.PLUS, split.getSide());
-
-        Assert.assertNull(split.getMinus());
-
-        SubOrientedPoint plusSub = ((SubOrientedPoint) split.getPlus());
-        Assert.assertNotNull(plusSub);
-
-        OrientedPoint plusHyper = (OrientedPoint) plusSub.getHyperplane();
-        Assert.assertEquals(1, plusHyper.getLocation().getX(), TEST_EPS);
-
-        Assert.assertSame(interval, plusSub.getRemainingRegion());
-    }
-
-    @Test
-    public void testSplit_equivalentHyperplanes() {
-        // arrange
-        OrientedPoint hyperplane = OrientedPoint.fromPointAndDirection(Vector1D.of(1), true, TEST_PRECISION);
-        IntervalsSet interval = new IntervalsSet(TEST_PRECISION);
-        SubOrientedPoint pt = new SubOrientedPoint(hyperplane, interval);
-
-        OrientedPoint splitter = OrientedPoint.fromPointAndDirection(Vector1D.of(1), true, TEST_PRECISION);
-
-        // act
-        SplitSubHyperplane<Vector1D> split = pt.split(splitter);
-
-        // assert
-        Assert.assertEquals(Side.HYPER, split.getSide());
-
-        Assert.assertNull(split.getMinus());
-        Assert.assertNull(split.getPlus());
-    }
-
-    @Test
-    public void testSplit_usesToleranceFromParentHyperplane() {
-        // arrange
-        DoublePrecisionContext parentPrecision = new EpsilonDoublePrecisionContext(0.1);
-        DoublePrecisionContext otherPrecision = new EpsilonDoublePrecisionContext(1e-10);
-
-        OrientedPoint hyperplane = OrientedPoint.fromPointAndDirection(Vector1D.of(1), true, parentPrecision);
-        SubOrientedPoint pt = hyperplane.wholeHyperplane();
-
-        // act/assert
-        SplitSubHyperplane<Vector1D> plusSplit = pt.split(OrientedPoint.fromPointAndDirection(Vector1D.of(0.899), true, otherPrecision));
-        Assert.assertNull(plusSplit.getMinus());
-        Assert.assertNotNull(plusSplit.getPlus());
-
-        SplitSubHyperplane<Vector1D> lowWithinTolerance = pt.split(OrientedPoint.fromPointAndDirection(Vector1D.of(0.901), true, otherPrecision));
-        Assert.assertNull(lowWithinTolerance.getMinus());
-        Assert.assertNull(lowWithinTolerance.getPlus());
-
-        SplitSubHyperplane<Vector1D> highWithinTolerance = pt.split(OrientedPoint.fromPointAndDirection(Vector1D.of(1.09), true, otherPrecision));
-        Assert.assertNull(highWithinTolerance.getMinus());
-        Assert.assertNull(highWithinTolerance.getPlus());
-
-        SplitSubHyperplane<Vector1D> minusSplit = pt.split(OrientedPoint.fromPointAndDirection(Vector1D.of(1.101), true, otherPrecision));
-        Assert.assertNotNull(minusSplit.getMinus());
-        Assert.assertNull(minusSplit.getPlus());
-    }
-}
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/Vector1DTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/Vector1DTest.java
index a0d7804..917b667 100644
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/Vector1DTest.java
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/oned/Vector1DTest.java
@@ -16,6 +16,7 @@
  */
 package org.apache.commons.geometry.euclidean.oned;
 
+import java.util.Comparator;
 import java.util.regex.Pattern;
 
 import org.apache.commons.geometry.core.Geometry;
@@ -59,6 +60,25 @@
     }
 
     @Test
+    public void testCoordinateAscendingOrderComparator() {
+        // arrange
+        Comparator<Vector1D> cmp = Vector1D.COORDINATE_ASCENDING_ORDER;
+
+        // act/assert
+        Assert.assertEquals(0, cmp.compare(Vector1D.of(1), Vector1D.of(1)));
+        Assert.assertEquals(1, cmp.compare(Vector1D.of(2), Vector1D.of(1)));
+        Assert.assertEquals(-1, cmp.compare(Vector1D.of(0), Vector1D.of(1)));
+
+        Assert.assertEquals(0, cmp.compare(Vector1D.of(0), Vector1D.of(0)));
+        Assert.assertEquals(1, cmp.compare(Vector1D.of(1e-15), Vector1D.of(0)));
+        Assert.assertEquals(-1, cmp.compare(Vector1D.of(-1e-15), Vector1D.of(0)));
+
+        Assert.assertEquals(-1, cmp.compare(Vector1D.of(1), null));
+        Assert.assertEquals(1, cmp.compare(null, Vector1D.of(1)));
+        Assert.assertEquals(0, cmp.compare(null, null));
+    }
+
+    @Test
     public void testCoordinates() {
         // act/assert
         Assert.assertEquals(-1, Vector1D.of(-1).getX(), 0.0);
@@ -95,6 +115,18 @@
     }
 
     @Test
+    public void testFinite() {
+        // act/assert
+        Assert.assertTrue(Vector1D.ZERO.isFinite());
+        Assert.assertTrue(Vector1D.of(1).isFinite());
+
+        Assert.assertFalse(Vector1D.of(Double.NEGATIVE_INFINITY).isFinite());
+        Assert.assertFalse(Vector1D.of(Double.POSITIVE_INFINITY).isFinite());
+
+        Assert.assertFalse(Vector1D.of(Double.NaN).isFinite());
+    }
+
+    @Test
     public void testZero() {
         // act
         Vector1D zero = Vector1D.of(1).getZero();
@@ -503,17 +535,17 @@
         Vector1D vec = Vector1D.of(1);
 
         // act/assert
-        Assert.assertTrue(vec.equals(vec, smallEps));
-        Assert.assertTrue(vec.equals(vec, largeEps));
+        Assert.assertTrue(vec.eq(vec, smallEps));
+        Assert.assertTrue(vec.eq(vec, largeEps));
 
-        Assert.assertTrue(vec.equals(Vector1D.of(1.0000007), smallEps));
-        Assert.assertTrue(vec.equals(Vector1D.of(1.0000007), largeEps));
+        Assert.assertTrue(vec.eq(Vector1D.of(1.0000007), smallEps));
+        Assert.assertTrue(vec.eq(Vector1D.of(1.0000007), largeEps));
 
-        Assert.assertFalse(vec.equals(Vector1D.of(1.004), smallEps));
-        Assert.assertTrue(vec.equals(Vector1D.of(1.004), largeEps));
+        Assert.assertFalse(vec.eq(Vector1D.of(1.004), smallEps));
+        Assert.assertTrue(vec.eq(Vector1D.of(1.004), largeEps));
 
-        Assert.assertFalse(vec.equals(Vector1D.of(2), smallEps));
-        Assert.assertFalse(vec.equals(Vector1D.of(-2), largeEps));
+        Assert.assertFalse(vec.eq(Vector1D.of(2), smallEps));
+        Assert.assertFalse(vec.eq(Vector1D.of(-2), largeEps));
     }
 
     @Test
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/AffineTransformMatrix3DTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/AffineTransformMatrix3DTest.java
index 754532e..10bf43a 100644
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/AffineTransformMatrix3DTest.java
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/AffineTransformMatrix3DTest.java
@@ -57,6 +57,43 @@
     }
 
     @Test
+    public void testFromColumnVectors_threeVectors() {
+        // arrange
+        Vector3D u = Vector3D.of(1, 2, 3);
+        Vector3D v = Vector3D.of(4, 5, 6);
+        Vector3D w = Vector3D.of(7, 8, 9);
+
+        // act
+        AffineTransformMatrix3D transform = AffineTransformMatrix3D.fromColumnVectors(u, v, w);
+
+        // assert
+        Assert.assertArrayEquals(new double[] {
+                1, 4, 7, 0,
+                2, 5, 8, 0,
+                3, 6, 9, 0
+        }, transform.toArray(), 0.0);
+    }
+
+    @Test
+    public void testFromColumnVectors_fourVectors() {
+        // arrange
+        Vector3D u = Vector3D.of(1, 2, 3);
+        Vector3D v = Vector3D.of(4, 5, 6);
+        Vector3D w = Vector3D.of(7, 8, 9);
+        Vector3D t = Vector3D.of(10, 11, 12);
+
+        // act
+        AffineTransformMatrix3D transform = AffineTransformMatrix3D.fromColumnVectors(u, v, w, t);
+
+        // assert
+        Assert.assertArrayEquals(new double[] {
+                1, 4, 7, 10,
+                2, 5, 8, 11,
+                3, 6, 9, 12
+        }, transform.toArray(), 0.0);
+    }
+
+    @Test
     public void testIdentity() {
         // act
         AffineTransformMatrix3D transform = AffineTransformMatrix3D.identity();
@@ -621,6 +658,90 @@
     }
 
     @Test
+    public void testDeterminant() {
+        // act/assert
+        Assert.assertEquals(1.0, AffineTransformMatrix3D.identity().determinant(), EPS);
+        Assert.assertEquals(1.0, AffineTransformMatrix3D.of(
+                1, 0, 0, 10,
+                0, 1, 0, 11,
+                0, 0, 1, 12
+            ).determinant(), EPS);
+        Assert.assertEquals(-1.0, AffineTransformMatrix3D.of(
+                -1, 0, 0, 10,
+                0, 1, 0, 11,
+                0, 0, 1, 12
+            ).determinant(), EPS);
+        Assert.assertEquals(1.0, AffineTransformMatrix3D.of(
+                -1, 0, 0, 10,
+                0, -1, 0, 11,
+                0, 0, 1, 12
+            ).determinant(), EPS);
+        Assert.assertEquals(-1.0, AffineTransformMatrix3D.of(
+                -1, 0, 0, 10,
+                0, -1, 0, 11,
+                0, 0, -1, 12
+            ).determinant(), EPS);
+        Assert.assertEquals(49.0, AffineTransformMatrix3D.of(
+                2, -3, 1, 10,
+                2, 0, -1, 11,
+                1, 4, 5, -12
+            ).determinant(), EPS);
+        Assert.assertEquals(0.0, AffineTransformMatrix3D.of(
+                1, 2, 3, 0,
+                4, 5, 6, 0,
+                7, 8, 9, 0
+            ).determinant(), EPS);
+    }
+
+    @Test
+    public void testPreservesOrientation() {
+        // act/assert
+        Assert.assertTrue(AffineTransformMatrix3D.identity().preservesOrientation());
+        Assert.assertTrue(AffineTransformMatrix3D.of(
+                1, 0, 0, 10,
+                0, 1, 0, 11,
+                0, 0, 1, 12
+            ).preservesOrientation());
+        Assert.assertTrue(AffineTransformMatrix3D.of(
+                2, -3, 1, 10,
+                2, 0, -1, 11,
+                1, 4, 5, -12
+            ).preservesOrientation());
+
+        Assert.assertFalse(AffineTransformMatrix3D.of(
+                -1, 0, 0, 10,
+                0, 1, 0, 11,
+                0, 0, 1, 12
+            ).preservesOrientation());
+
+        Assert.assertTrue(AffineTransformMatrix3D.of(
+                -1, 0, 0, 10,
+                0, -1, 0, 11,
+                0, 0, 1, 12
+            ).preservesOrientation());
+
+        Assert.assertFalse(AffineTransformMatrix3D.of(
+                -1, 0, 0, 10,
+                0, -1, 0, 11,
+                0, 0, -1, 12
+            ).preservesOrientation());
+        Assert.assertFalse(AffineTransformMatrix3D.of(
+                1, 2, 3, 0,
+                4, 5, 6, 0,
+                7, 8, 9, 0
+            ).preservesOrientation());
+    }
+
+    @Test
+    public void testToMatrix() {
+        // arrange
+        AffineTransformMatrix3D m = AffineTransformMatrix3D.createScale(3);
+
+        // act/assert
+        Assert.assertSame(m, m.toMatrix());
+    }
+
+    @Test
     public void testMultiply_combinesTransformOperations() {
         // arrange
         Vector3D translation1 = Vector3D.of(1, 2, 3);
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/ConvexSubPlaneTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/ConvexSubPlaneTest.java
new file mode 100644
index 0000000..bfad866
--- /dev/null
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/ConvexSubPlaneTest.java
@@ -0,0 +1,641 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.threed;
+
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.SplitLocation;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
+import org.apache.commons.geometry.euclidean.threed.rotation.QuaternionRotation;
+import org.apache.commons.geometry.euclidean.twod.ConvexArea;
+import org.apache.commons.geometry.euclidean.twod.Line;
+import org.apache.commons.geometry.euclidean.twod.Vector2D;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class ConvexSubPlaneTest {
+
+    private static final double TEST_EPS = 1e-10;
+
+    private static final DoublePrecisionContext TEST_PRECISION =
+            new EpsilonDoublePrecisionContext(TEST_EPS);
+
+    @Test
+    public void testFromConvexArea() {
+        // arrange
+        Plane plane = Plane.fromPointAndPlaneVectors(Vector3D.of(0, 0, 1),
+                Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
+        ConvexArea area = ConvexArea.fromVertexLoop(Arrays.asList(
+                    Vector2D.of(1, 0),
+                    Vector2D.of(3, 0),
+                    Vector2D.of(3, 1),
+                    Vector2D.of(1, 1)
+                ), TEST_PRECISION);
+
+        // act
+        ConvexSubPlane sp = ConvexSubPlane.fromConvexArea(plane, area);
+
+        // assert
+        Assert.assertFalse(sp.isFull());
+        Assert.assertFalse(sp.isEmpty());
+        Assert.assertTrue(sp.isFinite());
+
+        Assert.assertEquals(2, sp.getSize(), TEST_EPS);
+
+        Assert.assertSame(plane, sp.getPlane());
+        Assert.assertSame(plane, sp.getHyperplane());
+        Assert.assertSame(area, sp.getSubspaceRegion());
+    }
+
+    @Test
+    public void testFromVertices_infinite() {
+        // act
+        ConvexSubPlane sp = ConvexSubPlane.fromVertices(Arrays.asList(
+                    Vector3D.of(1, 0, 0),
+                    Vector3D.of(1, 1, 0),
+                    Vector3D.of(1, 1, 1)
+                ), TEST_PRECISION);
+
+        // assert
+        Assert.assertFalse(sp.isFull());
+        Assert.assertFalse(sp.isEmpty());
+        Assert.assertFalse(sp.isFinite());
+
+        EuclideanTestUtils.assertPositiveInfinity(sp.getSize());
+
+        checkPlane(sp.getPlane(), Vector3D.of(1, 0, 0), Vector3D.Unit.PLUS_Y, Vector3D.Unit.PLUS_Z);
+
+        checkPoints(sp, RegionLocation.OUTSIDE,
+                Vector3D.of(0, 1, 1), Vector3D.of(0, 1, 0), Vector3D.of(0, 1, -1),
+                Vector3D.of(0, 0, 1), Vector3D.of(0, 0, 0), Vector3D.of(0, 0, -1),
+                Vector3D.of(0, -1, 1), Vector3D.of(0, -1, 0), Vector3D.of(0, -1, -1));
+
+        checkPoints(sp, RegionLocation.OUTSIDE,
+                Vector3D.of(1, 1, -1), Vector3D.of(1, 0, -1), Vector3D.of(1, -1, -1));
+
+        checkPoints(sp, RegionLocation.BOUNDARY,
+                Vector3D.of(1, 1, 1), Vector3D.of(1, 1, 0),
+                Vector3D.of(1, 0, 0),
+                Vector3D.of(1, -1, 0));
+
+        checkPoints(sp, RegionLocation.INSIDE,
+                Vector3D.of(1, 0, 1), Vector3D.of(1, -1, 1));
+
+        checkPoints(sp, RegionLocation.OUTSIDE,
+                Vector3D.of(2, 1, 1), Vector3D.of(2, 1, 0), Vector3D.of(2, 1, -1),
+                Vector3D.of(2, 0, 1), Vector3D.of(2, 0, 0), Vector3D.of(2, 0, -1),
+                Vector3D.of(2, -1, 1), Vector3D.of(2, -1, 0), Vector3D.of(2, -1, -1));
+    }
+
+    @Test
+    public void testFromVertices_finite() {
+        // act
+        ConvexSubPlane sp = ConvexSubPlane.fromVertices(Arrays.asList(
+                    Vector3D.of(1, 0, 0),
+                    Vector3D.of(1, 1, 0),
+                    Vector3D.of(1, 1, 2),
+                    Vector3D.of(1, 0, 0)
+                ), TEST_PRECISION);
+
+        // assert
+        Assert.assertFalse(sp.isFull());
+        Assert.assertFalse(sp.isEmpty());
+        Assert.assertTrue(sp.isFinite());
+
+        Assert.assertEquals(1, sp.getSize(), TEST_EPS);
+
+        checkPlane(sp.getPlane(), Vector3D.of(1, 0, 0), Vector3D.Unit.PLUS_Y, Vector3D.Unit.PLUS_Z);
+
+        checkPoints(sp, RegionLocation.OUTSIDE,
+                Vector3D.of(0, 1, 1), Vector3D.of(0, 1, 0), Vector3D.of(0, 1, -1),
+                Vector3D.of(0, 0, 1), Vector3D.of(0, 0, 0), Vector3D.of(0, 0, -1),
+                Vector3D.of(0, -1, 1), Vector3D.of(0, -1, 0), Vector3D.of(0, -1, -1));
+
+        checkPoints(sp, RegionLocation.OUTSIDE,
+                Vector3D.of(1, 1, -1),
+                Vector3D.of(1, 0, 1), Vector3D.of(1, 0, -1),
+                Vector3D.of(1, -1, 1), Vector3D.of(1, -1, 0), Vector3D.of(1, -1, -1));
+
+        checkPoints(sp, RegionLocation.BOUNDARY,
+                Vector3D.of(1, 1, 1), Vector3D.of(1, 1, 0),
+                Vector3D.of(1, 0, 0));
+
+        checkPoints(sp, RegionLocation.INSIDE, Vector3D.of(1, 0.5, 0.5));
+
+        checkPoints(sp, RegionLocation.OUTSIDE,
+                Vector3D.of(2, 1, 1), Vector3D.of(2, 1, 0), Vector3D.of(2, 1, -1),
+                Vector3D.of(2, 0, 1), Vector3D.of(2, 0, 0), Vector3D.of(2, 0, -1),
+                Vector3D.of(2, -1, 1), Vector3D.of(2, -1, 0), Vector3D.of(2, -1, -1));
+    }
+
+    @Test
+    public void testFromVertexLoop() {
+        // act
+        ConvexSubPlane sp = ConvexSubPlane.fromVertexLoop(Arrays.asList(
+                    Vector3D.of(1, 0, 0),
+                    Vector3D.of(1, 1, 0),
+                    Vector3D.of(1, 1, 2)
+                ), TEST_PRECISION);
+
+        // assert
+        Assert.assertFalse(sp.isFull());
+        Assert.assertFalse(sp.isEmpty());
+        Assert.assertTrue(sp.isFinite());
+
+        Assert.assertEquals(1, sp.getSize(), TEST_EPS);
+
+        checkPlane(sp.getPlane(), Vector3D.of(1, 0, 0), Vector3D.Unit.PLUS_Y, Vector3D.Unit.PLUS_Z);
+
+        checkPoints(sp, RegionLocation.OUTSIDE,
+                Vector3D.of(0, 1, 1), Vector3D.of(0, 1, 0), Vector3D.of(0, 1, -1),
+                Vector3D.of(0, 0, 1), Vector3D.of(0, 0, 0), Vector3D.of(0, 0, -1),
+                Vector3D.of(0, -1, 1), Vector3D.of(0, -1, 0), Vector3D.of(0, -1, -1));
+
+        checkPoints(sp, RegionLocation.OUTSIDE,
+                Vector3D.of(1, 1, -1),
+                Vector3D.of(1, 0, 1), Vector3D.of(1, 0, -1),
+                Vector3D.of(1, -1, 1), Vector3D.of(1, -1, 0), Vector3D.of(1, -1, -1));
+
+        checkPoints(sp, RegionLocation.BOUNDARY,
+                Vector3D.of(1, 1, 1), Vector3D.of(1, 1, 0),
+                Vector3D.of(1, 0, 0));
+
+        checkPoints(sp, RegionLocation.INSIDE, Vector3D.of(1, 0.5, 0.5));
+
+        checkPoints(sp, RegionLocation.OUTSIDE,
+                Vector3D.of(2, 1, 1), Vector3D.of(2, 1, 0), Vector3D.of(2, 1, -1),
+                Vector3D.of(2, 0, 1), Vector3D.of(2, 0, 0), Vector3D.of(2, 0, -1),
+                Vector3D.of(2, -1, 1), Vector3D.of(2, -1, 0), Vector3D.of(2, -1, -1));
+    }
+
+    @Test
+    public void testToConvex() {
+        // arrange
+        ConvexSubPlane sp = ConvexSubPlane.fromVertexLoop(
+                Arrays.asList(Vector3D.Unit.PLUS_X,  Vector3D.Unit.PLUS_Y, Vector3D.Unit.PLUS_Z), TEST_PRECISION);
+
+        // act
+        List<ConvexSubPlane> convex = sp.toConvex();
+
+        // assert
+        Assert.assertEquals(1, convex.size());
+        Assert.assertSame(sp, convex.get(0));
+    }
+
+    @Test
+    public void testGetVertices_full() {
+        // arrange
+        Plane plane = Plane.fromNormal(Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+        ConvexSubPlane sp = ConvexSubPlane.fromConvexArea(plane, ConvexArea.full());
+
+        // act
+        List<Vector3D> vertices = sp.getVertices();
+
+        // assert
+        Assert.assertEquals(0, vertices.size());
+    }
+
+    @Test
+    public void testGetVertices_twoParallelLines() {
+        // arrange
+        Plane plane = Plane.fromNormal(Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+        ConvexSubPlane sp = ConvexSubPlane.fromConvexArea(plane, ConvexArea.fromBounds(
+                    Line.fromPointAndAngle(Vector2D.of(0, 1), Geometry.PI, TEST_PRECISION),
+                    Line.fromPointAndAngle(Vector2D.of(0, -1), Geometry.ZERO_PI, TEST_PRECISION)
+                ));
+
+        // act
+        List<Vector3D> vertices = sp.getVertices();
+
+        // assert
+        Assert.assertEquals(0, vertices.size());
+    }
+
+    @Test
+    public void testGetVertices_infiniteWithVertices() {
+        // arrange
+        Plane plane = Plane.fromPointAndPlaneVectors(Vector3D.of(0, 0, 1), Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
+        ConvexSubPlane sp = ConvexSubPlane.fromConvexArea(plane, ConvexArea.fromBounds(
+                    Line.fromPointAndAngle(Vector2D.of(0, 1), Geometry.PI, TEST_PRECISION),
+                    Line.fromPointAndAngle(Vector2D.of(0, -1), Geometry.ZERO_PI, TEST_PRECISION),
+                    Line.fromPointAndAngle(Vector2D.of(1, 0), Geometry.HALF_PI, TEST_PRECISION)
+                ));
+
+        // act
+        List<Vector3D> vertices = sp.getVertices();
+
+        // assert
+        Assert.assertEquals(2, vertices.size());
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, -1, 1), vertices.get(0), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 1, 1), vertices.get(1), TEST_EPS);
+    }
+
+    @Test
+    public void testGetVertices_finite() {
+        // arrange
+        Plane plane = Plane.fromPointAndPlaneVectors(Vector3D.of(0, 0, 1), Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
+        ConvexSubPlane sp = ConvexSubPlane.fromConvexArea(plane, ConvexArea.fromVertexLoop(Arrays.asList(
+                    Vector2D.ZERO,
+                    Vector2D.Unit.PLUS_X,
+                    Vector2D.Unit.PLUS_Y
+                ), TEST_PRECISION));
+
+        // act
+        List<Vector3D> vertices = sp.getVertices();
+
+        // assert
+        Assert.assertEquals(4, vertices.size());
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 0, 1), vertices.get(0), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 0, 1), vertices.get(1), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 1, 1), vertices.get(2), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 0, 1), vertices.get(3), TEST_EPS);
+    }
+
+    @Test
+    public void testReverse() {
+        // arrange
+        Vector3D p1 = Vector3D.of(1, 0, 1);
+        Vector3D p2 = Vector3D.of(2, 0, 1);
+        Vector3D p3 = Vector3D.of(1, 1, 1);
+
+        ConvexSubPlane sp = ConvexSubPlane.fromVertexLoop(Arrays.asList(p1, p2, p3), TEST_PRECISION);
+
+        // act
+        ConvexSubPlane reversed = sp.reverse();
+
+        // assert
+        Assert.assertEquals(sp.getPlane().reverse(), reversed.getPlane());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.Unit.MINUS_Z, reversed.getPlane().getNormal(), TEST_EPS);
+
+        Assert.assertEquals(0.5, reversed.getSize(), TEST_EPS);
+
+        checkVertices(reversed, p1, p3, p2, p1);
+
+        checkPoints(reversed, RegionLocation.INSIDE, Vector3D.of(1.25, 0.25, 1));
+
+        checkPoints(reversed, RegionLocation.BOUNDARY, p1, p2, p3);
+    }
+
+    @Test
+    public void testTransform_full() {
+        // arrange
+        Plane plane = Plane.fromPointAndPlaneVectors(Vector3D.Unit.PLUS_Z, Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
+        ConvexSubPlane sp = ConvexSubPlane.fromConvexArea(plane, ConvexArea.full());
+
+        AffineTransformMatrix3D transform = AffineTransformMatrix3D.identity()
+                .rotate(QuaternionRotation.fromAxisAngle(Vector3D.Unit.PLUS_X, Geometry.HALF_PI))
+                .translate(Vector3D.Unit.PLUS_Y);
+
+        // act
+        ConvexSubPlane transformed = sp.transform(transform);
+
+        // assert
+        Assert.assertTrue(transformed.isFull());
+        Assert.assertFalse(transformed.isEmpty());
+
+        checkPlane(transformed.getPlane(), Vector3D.ZERO, Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Z);
+    }
+
+    @Test
+    public void testTransform_halfSpace() {
+        // arrange
+        Plane plane = Plane.fromPointAndPlaneVectors(Vector3D.Unit.PLUS_Z, Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
+        ConvexSubPlane sp = ConvexSubPlane.fromConvexArea(plane,
+                ConvexArea.fromBounds(Line.fromPoints(Vector2D.of(1, 0), Vector2D.of(1, 1), TEST_PRECISION)));
+
+        AffineTransformMatrix3D transform = AffineTransformMatrix3D.createRotation(Vector3D.Unit.PLUS_Z,
+                QuaternionRotation.fromAxisAngle(Vector3D.Unit.PLUS_Y, Geometry.HALF_PI));
+
+        // act
+        ConvexSubPlane transformed = sp.transform(transform);
+
+        // assert
+        Assert.assertFalse(transformed.isFull());
+        Assert.assertFalse(transformed.isEmpty());
+
+        checkPlane(transformed.getPlane(), Vector3D.ZERO, Vector3D.Unit.MINUS_Z, Vector3D.Unit.PLUS_Y);
+    }
+
+    @Test
+    public void testTransform_finite() {
+        // arrange
+        ConvexSubPlane sp = ConvexSubPlane.fromVertexLoop(
+                Arrays.asList(Vector3D.of(1, 0, 0), Vector3D.of(0, 1, 0), Vector3D.of(0, 0, 1)), TEST_PRECISION);
+
+        Transform<Vector3D> transform = AffineTransformMatrix3D.createScale(2)
+                .rotate(QuaternionRotation.fromAxisAngle(Vector3D.Unit.PLUS_Y, Geometry.HALF_PI));
+
+        // act
+        ConvexSubPlane transformed = sp.transform(transform);
+
+        // assert
+        Vector3D midpt = Vector3D.of(2, 2, -2).multiply(1 / 3.0);
+        Vector3D normal = midpt.normalize();
+        Vector3D u = Vector3D.of(0, 2, 2).normalize();
+
+        checkPlane(transformed.getPlane(), midpt, u, normal.cross(u));
+
+        checkVertices(transformed, Vector3D.of(0, 0, -2), Vector3D.of(0, 2, 0),
+                Vector3D.of(2, 0, 0), Vector3D.of(0, 0, -2));
+
+        checkPoints(transformed, RegionLocation.INSIDE, midpt);
+    }
+
+    @Test
+    public void testTransform_reflection() {
+        // arrange
+        ConvexSubPlane sp = ConvexSubPlane.fromVertexLoop(
+                Arrays.asList(Vector3D.of(1, 0, 0), Vector3D.of(0, 1, 0), Vector3D.of(0, 0, 1)), TEST_PRECISION);
+
+        Transform<Vector3D> transform = AffineTransformMatrix3D.createScale(-1, 1, 1);
+
+        // act
+        ConvexSubPlane transformed = sp.transform(transform);
+
+        // assert
+        Vector3D midpt = Vector3D.of(-1, 1, 1).multiply(1 / 3.0);
+        Vector3D normal = midpt.negate().normalize();
+        Vector3D u = Vector3D.of(1, 1, 0).normalize();
+
+        checkPlane(transformed.getPlane(), midpt, u, normal.cross(u));
+
+        checkVertices(transformed, Vector3D.of(-1, 0, 0), Vector3D.of(0, 1, 0),
+                Vector3D.of(0, 0, 1), Vector3D.of(-1, 0, 0));
+
+        checkPoints(transformed, RegionLocation.INSIDE, Vector3D.of(-1, 1, 1).multiply(1 / 3.0));
+    }
+
+    @Test
+    public void testSplit_full() {
+        // arrange
+        Plane plane = Plane.fromPointAndNormal(Vector3D.ZERO, Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+        ConvexSubPlane sp = ConvexSubPlane.fromConvexArea(plane, ConvexArea.full());
+
+        Plane splitter = Plane.fromPointAndNormal(Vector3D.ZERO, Vector3D.Unit.PLUS_X, TEST_PRECISION);
+
+        // act
+        Split<ConvexSubPlane> split = sp.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        ConvexSubPlane minus = split.getMinus();
+        Assert.assertEquals(1, minus.getSubspaceRegion().getBoundaries().size());
+        checkPoints(minus, RegionLocation.BOUNDARY, Vector3D.ZERO, Vector3D.Unit.PLUS_Y, Vector3D.Unit.MINUS_Y);
+        checkPoints(minus, RegionLocation.INSIDE, Vector3D.Unit.MINUS_X);
+        checkPoints(minus, RegionLocation.OUTSIDE, Vector3D.Unit.PLUS_X);
+
+        ConvexSubPlane plus = split.getPlus();
+        Assert.assertEquals(1, plus.getSubspaceRegion().getBoundaries().size());
+        checkPoints(plus, RegionLocation.BOUNDARY, Vector3D.ZERO, Vector3D.Unit.PLUS_Y, Vector3D.Unit.MINUS_Y);
+        checkPoints(plus, RegionLocation.INSIDE, Vector3D.Unit.PLUS_X);
+        checkPoints(plus, RegionLocation.OUTSIDE, Vector3D.Unit.MINUS_X);
+    }
+
+    @Test
+    public void testSplit_both() {
+        // arrange
+        ConvexSubPlane sp = ConvexSubPlane.fromVertexLoop(Arrays.asList(
+                    Vector3D.of(1, 1, 1), Vector3D.of(1, 1, -3), Vector3D.of(0, 2, 0)
+                ), TEST_PRECISION);
+
+        Plane splitter = Plane.fromPointAndNormal(Vector3D.ZERO, Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+
+        // act
+        Split<ConvexSubPlane> split = sp.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        ConvexSubPlane minus = split.getMinus();
+        checkVertices(minus, Vector3D.of(1, 1, 0), Vector3D.of(1, 1, -3), Vector3D.of(0, 2, 0), Vector3D.of(1, 1, 0));
+
+        ConvexSubPlane plus = split.getPlus();
+        checkVertices(plus, Vector3D.of(1, 1, 1), Vector3D.of(1, 1, 0), Vector3D.of(0, 2, 0), Vector3D.of(1, 1, 1));
+    }
+
+    @Test
+    public void testSplit_plusOnly() {
+        // arrange
+        ConvexSubPlane sp = ConvexSubPlane.fromVertexLoop(Arrays.asList(
+                    Vector3D.of(1, 1, 1), Vector3D.of(1, 1, -3), Vector3D.of(0, 2, 0)
+                ), TEST_PRECISION);
+
+        Plane splitter = Plane.fromPointAndNormal(Vector3D.of(0, 0, -3.1), Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+
+        // act
+        Split<ConvexSubPlane> split = sp.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.PLUS, split.getLocation());
+
+        Assert.assertNull(split.getMinus());
+
+        ConvexSubPlane plus = split.getPlus();
+        checkVertices(plus, Vector3D.of(1, 1, 1), Vector3D.of(1, 1, -3), Vector3D.of(0, 2, 0), Vector3D.of(1, 1, 1));
+    }
+
+    @Test
+    public void testSplit_minusOnly() {
+        // arrange
+        ConvexSubPlane sp = ConvexSubPlane.fromVertexLoop(Arrays.asList(
+                    Vector3D.of(1, 1, 1), Vector3D.of(1, 1, -3), Vector3D.of(0, 2, 0)
+                ), TEST_PRECISION);
+
+        Plane splitter = Plane.fromPointAndNormal(Vector3D.of(0, 0, 1.1), Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+
+        // act
+        Split<ConvexSubPlane> split = sp.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.MINUS, split.getLocation());
+
+        ConvexSubPlane minus = split.getMinus();
+        checkVertices(minus, Vector3D.of(1, 1, 1), Vector3D.of(1, 1, -3), Vector3D.of(0, 2, 0), Vector3D.of(1, 1, 1));
+
+        Assert.assertNull(split.getPlus());
+    }
+
+    @Test
+    public void testSplit_parallelSplitter_on() {
+        // arrange
+        ConvexSubPlane sp = ConvexSubPlane.fromVertexLoop(Arrays.asList(
+                    Vector3D.of(1, 1, 1), Vector3D.of(1, 1, -3), Vector3D.of(0, 2, 0)
+                ), TEST_PRECISION);
+
+        Plane splitter = sp.getPlane();
+
+        // act
+        Split<ConvexSubPlane> split = sp.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.NEITHER, split.getLocation());
+
+        Assert.assertNull(split.getMinus());
+        Assert.assertNull(split.getPlus());
+    }
+
+    @Test
+    public void testSplit_parallelSplitter_minus() {
+        // arrange
+        ConvexSubPlane sp = ConvexSubPlane.fromVertexLoop(Arrays.asList(
+                    Vector3D.of(1, 1, 1), Vector3D.of(1, 1, -3), Vector3D.of(0, 2, 0)
+                ), TEST_PRECISION);
+
+        Plane plane = sp.getPlane();
+        Plane splitter = plane.translate(plane.getNormal());
+
+        // act
+        Split<ConvexSubPlane> split = sp.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.MINUS, split.getLocation());
+
+        Assert.assertSame(sp, split.getMinus());
+        Assert.assertNull(split.getPlus());
+    }
+
+    @Test
+    public void testSplit_parallelSplitter_plus() {
+        // arrange
+        ConvexSubPlane sp = ConvexSubPlane.fromVertexLoop(Arrays.asList(
+                    Vector3D.of(1, 1, 1), Vector3D.of(1, 1, -3), Vector3D.of(0, 2, 0)
+                ), TEST_PRECISION);
+
+        Plane plane = sp.getPlane();
+        Plane splitter = plane.translate(plane.getNormal().negate());
+
+        // act
+        Split<ConvexSubPlane> split = sp.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.PLUS, split.getLocation());
+
+        Assert.assertNull(split.getMinus());
+        Assert.assertSame(sp, split.getPlus());
+    }
+
+    @Test
+    public void testSplit_antiParallelSplitter_on() {
+        // arrange
+        ConvexSubPlane sp = ConvexSubPlane.fromVertexLoop(Arrays.asList(
+                    Vector3D.of(1, 1, 1), Vector3D.of(1, 1, -3), Vector3D.of(0, 2, 0)
+                ), TEST_PRECISION);
+
+        Plane splitter = sp.getPlane().reverse();
+
+        // act
+        Split<ConvexSubPlane> split = sp.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.NEITHER, split.getLocation());
+
+        Assert.assertNull(split.getMinus());
+        Assert.assertNull(split.getPlus());
+    }
+
+    @Test
+    public void testSplit_antiParallelSplitter_minus() {
+        // arrange
+        ConvexSubPlane sp = ConvexSubPlane.fromVertexLoop(Arrays.asList(
+                    Vector3D.of(1, 1, 1), Vector3D.of(1, 1, -3), Vector3D.of(0, 2, 0)
+                ), TEST_PRECISION);
+
+        Plane plane = sp.getPlane().reverse();
+        Plane splitter = plane.translate(plane.getNormal());
+
+        // act
+        Split<ConvexSubPlane> split = sp.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.MINUS, split.getLocation());
+
+        Assert.assertSame(sp, split.getMinus());
+        Assert.assertNull(split.getPlus());
+    }
+
+    @Test
+    public void testSplit_antiParallelSplitter_plus() {
+        // arrange
+        ConvexSubPlane sp = ConvexSubPlane.fromVertexLoop(Arrays.asList(
+                    Vector3D.of(1, 1, 1), Vector3D.of(1, 1, -3), Vector3D.of(0, 2, 0)
+                ), TEST_PRECISION);
+
+        Plane plane = sp.getPlane().reverse();
+        Plane splitter = plane.translate(plane.getNormal().negate());
+
+        // act
+        Split<ConvexSubPlane> split = sp.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.PLUS, split.getLocation());
+
+        Assert.assertNull(split.getMinus());
+        Assert.assertSame(sp, split.getPlus());
+    }
+
+    private static void checkPlane(Plane plane, Vector3D origin, Vector3D u, Vector3D v) {
+        u = u.normalize();
+        v = v.normalize();
+        Vector3D w = u.cross(v);
+
+        EuclideanTestUtils.assertCoordinatesEqual(origin, plane.getOrigin(), TEST_EPS);
+        Assert.assertTrue(plane.contains(origin));
+
+        EuclideanTestUtils.assertCoordinatesEqual(u, plane.getU(), TEST_EPS);
+        Assert.assertEquals(1.0, plane.getU().norm(), TEST_EPS);
+
+        EuclideanTestUtils.assertCoordinatesEqual(v, plane.getV(), TEST_EPS);
+        Assert.assertEquals(1.0, plane.getV().norm(), TEST_EPS);
+
+        EuclideanTestUtils.assertCoordinatesEqual(w, plane.getW(), TEST_EPS);
+        Assert.assertEquals(1.0, plane.getW().norm(), TEST_EPS);
+
+        EuclideanTestUtils.assertCoordinatesEqual(w, plane.getNormal(), TEST_EPS);
+        Assert.assertEquals(1.0, plane.getNormal().norm(), TEST_EPS);
+
+        double offset = plane.getOriginOffset();
+        Assert.assertEquals(Vector3D.ZERO.distance(plane.getOrigin()), Math.abs(offset), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(origin, plane.getNormal().multiply(-offset), TEST_EPS);
+    }
+
+    private static void checkPoints(ConvexSubPlane sp, RegionLocation loc, Vector3D ... pts) {
+        for (Vector3D pt : pts) {
+            Assert.assertEquals("Unexpected subplane location for point " + pt, loc, sp.classify(pt));
+        }
+    }
+
+    private static void checkVertices(ConvexSubPlane sp, Vector3D ... pts) {
+        List<Vector3D> actual = sp.getPlane().toSpace(
+                sp.getSubspaceRegion().getBoundaryPaths().get(0).getVertices());
+
+        Assert.assertEquals(pts.length, actual.size());
+
+        for (int i=0; i<pts.length; ++i) {
+            EuclideanTestUtils.assertCoordinatesEqual(pts[i], actual.get(i), TEST_EPS);
+        }
+    }
+}
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/ConvexVolumeTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/ConvexVolumeTest.java
new file mode 100644
index 0000000..ed062a2
--- /dev/null
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/ConvexVolumeTest.java
@@ -0,0 +1,228 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.threed;
+
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.commons.geometry.core.GeometryTestUtils;
+import org.apache.commons.geometry.core.Region;
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.SplitLocation;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
+import org.apache.commons.geometry.euclidean.twod.ConvexArea;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class ConvexVolumeTest {
+
+    private static final double TEST_EPS = 1e-10;
+
+    private static final DoublePrecisionContext TEST_PRECISION =
+            new EpsilonDoublePrecisionContext(TEST_EPS);
+
+    @Test
+    public void testFull() {
+        // act
+        ConvexVolume vol = ConvexVolume.full();
+
+        // assert
+        Assert.assertTrue(vol.isFull());
+        Assert.assertFalse(vol.isEmpty());
+
+        GeometryTestUtils.assertPositiveInfinity(vol.getSize());
+        Assert.assertNull(vol.getBarycenter());
+
+        Assert.assertEquals(0, vol.getBoundaries().size());
+        Assert.assertEquals(0, vol.getBoundarySize(), TEST_EPS);
+    }
+
+    @Test
+    public void testTOTree() {
+        // arrange
+        ConvexVolume volume = ConvexVolume.fromBounds(
+                    Plane.fromPointAndNormal(Vector3D.ZERO, Vector3D.Unit.MINUS_X, TEST_PRECISION),
+                    Plane.fromPointAndNormal(Vector3D.ZERO, Vector3D.Unit.MINUS_Y, TEST_PRECISION),
+                    Plane.fromPointAndNormal(Vector3D.ZERO, Vector3D.Unit.MINUS_Z, TEST_PRECISION),
+
+                    Plane.fromPointAndNormal(Vector3D.of(1, 1, 1), Vector3D.Unit.PLUS_X, TEST_PRECISION),
+                    Plane.fromPointAndNormal(Vector3D.of(1, 1, 1), Vector3D.Unit.PLUS_Y, TEST_PRECISION),
+                    Plane.fromPointAndNormal(Vector3D.of(1, 1, 1), Vector3D.Unit.PLUS_Z, TEST_PRECISION)
+                );
+
+        // act
+        RegionBSPTree3D tree = volume.toTree();
+
+        // assert
+        Assert.assertEquals(1, tree.getSize(), TEST_EPS);
+        Assert.assertEquals(6, tree.getBoundarySize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0.5, 0.5, 0.5), tree.getBarycenter(), TEST_EPS);
+
+        checkClassify(tree, RegionLocation.OUTSIDE,
+                Vector3D.of(-1, 0.5, 0.5), Vector3D.of(2, 0.5, 0.5),
+                Vector3D.of(0.5, -1, 0.5), Vector3D.of(0.5, 2, 0.5),
+                Vector3D.of(0.5, 0.5, -1), Vector3D.of(0.5, 0.5, 2));
+        checkClassify(tree, RegionLocation.BOUNDARY, Vector3D.ZERO);
+        checkClassify(tree, RegionLocation.INSIDE, Vector3D.of(0.5, 0.5, 0.5));
+    }
+
+    @Test
+    public void testFromBounds_noPlanes() {
+        // act
+        ConvexVolume vol = ConvexVolume.fromBounds();
+
+        // assert
+        Assert.assertSame(ConvexVolume.full(), vol);
+    }
+
+    @Test
+    public void testFromBounds_halfspace() {
+        // act
+        ConvexVolume vol = ConvexVolume.fromBounds(Plane.fromNormal(Vector3D.Unit.PLUS_Z, TEST_PRECISION));
+
+        // assert
+        Assert.assertFalse(vol.isFull());
+        Assert.assertFalse(vol.isEmpty());
+
+        GeometryTestUtils.assertPositiveInfinity(vol.getSize());
+        Assert.assertNull(vol.getBarycenter());
+
+        Assert.assertEquals(1, vol.getBoundaries().size());
+        GeometryTestUtils.assertPositiveInfinity(vol.getBoundarySize());
+
+        checkClassify(vol, RegionLocation.OUTSIDE, Vector3D.of(0, 0, 1));
+        checkClassify(vol, RegionLocation.BOUNDARY, Vector3D.of(0, 0, 0));
+        checkClassify(vol, RegionLocation.INSIDE, Vector3D.of(0, 0, -1));
+    }
+
+    @Test
+    public void testFromBounds_cube() {
+        // act
+        ConvexVolume vol = rect(Vector3D.of(1, 1, 1), 0.5, 1, 2);
+
+        // assert
+        Assert.assertFalse(vol.isFull());
+        Assert.assertFalse(vol.isEmpty());
+
+        Assert.assertEquals(8, vol.getSize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 1, 1), vol.getBarycenter(), TEST_EPS);
+
+        Assert.assertEquals(6, vol.getBoundaries().size());
+        Assert.assertEquals(28, vol.getBoundarySize(), TEST_EPS);
+
+        checkClassify(vol, RegionLocation.INSIDE, Vector3D.of(1, 1, 1));
+
+        checkClassify(vol, RegionLocation.BOUNDARY,
+                Vector3D.of(0.5, 0, -1), Vector3D.of(1.5, 2, 3));
+
+        checkClassify(vol, RegionLocation.OUTSIDE,
+                Vector3D.of(0, 1, 1), Vector3D.of(2, 1, 1),
+                Vector3D.of(1, -1, 1), Vector3D.of(1, 3, 1),
+                Vector3D.of(1, 1, -2), Vector3D.of(1, 1, 4));
+    }
+
+    @Test
+    public void testTrim() {
+        // arrange
+        ConvexVolume vol = rect(Vector3D.ZERO, 0.5, 0.5, 0.5);
+
+        ConvexSubPlane subplane = ConvexSubPlane.fromConvexArea(
+                Plane.fromNormal(Vector3D.Unit.PLUS_X, TEST_PRECISION), ConvexArea.full());
+
+        // act
+        ConvexSubPlane trimmed = vol.trim(subplane);
+
+        // assert
+        Assert.assertEquals(1, trimmed.getSize(), TEST_EPS);
+
+        List<Vector3D> vertices = trimmed.getPlane().toSpace(
+                trimmed.getSubspaceRegion().getBoundaryPaths().get(0).getVertices());
+
+        Assert.assertEquals(5, vertices.size());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 0.5, -0.5), vertices.get(0), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 0.5, 0.5), vertices.get(1), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, -0.5, 0.5), vertices.get(2), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, -0.5, -0.5), vertices.get(3), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 0.5, -0.5), vertices.get(4), TEST_EPS);
+    }
+
+    @Test
+    public void testSplit() {
+        // arrange
+        ConvexVolume vol = rect(Vector3D.ZERO, 0.5, 0.5, 0.5);
+
+        Plane splitter = Plane.fromNormal(Vector3D.Unit.PLUS_X, TEST_PRECISION);
+
+        // act
+        Split<ConvexVolume> split = vol.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        ConvexVolume minus = split.getMinus();
+        Assert.assertEquals(0.5, minus.getSize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(-0.25, 0, 0), minus.getBarycenter(), TEST_EPS);
+
+        ConvexVolume plus = split.getPlus();
+        Assert.assertEquals(0.5, plus.getSize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0.25, 0, 0), plus.getBarycenter(), TEST_EPS);
+    }
+
+    @Test
+    public void testTransform() {
+        // arrange
+        ConvexVolume vol = rect(Vector3D.ZERO, 0.5, 0.5, 0.5);
+
+        Transform<Vector3D> transform = AffineTransformMatrix3D.identity()
+                .translate(Vector3D.of(1, 2, 3))
+                .scale(Vector3D.of(2, 1, 1));
+
+        // act
+        ConvexVolume transformed = vol.transform(transform);
+
+        // assert
+        Assert.assertEquals(2, transformed.getSize(), TEST_EPS);
+        Assert.assertEquals(10, transformed.getBoundarySize(), TEST_EPS);
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(2, 2, 3), transformed.getBarycenter(), TEST_EPS);
+    }
+
+    private static ConvexVolume rect(Vector3D center, double xDelta, double yDelta, double zDelta) {
+        List<Plane> planes = Arrays.asList(
+                    Plane.fromPointAndNormal(center.add(Vector3D.of(xDelta, 0, 0)), Vector3D.Unit.PLUS_X, TEST_PRECISION),
+                    Plane.fromPointAndNormal(center.add(Vector3D.of(-xDelta, 0, 0)), Vector3D.Unit.MINUS_X, TEST_PRECISION),
+
+                    Plane.fromPointAndNormal(center.add(Vector3D.of(0, yDelta, 0)), Vector3D.Unit.PLUS_Y, TEST_PRECISION),
+                    Plane.fromPointAndNormal(center.add(Vector3D.of(0, -yDelta, 0)), Vector3D.Unit.MINUS_Y, TEST_PRECISION),
+
+                    Plane.fromPointAndNormal(center.add(Vector3D.of(0, 0, zDelta)), Vector3D.Unit.PLUS_Z, TEST_PRECISION),
+                    Plane.fromPointAndNormal(center.add(Vector3D.of( 0, 0, -zDelta)), Vector3D.Unit.MINUS_Z, TEST_PRECISION)
+                );
+
+        return ConvexVolume.fromBounds(planes);
+    }
+
+    private static void checkClassify(Region<Vector3D> region, RegionLocation loc, Vector3D ... pts) {
+        for (Vector3D pt : pts) {
+            Assert.assertEquals("Unexpected location for point " + pt, loc, region.classify(pt));
+        }
+    }
+}
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/FunctionTransform3DTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/FunctionTransform3DTest.java
new file mode 100644
index 0000000..cc32706
--- /dev/null
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/FunctionTransform3DTest.java
@@ -0,0 +1,197 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.threed;
+
+import java.util.function.Function;
+
+import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class FunctionTransform3DTest {
+
+    private static final double TEST_EPS = 1e-15;
+
+    @Test
+    public void testIdentity() {
+        // arrange
+        Vector3D p0 = Vector3D.of(0, 0, 0);
+        Vector3D p1 = Vector3D.of(1, 1, 1);
+        Vector3D p2 = Vector3D.of(-1, -1, -1);
+
+        // act
+        FunctionTransform3D t = FunctionTransform3D.identity();
+
+        // assert
+        Assert.assertTrue(t.preservesOrientation());
+
+        EuclideanTestUtils.assertCoordinatesEqual(p0, t.apply(p0), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(p1, t.apply(p1), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(p2, t.apply(p2), TEST_EPS);
+    }
+
+    @Test
+    public void testFrom_identity() {
+        // arrange
+        Vector3D p0 = Vector3D.of(0, 0, 0);
+        Vector3D p1 = Vector3D.of(1, 1, 1);
+        Vector3D p2 = Vector3D.of(-1, -1, -1);
+
+        // act
+        FunctionTransform3D t = FunctionTransform3D.from(Function.identity());
+
+        // assert
+        Assert.assertTrue(t.preservesOrientation());
+
+        EuclideanTestUtils.assertCoordinatesEqual(p0, t.apply(p0), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(p1, t.apply(p1), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(p2, t.apply(p2), TEST_EPS);
+    }
+
+    @Test
+    public void testFrom_scaleAndTranslate() {
+        // arrange
+        Vector3D p0 = Vector3D.of(0, 0, 0);
+        Vector3D p1 = Vector3D.of(1, 2, 3);
+        Vector3D p2 = Vector3D.of(-1, -2, -3);
+
+        // act
+        FunctionTransform3D t = FunctionTransform3D.from(v -> v.multiply(2).add(Vector3D.of(1, -1, 2)));
+
+        // assert
+        Assert.assertTrue(t.preservesOrientation());
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, -1, 2), t.apply(p0), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(3, 3, 8), t.apply(p1), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(-1, -5, -4), t.apply(p2), TEST_EPS);
+    }
+
+    @Test
+    public void testFrom_reflection_singleAxis() {
+        // arrange
+        Vector3D p0 = Vector3D.of(0, 0, 0);
+        Vector3D p1 = Vector3D.of(1, 2, 3);
+        Vector3D p2 = Vector3D.of(-1, -2, -3);
+
+        // act
+        FunctionTransform3D t = FunctionTransform3D.from(v -> Vector3D.of(-v.getX(), v.getY(), v.getZ()));
+
+        // assert
+        Assert.assertFalse(t.preservesOrientation());
+
+        EuclideanTestUtils.assertCoordinatesEqual(p0, t.apply(p0), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(-1, 2, 3), t.apply(p1), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, -2, -3), t.apply(p2), TEST_EPS);
+    }
+
+    @Test
+    public void testFrom_reflection_twoAxes() {
+        // arrange
+        Vector3D p0 = Vector3D.of(0, 0, 0);
+        Vector3D p1 = Vector3D.of(1, 2, 3);
+        Vector3D p2 = Vector3D.of(-1, -2, -3);
+
+        // act
+        FunctionTransform3D t = FunctionTransform3D.from(v -> Vector3D.of(-v.getX(), -v.getY(), v.getZ()));
+
+        // assert
+        Assert.assertTrue(t.preservesOrientation());
+
+        EuclideanTestUtils.assertCoordinatesEqual(p0, t.apply(p0), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(-1, -2, 3), t.apply(p1), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 2, -3), t.apply(p2), TEST_EPS);
+    }
+
+    @Test
+    public void testFrom_reflection_allAxes() {
+        // arrange
+        Vector3D p0 = Vector3D.of(0, 0, 0);
+        Vector3D p1 = Vector3D.of(1, 2, 3);
+        Vector3D p2 = Vector3D.of(-1, -2, -3);
+
+        // act
+        FunctionTransform3D t = FunctionTransform3D.from(Vector3D::negate);
+
+        // assert
+        Assert.assertFalse(t.preservesOrientation());
+
+        EuclideanTestUtils.assertCoordinatesEqual(p0, t.apply(p0), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(-1, -2, -3), t.apply(p1), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 2, 3), t.apply(p2), TEST_EPS);
+    }
+
+    @Test
+    public void testToMatrix() {
+        // act/assert
+        Assert.assertArrayEquals(new double[] {
+                    1, 0, 0, 0,
+                    0, 1, 0, 0,
+                    0, 0, 1, 0
+                },
+                FunctionTransform3D.identity().toMatrix().toArray(), TEST_EPS);
+        Assert.assertArrayEquals(new double[] {
+                    1, 0, 0, 2,
+                    0, 1, 0, 3,
+                    0, 0, 1, -4
+                },
+                FunctionTransform3D.from(v -> v.add(Vector3D.of(2, 3, -4))).toMatrix().toArray(), TEST_EPS);
+        Assert.assertArrayEquals(new double[] {
+                    3, 0, 0, 0,
+                    0, 3, 0, 0,
+                    0, 0, 3, 0
+                },
+                FunctionTransform3D.from(v -> v.multiply(3)).toMatrix().toArray(), TEST_EPS);
+        Assert.assertArrayEquals(new double[] {
+                    3, 0, 0, 6,
+                    0, 3, 0, 9,
+                    0, 0, 3, 12
+                },
+                FunctionTransform3D.from(v -> v.add(Vector3D.of(2, 3, 4)).multiply(3)).toMatrix().toArray(), TEST_EPS);
+    }
+
+    @Test
+    public void testTransformRoundTrip() {
+        // arrange
+        double eps = 1e-8;
+        double delta = 0.11;
+
+        Vector3D p1 = Vector3D.of(1.1, -3, 0);
+        Vector3D p2 = Vector3D.of(-5, 0.2, 2);
+        Vector3D vec = p1.vectorTo(p2);
+
+        EuclideanTestUtils.permuteSkipZero(-2, 2, delta, (translate, scale) -> {
+
+            FunctionTransform3D t = FunctionTransform3D.from(v -> {
+                return v.multiply(scale * 0.5)
+                    .add(Vector3D.of(translate, 0.5 * translate, 0.25 * translate))
+                    .multiply(scale * 1.5);
+            });
+
+            // act
+            Vector3D t1 = t.apply(p1);
+            Vector3D t2 = t.apply(p2);
+            Vector3D tvec = t.applyVector(vec);
+
+            Transform3D inverse = t.toMatrix().inverse();
+
+            // assert
+            EuclideanTestUtils.assertCoordinatesEqual(tvec, t1.vectorTo(t2), eps);
+            EuclideanTestUtils.assertCoordinatesEqual(p1, inverse.apply(t1), eps);
+            EuclideanTestUtils.assertCoordinatesEqual(p2, inverse.apply(t2), eps);
+        });
+    }
+}
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/Line3DTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/Line3DTest.java
new file mode 100644
index 0000000..4f01e68
--- /dev/null
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/Line3DTest.java
@@ -0,0 +1,459 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.threed;
+
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.exception.IllegalNormException;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
+import org.apache.commons.geometry.euclidean.oned.Interval;
+import org.apache.commons.geometry.euclidean.oned.Vector1D;
+import org.apache.commons.geometry.euclidean.threed.rotation.QuaternionRotation;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class Line3DTest {
+
+    private static final double TEST_EPS = 1e-10;
+
+    private static final DoublePrecisionContext TEST_PRECISION =
+            new EpsilonDoublePrecisionContext(TEST_EPS);
+
+    @Test
+    public void testFromPointAndDirection() {
+        // arrange
+        Line3D line = Line3D.fromPointAndDirection(Vector3D.of(-1, 1, 0), Vector3D.Unit.PLUS_Y, TEST_PRECISION);
+
+        // act/assert
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(-1, 0, 0), line.getOrigin(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.Unit.PLUS_Y, line.getDirection(), TEST_EPS);
+        Assert.assertSame(TEST_PRECISION, line.getPrecision());
+    }
+
+    @Test
+    public void testFromPointAndDirection_normalizesDirection() {
+        // arrange
+        Line3D line = Line3D.fromPointAndDirection(Vector3D.ZERO, Vector3D.of(1, 1, 1), TEST_PRECISION);
+
+        // act/assert
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.ZERO, line.getOrigin(), TEST_EPS);
+
+        double invSqrt3 = 1.0 / Math.sqrt(3);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(invSqrt3, invSqrt3, invSqrt3), line.getDirection(), TEST_EPS);
+        Assert.assertSame(TEST_PRECISION, line.getPrecision());
+    }
+
+    @Test(expected = IllegalNormException.class)
+    public void testFromPointAndDirection_illegalDirectionNorm() {
+        // act/assert
+        Line3D.fromPointAndDirection(Vector3D.ZERO, Vector3D.ZERO, TEST_PRECISION);
+    }
+
+    @Test
+    public void testFromPoints() {
+        // arrange
+        Line3D line = Line3D.fromPoints(Vector3D.of(-1, 1, 0), Vector3D.of(-1, 7, 0), TEST_PRECISION);
+
+        // act/assert
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(-1, 0, 0), line.getOrigin(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.Unit.PLUS_Y, line.getDirection(), TEST_EPS);
+        Assert.assertSame(TEST_PRECISION, line.getPrecision());
+    }
+
+    @Test(expected = IllegalNormException.class)
+    public void testFromPoints_pointsTooClose() {
+        // act/assert
+        Line3D.fromPoints(Vector3D.of(1, 1, 1), Vector3D.of(1, 1, 1 + 1e-16), TEST_PRECISION);
+    }
+
+    @Test
+    public void testTransform() {
+        // arrange
+        Vector3D pt = Vector3D.of(1, 2, 3);
+        Line3D line = Line3D.fromPointAndDirection(pt, Vector3D.of(1, 1, 1), TEST_PRECISION);
+
+        AffineTransformMatrix3D mat = AffineTransformMatrix3D.createRotation(pt,
+                QuaternionRotation.fromAxisAngle(Vector3D.Unit.PLUS_Y, Geometry.HALF_PI));
+
+        // act
+        Line3D result = line.transform(mat);
+
+        // assert
+        Assert.assertTrue(result.contains(pt));
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 1, -1).normalize(), result.getDirection(), TEST_EPS);
+    }
+
+    @Test
+    public void testTransform_reflectionInOneAxis() {
+        // arrange
+        Line3D line = Line3D.fromPointAndDirection(Vector3D.of(1, 0, 0), Vector3D.of(1, 1, 1), TEST_PRECISION);
+
+        Transform3D transform = FunctionTransform3D.from(v -> Vector3D.of(v.getX(), v.getY(), -v.getZ()));
+
+        // act
+        Line3D result = line.transform(transform);
+
+        // assert
+        Assert.assertTrue(result.contains(Vector3D.of(1, 0, 0)));
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 1, -1).normalize(), result.getDirection(), TEST_EPS);
+    }
+
+    @Test
+    public void testTransform_reflectionInTwoAxes() {
+        // arrange
+        Line3D line = Line3D.fromPointAndDirection(Vector3D.of(1, 0, 0), Vector3D.of(1, 1, 1), TEST_PRECISION);
+
+        Transform3D transform = FunctionTransform3D.from(v -> Vector3D.of(v.getX(), -v.getY(), -v.getZ()));
+
+        // act
+        Line3D result = line.transform(transform);
+
+        // assert
+        Assert.assertTrue(result.contains(Vector3D.of(1, 0, 0)));
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, -1, -1).normalize(), result.getDirection(), TEST_EPS);
+    }
+
+    @Test
+    public void testTransform_reflectionInThreeAxes() {
+        // arrange
+        Line3D line = Line3D.fromPointAndDirection(Vector3D.of(1, 0, 0), Vector3D.of(1, 1, 1), TEST_PRECISION);
+
+        Transform3D transform = FunctionTransform3D.from(Vector3D::negate);
+
+        // act
+        Line3D result = line.transform(transform);
+
+        // assert
+        Assert.assertTrue(result.contains(Vector3D.of(-1, 0, 0)));
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(-1, -1, -1).normalize(), result.getDirection(), TEST_EPS);
+    }
+
+    @Test
+    public void testSubspaceTransform() {
+        // arrange
+        Line3D line = Line3D.fromPointAndDirection(Vector3D.of(0, 0, 1), Vector3D.of(1, 0, 0), TEST_PRECISION);
+
+        Transform<Vector3D> transform = AffineTransformMatrix3D.identity()
+                .scale(2, 1, 1)
+                .translate(0.5, 1, 0)
+                .rotate(QuaternionRotation.fromAxisAngle(Vector3D.Unit.PLUS_Y, Geometry.HALF_PI));
+
+        // act
+        Line3D.SubspaceTransform result = line.subspaceTransform(transform);
+
+        // assert
+        Line3D tLine = result.getLine();
+        Transform<Vector1D> tSub = result.getTransform();
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 1, 0), tLine.getOrigin(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 0, -1), tLine.getDirection(), TEST_EPS);
+
+        Assert.assertEquals(0.5, tSub.apply(Vector1D.ZERO).getX(), TEST_EPS);
+        Assert.assertEquals(4.5, tSub.apply(Vector1D.of(2)).getX(), TEST_EPS);
+    }
+
+    @Test
+    public void testAbscissa() {
+        // arrange
+        Line3D line = Line3D.fromPointAndDirection(Vector3D.of(0, 0, -1), Vector3D.of(4, 3, 0), TEST_PRECISION);
+
+        // act/assert
+        Assert.assertEquals(0.0, line.abscissa(line.getOrigin()), TEST_EPS);
+
+        Assert.assertEquals(5.0, line.abscissa(Vector3D.of(4, 3, 0)), TEST_EPS);
+        Assert.assertEquals(5.0, line.abscissa(Vector3D.of(4, 3, 10)), TEST_EPS);
+
+        Assert.assertEquals(-5.0, line.abscissa(Vector3D.of(-4, -3, 0)), TEST_EPS);
+        Assert.assertEquals(-5.0, line.abscissa(Vector3D.of(-4, -3, -10)), TEST_EPS);
+    }
+
+    @Test
+    public void testToSubspace() {
+        // arrange
+        Line3D line = Line3D.fromPointAndDirection(Vector3D.of(0, 0, -1), Vector3D.of(4, 3, 0), TEST_PRECISION);
+
+        // act/assert
+        Assert.assertEquals(0.0, line.toSubspace(line.getOrigin()).getX(), TEST_EPS);
+
+        Assert.assertEquals(5.0, line.toSubspace(Vector3D.of(4, 3, -1)).getX(), TEST_EPS);
+        Assert.assertEquals(5.0, line.toSubspace(Vector3D.of(4, 3, 10)).getX(), TEST_EPS);
+
+        Assert.assertEquals(-5.0, line.toSubspace(Vector3D.of(-4, -3, -1)).getX(), TEST_EPS);
+        Assert.assertEquals(-5.0, line.toSubspace(Vector3D.of(-4, -3, -10)).getX(), TEST_EPS);
+    }
+
+    @Test
+    public void testPointAt() {
+        // arrange
+        Line3D line = Line3D.fromPointAndDirection(Vector3D.of(0, 0, -1), Vector3D.of(4, 3, 0), TEST_PRECISION);
+
+        // act/assert
+        EuclideanTestUtils.assertCoordinatesEqual(line.getOrigin(), line.pointAt(0.0), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(4, 3, -1), line.pointAt(5.0), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(-4, -3, -1), line.pointAt(-5.0), TEST_EPS);
+    }
+
+    @Test
+    public void testToSpace() {
+        // arrange
+        Line3D line = Line3D.fromPointAndDirection(Vector3D.of(0, 0, -1), Vector3D.of(4, 3, 0), TEST_PRECISION);
+
+        // act/assert
+        EuclideanTestUtils.assertCoordinatesEqual(line.getOrigin(), line.toSpace(Vector1D.of(0.0)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(4, 3, -1), line.toSpace(Vector1D.of(5.0)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(-4, -3, -1), line.toSpace(Vector1D.of(-5.0)), TEST_EPS);
+    }
+
+    @Test
+    public void testContains() {
+        Vector3D p1 = Vector3D.of(0, 0, 1);
+        Line3D l = Line3D.fromPoints(p1, Vector3D.of(0, 0, 2), TEST_PRECISION);
+        Assert.assertTrue(l.contains(p1));
+        Assert.assertTrue(l.contains(Vector3D.linearCombination(1.0, p1, 0.3, l.getDirection())));
+        Vector3D u = l.getDirection().orthogonal();
+        Vector3D v = l.getDirection().cross(u);
+        for (double alpha = 0; alpha < 2 * Math.PI; alpha += 0.3) {
+            Assert.assertTrue(! l.contains(p1.add(Vector3D.linearCombination(Math.cos(alpha), u,
+                                                               Math.sin(alpha), v))));
+        }
+    }
+
+    @Test
+    public void testSimilar() {
+        Vector3D p1  = Vector3D.of(1.2, 3.4, -5.8);
+        Vector3D p2  = Vector3D.of(3.4, -5.8, 1.2);
+        Line3D lA  = Line3D.fromPoints(p1, p2, TEST_PRECISION);
+        Line3D lB  = Line3D.fromPoints(p2, p1, TEST_PRECISION);
+        Assert.assertTrue(lA.isSimilarTo(lB));
+        Assert.assertTrue(!lA.isSimilarTo(Line3D.fromPoints(p1, p1.add(lA.getDirection().orthogonal()), TEST_PRECISION)));
+    }
+
+    @Test
+    public void testPointDistance() {
+        Line3D l = Line3D.fromPoints(Vector3D.of(0, 1, 1), Vector3D.of(0, 2, 2), TEST_PRECISION);
+        Assert.assertEquals(Math.sqrt(3.0 / 2.0), l.distance(Vector3D.of(1, 0, 1)), TEST_EPS);
+        Assert.assertEquals(0, l.distance(Vector3D.of(0, -4, -4)), TEST_EPS);
+    }
+
+    @Test
+    public void testLineDistance() {
+        Line3D l = Line3D.fromPoints(Vector3D.of(0, 1, 1), Vector3D.of(0, 2, 2), TEST_PRECISION);
+        Assert.assertEquals(1.0,
+                            l.distance(Line3D.fromPoints(Vector3D.of(1, 0, 1), Vector3D.of(1, 0, 2), TEST_PRECISION)),
+                            1.0e-10);
+        Assert.assertEquals(0.5,
+                            l.distance(Line3D.fromPoints(Vector3D.of(-0.5, 0, 0), Vector3D.of(-0.5, -1, -1), TEST_PRECISION)),
+                            1.0e-10);
+        Assert.assertEquals(0.0,
+                            l.distance(l),
+                            1.0e-10);
+        Assert.assertEquals(0.0,
+                            l.distance(Line3D.fromPoints(Vector3D.of(0, -4, -4), Vector3D.of(0, -5, -5), TEST_PRECISION)),
+                            1.0e-10);
+        Assert.assertEquals(0.0,
+                            l.distance(Line3D.fromPoints(Vector3D.of(0, -4, -4), Vector3D.of(0, -3, -4), TEST_PRECISION)),
+                            1.0e-10);
+        Assert.assertEquals(0.0,
+                            l.distance(Line3D.fromPoints(Vector3D.of(0, -4, -4), Vector3D.of(1, -4, -4), TEST_PRECISION)),
+                            1.0e-10);
+        Assert.assertEquals(Math.sqrt(8),
+                            l.distance(Line3D.fromPoints(Vector3D.of(0, -4, 0), Vector3D.of(1, -4, 0), TEST_PRECISION)),
+                            1.0e-10);
+    }
+
+    @Test
+    public void testClosest() {
+        Line3D l = Line3D.fromPoints(Vector3D.of(0, 1, 1), Vector3D.of(0, 2, 2), TEST_PRECISION);
+        Assert.assertEquals(0.0,
+                            l.closest(Line3D.fromPoints(Vector3D.of(1, 0, 1), Vector3D.of(1, 0, 2), TEST_PRECISION)).distance(Vector3D.of(0, 0, 0)),
+                            1.0e-10);
+        Assert.assertEquals(0.5,
+                            l.closest(Line3D.fromPoints(Vector3D.of(-0.5, 0, 0), Vector3D.of(-0.5, -1, -1), TEST_PRECISION)).distance(Vector3D.of(-0.5, 0, 0)),
+                            1.0e-10);
+        Assert.assertEquals(0.0,
+                            l.closest(l).distance(Vector3D.of(0, 0, 0)),
+                            1.0e-10);
+        Assert.assertEquals(0.0,
+                            l.closest(Line3D.fromPoints(Vector3D.of(0, -4, -4), Vector3D.of(0, -5, -5), TEST_PRECISION)).distance(Vector3D.of(0, 0, 0)),
+                            1.0e-10);
+        Assert.assertEquals(0.0,
+                            l.closest(Line3D.fromPoints(Vector3D.of(0, -4, -4), Vector3D.of(0, -3, -4), TEST_PRECISION)).distance(Vector3D.of(0, -4, -4)),
+                            1.0e-10);
+        Assert.assertEquals(0.0,
+                            l.closest(Line3D.fromPoints(Vector3D.of(0, -4, -4), Vector3D.of(1, -4, -4), TEST_PRECISION)).distance(Vector3D.of(0, -4, -4)),
+                            1.0e-10);
+        Assert.assertEquals(0.0,
+                            l.closest(Line3D.fromPoints(Vector3D.of(0, -4, 0), Vector3D.of(1, -4, 0), TEST_PRECISION)).distance(Vector3D.of(0, -2, -2)),
+                            1.0e-10);
+    }
+
+    @Test
+    public void testIntersection() {
+        Line3D l = Line3D.fromPoints(Vector3D.of(0, 1, 1), Vector3D.of(0, 2, 2), TEST_PRECISION);
+        Assert.assertNull(l.intersection(Line3D.fromPoints(Vector3D.of(1, 0, 1), Vector3D.of(1, 0, 2), TEST_PRECISION)));
+        Assert.assertNull(l.intersection(Line3D.fromPoints(Vector3D.of(-0.5, 0, 0), Vector3D.of(-0.5, -1, -1), TEST_PRECISION)));
+        Assert.assertEquals(0.0,
+                            l.intersection(l).distance(Vector3D.of(0, 0, 0)),
+                            1.0e-10);
+        Assert.assertEquals(0.0,
+                            l.intersection(Line3D.fromPoints(Vector3D.of(0, -4, -4), Vector3D.of(0, -5, -5), TEST_PRECISION)).distance(Vector3D.of(0, 0, 0)),
+                            1.0e-10);
+        Assert.assertEquals(0.0,
+                            l.intersection(Line3D.fromPoints(Vector3D.of(0, -4, -4), Vector3D.of(0, -3, -4), TEST_PRECISION)).distance(Vector3D.of(0, -4, -4)),
+                            1.0e-10);
+        Assert.assertEquals(0.0,
+                            l.intersection(Line3D.fromPoints(Vector3D.of(0, -4, -4), Vector3D.of(1, -4, -4), TEST_PRECISION)).distance(Vector3D.of(0, -4, -4)),
+                            1.0e-10);
+        Assert.assertNull(l.intersection(Line3D.fromPoints(Vector3D.of(0, -4, 0), Vector3D.of(1, -4, 0), TEST_PRECISION)));
+    }
+
+    @Test
+    public void testReverse() {
+        // arrange
+        Line3D line = Line3D.fromPoints(Vector3D.of(1653345.6696423641, 6170370.041579291, 90000),
+                             Vector3D.of(1650757.5050732433, 6160710.879908984, 0.9),
+                             TEST_PRECISION);
+        Vector3D expected = line.getDirection().negate();
+
+        // act
+        Line3D reversed = line.reverse();
+
+        // assert
+        EuclideanTestUtils.assertCoordinatesEqual(expected, reversed.getDirection(), TEST_EPS);
+    }
+
+    @Test
+    public void testSpan() {
+        // arrange
+        Line3D line = Line3D.fromPoints(Vector3D.ZERO, Vector3D.Unit.PLUS_X, TEST_PRECISION);
+
+        // act
+        Segment3D segment = line.span();
+
+        // assert
+        Assert.assertTrue(segment.isInfinite());
+        Assert.assertSame(line, segment.getLine());
+        Assert.assertTrue(segment.getInterval().isFull());
+    }
+
+    @Test
+    public void testSegment() {
+        // arrange
+        Line3D line = Line3D.fromPoints(Vector3D.of(0, 3, 0), Vector3D.of(1, 3, 0), TEST_PRECISION);
+        Interval interval = Interval.of(1, 2, TEST_PRECISION);
+
+        // act/assert
+        Segment3D intervalArgResult = line.segment(interval);
+        Assert.assertSame(line, intervalArgResult.getLine());
+        Assert.assertSame(interval, intervalArgResult.getInterval());
+
+        Segment3D doubleArgResult = line.segment(3, 4);
+        Assert.assertSame(line, doubleArgResult.getLine());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(3, 3, 0), doubleArgResult.getStartPoint(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(4, 3, 0), doubleArgResult.getEndPoint(), TEST_EPS);
+
+        Segment3D ptArgResult = line.segment(Vector3D.of(0, 4, 0), Vector3D.of(2, 5, 1));
+        Assert.assertSame(line, ptArgResult.getLine());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 3, 0), ptArgResult.getStartPoint(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(2, 3, 0), ptArgResult.getEndPoint(), TEST_EPS);
+
+        Segment3D fromResult = line.segmentFrom(Vector3D.of(1, 4, 0));
+        Assert.assertSame(line, fromResult.getLine());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 3, 0), fromResult.getStartPoint(), TEST_EPS);
+        Assert.assertNull(fromResult.getEndPoint());
+
+        Segment3D toResult = line.segmentTo(Vector3D.of(1, 4, 0));
+        Assert.assertSame(line, toResult.getLine());
+        Assert.assertNull(toResult.getStartPoint());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 3, 0), toResult.getEndPoint(), TEST_EPS);
+    }
+
+    @Test
+    public void testSubLine() {
+        // arrange
+        Line3D line = Line3D.fromPoints(Vector3D.of(0, 3, 0), Vector3D.of(1, 3, 0), TEST_PRECISION);
+
+        // act
+        SubLine3D sub = line.subline();
+
+        // assert
+        Assert.assertSame(line, sub.getLine());
+        Assert.assertTrue(sub.getSubspaceRegion().isEmpty());
+    }
+
+    @Test
+    public void testHashCode() {
+        // arrange
+        Line3D a = Line3D.fromPointAndDirection(Vector3D.of(1, 2, 3), Vector3D.of(4, 5, 6), TEST_PRECISION);
+        Line3D b = Line3D.fromPointAndDirection(Vector3D.of(1, 2, -1), Vector3D.of(4, 5, 6), TEST_PRECISION);
+        Line3D c = Line3D.fromPointAndDirection(Vector3D.of(1, 2, 3), Vector3D.of(4, 5, -1), TEST_PRECISION);
+        Line3D d = Line3D.fromPointAndDirection(Vector3D.of(1, 2, 3), Vector3D.of(4, 5, 6), new EpsilonDoublePrecisionContext(TEST_EPS + 1e-3));
+
+        Line3D e = Line3D.fromPointAndDirection(Vector3D.of(1, 2, 3), Vector3D.of(4, 5, 6), new EpsilonDoublePrecisionContext(TEST_EPS));
+
+        int hash = a.hashCode();
+
+        // act/assert
+        Assert.assertEquals(hash, a.hashCode());
+
+        Assert.assertNotEquals(hash, b.hashCode());
+        Assert.assertNotEquals(hash, c.hashCode());
+        Assert.assertNotEquals(hash, d.hashCode());
+
+        Assert.assertEquals(hash, e.hashCode());
+    }
+
+    @Test
+    public void testEquals() {
+        // arrange
+        Line3D a = Line3D.fromPointAndDirection(Vector3D.of(1, 2, 3), Vector3D.of(4, 5, 6), TEST_PRECISION);
+        Line3D b = Line3D.fromPointAndDirection(Vector3D.of(1, 2, -1), Vector3D.of(4, 5, 6), TEST_PRECISION);
+        Line3D c = Line3D.fromPointAndDirection(Vector3D.of(1, 2, 3), Vector3D.of(4, 5, -1), TEST_PRECISION);
+        Line3D d = Line3D.fromPointAndDirection(Vector3D.of(1, 2, 3), Vector3D.of(4, 5, 6), new EpsilonDoublePrecisionContext(TEST_EPS + 1e-3));
+
+        Line3D e = Line3D.fromPointAndDirection(Vector3D.of(1, 2, 3), Vector3D.of(4, 5, 6), new EpsilonDoublePrecisionContext(TEST_EPS));
+
+        // act/assert
+        Assert.assertTrue(a.equals(a));
+
+        Assert.assertFalse(a.equals(null));
+        Assert.assertFalse(a.equals(new Object()));
+
+        Assert.assertFalse(a.equals(b));
+        Assert.assertFalse(a.equals(c));
+        Assert.assertFalse(a.equals(d));
+
+        Assert.assertTrue(a.equals(e));
+        Assert.assertTrue(e.equals(a));
+    }
+
+    @Test
+    public void testToString() {
+        // arrange
+        Line3D line = Line3D.fromPointAndDirection(Vector3D.ZERO, Vector3D.Unit.PLUS_X, TEST_PRECISION);
+
+        // act
+        String str = line.toString();
+
+        // assert
+        Assert.assertTrue(str.contains("Line3D"));
+        Assert.assertTrue(str.matches(".*origin= \\(0(\\.0)?, 0(\\.0)?, 0(\\.0)?\\).*"));
+        Assert.assertTrue(str.matches(".*direction= \\(1(\\.0)?, 0(\\.0)?, 0(\\.0)?\\).*"));
+    }
+}
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/LineTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/LineTest.java
deleted file mode 100644
index fe667e0..0000000
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/LineTest.java
+++ /dev/null
@@ -1,151 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.threed;
-
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
-import org.junit.Assert;
-import org.junit.Test;
-
-public class LineTest {
-
-    private static final double TEST_EPS = 1e-10;
-
-    private static final DoublePrecisionContext TEST_PRECISION =
-            new EpsilonDoublePrecisionContext(TEST_EPS);
-
-    @Test
-    public void testContains() {
-        Vector3D p1 = Vector3D.of(0, 0, 1);
-        Line l = new Line(p1, Vector3D.of(0, 0, 2), TEST_PRECISION);
-        Assert.assertTrue(l.contains(p1));
-        Assert.assertTrue(l.contains(Vector3D.linearCombination(1.0, p1, 0.3, l.getDirection())));
-        Vector3D u = l.getDirection().orthogonal();
-        Vector3D v = l.getDirection().cross(u);
-        for (double alpha = 0; alpha < 2 * Math.PI; alpha += 0.3) {
-            Assert.assertTrue(! l.contains(p1.add(Vector3D.linearCombination(Math.cos(alpha), u,
-                                                               Math.sin(alpha), v))));
-        }
-    }
-
-    @Test
-    public void testSimilar() {
-        Vector3D p1  = Vector3D.of(1.2, 3.4, -5.8);
-        Vector3D p2  = Vector3D.of(3.4, -5.8, 1.2);
-        Line     lA  = new Line(p1, p2, TEST_PRECISION);
-        Line     lB  = new Line(p2, p1, TEST_PRECISION);
-        Assert.assertTrue(lA.isSimilarTo(lB));
-        Assert.assertTrue(!lA.isSimilarTo(new Line(p1, p1.add(lA.getDirection().orthogonal()), TEST_PRECISION)));
-    }
-
-    @Test
-    public void testPointDistance() {
-        Line l = new Line(Vector3D.of(0, 1, 1), Vector3D.of(0, 2, 2), TEST_PRECISION);
-        Assert.assertEquals(Math.sqrt(3.0 / 2.0), l.distance(Vector3D.of(1, 0, 1)), TEST_EPS);
-        Assert.assertEquals(0, l.distance(Vector3D.of(0, -4, -4)), TEST_EPS);
-    }
-
-    @Test
-    public void testLineDistance() {
-        Line l = new Line(Vector3D.of(0, 1, 1), Vector3D.of(0, 2, 2), TEST_PRECISION);
-        Assert.assertEquals(1.0,
-                            l.distance(new Line(Vector3D.of(1, 0, 1), Vector3D.of(1, 0, 2), TEST_PRECISION)),
-                            1.0e-10);
-        Assert.assertEquals(0.5,
-                            l.distance(new Line(Vector3D.of(-0.5, 0, 0), Vector3D.of(-0.5, -1, -1), TEST_PRECISION)),
-                            1.0e-10);
-        Assert.assertEquals(0.0,
-                            l.distance(l),
-                            1.0e-10);
-        Assert.assertEquals(0.0,
-                            l.distance(new Line(Vector3D.of(0, -4, -4), Vector3D.of(0, -5, -5), TEST_PRECISION)),
-                            1.0e-10);
-        Assert.assertEquals(0.0,
-                            l.distance(new Line(Vector3D.of(0, -4, -4), Vector3D.of(0, -3, -4), TEST_PRECISION)),
-                            1.0e-10);
-        Assert.assertEquals(0.0,
-                            l.distance(new Line(Vector3D.of(0, -4, -4), Vector3D.of(1, -4, -4), TEST_PRECISION)),
-                            1.0e-10);
-        Assert.assertEquals(Math.sqrt(8),
-                            l.distance(new Line(Vector3D.of(0, -4, 0), Vector3D.of(1, -4, 0), TEST_PRECISION)),
-                            1.0e-10);
-    }
-
-    @Test
-    public void testClosest() {
-        Line l = new Line(Vector3D.of(0, 1, 1), Vector3D.of(0, 2, 2), TEST_PRECISION);
-        Assert.assertEquals(0.0,
-                            l.closestPoint(new Line(Vector3D.of(1, 0, 1), Vector3D.of(1, 0, 2), TEST_PRECISION)).distance(Vector3D.of(0, 0, 0)),
-                            1.0e-10);
-        Assert.assertEquals(0.5,
-                            l.closestPoint(new Line(Vector3D.of(-0.5, 0, 0), Vector3D.of(-0.5, -1, -1), TEST_PRECISION)).distance(Vector3D.of(-0.5, 0, 0)),
-                            1.0e-10);
-        Assert.assertEquals(0.0,
-                            l.closestPoint(l).distance(Vector3D.of(0, 0, 0)),
-                            1.0e-10);
-        Assert.assertEquals(0.0,
-                            l.closestPoint(new Line(Vector3D.of(0, -4, -4), Vector3D.of(0, -5, -5), TEST_PRECISION)).distance(Vector3D.of(0, 0, 0)),
-                            1.0e-10);
-        Assert.assertEquals(0.0,
-                            l.closestPoint(new Line(Vector3D.of(0, -4, -4), Vector3D.of(0, -3, -4), TEST_PRECISION)).distance(Vector3D.of(0, -4, -4)),
-                            1.0e-10);
-        Assert.assertEquals(0.0,
-                            l.closestPoint(new Line(Vector3D.of(0, -4, -4), Vector3D.of(1, -4, -4), TEST_PRECISION)).distance(Vector3D.of(0, -4, -4)),
-                            1.0e-10);
-        Assert.assertEquals(0.0,
-                            l.closestPoint(new Line(Vector3D.of(0, -4, 0), Vector3D.of(1, -4, 0), TEST_PRECISION)).distance(Vector3D.of(0, -2, -2)),
-                            1.0e-10);
-    }
-
-    @Test
-    public void testIntersection() {
-        Line l = new Line(Vector3D.of(0, 1, 1), Vector3D.of(0, 2, 2), TEST_PRECISION);
-        Assert.assertNull(l.intersection(new Line(Vector3D.of(1, 0, 1), Vector3D.of(1, 0, 2), TEST_PRECISION)));
-        Assert.assertNull(l.intersection(new Line(Vector3D.of(-0.5, 0, 0), Vector3D.of(-0.5, -1, -1), TEST_PRECISION)));
-        Assert.assertEquals(0.0,
-                            l.intersection(l).distance(Vector3D.of(0, 0, 0)),
-                            1.0e-10);
-        Assert.assertEquals(0.0,
-                            l.intersection(new Line(Vector3D.of(0, -4, -4), Vector3D.of(0, -5, -5), TEST_PRECISION)).distance(Vector3D.of(0, 0, 0)),
-                            1.0e-10);
-        Assert.assertEquals(0.0,
-                            l.intersection(new Line(Vector3D.of(0, -4, -4), Vector3D.of(0, -3, -4), TEST_PRECISION)).distance(Vector3D.of(0, -4, -4)),
-                            1.0e-10);
-        Assert.assertEquals(0.0,
-                            l.intersection(new Line(Vector3D.of(0, -4, -4), Vector3D.of(1, -4, -4), TEST_PRECISION)).distance(Vector3D.of(0, -4, -4)),
-                            1.0e-10);
-        Assert.assertNull(l.intersection(new Line(Vector3D.of(0, -4, 0), Vector3D.of(1, -4, 0), TEST_PRECISION)));
-    }
-
-    @Test
-    public void testRevert() {
-
-        // setup
-        Line line = new Line(Vector3D.of(1653345.6696423641, 6170370.041579291, 90000),
-                             Vector3D.of(1650757.5050732433, 6160710.879908984, 0.9),
-                             TEST_PRECISION);
-        Vector3D expected = line.getDirection().negate();
-
-        // action
-        Line reverted = line.revert();
-
-        // verify
-        Assert.assertArrayEquals(expected.toArray(), reverted.getDirection().toArray(), 0);
-
-    }
-
-}
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/OBJWriter.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/OBJWriter.java
deleted file mode 100644
index 4346306..0000000
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/OBJWriter.java
+++ /dev/null
@@ -1,318 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.threed;
-
-import java.io.File;
-import java.io.IOException;
-import java.io.Writer;
-import java.nio.file.Files;
-import java.text.DecimalFormat;
-import java.util.ArrayList;
-import java.util.Comparator;
-import java.util.Date;
-import java.util.List;
-import java.util.Map;
-import java.util.TreeMap;
-
-import org.apache.commons.geometry.core.partitioning.BSPTree;
-import org.apache.commons.geometry.core.partitioning.BSPTreeVisitor;
-import org.apache.commons.geometry.core.partitioning.BoundaryAttribute;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.geometry.euclidean.twod.PolygonsSet;
-import org.apache.commons.geometry.euclidean.twod.Vector2D;
-
-/** This class creates simple OBJ files from {@link PolyhedronsSet} instances.
- * The output files can be opened in a 3D viewer for visual debugging of 3D
- * regions. This class is only intended for use in testing.
- *
- * @see https://en.wikipedia.org/wiki/Wavefront_.obj_file
- */
-public class OBJWriter {
-
-    /** Writes an OBJ file representing the given {@link PolyhedronsSet}. Only
-     * finite boundaries are written. Infinite boundaries are ignored.
-     * @param file The path of the file to write
-     * @param poly The input PolyhedronsSet
-     * @throws IOException
-     */
-    public static void write(String file, PolyhedronsSet poly) throws IOException {
-        write(new File(file), poly);
-    }
-
-    /** Writes an OBJ file representing the given {@link PolyhedronsSet}. Only
-     * finite boundaries are written. Infinite boundaries are ignored.
-     * @param file The file to write
-     * @param poly The input PolyhedronsSet
-     * @throws IOException
-     */
-    public static void write(File file, PolyhedronsSet poly) throws IOException {
-        // get the vertices and faces
-        MeshBuilder meshBuilder = new MeshBuilder(poly.getPrecision());
-        poly.getTree(true).visit(meshBuilder);
-
-        // write them to the file
-        try (Writer writer = Files.newBufferedWriter(file.toPath())) {
-            writer.write("# Generated by " + OBJWriter.class.getName() + " on " + new Date() + "\n");
-            writeVertices(writer, meshBuilder.getVertices());
-            writeFaces(writer, meshBuilder.getFaces());
-        }
-    }
-
-    /** Writes the given list of vertices to the file in the OBJ format.
-     * @param writer
-     * @param vertices
-     * @throws IOException
-     */
-    private static void writeVertices(Writer writer, List<Vector3D> vertices) throws IOException {
-        DecimalFormat df = new DecimalFormat("0.######");
-
-        for (Vector3D v : vertices) {
-            writer.write("v ");
-            writer.write(df.format(v.getX()));
-            writer.write(" ");
-            writer.write(df.format(v.getY()));
-            writer.write(" ");
-            writer.write(df.format(v.getZ()));
-            writer.write("\n");
-        }
-    }
-
-    /** Writes the given list of face vertex indices to the file in the OBJ format. The indices
-     * are expected to be 0-based and are converted to the 1-based format used by OBJ.
-     * @param writer
-     * @param faces
-     * @throws IOException
-     */
-    private static void writeFaces(Writer writer, List<int[]> faces) throws IOException {
-        for (int[] face : faces) {
-            writer.write("f ");
-            for (int idx : face) {
-                writer.write(String.valueOf(idx + 1)); // obj indices are 1-based
-                writer.write(" ");
-            }
-            writer.write("\n");
-        }
-    }
-
-    /** Class used to impose a strict sorting on 3D vertices.
-     * If all of the components of two vertices are within tolerance of each
-     * other, then the vertices are considered equal. This helps to avoid
-     * writing duplicate vertices in the OBJ output.
-     */
-    private static class VertexComparator implements Comparator<Vector3D> {
-
-        /** Precision context to deteremine floating-point equality */
-        private final DoublePrecisionContext precision;
-
-        /** Creates a new instance with the given tolerance value.
-         * @param tolerance
-         */
-        public VertexComparator(final DoublePrecisionContext precision) {
-            this.precision = precision;
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public int compare(Vector3D a, Vector3D b) {
-            int result = precision.compare(a.getX(), b.getX());
-            if (result == 0) {
-                result = precision.compare(a.getY(), b.getY());
-                if (result == 0) {
-                    result = precision.compare(a.getZ(), b.getZ());
-                }
-            }
-            return result;
-        }
-    }
-
-    /** Class for converting a 3D BSPTree into a list of vertices
-     * and face vertex indices.
-     */
-    private static class MeshBuilder implements BSPTreeVisitor<Vector3D> {
-
-        /** Precision context to deteremine floating-point equality */
-        private final DoublePrecisionContext precision;
-
-        /** Map of vertices to their index in the vertices list */
-        private Map<Vector3D, Integer> vertexIndexMap;
-
-        /** List of unique vertices in the BSPTree boundary */
-        private List<Vector3D> vertices;
-
-        /**
-         * List of face vertex indices. Each face will have 3 indices. Indices
-         * are 0-based.
-         * */
-        private List<int[]> faces;
-
-        /** Creates a new instance with the given tolerance.
-         * @param tolerance
-         */
-        public MeshBuilder(final DoublePrecisionContext precision) {
-            this.precision = precision;
-            this.vertexIndexMap = new TreeMap<>(new VertexComparator(precision));
-            this.vertices = new ArrayList<>();
-            this.faces = new ArrayList<>();
-        }
-
-        /** Returns the list of unique vertices found in the BSPTree.
-         * @return
-         */
-        public List<Vector3D> getVertices() {
-            return vertices;
-        }
-
-        /** Returns the list of 0-based face vertex indices for the BSPTree. Each face is
-         * a triangle with 3 indices.
-         * @return
-         */
-        public List<int[]> getFaces() {
-            return faces;
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public Order visitOrder(BSPTree<Vector3D> node) {
-            return Order.SUB_MINUS_PLUS;
-        }
-
-        /** {@inheritDoc} */
-        @SuppressWarnings("unchecked")
-        @Override
-        public void visitInternalNode(BSPTree<Vector3D> node) {
-            BoundaryAttribute<Vector3D> attr = (BoundaryAttribute<Vector3D>) node.getAttribute();
-
-            if (attr.getPlusOutside() != null) {
-                addBoundary((SubPlane) attr.getPlusOutside());
-            }
-            else if (attr.getPlusInside() != null) {
-                addBoundary((SubPlane) attr.getPlusInside());
-            }
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public void visitLeafNode(BSPTree<Vector3D> node) {
-            // do nothing
-        }
-
-        /** Adds the region boundary defined by the given {@link SubPlane}
-         * to the mesh.
-         * @param subplane
-         */
-        private void addBoundary(SubPlane subplane) {
-            Plane plane = (Plane) subplane.getHyperplane();
-            PolygonsSet poly = (PolygonsSet) subplane.getRemainingRegion();
-
-            TriangleExtractor triExtractor = new TriangleExtractor(precision);
-            poly.getTree(true).visit(triExtractor);
-
-            Vector3D v1, v2, v3;
-            for (Vector2D[] tri : triExtractor.getTriangles()) {
-                v1 = plane.toSpace(tri[0]);
-                v2 = plane.toSpace(tri[1]);
-                v3 = plane.toSpace(tri[2]);
-
-                faces.add(new int[] {
-                        getVertexIndex(v1),
-                        getVertexIndex(v2),
-                        getVertexIndex(v3)
-                });
-            }
-        }
-
-        /** Returns the 0-based index of the given vertex in the <code>vertices</code>
-         * list. If the vertex has not been encountered before, it is added
-         * to the list.
-         * @param vertex
-         * @return
-         */
-        private int getVertexIndex(Vector3D vertex) {
-            Integer idx = vertexIndexMap.get(vertex);
-            if (idx == null) {
-                idx = vertices.size();
-
-                vertices.add(vertex);
-                vertexIndexMap.put(vertex, idx);
-            }
-            return idx.intValue();
-        }
-    }
-
-    /** Visitor for extracting a collection of triangles from a 2D BSPTree.
-     */
-    private static class TriangleExtractor implements BSPTreeVisitor<Vector2D> {
-
-        /** Precision context to deteremine floating-point equality */
-        private final DoublePrecisionContext precision;
-
-        /** List of extracted triangles */
-        private List<Vector2D[]> triangles = new ArrayList<>();
-
-        /** Creates a new instance with the given geometric tolerance.
-         * @param tolerance
-         */
-        public TriangleExtractor(final DoublePrecisionContext precision) {
-            this.precision = precision;
-        }
-
-        /** Returns the list of extracted triangles.
-         * @return
-         */
-        public List<Vector2D[]> getTriangles() {
-            return triangles;
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public Order visitOrder(BSPTree<Vector2D> node) {
-            return Order.SUB_MINUS_PLUS;
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public void visitInternalNode(BSPTree<Vector2D> node) {
-            // do nothing
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public void visitLeafNode(BSPTree<Vector2D> node) {
-            if ((Boolean) node.getAttribute()) {
-                PolygonsSet convexPoly = new PolygonsSet(node.pruneAroundConvexCell(Boolean.TRUE,
-                        Boolean.FALSE, null), precision);
-
-                for (Vector2D[] loop : convexPoly.getVertices()) {
-                    if (loop.length > 0 && loop[0] != null) { // skip unclosed loops
-                        addTriangles(loop);
-                    }
-                }
-            }
-        }
-
-        /** Splits the 2D convex area defined by the given vertices into
-         * triangles and adds them to the internal list.
-         * @param vertices
-         */
-        private void addTriangles(Vector2D[] vertices) {
-            // use a triangle fan to add the convex region
-            for (int i=2; i<vertices.length; ++i) {
-                triangles.add(new Vector2D[] { vertices[0], vertices[i-1], vertices[i] });
-            }
-        }
-    }
-}
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/PLYParser.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/PLYParser.java
deleted file mode 100644
index b1d6e35..0000000
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/PLYParser.java
+++ /dev/null
@@ -1,289 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.threed;
-
-import java.io.BufferedReader;
-import java.io.EOFException;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.text.ParseException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-import java.util.StringTokenizer;
-
-import org.apache.commons.numbers.core.Precision;
-
-/** This class is a small and incomplete parser for PLY files.
- * <p>
- * This parser is only intended for test purposes, it does not
- * parse the full header, it does not handle all properties,
- * it has rudimentary error handling.
- * </p>
- */
-public class PLYParser {
-
-    /** Parsed vertices. */
-    private Vector3D[] vertices;
-
-    /** Parsed faces. */
-    private int[][] faces;
-
-    /** Reader for PLY data. */
-    private BufferedReader br;
-
-    /** Last parsed line. */
-    private String line;
-
-    /** Simple constructor.
-     * @param stream stream to parse (closing it remains caller responsibility)
-     * @exception IOException if stream cannot be read
-     * @exception ParseException if stream content cannot be parsed
-     */
-    public PLYParser(final InputStream stream)
-        throws IOException, ParseException {
-
-        try {
-            br = new BufferedReader(new InputStreamReader(stream, "UTF-8"));
-
-            // parse the header
-            List<Field> fields = parseNextLine();
-            if (fields.size() != 1 || fields.get(0).getToken() != Token.PLY) {
-                complain();
-            }
-
-            boolean parsing       = true;
-            int nbVertices        = -1;
-            int nbFaces           = -1;
-            int xIndex            = -1;
-            int yIndex            = -1;
-            int zIndex            = -1;
-            int vPropertiesNumber = -1;
-            boolean inVertexElt   = false;
-            boolean inFaceElt     = false;
-            while (parsing) {
-                fields = parseNextLine();
-                if (fields.size() < 1) {
-                    complain();
-                }
-                switch (fields.get(0).getToken()) {
-                    case FORMAT:
-                        if (fields.size() != 3 ||
-                        fields.get(1).getToken() != Token.ASCII ||
-                        fields.get(2).getToken() != Token.UNKNOWN ||
-                        !Precision.equals(Double.parseDouble(fields.get(2).getValue()), 1.0, 0.001)) {
-                            complain();
-                        }
-                        inVertexElt = false;
-                        inFaceElt   = false;
-                        break;
-                    case COMMENT:
-                        // we just ignore this line
-                        break;
-                    case ELEMENT:
-                        if (fields.size() != 3 ||
-                        (fields.get(1).getToken() != Token.VERTEX && fields.get(1).getToken() != Token.FACE) ||
-                        fields.get(2).getToken() != Token.UNKNOWN) {
-                            complain();
-                        }
-                        if (fields.get(1).getToken() == Token.VERTEX) {
-                            nbVertices  = Integer.parseInt(fields.get(2).getValue());
-                            inVertexElt = true;
-                            inFaceElt   = false;
-                        } else {
-                            nbFaces     = Integer.parseInt(fields.get(2).getValue());
-                            inVertexElt = false;
-                            inFaceElt   = true;
-                        }
-                        break;
-                    case PROPERTY:
-                        if (inVertexElt) {
-                            ++vPropertiesNumber;
-                            if (fields.size() != 3 ||
-                                (fields.get(1).getToken() != Token.CHAR   &&
-                                 fields.get(1).getToken() != Token.UCHAR  &&
-                                 fields.get(1).getToken() != Token.SHORT  &&
-                                 fields.get(1).getToken() != Token.USHORT &&
-                                 fields.get(1).getToken() != Token.INT    &&
-                                 fields.get(1).getToken() != Token.UINT   &&
-                                 fields.get(1).getToken() != Token.FLOAT  &&
-                                 fields.get(1).getToken() != Token.DOUBLE)) {
-                                complain();
-                            }
-                            if (fields.get(2).getToken() == Token.X) {
-                                xIndex = vPropertiesNumber;
-                            }else if (fields.get(2).getToken() == Token.Y) {
-                                yIndex = vPropertiesNumber;
-                            }else if (fields.get(2).getToken() == Token.Z) {
-                                zIndex = vPropertiesNumber;
-                            }
-                        } else if (inFaceElt) {
-                            if (fields.size() != 5 ||
-                                fields.get(1).getToken()  != Token.LIST   &&
-                                (fields.get(2).getToken() != Token.CHAR   &&
-                                 fields.get(2).getToken() != Token.UCHAR  &&
-                                 fields.get(2).getToken() != Token.SHORT  &&
-                                 fields.get(2).getToken() != Token.USHORT &&
-                                 fields.get(2).getToken() != Token.INT    &&
-                                 fields.get(2).getToken() != Token.UINT) ||
-                                (fields.get(3).getToken() != Token.CHAR   &&
-                                 fields.get(3).getToken() != Token.UCHAR  &&
-                                 fields.get(3).getToken() != Token.SHORT  &&
-                                 fields.get(3).getToken() != Token.USHORT &&
-                                 fields.get(3).getToken() != Token.INT    &&
-                                 fields.get(3).getToken() != Token.UINT) ||
-                                 fields.get(4).getToken() != Token.VERTEX_INDICES) {
-                                complain();
-                            }
-                        } else {
-                            complain();
-                        }
-                        break;
-                    case END_HEADER:
-                        inVertexElt = false;
-                        inFaceElt   = false;
-                        parsing     = false;
-                        break;
-                    default:
-                        throw new ParseException("unable to parse line: " + line, 0);
-                }
-            }
-            ++vPropertiesNumber;
-
-            // parse vertices
-            vertices = new Vector3D[nbVertices];
-            for (int i = 0; i < nbVertices; ++i) {
-                fields = parseNextLine();
-                if (fields.size() != vPropertiesNumber ||
-                    fields.get(xIndex).getToken() != Token.UNKNOWN ||
-                    fields.get(yIndex).getToken() != Token.UNKNOWN ||
-                    fields.get(zIndex).getToken() != Token.UNKNOWN) {
-                    complain();
-                }
-                vertices[i] = Vector3D.of(Double.parseDouble(fields.get(xIndex).getValue()),
-                                           Double.parseDouble(fields.get(yIndex).getValue()),
-                                           Double.parseDouble(fields.get(zIndex).getValue()));
-            }
-
-            // parse faces
-            faces = new int[nbFaces][];
-            for (int i = 0; i < nbFaces; ++i) {
-                fields = parseNextLine();
-                if (fields.isEmpty() ||
-                    fields.size() != (Integer.parseInt(fields.get(0).getValue()) + 1)) {
-                    complain();
-                }
-                faces[i] = new int[fields.size() - 1];
-                for (int j = 0; j < faces[i].length; ++j) {
-                    faces[i][j] = Integer.parseInt(fields.get(j + 1).getValue());
-                }
-            }
-
-        } catch (NumberFormatException nfe) {
-            complain();
-        }
-    }
-
-    /** Complain about a bad line.
-     * @exception ParseException always thrown
-     */
-    private void complain() throws ParseException {
-        throw new ParseException("unable to parse line: " + line, 0);
-    }
-
-    /** Parse next line.
-     * @return parsed fields
-     * @exception IOException if stream cannot be read
-     * @exception ParseException if the line does not contain the expected number of fields
-     */
-    private List<Field> parseNextLine()
-        throws IOException, ParseException {
-        final List<Field> fields = new ArrayList<>();
-        line = br.readLine();
-        if (line == null) {
-            throw new EOFException();
-        }
-        final StringTokenizer tokenizer = new StringTokenizer(line);
-        while (tokenizer.hasMoreTokens()) {
-            fields.add(new Field(tokenizer.nextToken()));
-        }
-        return fields;
-    }
-
-    /** Get the parsed vertices.
-     * @return parsed vertices
-     */
-    public List<Vector3D> getVertices() {
-        return Arrays.asList(vertices);
-    }
-
-    /** Get the parsed faces.
-     * @return parsed faces
-     */
-    public List<int[]> getFaces() {
-        return Arrays.asList(faces);
-    }
-
-    /** Tokens from PLY files. */
-    private static enum Token {
-        PLY, FORMAT, ASCII, BINARY_BIG_ENDIAN, BINARY_LITTLE_ENDIAN,
-        COMMENT, ELEMENT, VERTEX, FACE, PROPERTY, LIST, OBJ_INFO,
-        CHAR, UCHAR, SHORT, USHORT, INT, UINT, FLOAT, DOUBLE,
-        X, Y, Z, VERTEX_INDICES, END_HEADER, UNKNOWN;
-    }
-
-    /** Parsed line fields. */
-    private static class Field {
-
-        /** Token. */
-        private final Token token;
-
-        /** Value. */
-        private final String value;
-
-        /** Simple constructor.
-         * @param value field value
-         */
-        public Field(final String value) {
-            Token parsedToken = null;
-            try {
-                parsedToken = Token.valueOf(value.toUpperCase());
-            } catch (IllegalArgumentException iae) {
-                parsedToken = Token.UNKNOWN;
-            }
-            this.token = parsedToken;
-            this.value = value;
-        }
-
-        /** Get the recognized token.
-         * @return recognized token
-         */
-        public Token getToken() {
-            return token;
-        }
-
-        /** Get the field value.
-         * @return field value
-         */
-        public String getValue() {
-            return value;
-        }
-
-    }
-
-}
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/PlaneTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/PlaneTest.java
index d28c713..55d0930 100644
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/PlaneTest.java
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/PlaneTest.java
@@ -20,17 +20,20 @@
 import java.util.Arrays;
 import java.util.List;
 
+import org.apache.commons.geometry.core.Geometry;
 import org.apache.commons.geometry.core.GeometryTestUtils;
 import org.apache.commons.geometry.core.exception.GeometryException;
 import org.apache.commons.geometry.core.exception.IllegalNormException;
 import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
 import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
 import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
+import org.apache.commons.geometry.euclidean.threed.Plane.SubspaceTransform;
 import org.apache.commons.geometry.euclidean.threed.rotation.QuaternionRotation;
+import org.apache.commons.geometry.euclidean.twod.AffineTransformMatrix2D;
+import org.apache.commons.geometry.euclidean.twod.Vector2D;
 import org.junit.Assert;
 import org.junit.Test;
 
-@SuppressWarnings("javadoc")
 public class PlaneTest {
 
     private static final double TEST_EPS = 1e-10;
@@ -38,178 +41,158 @@
     private static final DoublePrecisionContext TEST_PRECISION =
             new EpsilonDoublePrecisionContext(TEST_EPS);
 
-    @Test(expected=IllegalNormException.class)
-    public void testUAndVAreIdentical() {
-        Plane.fromPointAndPlaneVectors(Vector3D.of(0, 0, 1), Vector3D.of(0, 0, 1), Vector3D.of(0, 0, 1), TEST_PRECISION);
-    }
+    @Test
+    public void testFromNormal() {
+        // act/assert
+        checkPlane(Plane.fromNormal(Vector3D.Unit.PLUS_X, TEST_PRECISION),
+                Vector3D.ZERO, Vector3D.Unit.PLUS_Z, Vector3D.Unit.MINUS_Y);
+        checkPlane(Plane.fromNormal(Vector3D.of(7, 0, 0), TEST_PRECISION),
+                Vector3D.ZERO, Vector3D.Unit.PLUS_Z, Vector3D.Unit.MINUS_Y);
 
-    @Test(expected=IllegalNormException.class)
-    public void testUAndVAreCollinear() {
-        Plane.fromPointAndPlaneVectors(Vector3D.of(0, 0, 1), Vector3D.of(0, 0, 1), Vector3D.of(0, 0, 2), TEST_PRECISION);
-    }
+        checkPlane(Plane.fromNormal(Vector3D.Unit.PLUS_Y, TEST_PRECISION),
+                Vector3D.ZERO, Vector3D.Unit.MINUS_Z, Vector3D.Unit.MINUS_X);
+        checkPlane(Plane.fromNormal(Vector3D.of(0, 5, 0), TEST_PRECISION),
+                Vector3D.ZERO, Vector3D.Unit.MINUS_Z, Vector3D.Unit.MINUS_X);
 
-    @Test(expected=IllegalNormException.class)
-    public void testUAndVAreCollinear2() {
-        Plane.fromPointAndPlaneVectors(Vector3D.of(0, 0, 1), Vector3D.of(0, 0, 1), Vector3D.of(0, 0, -2), TEST_PRECISION);
-    }
-
-    @Test(expected=GeometryException.class)
-    public void testPointsDoNotConstituteAPlane() {
-        Plane.fromPoints(Vector3D.of(0, 0, 1), Vector3D.of(0, 0, 1), Vector3D.of(0, 1, 0), TEST_PRECISION);
+        checkPlane(Plane.fromNormal(Vector3D.Unit.PLUS_Z, TEST_PRECISION),
+                Vector3D.ZERO, Vector3D.Unit.PLUS_Y, Vector3D.Unit.MINUS_X);
+        checkPlane(Plane.fromNormal(Vector3D.of(0, 0, 0.01), TEST_PRECISION),
+                Vector3D.ZERO, Vector3D.Unit.PLUS_Y, Vector3D.Unit.MINUS_X);
     }
 
     @Test
-    public void testContains() {
-        Plane plane = Plane.fromPointAndNormal(Vector3D.of(0, 0, 1), Vector3D.of(0, 0, 1), TEST_PRECISION);
-        Assert.assertTrue(plane.contains(Vector3D.of(0, 0, 1)));
-        Assert.assertTrue(plane.contains(Vector3D.of(17, -32, 1)));
-        Assert.assertTrue(! plane.contains(Vector3D.of(17, -32, 1.001)));
+    public void testFromNormal_illegalArguments() {
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            Plane.fromNormal(Vector3D.ZERO, TEST_PRECISION);
+        }, IllegalNormException.class);
     }
 
     @Test
-    public void testContainsLine() {
-        Plane plane = Plane.fromPointAndNormal(Vector3D.of(0, 0, 1), Vector3D.of(0, 0, 1), TEST_PRECISION);
-        Line line = new Line(Vector3D.of(1, 0, 1), Vector3D.of(2, 0, 1), TEST_PRECISION);
-        Assert.assertTrue(plane.contains(line));
-    }
+    public void testFromPointAndNormal() {
+        // arrange
+        Vector3D pt = Vector3D.of(1, 2, 3);
 
-    @Test(expected=IllegalNormException.class)
-    public void testFromPointPlaneVectorsWithZeroVector()
-    {
-        Plane.fromPointAndPlaneVectors(Vector3D.of(0, 0, 1), Vector3D.ZERO, Vector3D.of(1,0,0), TEST_PRECISION);
-    }
-
-    @Test(expected=IllegalNormException.class)
-    public void testFromPointAndNormalWithZeroNormal()
-    {
-        Plane.fromPointAndNormal(Vector3D.of(0, 0, 1), Vector3D.ZERO, TEST_PRECISION);
-    }
-
-    @Test(expected=IllegalNormException.class)
-    public void testFromNormal()
-    {
-        Plane.fromNormal(Vector3D.ZERO, TEST_PRECISION);
+        // act/assert
+        checkPlane(Plane.fromPointAndNormal(pt, Vector3D.of(0.1, 0, 0), TEST_PRECISION),
+                Vector3D.of(1, 0, 0), Vector3D.Unit.PLUS_Z, Vector3D.Unit.MINUS_Y);
+        checkPlane(Plane.fromPointAndNormal(pt, Vector3D.of(0, 2, 0), TEST_PRECISION),
+                Vector3D.of(0, 2, 0), Vector3D.Unit.MINUS_Z, Vector3D.Unit.MINUS_X);
+        checkPlane(Plane.fromPointAndNormal(pt, Vector3D.of(0, 0, 5), TEST_PRECISION),
+                Vector3D.of(0, 0, 3), Vector3D.Unit.PLUS_Y, Vector3D.Unit.MINUS_X);
     }
 
     @Test
-    public void testIsParallelAndGetOffset()
-    {
-        Plane plane = Plane.fromPointAndNormal(Vector3D.of(0, 0, 1), Vector3D.of(0, 0, 1), TEST_PRECISION);
-        Line parallelLine = new Line(Vector3D.of(1, 0, 2), Vector3D.of(2, 0, 2), TEST_PRECISION);
-        Assert.assertTrue(plane.isParallel(parallelLine));
-        Assert.assertEquals(1.0, plane.getOffset(parallelLine), TEST_EPS);
-        Line nonParallelLine = new Line(Vector3D.of(1, 0, 2), Vector3D.of(2, 0, 1), TEST_PRECISION);
-        Assert.assertFalse(plane.isParallel(nonParallelLine));
-        Assert.assertEquals(0.0, plane.getOffset(nonParallelLine), TEST_EPS);
+    public void testFromPointAndNormal_illegalArguments() {
+        // arrange
+        Vector3D pt = Vector3D.of(1, 2, 3);
+
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            Plane.fromPointAndNormal(pt, Vector3D.ZERO, TEST_PRECISION);
+        }, IllegalNormException.class);
     }
 
     @Test
-    public void testCreation()
-    {
-        Vector3D normalAliasW =  Vector3D.of(0, 0, 1);
-        Plane plane = Plane.fromPointAndNormal(Vector3D.of(0, 0, 1),normalAliasW , TEST_PRECISION);
-        Assert.assertEquals(normalAliasW, plane.getW());
-        double expectedX = 1.0;
-        Assert.assertEquals(expectedX,  Math.abs(plane.getV().getX()), TEST_EPS);
-        double expectedY = 1.0;
-        Assert.assertEquals(expectedY,  Math.abs(plane.getU().getY()), TEST_EPS);
-        Assert.assertEquals(-1.0, plane.getOriginOffset(), TEST_EPS);
-        Vector3D expectedOrigin = Vector3D.of(0, 0, 1);
-        Assert.assertEquals(expectedOrigin, plane.getOrigin());
+    public void testFromPointAndPlaneVectors() {
+        // arrange
+        Vector3D pt = Vector3D.of(1, 2, 3);
+
+        // act/assert
+        checkPlane(Plane.fromPointAndPlaneVectors(pt, Vector3D.of(2, 0, 0), Vector3D.of(3, 0.1, 0),  TEST_PRECISION),
+                Vector3D.of(0, 0, 3), Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y);
+
+        checkPlane(Plane.fromPointAndPlaneVectors(pt, Vector3D.of(2, 0, 0), Vector3D.of(3, -0.1, 0),  TEST_PRECISION),
+                Vector3D.of(0, 0, 3), Vector3D.Unit.PLUS_X, Vector3D.Unit.MINUS_Y);
+
+        checkPlane(Plane.fromPointAndPlaneVectors(pt, Vector3D.of(0, 0.1, 0), Vector3D.of(0, -3, 1),  TEST_PRECISION),
+                Vector3D.of(1, 0, 0), Vector3D.Unit.PLUS_Y, Vector3D.Unit.PLUS_Z);
     }
 
     @Test
-    public void testReverse()
-    {
-        Vector3D normalAliasW =  Vector3D.of(0, 0, 1);
-        Plane plane = Plane.fromPointAndNormal(Vector3D.of(0, 0, 1),normalAliasW , TEST_PRECISION);
-        Assert.assertEquals(-1.0, plane.getOriginOffset(), TEST_EPS);
-        Vector3D p1 = Vector3D.of(1,0,1);
-        Assert.assertTrue(plane.contains(p1));
-        Plane reversePlane = plane.reverse();
-        Assert.assertEquals(1.0, reversePlane.getOriginOffset(), TEST_EPS);
-        Vector3D p1XYswapped = Vector3D.of(0,1,1);
-        Assert.assertTrue(reversePlane.contains(p1XYswapped));
+    public void testFromPointAndPlaneVectors_illegalArguments() {
+        // arrange
+        Vector3D pt = Vector3D.of(1, 2, 3);
+
+        // act/assert
+
+        // identical vectors
+        GeometryTestUtils.assertThrows(() -> {
+            Plane.fromPointAndPlaneVectors(pt, Vector3D.of(0, 0, 1), Vector3D.of(0, 0, 1), TEST_PRECISION);
+        }, IllegalNormException.class);
+
+        // zero vector
+        GeometryTestUtils.assertThrows(() -> {
+            Plane.fromPointAndPlaneVectors(pt, Vector3D.of(0, 0, 1), Vector3D.ZERO, TEST_PRECISION);
+        }, IllegalNormException.class);
+
+        // collinear vectors
+        GeometryTestUtils.assertThrows(() -> {
+            Plane.fromPointAndPlaneVectors(pt, Vector3D.of(0, 0, 1), Vector3D.of(0, 0, 2), TEST_PRECISION);
+        }, IllegalNormException.class);
+
+        // collinear vectors - reversed
+        GeometryTestUtils.assertThrows(() -> {
+            Plane.fromPointAndPlaneVectors(pt, Vector3D.of(0, 0, 1), Vector3D.of(0, 0, -2), TEST_PRECISION);
+        }, IllegalNormException.class);
     }
 
     @Test
-    public void testIsPlaneParallel()
-    {
-        Plane plane = Plane.fromPointAndNormal(Vector3D.of(0, 0, 1), Vector3D.of(0, 0, 1), TEST_PRECISION);
-        Plane parallelPlane = Plane.fromPointAndNormal(Vector3D.of(0, 0, 1), Vector3D.of(0, 0, 1), TEST_PRECISION);
-        Plane parallelPlane2 = Plane.fromPointAndNormal(Vector3D.of(0, 0, 2), Vector3D.of(0, 0, 1), TEST_PRECISION);
-        Assert.assertTrue(plane.isParallel(parallelPlane));
-        Assert.assertTrue(plane.isParallel(parallelPlane2));
-        Plane nonParallelPlane = Plane.fromPointAndPlaneVectors(Vector3D.of(0, 0, 1), Vector3D.of(1, 1.5, 1), Vector3D.of(0,1,1), TEST_PRECISION);
-        Assert.assertFalse(plane.isParallel(nonParallelPlane));
+    public void testFromPoints() {
+        // arrange
+        Vector3D a = Vector3D.of(1, 1, 1);
+        Vector3D b = Vector3D.of(1, 1, 4.3);
+        Vector3D c = Vector3D.of(2.5, 1, 1);
+
+        // act/assert
+        checkPlane(Plane.fromPoints(a, b, c, TEST_PRECISION),
+                Vector3D.of(0, 1, 0), Vector3D.Unit.PLUS_Z, Vector3D.Unit.PLUS_X);
+
+        checkPlane(Plane.fromPoints(a, c, b, TEST_PRECISION),
+                Vector3D.of(0, 1, 0), Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Z);
     }
 
     @Test
-    public void testProjectLine() {
-        Plane plane = Plane.fromPointAndNormal(Vector3D.of(0, 0, 1), Vector3D.of(0, 0, 1), TEST_PRECISION);
-        Line line = new Line(Vector3D.of(1, 0, 1), Vector3D.of(2, 0, 2), TEST_PRECISION);
-        Line expectedProjection = new Line(Vector3D.of(1, 0, 1),Vector3D.of(2, 0, 1), TEST_PRECISION);
-        Assert.assertEquals(expectedProjection, plane.project(line));
-    }
-
-    @Test
-    public void testOffset() {
-        Vector3D p1 = Vector3D.of(1, 1, 1);
-        Plane plane = Plane.fromPointAndNormal(p1, Vector3D.of(0.2, 0, 0), TEST_PRECISION);
-        Assert.assertEquals(-5.0, plane.getOffset(Vector3D.of(-4, 0, 0)), TEST_EPS);
-        Assert.assertEquals(+5.0, plane.getOffset(Vector3D.of(6, 10, -12)), TEST_EPS);
-        Assert.assertEquals(0.3,
-                            plane.getOffset(Vector3D.linearCombination(1.0, p1, 0.3, plane.getNormal())),
-                            TEST_EPS);
-        Assert.assertEquals(-0.3,
-                            plane.getOffset(Vector3D.linearCombination(1.0, p1, -0.3, plane.getNormal())),
-                            TEST_EPS);
-    }
-
-    @Test(expected=IllegalNormException.class)
-    public void testVectorsAreColinear()
-    {
-      Plane.fromPointAndPlaneVectors(Vector3D.of(1, 1, 1), Vector3D.of(2, 0, 0), Vector3D.of(2,0,0), TEST_PRECISION);
-    }
-
-
-    @Test
-    public void testVectorsAreNormalizedForSuppliedUAndV() {
-        Plane plane = Plane.fromPointAndPlaneVectors(Vector3D.of(1, 1, 1), Vector3D.of(2, 0, 0), Vector3D.of(0,2,0), TEST_PRECISION);
-        Assert.assertEquals(1.0, plane.getNormal().norm(), TEST_EPS);
-        Assert.assertEquals(1.0, plane.getV().norm(), TEST_EPS);
-        Assert.assertEquals(1.0, plane.getU().norm(), TEST_EPS);
-    }
-
-
-
-    @Test
-    public void testVectorsAreNormalized() {
-        Plane plane = Plane.fromPointAndNormal(Vector3D.of(2, -3, 1), Vector3D.of(1, 4, 9), TEST_PRECISION);
-        Assert.assertEquals(1.0, plane.getNormal().norm(), TEST_EPS);
-        Assert.assertEquals(1.0, plane.getV().norm(), TEST_EPS);
-        Assert.assertEquals(1.0, plane.getU().norm(), TEST_EPS);
-    }
-
-
-    @Test
-    public void testPoint() {
-        Plane plane = Plane.fromPointAndNormal(Vector3D.of(2, -3, 1), Vector3D.of(1, 4, 9), TEST_PRECISION);
-        Assert.assertTrue(plane.contains(plane.getOrigin()));
-    }
-
-    @Test
-    public void testThreePoints() {
+    public void testFromPoints_planeContainsSourcePoints() {
+        // arrange
         Vector3D p1 = Vector3D.of(1.2, 3.4, -5.8);
         Vector3D p2 = Vector3D.of(3.4, -5.8, 1.2);
         Vector3D p3 = Vector3D.of(-2.0, 4.3, 0.7);
-        Plane    plane  = Plane.fromPoints(p1, p2, p3, TEST_PRECISION);
+
+        // act
+        Plane plane  = Plane.fromPoints(p1, p2, p3, TEST_PRECISION);
+
+        // assert
         Assert.assertTrue(plane.contains(p1));
         Assert.assertTrue(plane.contains(p2));
         Assert.assertTrue(plane.contains(p3));
     }
 
     @Test
+    public void testFromPoints_illegalArguments() {
+        // arrange
+        Vector3D a = Vector3D.of(1, 0, 0);
+        Vector3D b = Vector3D.of(0, 1, 0);
+
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            Plane.fromPoints(a, a, a, TEST_PRECISION);
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            Plane.fromPoints(a, a, b, TEST_PRECISION);
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            Plane.fromPoints(a, b, a, TEST_PRECISION);
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            Plane.fromPoints(b, a, a, TEST_PRECISION);
+        }, GeometryException.class);
+    }
+
+    @Test
     public void testFromPoints_collection_threePoints() {
         // arrange
         List<Vector3D> pts = Arrays.asList(
@@ -450,105 +433,655 @@
     }
 
     @Test
-    public void testRotate() {
+    public void testContains_point() {
+        // arrange
+        Plane plane = Plane.fromPointAndNormal(Vector3D.of(0, 0, 1), Vector3D.of(0, 0, 1), TEST_PRECISION);
+        double halfEps = 0.5 * TEST_EPS;
+
+        // act/assert
+        EuclideanTestUtils.permute(-100, 100, 5, (x, y) -> {
+
+            Assert.assertTrue(plane.contains(Vector3D.of(x, y, 1)));
+            Assert.assertTrue(plane.contains(Vector3D.of(x, y, 1 + halfEps)));
+            Assert.assertTrue(plane.contains(Vector3D.of(x, y, 1 - halfEps)));
+
+            Assert.assertFalse(plane.contains(Vector3D.of(x, y, 0.5)));
+            Assert.assertFalse(plane.contains(Vector3D.of(x, y, 1.5)));
+        });
+    }
+
+    @Test
+    public void testContains_line() {
+        // arrange
+        Plane plane = Plane.fromPointAndNormal(Vector3D.ZERO, Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+
+        // act/assert
+        Assert.assertTrue(plane.contains(
+                Line3D.fromPoints(Vector3D.of(1, 0, 0), Vector3D.of(2, 0, 0), TEST_PRECISION)));
+        Assert.assertTrue(plane.contains(
+                Line3D.fromPoints(Vector3D.of(-1, 0, 0), Vector3D.of(-2, 0, 0), TEST_PRECISION)));
+
+        Assert.assertFalse(plane.contains(
+                Line3D.fromPoints(Vector3D.of(1, 0, 2), Vector3D.of(2, 0, 2), TEST_PRECISION)));
+        Assert.assertFalse(plane.contains(
+                Line3D.fromPoints(Vector3D.ZERO, Vector3D.of(2, 0, 2), TEST_PRECISION)));
+    }
+
+    @Test
+    public void testContains_plane() {
+        // arrange
         Vector3D p1 = Vector3D.of(1.2, 3.4, -5.8);
         Vector3D p2 = Vector3D.of(3.4, -5.8, 1.2);
         Vector3D p3 = Vector3D.of(-2.0, 4.3, 0.7);
-        Plane    plane  = Plane.fromPoints(p1, p2, p3, TEST_PRECISION);
+        Plane planeA  = Plane.fromPoints(p1, p2, p3, TEST_PRECISION);
+
+        // act/assert
+        Assert.assertTrue(planeA.contains(planeA));
+        Assert.assertTrue(planeA.contains(Plane.fromPoints(p1, p3, p2, TEST_PRECISION)));
+        Assert.assertTrue(planeA.contains(Plane.fromPoints(p3, p1, p2, TEST_PRECISION)));
+        Assert.assertTrue(planeA.contains(Plane.fromPoints(p3, p2, p1, TEST_PRECISION)));
+
+        Assert.assertFalse(planeA.contains(Plane.fromPoints(p1, Vector3D.of(11.4, -3.8, 5.1), p2, TEST_PRECISION)));
+
+        Vector3D offset = planeA.getNormal().multiply(1e-8);
+        Assert.assertFalse(planeA.contains(Plane.fromPoints(p1.add(offset), p2, p3, TEST_PRECISION)));
+        Assert.assertFalse(planeA.contains(Plane.fromPoints(p1, p2.add(offset), p3, TEST_PRECISION)));
+        Assert.assertFalse(planeA.contains(Plane.fromPoints(p1, p2, p3.add(offset), TEST_PRECISION)));
+
+        Assert.assertFalse(planeA.contains(Plane.fromPoints(p1.add(offset),
+                p2.add(offset),
+                p3.add(offset), TEST_PRECISION)));
+    }
+
+    @Test
+    public void testReverse() {
+        // arrange
+        Vector3D pt = Vector3D.of(0, 0, 1);
+        Plane plane = Plane.fromPointAndPlaneVectors(pt, Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
+
+        // act
+        Plane reversed = plane.reverse();
+
+        // assert
+        checkPlane(reversed, pt, Vector3D.Unit.PLUS_Y, Vector3D.Unit.PLUS_X);
+
+        Assert.assertTrue(reversed.contains(Vector3D.of(1, 1, 1)));
+        Assert.assertTrue(reversed.contains(Vector3D.of(-1, -1, 1)));
+        Assert.assertFalse(reversed.contains(Vector3D.ZERO));
+
+        Assert.assertEquals(1.0, reversed.offset(Vector3D.ZERO), TEST_EPS);
+    }
+
+    @Test
+    public void testIsParallelAndOffset_line() {
+        // arrange
+        Plane plane = Plane.fromPointAndNormal(Vector3D.of(0, 0, 1), Vector3D.of(0, 0, 1), TEST_PRECISION);
+
+        Line3D parallelLine = Line3D.fromPoints(Vector3D.of(1, 0, 2), Vector3D.of(2, 0, 2), TEST_PRECISION);
+        Line3D nonParallelLine = Line3D.fromPoints(Vector3D.of(1, 0, 2), Vector3D.of(2, 0, 1), TEST_PRECISION);
+        Line3D containedLine = Line3D.fromPoints(Vector3D.of(2, 0, 1), Vector3D.of(1, 0, 1), TEST_PRECISION);
+
+        // act
+        Assert.assertTrue(plane.isParallel(parallelLine));
+        Assert.assertEquals(1.0, plane.offset(parallelLine), TEST_EPS);
+
+        Assert.assertFalse(plane.isParallel(nonParallelLine));
+        Assert.assertEquals(0.0, plane.offset(nonParallelLine), TEST_EPS);
+
+        Assert.assertTrue(plane.isParallel(containedLine));
+        Assert.assertEquals(0.0, plane.offset(containedLine), TEST_EPS);
+    }
+
+    @Test
+    public void testIsParallelAndOffset_plane() {
+        // arrange
+        Plane plane = Plane.fromPointAndNormal(Vector3D.of(0, 0, 1), Vector3D.of(0, 0, 1), TEST_PRECISION);
+        Plane parallelPlane = Plane.fromPointAndNormal(Vector3D.of(0, 0, 1), Vector3D.of(0, 0, 1), TEST_PRECISION);
+        Plane parallelPlane2 = Plane.fromPointAndNormal(Vector3D.of(0, 0, 2), Vector3D.of(0, 0, 1), TEST_PRECISION);
+        Plane parallelPlane3 = Plane.fromPointAndNormal(Vector3D.ZERO, Vector3D.of(0, 0, 1), TEST_PRECISION).reverse();
+        Plane nonParallelPlane = Plane.fromPointAndPlaneVectors(Vector3D.of(0, 0, 1), Vector3D.of(1, 1.5, 1), Vector3D.of(0,1,1), TEST_PRECISION);
+        Plane reversedPlane = plane.reverse();
+
+        // act/assert
+        Assert.assertTrue(plane.isParallel(parallelPlane));
+        Assert.assertEquals(0.0, plane.offset(parallelPlane), TEST_EPS);
+
+        Assert.assertTrue(plane.isParallel(parallelPlane2));
+        Assert.assertEquals(1.0, plane.offset(parallelPlane2), TEST_EPS);
+
+        Assert.assertTrue(plane.isParallel(parallelPlane3));
+        Assert.assertEquals(-1.0, plane.offset(parallelPlane3), TEST_EPS);
+
+        Assert.assertFalse(plane.isParallel(nonParallelPlane));
+        Assert.assertEquals(0.0, plane.offset(nonParallelPlane), TEST_EPS);
+
+        Assert.assertTrue(plane.isParallel(reversedPlane));
+        Assert.assertEquals(0.0, plane.offset(nonParallelPlane), TEST_EPS);
+    }
+
+    @Test
+    public void testProject_line() {
+        // arrange
+        Plane plane = Plane.fromPointAndNormal(Vector3D.Unit.PLUS_Z, Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+        Line3D line = Line3D.fromPoints(Vector3D.of(1, 0, 1), Vector3D.of(2, 0, 2), TEST_PRECISION);
+
+        // act
+        Line3D projected = plane.project(line);
+
+        // assert
+        Line3D expectedProjection = Line3D.fromPoints(Vector3D.of(1, 0, 1),Vector3D.of(2, 0, 1), TEST_PRECISION);
+        Assert.assertEquals(expectedProjection, projected);
+
+        Assert.assertTrue(plane.contains(projected));
+
+        Assert.assertTrue(projected.contains(Vector3D.of(1, 0, 1)));
+        Assert.assertTrue(projected.contains(Vector3D.of(2, 0, 1)));
+    }
+
+    @Test
+    public void testOffset_point() {
+        // arrange
+        Vector3D p1 = Vector3D.of(1, 1, 1);
+        Plane plane = Plane.fromPointAndNormal(p1, Vector3D.of(0.2, 0, 0), TEST_PRECISION);
+
+        // act/assert
+        Assert.assertEquals(-5.0, plane.offset(Vector3D.of(-4, 0, 0)), TEST_EPS);
+        Assert.assertEquals(+5.0, plane.offset(Vector3D.of(6, 10, -12)), TEST_EPS);
+        Assert.assertEquals(0.3,
+                            plane.offset(Vector3D.linearCombination(1.0, p1, 0.3, plane.getNormal())),
+                            TEST_EPS);
+        Assert.assertEquals(-0.3,
+                            plane.offset(Vector3D.linearCombination(1.0, p1, -0.3, plane.getNormal())),
+                            TEST_EPS);
+    }
+
+    @Test
+    public void testPointAt() {
+        // arrange
+        Vector3D pt = Vector3D.of(0, 0, 1);
+        Plane plane = Plane.fromPointAndPlaneVectors(pt, Vector3D.Unit.PLUS_Y, Vector3D.Unit.MINUS_X, TEST_PRECISION);
+
+        // act/assert
+        EuclideanTestUtils.assertCoordinatesEqual(pt, plane.pointAt(Vector2D.ZERO, 0), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.ZERO, plane.pointAt(Vector2D.ZERO, -1), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 0, -1), plane.pointAt(Vector2D.ZERO, -2), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 0, 2), plane.pointAt(Vector2D.ZERO, 1), TEST_EPS);
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(-1, 2, 1), plane.pointAt(Vector2D.of(2, 1), 0), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(4, -3, 6), plane.pointAt(Vector2D.of(-3, -4), 5), TEST_EPS);
+    }
+
+    @Test
+    public void testTransform_rotationAroundPoint() {
+        // arrange
+        Vector3D pt = Vector3D.of(0, 0, 1);
+        Plane plane = Plane.fromPointAndPlaneVectors(pt, Vector3D.Unit.PLUS_Y, Vector3D.Unit.MINUS_X, TEST_PRECISION);
+
+        AffineTransformMatrix3D mat = AffineTransformMatrix3D.createRotation(pt,
+                QuaternionRotation.fromAxisAngle(Vector3D.Unit.PLUS_Y, Geometry.HALF_PI));
+
+        // act
+        Plane result = plane.transform(mat);
+
+        // assert
+        checkPlane(result, Vector3D.ZERO, Vector3D.Unit.PLUS_Y, Vector3D.Unit.PLUS_Z);
+    }
+
+    @Test
+    public void testTransform_asymmetricScaling() {
+        // arrange
+        Vector3D pt = Vector3D.of(0, 1, 0);
+        Plane plane = Plane.fromPointAndPlaneVectors(pt, Vector3D.Unit.MINUS_Z, Vector3D.of(-1, 1, 0), TEST_PRECISION);
+
+        AffineTransformMatrix3D mat = AffineTransformMatrix3D.createScale(2, 1, 1);
+
+        // act
+        Plane result = plane.transform(mat);
+
+        // assert
+        Vector3D expectedU = Vector3D.Unit.MINUS_Z;
+        Vector3D expectedV = Vector3D.Unit.of(-2, 1, 0);
+        Vector3D expectedNormal = Vector3D.Unit.of(1, 2, 0);
+
+        Vector3D transformedPt = mat.apply(plane.getOrigin());
+        Vector3D expectedOrigin = transformedPt.project(expectedNormal);
+
+        checkPlane(result, expectedOrigin, expectedU, expectedV);
+
+        Assert.assertTrue(result.contains(transformedPt));
+        Assert.assertFalse(plane.contains(transformedPt));
+    }
+
+    @Test
+    public void testTransform_negateOneComponent() {
+        // arrange
+        Vector3D pt = Vector3D.of(0, 0, 1);
+        Plane plane = Plane.fromPointAndPlaneVectors(pt, Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
+
+        Transform3D transform = FunctionTransform3D.from(v -> Vector3D.of(-v.getX(), v.getY(), v.getZ()));
+
+        // act
+        Plane result = plane.transform(transform);
+
+        // assert
+        checkPlane(result, Vector3D.of(0, 0, 1), Vector3D.Unit.MINUS_X, Vector3D.Unit.PLUS_Y);
+    }
+
+    @Test
+    public void testTransform_negateTwoComponents() {
+        // arrange
+        Vector3D pt = Vector3D.of(0, 0, 1);
+        Plane plane = Plane.fromPointAndPlaneVectors(pt, Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
+
+        Transform3D transform = FunctionTransform3D.from(v -> Vector3D.of(-v.getX(), -v.getY(), v.getZ()));
+
+        // act
+        Plane result = plane.transform(transform);
+
+        // assert
+        checkPlane(result, Vector3D.of(0, 0, 1), Vector3D.Unit.MINUS_X, Vector3D.Unit.MINUS_Y);
+    }
+
+    @Test
+    public void testTransform_negateAllComponents() {
+        // arrange
+        Vector3D pt = Vector3D.of(0, 0, 1);
+        Plane plane = Plane.fromPointAndPlaneVectors(pt, Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
+
+        Transform3D transform = FunctionTransform3D.from(Vector3D::negate);
+
+        // act
+        Plane result = plane.transform(transform);
+
+        // assert
+        checkPlane(result, Vector3D.of(0, 0, -1), Vector3D.Unit.MINUS_X, Vector3D.Unit.MINUS_Y);
+    }
+
+    @Test
+    public void testSubspaceTransform() {
+        // arrange
+        Plane plane = Plane.fromPointAndPlaneVectors(Vector3D.of(0, 0, 1), Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
+
+        // act/assert
+        checkSubspaceTransform(plane.subspaceTransform(AffineTransformMatrix3D.createScale(2, 3, 4)),
+                Vector3D.of(0, 0, 4), Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y,
+                Vector3D.of(0, 0, 4), Vector3D.of(2, 0, 4), Vector3D.of(0, 3, 4));
+
+        checkSubspaceTransform(plane.subspaceTransform(AffineTransformMatrix3D.createTranslation(2, 3, 4)),
+                Vector3D.of(0, 0, 5), Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y,
+                Vector3D.of(2, 3, 5), Vector3D.of(3, 3, 5), Vector3D.of(2, 4, 5));
+
+        checkSubspaceTransform(plane.subspaceTransform(QuaternionRotation.fromAxisAngle(Vector3D.Unit.PLUS_Y, Geometry.HALF_PI)),
+                Vector3D.of(1, 0, 0), Vector3D.Unit.MINUS_Z, Vector3D.Unit.PLUS_Y,
+                Vector3D.of(1, 0, 0), Vector3D.of(1, 0, -1), Vector3D.of(1, 1, 0));
+    }
+
+    private void checkSubspaceTransform(SubspaceTransform st,
+            Vector3D origin, Vector3D u, Vector3D v,
+            Vector3D tOrigin, Vector3D tU, Vector3D tV) {
+
+        Plane plane = st.getPlane();
+        AffineTransformMatrix2D transform = st.getTransform();
+
+        checkPlane(plane, origin, u, v);
+
+        EuclideanTestUtils.assertCoordinatesEqual(tOrigin, plane.toSpace(transform.apply(Vector2D.ZERO)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(tU, plane.toSpace(transform.apply(Vector2D.Unit.PLUS_X)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(tV, plane.toSpace(transform.apply(Vector2D.Unit.PLUS_Y)), TEST_EPS);
+    }
+
+    @Test
+    public void testSubspaceTransform_transformsPointsCorrectly() {
+        // arrange
+        Plane plane = Plane.fromPointAndNormal(Vector3D.of(1, 2, 3), Vector3D.of(1, 1, 1), TEST_PRECISION);
+
+        EuclideanTestUtils.permuteSkipZero(-2, 2, 0.5, (a, b, c) -> {
+            // create a somewhat complicate transform to try to hit all of the edge cases
+            AffineTransformMatrix3D transform = AffineTransformMatrix3D.createTranslation(Vector3D.of(a, b, c))
+                    .rotate(QuaternionRotation.fromAxisAngle(Vector3D.of(b, c, a), Geometry.PI * c))
+                    .scale(0.1, 4, 8);
+
+            // act
+            SubspaceTransform st = plane.subspaceTransform(transform);
+
+            // assert
+            EuclideanTestUtils.permute(-5, 5, 1, (x, y) -> {
+                Vector2D subPt = Vector2D.of(x, y);
+                Vector3D expected = transform.apply(plane.toSpace(subPt));
+                Vector3D actual = st.getPlane().toSpace(
+                        st.getTransform().apply(subPt));
+
+                EuclideanTestUtils.assertCoordinatesEqual(expected, actual, TEST_EPS);
+            });
+        });
+    }
+
+    @Test
+    public void testRotate() {
+        // arrange
+        Vector3D p1 = Vector3D.of(1.2, 3.4, -5.8);
+        Vector3D p2 = Vector3D.of(3.4, -5.8, 1.2);
+        Vector3D p3 = Vector3D.of(-2.0, 4.3, 0.7);
+        Plane plane  = Plane.fromPoints(p1, p2, p3, TEST_PRECISION);
         Vector3D oldNormal = plane.getNormal();
 
+        // act/assert
         plane = plane.rotate(p2, QuaternionRotation.fromAxisAngle(p2.subtract(p1), 1.7));
         Assert.assertTrue(plane.contains(p1));
         Assert.assertTrue(plane.contains(p2));
-        Assert.assertTrue(! plane.contains(p3));
+        Assert.assertFalse(plane.contains(p3));
 
         plane = plane.rotate(p2, QuaternionRotation.fromAxisAngle(oldNormal, 0.1));
-        Assert.assertTrue(! plane.contains(p1));
+        Assert.assertFalse(plane.contains(p1));
         Assert.assertTrue(plane.contains(p2));
-        Assert.assertTrue(! plane.contains(p3));
+        Assert.assertFalse(plane.contains(p3));
 
         plane = plane.rotate(p1, QuaternionRotation.fromAxisAngle(oldNormal, 0.1));
-        Assert.assertTrue(! plane.contains(p1));
-        Assert.assertTrue(! plane.contains(p2));
-        Assert.assertTrue(! plane.contains(p3));
-
+        Assert.assertFalse(plane.contains(p1));
+        Assert.assertFalse(plane.contains(p2));
+        Assert.assertFalse(plane.contains(p3));
     }
 
     @Test
     public void testTranslate() {
+        // arrange
         Vector3D p1 = Vector3D.of(1.2, 3.4, -5.8);
         Vector3D p2 = Vector3D.of(3.4, -5.8, 1.2);
         Vector3D p3 = Vector3D.of(-2.0, 4.3, 0.7);
-        Plane    plane  = Plane.fromPoints(p1, p2, p3, TEST_PRECISION);
+        Plane plane  = Plane.fromPoints(p1, p2, p3, TEST_PRECISION);
 
+        // act/assert
         plane = plane.translate(Vector3D.linearCombination(2.0, plane.getU(), -1.5, plane.getV()));
         Assert.assertTrue(plane.contains(p1));
         Assert.assertTrue(plane.contains(p2));
         Assert.assertTrue(plane.contains(p3));
 
         plane = plane.translate(Vector3D.linearCombination(-1.2, plane.getNormal()));
-        Assert.assertTrue(! plane.contains(p1));
-        Assert.assertTrue(! plane.contains(p2));
-        Assert.assertTrue(! plane.contains(p3));
+        Assert.assertFalse(plane.contains(p1));
+        Assert.assertFalse(plane.contains(p2));
+        Assert.assertFalse(plane.contains(p3));
 
         plane = plane.translate(Vector3D.linearCombination(+1.2, plane.getNormal()));
         Assert.assertTrue(plane.contains(p1));
         Assert.assertTrue(plane.contains(p2));
         Assert.assertTrue(plane.contains(p3));
-
     }
 
     @Test
-    public void testIntersection() {
+    public void testIntersection_withLine() {
+        // arrange
         Plane plane = Plane.fromPointAndNormal(Vector3D.of(1, 2, 3), Vector3D.of(-4, 1, -5), TEST_PRECISION);
-        Line  line = new Line(Vector3D.of(0.2, -3.5, 0.7), Vector3D.of(1.2, -2.5, -0.3), TEST_PRECISION);
+        Line3D line = Line3D.fromPoints(Vector3D.of(0.2, -3.5, 0.7), Vector3D.of(1.2, -2.5, -0.3), TEST_PRECISION);
+
+        // act
         Vector3D point = plane.intersection(line);
+
+        // assert
         Assert.assertTrue(plane.contains(point));
         Assert.assertTrue(line.contains(point));
-        Assert.assertNull(plane.intersection(new Line(Vector3D.of(10, 10, 10),
+        Assert.assertNull(plane.intersection(Line3D.fromPoints(Vector3D.of(10, 10, 10),
                                                   Vector3D.of(10, 10, 10).add(plane.getNormal().orthogonal()),
                                                   TEST_PRECISION)));
     }
 
     @Test
-    public void testIntersection2() {
-        Vector3D p1  = Vector3D.of(1.2, 3.4, -5.8);
-        Vector3D p2  = Vector3D.of(3.4, -5.8, 1.2);
-        Plane    planeA  = Plane.fromPoints(p1, p2, Vector3D.of(-2.0, 4.3, 0.7), TEST_PRECISION);
-        Plane    planeB  = Plane.fromPoints(p1, Vector3D.of(11.4, -3.8, 5.1), p2, TEST_PRECISION);
-        Line     line   = planeA.intersection(planeB);
+    public void testIntersection_withLine_noIntersection() {
+        // arrange
+        Vector3D pt = Vector3D.of(1, 2, 3);
+        Vector3D normal = Vector3D.of(-4, 1, -5);
+
+        Plane plane = Plane.fromPointAndNormal(pt, normal, TEST_PRECISION);
+
+        // act/assert
+        Assert.assertNull(plane.intersection(Line3D.fromPoints(pt, pt.add(plane.getU()), TEST_PRECISION)));
+
+        Vector3D offsetPt = pt.add(plane.getNormal());
+        Assert.assertNull(plane.intersection(Line3D.fromPoints(offsetPt, offsetPt.add(plane.getV()), TEST_PRECISION)));
+    }
+
+    @Test
+    public void testIntersection_withPlane() {
+        // arrange
+        Vector3D p1 = Vector3D.of(1.2, 3.4, -5.8);
+        Vector3D p2 = Vector3D.of(3.4, -5.8, 1.2);
+        Plane planeA = Plane.fromPoints(p1, p2, Vector3D.of(-2.0, 4.3, 0.7), TEST_PRECISION);
+        Plane planeB = Plane.fromPoints(p1, Vector3D.of(11.4, -3.8, 5.1), p2, TEST_PRECISION);
+
+        // act
+        Line3D line = planeA.intersection(planeB);
+
+        // assert
         Assert.assertTrue(line.contains(p1));
         Assert.assertTrue(line.contains(p2));
+        EuclideanTestUtils.assertCoordinatesEqual(planeA.getNormal().cross(planeB.getNormal()).normalize(),
+                line.getDirection(), TEST_EPS);
+
         Assert.assertNull(planeA.intersection(planeA));
     }
 
     @Test
-    public void testIntersection3() {
-        Vector3D reference = Vector3D.of(1.2, 3.4, -5.8);
-        Plane p1 = Plane.fromPointAndNormal(reference, Vector3D.of(1, 3, 3), TEST_PRECISION);
-        Plane p2 = Plane.fromPointAndNormal(reference, Vector3D.of(-2, 4, 0), TEST_PRECISION);
-        Plane p3 = Plane.fromPointAndNormal(reference, Vector3D.of(7, 0, -4), TEST_PRECISION);
-        Vector3D plane = Plane.intersection(p1, p2, p3);
-        Assert.assertEquals(reference.getX(), plane.getX(), TEST_EPS);
-        Assert.assertEquals(reference.getY(), plane.getY(), TEST_EPS);
-        Assert.assertEquals(reference.getZ(), plane.getZ(), TEST_EPS);
+    public void testIntersection_withPlane_noIntersection() {
+        // arrange
+        Plane plane = Plane.fromPointAndNormal(Vector3D.Unit.PLUS_Z, Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+
+        // act/assert
+        Assert.assertNull(plane.intersection(plane));
+        Assert.assertNull(plane.intersection(plane.reverse()));
+
+        Assert.assertNull(plane.intersection(Plane.fromPointAndNormal(Vector3D.ZERO, Vector3D.Unit.PLUS_Z, TEST_PRECISION)));
+        Assert.assertNull(plane.intersection(Plane.fromPointAndNormal(Vector3D.of(0, 0, 2), Vector3D.Unit.PLUS_Z, TEST_PRECISION)));
     }
 
     @Test
-    public void testSimilar() {
-        Vector3D p1  = Vector3D.of(1.2, 3.4, -5.8);
-        Vector3D p2  = Vector3D.of(3.4, -5.8, 1.2);
-        Vector3D p3  = Vector3D.of(-2.0, 4.3, 0.7);
-        Plane    planeA  = Plane.fromPoints(p1, p2, p3, TEST_PRECISION);
-        Plane    planeB  = Plane.fromPoints(p1, Vector3D.of(11.4, -3.8, 5.1), p2, TEST_PRECISION);
-        Assert.assertTrue(! planeA.contains(planeB));
-        Assert.assertTrue(planeA.contains(planeA));
-        Assert.assertTrue(planeA.contains(Plane.fromPoints(p1, p3, p2, TEST_PRECISION)));
-        Vector3D shift = Vector3D.linearCombination(0.3, planeA.getNormal());
-        Assert.assertTrue(! planeA.contains(Plane.fromPoints(p1.add(shift),
-                                                     p3.add(shift),
-                                                     p2.add(shift),
-                                                     TEST_PRECISION)));
+    public void testIntersection_threePlanes() {
+        // arrange
+        Vector3D pt = Vector3D.of(1.2, 3.4, -5.8);
+        Plane a = Plane.fromPointAndNormal(pt, Vector3D.of(1, 3, 3), TEST_PRECISION);
+        Plane b = Plane.fromPointAndNormal(pt, Vector3D.of(-2, 4, 0), TEST_PRECISION);
+        Plane c = Plane.fromPointAndNormal(pt, Vector3D.of(7, 0, -4), TEST_PRECISION);
+
+        // act
+        Vector3D result = Plane.intersection(a, b, c);
+
+        // assert
+        EuclideanTestUtils.assertCoordinatesEqual(pt, result, TEST_EPS);
+    }
+
+    @Test
+    public void testIntersection_threePlanes_intersectInLine() {
+        // arrange
+        Plane a = Plane.fromPointAndNormal(Vector3D.ZERO, Vector3D.of(1, 0, 0), TEST_PRECISION);
+        Plane b = Plane.fromPointAndNormal(Vector3D.ZERO, Vector3D.of(1, 0.5, 0), TEST_PRECISION);
+        Plane c = Plane.fromPointAndNormal(Vector3D.ZERO, Vector3D.of(1, 1, 0), TEST_PRECISION);
+
+        // act
+        Vector3D result = Plane.intersection(a, b, c);
+
+        // assert
+        Assert.assertNull(result);
+    }
+
+    @Test
+    public void testIntersection_threePlanes_twoParallel() {
+        // arrange
+        Plane a = Plane.fromPointAndNormal(Vector3D.ZERO, Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+        Plane b = Plane.fromPointAndNormal(Vector3D.ZERO, Vector3D.Unit.PLUS_X, TEST_PRECISION);
+        Plane c = Plane.fromPointAndNormal(Vector3D.of(0, 0, 1), Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+
+        // act
+        Vector3D result = Plane.intersection(a, b, c);
+
+        // assert
+        Assert.assertNull(result);
+    }
+
+    @Test
+    public void testIntersection_threePlanes_allParallel() {
+        // arrange
+        Plane a = Plane.fromPointAndNormal(Vector3D.ZERO, Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+        Plane b = Plane.fromPointAndNormal(Vector3D.of(0, 0, 1), Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+        Plane c = Plane.fromPointAndNormal(Vector3D.of(0, 0, 2), Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+
+        // act
+        Vector3D result = Plane.intersection(a, b, c);
+
+        // assert
+        Assert.assertNull(result);
+    }
+
+    @Test
+    public void testIntersection_threePlanes_coincidentPlanes() {
+        // arrange
+        Plane a = Plane.fromPointAndNormal(Vector3D.ZERO, Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+        Plane b = Plane.fromPointAndNormal(Vector3D.ZERO, Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+        Plane c = b.reverse();
+
+        // act
+        Vector3D result = Plane.intersection(a, b, c);
+
+        // assert
+        Assert.assertNull(result);
+    }
+
+    @Test
+    public void testSpan() {
+        // arrange
+        Plane plane = Plane.fromPointAndNormal(Vector3D.ZERO, Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+
+        // act
+        ConvexSubPlane sub = plane.span();
+
+        // assert
+        Assert.assertSame(plane, sub.getPlane());
+        Assert.assertTrue(sub.isFull());
+
+        Assert.assertTrue(sub.contains(Vector3D.ZERO));
+        Assert.assertTrue(sub.contains(Vector3D.of(1, 1, 0)));
+
+        Assert.assertFalse(sub.contains(Vector3D.of(0, 0, 1)));
+    }
+
+    @Test
+    public void testSimilarOrientation() {
+        // arrange
+        Plane plane = Plane.fromNormal(Vector3D.of(1, 0, 0), TEST_PRECISION);
+
+        // act/assert
+        Assert.assertTrue(plane.similarOrientation(plane));
+        Assert.assertTrue(plane.similarOrientation(Plane.fromNormal(Vector3D.of(1, 1, 0), TEST_PRECISION)));
+        Assert.assertTrue(plane.similarOrientation(Plane.fromNormal(Vector3D.of(1, -1, 0), TEST_PRECISION)));
+
+        Assert.assertFalse(plane.similarOrientation(Plane.fromNormal(Vector3D.of(0, 1, 0), TEST_PRECISION)));
+        Assert.assertFalse(plane.similarOrientation(Plane.fromNormal(Vector3D.of(-1, 1, 0), TEST_PRECISION)));
+        Assert.assertFalse(plane.similarOrientation(Plane.fromNormal(Vector3D.of(-1, 1, 0), TEST_PRECISION)));
+        Assert.assertFalse(plane.similarOrientation(Plane.fromNormal(Vector3D.of(0, -1, 0), TEST_PRECISION)));
+    }
+
+    @Test
+    public void testEq() {
+        // arrange
+        double eps = 1e-3;
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(eps);
+
+        Vector3D pt = Vector3D.of(1, 2, 3);
+        Vector3D u = Vector3D.Unit.PLUS_X;
+        Vector3D v = Vector3D.Unit.PLUS_Y;
+
+        Vector3D ptPrime = Vector3D.of(1.0001, 2.0001, 3.0001);
+        Vector3D uPrime = Vector3D.Unit.of(1, 1e-4, 0);
+        Vector3D vPrime = Vector3D.Unit.of(0, 1, 1e-4);
+
+        Plane a = Plane.fromPointAndPlaneVectors(pt, u, v, precision);
+
+        Plane b = Plane.fromPointAndPlaneVectors(Vector3D.of(1, 2, 4), u, v, precision);
+        Plane c = Plane.fromPointAndPlaneVectors(pt, Vector3D.Unit.MINUS_X, v, precision);
+        Plane d = Plane.fromPointAndPlaneVectors(pt, u, Vector3D.Unit.MINUS_Y, precision);
+        Plane e = Plane.fromPointAndPlaneVectors(pt, u, v, TEST_PRECISION);
+
+        Plane f = Plane.fromPointAndPlaneVectors(ptPrime, uPrime, vPrime, new EpsilonDoublePrecisionContext(eps));
+
+        // act/assert
+        Assert.assertTrue(a.eq(a));
+
+        Assert.assertFalse(a.eq(b));
+        Assert.assertFalse(a.eq(c));
+        Assert.assertFalse(a.eq(d));
+        Assert.assertFalse(a.eq(e));
+
+        Assert.assertTrue(a.eq(f));
+        Assert.assertTrue(f.eq(a));
+    }
+
+    @Test
+    public void testHashCode() {
+        // arrange
+        Vector3D pt = Vector3D.of(1, 2, 3);
+        Vector3D u = Vector3D.Unit.PLUS_X;
+        Vector3D v = Vector3D.Unit.PLUS_Y;
+
+        Plane a = Plane.fromPointAndPlaneVectors(pt, u, v, TEST_PRECISION);
+        Plane b = Plane.fromPointAndPlaneVectors(Vector3D.of(1, 2, 4), u, v, TEST_PRECISION);
+        Plane c = Plane.fromPointAndPlaneVectors(pt, Vector3D.of(1, 1, 0), v, TEST_PRECISION);
+        Plane d = Plane.fromPointAndPlaneVectors(pt, u, Vector3D.Unit.MINUS_Y, TEST_PRECISION);
+        Plane e = Plane.fromPointAndPlaneVectors(pt, u, v, new EpsilonDoublePrecisionContext(1e-8));
+        Plane f = Plane.fromPointAndPlaneVectors(pt, u, v, TEST_PRECISION);
+
+        // act/assert
+        int hash = a.hashCode();
+
+        Assert.assertEquals(hash, a.hashCode());
+
+        Assert.assertNotEquals(hash, b.hashCode());
+        Assert.assertNotEquals(hash, c.hashCode());
+        Assert.assertNotEquals(hash, d.hashCode());
+        Assert.assertNotEquals(hash, e.hashCode());
+
+        Assert.assertEquals(hash, f.hashCode());
+    }
+
+    @Test
+    public void testEquals() {
+        // arrange
+        Vector3D pt = Vector3D.of(1, 2, 3);
+        Vector3D u = Vector3D.Unit.PLUS_X;
+        Vector3D v = Vector3D.Unit.PLUS_Y;
+
+        Plane a = Plane.fromPointAndPlaneVectors(pt, u, v, TEST_PRECISION);
+        Plane b = Plane.fromPointAndPlaneVectors(Vector3D.of(1, 2, 4), u, v, TEST_PRECISION);
+        Plane c = Plane.fromPointAndPlaneVectors(pt, Vector3D.Unit.MINUS_X, v, TEST_PRECISION);
+        Plane d = Plane.fromPointAndPlaneVectors(pt, u, Vector3D.Unit.MINUS_Y, TEST_PRECISION);
+        Plane e = Plane.fromPointAndPlaneVectors(pt, u, v, new EpsilonDoublePrecisionContext(1e-8));
+        Plane f = Plane.fromPointAndPlaneVectors(pt, u, v, TEST_PRECISION);
+
+        // act/assert
+        Assert.assertTrue(a.equals(a));
+
+        Assert.assertFalse(a.equals(null));
+        Assert.assertFalse(a.equals(new Object()));
+
+        Assert.assertFalse(a.equals(b));
+        Assert.assertFalse(a.equals(c));
+        Assert.assertFalse(a.equals(d));
+        Assert.assertFalse(a.equals(e));
+
+        Assert.assertTrue(a.equals(f));
+        Assert.assertTrue(f.equals(a));
+    }
+
+    @Test
+    public void testToString() {
+        // arrange
+        Plane plane = Plane.fromPointAndPlaneVectors(Vector3D.ZERO, Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
+
+        // act
+        String str = plane.toString();
+
+        // assert
+        Assert.assertTrue(str.startsWith("Plane["));
+        Assert.assertTrue(str.matches(".*origin= \\(0(\\.0)?, 0(\\.0)?\\, 0(\\.0)?\\).*"));
+        Assert.assertTrue(str.matches(".*u= \\(1(\\.0)?, 0(\\.0)?\\, 0(\\.0)?\\).*"));
+        Assert.assertTrue(str.matches(".*v= \\(0(\\.0)?, 1(\\.0)?\\, 0(\\.0)?\\).*"));
+        Assert.assertTrue(str.matches(".*w= \\(0(\\.0)?, 0(\\.0)?\\, 1(\\.0)?\\).*"));
     }
 
     private static void checkPlane(Plane plane, Vector3D origin, Vector3D u, Vector3D v) {
@@ -576,7 +1109,6 @@
         EuclideanTestUtils.assertCoordinatesEqual(origin, plane.getNormal().multiply(-offset), TEST_EPS);
     }
 
-
     private static <T> List<T> rotate(List<T> list, int shift) {
         int size = list.size();
 
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/PolyhedronsSetTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/PolyhedronsSetTest.java
deleted file mode 100644
index 228f3fc..0000000
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/PolyhedronsSetTest.java
+++ /dev/null
@@ -1,1655 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.threed;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.io.Reader;
-import java.text.ParseException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-
-import org.apache.commons.geometry.core.partitioning.BSPTree;
-import org.apache.commons.geometry.core.partitioning.BSPTreeVisitor;
-import org.apache.commons.geometry.core.partitioning.BoundaryAttribute;
-import org.apache.commons.geometry.core.partitioning.BoundaryProjection;
-import org.apache.commons.geometry.core.partitioning.Region;
-import org.apache.commons.geometry.core.partitioning.RegionFactory;
-import org.apache.commons.geometry.core.partitioning.SubHyperplane;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
-import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
-import org.apache.commons.geometry.euclidean.threed.rotation.QuaternionRotation;
-import org.apache.commons.geometry.euclidean.twod.PolygonsSet;
-import org.apache.commons.geometry.euclidean.twod.SubLine;
-import org.apache.commons.geometry.euclidean.twod.Vector2D;
-import org.apache.commons.rng.UniformRandomProvider;
-import org.apache.commons.rng.simple.RandomSource;
-import org.junit.Assert;
-import org.junit.Test;
-
-public class PolyhedronsSetTest {
-
-    private static final double TEST_EPS = 1e-10;
-
-    private static final DoublePrecisionContext TEST_PRECISION =
-            new EpsilonDoublePrecisionContext(TEST_EPS);
-
-    @Test
-    public void testWholeSpace() {
-        // act
-        PolyhedronsSet polySet = new PolyhedronsSet(TEST_PRECISION);
-
-        // assert
-        Assert.assertSame(TEST_PRECISION, polySet.getPrecision());
-        EuclideanTestUtils.assertPositiveInfinity(polySet.getSize());
-        Assert.assertEquals(0.0, polySet.getBoundarySize(), TEST_EPS);
-        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.NaN, polySet.getBarycenter(), TEST_EPS);
-        Assert.assertFalse(polySet.isEmpty());
-        Assert.assertTrue(polySet.isFull());
-
-        checkPoints(Region.Location.INSIDE, polySet,
-                Vector3D.of(-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE),
-                Vector3D.of(-100, -100, -100),
-                Vector3D.of(0, 0, 0),
-                Vector3D.of(100, 100, 100),
-                Vector3D.of(Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE));
-    }
-
-    @Test
-    public void testEmptyRegion() {
-        // act
-        PolyhedronsSet polySet = new PolyhedronsSet(new BSPTree<Vector3D>(Boolean.FALSE), TEST_PRECISION);
-
-        // assert
-        Assert.assertSame(TEST_PRECISION, polySet.getPrecision());
-        Assert.assertEquals(0.0, polySet.getSize(), TEST_EPS);
-        Assert.assertEquals(0.0, polySet.getBoundarySize(), TEST_EPS);
-        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.NaN, polySet.getBarycenter(), TEST_EPS);
-        Assert.assertTrue(polySet.isEmpty());
-        Assert.assertFalse(polySet.isFull());
-
-        checkPoints(Region.Location.OUTSIDE, polySet,
-                Vector3D.of(-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE),
-                Vector3D.of(-100, -100, -100),
-                Vector3D.of(0, 0, 0),
-                Vector3D.of(100, 100, 100),
-                Vector3D.of(Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE));
-    }
-
-    @Test
-    public void testHalfSpace() {
-        // arrange
-        List<SubHyperplane<Vector3D>> boundaries = new ArrayList<>();
-        boundaries.add(new SubPlane(Plane.fromPointAndNormal(Vector3D.ZERO, Vector3D.Unit.PLUS_Y, TEST_PRECISION),
-                new PolygonsSet(TEST_PRECISION)));
-
-        // act
-        PolyhedronsSet polySet = new PolyhedronsSet(boundaries, TEST_PRECISION);
-
-        // assert
-        Assert.assertSame(TEST_PRECISION, polySet.getPrecision());
-        EuclideanTestUtils.assertPositiveInfinity(polySet.getSize());
-        EuclideanTestUtils.assertPositiveInfinity(polySet.getBoundarySize());
-        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.NaN, polySet.getBarycenter(), TEST_EPS);
-        Assert.assertFalse(polySet.isEmpty());
-        Assert.assertFalse(polySet.isFull());
-
-        checkPoints(Region.Location.INSIDE, polySet,
-                Vector3D.of(-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE),
-                Vector3D.of(-100, -100, -100));
-        checkPoints(Region.Location.BOUNDARY, polySet, Vector3D.of(0, 0, 0));
-        checkPoints(Region.Location.OUTSIDE, polySet,
-                Vector3D.of(100, 100, 100),
-                Vector3D.of(Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE));
-    }
-
-    @Test
-    public void testInvertedRegion() {
-        // arrange
-        List<SubHyperplane<Vector3D>> boundaries = createBoxBoundaries(Vector3D.ZERO, 1.0, TEST_EPS);
-        PolyhedronsSet box = new PolyhedronsSet(boundaries, TEST_PRECISION);
-
-        // act
-        PolyhedronsSet polySet = (PolyhedronsSet) new RegionFactory<Vector3D>().getComplement(box);
-
-        // assert
-        Assert.assertSame(TEST_PRECISION, polySet.getPrecision());
-        EuclideanTestUtils.assertPositiveInfinity(polySet.getSize());
-        Assert.assertEquals(6, polySet.getBoundarySize(), TEST_EPS);
-        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.NaN, polySet.getBarycenter(), TEST_EPS);
-        Assert.assertFalse(polySet.isEmpty());
-        Assert.assertFalse(polySet.isFull());
-
-        checkPoints(Region.Location.INSIDE, polySet,
-                Vector3D.of(-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE),
-                Vector3D.of(-100, -100, -100),
-                Vector3D.of(100, 100, 100),
-                Vector3D.of(Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE));
-        checkPoints(Region.Location.OUTSIDE, polySet,
-                Vector3D.of(0, 0, 0));
-    }
-
-    @Test
-    public void testCreateFromBoundaries_noBoundaries_treeRepresentsWholeSpace() {
-        // arrange
-        List<SubHyperplane<Vector3D>> boundaries = new ArrayList<>();
-
-        // act
-        PolyhedronsSet polySet = new PolyhedronsSet(boundaries, TEST_PRECISION);
-
-        // assert
-        Assert.assertSame(TEST_PRECISION, polySet.getPrecision());
-        EuclideanTestUtils.assertPositiveInfinity(polySet.getSize());
-        Assert.assertEquals(0.0, polySet.getBoundarySize(), TEST_EPS);
-        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.NaN, polySet.getBarycenter(), TEST_EPS);
-        Assert.assertFalse(polySet.isEmpty());
-        Assert.assertTrue(polySet.isFull());
-    }
-
-    @Test
-    public void testCreateFromBoundaries_unitBox() {
-        // arrange
-        List<SubHyperplane<Vector3D>> boundaries = createBoxBoundaries(Vector3D.ZERO, 1.0, TEST_EPS);
-
-        // act
-        PolyhedronsSet polySet = new PolyhedronsSet(boundaries, TEST_PRECISION);
-
-        // assert
-        Assert.assertSame(TEST_PRECISION, polySet.getPrecision());
-        Assert.assertEquals(1.0, polySet.getSize(), TEST_EPS);
-        Assert.assertEquals(6.0, polySet.getBoundarySize(), TEST_EPS);
-        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.ZERO, polySet.getBarycenter(), TEST_EPS);
-        Assert.assertFalse(polySet.isEmpty());
-        Assert.assertFalse(polySet.isFull());
-
-        checkPoints(Region.Location.OUTSIDE, polySet,
-                Vector3D.of(-1, 0, 0),
-                Vector3D.of(1, 0, 0),
-                Vector3D.of(0, -1, 0),
-                Vector3D.of(0, 1, 0),
-                Vector3D.of(0, 0, -1),
-                Vector3D.of(0, 0, 1),
-
-                Vector3D.of(1, 1, 1),
-                Vector3D.of(1, 1, -1),
-                Vector3D.of(1, -1, 1),
-                Vector3D.of(1, -1, -1),
-                Vector3D.of(-1, 1, 1),
-                Vector3D.of(-1, 1, -1),
-                Vector3D.of(-1, -1, 1),
-                Vector3D.of(-1, -1, -1));
-
-        checkPoints(Region.Location.BOUNDARY, polySet,
-                Vector3D.of(0.5, 0, 0),
-                Vector3D.of(-0.5, 0, 0),
-                Vector3D.of(0, 0.5, 0),
-                Vector3D.of(0, -0.5, 0),
-                Vector3D.of(0, 0, 0.5),
-                Vector3D.of(0, 0, -0.5),
-
-                Vector3D.of(0.5, 0.5, 0.5),
-                Vector3D.of(0.5, 0.5, -0.5),
-                Vector3D.of(0.5, -0.5, 0.5),
-                Vector3D.of(0.5, -0.5, -0.5),
-                Vector3D.of(-0.5, 0.5, 0.5),
-                Vector3D.of(-0.5, 0.5, -0.5),
-                Vector3D.of(-0.5, -0.5, 0.5),
-                Vector3D.of(-0.5, -0.5, -0.5));
-
-        checkPoints(Region.Location.INSIDE, polySet,
-                Vector3D.of(0, 0, 0),
-
-                Vector3D.of(0.4, 0.4, 0.4),
-                Vector3D.of(0.4, 0.4, -0.4),
-                Vector3D.of(0.4, -0.4, 0.4),
-                Vector3D.of(0.4, -0.4, -0.4),
-                Vector3D.of(-0.4, 0.4, 0.4),
-                Vector3D.of(-0.4, 0.4, -0.4),
-                Vector3D.of(-0.4, -0.4, 0.4),
-                Vector3D.of(-0.4, -0.4, -0.4));
-    }
-
-    @Test
-    public void testCreateFromBoundaries_twoBoxes_disjoint() {
-        // arrange
-        List<SubHyperplane<Vector3D>> boundaries = new ArrayList<>();
-        boundaries.addAll(createBoxBoundaries(Vector3D.ZERO, 1.0, TEST_EPS));
-        boundaries.addAll(createBoxBoundaries(Vector3D.of(2, 0, 0), 1.0, TEST_EPS));
-
-        // act
-        PolyhedronsSet polySet = new PolyhedronsSet(boundaries, TEST_PRECISION);
-
-        // assert
-        Assert.assertSame(TEST_PRECISION, polySet.getPrecision());
-        Assert.assertEquals(2.0, polySet.getSize(), TEST_EPS);
-        Assert.assertEquals(12.0, polySet.getBoundarySize(), TEST_EPS);
-        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 0, 0), polySet.getBarycenter(), TEST_EPS);
-        Assert.assertFalse(polySet.isEmpty());
-        Assert.assertFalse(polySet.isFull());
-
-        checkPoints(Region.Location.OUTSIDE, polySet,
-                Vector3D.of(-1, 0, 0),
-                Vector3D.of(1, 0, 0),
-                Vector3D.of(3, 0, 0));
-
-        checkPoints(Region.Location.INSIDE, polySet,
-                Vector3D.of(0, 0, 0),
-                Vector3D.of(2, 0, 0));
-    }
-
-    @Test
-    public void testCreateFromBoundaries_twoBoxes_sharedSide() {
-        // arrange
-        List<SubHyperplane<Vector3D>> boundaries = new ArrayList<>();
-        boundaries.addAll(createBoxBoundaries(Vector3D.of(0, 0, 0), 1.0, TEST_EPS));
-        boundaries.addAll(createBoxBoundaries(Vector3D.of(1, 0, 0), 1.0, TEST_EPS));
-
-        // act
-        PolyhedronsSet polySet = new PolyhedronsSet(boundaries, TEST_PRECISION);
-
-        // assert
-        Assert.assertSame(TEST_PRECISION, polySet.getPrecision());
-        Assert.assertEquals(2.0, polySet.getSize(), TEST_EPS);
-        Assert.assertEquals(10.0, polySet.getBoundarySize(), TEST_EPS);
-        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0.5, 0, 0), polySet.getBarycenter(), TEST_EPS);
-        Assert.assertFalse(polySet.isEmpty());
-        Assert.assertFalse(polySet.isFull());
-
-        checkPoints(Region.Location.OUTSIDE, polySet,
-                Vector3D.of(-1, 0, 0),
-                Vector3D.of(2, 0, 0));
-
-        checkPoints(Region.Location.INSIDE, polySet,
-                Vector3D.of(0, 0, 0),
-                Vector3D.of(1, 0, 0));
-    }
-
-    @Test
-    public void testCreateFromBoundaries_twoBoxes_separationLessThanTolerance() {
-        // arrange
-        double eps = 1e-6;
-        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(eps);
-        List<SubHyperplane<Vector3D>> boundaries = new ArrayList<>();
-        boundaries.addAll(createBoxBoundaries(Vector3D.of(0, 0, 0), 1.0, eps));
-        boundaries.addAll(createBoxBoundaries(Vector3D.of(1 + 1e-7, 0, 0), 1.0, eps));
-
-        // act
-        PolyhedronsSet polySet = new PolyhedronsSet(boundaries, precision);
-
-        // assert
-        Assert.assertSame(precision, polySet.getPrecision());
-        Assert.assertEquals(2.0, polySet.getSize(), eps);
-        Assert.assertEquals(10.0, polySet.getBoundarySize(), eps);
-        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0.5 + 5e-8, 0, 0), polySet.getBarycenter(), TEST_EPS);
-        Assert.assertFalse(polySet.isEmpty());
-        Assert.assertFalse(polySet.isFull());
-
-        checkPoints(Region.Location.OUTSIDE, polySet,
-                Vector3D.of(-1, 0, 0),
-                Vector3D.of(2, 0, 0));
-
-        checkPoints(Region.Location.INSIDE, polySet,
-                Vector3D.of(0, 0, 0),
-                Vector3D.of(1, 0, 0));
-    }
-
-    @Test
-    public void testCreateFromBoundaries_twoBoxes_sharedEdge() {
-        // arrange
-        List<SubHyperplane<Vector3D>> boundaries = new ArrayList<>();
-        boundaries.addAll(createBoxBoundaries(Vector3D.of(0, 0, 0), 1.0, TEST_EPS));
-        boundaries.addAll(createBoxBoundaries(Vector3D.of(1, 1, 0), 1.0, TEST_EPS));
-
-        // act
-        PolyhedronsSet polySet = new PolyhedronsSet(boundaries, TEST_PRECISION);
-
-        // assert
-        Assert.assertSame(TEST_PRECISION, polySet.getPrecision());
-        Assert.assertEquals(2.0, polySet.getSize(), TEST_EPS);
-        Assert.assertEquals(12.0, polySet.getBoundarySize(), TEST_EPS);
-        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0.5, 0.5, 0), polySet.getBarycenter(), TEST_EPS);
-        Assert.assertFalse(polySet.isEmpty());
-        Assert.assertFalse(polySet.isFull());
-
-        checkPoints(Region.Location.OUTSIDE, polySet,
-                Vector3D.of(-1, 0, 0),
-                Vector3D.of(1, 0, 0),
-                Vector3D.of(0, 1, 0),
-                Vector3D.of(2, 1, 0));
-
-        checkPoints(Region.Location.INSIDE, polySet,
-                Vector3D.of(0, 0, 0),
-                Vector3D.of(1, 1, 0));
-    }
-
-    @Test
-    public void testCreateFromBoundaries_twoBoxes_sharedPoint() {
-        // arrange
-        List<SubHyperplane<Vector3D>> boundaries = new ArrayList<>();
-        boundaries.addAll(createBoxBoundaries(Vector3D.of(0, 0, 0), 1.0, TEST_EPS));
-        boundaries.addAll(createBoxBoundaries(Vector3D.of(1, 1, 1), 1.0, TEST_EPS));
-
-        // act
-        PolyhedronsSet polySet = new PolyhedronsSet(boundaries, TEST_PRECISION);
-
-        // assert
-        Assert.assertSame(TEST_PRECISION, polySet.getPrecision());
-        Assert.assertEquals(2.0, polySet.getSize(), TEST_EPS);
-        Assert.assertEquals(12.0, polySet.getBoundarySize(), TEST_EPS);
-        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0.5, 0.5, 0.5), polySet.getBarycenter(), TEST_EPS);
-        Assert.assertFalse(polySet.isEmpty());
-        Assert.assertFalse(polySet.isFull());
-
-        checkPoints(Region.Location.OUTSIDE, polySet,
-                Vector3D.of(-1, 0, 0),
-                Vector3D.of(1, 0, 0),
-                Vector3D.of(0, 1, 1),
-                Vector3D.of(2, 1, 1));
-
-        checkPoints(Region.Location.INSIDE, polySet,
-                Vector3D.of(0, 0, 0),
-                Vector3D.of(1, 1, 1));
-    }
-
-    @Test
-    public void testCreateBox() {
-        // act
-        PolyhedronsSet tree = new PolyhedronsSet(0, 1, 0, 1, 0, 1, TEST_PRECISION);
-
-        // assert
-        Assert.assertEquals(1.0, tree.getSize(), TEST_EPS);
-        Assert.assertEquals(6.0, tree.getBoundarySize(), TEST_EPS);
-        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0.5, 0.5, 0.5), tree.getBarycenter(), TEST_EPS);
-
-        for (double x = -0.25; x < 1.25; x += 0.1) {
-            boolean xOK = (x >= 0.0) && (x <= 1.0);
-            for (double y = -0.25; y < 1.25; y += 0.1) {
-                boolean yOK = (y >= 0.0) && (y <= 1.0);
-                for (double z = -0.25; z < 1.25; z += 0.1) {
-                    boolean zOK = (z >= 0.0) && (z <= 1.0);
-                    Region.Location expected =
-                        (xOK && yOK && zOK) ? Region.Location.INSIDE : Region.Location.OUTSIDE;
-                    Assert.assertEquals(expected, tree.checkPoint(Vector3D.of(x, y, z)));
-                }
-            }
-        }
-        checkPoints(Region.Location.BOUNDARY, tree, new Vector3D[] {
-            Vector3D.of(0.0, 0.5, 0.5),
-            Vector3D.of(1.0, 0.5, 0.5),
-            Vector3D.of(0.5, 0.0, 0.5),
-            Vector3D.of(0.5, 1.0, 0.5),
-            Vector3D.of(0.5, 0.5, 0.0),
-            Vector3D.of(0.5, 0.5, 1.0)
-        });
-        checkPoints(Region.Location.OUTSIDE, tree, new Vector3D[] {
-            Vector3D.of(0.0, 1.2, 1.2),
-            Vector3D.of(1.0, 1.2, 1.2),
-            Vector3D.of(1.2, 0.0, 1.2),
-            Vector3D.of(1.2, 1.0, 1.2),
-            Vector3D.of(1.2, 1.2, 0.0),
-            Vector3D.of(1.2, 1.2, 1.0)
-        });
-    }
-
-    @Test
-    public void testInvertedBox() {
-        // arrange
-        PolyhedronsSet tree = new PolyhedronsSet(0, 1, 0, 1, 0, 1, TEST_PRECISION);
-
-        // act
-        tree = (PolyhedronsSet) new RegionFactory<Vector3D>().getComplement(tree);
-
-        // assert
-        EuclideanTestUtils.assertPositiveInfinity(tree.getSize());
-        Assert.assertEquals(6.0, tree.getBoundarySize(), 1.0e-10);
-
-        Vector3D barycenter = tree.getBarycenter();
-        Assert.assertTrue(Double.isNaN(barycenter.getX()));
-        Assert.assertTrue(Double.isNaN(barycenter.getY()));
-        Assert.assertTrue(Double.isNaN(barycenter.getZ()));
-
-        for (double x = -0.25; x < 1.25; x += 0.1) {
-            boolean xOK = (x < 0.0) || (x > 1.0);
-            for (double y = -0.25; y < 1.25; y += 0.1) {
-                boolean yOK = (y < 0.0) || (y > 1.0);
-                for (double z = -0.25; z < 1.25; z += 0.1) {
-                    boolean zOK = (z < 0.0) || (z > 1.0);
-                    Region.Location expected =
-                        (xOK || yOK || zOK) ? Region.Location.INSIDE : Region.Location.OUTSIDE;
-                    Assert.assertEquals(expected, tree.checkPoint(Vector3D.of(x, y, z)));
-                }
-            }
-        }
-        checkPoints(Region.Location.BOUNDARY, tree, new Vector3D[] {
-            Vector3D.of(0.0, 0.5, 0.5),
-            Vector3D.of(1.0, 0.5, 0.5),
-            Vector3D.of(0.5, 0.0, 0.5),
-            Vector3D.of(0.5, 1.0, 0.5),
-            Vector3D.of(0.5, 0.5, 0.0),
-            Vector3D.of(0.5, 0.5, 1.0)
-        });
-        checkPoints(Region.Location.INSIDE, tree, new Vector3D[] {
-            Vector3D.of(0.0, 1.2, 1.2),
-            Vector3D.of(1.0, 1.2, 1.2),
-            Vector3D.of(1.2, 0.0, 1.2),
-            Vector3D.of(1.2, 1.0, 1.2),
-            Vector3D.of(1.2, 1.2, 0.0),
-            Vector3D.of(1.2, 1.2, 1.0)
-        });
-    }
-
-    @Test
-    public void testTetrahedron() {
-        // arrange
-        Vector3D vertex1 = Vector3D.of(1, 2, 3);
-        Vector3D vertex2 = Vector3D.of(2, 2, 4);
-        Vector3D vertex3 = Vector3D.of(2, 3, 3);
-        Vector3D vertex4 = Vector3D.of(1, 3, 4);
-
-        // act
-        PolyhedronsSet tree =
-            (PolyhedronsSet) new RegionFactory<Vector3D>().buildConvex(
-                Plane.fromPoints(vertex3, vertex2, vertex1, TEST_PRECISION),
-                Plane.fromPoints(vertex2, vertex3, vertex4, TEST_PRECISION),
-                Plane.fromPoints(vertex4, vertex3, vertex1, TEST_PRECISION),
-                Plane.fromPoints(vertex1, vertex2, vertex4, TEST_PRECISION));
-
-        // assert
-        Assert.assertEquals(1.0 / 3.0, tree.getSize(), TEST_EPS);
-        Assert.assertEquals(2.0 * Math.sqrt(3.0), tree.getBoundarySize(), TEST_EPS);
-        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1.5, 2.5, 3.5), tree.getBarycenter(), TEST_EPS);
-
-        double third = 1.0 / 3.0;
-        checkPoints(Region.Location.BOUNDARY, tree, new Vector3D[] {
-            vertex1, vertex2, vertex3, vertex4,
-            Vector3D.linearCombination(third, vertex1, third, vertex2, third, vertex3),
-            Vector3D.linearCombination(third, vertex2, third, vertex3, third, vertex4),
-            Vector3D.linearCombination(third, vertex3, third, vertex4, third, vertex1),
-            Vector3D.linearCombination(third, vertex4, third, vertex1, third, vertex2)
-        });
-        checkPoints(Region.Location.OUTSIDE, tree, new Vector3D[] {
-            Vector3D.of(1, 2, 4),
-            Vector3D.of(2, 2, 3),
-            Vector3D.of(2, 3, 4),
-            Vector3D.of(1, 3, 3)
-        });
-    }
-
-    @Test
-    public void testSphere() {
-        // arrange
-        // (use a high tolerance value here since the sphere is only an approximation)
-        double approximationTolerance = 0.2;
-        double radius = 1.0;
-
-        // act
-        PolyhedronsSet polySet = createSphere(Vector3D.of(1, 2, 3), radius, 8, 16);
-
-        // assert
-        Assert.assertEquals(sphereVolume(radius), polySet.getSize(), approximationTolerance);
-        Assert.assertEquals(sphereSurface(radius), polySet.getBoundarySize(), approximationTolerance);
-        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 2, 3), polySet.getBarycenter(), TEST_EPS);
-        Assert.assertFalse(polySet.isEmpty());
-        Assert.assertFalse(polySet.isFull());
-
-        checkPoints(Region.Location.OUTSIDE, polySet,
-                Vector3D.of(-0.1, 2, 3),
-                Vector3D.of(2.1, 2, 3),
-                Vector3D.of(1, 0.9, 3),
-                Vector3D.of(1, 3.1, 3),
-                Vector3D.of(1, 2, 1.9),
-                Vector3D.of(1, 2, 4.1),
-                Vector3D.of(1.6, 2.6, 3.6));
-
-        checkPoints(Region.Location.INSIDE, polySet,
-                Vector3D.of(1, 2, 3),
-                Vector3D.of(0.1, 2, 3),
-                Vector3D.of(1.9, 2, 3),
-                Vector3D.of(1, 2.1, 3),
-                Vector3D.of(1, 2.9, 3),
-                Vector3D.of(1, 2, 2.1),
-                Vector3D.of(1, 2, 3.9),
-                Vector3D.of(1.5, 2.5, 3.5));
-    }
-
-    @Test
-    public void testIsometry() {
-        // arrange
-        Vector3D vertex1 = Vector3D.of(1.1, 2.2, 3.3);
-        Vector3D vertex2 = Vector3D.of(2.0, 2.4, 4.2);
-        Vector3D vertex3 = Vector3D.of(2.8, 3.3, 3.7);
-        Vector3D vertex4 = Vector3D.of(1.0, 3.6, 4.5);
-
-        // act
-        PolyhedronsSet tree =
-            (PolyhedronsSet) new RegionFactory<Vector3D>().buildConvex(
-                Plane.fromPoints(vertex3, vertex2, vertex1, TEST_PRECISION),
-                Plane.fromPoints(vertex2, vertex3, vertex4, TEST_PRECISION),
-                Plane.fromPoints(vertex4, vertex3, vertex1, TEST_PRECISION),
-                Plane.fromPoints(vertex1, vertex2, vertex4, TEST_PRECISION));
-
-        // assert
-        Vector3D barycenter = tree.getBarycenter();
-        Vector3D s = Vector3D.of(10.2, 4.3, -6.7);
-        Vector3D c = Vector3D.of(-0.2, 2.1, -3.2);
-        QuaternionRotation r = QuaternionRotation.fromAxisAngle(Vector3D.of(6.2, -4.4, 2.1), 0.12);
-
-        tree = tree.rotate(c, r).translate(s);
-
-        Vector3D newB =
-                Vector3D.linearCombination(1.0, s,
-                         1.0, c,
-                         1.0, r.apply(barycenter.subtract(c)));
-        Assert.assertEquals(0.0,
-                            newB.subtract(tree.getBarycenter()).norm(),
-                            TEST_EPS);
-
-        final Vector3D[] expectedV = new Vector3D[] {
-                Vector3D.linearCombination(1.0, s,
-                         1.0, c,
-                         1.0, r.apply(vertex1.subtract(c))),
-                            Vector3D.linearCombination(1.0, s,
-                                      1.0, c,
-                                      1.0, r.apply(vertex2.subtract(c))),
-                                        Vector3D.linearCombination(1.0, s,
-                                                   1.0, c,
-                                                   1.0, r.apply(vertex3.subtract(c))),
-                                                    Vector3D.linearCombination(1.0, s,
-                                                                1.0, c,
-                                                                1.0, r.apply(vertex4.subtract(c)))
-        };
-        tree.getTree(true).visit(new BSPTreeVisitor<Vector3D>() {
-
-            @Override
-            public Order visitOrder(BSPTree<Vector3D> node) {
-                return Order.MINUS_SUB_PLUS;
-            }
-
-            @Override
-            public void visitInternalNode(BSPTree<Vector3D> node) {
-                @SuppressWarnings("unchecked")
-                BoundaryAttribute<Vector3D> attribute =
-                    (BoundaryAttribute<Vector3D>) node.getAttribute();
-                if (attribute.getPlusOutside() != null) {
-                    checkFacet((SubPlane) attribute.getPlusOutside());
-                }
-                if (attribute.getPlusInside() != null) {
-                    checkFacet((SubPlane) attribute.getPlusInside());
-                }
-            }
-
-            @Override
-            public void visitLeafNode(BSPTree<Vector3D> node) {
-            }
-
-            private void checkFacet(SubPlane facet) {
-                Plane plane = (Plane) facet.getHyperplane();
-                Vector2D[][] vertices =
-                    ((PolygonsSet) facet.getRemainingRegion()).getVertices();
-                Assert.assertEquals(1, vertices.length);
-                for (int i = 0; i < vertices[0].length; ++i) {
-                    Vector3D v = plane.toSpace(vertices[0][i]);
-                    double d = Double.POSITIVE_INFINITY;
-                    for (int k = 0; k < expectedV.length; ++k) {
-                        d = Math.min(d, v.subtract(expectedV[k]).norm());
-                    }
-                    Assert.assertEquals(0, d, TEST_EPS);
-                }
-            }
-
-        });
-
-    }
-
-    @Test
-    public void testBuildBox() {
-        // arrange
-        double x = 1.0;
-        double y = 2.0;
-        double z = 3.0;
-        double w = 0.1;
-        double l = 1.0;
-
-        // act
-        PolyhedronsSet tree =
-            new PolyhedronsSet(x - l, x + l, y - w, y + w, z - w, z + w, TEST_PRECISION);
-
-        // assert
-        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(x, y, z), tree.getBarycenter(), TEST_EPS);
-        Assert.assertEquals(8 * l * w * w, tree.getSize(), TEST_EPS);
-        Assert.assertEquals(8 * w * (2 * l + w), tree.getBoundarySize(), TEST_EPS);
-    }
-
-    @Test
-    public void testCross() {
-        // arrange
-        double x = 1.0;
-        double y = 2.0;
-        double z = 3.0;
-        double w = 0.1;
-        double l = 1.0;
-        PolyhedronsSet xBeam =
-            new PolyhedronsSet(x - l, x + l, y - w, y + w, z - w, z + w, TEST_PRECISION);
-        PolyhedronsSet yBeam =
-            new PolyhedronsSet(x - w, x + w, y - l, y + l, z - w, z + w, TEST_PRECISION);
-        PolyhedronsSet zBeam =
-            new PolyhedronsSet(x - w, x + w, y - w, y + w, z - l, z + l, TEST_PRECISION);
-        RegionFactory<Vector3D> factory = new RegionFactory<>();
-
-        // act
-        PolyhedronsSet tree = (PolyhedronsSet) factory.union(xBeam, factory.union(yBeam, zBeam));
-
-        // assert
-        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(x, y, z), tree.getBarycenter(), TEST_EPS);
-        Assert.assertEquals(8 * w * w * (3 * l - 2 * w), tree.getSize(), TEST_EPS);
-        Assert.assertEquals(24 * w * (2 * l - w), tree.getBoundarySize(), TEST_EPS);
-    }
-
-    // Issue MATH-780
-    // See https://issues.apache.org/jira/browse/MATH-780
-    @Test
-    public void testCreateFromBoundaries_handlesSmallBoundariesCreatedDuringConstruction() {
-        // arrange
-        float[] coords = {
-            1.000000f, -1.000000f, -1.000000f,
-            1.000000f, -1.000000f, 1.000000f,
-            -1.000000f, -1.000000f, 1.000000f,
-            -1.000000f, -1.000000f, -1.000000f,
-            1.000000f, 1.000000f, -1f,
-            0.999999f, 1.000000f, 1.000000f,   // 1.000000f, 1.000000f, 1.000000f,
-            -1.000000f, 1.000000f, 1.000000f,
-            -1.000000f, 1.000000f, -1.000000f};
-        int[] indices = {
-            0, 1, 2, 0, 2, 3,
-            4, 7, 6, 4, 6, 5,
-            0, 4, 5, 0, 5, 1,
-            1, 5, 6, 1, 6, 2,
-            2, 6, 7, 2, 7, 3,
-            4, 0, 3, 4, 3, 7};
-        ArrayList<SubHyperplane<Vector3D>> subHyperplaneList = new ArrayList<>();
-        for (int idx = 0; idx < indices.length; idx += 3) {
-            int idxA = indices[idx] * 3;
-            int idxB = indices[idx + 1] * 3;
-            int idxC = indices[idx + 2] * 3;
-            Vector3D v_1 = Vector3D.of(coords[idxA], coords[idxA + 1], coords[idxA + 2]);
-            Vector3D v_2 = Vector3D.of(coords[idxB], coords[idxB + 1], coords[idxB + 2]);
-            Vector3D v_3 = Vector3D.of(coords[idxC], coords[idxC + 1], coords[idxC + 2]);
-            Vector3D[] vertices = {v_1, v_2, v_3};
-            Plane polyPlane = Plane.fromPoints(v_1, v_2, v_3, TEST_PRECISION);
-            ArrayList<SubHyperplane<Vector2D>> lines = new ArrayList<>();
-
-            Vector2D[] projPts = new Vector2D[vertices.length];
-            for (int ptIdx = 0; ptIdx < projPts.length; ptIdx++) {
-                projPts[ptIdx] = polyPlane.toSubSpace(vertices[ptIdx]);
-            }
-
-            SubLine lineInPlane = null;
-            for (int ptIdx = 0; ptIdx < projPts.length; ptIdx++) {
-                lineInPlane = new SubLine(projPts[ptIdx], projPts[(ptIdx + 1) % projPts.length], TEST_PRECISION);
-                lines.add(lineInPlane);
-            }
-            Region<Vector2D> polyRegion = new PolygonsSet(lines, TEST_PRECISION);
-            SubPlane polygon = new SubPlane(polyPlane, polyRegion);
-            subHyperplaneList.add(polygon);
-        }
-
-        // act
-        PolyhedronsSet polyhedronsSet = new PolyhedronsSet(subHyperplaneList, TEST_PRECISION);
-
-        // assert
-        Assert.assertEquals(8.0, polyhedronsSet.getSize(), 3.0e-6);
-        Assert.assertEquals(24.0, polyhedronsSet.getBoundarySize(), 5.0e-6);
-    }
-
-    @Test
-    public void testTooThinBox() {
-        // act
-        PolyhedronsSet polyhedronsSet = new PolyhedronsSet(0.0, 0.0, 0.0, 1.0, 0.0, 1.0, TEST_PRECISION);
-
-        // assert
-        Assert.assertEquals(0.0, polyhedronsSet.getSize(), TEST_EPS);
-    }
-
-    @Test
-    public void testWrongUsage() {
-        // the following is a wrong usage of the constructor.
-        // as explained in the javadoc, the failure is NOT detected at construction
-        // time but occurs later on
-        PolyhedronsSet ps = new PolyhedronsSet(new BSPTree<Vector3D>(), TEST_PRECISION);
-        Assert.assertNotNull(ps);
-        try {
-            ps.checkPoint(Vector3D.ZERO);
-            Assert.fail("an exception should have been thrown");
-        } catch (NullPointerException npe) {
-            // this is expected
-        }
-    }
-
-    @Test
-    public void testDumpParse() throws IOException, ParseException {
-        // arrange
-        double eps = 1e-8;
-        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(eps);
-
-        Vector3D[] verts=new Vector3D[8];
-        double xmin=-1,xmax=1;
-        double ymin=-1,ymax=1;
-        double zmin=-1,zmax=1;
-        verts[0]=Vector3D.of(xmin,ymin,zmin);
-        verts[1]=Vector3D.of(xmax,ymin,zmin);
-        verts[2]=Vector3D.of(xmax,ymax,zmin);
-        verts[3]=Vector3D.of(xmin,ymax,zmin);
-        verts[4]=Vector3D.of(xmin,ymin,zmax);
-        verts[5]=Vector3D.of(xmax,ymin,zmax);
-        verts[6]=Vector3D.of(xmax,ymax,zmax);
-        verts[7]=Vector3D.of(xmin,ymax,zmax);
-        //
-        int[][] faces=new int[12][];
-        faces[0]=new int[]{3,1,0};  // bottom (-z)
-        faces[1]=new int[]{1,3,2};  // bottom (-z)
-        faces[2]=new int[]{5,7,4};  // top (+z)
-        faces[3]=new int[]{7,5,6};  // top (+z)
-        faces[4]=new int[]{2,5,1};  // right (+x)
-        faces[5]=new int[]{5,2,6};  // right (+x)
-        faces[6]=new int[]{4,3,0};  // left (-x)
-        faces[7]=new int[]{3,4,7};  // left (-x)
-        faces[8]=new int[]{4,1,5};  // front (-y)
-        faces[9]=new int[]{1,4,0};  // front (-y)
-        faces[10]=new int[]{3,6,2}; // back (+y)
-        faces[11]=new int[]{6,3,7}; // back (+y)
-
-        PolyhedronsSet polyset = new PolyhedronsSet(Arrays.asList(verts), Arrays.asList(faces), precision);
-
-        // act
-        String dump = EuclideanTestUtils.dump(polyset);
-        PolyhedronsSet parsed = EuclideanTestUtils.parsePolyhedronsSet(dump, precision);
-
-        // assert
-        Assert.assertEquals(8.0, polyset.getSize(), TEST_EPS);
-        Assert.assertEquals(24.0, polyset.getBoundarySize(), TEST_EPS);
-
-        Assert.assertEquals(8.0, parsed.getSize(), TEST_EPS);
-        Assert.assertEquals(24.0, parsed.getBoundarySize(), TEST_EPS);
-        Assert.assertTrue(new RegionFactory<Vector3D>().difference(polyset, parsed).isEmpty());
-    }
-
-    @Test
-    public void testCreateFromBRep_connectedFacets() throws IOException, ParseException {
-        InputStream stream = getClass().getResourceAsStream("pentomino-N.ply");
-        PLYParser   parser = new PLYParser(stream);
-        stream.close();
-        PolyhedronsSet polyhedron = new PolyhedronsSet(parser.getVertices(), parser.getFaces(), TEST_PRECISION);
-        Assert.assertEquals( 5.0, polyhedron.getSize(), TEST_EPS);
-        Assert.assertEquals(22.0, polyhedron.getBoundarySize(), TEST_EPS);
-    }
-
-    // GEOMETRY-59
-    @Test
-    public void testCreateFromBRep_slightlyConcavePrism() {
-        // arrange
-        Vector3D vertices[] = {
-                Vector3D.of( 0, 0, 0 ),
-                Vector3D.of( 2, 1e-7, 0 ),
-                Vector3D.of( 4, 0, 0 ),
-                Vector3D.of( 2, 2, 0 ),
-                Vector3D.of( 0, 0, 2 ),
-                Vector3D.of( 2, 1e-7, 2 ),
-                Vector3D.of( 4, 0, 2 ),
-                Vector3D.of( 2, 2, 2 )
-        };
-
-        int facets[][] = {
-                { 4, 5, 6, 7 },
-                { 3, 2, 1, 0 },
-                { 0, 1, 5, 4 },
-                { 1, 2, 6, 5 },
-                { 2, 3, 7, 6 },
-                { 3, 0, 4, 7 }
-        };
-
-        // act
-        PolyhedronsSet prism = new PolyhedronsSet(
-                Arrays.asList(vertices),
-                Arrays.asList(facets),
-                TEST_PRECISION);
-
-
-        // assert
-        Assert.assertTrue(Double.isFinite(prism.getSize()));
-
-        checkPoints(Region.Location.INSIDE, prism, Vector3D.of(2, 1, 1));
-        checkPoints(Region.Location.OUTSIDE, prism,
-                Vector3D.of(2, 1, 3), Vector3D.of(2, 1, -3),
-                Vector3D.of(2, -1, 1), Vector3D.of(2, 3, 1),
-                Vector3D.of(-1, 1, 1), Vector3D.of(4, 1, 1));
-    }
-
-    @Test
-    public void testCreateFromBRep_verticesTooClose() throws IOException, ParseException {
-        checkError("pentomino-N-too-close.ply", "Vertices are too close");
-    }
-
-    @Test
-    public void testCreateFromBRep_hole() throws IOException, ParseException {
-        checkError("pentomino-N-hole.ply", "connected to one facet only");
-    }
-
-    @Test
-    public void testCreateFromBRep_nonPlanar() throws IOException, ParseException {
-        checkError("pentomino-N-out-of-plane.ply", "do not define a plane");
-    }
-
-    @Test
-    public void testCreateFromBRep_badOrientation() throws IOException, ParseException {
-        checkError("pentomino-N-bad-orientation.ply", "Facet orientation mismatch");
-    }
-
-    @Test
-    public void testCreateFromBRep_wrongNumberOfPoints() throws IOException, ParseException {
-        checkError(Arrays.asList(Vector3D.ZERO, Vector3D.of(1, 0, 0), Vector3D.of(0, 1, 0), Vector3D.of(0, 0, 1)),
-                   Arrays.asList(new int[] { 0, 1, 2 }, new int[] {2, 3}),
-                   "");
-    }
-
-    private void checkError(final String resourceName, final String expected) {
-        try (InputStream stream = getClass().getResourceAsStream(resourceName)) {
-            PLYParser parser = new PLYParser(stream);
-            checkError(parser.getVertices(), parser.getFaces(), expected);
-        } catch (IOException ioe) {
-            Assert.fail(ioe.getLocalizedMessage());
-        } catch (ParseException pe) {
-            Assert.fail(pe.getLocalizedMessage());
-        }
-    }
-
-    private void checkError(final List<Vector3D> vertices, final List<int[]> facets,
-                            final String expected) {
-        try {
-            new PolyhedronsSet(vertices, facets, TEST_PRECISION);
-            Assert.fail("an exception should have been thrown");
-        } catch (RuntimeException e) {
-            String actual = e.getMessage();
-            Assert.assertTrue("Expected string to contain \"" + expected + "\" but was \"" + actual + "\"",
-                    actual.contains(expected));
-        }
-    }
-
-    @Test
-    public void testFirstIntersection() {
-        // arrange
-        List<SubHyperplane<Vector3D>> boundaries = createBoxBoundaries(Vector3D.ZERO, 2.0, TEST_EPS);
-        PolyhedronsSet polySet = new PolyhedronsSet(boundaries, TEST_PRECISION);
-
-        Line xPlus = new Line(Vector3D.ZERO, Vector3D.of(1, 0, 0), TEST_PRECISION);
-        Line xMinus = new Line(Vector3D.ZERO, Vector3D.of(-1, 0, 0), TEST_PRECISION);
-
-        Line yPlus = new Line(Vector3D.ZERO, Vector3D.of(0, 1, 0), TEST_PRECISION);
-        Line yMinus = new Line(Vector3D.ZERO, Vector3D.of(0, -1, 0), TEST_PRECISION);
-
-        Line zPlus = new Line(Vector3D.ZERO, Vector3D.of(0, 0, 1), TEST_PRECISION);
-        Line zMinus = new Line(Vector3D.ZERO, Vector3D.of(0, 0, -1), TEST_PRECISION);
-
-        // act/assert
-        assertSubPlaneNormal(Vector3D.of(-1, 0, 0), polySet.firstIntersection(Vector3D.of(-1.1, 0, 0), xPlus));
-        assertSubPlaneNormal(Vector3D.of(-1, 0, 0), polySet.firstIntersection(Vector3D.of(-1, 0, 0), xPlus));
-        assertSubPlaneNormal(Vector3D.of(1, 0, 0), polySet.firstIntersection(Vector3D.of(-0.9, 0, 0), xPlus));
-        Assert.assertEquals(null, polySet.firstIntersection(Vector3D.of(1.1, 0, 0), xPlus));
-
-        assertSubPlaneNormal(Vector3D.of(1, 0, 0), polySet.firstIntersection(Vector3D.of(1.1, 0, 0), xMinus));
-        assertSubPlaneNormal(Vector3D.of(1, 0, 0), polySet.firstIntersection(Vector3D.of(1, 0, 0), xMinus));
-        assertSubPlaneNormal(Vector3D.of(-1, 0, 0), polySet.firstIntersection(Vector3D.of(0.9, 0, 0), xMinus));
-        Assert.assertEquals(null, polySet.firstIntersection(Vector3D.of(-1.1, 0, 0), xMinus));
-
-        assertSubPlaneNormal(Vector3D.of(0, -1, 0), polySet.firstIntersection(Vector3D.of(0, -1.1, 0), yPlus));
-        assertSubPlaneNormal(Vector3D.of(0, -1, 0), polySet.firstIntersection(Vector3D.of(0, -1, 0), yPlus));
-        assertSubPlaneNormal(Vector3D.of(0, 1, 0), polySet.firstIntersection(Vector3D.of(0, -0.9, 0), yPlus));
-        Assert.assertEquals(null, polySet.firstIntersection(Vector3D.of(0, 1.1, 0), yPlus));
-
-        assertSubPlaneNormal(Vector3D.of(0, 1, 0), polySet.firstIntersection(Vector3D.of(0, 1.1, 0), yMinus));
-        assertSubPlaneNormal(Vector3D.of(0, 1, 0), polySet.firstIntersection(Vector3D.of(0, 1, 0), yMinus));
-        assertSubPlaneNormal(Vector3D.of(0, -1, 0), polySet.firstIntersection(Vector3D.of(0, 0.9, 0), yMinus));
-        Assert.assertEquals(null, polySet.firstIntersection(Vector3D.of(0, -1.1, 0), yMinus));
-
-        assertSubPlaneNormal(Vector3D.of(0, 0, -1), polySet.firstIntersection(Vector3D.of(0, 0, -1.1), zPlus));
-        assertSubPlaneNormal(Vector3D.of(0, 0, -1), polySet.firstIntersection(Vector3D.of(0, 0, -1), zPlus));
-        assertSubPlaneNormal(Vector3D.of(0, 0, 1), polySet.firstIntersection(Vector3D.of(0, 0, -0.9), zPlus));
-        Assert.assertEquals(null, polySet.firstIntersection(Vector3D.of(0, 0, 1.1), zPlus));
-
-        assertSubPlaneNormal(Vector3D.of(0, 0, 1), polySet.firstIntersection(Vector3D.of(0, 0, 1.1), zMinus));
-        assertSubPlaneNormal(Vector3D.of(0, 0, 1), polySet.firstIntersection(Vector3D.of(0, 0, 1), zMinus));
-        assertSubPlaneNormal(Vector3D.of(0, 0, -1), polySet.firstIntersection(Vector3D.of(0, 0, 0.9), zMinus));
-        Assert.assertEquals(null, polySet.firstIntersection(Vector3D.of(0, 0, -1.1), zMinus));
-    }
-
-    // issue GEOMETRY-38
-    @Test
-    public void testFirstIntersection_linePassesThroughVertex() {
-        // arrange
-        Vector3D lowerCorner = Vector3D.ZERO;
-        Vector3D upperCorner = Vector3D.of(1, 1, 1);
-        Vector3D center = lowerCorner.lerp(upperCorner, 0.5);
-
-        List<SubHyperplane<Vector3D>> boundaries = createBoxBoundaries(center, 1.0, TEST_EPS);
-        PolyhedronsSet polySet = new PolyhedronsSet(boundaries, TEST_PRECISION);
-
-        Line upDiagonal = new Line(lowerCorner, upperCorner, TEST_PRECISION);
-        Line downDiagonal = upDiagonal.revert();
-
-        // act/assert
-        SubPlane upFromOutsideResult = (SubPlane) polySet.firstIntersection(Vector3D.of(-1, -1, -1), upDiagonal);
-        Assert.assertNotNull(upFromOutsideResult);
-        EuclideanTestUtils.assertCoordinatesEqual(lowerCorner,
-                ((Plane) upFromOutsideResult.getHyperplane()).intersection(upDiagonal), TEST_EPS);
-
-        SubPlane upFromCenterResult = (SubPlane) polySet.firstIntersection(center, upDiagonal);
-        Assert.assertNotNull(upFromCenterResult);
-        EuclideanTestUtils.assertCoordinatesEqual(upperCorner,
-                ((Plane) upFromCenterResult.getHyperplane()).intersection(upDiagonal), TEST_EPS);
-
-        SubPlane downFromOutsideResult = (SubPlane) polySet.firstIntersection(Vector3D.of(2, 2, 2), downDiagonal);
-        Assert.assertNotNull(downFromOutsideResult);
-        EuclideanTestUtils.assertCoordinatesEqual(upperCorner,
-                ((Plane) downFromOutsideResult.getHyperplane()).intersection(downDiagonal), TEST_EPS);
-
-        SubPlane downFromCenterResult = (SubPlane) polySet.firstIntersection(center, downDiagonal);
-        Assert.assertNotNull(downFromCenterResult);
-        EuclideanTestUtils.assertCoordinatesEqual(lowerCorner,
-                ((Plane) downFromCenterResult.getHyperplane()).intersection(downDiagonal), TEST_EPS);
-    }
-
-    // Issue GEOMETRY-43
-    @Test
-    public void testFirstIntersection_lineParallelToFace() {
-        // arrange - setup box
-        Vector3D lowerCorner = Vector3D.ZERO;
-        Vector3D upperCorner = Vector3D.of(1, 1, 1);
-        Vector3D center = lowerCorner.lerp(upperCorner, 0.5);
-        List<SubHyperplane<Vector3D>> boundaries = createBoxBoundaries(center, 1.0, TEST_EPS);
-        PolyhedronsSet polySet = new PolyhedronsSet(boundaries, TEST_PRECISION);
-
-        Vector3D firstPointOnLine = Vector3D.of(0.5, -1.0, 0);
-        Vector3D secondPointOnLine = Vector3D.of(0.5, 2.0, 0);
-        Line bottomLine = new Line(firstPointOnLine, secondPointOnLine, TEST_PRECISION);
-
-        Vector3D expectedIntersection1 = Vector3D.of(0.5, 0, 0.0);
-        Vector3D expectedIntersection2 = Vector3D.of(0.5, 1.0, 0.0);
-
-        // act/assert
-        SubPlane bottom = (SubPlane) polySet.firstIntersection(firstPointOnLine, bottomLine);
-        Assert.assertNotNull(bottom);
-        EuclideanTestUtils.assertCoordinatesEqual(expectedIntersection1,
-                ((Plane) bottom.getHyperplane()).intersection(bottomLine), TEST_EPS);
-
-        bottom = (SubPlane) polySet.firstIntersection(Vector3D.of(0.5, 0.1, 0.0), bottomLine);
-        Assert.assertNotNull(bottom);
-        Vector3D intersection = ((Plane) bottom.getHyperplane()).intersection(bottomLine);
-        Assert.assertNotNull(intersection);
-        EuclideanTestUtils.assertCoordinatesEqual(expectedIntersection2, intersection, TEST_EPS);
-    }
-
-    @Test
-    public void testFirstIntersection_rayPointOnFace() {
-        // arrange
-        Vector3D lowerCorner = Vector3D.ZERO;
-        Vector3D upperCorner = Vector3D.of(1, 1, 1);
-        Vector3D center = lowerCorner.lerp(upperCorner, 0.5);
-        List<SubHyperplane<Vector3D>> boundaries = createBoxBoundaries(center, 1.0, TEST_EPS);
-        PolyhedronsSet polySet = new PolyhedronsSet(boundaries, TEST_PRECISION);
-
-        Vector3D pt = Vector3D.of(0.5, 0.5, 0);
-        Line intoBoxLine = new Line(pt, pt.add(Vector3D.Unit.PLUS_Z), TEST_PRECISION);
-        Line outOfBoxLine = new Line(pt, pt.add(Vector3D.Unit.MINUS_Z), TEST_PRECISION);
-
-        // act/assert
-        SubPlane intoBoxResult = (SubPlane) polySet.firstIntersection(pt, intoBoxLine);
-        Vector3D intoBoxPt = ((Plane) intoBoxResult.getHyperplane()).intersection(intoBoxLine);
-        EuclideanTestUtils.assertCoordinatesEqual(pt, intoBoxPt, TEST_EPS);
-
-        SubPlane outOfBoxResult = (SubPlane) polySet.firstIntersection(pt, outOfBoxLine);
-        Vector3D outOfBoxPt = ((Plane) outOfBoxResult.getHyperplane()).intersection(outOfBoxLine);
-        EuclideanTestUtils.assertCoordinatesEqual(pt, outOfBoxPt, TEST_EPS);
-    }
-
-    @Test
-    public void testFirstIntersection_rayPointOnVertex() {
-        // arrange
-        Vector3D lowerCorner = Vector3D.ZERO;
-        Vector3D upperCorner = Vector3D.of(1, 1, 1);
-        Vector3D center = lowerCorner.lerp(upperCorner, 0.5);
-
-        List<SubHyperplane<Vector3D>> boundaries = createBoxBoundaries(center, 1.0, TEST_EPS);
-        PolyhedronsSet polySet = new PolyhedronsSet(boundaries, TEST_PRECISION);
-
-        Line intoBoxLine = new Line(lowerCorner, upperCorner, TEST_PRECISION);
-        Line outOfBoxLine = intoBoxLine.revert();
-
-        // act/assert
-        SubPlane intoBoxResult = (SubPlane) polySet.firstIntersection(lowerCorner, intoBoxLine);
-        Vector3D intoBoxPt = ((Plane) intoBoxResult.getHyperplane()).intersection(intoBoxLine);
-        EuclideanTestUtils.assertCoordinatesEqual(lowerCorner, intoBoxPt, TEST_EPS);
-
-        SubPlane outOfBoxResult = (SubPlane) polySet.firstIntersection(lowerCorner, outOfBoxLine);
-        Vector3D outOfBoxPt = ((Plane) outOfBoxResult.getHyperplane()).intersection(outOfBoxLine);
-        EuclideanTestUtils.assertCoordinatesEqual(lowerCorner, outOfBoxPt, TEST_EPS);
-    }
-
-    // Issue 1211
-    // See https://issues.apache.org/jira/browse/MATH-1211
-    @Test
-    public void testFirstIntersection_onlyReturnsPointsInDirectionOfRay() throws IOException, ParseException {
-        // arrange
-        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-8);
-        PolyhedronsSet polyset = EuclideanTestUtils.parsePolyhedronsSet(loadTestData("issue-1211.bsp"), precision);
-        UniformRandomProvider random = RandomSource.create(RandomSource.WELL_1024_A, 0xb97c9d1ade21e40al);
-
-        // act/assert
-        int nrays = 1000;
-        for (int i = 0; i < nrays; i++) {
-            Vector3D origin    = Vector3D.ZERO;
-            Vector3D direction = Vector3D.of(2 * random.nextDouble() - 1,
-                                              2 * random.nextDouble() - 1,
-                                              2 * random.nextDouble() - 1).normalize();
-            Line line = new Line(origin, origin.add(direction), polyset.getPrecision());
-            SubHyperplane<Vector3D> plane = polyset.firstIntersection(origin, line);
-            if (plane != null) {
-                Vector3D intersectionPoint = ((Plane)plane.getHyperplane()).intersection(line);
-                double dotProduct = direction.dot(intersectionPoint.subtract(origin));
-                Assert.assertTrue(dotProduct > 0);
-            }
-        }
-    }
-
-    @Test
-    public void testBoolean_union() throws IOException {
-        // arrange
-        double tolerance = 0.05;
-        double size = 1.0;
-        double radius = size * 0.5;
-        PolyhedronsSet box = new PolyhedronsSet(0, size, 0, size, 0, size, TEST_PRECISION);
-        PolyhedronsSet sphere = createSphere(Vector3D.of(size * 0.5, size * 0.5, size), radius, 8, 16);
-
-        // act
-        PolyhedronsSet result = (PolyhedronsSet) new RegionFactory<Vector3D>().union(box, sphere);
-
-        // OBJWriter.write("union.obj", result);
-
-        // assert
-        Assert.assertEquals(cubeVolume(size) + (sphereVolume(radius) * 0.5),
-                result.getSize(), tolerance);
-        Assert.assertEquals(cubeSurface(size) - circleSurface(radius) + (0.5 * sphereSurface(radius)),
-                result.getBoundarySize(), tolerance);
-        Assert.assertFalse(result.isEmpty());
-        Assert.assertFalse(result.isFull());
-
-        checkPoints(Region.Location.OUTSIDE, result,
-                Vector3D.of(-0.1, 0.5, 0.5),
-                Vector3D.of(1.1, 0.5, 0.5),
-                Vector3D.of(0.5, -0.1, 0.5),
-                Vector3D.of(0.5, 1.1, 0.5),
-                Vector3D.of(0.5, 0.5, -0.1),
-                Vector3D.of(0.5, 0.5, 1.6));
-
-        checkPoints(Region.Location.INSIDE, result,
-                Vector3D.of(0.1, 0.5, 0.5),
-                Vector3D.of(0.9, 0.5, 0.5),
-                Vector3D.of(0.5, 0.1, 0.5),
-                Vector3D.of(0.5, 0.9, 0.5),
-                Vector3D.of(0.5, 0.5, 0.1),
-                Vector3D.of(0.5, 0.5, 1.4));
-    }
-
-    @Test
-    public void testUnion_self() {
-        // arrange
-        double tolerance = 0.2;
-        double radius = 1.0;
-
-        PolyhedronsSet sphere = createSphere(Vector3D.ZERO, radius, 8, 16);
-
-        // act
-        PolyhedronsSet result = (PolyhedronsSet) new RegionFactory<Vector3D>().union(sphere, sphere.copySelf());
-
-        // assert
-        Assert.assertEquals(sphereVolume(radius), result.getSize(), tolerance);
-        Assert.assertEquals(sphereSurface(radius), result.getBoundarySize(), tolerance);
-        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.ZERO, result.getBarycenter(), TEST_EPS);
-        Assert.assertFalse(result.isEmpty());
-        Assert.assertFalse(result.isFull());
-
-        checkPoints(Region.Location.OUTSIDE, result,
-                Vector3D.of(-1.1, 0, 0),
-                Vector3D.of(1.1, 0, 0),
-                Vector3D.of(0, -1.1, 0),
-                Vector3D.of(0, 1.1, 0),
-                Vector3D.of(0, 0, -1.1),
-                Vector3D.of(0, 0, 1.1));
-
-        checkPoints(Region.Location.INSIDE, result,
-                Vector3D.of(-0.9, 0, 0),
-                Vector3D.of(0.9, 0, 0),
-                Vector3D.of(0, -0.9, 0),
-                Vector3D.of(0, 0.9, 0),
-                Vector3D.of(0, 0, -0.9),
-                Vector3D.of(0, 0, 0.9),
-                Vector3D.ZERO);
-    }
-
-    @Test
-    public void testBoolean_intersection() throws IOException {
-        // arrange
-        double tolerance = 0.05;
-        double size = 1.0;
-        double radius = size * 0.5;
-        PolyhedronsSet box = new PolyhedronsSet(0, size, 0, size, 0, size, TEST_PRECISION);
-        PolyhedronsSet sphere = createSphere(Vector3D.of(size * 0.5, size * 0.5, size), radius, 8, 16);
-
-        // act
-        PolyhedronsSet result = (PolyhedronsSet) new RegionFactory<Vector3D>().intersection(box, sphere);
-
-        // OBJWriter.write("intersection.obj", result);
-
-        // assert
-        Assert.assertEquals((sphereVolume(radius) * 0.5), result.getSize(), tolerance);
-        Assert.assertEquals(circleSurface(radius) + (0.5 * sphereSurface(radius)),
-                result.getBoundarySize(), tolerance);
-        Assert.assertFalse(result.isEmpty());
-        Assert.assertFalse(result.isFull());
-
-        checkPoints(Region.Location.OUTSIDE, result,
-                Vector3D.of(-0.1, 0.5, 1.0),
-                Vector3D.of(1.1, 0.5, 1.0),
-                Vector3D.of(0.5, -0.1, 1.0),
-                Vector3D.of(0.5, 1.1, 1.0),
-                Vector3D.of(0.5, 0.5, 0.4),
-                Vector3D.of(0.5, 0.5, 1.1));
-
-        checkPoints(Region.Location.INSIDE, result,
-                Vector3D.of(0.1, 0.5, 0.9),
-                Vector3D.of(0.9, 0.5, 0.9),
-                Vector3D.of(0.5, 0.1, 0.9),
-                Vector3D.of(0.5, 0.9, 0.9),
-                Vector3D.of(0.5, 0.5, 0.6),
-                Vector3D.of(0.5, 0.5, 0.9));
-    }
-
-    @Test
-    public void testIntersection_self() {
-        // arrange
-        double tolerance = 0.2;
-        double radius = 1.0;
-
-        PolyhedronsSet sphere = createSphere(Vector3D.ZERO, radius, 8, 16);
-
-        // act
-        PolyhedronsSet result = (PolyhedronsSet) new RegionFactory<Vector3D>().intersection(sphere, sphere.copySelf());
-
-        // assert
-        Assert.assertEquals(sphereVolume(radius), result.getSize(), tolerance);
-        Assert.assertEquals(sphereSurface(radius), result.getBoundarySize(), tolerance);
-        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.ZERO, result.getBarycenter(), TEST_EPS);
-        Assert.assertFalse(result.isEmpty());
-        Assert.assertFalse(result.isFull());
-
-        checkPoints(Region.Location.OUTSIDE, result,
-                Vector3D.of(-1.1, 0, 0),
-                Vector3D.of(1.1, 0, 0),
-                Vector3D.of(0, -1.1, 0),
-                Vector3D.of(0, 1.1, 0),
-                Vector3D.of(0, 0, -1.1),
-                Vector3D.of(0, 0, 1.1));
-
-        checkPoints(Region.Location.INSIDE, result,
-                Vector3D.of(-0.9, 0, 0),
-                Vector3D.of(0.9, 0, 0),
-                Vector3D.of(0, -0.9, 0),
-                Vector3D.of(0, 0.9, 0),
-                Vector3D.of(0, 0, -0.9),
-                Vector3D.of(0, 0, 0.9),
-                Vector3D.ZERO);
-    }
-
-    @Test
-    public void testBoolean_xor_twoCubes() throws IOException {
-        // arrange
-        double size = 1.0;
-        PolyhedronsSet box1 = new PolyhedronsSet(
-                0, size,
-                0, size,
-                0, size, TEST_PRECISION);
-        PolyhedronsSet box2 = new PolyhedronsSet(
-                0.5, size + 0.5,
-                0.5, size + 0.5,
-                0.5, size + 0.5, TEST_PRECISION);
-
-        // act
-        PolyhedronsSet result = (PolyhedronsSet) new RegionFactory<Vector3D>().xor(box1, box2);
-
-        // OBJWriter.write("xor_twoCubes.obj", result);
-
-        Assert.assertEquals((2 * cubeVolume(size)) - (2 * cubeVolume(size * 0.5)), result.getSize(), TEST_EPS);
-
-        // assert
-        Assert.assertEquals(2 * cubeSurface(size), result.getBoundarySize(), TEST_EPS);
-        Assert.assertFalse(result.isEmpty());
-        Assert.assertFalse(result.isFull());
-
-        checkPoints(Region.Location.OUTSIDE, result,
-                Vector3D.of(-0.1, -0.1, -0.1),
-                Vector3D.of(0.75, 0.75, 0.75),
-                Vector3D.of(1.6, 1.6, 1.6));
-
-        checkPoints(Region.Location.BOUNDARY, result,
-                Vector3D.of(0, 0, 0),
-                Vector3D.of(0.5, 0.5, 0.5),
-                Vector3D.of(1, 1, 1),
-                Vector3D.of(1.5, 1.5, 1.5));
-
-        checkPoints(Region.Location.INSIDE, result,
-                Vector3D.of(0.1, 0.1, 0.1),
-                Vector3D.of(0.4, 0.4, 0.4),
-                Vector3D.of(1.1, 1.1, 1.1),
-                Vector3D.of(1.4, 1.4, 1.4));
-    }
-
-    @Test
-    public void testBoolean_xor_cubeAndSphere() throws IOException {
-        // arrange
-        double tolerance = 0.05;
-        double size = 1.0;
-        double radius = size * 0.5;
-        PolyhedronsSet box = new PolyhedronsSet(0, size, 0, size, 0, size, TEST_PRECISION);
-        PolyhedronsSet sphere = createSphere(Vector3D.of(size * 0.5, size * 0.5, size), radius, 8, 16);
-
-        // act
-        PolyhedronsSet result = (PolyhedronsSet) new RegionFactory<Vector3D>().xor(box, sphere);
-
-        // OBJWriter.write("xor_cubeAndSphere.obj", result);
-
-        Assert.assertEquals(cubeVolume(size), result.getSize(), tolerance);
-
-        // assert
-        Assert.assertEquals(cubeSurface(size) + (sphereSurface(radius)),
-                result.getBoundarySize(), tolerance);
-        Assert.assertFalse(result.isEmpty());
-        Assert.assertFalse(result.isFull());
-
-        checkPoints(Region.Location.OUTSIDE, result,
-                Vector3D.of(-0.1, 0.5, 0.5),
-                Vector3D.of(1.1, 0.5, 0.5),
-                Vector3D.of(0.5, -0.1, 0.5),
-                Vector3D.of(0.5, 1.1, 0.5),
-                Vector3D.of(0.5, 0.5, -0.1),
-                Vector3D.of(0.5, 0.5, 1.6),
-                Vector3D.of(0.5, 0.5, 0.9));
-
-        checkPoints(Region.Location.INSIDE, result,
-                Vector3D.of(0.1, 0.5, 0.5),
-                Vector3D.of(0.9, 0.5, 0.5),
-                Vector3D.of(0.5, 0.1, 0.5),
-                Vector3D.of(0.5, 0.9, 0.5),
-                Vector3D.of(0.5, 0.5, 0.1),
-                Vector3D.of(0.5, 0.5, 1.4));
-    }
-
-    @Test
-    public void testXor_self() {
-        // arrange
-        double radius = 1.0;
-
-        PolyhedronsSet sphere = createSphere(Vector3D.ZERO, radius, 8, 16);
-
-        // act
-        PolyhedronsSet result = (PolyhedronsSet) new RegionFactory<Vector3D>().xor(sphere, sphere.copySelf());
-
-        // assert
-        Assert.assertEquals(0.0, result.getSize(), TEST_EPS);
-        Assert.assertEquals(0.0, result.getBoundarySize(), TEST_EPS);
-        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.NaN, result.getBarycenter(), TEST_EPS);
-        Assert.assertTrue(result.isEmpty());
-        Assert.assertFalse(result.isFull());
-
-        checkPoints(Region.Location.OUTSIDE, result,
-                Vector3D.of(-1.1, 0, 0),
-                Vector3D.of(1.1, 0, 0),
-                Vector3D.of(0, -1.1, 0),
-                Vector3D.of(0, 1.1, 0),
-                Vector3D.of(0, 0, -1.1),
-                Vector3D.of(0, 0, 1.1),
-                Vector3D.of(-0.9, 0, 0),
-                Vector3D.of(0.9, 0, 0),
-                Vector3D.of(0, -0.9, 0),
-                Vector3D.of(0, 0.9, 0),
-                Vector3D.of(0, 0, -0.9),
-                Vector3D.of(0, 0, 0.9),
-                Vector3D.ZERO);
-    }
-
-    @Test
-    public void testBoolean_difference() throws IOException {
-        // arrange
-        double tolerance = 0.05;
-        double size = 1.0;
-        double radius = size * 0.5;
-        PolyhedronsSet box = new PolyhedronsSet(0, size, 0, size, 0, size, TEST_PRECISION);
-        PolyhedronsSet sphere = createSphere(Vector3D.of(size * 0.5, size * 0.5, size), radius, 8, 16);
-
-        // act
-        PolyhedronsSet result = (PolyhedronsSet) new RegionFactory<Vector3D>().difference(box, sphere);
-
-        // OBJWriter.write("difference.obj", result);
-
-        // assert
-        Assert.assertEquals(cubeVolume(size) - (sphereVolume(radius) * 0.5), result.getSize(), tolerance);
-        Assert.assertEquals(cubeSurface(size) - circleSurface(radius) + (0.5 * sphereSurface(radius)),
-                result.getBoundarySize(), tolerance);
-        Assert.assertFalse(result.isEmpty());
-        Assert.assertFalse(result.isFull());
-
-        checkPoints(Region.Location.OUTSIDE, result,
-                Vector3D.of(-0.1, 0.5, 1.0),
-                Vector3D.of(1.1, 0.5, 1.0),
-                Vector3D.of(0.5, -0.1, 1.0),
-                Vector3D.of(0.5, 1.1, 1.0),
-                Vector3D.of(0.5, 0.5, -0.1),
-                Vector3D.of(0.5, 0.5, 0.6));
-
-        checkPoints(Region.Location.INSIDE, result,
-                Vector3D.of(0.1, 0.5, 0.4),
-                Vector3D.of(0.9, 0.5, 0.4),
-                Vector3D.of(0.5, 0.1, 0.4),
-                Vector3D.of(0.5, 0.9, 0.4),
-                Vector3D.of(0.5, 0.5, 0.1),
-                Vector3D.of(0.5, 0.5, 0.4));
-    }
-
-    @Test
-    public void testDifference_self() {
-        // arrange
-        double radius = 1.0;
-
-        PolyhedronsSet sphere = createSphere(Vector3D.ZERO, radius, 8, 16);
-
-        // act
-        PolyhedronsSet result = (PolyhedronsSet) new RegionFactory<Vector3D>().difference(sphere, sphere.copySelf());
-
-        // assert
-        Assert.assertEquals(0.0, result.getSize(), TEST_EPS);
-        Assert.assertEquals(0.0, result.getBoundarySize(), TEST_EPS);
-        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.NaN, result.getBarycenter(), TEST_EPS);
-        Assert.assertTrue(result.isEmpty());
-        Assert.assertFalse(result.isFull());
-
-        checkPoints(Region.Location.OUTSIDE, result,
-                Vector3D.of(-1.1, 0, 0),
-                Vector3D.of(1.1, 0, 0),
-                Vector3D.of(0, -1.1, 0),
-                Vector3D.of(0, 1.1, 0),
-                Vector3D.of(0, 0, -1.1),
-                Vector3D.of(0, 0, 1.1),
-                Vector3D.of(-0.9, 0, 0),
-                Vector3D.of(0.9, 0, 0),
-                Vector3D.of(0, -0.9, 0),
-                Vector3D.of(0, 0.9, 0),
-                Vector3D.of(0, 0, -0.9),
-                Vector3D.of(0, 0, 0.9),
-                Vector3D.ZERO);
-    }
-
-    @Test
-    public void testBoolean_multiple() throws IOException {
-        // arrange
-        double tolerance = 0.05;
-        double size = 1.0;
-        double radius = size * 0.5;
-        PolyhedronsSet box = new PolyhedronsSet(0, size, 0, size, 0, size, TEST_PRECISION);
-        PolyhedronsSet sphereToAdd = createSphere(Vector3D.of(size * 0.5, size * 0.5, size), radius, 8, 16);
-        PolyhedronsSet sphereToRemove1 = createSphere(Vector3D.of(size * 0.5, 0, size * 0.5), radius, 8, 16);
-        PolyhedronsSet sphereToRemove2 = createSphere(Vector3D.of(size * 0.5, 1, size * 0.5), radius, 8, 16);
-
-        RegionFactory<Vector3D> factory = new RegionFactory<Vector3D>();
-
-        // act
-        PolyhedronsSet result = (PolyhedronsSet) factory.union(box, sphereToAdd);
-        result = (PolyhedronsSet) factory.difference(result, sphereToRemove1);
-        result = (PolyhedronsSet) factory.difference(result, sphereToRemove2);
-
-        // OBJWriter.write("multiple.obj", result);
-
-        // assert
-        Assert.assertEquals(cubeVolume(size) - (sphereVolume(radius) * 0.5),
-                result.getSize(), tolerance);
-        Assert.assertEquals(cubeSurface(size) - (3.0 * circleSurface(radius)) + (1.5 * sphereSurface(radius)),
-                result.getBoundarySize(), tolerance);
-        Assert.assertFalse(result.isEmpty());
-        Assert.assertFalse(result.isFull());
-
-        checkPoints(Region.Location.OUTSIDE, result,
-                Vector3D.of(-0.1, 0.5, 0.5),
-                Vector3D.of(1.1, 0.5, 0.5),
-                Vector3D.of(0.5, 0.4, 0.5),
-                Vector3D.of(0.5, 0.6, 0.5),
-                Vector3D.of(0.5, 0.5, -0.1),
-                Vector3D.of(0.5, 0.5, 1.6));
-
-        checkPoints(Region.Location.INSIDE, result,
-                Vector3D.of(0.1, 0.5, 0.1),
-                Vector3D.of(0.9, 0.5, 0.1),
-                Vector3D.of(0.5, 0.4, 0.1),
-                Vector3D.of(0.5, 0.6, 0.1),
-                Vector3D.of(0.5, 0.5, 0.1),
-                Vector3D.of(0.5, 0.5, 1.4));
-    }
-
-    @Test
-    public void testProjectToBoundary() {
-        // arrange
-        PolyhedronsSet polySet = new PolyhedronsSet(0, 1, 0, 1, 0, 1, TEST_PRECISION);
-
-        // act/assert
-        checkProjectToBoundary(polySet, Vector3D.of(0.4, 0.5, 0.5),
-                Vector3D.of(0, 0.5, 0.5), -0.4);
-        checkProjectToBoundary(polySet, Vector3D.of(1.5, 0.5, 0.5),
-                Vector3D.of(1, 0.5, 0.5), 0.5);
-        checkProjectToBoundary(polySet, Vector3D.of(2, 2, 2),
-                Vector3D.of(1, 1, 1), Math.sqrt(3));
-    }
-
-    @Test
-    public void testProjectToBoundary_invertedRegion() {
-        // arrange
-        PolyhedronsSet polySet = new PolyhedronsSet(0, 1, 0, 1, 0, 1, TEST_PRECISION);
-        polySet = (PolyhedronsSet) new RegionFactory<Vector3D>().getComplement(polySet);
-
-        // act/assert
-        checkProjectToBoundary(polySet, Vector3D.of(0.4, 0.5, 0.5),
-                Vector3D.of(0, 0.5, 0.5), 0.4);
-        checkProjectToBoundary(polySet, Vector3D.of(1.5, 0.5, 0.5),
-                Vector3D.of(1, 0.5, 0.5), -0.5);
-        checkProjectToBoundary(polySet, Vector3D.of(2, 2, 2),
-                Vector3D.of(1, 1, 1), -Math.sqrt(3));
-    }
-
-    private void checkProjectToBoundary(PolyhedronsSet poly, Vector3D toProject,
-            Vector3D expectedPoint, double expectedOffset) {
-        BoundaryProjection<Vector3D> proj = poly.projectToBoundary(toProject);
-
-        EuclideanTestUtils.assertCoordinatesEqual(toProject, proj.getOriginal(), TEST_EPS);
-        EuclideanTestUtils.assertCoordinatesEqual(expectedPoint, proj.getProjected(), TEST_EPS);
-        Assert.assertEquals(expectedOffset, proj.getOffset(), TEST_EPS);
-    }
-
-    private String loadTestData(final String resourceName)
-            throws IOException {
-        try (Reader reader = new InputStreamReader(getClass().getResourceAsStream(resourceName), "UTF-8")) {
-            StringBuilder builder = new StringBuilder();
-            for (int c = reader.read(); c >= 0; c = reader.read()) {
-                builder.append((char) c);
-            }
-            return builder.toString();
-        }
-    }
-
-    private void checkPoints(Region.Location expected, PolyhedronsSet poly, Vector3D ... points) {
-        for (int i = 0; i < points.length; ++i) {
-            Assert.assertEquals("Incorrect location for " + points[i], expected, poly.checkPoint(points[i]));
-        }
-    }
-
-    private List<SubHyperplane<Vector3D>> createBoxBoundaries(Vector3D center, double size, double eps) {
-        List<SubHyperplane<Vector3D>> boundaries = new ArrayList<>();
-
-        double offset = size * 0.5;
-        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(eps);
-
-        Plane xMinus = Plane.fromPointAndNormal(center.add(Vector3D.of(-offset, 0, 0)), Vector3D.Unit.MINUS_X, precision);
-        Plane xPlus = Plane.fromPointAndNormal(center.add(Vector3D.of(offset, 0, 0)), Vector3D.Unit.PLUS_X, precision);
-        Plane yPlus = Plane.fromPointAndNormal(center.add(Vector3D.of(0, offset, 0)), Vector3D.Unit.PLUS_Y, precision);
-        Plane yMinus = Plane.fromPointAndNormal(center.add(Vector3D.of(0, -offset, 0)), Vector3D.Unit.MINUS_Y, precision);
-        Plane zPlus = Plane.fromPointAndNormal(center.add(Vector3D.of(0, 0, offset)), Vector3D.Unit.PLUS_Z, precision);
-        Plane zMinus = Plane.fromPointAndNormal(center.add(Vector3D.of(0, 0, -offset)), Vector3D.Unit.MINUS_Z, precision);
-
-        // +x
-        boundaries.add(createSubPlane(xPlus,
-                        center.add(Vector3D.of(offset, offset, offset)),
-                        center.add(Vector3D.of(offset, -offset, offset)),
-                        center.add(Vector3D.of(offset, -offset, -offset)),
-                        center.add(Vector3D.of(offset, offset, -offset))));
-
-        // -x
-        boundaries.add(createSubPlane(xMinus,
-                        center.add(Vector3D.of(-offset, -offset, offset)),
-                        center.add(Vector3D.of(-offset, offset, offset)),
-                        center.add(Vector3D.of(-offset, offset, -offset)),
-                        center.add(Vector3D.of(-offset, -offset, -offset))));
-
-        // +y
-        boundaries.add(createSubPlane(yPlus,
-                        center.add(Vector3D.of(-offset, offset, offset)),
-                        center.add(Vector3D.of(offset, offset, offset)),
-                        center.add(Vector3D.of(offset, offset, -offset)),
-                        center.add(Vector3D.of(-offset, offset, -offset))));
-
-        // -y
-        boundaries.add(createSubPlane(yMinus,
-                        center.add(Vector3D.of(-offset, -offset, offset)),
-                        center.add(Vector3D.of(-offset, -offset, -offset)),
-                        center.add(Vector3D.of(offset, -offset, -offset)),
-                        center.add(Vector3D.of(offset, -offset, offset))));
-
-        // +z
-        boundaries.add(createSubPlane(zPlus,
-                        center.add(Vector3D.of(-offset, -offset, offset)),
-                        center.add(Vector3D.of(offset, -offset, offset)),
-                        center.add(Vector3D.of(offset, offset, offset)),
-                        center.add(Vector3D.of(-offset, offset, offset))));
-
-        // -z
-        boundaries.add(createSubPlane(zMinus,
-                        center.add(Vector3D.of(-offset, -offset, -offset)),
-                        center.add(Vector3D.of(-offset, offset, -offset)),
-                        center.add(Vector3D.of(offset, offset, -offset)),
-                        center.add(Vector3D.of(offset, -offset, -offset))));
-
-        return boundaries;
-    }
-
-    private SubPlane createSubPlane(Plane plane, Vector3D...points) {
-        Vector2D[] points2d = new Vector2D[points.length];
-        for (int i=0; i<points.length; ++i) {
-            points2d[i] = plane.toSubSpace(points[i]);
-        }
-
-        PolygonsSet polygon = new PolygonsSet(plane.getPrecision(), points2d);
-
-        return new SubPlane(plane, polygon);
-    }
-
-    private PolyhedronsSet createSphere(Vector3D center, double radius, int stacks, int slices) {
-        List<Plane> planes = new ArrayList<>();
-
-        // add top and bottom planes (+/- z)
-        Vector3D topZ = Vector3D.of(center.getX(), center.getY(), center.getZ() + radius);
-        Vector3D bottomZ = Vector3D.of(center.getX(), center.getY(), center.getZ() - radius);
-
-        planes.add(Plane.fromPointAndNormal(topZ, Vector3D.Unit.PLUS_Z, TEST_PRECISION));
-        planes.add(Plane.fromPointAndNormal(bottomZ, Vector3D.Unit.MINUS_Z, TEST_PRECISION));
-
-        // add the side planes
-        double vDelta = Math.PI / stacks;
-        double hDelta = Math.PI * 2 / slices;
-
-        double adjustedRadius = (radius + (radius * Math.cos(vDelta * 0.5))) / 2.0;
-
-        double vAngle;
-        double hAngle;
-        double stackRadius;
-        double stackHeight;
-        double x, y;
-        Vector3D pt;
-        Vector3D norm;
-
-        vAngle = -0.5 * vDelta;
-        for (int v=0; v<stacks; ++v) {
-            vAngle += vDelta;
-
-            stackRadius = Math.sin(vAngle) * adjustedRadius;
-            stackHeight = Math.cos(vAngle) * adjustedRadius;
-
-            hAngle = -0.5 * hDelta;
-            for (int h=0; h<slices; ++h) {
-                hAngle += hDelta;
-
-                x = Math.cos(hAngle) * stackRadius;
-                y = Math.sin(hAngle) * stackRadius;
-
-                norm = Vector3D.of(x, y, stackHeight).normalize();
-                pt = center.add(norm.multiply(adjustedRadius));
-
-                planes.add(Plane.fromPointAndNormal(pt, norm, TEST_PRECISION));
-            }
-        }
-
-        return (PolyhedronsSet) new RegionFactory<Vector3D>().buildConvex(planes.toArray(new Plane[0]));
-    }
-
-    private void assertSubPlaneNormal(Vector3D expectedNormal, SubHyperplane<Vector3D> sub) {
-        Vector3D norm = ((Plane) sub.getHyperplane()).getNormal();
-        EuclideanTestUtils.assertCoordinatesEqual(expectedNormal, norm, TEST_EPS);
-    }
-
-    private double cubeVolume(double size) {
-        return size * size * size;
-    }
-
-    private double cubeSurface(double size) {
-        return 6.0 * size * size;
-    }
-
-    private double sphereVolume(double radius) {
-        return 4.0 * Math.PI * radius * radius * radius / 3.0;
-    }
-
-    private double sphereSurface(double radius) {
-        return 4.0 * Math.PI * radius * radius;
-    }
-
-    private double circleSurface(double radius) {
-        return Math.PI * radius * radius;
-    }
-}
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/RegionBSPTree3DTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/RegionBSPTree3DTest.java
new file mode 100644
index 0000000..17559ba
--- /dev/null
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/RegionBSPTree3DTest.java
@@ -0,0 +1,1633 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.threed;
+
+import java.io.IOException;
+import java.text.ParseException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.GeometryTestUtils;
+import org.apache.commons.geometry.core.Region;
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.exception.GeometryValueException;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.SplitLocation;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
+import org.apache.commons.geometry.euclidean.threed.RegionBSPTree3D.RegionNode3D;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class RegionBSPTree3DTest {
+
+    private static final double TEST_EPS = 1e-10;
+
+    private static final DoublePrecisionContext TEST_PRECISION =
+            new EpsilonDoublePrecisionContext(TEST_EPS);
+
+    @Test
+    public void testCtor_default() {
+        // act
+        RegionBSPTree3D tree = new RegionBSPTree3D();
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertTrue(tree.isEmpty());
+    }
+
+    @Test
+    public void testCtor_boolean() {
+        // act
+        RegionBSPTree3D a = new RegionBSPTree3D(true);
+        RegionBSPTree3D b = new RegionBSPTree3D(false);
+
+        // assert
+        Assert.assertTrue(a.isFull());
+        Assert.assertFalse(a.isEmpty());
+
+        Assert.assertFalse(b.isFull());
+        Assert.assertTrue(b.isEmpty());
+    }
+
+    @Test
+    public void testEmpty() {
+        // act
+        RegionBSPTree3D tree = RegionBSPTree3D.empty();
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertTrue(tree.isEmpty());
+
+        Assert.assertNull(tree.getBarycenter());
+        Assert.assertEquals(0.0, tree.getSize(), TEST_EPS);
+        Assert.assertEquals(0, tree.getBoundarySize(), TEST_EPS);
+
+        checkClassify(tree, RegionLocation.OUTSIDE,
+                Vector3D.of(-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE),
+                Vector3D.of(-100, -100, -100),
+                Vector3D.of(0, 0, 0),
+                Vector3D.of(100, 100, 100),
+                Vector3D.of(Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE));
+    }
+
+    @Test
+    public void testFull() {
+        // act
+        RegionBSPTree3D tree = RegionBSPTree3D.full();
+
+        // assert
+        Assert.assertTrue(tree.isFull());
+        Assert.assertFalse(tree.isEmpty());
+
+        Assert.assertNull(tree.getBarycenter());
+        GeometryTestUtils.assertPositiveInfinity(tree.getSize());
+        Assert.assertEquals(0, tree.getBoundarySize(), TEST_EPS);
+
+        checkClassify(tree, RegionLocation.INSIDE,
+                Vector3D.of(-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE),
+                Vector3D.of(-100, -100, -100),
+                Vector3D.of(0, 0, 0),
+                Vector3D.of(100, 100, 100),
+                Vector3D.of(Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE));
+    }
+
+    @Test
+    public void testCopy() {
+        // arrange
+        RegionBSPTree3D tree = new RegionBSPTree3D(true);
+        tree.getRoot().cut(Plane.fromNormal(Vector3D.Unit.PLUS_Z, TEST_PRECISION));
+
+        // act
+        RegionBSPTree3D copy = tree.copy();
+
+        // assert
+        Assert.assertNotSame(tree, copy);
+        Assert.assertEquals(3, copy.count());
+    }
+
+    @Test
+    public void testBoundaries() {
+        // arrange
+        RegionBSPTree3D tree = RegionBSPTree3D.builder(TEST_PRECISION)
+                .addRect(Vector3D.ZERO, Vector3D.of(1, 1, 1))
+                .build();
+
+        // act
+        List<ConvexSubPlane> subplanes = new ArrayList<>();
+        tree.boundaries().forEach(subplanes::add);
+
+        // assert
+        Assert.assertEquals(6, subplanes.size());
+    }
+
+    @Test
+    public void testGetBoundaries() {
+        // arrange
+        RegionBSPTree3D tree = RegionBSPTree3D.builder(TEST_PRECISION)
+                .addRect(Vector3D.ZERO, Vector3D.of(1, 1, 1))
+                .build();
+
+        // act
+        List<ConvexSubPlane> subplanes = tree.getBoundaries();
+
+        // assert
+        Assert.assertEquals(6, subplanes.size());
+    }
+
+    @Test
+    public void testHalfSpace() {
+        // act
+        RegionBSPTree3D tree = RegionBSPTree3D.empty();
+        tree.insert(Plane.fromPointAndNormal(Vector3D.ZERO, Vector3D.Unit.PLUS_Y, TEST_PRECISION).span());
+
+        // assert
+        Assert.assertFalse(tree.isEmpty());
+        Assert.assertFalse(tree.isFull());
+
+        EuclideanTestUtils.assertPositiveInfinity(tree.getSize());
+        EuclideanTestUtils.assertPositiveInfinity(tree.getBoundarySize());
+        Assert.assertNull(tree.getBarycenter());
+
+        checkClassify(tree, RegionLocation.INSIDE,
+                Vector3D.of(-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE),
+                Vector3D.of(-100, -100, -100));
+        checkClassify(tree, RegionLocation.BOUNDARY, Vector3D.of(0, 0, 0));
+        checkClassify(tree, RegionLocation.OUTSIDE,
+                Vector3D.of(100, 100, 100),
+                Vector3D.of(Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE));
+    }
+
+    @Test
+    public void testFromConvexVolume_full() {
+        // arrange
+        ConvexVolume volume = ConvexVolume.full();
+
+        // act
+        RegionBSPTree3D tree = RegionBSPTree3D.from(volume);
+        Assert.assertNull(tree.getBarycenter());
+
+        // assert
+        Assert.assertTrue(tree.isFull());
+    }
+
+    @Test
+    public void testFromConvexVolume_infinite() {
+        // arrange
+        ConvexVolume volume = ConvexVolume.fromBounds(Plane.fromNormal(Vector3D.Unit.PLUS_Z, TEST_PRECISION));
+
+        // act
+        RegionBSPTree3D tree = RegionBSPTree3D.from(volume);
+
+        // assert
+        GeometryTestUtils.assertPositiveInfinity(tree.getSize());
+        GeometryTestUtils.assertPositiveInfinity(tree.getBoundarySize());
+        Assert.assertNull(tree.getBarycenter());
+
+        checkClassify(tree, RegionLocation.OUTSIDE, Vector3D.of(0, 0, 1));
+        checkClassify(tree, RegionLocation.BOUNDARY, Vector3D.ZERO);
+        checkClassify(tree, RegionLocation.INSIDE, Vector3D.of(0, 0, -1));
+    }
+
+    @Test
+    public void testFromConvexVolume_finite() {
+        // arrange
+        ConvexVolume volume = ConvexVolume.fromBounds(
+                    Plane.fromPointAndNormal(Vector3D.ZERO, Vector3D.Unit.MINUS_X, TEST_PRECISION),
+                    Plane.fromPointAndNormal(Vector3D.ZERO, Vector3D.Unit.MINUS_Y, TEST_PRECISION),
+                    Plane.fromPointAndNormal(Vector3D.ZERO, Vector3D.Unit.MINUS_Z, TEST_PRECISION),
+
+                    Plane.fromPointAndNormal(Vector3D.of(1, 1, 1), Vector3D.Unit.PLUS_X, TEST_PRECISION),
+                    Plane.fromPointAndNormal(Vector3D.of(1, 1, 1), Vector3D.Unit.PLUS_Y, TEST_PRECISION),
+                    Plane.fromPointAndNormal(Vector3D.of(1, 1, 1), Vector3D.Unit.PLUS_Z, TEST_PRECISION)
+                );
+
+        // act
+        RegionBSPTree3D tree = RegionBSPTree3D.from(volume);
+
+        // assert
+        Assert.assertEquals(1, tree.getSize(), TEST_EPS);
+        Assert.assertEquals(6, tree.getBoundarySize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0.5, 0.5, 0.5), tree.getBarycenter(), TEST_EPS);
+
+        checkClassify(tree, RegionLocation.OUTSIDE,
+                Vector3D.of(-1, 0.5, 0.5), Vector3D.of(2, 0.5, 0.5),
+                Vector3D.of(0.5, -1, 0.5), Vector3D.of(0.5, 2, 0.5),
+                Vector3D.of(0.5, 0.5, -1), Vector3D.of(0.5, 0.5, 2));
+        checkClassify(tree, RegionLocation.BOUNDARY, Vector3D.ZERO);
+        checkClassify(tree, RegionLocation.INSIDE, Vector3D.of(0.5, 0.5, 0.5));
+    }
+
+    @Test
+    public void testRaycastFirstFace() {
+        // arrange
+        RegionBSPTree3D tree = RegionBSPTree3D.builder(TEST_PRECISION)
+                .addCenteredCube(Vector3D.ZERO, 2)
+                .build();
+
+        Line3D xPlus = Line3D.fromPoints(Vector3D.ZERO, Vector3D.of(1, 0, 0), TEST_PRECISION);
+        Line3D xMinus = Line3D.fromPoints(Vector3D.ZERO, Vector3D.of(-1, 0, 0), TEST_PRECISION);
+
+        Line3D yPlus = Line3D.fromPoints(Vector3D.ZERO, Vector3D.of(0, 1, 0), TEST_PRECISION);
+        Line3D yMinus = Line3D.fromPoints(Vector3D.ZERO, Vector3D.of(0, -1, 0), TEST_PRECISION);
+
+        Line3D zPlus = Line3D.fromPoints(Vector3D.ZERO, Vector3D.of(0, 0, 1), TEST_PRECISION);
+        Line3D zMinus = Line3D.fromPoints(Vector3D.ZERO, Vector3D.of(0, 0, -1), TEST_PRECISION);
+
+        // act/assert
+        assertSubPlaneNormal(Vector3D.of(-1, 0, 0), tree.raycastFirst(xPlus.segmentFrom(Vector3D.of(-1.1, 0, 0))));
+        assertSubPlaneNormal(Vector3D.of(-1, 0, 0), tree.raycastFirst(xPlus.segmentFrom(Vector3D.of(-1, 0, 0))));
+        assertSubPlaneNormal(Vector3D.of(1, 0, 0), tree.raycastFirst(xPlus.segmentFrom(Vector3D.of(-0.9, 0, 0))));
+        Assert.assertEquals(null, tree.raycastFirst(xPlus.segmentFrom(Vector3D.of(1.1, 0, 0))));
+
+        assertSubPlaneNormal(Vector3D.of(1, 0, 0), tree.raycastFirst(xMinus.segmentFrom(Vector3D.of(1.1, 0, 0))));
+        assertSubPlaneNormal(Vector3D.of(1, 0, 0), tree.raycastFirst(xMinus.segmentFrom(Vector3D.of(1, 0, 0))));
+        assertSubPlaneNormal(Vector3D.of(-1, 0, 0), tree.raycastFirst(xMinus.segmentFrom(Vector3D.of(0.9, 0, 0))));
+        Assert.assertEquals(null, tree.raycastFirst(xMinus.segmentFrom(Vector3D.of(-1.1, 0, 0))));
+
+        assertSubPlaneNormal(Vector3D.of(0, -1, 0), tree.raycastFirst(yPlus.segmentFrom(Vector3D.of(0, -1.1, 0))));
+        assertSubPlaneNormal(Vector3D.of(0, -1, 0), tree.raycastFirst(yPlus.segmentFrom(Vector3D.of(0, -1, 0))));
+        assertSubPlaneNormal(Vector3D.of(0, 1, 0), tree.raycastFirst(yPlus.segmentFrom(Vector3D.of(0, -0.9, 0))));
+        Assert.assertEquals(null, tree.raycastFirst(yPlus.segmentFrom(Vector3D.of(0, 1.1, 0))));
+
+        assertSubPlaneNormal(Vector3D.of(0, 1, 0), tree.raycastFirst(yMinus.segmentFrom(Vector3D.of(0, 1.1, 0))));
+        assertSubPlaneNormal(Vector3D.of(0, 1, 0), tree.raycastFirst(yMinus.segmentFrom(Vector3D.of(0, 1, 0))));
+        assertSubPlaneNormal(Vector3D.of(0, -1, 0), tree.raycastFirst(yMinus.segmentFrom(Vector3D.of(0, 0.9, 0))));
+        Assert.assertEquals(null, tree.raycastFirst(yMinus.segmentFrom(Vector3D.of(0, -1.1, 0))));
+
+        assertSubPlaneNormal(Vector3D.of(0, 0, -1), tree.raycastFirst(zPlus.segmentFrom(Vector3D.of(0, 0, -1.1))));
+        assertSubPlaneNormal(Vector3D.of(0, 0, -1), tree.raycastFirst(zPlus.segmentFrom(Vector3D.of(0, 0, -1))));
+        assertSubPlaneNormal(Vector3D.of(0, 0, 1), tree.raycastFirst(zPlus.segmentFrom(Vector3D.of(0, 0, -0.9))));
+        Assert.assertEquals(null, tree.raycastFirst(zPlus.segmentFrom(Vector3D.of(0, 0, 1.1))));
+
+        assertSubPlaneNormal(Vector3D.of(0, 0, 1), tree.raycastFirst(zMinus.segmentFrom(Vector3D.of(0, 0, 1.1))));
+        assertSubPlaneNormal(Vector3D.of(0, 0, 1), tree.raycastFirst(zMinus.segmentFrom(Vector3D.of(0, 0, 1))));
+        assertSubPlaneNormal(Vector3D.of(0, 0, -1), tree.raycastFirst(zMinus.segmentFrom(Vector3D.of(0, 0, 0.9))));
+        Assert.assertEquals(null, tree.raycastFirst(zMinus.segmentFrom(Vector3D.of(0, 0, -1.1))));
+    }
+
+    // issue GEOMETRY-38
+    @Test
+    public void testRaycastFirstFace_linePassesThroughVertex() {
+        // arrange
+        Vector3D lowerCorner = Vector3D.ZERO;
+        Vector3D upperCorner = Vector3D.of(1, 1, 1);
+        Vector3D center = lowerCorner.lerp(upperCorner, 0.5);
+
+        RegionBSPTree3D tree = RegionBSPTree3D.builder(TEST_PRECISION)
+                .addRect(lowerCorner, upperCorner)
+                .build();
+
+        Line3D upDiagonal = Line3D.fromPoints(lowerCorner, upperCorner, TEST_PRECISION);
+        Line3D downDiagonal = upDiagonal.reverse();
+
+        // act/assert
+        ConvexSubPlane upFromOutsideResult = tree.raycastFirst(upDiagonal.segmentFrom(Vector3D.of(-1, -1, -1)));
+        Assert.assertNotNull(upFromOutsideResult);
+        EuclideanTestUtils.assertCoordinatesEqual(lowerCorner, upFromOutsideResult.getPlane().intersection(upDiagonal), TEST_EPS);
+
+        ConvexSubPlane upFromCenterResult = tree.raycastFirst(upDiagonal.segmentFrom(center));
+        Assert.assertNotNull(upFromCenterResult);
+        EuclideanTestUtils.assertCoordinatesEqual(upperCorner, upFromCenterResult.getPlane().intersection(upDiagonal), TEST_EPS);
+
+        ConvexSubPlane downFromOutsideResult = tree.raycastFirst(downDiagonal.segmentFrom(Vector3D.of(2, 2, 2)));
+        Assert.assertNotNull(downFromOutsideResult);
+        EuclideanTestUtils.assertCoordinatesEqual(upperCorner, downFromOutsideResult.getPlane().intersection(downDiagonal), TEST_EPS);
+
+        ConvexSubPlane downFromCenterResult = tree.raycastFirst(downDiagonal.segmentFrom(center));
+        Assert.assertNotNull(downFromCenterResult);
+        EuclideanTestUtils.assertCoordinatesEqual(lowerCorner, downFromCenterResult.getPlane().intersection(downDiagonal), TEST_EPS);
+    }
+
+    // Issue GEOMETRY-43
+    @Test
+    public void testFirstIntersection_lineParallelToFace() {
+        // arrange - setup box
+        Vector3D lowerCorner = Vector3D.ZERO;
+        Vector3D upperCorner = Vector3D.of(1, 1, 1);
+
+        RegionBSPTree3D tree = RegionBSPTree3D.builder(TEST_PRECISION)
+                .addRect(lowerCorner, upperCorner)
+                .build();
+
+        Vector3D firstPointOnLine = Vector3D.of(0.5, -1.0, 0);
+        Vector3D secondPointOnLine = Vector3D.of(0.5, 2.0, 0);
+        Line3D bottomLine = Line3D.fromPoints(firstPointOnLine, secondPointOnLine, TEST_PRECISION);
+
+        Vector3D expectedIntersection1 = Vector3D.of(0.5, 0, 0.0);
+        Vector3D expectedIntersection2 = Vector3D.of(0.5, 1.0, 0.0);
+
+        // act/assert
+        ConvexSubPlane bottom = tree.raycastFirst(bottomLine.segmentFrom(firstPointOnLine));
+        Assert.assertNotNull(bottom);
+        EuclideanTestUtils.assertCoordinatesEqual(expectedIntersection1, bottom.getHyperplane().intersection(bottomLine), TEST_EPS);
+
+        bottom = tree.raycastFirst(bottomLine.segmentFrom(Vector3D.of(0.5, 0.1, 0.0)));
+        Assert.assertNotNull(bottom);
+        Vector3D intersection = bottom.getPlane().intersection(bottomLine);
+        Assert.assertNotNull(intersection);
+        EuclideanTestUtils.assertCoordinatesEqual(expectedIntersection2, intersection, TEST_EPS);
+    }
+
+    @Test
+    public void testRaycastFirstFace_rayPointOnFace() {
+        // arrange
+        Vector3D lowerCorner = Vector3D.ZERO;
+        Vector3D upperCorner = Vector3D.of(1, 1, 1);
+
+        RegionBSPTree3D tree = RegionBSPTree3D.builder(TEST_PRECISION)
+                .addRect(lowerCorner, upperCorner)
+                .build();
+
+        Vector3D pt = Vector3D.of(0.5, 0.5, 0);
+        Line3D intoBoxLine = Line3D.fromPoints(pt, pt.add(Vector3D.Unit.PLUS_Z), TEST_PRECISION);
+        Line3D outOfBoxLine = Line3D.fromPoints(pt, pt.add(Vector3D.Unit.MINUS_Z), TEST_PRECISION);
+
+        // act/assert
+        ConvexSubPlane intoBoxResult = tree.raycastFirst(intoBoxLine.segmentFrom(pt));
+        Vector3D intoBoxPt = intoBoxResult.getPlane().intersection(intoBoxLine);
+        EuclideanTestUtils.assertCoordinatesEqual(pt, intoBoxPt, TEST_EPS);
+
+        ConvexSubPlane outOfBoxResult = tree.raycastFirst(outOfBoxLine.segmentFrom(pt));
+        Vector3D outOfBoxPt = outOfBoxResult.getPlane().intersection(outOfBoxLine);
+        EuclideanTestUtils.assertCoordinatesEqual(pt, outOfBoxPt, TEST_EPS);
+    }
+
+    @Test
+    public void testRaycastFirstFace_rayPointOnVertex() {
+        // arrange
+        Vector3D lowerCorner = Vector3D.ZERO;
+        Vector3D upperCorner = Vector3D.of(1, 1, 1);
+
+        RegionBSPTree3D tree = RegionBSPTree3D.builder(TEST_PRECISION)
+                .addRect(lowerCorner, upperCorner)
+                .build();
+
+        Line3D intoBoxLine = Line3D.fromPoints(lowerCorner, upperCorner, TEST_PRECISION);
+        Line3D outOfBoxLine = intoBoxLine.reverse();
+
+        // act/assert
+        ConvexSubPlane intoBoxResult = tree.raycastFirst(intoBoxLine.segmentFrom(lowerCorner));
+        Vector3D intoBoxPt = intoBoxResult.getPlane().intersection(intoBoxLine);
+        EuclideanTestUtils.assertCoordinatesEqual(lowerCorner, intoBoxPt, TEST_EPS);
+
+        ConvexSubPlane outOfBoxResult = tree.raycastFirst(outOfBoxLine.segmentFrom(lowerCorner));
+        Vector3D outOfBoxPt = outOfBoxResult.getPlane().intersection(outOfBoxLine);
+        EuclideanTestUtils.assertCoordinatesEqual(lowerCorner, outOfBoxPt, TEST_EPS);
+    }
+
+    @Test
+    public void testRaycastFirstFace_onlyReturnsPointsWithinSegment() throws IOException, ParseException {
+        // arrange
+        Vector3D lowerCorner = Vector3D.ZERO;
+        Vector3D upperCorner = Vector3D.of(1, 1, 1);
+
+        RegionBSPTree3D tree = RegionBSPTree3D.builder(TEST_PRECISION)
+                .addRect(lowerCorner, upperCorner)
+                .build();
+
+        Line3D line = Line3D.fromPointAndDirection(Vector3D.of(0.5, 0.5, 0.5), Vector3D.Unit.PLUS_X, TEST_PRECISION);
+
+        // act/assert
+        assertSubPlaneNormal(Vector3D.Unit.MINUS_X, tree.raycastFirst(line.span()));
+        assertSubPlaneNormal(Vector3D.Unit.PLUS_X, tree.raycastFirst(line.reverse().span()));
+
+        assertSubPlaneNormal(Vector3D.Unit.MINUS_X, tree.raycastFirst(line.segment(Vector3D.of(-2, 0.5, 0.5), Vector3D.of(0.5, 0.5, 0.5))));
+        assertSubPlaneNormal(Vector3D.Unit.MINUS_X, tree.raycastFirst(line.segment(Vector3D.of(-2, 0.5, 0.5), Vector3D.of(0, 0.5, 0.5))));
+
+        assertSubPlaneNormal(Vector3D.Unit.PLUS_X, tree.raycastFirst(line.segment(Vector3D.of(0.5, 0.5, 0.5), Vector3D.of(2, 0.5, 0.5))));
+        assertSubPlaneNormal(Vector3D.Unit.PLUS_X, tree.raycastFirst(line.segment(Vector3D.of(0.5, 0.5, 0.5), Vector3D.of(1, 0.5, 0.5))));
+
+        Assert.assertNull(tree.raycastFirst(line.segment(Vector3D.of(-2, 0.5, 0.5), Vector3D.of(-1, 0.5, 0.5))));
+        Assert.assertNull(tree.raycastFirst(line.segment(Vector3D.of(-2, 0.5, 0.5), Vector3D.of(-1, 0.5, 0.5))));
+        Assert.assertNull(tree.raycastFirst(line.segment(Vector3D.of(0.25, 0.5, 0.5), Vector3D.of(0.75, 0.5, 0.5))));
+    }
+
+    @Test
+    public void testInvertedRegion() {
+        // arrange
+        RegionBSPTree3D tree = RegionBSPTree3D.builder(TEST_PRECISION)
+                .addCenteredCube(Vector3D.ZERO, 1)
+                .build();
+
+        // act
+        tree.complement();
+
+        // assert
+        Assert.assertFalse(tree.isEmpty());
+        Assert.assertFalse(tree.isFull());
+
+        EuclideanTestUtils.assertPositiveInfinity(tree.getSize());
+        Assert.assertEquals(6, tree.getBoundarySize(), TEST_EPS);
+        Assert.assertNull(tree.getBarycenter());
+
+        checkClassify(tree, RegionLocation.INSIDE,
+                Vector3D.of(-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE),
+                Vector3D.of(-100, -100, -100),
+                Vector3D.of(100, 100, 100),
+                Vector3D.of(Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE));
+        checkClassify(tree, RegionLocation.OUTSIDE,
+                Vector3D.of(0, 0, 0));
+    }
+
+    @Test
+    public void testUnitBox() {
+        // act
+        RegionBSPTree3D tree = RegionBSPTree3D.builder(TEST_PRECISION)
+                .addCenteredCube(Vector3D.ZERO, 1)
+                .build();
+
+        // assert
+        Assert.assertFalse(tree.isEmpty());
+        Assert.assertFalse(tree.isFull());
+
+        Assert.assertEquals(1.0, tree.getSize(), TEST_EPS);
+        Assert.assertEquals(6.0, tree.getBoundarySize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.ZERO, tree.getBarycenter(), TEST_EPS);
+
+        checkClassify(tree, RegionLocation.OUTSIDE,
+                Vector3D.of(-1, 0, 0),
+                Vector3D.of(1, 0, 0),
+                Vector3D.of(0, -1, 0),
+                Vector3D.of(0, 1, 0),
+                Vector3D.of(0, 0, -1),
+                Vector3D.of(0, 0, 1),
+
+                Vector3D.of(1, 1, 1),
+                Vector3D.of(1, 1, -1),
+                Vector3D.of(1, -1, 1),
+                Vector3D.of(1, -1, -1),
+                Vector3D.of(-1, 1, 1),
+                Vector3D.of(-1, 1, -1),
+                Vector3D.of(-1, -1, 1),
+                Vector3D.of(-1, -1, -1));
+
+        checkClassify(tree, RegionLocation.BOUNDARY,
+                Vector3D.of(0.5, 0, 0),
+                Vector3D.of(-0.5, 0, 0),
+                Vector3D.of(0, 0.5, 0),
+                Vector3D.of(0, -0.5, 0),
+                Vector3D.of(0, 0, 0.5),
+                Vector3D.of(0, 0, -0.5),
+
+                Vector3D.of(0.5, 0.5, 0.5),
+                Vector3D.of(0.5, 0.5, -0.5),
+                Vector3D.of(0.5, -0.5, 0.5),
+                Vector3D.of(0.5, -0.5, -0.5),
+                Vector3D.of(-0.5, 0.5, 0.5),
+                Vector3D.of(-0.5, 0.5, -0.5),
+                Vector3D.of(-0.5, -0.5, 0.5),
+                Vector3D.of(-0.5, -0.5, -0.5));
+
+        checkClassify(tree, RegionLocation.INSIDE,
+                Vector3D.of(0, 0, 0),
+
+                Vector3D.of(0.4, 0.4, 0.4),
+                Vector3D.of(0.4, 0.4, -0.4),
+                Vector3D.of(0.4, -0.4, 0.4),
+                Vector3D.of(0.4, -0.4, -0.4),
+                Vector3D.of(-0.4, 0.4, 0.4),
+                Vector3D.of(-0.4, 0.4, -0.4),
+                Vector3D.of(-0.4, -0.4, 0.4),
+                Vector3D.of(-0.4, -0.4, -0.4));
+    }
+
+    @Test
+    public void testTwoBoxes_disjoint() {
+        // act
+        RegionBSPTree3D tree = RegionBSPTree3D.empty();
+        tree.union(RegionBSPTree3D.builder(TEST_PRECISION)
+                .addCenteredCube(Vector3D.ZERO, 1)
+                .build());
+        tree.union(RegionBSPTree3D.builder(TEST_PRECISION)
+                .addCenteredCube(Vector3D.of(2, 0, 0), 1)
+                .build());
+
+        // assert
+        Assert.assertFalse(tree.isEmpty());
+        Assert.assertFalse(tree.isFull());
+
+        Assert.assertEquals(2.0, tree.getSize(), TEST_EPS);
+        Assert.assertEquals(12.0, tree.getBoundarySize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 0, 0), tree.getBarycenter(), TEST_EPS);
+
+        checkClassify(tree, RegionLocation.OUTSIDE,
+                Vector3D.of(-1, 0, 0),
+                Vector3D.of(1, 0, 0),
+                Vector3D.of(3, 0, 0));
+
+        checkClassify(tree, RegionLocation.INSIDE,
+                Vector3D.of(0, 0, 0),
+                Vector3D.of(2, 0, 0));
+    }
+
+    @Test
+    public void testTwoBoxes_sharedSide() {
+        // act
+        RegionBSPTree3D tree = RegionBSPTree3D.empty();
+        tree.union(RegionBSPTree3D.builder(TEST_PRECISION)
+                .addCenteredCube(Vector3D.ZERO, 1)
+                .build());
+        tree.union(RegionBSPTree3D.builder(TEST_PRECISION)
+                .addCenteredCube(Vector3D.of(1, 0, 0), 1)
+                .build());
+
+        // assert
+        Assert.assertFalse(tree.isEmpty());
+        Assert.assertFalse(tree.isFull());
+
+        Assert.assertEquals(2.0, tree.getSize(), TEST_EPS);
+        Assert.assertEquals(10.0, tree.getBoundarySize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0.5, 0, 0), tree.getBarycenter(), TEST_EPS);
+
+        checkClassify(tree, RegionLocation.OUTSIDE,
+                Vector3D.of(-1, 0, 0),
+                Vector3D.of(2, 0, 0));
+
+        checkClassify(tree, RegionLocation.INSIDE,
+                Vector3D.of(0, 0, 0),
+                Vector3D.of(1, 0, 0));
+    }
+
+    @Test
+    public void testTwoBoxes_separationLessThanTolerance() {
+        // arrange
+        double eps = 1e-6;
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(eps);
+
+        // act
+        RegionBSPTree3D tree = RegionBSPTree3D.empty();
+        tree.union(RegionBSPTree3D.builder(precision)
+                .addCenteredCube(Vector3D.ZERO, 1)
+                .build());
+        tree.union(RegionBSPTree3D.builder(precision)
+                .addCenteredCube(Vector3D.of(1 + 1e-7, 0, 0), 1)
+                .build());
+
+        // assert
+        Assert.assertFalse(tree.isEmpty());
+        Assert.assertFalse(tree.isFull());
+
+        Assert.assertEquals(2.0, tree.getSize(), eps);
+        Assert.assertEquals(10.0, tree.getBoundarySize(), eps);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0.5 + 5.208e-8, 0, 0), tree.getBarycenter(), TEST_EPS);
+
+        checkClassify(tree, RegionLocation.OUTSIDE,
+                Vector3D.of(-1, 0, 0),
+                Vector3D.of(2, 0, 0));
+
+        checkClassify(tree, RegionLocation.INSIDE,
+                Vector3D.of(0, 0, 0),
+                Vector3D.of(1, 0, 0));
+    }
+
+    @Test
+    public void testTwoBoxes_sharedEdge() {
+        // act
+        RegionBSPTree3D tree = RegionBSPTree3D.empty();
+        tree.union(RegionBSPTree3D.builder(TEST_PRECISION)
+                .addCenteredCube(Vector3D.ZERO, 1)
+                .build());
+        tree.union(RegionBSPTree3D.builder(TEST_PRECISION)
+                .addCenteredCube(Vector3D.of(1, 1, 0), 1)
+                .build());
+
+        // assert
+        Assert.assertFalse(tree.isEmpty());
+        Assert.assertFalse(tree.isFull());
+
+        Assert.assertEquals(2.0, tree.getSize(), TEST_EPS);
+        Assert.assertEquals(12.0, tree.getBoundarySize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0.5, 0.5, 0), tree.getBarycenter(), TEST_EPS);
+
+
+        checkClassify(tree, RegionLocation.OUTSIDE,
+                Vector3D.of(-1, 0, 0),
+                Vector3D.of(1, 0, 0),
+                Vector3D.of(0, 1, 0),
+                Vector3D.of(2, 1, 0));
+
+        checkClassify(tree, RegionLocation.INSIDE,
+                Vector3D.of(0, 0, 0),
+                Vector3D.of(1, 1, 0));
+    }
+
+    @Test
+    public void testTwoBoxes_sharedPoint() {
+        // act
+        RegionBSPTree3D tree = RegionBSPTree3D.empty();
+        tree.union(RegionBSPTree3D.builder(TEST_PRECISION)
+                .addCenteredCube(Vector3D.ZERO, 1)
+                .build());
+        tree.union(RegionBSPTree3D.builder(TEST_PRECISION)
+                .addCenteredCube(Vector3D.of(1, 1, 1), 1)
+                .build());
+
+        // assert
+        Assert.assertFalse(tree.isEmpty());
+        Assert.assertFalse(tree.isFull());
+
+        Assert.assertEquals(2.0, tree.getSize(), TEST_EPS);
+        Assert.assertEquals(12.0, tree.getBoundarySize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0.5, 0.5, 0.5), tree.getBarycenter(), TEST_EPS);
+
+        checkClassify(tree, RegionLocation.OUTSIDE,
+                Vector3D.of(-1, 0, 0),
+                Vector3D.of(1, 0, 0),
+                Vector3D.of(0, 1, 1),
+                Vector3D.of(2, 1, 1));
+
+        checkClassify(tree, RegionLocation.INSIDE,
+                Vector3D.of(0, 0, 0),
+                Vector3D.of(1, 1, 1));
+    }
+
+    @Test
+    public void testTetrahedron() {
+        // arrange
+        Vector3D vertex1 = Vector3D.of(1, 2, 3);
+        Vector3D vertex2 = Vector3D.of(2, 2, 4);
+        Vector3D vertex3 = Vector3D.of(2, 3, 3);
+        Vector3D vertex4 = Vector3D.of(1, 3, 4);
+
+        // act
+        RegionBSPTree3D tree = RegionBSPTree3D.builder(TEST_PRECISION)
+                .addFacet(vertex3, vertex2, vertex1)
+                .addFacet(vertex2, vertex3, vertex4)
+                .addFacet(vertex4, vertex3, vertex1)
+                .addFacet(vertex1, vertex2, vertex4)
+                .build();
+
+        // assert
+        Assert.assertEquals(1.0 / 3.0, tree.getSize(), TEST_EPS);
+        Assert.assertEquals(2.0 * Math.sqrt(3.0), tree.getBoundarySize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1.5, 2.5, 3.5), tree.getBarycenter(), TEST_EPS);
+
+        double third = 1.0 / 3.0;
+        checkClassify(tree, RegionLocation.BOUNDARY,
+            vertex1, vertex2, vertex3, vertex4,
+            Vector3D.linearCombination(third, vertex1, third, vertex2, third, vertex3),
+            Vector3D.linearCombination(third, vertex2, third, vertex3, third, vertex4),
+            Vector3D.linearCombination(third, vertex3, third, vertex4, third, vertex1),
+            Vector3D.linearCombination(third, vertex4, third, vertex1, third, vertex2)
+        );
+        checkClassify(tree, RegionLocation.OUTSIDE,
+            Vector3D.of(1, 2, 4),
+            Vector3D.of(2, 2, 3),
+            Vector3D.of(2, 3, 4),
+            Vector3D.of(1, 3, 3)
+        );
+    }
+
+    @Test
+    public void testSphere() {
+        // arrange
+        // (use a high tolerance value here since the sphere is only an approximation)
+        double approximationTolerance = 0.2;
+        double radius = 1.0;
+
+        // act
+        RegionBSPTree3D tree = createSphere(Vector3D.of(1, 2, 3), radius, 8, 16);
+
+        // assert
+        Assert.assertFalse(tree.isEmpty());
+        Assert.assertFalse(tree.isFull());
+
+        Assert.assertEquals(sphereVolume(radius), tree.getSize(), approximationTolerance);
+        Assert.assertEquals(sphereSurface(radius), tree.getBoundarySize(), approximationTolerance);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 2, 3), tree.getBarycenter(), TEST_EPS);
+
+        checkClassify(tree, RegionLocation.OUTSIDE,
+                Vector3D.of(-0.1, 2, 3),
+                Vector3D.of(2.1, 2, 3),
+                Vector3D.of(1, 0.9, 3),
+                Vector3D.of(1, 3.1, 3),
+                Vector3D.of(1, 2, 1.9),
+                Vector3D.of(1, 2, 4.1),
+                Vector3D.of(1.6, 2.6, 3.6));
+
+        checkClassify(tree, RegionLocation.INSIDE,
+                Vector3D.of(1, 2, 3),
+                Vector3D.of(0.1, 2, 3),
+                Vector3D.of(1.9, 2, 3),
+                Vector3D.of(1, 2.1, 3),
+                Vector3D.of(1, 2.9, 3),
+                Vector3D.of(1, 2, 2.1),
+                Vector3D.of(1, 2, 3.9),
+                Vector3D.of(1.5, 2.5, 3.5));
+    }
+
+    @Test
+    public void testProjectToBoundary() {
+        // arrange
+        RegionBSPTree3D tree = RegionBSPTree3D.builder(TEST_PRECISION)
+                .addCube(Vector3D.ZERO, 1)
+                .build();
+
+        // act/assert
+        checkProject(tree, Vector3D.of(0.5, 0.5, 0.5), Vector3D.of(0, 0.5, 0.5));
+        checkProject(tree, Vector3D.of(0.4, 0.5, 0.5), Vector3D.of(0, 0.5, 0.5));
+        checkProject(tree, Vector3D.of(1.5, 0.5, 0.5), Vector3D.of(1, 0.5, 0.5));
+        checkProject(tree, Vector3D.of(2, 2, 2), Vector3D.of(1, 1, 1));
+    }
+
+    @Test
+    public void testProjectToBoundary_invertedRegion() {
+        // arrange
+        RegionBSPTree3D tree = RegionBSPTree3D.builder(TEST_PRECISION)
+                .addCube(Vector3D.ZERO, 1)
+                .build();
+
+        tree.complement();
+
+        // act/assert
+        checkProject(tree, Vector3D.of(0.4, 0.5, 0.5), Vector3D.of(0, 0.5, 0.5));
+        checkProject(tree, Vector3D.of(1.5, 0.5, 0.5), Vector3D.of(1, 0.5, 0.5));
+        checkProject(tree, Vector3D.of(2, 2, 2), Vector3D.of(1, 1, 1));
+    }
+
+    private void checkProject(RegionBSPTree3D tree, Vector3D toProject, Vector3D expectedPoint) {
+        Vector3D proj = tree.project(toProject);
+
+        EuclideanTestUtils.assertCoordinatesEqual(expectedPoint, proj, TEST_EPS);
+    }
+
+    @Test
+    public void testBoolean_union() throws IOException {
+        // arrange
+        double tolerance = 0.05;
+        double size = 1.0;
+        double radius = size * 0.5;
+        RegionBSPTree3D box = RegionBSPTree3D.builder(TEST_PRECISION)
+                .addCube(Vector3D.ZERO, size)
+                .build();
+        RegionBSPTree3D sphere = createSphere(Vector3D.of(size * 0.5, size * 0.5, size), radius, 8, 16);
+
+        // act
+        RegionBSPTree3D result = RegionBSPTree3D.empty();
+        result.union(box, sphere);
+
+        // assert
+        Assert.assertFalse(result.isEmpty());
+        Assert.assertFalse(result.isFull());
+
+        Assert.assertEquals(cubeVolume(size) + (sphereVolume(radius) * 0.5),
+                result.getSize(), tolerance);
+        Assert.assertEquals(cubeSurface(size) - circleSurface(radius) + (0.5 * sphereSurface(radius)),
+                result.getBoundarySize(), tolerance);
+
+        checkClassify(result, RegionLocation.OUTSIDE,
+                Vector3D.of(-0.1, 0.5, 0.5),
+                Vector3D.of(1.1, 0.5, 0.5),
+                Vector3D.of(0.5, -0.1, 0.5),
+                Vector3D.of(0.5, 1.1, 0.5),
+                Vector3D.of(0.5, 0.5, -0.1),
+                Vector3D.of(0.5, 0.5, 1.6));
+
+        checkClassify(result, RegionLocation.INSIDE,
+                Vector3D.of(0.1, 0.5, 0.5),
+                Vector3D.of(0.9, 0.5, 0.5),
+                Vector3D.of(0.5, 0.1, 0.5),
+                Vector3D.of(0.5, 0.9, 0.5),
+                Vector3D.of(0.5, 0.5, 0.1),
+                Vector3D.of(0.5, 0.5, 1.4));
+    }
+
+    @Test
+    public void testUnion_self() {
+        // arrange
+        double tolerance = 0.2;
+        double radius = 1.0;
+
+        RegionBSPTree3D sphere = createSphere(Vector3D.ZERO, radius, 8, 16);
+
+        RegionBSPTree3D copy = RegionBSPTree3D.empty();
+        copy.copy(sphere);
+
+        // act
+        RegionBSPTree3D result = RegionBSPTree3D.empty();
+        result.union(sphere, copy);
+
+        // assert
+        Assert.assertFalse(result.isEmpty());
+        Assert.assertFalse(result.isFull());
+
+        Assert.assertEquals(sphereVolume(radius), result.getSize(), tolerance);
+        Assert.assertEquals(sphereSurface(radius), result.getBoundarySize(), tolerance);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.ZERO, result.getBarycenter(), TEST_EPS);
+
+        checkClassify(result, RegionLocation.OUTSIDE,
+                Vector3D.of(-1.1, 0, 0),
+                Vector3D.of(1.1, 0, 0),
+                Vector3D.of(0, -1.1, 0),
+                Vector3D.of(0, 1.1, 0),
+                Vector3D.of(0, 0, -1.1),
+                Vector3D.of(0, 0, 1.1));
+
+        checkClassify(result, RegionLocation.INSIDE,
+                Vector3D.of(-0.9, 0, 0),
+                Vector3D.of(0.9, 0, 0),
+                Vector3D.of(0, -0.9, 0),
+                Vector3D.of(0, 0.9, 0),
+                Vector3D.of(0, 0, -0.9),
+                Vector3D.of(0, 0, 0.9),
+                Vector3D.ZERO);
+    }
+
+    @Test
+    public void testBoolean_intersection() throws IOException {
+        // arrange
+        double tolerance = 0.05;
+        double size = 1.0;
+        double radius = size * 0.5;
+        RegionBSPTree3D box = RegionBSPTree3D.builder(TEST_PRECISION)
+                .addCube(Vector3D.ZERO, size)
+                .build();
+        RegionBSPTree3D sphere = createSphere(Vector3D.of(size * 0.5, size * 0.5, size), radius, 8, 16);
+
+        // act
+        RegionBSPTree3D result = RegionBSPTree3D.empty();
+        result.intersection(box, sphere);
+
+        // assert
+        Assert.assertFalse(result.isEmpty());
+        Assert.assertFalse(result.isFull());
+
+        Assert.assertEquals((sphereVolume(radius) * 0.5), result.getSize(), tolerance);
+        Assert.assertEquals(circleSurface(radius) + (0.5 * sphereSurface(radius)),
+                result.getBoundarySize(), tolerance);
+
+        checkClassify(result, RegionLocation.OUTSIDE,
+                Vector3D.of(-0.1, 0.5, 1.0),
+                Vector3D.of(1.1, 0.5, 1.0),
+                Vector3D.of(0.5, -0.1, 1.0),
+                Vector3D.of(0.5, 1.1, 1.0),
+                Vector3D.of(0.5, 0.5, 0.4),
+                Vector3D.of(0.5, 0.5, 1.1));
+
+        checkClassify(result, RegionLocation.INSIDE,
+                Vector3D.of(0.1, 0.5, 0.9),
+                Vector3D.of(0.9, 0.5, 0.9),
+                Vector3D.of(0.5, 0.1, 0.9),
+                Vector3D.of(0.5, 0.9, 0.9),
+                Vector3D.of(0.5, 0.5, 0.6),
+                Vector3D.of(0.5, 0.5, 0.9));
+    }
+
+    @Test
+    public void testIntersection_self() {
+        // arrange
+        double tolerance = 0.2;
+        double radius = 1.0;
+
+        RegionBSPTree3D sphere = createSphere(Vector3D.ZERO, radius, 8, 16);
+        RegionBSPTree3D copy = RegionBSPTree3D.empty();
+        copy.copy(sphere);
+
+        // act
+        RegionBSPTree3D result = RegionBSPTree3D.empty();
+        result.intersection(sphere, copy);
+
+        // assert
+        Assert.assertFalse(result.isEmpty());
+        Assert.assertFalse(result.isFull());
+
+        Assert.assertEquals(sphereVolume(radius), result.getSize(), tolerance);
+        Assert.assertEquals(sphereSurface(radius), result.getBoundarySize(), tolerance);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.ZERO, result.getBarycenter(), TEST_EPS);
+
+        checkClassify(result, RegionLocation.OUTSIDE,
+                Vector3D.of(-1.1, 0, 0),
+                Vector3D.of(1.1, 0, 0),
+                Vector3D.of(0, -1.1, 0),
+                Vector3D.of(0, 1.1, 0),
+                Vector3D.of(0, 0, -1.1),
+                Vector3D.of(0, 0, 1.1));
+
+        checkClassify(result, RegionLocation.INSIDE,
+                Vector3D.of(-0.9, 0, 0),
+                Vector3D.of(0.9, 0, 0),
+                Vector3D.of(0, -0.9, 0),
+                Vector3D.of(0, 0.9, 0),
+                Vector3D.of(0, 0, -0.9),
+                Vector3D.of(0, 0, 0.9),
+                Vector3D.ZERO);
+    }
+
+    @Test
+    public void testBoolean_xor_twoCubes() throws IOException {
+        // arrange
+        double size = 1.0;
+        RegionBSPTree3D box1 = RegionBSPTree3D.builder(TEST_PRECISION)
+                .addCube(Vector3D.ZERO, size)
+                .build();
+        RegionBSPTree3D box2 = RegionBSPTree3D.builder(TEST_PRECISION)
+                .addCube(Vector3D.of(0.5, 0.5, 0.5), size)
+                .build();
+
+        // act
+        RegionBSPTree3D result = RegionBSPTree3D.empty();
+        result.xor(box1, box2);
+
+        // assert
+        Assert.assertFalse(result.isEmpty());
+        Assert.assertFalse(result.isFull());
+
+        Assert.assertEquals((2 * cubeVolume(size)) - (2 * cubeVolume(size * 0.5)), result.getSize(), TEST_EPS);
+        Assert.assertEquals(2 * cubeSurface(size), result.getBoundarySize(), TEST_EPS);
+
+        checkClassify(result, RegionLocation.OUTSIDE,
+                Vector3D.of(-0.1, -0.1, -0.1),
+                Vector3D.of(0.75, 0.75, 0.75),
+                Vector3D.of(1.6, 1.6, 1.6));
+
+        checkClassify(result, RegionLocation.BOUNDARY,
+                Vector3D.of(0, 0, 0),
+                Vector3D.of(0.5, 0.5, 0.5),
+                Vector3D.of(1, 1, 1),
+                Vector3D.of(1.5, 1.5, 1.5));
+
+        checkClassify(result, RegionLocation.INSIDE,
+                Vector3D.of(0.1, 0.1, 0.1),
+                Vector3D.of(0.4, 0.4, 0.4),
+                Vector3D.of(1.1, 1.1, 1.1),
+                Vector3D.of(1.4, 1.4, 1.4));
+    }
+
+    @Test
+    public void testBoolean_xor_cubeAndSphere() throws IOException {
+        // arrange
+        double tolerance = 0.05;
+        double size = 1.0;
+        double radius = size * 0.5;
+        RegionBSPTree3D box = RegionBSPTree3D.builder(TEST_PRECISION)
+                .addCube(Vector3D.ZERO, size)
+                .build();
+        RegionBSPTree3D sphere = createSphere(Vector3D.of(size * 0.5, size * 0.5, size), radius, 8, 16);
+
+        // act
+        RegionBSPTree3D result = RegionBSPTree3D.empty();
+        result.xor(box, sphere);
+
+        // assert
+        Assert.assertFalse(result.isEmpty());
+        Assert.assertFalse(result.isFull());
+
+        Assert.assertEquals(cubeVolume(size), result.getSize(), tolerance);
+        Assert.assertEquals(cubeSurface(size) + (sphereSurface(radius)),
+                result.getBoundarySize(), tolerance);
+
+        checkClassify(result, RegionLocation.OUTSIDE,
+                Vector3D.of(-0.1, 0.5, 0.5),
+                Vector3D.of(1.1, 0.5, 0.5),
+                Vector3D.of(0.5, -0.1, 0.5),
+                Vector3D.of(0.5, 1.1, 0.5),
+                Vector3D.of(0.5, 0.5, -0.1),
+                Vector3D.of(0.5, 0.5, 1.6),
+                Vector3D.of(0.5, 0.5, 0.9));
+
+        checkClassify(result, RegionLocation.INSIDE,
+                Vector3D.of(0.1, 0.5, 0.5),
+                Vector3D.of(0.9, 0.5, 0.5),
+                Vector3D.of(0.5, 0.1, 0.5),
+                Vector3D.of(0.5, 0.9, 0.5),
+                Vector3D.of(0.5, 0.5, 0.1),
+                Vector3D.of(0.5, 0.5, 1.4));
+    }
+
+    @Test
+    public void testXor_self() {
+        // arrange
+        double radius = 1.0;
+
+        RegionBSPTree3D sphere = createSphere(Vector3D.ZERO, radius, 8, 16);
+        RegionBSPTree3D copy = RegionBSPTree3D.empty();
+        copy.copy(sphere);
+
+        // act
+        RegionBSPTree3D result = RegionBSPTree3D.empty();
+        result.xor(sphere, copy);
+
+        // assert
+        Assert.assertTrue(result.isEmpty());
+        Assert.assertFalse(result.isFull());
+
+        Assert.assertEquals(0.0, result.getSize(), TEST_EPS);
+        Assert.assertEquals(0.0, result.getBoundarySize(), TEST_EPS);
+        Assert.assertNull(result.getBarycenter());
+
+        checkClassify(result, RegionLocation.OUTSIDE,
+                Vector3D.of(-1.1, 0, 0),
+                Vector3D.of(1.1, 0, 0),
+                Vector3D.of(0, -1.1, 0),
+                Vector3D.of(0, 1.1, 0),
+                Vector3D.of(0, 0, -1.1),
+                Vector3D.of(0, 0, 1.1),
+                Vector3D.of(-0.9, 0, 0),
+                Vector3D.of(0.9, 0, 0),
+                Vector3D.of(0, -0.9, 0),
+                Vector3D.of(0, 0.9, 0),
+                Vector3D.of(0, 0, -0.9),
+                Vector3D.of(0, 0, 0.9),
+                Vector3D.ZERO);
+    }
+
+    @Test
+    public void testBoolean_difference() throws IOException {
+        // arrange
+        double tolerance = 0.05;
+        double size = 1.0;
+        double radius = size * 0.5;
+        RegionBSPTree3D box = RegionBSPTree3D.builder(TEST_PRECISION)
+                .addCube(Vector3D.ZERO, size)
+                .build();
+        RegionBSPTree3D sphere = createSphere(Vector3D.of(size * 0.5, size * 0.5, size), radius, 8, 16);
+
+        // act
+        RegionBSPTree3D result = RegionBSPTree3D.empty();
+        result.difference(box, sphere);
+
+        // assert
+        Assert.assertFalse(result.isEmpty());
+        Assert.assertFalse(result.isFull());
+
+        Assert.assertEquals(cubeVolume(size) - (sphereVolume(radius) * 0.5), result.getSize(), tolerance);
+        Assert.assertEquals(cubeSurface(size) - circleSurface(radius) + (0.5 * sphereSurface(radius)),
+                result.getBoundarySize(), tolerance);
+
+        checkClassify(result, RegionLocation.OUTSIDE,
+                Vector3D.of(-0.1, 0.5, 1.0),
+                Vector3D.of(1.1, 0.5, 1.0),
+                Vector3D.of(0.5, -0.1, 1.0),
+                Vector3D.of(0.5, 1.1, 1.0),
+                Vector3D.of(0.5, 0.5, -0.1),
+                Vector3D.of(0.5, 0.5, 0.6));
+
+        checkClassify(result, RegionLocation.INSIDE,
+                Vector3D.of(0.1, 0.5, 0.4),
+                Vector3D.of(0.9, 0.5, 0.4),
+                Vector3D.of(0.5, 0.1, 0.4),
+                Vector3D.of(0.5, 0.9, 0.4),
+                Vector3D.of(0.5, 0.5, 0.1),
+                Vector3D.of(0.5, 0.5, 0.4));
+    }
+
+    @Test
+    public void testDifference_self() {
+        // arrange
+        double radius = 1.0;
+
+        RegionBSPTree3D sphere = createSphere(Vector3D.ZERO, radius, 8, 16);
+        RegionBSPTree3D copy = sphere.copy();
+
+        // act
+        RegionBSPTree3D result = RegionBSPTree3D.empty();
+        result.difference(sphere, copy);
+
+        // assert
+        Assert.assertTrue(result.isEmpty());
+        Assert.assertFalse(result.isFull());
+
+        Assert.assertEquals(0.0, result.getSize(), TEST_EPS);
+        Assert.assertEquals(0.0, result.getBoundarySize(), TEST_EPS);
+        Assert.assertNull(result.getBarycenter());
+
+        checkClassify(result, RegionLocation.OUTSIDE,
+                Vector3D.of(-1.1, 0, 0),
+                Vector3D.of(1.1, 0, 0),
+                Vector3D.of(0, -1.1, 0),
+                Vector3D.of(0, 1.1, 0),
+                Vector3D.of(0, 0, -1.1),
+                Vector3D.of(0, 0, 1.1),
+                Vector3D.of(-0.9, 0, 0),
+                Vector3D.of(0.9, 0, 0),
+                Vector3D.of(0, -0.9, 0),
+                Vector3D.of(0, 0.9, 0),
+                Vector3D.of(0, 0, -0.9),
+                Vector3D.of(0, 0, 0.9),
+                Vector3D.ZERO);
+    }
+
+    @Test
+    public void testBoolean_multiple() throws IOException {
+        // arrange
+        double tolerance = 0.05;
+        double size = 1.0;
+        double radius = size * 0.5;
+        RegionBSPTree3D box = RegionBSPTree3D.builder(TEST_PRECISION)
+                .addCube(Vector3D.ZERO, size)
+                .build();
+        RegionBSPTree3D sphereToAdd = createSphere(Vector3D.of(size * 0.5, size * 0.5, size), radius, 8, 16);
+        RegionBSPTree3D sphereToRemove1 = createSphere(Vector3D.of(size * 0.5, 0, size * 0.5), radius, 8, 16);
+        RegionBSPTree3D sphereToRemove2 = createSphere(Vector3D.of(size * 0.5, 1, size * 0.5), radius, 8, 16);
+
+        // act
+        RegionBSPTree3D result = RegionBSPTree3D.empty();
+        result.union(box, sphereToAdd);
+        result.difference(sphereToRemove1);
+        result.difference(sphereToRemove2);
+
+        // assert
+        Assert.assertFalse(result.isEmpty());
+        Assert.assertFalse(result.isFull());
+
+        Assert.assertEquals(cubeVolume(size) - (sphereVolume(radius) * 0.5),
+                result.getSize(), tolerance);
+        Assert.assertEquals(cubeSurface(size) - (3.0 * circleSurface(radius)) + (1.5 * sphereSurface(radius)),
+                result.getBoundarySize(), tolerance);
+
+        checkClassify(result, RegionLocation.OUTSIDE,
+                Vector3D.of(-0.1, 0.5, 0.5),
+                Vector3D.of(1.1, 0.5, 0.5),
+                Vector3D.of(0.5, 0.4, 0.5),
+                Vector3D.of(0.5, 0.6, 0.5),
+                Vector3D.of(0.5, 0.5, -0.1),
+                Vector3D.of(0.5, 0.5, 1.6));
+
+        checkClassify(result, RegionLocation.INSIDE,
+                Vector3D.of(0.1, 0.5, 0.1),
+                Vector3D.of(0.9, 0.5, 0.1),
+                Vector3D.of(0.5, 0.4, 0.1),
+                Vector3D.of(0.5, 0.6, 0.1),
+                Vector3D.of(0.5, 0.5, 0.1),
+                Vector3D.of(0.5, 0.5, 1.4));
+    }
+
+    @Test
+    public void testToConvex_empty() {
+        // act
+        List<ConvexVolume> result = RegionBSPTree3D.empty().toConvex();
+
+        // assert
+        Assert.assertEquals(0, result.size());
+    }
+
+    @Test
+    public void testToConvex_singleBox() {
+        // arrange
+        RegionBSPTree3D tree = RegionBSPTree3D.builder(TEST_PRECISION)
+                .addCube(Vector3D.of(1, 2, 3), 1)
+                .build();
+
+        // act
+        List<ConvexVolume> result = tree.toConvex();
+
+        // assert
+        Assert.assertEquals(1, result.size());
+
+        ConvexVolume vol = result.get(0);
+        Assert.assertEquals(1, vol.getSize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1.5, 2.5, 3.5), vol.getBarycenter(), TEST_EPS);
+    }
+
+    @Test
+    public void testToConvex_multipleBoxes() {
+        // arrange
+        RegionBSPTree3D tree = RegionBSPTree3D.builder(TEST_PRECISION)
+                .addCube(Vector3D.of(4, 5, 6), 1)
+                .build();
+        tree.union(RegionBSPTree3D.builder(TEST_PRECISION)
+                .addRect(Vector3D.ZERO, 2, 1, 1)
+                .build());
+
+        // act
+        List<ConvexVolume> result = tree.toConvex();
+
+        // assert
+        Assert.assertEquals(2, result.size());
+
+        boolean smallFirst = result.get(0).getSize() < result.get(1).getSize();
+
+        ConvexVolume small = smallFirst ? result.get(0) : result.get(1);
+        ConvexVolume large = smallFirst ? result.get(1) : result.get(0);
+
+        Assert.assertEquals(1, small.getSize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(4.5, 5.5, 6.5), small.getBarycenter(), TEST_EPS);
+
+        Assert.assertEquals(2, large.getSize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 0.5, 0.5), large.getBarycenter(), TEST_EPS);
+    }
+
+    @Test
+    public void testSplit() {
+        // arrange
+        RegionBSPTree3D tree = RegionBSPTree3D.builder(TEST_PRECISION)
+                .addCube(Vector3D.of(-0.5, -0.5, -0.5), 1)
+                .build();
+
+        Plane splitter = Plane.fromNormal(Vector3D.Unit.PLUS_X, TEST_PRECISION);
+
+        // act
+        Split<RegionBSPTree3D> split = tree.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        RegionBSPTree3D minus = split.getMinus();
+        Assert.assertEquals(0.5, minus.getSize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(-0.25, 0, 0), minus.getBarycenter(), TEST_EPS);
+
+        RegionBSPTree3D plus = split.getPlus();
+        Assert.assertEquals(0.5, plus.getSize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0.25, 0, 0), plus.getBarycenter(), TEST_EPS);
+    }
+
+    @Test
+    public void testGetNodeRegion() {
+        // arrange
+        RegionBSPTree3D tree = RegionBSPTree3D.builder(TEST_PRECISION)
+                .addRect(Vector3D.ZERO, Vector3D.of(1, 1, 1))
+                .build();
+
+        // act/assert
+        ConvexVolume rootVol = tree.getRoot().getNodeRegion();
+        GeometryTestUtils.assertPositiveInfinity(rootVol.getSize());
+        Assert.assertNull(rootVol.getBarycenter());
+
+        ConvexVolume plusVol = tree.getRoot().getPlus().getNodeRegion();
+        GeometryTestUtils.assertPositiveInfinity(plusVol.getSize());
+        Assert.assertNull(plusVol.getBarycenter());
+
+        ConvexVolume centerVol = tree.findNode(Vector3D.of(0.5, 0.5, 0.5)).getNodeRegion();
+        Assert.assertEquals(1, centerVol.getSize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0.5, 0.5, 0.5), centerVol.getBarycenter(), TEST_EPS);
+    }
+
+    @Test
+    public void testBuilder_nothingAdded() {
+        // act
+        RegionBSPTree3D tree = RegionBSPTree3D.builder(TEST_PRECISION).build();
+
+        // assert
+        Assert.assertTrue(tree.isEmpty());
+    }
+
+    @Test
+    public void testBuilder_rectMethods() {
+        // act
+        RegionBSPTree3D tree = RegionBSPTree3D.builder(TEST_PRECISION)
+
+                .addRect(Vector3D.ZERO, Vector3D.of(2, 1, 1))
+                .addRect(Vector3D.of(0, 0, -1), Vector3D.of(-1, -2, -2))
+
+                .addRect(Vector3D.of(0, 0, 5), 1, 2, 3)
+                .addRect(Vector3D.of(0, 0, 10), -3, -2, -1)
+
+                .addCenteredRect(Vector3D.of(0, 0, 15), 2, 3, 4)
+                .addCenteredRect(Vector3D.of(0, 0, 20), 4, 3, 2)
+
+                .build();
+
+        // act
+        Assert.assertEquals(2 + 2 + 6 + 6 + 24 + 24, tree.getSize(), TEST_EPS);
+
+        checkClassify(tree, RegionLocation.INSIDE,
+                Vector3D.of(1, 0.5, 0.5),
+                Vector3D.of(-0.5, -1, -1.5),
+
+                Vector3D.of(0.5, 1, 6.5),
+                Vector3D.of(-1.5, -1, 9.5),
+
+                Vector3D.of(0, 0, 15),
+                Vector3D.of(0, 0, 20));
+
+        checkClassify(tree, RegionLocation.BOUNDARY,
+                Vector3D.of(-1, -1.5, 13),
+                Vector3D.of(1, 1.5, 17),
+
+                Vector3D.of(-2, -1.5, 19),
+                Vector3D.of(2, 1.5, 21));
+    }
+
+    @Test
+    public void testBuilder_rectMethods_invalidDimensions() {
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            RegionBSPTree3D.builder(TEST_PRECISION).addRect(Vector3D.ZERO, 1e-20, 1, 1);
+        }, GeometryValueException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            RegionBSPTree3D.builder(TEST_PRECISION).addRect(Vector3D.ZERO, 1, 1e-20, 1);
+        }, GeometryValueException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            RegionBSPTree3D.builder(TEST_PRECISION).addRect(Vector3D.ZERO, 1, 1, 1e-20);
+        }, GeometryValueException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            RegionBSPTree3D.builder(TEST_PRECISION).addRect(Vector3D.ZERO, 0, 0, 0);
+        }, GeometryValueException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            RegionBSPTree3D.builder(TEST_PRECISION).addRect(Vector3D.of(1, 2, 3), Vector3D.of(1, 2, 3));
+        }, GeometryValueException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            RegionBSPTree3D.builder(TEST_PRECISION).addCenteredRect(Vector3D.of(1, 2, 3), 0, 0, 0);
+        }, GeometryValueException.class);
+    }
+
+    @Test
+    public void testBuilder_cubeMethods() {
+        // act
+        RegionBSPTree3D tree = RegionBSPTree3D.builder(TEST_PRECISION)
+                .addCube(Vector3D.of(1, 0, 0), 2)
+                .addCenteredCube(Vector3D.of(-2, -3, -4), 3)
+                .build();
+
+        // assert
+        Assert.assertEquals(8 + 27, tree.getSize(), TEST_EPS);
+
+        checkClassify(tree, RegionLocation.INSIDE,
+                Vector3D.of(2, 1, 1),
+                Vector3D.of(-2, -3, -4));
+
+        checkClassify(tree, RegionLocation.BOUNDARY,
+                Vector3D.of(-3.5, -4.5, -5.5),
+                Vector3D.of(-0.5, -1.5, -2.5));
+    }
+
+    @Test
+    public void testBuilder_cubeMethods_invalidDimensions() {
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            RegionBSPTree3D.builder(TEST_PRECISION).addCube(Vector3D.ZERO, 1e-20);
+        }, GeometryValueException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            RegionBSPTree3D.builder(TEST_PRECISION).addCube(Vector3D.of(1, 2, 3), 1e-20);
+        }, GeometryValueException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            RegionBSPTree3D.builder(TEST_PRECISION).addCenteredCube(Vector3D.of(1, 2, 3), 0);
+        }, GeometryValueException.class);
+    }
+
+    @Test
+    public void testBuilder_addIndexedFacets_triangles() {
+        // arrange
+        Vector3D vertices[] = {
+                Vector3D.ZERO,
+                Vector3D.of(1, 0, 0),
+                Vector3D.of(1, 1, 0),
+                Vector3D.of(0, 1, 0),
+
+                Vector3D.of(0, 0, 1),
+                Vector3D.of(1, 0, 1),
+                Vector3D.of(1, 1, 1),
+                Vector3D.of(0, 1, 1)
+        };
+
+        int[][] facets = {
+                { 0, 3, 2 },
+                { 0, 2, 1 },
+
+                { 4, 5, 6 },
+                { 4, 6, 7 },
+
+                { 5, 1, 2 },
+                { 5, 2, 6 },
+
+                { 4, 7, 3 },
+                { 4, 3, 0 },
+
+                { 4, 0, 1 },
+                { 4, 1, 5 },
+
+                { 7, 6, 2 },
+                { 7, 2, 3 }
+        };
+
+        // act
+        RegionBSPTree3D tree = RegionBSPTree3D.builder(TEST_PRECISION)
+                .withVertexList(vertices)
+                .addIndexedFacets(facets)
+                .build();
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertFalse(tree.isEmpty());
+
+        Assert.assertEquals(1, tree.getSize(), TEST_EPS);
+        Assert.assertEquals(6, tree.getBoundarySize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0.5, 0.5, 0.5), tree.getBarycenter(), TEST_EPS);
+    }
+
+    @Test
+    public void testBuilder_addIndexedFacets_concaveFacets() {
+        // arrange
+        Vector3D[] vertices = {
+                Vector3D.of(-1, 0, 1),
+                Vector3D.of(-1, 0, 0),
+
+                Vector3D.of(0, 2, 1),
+                Vector3D.of(0, 2, 0),
+
+                Vector3D.of(1, 0, 1),
+                Vector3D.of(1, 0, 0),
+
+                Vector3D.of(0, 1, 1),
+                Vector3D.of(0, 1, 0)
+        };
+
+        int[][] facets = {
+                { 0, 2, 3, 1 },
+                { 4, 5, 3, 2 },
+                { 0, 1, 7, 6 },
+                { 4, 6, 7, 5 },
+                { 0, 6, 4, 2 },
+                { 1, 3, 5, 7 }
+        };
+
+        // act
+        RegionBSPTree3D tree = RegionBSPTree3D.builder(TEST_PRECISION)
+                .withVertexList(vertices)
+                .addIndexedFacets(facets)
+                .build();
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertFalse(tree.isEmpty());
+
+        Assert.assertTrue(Double.isFinite(tree.getSize()));
+        Assert.assertTrue(Double.isFinite(tree.getBoundarySize()));
+        Assert.assertNotNull(tree.getBarycenter());
+
+        checkClassify(tree, RegionLocation.INSIDE, Vector3D.of(0, 1.5, 0.5));
+        checkClassify(tree, RegionLocation.OUTSIDE, Vector3D.of(0, 0.5, 0.5));
+    }
+
+    @Test
+    public void testBuilder_addIndexedFacets_multipleVertexLists() {
+        // arrange
+        Vector3D p0 = Vector3D.ZERO;
+        Vector3D p1 = Vector3D.Unit.PLUS_X;
+        Vector3D p2 = Vector3D.Unit.PLUS_Y;
+        Vector3D p3 = Vector3D.Unit.PLUS_Z;
+
+        // act
+        RegionBSPTree3D tree = RegionBSPTree3D.builder(TEST_PRECISION)
+                .withVertexList(p1, p2, p3)
+                .addIndexedFacet(Arrays.asList(2, 0, 1))
+
+                .withVertexList(p0, p1, p2, p3)
+                .addIndexedFacet(0, 2, 1)
+                .addIndexedFacets(new int[][] {
+                        { 0, 1, 3 },
+                        { 0, 3, 2 }
+                })
+                .build();
+
+        // assert
+        Assert.assertEquals(0.5 / 3.0, tree.getSize(), TEST_EPS);
+
+        checkClassify(tree, RegionLocation.INSIDE, Vector3D.of(0.25, 0.25, 0.25));
+    }
+
+    // GEOMETRY-59
+    @Test
+    public void testSlightlyConcavePrism() {
+        // arrange
+        Vector3D vertices[] = {
+            Vector3D.of(0, 0, 0),
+            Vector3D.of(2, 1e-7, 0),
+            Vector3D.of(4, 0, 0),
+            Vector3D.of(2, 2, 0),
+            Vector3D.of(0, 0, 2),
+            Vector3D.of(2, 1e-7, 2),
+            Vector3D.of(4, 0, 2),
+            Vector3D.of(2, 2, 2)
+        };
+
+        int facets[][] = {
+            { 4, 5, 6, 7 },
+            { 3, 2, 1, 0 },
+            { 0, 1, 5, 4 },
+            { 1, 2, 6, 5 },
+            { 2, 3, 7, 6 },
+            { 3, 0, 4, 7 }
+        };
+
+        // act
+        RegionBSPTree3D tree = RegionBSPTree3D.builder(TEST_PRECISION)
+                .withVertexList(vertices)
+                .addIndexedFacets(facets)
+                .build();
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertFalse(tree.isEmpty());
+
+        checkClassify(tree, RegionLocation.INSIDE, Vector3D.of(2, 1, 1));
+        checkClassify(tree, RegionLocation.OUTSIDE,
+                Vector3D.of(2, 1, 3), Vector3D.of(2, 1, -3),
+                Vector3D.of(2, -1, 1), Vector3D.of(2, 3, 1),
+                Vector3D.of(-1, 1, 1), Vector3D.of(4, 1, 1));
+    }
+
+    private static RegionBSPTree3D createSphere(final Vector3D center, final double radius, final int stacks, final int slices) {
+
+        final List<Plane> planes = new ArrayList<>();
+
+        // add top and bottom planes (+/- z)
+        final Vector3D topZ = Vector3D.of(center.getX(), center.getY(), center.getZ() + radius);
+        final Vector3D bottomZ = Vector3D.of(center.getX(), center.getY(), center.getZ() - radius);
+
+        planes.add(Plane.fromPointAndNormal(topZ, Vector3D.Unit.PLUS_Z, TEST_PRECISION));
+        planes.add(Plane.fromPointAndNormal(bottomZ, Vector3D.Unit.MINUS_Z, TEST_PRECISION));
+
+        // add the side planes
+        final double vDelta = Geometry.PI / stacks;
+        final double hDelta = Geometry.PI * 2 / slices;
+
+        final double adjustedRadius = (radius + (radius * Math.cos(vDelta * 0.5))) / 2.0;
+
+        double vAngle;
+        double hAngle;
+        double stackRadius;
+        double stackHeight;
+        double x;
+        double y;
+        Vector3D pt;
+        Vector3D norm;
+
+        vAngle = -0.5 * vDelta;
+        for (int v=0; v<stacks; ++v) {
+            vAngle += vDelta;
+
+            stackRadius = Math.sin(vAngle) * adjustedRadius;
+            stackHeight = Math.cos(vAngle) * adjustedRadius;
+
+            hAngle = -0.5 * hDelta;
+            for (int h=0; h<slices; ++h) {
+                hAngle += hDelta;
+
+                x = Math.cos(hAngle) * stackRadius;
+                y = Math.sin(hAngle) * stackRadius;
+
+                norm = Vector3D.of(x, y, stackHeight).normalize();
+                pt = center.add(norm.multiply(adjustedRadius));
+
+                planes.add(Plane.fromPointAndNormal(pt, norm, TEST_PRECISION));
+            }
+        }
+
+        RegionBSPTree3D tree = RegionBSPTree3D.full();
+        RegionNode3D node = tree.getRoot();
+
+        for (Plane plane : planes) {
+            node = node.cut(plane).getMinus();
+        }
+
+        return tree;
+    }
+
+    private static void assertSubPlaneNormal(Vector3D expectedNormal, ConvexSubPlane sub) {
+        EuclideanTestUtils.assertCoordinatesEqual(expectedNormal, sub.getPlane().getNormal(), TEST_EPS);
+    }
+
+    private static void checkClassify(Region<Vector3D> region, RegionLocation loc, Vector3D ... points) {
+        for (Vector3D point : points) {
+            String msg = "Unexpected location for point " + point;
+
+            Assert.assertEquals(msg, loc, region.classify(point));
+        }
+    }
+
+    private double cubeVolume(double size) {
+        return size * size * size;
+    }
+
+    private double cubeSurface(double size) {
+        return 6.0 * size * size;
+    }
+
+    private double sphereVolume(double radius) {
+        return 4.0 * Math.PI * radius * radius * radius / 3.0;
+    }
+
+    private double sphereSurface(double radius) {
+        return 4.0 * Math.PI * radius * radius;
+    }
+
+    private double circleSurface(double radius) {
+        return Math.PI * radius * radius;
+    }
+}
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/Segment3DTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/Segment3DTest.java
new file mode 100644
index 0000000..4af514d
--- /dev/null
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/Segment3DTest.java
@@ -0,0 +1,387 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.threed;
+
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.GeometryTestUtils;
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.exception.GeometryValueException;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
+import org.apache.commons.geometry.euclidean.oned.Interval;
+import org.apache.commons.geometry.euclidean.oned.Vector1D;
+import org.apache.commons.geometry.euclidean.threed.rotation.QuaternionRotation;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class Segment3DTest {
+
+    private static final double TEST_EPS = 1e-10;
+
+    private static final DoublePrecisionContext TEST_PRECISION =
+            new EpsilonDoublePrecisionContext(TEST_EPS);
+
+    @Test
+    public void testFromPoints() {
+        // arrange
+        Vector3D p0 = Vector3D.of(1, 3, 2);
+        Vector3D p1 = Vector3D.of(1, 2, 3);
+        Vector3D p2 = Vector3D.of(-3, 4, 5);
+        Vector3D p3 = Vector3D.of(-5, -6, -8);
+
+        // act/assert
+
+        checkFiniteSegment(Segment3D.fromPoints(p0, p1, TEST_PRECISION), p0, p1);
+        checkFiniteSegment(Segment3D.fromPoints(p1, p0, TEST_PRECISION), p1, p0);
+
+        checkFiniteSegment(Segment3D.fromPoints(p0, p2, TEST_PRECISION), p0, p2);
+        checkFiniteSegment(Segment3D.fromPoints(p2, p0, TEST_PRECISION), p2, p0);
+
+        checkFiniteSegment(Segment3D.fromPoints(p0, p3, TEST_PRECISION), p0, p3);
+        checkFiniteSegment(Segment3D.fromPoints(p3, p0, TEST_PRECISION), p3, p0);
+    }
+
+    @Test
+    public void testFromPoints_invalidArgs() {
+        // arrange
+        Vector3D p0 = Vector3D.of(-1, 2, -3);
+
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            Segment3D.fromPoints(p0, p0, TEST_PRECISION);
+        }, GeometryValueException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            Segment3D.fromPoints(p0, Vector3D.POSITIVE_INFINITY, TEST_PRECISION);
+        }, GeometryValueException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            Segment3D.fromPoints(p0, Vector3D.NEGATIVE_INFINITY, TEST_PRECISION);
+        }, GeometryValueException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            Segment3D.fromPoints(p0, Vector3D.NaN, TEST_PRECISION);
+        }, GeometryValueException.class);
+    }
+
+    @Test
+    public void testFromPointAndDirection() {
+        // act
+        Segment3D seg = Segment3D.fromPointAndDirection(Vector3D.of(1, 3, -2), Vector3D.Unit.PLUS_Y, TEST_PRECISION);
+
+        // assert
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 3, -2), seg.getStartPoint(), TEST_EPS);
+        Assert.assertNull(seg.getEndPoint());
+
+        Line3D line = seg.getLine();
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 0, -2), line.getOrigin(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.Unit.PLUS_Y, line.getDirection(), TEST_EPS);
+    }
+
+    @Test
+    public void testFromInterval_intervalArg_finite() {
+        // arrange
+        DoublePrecisionContext intervalPrecision = new EpsilonDoublePrecisionContext(1e-2);
+        Interval interval = Interval.of(-1, 2, intervalPrecision);
+
+        Line3D line = Line3D.fromPointAndDirection(Vector3D.ZERO, Vector3D.of(1, 1, 1), TEST_PRECISION);
+
+        // act
+        Segment3D segment = Segment3D.fromInterval(line, interval);
+
+        // assert
+        double side = 1.0 / Math.sqrt(3);
+        checkFiniteSegment(segment, Vector3D.of(-side, -side, -side), Vector3D.of(2 * side, 2 * side, 2 * side));
+
+        Assert.assertSame(TEST_PRECISION, segment.getPrecision());
+    }
+
+    @Test
+    public void testFromInterval_intervalArg_full() {
+        // arrange
+        Line3D line = Line3D.fromPointAndDirection(Vector3D.ZERO, Vector3D.of(1, 1, 1), TEST_PRECISION);
+
+        // act
+        Segment3D segment = Segment3D.fromInterval(line, Interval.full());
+
+        // assert
+        Assert.assertTrue(segment.isInfinite());
+        Assert.assertFalse(segment.isFinite());
+
+        GeometryTestUtils.assertNegativeInfinity(segment.getSubspaceStart());
+        GeometryTestUtils.assertPositiveInfinity(segment.getSubspaceEnd());
+
+        Assert.assertNull(segment.getStartPoint());
+        Assert.assertNull(segment.getEndPoint());
+
+        Assert.assertSame(Interval.full(), segment.getInterval());
+        Assert.assertSame(TEST_PRECISION, segment.getPrecision());
+    }
+
+    @Test
+    public void testFromInterval_intervalArg_positiveHalfSpace() {
+        // arrange
+        DoublePrecisionContext intervalPrecision = new EpsilonDoublePrecisionContext(1e-2);
+        Interval interval = Interval.min(-1, intervalPrecision);
+
+        Line3D line = Line3D.fromPointAndDirection(Vector3D.ZERO, Vector3D.of(1, 1, 1), TEST_PRECISION);
+
+        // act
+        Segment3D segment = Segment3D.fromInterval(line, interval);
+
+        // assert
+        Assert.assertTrue(segment.isInfinite());
+        Assert.assertFalse(segment.isFinite());
+
+        Assert.assertEquals(-1.0, segment.getSubspaceStart(), TEST_EPS);
+        GeometryTestUtils.assertPositiveInfinity(segment.getSubspaceEnd());
+
+        double side = 1.0 / Math.sqrt(3);
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(-side, -side, -side), segment.getStartPoint(), TEST_EPS);
+        Assert.assertNull(segment.getEndPoint());
+
+        Assert.assertSame(interval, segment.getInterval());
+        Assert.assertSame(TEST_PRECISION, segment.getPrecision());
+    }
+
+    @Test
+    public void testFromInterval_intervalArg_negativeHalfSpace() {
+        // arrange
+        DoublePrecisionContext intervalPrecision = new EpsilonDoublePrecisionContext(1e-2);
+        Interval interval = Interval.max(2, intervalPrecision);
+
+        Line3D line = Line3D.fromPointAndDirection(Vector3D.ZERO, Vector3D.of(1, 1, 1), TEST_PRECISION);
+
+        // act
+        Segment3D segment = Segment3D.fromInterval(line, interval);
+
+        // assert
+        GeometryTestUtils.assertNegativeInfinity(segment.getSubspaceStart());
+        Assert.assertEquals(2, segment.getSubspaceEnd(), TEST_EPS);
+
+        double side = 1.0 / Math.sqrt(3);
+
+        Assert.assertNull(segment.getStartPoint());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(2 * side, 2 * side, 2 * side), segment.getEndPoint(), TEST_EPS);
+
+        Assert.assertSame(interval, segment.getInterval());
+        Assert.assertSame(TEST_PRECISION, segment.getPrecision());
+    }
+
+    @Test
+    public void testFromInterval_doubleArgs_finite() {
+        // arrange
+        Line3D line = Line3D.fromPointAndDirection(Vector3D.ZERO, Vector3D.of(1, 1, 1), TEST_PRECISION);
+
+        // act
+        Segment3D segment = Segment3D.fromInterval(line, -1, 2);
+
+        // assert
+        double side = 1.0 / Math.sqrt(3);
+        checkFiniteSegment(segment, Vector3D.of(-side, -side, -side), Vector3D.of(2 * side, 2 * side, 2 * side));
+
+        Assert.assertSame(TEST_PRECISION, segment.getPrecision());
+    }
+
+    @Test
+    public void testFromInterval_doubleArgs_full() {
+        // arrange
+        Line3D line = Line3D.fromPointAndDirection(Vector3D.ZERO, Vector3D.of(1, 1, 1), TEST_PRECISION);
+
+        // act
+        Segment3D segment = Segment3D.fromInterval(line, Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY);
+
+        // assert
+        GeometryTestUtils.assertNegativeInfinity(segment.getSubspaceStart());
+        GeometryTestUtils.assertPositiveInfinity(segment.getSubspaceEnd());
+
+        Assert.assertNull(segment.getStartPoint());
+        Assert.assertNull(segment.getEndPoint());
+
+        Assert.assertSame(TEST_PRECISION, segment.getPrecision());
+    }
+
+    @Test
+    public void testFromInterval_doubleArgs_positiveHalfSpace() {
+        // arrange
+        Line3D line = Line3D.fromPointAndDirection(Vector3D.ZERO, Vector3D.of(1, 1, 1), TEST_PRECISION);
+
+        // act
+        Segment3D segment = Segment3D.fromInterval(line, -1, Double.POSITIVE_INFINITY);
+
+        // assert
+        Assert.assertEquals(-1.0, segment.getSubspaceStart(), TEST_EPS);
+        GeometryTestUtils.assertPositiveInfinity(segment.getSubspaceEnd());
+
+        double side = 1.0 / Math.sqrt(3);
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(-side, -side, -side), segment.getStartPoint(), TEST_EPS);
+        Assert.assertNull(segment.getEndPoint());
+
+        Assert.assertSame(TEST_PRECISION, segment.getPrecision());
+    }
+
+    @Test
+    public void testFromInterval_doubleArgs_negativeHalfSpace() {
+        // arrange
+        Line3D line = Line3D.fromPointAndDirection(Vector3D.ZERO, Vector3D.of(1, 1, 1), TEST_PRECISION);
+
+        // act
+        Segment3D segment = Segment3D.fromInterval(line, 2, Double.NEGATIVE_INFINITY);
+
+        // assert
+        GeometryTestUtils.assertNegativeInfinity(segment.getSubspaceStart());
+        Assert.assertEquals(2, segment.getSubspaceEnd(), TEST_EPS);
+
+        double side = 1.0 / Math.sqrt(3);
+
+        Assert.assertNull(segment.getStartPoint());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(2 * side, 2 * side, 2 * side), segment.getEndPoint(), TEST_EPS);
+
+        Assert.assertSame(TEST_PRECISION, segment.getPrecision());
+    }
+
+    @Test
+    public void testFromInterval_vectorArgs() {
+        // arrange
+        Line3D line = Line3D.fromPointAndDirection(Vector3D.ZERO, Vector3D.of(1, 1, 1), TEST_PRECISION);
+
+        // act
+        Segment3D segment = Segment3D.fromInterval(line, Vector1D.of(-1), Vector1D.of(2));
+
+        // assert
+        double side = 1.0 / Math.sqrt(3);
+        checkFiniteSegment(segment, Vector3D.of(-side, -side, -side), Vector3D.of(2 * side, 2 * side, 2 * side));
+
+        Assert.assertSame(TEST_PRECISION, segment.getPrecision());
+    }
+
+    @Test
+    public void testGetSubspaceRegion() {
+        // arrange
+        Line3D line = Line3D.fromPointAndDirection(Vector3D.ZERO, Vector3D.of(1, 1, 1), TEST_PRECISION);
+        Interval interval = Interval.full();
+
+        Segment3D segment = Segment3D.fromInterval(line, interval);
+
+        // act/assert
+        Assert.assertSame(interval, segment.getInterval());
+        Assert.assertSame(interval, segment.getSubspaceRegion());
+    }
+
+    @Test
+    public void testTransform_infinite() {
+        // arrange
+        Line3D line = Line3D.fromPointAndDirection(Vector3D.of(1, 0, 0), Vector3D.of(0, 1, -1), TEST_PRECISION);
+        Segment3D segment = Segment3D.fromInterval(line,
+                Interval.min(line.toSubspace(Vector3D.of(1, 0, 0)).getX(), TEST_PRECISION));
+
+        Transform<Vector3D> transform = AffineTransformMatrix3D.identity()
+                .scale(2, 1, 1)
+                .rotate(QuaternionRotation.fromAxisAngle(Vector3D.Unit.PLUS_Y, Geometry.HALF_PI));
+
+        // act
+        Segment3D transformed = segment.transform(transform);
+
+        // assert
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 0, -2), transformed.getStartPoint(), TEST_EPS);
+        Assert.assertNull(transformed.getEndPoint());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(-1, 1, 0).normalize(), transformed.getLine().getDirection(), TEST_EPS);
+    }
+
+    @Test
+    public void testTransform_finite() {
+        // arrange
+        Segment3D segment = Segment3D.fromPoints(Vector3D.of(1, 0, 0), Vector3D.of(2, 1, 0), TEST_PRECISION);
+
+        Transform<Vector3D> transform = AffineTransformMatrix3D.identity()
+                .scale(2, 1, 1)
+                .rotate(QuaternionRotation.fromAxisAngle(Vector3D.Unit.PLUS_Y, Geometry.HALF_PI));
+
+        // act
+        Segment3D transformed = segment.transform(transform);
+
+        // assert
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 0, -2), transformed.getStartPoint(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 1, -4), transformed.getEndPoint(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 1, -2).normalize(), transformed.getLine().getDirection(), TEST_EPS);
+    }
+
+    @Test
+    public void testContains() {
+        // arrange
+        Vector3D p1 = Vector3D.of(1, 0, 0);
+        Vector3D p2 = Vector3D.of(3, 0, 2);
+        Segment3D segment = Segment3D.fromPoints(p1, p2, TEST_PRECISION);
+
+        // act/assert
+        Assert.assertTrue(segment.contains(p1));
+        Assert.assertTrue(segment.contains(p2));
+        Assert.assertTrue(segment.contains(p1.lerp(p2, 0.5)));
+
+        Assert.assertFalse(segment.contains(p1.lerp(p2, -1)));
+        Assert.assertFalse(segment.contains(p1.lerp(p2, 2)));
+
+        Assert.assertFalse(segment.contains(Vector3D.ZERO));
+    }
+
+    @Test
+    public void testToString() {
+        // arrange
+        Line3D line = Line3D.fromPoints(Vector3D.ZERO, Vector3D.Unit.PLUS_X, TEST_PRECISION);
+
+        Segment3D full = Segment3D.fromInterval(line, Interval.full());
+        Segment3D startOnly = Segment3D.fromInterval(line, 0, Double.POSITIVE_INFINITY);
+        Segment3D endOnly = Segment3D.fromInterval(line, Double.NEGATIVE_INFINITY, 0);
+        Segment3D finite = Segment3D.fromPoints(Vector3D.ZERO, Vector3D.Unit.PLUS_X, TEST_PRECISION);
+
+        // act/assert
+        String fullStr = full.toString();
+        Assert.assertTrue(fullStr.contains("lineOrigin=") && fullStr.contains("lineDirection="));
+
+        String startOnlyStr = startOnly.toString();
+        Assert.assertTrue(startOnlyStr.contains("start=") && startOnlyStr.contains("direction="));
+
+        String endOnlyStr = endOnly.toString();
+        Assert.assertTrue(endOnlyStr.contains("direction=") && endOnlyStr.contains("end="));
+
+        String finiteStr = finite.toString();
+        Assert.assertTrue(finiteStr.contains("start=") && finiteStr.contains("end="));
+    }
+
+    private static void checkFiniteSegment(Segment3D segment, Vector3D start, Vector3D end) {
+        checkFiniteSegment(segment, start, end, TEST_PRECISION);
+    }
+
+    private static void checkFiniteSegment(Segment3D segment, Vector3D start, Vector3D end, DoublePrecisionContext precision) {
+        Assert.assertFalse(segment.isInfinite());
+        Assert.assertTrue(segment.isFinite());
+
+        EuclideanTestUtils.assertCoordinatesEqual(start, segment.getStartPoint(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(end, segment.getEndPoint(), TEST_EPS);
+
+        Line3D line = segment.getLine();
+
+        Assert.assertEquals(line.toSubspace(segment.getStartPoint()).getX(), segment.getSubspaceStart(), TEST_EPS);
+        Assert.assertEquals(line.toSubspace(segment.getEndPoint()).getX(), segment.getSubspaceEnd(), TEST_EPS);
+
+        Assert.assertSame(precision, segment.getPrecision());
+        Assert.assertSame(precision, line.getPrecision());
+    }
+}
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/SphericalCoordinatesTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/SphericalCoordinatesTest.java
index 72a7c34..b98ac87 100644
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/SphericalCoordinatesTest.java
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/SphericalCoordinatesTest.java
@@ -237,6 +237,25 @@
     }
 
     @Test
+    public void testFinite() {
+        // act/assert
+        Assert.assertTrue(SphericalCoordinates.of(1, 1, 1).isFinite());
+
+        Assert.assertFalse(SphericalCoordinates.of(0, 0, Double.NEGATIVE_INFINITY).isFinite());
+        Assert.assertFalse(SphericalCoordinates.of(0, Double.NEGATIVE_INFINITY, 0).isFinite());
+        Assert.assertFalse(SphericalCoordinates.of(Double.NEGATIVE_INFINITY, 0, 0).isFinite());
+        Assert.assertFalse(SphericalCoordinates.of(0, 0, Double.POSITIVE_INFINITY).isFinite());
+        Assert.assertFalse(SphericalCoordinates.of(0, Double.POSITIVE_INFINITY, 0).isFinite());
+        Assert.assertFalse(SphericalCoordinates.of(Double.POSITIVE_INFINITY, 0, 0).isFinite());
+
+        Assert.assertFalse(SphericalCoordinates.of(0, 0, Double.NaN).isFinite());
+        Assert.assertFalse(SphericalCoordinates.of(0, Double.NEGATIVE_INFINITY, Double.NaN).isFinite());
+        Assert.assertFalse(SphericalCoordinates.of(Double.NaN, 0, Double.NEGATIVE_INFINITY).isFinite());
+        Assert.assertFalse(SphericalCoordinates.of(Double.POSITIVE_INFINITY, Double.NaN, 0).isFinite());
+        Assert.assertFalse(SphericalCoordinates.of(0, Double.NaN, Double.POSITIVE_INFINITY).isFinite());
+    }
+
+    @Test
     public void testHashCode() {
         // arrange
         SphericalCoordinates a = SphericalCoordinates.of(1, 2, 3);
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/SubLine3DTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/SubLine3DTest.java
new file mode 100644
index 0000000..19e939d
--- /dev/null
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/SubLine3DTest.java
@@ -0,0 +1,199 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.threed;
+
+import java.util.List;
+
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
+import org.apache.commons.geometry.euclidean.oned.Interval;
+import org.apache.commons.geometry.euclidean.oned.RegionBSPTree1D;
+import org.apache.commons.geometry.euclidean.threed.rotation.QuaternionRotation;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class SubLine3DTest {
+
+    private static final double TEST_EPS = 1e-10;
+
+    private static final DoublePrecisionContext TEST_PRECISION =
+            new EpsilonDoublePrecisionContext(TEST_EPS);
+
+    private Line3D line = Line3D.fromPointAndDirection(Vector3D.ZERO, Vector3D.of(1, 1, 0), TEST_PRECISION);
+
+    @Test
+    public void testCtor_default() {
+        // act
+        SubLine3D sub = new SubLine3D(line);
+
+        // assert
+        Assert.assertSame(line, sub.getLine());
+        Assert.assertSame(TEST_PRECISION, sub.getPrecision());
+        Assert.assertTrue(sub.getSubspaceRegion().isEmpty());
+    }
+
+    @Test
+    public void testCtor_true() {
+        // act
+        SubLine3D sub = new SubLine3D(line, true);
+
+        // assert
+        Assert.assertSame(line, sub.getLine());
+        Assert.assertSame(TEST_PRECISION, sub.getPrecision());
+        Assert.assertTrue(sub.getSubspaceRegion().isFull());
+    }
+
+    @Test
+    public void testCtor_false() {
+        // act
+        SubLine3D sub = new SubLine3D(line, false);
+
+        // assert
+        Assert.assertSame(line, sub.getLine());
+        Assert.assertSame(TEST_PRECISION, sub.getPrecision());
+        Assert.assertTrue(sub.getSubspaceRegion().isEmpty());
+    }
+
+    @Test
+    public void testCtor_lineAndRegion() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.empty();
+
+        // act
+        SubLine3D sub = new SubLine3D(line, tree);
+
+        // assert
+        Assert.assertSame(line, sub.getLine());
+        Assert.assertSame(TEST_PRECISION, sub.getPrecision());
+        Assert.assertSame(tree, sub.getSubspaceRegion());
+    }
+
+    @Test
+    public void testTransform_full() {
+        // arrange
+        SubLine3D sub = new SubLine3D(line, true);
+
+        Transform<Vector3D> transform = AffineTransformMatrix3D.identity()
+                .translate(Vector3D.of(1, 0, 0))
+                .scale(Vector3D.of(2, 1, 1))
+                .rotate(QuaternionRotation.fromAxisAngle(Vector3D.Unit.PLUS_Y, Geometry.HALF_PI));
+
+        // act
+        SubLine3D result = sub.transform(transform);
+
+        // assert
+        Line3D resultLine = result.getLine();
+
+        Vector3D expectedOrigin = Line3D.fromPoints(Vector3D.of(0, 0, -2), Vector3D.of(0, 1, -4), TEST_PRECISION)
+                .getOrigin();
+
+        EuclideanTestUtils.assertCoordinatesEqual(expectedOrigin, resultLine.getOrigin(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 1, -2).normalize(), resultLine.getDirection(), TEST_EPS);
+
+        Assert.assertTrue(result.getSubspaceRegion().isFull());
+    }
+
+    @Test
+    public void testTransform_finite() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.empty();
+        tree.add(Interval.of(
+                line.toSubspace(Vector3D.of(1, 1, 0)).getX(),
+                line.toSubspace(Vector3D.of(2, 2, 0)).getX(), TEST_PRECISION));
+
+        SubLine3D sub = new SubLine3D(line, tree);
+
+        Transform<Vector3D> transform = AffineTransformMatrix3D.identity()
+                .translate(Vector3D.of(1, 0, 0))
+                .scale(Vector3D.of(2, 1, 1))
+                .rotate(QuaternionRotation.fromAxisAngle(Vector3D.Unit.PLUS_Y, Geometry.HALF_PI));
+
+        // act
+        SubLine3D result = sub.transform(transform);
+
+        // assert
+        Line3D resultLine = result.getLine();
+
+        Vector3D expectedOrigin = Line3D.fromPoints(Vector3D.of(0, 0, -2), Vector3D.of(0, 1, -4), TEST_PRECISION)
+                .getOrigin();
+
+        EuclideanTestUtils.assertCoordinatesEqual(expectedOrigin, resultLine.getOrigin(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 1, -2).normalize(), resultLine.getDirection(), TEST_EPS);
+
+        Assert.assertFalse(result.getSubspaceRegion().isFull());
+
+        List<Interval> intervals = result.getSubspaceRegion().toIntervals();
+        Assert.assertEquals(1, intervals.size());
+
+        Interval resultInterval = intervals.get(0);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 1, -4),
+                resultLine.toSpace(resultInterval.getMin()), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 2, -6),
+                resultLine.toSpace(resultInterval.getMax()), TEST_EPS);
+    }
+
+    @Test
+    public void testToConvex_full() {
+        // arrange
+        SubLine3D sub = new SubLine3D(line, true);
+
+        // act
+        List<Segment3D> segments = sub.toConvex();
+
+        // assert
+        Assert.assertEquals(1, segments.size());
+        Assert.assertTrue(segments.get(0).getSubspaceRegion().isFull());
+    }
+
+    @Test
+    public void testToConvex_finite() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.empty();
+        tree.add(Interval.of(
+                line.toSubspace(Vector3D.of(1, 1, 0)).getX(),
+                line.toSubspace(Vector3D.of(2, 2, 0)).getX(), TEST_PRECISION));
+
+        SubLine3D sub = new SubLine3D(line, tree);
+
+        // act
+        List<Segment3D> segments = sub.toConvex();
+
+        // assert
+        Assert.assertEquals(1, segments.size());
+
+        Segment3D segment = segments.get(0);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 1, 0), segment.getStartPoint(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(2, 2, 0), segment.getEndPoint(), TEST_EPS);
+    }
+
+    @Test
+    public void testToString() {
+        // arrange
+        SubLine3D sub = new SubLine3D(line);
+
+        // act
+        String str = sub.toString();
+
+        // assert
+        Assert.assertTrue(str.contains("SubLine3D[lineOrigin= "));
+        Assert.assertTrue(str.contains(", lineDirection= "));
+        Assert.assertTrue(str.contains(", region= "));
+    }
+}
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/SubLineTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/SubLineTest.java
deleted file mode 100644
index 38d2041..0000000
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/SubLineTest.java
+++ /dev/null
@@ -1,170 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.threed;
-
-import java.util.List;
-
-import org.apache.commons.geometry.core.partitioning.RegionFactory;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
-import org.apache.commons.geometry.euclidean.oned.IntervalsSet;
-import org.apache.commons.geometry.euclidean.oned.Vector1D;
-import org.junit.Assert;
-import org.junit.Test;
-
-public class SubLineTest {
-
-    private static final double TEST_EPS = 1e-10;
-
-    private static final DoublePrecisionContext TEST_PRECISION =
-            new EpsilonDoublePrecisionContext(TEST_EPS);
-
-    @Test
-    public void testEndPoints() {
-        Vector3D p1 = Vector3D.of(-1, -7, 2);
-        Vector3D p2 = Vector3D.of(7, -1, 0);
-        Segment segment = new Segment(p1, p2, new Line(p1, p2, TEST_PRECISION));
-        SubLine sub = new SubLine(segment);
-        List<Segment> segments = sub.getSegments();
-        Assert.assertEquals(1, segments.size());
-        Assert.assertEquals(0.0, Vector3D.of(-1, -7, 2).distance(segments.get(0).getStart()), TEST_EPS);
-        Assert.assertEquals(0.0, Vector3D.of( 7, -1, 0).distance(segments.get(0).getEnd()), TEST_EPS);
-    }
-
-    @Test
-    public void testNoEndPoints() {
-        SubLine wholeLine = new Line(Vector3D.of(-1, 7, 2), Vector3D.of(7, 1, 0), TEST_PRECISION).wholeLine();
-        List<Segment> segments = wholeLine.getSegments();
-        Assert.assertEquals(1, segments.size());
-        Assert.assertTrue(Double.isInfinite(segments.get(0).getStart().getX()) &&
-                          segments.get(0).getStart().getX() < 0);
-        Assert.assertTrue(Double.isInfinite(segments.get(0).getStart().getY()) &&
-                          segments.get(0).getStart().getY() > 0);
-        Assert.assertTrue(Double.isInfinite(segments.get(0).getStart().getZ()) &&
-                          segments.get(0).getStart().getZ() > 0);
-        Assert.assertTrue(Double.isInfinite(segments.get(0).getEnd().getX()) &&
-                          segments.get(0).getEnd().getX() > 0);
-        Assert.assertTrue(Double.isInfinite(segments.get(0).getEnd().getY()) &&
-                          segments.get(0).getEnd().getY() < 0);
-        Assert.assertTrue(Double.isInfinite(segments.get(0).getEnd().getZ()) &&
-                          segments.get(0).getEnd().getZ() < 0);
-    }
-
-    @Test
-    public void testNoSegments() {
-        SubLine empty = new SubLine(new Line(Vector3D.of(-1, -7, 2), Vector3D.of(7, -1, 0), TEST_PRECISION),
-                                    (IntervalsSet) new RegionFactory<Vector1D>().getComplement(new IntervalsSet(TEST_PRECISION)));
-        List<Segment> segments = empty.getSegments();
-        Assert.assertEquals(0, segments.size());
-    }
-
-    @Test
-    public void testSeveralSegments() {
-        SubLine twoSubs = new SubLine(new Line(Vector3D.of(-1, -7, 2), Vector3D.of(7, -1, 0), TEST_PRECISION),
-                                      (IntervalsSet) new RegionFactory<Vector1D>().union(new IntervalsSet(1, 2, TEST_PRECISION),
-                                                                                            new IntervalsSet(3, 4, TEST_PRECISION)));
-        List<Segment> segments = twoSubs.getSegments();
-        Assert.assertEquals(2, segments.size());
-    }
-
-    @Test
-    public void testHalfInfiniteNeg() {
-        SubLine empty = new SubLine(new Line(Vector3D.of(-1, -7, 2), Vector3D.of(7, -1, -2), TEST_PRECISION),
-                                    new IntervalsSet(Double.NEGATIVE_INFINITY, 0.0, TEST_PRECISION));
-        List<Segment> segments = empty.getSegments();
-        Assert.assertEquals(1, segments.size());
-        Assert.assertTrue(Double.isInfinite(segments.get(0).getStart().getX()) &&
-                          segments.get(0).getStart().getX() < 0);
-        Assert.assertTrue(Double.isInfinite(segments.get(0).getStart().getY()) &&
-                          segments.get(0).getStart().getY() < 0);
-        Assert.assertTrue(Double.isInfinite(segments.get(0).getStart().getZ()) &&
-                          segments.get(0).getStart().getZ() > 0);
-        Assert.assertEquals(0.0, Vector3D.of(3, -4, 0).distance(segments.get(0).getEnd()), TEST_EPS);
-    }
-
-    @Test
-    public void testHalfInfinitePos() {
-        SubLine empty = new SubLine(new Line(Vector3D.of(-1, -7, 2), Vector3D.of(7, -1, -2), TEST_PRECISION),
-                                    new IntervalsSet(0.0, Double.POSITIVE_INFINITY, TEST_PRECISION));
-        List<Segment> segments = empty.getSegments();
-        Assert.assertEquals(1, segments.size());
-        Assert.assertEquals(0.0, Vector3D.of(3, -4, 0).distance(segments.get(0).getStart()), 1.0e-10);
-        Assert.assertTrue(Double.isInfinite(segments.get(0).getEnd().getX()) &&
-                          segments.get(0).getEnd().getX() > 0);
-        Assert.assertTrue(Double.isInfinite(segments.get(0).getEnd().getY()) &&
-                          segments.get(0).getEnd().getY() > 0);
-        Assert.assertTrue(Double.isInfinite(segments.get(0).getEnd().getZ()) &&
-                          segments.get(0).getEnd().getZ() < 0);
-    }
-
-    @Test
-    public void testIntersectionInsideInside() {
-        SubLine sub1 = new SubLine(Vector3D.of(1, 1, 1), Vector3D.of(3, 1, 1), TEST_PRECISION);
-        SubLine sub2 = new SubLine(Vector3D.of(2, 0, 0), Vector3D.of(2, 2, 2), TEST_PRECISION);
-        Assert.assertEquals(0.0, Vector3D.of(2, 1, 1).distance(sub1.intersection(sub2, true)), TEST_EPS);
-        Assert.assertEquals(0.0, Vector3D.of(2, 1, 1).distance(sub1.intersection(sub2, false)), TEST_EPS);
-    }
-
-    @Test
-    public void testIntersectionInsideBoundary() {
-        SubLine sub1 = new SubLine(Vector3D.of(1, 1, 1), Vector3D.of(3, 1, 1), TEST_PRECISION);
-        SubLine sub2 = new SubLine(Vector3D.of(2, 0, 0), Vector3D.of(2, 1, 1), TEST_PRECISION);
-        Assert.assertEquals(0.0, Vector3D.of(2, 1, 1).distance(sub1.intersection(sub2, true)), TEST_EPS);
-        Assert.assertNull(sub1.intersection(sub2, false));
-    }
-
-    @Test
-    public void testIntersectionInsideOutside() {
-        SubLine sub1 = new SubLine(Vector3D.of(1, 1, 1), Vector3D.of(3, 1, 1), TEST_PRECISION);
-        SubLine sub2 = new SubLine(Vector3D.of(2, 0, 0), Vector3D.of(2, 0.5, 0.5), TEST_PRECISION);
-        Assert.assertNull(sub1.intersection(sub2, true));
-        Assert.assertNull(sub1.intersection(sub2, false));
-    }
-
-    @Test
-    public void testIntersectionBoundaryBoundary() {
-        SubLine sub1 = new SubLine(Vector3D.of(1, 1, 1), Vector3D.of(2, 1, 1), TEST_PRECISION);
-        SubLine sub2 = new SubLine(Vector3D.of(2, 0, 0), Vector3D.of(2, 1, 1), TEST_PRECISION);
-        Assert.assertEquals(0.0, Vector3D.of(2, 1, 1).distance(sub1.intersection(sub2, true)),  TEST_EPS);
-        Assert.assertNull(sub1.intersection(sub2, false));
-    }
-
-    @Test
-    public void testIntersectionBoundaryOutside() {
-        SubLine sub1 = new SubLine(Vector3D.of(1, 1, 1), Vector3D.of(2, 1, 1), TEST_PRECISION);
-        SubLine sub2 = new SubLine(Vector3D.of(2, 0, 0), Vector3D.of(2, 0.5, 0.5), TEST_PRECISION);
-        Assert.assertNull(sub1.intersection(sub2, true));
-        Assert.assertNull(sub1.intersection(sub2, false));
-    }
-
-    @Test
-    public void testIntersectionOutsideOutside() {
-        SubLine sub1 = new SubLine(Vector3D.of(1, 1, 1), Vector3D.of(1.5, 1, 1), TEST_PRECISION);
-        SubLine sub2 = new SubLine(Vector3D.of(2, 0, 0), Vector3D.of(2, 0.5, 0.5), TEST_PRECISION);
-        Assert.assertNull(sub1.intersection(sub2, true));
-        Assert.assertNull(sub1.intersection(sub2, false));
-    }
-
-    @Test
-    public void testIntersectionNotIntersecting() {
-        SubLine sub1 = new SubLine(Vector3D.of(1, 1, 1), Vector3D.of(1.5, 1, 1), TEST_PRECISION);
-        SubLine sub2 = new SubLine(Vector3D.of(2, 3, 0), Vector3D.of(2, 3, 0.5), TEST_PRECISION);
-        Assert.assertNull(sub1.intersection(sub2, true));
-        Assert.assertNull(sub1.intersection(sub2, false));
-    }
-
-}
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/SubPlaneTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/SubPlaneTest.java
new file mode 100644
index 0000000..b0ff93e
--- /dev/null
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/SubPlaneTest.java
@@ -0,0 +1,496 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.threed;
+
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.GeometryTestUtils;
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.exception.GeometryException;
+import org.apache.commons.geometry.core.partitioning.ConvexSubHyperplane;
+import org.apache.commons.geometry.core.partitioning.Hyperplane;
+import org.apache.commons.geometry.core.partitioning.HyperplaneBoundedRegion;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.SplitLocation;
+import org.apache.commons.geometry.core.partitioning.SubHyperplane;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
+import org.apache.commons.geometry.euclidean.threed.rotation.QuaternionRotation;
+import org.apache.commons.geometry.euclidean.twod.ConvexArea;
+import org.apache.commons.geometry.euclidean.twod.Line;
+import org.apache.commons.geometry.euclidean.twod.RegionBSPTree2D;
+import org.apache.commons.geometry.euclidean.twod.Vector2D;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class SubPlaneTest {
+
+    private static final double TEST_EPS = 1e-10;
+
+    private static final DoublePrecisionContext TEST_PRECISION =
+            new EpsilonDoublePrecisionContext(TEST_EPS);
+
+    private static final Plane XY_PLANE = Plane.fromPointAndPlaneVectors(Vector3D.ZERO,
+            Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
+
+    @Test
+    public void testCtor_plane() {
+        // act
+        SubPlane sp = new SubPlane(XY_PLANE);
+
+        // assert
+        Assert.assertFalse(sp.isFull());
+        Assert.assertTrue(sp.isEmpty());
+
+        Assert.assertEquals(0, sp.getSize(), TEST_EPS);
+    }
+
+    @Test
+    public void testCtor_plane_booleanFalse() {
+        // act
+        SubPlane sp = new SubPlane(XY_PLANE, false);
+
+        // assert
+        Assert.assertFalse(sp.isFull());
+        Assert.assertTrue(sp.isEmpty());
+
+        Assert.assertEquals(0, sp.getSize(), TEST_EPS);
+    }
+
+    @Test
+    public void testCtor_plane_booleanTrue() {
+        // act
+        SubPlane sp = new SubPlane(XY_PLANE, true);
+
+        // assert
+        Assert.assertTrue(sp.isFull());
+        Assert.assertFalse(sp.isEmpty());
+
+        GeometryTestUtils.assertPositiveInfinity(sp.getSize());
+    }
+
+    @Test
+    public void testToConvex_full() {
+        // act
+        SubPlane sp = new SubPlane(XY_PLANE, true);
+
+        // act
+        List<ConvexSubPlane> convex = sp.toConvex();
+
+        // assert
+        Assert.assertEquals(1, convex.size());
+        Assert.assertTrue(convex.get(0).isFull());
+    }
+
+    @Test
+    public void testToConvex_empty() {
+        // act
+        SubPlane sp = new SubPlane(XY_PLANE, false);
+
+        // act
+        List<ConvexSubPlane> convex = sp.toConvex();
+
+        // assert
+        Assert.assertEquals(0, convex.size());
+    }
+
+    @Test
+    public void testToConvex_nonConvexRegion() {
+        // act
+        ConvexArea a = ConvexArea.fromVertexLoop(Arrays.asList(
+                    Vector2D.of(0, 0), Vector2D.of(1, 0),
+                    Vector2D.of(1, 1), Vector2D.of(0, 1)
+                ), TEST_PRECISION);
+        ConvexArea b = ConvexArea.fromVertexLoop(Arrays.asList(
+                    Vector2D.of(1, 0), Vector2D.of(2, 0),
+                    Vector2D.of(2, 1), Vector2D.of(1, 1)
+                ), TEST_PRECISION);
+
+        SubPlane sp = new SubPlane(XY_PLANE, false);
+        sp.add(ConvexSubPlane.fromConvexArea(XY_PLANE, a));
+        sp.add(ConvexSubPlane.fromConvexArea(XY_PLANE, b));
+
+        // act
+        List<ConvexSubPlane> convex = sp.toConvex();
+
+        // assert
+        Assert.assertEquals(2, convex.size());
+        Assert.assertEquals(1, convex.get(0).getSize(), TEST_EPS);
+        Assert.assertEquals(1, convex.get(1).getSize(), TEST_EPS);
+    }
+
+    @Test
+    public void testSplit_empty() {
+        // arrange
+        SubPlane sp = new SubPlane(XY_PLANE, false);
+
+        Plane splitter = Plane.fromNormal(Vector3D.Unit.PLUS_X, TEST_PRECISION);
+
+        // act
+        Split<SubPlane> split = sp.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.NEITHER, split.getLocation());
+
+        Assert.assertNull(split.getMinus());
+        Assert.assertNull(split.getPlus());
+    }
+
+    @Test
+    public void testSplit_halfSpace() {
+        // arrange
+        SubPlane sp = new SubPlane(XY_PLANE, false);
+        sp.getSubspaceRegion().getRoot().cut(
+                Line.fromPointAndAngle(Vector2D.ZERO, Geometry.ZERO_PI, TEST_PRECISION));
+
+        Plane splitter = Plane.fromNormal(Vector3D.Unit.PLUS_X, TEST_PRECISION);
+
+        // act
+        Split<SubPlane> split = sp.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        SubPlane minus = split.getMinus();
+        checkPoints(minus, RegionLocation.INSIDE, Vector3D.of(-1, 1, 0));
+        checkPoints(minus, RegionLocation.OUTSIDE, Vector3D.of(1, 1, 0), Vector3D.of(0, -1, 0));
+
+        SubPlane plus = split.getPlus();
+        checkPoints(plus, RegionLocation.OUTSIDE, Vector3D.of(-1, 1, 0), Vector3D.of(0, -1, 0));
+        checkPoints(plus, RegionLocation.INSIDE, Vector3D.of(1, 1, 0));
+    }
+
+    @Test
+    public void testSplit_both() {
+        // arrange
+        SubPlane sp = new SubPlane(XY_PLANE, false);
+        sp.getSubspaceRegion().union(RegionBSPTree2D.builder(TEST_PRECISION).addRect(Vector2D.of(-1, -1), Vector2D.of(1, 1)).build());
+
+        Plane splitter = Plane.fromNormal(Vector3D.Unit.PLUS_X, TEST_PRECISION);
+
+        // act
+        Split<SubPlane> split = sp.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        SubPlane minus = split.getMinus();
+        checkPoints(minus, RegionLocation.INSIDE, Vector3D.of(-0.5, 0, 0));
+        checkPoints(minus, RegionLocation.OUTSIDE,
+                Vector3D.of(0.5, 0, 0), Vector3D.of(1.5, 0, 0),
+                Vector3D.of(0, 1.5, 0), Vector3D.of(0, -1.5, 0));
+
+        SubPlane plus = split.getPlus();
+        checkPoints(plus, RegionLocation.INSIDE, Vector3D.of(0.5, 0, 0));
+        checkPoints(plus, RegionLocation.OUTSIDE,
+                Vector3D.of(-0.5, 0, 0), Vector3D.of(1.5, 0, 0),
+                Vector3D.of(0, 1.5, 0), Vector3D.of(0, -1.5, 0));
+    }
+
+    @Test
+    public void testSplit_intersects_plusOnly() {
+        // arrange
+        SubPlane sp = new SubPlane(XY_PLANE, false);
+        sp.getSubspaceRegion().union(RegionBSPTree2D.builder(TEST_PRECISION).addRect(Vector2D.of(-1, -1), Vector2D.of(1, 1)).build());
+
+        Plane splitter = Plane.fromPointAndNormal(Vector3D.of(0, 0, 1), Vector3D.of(0.1, 0, 1), TEST_PRECISION);
+
+        // act
+        Split<SubPlane> split = sp.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.MINUS, split.getLocation());
+
+        Assert.assertSame(sp, split.getMinus());
+        Assert.assertNull(split.getPlus());
+    }
+
+    @Test
+    public void testSplit_intersects_minusOnly() {
+        // arrange
+        SubPlane sp = new SubPlane(XY_PLANE, false);
+        sp.getSubspaceRegion().union(RegionBSPTree2D.builder(TEST_PRECISION).addRect(Vector2D.of(-1, -1), Vector2D.of(1, 1)).build());
+
+        Plane splitter = Plane.fromPointAndNormal(Vector3D.of(0, 0, 1), Vector3D.of(0.1, 0, -1), TEST_PRECISION);
+
+        // act
+        Split<SubPlane> split = sp.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.PLUS, split.getLocation());
+
+        Assert.assertNull(split.getMinus());
+        Assert.assertSame(sp, split.getPlus());
+    }
+
+    @Test
+    public void testSplit_parallel_plusOnly() {
+        // arrange
+        SubPlane sp = new SubPlane(XY_PLANE, false);
+        sp.getSubspaceRegion().union(RegionBSPTree2D.builder(TEST_PRECISION).addRect(Vector2D.of(-1, -1), Vector2D.of(1, 1)).build());
+
+        Plane splitter = Plane.fromPointAndNormal(Vector3D.of(0, 0, 1), Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+
+        // act
+        Split<SubPlane> split = sp.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.MINUS, split.getLocation());
+
+        Assert.assertSame(sp, split.getMinus());
+        Assert.assertNull(split.getPlus());
+    }
+
+    @Test
+    public void testSplit_parallel_minusOnly() {
+        // arrange
+        SubPlane sp = new SubPlane(XY_PLANE, false);
+        sp.getSubspaceRegion().union(RegionBSPTree2D.builder(TEST_PRECISION).addRect(Vector2D.of(-1, -1), Vector2D.of(1, 1)).build());
+
+        Plane splitter = Plane.fromPointAndNormal(Vector3D.of(0, 0, 1), Vector3D.Unit.MINUS_Z, TEST_PRECISION);
+
+        // act
+        Split<SubPlane> split = sp.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.PLUS, split.getLocation());
+
+        Assert.assertNull(split.getMinus());
+        Assert.assertSame(sp, split.getPlus());
+    }
+
+    @Test
+    public void testSplit_coincident() {
+        // arrange
+        SubPlane sp = new SubPlane(XY_PLANE, false);
+        sp.getSubspaceRegion().union(RegionBSPTree2D.builder(TEST_PRECISION).addRect(Vector2D.of(-1, -1), Vector2D.of(1, 1)).build());
+
+        // act
+        Split<SubPlane> split = sp.split(sp.getPlane());
+
+        // assert
+        Assert.assertEquals(SplitLocation.NEITHER, split.getLocation());
+
+        Assert.assertNull(split.getMinus());
+        Assert.assertNull(split.getPlus());
+    }
+
+    @Test
+    public void testTransform_empty() {
+        // arrange
+        SubPlane sp = new SubPlane(XY_PLANE, false);
+
+        AffineTransformMatrix3D transform = AffineTransformMatrix3D.createTranslation(Vector3D.Unit.PLUS_Z);
+
+        // act
+        SubPlane result = sp.transform(transform);
+
+        // assert
+        Assert.assertNotSame(sp, result);
+
+        Plane resultPlane = result.getPlane();
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 0, 1), resultPlane.getOrigin(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.Unit.PLUS_Z, resultPlane.getNormal(), TEST_EPS);
+
+        Assert.assertFalse(result.isFull());
+        Assert.assertTrue(result.isEmpty());
+    }
+
+    @Test
+    public void testTransform_full() {
+        // arrange
+        SubPlane sp = new SubPlane(XY_PLANE, true);
+
+        AffineTransformMatrix3D transform = AffineTransformMatrix3D.createTranslation(Vector3D.Unit.PLUS_Z);
+
+        // act
+        SubPlane result = sp.transform(transform);
+
+        // assert
+        Assert.assertNotSame(sp, result);
+
+        Plane resultPlane = result.getPlane();
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 0, 1), resultPlane.getOrigin(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.Unit.PLUS_Z, resultPlane.getNormal(), TEST_EPS);
+
+        Assert.assertTrue(result.isFull());
+        Assert.assertFalse(result.isEmpty());
+    }
+
+    @Test
+    public void testTransform() {
+        // arrange
+        ConvexArea area = ConvexArea.fromVertexLoop(
+                Arrays.asList(Vector2D.ZERO, Vector2D.Unit.PLUS_X, Vector2D.Unit.PLUS_Y), TEST_PRECISION);
+        Plane plane = Plane.fromPointAndPlaneVectors(Vector3D.of(0, 0, 1), Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
+
+        SubPlane sp = new SubPlane(plane, RegionBSPTree2D.from(area));
+
+        Transform<Vector3D> transform = AffineTransformMatrix3D.identity()
+                .rotate(QuaternionRotation.fromAxisAngle(Vector3D.Unit.PLUS_Y, Geometry.HALF_PI))
+                .translate(Vector3D.of(1, 0, 0));
+
+        // act
+        SubPlane result = sp.transform(transform);
+
+        // assert
+        Assert.assertNotSame(sp, result);
+
+        Plane resultPlane = result.getPlane();
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(2, 0, 0), resultPlane.getOrigin(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.Unit.PLUS_X, resultPlane.getNormal(), TEST_EPS);
+
+        checkPoints(result, RegionLocation.INSIDE, Vector3D.of(2, 0.25, -0.25));
+        checkPoints(result, RegionLocation.OUTSIDE, Vector3D.of(1, 0.25, -0.25), Vector3D.of(3, 0.25, -0.25));
+
+        checkPoints(result, RegionLocation.BOUNDARY,
+                Vector3D.of(2, 0, 0), Vector3D.of(2, 0, -1), Vector3D.of(2, 1, 0));
+    }
+
+    @Test
+    public void testTransform_reflection() {
+        // arrange
+        ConvexArea area = ConvexArea.fromVertexLoop(
+                Arrays.asList(Vector2D.ZERO, Vector2D.Unit.PLUS_X, Vector2D.Unit.PLUS_Y), TEST_PRECISION);
+        Plane plane = Plane.fromPointAndPlaneVectors(Vector3D.of(0, 0, 1), Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
+
+        SubPlane sp = new SubPlane(plane, RegionBSPTree2D.from(area));
+
+        Transform<Vector3D> transform = AffineTransformMatrix3D.createScale(-1, 1, 1);
+
+        // act
+        SubPlane result = sp.transform(transform);
+
+        // assert
+        Assert.assertNotSame(sp, result);
+
+        Plane resultPlane = result.getPlane();
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 0, 1), resultPlane.getOrigin(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.Unit.MINUS_Z, resultPlane.getNormal(), TEST_EPS);
+
+        checkPoints(result, RegionLocation.INSIDE, Vector3D.of(-0.25, 0.25, 1));
+        checkPoints(result, RegionLocation.OUTSIDE, Vector3D.of(0.25, 0.25, 0), Vector3D.of(0.25, 0.25, 2));
+
+        checkPoints(result, RegionLocation.BOUNDARY,
+                Vector3D.of(-1, 0, 1), Vector3D.of(0, 1, 1), Vector3D.of(0, 0, 1));
+    }
+
+    @Test
+    public void testBuilder() {
+        // arrange
+        Plane mainPlane = Plane.fromPointAndPlaneVectors(
+                Vector3D.of(0, 0, 1), Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
+        SubPlane.SubPlaneBuilder builder = new SubPlane.SubPlaneBuilder(mainPlane);
+
+        ConvexArea a = ConvexArea.fromVertexLoop(
+                Arrays.asList(Vector2D.ZERO, Vector2D.Unit.PLUS_X, Vector2D.Unit.PLUS_Y), TEST_PRECISION);
+        ConvexArea b = ConvexArea.fromVertexLoop(
+                Arrays.asList(Vector2D.Unit.PLUS_X, Vector2D.of(1, 1), Vector2D.Unit.PLUS_Y), TEST_PRECISION);
+
+        Plane closePlane = Plane.fromPointAndPlaneVectors(
+                Vector3D.of(1e-16, 0, 1), Vector3D.of(1, 1e-16, 0), Vector3D.Unit.PLUS_Y, TEST_PRECISION);
+
+        // act
+        builder.add(ConvexSubPlane.fromConvexArea(closePlane, a));
+        builder.add(new SubPlane(closePlane, RegionBSPTree2D.from(b)));
+
+        SubPlane result = builder.build();
+
+        // assert
+        Assert.assertFalse(result.isFull());
+        Assert.assertFalse(result.isEmpty());
+        Assert.assertTrue(result.isFinite());
+        Assert.assertFalse(result.isInfinite());
+
+        checkPoints(result, RegionLocation.INSIDE, Vector3D.of(0.5, 0.5, 1));
+        checkPoints(result, RegionLocation.OUTSIDE,
+                Vector3D.of(-1, 0.5, 1), Vector3D.of(2, 0.5, 1),
+                Vector3D.of(0.5, -1, 1), Vector3D.of(0.5, 2, 1));
+        checkPoints(result, RegionLocation.BOUNDARY,
+                Vector3D.of(0, 0, 1), Vector3D.of(1, 0, 1),
+                Vector3D.of(1, 1, 1), Vector3D.of(0, 1, 1));
+    }
+
+    @Test
+    public void testSubPlaneAddMethods_validatesPlane() {
+        // arrange
+        SubPlane sp = new SubPlane(XY_PLANE, false);
+
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            sp.add(ConvexSubPlane.fromConvexArea(
+                    Plane.fromPointAndPlaneVectors(Vector3D.ZERO, Vector3D.Unit.PLUS_Y, Vector3D.Unit.MINUS_X, TEST_PRECISION),
+                    ConvexArea.full()));
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            sp.add(new SubPlane(
+                    Plane.fromPointAndPlaneVectors(Vector3D.of(0, 0, -1), Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION),
+                    false));
+        }, GeometryException.class);
+    }
+
+    @Test
+    public void testBuilder_addUnknownType() {
+        // arrange
+        SubPlane.SubPlaneBuilder sp = new SubPlane.SubPlaneBuilder(XY_PLANE);
+
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            sp.add(new StubSubPlane(XY_PLANE));
+        }, IllegalArgumentException.class);
+    }
+
+    private static void checkPoints(SubPlane sp, RegionLocation loc, Vector3D ... pts) {
+        for (Vector3D pt : pts) {
+            Assert.assertEquals("Unexpected subplane location for point " + pt, loc, sp.classify(pt));
+        }
+    }
+
+    private static class StubSubPlane extends AbstractSubPlane<RegionBSPTree2D> implements SubHyperplane<Vector3D> {
+
+        private static final long serialVersionUID = 1L;
+
+        StubSubPlane(Plane plane) {
+            super(plane);
+        }
+
+        @Override
+        public Split<? extends SubHyperplane<Vector3D>> split(Hyperplane<Vector3D> splitter) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public SubHyperplane<Vector3D> transform(Transform<Vector3D> transform) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public List<? extends ConvexSubHyperplane<Vector3D>> toConvex() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public HyperplaneBoundedRegion<Vector2D> getSubspaceRegion() {
+            throw new UnsupportedOperationException();
+        }
+    }
+}
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/Vector3DTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/Vector3DTest.java
index 9f55682..c67ce65 100644
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/Vector3DTest.java
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/Vector3DTest.java
@@ -17,6 +17,7 @@
 
 package org.apache.commons.geometry.euclidean.threed;
 
+import java.util.Comparator;
 import java.util.regex.Pattern;
 
 import org.apache.commons.geometry.core.Geometry;
@@ -77,6 +78,27 @@
     }
 
     @Test
+    public void testCoordinateAscendingOrder() {
+        // arrange
+        Comparator<Vector3D> cmp = Vector3D.COORDINATE_ASCENDING_ORDER;
+
+        // act/assert
+        Assert.assertEquals(0, cmp.compare(Vector3D.of(1, 2, 3), Vector3D.of(1, 2, 3)));
+
+        Assert.assertEquals(-1, cmp.compare(Vector3D.of(0, 2, 3), Vector3D.of(1, 2, 3)));
+        Assert.assertEquals(-1, cmp.compare(Vector3D.of(1, 1, 3), Vector3D.of(1, 2, 3)));
+        Assert.assertEquals(-1, cmp.compare(Vector3D.of(1, 2, 2), Vector3D.of(1, 2, 3)));
+
+        Assert.assertEquals(1, cmp.compare(Vector3D.of(2, 2, 3), Vector3D.of(1, 2, 3)));
+        Assert.assertEquals(1, cmp.compare(Vector3D.of(1, 3, 3), Vector3D.of(1, 2, 3)));
+        Assert.assertEquals(1, cmp.compare(Vector3D.of(1, 2, 4), Vector3D.of(1, 2, 3)));
+
+        Assert.assertEquals(-1, cmp.compare(Vector3D.of(1, 2, 3), null));
+        Assert.assertEquals(1, cmp.compare(null, Vector3D.of(1, 2, 3)));
+        Assert.assertEquals(0, cmp.compare(null, null));
+    }
+
+    @Test
     public void testCoordinates() {
         // arrange
         Vector3D c = Vector3D.of(1, 2, 3);
@@ -143,6 +165,26 @@
     }
 
     @Test
+    public void testFinite() {
+        // act/assert
+        Assert.assertTrue(Vector3D.ZERO.isFinite());
+        Assert.assertTrue(Vector3D.of(1, 1, 1).isFinite());
+
+        Assert.assertFalse(Vector3D.of(0, 0, Double.NEGATIVE_INFINITY).isFinite());
+        Assert.assertFalse(Vector3D.of(0, Double.NEGATIVE_INFINITY, 0).isFinite());
+        Assert.assertFalse(Vector3D.of(Double.NEGATIVE_INFINITY, 0, 0).isFinite());
+        Assert.assertFalse(Vector3D.of(0, 0, Double.POSITIVE_INFINITY).isFinite());
+        Assert.assertFalse(Vector3D.of(0, Double.POSITIVE_INFINITY, 0).isFinite());
+        Assert.assertFalse(Vector3D.of(Double.POSITIVE_INFINITY, 0, 0).isFinite());
+
+        Assert.assertFalse(Vector3D.of(0, 0, Double.NaN).isFinite());
+        Assert.assertFalse(Vector3D.of(0, Double.NEGATIVE_INFINITY, Double.NaN).isFinite());
+        Assert.assertFalse(Vector3D.of(Double.NaN, 0, Double.NEGATIVE_INFINITY).isFinite());
+        Assert.assertFalse(Vector3D.of(Double.POSITIVE_INFINITY, Double.NaN, 0).isFinite());
+        Assert.assertFalse(Vector3D.of(0, Double.NaN, Double.POSITIVE_INFINITY).isFinite());
+    }
+
+    @Test
     public void testZero() {
         // act
         Vector3D zero = Vector3D.of(1, 2, 3).getZero();
@@ -973,26 +1015,26 @@
         Vector3D vec = Vector3D.of(1, -2, 3);
 
         // act/assert
-        Assert.assertTrue(vec.equals(vec, smallEps));
-        Assert.assertTrue(vec.equals(vec, largeEps));
+        Assert.assertTrue(vec.eq(vec, smallEps));
+        Assert.assertTrue(vec.eq(vec, largeEps));
 
-        Assert.assertTrue(vec.equals(Vector3D.of(1.0000007, -2.0000009, 3.0000009), smallEps));
-        Assert.assertTrue(vec.equals(Vector3D.of(1.0000007, -2.0000009, 3.0000009), largeEps));
+        Assert.assertTrue(vec.eq(Vector3D.of(1.0000007, -2.0000009, 3.0000009), smallEps));
+        Assert.assertTrue(vec.eq(Vector3D.of(1.0000007, -2.0000009, 3.0000009), largeEps));
 
-        Assert.assertFalse(vec.equals(Vector3D.of(1.004, -2, 3), smallEps));
-        Assert.assertFalse(vec.equals(Vector3D.of(1, -2.004, 3), smallEps));
-        Assert.assertFalse(vec.equals(Vector3D.of(1, -2, 2.999), smallEps));
-        Assert.assertTrue(vec.equals(Vector3D.of(1.004, -2.004, 2.999), largeEps));
+        Assert.assertFalse(vec.eq(Vector3D.of(1.004, -2, 3), smallEps));
+        Assert.assertFalse(vec.eq(Vector3D.of(1, -2.004, 3), smallEps));
+        Assert.assertFalse(vec.eq(Vector3D.of(1, -2, 2.999), smallEps));
+        Assert.assertTrue(vec.eq(Vector3D.of(1.004, -2.004, 2.999), largeEps));
 
-        Assert.assertFalse(vec.equals(Vector3D.of(2, -2, 3), smallEps));
-        Assert.assertFalse(vec.equals(Vector3D.of(1, -3, 3), smallEps));
-        Assert.assertFalse(vec.equals(Vector3D.of(1, -2, 4), smallEps));
-        Assert.assertFalse(vec.equals(Vector3D.of(2, -3, 4), smallEps));
+        Assert.assertFalse(vec.eq(Vector3D.of(2, -2, 3), smallEps));
+        Assert.assertFalse(vec.eq(Vector3D.of(1, -3, 3), smallEps));
+        Assert.assertFalse(vec.eq(Vector3D.of(1, -2, 4), smallEps));
+        Assert.assertFalse(vec.eq(Vector3D.of(2, -3, 4), smallEps));
 
-        Assert.assertFalse(vec.equals(Vector3D.of(2, -2, 3), largeEps));
-        Assert.assertFalse(vec.equals(Vector3D.of(1, -3, 3), largeEps));
-        Assert.assertFalse(vec.equals(Vector3D.of(1, -2, 4), largeEps));
-        Assert.assertFalse(vec.equals(Vector3D.of(2, -3, 4), largeEps));
+        Assert.assertFalse(vec.eq(Vector3D.of(2, -2, 3), largeEps));
+        Assert.assertFalse(vec.eq(Vector3D.of(1, -3, 3), largeEps));
+        Assert.assertFalse(vec.eq(Vector3D.of(1, -2, 4), largeEps));
+        Assert.assertFalse(vec.eq(Vector3D.of(2, -3, 4), largeEps));
     }
 
     @Test
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/rotation/QuaternionRotationTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/rotation/QuaternionRotationTest.java
index f4f40d0..5d25bde 100644
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/rotation/QuaternionRotationTest.java
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/rotation/QuaternionRotationTest.java
@@ -23,6 +23,7 @@
 
 import org.apache.commons.geometry.core.Geometry;
 import org.apache.commons.geometry.core.GeometryTestUtils;
+import org.apache.commons.geometry.core.exception.GeometryValueException;
 import org.apache.commons.geometry.core.exception.IllegalNormException;
 import org.apache.commons.geometry.core.internal.SimpleTupleFormat;
 import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
@@ -266,15 +267,31 @@
     @Test
     public void testFromAxisAngle_invalidAngle() {
         // act/assert
-        GeometryTestUtils.assertThrows(() -> QuaternionRotation.fromAxisAngle(Vector3D.Unit.PLUS_X, Double.NaN), IllegalArgumentException.class,
+        GeometryTestUtils.assertThrows(() -> QuaternionRotation.fromAxisAngle(Vector3D.Unit.PLUS_X, Double.NaN), GeometryValueException.class,
                 "Invalid angle: NaN");
-        GeometryTestUtils.assertThrows(() -> QuaternionRotation.fromAxisAngle(Vector3D.Unit.PLUS_X, Double.POSITIVE_INFINITY), IllegalArgumentException.class,
+        GeometryTestUtils.assertThrows(() -> QuaternionRotation.fromAxisAngle(Vector3D.Unit.PLUS_X, Double.POSITIVE_INFINITY), GeometryValueException.class,
                 "Invalid angle: Infinity");
-        GeometryTestUtils.assertThrows(() -> QuaternionRotation.fromAxisAngle(Vector3D.Unit.PLUS_X, Double.NEGATIVE_INFINITY), IllegalArgumentException.class,
+        GeometryTestUtils.assertThrows(() -> QuaternionRotation.fromAxisAngle(Vector3D.Unit.PLUS_X, Double.NEGATIVE_INFINITY), GeometryValueException.class,
                 "Invalid angle: -Infinity");
     }
 
     @Test
+    public void testApplyVector() {
+        // arrange
+        QuaternionRotation q = QuaternionRotation.fromAxisAngle(Vector3D.of(1, 1, 1), Geometry.HALF_PI);
+
+        EuclideanTestUtils.permute(-2, 2, 0.2, (x, y, z) -> {
+            Vector3D input = Vector3D.of(x, y, z);
+
+            // act
+            Vector3D pt = q.apply(input);
+            Vector3D vec = q.applyVector(input);
+
+            EuclideanTestUtils.assertCoordinatesEqual(pt, vec, EPS);
+        });
+    }
+
+    @Test
     public void testInverse() {
         // arrange
         QuaternionRotation rot = QuaternionRotation.of(0.5, 0.5, 0.5, 0.5);
@@ -692,50 +709,50 @@
     }
 
     @Test
-    public void testToTransformMatrix() {
+    public void testToMatrix() {
         // act/assert
         // --- x axes
-        assertTransformEquals(StandardRotations.IDENTITY, QuaternionRotation.fromAxisAngle(PLUS_X_DIR, Geometry.ZERO_PI).toTransformMatrix());
+        assertTransformEquals(StandardRotations.IDENTITY, QuaternionRotation.fromAxisAngle(PLUS_X_DIR, Geometry.ZERO_PI).toMatrix());
 
-        assertTransformEquals(StandardRotations.PLUS_X_HALF_PI, QuaternionRotation.fromAxisAngle(PLUS_X_DIR, Geometry.HALF_PI).toTransformMatrix());
-        assertTransformEquals(StandardRotations.PLUS_X_HALF_PI, QuaternionRotation.fromAxisAngle(MINUS_X_DIR, Geometry.MINUS_HALF_PI).toTransformMatrix());
+        assertTransformEquals(StandardRotations.PLUS_X_HALF_PI, QuaternionRotation.fromAxisAngle(PLUS_X_DIR, Geometry.HALF_PI).toMatrix());
+        assertTransformEquals(StandardRotations.PLUS_X_HALF_PI, QuaternionRotation.fromAxisAngle(MINUS_X_DIR, Geometry.MINUS_HALF_PI).toMatrix());
 
-        assertTransformEquals(StandardRotations.MINUS_X_HALF_PI, QuaternionRotation.fromAxisAngle(MINUS_X_DIR, Geometry.HALF_PI).toTransformMatrix());
-        assertTransformEquals(StandardRotations.MINUS_X_HALF_PI, QuaternionRotation.fromAxisAngle(PLUS_X_DIR, Geometry.MINUS_HALF_PI).toTransformMatrix());
+        assertTransformEquals(StandardRotations.MINUS_X_HALF_PI, QuaternionRotation.fromAxisAngle(MINUS_X_DIR, Geometry.HALF_PI).toMatrix());
+        assertTransformEquals(StandardRotations.MINUS_X_HALF_PI, QuaternionRotation.fromAxisAngle(PLUS_X_DIR, Geometry.MINUS_HALF_PI).toMatrix());
 
-        assertTransformEquals(StandardRotations.X_PI, QuaternionRotation.fromAxisAngle(PLUS_X_DIR, Geometry.PI).toTransformMatrix());
-        assertTransformEquals(StandardRotations.X_PI, QuaternionRotation.fromAxisAngle(MINUS_X_DIR, Geometry.PI).toTransformMatrix());
+        assertTransformEquals(StandardRotations.X_PI, QuaternionRotation.fromAxisAngle(PLUS_X_DIR, Geometry.PI).toMatrix());
+        assertTransformEquals(StandardRotations.X_PI, QuaternionRotation.fromAxisAngle(MINUS_X_DIR, Geometry.PI).toMatrix());
 
         // --- y axes
-        assertTransformEquals(StandardRotations.IDENTITY, QuaternionRotation.fromAxisAngle(PLUS_Y_DIR, Geometry.ZERO_PI).toTransformMatrix());
+        assertTransformEquals(StandardRotations.IDENTITY, QuaternionRotation.fromAxisAngle(PLUS_Y_DIR, Geometry.ZERO_PI).toMatrix());
 
-        assertTransformEquals(StandardRotations.PLUS_Y_HALF_PI, QuaternionRotation.fromAxisAngle(PLUS_Y_DIR, Geometry.HALF_PI).toTransformMatrix());
-        assertTransformEquals(StandardRotations.PLUS_Y_HALF_PI, QuaternionRotation.fromAxisAngle(MINUS_Y_DIR, Geometry.MINUS_HALF_PI).toTransformMatrix());
+        assertTransformEquals(StandardRotations.PLUS_Y_HALF_PI, QuaternionRotation.fromAxisAngle(PLUS_Y_DIR, Geometry.HALF_PI).toMatrix());
+        assertTransformEquals(StandardRotations.PLUS_Y_HALF_PI, QuaternionRotation.fromAxisAngle(MINUS_Y_DIR, Geometry.MINUS_HALF_PI).toMatrix());
 
-        assertTransformEquals(StandardRotations.MINUS_Y_HALF_PI, QuaternionRotation.fromAxisAngle(MINUS_Y_DIR, Geometry.HALF_PI).toTransformMatrix());
-        assertTransformEquals(StandardRotations.MINUS_Y_HALF_PI, QuaternionRotation.fromAxisAngle(PLUS_Y_DIR, Geometry.MINUS_HALF_PI).toTransformMatrix());
+        assertTransformEquals(StandardRotations.MINUS_Y_HALF_PI, QuaternionRotation.fromAxisAngle(MINUS_Y_DIR, Geometry.HALF_PI).toMatrix());
+        assertTransformEquals(StandardRotations.MINUS_Y_HALF_PI, QuaternionRotation.fromAxisAngle(PLUS_Y_DIR, Geometry.MINUS_HALF_PI).toMatrix());
 
-        assertTransformEquals(StandardRotations.Y_PI, QuaternionRotation.fromAxisAngle(PLUS_Y_DIR, Geometry.PI).toTransformMatrix());
-        assertTransformEquals(StandardRotations.Y_PI, QuaternionRotation.fromAxisAngle(MINUS_Y_DIR, Geometry.PI).toTransformMatrix());
+        assertTransformEquals(StandardRotations.Y_PI, QuaternionRotation.fromAxisAngle(PLUS_Y_DIR, Geometry.PI).toMatrix());
+        assertTransformEquals(StandardRotations.Y_PI, QuaternionRotation.fromAxisAngle(MINUS_Y_DIR, Geometry.PI).toMatrix());
 
         // --- z axes
-        assertTransformEquals(StandardRotations.IDENTITY, QuaternionRotation.fromAxisAngle(PLUS_Z_DIR, Geometry.ZERO_PI).toTransformMatrix());
+        assertTransformEquals(StandardRotations.IDENTITY, QuaternionRotation.fromAxisAngle(PLUS_Z_DIR, Geometry.ZERO_PI).toMatrix());
 
-        assertTransformEquals(StandardRotations.PLUS_Z_HALF_PI, QuaternionRotation.fromAxisAngle(PLUS_Z_DIR, Geometry.HALF_PI).toTransformMatrix());
-        assertTransformEquals(StandardRotations.PLUS_Z_HALF_PI, QuaternionRotation.fromAxisAngle(MINUS_Z_DIR, Geometry.MINUS_HALF_PI).toTransformMatrix());
+        assertTransformEquals(StandardRotations.PLUS_Z_HALF_PI, QuaternionRotation.fromAxisAngle(PLUS_Z_DIR, Geometry.HALF_PI).toMatrix());
+        assertTransformEquals(StandardRotations.PLUS_Z_HALF_PI, QuaternionRotation.fromAxisAngle(MINUS_Z_DIR, Geometry.MINUS_HALF_PI).toMatrix());
 
-        assertTransformEquals(StandardRotations.MINUS_Z_HALF_PI, QuaternionRotation.fromAxisAngle(MINUS_Z_DIR, Geometry.HALF_PI).toTransformMatrix());
-        assertTransformEquals(StandardRotations.MINUS_Z_HALF_PI, QuaternionRotation.fromAxisAngle(PLUS_Z_DIR, Geometry.MINUS_HALF_PI).toTransformMatrix());
+        assertTransformEquals(StandardRotations.MINUS_Z_HALF_PI, QuaternionRotation.fromAxisAngle(MINUS_Z_DIR, Geometry.HALF_PI).toMatrix());
+        assertTransformEquals(StandardRotations.MINUS_Z_HALF_PI, QuaternionRotation.fromAxisAngle(PLUS_Z_DIR, Geometry.MINUS_HALF_PI).toMatrix());
 
-        assertTransformEquals(StandardRotations.Z_PI, QuaternionRotation.fromAxisAngle(PLUS_Z_DIR, Geometry.PI).toTransformMatrix());
-        assertTransformEquals(StandardRotations.Z_PI, QuaternionRotation.fromAxisAngle(MINUS_Z_DIR, Geometry.PI).toTransformMatrix());
+        assertTransformEquals(StandardRotations.Z_PI, QuaternionRotation.fromAxisAngle(PLUS_Z_DIR, Geometry.PI).toMatrix());
+        assertTransformEquals(StandardRotations.Z_PI, QuaternionRotation.fromAxisAngle(MINUS_Z_DIR, Geometry.PI).toMatrix());
 
         // --- diagonal
-        assertTransformEquals(StandardRotations.PLUS_DIAGONAL_TWO_THIRDS_PI, QuaternionRotation.fromAxisAngle(PLUS_DIAGONAL, TWO_THIRDS_PI).toTransformMatrix());
-        assertTransformEquals(StandardRotations.PLUS_DIAGONAL_TWO_THIRDS_PI, QuaternionRotation.fromAxisAngle(MINUS_DIAGONAL, MINUS_TWO_THIRDS_PI).toTransformMatrix());
+        assertTransformEquals(StandardRotations.PLUS_DIAGONAL_TWO_THIRDS_PI, QuaternionRotation.fromAxisAngle(PLUS_DIAGONAL, TWO_THIRDS_PI).toMatrix());
+        assertTransformEquals(StandardRotations.PLUS_DIAGONAL_TWO_THIRDS_PI, QuaternionRotation.fromAxisAngle(MINUS_DIAGONAL, MINUS_TWO_THIRDS_PI).toMatrix());
 
-        assertTransformEquals(StandardRotations.MINUS_DIAGONAL_TWO_THIRDS_PI, QuaternionRotation.fromAxisAngle(MINUS_DIAGONAL, TWO_THIRDS_PI).toTransformMatrix());
-        assertTransformEquals(StandardRotations.MINUS_DIAGONAL_TWO_THIRDS_PI, QuaternionRotation.fromAxisAngle(PLUS_DIAGONAL, MINUS_TWO_THIRDS_PI).toTransformMatrix());
+        assertTransformEquals(StandardRotations.MINUS_DIAGONAL_TWO_THIRDS_PI, QuaternionRotation.fromAxisAngle(MINUS_DIAGONAL, TWO_THIRDS_PI).toMatrix());
+        assertTransformEquals(StandardRotations.MINUS_DIAGONAL_TWO_THIRDS_PI, QuaternionRotation.fromAxisAngle(PLUS_DIAGONAL, MINUS_TWO_THIRDS_PI).toMatrix());
     }
 
     @Test
@@ -1571,6 +1588,8 @@
         Assert.assertEquals(msg, x, q.getX(), EPS);
         Assert.assertEquals(msg, y, q.getY(), EPS);
         Assert.assertEquals(msg, z, q.getZ(), EPS);
+
+        Assert.assertTrue(qrot.preservesOrientation());
     }
 
     private static void checkVector(Vector3D v, double x, double y, double z) {
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/AbstractSegmentConnectorTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/AbstractSegmentConnectorTest.java
new file mode 100644
index 0000000..500d5f1
--- /dev/null
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/AbstractSegmentConnectorTest.java
@@ -0,0 +1,525 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.twod;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Random;
+
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class AbstractSegmentConnectorTest {
+
+    private static final double TEST_EPS = 1e-10;
+
+    private static final DoublePrecisionContext TEST_PRECISION =
+            new EpsilonDoublePrecisionContext(TEST_EPS);
+
+    private static final Line Y_AXIS = Line.fromPointAndAngle(Vector2D.ZERO, Geometry.HALF_PI,
+            TEST_PRECISION);
+
+    private TestConnector connector = new TestConnector();
+
+    @Test
+    public void testConnectAll_emptyCollection() {
+        // act
+        List<Polyline> paths = connector.connectAll(Collections.emptyList());
+
+        // assert
+        Assert.assertEquals(0, paths.size());
+    }
+
+    @Test
+    public void testConnectAll_singleInfiniteLine() {
+        // arrange
+        Segment segment = Y_AXIS.span();
+
+        // act
+        List<Polyline> paths = connector.connectAll(Arrays.asList(segment));
+
+        // assert
+        Assert.assertEquals(1, paths.size());
+
+        Polyline path = paths.get(0);
+        Assert.assertEquals(1, path.getSegments().size());
+        Assert.assertSame(segment, path.getStartSegment());
+    }
+
+    @Test
+    public void testConnectAll_singleHalfInfiniteLine_noEndPoint() {
+        // arrange
+        Segment segment = Y_AXIS.segmentFrom(Vector2D.ZERO);
+
+        // act
+        List<Polyline> paths = connector.connectAll(Arrays.asList(segment));
+
+        // assert
+        Assert.assertEquals(1, paths.size());
+
+        Polyline path = paths.get(0);
+        Assert.assertEquals(1, path.getSegments().size());
+        Assert.assertSame(segment, path.getStartSegment());
+    }
+
+    @Test
+    public void testConnectAll_singleHalfInfiniteLine_noStartPoint() {
+        // arrange
+        Segment segment = Y_AXIS.segmentTo(Vector2D.ZERO);
+
+        // act
+        List<Polyline> paths = connector.connectAll(Arrays.asList(segment));
+
+        // assert
+        Assert.assertEquals(1, paths.size());
+
+        Polyline path = paths.get(0);
+        Assert.assertEquals(1, path.getSegments().size());
+        Assert.assertSame(segment, path.getStartSegment());
+    }
+
+    @Test
+    public void testConnectAll_disjointSegments() {
+        // arrange
+        Segment a = Y_AXIS.segment(Vector2D.of(0, 1), Vector2D.of(0, 2));
+        Segment b = Y_AXIS.segment(Vector2D.of(0, -1), Vector2D.ZERO);
+
+        List<Segment> segments = Arrays.asList(a, b);
+
+        // act
+        List<Polyline> paths = connector.connectAll(segments);
+
+        // assert
+        Assert.assertEquals(2, paths.size());
+
+        assertFinitePath(paths.get(0), Vector2D.of(0, -1), Vector2D.ZERO);
+        assertFinitePath(paths.get(1), Vector2D.of(0, 1), Vector2D.of(0, 2));
+    }
+
+    @Test
+    public void testConnectAll_singleClosedPath() {
+        // arrange
+        Polyline input = Polyline.builder(TEST_PRECISION)
+                .appendVertices(Vector2D.of(1, 1), Vector2D.ZERO, Vector2D.of(1, 0))
+                .close();
+
+        List<Segment> segments = new ArrayList<>(input.getSegments());
+        shuffle(segments);
+
+        // act
+        List<Polyline> paths = connector.connectAll(segments);
+
+        // assert
+        Assert.assertEquals(1, paths.size());
+
+        assertFinitePath(paths.get(0),
+                Vector2D.ZERO, Vector2D.of(1, 0), Vector2D.of(1, 1), Vector2D.ZERO);
+    }
+
+    @Test
+    public void testConnectAll_multipleClosedPaths() {
+        // arrange
+        Polyline a = Polyline.builder(TEST_PRECISION)
+                .appendVertices(Vector2D.of(1, 1), Vector2D.ZERO, Vector2D.of(1, 0))
+                .close();
+
+        Polyline b = Polyline.builder(TEST_PRECISION)
+                .appendVertices(Vector2D.of(0, 1), Vector2D.of(-1, 0), Vector2D.of(-0.5, 0))
+                .close();
+
+        Polyline c = Polyline.builder(TEST_PRECISION)
+                .appendVertices(Vector2D.of(1, 3), Vector2D.of(0, 2), Vector2D.of(1, 2))
+                .close();
+
+        List<Segment> segments = new ArrayList<>();
+        segments.addAll(a.getSegments());
+        segments.addAll(b.getSegments());
+        segments.addAll(c.getSegments());
+
+        shuffle(segments);
+
+        // act
+        List<Polyline> paths = connector.connectAll(segments);
+
+        // assert
+        Assert.assertEquals(3, paths.size());
+
+        assertFinitePath(paths.get(0),
+                Vector2D.of(-1, 0), Vector2D.of(-0.5, 0), Vector2D.of(0, 1), Vector2D.of(-1, 0));
+
+        assertFinitePath(paths.get(1),
+                Vector2D.ZERO, Vector2D.of(1, 0), Vector2D.of(1, 1), Vector2D.ZERO);
+
+        assertFinitePath(paths.get(2),
+                Vector2D.of(0, 2), Vector2D.of(1, 2), Vector2D.of(1, 3), Vector2D.of(0, 2));
+    }
+
+    @Test
+    public void testConnectAll_singleOpenPath() {
+        // arrange
+        Polyline input = Polyline.builder(TEST_PRECISION)
+                .appendVertices(Vector2D.of(1, 1), Vector2D.ZERO, Vector2D.of(1, 0))
+                .build();
+
+        List<Segment> segments = new ArrayList<>(input.getSegments());
+        shuffle(segments);
+
+        // act
+        List<Polyline> paths = connector.connectAll(segments);
+
+        // assert
+        Assert.assertEquals(1, paths.size());
+
+        assertFinitePath(paths.get(0),
+                Vector2D.of(1, 1), Vector2D.ZERO, Vector2D.of(1, 0));
+    }
+
+    @Test
+    public void testConnectAll_mixOfOpenConnectedAndInfinite() {
+        // arrange
+        Segment inputYInf = Y_AXIS.segmentTo(Vector2D.ZERO);
+        Segment inputXInf = Line.fromPoints(Vector2D.ZERO, Vector2D.Unit.MINUS_X, TEST_PRECISION)
+                .segmentFrom(Vector2D.ZERO);
+
+        Polyline closedPath = Polyline.builder(TEST_PRECISION)
+                .appendVertices(Vector2D.of(0, 2), Vector2D.of(1, 2), Vector2D.of(1, 3))
+                .close();
+
+        Polyline openPath = Polyline.builder(TEST_PRECISION)
+                .appendVertices(Vector2D.of(-1, 3), Vector2D.of(0, 1), Vector2D.of(1, 1))
+                .build();
+
+        List<Segment> segments = new ArrayList<>();
+        segments.add(inputYInf);
+        segments.add(inputXInf);
+        segments.addAll(closedPath.getSegments());
+        segments.addAll(openPath.getSegments());
+
+        shuffle(segments);
+
+        // act
+        List<Polyline> paths = connector.connectAll(segments);
+
+        // assert
+        Assert.assertEquals(3, paths.size());
+
+        assertFinitePath(paths.get(0),
+                Vector2D.of(-1, 3), Vector2D.of(0, 1), Vector2D.of(1, 1));
+
+        Polyline infPath = paths.get(1);
+        Assert.assertTrue(infPath.isInfinite());
+        Assert.assertEquals(2, infPath.getSegments().size());
+        Assert.assertSame(inputYInf, infPath.getSegments().get(0));
+        Assert.assertSame(inputXInf, infPath.getSegments().get(1));
+
+        assertFinitePath(paths.get(2),
+                Vector2D.of(0, 2), Vector2D.of(1, 2), Vector2D.of(1, 3), Vector2D.of(0, 2));
+    }
+
+    @Test
+    public void testConnectAll_pathWithSinglePoint() {
+        // arrange
+        Vector2D p0 = Vector2D.ZERO;
+
+        List<Segment> segments = Arrays.asList(Line.fromPointAndAngle(p0, 0, TEST_PRECISION).segment(p0, p0));
+
+        // act
+        List<Polyline> paths = connector.connectAll(segments);
+
+        // assert
+        Assert.assertEquals(1, paths.size());
+
+        assertFinitePath(paths.get(0), p0, p0);
+    }
+
+    @Test
+    public void testConnectAll_pathWithPointLikeConnectedSegments() {
+        // arrange
+        Vector2D p0 = Vector2D.ZERO;
+        Vector2D p1 = Vector2D.of(1, 0);
+        Vector2D p2 = Vector2D.of(1, 1);
+
+        Vector2D almostP0 = Vector2D.of(-1e-20, -1e-20);
+        Vector2D almostP1 = Vector2D.of(1 - 1e-15, 0);
+
+        Polyline input = Polyline.builder(TEST_PRECISION)
+                .appendVertices(p0, p1)
+                .append(Line.fromPointAndAngle(p1, 0.25 * Geometry.PI, TEST_PRECISION).segment(p1, p1))
+                .append(Line.fromPointAndAngle(p1, -0.25 * Geometry.PI, TEST_PRECISION).segment(almostP1, almostP1))
+                .append(p2)
+                .append(p0)
+                .append(Line.fromPointAndAngle(Vector2D.ZERO, Geometry.MINUS_HALF_PI, TEST_PRECISION)
+                        .segment(almostP0, almostP0))
+                .build();
+
+        List<Segment> segments = new ArrayList<>(input.getSegments());
+        shuffle(segments);
+
+        // act
+        List<Polyline> paths = connector.connectAll(segments);
+
+        // assert
+        Assert.assertEquals(1, paths.size());
+
+        assertFinitePath(paths.get(0), p0, p1, almostP1, p1, p2, p0, almostP0);
+    }
+
+    @Test
+    public void testConnectAll_flatLineRegion() {
+        // arrange
+        Vector2D p0 = Vector2D.ZERO;
+        Vector2D p1 = Vector2D.of(1, 0);
+
+        Segment seg0 = Segment.fromPoints(p0, p1, TEST_PRECISION);
+        Segment seg1 = Segment.fromPoints(p1, p0, TEST_PRECISION);
+        Segment seg2 = Line.fromPointAndAngle(p1, Geometry.HALF_PI, TEST_PRECISION).segment(p1, p1);
+        Segment seg3 = Line.fromPointAndAngle(p0, Geometry.MINUS_HALF_PI, TEST_PRECISION).segment(p0, p0);
+
+        List<Segment> segments = new ArrayList<>(Arrays.asList(seg0, seg1, seg2, seg3));
+        shuffle(segments);
+
+        // act
+        List<Polyline> paths = connector.connectAll(segments);
+
+        // assert
+        Assert.assertEquals(1, paths.size());
+
+        Polyline path = paths.get(0);
+        Assert.assertSame(seg0, path.getSegments().get(0));
+        Assert.assertSame(seg2, path.getSegments().get(1));
+        Assert.assertSame(seg1, path.getSegments().get(2));
+        Assert.assertSame(seg3, path.getSegments().get(3));
+    }
+
+    @Test
+    public void testConnectAll_singlePointRegion() {
+        // arrange
+        Vector2D p0 = Vector2D.of(1, 0);
+
+        Segment seg0 = Line.fromPointAndAngle(p0, Geometry.ZERO_PI, TEST_PRECISION).segment(p0, p0);
+        Segment seg1 = Line.fromPointAndAngle(p0, Geometry.HALF_PI, TEST_PRECISION).segment(p0, p0);
+        Segment seg2 = Line.fromPointAndAngle(p0, Geometry.PI, TEST_PRECISION).segment(p0, p0);
+        Segment seg3 = Line.fromPointAndAngle(p0, Geometry.MINUS_HALF_PI, TEST_PRECISION).segment(p0, p0);
+
+        List<Segment> segments = new ArrayList<>(Arrays.asList(seg0, seg1, seg2, seg3));
+        shuffle(segments);
+
+        // act
+        List<Polyline> paths = connector.connectAll(segments);
+
+        // assert
+        Assert.assertEquals(1, paths.size());
+
+        Polyline path = paths.get(0);
+        Assert.assertSame(seg2, path.getSegments().get(0));
+        Assert.assertSame(seg3, path.getSegments().get(1));
+        Assert.assertSame(seg0, path.getSegments().get(2));
+        Assert.assertSame(seg1, path.getSegments().get(3));
+    }
+
+    @Test
+    public void testConnectAll_pathWithPointLikeUnconnectedSegments() {
+        // arrange
+        Vector2D p0 = Vector2D.ZERO;
+        Vector2D p1 = Vector2D.of(1, 0);
+
+        Segment seg0 = Line.fromPointAndAngle(p1, Geometry.ZERO_PI, TEST_PRECISION).segment(p1, p1);
+        Segment seg1 = Line.fromPointAndAngle(p1, 0.25 * Geometry.PI, TEST_PRECISION).segment(p1, p1);
+        Segment seg2 = Line.fromPointAndAngle(p0, 0, TEST_PRECISION).segment(p0, p0);
+
+        List<Segment> segments = new ArrayList<>(Arrays.asList(seg0, seg1, seg2));
+
+        shuffle(segments);
+
+        // act
+        List<Polyline> paths = connector.connectAll(segments);
+
+        // assert
+        Assert.assertEquals(2, paths.size());
+
+        Polyline path0 = paths.get(0);
+        Assert.assertEquals(1, path0.getSegments().size());
+        Assert.assertSame(seg2, path0.getSegments().get(0));
+
+        Polyline path1 = paths.get(1);
+        Assert.assertEquals(2, path1.getSegments().size());
+        Assert.assertSame(seg0, path1.getSegments().get(0));
+        Assert.assertSame(seg1, path1.getSegments().get(1));
+    }
+
+    @Test
+    public void testConnectAll_pathStartingWithPoint() {
+        // arrange
+        Vector2D p0 = Vector2D.ZERO;
+        Vector2D p1 = Vector2D.of(1, 0);
+        Vector2D p2 = Vector2D.of(1, 1);
+
+        Segment seg0 = Line.fromPointAndAngle(p0, Geometry.PI, TEST_PRECISION).segment(p0, p0);
+        Segment seg1 = Segment.fromPoints(p0, p1, TEST_PRECISION);
+        Segment seg2 = Segment.fromPoints(p1, p2, TEST_PRECISION);
+
+        List<Segment> segments = new ArrayList<>(Arrays.asList(seg0, seg1, seg2));
+
+        shuffle(segments);
+
+        // act
+        List<Polyline> paths = connector.connectAll(segments);
+
+        // assert
+        Assert.assertEquals(1, paths.size());
+
+        Polyline path = paths.get(0);
+        Assert.assertSame(seg0, path.getSegments().get(0));
+        Assert.assertSame(seg1, path.getSegments().get(1));
+        Assert.assertSame(seg2, path.getSegments().get(2));
+    }
+
+    @Test
+    public void testConnectAll_intersectingPaths() {
+        // arrange
+        Polyline a = Polyline.builder(TEST_PRECISION)
+                .appendVertices(Vector2D.of(-1, 1), Vector2D.of(0.5, 0), Vector2D.of(-1, -1))
+                .build();
+
+        Polyline b = Polyline.builder(TEST_PRECISION)
+                .appendVertices(Vector2D.of(1, 1), Vector2D.of(-0.5, 0), Vector2D.of(1, -1))
+                .build();
+
+        List<Segment> segments = new ArrayList<>();
+        segments.addAll(a.getSegments());
+        segments.addAll(b.getSegments());
+
+        shuffle(segments);
+
+        // act
+        List<Polyline> paths = connector.connectAll(segments);
+
+        // assert
+        Assert.assertEquals(2, paths.size());
+
+        assertFinitePath(paths.get(0),
+                Vector2D.of(-1, 1), Vector2D.of(0.5, 0), Vector2D.of(-1, -1));
+
+        assertFinitePath(paths.get(1),
+                Vector2D.of(1, 1), Vector2D.of(-0.5, 0), Vector2D.of(1, -1));
+    }
+
+    @Test
+    public void testInstancesCanBeReused() {
+        // arrange
+        Segment a = Segment.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_X, TEST_PRECISION);
+        Segment b = Segment.fromPoints(Vector2D.Unit.PLUS_X, Vector2D.Unit.PLUS_Y, TEST_PRECISION);
+
+        // act
+        List<Polyline> firstPaths = connector.connectAll(Arrays.asList(a));
+        List<Polyline> secondPaths = connector.connectAll(Arrays.asList(b));
+
+        // assert
+        Assert.assertEquals(1, firstPaths.size());
+        Assert.assertEquals(1, secondPaths.size());
+
+        Assert.assertSame(a, firstPaths.get(0).getSegments().get(0));
+        Assert.assertSame(b, secondPaths.get(0).getSegments().get(0));
+    }
+
+    @Test
+    public void testAdd() {
+        // arrange
+        Segment a = Segment.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_X, TEST_PRECISION);
+        Segment b = Segment.fromPoints(Vector2D.Unit.PLUS_X, Vector2D.of(1, 1), TEST_PRECISION);
+        Segment c = Segment.fromPoints(Vector2D.Unit.PLUS_X, Vector2D.of(2, 0), TEST_PRECISION);
+
+        // act
+        connector.add(Arrays.asList(a, b));
+        connector.add(Arrays.asList(c));
+
+        List<Polyline> paths = connector.connectAll();
+
+        // assert
+        Assert.assertEquals(2, paths.size());
+
+        assertFinitePath(paths.get(0), Vector2D.ZERO, Vector2D.Unit.PLUS_X, Vector2D.of(2, 0));
+        assertFinitePath(paths.get(1), Vector2D.Unit.PLUS_X, Vector2D.of(1, 1));
+    }
+
+    @Test
+    public void testConnect() {
+        // arrange
+        Segment a = Segment.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_X, TEST_PRECISION);
+        Segment b = Segment.fromPoints(Vector2D.Unit.PLUS_X, Vector2D.of(1, 1), TEST_PRECISION);
+        Segment c = Segment.fromPoints(Vector2D.Unit.PLUS_X, Vector2D.of(2, 0), TEST_PRECISION);
+
+        // act
+        connector.connect(Arrays.asList(a, b));
+        connector.connect(Arrays.asList(c));
+
+        List<Polyline> paths = connector.connectAll();
+
+        // assert
+        Assert.assertEquals(2, paths.size());
+
+        assertFinitePath(paths.get(0), Vector2D.ZERO, Vector2D.Unit.PLUS_X, Vector2D.of(1, 1));
+        assertFinitePath(paths.get(1), Vector2D.Unit.PLUS_X, Vector2D.of(2, 0));
+    }
+
+    private static List<Segment> shuffle(final List<Segment> segments) {
+        return shuffle(segments, 1);
+    }
+
+    private static List<Segment> shuffle(final List<Segment> segments, final int seed) {
+        Collections.shuffle(segments, new Random(seed));
+
+        return segments;
+    }
+
+    private static void assertFinitePath(Polyline path, Vector2D ... vertices)
+    {
+        Assert.assertFalse(path.isInfinite());
+        Assert.assertTrue(path.isFinite());
+
+        assertPathVertices(path, vertices);
+    }
+
+    private static void assertPathVertices(Polyline path, Vector2D ... vertices) {
+        List<Vector2D> expectedVertices = Arrays.asList(vertices);
+        List<Vector2D> actualVertices = path.getVertices();
+
+        String msg = "Expected path vertices to equal " + expectedVertices + " but was " + actualVertices;
+        Assert.assertEquals(msg, expectedVertices.size(), actualVertices.size());
+
+        for (int i=0; i<expectedVertices.size(); ++i) {
+            EuclideanTestUtils.assertCoordinatesEqual(expectedVertices.get(i), actualVertices.get(i), TEST_EPS);
+        }
+    }
+
+    private static class TestConnector extends AbstractSegmentConnector {
+
+        private static final long serialVersionUID = 1L;
+
+        @Override
+        protected ConnectableSegment selectConnection(ConnectableSegment incoming, List<ConnectableSegment> outgoing) {
+            // just choose the first element
+            return outgoing.get(0);
+        }
+    }
+}
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/AffineTransformMatrix2DTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/AffineTransformMatrix2DTest.java
index fd819b4..76445e0 100644
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/AffineTransformMatrix2DTest.java
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/AffineTransformMatrix2DTest.java
@@ -632,6 +632,60 @@
     }
 
     @Test
+    public void testDeterminant() {
+        // act/assert
+        Assert.assertEquals(1.0, AffineTransformMatrix2D.identity().determinant(), EPS);
+        Assert.assertEquals(6.0, AffineTransformMatrix2D.of(
+                2, 0, 4,
+                0, 3, 5
+            ).determinant(), EPS);
+        Assert.assertEquals(-6.0, AffineTransformMatrix2D.of(
+                2, 0, 4,
+                0, -3, 5
+            ).determinant(), EPS);
+        Assert.assertEquals(-5.0, AffineTransformMatrix2D.of(
+                1, 3, 0,
+                2, 1, 0
+            ).determinant(), EPS);
+        Assert.assertEquals(-0.0, AffineTransformMatrix2D.of(
+                0, 0, 1,
+                0, 0, 2
+            ).determinant(), EPS);
+    }
+
+    @Test
+    public void testPreservesOrientation() {
+        // act/assert
+        Assert.assertTrue(AffineTransformMatrix2D.identity().preservesOrientation());
+        Assert.assertTrue(AffineTransformMatrix2D.of(
+                2, 0, 4,
+                0, 3, 5
+            ).preservesOrientation());
+
+        Assert.assertFalse(AffineTransformMatrix2D.of(
+                2, 0, 4,
+                0, -3, 5
+            ).preservesOrientation());
+        Assert.assertFalse(AffineTransformMatrix2D.of(
+                1, 3, 0,
+                2, 1, 0
+            ).preservesOrientation());
+        Assert.assertFalse(AffineTransformMatrix2D.of(
+                0, 0, 1,
+                0, 0, 2
+            ).preservesOrientation());
+    }
+
+    @Test
+    public void testToMatrix() {
+        // arrange
+        AffineTransformMatrix2D t = AffineTransformMatrix2D.createScale(2.0);
+
+        // act/assert
+        Assert.assertSame(t, t.toMatrix());
+    }
+
+    @Test
     public void testMultiply() {
         // arrange
         AffineTransformMatrix2D a = AffineTransformMatrix2D.of(
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/ConvexAreaTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/ConvexAreaTest.java
new file mode 100644
index 0000000..b6666ab
--- /dev/null
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/ConvexAreaTest.java
@@ -0,0 +1,1210 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.twod;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.GeometryTestUtils;
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.exception.GeometryException;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.SplitLocation;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class ConvexAreaTest {
+
+    private static final double TEST_EPS = 1e-10;
+
+    private static final DoublePrecisionContext TEST_PRECISION =
+            new EpsilonDoublePrecisionContext(TEST_EPS);
+
+    @Test
+    public void testFull() {
+        // act
+        ConvexArea area = ConvexArea.full();
+
+        // assert
+        Assert.assertTrue(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+
+        Assert.assertEquals(0.0, area.getBoundarySize(), TEST_EPS);
+        GeometryTestUtils.assertPositiveInfinity(area.getSize());
+        Assert.assertNull(area.getBarycenter());
+    }
+
+    @Test
+    public void testToTree() {
+        // arrange
+        ConvexArea area = ConvexArea.fromBounds(
+                    Line.fromPointAndAngle(Vector2D.ZERO, Geometry.ZERO_PI, TEST_PRECISION),
+                    Line.fromPointAndAngle(Vector2D.of(1, 0), Geometry.HALF_PI, TEST_PRECISION),
+                    Line.fromPointAndAngle(Vector2D.of(1, 1), Geometry.PI, TEST_PRECISION),
+                    Line.fromPointAndAngle(Vector2D.of(0, 1), Geometry.MINUS_HALF_PI, TEST_PRECISION)
+                );
+
+        // act
+        RegionBSPTree2D tree = area.toTree();
+
+        // assert
+        Assert.assertEquals(1, tree.getSize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(0.5, 0.5), tree.getBarycenter(), TEST_EPS);
+    }
+
+    @Test
+    public void testTransform_full() {
+        // arrange
+        Transform2D transform = FunctionTransform2D.from(v -> v.multiply(3));
+        ConvexArea area = ConvexArea.full();
+
+        // act
+        ConvexArea transformed = area.transform(transform);
+
+        // assert
+        Assert.assertSame(area, transformed);
+    }
+
+    @Test
+    public void testTransform_infinite() {
+        // arrange
+        AffineTransformMatrix2D mat = AffineTransformMatrix2D
+                .createRotation(Vector2D.of(0, 1), Geometry.HALF_PI)
+                .scale(Vector2D.of(3, 2));
+
+        ConvexArea area = ConvexArea.fromBounds(
+                Line.fromPointAndAngle(Vector2D.ZERO, 0.25 * Geometry.PI, TEST_PRECISION),
+                Line.fromPointAndAngle(Vector2D.ZERO, -0.25 * Geometry.PI, TEST_PRECISION));
+
+        // act
+        ConvexArea transformed = area.transform(mat);
+
+        // assert
+        Assert.assertNotSame(area, transformed);
+
+        List<Polyline> paths = transformed.getBoundaryPaths();
+        Assert.assertEquals(1, paths.size());
+
+        List<Segment> segments = paths.get(0).getSegments();
+        Assert.assertEquals(2, segments.size());
+
+        Segment firstSegment = segments.get(0);
+        Assert.assertNull(firstSegment.getStartPoint());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(3, 2), firstSegment.getEndPoint(), TEST_EPS);
+        Assert.assertEquals(Math.atan2(2, 3), firstSegment.getLine().getAngle(), TEST_EPS);
+
+        Segment secondSegment = segments.get(1);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(3, 2), secondSegment.getStartPoint(), TEST_EPS);
+        Assert.assertNull(secondSegment.getEndPoint());
+        Assert.assertEquals(Math.atan2(2, -3), secondSegment.getLine().getAngle(), TEST_EPS);
+    }
+
+    @Test
+    public void testTransform_finite() {
+        // arrange
+        AffineTransformMatrix2D mat = AffineTransformMatrix2D.createScale(Vector2D.of(1, 2));
+
+        ConvexArea area = ConvexArea.fromVertexLoop(Arrays.asList(
+                    Vector2D.of(1, 1), Vector2D.of(2, 1),
+                    Vector2D.of(2, 2), Vector2D.of(1, 2)
+                ), TEST_PRECISION);
+
+        // act
+        ConvexArea transformed = area.transform(mat);
+
+        // assert
+        Assert.assertNotSame(area, transformed);
+
+        List<Segment> segments = transformed.getBoundaries();
+        Assert.assertEquals(4, segments.size());
+
+        Assert.assertEquals(2, transformed.getSize(), TEST_EPS);
+        Assert.assertEquals(6, transformed.getBoundarySize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1.5, 3), transformed.getBarycenter(), TEST_EPS);
+
+        checkRegion(transformed, RegionLocation.BOUNDARY,
+                Vector2D.of(1, 2), Vector2D.of(2, 2), Vector2D.of(2, 4), Vector2D.of(1, 4));
+        checkRegion(transformed, RegionLocation.INSIDE, transformed.getBarycenter());
+    }
+
+    @Test
+    public void testTransform_finite_withSingleReflection() {
+        // arrange
+        AffineTransformMatrix2D mat = AffineTransformMatrix2D.createScale(Vector2D.of(-1, 2));
+
+        ConvexArea area = ConvexArea.fromVertexLoop(Arrays.asList(
+                    Vector2D.of(1, 1), Vector2D.of(2, 1),
+                    Vector2D.of(2, 2), Vector2D.of(1, 2)
+                ), TEST_PRECISION);
+
+        // act
+        ConvexArea transformed = area.transform(mat);
+
+        // assert
+        Assert.assertNotSame(area, transformed);
+
+        List<Segment> segments = transformed.getBoundaries();
+        Assert.assertEquals(4, segments.size());
+
+        Assert.assertEquals(2, transformed.getSize(), TEST_EPS);
+        Assert.assertEquals(6, transformed.getBoundarySize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(-1.5, 3), transformed.getBarycenter(), TEST_EPS);
+
+        checkRegion(transformed, RegionLocation.BOUNDARY,
+                Vector2D.of(-1, 2), Vector2D.of(-2, 2), Vector2D.of(-2, 4), Vector2D.of(-1, 4));
+        checkRegion(transformed, RegionLocation.INSIDE, transformed.getBarycenter());
+    }
+
+    @Test
+    public void testTransform_finite_withDoubleReflection() {
+        // arrange
+        AffineTransformMatrix2D mat = AffineTransformMatrix2D.createScale(Vector2D.of(-1, -2));
+
+        ConvexArea area = ConvexArea.fromVertexLoop(Arrays.asList(
+                    Vector2D.of(1, 1), Vector2D.of(2, 1),
+                    Vector2D.of(2, 2), Vector2D.of(1, 2)
+                ), TEST_PRECISION);
+
+        // act
+        ConvexArea transformed = area.transform(mat);
+
+        // assert
+        Assert.assertNotSame(area, transformed);
+
+        List<Segment> segments = transformed.getBoundaries();
+        Assert.assertEquals(4, segments.size());
+
+        Assert.assertEquals(2, transformed.getSize(), TEST_EPS);
+        Assert.assertEquals(6, transformed.getBoundarySize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(-1.5, -3), transformed.getBarycenter(), TEST_EPS);
+
+        checkRegion(transformed, RegionLocation.BOUNDARY,
+                Vector2D.of(-1, -2), Vector2D.of(-2, -2), Vector2D.of(-2, -4), Vector2D.of(-1, -4));
+        checkRegion(transformed, RegionLocation.INSIDE, transformed.getBarycenter());
+    }
+
+    @Test
+    public void testGetVertices_full() {
+        // arrange
+        ConvexArea area = ConvexArea.full();
+
+        // act/assert
+        Assert.assertEquals(0, area.getVertices().size());
+    }
+
+    @Test
+    public void testGetVertices_twoParallelLines() {
+        // arrange
+        ConvexArea area = ConvexArea.fromBounds(
+                    Line.fromPointAndAngle(Vector2D.of(0, 1), Geometry.PI, TEST_PRECISION),
+                    Line.fromPointAndAngle(Vector2D.of(0, -1), Geometry.ZERO_PI, TEST_PRECISION)
+                );
+
+        // act/assert
+        Assert.assertEquals(0, area.getVertices().size());
+    }
+
+    @Test
+    public void testGetVertices_infiniteWithVertices() {
+        // arrange
+        ConvexArea area = ConvexArea.fromBounds(
+                    Line.fromPointAndAngle(Vector2D.of(0, 1), Geometry.PI, TEST_PRECISION),
+                    Line.fromPointAndAngle(Vector2D.of(0, -1), Geometry.ZERO_PI, TEST_PRECISION),
+                    Line.fromPointAndAngle(Vector2D.of(1, 0), Geometry.HALF_PI, TEST_PRECISION)
+                );
+
+        // act
+        List<Vector2D> vertices = area.getVertices();
+
+        // assert
+        Assert.assertEquals(2, vertices.size());
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1, -1), vertices.get(0), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1, 1), vertices.get(1), TEST_EPS);
+    }
+
+    @Test
+    public void testGetVertices_finite() {
+        // arrange
+        ConvexArea area = ConvexArea.fromVertexLoop(Arrays.asList(
+                    Vector2D.ZERO,
+                    Vector2D.Unit.PLUS_X,
+                    Vector2D.Unit.PLUS_Y
+                ), TEST_PRECISION);
+
+        // act
+        List<Vector2D> vertices = area.getVertices();
+
+        // assert
+        Assert.assertEquals(4, vertices.size());
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.ZERO, vertices.get(0), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.Unit.PLUS_X, vertices.get(1), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.Unit.PLUS_Y, vertices.get(2), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.ZERO, vertices.get(3), TEST_EPS);
+    }
+
+    @Test
+    public void testProject_full() {
+        // arrange
+        ConvexArea area = ConvexArea.full();
+
+        // act/assert
+        Assert.assertNull(area.project(Vector2D.ZERO));
+        Assert.assertNull(area.project(Vector2D.Unit.PLUS_X));
+    }
+
+    @Test
+    public void testProject_halfSpace() {
+        // arrange
+        ConvexArea area = ConvexArea.fromBounds(
+                Line.fromPointAndAngle(Vector2D.ZERO, Geometry.HALF_PI, TEST_PRECISION));
+
+        // act/assert
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(0, 1), area.project(Vector2D.of(1, 1)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(0, 2), area.project(Vector2D.of(-2, 2)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(0, -3), area.project(Vector2D.of(1, -3)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(0, -4), area.project(Vector2D.of(-2, -4)), TEST_EPS);
+    }
+
+    @Test
+    public void testProject_square() {
+        // arrange
+        ConvexArea area = ConvexArea.fromBounds(createSquareBoundingLines(Vector2D.ZERO, 1, 1));
+
+        // act/assert
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1, 1), area.project(Vector2D.of(1, 1)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1, 1), area.project(Vector2D.of(2, 2)), TEST_EPS);
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.ZERO, area.project(Vector2D.ZERO), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.ZERO, area.project(Vector2D.of(-1, -1)), TEST_EPS);
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(0, 0.5), area.project(Vector2D.of(0.1, 0.5)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(0.2, 1), area.project(Vector2D.of(0.2, 0.9)), TEST_EPS);
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(0.5, 0), area.project(Vector2D.of(0.5, 0.5)), TEST_EPS);
+    }
+
+    @Test
+    public void testTrim_full() {
+        // arrange
+        ConvexArea area = ConvexArea.full();
+        Segment segment = Segment.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_Y, TEST_PRECISION);
+
+        // act
+        Segment trimmed = area.trim(segment);
+
+        // assert
+        Assert.assertSame(segment, trimmed);
+    }
+
+    @Test
+    public void testTrim_halfSpace() {
+        // arrange
+        ConvexArea area = ConvexArea.fromBounds(Line.fromPointAndAngle(Vector2D.ZERO, Geometry.ZERO_PI, TEST_PRECISION));
+        Segment segment = Line.fromPoints(Vector2D.Unit.MINUS_Y, Vector2D.Unit.PLUS_Y, TEST_PRECISION).span();
+
+        // act
+        Segment trimmed = area.trim(segment);
+
+        // assert
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.ZERO, trimmed.getStartPoint(), TEST_EPS);
+        GeometryTestUtils.assertPositiveInfinity(trimmed.getSubspaceEnd());
+    }
+
+    @Test
+    public void testTrim_square() {
+        // arrange
+        ConvexArea area = ConvexArea.fromBounds(createSquareBoundingLines(Vector2D.ZERO, 1, 1));
+        Segment segment = Line.fromPoints(Vector2D.of(0.5, 0), Vector2D.of(0.5, 1), TEST_PRECISION).span();
+
+        // act
+        Segment trimmed = area.trim(segment);
+
+        // assert
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(0.5, 0), trimmed.getStartPoint(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(0.5, 1), trimmed.getEndPoint(), TEST_EPS);
+    }
+
+    @Test
+    public void testTrim_segmentOutsideOfRegion() {
+        // arrange
+        ConvexArea area = ConvexArea.fromBounds(createSquareBoundingLines(Vector2D.ZERO, 1, 1));
+        Segment segment = Line.fromPoints(Vector2D.of(-0.5, 0), Vector2D.of(-0.5, 1), TEST_PRECISION).span();
+
+        // act
+        Segment trimmed = area.trim(segment);
+
+        // assert
+        Assert.assertNull(trimmed);
+    }
+
+    @Test
+    public void testTrim_segmentDirectlyOnBoundaryOfRegion() {
+        // arrange
+        ConvexArea area = ConvexArea.fromBounds(createSquareBoundingLines(Vector2D.ZERO, 1, 1));
+        Segment segment = Line.fromPoints(Vector2D.of(1, 0), Vector2D.of(1, 1), TEST_PRECISION).span();
+
+        // act
+        Segment trimmed = area.trim(segment);
+
+        // assert
+        Assert.assertNull(trimmed);
+    }
+
+    @Test
+    public void testSplit_full() {
+        // arrange
+        ConvexArea input = ConvexArea.full();
+
+        Line splitter = Line.fromPointAndAngle(Vector2D.ZERO, Geometry.ZERO_PI, TEST_PRECISION);
+
+        // act
+        Split<ConvexArea> split = input.split(splitter);
+
+        // act
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        ConvexArea minus = split.getMinus();
+        Assert.assertFalse(minus.isFull());
+        Assert.assertFalse(minus.isEmpty());
+
+        GeometryTestUtils.assertPositiveInfinity(minus.getBoundarySize());
+        GeometryTestUtils.assertPositiveInfinity(minus.getSize());
+        Assert.assertNull(minus.getBarycenter());
+
+        List<Segment> minusSegments = minus.getBoundaries();
+        Assert.assertEquals(1,minusSegments.size());
+        Assert.assertEquals(splitter, minusSegments.get(0).getLine());
+
+        ConvexArea plus = split.getPlus();
+        Assert.assertFalse(plus.isFull());
+        Assert.assertFalse(plus.isEmpty());
+
+        GeometryTestUtils.assertPositiveInfinity(plus.getBoundarySize());
+        GeometryTestUtils.assertPositiveInfinity(plus.getSize());
+        Assert.assertNull(plus.getBarycenter());
+
+        List<Segment> plusSegments = plus.getBoundaries();
+        Assert.assertEquals(1, plusSegments.size());
+        Assert.assertEquals(splitter, plusSegments.get(0).getLine().reverse());
+    }
+
+    @Test
+    public void testSplit_halfSpace_split() {
+        // arrange
+        ConvexArea area = ConvexArea.fromBounds(Line.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_X, TEST_PRECISION));
+        Line splitter = Line.fromPointAndAngle(Vector2D.ZERO, 0.25 * Geometry.PI, TEST_PRECISION);
+
+        // act
+        Split<ConvexArea> split = area.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        ConvexArea minus = split.getMinus();
+        Assert.assertFalse(minus.isFull());
+        Assert.assertFalse(minus.isEmpty());
+
+        GeometryTestUtils.assertPositiveInfinity(minus.getBoundarySize());
+        GeometryTestUtils.assertPositiveInfinity(minus.getSize());
+        Assert.assertNull(minus.getBarycenter());
+
+        Assert.assertEquals(2, minus.getBoundaries().size());
+
+        ConvexArea plus = split.getPlus();
+        Assert.assertFalse(plus.isFull());
+        Assert.assertFalse(plus.isEmpty());
+
+        GeometryTestUtils.assertPositiveInfinity(plus.getBoundarySize());
+        GeometryTestUtils.assertPositiveInfinity(plus.getSize());
+        Assert.assertNull(plus.getBarycenter());
+
+        Assert.assertEquals(2, plus.getBoundaries().size());
+    }
+
+    @Test
+    public void testSplit_halfSpace_splitOnBoundary() {
+        // arrange
+        ConvexArea area = ConvexArea.fromBounds(Line.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_X, TEST_PRECISION));
+        Line splitter = Line.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_X, TEST_PRECISION);
+
+        // act
+        Split<ConvexArea> split = area.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.MINUS, split.getLocation());
+
+        Assert.assertSame(area, split.getMinus());
+        Assert.assertNull(split.getPlus());
+    }
+
+    @Test
+    public void testSplit_halfSpace_splitOnBoundaryWithReversedSplitter() {
+        // arrange
+        ConvexArea area = ConvexArea.fromBounds(Line.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_X, TEST_PRECISION));
+        Line splitter = Line.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_X, TEST_PRECISION).reverse();
+
+        // act
+        Split<ConvexArea> split = area.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.PLUS, split.getLocation());
+
+        Assert.assertNull(split.getMinus());
+        Assert.assertSame(area, split.getPlus());
+    }
+
+    @Test
+    public void testSplit_square_split() {
+        // arrange
+        ConvexArea area = ConvexArea.fromBounds(createSquareBoundingLines(Vector2D.of(1, 1), 2, 1));
+        Line splitter = Line.fromPointAndAngle(Vector2D.of(2, 1), Geometry.HALF_PI, TEST_PRECISION);
+
+        // act
+        Split<ConvexArea> split = area.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        ConvexArea minus = split.getMinus();
+        Assert.assertFalse(minus.isFull());
+        Assert.assertFalse(minus.isEmpty());
+
+        Assert.assertEquals(4, minus.getBoundarySize(), TEST_EPS);
+        Assert.assertEquals(1, minus.getSize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1.5, 1.5), minus.getBarycenter(), TEST_EPS);
+
+        Assert.assertEquals(4, minus.getBoundaries().size());
+
+        ConvexArea plus = split.getPlus();
+        Assert.assertFalse(plus.isFull());
+        Assert.assertFalse(plus.isEmpty());
+
+        Assert.assertEquals(4, plus.getBoundarySize(), TEST_EPS);
+        Assert.assertEquals(1, plus.getSize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(2.5, 1.5), plus.getBarycenter(), TEST_EPS);
+
+        Assert.assertEquals(4, plus.getBoundaries().size());
+    }
+
+    @Test
+    public void testSplit_square_splitOnVertices() {
+        // arrange
+        ConvexArea area = ConvexArea.fromBounds(createSquareBoundingLines(Vector2D.of(1, 1), 1, 1));
+        Line splitter = Line.fromPoints(Vector2D.of(1, 1), Vector2D.of(2, 2), TEST_PRECISION);
+
+        // act
+        Split<ConvexArea> split = area.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        ConvexArea minus = split.getMinus();
+        Assert.assertFalse(minus.isFull());
+        Assert.assertFalse(minus.isEmpty());
+
+        Assert.assertEquals(2 + Math.sqrt(2), minus.getBoundarySize(), TEST_EPS);
+        Assert.assertEquals(0.5, minus.getSize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(4.0 / 3.0, 5.0 / 3.0), minus.getBarycenter(), TEST_EPS);
+
+        Assert.assertEquals(3, minus.getBoundaries().size());
+
+        ConvexArea plus = split.getPlus();
+        Assert.assertFalse(plus.isFull());
+        Assert.assertFalse(plus.isEmpty());
+
+        Assert.assertEquals(2 + Math.sqrt(2), plus.getBoundarySize(), TEST_EPS);
+        Assert.assertEquals(0.5, plus.getSize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(5.0 / 3.0, 4.0 / 3.0), plus.getBarycenter(), TEST_EPS);
+
+        Assert.assertEquals(3, plus.getBoundaries().size());
+    }
+
+    @Test
+    public void testSplit_square_splitOnVerticesWithReversedSplitter() {
+        // arrange
+        ConvexArea area = ConvexArea.fromBounds(createSquareBoundingLines(Vector2D.of(1, 1), 1, 1));
+        Line splitter = Line.fromPoints(Vector2D.of(1, 1), Vector2D.of(2, 2), TEST_PRECISION).reverse();
+
+        // act
+        Split<ConvexArea> split = area.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        ConvexArea minus = split.getMinus();
+        Assert.assertFalse(minus.isFull());
+        Assert.assertFalse(minus.isEmpty());
+
+        Assert.assertEquals(2 + Math.sqrt(2), minus.getBoundarySize(), TEST_EPS);
+        Assert.assertEquals(0.5, minus.getSize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(5.0 / 3.0, 4.0 / 3.0), minus.getBarycenter(), TEST_EPS);
+
+        Assert.assertEquals(3, minus.getBoundaries().size());
+
+        ConvexArea plus = split.getPlus();
+        Assert.assertFalse(plus.isFull());
+        Assert.assertFalse(plus.isEmpty());
+
+        Assert.assertEquals(2 + Math.sqrt(2), plus.getBoundarySize(), TEST_EPS);
+        Assert.assertEquals(0.5, plus.getSize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(4.0 / 3.0, 5.0 / 3.0), plus.getBarycenter(), TEST_EPS);
+
+        Assert.assertEquals(3, plus.getBoundaries().size());
+    }
+
+    @Test
+    public void testSplit_square_entirelyOnMinus() {
+        // arrange
+        ConvexArea area = ConvexArea.fromBounds(createSquareBoundingLines(Vector2D.of(1, 1), 1, 1));
+        Line splitter = Line.fromPoints(Vector2D.of(3, 1), Vector2D.of(3, 2), TEST_PRECISION);
+
+        // act
+        Split<ConvexArea> split = area.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.MINUS, split.getLocation());
+        Assert.assertSame(area, split.getMinus());
+        Assert.assertNull(split.getPlus());
+    }
+
+    @Test
+    public void testSplit_square_onMinusBoundary() {
+        // arrange
+        ConvexArea area = ConvexArea.fromBounds(createSquareBoundingLines(Vector2D.of(1, 1), 1, 1));
+        Line splitter = Line.fromPoints(Vector2D.of(2, 1), Vector2D.of(2, 2), TEST_PRECISION);
+
+        // act
+        Split<ConvexArea> split = area.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.MINUS, split.getLocation());
+        Assert.assertSame(area, split.getMinus());
+        Assert.assertNull(split.getPlus());
+    }
+
+    @Test
+    public void testSplit_square_entirelyOnPlus() {
+        // arrange
+        ConvexArea area = ConvexArea.fromBounds(createSquareBoundingLines(Vector2D.of(1, 1), 1, 1));
+        Line splitter = Line.fromPoints(Vector2D.of(0, 1), Vector2D.of(0, 2), TEST_PRECISION);
+
+        // act
+        Split<ConvexArea> split = area.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.PLUS, split.getLocation());
+        Assert.assertNull(split.getMinus());
+        Assert.assertSame(area, split.getPlus());
+    }
+
+    @Test
+    public void testSplit_square_onPlusBoundary() {
+        // arrange
+        ConvexArea area = ConvexArea.fromBounds(createSquareBoundingLines(Vector2D.of(1, 1), 1, 1));
+        Line splitter = Line.fromPoints(Vector2D.of(1, 1), Vector2D.of(1, 2), TEST_PRECISION);
+
+        // act
+        Split<ConvexArea> split = area.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.PLUS, split.getLocation());
+        Assert.assertNull(split.getMinus());
+        Assert.assertSame(area, split.getPlus());
+    }
+
+    @Test
+    public void testToString() {
+        // arrange
+        ConvexArea area = ConvexArea.full();
+
+        // act
+        String str = area.toString();
+
+        // assert
+        Assert.assertTrue(str.contains("ConvexArea"));
+        Assert.assertTrue(str.contains("boundaries= "));
+    }
+
+    @Test
+    public void testFromVertices_noVertices() {
+        // act
+        ConvexArea area = ConvexArea.fromVertices(Arrays.asList(), TEST_PRECISION);
+
+        // assert
+        Assert.assertTrue(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+
+        Assert.assertEquals(0, area.getBoundarySize(), TEST_EPS);
+        GeometryTestUtils.assertPositiveInfinity(area.getSize());
+        Assert.assertNull(area.getBarycenter());
+    }
+
+    @Test
+    public void testFromVertices_singleUniqueVertex() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-3);
+
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            ConvexArea.fromVertices(Arrays.asList(Vector2D.ZERO), precision);
+        }, IllegalStateException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            ConvexArea.fromVertices(Arrays.asList(Vector2D.ZERO, Vector2D.of(1e-4, 1e-4)), precision);
+        }, IllegalStateException.class);
+    }
+
+    @Test
+    public void testFromVertices_twoVertices() {
+        // act
+        ConvexArea area = ConvexArea.fromVertices(Arrays.asList(
+                    Vector2D.ZERO,
+                    Vector2D.Unit.PLUS_X
+                ), TEST_PRECISION);
+
+        // assert
+        Assert.assertFalse(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+
+        GeometryTestUtils.assertPositiveInfinity(area.getSize());
+        GeometryTestUtils.assertPositiveInfinity(area.getBoundarySize());
+        Assert.assertNull(area.getBarycenter());
+
+        Assert.assertTrue(area.contains(Vector2D.Unit.PLUS_Y));
+        Assert.assertFalse(area.contains(Vector2D.Unit.MINUS_Y));
+    }
+
+    @Test
+    public void testFromVertices_threeVertices() {
+        // act
+        ConvexArea area = ConvexArea.fromVertices(Arrays.asList(
+                    Vector2D.ZERO,
+                    Vector2D.Unit.PLUS_X,
+                    Vector2D.of(1, 1)
+                ), TEST_PRECISION);
+
+        // assert
+        Assert.assertFalse(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+
+        GeometryTestUtils.assertPositiveInfinity(area.getSize());
+        GeometryTestUtils.assertPositiveInfinity(area.getBoundarySize());
+        Assert.assertNull(area.getBarycenter());
+
+        Assert.assertTrue(area.contains(Vector2D.Unit.PLUS_Y));
+        Assert.assertFalse(area.contains(Vector2D.Unit.MINUS_Y));
+        Assert.assertFalse(area.contains(Vector2D.of(2, 2)));
+    }
+
+    @Test
+    public void testFromVertices_finite() {
+        // act
+        ConvexArea area = ConvexArea.fromVertices(Arrays.asList(
+                    Vector2D.ZERO,
+                    Vector2D.Unit.PLUS_X,
+                    Vector2D.of(1, 1),
+                    Vector2D.Unit.PLUS_Y,
+                    Vector2D.ZERO
+                ), TEST_PRECISION);
+
+        // assert
+        Assert.assertFalse(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+
+        Assert.assertEquals(1, area.getSize(), TEST_EPS);
+        Assert.assertEquals(4, area.getBoundarySize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(0.5, 0.5), area.getBarycenter(), TEST_EPS);
+    }
+
+    @Test
+    public void testFromVertices_handlesDuplicatePoints() {
+        // arrange
+        double eps = 1e-3;
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(eps);
+
+        // act
+        ConvexArea area = ConvexArea.fromVertices(Arrays.asList(
+                    Vector2D.ZERO,
+                    Vector2D.of(1e-4, 1e-4),
+                    Vector2D.Unit.PLUS_X,
+                    Vector2D.of(1, 1e-4),
+                    Vector2D.of(1, 1),
+                    Vector2D.of(0, 1),
+                    Vector2D.of(1e-4, 1),
+                    Vector2D.of(1e-4, 1e-4)
+                ), precision);
+
+        // assert
+        Assert.assertFalse(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+
+        Assert.assertEquals(1, area.getSize(), eps);
+        Assert.assertEquals(4, area.getBoundarySize(), eps);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(0.5, 0.5), area.getBarycenter(), eps);
+    }
+
+    @Test
+    public void testFromVertices_clockwiseWinding() {
+        // act
+        GeometryTestUtils.assertThrows(() -> {
+            ConvexArea.fromVertices(
+                    Arrays.asList(
+                            Vector2D.ZERO,
+                            Vector2D.Unit.PLUS_Y,
+                            Vector2D.of(1, 1),
+                            Vector2D.Unit.PLUS_X,
+                            Vector2D.ZERO
+                    ),TEST_PRECISION);
+        }, GeometryException.class);
+    }
+
+    @Test
+    public void testFromVertexLoops_noVertices() {
+        // act
+        ConvexArea area = ConvexArea.fromVertexLoop(Arrays.asList(), TEST_PRECISION);
+
+        // assert
+        Assert.assertTrue(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+
+        Assert.assertEquals(0, area.getBoundarySize(), TEST_EPS);
+        GeometryTestUtils.assertPositiveInfinity(area.getSize());
+        Assert.assertNull(area.getBarycenter());
+    }
+
+    @Test
+    public void testFromVertexLoop_singleUniqueVertex() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-3);
+
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            ConvexArea.fromVertexLoop(Arrays.asList(Vector2D.ZERO), precision);
+        }, IllegalStateException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            ConvexArea.fromVertexLoop(Arrays.asList(Vector2D.ZERO, Vector2D.of(1e-4, 1e-4)), precision);
+        }, IllegalStateException.class);
+    }
+
+    @Test
+    public void testFromVertexLoop_twoVertices_fails() {
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            ConvexArea.fromVertexLoop(Arrays.asList(Vector2D.ZERO, Vector2D.Unit.PLUS_X), TEST_PRECISION);
+        }, GeometryException.class);
+    }
+
+    @Test
+    public void testFromVertexLoop_square_closeRequired() {
+        // act
+        ConvexArea area = ConvexArea.fromVertexLoop(Arrays.asList(
+                    Vector2D.ZERO,
+                    Vector2D.Unit.PLUS_X,
+                    Vector2D.of(1, 1),
+                    Vector2D.of(0, 1)
+                ), TEST_PRECISION);
+
+        // assert
+        Assert.assertFalse(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+
+        Assert.assertEquals(1, area.getSize(), TEST_EPS);
+        Assert.assertEquals(4, area.getBoundarySize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(0.5, 0.5), area.getBarycenter(), TEST_EPS);
+    }
+
+    @Test
+    public void testFromVertexLoop_square_closeNotRequired() {
+        // act
+        ConvexArea area = ConvexArea.fromVertexLoop(Arrays.asList(
+                    Vector2D.ZERO,
+                    Vector2D.Unit.PLUS_X,
+                    Vector2D.of(1, 1),
+                    Vector2D.of(0, 1),
+                    Vector2D.ZERO
+                ), TEST_PRECISION);
+
+        // assert
+        Assert.assertFalse(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+
+        Assert.assertEquals(1, area.getSize(), TEST_EPS);
+        Assert.assertEquals(4, area.getBoundarySize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(0.5, 0.5), area.getBarycenter(), TEST_EPS);
+    }
+
+    @Test
+    public void testFromVertexLoop_handlesDuplicatePoints() {
+        // arrange
+        double eps = 1e-3;
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(eps);
+
+        // act
+        ConvexArea area = ConvexArea.fromVertexLoop(Arrays.asList(
+                    Vector2D.ZERO,
+                    Vector2D.of(1e-4, 1e-4),
+                    Vector2D.Unit.PLUS_X,
+                    Vector2D.of(1, 1e-4),
+                    Vector2D.of(1, 1),
+                    Vector2D.of(0, 1),
+                    Vector2D.of(1e-4, 1),
+                    Vector2D.of(1e-4, 1e-4)
+                ), precision);
+
+        // assert
+        Assert.assertFalse(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+
+        Assert.assertEquals(1, area.getSize(), eps);
+        Assert.assertEquals(4, area.getBoundarySize(), eps);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(0.5, 0.5), area.getBarycenter(), eps);
+    }
+
+    @Test
+    public void testFromVertexLoop_clockwiseWinding() {
+        // act
+        GeometryTestUtils.assertThrows(() -> {
+            ConvexArea.fromVertexLoop(
+                    Arrays.asList(
+                            Vector2D.ZERO,
+                            Vector2D.Unit.PLUS_Y,
+                            Vector2D.of(1, 1),
+                            Vector2D.Unit.PLUS_X
+                    ),TEST_PRECISION);
+        }, GeometryException.class);
+    }
+
+    @Test
+    public void testFromPath_empty() {
+        // act
+        ConvexArea area = ConvexArea.fromPath(Polyline.empty());
+
+        // assert
+        Assert.assertTrue(area.isFull());
+    }
+
+    @Test
+    public void testFromPath_infinite() {
+        // act
+        ConvexArea area = ConvexArea.fromPath(Polyline.fromVertices(
+                Arrays.asList(Vector2D.ZERO, Vector2D.Unit.PLUS_X),TEST_PRECISION));
+
+        // assert
+        Assert.assertFalse(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+
+        GeometryTestUtils.assertPositiveInfinity(area.getBoundarySize());
+        GeometryTestUtils.assertPositiveInfinity(area.getSize());
+        Assert.assertNull(area.getBarycenter());
+
+        checkRegion(area, RegionLocation.INSIDE, Vector2D.Unit.PLUS_Y);
+        checkRegion(area, RegionLocation.BOUNDARY, Vector2D.ZERO);
+        checkRegion(area, RegionLocation.OUTSIDE, Vector2D.Unit.MINUS_Y);
+    }
+
+    @Test
+    public void testFromPath_finite() {
+        // act
+        ConvexArea area = ConvexArea.fromPath(Polyline.fromVertexLoop(
+                Arrays.asList(
+                        Vector2D.ZERO,
+                        Vector2D.Unit.PLUS_X,
+                        Vector2D.of(1, 1),
+                        Vector2D.Unit.PLUS_Y
+                ),TEST_PRECISION));
+
+        // assert
+        Assert.assertFalse(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+
+        Assert.assertEquals(1, area.getSize(), TEST_EPS);
+        Assert.assertEquals(4, area.getBoundarySize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(0.5, 0.5), area.getBarycenter(), TEST_EPS);
+    }
+
+    @Test
+    public void testFromPath_clockwiseWinding() {
+        // act
+        GeometryTestUtils.assertThrows(() -> {
+            ConvexArea.fromPath(Polyline.fromVertexLoop(
+                    Arrays.asList(
+                            Vector2D.ZERO,
+                            Vector2D.Unit.PLUS_Y,
+                            Vector2D.of(1, 1),
+                            Vector2D.Unit.PLUS_X
+                    ),TEST_PRECISION));
+        }, GeometryException.class);
+    }
+
+    @Test
+    public void testFromBounds_noLines() {
+        // act
+        ConvexArea area = ConvexArea.fromBounds(Collections.emptyList());
+
+        // assert
+        Assert.assertSame(ConvexArea.full(), area);
+    }
+
+    @Test
+    public void testFromBounds_singleLine() {
+        // arrange
+        Line line = Line.fromPoints(Vector2D.of(0, 1), Vector2D.of(1, 3), TEST_PRECISION);
+
+        // act
+        ConvexArea area = ConvexArea.fromBounds(line);
+
+        // assert
+        Assert.assertFalse(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+
+        GeometryTestUtils.assertPositiveInfinity(area.getBoundarySize());
+        GeometryTestUtils.assertPositiveInfinity(area.getSize());
+        Assert.assertNull(area.getBarycenter());
+
+        List<Segment> segments = area.getBoundaries();
+        Assert.assertEquals(1, segments.size());
+        Assert.assertSame(line, segments.get(0).getLine());
+
+        checkRegion(area, RegionLocation.INSIDE, Vector2D.of(-1, 1), Vector2D.of(0, 2));
+        checkRegion(area, RegionLocation.BOUNDARY, Vector2D.of(0, 1), Vector2D.of(2, 5));
+        checkRegion(area, RegionLocation.OUTSIDE, Vector2D.ZERO, Vector2D.of(2, 3));
+    }
+
+    @Test
+    public void testFromBounds_twoLines() {
+        // arrange
+        Line a = Line.fromPointAndAngle(Vector2D.ZERO, Geometry.HALF_PI, TEST_PRECISION);
+        Line b = Line.fromPointAndAngle(Vector2D.ZERO, Geometry.PI, TEST_PRECISION);
+
+        // act
+        ConvexArea area = ConvexArea.fromBounds(a, b);
+
+        // assert
+        Assert.assertFalse(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+
+        GeometryTestUtils.assertPositiveInfinity(area.getBoundarySize());
+        GeometryTestUtils.assertPositiveInfinity(area.getSize());
+        Assert.assertNull(area.getBarycenter());
+
+        List<Segment> segments = area.getBoundaries();
+        Assert.assertEquals(2, segments.size());
+
+        checkRegion(area, RegionLocation.INSIDE, Vector2D.of(-1, -1));
+        checkRegion(area, RegionLocation.BOUNDARY,
+                Vector2D.ZERO, Vector2D.of(-1, 0), Vector2D.of(0, -1));
+        checkRegion(area, RegionLocation.OUTSIDE,
+                Vector2D.of(-1, 1), Vector2D.of(1, 1), Vector2D.of(1, -1));
+    }
+
+    @Test
+    public void testFromBounds_triangle() {
+        // arrange
+        Line a = Line.fromPointAndAngle(Vector2D.ZERO, Geometry.HALF_PI, TEST_PRECISION);
+        Line b = Line.fromPointAndAngle(Vector2D.ZERO, Geometry.PI, TEST_PRECISION);
+        Line c = Line.fromPointAndAngle(Vector2D.of(-2, 0), -0.25 * Geometry.PI, TEST_PRECISION);
+
+        // act
+        ConvexArea area = ConvexArea.fromBounds(a, b, c);
+
+        // assert
+        Assert.assertFalse(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+
+        Assert.assertEquals(4 + (2 * Math.sqrt(2)), area.getBoundarySize(), TEST_EPS);
+        Assert.assertEquals(2, area.getSize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(-2.0 / 3.0, -2.0 / 3.0), area.getBarycenter(), TEST_EPS);
+
+        List<Segment> segments = area.getBoundaries();
+        Assert.assertEquals(3, segments.size());
+
+        checkRegion(area, RegionLocation.INSIDE, Vector2D.of(-0.5, -0.5));
+        checkRegion(area, RegionLocation.BOUNDARY,
+                Vector2D.ZERO, Vector2D.of(-1, 0), Vector2D.of(0, -1));
+        checkRegion(area, RegionLocation.OUTSIDE,
+                Vector2D.of(-1, 1), Vector2D.of(1, 1), Vector2D.of(1, -1), Vector2D.of(-2, -2));
+    }
+
+    @Test
+    public void testFromBounds_square() {
+        // arrange
+        List<Line> square = createSquareBoundingLines(Vector2D.ZERO, 1, 1);
+
+        // act
+        ConvexArea area = ConvexArea.fromBounds(square);
+
+        // assert
+        Assert.assertFalse(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+
+        Assert.assertEquals(4, area.getBoundarySize(), TEST_EPS);
+        Assert.assertEquals(1, area.getSize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(0.5, 0.5), area.getBarycenter(), TEST_EPS);
+
+        List<Segment> segments = area.getBoundaries();
+        Assert.assertEquals(4, segments.size());
+
+        checkRegion(area, RegionLocation.INSIDE, Vector2D.of(0.5, 0.5));
+        checkRegion(area, RegionLocation.BOUNDARY,
+                Vector2D.ZERO, Vector2D.of(1, 1),
+                Vector2D.of(0.5, 0), Vector2D.of(0.5, 1),
+                Vector2D.of(0, 0.5), Vector2D.of(1, 0.5));
+        checkRegion(area, RegionLocation.OUTSIDE,
+                Vector2D.of(-1, -1), Vector2D.of(2, 2));
+    }
+
+    @Test
+    public void testFromBounds_square_extraLines() {
+        // arrange
+        List<Line> extraLines = new ArrayList<>();
+        extraLines.add(Line.fromPoints(Vector2D.of(10, 10), Vector2D.of(10, 11), TEST_PRECISION));
+        extraLines.add(Line.fromPoints(Vector2D.of(-10, 10), Vector2D.of(-10, 9), TEST_PRECISION));
+        extraLines.add(Line.fromPoints(Vector2D.of(0, 10), Vector2D.of(-1, 11), TEST_PRECISION));
+        extraLines.addAll(createSquareBoundingLines(Vector2D.ZERO, 1, 1));
+
+        // act
+        ConvexArea area = ConvexArea.fromBounds(extraLines);
+
+        // assert
+        Assert.assertFalse(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+
+        Assert.assertEquals(4, area.getBoundarySize(), TEST_EPS);
+        Assert.assertEquals(1, area.getSize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(0.5, 0.5), area.getBarycenter(), TEST_EPS);
+
+        List<Segment> segments = area.getBoundaries();
+        Assert.assertEquals(4, segments.size());
+
+        checkRegion(area, RegionLocation.INSIDE, Vector2D.of(0.5, 0.5));
+        checkRegion(area, RegionLocation.BOUNDARY,
+                Vector2D.ZERO, Vector2D.of(1, 1),
+                Vector2D.of(0.5, 0), Vector2D.of(0.5, 1),
+                Vector2D.of(0, 0.5), Vector2D.of(1, 0.5));
+        checkRegion(area, RegionLocation.OUTSIDE,
+                Vector2D.of(-1, -1), Vector2D.of(2, 2));
+    }
+
+    @Test
+    public void testFromBounds_square_duplicateLines() {
+        // arrange
+        List<Line> duplicateLines = new ArrayList<>();
+        duplicateLines.addAll(createSquareBoundingLines(Vector2D.ZERO, 1, 1));
+        duplicateLines.addAll(createSquareBoundingLines(Vector2D.ZERO, 1, 1));
+
+        // act
+        ConvexArea area = ConvexArea.fromBounds(duplicateLines);
+
+        // assert
+        Assert.assertFalse(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+
+        Assert.assertEquals(4, area.getBoundarySize(), TEST_EPS);
+        Assert.assertEquals(1, area.getSize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(0.5, 0.5), area.getBarycenter(), TEST_EPS);
+
+        List<Segment> segments = area.getBoundaries();
+        Assert.assertEquals(4, segments.size());
+
+        checkRegion(area, RegionLocation.INSIDE, Vector2D.of(0.5, 0.5));
+        checkRegion(area, RegionLocation.BOUNDARY,
+                Vector2D.ZERO, Vector2D.of(1, 1),
+                Vector2D.of(0.5, 0), Vector2D.of(0.5, 1),
+                Vector2D.of(0, 0.5), Vector2D.of(1, 0.5));
+        checkRegion(area, RegionLocation.OUTSIDE,
+                Vector2D.of(-1, -1), Vector2D.of(2, 2));
+    }
+
+    @Test
+    public void testFromBounds_duplicateLines_similarOrientation() {
+        // arrange
+        Line a = Line.fromPointAndAngle(Vector2D.of(0, 1), Geometry.ZERO_PI, TEST_PRECISION);
+        Line b = Line.fromPointAndAngle(Vector2D.of(0, 1), Geometry.ZERO_PI, TEST_PRECISION);
+        Line c = Line.fromPointAndAngle(Vector2D.of(0, 1), Geometry.ZERO_PI, TEST_PRECISION);
+
+        // act
+        ConvexArea area = ConvexArea.fromBounds(a, b, c);
+
+        // assert
+        Assert.assertFalse(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+
+        GeometryTestUtils.assertPositiveInfinity(area.getBoundarySize());
+        GeometryTestUtils.assertPositiveInfinity(area.getSize());
+        Assert.assertNull(area.getBarycenter());
+
+        List<Segment> segments = area.getBoundaries();
+        Assert.assertEquals(1, segments.size());
+
+        checkRegion(area, RegionLocation.BOUNDARY, Vector2D.of(0, 1), Vector2D.of(1, 1), Vector2D.of(-1, 1));
+        checkRegion(area, RegionLocation.INSIDE, Vector2D.of(0, 2), Vector2D.of(1, 2), Vector2D.of(-1, 2));
+        checkRegion(area, RegionLocation.OUTSIDE, Vector2D.of(0, 0), Vector2D.of(1, 0), Vector2D.of(-1, 0));
+    }
+
+    @Test
+    public void testFromBounds_duplicateLines_differentOrientation() {
+        // arrange
+        Line a = Line.fromPointAndAngle(Vector2D.of(0, 1), Geometry.ZERO_PI, TEST_PRECISION);
+        Line b = Line.fromPointAndAngle(Vector2D.of(0, 1), Geometry.PI, TEST_PRECISION);
+        Line c = Line.fromPointAndAngle(Vector2D.of(0, 1), Geometry.ZERO_PI, TEST_PRECISION);
+
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            ConvexArea.fromBounds(a, b, c);
+        }, GeometryException.class);
+    }
+
+    @Test
+    public void testFromBounds_boundsDoNotProduceAConvexRegion() {
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            ConvexArea.fromBounds(Arrays.asList(
+                        Line.fromPointAndAngle(Vector2D.ZERO, Geometry.ZERO_PI, TEST_PRECISION),
+                        Line.fromPointAndAngle(Vector2D.of(0, -1), Geometry.PI, TEST_PRECISION),
+                        Line.fromPointAndAngle(Vector2D.ZERO, Geometry.HALF_PI, TEST_PRECISION)
+                    ));
+        }, GeometryException.class);
+    }
+
+    private static void checkRegion(ConvexArea area, RegionLocation loc, Vector2D ... pts) {
+        for (Vector2D pt : pts) {
+            Assert.assertEquals("Unexpected region location for point " + pt, loc, area.classify(pt));
+        }
+    }
+
+    private static List<Line> createSquareBoundingLines(final Vector2D lowerLeft, final double width, final double height) {
+        final Vector2D lowerRight = Vector2D.of(lowerLeft.getX() + width, lowerLeft.getY());
+        final Vector2D upperRight = Vector2D.of(lowerLeft.getX() + width, lowerLeft.getY() + height);
+        final Vector2D upperLeft = Vector2D.of(lowerLeft.getX(), lowerLeft.getY() + height);
+
+        return Arrays.asList(
+                    Line.fromPoints(lowerLeft, lowerRight, TEST_PRECISION),
+                    Line.fromPoints(upperRight, upperLeft, TEST_PRECISION),
+                    Line.fromPoints(lowerRight, upperRight, TEST_PRECISION),
+                    Line.fromPoints(upperLeft, lowerLeft, TEST_PRECISION)
+                );
+    }
+}
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/FunctionTransform2DTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/FunctionTransform2DTest.java
new file mode 100644
index 0000000..845b187
--- /dev/null
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/FunctionTransform2DTest.java
@@ -0,0 +1,190 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.twod;
+
+import java.util.function.Function;
+
+import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class FunctionTransform2DTest {
+
+    private static final double TEST_EPS = 1e-15;
+
+    @Test
+    public void testIdentity() {
+        // arrange
+        Vector2D p0 = Vector2D.of(0, 0);
+        Vector2D p1 = Vector2D.of(1, 1);
+        Vector2D p2 = Vector2D.of(-1, -1);
+
+        // act
+        FunctionTransform2D t = FunctionTransform2D.identity();
+
+        // assert
+        Assert.assertTrue(t.preservesOrientation());
+
+        EuclideanTestUtils.assertCoordinatesEqual(p0, t.apply(p0), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(p1, t.apply(p1), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(p2, t.apply(p2), TEST_EPS);
+    }
+
+    @Test
+    public void testFrom_identity() {
+        // arrange
+        Vector2D p0 = Vector2D.of(0, 0);
+        Vector2D p1 = Vector2D.of(1, 1);
+        Vector2D p2 = Vector2D.of(-1, -1);
+
+        // act
+        FunctionTransform2D t = FunctionTransform2D.from(Function.identity());
+
+        // assert
+        Assert.assertTrue(t.preservesOrientation());
+
+        EuclideanTestUtils.assertCoordinatesEqual(p0, t.apply(p0), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(p1, t.apply(p1), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(p2, t.apply(p2), TEST_EPS);
+    }
+
+    @Test
+    public void testFrom_scaleAndTranslate() {
+        // arrange
+        Vector2D p0 = Vector2D.of(0, 0);
+        Vector2D p1 = Vector2D.of(1, 2);
+        Vector2D p2 = Vector2D.of(-1, -2);
+
+        // act
+        FunctionTransform2D t = FunctionTransform2D.from(v -> v.multiply(2).add(Vector2D.of(1, -1)));
+
+        // assert
+        Assert.assertTrue(t.preservesOrientation());
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1, -1), t.apply(p0), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(3, 3), t.apply(p1), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(-1, -5), t.apply(p2), TEST_EPS);
+    }
+
+    @Test
+    public void testFrom_reflection_singleAxis() {
+        // arrange
+        Vector2D p0 = Vector2D.of(0, 0);
+        Vector2D p1 = Vector2D.of(1, 2);
+        Vector2D p2 = Vector2D.of(-1, -2);
+
+        // act
+        FunctionTransform2D t = FunctionTransform2D.from(v -> Vector2D.of(-v.getX(), v.getY()));
+
+        // assert
+        Assert.assertFalse(t.preservesOrientation());
+
+        EuclideanTestUtils.assertCoordinatesEqual(p0, t.apply(p0), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(-1, 2), t.apply(p1), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1, -2), t.apply(p2), TEST_EPS);
+    }
+
+    @Test
+    public void testFrom_reflection_bothAxes() {
+        // arrange
+        Vector2D p0 = Vector2D.of(0, 0);
+        Vector2D p1 = Vector2D.of(1, 2);
+        Vector2D p2 = Vector2D.of(-1, -2);
+
+        // act
+        FunctionTransform2D t = FunctionTransform2D.from(Vector2D::negate);
+
+        // assert
+        Assert.assertTrue(t.preservesOrientation());
+
+        EuclideanTestUtils.assertCoordinatesEqual(p0, t.apply(p0), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(-1, -2), t.apply(p1), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1, 2), t.apply(p2), TEST_EPS);
+    }
+
+    @Test
+    public void testApplyVector() {
+        // arrange
+        Transform2D t = FunctionTransform2D.from(v -> {
+            return v.multiply(-2).add(Vector2D.of(4, 5));
+        });
+
+        // act/assert
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.ZERO, t.applyVector(Vector2D.ZERO), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(-2, 0), t.applyVector(Vector2D.Unit.PLUS_X), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(-4, -4), t.applyVector(Vector2D.of(2, 2)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(2, 0), t.applyVector(Vector2D.of(-1, 0)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(4, 6), t.applyVector(Vector2D.of(-2, -3)), TEST_EPS);
+    }
+
+    @Test
+    public void testToMatrix() {
+        // act/assert
+        Assert.assertArrayEquals(new double[] {
+                    1, 0, 0,
+                    0, 1, 0
+                },
+                FunctionTransform2D.identity().toMatrix().toArray(), TEST_EPS);
+        Assert.assertArrayEquals(new double[] {
+                    1, 0, 2,
+                    0, 1, 3
+                },
+                FunctionTransform2D.from(v -> v.add(Vector2D.of(2, 3))).toMatrix().toArray(), TEST_EPS);
+        Assert.assertArrayEquals(new double[] {
+                    3, 0, 0,
+                    0, 3, 0
+                },
+                FunctionTransform2D.from(v -> v.multiply(3)).toMatrix().toArray(), TEST_EPS);
+        Assert.assertArrayEquals(new double[] {
+                    3, 0, 6,
+                    0, 3, 9
+                },
+                FunctionTransform2D.from(v -> v.add(Vector2D.of(2, 3)).multiply(3)).toMatrix().toArray(), TEST_EPS);
+    }
+
+    @Test
+    public void testTransformRoundTrip() {
+        // arrange
+        double eps = 1e-8;
+        double delta = 0.11;
+
+        Vector2D p1 = Vector2D.of(1.1, -3);
+        Vector2D p2 = Vector2D.of(-5, 0.2);
+        Vector2D vec = p1.vectorTo(p2);
+
+        EuclideanTestUtils.permuteSkipZero(-2, 2, delta, (translate, scale) -> {
+
+            FunctionTransform2D t = FunctionTransform2D.from(v -> {
+                return v.multiply(scale * 0.5)
+                    .add(Vector2D.of(translate, 0.5 * translate))
+                    .multiply(scale * 1.5);
+            });
+
+            // act
+            Vector2D t1 = t.apply(p1);
+            Vector2D t2 = t.apply(p2);
+            Vector2D tvec = t.applyVector(vec);
+
+            Transform2D inverse = t.toMatrix().inverse();
+
+            // assert
+            EuclideanTestUtils.assertCoordinatesEqual(tvec, t1.vectorTo(t2), eps);
+            EuclideanTestUtils.assertCoordinatesEqual(p1, inverse.apply(t1), eps);
+            EuclideanTestUtils.assertCoordinatesEqual(p2, inverse.apply(t2), eps);
+        });
+    }
+}
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/InteriorAngleSegmentConnectorTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/InteriorAngleSegmentConnectorTest.java
new file mode 100644
index 0000000..1a18650
--- /dev/null
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/InteriorAngleSegmentConnectorTest.java
@@ -0,0 +1,342 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.twod;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Random;
+import java.util.function.Consumer;
+
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
+import org.apache.commons.geometry.euclidean.twod.InteriorAngleSegmentConnector.Maximize;
+import org.apache.commons.geometry.euclidean.twod.InteriorAngleSegmentConnector.Minimize;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class InteriorAngleSegmentConnectorTest {
+
+    private static final double TEST_EPS = 1e-10;
+
+    private static final DoublePrecisionContext TEST_PRECISION =
+            new EpsilonDoublePrecisionContext(TEST_EPS);
+
+    @Test
+    public void testConnectAll_noSegments() {
+        runWithMaxAndMin(connector -> {
+            // arrange
+            List<Segment> segments = new ArrayList<>();
+
+            // act
+            List<Polyline> paths = connector.connectAll(segments);
+
+            // assert
+            Assert.assertEquals(0, paths.size());
+        });
+    }
+
+    @Test
+    public void testConnectAll_singleFiniteSegment() {
+        runWithMaxAndMin(connector -> {
+            // arrange
+            List<Segment> segments = Arrays.asList(
+                        Segment.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_X, TEST_PRECISION)
+                    );
+
+            // act
+            List<Polyline> paths = connector.connectAll(segments);
+
+            // assert
+            Assert.assertEquals(1, paths.size());
+
+            assertFinitePath(paths.get(0), Vector2D.ZERO, Vector2D.Unit.PLUS_X);
+        });
+    }
+
+    @Test
+    public void testConnectAll_dualConnectedSegments() {
+        runWithMaxAndMin(connector -> {
+            // arrange
+            List<Segment> segments = Arrays.asList(
+                        Segment.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_X, TEST_PRECISION),
+                        Segment.fromPoints(Vector2D.Unit.PLUS_X, Vector2D.ZERO, TEST_PRECISION)
+                    );
+
+            // act
+            List<Polyline> paths = connector.connectAll(segments);
+
+            // assert
+            Assert.assertEquals(1, paths.size());
+
+            Assert.assertTrue(paths.get(0).isClosed());
+            assertFinitePath(paths.get(0), Vector2D.ZERO, Vector2D.Unit.PLUS_X, Vector2D.ZERO);
+        });
+    }
+
+    @Test
+    public void testConnectAll_singleFiniteSegmentLoop() {
+        runWithMaxAndMin(connector -> {
+            // arrange
+            List<Segment> segments = shuffle(createSquare(Vector2D.ZERO, 1, 1));
+
+            // act
+            List<Polyline> paths = connector.connectAll(segments);
+
+            // assert
+            Assert.assertEquals(1, paths.size());
+
+            assertFinitePath(paths.get(0),
+                    Vector2D.ZERO, Vector2D.Unit.PLUS_X, Vector2D.of(1, 1),
+                    Vector2D.of(0, 1), Vector2D.ZERO);
+        });
+    }
+
+    @Test
+    public void testConnectAll_disjointPaths() {
+        runWithMaxAndMin(connector -> {
+            // arrange
+            List<Segment> segments = new ArrayList<>();
+            segments.addAll(createSquare(Vector2D.ZERO, 1, 1));
+
+            Vector2D pt = Vector2D.of(0, 2);
+            Segment a = Line.fromPointAndAngle(pt, Geometry.ZERO_PI, TEST_PRECISION).segmentTo(pt);
+            Segment b = Line.fromPointAndAngle(pt, Geometry.HALF_PI, TEST_PRECISION).segmentFrom(pt);
+
+            segments.add(a);
+            segments.add(b);
+
+            shuffle(segments);
+
+            // act
+            List<Polyline> paths = connector.connectAll(segments);
+
+            // assert
+            Assert.assertEquals(2, paths.size());
+
+            assertFinitePath(paths.get(0),
+                    Vector2D.ZERO, Vector2D.Unit.PLUS_X, Vector2D.of(1, 1),
+                    Vector2D.of(0, 1), Vector2D.ZERO);
+
+            assertInfinitePath(paths.get(1), a, b, pt);
+        });
+    }
+
+    @Test
+    public void testConnectAll_squaresJoinedAtVertex_maximize() {
+        // arrange
+        Maximize connector = new Maximize();
+
+        List<Segment> segments = new ArrayList<>();
+        segments.addAll(createSquare(Vector2D.ZERO, 1, 1));
+        segments.addAll(createSquare(Vector2D.of(1, 1), 1, 1));
+
+        shuffle(segments);
+
+        // act
+        List<Polyline> paths = connector.connectAll(segments);
+
+        // assert
+        Assert.assertEquals(1, paths.size());
+
+        assertFinitePath(paths.get(0),
+                Vector2D.ZERO, Vector2D.Unit.PLUS_X, Vector2D.of(1, 1),
+                Vector2D.of(2, 1), Vector2D.of(2, 2),
+                Vector2D.of(1, 2), Vector2D.of(1, 1),
+                Vector2D.of(0, 1), Vector2D.ZERO);
+    }
+
+    @Test
+    public void testConnectAll_mutipleSegmentsAtVertex_maximize() {
+        // arrange
+        Maximize connector = new Maximize();
+
+        List<Segment> segments = new ArrayList<>();
+        segments.add(Segment.fromPoints(Vector2D.ZERO, Vector2D.of(2, 2), TEST_PRECISION));
+
+        segments.add(Segment.fromPoints(Vector2D.of(2, 2), Vector2D.of(2, 4), TEST_PRECISION));
+        segments.add(Segment.fromPoints(Vector2D.of(2, 2), Vector2D.of(1, 3), TEST_PRECISION));
+
+        // act
+        List<Polyline> paths = connector.connectAll(segments);
+
+        // assert
+        Assert.assertEquals(2, paths.size());
+
+        assertFinitePath(paths.get(0),
+                Vector2D.ZERO, Vector2D.of(2, 2), Vector2D.of(2, 4));
+
+        assertFinitePath(paths.get(1), Vector2D.of(2, 2), Vector2D.of(1, 3));
+    }
+
+    @Test
+    public void testConnectAll_squaresJoinedAtVertex_minimize() {
+        // arrange
+        Minimize connector = new Minimize();
+
+        List<Segment> segments = new ArrayList<>();
+        segments.addAll(createSquare(Vector2D.ZERO, 1, 1));
+        segments.addAll(createSquare(Vector2D.of(1, 1), 1, 1));
+
+        shuffle(segments);
+
+        // act
+        List<Polyline> paths = connector.connectAll(segments);
+
+        // assert
+        Assert.assertEquals(2, paths.size());
+
+        assertFinitePath(paths.get(0),
+                Vector2D.ZERO, Vector2D.Unit.PLUS_X, Vector2D.of(1, 1),
+                Vector2D.of(0, 1), Vector2D.ZERO);
+
+        assertFinitePath(paths.get(1),
+                Vector2D.of(1, 1), Vector2D.of(2, 1), Vector2D.of(2, 2),
+                Vector2D.of(1, 2), Vector2D.of(1, 1));
+    }
+
+    @Test
+    public void testConnectAll_mutipleSegmentsAtVertex_minimize() {
+        // arrange
+        Minimize connector = new Minimize();
+
+        List<Segment> segments = new ArrayList<>();
+        segments.add(Segment.fromPoints(Vector2D.ZERO, Vector2D.of(2, 2), TEST_PRECISION));
+
+        segments.add(Segment.fromPoints(Vector2D.of(2, 2), Vector2D.of(2, 4), TEST_PRECISION));
+        segments.add(Segment.fromPoints(Vector2D.of(2, 2), Vector2D.of(1, 3), TEST_PRECISION));
+
+        // act
+        List<Polyline> paths = connector.connectAll(segments);
+
+        // assert
+        Assert.assertEquals(2, paths.size());
+
+        assertFinitePath(paths.get(0),
+                Vector2D.ZERO, Vector2D.of(2, 2), Vector2D.of(1, 3));
+
+        assertFinitePath(paths.get(1), Vector2D.of(2, 2), Vector2D.of(2, 4));
+    }
+
+    @Test
+    public void testConnectMaximized() {
+        // arrange
+        List<Segment> segments = new ArrayList<>();
+        segments.add(Segment.fromPoints(Vector2D.ZERO, Vector2D.of(2, 2), TEST_PRECISION));
+
+        segments.add(Segment.fromPoints(Vector2D.of(2, 2), Vector2D.of(2, 4), TEST_PRECISION));
+        segments.add(Segment.fromPoints(Vector2D.of(2, 2), Vector2D.of(1, 3), TEST_PRECISION));
+
+        // act
+        List<Polyline> paths = InteriorAngleSegmentConnector.connectMaximized(segments);
+
+        // assert
+        Assert.assertEquals(2, paths.size());
+
+        assertFinitePath(paths.get(0),
+                Vector2D.ZERO, Vector2D.of(2, 2), Vector2D.of(2, 4));
+
+        assertFinitePath(paths.get(1), Vector2D.of(2, 2), Vector2D.of(1, 3));
+    }
+
+    @Test
+    public void testConnectMinimized() {
+        // arrange
+        List<Segment> segments = new ArrayList<>();
+        segments.add(Segment.fromPoints(Vector2D.ZERO, Vector2D.of(2, 2), TEST_PRECISION));
+
+        segments.add(Segment.fromPoints(Vector2D.of(2, 2), Vector2D.of(2, 4), TEST_PRECISION));
+        segments.add(Segment.fromPoints(Vector2D.of(2, 2), Vector2D.of(1, 3), TEST_PRECISION));
+
+        // act
+        List<Polyline> paths = InteriorAngleSegmentConnector.connectMinimized(segments);
+
+        // assert
+        Assert.assertEquals(2, paths.size());
+
+        assertFinitePath(paths.get(0),
+                Vector2D.ZERO, Vector2D.of(2, 2), Vector2D.of(1, 3));
+
+        assertFinitePath(paths.get(1), Vector2D.of(2, 2), Vector2D.of(2, 4));
+    }
+
+    /**
+     * Run the given consumer function twice, once with a Maximize instance and once with
+     * a Minimize instance.
+     */
+    private static void runWithMaxAndMin(Consumer<InteriorAngleSegmentConnector> body) {
+        body.accept(new Maximize());
+        body.accept(new Minimize());
+    }
+
+    private static List<Segment> createSquare(final Vector2D lowerLeft, final double width, final double height) {
+        final Vector2D lowerRight = Vector2D.of(lowerLeft.getX() + width, lowerLeft.getY());
+        final Vector2D upperRight = Vector2D.of(lowerLeft.getX() + width, lowerLeft.getY() + height);
+        final Vector2D upperLeft = Vector2D.of(lowerLeft.getX(), lowerLeft.getY() + height);
+
+        return Arrays.asList(
+                    Segment.fromPoints(lowerLeft, lowerRight, TEST_PRECISION),
+                    Segment.fromPoints(lowerRight, upperRight, TEST_PRECISION),
+                    Segment.fromPoints(upperRight, upperLeft, TEST_PRECISION),
+                    Segment.fromPoints(upperLeft, lowerLeft, TEST_PRECISION)
+                );
+    }
+
+    private static List<Segment> shuffle(final List<Segment> segments) {
+        return shuffle(segments, 1);
+    }
+
+    private static List<Segment> shuffle(final List<Segment> segments, final int seed) {
+        Collections.shuffle(segments, new Random(seed));
+
+        return segments;
+    }
+
+    private static void assertInfinitePath(Polyline path, Segment start, Segment end,
+            Vector2D ... vertices) {
+        Assert.assertTrue(path.isInfinite());
+        Assert.assertFalse(path.isFinite());
+
+        Assert.assertEquals(start, path.getStartSegment());
+        Assert.assertEquals(end, path.getEndSegment());
+
+        assertPathVertices(path, vertices);
+    }
+
+    private static void assertFinitePath(Polyline path, Vector2D ... vertices)
+    {
+        Assert.assertFalse(path.isInfinite());
+        Assert.assertTrue(path.isFinite());
+
+        assertPathVertices(path, vertices);
+    }
+
+    private static void assertPathVertices(Polyline path, Vector2D ... vertices) {
+        List<Vector2D> expectedVertices = Arrays.asList(vertices);
+        List<Vector2D> actualVertices = path.getVertices();
+
+        String msg = "Expected path vertices to equal " + expectedVertices + " but was " + actualVertices;
+        Assert.assertEquals(msg, expectedVertices.size(), actualVertices.size());
+
+        for (int i=0; i<expectedVertices.size(); ++i) {
+            EuclideanTestUtils.assertCoordinatesEqual(expectedVertices.get(i), actualVertices.get(i), TEST_EPS);
+        }
+    }
+}
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/LineTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/LineTest.java
index 3907ec1..45e8701 100644
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/LineTest.java
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/LineTest.java
@@ -19,11 +19,13 @@
 import org.apache.commons.geometry.core.Geometry;
 import org.apache.commons.geometry.core.GeometryTestUtils;
 import org.apache.commons.geometry.core.exception.GeometryValueException;
-import org.apache.commons.geometry.core.partitioning.Transform;
 import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
 import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
 import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
+import org.apache.commons.geometry.euclidean.oned.AffineTransformMatrix1D;
+import org.apache.commons.geometry.euclidean.oned.Interval;
 import org.apache.commons.geometry.euclidean.oned.Vector1D;
+import org.apache.commons.geometry.euclidean.twod.Line.SubspaceTransform;
 import org.apache.commons.numbers.angle.PlaneAngleRadians;
 import org.junit.Assert;
 import org.junit.Test;
@@ -234,15 +236,6 @@
     }
 
     @Test
-    public void testCopySelf() {
-        // arrange
-        Line line = Line.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_X, TEST_PRECISION);
-
-        // act/assert
-        Assert.assertSame(line, line.copySelf());
-    }
-
-    @Test
     public void testReverse() {
         // arrange
         Vector2D pt = Vector2D.of(0, 1);
@@ -262,15 +255,27 @@
     }
 
     @Test
-    public void testToSubSpace() {
+    public void testAbscissa() {
+        // arrange
+        Line line = Line.fromPoints(Vector2D.of(-2, -2), Vector2D.of(2, 1), TEST_PRECISION);
+
+        // act/assert
+        Assert.assertEquals(0.0, line.abscissa(Vector2D.of(-3,  4)), TEST_EPS);
+        Assert.assertEquals(0.0, line.abscissa(Vector2D.of( 3, -4)), TEST_EPS);
+        Assert.assertEquals(5.0, line.abscissa(Vector2D.of(7, -1)), TEST_EPS);
+        Assert.assertEquals(-5.0, line.abscissa(Vector2D.of(-1, -7)), TEST_EPS);
+    }
+
+    @Test
+    public void testToSubspace() {
         // arrange
         Line line = Line.fromPoints(Vector2D.of(2, 1), Vector2D.of(-2, -2), TEST_PRECISION);
 
         // act/assert
-        Assert.assertEquals(0.0, line.toSubSpace(Vector2D.of(-3,  4)).getX(), TEST_EPS);
-        Assert.assertEquals(0.0, line.toSubSpace(Vector2D.of( 3, -4)).getX(), TEST_EPS);
-        Assert.assertEquals(-5.0, line.toSubSpace(Vector2D.of(7, -1)).getX(), TEST_EPS);
-        Assert.assertEquals(5.0, line.toSubSpace(Vector2D.of(-1, -7)).getX(), TEST_EPS);
+        Assert.assertEquals(0.0, line.toSubspace(Vector2D.of(-3,  4)).getX(), TEST_EPS);
+        Assert.assertEquals(0.0, line.toSubspace(Vector2D.of( 3, -4)).getX(), TEST_EPS);
+        Assert.assertEquals(-5.0, line.toSubspace(Vector2D.of(7, -1)).getX(), TEST_EPS);
+        Assert.assertEquals(5.0, line.toSubspace(Vector2D.of(-1, -7)).getX(), TEST_EPS);
     }
 
     @Test
@@ -380,6 +385,27 @@
     }
 
     @Test
+    public void testAngle() {
+        // arrange
+        Line a = Line.fromPointAndAngle(Vector2D.ZERO, Geometry.ZERO_PI, TEST_PRECISION);
+        Line b = Line.fromPointAndAngle(Vector2D.of(1, 4), Geometry.PI, TEST_PRECISION);
+        Line c = Line.fromPointAndDirection(Vector2D.of(1, 1), Vector2D.of(2, 2), TEST_PRECISION);
+
+        // act/assert
+        Assert.assertEquals(Geometry.ZERO_PI, a.angle(a), TEST_EPS);
+        Assert.assertEquals(-Geometry.PI, a.angle(b), TEST_EPS);
+        Assert.assertEquals(0.25 * Geometry.PI, a.angle(c), TEST_EPS);
+
+        Assert.assertEquals(Geometry.ZERO_PI, b.angle(b), TEST_EPS);
+        Assert.assertEquals(-Geometry.PI, b.angle(a), TEST_EPS);
+        Assert.assertEquals(-0.75 * Geometry.PI, b.angle(c), TEST_EPS);
+
+        Assert.assertEquals(Geometry.ZERO_PI, c.angle(c), TEST_EPS);
+        Assert.assertEquals(-0.25 * Geometry.PI, c.angle(a), TEST_EPS);
+        Assert.assertEquals(0.75 * Geometry.PI, c.angle(b), TEST_EPS);
+    }
+
+    @Test
     public void testProject() {
         // --- arrange
         Line xAxis = Line.fromPointAndDirection(Vector2D.ZERO, Vector2D.Unit.PLUS_X, TEST_PRECISION);
@@ -407,12 +433,12 @@
     }
 
     @Test
-    public void testWholeHyperplane() {
+    public void testSpan() {
         // arrange
         Line line = Line.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_X, TEST_PRECISION);
 
         // act
-        SubLine result = line.wholeHyperplane();
+        Segment result = line.span();
 
         // assert
         Assert.assertSame(line, result.getHyperplane());
@@ -420,20 +446,148 @@
     }
 
     @Test
-    public void testWholeSpace() {
+    public void testSegment_interval() {
         // arrange
-        Line line = Line.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_X, TEST_PRECISION);
+        Line line = Line.fromPointAndAngle(Vector2D.of(0, 1), Geometry.ZERO_PI, TEST_PRECISION);
+        Interval interval = Interval.of(1, 2, TEST_PRECISION);
 
         // act
-        PolygonsSet result = line.wholeSpace();
+        Segment segment = line.segment(interval);
 
         // assert
-        GeometryTestUtils.assertPositiveInfinity(result.getSize());
-        Assert.assertSame(TEST_PRECISION, result.getPrecision());
+        Assert.assertSame(line, segment.getLine());
+        Assert.assertSame(interval, segment.getSubspaceRegion());
     }
 
     @Test
-    public void testGetOffset_parallelLines() {
+    public void testSegment_doubles() {
+        // arrange
+        Line line = Line.fromPointAndAngle(Vector2D.of(0, 1), Geometry.ZERO_PI, TEST_PRECISION);
+
+        // act
+        Segment segment = line.segment(1, 2);
+
+        // assert
+        Assert.assertSame(line, segment.getLine());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1, 1), segment.getStartPoint(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(2, 1), segment.getEndPoint(), TEST_EPS);
+    }
+
+    @Test
+    public void testSegment_pointsOnLine() {
+        // arrange
+        Line line = Line.fromPointAndAngle(Vector2D.of(0, 1), Geometry.ZERO_PI, TEST_PRECISION);
+
+        // act
+        Segment segment = line.segment(Vector2D.of(3, 1), Vector2D.of(2, 1));
+
+        // assert
+        Assert.assertSame(line, segment.getLine());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(2, 1), segment.getStartPoint(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(3, 1), segment.getEndPoint(), TEST_EPS);
+    }
+
+    @Test
+    public void testSegment_pointsProjectedOnLine() {
+        // arrange
+        Line line = Line.fromPointAndAngle(Vector2D.of(0, 1), Geometry.ZERO_PI, TEST_PRECISION);
+
+        // act
+        Segment segment = line.segment(Vector2D.of(-3, 2), Vector2D.of(2, -1));
+
+        // assert
+        Assert.assertSame(line, segment.getLine());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(-3, 1), segment.getStartPoint(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(2, 1), segment.getEndPoint(), TEST_EPS);
+    }
+
+    @Test
+    public void testSegmentTo_pointOnLine() {
+        // arrange
+        Line line = Line.fromPointAndAngle(Vector2D.of(0, 1), Geometry.PI, TEST_PRECISION);
+
+        // act
+        Segment segment = line.segmentTo(Vector2D.of(-3, 1));
+
+        // assert
+        Assert.assertSame(line, segment.getLine());
+        Assert.assertTrue(segment.isInfinite());
+        Assert.assertNull(segment.getStartPoint());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(-3, 1), segment.getEndPoint(), TEST_EPS);
+
+        Assert.assertTrue(segment.contains(Vector2D.of(1, 1)));
+        Assert.assertFalse(segment.contains(Vector2D.of(-4, 1)));
+    }
+
+    @Test
+    public void testSegmentTo_pointProjectedOnLine() {
+        // arrange
+        Line line = Line.fromPointAndAngle(Vector2D.of(0, 1), Geometry.PI, TEST_PRECISION);
+
+        // act
+        Segment segment = line.segmentTo(Vector2D.of(-3, 5));
+
+        // assert
+        Assert.assertSame(line, segment.getLine());
+        Assert.assertTrue(segment.isInfinite());
+        Assert.assertNull(segment.getStartPoint());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(-3, 1), segment.getEndPoint(), TEST_EPS);
+
+        Assert.assertTrue(segment.contains(Vector2D.of(1, 1)));
+        Assert.assertFalse(segment.contains(Vector2D.of(-4, 1)));
+    }
+
+    @Test
+    public void testSegmentFrom_pointOnLine() {
+        // arrange
+        Line line = Line.fromPointAndAngle(Vector2D.of(0, 1), Geometry.PI, TEST_PRECISION);
+
+        // act
+        Segment segment = line.segmentFrom(Vector2D.of(-3, 1));
+
+        // assert
+        Assert.assertSame(line, segment.getLine());
+        Assert.assertTrue(segment.isInfinite());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(-3, 1), segment.getStartPoint(), TEST_EPS);
+        Assert.assertNull(segment.getEndPoint());
+
+        Assert.assertFalse(segment.contains(Vector2D.of(1, 1)));
+        Assert.assertTrue(segment.contains(Vector2D.of(-4, 1)));
+    }
+
+    @Test
+    public void testSegmentFrom_pointProjectedOnLine() {
+        // arrange
+        Line line = Line.fromPointAndAngle(Vector2D.of(0, 1), Geometry.PI, TEST_PRECISION);
+
+        // act
+        Segment segment = line.segmentFrom(Vector2D.of(-3, 5));
+
+        // assert
+        Assert.assertSame(line, segment.getLine());
+        Assert.assertTrue(segment.isInfinite());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(-3, 1), segment.getStartPoint(), TEST_EPS);
+        Assert.assertNull(segment.getEndPoint());
+
+        Assert.assertFalse(segment.contains(Vector2D.of(1, 1)));
+        Assert.assertTrue(segment.contains(Vector2D.of(-4, 1)));
+    }
+
+    @Test
+    public void testSubline() {
+        // arrange
+        Line line = Line.fromPointAndAngle(Vector2D.of(0, 1), Geometry.ZERO_PI, TEST_PRECISION);
+
+        // act
+        SubLine subline = line.subline();
+
+        // assert
+        Assert.assertSame(line, subline.getLine());
+        Assert.assertTrue(subline.isEmpty());
+    }
+
+    @Test
+    public void testOffset_parallelLines() {
         // arrange
         double dist = Math.sin(Math.atan2(2, 1));
 
@@ -443,35 +597,35 @@
         Line d = Line.fromPoints(Vector2D.of(1, 0), Vector2D.of(0, -2), TEST_PRECISION);
 
         // act/assert
-        Assert.assertEquals(-dist, a.getOffset(b), TEST_EPS);
-        Assert.assertEquals(dist, b.getOffset(a), TEST_EPS);
+        Assert.assertEquals(-dist, a.offset(b), TEST_EPS);
+        Assert.assertEquals(dist, b.offset(a), TEST_EPS);
 
-        Assert.assertEquals(dist, a.getOffset(c), TEST_EPS);
-        Assert.assertEquals(-dist, c.getOffset(a), TEST_EPS);
+        Assert.assertEquals(dist, a.offset(c), TEST_EPS);
+        Assert.assertEquals(-dist, c.offset(a), TEST_EPS);
 
-        Assert.assertEquals(3 * dist, a.getOffset(d), TEST_EPS);
-        Assert.assertEquals(3 * dist, d.getOffset(a), TEST_EPS);
+        Assert.assertEquals(3 * dist, a.offset(d), TEST_EPS);
+        Assert.assertEquals(3 * dist, d.offset(a), TEST_EPS);
     }
 
     @Test
-    public void testGetOffset_coincidentLines() {
+    public void testOffset_coincidentLines() {
         // arrange
         Line a = Line.fromPoints(Vector2D.of(-2, 0), Vector2D.of(0, 4), TEST_PRECISION);
         Line b = Line.fromPoints(Vector2D.of(-2, 0), Vector2D.of(0, 4), TEST_PRECISION);
         Line c = b.reverse();
 
         // act/assert
-        Assert.assertEquals(0, a.getOffset(a), TEST_EPS);
+        Assert.assertEquals(0, a.offset(a), TEST_EPS);
 
-        Assert.assertEquals(0, a.getOffset(b), TEST_EPS);
-        Assert.assertEquals(0, b.getOffset(a), TEST_EPS);
+        Assert.assertEquals(0, a.offset(b), TEST_EPS);
+        Assert.assertEquals(0, b.offset(a), TEST_EPS);
 
-        Assert.assertEquals(0, a.getOffset(c), TEST_EPS);
-        Assert.assertEquals(0, c.getOffset(a), TEST_EPS);
+        Assert.assertEquals(0, a.offset(c), TEST_EPS);
+        Assert.assertEquals(0, c.offset(a), TEST_EPS);
     }
 
     @Test
-    public void testGetOffset_nonParallelLines() {
+    public void testOffset_nonParallelLines() {
         // arrange
         Line a = Line.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_X, TEST_PRECISION);
         Line b = Line.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_Y, TEST_PRECISION);
@@ -479,38 +633,38 @@
         Line d = Line.fromPoints(Vector2D.of(1, 0), Vector2D.of(0, 4), TEST_PRECISION);
 
         // act/assert
-        Assert.assertEquals(0, a.getOffset(b), TEST_EPS);
-        Assert.assertEquals(0, b.getOffset(a), TEST_EPS);
+        Assert.assertEquals(0, a.offset(b), TEST_EPS);
+        Assert.assertEquals(0, b.offset(a), TEST_EPS);
 
-        Assert.assertEquals(0, a.getOffset(c), TEST_EPS);
-        Assert.assertEquals(0, c.getOffset(a), TEST_EPS);
+        Assert.assertEquals(0, a.offset(c), TEST_EPS);
+        Assert.assertEquals(0, c.offset(a), TEST_EPS);
 
-        Assert.assertEquals(0, a.getOffset(d), TEST_EPS);
-        Assert.assertEquals(0, d.getOffset(a), TEST_EPS);
+        Assert.assertEquals(0, a.offset(d), TEST_EPS);
+        Assert.assertEquals(0, d.offset(a), TEST_EPS);
     }
 
     @Test
-    public void testGetOffset_point() {
+    public void testOffset_point() {
         // arrange
         Line line = Line.fromPoints(Vector2D.of(-1, 0), Vector2D.of(0, 2), TEST_PRECISION);
         Line reversed = line.reverse();
 
         // act/assert
-        Assert.assertEquals(0.0, line.getOffset(Vector2D.of(-0.5, 1)), TEST_EPS);
-        Assert.assertEquals(0.0, line.getOffset(Vector2D.of(-1.5, -1)), TEST_EPS);
-        Assert.assertEquals(0.0, line.getOffset(Vector2D.of(0.5, 3)), TEST_EPS);
+        Assert.assertEquals(0.0, line.offset(Vector2D.of(-0.5, 1)), TEST_EPS);
+        Assert.assertEquals(0.0, line.offset(Vector2D.of(-1.5, -1)), TEST_EPS);
+        Assert.assertEquals(0.0, line.offset(Vector2D.of(0.5, 3)), TEST_EPS);
 
         double d = Math.sin(Math.atan2(2, 1));
 
-        Assert.assertEquals(d, line.getOffset(Vector2D.ZERO), TEST_EPS);
-        Assert.assertEquals(-d, line.getOffset(Vector2D.of(-1, 2)), TEST_EPS);
+        Assert.assertEquals(d, line.offset(Vector2D.ZERO), TEST_EPS);
+        Assert.assertEquals(-d, line.offset(Vector2D.of(-1, 2)), TEST_EPS);
 
-        Assert.assertEquals(-d, reversed.getOffset(Vector2D.ZERO), TEST_EPS);
-        Assert.assertEquals(d, reversed.getOffset(Vector2D.of(-1, 2)), TEST_EPS);
+        Assert.assertEquals(-d, reversed.offset(Vector2D.ZERO), TEST_EPS);
+        Assert.assertEquals(d, reversed.offset(Vector2D.of(-1, 2)), TEST_EPS);
     }
 
     @Test
-    public void testGetOffset_point_permute() {
+    public void testOffset_point_permute() {
         // arrange
         Line line = Line.fromPoints(Vector2D.of(-1, 0), Vector2D.of(0, 2), TEST_PRECISION);
         Vector2D lineOrigin = line.getOrigin();
@@ -519,7 +673,7 @@
             Vector2D pt = Vector2D.of(x, y);
 
             // act
-            double offset = line.getOffset(pt);
+            double offset = line.offset(pt);
 
             // arrange
             Vector2D vec = lineOrigin.vectorTo(pt).reject(line.getDirection());
@@ -531,7 +685,7 @@
     }
 
     @Test
-    public void testSameOrientationAs() {
+    public void testSimilarOrientation() {
         // arrange
         Line a = Line.fromPointAndAngle(Vector2D.ZERO, Geometry.ZERO_PI, TEST_PRECISION);
         Line b = Line.fromPointAndAngle(Vector2D.of(4, 5), Geometry.ZERO_PI, TEST_PRECISION);
@@ -543,38 +697,38 @@
         Line g = Line.fromPointAndAngle(Vector2D.of(6, -3), -0.8 * Geometry.PI, TEST_PRECISION);
 
         // act/assert
-        Assert.assertTrue(a.sameOrientationAs(a));
-        Assert.assertTrue(a.sameOrientationAs(b));
-        Assert.assertTrue(b.sameOrientationAs(a));
-        Assert.assertTrue(a.sameOrientationAs(c));
-        Assert.assertTrue(c.sameOrientationAs(a));
-        Assert.assertTrue(a.sameOrientationAs(d));
-        Assert.assertTrue(d.sameOrientationAs(a));
+        Assert.assertTrue(a.similarOrientation(a));
+        Assert.assertTrue(a.similarOrientation(b));
+        Assert.assertTrue(b.similarOrientation(a));
+        Assert.assertTrue(a.similarOrientation(c));
+        Assert.assertTrue(c.similarOrientation(a));
+        Assert.assertTrue(a.similarOrientation(d));
+        Assert.assertTrue(d.similarOrientation(a));
 
-        Assert.assertFalse(c.sameOrientationAs(d));
-        Assert.assertFalse(d.sameOrientationAs(c));
+        Assert.assertFalse(c.similarOrientation(d));
+        Assert.assertFalse(d.similarOrientation(c));
 
-        Assert.assertTrue(e.sameOrientationAs(f));
-        Assert.assertTrue(f.sameOrientationAs(e));
-        Assert.assertTrue(e.sameOrientationAs(g));
-        Assert.assertTrue(g.sameOrientationAs(e));
+        Assert.assertTrue(e.similarOrientation(f));
+        Assert.assertTrue(f.similarOrientation(e));
+        Assert.assertTrue(e.similarOrientation(g));
+        Assert.assertTrue(g.similarOrientation(e));
 
-        Assert.assertFalse(a.sameOrientationAs(e));
-        Assert.assertFalse(e.sameOrientationAs(a));
+        Assert.assertFalse(a.similarOrientation(e));
+        Assert.assertFalse(e.similarOrientation(a));
     }
 
     @Test
-    public void testSameOrientationAs_orthogonal() {
+    public void testSimilarOrientation_orthogonal() {
         // arrange
         Line a = Line.fromPointAndDirection(Vector2D.ZERO, Vector2D.Unit.PLUS_X, TEST_PRECISION);
         Line b = Line.fromPointAndDirection(Vector2D.of(4, 5), Vector2D.Unit.PLUS_Y, TEST_PRECISION);
         Line c = Line.fromPointAndDirection(Vector2D.of(-4, -5), Vector2D.Unit.MINUS_Y, TEST_PRECISION);
 
         // act/assert
-        Assert.assertTrue(a.sameOrientationAs(b));
-        Assert.assertTrue(b.sameOrientationAs(a));
-        Assert.assertTrue(a.sameOrientationAs(c));
-        Assert.assertTrue(c.sameOrientationAs(a));
+        Assert.assertTrue(a.similarOrientation(b));
+        Assert.assertTrue(b.similarOrientation(a));
+        Assert.assertTrue(a.similarOrientation(c));
+        Assert.assertTrue(c.similarOrientation(a));
     }
 
     @Test
@@ -676,8 +830,8 @@
                 Vector2D point = line.pointAt(abscissa, offset);
 
                 // assert
-                Assert.assertEquals(abscissa, line.toSubSpace(point).getX(), TEST_EPS);
-                Assert.assertEquals(offset, line.getOffset(point), TEST_EPS);
+                Assert.assertEquals(abscissa, line.toSubspace(point).getX(), TEST_EPS);
+                Assert.assertEquals(offset, line.offset(point), TEST_EPS);
             }
         }
     }
@@ -910,6 +1064,97 @@
     }
 
     @Test
+    public void testSubspaceTransform() {
+        // arrange
+        Line line = Line.fromPoints(Vector2D.of(1, 0), Vector2D.of(1, 1), TEST_PRECISION);
+
+        // act/assert
+        checkSubspaceTransform(line.subspaceTransform(AffineTransformMatrix2D.createScale(2, 3)),
+                Vector2D.of(2, 0), Vector2D.Unit.PLUS_Y,
+                Vector2D.of(2, 0), Vector2D.of(2, 3));
+
+        checkSubspaceTransform(line.subspaceTransform(AffineTransformMatrix2D.createTranslation(2, 3)),
+                Vector2D.of(3, 0), Vector2D.Unit.PLUS_Y,
+                Vector2D.of(3, 3), Vector2D.of(3, 4));
+
+        checkSubspaceTransform(line.subspaceTransform(AffineTransformMatrix2D.createRotation(Geometry.HALF_PI)),
+                Vector2D.of(0, 1), Vector2D.Unit.MINUS_X,
+                Vector2D.of(0, 1), Vector2D.of(-1, 1));
+    }
+
+    private void checkSubspaceTransform(SubspaceTransform st, Vector2D origin, Vector2D dir, Vector2D tZero, Vector2D tOne) {
+
+        Line line = st.getLine();
+        AffineTransformMatrix1D transform = st.getTransform();
+
+        checkLine(line, origin, dir);
+
+        EuclideanTestUtils.assertCoordinatesEqual(tZero, line.toSpace(transform.apply(Vector1D.ZERO)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(tOne, line.toSpace(transform.apply(Vector1D.Unit.PLUS)), TEST_EPS);
+    }
+
+    @Test
+    public void testSubspaceTransform_transformsPointsCorrectly() {
+        // arrange
+        Line line = Line.fromPointAndDirection(Vector2D.of(1, 0), Vector2D.of(1, 1), TEST_PRECISION);
+
+        EuclideanTestUtils.permuteSkipZero(-2, 2, 0.5, (a, b) -> {
+            // create a somewhat complicate transform to try to hit all of the edge cases
+            AffineTransformMatrix2D transform = AffineTransformMatrix2D.createTranslation(Vector2D.of(a, b))
+                    .rotate(a * b)
+                    .scale(0.1, 4);
+
+            // act
+            SubspaceTransform st = line.subspaceTransform(transform);
+
+            // assert
+            for (double x=-5.0; x<=5.0; x+=1) {
+                Vector1D subPt = Vector1D.of(x);
+                Vector2D expected = transform.apply(line.toSpace(subPt));
+                Vector2D actual = st.getLine().toSpace(
+                        st.getTransform().apply(subPt));
+
+                EuclideanTestUtils.assertCoordinatesEqual(expected, actual, TEST_EPS);
+            };
+        });
+    }
+
+    @Test
+    public void testEq() {
+        // arrange
+        DoublePrecisionContext precision1 = new EpsilonDoublePrecisionContext(1e-3);
+        DoublePrecisionContext precision2 = new EpsilonDoublePrecisionContext(1e-2);
+
+        Vector2D p = Vector2D.of(1, 2);
+        double angle = 1.0;
+
+        Line a = Line.fromPointAndAngle(p, angle, precision1);
+        Line b = Line.fromPointAndAngle(Vector2D.ZERO, angle, precision1);
+        Line c = Line.fromPointAndAngle(p, angle + 1.0, precision1);
+        Line d = Line.fromPointAndAngle(p, angle, precision2);
+
+        Line e = Line.fromPointAndAngle(p, angle, precision1);
+        Line f = Line.fromPointAndAngle(p.add(Vector2D.of(1e-4, 1e-4)), angle, precision1);
+        Line g = Line.fromPointAndAngle(p, angle + 1e-4, precision1);
+
+        // act/assert
+        Assert.assertTrue(a.eq(a));
+
+        Assert.assertTrue(a.eq(e));
+        Assert.assertTrue(e.eq(a));
+
+        Assert.assertTrue(a.eq(f));
+        Assert.assertTrue(f.eq(a));
+
+        Assert.assertTrue(a.eq(g));
+        Assert.assertTrue(g.eq(a));
+
+        Assert.assertFalse(a.eq(b));
+        Assert.assertFalse(a.eq(c));
+        Assert.assertFalse(a.eq(d));
+    }
+
+    @Test
     public void testHashCode() {
         // arrange
         DoublePrecisionContext precision1 = new EpsilonDoublePrecisionContext(1e-4);
@@ -977,25 +1222,6 @@
         Assert.assertTrue(str.contains("direction= (1.0, 0.0)"));
     }
 
-    @Test
-    public void testLineTransform() {
-
-        Line l1 = Line.fromPoints(Vector2D.of(1.0 ,1.0), Vector2D.of(4.0 ,1.0), TEST_PRECISION);
-        Transform<Vector2D, Vector1D> t1 =
-            Line.getTransform(Vector2D.of(0.0, 0.5), Vector2D.of(-1.0, 0.0), Vector2D.of(1.0, 1.5));
-        Assert.assertEquals(0.5 * Math.PI,
-                            ((Line) t1.apply(l1)).getAngle(),
-                            1.0e-10);
-
-        Line l2 = Line.fromPoints(Vector2D.of(0.0, 0.0), Vector2D.of(1.0, 1.0), TEST_PRECISION);
-        Transform<Vector2D, Vector1D> t2 =
-            Line.getTransform(Vector2D.of(0.0, 0.5), Vector2D.of(-1.0, 0.0), Vector2D.of(1.0, 1.5));
-        Assert.assertEquals(Math.atan2(1.0, -2.0),
-                            ((Line) t2.apply(l2)).getAngle(),
-                            1.0e-10);
-
-    }
-
     /**
      * Check that the line has the given defining properties.
      * @param line
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/NestedLoopsTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/NestedLoopsTest.java
deleted file mode 100644
index 05a0fb4..0000000
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/NestedLoopsTest.java
+++ /dev/null
@@ -1,73 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.twod;
-
-import java.lang.reflect.Field;
-import java.util.Arrays;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
-import org.junit.Assert;
-import org.junit.Test;
-
-public class NestedLoopsTest {
-
-    private static final double TEST_EPS = 1e-10;
-
-    private static final DoublePrecisionContext TEST_PRECISION =
-            new EpsilonDoublePrecisionContext(TEST_EPS);
-
-    @SuppressWarnings("unchecked")
-    @Test
-    public void testNestedLoops() throws Exception {
-        Vector2D oneOne = Vector2D.of(1.0, 1.0);
-        Vector2D oneNegativeOne = Vector2D.of(1.0, -1.0);
-        Vector2D negativeOneNegativeOne = Vector2D.of(-1.0, -1.0);
-        Vector2D negativeOneOne = Vector2D.of(-1.0, 1.0);
-        Vector2D origin = Vector2D.of(0, 0);
-
-        Vector2D [] vertices = new Vector2D[]{
-                oneOne,
-                oneNegativeOne,
-                negativeOneNegativeOne,
-                negativeOneOne,
-                origin
-        };
-
-        NestedLoops nestedLoops = new NestedLoops(TEST_PRECISION);
-        nestedLoops.add(vertices);
-        nestedLoops.correctOrientation();
-
-        Field surroundedField = nestedLoops.getClass().getDeclaredField("surrounded");
-        Field loopField = nestedLoops.getClass().getDeclaredField("loop");
-        surroundedField.setAccessible(Boolean.TRUE);
-        loopField.setAccessible(Boolean.TRUE);
-        List<NestedLoops> surrounded = (List<NestedLoops>) surroundedField.get(nestedLoops);
-        Vector2D[] loop = (Vector2D []) loopField.get(surrounded.get(0));
-        Set<Vector2D> vertexSet = new HashSet<>(Arrays.asList(loop));
-        Assert.assertTrue(vertexSet.contains(oneOne));
-        Assert.assertTrue(vertexSet.contains(oneNegativeOne));
-        Assert.assertTrue(vertexSet.contains(negativeOneNegativeOne));
-        Assert.assertTrue(vertexSet.contains(negativeOneOne));
-        Assert.assertTrue(vertexSet.contains(origin));
-    }
-
-}
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/PolarCoordinatesTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/PolarCoordinatesTest.java
index 81e94dc..4f22f1c 100644
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/PolarCoordinatesTest.java
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/PolarCoordinatesTest.java
@@ -173,6 +173,26 @@
     }
 
     @Test
+    public void testIsFinite() {
+        // act/assert
+        Assert.assertTrue(PolarCoordinates.of(1, 0).isFinite());
+        Assert.assertTrue(PolarCoordinates.of(1, Geometry.PI).isFinite());
+
+        Assert.assertFalse(PolarCoordinates.of(Double.NaN, Double.NaN).isFinite());
+
+        Assert.assertFalse(PolarCoordinates.of(Double.POSITIVE_INFINITY, 0).isFinite());
+        Assert.assertFalse(PolarCoordinates.of(Double.NEGATIVE_INFINITY, 0).isFinite());
+        Assert.assertFalse(PolarCoordinates.of(Double.NEGATIVE_INFINITY, Double.NaN).isFinite());
+
+        Assert.assertFalse(PolarCoordinates.of(0, Double.POSITIVE_INFINITY).isFinite());
+        Assert.assertFalse(PolarCoordinates.of(0, Double.NEGATIVE_INFINITY).isFinite());
+        Assert.assertFalse(PolarCoordinates.of(Double.NaN, Double.NEGATIVE_INFINITY).isFinite());
+
+        Assert.assertFalse(PolarCoordinates.of(Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY).isFinite());
+        Assert.assertFalse(PolarCoordinates.of(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY).isFinite());
+    }
+
+    @Test
     public void testHashCode() {
         // arrange
         PolarCoordinates a = PolarCoordinates.of(1, 2);
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/PolygonsSetTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/PolygonsSetTest.java
deleted file mode 100644
index 53be220..0000000
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/PolygonsSetTest.java
+++ /dev/null
@@ -1,1849 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.twod;
-
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-
-import org.apache.commons.geometry.core.partitioning.BSPTree;
-import org.apache.commons.geometry.core.partitioning.BSPTreeVisitor;
-import org.apache.commons.geometry.core.partitioning.BoundaryProjection;
-import org.apache.commons.geometry.core.partitioning.Hyperplane;
-import org.apache.commons.geometry.core.partitioning.Region;
-import org.apache.commons.geometry.core.partitioning.Region.Location;
-import org.apache.commons.geometry.core.partitioning.RegionFactory;
-import org.apache.commons.geometry.core.partitioning.SubHyperplane;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
-import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
-import org.apache.commons.geometry.euclidean.oned.Interval;
-import org.apache.commons.geometry.euclidean.oned.IntervalsSet;
-import org.apache.commons.geometry.euclidean.oned.Vector1D;
-import org.apache.commons.numbers.core.Precision;
-import org.junit.Assert;
-import org.junit.Test;
-
-public class PolygonsSetTest {
-
-    private static final double TEST_EPS = 1e-10;
-
-    private static final DoublePrecisionContext TEST_PRECISION =
-            new EpsilonDoublePrecisionContext(TEST_EPS);
-
-    @Test
-    public void testFull() {
-        // act
-        PolygonsSet poly = new PolygonsSet(TEST_PRECISION);
-
-        // assert
-        Assert.assertSame(TEST_PRECISION, poly.getPrecision());
-        EuclideanTestUtils.assertPositiveInfinity(poly.getSize());
-        Assert.assertEquals(0.0, poly.getBoundarySize(), TEST_EPS);
-        Assert.assertEquals(0, poly.getVertices().length);
-        Assert.assertFalse(poly.isEmpty());
-        Assert.assertTrue(poly.isFull());
-        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.NaN, poly.getBarycenter(), TEST_EPS);
-
-        checkPoints(Region.Location.INSIDE, poly,
-                Vector2D.of(Double.NEGATIVE_INFINITY, Double.NEGATIVE_INFINITY),
-                Vector2D.ZERO,
-                Vector2D.of(Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY));
-
-        for (double y = -1; y < 1; y += 0.1) {
-            for (double x = -1; x < 1; x += 0.1) {
-                EuclideanTestUtils.assertNegativeInfinity(poly.projectToBoundary(Vector2D.of(x, y)).getOffset());
-            }
-        }
-    }
-
-    @Test
-    public void testEmpty() {
-        // act
-        PolygonsSet poly = (PolygonsSet) new RegionFactory<Vector2D>().getComplement(new PolygonsSet(TEST_PRECISION));
-
-        // assert
-        Assert.assertSame(TEST_PRECISION, poly.getPrecision());
-        Assert.assertEquals(0.0, poly.getSize(), TEST_EPS);
-        Assert.assertEquals(0.0, poly.getBoundarySize(), TEST_EPS);
-        Assert.assertEquals(0, poly.getVertices().length);
-        Assert.assertTrue(poly.isEmpty());
-        Assert.assertFalse(poly.isFull());
-        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.NaN, poly.getBarycenter(), TEST_EPS);
-
-        checkPoints(Region.Location.OUTSIDE, poly,
-                Vector2D.of(Double.NEGATIVE_INFINITY, Double.NEGATIVE_INFINITY),
-                Vector2D.ZERO,
-                Vector2D.of(Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY));
-
-
-        for (double y = -1; y < 1; y += 0.1) {
-            for (double x = -1; x < 1; x += 0.1) {
-                EuclideanTestUtils.assertPositiveInfinity(poly.projectToBoundary(Vector2D.of(x, y)).getOffset());
-            }
-        }
-    }
-
-    @Test
-    public void testInfiniteLines_single() {
-        // arrange
-        Line line = Line.fromPoints(Vector2D.of(0, 0), Vector2D.of(1, 1), TEST_PRECISION);
-
-        List<SubHyperplane<Vector2D>> boundaries = new ArrayList<SubHyperplane<Vector2D>>();
-        boundaries.add(line.wholeHyperplane());
-
-        // act
-        PolygonsSet poly = new PolygonsSet(boundaries, TEST_PRECISION);
-
-        // assert
-        Assert.assertSame(TEST_PRECISION, poly.getPrecision());
-        EuclideanTestUtils.assertPositiveInfinity(poly.getSize());
-        EuclideanTestUtils.assertPositiveInfinity(poly.getBoundarySize());
-        Assert.assertFalse(poly.isEmpty());
-        Assert.assertFalse(poly.isFull());
-        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.NaN, poly.getBarycenter(), TEST_EPS);
-
-        checkVertexLoopsEquivalent(new Vector2D[][] {
-            {
-                null,
-                line.toSpace(Vector1D.of(-Float.MAX_VALUE)),
-                line.toSpace(Vector1D.of(Float.MAX_VALUE))
-            }
-        }, poly.getVertices());
-
-        checkPoints(Region.Location.OUTSIDE, poly,
-                Vector2D.of(1, -1),
-                Vector2D.of(Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY));
-        checkPoints(Region.Location.INSIDE, poly,
-                Vector2D.of(-1, 1),
-                Vector2D.of(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY));
-        checkPoints(Region.Location.BOUNDARY, poly, Vector2D.ZERO);
-    }
-
-    @Test
-    public void testInfiniteLines_twoIntersecting() {
-        // arrange
-        Line line1 = Line.fromPoints(Vector2D.of(0, 0), Vector2D.of(1, 1), TEST_PRECISION);
-        Line line2 = Line.fromPoints(Vector2D.of(1, -1), Vector2D.of(0, 0), TEST_PRECISION);
-
-        List<SubHyperplane<Vector2D>> boundaries = new ArrayList<SubHyperplane<Vector2D>>();
-        boundaries.add(line1.wholeHyperplane());
-        boundaries.add(line2.wholeHyperplane());
-
-        // act
-        PolygonsSet poly = new PolygonsSet(boundaries, TEST_PRECISION);
-
-        // assert
-        Assert.assertSame(TEST_PRECISION, poly.getPrecision());
-        EuclideanTestUtils.assertPositiveInfinity(poly.getSize());
-        EuclideanTestUtils.assertPositiveInfinity(poly.getBoundarySize());
-        Assert.assertFalse(poly.isEmpty());
-        Assert.assertFalse(poly.isFull());
-        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.NaN, poly.getBarycenter(), TEST_EPS);
-
-        checkVertexLoopsEquivalent(new Vector2D[][] {
-            {
-                null,
-                line2.toSpace(Vector1D.of(-Float.MAX_VALUE)),
-                line2.toSpace(Vector1D.of(Float.MAX_VALUE))
-            }
-        }, poly.getVertices());
-
-        checkPoints(Region.Location.INSIDE, poly,
-                Vector2D.of(-1, 0),
-                Vector2D.of(-Float.MAX_VALUE, Float.MAX_VALUE / 2.0));
-        checkPoints(Region.Location.OUTSIDE, poly,
-                Vector2D.of(1, 0),
-                Vector2D.of(Float.MAX_VALUE, Float.MAX_VALUE / 2.0));
-        checkPoints(Region.Location.BOUNDARY, poly, Vector2D.ZERO);
-    }
-
-    @Test
-    public void testInfiniteLines_twoParallel_facingIn() {
-        // arrange
-        Line line1 = Line.fromPoints(Vector2D.of(1, 1), Vector2D.of(0, 1), TEST_PRECISION);
-        Line line2 = Line.fromPoints(Vector2D.of(0, -1), Vector2D.of(1, -1), TEST_PRECISION);
-
-        List<SubHyperplane<Vector2D>> boundaries = new ArrayList<SubHyperplane<Vector2D>>();
-        boundaries.add(line1.wholeHyperplane());
-        boundaries.add(line2.wholeHyperplane());
-
-        // act
-        PolygonsSet poly = new PolygonsSet(boundaries, TEST_PRECISION);
-
-        // assert
-        Assert.assertSame(TEST_PRECISION, poly.getPrecision());
-        EuclideanTestUtils.assertPositiveInfinity(poly.getSize());
-        EuclideanTestUtils.assertPositiveInfinity(poly.getBoundarySize());
-        Assert.assertFalse(poly.isEmpty());
-        Assert.assertFalse(poly.isFull());
-        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.NaN, poly.getBarycenter(), TEST_EPS);
-
-        checkVertexLoopsEquivalent(new Vector2D[][] {
-            {
-                null,
-                line1.toSpace(Vector1D.of(-Float.MAX_VALUE)),
-                line1.toSpace(Vector1D.of(Float.MAX_VALUE))
-            },
-            {
-                null,
-                line2.toSpace(Vector1D.of(-Float.MAX_VALUE)),
-                line2.toSpace(Vector1D.of(Float.MAX_VALUE))
-            }
-        }, poly.getVertices());
-
-        checkPoints(Region.Location.INSIDE, poly,
-                Vector2D.of(0, 0),
-                Vector2D.of(0, 0.9),
-                Vector2D.of(0, -0.9));
-        checkPoints(Region.Location.OUTSIDE, poly,
-                Vector2D.of(0, 1.1),
-                Vector2D.of(0, -1.1));
-        checkPoints(Region.Location.BOUNDARY, poly,
-                Vector2D.of(0, 1),
-                Vector2D.of(0, -1));
-    }
-
-    @Test
-    public void testInfiniteLines_twoParallel_facingOut() {
-        // arrange
-        Line line1 = Line.fromPoints(Vector2D.of(0, 1), Vector2D.of(1, 1), TEST_PRECISION);
-        Line line2 = Line.fromPoints(Vector2D.of(1, -1), Vector2D.of(0, -1), TEST_PRECISION);
-
-        List<SubHyperplane<Vector2D>> boundaries = new ArrayList<SubHyperplane<Vector2D>>();
-        boundaries.add(line1.wholeHyperplane());
-        boundaries.add(line2.wholeHyperplane());
-
-        // act
-        PolygonsSet poly = new PolygonsSet(boundaries, TEST_PRECISION);
-
-        // assert
-        Assert.assertSame(TEST_PRECISION, poly.getPrecision());
-        EuclideanTestUtils.assertPositiveInfinity(poly.getSize());
-        EuclideanTestUtils.assertPositiveInfinity(poly.getBoundarySize());
-        Assert.assertFalse(poly.isEmpty());
-        Assert.assertFalse(poly.isFull());
-        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.NaN, poly.getBarycenter(), TEST_EPS);
-
-        checkVertexLoopsEquivalent(new Vector2D[][] {
-            {
-                null,
-                line1.toSpace(Vector1D.of(-Float.MAX_VALUE)),
-                line1.toSpace(Vector1D.of(Float.MAX_VALUE))
-            },
-            {
-                null,
-                line2.toSpace(Vector1D.of(-Float.MAX_VALUE)),
-                line2.toSpace(Vector1D.of(Float.MAX_VALUE))
-            }
-        }, poly.getVertices());
-
-        checkPoints(Region.Location.OUTSIDE, poly,
-                Vector2D.of(0, 0),
-                Vector2D.of(0, 0.9),
-                Vector2D.of(0, -0.9));
-        checkPoints(Region.Location.INSIDE, poly,
-                Vector2D.of(0, 1.1),
-                Vector2D.of(0, -1.1));
-        checkPoints(Region.Location.BOUNDARY, poly,
-                Vector2D.of(0, 1),
-                Vector2D.of(0, -1));
-    }
-
-    @Test
-    public void testMixedFiniteAndInfiniteLines_explicitInfiniteBoundaries() {
-        // arrange
-        Line line1 = Line.fromPoints(Vector2D.of(3, 3), Vector2D.of(0, 3), TEST_PRECISION);
-        Line line2 = Line.fromPoints(Vector2D.of(0, -3), Vector2D.of(3, -3), TEST_PRECISION);
-
-        List<SubHyperplane<Vector2D>> boundaries = new ArrayList<SubHyperplane<Vector2D>>();
-        boundaries.add(line1.wholeHyperplane());
-        boundaries.add(line2.wholeHyperplane());
-        boundaries.add(buildSegment(Vector2D.of(0, 3), Vector2D.of(0, -3)));
-
-        // act
-        PolygonsSet poly = new PolygonsSet(boundaries, TEST_PRECISION);
-
-        // assert
-        Assert.assertSame(TEST_PRECISION, poly.getPrecision());
-        EuclideanTestUtils.assertPositiveInfinity(poly.getSize());
-        EuclideanTestUtils.assertPositiveInfinity(poly.getBoundarySize());
-        Assert.assertFalse(poly.isEmpty());
-        Assert.assertFalse(poly.isFull());
-        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.NaN, poly.getBarycenter(), TEST_EPS);
-
-        checkVertexLoopsEquivalent(new Vector2D[][] {
-            {
-                null,
-                Vector2D.of(1, 3), // dummy point
-                Vector2D.of(0, 3),
-                Vector2D.of(0, -3),
-                Vector2D.of(1, -3) // dummy point
-            }
-        }, poly.getVertices());
-
-        checkPoints(Region.Location.INSIDE, poly,
-                Vector2D.of(0.1, 2.9),
-                Vector2D.of(0.1, 0),
-                Vector2D.of(0.1, -2.9));
-        checkPoints(Region.Location.OUTSIDE, poly,
-                Vector2D.of(0, 3.1),
-                Vector2D.of(-0.5, 0),
-                Vector2D.of(0, -3.1));
-        checkPoints(Region.Location.BOUNDARY, poly,
-                Vector2D.of(3, 3),
-                Vector2D.of(0, 0),
-                Vector2D.of(3, -3));
-    }
-
-    // The polygon in this test is created from finite boundaries but the generated
-    // loop still begins and ends with infinite lines. This is because the boundaries
-    // used as input do not form a closed region, therefore the region itself is unclosed.
-    // In other words, the boundaries used as input only define the region, not the points
-    // returned from the getVertices() method.
-    @Test
-    public void testMixedFiniteAndInfiniteLines_impliedInfiniteBoundaries() {
-        // arrange
-        Line line = Line.fromPoints(Vector2D.of(3, 0), Vector2D.of(3, 3), TEST_PRECISION);
-
-        List<SubHyperplane<Vector2D>> boundaries = new ArrayList<SubHyperplane<Vector2D>>();
-        boundaries.add(buildSegment(Vector2D.of(0, 3), Vector2D.of(0, 0)));
-        boundaries.add(buildSegment(Vector2D.of(0, 0), Vector2D.of(3, 0)));
-        boundaries.add(new SubLine(line, new IntervalsSet(0, Double.POSITIVE_INFINITY, TEST_PRECISION)));
-
-        // act
-        PolygonsSet poly = new PolygonsSet(boundaries, TEST_PRECISION);
-
-        // assert
-        Assert.assertSame(TEST_PRECISION, poly.getPrecision());
-        EuclideanTestUtils.assertPositiveInfinity(poly.getSize());
-        EuclideanTestUtils.assertPositiveInfinity(poly.getBoundarySize());
-        Assert.assertFalse(poly.isEmpty());
-        Assert.assertFalse(poly.isFull());
-        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.NaN, poly.getBarycenter(), TEST_EPS);
-
-        checkVertexLoopsEquivalent(new Vector2D[][] {
-            {
-                null,
-                Vector2D.of(0, 1), // dummy point
-                Vector2D.of(0, 0),
-                Vector2D.of(3, 0),
-                Vector2D.of(3, 1) // dummy point
-            }
-        }, poly.getVertices());
-
-        checkPoints(Region.Location.INSIDE, poly,
-                Vector2D.of(0.1, Float.MAX_VALUE),
-                Vector2D.of(0.1, 0.1),
-                Vector2D.of(1.5, 0.1),
-                Vector2D.of(2.9, 0.1),
-                Vector2D.of(2.9, Float.MAX_VALUE));
-        checkPoints(Region.Location.OUTSIDE, poly,
-                Vector2D.of(-0.1, Float.MAX_VALUE),
-                Vector2D.of(-0.1, 0.1),
-                Vector2D.of(1.5, -0.1),
-                Vector2D.of(3.1, 0.1),
-                Vector2D.of(3.1, Float.MAX_VALUE));
-        checkPoints(Region.Location.BOUNDARY, poly,
-                Vector2D.of(0, 1),
-                Vector2D.of(1, 0),
-                Vector2D.of(3, 1));
-    }
-
-    @Test
-    public void testBox() {
-        // act
-        PolygonsSet box = new PolygonsSet(0, 2, -1, 1, TEST_PRECISION);
-
-        // assert
-        Assert.assertEquals(4.0, box.getSize(), TEST_EPS);
-        Assert.assertEquals(8.0, box.getBoundarySize(), TEST_EPS);
-        Assert.assertFalse(box.isEmpty());
-        Assert.assertFalse(box.isFull());
-        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1, 0), box.getBarycenter(), TEST_EPS);
-
-        checkVertexLoopsEquivalent(new Vector2D[][] {
-            {
-                Vector2D.of(2, -1),
-                Vector2D.of(2, 1),
-                Vector2D.of(0, 1),
-                Vector2D.of(0, -1)
-            }
-        }, box.getVertices());
-
-        checkPoints(Region.Location.INSIDE, box,
-                Vector2D.of(0.1, 0),
-                Vector2D.of(1.9, 0),
-                Vector2D.of(1, 0.9),
-                Vector2D.of(1, -0.9));
-        checkPoints(Region.Location.OUTSIDE, box,
-                Vector2D.of(-0.1, 0),
-                Vector2D.of(2.1, 0),
-                Vector2D.of(1, -1.1),
-                Vector2D.of(1, 1.1));
-        checkPoints(Region.Location.BOUNDARY, box,
-                Vector2D.of(0, 0),
-                Vector2D.of(2, 0),
-                Vector2D.of(1, 1),
-                Vector2D.of(1, -1));
-    }
-
-    @Test
-    public void testInvertedBox() {
-        // arrange
-        List<SubHyperplane<Vector2D>> boundaries = new ArrayList<SubHyperplane<Vector2D>>();
-        boundaries.add(buildSegment(Vector2D.of(0, -1), Vector2D.of(0, 1)));
-        boundaries.add(buildSegment(Vector2D.of(2, 1), Vector2D.of(2, -1)));
-        boundaries.add(buildSegment(Vector2D.of(0, 1), Vector2D.of(2, 1)));
-        boundaries.add(buildSegment(Vector2D.of(2, -1), Vector2D.of(0, -1)));
-
-        // act
-        PolygonsSet box = new PolygonsSet(boundaries, TEST_PRECISION);
-
-        // assert
-        EuclideanTestUtils.assertPositiveInfinity(box.getSize());
-        Assert.assertEquals(8.0, box.getBoundarySize(), TEST_EPS);
-        Assert.assertFalse(box.isEmpty());
-        Assert.assertFalse(box.isFull());
-        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.NaN, box.getBarycenter(), TEST_EPS);
-
-        checkVertexLoopsEquivalent(new Vector2D[][] {
-            {
-                Vector2D.of(0, -1),
-                Vector2D.of(0, 1),
-                Vector2D.of(2, 1),
-                Vector2D.of(2, -1)
-            }
-        }, box.getVertices());
-
-        checkPoints(Region.Location.OUTSIDE, box,
-                Vector2D.of(0.1, 0),
-                Vector2D.of(1.9, 0),
-                Vector2D.of(1, 0.9),
-                Vector2D.of(1, -0.9));
-        checkPoints(Region.Location.INSIDE, box,
-                Vector2D.of(-0.1, 0),
-                Vector2D.of(2.1, 0),
-                Vector2D.of(1, -1.1),
-                Vector2D.of(1, 1.1));
-        checkPoints(Region.Location.BOUNDARY, box,
-                Vector2D.of(0, 0),
-                Vector2D.of(2, 0),
-                Vector2D.of(1, 1),
-                Vector2D.of(1, -1));
-    }
-
-    @Test
-    public void testSimplyConnected() {
-        // arrange
-        Vector2D[][] vertices = new Vector2D[][] {
-            new Vector2D[] {
-                Vector2D.of(36.0, 22.0),
-                Vector2D.of(39.0, 32.0),
-                Vector2D.of(19.0, 32.0),
-                Vector2D.of( 6.0, 16.0),
-                Vector2D.of(31.0, 10.0),
-                Vector2D.of(42.0, 16.0),
-                Vector2D.of(34.0, 20.0),
-                Vector2D.of(29.0, 19.0),
-                Vector2D.of(23.0, 22.0),
-                Vector2D.of(33.0, 25.0)
-            }
-        };
-
-        // act
-        PolygonsSet set = buildSet(vertices);
-
-        // assert
-        checkPoints(Region.Location.INSIDE, set,
-            Vector2D.of(30.0, 15.0),
-            Vector2D.of(15.0, 20.0),
-            Vector2D.of(24.0, 25.0),
-            Vector2D.of(35.0, 30.0),
-            Vector2D.of(19.0, 17.0));
-        checkPoints(Region.Location.OUTSIDE, set,
-            Vector2D.of(50.0, 30.0),
-            Vector2D.of(30.0, 35.0),
-            Vector2D.of(10.0, 25.0),
-            Vector2D.of(10.0, 10.0),
-            Vector2D.of(40.0, 10.0),
-            Vector2D.of(50.0, 15.0),
-            Vector2D.of(30.0, 22.0));
-        checkPoints(Region.Location.BOUNDARY, set,
-            Vector2D.of(30.0, 32.0),
-            Vector2D.of(34.0, 20.0));
-
-        checkVertexLoopsEquivalent(vertices, set.getVertices());
-    }
-
-    @Test
-    public void testStair() {
-        // arrange
-        Vector2D[][] vertices = new Vector2D[][] {
-            new Vector2D[] {
-                Vector2D.of( 0.0, 0.0),
-                Vector2D.of( 0.0, 2.0),
-                Vector2D.of(-0.1, 2.0),
-                Vector2D.of(-0.1, 1.0),
-                Vector2D.of(-0.3, 1.0),
-                Vector2D.of(-0.3, 1.5),
-                Vector2D.of(-1.3, 1.5),
-                Vector2D.of(-1.3, 2.0),
-                Vector2D.of(-1.8, 2.0),
-                Vector2D.of(-1.8 - 1.0 / Math.sqrt(2.0),
-                            2.0 - 1.0 / Math.sqrt(2.0))
-            }
-        };
-
-        // act
-        PolygonsSet set = buildSet(vertices);
-
-        // assert
-        checkVertexLoopsEquivalent(vertices, set.getVertices());
-
-        Assert.assertEquals(1.1 + 0.95 * Math.sqrt(2.0), set.getSize(), TEST_EPS);
-    }
-
-    @Test
-    public void testHole() {
-        // arrange
-        Vector2D[][] vertices = new Vector2D[][] {
-            new Vector2D[] {
-                Vector2D.of(0.0, 0.0),
-                Vector2D.of(3.0, 0.0),
-                Vector2D.of(3.0, 3.0),
-                Vector2D.of(0.0, 3.0)
-            }, new Vector2D[] {
-                Vector2D.of(1.0, 2.0),
-                Vector2D.of(2.0, 2.0),
-                Vector2D.of(2.0, 1.0),
-                Vector2D.of(1.0, 1.0)
-            }
-        };
-
-        // act
-        PolygonsSet set = buildSet(vertices);
-
-        // assert
-        checkPoints(Region.Location.INSIDE, set, new Vector2D[] {
-            Vector2D.of(0.5, 0.5),
-            Vector2D.of(1.5, 0.5),
-            Vector2D.of(2.5, 0.5),
-            Vector2D.of(0.5, 1.5),
-            Vector2D.of(2.5, 1.5),
-            Vector2D.of(0.5, 2.5),
-            Vector2D.of(1.5, 2.5),
-            Vector2D.of(2.5, 2.5),
-            Vector2D.of(0.5, 1.0)
-        });
-        checkPoints(Region.Location.OUTSIDE, set, new Vector2D[] {
-            Vector2D.of(1.5, 1.5),
-            Vector2D.of(3.5, 1.0),
-            Vector2D.of(4.0, 1.5),
-            Vector2D.of(6.0, 6.0)
-        });
-        checkPoints(Region.Location.BOUNDARY, set, new Vector2D[] {
-            Vector2D.of(1.0, 1.0),
-            Vector2D.of(1.5, 0.0),
-            Vector2D.of(1.5, 1.0),
-            Vector2D.of(1.5, 2.0),
-            Vector2D.of(1.5, 3.0),
-            Vector2D.of(3.0, 3.0)
-        });
-        checkVertexLoopsEquivalent(vertices, set.getVertices());
-
-        for (double x = -0.999; x < 3.999; x += 0.11) {
-            Vector2D v = Vector2D.of(x, x + 0.5);
-            BoundaryProjection<Vector2D> projection = set.projectToBoundary(v);
-            Assert.assertTrue(projection.getOriginal() == v);
-            Vector2D p = projection.getProjected();
-            if (x < -0.5) {
-                Assert.assertEquals(0.0,      p.getX(), TEST_EPS);
-                Assert.assertEquals(0.0,      p.getY(), TEST_EPS);
-                Assert.assertEquals(+v.distance(Vector2D.ZERO), projection.getOffset(), TEST_EPS);
-            } else if (x < 0.5) {
-                Assert.assertEquals(0.0,      p.getX(), TEST_EPS);
-                Assert.assertEquals(v.getY(), p.getY(), TEST_EPS);
-                Assert.assertEquals(-v.getX(), projection.getOffset(), TEST_EPS);
-            } else if (x < 1.25) {
-                Assert.assertEquals(1.0,      p.getX(), TEST_EPS);
-                Assert.assertEquals(v.getY(), p.getY(), TEST_EPS);
-                Assert.assertEquals(v.getX() - 1.0, projection.getOffset(), TEST_EPS);
-            } else if (x < 2.0) {
-                Assert.assertEquals(v.getX(), p.getX(), TEST_EPS);
-                Assert.assertEquals(2.0,      p.getY(), TEST_EPS);
-                Assert.assertEquals(2.0 - v.getY(), projection.getOffset(), TEST_EPS);
-            } else if (x < 3.0) {
-                Assert.assertEquals(v.getX(), p.getX(), TEST_EPS);
-                Assert.assertEquals(3.0,      p.getY(), TEST_EPS);
-                Assert.assertEquals(v.getY() - 3.0, projection.getOffset(), TEST_EPS);
-            } else {
-                Assert.assertEquals(3.0,      p.getX(), TEST_EPS);
-                Assert.assertEquals(3.0,      p.getY(), TEST_EPS);
-                Assert.assertEquals(+v.distance(Vector2D.of(3, 3)), projection.getOffset(), TEST_EPS);
-            }
-        }
-    }
-
-    @Test
-    public void testDisjointPolygons() {
-        // arrange
-        Vector2D[][] vertices = new Vector2D[][] {
-            new Vector2D[] {
-                Vector2D.of(0.0, 1.0),
-                Vector2D.of(2.0, 1.0),
-                Vector2D.of(1.0, 2.0)
-            }, new Vector2D[] {
-                Vector2D.of(4.0, 0.0),
-                Vector2D.of(5.0, 1.0),
-                Vector2D.of(3.0, 1.0)
-            }
-        };
-
-        // act
-        PolygonsSet set = buildSet(vertices);
-
-        // assert
-        Assert.assertEquals(Region.Location.INSIDE, set.checkPoint(Vector2D.of(1.0, 1.5)));
-        checkPoints(Region.Location.INSIDE, set, new Vector2D[] {
-            Vector2D.of(1.0, 1.5),
-            Vector2D.of(4.5, 0.8)
-        });
-        checkPoints(Region.Location.OUTSIDE, set, new Vector2D[] {
-            Vector2D.of(1.0, 0.0),
-            Vector2D.of(3.5, 1.2),
-            Vector2D.of(2.5, 1.0),
-            Vector2D.of(3.0, 4.0)
-        });
-        checkPoints(Region.Location.BOUNDARY, set, new Vector2D[] {
-            Vector2D.of(1.0, 1.0),
-            Vector2D.of(3.5, 0.5),
-            Vector2D.of(0.0, 1.0)
-        });
-        checkVertexLoopsEquivalent(vertices, set.getVertices());
-    }
-
-    @Test
-    public void testOppositeHyperplanes() {
-        // arrange
-        Vector2D[][] vertices = new Vector2D[][] {
-            new Vector2D[] {
-                Vector2D.of(1.0, 0.0),
-                Vector2D.of(2.0, 1.0),
-                Vector2D.of(3.0, 1.0),
-                Vector2D.of(2.0, 2.0),
-                Vector2D.of(1.0, 1.0),
-                Vector2D.of(0.0, 1.0)
-            }
-        };
-
-        // act
-        PolygonsSet set = buildSet(vertices);
-
-        // assert
-        checkVertexLoopsEquivalent(vertices, set.getVertices());
-    }
-
-    @Test
-    public void testSingularPoint() {
-        // arrange
-        Vector2D[][] vertices = new Vector2D[][] {
-            new Vector2D[] {
-                Vector2D.of( 0.0,  0.0),
-                Vector2D.of( 1.0,  0.0),
-                Vector2D.of( 1.0,  1.0),
-                Vector2D.of( 0.0,  1.0),
-                Vector2D.of( 0.0,  0.0),
-                Vector2D.of(-1.0,  0.0),
-                Vector2D.of(-1.0, -1.0),
-                Vector2D.of( 0.0, -1.0)
-            }
-        };
-
-        // act
-        PolygonsSet set = buildSet(vertices);
-
-        // assert
-        checkVertexLoopsEquivalent(new Vector2D[][] {
-            {
-                Vector2D.of( 0.0,  0.0),
-                Vector2D.of( 1.0,  0.0),
-                Vector2D.of( 1.0,  1.0),
-                Vector2D.of( 0.0,  1.0)
-            },
-            {
-                Vector2D.of( 0.0,  0.0),
-                Vector2D.of(-1.0,  0.0),
-                Vector2D.of(-1.0, -1.0),
-                Vector2D.of( 0.0, -1.0)
-            }
-        }, set.getVertices());
-    }
-
-    @Test
-    public void testLineIntersection() {
-        // arrange
-        Vector2D[][] vertices = new Vector2D[][] {
-            new Vector2D[] {
-                Vector2D.of( 0.0,  0.0),
-                Vector2D.of( 2.0,  0.0),
-                Vector2D.of( 2.0,  1.0),
-                Vector2D.of( 3.0,  1.0),
-                Vector2D.of( 3.0,  3.0),
-                Vector2D.of( 1.0,  3.0),
-                Vector2D.of( 1.0,  2.0),
-                Vector2D.of( 0.0,  2.0)
-            }
-        };
-
-        // act
-        PolygonsSet set = buildSet(vertices);
-
-        // assert
-        Line l1 = Line.fromPointAndAngle(Vector2D.of(-1.5, 0.0), Math.PI / 4, TEST_PRECISION);
-        SubLine s1 = (SubLine) set.intersection(l1.wholeHyperplane());
-        List<Interval> i1 = ((IntervalsSet) s1.getRemainingRegion()).asList();
-        Assert.assertEquals(2, i1.size());
-        Interval v10 = i1.get(0);
-        Vector2D p10Lower = l1.toSpace(Vector1D.of(v10.getInf()));
-        Assert.assertEquals(0.0, p10Lower.getX(), TEST_EPS);
-        Assert.assertEquals(1.5, p10Lower.getY(), TEST_EPS);
-        Vector2D p10Upper = l1.toSpace(Vector1D.of(v10.getSup()));
-        Assert.assertEquals(0.5, p10Upper.getX(), TEST_EPS);
-        Assert.assertEquals(2.0, p10Upper.getY(), TEST_EPS);
-        Interval v11 = i1.get(1);
-        Vector2D p11Lower = l1.toSpace(Vector1D.of(v11.getInf()));
-        Assert.assertEquals(1.0, p11Lower.getX(), TEST_EPS);
-        Assert.assertEquals(2.5, p11Lower.getY(), TEST_EPS);
-        Vector2D p11Upper = l1.toSpace(Vector1D.of(v11.getSup()));
-        Assert.assertEquals(1.5, p11Upper.getX(), TEST_EPS);
-        Assert.assertEquals(3.0, p11Upper.getY(), TEST_EPS);
-
-        Line l2 = Line.fromPointAndAngle(Vector2D.of(-1.0, 2.0), 0, TEST_PRECISION);
-        SubLine s2 = (SubLine) set.intersection(l2.wholeHyperplane());
-        List<Interval> i2 = ((IntervalsSet) s2.getRemainingRegion()).asList();
-        Assert.assertEquals(1, i2.size());
-        Interval v20 = i2.get(0);
-        Vector2D p20Lower = l2.toSpace(Vector1D.of(v20.getInf()));
-        Assert.assertEquals(1.0, p20Lower.getX(), TEST_EPS);
-        Assert.assertEquals(2.0, p20Lower.getY(), TEST_EPS);
-        Vector2D p20Upper = l2.toSpace(Vector1D.of(v20.getSup()));
-        Assert.assertEquals(3.0, p20Upper.getX(), TEST_EPS);
-        Assert.assertEquals(2.0, p20Upper.getY(), TEST_EPS);
-    }
-
-    @Test
-    public void testUnlimitedSubHyperplane() {
-        // arrange
-        Vector2D[][] vertices1 = new Vector2D[][] {
-            new Vector2D[] {
-                Vector2D.of(0.0, 0.0),
-                Vector2D.of(4.0, 0.0),
-                Vector2D.of(1.4, 1.5),
-                Vector2D.of(0.0, 3.5)
-            }
-        };
-        PolygonsSet set1 = buildSet(vertices1);
-        Vector2D[][] vertices2 = new Vector2D[][] {
-            new Vector2D[] {
-                Vector2D.of(1.4,  0.2),
-                Vector2D.of(2.8, -1.2),
-                Vector2D.of(2.5,  0.6)
-            }
-        };
-        PolygonsSet set2 = buildSet(vertices2);
-
-        // act
-        PolygonsSet set =
-            (PolygonsSet) new RegionFactory<Vector2D>().union(set1.copySelf(),
-                                                                 set2.copySelf());
-
-        // assert
-        checkVertexLoopsEquivalent(vertices1, set1.getVertices());
-        checkVertexLoopsEquivalent(vertices2, set2.getVertices());
-        checkVertexLoopsEquivalent(new Vector2D[][] {
-            new Vector2D[] {
-                Vector2D.of(0.0,  0.0),
-                Vector2D.of(1.6,  0.0),
-                Vector2D.of(2.8, -1.2),
-                Vector2D.of(2.6,  0.0),
-                Vector2D.of(4.0,  0.0),
-                Vector2D.of(1.4,  1.5),
-                Vector2D.of(0.0,  3.5)
-            }
-        }, set.getVertices());
-    }
-
-    @Test
-    public void testUnion() {
-        // arrange
-        Vector2D[][] vertices1 = new Vector2D[][] {
-            new Vector2D[] {
-                Vector2D.of( 0.0,  0.0),
-                Vector2D.of( 2.0,  0.0),
-                Vector2D.of( 2.0,  2.0),
-                Vector2D.of( 0.0,  2.0)
-            }
-        };
-        PolygonsSet set1 = buildSet(vertices1);
-        Vector2D[][] vertices2 = new Vector2D[][] {
-            new Vector2D[] {
-                Vector2D.of( 1.0,  1.0),
-                Vector2D.of( 3.0,  1.0),
-                Vector2D.of( 3.0,  3.0),
-                Vector2D.of( 1.0,  3.0)
-            }
-        };
-        PolygonsSet set2 = buildSet(vertices2);
-
-        // act
-        PolygonsSet set  = (PolygonsSet) new RegionFactory<Vector2D>().union(set1.copySelf(),
-                                                                                set2.copySelf());
-
-        // assert
-        checkVertexLoopsEquivalent(vertices1, set1.getVertices());
-        checkVertexLoopsEquivalent(vertices2, set2.getVertices());
-        checkVertexLoopsEquivalent(new Vector2D[][] {
-            new Vector2D[] {
-                Vector2D.of( 0.0,  0.0),
-                Vector2D.of( 2.0,  0.0),
-                Vector2D.of( 2.0,  1.0),
-                Vector2D.of( 3.0,  1.0),
-                Vector2D.of( 3.0,  3.0),
-                Vector2D.of( 1.0,  3.0),
-                Vector2D.of( 1.0,  2.0),
-                Vector2D.of( 0.0,  2.0)
-            }
-        }, set.getVertices());
-
-        checkPoints(Region.Location.INSIDE, set, new Vector2D[] {
-            Vector2D.of(1.0, 1.0),
-            Vector2D.of(0.5, 0.5),
-            Vector2D.of(2.0, 2.0),
-            Vector2D.of(2.5, 2.5),
-            Vector2D.of(0.5, 1.5),
-            Vector2D.of(1.5, 1.5),
-            Vector2D.of(1.5, 0.5),
-            Vector2D.of(1.5, 2.5),
-            Vector2D.of(2.5, 1.5),
-            Vector2D.of(2.5, 2.5)
-        });
-        checkPoints(Region.Location.OUTSIDE, set, new Vector2D[] {
-            Vector2D.of(-0.5, 0.5),
-            Vector2D.of( 0.5, 2.5),
-            Vector2D.of( 2.5, 0.5),
-            Vector2D.of( 3.5, 2.5)
-        });
-        checkPoints(Region.Location.BOUNDARY, set, new Vector2D[] {
-            Vector2D.of(0.0, 0.0),
-            Vector2D.of(0.5, 2.0),
-            Vector2D.of(2.0, 0.5),
-            Vector2D.of(2.5, 1.0),
-            Vector2D.of(3.0, 2.5)
-        });
-    }
-
-    @Test
-    public void testIntersection() {
-        // arrange
-        Vector2D[][] vertices1 = new Vector2D[][] {
-            new Vector2D[] {
-                Vector2D.of( 0.0,  0.0),
-                Vector2D.of( 2.0,  0.0),
-                Vector2D.of( 2.0,  2.0),
-                Vector2D.of( 0.0,  2.0)
-            }
-        };
-        PolygonsSet set1 = buildSet(vertices1);
-        Vector2D[][] vertices2 = new Vector2D[][] {
-            new Vector2D[] {
-                Vector2D.of( 1.0,  1.0),
-                Vector2D.of( 3.0,  1.0),
-                Vector2D.of( 3.0,  3.0),
-                Vector2D.of( 1.0,  3.0)
-            }
-        };
-        PolygonsSet set2 = buildSet(vertices2);
-
-        // act
-        PolygonsSet set  = (PolygonsSet) new RegionFactory<Vector2D>().intersection(set1.copySelf(),
-                                                                                       set2.copySelf());
-
-        // assert
-        checkVertexLoopsEquivalent(vertices1, set1.getVertices());
-        checkVertexLoopsEquivalent(vertices2, set2.getVertices());
-        checkVertexLoopsEquivalent(new Vector2D[][] {
-            new Vector2D[] {
-                Vector2D.of( 1.0,  1.0),
-                Vector2D.of( 2.0,  1.0),
-                Vector2D.of( 2.0,  2.0),
-                Vector2D.of( 1.0,  2.0)
-            }
-        }, set.getVertices());
-
-        checkPoints(Region.Location.INSIDE, set, new Vector2D[] {
-            Vector2D.of(1.5, 1.5)
-        });
-        checkPoints(Region.Location.OUTSIDE, set, new Vector2D[] {
-            Vector2D.of(0.5, 1.5),
-            Vector2D.of(2.5, 1.5),
-            Vector2D.of(1.5, 0.5),
-            Vector2D.of(0.5, 0.5)
-        });
-        checkPoints(Region.Location.BOUNDARY, set, new Vector2D[] {
-            Vector2D.of(1.0, 1.0),
-            Vector2D.of(2.0, 2.0),
-            Vector2D.of(1.0, 1.5),
-            Vector2D.of(1.5, 2.0)
-        });
-    }
-
-    @Test
-    public void testXor() {
-        // arrange
-        Vector2D[][] vertices1 = new Vector2D[][] {
-            new Vector2D[] {
-                Vector2D.of( 0.0,  0.0),
-                Vector2D.of( 2.0,  0.0),
-                Vector2D.of( 2.0,  2.0),
-                Vector2D.of( 0.0,  2.0)
-            }
-        };
-        PolygonsSet set1 = buildSet(vertices1);
-        Vector2D[][] vertices2 = new Vector2D[][] {
-            new Vector2D[] {
-                Vector2D.of( 1.0,  1.0),
-                Vector2D.of( 3.0,  1.0),
-                Vector2D.of( 3.0,  3.0),
-                Vector2D.of( 1.0,  3.0)
-            }
-        };
-        PolygonsSet set2 = buildSet(vertices2);
-
-        // act
-        PolygonsSet set  = (PolygonsSet) new RegionFactory<Vector2D>().xor(set1.copySelf(),
-                                                                              set2.copySelf());
-
-        // assert
-        checkVertexLoopsEquivalent(vertices1, set1.getVertices());
-        checkVertexLoopsEquivalent(vertices2, set2.getVertices());
-        checkVertexLoopsEquivalent(new Vector2D[][] {
-            new Vector2D[] {
-                Vector2D.of( 0.0,  0.0),
-                Vector2D.of( 2.0,  0.0),
-                Vector2D.of( 2.0,  1.0),
-                Vector2D.of( 3.0,  1.0),
-                Vector2D.of( 3.0,  3.0),
-                Vector2D.of( 1.0,  3.0),
-                Vector2D.of( 1.0,  2.0),
-                Vector2D.of( 0.0,  2.0)
-            },
-            new Vector2D[] {
-                Vector2D.of( 1.0,  1.0),
-                Vector2D.of( 1.0,  2.0),
-                Vector2D.of( 2.0,  2.0),
-                Vector2D.of( 2.0,  1.0)
-            }
-        }, set.getVertices());
-
-        checkPoints(Region.Location.INSIDE, set, new Vector2D[] {
-            Vector2D.of(0.5, 0.5),
-            Vector2D.of(2.5, 2.5),
-            Vector2D.of(0.5, 1.5),
-            Vector2D.of(1.5, 0.5),
-            Vector2D.of(1.5, 2.5),
-            Vector2D.of(2.5, 1.5),
-            Vector2D.of(2.5, 2.5)
-        });
-        checkPoints(Region.Location.OUTSIDE, set, new Vector2D[] {
-            Vector2D.of(-0.5, 0.5),
-            Vector2D.of( 0.5, 2.5),
-            Vector2D.of( 2.5, 0.5),
-            Vector2D.of( 1.5, 1.5),
-            Vector2D.of( 3.5, 2.5)
-        });
-        checkPoints(Region.Location.BOUNDARY, set, new Vector2D[] {
-            Vector2D.of(1.0, 1.0),
-            Vector2D.of(2.0, 2.0),
-            Vector2D.of(1.5, 1.0),
-            Vector2D.of(2.0, 1.5),
-            Vector2D.of(0.0, 0.0),
-            Vector2D.of(0.5, 2.0),
-            Vector2D.of(2.0, 0.5),
-            Vector2D.of(2.5, 1.0),
-            Vector2D.of(3.0, 2.5)
-        });
-    }
-
-    @Test
-    public void testDifference() {
-        // arrange
-        Vector2D[][] vertices1 = new Vector2D[][] {
-            new Vector2D[] {
-                Vector2D.of( 0.0,  0.0),
-                Vector2D.of( 2.0,  0.0),
-                Vector2D.of( 2.0,  2.0),
-                Vector2D.of( 0.0,  2.0)
-            }
-        };
-        PolygonsSet set1 = buildSet(vertices1);
-        Vector2D[][] vertices2 = new Vector2D[][] {
-            new Vector2D[] {
-                Vector2D.of( 1.0,  1.0),
-                Vector2D.of( 3.0,  1.0),
-                Vector2D.of( 3.0,  3.0),
-                Vector2D.of( 1.0,  3.0)
-            }
-        };
-        PolygonsSet set2 = buildSet(vertices2);
-
-        // act
-        PolygonsSet set  = (PolygonsSet) new RegionFactory<Vector2D>().difference(set1.copySelf(),
-                                                                                     set2.copySelf());
-
-        // assert
-        checkVertexLoopsEquivalent(vertices1, set1.getVertices());
-        checkVertexLoopsEquivalent(vertices2, set2.getVertices());
-        checkVertexLoopsEquivalent(new Vector2D[][] {
-            new Vector2D[] {
-                Vector2D.of( 0.0,  0.0),
-                Vector2D.of( 2.0,  0.0),
-                Vector2D.of( 2.0,  1.0),
-                Vector2D.of( 1.0,  1.0),
-                Vector2D.of( 1.0,  2.0),
-                Vector2D.of( 0.0,  2.0)
-            }
-        }, set.getVertices());
-
-        checkPoints(Region.Location.INSIDE, set, new Vector2D[] {
-            Vector2D.of(0.5, 0.5),
-            Vector2D.of(0.5, 1.5),
-            Vector2D.of(1.5, 0.5)
-        });
-        checkPoints(Region.Location.OUTSIDE, set, new Vector2D[] {
-            Vector2D.of( 2.5, 2.5),
-            Vector2D.of(-0.5, 0.5),
-            Vector2D.of( 0.5, 2.5),
-            Vector2D.of( 2.5, 0.5),
-            Vector2D.of( 1.5, 1.5),
-            Vector2D.of( 3.5, 2.5),
-            Vector2D.of( 1.5, 2.5),
-            Vector2D.of( 2.5, 1.5),
-            Vector2D.of( 2.0, 1.5),
-            Vector2D.of( 2.0, 2.0),
-            Vector2D.of( 2.5, 1.0),
-            Vector2D.of( 2.5, 2.5),
-            Vector2D.of( 3.0, 2.5)
-        });
-        checkPoints(Region.Location.BOUNDARY, set, new Vector2D[] {
-            Vector2D.of(1.0, 1.0),
-            Vector2D.of(1.5, 1.0),
-            Vector2D.of(0.0, 0.0),
-            Vector2D.of(0.5, 2.0),
-            Vector2D.of(2.0, 0.5)
-        });
-    }
-
-    @Test
-    public void testEmptyDifference() {
-        // arrange
-        Vector2D[][] vertices1 = new Vector2D[][] {
-            new Vector2D[] {
-                Vector2D.of( 0.5, 3.5),
-                Vector2D.of( 0.5, 4.5),
-                Vector2D.of(-0.5, 4.5),
-                Vector2D.of(-0.5, 3.5)
-            }
-        };
-        PolygonsSet set1 = buildSet(vertices1);
-        Vector2D[][] vertices2 = new Vector2D[][] {
-            new Vector2D[] {
-                Vector2D.of( 1.0, 2.0),
-                Vector2D.of( 1.0, 8.0),
-                Vector2D.of(-1.0, 8.0),
-                Vector2D.of(-1.0, 2.0)
-            }
-        };
-        PolygonsSet set2 = buildSet(vertices2);
-
-        // act
-        PolygonsSet diff = (PolygonsSet) new RegionFactory<Vector2D>().difference(set1.copySelf(), set2.copySelf());
-
-        // assert
-        Assert.assertEquals(0.0, diff.getSize(), TEST_EPS);
-        Assert.assertTrue(diff.isEmpty());
-    }
-
-    @Test
-    public void testChoppedHexagon() {
-        // arrange
-        double pi6   = Math.PI / 6.0;
-        double sqrt3 = Math.sqrt(3.0);
-        SubLine[] hyp = {
-            Line.fromPointAndAngle(Vector2D.of(   0.0, 1.0),  5 * pi6, TEST_PRECISION).wholeHyperplane(),
-            Line.fromPointAndAngle(Vector2D.of(-sqrt3, 1.0),  7 * pi6, TEST_PRECISION).wholeHyperplane(),
-            Line.fromPointAndAngle(Vector2D.of(-sqrt3, 1.0),  9 * pi6, TEST_PRECISION).wholeHyperplane(),
-            Line.fromPointAndAngle(Vector2D.of(-sqrt3, 0.0), 11 * pi6, TEST_PRECISION).wholeHyperplane(),
-            Line.fromPointAndAngle(Vector2D.of(   0.0, 0.0), 13 * pi6, TEST_PRECISION).wholeHyperplane(),
-            Line.fromPointAndAngle(Vector2D.of(   0.0, 1.0),  3 * pi6, TEST_PRECISION).wholeHyperplane(),
-            Line.fromPointAndAngle(Vector2D.of(-5.0 * sqrt3 / 6.0, 0.0), 9 * pi6, TEST_PRECISION).wholeHyperplane()
-        };
-        hyp[1] = (SubLine) hyp[1].split(hyp[0].getHyperplane()).getMinus();
-        hyp[2] = (SubLine) hyp[2].split(hyp[1].getHyperplane()).getMinus();
-        hyp[3] = (SubLine) hyp[3].split(hyp[2].getHyperplane()).getMinus();
-        hyp[4] = (SubLine) hyp[4].split(hyp[3].getHyperplane()).getMinus().split(hyp[0].getHyperplane()).getMinus();
-        hyp[5] = (SubLine) hyp[5].split(hyp[4].getHyperplane()).getMinus().split(hyp[0].getHyperplane()).getMinus();
-        hyp[6] = (SubLine) hyp[6].split(hyp[3].getHyperplane()).getMinus().split(hyp[1].getHyperplane()).getMinus();
-        BSPTree<Vector2D> tree = new BSPTree<>(Boolean.TRUE);
-        for (int i = hyp.length - 1; i >= 0; --i) {
-            tree = new BSPTree<>(hyp[i], new BSPTree<Vector2D>(Boolean.FALSE), tree, null);
-        }
-        PolygonsSet set = new PolygonsSet(tree, TEST_PRECISION);
-        SubLine splitter =
-            Line.fromPointAndAngle(Vector2D.of(-2.0 * sqrt3 / 3.0, 0.0), 9 * pi6, TEST_PRECISION).wholeHyperplane();
-
-        // act
-        PolygonsSet slice =
-            new PolygonsSet(new BSPTree<>(splitter,
-                                                     set.getTree(false).split(splitter).getPlus(),
-                                                     new BSPTree<Vector2D>(Boolean.FALSE), null),
-                    TEST_PRECISION);
-
-        // assert
-        Assert.assertEquals(Region.Location.OUTSIDE,
-                            slice.checkPoint(Vector2D.of(0.1, 0.5)));
-        Assert.assertEquals(11.0 / 3.0, slice.getBoundarySize(), TEST_EPS);
-    }
-
-    @Test
-    public void testConcentric() {
-        // arrange
-        double h = Math.sqrt(3.0) / 2.0;
-        Vector2D[][] vertices1 = new Vector2D[][] {
-            new Vector2D[] {
-                Vector2D.of( 0.00, 0.1 * h),
-                Vector2D.of( 0.05, 0.1 * h),
-                Vector2D.of( 0.10, 0.2 * h),
-                Vector2D.of( 0.05, 0.3 * h),
-                Vector2D.of(-0.05, 0.3 * h),
-                Vector2D.of(-0.10, 0.2 * h),
-                Vector2D.of(-0.05, 0.1 * h)
-            }
-        };
-        PolygonsSet set1 = buildSet(vertices1);
-        Vector2D[][] vertices2 = new Vector2D[][] {
-            new Vector2D[] {
-                Vector2D.of( 0.00, 0.0 * h),
-                Vector2D.of( 0.10, 0.0 * h),
-                Vector2D.of( 0.20, 0.2 * h),
-                Vector2D.of( 0.10, 0.4 * h),
-                Vector2D.of(-0.10, 0.4 * h),
-                Vector2D.of(-0.20, 0.2 * h),
-                Vector2D.of(-0.10, 0.0 * h)
-            }
-        };
-        PolygonsSet set2 = buildSet(vertices2);
-
-        // act/assert
-        Assert.assertTrue(set2.contains(set1));
-    }
-
-    @Test
-    public void testBug20040520() {
-        // arrange
-        BSPTree<Vector2D> a0 =
-            new BSPTree<>(buildSegment(Vector2D.of(0.85, -0.05),
-                                                  Vector2D.of(0.90, -0.10)),
-                                                  new BSPTree<Vector2D>(Boolean.FALSE),
-                                                  new BSPTree<Vector2D>(Boolean.TRUE),
-                                                  null);
-        BSPTree<Vector2D> a1 =
-            new BSPTree<>(buildSegment(Vector2D.of(0.85, -0.10),
-                                                  Vector2D.of(0.90, -0.10)),
-                                                  new BSPTree<Vector2D>(Boolean.FALSE), a0, null);
-        BSPTree<Vector2D> a2 =
-            new BSPTree<>(buildSegment(Vector2D.of(0.90, -0.05),
-                                                  Vector2D.of(0.85, -0.05)),
-                                                  new BSPTree<Vector2D>(Boolean.FALSE), a1, null);
-        BSPTree<Vector2D> a3 =
-            new BSPTree<>(buildSegment(Vector2D.of(0.82, -0.05),
-                                                  Vector2D.of(0.82, -0.08)),
-                                                  new BSPTree<Vector2D>(Boolean.FALSE),
-                                                  new BSPTree<Vector2D>(Boolean.TRUE),
-                                                  null);
-        BSPTree<Vector2D> a4 =
-            new BSPTree<>(buildHalfLine(Vector2D.of(0.85, -0.05),
-                                                   Vector2D.of(0.80, -0.05),
-                                                   false),
-                                                   new BSPTree<Vector2D>(Boolean.FALSE), a3, null);
-        BSPTree<Vector2D> a5 =
-            new BSPTree<>(buildSegment(Vector2D.of(0.82, -0.08),
-                                                  Vector2D.of(0.82, -0.18)),
-                                                  new BSPTree<Vector2D>(Boolean.FALSE),
-                                                  new BSPTree<Vector2D>(Boolean.TRUE),
-                                                  null);
-        BSPTree<Vector2D> a6 =
-            new BSPTree<>(buildHalfLine(Vector2D.of(0.82, -0.18),
-                                                   Vector2D.of(0.85, -0.15),
-                                                   true),
-                                                   new BSPTree<Vector2D>(Boolean.FALSE), a5, null);
-        BSPTree<Vector2D> a7 =
-            new BSPTree<>(buildHalfLine(Vector2D.of(0.85, -0.05),
-                                                   Vector2D.of(0.82, -0.08),
-                                                   false),
-                                                   a4, a6, null);
-        BSPTree<Vector2D> a8 =
-            new BSPTree<>(buildLine(Vector2D.of(0.85, -0.25),
-                                               Vector2D.of(0.85,  0.05)),
-                                               a2, a7, null);
-        BSPTree<Vector2D> a9 =
-            new BSPTree<>(buildLine(Vector2D.of(0.90,  0.05),
-                                               Vector2D.of(0.90, -0.50)),
-                                               a8, new BSPTree<Vector2D>(Boolean.FALSE), null);
-
-        BSPTree<Vector2D> b0 =
-            new BSPTree<>(buildSegment(Vector2D.of(0.92, -0.12),
-                                                  Vector2D.of(0.92, -0.08)),
-                                                  new BSPTree<Vector2D>(Boolean.FALSE), new BSPTree<Vector2D>(Boolean.TRUE),
-                                                  null);
-        BSPTree<Vector2D> b1 =
-            new BSPTree<>(buildHalfLine(Vector2D.of(0.92, -0.08),
-                                                   Vector2D.of(0.90, -0.10),
-                                                   true),
-                                                   new BSPTree<Vector2D>(Boolean.FALSE), b0, null);
-        BSPTree<Vector2D> b2 =
-            new BSPTree<>(buildSegment(Vector2D.of(0.92, -0.18),
-                                                  Vector2D.of(0.92, -0.12)),
-                                                  new BSPTree<Vector2D>(Boolean.FALSE), new BSPTree<Vector2D>(Boolean.TRUE),
-                                                  null);
-        BSPTree<Vector2D> b3 =
-            new BSPTree<>(buildSegment(Vector2D.of(0.85, -0.15),
-                                                  Vector2D.of(0.90, -0.20)),
-                                                  new BSPTree<Vector2D>(Boolean.FALSE), b2, null);
-        BSPTree<Vector2D> b4 =
-            new BSPTree<>(buildSegment(Vector2D.of(0.95, -0.15),
-                                                  Vector2D.of(0.85, -0.05)),
-                                                  b1, b3, null);
-        BSPTree<Vector2D> b5 =
-            new BSPTree<>(buildHalfLine(Vector2D.of(0.85, -0.05),
-                                                   Vector2D.of(0.85, -0.25),
-                                                   true),
-                                                   new BSPTree<Vector2D>(Boolean.FALSE), b4, null);
-        BSPTree<Vector2D> b6 =
-            new BSPTree<>(buildLine(Vector2D.of(0.0, -1.10),
-                                               Vector2D.of(1.0, -0.10)),
-                                               new BSPTree<Vector2D>(Boolean.FALSE), b5, null);
-
-        // act
-        PolygonsSet c =
-            (PolygonsSet) new RegionFactory<Vector2D>().union(new PolygonsSet(a9, TEST_PRECISION),
-                                                                 new PolygonsSet(b6, TEST_PRECISION));
-
-        // assert
-        checkPoints(Region.Location.INSIDE, c, new Vector2D[] {
-            Vector2D.of(0.83, -0.06),
-            Vector2D.of(0.83, -0.15),
-            Vector2D.of(0.88, -0.15),
-            Vector2D.of(0.88, -0.09),
-            Vector2D.of(0.88, -0.07),
-            Vector2D.of(0.91, -0.18),
-            Vector2D.of(0.91, -0.10)
-        });
-
-        checkPoints(Region.Location.OUTSIDE, c, new Vector2D[] {
-            Vector2D.of(0.80, -0.10),
-            Vector2D.of(0.83, -0.50),
-            Vector2D.of(0.83, -0.20),
-            Vector2D.of(0.83, -0.02),
-            Vector2D.of(0.87, -0.50),
-            Vector2D.of(0.87, -0.20),
-            Vector2D.of(0.87, -0.02),
-            Vector2D.of(0.91, -0.20),
-            Vector2D.of(0.91, -0.08),
-            Vector2D.of(0.93, -0.15)
-        });
-
-        checkVertexLoopsEquivalent(new Vector2D[][] {
-            new Vector2D[] {
-                Vector2D.of(0.85, -0.15),
-                Vector2D.of(0.90, -0.20),
-                Vector2D.of(0.92, -0.18),
-                Vector2D.of(0.92, -0.08),
-                Vector2D.of(0.90, -0.10),
-                Vector2D.of(0.90, -0.05),
-                Vector2D.of(0.82, -0.05),
-                Vector2D.of(0.82, -0.18),
-            }
-        }, c.getVertices());
-    }
-
-    @Test
-    public void testBug20041003() {
-        // arrange
-        Line[] l = {
-            Line.fromPoints(Vector2D.of(0.0, 0.625000007541172),
-                     Vector2D.of(1.0, 0.625000007541172), TEST_PRECISION),
-            Line.fromPoints(Vector2D.of(-0.19204433621902645, 0.0),
-                     Vector2D.of(-0.19204433621902645, 1.0), TEST_PRECISION),
-            Line.fromPoints(Vector2D.of(-0.40303524786887,  0.4248364535319128),
-                     Vector2D.of(-1.12851149797877, -0.2634107480798909), TEST_PRECISION),
-            Line.fromPoints(Vector2D.of(0.0, 2.0),
-                     Vector2D.of(1.0, 2.0), TEST_PRECISION)
-        };
-
-        BSPTree<Vector2D> node1 =
-            new BSPTree<>(new SubLine(l[0],
-                                                 new IntervalsSet(intersectionAbscissa(l[0], l[1]),
-                                                                  intersectionAbscissa(l[0], l[2]),
-                                                                  TEST_PRECISION)),
-                                     new BSPTree<Vector2D>(Boolean.TRUE),
-                                     new BSPTree<Vector2D>(Boolean.FALSE),
-                                     null);
-        BSPTree<Vector2D> node2 =
-            new BSPTree<>(new SubLine(l[1],
-                                                 new IntervalsSet(intersectionAbscissa(l[1], l[2]),
-                                                                  intersectionAbscissa(l[1], l[3]),
-                                                                  TEST_PRECISION)),
-                                     node1,
-                                     new BSPTree<Vector2D>(Boolean.FALSE),
-                                     null);
-        BSPTree<Vector2D> node3 =
-            new BSPTree<>(new SubLine(l[2],
-                                                 new IntervalsSet(intersectionAbscissa(l[2], l[3]),
-                                                 Double.POSITIVE_INFINITY, TEST_PRECISION)),
-                                     node2,
-                                     new BSPTree<Vector2D>(Boolean.FALSE),
-                                     null);
-        BSPTree<Vector2D> node4 =
-            new BSPTree<>(l[3].wholeHyperplane(),
-                                     node3,
-                                     new BSPTree<Vector2D>(Boolean.FALSE),
-                                     null);
-
-        // act
-        PolygonsSet set = new PolygonsSet(node4, TEST_PRECISION);
-
-        // assert
-        Assert.assertEquals(0, set.getVertices().length);
-    }
-
-    @Test
-    public void testSqueezedHexa() {
-        // act
-        PolygonsSet set = new PolygonsSet(TEST_PRECISION,
-                                          Vector2D.of(-6, -4), Vector2D.of(-8, -8), Vector2D.of(  8, -8),
-                                          Vector2D.of( 6, -4), Vector2D.of(10,  4), Vector2D.of(-10,  4));
-
-        // assert
-        Assert.assertEquals(Location.OUTSIDE, set.checkPoint(Vector2D.of(0, 6)));
-    }
-
-    @Test
-    public void testIssue880Simplified() {
-        // arrange
-        Vector2D[] vertices1 = new Vector2D[] {
-            Vector2D.of( 90.13595870833188,  38.33604606376991),
-            Vector2D.of( 90.14047850603913,  38.34600084496253),
-            Vector2D.of( 90.11045289492762,  38.36801537312368),
-            Vector2D.of( 90.10871471476526,  38.36878044144294),
-            Vector2D.of( 90.10424901707671,  38.374300101757),
-            Vector2D.of( 90.0979455456843,   38.373578376172475),
-            Vector2D.of( 90.09081227075944,  38.37526295920463),
-            Vector2D.of( 90.09081378927135,  38.375193883266434)
-        };
-
-        // act
-        PolygonsSet set1 = new PolygonsSet(TEST_PRECISION, vertices1);
-
-        // assert
-        Assert.assertEquals(Location.OUTSIDE, set1.checkPoint(Vector2D.of(90.12,  38.32)));
-        Assert.assertEquals(Location.OUTSIDE, set1.checkPoint(Vector2D.of(90.135, 38.355)));
-
-    }
-
-    @Test
-    public void testIssue880Complete() {
-        Vector2D[] vertices1 = new Vector2D[] {
-                Vector2D.of( 90.08714908223715,  38.370299337260235),
-                Vector2D.of( 90.08709517675004,  38.3702895991413),
-                Vector2D.of( 90.08401538704919,  38.368849330127944),
-                Vector2D.of( 90.08258210430711,  38.367634558585564),
-                Vector2D.of( 90.08251455106665,  38.36763409247078),
-                Vector2D.of( 90.08106599752608,  38.36761621664249),
-                Vector2D.of( 90.08249585300035,  38.36753627557965),
-                Vector2D.of( 90.09075743352184,  38.35914647644972),
-                Vector2D.of( 90.09099945896571,  38.35896264724079),
-                Vector2D.of( 90.09269383800086,  38.34595756121246),
-                Vector2D.of( 90.09638631543191,  38.3457988093121),
-                Vector2D.of( 90.09666417351019,  38.34523360999418),
-                Vector2D.of( 90.1297082145872,  38.337670454923625),
-                Vector2D.of( 90.12971687748956,  38.337669827794684),
-                Vector2D.of( 90.1240820219179,  38.34328502001131),
-                Vector2D.of( 90.13084259656404,  38.34017811765017),
-                Vector2D.of( 90.13378567942857,  38.33860579180606),
-                Vector2D.of( 90.13519557833206,  38.33621054663689),
-                Vector2D.of( 90.13545616732307,  38.33614965452864),
-                Vector2D.of( 90.13553111202748,  38.33613962818305),
-                Vector2D.of( 90.1356903436448,  38.33610227127048),
-                Vector2D.of( 90.13576283227428,  38.33609255422783),
-                Vector2D.of( 90.13595870833188,  38.33604606376991),
-                Vector2D.of( 90.1361556630693,  38.3360024198866),
-                Vector2D.of( 90.13622408795709,  38.335987048115726),
-                Vector2D.of( 90.13696189099994,  38.33581914328681),
-                Vector2D.of( 90.13746655304897,  38.33616706665265),
-                Vector2D.of( 90.13845973716064,  38.33650776167099),
-                Vector2D.of( 90.13950901827667,  38.3368469456463),
-                Vector2D.of( 90.14393814424852,  38.337591835857495),
-                Vector2D.of( 90.14483839716831,  38.337076122362475),
-                Vector2D.of( 90.14565474433601,  38.33769000964429),
-                Vector2D.of( 90.14569421179482,  38.3377117256905),
-                Vector2D.of( 90.14577067124333,  38.33770883625908),
-                Vector2D.of( 90.14600350631684,  38.337714326520995),
-                Vector2D.of( 90.14600355139731,  38.33771435193319),
-                Vector2D.of( 90.14600369112401,  38.33771443882085),
-                Vector2D.of( 90.14600382486884,  38.33771453466096),
-                Vector2D.of( 90.14600395205912,  38.33771463904344),
-                Vector2D.of( 90.14600407214999,  38.337714751520764),
-                Vector2D.of( 90.14600418462749,  38.337714871611695),
-                Vector2D.of( 90.14600422249327,  38.337714915811034),
-                Vector2D.of( 90.14867838361471,  38.34113888210675),
-                Vector2D.of( 90.14923750157374,  38.341582537502575),
-                Vector2D.of( 90.14877083250991,  38.34160685841391),
-                Vector2D.of( 90.14816667319519,  38.34244232585684),
-                Vector2D.of( 90.14797696744586,  38.34248455284745),
-                Vector2D.of( 90.14484318014337,  38.34385573215269),
-                Vector2D.of( 90.14477919958296,  38.3453797747614),
-                Vector2D.of( 90.14202393306448,  38.34464324839456),
-                Vector2D.of( 90.14198920640195,  38.344651155237216),
-                Vector2D.of( 90.14155207025175,  38.34486424263724),
-                Vector2D.of( 90.1415196143314,  38.344871730519),
-                Vector2D.of( 90.14128611910814,  38.34500196593859),
-                Vector2D.of( 90.14047850603913,  38.34600084496253),
-                Vector2D.of( 90.14045907000337,  38.34601860032171),
-                Vector2D.of( 90.14039496493928,  38.346223030432384),
-                Vector2D.of( 90.14037626063737,  38.346240203360026),
-                Vector2D.of( 90.14030005823724,  38.34646920000705),
-                Vector2D.of( 90.13799164754806,  38.34903093011013),
-                Vector2D.of( 90.11045289492762,  38.36801537312368),
-                Vector2D.of( 90.10871471476526,  38.36878044144294),
-                Vector2D.of( 90.10424901707671,  38.374300101757),
-                Vector2D.of( 90.10263482039932,  38.37310041316073),
-                Vector2D.of( 90.09834601753448,  38.373615053823414),
-                Vector2D.of( 90.0979455456843,  38.373578376172475),
-                Vector2D.of( 90.09086514328669,  38.37527884194668),
-                Vector2D.of( 90.09084931407364,  38.37590801712463),
-                Vector2D.of( 90.09081227075944,  38.37526295920463),
-                Vector2D.of( 90.09081378927135,  38.375193883266434)
-        };
-        PolygonsSet set1 = new PolygonsSet(TEST_PRECISION, vertices1);
-        Assert.assertEquals(Location.OUTSIDE, set1.checkPoint(Vector2D.of(90.0905,  38.3755)));
-        Assert.assertEquals(Location.INSIDE,  set1.checkPoint(Vector2D.of(90.09084, 38.3755)));
-        Assert.assertEquals(Location.OUTSIDE, set1.checkPoint(Vector2D.of(90.0913,  38.3755)));
-        Assert.assertEquals(Location.INSIDE,  set1.checkPoint(Vector2D.of(90.1042,  38.3739)));
-        Assert.assertEquals(Location.INSIDE,  set1.checkPoint(Vector2D.of(90.1111,  38.3673)));
-        Assert.assertEquals(Location.OUTSIDE, set1.checkPoint(Vector2D.of(90.0959,  38.3457)));
-
-        Vector2D[] vertices2 = new Vector2D[] {
-                Vector2D.of( 90.13067558880044,  38.36977255037573),
-                Vector2D.of( 90.12907570488,  38.36817308242706),
-                Vector2D.of( 90.1342774136516,  38.356886880294724),
-                Vector2D.of( 90.13090330629757,  38.34664392676211),
-                Vector2D.of( 90.13078571364593,  38.344904617518466),
-                Vector2D.of( 90.1315602208914,  38.3447185040846),
-                Vector2D.of( 90.1316336226821,  38.34470643148342),
-                Vector2D.of( 90.134020944832,  38.340936644972885),
-                Vector2D.of( 90.13912536387306,  38.335497255122334),
-                Vector2D.of( 90.1396178806582,  38.334878075552126),
-                Vector2D.of( 90.14083049696671,  38.33316530644106),
-                Vector2D.of( 90.14145252901329,  38.33152722916191),
-                Vector2D.of( 90.1404779335565,  38.32863516047786),
-                Vector2D.of( 90.14282712131586,  38.327504432532066),
-                Vector2D.of( 90.14616669875488,  38.3237354115015),
-                Vector2D.of( 90.14860976050608,  38.315714862457924),
-                Vector2D.of( 90.14999277782437,  38.3164932507504),
-                Vector2D.of( 90.15005207194997,  38.316534677663356),
-                Vector2D.of( 90.15508513859612,  38.31878731691609),
-                Vector2D.of( 90.15919938519221,  38.31852743183782),
-                Vector2D.of( 90.16093758658837,  38.31880662005153),
-                Vector2D.of( 90.16099420184912,  38.318825953291594),
-                Vector2D.of( 90.1665411125756,  38.31859497874757),
-                Vector2D.of( 90.16999653861313,  38.32505772048029),
-                Vector2D.of( 90.17475243391698,  38.32594398441148),
-                Vector2D.of( 90.17940844844992,  38.327427213761325),
-                Vector2D.of( 90.20951909541378,  38.330616833491774),
-                Vector2D.of( 90.2155400467941,  38.331746223670336),
-                Vector2D.of( 90.21559881391778,  38.33175551425302),
-                Vector2D.of( 90.21916646426041,  38.332584299620805),
-                Vector2D.of( 90.23863749852285,  38.34778978875795),
-                Vector2D.of( 90.25459855175802,  38.357790570608984),
-                Vector2D.of( 90.25964298227257,  38.356918010203174),
-                Vector2D.of( 90.26024593994703,  38.361692743151366),
-                Vector2D.of( 90.26146187570015,  38.36311080550837),
-                Vector2D.of( 90.26614159359622,  38.36510808579902),
-                Vector2D.of( 90.26621342936448,  38.36507942500333),
-                Vector2D.of( 90.26652190211962,  38.36494042196722),
-                Vector2D.of( 90.26621240678867,  38.365113172030874),
-                Vector2D.of( 90.26614057102057,  38.365141832826794),
-                Vector2D.of( 90.26380080055299,  38.3660381760273),
-                Vector2D.of( 90.26315345241,  38.36670658276421),
-                Vector2D.of( 90.26251574942881,  38.367490323488084),
-                Vector2D.of( 90.26247873448426,  38.36755266444749),
-                Vector2D.of( 90.26234628016698,  38.36787989125406),
-                Vector2D.of( 90.26214559424784,  38.36945909356126),
-                Vector2D.of( 90.25861728442555,  38.37200753430875),
-                Vector2D.of( 90.23905557537864,  38.375405314295904),
-                Vector2D.of( 90.22517251874075,  38.38984691662256),
-                Vector2D.of( 90.22549955153215,  38.3911564273979),
-                Vector2D.of( 90.22434386063355,  38.391476432092134),
-                Vector2D.of( 90.22147729457276,  38.39134652252034),
-                Vector2D.of( 90.22142070120117,  38.391349167741964),
-                Vector2D.of( 90.20665060751588,  38.39475580900313),
-                Vector2D.of( 90.20042268367109,  38.39842558622888),
-                Vector2D.of( 90.17423771242085,  38.402727751805344),
-                Vector2D.of( 90.16756796257476,  38.40913898597597),
-                Vector2D.of( 90.16728283954308,  38.411255399912875),
-                Vector2D.of( 90.16703538220418,  38.41136059866693),
-                Vector2D.of( 90.16725865657685,  38.41013618805954),
-                Vector2D.of( 90.16746107640665,  38.40902614307544),
-                Vector2D.of( 90.16122795307462,  38.39773101873203)
-        };
-        PolygonsSet set2 = new PolygonsSet(TEST_PRECISION, vertices2);
-        PolygonsSet set  = (PolygonsSet) new
-                RegionFactory<Vector2D>().difference(set1.copySelf(),
-                                                        set2.copySelf());
-
-        Vector2D[][] vertices = set.getVertices();
-        Assert.assertTrue(vertices[0][0] != null);
-        Assert.assertEquals(1, vertices.length);
-    }
-
-    @Test
-    public void testTooThinBox() {
-        // act/assert
-        Assert.assertEquals(0.0,
-                            new PolygonsSet(0.0, 0.0, 0.0, 10.3206397147574, TEST_PRECISION).getSize(),
-                            TEST_EPS);
-    }
-
-    @Test
-    public void testWrongUsage() {
-        // the following is a wrong usage of the constructor.
-        // as explained in the javadoc, the failure is NOT detected at construction
-        // time but occurs later on
-        PolygonsSet ps = new PolygonsSet(new BSPTree<Vector2D>(), TEST_PRECISION);
-        Assert.assertNotNull(ps);
-        try {
-            ps.getSize();
-            Assert.fail("an exception should have been thrown");
-        } catch (NullPointerException npe) {
-            // this is expected
-        }
-    }
-
-    @Test
-    public void testIssue1162() {
-        // arrange
-        PolygonsSet p = new PolygonsSet(TEST_PRECISION,
-                                                Vector2D.of(4.267199999996532, -11.928637756014894),
-                                                Vector2D.of(4.267200000026445, -14.12360595809307),
-                                                Vector2D.of(9.144000000273694, -14.12360595809307),
-                                                Vector2D.of(9.144000000233383, -11.928637756020067));
-
-        PolygonsSet w = new PolygonsSet(TEST_PRECISION,
-                                                Vector2D.of(2.56735636510452512E-9, -11.933116461089332),
-                                                Vector2D.of(2.56735636510452512E-9, -12.393225665247766),
-                                                Vector2D.of(2.56735636510452512E-9, -27.785625665247778),
-                                                Vector2D.of(4.267200000030211,      -27.785625665247778),
-                                                Vector2D.of(4.267200000030211,      -11.933116461089332));
-
-        // act/assert
-        Assert.assertFalse(p.contains(w));
-    }
-
-    @Test
-    public void testThinRectangle_toleranceLessThanWidth_resultIsAccurate() {
-        // if tolerance is smaller than rectangle width, the rectangle is computed accurately
-
-        // arrange
-        RegionFactory<Vector2D> factory = new RegionFactory<>();
-        Vector2D pA = Vector2D.of(0.0,        1.0);
-        Vector2D pB = Vector2D.of(0.0,        0.0);
-        Vector2D pC = Vector2D.of(1.0 / 64.0, 0.0);
-        Vector2D pD = Vector2D.of(1.0 / 64.0, 1.0);
-
-        // if tolerance is smaller than rectangle width, the rectangle is computed accurately
-        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1.0 / 256);
-        Hyperplane<Vector2D>[] h1 = new Line[] {
-            Line.fromPoints(pA, pB, precision),
-            Line.fromPoints(pB, pC, precision),
-            Line.fromPoints(pC, pD, precision),
-            Line.fromPoints(pD, pA, precision)
-        };
-
-        // act
-        Region<Vector2D> accuratePolygon = factory.buildConvex(h1);
-
-        // assert
-        Assert.assertEquals(1.0 / 64.0, accuratePolygon.getSize(), TEST_EPS);
-        EuclideanTestUtils.assertPositiveInfinity(new RegionFactory<Vector2D>().getComplement(accuratePolygon).getSize());
-        Assert.assertEquals(2 * (1.0 + 1.0 / 64.0), accuratePolygon.getBoundarySize(), TEST_EPS);
-    }
-
-    @Test
-    public void testThinRectangle_toleranceGreaterThanWidth_resultIsDegenerate() {
-        // if tolerance is larger than rectangle width, the rectangle degenerates
-        // as of 3.3, its two long edges cannot be distinguished anymore and this part of the test did fail
-        // this has been fixed in 3.4 (issue MATH-1174)
-
-        // arrange
-        RegionFactory<Vector2D> factory = new RegionFactory<>();
-        Vector2D pA = Vector2D.of(0.0,        1.0);
-        Vector2D pB = Vector2D.of(0.0,        0.0);
-        Vector2D pC = Vector2D.of(1.0 / 64.0, 0.0);
-        Vector2D pD = Vector2D.of(1.0 / 64.0, 1.0);
-
-        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1.0 / 16);
-
-        Hyperplane<Vector2D>[] h2 = new Line[] {
-                Line.fromPointAndDirection(pA, Vector2D.Unit.MINUS_Y, precision),
-                Line.fromPointAndDirection(pB, Vector2D.Unit.PLUS_X, precision),
-                Line.fromPointAndDirection(pC, Vector2D.Unit.PLUS_Y, precision),
-                Line.fromPointAndDirection(pD, Vector2D.Unit.MINUS_X, precision)
-            };
-
-        // act
-        Region<Vector2D> degeneratedPolygon = factory.buildConvex(h2);
-
-        // assert
-        Assert.assertEquals(0.0, degeneratedPolygon.getSize(), TEST_EPS);
-        Assert.assertTrue(degeneratedPolygon.isEmpty());
-    }
-
-    @Test(expected = IllegalArgumentException.class)
-    public void testInconsistentHyperplanes() {
-        // act
-        new RegionFactory<Vector2D>().buildConvex(Line.fromPoints(Vector2D.of(0, 0), Vector2D.of(0, 1), TEST_PRECISION),
-                                                     Line.fromPoints(Vector2D.of(1, 1), Vector2D.of(1, 0), TEST_PRECISION));
-    }
-
-    @Test
-    public void testBoundarySimplification() {
-        // a simple square will result in a 4 cuts and 5 leafs tree
-        PolygonsSet square = new PolygonsSet(TEST_PRECISION,
-                                             Vector2D.of(0, 0),
-                                             Vector2D.of(1, 0),
-                                             Vector2D.of(1, 1),
-                                             Vector2D.of(0, 1));
-        Vector2D[][] squareBoundary = square.getVertices();
-        Assert.assertEquals(1, squareBoundary.length);
-        Assert.assertEquals(4, squareBoundary[0].length);
-        Counter squareCount = new Counter();
-        squareCount.count(square);
-        Assert.assertEquals(4, squareCount.getInternalNodes());
-        Assert.assertEquals(5, squareCount.getLeafNodes());
-
-        // splitting the square in two halves increases the BSP tree
-        // with 3 more cuts and 3 more leaf nodes
-        SubLine cut = Line.fromPointAndAngle(Vector2D.of(0.5, 0.5), 0.0, square.getPrecision()).wholeHyperplane();
-        PolygonsSet splitSquare = new PolygonsSet(square.getTree(false).split(cut),
-                                                  square.getPrecision());
-        Counter splitSquareCount = new Counter();
-        splitSquareCount.count(splitSquare);
-        Assert.assertEquals(squareCount.getInternalNodes() + 3, splitSquareCount.getInternalNodes());
-        Assert.assertEquals(squareCount.getLeafNodes()     + 3, splitSquareCount.getLeafNodes());
-
-        // the number of vertices should not change, as the intermediate vertices
-        // at (0.0, 0.5) and (1.0, 0.5) induced by the top level horizontal split
-        // should be removed during the boundary extraction process
-        Vector2D[][] splitBoundary = splitSquare.getVertices();
-        Assert.assertEquals(1, splitBoundary.length);
-        Assert.assertEquals(4, splitBoundary[0].length);
-    }
-
-    private static class Counter {
-
-        private int internalNodes;
-        private int leafNodes;
-
-        public void count(PolygonsSet polygonsSet) {
-            leafNodes     = 0;
-            internalNodes = 0;
-            polygonsSet.getTree(false).visit(new BSPTreeVisitor<Vector2D>() {
-                @Override
-                public Order visitOrder(BSPTree<Vector2D> node) {
-                    return Order.SUB_PLUS_MINUS;
-                }
-                @Override
-                public void visitInternalNode(BSPTree<Vector2D> node) {
-                    ++internalNodes;
-                }
-                @Override
-                public void visitLeafNode(BSPTree<Vector2D> node) {
-                    ++leafNodes;
-                }
-
-            });
-        }
-
-        public int getInternalNodes() {
-            return internalNodes;
-        }
-
-        public int getLeafNodes() {
-            return leafNodes;
-        }
-    }
-
-    private PolygonsSet buildSet(Vector2D[][] vertices) {
-        ArrayList<SubHyperplane<Vector2D>> edges = new ArrayList<>();
-        for (int i = 0; i < vertices.length; ++i) {
-            int l = vertices[i].length;
-            for (int j = 0; j < l; ++j) {
-                edges.add(buildSegment(vertices[i][j], vertices[i][(j + 1) % l]));
-            }
-        }
-        return new PolygonsSet(edges, TEST_PRECISION);
-    }
-
-    private SubHyperplane<Vector2D> buildLine(Vector2D start, Vector2D end) {
-        return Line.fromPoints(start, end, TEST_PRECISION).wholeHyperplane();
-    }
-
-    private double intersectionAbscissa(Line l0, Line l1) {
-        Vector2D p = l0.intersection(l1);
-        return (l0.toSubSpace(p)).getX();
-    }
-
-    private SubHyperplane<Vector2D> buildHalfLine(Vector2D start, Vector2D end,
-                                                     boolean startIsVirtual) {
-        Line   line  = Line.fromPoints(start, end, TEST_PRECISION);
-        double lower = startIsVirtual ? Double.NEGATIVE_INFINITY : (line.toSubSpace(start)).getX();
-        double upper = startIsVirtual ? (line.toSubSpace(end)).getX() : Double.POSITIVE_INFINITY;
-        return new SubLine(line, new IntervalsSet(lower, upper, TEST_PRECISION));
-    }
-
-    private SubHyperplane<Vector2D> buildSegment(Vector2D start, Vector2D end) {
-        Line   line  = Line.fromPoints(start, end, TEST_PRECISION);
-        double lower = (line.toSubSpace(start)).getX();
-        double upper = (line.toSubSpace(end)).getX();
-        return new SubLine(line, new IntervalsSet(lower, upper, TEST_PRECISION));
-    }
-
-    private void checkPoints(Region.Location expected, PolygonsSet poly, Vector2D ... points) {
-        for (int i = 0; i < points.length; ++i) {
-            Assert.assertEquals("Incorrect location for " + points[i], expected, poly.checkPoint(points[i]));
-        }
-    }
-
-    /** Asserts that the two arrays of vertex loops have equivalent content.
-     * @param expectedLoops
-     * @param actualLoops
-     */
-    private void checkVertexLoopsEquivalent(Vector2D[][] expectedLoops, Vector2D[][] actualLoops) {
-        Assert.assertEquals("Expected vertices array to have length of " + expectedLoops.length + " but was " + actualLoops.length,
-                expectedLoops.length, actualLoops.length);
-
-        // go through each loop in the expected array and try to find a match in the actual array
-        for (Vector2D[] expectedLoop : expectedLoops) {
-            boolean foundMatch = false;
-            for (Vector2D[] actualLoop : actualLoops) {
-                if (vertexLoopsEquivalent(expectedLoop, actualLoop, TEST_EPS)) {
-                    foundMatch = true;
-                    break;
-                }
-            }
-
-            if (!foundMatch) {
-                StringBuilder sb = new StringBuilder();
-                for (Vector2D[] actualLoop : actualLoops) {
-                    sb.append(Arrays.toString(actualLoop));
-                    sb.append(", ");
-                }
-                if (sb.length() > 0) {
-                    sb.delete(sb.length() - 2, sb.length());
-                }
-                Assert.fail("Failed to find vertex loop " + Arrays.toString(expectedLoop) + " in loop array [" +
-                        sb.toString() + "].");
-            }
-        }
-    }
-
-    /** Returns true if the two sets of vertices can be considered equivalent using the given
-     * tolerance. For open loops, (i.e. ones that start with null) this means that the two loops
-     * must have the exact same elements in the exact same order. For closed loops, equivalent
-     * means that one of the loops can be rotated to match the other (e.g. [3, 1, 2] is equivalent
-     * to [1, 2, 3]).
-     * @param a
-     * @param b
-     * @param tolerance
-     * @return
-     */
-    private boolean vertexLoopsEquivalent(Vector2D[] a, Vector2D[] b, double tolerance) {
-        if (a.length == b.length) {
-            if (a.length < 1) {
-                // the loops are empty
-                return true;
-            }
-            if (a[0] == null || b[0] == null) {
-                // at least one of the loops is unclosed, so there is only one
-                // possible sequence that could match
-                return vertexLoopsEqual(a, 0, b, 0, tolerance);
-            }
-            else {
-                // the loops are closed so they could be equivalent but
-                // start at different vertices
-                for (int i=0; i<a.length; ++i) {
-                    if (vertexLoopsEqual(a, 0, b, i, tolerance)) {
-                        return true;
-                    }
-                }
-            }
-        }
-
-        return false;
-    }
-
-    /** Returns true if the two vertex loops have the same elements, starting
-     * from the given indices and allowing loop-around.
-     * @param a
-     * @param aStartIdx
-     * @param b
-     * @param bStartIdx
-     * @param tolerance
-     * @return
-     */
-    private boolean vertexLoopsEqual(Vector2D[] a, int aStartIdx,
-            Vector2D[] b, int bStartIdx, double tolerance) {
-
-        int len = a.length;
-
-        Vector2D ptA;
-        Vector2D ptB;
-        for (int i=0; i<len; ++i) {
-            ptA = a[(i + aStartIdx) % len];
-            ptB = b[(i + bStartIdx) % len];
-
-            if (!((ptA == null && ptB == null) ||
-                    (Precision.equals(ptA.getX(), ptB.getX(), tolerance) &&
-                     Precision.equals(ptA.getY(), ptB.getY(), tolerance)))) {
-                return false;
-            }
-        }
-
-        return true;
-    }
-}
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/PolylineTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/PolylineTest.java
new file mode 100644
index 0000000..f95495b
--- /dev/null
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/PolylineTest.java
@@ -0,0 +1,1306 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.twod;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.GeometryTestUtils;
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
+import org.apache.commons.geometry.euclidean.twod.Polyline.Builder;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class PolylineTest {
+
+    private static final double TEST_EPS = 1e-10;
+
+    private static final DoublePrecisionContext TEST_PRECISION =
+            new EpsilonDoublePrecisionContext(TEST_EPS);
+
+    @Test
+    public void testFromSegments_empty() {
+        // act
+        Polyline path = Polyline.fromSegments(new ArrayList<>());
+
+        // assert
+        Assert.assertTrue(path.isEmpty());
+        Assert.assertFalse(path.isInfinite());
+        Assert.assertTrue(path.isFinite());
+        Assert.assertFalse(path.isClosed());
+
+        Assert.assertNull(path.getStartSegment());
+        Assert.assertNull(path.getStartVertex());
+
+        Assert.assertNull(path.getEndSegment());
+        Assert.assertNull(path.getEndVertex());
+
+        Assert.assertEquals(0, path.getSegments().size());
+
+        Assert.assertEquals(0, path.getVertices().size());
+    }
+
+    @Test
+    public void testFromSegments_singleFiniteSegment() {
+        // arrange
+        Segment a = Segment.fromPoints(Vector2D.ZERO, Vector2D.of(1, 0), TEST_PRECISION);
+
+        // act
+        Polyline path = Polyline.fromSegments(a);
+
+        // assert
+        Assert.assertFalse(path.isEmpty());
+        Assert.assertFalse(path.isInfinite());
+        Assert.assertTrue(path.isFinite());
+        Assert.assertFalse(path.isClosed());
+
+        Assert.assertSame(a, path.getStartSegment());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.ZERO, path.getStartVertex(), TEST_EPS);
+
+        Assert.assertSame(a, path.getEndSegment());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1, 0), path.getEndVertex(), TEST_EPS);
+
+        List<Segment> segments = path.getSegments();
+        Assert.assertEquals(1, segments.size());
+        Assert.assertSame(a, segments.get(0));
+
+        Assert.assertEquals(Arrays.asList(Vector2D.ZERO, Vector2D.of(1, 0)), path.getVertices());
+    }
+
+    @Test
+    public void testFromSegments_singleInfiniteSegment() {
+        // arrange
+        Segment a = Line.fromPoints(Vector2D.ZERO, Vector2D.of(1, 0), TEST_PRECISION).span();
+
+        // act
+        Polyline path = Polyline.fromSegments(a);
+
+        // assert
+        Assert.assertFalse(path.isEmpty());
+        Assert.assertTrue(path.isInfinite());
+        Assert.assertFalse(path.isFinite());
+        Assert.assertFalse(path.isClosed());
+
+        Assert.assertSame(a, path.getStartSegment());
+        Assert.assertNull(path.getStartVertex());
+
+        Assert.assertSame(a, path.getEndSegment());
+        Assert.assertNull(path.getEndVertex());
+
+        List<Segment> segments = path.getSegments();
+        Assert.assertEquals(1, segments.size());
+        Assert.assertSame(a, segments.get(0));
+
+        Assert.assertEquals(0, path.getVertices().size());
+    }
+
+    @Test
+    public void testFromSegments_finiteSegments_notClosed() {
+        // arrange
+        Vector2D p1 = Vector2D.ZERO;
+        Vector2D p2 = Vector2D.of(1, 0);
+        Vector2D p3 = Vector2D.of(1, 1);
+
+        Segment a = Segment.fromPoints(p1, p2, TEST_PRECISION);
+        Segment b = Segment.fromPoints(p2, p3, TEST_PRECISION);
+
+        // act
+        Polyline path = Polyline.fromSegments(a, b);
+
+        // assert
+        Assert.assertFalse(path.isEmpty());
+        Assert.assertFalse(path.isInfinite());
+        Assert.assertTrue(path.isFinite());
+        Assert.assertFalse(path.isClosed());
+
+        Assert.assertSame(a, path.getStartSegment());
+        EuclideanTestUtils.assertCoordinatesEqual(p1, path.getStartVertex(), TEST_EPS);
+
+        Assert.assertSame(b, path.getEndSegment());
+        EuclideanTestUtils.assertCoordinatesEqual(p3, path.getEndVertex(), TEST_EPS);
+
+        List<Segment> segments = path.getSegments();
+        Assert.assertEquals(2, segments.size());
+        Assert.assertSame(a, segments.get(0));
+        Assert.assertSame(b, segments.get(1));
+
+        Assert.assertEquals(Arrays.asList(p1, p2, p3), path.getVertices());
+    }
+
+    @Test
+    public void testFromSegments_finiteSegments_closed() {
+        // arrange
+        Vector2D p1 = Vector2D.ZERO;
+        Vector2D p2 = Vector2D.of(1, 0);
+        Vector2D p3 = Vector2D.of(1, 1);
+
+        Segment a = Segment.fromPoints(p1, p2, TEST_PRECISION);
+        Segment b = Segment.fromPoints(p2, p3, TEST_PRECISION);
+        Segment c = Segment.fromPoints(p3, p1, TEST_PRECISION);
+
+        // act
+        Polyline path = Polyline.fromSegments(Arrays.asList(a, b, c));
+
+        // assert
+        Assert.assertFalse(path.isEmpty());
+        Assert.assertFalse(path.isInfinite());
+        Assert.assertTrue(path.isFinite());
+        Assert.assertTrue(path.isClosed());
+
+        Assert.assertSame(a, path.getStartSegment());
+        EuclideanTestUtils.assertCoordinatesEqual(p1, path.getStartVertex(), TEST_EPS);
+
+        Assert.assertSame(c, path.getEndSegment());
+        EuclideanTestUtils.assertCoordinatesEqual(p1, path.getEndVertex(), TEST_EPS);
+
+        List<Segment> segments = path.getSegments();
+        Assert.assertEquals(3, segments.size());
+        Assert.assertSame(a, segments.get(0));
+        Assert.assertSame(b, segments.get(1));
+        Assert.assertSame(c, segments.get(2));
+
+        Assert.assertEquals(Arrays.asList(p1, p2, p3, p1), path.getVertices());
+    }
+
+    @Test
+    public void testFromSegments_infiniteSegments() {
+        // arrange
+        Segment a = Line.fromPointAndAngle(Vector2D.ZERO, 0, TEST_PRECISION).segment(Double.NEGATIVE_INFINITY, 1.0);
+        Segment b = Line.fromPointAndAngle(Vector2D.of(1, 0), Geometry.HALF_PI, TEST_PRECISION).segment(0.0, Double.POSITIVE_INFINITY);
+
+        // act
+        Polyline path = Polyline.fromSegments(Arrays.asList(a, b));
+
+        // assert
+        Assert.assertFalse(path.isEmpty());
+        Assert.assertTrue(path.isInfinite());
+        Assert.assertFalse(path.isFinite());
+        Assert.assertFalse(path.isClosed());
+
+        Assert.assertSame(a, path.getStartSegment());
+        Assert.assertNull(path.getStartVertex());
+
+        Assert.assertSame(b, path.getEndSegment());
+        Assert.assertNull(path.getEndVertex());
+
+        List<Segment> segments = path.getSegments();
+        Assert.assertEquals(2, segments.size());
+        Assert.assertSame(a, segments.get(0));
+        Assert.assertSame(b, segments.get(1));
+
+        Assert.assertEquals(Arrays.asList(Vector2D.of(1, 0)), path.getVertices());
+    }
+
+    @Test
+    public void testFromSegments_finiteAndInfiniteSegments_startInfinite() {
+        // arrange
+        Segment a = Line.fromPointAndAngle(Vector2D.ZERO, 0, TEST_PRECISION).segment(Double.NEGATIVE_INFINITY, 1.0);
+        Segment b = Segment.fromPoints(Vector2D.of(1, 0), Vector2D.of(1, 1), TEST_PRECISION);
+
+        // act
+        Polyline path = Polyline.fromSegments(Arrays.asList(a, b));
+
+        // assert
+        Assert.assertFalse(path.isEmpty());
+        Assert.assertTrue(path.isInfinite());
+        Assert.assertFalse(path.isFinite());
+        Assert.assertFalse(path.isClosed());
+
+        Assert.assertSame(a, path.getStartSegment());
+        Assert.assertNull(path.getStartVertex());
+
+        Assert.assertSame(b, path.getEndSegment());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1, 1), path.getEndVertex(), TEST_EPS);
+
+        List<Segment> segments = path.getSegments();
+        Assert.assertEquals(2, segments.size());
+        Assert.assertSame(a, segments.get(0));
+        Assert.assertSame(b, segments.get(1));
+
+        Assert.assertEquals(Arrays.asList(Vector2D.of(1, 0), Vector2D.of(1, 1)), path.getVertices());
+    }
+
+    @Test
+    public void testFromSegments_finiteAndInfiniteSegments_endInfinite() {
+        // arrange
+        Segment a = Segment.fromPoints(Vector2D.ZERO, Vector2D.of(1, 0), TEST_PRECISION);
+        Segment b = Line.fromPointAndAngle(Vector2D.of(1, 0), Geometry.HALF_PI, TEST_PRECISION).segment(0.0, Double.POSITIVE_INFINITY);
+
+        // act
+        Polyline path = Polyline.fromSegments(Arrays.asList(a, b));
+
+        // assert
+        Assert.assertFalse(path.isEmpty());
+        Assert.assertTrue(path.isInfinite());
+        Assert.assertFalse(path.isFinite());
+        Assert.assertFalse(path.isClosed());
+
+        Assert.assertSame(a, path.getStartSegment());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.ZERO, path.getStartVertex(), TEST_EPS);
+
+        Assert.assertSame(b, path.getEndSegment());
+        Assert.assertNull(path.getEndVertex());
+
+        List<Segment> segments = path.getSegments();
+        Assert.assertEquals(2, segments.size());
+        Assert.assertSame(a, segments.get(0));
+        Assert.assertSame(b, segments.get(1));
+
+        Assert.assertEquals(Arrays.asList(Vector2D.ZERO, Vector2D.of(1, 0)), path.getVertices());
+    }
+
+    @Test
+    public void testFromSegments_segmentsNotConnected() {
+        // arrange
+        Segment a = Segment.fromPoints(Vector2D.ZERO, Vector2D.of(1, 0), TEST_PRECISION);
+        Segment b = Segment.fromPoints(Vector2D.of(1.01, 0), Vector2D.of(1, 0), TEST_PRECISION);
+
+        Segment c = Line.fromPointAndAngle(Vector2D.ZERO, Geometry.ZERO_PI, TEST_PRECISION).span();
+        Segment d = Line.fromPointAndAngle(Vector2D.of(1, 0), Geometry.HALF_PI, TEST_PRECISION).span();
+
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            Polyline.fromSegments(a, b);
+        }, IllegalStateException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            Polyline.fromSegments(c, b);
+        }, IllegalStateException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            Polyline.fromSegments(a, d);
+        }, IllegalStateException.class);
+    }
+
+    @Test
+    public void testFromVertices_empty() {
+        // act
+        Polyline path = Polyline.fromVertices(new ArrayList<>(), TEST_PRECISION);
+
+        // assert
+        Assert.assertTrue(path.isEmpty());
+        Assert.assertFalse(path.isInfinite());
+        Assert.assertTrue(path.isFinite());
+        Assert.assertFalse(path.isClosed());
+
+        Assert.assertNull(path.getStartSegment());
+        Assert.assertNull(path.getStartVertex());
+
+        Assert.assertNull(path.getEndSegment());
+        Assert.assertNull(path.getEndVertex());
+
+        Assert.assertEquals(0, path.getSegments().size());
+
+        Assert.assertEquals(0, path.getVertices().size());
+    }
+
+    @Test
+    public void testFromVertices_singleVertex_failsToCreatePath() {
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            Polyline.fromVertices(Arrays.asList(Vector2D.ZERO), TEST_PRECISION);
+        }, IllegalStateException.class);
+    }
+
+    @Test
+    public void testFromVertices_twoVertices() {
+        // arrange
+        Vector2D p1 = Vector2D.ZERO;
+        Vector2D p2 = Vector2D.of(1, 0);
+
+        // act
+        Polyline path = Polyline.fromVertices(Arrays.asList(p1, p2), TEST_PRECISION);
+
+        // assert
+        Assert.assertFalse(path.isEmpty());
+        Assert.assertFalse(path.isInfinite());
+        Assert.assertTrue(path.isFinite());
+        Assert.assertFalse(path.isClosed());
+
+        assertFiniteSegment(path.getStartSegment(), p1, p2);
+        EuclideanTestUtils.assertCoordinatesEqual(p1, path.getStartVertex(), TEST_EPS);
+
+        Assert.assertSame(path.getStartSegment(), path.getEndSegment());
+        EuclideanTestUtils.assertCoordinatesEqual(p2, path.getEndVertex(), TEST_EPS);
+
+        List<Segment> segments = path.getSegments();
+        Assert.assertEquals(1, segments.size());
+        assertFiniteSegment(segments.get(0), p1, p2);
+
+        Assert.assertEquals(Arrays.asList(p1, p2), path.getVertices());
+    }
+
+    @Test
+    public void testFromVertices_multipleVertices_notClosed() {
+        // arrange
+        Vector2D p1 = Vector2D.ZERO;
+        Vector2D p2 = Vector2D.of(1, 0);
+        Vector2D p3 = Vector2D.of(1, 1);
+        Vector2D p4 = Vector2D.of(0, 1);
+
+        // act
+        Polyline path = Polyline.fromVertices(Arrays.asList(p1, p2, p3, p4), TEST_PRECISION);
+
+        // assert
+        Assert.assertFalse(path.isEmpty());
+        Assert.assertFalse(path.isInfinite());
+        Assert.assertTrue(path.isFinite());
+        Assert.assertFalse(path.isClosed());
+
+        assertFiniteSegment(path.getStartSegment(), p1, p2);
+        EuclideanTestUtils.assertCoordinatesEqual(p1, path.getStartVertex(), TEST_EPS);
+
+        assertFiniteSegment(path.getEndSegment(), p3, p4);
+        EuclideanTestUtils.assertCoordinatesEqual(p4, path.getEndVertex(), TEST_EPS);
+
+        List<Segment> segments = path.getSegments();
+        Assert.assertEquals(3, segments.size());
+        assertFiniteSegment(segments.get(0), p1, p2);
+        assertFiniteSegment(segments.get(1), p2, p3);
+        assertFiniteSegment(segments.get(2), p3, p4);
+
+        Assert.assertEquals(Arrays.asList(p1, p2, p3, p4), path.getVertices());
+    }
+
+    @Test
+    public void testFromVertices_multipleVertices_closed() {
+        // arrange
+        Vector2D p1 = Vector2D.ZERO;
+        Vector2D p2 = Vector2D.of(1, 0);
+        Vector2D p3 = Vector2D.of(1, 1);
+        Vector2D p4 = Vector2D.of(0, 1);
+
+        // act
+        Polyline path = Polyline.fromVertices(Arrays.asList(p1, p2, p3, p4, p1), TEST_PRECISION);
+
+        // assert
+        Assert.assertFalse(path.isEmpty());
+        Assert.assertFalse(path.isInfinite());
+        Assert.assertTrue(path.isFinite());
+        Assert.assertTrue(path.isClosed());
+
+        assertFiniteSegment(path.getStartSegment(), p1, p2);
+        EuclideanTestUtils.assertCoordinatesEqual(p1, path.getStartVertex(), TEST_EPS);
+
+        assertFiniteSegment(path.getEndSegment(), p4, p1);
+        EuclideanTestUtils.assertCoordinatesEqual(p1, path.getEndVertex(), TEST_EPS);
+
+        List<Segment> segments = path.getSegments();
+        Assert.assertEquals(4, segments.size());
+        assertFiniteSegment(segments.get(0), p1, p2);
+        assertFiniteSegment(segments.get(1), p2, p3);
+        assertFiniteSegment(segments.get(2), p3, p4);
+        assertFiniteSegment(segments.get(3), p4, p1);
+
+        Assert.assertEquals(Arrays.asList(p1, p2, p3, p4, p1), path.getVertices());
+    }
+
+    @Test
+    public void testFromVertexLoop_empty() {
+        // act
+        Polyline path = Polyline.fromVertexLoop(new ArrayList<>(), TEST_PRECISION);
+
+        // assert
+        Assert.assertTrue(path.isEmpty());
+        Assert.assertFalse(path.isInfinite());
+        Assert.assertTrue(path.isFinite());
+        Assert.assertFalse(path.isClosed());
+
+        Assert.assertNull(path.getStartSegment());
+        Assert.assertNull(path.getStartVertex());
+
+        Assert.assertNull(path.getEndSegment());
+        Assert.assertNull(path.getEndVertex());
+
+        Assert.assertEquals(0, path.getSegments().size());
+
+        Assert.assertEquals(0, path.getVertices().size());
+    }
+
+    @Test
+    public void testFromVertexLoop_singleVertex_failsToCreatePath() {
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            Polyline.fromVertexLoop(Arrays.asList(Vector2D.ZERO), TEST_PRECISION);
+        }, IllegalStateException.class);
+    }
+
+    @Test
+    public void testFromVertexLoop_closeRequired() {
+        // arrange
+        Vector2D p1 = Vector2D.ZERO;
+        Vector2D p2 = Vector2D.of(1, 0);
+        Vector2D p3 = Vector2D.of(1, 1);
+
+        // act
+        Polyline path = Polyline.fromVertexLoop(Arrays.asList(p1, p2, p3), TEST_PRECISION);
+
+        // assert
+        Assert.assertFalse(path.isEmpty());
+        Assert.assertFalse(path.isInfinite());
+        Assert.assertTrue(path.isFinite());
+        Assert.assertTrue(path.isClosed());
+
+        List<Segment> segments = path.getSegments();
+        Assert.assertEquals(3, segments.size());
+        assertFiniteSegment(segments.get(0), p1, p2);
+        assertFiniteSegment(segments.get(1), p2, p3);
+        assertFiniteSegment(segments.get(2), p3, p1);
+
+        Assert.assertEquals(Arrays.asList(p1, p2, p3, p1), path.getVertices());
+    }
+
+    @Test
+    public void testFromVertexLoop_closeNotRequired() {
+        // arrange
+        Vector2D p1 = Vector2D.ZERO;
+        Vector2D p2 = Vector2D.of(1, 0);
+        Vector2D p3 = Vector2D.of(1, 1);
+
+        // act
+        Polyline path = Polyline.fromVertexLoop(Arrays.asList(p1, p2, p3, Vector2D.of(0, 0)), TEST_PRECISION);
+
+        // assert
+        Assert.assertFalse(path.isEmpty());
+        Assert.assertFalse(path.isInfinite());
+        Assert.assertTrue(path.isFinite());
+        Assert.assertTrue(path.isClosed());
+
+        List<Segment> segments = path.getSegments();
+        Assert.assertEquals(3, segments.size());
+        assertFiniteSegment(segments.get(0), p1, p2);
+        assertFiniteSegment(segments.get(1), p2, p3);
+        assertFiniteSegment(segments.get(2), p3, p1);
+
+        Assert.assertEquals(Arrays.asList(p1, p2, p3, p1), path.getVertices());
+    }
+
+    @Test
+    public void testFromVertices_booleanArg() {
+        // arrange
+        Vector2D p1 = Vector2D.ZERO;
+        Vector2D p2 = Vector2D.of(1, 0);
+        Vector2D p3 = Vector2D.of(0, 1);
+
+        // act
+        Polyline open = Polyline.fromVertices(Arrays.asList(p1, p2, p3), false, TEST_PRECISION);
+        Polyline closed = Polyline.fromVertices(Arrays.asList(p1, p2, p3), true, TEST_PRECISION);
+
+        // assert
+        Assert.assertFalse(open.isClosed());
+
+        List<Segment> openSegments = open.getSegments();
+        Assert.assertEquals(2, openSegments.size());
+        assertFiniteSegment(openSegments.get(0), p1, p2);
+        assertFiniteSegment(openSegments.get(1), p2, p3);
+
+        Assert.assertTrue(closed.isClosed());
+
+        List<Segment> closedSegments = closed.getSegments();
+        Assert.assertEquals(3, closedSegments.size());
+        assertFiniteSegment(closedSegments.get(0), p1, p2);
+        assertFiniteSegment(closedSegments.get(1), p2, p3);
+        assertFiniteSegment(closedSegments.get(2), p3, p1);
+    }
+
+    @Test
+    public void testGetSegments_listIsNotModifiable() {
+        // arrange
+        Segment a = Segment.fromPoints(Vector2D.ZERO, Vector2D.of(1, 0), TEST_PRECISION);
+        List<Segment> inputSegments = new ArrayList<>(Arrays.asList(a));
+
+        // act
+        Polyline path = Polyline.fromSegments(inputSegments);
+
+        inputSegments.clear();
+
+        // assert
+        Assert.assertNotSame(inputSegments, path.getSegments());
+        Assert.assertEquals(1, path.getSegments().size());
+
+        GeometryTestUtils.assertThrows(() -> {
+            path.getSegments().add(a);
+        }, UnsupportedOperationException.class);
+    }
+
+    @Test
+    public void testIterable() {
+        // arrange
+        Polyline path = Polyline.builder(TEST_PRECISION)
+                .appendVertices(Vector2D.ZERO, Vector2D.Unit.PLUS_X, Vector2D.of(1, 1)).build();
+
+        // act
+        List<Segment> segments = new ArrayList<>();
+        for (Segment segment : path) {
+            segments.add(segment);
+        }
+
+        // assert
+        Assert.assertEquals(2, segments.size());
+        assertFiniteSegment(segments.get(0), Vector2D.Unit.ZERO, Vector2D.Unit.PLUS_X);
+        assertFiniteSegment(segments.get(1), Vector2D.Unit.PLUS_X, Vector2D.of(1, 1));
+    }
+
+    @Test
+    public void testTransform_empty() {
+        // arrange
+        Polyline path = Polyline.empty();
+        FunctionTransform2D t = FunctionTransform2D.from(v -> v.add(Vector2D.Unit.PLUS_X));
+
+        // act/assert
+        Assert.assertSame(path, path.transform(t));
+    }
+
+    @Test
+    public void testTransform_finite() {
+        // arrange
+        Polyline path = Polyline.builder(TEST_PRECISION)
+                .append(Vector2D.Unit.ZERO)
+                .append(Vector2D.Unit.PLUS_X)
+                .append(Vector2D.Unit.PLUS_Y)
+                .close();
+
+        AffineTransformMatrix2D t =
+                AffineTransformMatrix2D.createRotation(Vector2D.of(1, 1), Geometry.HALF_PI);
+
+        // act
+        Polyline result = path.transform(t);
+
+        // assert
+        Assert.assertNotSame(path, result);
+        Assert.assertTrue(result.isClosed());
+        Assert.assertTrue(result.isFinite());
+
+        List<Segment> segments = result.getSegments();
+
+        Assert.assertEquals(3, segments.size());
+        assertFiniteSegment(segments.get(0), Vector2D.of(2, 0), Vector2D.of(2, 1));
+        assertFiniteSegment(segments.get(1), Vector2D.of(2, 1), Vector2D.Unit.PLUS_X);
+        assertFiniteSegment(segments.get(2), Vector2D.Unit.PLUS_X, Vector2D.of(2, 0));
+    }
+
+    @Test
+    public void testTransform_infinite() {
+        // arrange
+        Polyline path = Polyline.fromSegments(
+                Line.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_Y, TEST_PRECISION).span());
+
+        FunctionTransform2D t = FunctionTransform2D.from(v -> v.add(Vector2D.Unit.PLUS_X));
+
+        // act
+        Polyline result = path.transform(t);
+
+        // assert
+        Assert.assertNotSame(path, result);
+        Assert.assertFalse(result.isClosed());
+        Assert.assertFalse(result.isFinite());
+
+        List<Segment> segments = result.getSegments();
+
+        Assert.assertEquals(1, segments.size());
+        Segment segment = segments.get(0);
+        Assert.assertTrue(segment.isInfinite());
+        Assert.assertNull(segment.getStartPoint());
+        Assert.assertNull(segment.getEndPoint());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.Unit.PLUS_X, segment.getLine().getOrigin(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.Unit.PLUS_Y, segment.getLine().getDirection(), TEST_EPS);
+    }
+
+    @Test
+    public void testReverse_empty() {
+        // arrange
+        Polyline path = Polyline.empty();
+
+        // act/assert
+        Assert.assertSame(path, path.reverse());
+    }
+
+    @Test
+    public void testReverse() {
+        // arrange
+        Polyline path = Polyline.builder(TEST_PRECISION)
+                .append(Vector2D.Unit.ZERO)
+                .append(Vector2D.Unit.PLUS_X)
+                .append(Vector2D.Unit.PLUS_Y)
+                .close();
+
+        // act
+        Polyline result = path.reverse();
+
+        // assert
+        Assert.assertNotSame(path, result);
+        Assert.assertTrue(result.isClosed());
+        Assert.assertTrue(result.isFinite());
+
+        List<Segment> segments = result.getSegments();
+
+        Assert.assertEquals(3, segments.size());
+        assertFiniteSegment(segments.get(0), Vector2D.Unit.ZERO, Vector2D.Unit.PLUS_Y);
+        assertFiniteSegment(segments.get(1), Vector2D.Unit.PLUS_Y, Vector2D.Unit.PLUS_X);
+        assertFiniteSegment(segments.get(2), Vector2D.Unit.PLUS_X, Vector2D.Unit.ZERO);
+    }
+
+    @Test
+    public void testReverse_singleInfinite() {
+        // arrange
+        Polyline path = Polyline.fromSegments(
+                Line.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_Y, TEST_PRECISION).span());
+
+        // act
+        Polyline result = path.reverse();
+
+        // assert
+        Assert.assertNotSame(path, result);
+        Assert.assertFalse(result.isClosed());
+        Assert.assertFalse(result.isFinite());
+
+        List<Segment> segments = result.getSegments();
+
+        Assert.assertEquals(1, segments.size());
+        Segment segment = segments.get(0);
+        Assert.assertTrue(segment.isInfinite());
+        Assert.assertNull(segment.getStartPoint());
+        Assert.assertNull(segment.getEndPoint());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.Unit.ZERO, segment.getLine().getOrigin(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.Unit.MINUS_Y, segment.getLine().getDirection(), TEST_EPS);
+    }
+
+    @Test
+    public void testReverse_doubleInfinite() {
+        // arrange
+        Segment a = Line.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_Y, TEST_PRECISION).segmentTo(Vector2D.ZERO);
+        Segment b = Line.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_X, TEST_PRECISION).segmentFrom(Vector2D.ZERO);
+
+        Polyline path = Polyline.fromSegments(a, b);
+
+        // act
+        Polyline result = path.reverse();
+
+        // assert
+        Assert.assertNotSame(path, result);
+        Assert.assertFalse(result.isClosed());
+        Assert.assertFalse(result.isFinite());
+
+        List<Segment> segments = result.getSegments();
+        Assert.assertEquals(2, segments.size());
+
+        Segment bResult = segments.get(0);
+        Assert.assertTrue(bResult.isInfinite());
+        Assert.assertNull(bResult.getStartPoint());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.ZERO, bResult.getEndPoint(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.ZERO, bResult.getLine().getOrigin(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.Unit.MINUS_X, bResult.getLine().getDirection(), TEST_EPS);
+
+        Segment aResult = segments.get(1);
+        Assert.assertTrue(aResult.isInfinite());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.ZERO, aResult.getStartPoint(), TEST_EPS);
+        Assert.assertNull(aResult.getEndPoint());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.ZERO, aResult.getLine().getOrigin(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.Unit.MINUS_Y, aResult.getLine().getDirection(), TEST_EPS);
+    }
+
+    @Test
+    public void testToTree() {
+        // arrange
+        Polyline path = Polyline.builder(TEST_PRECISION)
+                .appendVertices(Vector2D.ZERO, Vector2D.Unit.PLUS_X, Vector2D.of(1, 1), Vector2D.of(0, 1))
+                .close();
+
+        // act
+        RegionBSPTree2D tree = path.toTree();
+
+        // assert
+        Assert.assertEquals(1, tree.getSize(), TEST_EPS);
+        Assert.assertEquals(4, tree.getBoundarySize(), TEST_EPS);
+
+        Assert.assertEquals(RegionLocation.INSIDE, tree.classify(Vector2D.of(0.5, 0.5)));
+
+        Assert.assertEquals(RegionLocation.OUTSIDE, tree.classify(Vector2D.of(0.5, -1)));
+        Assert.assertEquals(RegionLocation.OUTSIDE, tree.classify(Vector2D.of(0.5, 2)));
+        Assert.assertEquals(RegionLocation.OUTSIDE, tree.classify(Vector2D.of(-1, 0.5)));
+        Assert.assertEquals(RegionLocation.OUTSIDE, tree.classify(Vector2D.of(2, 0.5)));
+    }
+
+    @Test
+    public void testSimplify() {
+        // arrange
+        Builder builder = Polyline.builder(TEST_PRECISION);
+
+        Polyline path = builder.appendVertices(
+                Vector2D.of(-1, 0),
+                Vector2D.ZERO,
+                Vector2D.of(1, 0),
+                Vector2D.of(1, 1),
+                Vector2D.of(1, 2))
+            .build();
+
+        // act
+        Polyline result = path.simplify();
+
+        // assert
+        List<Segment> segments = result.getSegments();
+        Assert.assertEquals(2, segments.size());
+        assertFiniteSegment(segments.get(0), Vector2D.of(-1, 0), Vector2D.of(1, 0));
+        assertFiniteSegment(segments.get(1), Vector2D.of(1, 0), Vector2D.of(1, 2));
+    }
+
+    @Test
+    public void testSimplify_startAndEndCombined() {
+        // arrange
+        Builder builder = Polyline.builder(TEST_PRECISION);
+
+        Polyline path = builder.appendVertices(
+                Vector2D.ZERO,
+                Vector2D.of(1, 0),
+                Vector2D.of(0, 1),
+                Vector2D.of(-1, 0))
+            .close();
+
+        // act
+        Polyline result = path.simplify();
+
+        // assert
+        Assert.assertNotSame(path, result);
+        Assert.assertTrue(result.isClosed());
+        Assert.assertFalse(result.isInfinite());
+
+        List<Segment> segments = result.getSegments();
+        Assert.assertEquals(3, segments.size());
+        assertFiniteSegment(segments.get(0), Vector2D.of(-1, 0), Vector2D.of(1, 0));
+        assertFiniteSegment(segments.get(1), Vector2D.of(1, 0), Vector2D.of(0, 1));
+        assertFiniteSegment(segments.get(2), Vector2D.of(0, 1), Vector2D.of(-1, 0));
+    }
+
+    @Test
+    public void testSimplify_empty() {
+        // arrange
+        Builder builder = Polyline.builder(TEST_PRECISION);
+
+        Polyline path = builder.build();
+
+        // act
+        Polyline result = path.simplify();
+
+        // assert
+        Assert.assertNotSame(path, result);
+        Assert.assertFalse(result.isClosed());
+        Assert.assertFalse(result.isInfinite());
+
+        List<Segment> segments = result.getSegments();
+        Assert.assertEquals(0, segments.size());
+    }
+
+    @Test
+    public void testSimplify_infiniteSegment() {
+        // arrange
+        Line line = Line.fromPointAndAngle(Vector2D.ZERO, Geometry.ZERO_PI, TEST_PRECISION);
+
+        Builder builder = Polyline.builder(TEST_PRECISION);
+        Polyline path = builder
+                .append(line.span())
+                .build();
+
+        // act
+        Polyline result = path.simplify();
+
+        // assert
+        Assert.assertNotSame(path, result);
+        Assert.assertFalse(result.isClosed());
+        Assert.assertTrue(result.isInfinite());
+
+        Assert.assertNull(result.getStartVertex());
+        Assert.assertNull(result.getEndVertex());
+
+        List<Segment> segments = result.getSegments();
+        Assert.assertEquals(1, segments.size());
+        Assert.assertSame(line, segments.get(0).getLine());
+    }
+
+    @Test
+    public void testSimplify_combinedInfiniteSegment() {
+        // arrange
+        Line line = Line.fromPointAndAngle(Vector2D.ZERO, Geometry.ZERO_PI, TEST_PRECISION);
+        Split<Segment> split = line.span().split(Line.fromPointAndAngle(Vector2D.ZERO, Geometry.HALF_PI, TEST_PRECISION));
+
+        Builder builder = Polyline.builder(TEST_PRECISION);
+        Polyline path = builder
+                .append(split.getMinus())
+                .append(split.getPlus())
+                .build();
+
+        // act
+        Polyline result = path.simplify();
+
+        // assert
+        Assert.assertNotSame(path, result);
+        Assert.assertFalse(result.isClosed());
+        Assert.assertTrue(result.isInfinite());
+
+        Assert.assertNull(result.getStartVertex());
+        Assert.assertNull(result.getEndVertex());
+
+        List<Segment> segments = result.getSegments();
+        Assert.assertEquals(1, segments.size());
+        Assert.assertSame(line, segments.get(0).getLine());
+    }
+
+    @Test
+    public void testSimplify_startAndEndNotCombinedWhenNotClosed() {
+        // arrange
+        Line xAxis = Line.fromPointAndAngle(Vector2D.ZERO, Geometry.ZERO_PI, TEST_PRECISION);
+        Builder builder = Polyline.builder(TEST_PRECISION);
+
+        Polyline path = builder
+                .append(xAxis.segment(0, 1))
+                .appendVertices(
+                        Vector2D.of(2, 1),
+                        Vector2D.of(3, 0))
+                .append(xAxis.segment(3, 4))
+            .build();
+
+        // act
+        Polyline result = path.simplify();
+
+        // assert
+        Assert.assertNotSame(path, result);
+        Assert.assertFalse(result.isClosed());
+        Assert.assertFalse(result.isInfinite());
+
+        List<Segment> segments = result.getSegments();
+        Assert.assertEquals(4, segments.size());
+        assertFiniteSegment(segments.get(0), Vector2D.ZERO, Vector2D.of(1, 0));
+        assertFiniteSegment(segments.get(1), Vector2D.of(1, 0), Vector2D.of(2, 1));
+        assertFiniteSegment(segments.get(2), Vector2D.of(2, 1), Vector2D.of(3, 0));
+        assertFiniteSegment(segments.get(3), Vector2D.of(3, 0), Vector2D.of(4, 0));
+    }
+
+    @Test
+    public void testSimplify_subsequentCallsToReturnedObjectReturnSameObject() {
+        // arrange
+        Builder builder = Polyline.builder(TEST_PRECISION);
+        Polyline path = builder.appendVertices(
+                    Vector2D.ZERO,
+                    Vector2D.of(1, 0),
+                    Vector2D.of(2, 0))
+                .build();
+
+        // act
+        Polyline result = path.simplify();
+
+        // assert
+        Assert.assertNotSame(path, result);
+        Assert.assertSame(result, result.simplify());
+    }
+
+    @Test
+    public void testToString() {
+        // arrange
+        Line yAxis = Line.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_Y, TEST_PRECISION);
+        Line xAxis = Line.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_X, TEST_PRECISION);
+
+        Polyline empty = Polyline.empty();
+
+        Polyline singleFullSegment = Polyline.fromSegments(xAxis.span());
+        Polyline singleFiniteSegment = Polyline.fromSegments(
+                Segment.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_X, TEST_PRECISION));
+
+        Polyline startOpenPath = Polyline.builder(TEST_PRECISION)
+                .append(xAxis.segmentTo(Vector2D.Unit.PLUS_X))
+                .append(Vector2D.of(1, 1))
+                .build();
+
+        Polyline endOpenPath = Polyline.builder(TEST_PRECISION)
+                .append(Vector2D.of(0, 1))
+                .append(Vector2D.ZERO)
+                .append(xAxis.segmentFrom(Vector2D.ZERO))
+                .build();
+
+        Polyline doubleOpenPath = Polyline.fromSegments(yAxis.segmentTo(Vector2D.ZERO),
+                xAxis.segmentFrom(Vector2D.ZERO));
+
+        Polyline nonOpenPath = Polyline.builder(TEST_PRECISION)
+                .append(Vector2D.ZERO)
+                .append(Vector2D.Unit.PLUS_X)
+                .append(Vector2D.of(1, 1))
+                .build();
+
+        // act/assert
+        String emptyStr = empty.toString();
+        Assert.assertTrue(emptyStr.contains("empty= true"));
+
+        String singleFullStr = singleFullSegment.toString();
+        Assert.assertTrue(singleFullStr.contains("segment= Segment["));
+
+        String singleFiniteStr = singleFiniteSegment.toString();
+        Assert.assertTrue(singleFiniteStr.contains("segment= Segment["));
+
+        String startOpenStr = startOpenPath.toString();
+        Assert.assertTrue(startOpenStr.contains("startDirection= ") && startOpenStr.contains("vertices= "));
+
+        String endOpenStr = endOpenPath.toString();
+        Assert.assertTrue(endOpenStr.contains("vertices= ") && endOpenStr.contains("endDirection= "));
+
+        String doubleOpenStr = doubleOpenPath.toString();
+        Assert.assertTrue(doubleOpenStr.contains("startDirection= ") && doubleOpenStr.contains("vertices= ") &&
+                doubleOpenStr.contains("endDirection= "));
+
+        String nonOpenStr = nonOpenPath.toString();
+        Assert.assertTrue(nonOpenStr.contains("vertices= "));
+    }
+
+    @Test
+    public void testBuilder_prependAndAppend_segments() {
+        // arrange
+        Vector2D p1 = Vector2D.ZERO;
+        Vector2D p2 = Vector2D.of(1, 0);
+        Vector2D p3 = Vector2D.of(1, 1);
+        Vector2D p4 = Vector2D.of(1, 0);
+
+        Segment a = Segment.fromPoints(p1, p2, TEST_PRECISION);
+        Segment b = Segment.fromPoints(p2, p3, TEST_PRECISION);
+        Segment c = Segment.fromPoints(p3, p4, TEST_PRECISION);
+        Segment d = Segment.fromPoints(p4, p1, TEST_PRECISION);
+
+        Builder builder = Polyline.builder(null);
+
+        // act
+        builder.prepend(b)
+            .append(c)
+            .prepend(a)
+            .append(d);
+
+        Polyline path = builder.build();
+
+        // assert
+        List<Segment> segments = path.getSegments();
+        Assert.assertEquals(4, segments.size());
+        Assert.assertSame(a, segments.get(0));
+        Assert.assertSame(b, segments.get(1));
+        Assert.assertSame(c, segments.get(2));
+        Assert.assertSame(d, segments.get(3));
+    }
+
+    @Test
+    public void testBuilder_prependAndAppend_disconnectedSegments() {
+        // arrange
+        Segment a = Segment.fromPoints(Vector2D.ZERO, Vector2D.of(1, 0), TEST_PRECISION);
+
+        Builder builder = Polyline.builder(null);
+        builder.append(a);
+
+        // act
+        GeometryTestUtils.assertThrows(() -> {
+            builder.append(a);
+        }, IllegalStateException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            builder.prepend(a);
+        }, IllegalStateException.class);
+    }
+
+    @Test
+    public void testBuilder_prependAndAppend_vertices() {
+        // arrange
+        Vector2D p1 = Vector2D.ZERO;
+        Vector2D p2 = Vector2D.of(1, 0);
+        Vector2D p3 = Vector2D.of(1, 1);
+        Vector2D p4 = Vector2D.of(1, 0);
+
+        Builder builder = Polyline.builder(TEST_PRECISION);
+
+        // act
+        builder.prepend(p2)
+            .append(p3)
+            .prepend(p1)
+            .append(p4)
+            .append(p1);
+
+        Polyline path = builder.build();
+
+        // assert
+        List<Segment> segments = path.getSegments();
+        Assert.assertEquals(4, segments.size());
+        assertFiniteSegment(segments.get(0), p1, p2);
+        assertFiniteSegment(segments.get(1), p2, p3);
+        assertFiniteSegment(segments.get(2), p3, p4);
+        assertFiniteSegment(segments.get(3), p4, p1);
+    }
+
+    @Test
+    public void testBuilder_prependAndAppend_noPrecisionSpecified() {
+        // arrange
+        Vector2D p = Vector2D.ZERO;
+        Builder builder = Polyline.builder(null);
+
+        String msg = "Unable to create line segment: no vertex precision specified";
+
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            builder.append(p);
+        }, IllegalStateException.class, msg);
+
+        GeometryTestUtils.assertThrows(() -> {
+            builder.prepend(p);
+        }, IllegalStateException.class, msg);
+    }
+
+    @Test
+    public void testBuilder_prependAndAppend_addingToInfinitePath() {
+        // arrange
+        Vector2D p = Vector2D.Unit.PLUS_X;
+        Builder builder = Polyline.builder(TEST_PRECISION);
+
+        builder.append(Line.fromPointAndAngle(Vector2D.ZERO, Geometry.ZERO_PI, TEST_PRECISION).span());
+
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            builder.append(p);
+        }, IllegalStateException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            builder.prepend(p);
+        }, IllegalStateException.class);
+    }
+
+    @Test
+    public void testBuilder_prependAndAppend_ignoresEquivalentVertices() {
+        // arrange
+        Vector2D p = Vector2D.ZERO;
+
+        Builder builder = Polyline.builder(TEST_PRECISION);
+        builder.append(p);
+
+        // act
+        builder.append(p)
+            .prepend(p)
+            .append(Vector2D.of(0, 1e-20))
+            .prepend(Vector2D.of(1e-20, 0));
+
+        builder.append(Vector2D.Unit.PLUS_X);
+
+        // assert
+        Polyline path = builder.build();
+
+        List<Segment> segments = path.getSegments();
+        Assert.assertEquals(1, segments.size());
+        assertFiniteSegment(segments.get(0), p, Vector2D.Unit.PLUS_X);
+    }
+
+    @Test
+    public void testBuilder_prependAndAppend_mixedVerticesAndSegments() {
+        // arrange
+        Vector2D p1 = Vector2D.ZERO;
+        Vector2D p2 = Vector2D.of(1, 0);
+        Vector2D p3 = Vector2D.of(1, 1);
+        Vector2D p4 = Vector2D.of(0, 1);
+
+        Segment a = Segment.fromPoints(p1, p2, TEST_PRECISION);
+        Segment c = Segment.fromPoints(p3, p4, TEST_PRECISION);
+
+        Builder builder = Polyline.builder(TEST_PRECISION);
+
+        // act
+        builder.prepend(p2)
+            .append(p3)
+            .append(c)
+            .prepend(a)
+            .append(p1);
+
+        Polyline path = builder.build();
+
+        // assert
+        List<Segment> segments = path.getSegments();
+        Assert.assertEquals(4, segments.size());
+        assertFiniteSegment(segments.get(0), p1, p2);
+        assertFiniteSegment(segments.get(1), p2, p3);
+        assertFiniteSegment(segments.get(2), p3, p4);
+        assertFiniteSegment(segments.get(3), p4, p1);
+    }
+
+    @Test
+    public void testBuilder_appendVertices() {
+        // arrange
+        Vector2D p1 = Vector2D.ZERO;
+        Vector2D p2 = Vector2D.of(1, 0);
+        Vector2D p3 = Vector2D.of(1, 1);
+        Vector2D p4 = Vector2D.of(0, 1);
+
+        Builder builder = Polyline.builder(TEST_PRECISION);
+
+        // act
+        builder.appendVertices(p1, p2)
+            .appendVertices(Arrays.asList(p3, p4, p1));
+
+        Polyline path = builder.build();
+
+        // assert
+        List<Segment> segments = path.getSegments();
+        Assert.assertEquals(4, segments.size());
+        assertFiniteSegment(segments.get(0), p1, p2);
+        assertFiniteSegment(segments.get(1), p2, p3);
+        assertFiniteSegment(segments.get(2), p3, p4);
+        assertFiniteSegment(segments.get(3), p4, p1);
+    }
+
+    @Test
+    public void testBuilder_prependVertices() {
+        // arrange
+        Vector2D p1 = Vector2D.ZERO;
+        Vector2D p2 = Vector2D.of(1, 0);
+        Vector2D p3 = Vector2D.of(1, 1);
+        Vector2D p4 = Vector2D.of(0, 1);
+
+        Builder builder = Polyline.builder(TEST_PRECISION);
+
+        // act
+        builder.prependVertices(p3, p4, p1)
+            .prependVertices(Arrays.asList(p1, p2));
+
+        Polyline path = builder.build();
+
+        // assert
+        List<Segment> segments = path.getSegments();
+        Assert.assertEquals(4, segments.size());
+        assertFiniteSegment(segments.get(0), p1, p2);
+        assertFiniteSegment(segments.get(1), p2, p3);
+        assertFiniteSegment(segments.get(2), p3, p4);
+        assertFiniteSegment(segments.get(3), p4, p1);
+    }
+
+    @Test
+    public void testBuilder_close_notYetClosed() {
+        // arrange
+        Vector2D p1 = Vector2D.ZERO;
+        Vector2D p2 = Vector2D.of(1, 0);
+        Vector2D p3 = Vector2D.of(1, 1);
+
+        Builder builder = Polyline.builder(TEST_PRECISION);
+
+        // act
+        builder.append(p1)
+            .append(p2)
+            .append(p3);
+
+        Polyline path = builder.close();
+
+        // assert
+        List<Segment> segments = path.getSegments();
+        Assert.assertEquals(3, segments.size());
+        assertFiniteSegment(segments.get(0), p1, p2);
+        assertFiniteSegment(segments.get(1), p2, p3);
+        assertFiniteSegment(segments.get(2), p3, p1);
+    }
+
+    @Test
+    public void testBuilder_close_alreadyClosed() {
+        // arrange
+        Vector2D p1 = Vector2D.ZERO;
+        Vector2D p2 = Vector2D.of(1, 0);
+        Vector2D p3 = Vector2D.of(1, 1);
+
+        Builder builder = Polyline.builder(TEST_PRECISION);
+
+        // act
+        builder.append(p1)
+            .append(p2)
+            .append(p3)
+            .append(p1);
+
+        Polyline path = builder.close();
+
+        // assert
+        List<Segment> segments = path.getSegments();
+        Assert.assertEquals(3, segments.size());
+        assertFiniteSegment(segments.get(0), p1, p2);
+        assertFiniteSegment(segments.get(1), p2, p3);
+        assertFiniteSegment(segments.get(2), p3, p1);
+    }
+
+    @Test
+    public void testBuilder_close_infiniteSegmentAtStart() {
+        // arrange
+        Builder builder = Polyline.builder(TEST_PRECISION);
+
+        builder.append(Line.fromPointAndAngle(Vector2D.ZERO, Geometry.ZERO_PI, TEST_PRECISION)
+                .segment(Double.NEGATIVE_INFINITY, 1))
+            .append(Vector2D.of(1, 1));
+
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            builder.close();
+        }, IllegalStateException.class, "Unable to close polyline: polyline is infinite");
+    }
+
+    @Test
+    public void testBuilder_close_infiniteSegmentAtEnd() {
+        // arrange
+        Builder builder = Polyline.builder(TEST_PRECISION);
+
+        builder
+            .append(Vector2D.ZERO)
+            .append(Vector2D.Unit.PLUS_X)
+            .append(Line.fromPointAndAngle(Vector2D.Unit.PLUS_X, Geometry.HALF_PI, TEST_PRECISION)
+                .segment(0, Double.POSITIVE_INFINITY));
+
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            builder.close();
+        }, IllegalStateException.class, "Unable to close polyline: polyline is infinite");
+    }
+
+    @Test
+    public void testBuilder_close_emptyPath() {
+        // arrange
+        Builder builder = Polyline.builder(TEST_PRECISION);
+
+        // act
+        Polyline path = builder.close();
+
+        // assert
+        Assert.assertEquals(0, path.getSegments().size());
+    }
+
+    @Test
+    public void testBuilder_close_obtuseTriangle() {
+        // arrange
+        Builder builder = Polyline.builder(TEST_PRECISION);
+        builder.appendVertices(Vector2D.ZERO, Vector2D.of(1, 0), Vector2D.of(2, 1));
+
+        // act
+        Polyline path = builder.close();
+
+        // assert
+        Assert.assertEquals(3, path.getSegments().size());
+        assertFiniteSegment(path.getSegments().get(0), Vector2D.ZERO, Vector2D.of(1, 0));
+        assertFiniteSegment(path.getSegments().get(1), Vector2D.of(1, 0), Vector2D.of(2, 1));
+        assertFiniteSegment(path.getSegments().get(2), Vector2D.of(2, 1), Vector2D.ZERO);
+    }
+
+    private static void assertFiniteSegment(Segment segment, Vector2D start, Vector2D end) {
+        Assert.assertFalse(segment.isInfinite());
+        Assert.assertTrue(segment.isFinite());
+
+        EuclideanTestUtils.assertCoordinatesEqual(start, segment.getStartPoint(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(end, segment.getEndPoint(), TEST_EPS);
+    }
+}
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/RegionBSPTree2DTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/RegionBSPTree2DTest.java
new file mode 100644
index 0000000..e84f271
--- /dev/null
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/RegionBSPTree2DTest.java
@@ -0,0 +1,1237 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.euclidean.twod;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.GeometryTestUtils;
+import org.apache.commons.geometry.core.Region;
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.exception.GeometryValueException;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.SplitLocation;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
+import org.apache.commons.geometry.euclidean.oned.Interval;
+import org.apache.commons.geometry.euclidean.twod.RegionBSPTree2D.RegionNode2D;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class RegionBSPTree2DTest {
+
+    private static final double TEST_EPS = 1e-10;
+
+    private static final DoublePrecisionContext TEST_PRECISION =
+            new EpsilonDoublePrecisionContext(TEST_EPS);
+
+    private static final Comparator<Segment> SEGMENT_COMPARATOR =
+            (a, b) -> Vector2D.COORDINATE_ASCENDING_ORDER.compare(a.getStartPoint(), b.getStartPoint());
+
+    private static final Line X_AXIS = Line.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_X, TEST_PRECISION);
+
+    private static final Line Y_AXIS = Line.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_Y, TEST_PRECISION);
+
+    @Test
+    public void testCtor_booleanArg_true() {
+        // act
+        RegionBSPTree2D tree = new RegionBSPTree2D(true);
+
+        // assert
+        Assert.assertTrue(tree.isFull());
+        Assert.assertFalse(tree.isEmpty());
+        Assert.assertEquals(1, tree.count());
+    }
+
+    @Test
+    public void testCtor_booleanArg_false() {
+        // act
+        RegionBSPTree2D tree = new RegionBSPTree2D(false);
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertTrue(tree.isEmpty());
+        Assert.assertEquals(1, tree.count());
+    }
+
+    @Test
+    public void testCtor_default() {
+        // act
+        RegionBSPTree2D tree = new RegionBSPTree2D();
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertTrue(tree.isEmpty());
+        Assert.assertEquals(1, tree.count());
+    }
+
+    @Test
+    public void testFull_factoryMethod() {
+        // act
+        RegionBSPTree2D tree = RegionBSPTree2D.full();
+
+        // assert
+        Assert.assertTrue(tree.isFull());
+        Assert.assertFalse(tree.isEmpty());
+        Assert.assertEquals(1, tree.count());
+    }
+
+    @Test
+    public void testEmpty_factoryMethod() {
+        // act
+        RegionBSPTree2D tree = RegionBSPTree2D.empty();
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertTrue(tree.isEmpty());
+        Assert.assertEquals(1, tree.count());
+    }
+
+    @Test
+    public void testCopy() {
+        // arrange
+        RegionBSPTree2D tree = new RegionBSPTree2D(true);
+        tree.getRoot().cut(Line.fromPointAndAngle(Vector2D.ZERO, Geometry.ZERO_PI, TEST_PRECISION));
+
+        // act
+        RegionBSPTree2D copy = tree.copy();
+
+        // assert
+        Assert.assertNotSame(tree, copy);
+        Assert.assertEquals(3, copy.count());
+    }
+
+    @Test
+    public void testBoundaries() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.builder(TEST_PRECISION)
+                .addRect(Vector2D.ZERO, Vector2D.of(1, 1))
+                .build();
+
+        // act
+        List<Segment> segments = new ArrayList<>();
+        tree.boundaries().forEach(segments::add);
+
+        // assert
+        Assert.assertEquals(4, segments.size());
+    }
+
+    @Test
+    public void testGetBoundaries() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.builder(TEST_PRECISION)
+                .addRect(Vector2D.ZERO, Vector2D.of(1, 1))
+                .build();
+
+        // act
+        List<Segment> segments = tree.getBoundaries();
+
+        // assert
+        Assert.assertEquals(4, segments.size());
+    }
+
+    @Test
+    public void testGetBoundaryPaths_cachesResult() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.empty();
+        tree.insert(Segment.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_X, TEST_PRECISION));
+
+        // act
+        List<Polyline> a = tree.getBoundaryPaths();
+        List<Polyline> b = tree.getBoundaryPaths();
+
+        // assert
+        Assert.assertSame(a, b);
+    }
+
+    @Test
+    public void testGetBoundaryPaths_recomputesResultOnChange() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.empty();
+        tree.insert(Segment.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_X, TEST_PRECISION));
+
+        // act
+        List<Polyline> a = tree.getBoundaryPaths();
+        tree.insert(Segment.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_Y, TEST_PRECISION));
+        List<Polyline> b = tree.getBoundaryPaths();
+
+        // assert
+        Assert.assertNotSame(a, b);
+    }
+
+    @Test
+    public void testGetBoundaryPaths_isUnmodifiable() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.empty();
+        tree.insert(Segment.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_X, TEST_PRECISION));
+
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            tree.getBoundaryPaths().add(Polyline.builder(null).build());
+        }, UnsupportedOperationException.class);
+    }
+
+    @Test
+    public void testAdd_convexArea() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.empty();
+
+        // act
+        tree.add(ConvexArea.fromVertexLoop(Arrays.asList(
+                    Vector2D.ZERO, Vector2D.of(2, 0),
+                    Vector2D.of(2, 2), Vector2D.of(0, 2)
+                ), TEST_PRECISION));
+        tree.add(ConvexArea.fromVertexLoop(Arrays.asList(
+                Vector2D.of(1, 1), Vector2D.of(3, 1),
+                Vector2D.of(3, 3), Vector2D.of(1, 3)
+            ), TEST_PRECISION));
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertFalse(tree.isEmpty());
+
+        Assert.assertEquals(7, tree.getSize(), TEST_EPS);
+        Assert.assertEquals(12, tree.getBoundarySize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1.5, 1.5), tree.getBarycenter(), TEST_EPS);
+
+        checkClassify(tree, RegionLocation.INSIDE,
+                Vector2D.of(1, 1), Vector2D.of(1.5, 1.5), Vector2D.of(2, 2));
+    }
+
+    @Test
+    public void testToConvex_full() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.full();
+
+        // act
+        List<ConvexArea> result = tree.toConvex();
+
+        // assert
+        Assert.assertEquals(1, result.size());
+        Assert.assertTrue(result.get(0).isFull());
+    }
+
+    @Test
+    public void testToConvex_empty() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.empty();
+
+        // act
+        List<ConvexArea> result = tree.toConvex();
+
+        // assert
+        Assert.assertEquals(0, result.size());
+    }
+
+    @Test
+    public void testToConvex_halfSpace() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.full();
+        tree.getRoot().insertCut(Line.fromPointAndAngle(Vector2D.ZERO, Geometry.ZERO_PI, TEST_PRECISION));
+
+        // act
+        List<ConvexArea> result = tree.toConvex();
+
+        // assert
+        Assert.assertEquals(1, result.size());
+
+        ConvexArea area = result.get(0);
+        Assert.assertFalse(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+
+        checkClassify(area, RegionLocation.INSIDE, Vector2D.of(0, 1));
+        checkClassify(area, RegionLocation.BOUNDARY, Vector2D.ZERO);
+        checkClassify(area, RegionLocation.OUTSIDE, Vector2D.of(0, -1));
+    }
+
+    @Test
+    public void testToConvex_quadrantComplement() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.full();
+        tree.getRoot().cut(Line.fromPointAndAngle(Vector2D.ZERO, Geometry.PI, TEST_PRECISION))
+            .getPlus().cut(Line.fromPointAndAngle(Vector2D.ZERO, Geometry.HALF_PI, TEST_PRECISION));
+
+        tree.complement();
+
+        // act
+        List<ConvexArea> result = tree.toConvex();
+
+        // assert
+        Assert.assertEquals(1, result.size());
+
+        ConvexArea area = result.get(0);
+        Assert.assertFalse(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+
+        checkClassify(area, RegionLocation.INSIDE, Vector2D.of(1, 1));
+        checkClassify(area, RegionLocation.BOUNDARY, Vector2D.ZERO, Vector2D.of(1, 0), Vector2D.of(0, 1));
+        checkClassify(area, RegionLocation.OUTSIDE, Vector2D.of(1, -1), Vector2D.of(-1, -1), Vector2D.of(-1, 1));
+    }
+
+    @Test
+    public void testToConvex_square() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.builder(TEST_PRECISION)
+                .addSquare(Vector2D.ZERO, 1)
+                .build();
+
+        // act
+        List<ConvexArea> result = tree.toConvex();
+
+        // assert
+        Assert.assertEquals(1, result.size());
+
+        ConvexArea area = result.get(0);
+        Assert.assertFalse(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+
+        Assert.assertEquals(1, area.getSize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(0.5, 0.5), area.getBarycenter(), TEST_EPS);
+
+        checkClassify(area, RegionLocation.INSIDE, Vector2D.of(0.5, 0.5));
+        checkClassify(area, RegionLocation.BOUNDARY, Vector2D.ZERO, Vector2D.of(1, 1));
+        checkClassify(area, RegionLocation.OUTSIDE,
+                Vector2D.of(0.5, -1), Vector2D.of(0.5, 2),
+                Vector2D.of(-1, 0.5), Vector2D.of(2, 0.5));
+    }
+
+    @Test
+    public void testToConvex_multipleConvexAreas() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.empty();
+        tree.insert(Arrays.asList(
+                    Segment.fromPoints(Vector2D.ZERO, Vector2D.of(1, 1), TEST_PRECISION),
+
+                    Segment.fromPoints(Vector2D.of(1, 1), Vector2D.of(0, 1), TEST_PRECISION),
+                    Segment.fromPoints(Vector2D.of(0, 1), Vector2D.ZERO, TEST_PRECISION),
+
+                    Segment.fromPoints(Vector2D.ZERO, Vector2D.of(1, 0), TEST_PRECISION),
+                    Segment.fromPoints(Vector2D.of(1, 0), Vector2D.of(1, 1), TEST_PRECISION)
+                ));
+
+        // act
+        List<ConvexArea> result = tree.toConvex();
+
+        // assert
+        Collections.sort(result, (a, b) ->
+            Vector2D.COORDINATE_ASCENDING_ORDER.compare(a.getBarycenter(), b.getBarycenter()));
+
+        Assert.assertEquals(2, result.size());
+
+        ConvexArea firstArea = result.get(0);
+        Assert.assertFalse(firstArea.isFull());
+        Assert.assertFalse(firstArea.isEmpty());
+
+        Assert.assertEquals(0.5, firstArea.getSize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1.0 / 3.0, 2.0 / 3.0), firstArea.getBarycenter(), TEST_EPS);
+
+        checkClassify(firstArea, RegionLocation.INSIDE, Vector2D.of(1.0 / 3.0, 2.0 / 3.0));
+        checkClassify(firstArea, RegionLocation.BOUNDARY, Vector2D.ZERO, Vector2D.of(1, 1), Vector2D.of(0.5, 0.5));
+        checkClassify(firstArea, RegionLocation.OUTSIDE,
+                Vector2D.of(0.25, -1), Vector2D.of(0.25, 2),
+                Vector2D.of(-1, 0.5), Vector2D.of(0.75, 0.5));
+
+        ConvexArea secondArea = result.get(1);
+        Assert.assertFalse(secondArea.isFull());
+        Assert.assertFalse(secondArea.isEmpty());
+
+        Assert.assertEquals(0.5, secondArea.getSize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(2.0 / 3.0, 1.0 / 3.0), secondArea.getBarycenter(), TEST_EPS);
+
+        checkClassify(secondArea, RegionLocation.INSIDE, Vector2D.of(2.0 / 3.0, 1.0 / 3.0));
+        checkClassify(secondArea, RegionLocation.BOUNDARY, Vector2D.ZERO, Vector2D.of(1, 1), Vector2D.of(0.5, 0.5));
+        checkClassify(secondArea, RegionLocation.OUTSIDE,
+                Vector2D.of(0.75, -1), Vector2D.of(0.75, 2),
+                Vector2D.of(2, 0.5), Vector2D.of(0.25, 0.5));
+    }
+
+    @Test
+    public void testGetNodeRegion() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.empty();
+
+        RegionNode2D root = tree.getRoot();
+        root.cut(Line.fromPointAndAngle(Vector2D.ZERO, Geometry.ZERO_PI, TEST_PRECISION));
+
+        RegionNode2D minus = root.getMinus();
+        minus.cut(Line.fromPointAndAngle(Vector2D.ZERO, Geometry.HALF_PI, TEST_PRECISION));
+
+        Vector2D origin = Vector2D.ZERO;
+
+        Vector2D a = Vector2D.of(1, 0);
+        Vector2D b = Vector2D.of(1, 1);
+        Vector2D c = Vector2D.of(0, 1);
+        Vector2D d = Vector2D.of(-1, 1);
+        Vector2D e = Vector2D.of(-1, 0);
+        Vector2D f = Vector2D.of(-1, -1);
+        Vector2D g = Vector2D.of(0, -1);
+        Vector2D h = Vector2D.of(1, -1);
+
+        // act/assert
+        checkConvexArea(root.getNodeRegion(), Arrays.asList(origin, a, b, c, d, e, f, g, h), Arrays.asList());
+
+        checkConvexArea(minus.getNodeRegion(), Arrays.asList(b, c, d), Arrays.asList(f, g, h));
+        checkConvexArea(root.getPlus().getNodeRegion(), Arrays.asList(f, g, h), Arrays.asList(b, c, d));
+
+        checkConvexArea(minus.getMinus().getNodeRegion(), Arrays.asList(d), Arrays.asList(a, b, f, g, h));
+        checkConvexArea(minus.getPlus().getNodeRegion(), Arrays.asList(b), Arrays.asList(d, e, f, g, h));
+    }
+
+    @Test
+    public void testSplit_full() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.full();
+
+        Line splitter = Line.fromPointAndAngle(Vector2D.of(1, 0), 0.25 * Geometry.PI, TEST_PRECISION);
+
+        // act
+        Split<RegionBSPTree2D> split = tree.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        checkClassify(split.getMinus(), RegionLocation.INSIDE, Vector2D.of(0, 1));
+        checkClassify(split.getMinus(), RegionLocation.OUTSIDE, Vector2D.of(1, -1));
+
+        List<Polyline> minusBoundaryList = split.getMinus().getBoundaryPaths();
+        Assert.assertEquals(1, minusBoundaryList.size());
+
+        Polyline minusBoundary = minusBoundaryList.get(0);
+        Assert.assertEquals(1, minusBoundary.getSegments().size());
+        Assert.assertTrue(minusBoundary.isInfinite());
+        Assert.assertSame(splitter, minusBoundary.getStartSegment().getLine());
+
+        checkClassify(split.getPlus(), RegionLocation.OUTSIDE, Vector2D.of(0, 1));
+        checkClassify(split.getPlus(), RegionLocation.INSIDE, Vector2D.of(1, -1));
+
+        List<Polyline> plusBoundaryList = split.getPlus().getBoundaryPaths();
+        Assert.assertEquals(1, plusBoundaryList.size());
+
+        Polyline plusBoundary = minusBoundaryList.get(0);
+        Assert.assertEquals(1, plusBoundary.getSegments().size());
+        Assert.assertTrue(plusBoundary.isInfinite());
+        Assert.assertSame(splitter, plusBoundary.getStartSegment().getLine());
+    }
+
+    @Test
+    public void testSplit_empty() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.empty();
+
+        Line splitter = Line.fromPointAndAngle(Vector2D.of(1, 0), 0.25 * Geometry.PI, TEST_PRECISION);
+
+        // act
+        Split<RegionBSPTree2D> split = tree.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.NEITHER, split.getLocation());
+
+        Assert.assertNull(split.getMinus());
+        Assert.assertNull(split.getPlus());
+    }
+
+    @Test
+    public void testSplit_bothSides() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.builder(TEST_PRECISION)
+                .addRect(Vector2D.ZERO, 2, 1)
+                .build();
+
+        Line splitter = Line.fromPointAndAngle(Vector2D.ZERO, 0.25 * Geometry.PI, TEST_PRECISION);
+
+        // act
+        Split<RegionBSPTree2D> split = tree.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        List<Polyline> minusPath = split.getMinus().getBoundaryPaths();
+        Assert.assertEquals(1, minusPath.size());
+        checkVertices(minusPath.get(0), Vector2D.ZERO, Vector2D.of(1, 1),
+                Vector2D.of(0, 1), Vector2D.ZERO);
+
+        List<Polyline> plusPath = split.getPlus().getBoundaryPaths();
+        Assert.assertEquals(1, plusPath.size());
+        checkVertices(plusPath.get(0), Vector2D.ZERO, Vector2D.of(2, 0),
+                Vector2D.of(2, 1), Vector2D.of(1, 1), Vector2D.ZERO);
+    }
+
+    @Test
+    public void testSplit_plusSideOnly() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.builder(TEST_PRECISION)
+                .addRect(Vector2D.ZERO, 2, 1)
+                .build();
+
+        Line splitter = Line.fromPointAndAngle(Vector2D.of(0, 1), 0.25 * Geometry.PI, TEST_PRECISION);
+
+        // act
+        Split<RegionBSPTree2D> split = tree.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.PLUS, split.getLocation());
+
+        Assert.assertNull(split.getMinus());
+
+        List<Polyline> plusPath = split.getPlus().getBoundaryPaths();
+        Assert.assertEquals(1, plusPath.size());
+        checkVertices(plusPath.get(0), Vector2D.ZERO, Vector2D.of(2, 0),
+                Vector2D.of(2, 1), Vector2D.of(0, 1), Vector2D.ZERO);
+    }
+
+    @Test
+    public void testSplit_minusSideOnly() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.builder(TEST_PRECISION)
+                .addRect(Vector2D.ZERO, 2, 1)
+                .build();
+
+        Line splitter = Line.fromPointAndAngle(Vector2D.of(0, 1), 0.25 * Geometry.PI, TEST_PRECISION)
+                .reverse();
+
+        // act
+        Split<RegionBSPTree2D> split = tree.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.MINUS, split.getLocation());
+
+        List<Polyline> minusPath = split.getMinus().getBoundaryPaths();
+        Assert.assertEquals(1, minusPath.size());
+        checkVertices(minusPath.get(0), Vector2D.ZERO, Vector2D.of(2, 0),
+                Vector2D.of(2, 1), Vector2D.of(0, 1), Vector2D.ZERO);
+
+        Assert.assertNull(split.getPlus());
+    }
+
+    @Test
+    public void testGeometricProperties_full() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.full();
+
+        // act/assert
+        GeometryTestUtils.assertPositiveInfinity(tree.getSize());
+        Assert.assertNull(tree.getBarycenter());
+
+        Assert.assertEquals(0, tree.getBoundarySize(), TEST_EPS);
+
+        Assert.assertEquals(0, tree.getBoundaries().size());
+        Assert.assertEquals(0, tree.getBoundaryPaths().size());
+    }
+
+    @Test
+    public void testGeometricProperties_empty() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.empty();
+
+        // act/assert
+        Assert.assertEquals(0, tree.getSize(), TEST_EPS);
+        Assert.assertNull(tree.getBarycenter());
+
+        Assert.assertEquals(0, tree.getBoundarySize(), TEST_EPS);
+
+        Assert.assertEquals(0, tree.getBoundaries().size());
+        Assert.assertEquals(0, tree.getBoundaryPaths().size());
+    }
+
+    @Test
+    public void testGeometricProperties_halfSpace() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.full();
+        tree.getRoot().cut(X_AXIS);
+
+        // act/assert
+        GeometryTestUtils.assertPositiveInfinity(tree.getSize());
+        Assert.assertNull(tree.getBarycenter());
+
+        GeometryTestUtils.assertPositiveInfinity(tree.getBoundarySize());
+
+        List<Segment> segments = tree.getBoundaries();
+        Assert.assertEquals(1, segments.size());
+
+        Segment segment = segments.get(0);
+        Assert.assertSame(X_AXIS, segment.getLine());
+        Assert.assertNull(segment.getStartPoint());
+        Assert.assertNull(segment.getEndPoint());
+
+        List<Polyline> paths = tree.getBoundaryPaths();
+        Assert.assertEquals(1, paths.size());
+
+        Polyline path = paths.get(0);
+        Assert.assertEquals(1, path.getSegments().size());
+        assertSegmentsEqual(segment, path.getStartSegment());
+    }
+
+    @Test
+    public void testGeometricProperties_complementedHalfSpace() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.full();
+        tree.getRoot().cut(X_AXIS);
+
+        tree.complement();
+
+        // act/assert
+        GeometryTestUtils.assertPositiveInfinity(tree.getSize());
+        Assert.assertNull(tree.getBarycenter());
+
+        GeometryTestUtils.assertPositiveInfinity(tree.getBoundarySize());
+
+        List<Segment> segments = tree.getBoundaries();
+        Assert.assertEquals(1, segments.size());
+
+        Segment segment = segments.get(0);
+        Assert.assertEquals(X_AXIS.reverse(), segment.getLine());
+        Assert.assertNull(segment.getStartPoint());
+        Assert.assertNull(segment.getEndPoint());
+
+        List<Polyline> paths = tree.getBoundaryPaths();
+        Assert.assertEquals(1, paths.size());
+
+        Polyline path = paths.get(0);
+        Assert.assertEquals(1, path.getSegments().size());
+        assertSegmentsEqual(segment, path.getSegments().get(0));
+    }
+
+    @Test
+    public void testGeometricProperties_quadrant() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.empty();
+        tree.getRoot().cut(X_AXIS)
+            .getMinus().cut(Y_AXIS);
+
+        // act/assert
+        GeometryTestUtils.assertPositiveInfinity(tree.getSize());
+        Assert.assertNull(tree.getBarycenter());
+
+        GeometryTestUtils.assertPositiveInfinity(tree.getBoundarySize());
+
+        List<Segment> segments = new ArrayList<>(tree.getBoundaries());
+        Assert.assertEquals(2, segments.size());
+
+        Collections.sort(segments, SEGMENT_COMPARATOR);
+
+        Segment firstSegment = segments.get(0);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.ZERO, firstSegment.getStartPoint(), TEST_EPS);
+        Assert.assertNull(firstSegment.getEndPoint());
+        Assert.assertSame(Y_AXIS, firstSegment.getLine());
+
+        Segment secondSegment = segments.get(1);
+        Assert.assertNull(secondSegment.getStartPoint());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.ZERO, secondSegment.getEndPoint(), TEST_EPS);
+        Assert.assertSame(X_AXIS, secondSegment.getLine());
+
+        List<Polyline> paths = tree.getBoundaryPaths();
+        Assert.assertEquals(1, paths.size());
+
+        Polyline path = paths.get(0);
+        Assert.assertEquals(2, path.getSegments().size());
+        assertSegmentsEqual(secondSegment, path.getSegments().get(0));
+        assertSegmentsEqual(firstSegment, path.getSegments().get(1));
+    }
+
+    @Test
+    public void testGeometricProperties_complementedQuadrant() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.empty();
+        tree.getRoot().cut(X_AXIS)
+            .getMinus().cut(Y_AXIS);
+
+        tree.complement();
+
+        // act/assert
+        GeometryTestUtils.assertPositiveInfinity(tree.getSize());
+        Assert.assertNull(tree.getBarycenter());
+
+        GeometryTestUtils.assertPositiveInfinity(tree.getBoundarySize());
+
+        List<Segment> segments = new ArrayList<>(tree.getBoundaries());
+        Assert.assertEquals(2, segments.size());
+
+        Collections.sort(segments, SEGMENT_COMPARATOR);
+
+        Segment firstSegment = segments.get(0);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.ZERO, firstSegment.getStartPoint(), TEST_EPS);
+        Assert.assertNull(firstSegment.getEndPoint());
+        Assert.assertEquals(X_AXIS.reverse(), firstSegment.getLine());
+
+        Segment secondSegment = segments.get(1);
+        Assert.assertNull(secondSegment.getStartPoint());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.ZERO, secondSegment.getEndPoint(), TEST_EPS);
+        Assert.assertEquals(Y_AXIS.reverse(), secondSegment.getLine());
+
+        List<Polyline> paths = tree.getBoundaryPaths();
+        Assert.assertEquals(1, paths.size());
+
+        Polyline path = paths.get(0);
+        Assert.assertEquals(2, path.getSegments().size());
+        assertSegmentsEqual(secondSegment, path.getSegments().get(0));
+        assertSegmentsEqual(firstSegment, path.getSegments().get(1));
+    }
+
+    @Test
+    public void testGeometricProperties_closedRegion() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.empty();
+        tree.insert(Polyline.builder(TEST_PRECISION)
+                .appendVertices(Vector2D.ZERO, Vector2D.of(1, 0), Vector2D.of(2, 1))
+                .close());
+
+        // act/assert
+        Assert.assertEquals(0.5, tree.getSize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1, 1.0 / 3.0), tree.getBarycenter(), TEST_EPS);
+
+        Assert.assertEquals(1.0 + Math.sqrt(2) + Math.sqrt(5), tree.getBoundarySize(), TEST_EPS);
+
+        List<Segment> segments = new ArrayList<>(tree.getBoundaries());
+        Collections.sort(segments, SEGMENT_COMPARATOR);
+
+        Assert.assertEquals(3, segments.size());
+
+        checkFiniteSegment(segments.get(0), Vector2D.ZERO, Vector2D.of(1, 0));
+        checkFiniteSegment(segments.get(1), Vector2D.of(1, 0), Vector2D.of(2, 1));
+        checkFiniteSegment(segments.get(2), Vector2D.of(2, 1), Vector2D.ZERO);
+
+        List<Polyline> paths = tree.getBoundaryPaths();
+        Assert.assertEquals(1, paths.size());
+
+        checkVertices(paths.get(0), Vector2D.ZERO, Vector2D.of(1, 0), Vector2D.of(2, 1), Vector2D.ZERO);
+    }
+
+    @Test
+    public void testGeometricProperties_complementedClosedRegion() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.empty();
+        tree.insert(Polyline.builder(TEST_PRECISION)
+                .appendVertices(Vector2D.ZERO, Vector2D.of(1, 0), Vector2D.of(2, 1))
+                .close());
+
+        tree.complement();
+
+        // act/assert
+        GeometryTestUtils.assertPositiveInfinity(tree.getSize());
+        Assert.assertNull(tree.getBarycenter());
+
+        Assert.assertEquals(1.0 + Math.sqrt(2) + Math.sqrt(5), tree.getBoundarySize(), TEST_EPS);
+
+        List<Segment> segments = new ArrayList<>(tree.getBoundaries());
+        Collections.sort(segments, SEGMENT_COMPARATOR);
+
+        Assert.assertEquals(3, segments.size());
+
+        checkFiniteSegment(segments.get(0), Vector2D.ZERO, Vector2D.of(2, 1));
+        checkFiniteSegment(segments.get(1), Vector2D.of(1, 0), Vector2D.ZERO);
+        checkFiniteSegment(segments.get(2), Vector2D.of(2, 1), Vector2D.of(1, 0));
+
+        List<Polyline> paths = tree.getBoundaryPaths();
+        Assert.assertEquals(1, paths.size());
+
+        checkVertices(paths.get(0), Vector2D.ZERO, Vector2D.of(2, 1), Vector2D.of(1, 0), Vector2D.ZERO);
+    }
+
+    @Test
+    public void testGeometricProperties_regionWithHole() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.builder(TEST_PRECISION)
+                .addRect(Vector2D.ZERO, Vector2D.of(3, 3))
+                .build();
+        RegionBSPTree2D inner = RegionBSPTree2D.builder(TEST_PRECISION)
+                .addRect(Vector2D.of(1, 1), Vector2D.of(2, 2))
+                .build();
+
+        tree.difference(inner);
+
+        // act/assert
+        Assert.assertEquals(8, tree.getSize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1.5, 1.5), tree.getBarycenter(), TEST_EPS);
+
+        Assert.assertEquals(16, tree.getBoundarySize(), TEST_EPS);
+
+        List<Segment> segments = new ArrayList<>(tree.getBoundaries());
+        Collections.sort(segments, SEGMENT_COMPARATOR);
+
+        Assert.assertEquals(8, segments.size());
+
+        checkFiniteSegment(segments.get(0), Vector2D.ZERO, Vector2D.of(3, 0));
+        checkFiniteSegment(segments.get(1), Vector2D.of(0, 3), Vector2D.ZERO);
+        checkFiniteSegment(segments.get(2), Vector2D.of(1, 1), Vector2D.of(1, 2));
+        checkFiniteSegment(segments.get(3), Vector2D.of(1, 2), Vector2D.of(2, 2));
+        checkFiniteSegment(segments.get(4), Vector2D.of(2, 1), Vector2D.of(1, 1));
+        checkFiniteSegment(segments.get(5), Vector2D.of(2, 2), Vector2D.of(2, 1));
+        checkFiniteSegment(segments.get(6), Vector2D.of(3, 0), Vector2D.of(3, 3));
+        checkFiniteSegment(segments.get(7), Vector2D.of(3, 3), Vector2D.of(0, 3));
+
+        List<Polyline> paths = tree.getBoundaryPaths();
+        Assert.assertEquals(2, paths.size());
+
+        checkVertices(paths.get(0), Vector2D.ZERO, Vector2D.of(3, 0), Vector2D.of(3, 3),
+                Vector2D.of(0, 3), Vector2D.ZERO);
+        checkVertices(paths.get(1), Vector2D.of(1, 1), Vector2D.of(1, 2), Vector2D.of(2, 2),
+                Vector2D.of(2, 1), Vector2D.of(1, 1));
+    }
+
+    @Test
+    public void testGeometricProperties_complementedRegionWithHole() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.builder(TEST_PRECISION)
+                .addRect(Vector2D.ZERO, Vector2D.of(3, 3))
+                .build();
+        RegionBSPTree2D inner = RegionBSPTree2D.builder(TEST_PRECISION)
+                .addRect(Vector2D.of(1, 1), Vector2D.of(2, 2))
+                .build();
+
+        tree.difference(inner);
+
+        tree.complement();
+
+        // act/assert
+        GeometryTestUtils.assertPositiveInfinity(tree.getSize());
+        Assert.assertNull(tree.getBarycenter());
+
+        Assert.assertEquals(16, tree.getBoundarySize(), TEST_EPS);
+
+        List<Segment> segments = new ArrayList<>(tree.getBoundaries());
+        Collections.sort(segments, SEGMENT_COMPARATOR);
+
+        Assert.assertEquals(8, segments.size());
+
+        checkFiniteSegment(segments.get(0), Vector2D.ZERO, Vector2D.of(0, 3));
+        checkFiniteSegment(segments.get(1), Vector2D.of(0, 3), Vector2D.of(3, 3));
+        checkFiniteSegment(segments.get(2), Vector2D.of(1, 1), Vector2D.of(2, 1));
+        checkFiniteSegment(segments.get(3), Vector2D.of(1, 2), Vector2D.of(1, 1));
+        checkFiniteSegment(segments.get(4), Vector2D.of(2, 1), Vector2D.of(2, 2));
+        checkFiniteSegment(segments.get(5), Vector2D.of(2, 2), Vector2D.of(1, 2));
+        checkFiniteSegment(segments.get(6), Vector2D.of(3, 0), Vector2D.ZERO);
+        checkFiniteSegment(segments.get(7), Vector2D.of(3, 3), Vector2D.of(3, 0));
+
+        List<Polyline> paths = tree.getBoundaryPaths();
+        Assert.assertEquals(2, paths.size());
+
+        checkVertices(paths.get(0), Vector2D.ZERO, Vector2D.of(0, 3), Vector2D.of(3, 3),
+                Vector2D.of(3, 0), Vector2D.ZERO);
+        checkVertices(paths.get(1), Vector2D.of(1, 1), Vector2D.of(2, 1), Vector2D.of(2, 2),
+                Vector2D.of(1, 2), Vector2D.of(1, 1));
+    }
+
+    @Test
+    public void testFromConvexArea_full() {
+        // arrange
+        ConvexArea area = ConvexArea.full();
+
+        // act
+        RegionBSPTree2D tree = RegionBSPTree2D.from(area);
+        Assert.assertNull(tree.getBarycenter());
+
+        // assert
+        Assert.assertTrue(tree.isFull());
+    }
+
+    @Test
+    public void testFromConvexArea_infinite() {
+        // arrange
+        ConvexArea area = ConvexArea.fromVertices(
+                Arrays.asList(Vector2D.ZERO, Vector2D.Unit.PLUS_Y), TEST_PRECISION);
+
+        // act
+        RegionBSPTree2D tree = RegionBSPTree2D.from(area);
+
+        // assert
+        GeometryTestUtils.assertPositiveInfinity(tree.getSize());
+        GeometryTestUtils.assertPositiveInfinity(tree.getBoundarySize());
+        Assert.assertNull(tree.getBarycenter());
+
+        checkClassify(tree, RegionLocation.OUTSIDE, Vector2D.of(1, 0));
+        checkClassify(tree, RegionLocation.BOUNDARY, Vector2D.ZERO);
+        checkClassify(tree, RegionLocation.INSIDE, Vector2D.of(-1, 0));
+    }
+
+    @Test
+    public void testFromConvexArea_finite() {
+        // arrange
+        ConvexArea area = ConvexArea.fromVertexLoop(
+                Arrays.asList(Vector2D.ZERO, Vector2D.Unit.PLUS_X, Vector2D.of(1, 1), Vector2D.Unit.PLUS_Y), TEST_PRECISION);
+
+        // act
+        RegionBSPTree2D tree = RegionBSPTree2D.from(area);
+
+        // assert
+        Assert.assertEquals(1, tree.getSize(), TEST_EPS);
+        Assert.assertEquals(4, tree.getBoundarySize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(0.5, 0.5), tree.getBarycenter(), TEST_EPS);
+
+        checkClassify(tree, RegionLocation.OUTSIDE,
+                Vector2D.of(-1, 0.5), Vector2D.of(2, 0.5),
+                Vector2D.of(0.5, -1), Vector2D.of(0.5, 2));
+        checkClassify(tree, RegionLocation.BOUNDARY,
+                Vector2D.of(0, 0.5), Vector2D.of(1, 0.5),
+                Vector2D.of(0.5, 0), Vector2D.of(0.5, 1));
+        checkClassify(tree, RegionLocation.INSIDE, Vector2D.of(0.5, 0.5));
+    }
+
+    @Test
+    public void testBuilder_rectMethods() {
+        // act
+        RegionBSPTree2D tree = RegionBSPTree2D.builder(TEST_PRECISION)
+                .addCenteredRect(Vector2D.ZERO, 1, 2)
+                .addCenteredSquare(Vector2D.of(5, 0), -2)
+
+                .addRect(Vector2D.of(10, 5), -2, -3)
+                .addRect(Vector2D.of(15, 5), Vector2D.of(16, 4))
+
+                .addSquare(Vector2D.of(20, 1), 3)
+
+                .build();
+
+        // assert
+        Assert.assertEquals(2 + 4 + 6 + 1 + 9, tree.getSize(), TEST_EPS);
+
+        checkClassify(tree, RegionLocation.INSIDE,
+                Vector2D.ZERO,
+                Vector2D.of(5, 0),
+                Vector2D.of(9, 3.5),
+                Vector2D.of(15.5, 4.5),
+                Vector2D.of(21.5, 2.5));
+
+        checkClassify(tree, RegionLocation.BOUNDARY,
+                Vector2D.of(-0.5, -1), Vector2D.of(0.5, 1),
+                Vector2D.of(4, -1), Vector2D.of(6, 1));
+    }
+
+    @Test
+    public void testBuilder_rectMethods_zeroSize() {
+        // arrange
+        final RegionBSPTree2D.Builder builder = RegionBSPTree2D.builder(TEST_PRECISION);
+
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            builder.addRect(Vector2D.of(1, 1), 0, 2);
+        }, GeometryValueException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            builder.addRect(Vector2D.of(1, 1), 2, 0);
+        }, GeometryValueException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            builder.addRect(Vector2D.of(2, 3), 0, 0);
+        }, GeometryValueException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            builder.addRect(Vector2D.of(1, 1), Vector2D.of(1, 3));
+        }, GeometryValueException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            builder.addRect(Vector2D.of(1, 1), Vector2D.of(3, 1));
+        }, GeometryValueException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            builder.addRect(Vector2D.of(2, 3), Vector2D.of(2, 3));
+        }, GeometryValueException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            builder.addCenteredRect(Vector2D.of(2, 3), 0, 1e-20);
+        }, GeometryValueException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            builder.addSquare(Vector2D.of(2, 3), 1e-20);
+        }, GeometryValueException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            builder.addCenteredSquare(Vector2D.of(2, 3), 0);
+        }, GeometryValueException.class);
+    }
+
+    @Test
+    public void testBuilder_mixedArguments() {
+        // arrange
+        Line minusYAxis = Line.fromPointAndAngle(Vector2D.ZERO, Geometry.MINUS_HALF_PI, TEST_PRECISION);
+
+        Polyline path = Polyline.builder(TEST_PRECISION)
+            .append(Vector2D.Unit.PLUS_X)
+            .append(Vector2D.of(1, 1))
+            .build();
+
+        // act
+        RegionBSPTree2D tree = RegionBSPTree2D.builder(TEST_PRECISION)
+                .add(Segment.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_X, TEST_PRECISION))
+                .add(path)
+                .addSegment(Vector2D.of(1, 1), Vector2D.Unit.PLUS_Y)
+                .add(new SubLine(minusYAxis, Interval.of(-1, 0, TEST_PRECISION).toTree()))
+                .build();
+
+        // assert
+        Assert.assertEquals(1, tree.getSize(), TEST_EPS);
+        Assert.assertEquals(4, tree.getBoundarySize(), TEST_EPS);
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(0.5, 0.5), tree.getBarycenter(), TEST_EPS);
+    }
+
+    @Test
+    public void testProject_fullAndEmpty() {
+        // act/assert
+        Assert.assertNull(RegionBSPTree2D.full().project(Vector2D.ZERO));
+        Assert.assertNull(RegionBSPTree2D.empty().project(Vector2D.of(1, 2)));
+    }
+
+    @Test
+    public void testProject_halfSpace() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.full();
+        tree.getRoot().cut(X_AXIS);
+
+        // act/assert
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.ZERO, tree.project(Vector2D.ZERO), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(-1, 0), tree.project(Vector2D.of(-1, 0)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(2, 0),
+                tree.project(Vector2D.of(2, -1)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(-3, 0),
+                tree.project(Vector2D.of(-3, 1)), TEST_EPS);
+    }
+
+    @Test
+    public void testProject_rect() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.builder(TEST_PRECISION)
+                .addSquare(Vector2D.of(1, 1), 1)
+                .build();
+
+        // act/assert
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1, 1), tree.project(Vector2D.ZERO), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1, 1), tree.project(Vector2D.of(1, 0)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1.5, 1), tree.project(Vector2D.of(1.5, 0)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(2, 1), tree.project(Vector2D.of(2, 0)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(2, 1), tree.project(Vector2D.of(3, 0)), TEST_EPS);
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1, 2), tree.project(Vector2D.of(1, 3)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1, 2), tree.project(Vector2D.of(1, 3)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1.5, 2), tree.project(Vector2D.of(1.5, 3)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(2, 2), tree.project(Vector2D.of(2, 3)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(2, 2), tree.project(Vector2D.of(3, 3)), TEST_EPS);
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1, 1.5), tree.project(Vector2D.of(0, 1.5)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1, 1.5), tree.project(Vector2D.of(1.5, 1.5)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(2, 1.5), tree.project(Vector2D.of(3, 1.5)), TEST_EPS);
+    }
+
+    @Test
+    public void testTransform() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.builder(TEST_PRECISION)
+                .addRect(Vector2D.of(1, 1), 2, 1)
+                .build();
+
+        AffineTransformMatrix2D transform = AffineTransformMatrix2D.createScale(0.5, 2)
+                .rotate(Geometry.HALF_PI)
+                .translate(Vector2D.of(0, -1));
+
+        // act
+        tree.transform(transform);
+
+        // assert
+        List<Polyline> paths = tree.getBoundaryPaths();
+        Assert.assertEquals(1, paths.size());
+
+        Polyline path = paths.get(0);
+        Assert.assertEquals(4, path.getSegments().size());
+        checkFiniteSegment(path.getSegments().get(0), Vector2D.of(-4, -0.5), Vector2D.of(-2, -0.5));
+        checkFiniteSegment(path.getSegments().get(1), Vector2D.of(-2, -0.5), Vector2D.of(-2, 0.5));
+        checkFiniteSegment(path.getSegments().get(2), Vector2D.of(-2, 0.5), Vector2D.of(-4, 0.5));
+        checkFiniteSegment(path.getSegments().get(3), Vector2D.of(-4, 0.5), Vector2D.of(-4, -0.5));
+    }
+
+    @Test
+    public void testTransform_halfSpace() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.empty();
+        tree.getRoot().insertCut(Line.fromPointAndAngle(Vector2D.of(0, 1), Geometry.ZERO_PI, TEST_PRECISION));
+
+        AffineTransformMatrix2D transform = AffineTransformMatrix2D.createScale(0.5, 2)
+                .rotate(Geometry.HALF_PI)
+                .translate(Vector2D.of(1, 0));
+
+        // act
+        tree.transform(transform);
+
+        // assert
+        List<Polyline> paths = tree.getBoundaryPaths();
+        Assert.assertEquals(1, paths.size());
+
+        Polyline path = paths.get(0);
+        Assert.assertEquals(1, path.getSegments().size());
+        Segment segment = path.getStartSegment();
+        Assert.assertNull(segment.getStartPoint());
+        Assert.assertNull(segment.getEndPoint());
+
+        Line expectedLine = Line.fromPointAndAngle(Vector2D.of(-1, 0), Geometry.HALF_PI, TEST_PRECISION);
+        Assert.assertTrue(expectedLine.eq(segment.getLine()));
+    }
+
+    @Test
+    public void testTransform_fullAndEmpty() {
+        // arrange
+        RegionBSPTree2D full = RegionBSPTree2D.full();
+        RegionBSPTree2D empty = RegionBSPTree2D.empty();
+
+        AffineTransformMatrix2D transform = AffineTransformMatrix2D.createRotation(Geometry.HALF_PI);
+
+        // act
+        full.transform(transform);
+        empty.transform(transform);
+
+        // assert
+        Assert.assertTrue(full.isFull());
+        Assert.assertTrue(empty.isEmpty());
+    }
+
+    @Test
+    public void testTransform_reflection() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.builder(TEST_PRECISION)
+                .addSquare(Vector2D.of(1, 1), 1)
+                .build();
+
+        Transform2D transform = FunctionTransform2D.from(v -> Vector2D.of(-v.getX(), v.getY()));
+
+        // act
+        tree.transform(transform);
+
+        // assert
+        List<Polyline> paths = tree.getBoundaryPaths();
+        Assert.assertEquals(1, paths.size());
+
+        Polyline path = paths.get(0);
+        Assert.assertEquals(4, path.getSegments().size());
+        checkFiniteSegment(path.getSegments().get(0), Vector2D.of(-2, 1), Vector2D.of(-1, 1));
+        checkFiniteSegment(path.getSegments().get(1), Vector2D.of(-1, 1), Vector2D.of(-1, 2));
+        checkFiniteSegment(path.getSegments().get(2), Vector2D.of(-1, 2), Vector2D.of(-2, 2));
+        checkFiniteSegment(path.getSegments().get(3), Vector2D.of(-2, 2), Vector2D.of(-2, 1));
+    }
+
+    @Test
+    public void testTransform_doubleReflection() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.builder(TEST_PRECISION)
+                .addSquare(Vector2D.of(1, 1), 1)
+                .build();
+
+        Transform2D transform = FunctionTransform2D.from(Vector2D::negate);
+
+        // act
+        tree.transform(transform);
+
+        // assert
+        List<Polyline> paths = tree.getBoundaryPaths();
+        Assert.assertEquals(1, paths.size());
+
+        Polyline path = paths.get(0);
+        Assert.assertEquals(4, path.getSegments().size());
+        checkFiniteSegment(path.getSegments().get(0), Vector2D.of(-2, -2), Vector2D.of(-1, -2));
+        checkFiniteSegment(path.getSegments().get(1), Vector2D.of(-1, -2), Vector2D.of(-1, -1));
+        checkFiniteSegment(path.getSegments().get(2), Vector2D.of(-1, -1), Vector2D.of(-2, -1));
+        checkFiniteSegment(path.getSegments().get(3), Vector2D.of(-2, -1), Vector2D.of(-2, -2));
+    }
+
+    @Test
+    public void testBooleanOperations() {
+        // arrange
+        RegionBSPTree2D tree = RegionBSPTree2D.builder(TEST_PRECISION)
+                .addSquare(Vector2D.ZERO, 3)
+                .build();
+        RegionBSPTree2D temp;
+
+        // act
+        temp = RegionBSPTree2D.builder(TEST_PRECISION).addSquare(Vector2D.of(1, 1), 1).build();
+        temp.complement();
+        tree.intersection(temp);
+
+        temp = RegionBSPTree2D.builder(TEST_PRECISION).addSquare(Vector2D.of(3, 0), 3).build();
+        tree.union(temp);
+
+        temp = RegionBSPTree2D.builder(TEST_PRECISION).addRect(Vector2D.of(2, 1), 3, 1).build();
+        tree.difference(temp);
+
+        temp.setFull();
+        tree.xor(temp);
+
+        // assert
+        List<Polyline> paths = tree.getBoundaryPaths();
+        Assert.assertEquals(2, paths.size());
+
+        checkVertices(paths.get(0), Vector2D.ZERO, Vector2D.of(0, 3), Vector2D.of(6, 3),
+                Vector2D.of(6, 0), Vector2D.ZERO);
+
+        checkVertices(paths.get(1), Vector2D.of(1, 1), Vector2D.of(5, 1), Vector2D.of(5, 2),
+                Vector2D.of(1, 2), Vector2D.of(1, 1));
+    }
+
+    private static void assertSegmentsEqual(Segment expected, Segment actual) {
+        Assert.assertEquals(expected.getLine(), actual.getLine());
+
+        Vector2D expectedStart = expected.getStartPoint();
+        Vector2D expectedEnd = expected.getEndPoint();
+
+        if (expectedStart != null) {
+            EuclideanTestUtils.assertCoordinatesEqual(expectedStart, actual.getStartPoint(), TEST_EPS);
+        }
+        else {
+            Assert.assertNull(actual.getStartPoint());
+        }
+
+        if (expectedEnd != null) {
+            EuclideanTestUtils.assertCoordinatesEqual(expectedEnd, actual.getEndPoint(), TEST_EPS);
+        }
+        else {
+            Assert.assertNull(actual.getEndPoint());
+        }
+    }
+
+    private static void checkFiniteSegment(Segment segment, Vector2D start, Vector2D end) {
+        EuclideanTestUtils.assertCoordinatesEqual(start, segment.getStartPoint(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(end, segment.getEndPoint(), TEST_EPS);
+    }
+
+    private static void checkClassify(Region<Vector2D> region, RegionLocation loc, Vector2D ... points) {
+        for (Vector2D point : points) {
+            String msg = "Unexpected location for point " + point;
+
+            Assert.assertEquals(msg, loc, region.classify(point));
+        }
+    }
+
+    private static void checkConvexArea(final ConvexArea area, final List<Vector2D> inside, final List<Vector2D> outside) {
+        checkClassify(area, RegionLocation.INSIDE, inside.toArray(new Vector2D[0]));
+        checkClassify(area, RegionLocation.OUTSIDE, outside.toArray(new Vector2D[0]));
+    }
+
+    /** Assert that the given path is finite and contains the given vertices.
+     * @param path
+     * @param vertices
+     */
+    private static void checkVertices(Polyline path, Vector2D ... vertices) {
+        Assert.assertTrue("Line segment path is not finite", path.isFinite());
+
+        List<Vector2D> actual = path.getVertices();
+
+        Assert.assertEquals("Vertex lists have different lengths", vertices.length, actual.size());
+
+        for (int i=0; i<vertices.length; ++i) {
+            EuclideanTestUtils.assertCoordinatesEqual(vertices[i], actual.get(i), TEST_EPS);
+        }
+    }
+}
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/SegmentTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/SegmentTest.java
index d6085b6..704795b 100644
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/SegmentTest.java
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/SegmentTest.java
@@ -16,8 +16,19 @@
  */
 package org.apache.commons.geometry.euclidean.twod;
 
+import java.util.List;
+
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.GeometryTestUtils;
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.exception.GeometryValueException;
+import org.apache.commons.geometry.core.partitioning.HyperplaneLocation;
+import org.apache.commons.geometry.core.partitioning.Split;
 import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
 import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
+import org.apache.commons.geometry.euclidean.oned.Interval;
+import org.apache.commons.geometry.euclidean.oned.Vector1D;
 import org.junit.Assert;
 import org.junit.Test;
 
@@ -29,21 +40,861 @@
             new EpsilonDoublePrecisionContext(TEST_EPS);
 
     @Test
-    public void testDistance() {
-        Vector2D start = Vector2D.of(2, 2);
-        Vector2D end = Vector2D.of(-2, -2);
-        Segment segment = new Segment(start, end, Line.fromPoints(start, end, TEST_PRECISION));
+    public void testFromPoints() {
+        // arrange
+        Vector2D p0 = Vector2D.of(1, 3);
+        Vector2D p1 = Vector2D.of(1, 2);
+        Vector2D p2 = Vector2D.of(-3, 4);
+        Vector2D p3 = Vector2D.of(-5, -6);
 
-        // distance to center of segment
-        Assert.assertEquals(Math.sqrt(2), segment.distance(Vector2D.of(1, -1)), TEST_EPS);
+        // act/assert
 
-        // distance a point on segment
-        Assert.assertEquals(Math.sin(Math.PI / 4.0), segment.distance(Vector2D.of(0, -1)), TEST_EPS);
+        checkFiniteSegment(Segment.fromPoints(p0, p1, TEST_PRECISION), p0, p1);
+        checkFiniteSegment(Segment.fromPoints(p1, p0, TEST_PRECISION), p1, p0);
 
-        // distance to end point
-        Assert.assertEquals(Math.sqrt(8), segment.distance(Vector2D.of(0, 4)), TEST_EPS);
+        checkFiniteSegment(Segment.fromPoints(p0, p2, TEST_PRECISION), p0, p2);
+        checkFiniteSegment(Segment.fromPoints(p2, p0, TEST_PRECISION), p2, p0);
 
-        // distance to start point
-        Assert.assertEquals(Math.sqrt(8), segment.distance(Vector2D.of(0, -4)), TEST_EPS);
+        checkFiniteSegment(Segment.fromPoints(p0, p3, TEST_PRECISION), p0, p3);
+        checkFiniteSegment(Segment.fromPoints(p3, p0, TEST_PRECISION), p3, p0);
+    }
+
+    @Test
+    public void testFromPoints_invalidArgs() {
+        // arrange
+        Vector2D p0 = Vector2D.of(-1, 2);
+
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            Segment.fromPoints(p0, p0, TEST_PRECISION);
+        }, GeometryValueException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            Segment.fromPoints(p0, Vector2D.POSITIVE_INFINITY, TEST_PRECISION);
+        }, GeometryValueException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            Segment.fromPoints(p0, Vector2D.NEGATIVE_INFINITY, TEST_PRECISION);
+        }, GeometryValueException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            Segment.fromPoints(p0, Vector2D.NaN, TEST_PRECISION);
+        }, GeometryValueException.class);
+    }
+
+    @Test
+    public void testFromPointAndDirection() {
+        // act
+        Segment seg = Segment.fromPointAndDirection(Vector2D.of(1, 3), Vector2D.Unit.PLUS_Y, TEST_PRECISION);
+
+        // assert
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1, 3), seg.getStartPoint(), TEST_EPS);
+        Assert.assertNull(seg.getEndPoint());
+
+        Line line = seg.getLine();
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1, 0), line.getOrigin(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.Unit.PLUS_Y, line.getDirection(), TEST_EPS);
+    }
+
+    @Test
+    public void testFromInterval_intervalArg_finite() {
+        // arrange
+        DoublePrecisionContext intervalPrecision = new EpsilonDoublePrecisionContext(1e-2);
+        Interval interval = Interval.of(-1, 2, intervalPrecision);
+
+        Line line = Line.fromPointAndDirection(Vector2D.ZERO, Vector2D.of(1, 1), TEST_PRECISION);
+
+        // act
+        Segment segment = Segment.fromInterval(line, interval);
+
+        // assert
+        double side = 1.0 / Math.sqrt(2);
+        checkFiniteSegment(segment, Vector2D.of(-side, -side), Vector2D.of(2 * side, 2 * side));
+
+        Assert.assertSame(TEST_PRECISION, segment.getPrecision());
+    }
+
+    @Test
+    public void testFromInterval_intervalArg_full() {
+        // arrange
+        Line line = Line.fromPointAndDirection(Vector2D.ZERO, Vector2D.of(1, 1), TEST_PRECISION);
+
+        // act
+        Segment segment = Segment.fromInterval(line, Interval.full());
+
+        // assert
+        GeometryTestUtils.assertNegativeInfinity(segment.getSubspaceStart());
+        GeometryTestUtils.assertPositiveInfinity(segment.getSubspaceEnd());
+
+        Assert.assertNull(segment.getStartPoint());
+        Assert.assertNull(segment.getEndPoint());
+
+        Assert.assertSame(Interval.full(), segment.getInterval());
+        Assert.assertSame(TEST_PRECISION, segment.getPrecision());
+    }
+
+    @Test
+    public void testFromInterval_intervalArg_positiveHalfSpace() {
+        // arrange
+        DoublePrecisionContext intervalPrecision = new EpsilonDoublePrecisionContext(1e-2);
+        Interval interval = Interval.min(-1, intervalPrecision);
+
+        Line line = Line.fromPointAndDirection(Vector2D.ZERO, Vector2D.of(1, 1), TEST_PRECISION);
+
+        // act
+        Segment segment = Segment.fromInterval(line, interval);
+
+        // assert
+        Assert.assertEquals(-1.0, segment.getSubspaceStart(), TEST_EPS);
+        GeometryTestUtils.assertPositiveInfinity(segment.getSubspaceEnd());
+
+        double side = 1.0 / Math.sqrt(2);
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(-side, -side), segment.getStartPoint(), TEST_EPS);
+        Assert.assertNull(segment.getEndPoint());
+
+        Assert.assertSame(interval, segment.getInterval());
+        Assert.assertSame(TEST_PRECISION, segment.getPrecision());
+    }
+
+    @Test
+    public void testFromInterval_intervalArg_negativeHalfSpace() {
+        // arrange
+        DoublePrecisionContext intervalPrecision = new EpsilonDoublePrecisionContext(1e-2);
+        Interval interval = Interval.max(2, intervalPrecision);
+
+        Line line = Line.fromPointAndDirection(Vector2D.ZERO, Vector2D.of(1, 1), TEST_PRECISION);
+
+        // act
+        Segment segment = Segment.fromInterval(line, interval);
+
+        // assert
+        GeometryTestUtils.assertNegativeInfinity(segment.getSubspaceStart());
+        Assert.assertEquals(2, segment.getSubspaceEnd(), TEST_EPS);
+
+        double side = 1.0 / Math.sqrt(2);
+
+        Assert.assertNull(segment.getStartPoint());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(2 * side, 2 * side), segment.getEndPoint(), TEST_EPS);
+
+        Assert.assertSame(interval, segment.getInterval());
+        Assert.assertSame(TEST_PRECISION, segment.getPrecision());
+    }
+
+    @Test
+    public void testFromInterval_doubleArgs_finite() {
+        // arrange
+        Line line = Line.fromPointAndDirection(Vector2D.ZERO, Vector2D.of(1, 1), TEST_PRECISION);
+
+        // act
+        Segment segment = Segment.fromInterval(line, -1, 2);
+
+        // assert
+        double side = 1.0 / Math.sqrt(2);
+        checkFiniteSegment(segment, Vector2D.of(-side, -side), Vector2D.of(2 * side, 2 * side));
+
+        Assert.assertSame(TEST_PRECISION, segment.getPrecision());
+    }
+
+    @Test
+    public void testFromInterval_doubleArgs_full() {
+        // arrange
+        Line line = Line.fromPointAndDirection(Vector2D.ZERO, Vector2D.of(1, 1), TEST_PRECISION);
+
+        // act
+        Segment segment = Segment.fromInterval(line, Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY);
+
+        // assert
+        GeometryTestUtils.assertNegativeInfinity(segment.getSubspaceStart());
+        GeometryTestUtils.assertPositiveInfinity(segment.getSubspaceEnd());
+
+        Assert.assertNull(segment.getStartPoint());
+        Assert.assertNull(segment.getEndPoint());
+
+        Assert.assertSame(TEST_PRECISION, segment.getPrecision());
+    }
+
+    @Test
+    public void testFromInterval_doubleArgs_positiveHalfSpace() {
+        // arrange
+        Line line = Line.fromPointAndDirection(Vector2D.ZERO, Vector2D.of(1, 1), TEST_PRECISION);
+
+        // act
+        Segment segment = Segment.fromInterval(line, -1, Double.POSITIVE_INFINITY);
+
+        // assert
+        Assert.assertEquals(-1.0, segment.getSubspaceStart(), TEST_EPS);
+        GeometryTestUtils.assertPositiveInfinity(segment.getSubspaceEnd());
+
+        double side = 1.0 / Math.sqrt(2);
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(-side, -side), segment.getStartPoint(), TEST_EPS);
+        Assert.assertNull(segment.getEndPoint());
+
+        Assert.assertSame(TEST_PRECISION, segment.getPrecision());
+    }
+
+    @Test
+    public void testFromInterval_doubleArgs_negativeHalfSpace() {
+        // arrange
+        Line line = Line.fromPointAndDirection(Vector2D.ZERO, Vector2D.of(1, 1), TEST_PRECISION);
+
+        // act
+        Segment segment = Segment.fromInterval(line, 2, Double.NEGATIVE_INFINITY);
+
+        // assert
+        GeometryTestUtils.assertNegativeInfinity(segment.getSubspaceStart());
+        Assert.assertEquals(2, segment.getSubspaceEnd(), TEST_EPS);
+
+        double side = 1.0 / Math.sqrt(2);
+
+        Assert.assertNull(segment.getStartPoint());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(2 * side, 2 * side), segment.getEndPoint(), TEST_EPS);
+
+        Assert.assertSame(TEST_PRECISION, segment.getPrecision());
+    }
+
+    @Test
+    public void testFromInterval_vectorArgs() {
+        // arrange
+        Line line = Line.fromPointAndDirection(Vector2D.ZERO, Vector2D.of(1, 1), TEST_PRECISION);
+
+        // act
+        Segment segment = Segment.fromInterval(line, Vector1D.of(-1), Vector1D.of(2));
+
+        // assert
+        double side = 1.0 / Math.sqrt(2);
+        checkFiniteSegment(segment, Vector2D.of(-side, -side), Vector2D.of(2 * side, 2 * side));
+
+        Assert.assertSame(TEST_PRECISION, segment.getPrecision());
+    }
+
+    @Test
+    public void testIsFull() {
+        // arrange
+        Line line = Line.fromPoints(Vector2D.of(-1, 0), Vector2D.of(4, 5), TEST_PRECISION);
+
+        // act/assert
+        Assert.assertTrue(Segment.fromInterval(line, Interval.full()).isFull());
+        Assert.assertTrue(Segment.fromInterval(line, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY).isFull());
+
+        Assert.assertFalse(Segment.fromInterval(line, Interval.min(0, TEST_PRECISION)).isFull());
+        Assert.assertFalse(Segment.fromInterval(line, Interval.max(0, TEST_PRECISION)).isFull());
+
+        Assert.assertFalse(Segment.fromPoints(Vector2D.of(1, 1), Vector2D.of(2, 2), TEST_PRECISION).isFull());
+
+        Assert.assertFalse(Segment.fromInterval(line, Interval.point(1, TEST_PRECISION)).isEmpty());
+    }
+
+    @Test
+    public void testIsInfinite() {
+        // arrange
+        Line line = Line.fromPoints(Vector2D.of(-1, 0), Vector2D.of(4, 5), TEST_PRECISION);
+
+        // act/assert
+        Assert.assertTrue(Segment.fromInterval(line, Interval.full()).isInfinite());
+        Assert.assertTrue(Segment.fromInterval(line, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY).isInfinite());
+
+        Assert.assertTrue(Segment.fromInterval(line, Interval.min(0, TEST_PRECISION)).isInfinite());
+        Assert.assertTrue(Segment.fromInterval(line, Interval.max(0, TEST_PRECISION)).isInfinite());
+
+        Assert.assertFalse(Segment.fromPoints(Vector2D.of(1, 1), Vector2D.of(2, 2), TEST_PRECISION).isInfinite());
+        Assert.assertFalse(Segment.fromInterval(line, Interval.point(1, TEST_PRECISION)).isInfinite());
+    }
+
+    @Test
+    public void testIsFinite() {
+        // arrange
+        Line line = Line.fromPoints(Vector2D.of(-1, 0), Vector2D.of(4, 5), TEST_PRECISION);
+
+        // act/assert
+        Assert.assertTrue(Segment.fromPoints(Vector2D.of(1, 1), Vector2D.of(2, 2), TEST_PRECISION).isFinite());
+        Assert.assertTrue(Segment.fromInterval(line, Interval.point(1, TEST_PRECISION)).isFinite());
+
+        Assert.assertFalse(Segment.fromInterval(line, Interval.full()).isFinite());
+        Assert.assertFalse(Segment.fromInterval(line, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY).isFinite());
+
+        Assert.assertFalse(Segment.fromInterval(line, Interval.min(0, TEST_PRECISION)).isFinite());
+        Assert.assertFalse(Segment.fromInterval(line, Interval.max(0, TEST_PRECISION)).isFinite());
+    }
+
+    @Test
+    public void testIsEmpty_alwaysReturnsFalse() {
+        // arrange
+        Line line = Line.fromPoints(Vector2D.of(-1, 0), Vector2D.of(4, 5), TEST_PRECISION);
+
+        // act/assert
+        Assert.assertFalse(Segment.fromInterval(line, Interval.full()).isEmpty());
+        Assert.assertFalse(Segment.fromInterval(line, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY).isEmpty());
+
+        Assert.assertFalse(Segment.fromInterval(line, Interval.min(0, TEST_PRECISION)).isEmpty());
+        Assert.assertFalse(Segment.fromInterval(line, Interval.max(0, TEST_PRECISION)).isEmpty());
+
+        Assert.assertFalse(Segment.fromPoints(Vector2D.of(1, 1), Vector2D.of(2, 2), TEST_PRECISION).isEmpty());
+
+        Assert.assertFalse(Segment.fromInterval(line, Interval.point(1, TEST_PRECISION)).isEmpty());
+    }
+
+    @Test
+    public void testGetSize() {
+        // arrange
+        Line line = Line.fromPoints(Vector2D.of(-1, 0), Vector2D.of(4, 5), TEST_PRECISION);
+
+        // act/assert
+        GeometryTestUtils.assertPositiveInfinity(Segment.fromInterval(line, Interval.full()).getSize());
+        GeometryTestUtils.assertPositiveInfinity(Segment.fromInterval(line, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY).getSize());
+
+        GeometryTestUtils.assertPositiveInfinity(Segment.fromInterval(line, Interval.min(0, TEST_PRECISION)).getSize());
+        GeometryTestUtils.assertPositiveInfinity(Segment.fromInterval(line, Interval.max(0, TEST_PRECISION)).getSize());
+
+        Assert.assertEquals(Math.sqrt(2), Segment.fromPoints(Vector2D.of(1, 1), Vector2D.of(2, 2), TEST_PRECISION).getSize(), TEST_EPS);
+        Assert.assertEquals(9.0, Segment.fromPoints(Vector2D.of(1, 1), Vector2D.of(1, 10), TEST_PRECISION).getSize(), TEST_EPS);
+
+        Assert.assertEquals(0.0, Segment.fromInterval(line, Interval.point(1, TEST_PRECISION)).getSize(), TEST_EPS);
+        Assert.assertEquals(1.0, Segment.fromInterval(line, Interval.of(1, 2, TEST_PRECISION)).getSize(), TEST_EPS);
+    }
+
+    @Test
+    public void testClassify() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-2);
+        Segment segment = Segment.fromPoints(Vector2D.ZERO, Vector2D.of(1, 2), precision);
+
+        // act/assert
+        checkClassify(segment, RegionLocation.OUTSIDE,
+                Vector2D.of(0.25, 1), Vector2D.of(0.75, 1),
+                Vector2D.of(-1, -2), Vector2D.of(-0.1, 0),
+                Vector2D.of(1.1, 2), Vector2D.of(2, 4));
+
+        checkClassify(segment, RegionLocation.BOUNDARY,
+                Vector2D.ZERO, Vector2D.of(0.005, 0),
+                Vector2D.of(1, 2), Vector2D.of(1, 1.995));
+
+        checkClassify(segment, RegionLocation.INSIDE,
+                Vector2D.of(0.25, 0.5), Vector2D.of(0.495, 1),
+                Vector2D.of(0.75, 1.5));
+    }
+
+    @Test
+    public void testClosest() {
+        // arrange
+        Segment segment = Segment.fromPoints(Vector2D.ZERO, Vector2D.of(1, 1), TEST_PRECISION);
+
+        // act/assert
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.ZERO, segment.closest(Vector2D.of(-1, -1)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.ZERO, segment.closest(Vector2D.of(-2, 2)), TEST_EPS);
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(0.5, 0.5), segment.closest(Vector2D.of(0.5, 0.5)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(0.5, 0.5), segment.closest(Vector2D.of(0, 1)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(0.5, 0.5), segment.closest(Vector2D.of(1, 0)), TEST_EPS);
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1, 1), segment.closest(Vector2D.of(2, 2)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1, 1), segment.closest(Vector2D.of(5, 10)), TEST_EPS);
+    }
+
+    @Test
+    public void testToConvex() {
+        // arrange
+        Segment segment = Segment.fromPoints(Vector2D.ZERO, Vector2D.of(1, 1), TEST_PRECISION);
+
+        // act
+        List<Segment> segments = segment.toConvex();
+
+        // assert
+        Assert.assertEquals(1, segments.size());
+        Assert.assertSame(segment, segments.get(0));
+    }
+
+    @Test
+    public void testTransform_finite() {
+        // arrange
+        Segment segment = Segment.fromPoints(Vector2D.of(0, 1), Vector2D.of(2, 3), TEST_PRECISION);
+
+        Transform2D translation = AffineTransformMatrix2D.createTranslation(-1, 1);
+        Transform2D rotation = AffineTransformMatrix2D.createRotation(Geometry.HALF_PI);
+        Transform2D scale = AffineTransformMatrix2D.createScale(2, 3);
+        Transform2D reflect = FunctionTransform2D.from((pt) -> Vector2D.of(pt.getX(), -pt.getY()));
+
+        // act/assert
+        checkFiniteSegment(segment.transform(translation), Vector2D.of(-1, 2), Vector2D.of(1, 4));
+        checkFiniteSegment(segment.transform(rotation), Vector2D.of(-1, 0), Vector2D.of(-3, 2));
+        checkFiniteSegment(segment.transform(scale), Vector2D.of(0, 3), Vector2D.of(4, 9));
+        checkFiniteSegment(segment.transform(reflect), Vector2D.of(0, -1), Vector2D.of(2, -3));
+    }
+
+    @Test
+    public void testTransform_singlePoint() {
+        // arrange
+        Segment segment = Segment.fromInterval(Line.fromPoints(Vector2D.of(0, 1), Vector2D.of(1, 1), TEST_PRECISION),
+                Interval.point(0, TEST_PRECISION));
+
+        Transform2D translation = AffineTransformMatrix2D.createTranslation(-1, 1);
+        Transform2D rotation = AffineTransformMatrix2D.createRotation(Geometry.HALF_PI);
+        Transform2D scale = AffineTransformMatrix2D.createScale(2, 3);
+        Transform2D reflect = FunctionTransform2D.from((pt) -> Vector2D.of(pt.getX(), -pt.getY()));
+
+        // act/assert
+        checkFiniteSegment(segment.transform(translation), Vector2D.of(-1, 2), Vector2D.of(-1, 2));
+        checkFiniteSegment(segment.transform(rotation), Vector2D.of(-1, 0), Vector2D.of(-1, 0));
+        checkFiniteSegment(segment.transform(scale), Vector2D.of(0, 3), Vector2D.of(0, 3));
+        checkFiniteSegment(segment.transform(reflect), Vector2D.of(0, -1), Vector2D.of(0, -1));
+    }
+
+    @Test
+    public void testTransform_full() {
+        // arrange
+        Segment segment = Segment.fromInterval(Line.fromPoints(Vector2D.ZERO, Vector2D.of(2, 1), TEST_PRECISION),
+                Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY);
+
+        Transform2D translation = AffineTransformMatrix2D.createTranslation(-1, 1);
+        Transform2D rotation = AffineTransformMatrix2D.createRotation(Geometry.HALF_PI);
+        Transform2D scale = AffineTransformMatrix2D.createScale(2, 3);
+        Transform2D reflect = FunctionTransform2D.from((pt) -> Vector2D.of(pt.getX(), -pt.getY()));
+
+        // act/assert
+        Segment translated = segment.transform(translation);
+        Assert.assertTrue(translated.isFull());
+        Assert.assertTrue(translated.contains(Vector2D.of(-1, 1)));
+        Assert.assertTrue(translated.contains(Vector2D.of(1, 2)));
+        Assert.assertNull(translated.getStartPoint());
+        Assert.assertNull(translated.getEndPoint());
+
+        Segment rotated = segment.transform(rotation);
+        Assert.assertTrue(rotated.isFull());
+        Assert.assertTrue(rotated.contains(Vector2D.ZERO));
+        Assert.assertTrue(rotated.contains(Vector2D.of(-1, 2)));
+        Assert.assertNull(rotated.getStartPoint());
+        Assert.assertNull(rotated.getEndPoint());
+
+        Segment scaled = segment.transform(scale);
+        Assert.assertTrue(scaled.isFull());
+        Assert.assertTrue(scaled.contains(Vector2D.ZERO));
+        Assert.assertTrue(scaled.contains(Vector2D.of(4, 3)));
+        Assert.assertNull(scaled.getStartPoint());
+        Assert.assertNull(scaled.getEndPoint());
+
+        Segment reflected = segment.transform(reflect);
+        Assert.assertTrue(reflected.isFull());
+        Assert.assertTrue(reflected.contains(Vector2D.ZERO));
+        Assert.assertTrue(reflected.contains(Vector2D.of(2, -1)));
+        Assert.assertNull(reflected.getStartPoint());
+        Assert.assertNull(reflected.getEndPoint());
+    }
+
+    @Test
+    public void testTransform_positiveHalfspace() {
+        // arrange
+        Segment segment = Segment.fromInterval(Line.fromPoints(Vector2D.ZERO, Vector2D.of(2, 1), TEST_PRECISION),
+                0.0, Double.POSITIVE_INFINITY);
+
+        Transform2D translation = AffineTransformMatrix2D.createTranslation(-1, 1);
+        Transform2D rotation = AffineTransformMatrix2D.createRotation(Geometry.HALF_PI);
+        Transform2D scale = AffineTransformMatrix2D.createScale(2, 3);
+        Transform2D reflect = FunctionTransform2D.from((pt) -> Vector2D.of(pt.getX(), -pt.getY()));
+
+        // act/assert
+        Segment translated = segment.transform(translation);
+        Assert.assertTrue(translated.isInfinite());
+        Assert.assertTrue(translated.contains(Vector2D.of(-1, 1)));
+        Assert.assertTrue(translated.contains(Vector2D.of(1, 2)));
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(-1, 1), translated.getStartPoint(), TEST_EPS);
+        Assert.assertNull(translated.getEndPoint());
+
+        Segment rotated = segment.transform(rotation);
+        Assert.assertTrue(rotated.isInfinite());
+        Assert.assertTrue(rotated.contains(Vector2D.ZERO));
+        Assert.assertTrue(rotated.contains(Vector2D.of(-1, 2)));
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.ZERO, rotated.getStartPoint(), TEST_EPS);
+        Assert.assertNull(rotated.getEndPoint());
+
+        Segment scaled = segment.transform(scale);
+        Assert.assertTrue(scaled.isInfinite());
+        Assert.assertTrue(scaled.contains(Vector2D.ZERO));
+        Assert.assertTrue(scaled.contains(Vector2D.of(4, 3)));
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.ZERO, scaled.getStartPoint(), TEST_EPS);
+        Assert.assertNull(scaled.getEndPoint());
+
+        Segment reflected = segment.transform(reflect);
+        Assert.assertTrue(reflected.isInfinite());
+        Assert.assertTrue(reflected.contains(Vector2D.ZERO));
+        Assert.assertTrue(reflected.contains(Vector2D.of(2, -1)));
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.ZERO, reflected.getStartPoint(), TEST_EPS);
+        Assert.assertNull(reflected.getEndPoint());
+    }
+
+    @Test
+    public void testTransform_negativeHalfspace() {
+        // arrange
+        Segment segment = Segment.fromInterval(Line.fromPoints(Vector2D.ZERO, Vector2D.of(2, 1), TEST_PRECISION),
+                Double.NEGATIVE_INFINITY, 0.0);
+
+        Transform2D translation = AffineTransformMatrix2D.createTranslation(-1, 1);
+        Transform2D rotation = AffineTransformMatrix2D.createRotation(Geometry.HALF_PI);
+        Transform2D scale = AffineTransformMatrix2D.createScale(2, 3);
+        Transform2D reflect = FunctionTransform2D.from((pt) -> Vector2D.of(pt.getX(), -pt.getY()));
+
+        // act/assert
+        Segment translated = segment.transform(translation);
+        Assert.assertTrue(translated.isInfinite());
+        Assert.assertTrue(translated.contains(Vector2D.of(-1, 1)));
+        Assert.assertTrue(translated.contains(Vector2D.of(-3, 0)));
+        Assert.assertNull(translated.getStartPoint());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(-1, 1), translated.getEndPoint(), TEST_EPS);
+
+        Segment rotated = segment.transform(rotation);
+        Assert.assertTrue(rotated.isInfinite());
+        Assert.assertTrue(rotated.contains(Vector2D.ZERO));
+        Assert.assertTrue(rotated.contains(Vector2D.of(1, -2)));
+        Assert.assertNull(rotated.getStartPoint());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.ZERO, rotated.getEndPoint(), TEST_EPS);
+
+        Segment scaled = segment.transform(scale);
+        Assert.assertTrue(scaled.isInfinite());
+        Assert.assertTrue(scaled.contains(Vector2D.ZERO));
+        Assert.assertTrue(scaled.contains(Vector2D.of(-4, -3)));
+        Assert.assertNull(scaled.getStartPoint());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.ZERO, scaled.getEndPoint(), TEST_EPS);
+
+        Segment reflected = segment.transform(reflect);
+        Assert.assertTrue(reflected.isInfinite());
+        Assert.assertTrue(reflected.contains(Vector2D.ZERO));
+        Assert.assertTrue(reflected.contains(Vector2D.of(-2, 1)));
+        Assert.assertNull(reflected.getStartPoint());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.ZERO, reflected.getEndPoint(), TEST_EPS);
+    }
+
+    @Test
+    public void testIntersection_line() {
+        // arrange
+        Segment aSeg = Segment.fromPoints(Vector2D.of(1, 0), Vector2D.of(2, 0), TEST_PRECISION);
+        Segment bSeg = Segment.fromPoints(Vector2D.of(-1, -1), Vector2D.of(1, 1), TEST_PRECISION);
+
+        Line xAxis = Line.fromPointAndAngle(Vector2D.ZERO, Geometry.ZERO_PI, TEST_PRECISION);
+        Line yAxis = Line.fromPointAndAngle(Vector2D.ZERO, Geometry.HALF_PI, TEST_PRECISION);
+        Line angledLine = Line.fromPoints(Vector2D.of(1, 1), Vector2D.of(2, 0), TEST_PRECISION);
+
+        // act/assert
+        Assert.assertNull(aSeg.intersection(xAxis));
+        Assert.assertNull(aSeg.intersection(yAxis));
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.ZERO, bSeg.intersection(xAxis), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.ZERO, bSeg.intersection(yAxis), TEST_EPS);
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1, 1), bSeg.intersection(angledLine), TEST_EPS);
+    }
+
+    @Test
+    public void testIntersection_lineSegment() {
+        // arrange
+        Segment a = Segment.fromPoints(Vector2D.of(1, 0), Vector2D.of(2, 0), TEST_PRECISION);
+        Segment b = Segment.fromPoints(Vector2D.of(-1, -1), Vector2D.of(1, 1), TEST_PRECISION);
+        Segment c = Segment.fromPoints(Vector2D.of(-1, 0), Vector2D.ZERO, TEST_PRECISION);
+        Segment d = Segment.fromPoints(Vector2D.of(0, 3), Vector2D.of(3, 0), TEST_PRECISION);
+
+        // act/assert
+        Assert.assertNull(a.intersection(a));
+        Assert.assertNull(a.intersection(c));
+        Assert.assertNull(a.intersection(b));
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.ZERO, b.intersection(c), TEST_EPS);
+
+        Assert.assertNull(b.intersection(d));
+        Assert.assertNull(d.intersection(b));
+    }
+
+    @Test
+    public void testReverse_full() {
+        // arrange
+        Line line = Line.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_X, TEST_PRECISION);
+        Segment segment = line.span();
+
+        // act
+        Segment result = segment.reverse();
+
+        // assert
+        checkInfiniteSegment(result, segment.getLine().reverse(), null, null);
+    }
+
+    @Test
+    public void testReverse_positiveHalfSpace() {
+        // arrange
+        Line line = Line.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_X, TEST_PRECISION);
+        Segment segment = line.segment(1, Double.POSITIVE_INFINITY);
+
+        // act
+        Segment result = segment.reverse();
+
+        // assert
+        checkInfiniteSegment(result, segment.getLine().reverse(), null, Vector2D.of(1, 0));
+    }
+
+    @Test
+    public void testReverse_negativeHalfSpace() {
+        // arrange
+        Line line = Line.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_X, TEST_PRECISION);
+        Segment segment = line.segment(Double.NEGATIVE_INFINITY, 1);
+
+        // act
+        Segment result = segment.reverse();
+
+        // assert
+        checkInfiniteSegment(result, segment.getLine().reverse(), Vector2D.of(1, 0), null);
+    }
+
+    @Test
+    public void testReverse_finiteSegment() {
+        // arrange
+        Line line = Line.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_X, TEST_PRECISION);
+        Segment segment = line.segment(3, 4);
+
+        // act
+        Segment result = segment.reverse();
+
+        // assert
+        checkFiniteSegment(result, Vector2D.of(4, 0), Vector2D.of(3, 0));
+    }
+
+    @Test
+    public void testSplit_finite() {
+        // arrange
+        Vector2D start = Vector2D.of(1, 1);
+        Vector2D end = Vector2D.of(3, 2);
+        Vector2D middle = start.lerp(end, 0.5);
+
+        Segment seg = Segment.fromPoints(start, end, TEST_PRECISION);
+
+        // act/assert
+        Split<Segment> both = seg.split(Line.fromPointAndDirection(middle, Vector2D.of(1, -2), TEST_PRECISION));
+        checkFiniteSegment(both.getMinus(), middle, end);
+        checkFiniteSegment(both.getPlus(), start, middle);
+
+        Split<Segment> bothReversed = seg.split(Line.fromPointAndDirection(middle, Vector2D.of(-1, 2), TEST_PRECISION));
+        checkFiniteSegment(bothReversed.getMinus(), start, middle);
+        checkFiniteSegment(bothReversed.getPlus(), middle, end);
+
+        Split<Segment> minusOnlyOrthogonal = seg.split(Line.fromPointAndDirection(start, Vector2D.of(1, -2), TEST_PRECISION));
+        Assert.assertSame(seg, minusOnlyOrthogonal.getMinus());
+        Assert.assertNull(minusOnlyOrthogonal.getPlus());
+
+        Split<Segment> minusOnlyParallel = seg.split(Line.fromPointAndDirection(Vector2D.ZERO, Vector2D.of(2, 1), TEST_PRECISION));
+        Assert.assertSame(seg, minusOnlyParallel.getMinus());
+        Assert.assertNull(minusOnlyParallel.getPlus());
+
+        Split<Segment> plusOnlyOrthogonal = seg.split(Line.fromPointAndDirection(end, Vector2D.of(1, -2), TEST_PRECISION));
+        Assert.assertNull(plusOnlyOrthogonal.getMinus());
+        Assert.assertSame(seg, plusOnlyOrthogonal.getPlus());
+
+        Split<Segment> plusOnlyParallel = seg.split(Line.fromPointAndDirection(Vector2D.ZERO, Vector2D.of(-2, -1), TEST_PRECISION));
+        Assert.assertNull(plusOnlyParallel.getMinus());
+        Assert.assertSame(seg, plusOnlyParallel.getPlus());
+
+        Split<Segment> hyper = seg.split(Line.fromPointAndDirection(start, Vector2D.of(2, 1), TEST_PRECISION));
+        Assert.assertNull(hyper.getMinus());
+        Assert.assertNull(hyper.getPlus());
+    }
+
+    @Test
+    public void testSplit_full() {
+        // arrange
+        Vector2D p1 = Vector2D.of(1, 1);
+        Vector2D p2 = Vector2D.of(3, 2);
+        Vector2D middle = p1.lerp(p2, 0.5);
+
+        Line line = Line.fromPoints(p1, p2, TEST_PRECISION);
+
+        Segment seg = Segment.fromInterval(line, Interval.full());
+
+        // act/assert
+        Split<Segment> both = seg.split(Line.fromPointAndDirection(middle, Vector2D.of(1, -2), TEST_PRECISION));
+        checkInfiniteSegment(both.getMinus(), line,  middle, null);
+        checkInfiniteSegment(both.getPlus(), line, null, middle);
+
+        Split<Segment> bothReversed = seg.split(Line.fromPointAndDirection(middle, Vector2D.of(-1, 2), TEST_PRECISION));
+        checkInfiniteSegment(bothReversed.getMinus(), line,  null, middle);
+        checkInfiniteSegment(bothReversed.getPlus(), line, middle, null);
+
+        Split<Segment> minusOnlyParallel = seg.split(Line.fromPointAndDirection(Vector2D.ZERO, Vector2D.of(2, 1), TEST_PRECISION));
+        Assert.assertSame(seg, minusOnlyParallel.getMinus());
+        Assert.assertNull(minusOnlyParallel.getPlus());
+
+        Split<Segment> plusOnlyParallel = seg.split(Line.fromPointAndDirection(Vector2D.ZERO, Vector2D.of(-2, -1), TEST_PRECISION));
+        Assert.assertNull(plusOnlyParallel.getMinus());
+        Assert.assertSame(seg, plusOnlyParallel.getPlus());
+
+        Split<Segment> hyper = seg.split(Line.fromPointAndDirection(p1, Vector2D.of(2, 1), TEST_PRECISION));
+        Assert.assertNull(hyper.getMinus());
+        Assert.assertNull(hyper.getPlus());
+    }
+
+    @Test
+    public void testSplit_positiveHalfSpace() {
+        // arrange
+        Vector2D p1 = Vector2D.of(1, 1);
+        Vector2D p2 = Vector2D.of(3, 2);
+        Vector2D middle = p1.lerp(p2, 0.5);
+
+        Line line = Line.fromPoints(p1, p2, TEST_PRECISION);
+
+        Segment seg = Segment.fromInterval(line, Interval.min(line.toSubspace(p1).getX(), TEST_PRECISION));
+
+        // act/assert
+        Split<Segment> both = seg.split(Line.fromPointAndDirection(middle, Vector2D.of(1, -2), TEST_PRECISION));
+        checkInfiniteSegment(both.getMinus(), line,  middle, null);
+        checkFiniteSegment(both.getPlus(), p1, middle);
+
+        Split<Segment> bothReversed = seg.split(Line.fromPointAndDirection(middle, Vector2D.of(-1, 2), TEST_PRECISION));
+        checkFiniteSegment(bothReversed.getMinus(), p1, middle);
+        checkInfiniteSegment(bothReversed.getPlus(), line, middle, null);
+
+        Split<Segment> minusOnlyParallel = seg.split(Line.fromPointAndDirection(Vector2D.ZERO, Vector2D.of(2, 1), TEST_PRECISION));
+        Assert.assertSame(seg, minusOnlyParallel.getMinus());
+        Assert.assertNull(minusOnlyParallel.getPlus());
+
+        Split<Segment> minusOnlyOrthogonal = seg.split(Line.fromPointAndDirection(p1, Vector2D.of(1, -2), TEST_PRECISION));
+        Assert.assertSame(seg, minusOnlyOrthogonal.getMinus());
+        Assert.assertNull(minusOnlyOrthogonal.getPlus());
+
+        Split<Segment> plusOnlyParallel = seg.split(Line.fromPointAndDirection(Vector2D.ZERO, Vector2D.of(-2, -1), TEST_PRECISION));
+        Assert.assertNull(plusOnlyParallel.getMinus());
+        Assert.assertSame(seg, plusOnlyParallel.getPlus());
+
+        Split<Segment> hyper = seg.split(Line.fromPointAndDirection(p1, Vector2D.of(2, 1), TEST_PRECISION));
+        Assert.assertNull(hyper.getMinus());
+        Assert.assertNull(hyper.getPlus());
+    }
+
+    @Test
+    public void testSplit_negativeHalfSpace() {
+        // arrange
+        Vector2D p1 = Vector2D.of(1, 1);
+        Vector2D p2 = Vector2D.of(3, 2);
+        Vector2D middle = p1.lerp(p2, 0.5);
+
+        Line line = Line.fromPoints(p1, p2, TEST_PRECISION);
+
+        Segment seg = Segment.fromInterval(line, Interval.max(line.toSubspace(p2).getX(), TEST_PRECISION));
+
+        // act/assert
+        Split<Segment> both = seg.split(Line.fromPointAndDirection(middle, Vector2D.of(1, -2), TEST_PRECISION));
+        checkFiniteSegment(both.getMinus(), middle, p2);
+        checkInfiniteSegment(both.getPlus(), line, null, middle);
+
+        Split<Segment> bothReversed = seg.split(Line.fromPointAndDirection(middle, Vector2D.of(-1, 2), TEST_PRECISION));
+        checkInfiniteSegment(bothReversed.getMinus(), line, null, middle);
+        checkFiniteSegment(bothReversed.getPlus(), middle, p2);
+
+        Split<Segment> minusOnlyParallel = seg.split(Line.fromPointAndDirection(Vector2D.ZERO, Vector2D.of(2, 1), TEST_PRECISION));
+        Assert.assertSame(seg, minusOnlyParallel.getMinus());
+        Assert.assertNull(minusOnlyParallel.getPlus());
+
+        Split<Segment> plusOnlyParallel = seg.split(Line.fromPointAndDirection(Vector2D.ZERO, Vector2D.of(-2, -1), TEST_PRECISION));
+        Assert.assertNull(plusOnlyParallel.getMinus());
+        Assert.assertSame(seg, plusOnlyParallel.getPlus());
+
+        Split<Segment> plusOnlyOrthogonal = seg.split(Line.fromPointAndDirection(p2, Vector2D.of(1, -2), TEST_PRECISION));
+        Assert.assertNull(plusOnlyOrthogonal.getMinus());
+        Assert.assertSame(seg, plusOnlyOrthogonal.getPlus());
+
+        Split<Segment> hyper = seg.split(Line.fromPointAndDirection(p1, Vector2D.of(2, 1), TEST_PRECISION));
+        Assert.assertNull(hyper.getMinus());
+        Assert.assertNull(hyper.getPlus());
+    }
+
+    @Test
+    public void testToString() {
+        // arrange
+        Line line = Line.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_X, TEST_PRECISION);
+
+        Segment full = Segment.fromInterval(line, Interval.full());
+        Segment startOnly = Segment.fromInterval(line, 0, Double.POSITIVE_INFINITY);
+        Segment endOnly = Segment.fromInterval(line, Double.NEGATIVE_INFINITY, 0);
+        Segment finite = Segment.fromPoints(Vector2D.ZERO, Vector2D.Unit.PLUS_X, TEST_PRECISION);
+
+        // act/assert
+        String fullStr = full.toString();
+        Assert.assertTrue(fullStr.contains("lineOrigin=") && fullStr.contains("lineDirection="));
+
+        String startOnlyStr = startOnly.toString();
+        Assert.assertTrue(startOnlyStr.contains("start=") && startOnlyStr.contains("direction="));
+
+        String endOnlyStr = endOnly.toString();
+        Assert.assertTrue(endOnlyStr.contains("direction=") && endOnlyStr.contains("end="));
+
+        String finiteStr = finite.toString();
+        Assert.assertTrue(finiteStr.contains("start=") && finiteStr.contains("end="));
+    }
+
+    @Test
+    public void testEmpty() {
+        // act
+        Polyline path = Polyline.empty();
+
+        // assert
+        Assert.assertTrue(path.isEmpty());
+        Assert.assertFalse(path.isClosed());
+        Assert.assertTrue(path.isFinite());
+        Assert.assertFalse(path.isInfinite());
+
+        Assert.assertEquals(0, path.getSegments().size());
+    }
+
+    private static void checkClassify(Segment segment, RegionLocation loc, Vector2D ... points) {
+        for (Vector2D pt : points) {
+            String msg = "Unexpected location for point " + pt;
+
+            Assert.assertEquals(msg, loc, segment.classify(pt));
+        }
+    }
+
+    private static void checkFiniteSegment(Segment segment, Vector2D start, Vector2D end) {
+        checkFiniteSegment(segment, start, end, TEST_PRECISION);
+    }
+
+    private static void checkFiniteSegment(Segment segment, Vector2D start, Vector2D end, DoublePrecisionContext precision) {
+        Assert.assertFalse(segment.isInfinite());
+
+        EuclideanTestUtils.assertCoordinatesEqual(start, segment.getStartPoint(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(end, segment.getEndPoint(), TEST_EPS);
+
+        Line line = segment.getLine();
+        Assert.assertEquals(HyperplaneLocation.ON, line.classify(segment.getStartPoint()));
+        Assert.assertEquals(HyperplaneLocation.ON, line.classify(segment.getEndPoint()));
+
+        Assert.assertEquals(line.toSubspace(segment.getStartPoint()).getX(), segment.getSubspaceStart(), TEST_EPS);
+        Assert.assertEquals(line.toSubspace(segment.getEndPoint()).getX(), segment.getSubspaceEnd(), TEST_EPS);
+
+        Assert.assertSame(precision, segment.getPrecision());
+        Assert.assertSame(precision, line.getPrecision());
+    }
+
+    private static void checkInfiniteSegment(Segment segment, Line line, Vector2D start, Vector2D end) {
+        checkInfiniteSegment(segment, line, start, end, TEST_PRECISION);
+    }
+
+    private static void checkInfiniteSegment(Segment segment, Line line, Vector2D start, Vector2D end,
+            DoublePrecisionContext precision) {
+
+        Assert.assertTrue(segment.isInfinite());
+
+        Assert.assertEquals(line, segment.getLine());
+
+        if (start == null) {
+            Assert.assertNull(segment.getStartPoint());
+        }
+        else {
+            EuclideanTestUtils.assertCoordinatesEqual(start, segment.getStartPoint(), TEST_EPS);
+            Assert.assertEquals(line.toSubspace(segment.getStartPoint()).getX(), segment.getSubspaceStart(), TEST_EPS);
+        }
+
+        if (end == null) {
+            Assert.assertNull(segment.getEndPoint());
+        }
+        else {
+            EuclideanTestUtils.assertCoordinatesEqual(end, segment.getEndPoint(), TEST_EPS);
+            Assert.assertEquals(line.toSubspace(segment.getEndPoint()).getX(), segment.getSubspaceEnd(), TEST_EPS);
+        }
+
+        Assert.assertSame(precision, segment.getPrecision());
+        Assert.assertSame(precision, line.getPrecision());
     }
 }
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/SubLineTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/SubLineTest.java
index 2e086bc..f663ebe 100644
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/SubLineTest.java
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/SubLineTest.java
@@ -18,11 +18,23 @@
 
 import java.util.List;
 
-import org.apache.commons.geometry.core.partitioning.RegionFactory;
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.GeometryTestUtils;
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.exception.GeometryException;
+import org.apache.commons.geometry.core.partitioning.ConvexSubHyperplane;
+import org.apache.commons.geometry.core.partitioning.Hyperplane;
+import org.apache.commons.geometry.core.partitioning.HyperplaneBoundedRegion;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.SplitLocation;
+import org.apache.commons.geometry.core.partitioning.SubHyperplane;
 import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
 import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
-import org.apache.commons.geometry.euclidean.oned.IntervalsSet;
+import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
+import org.apache.commons.geometry.euclidean.oned.Interval;
+import org.apache.commons.geometry.euclidean.oned.RegionBSPTree1D;
 import org.apache.commons.geometry.euclidean.oned.Vector1D;
+import org.apache.commons.geometry.euclidean.twod.SubLine.SubLineBuilder;
 import org.junit.Assert;
 import org.junit.Test;
 
@@ -33,130 +45,628 @@
     private static final DoublePrecisionContext TEST_PRECISION =
             new EpsilonDoublePrecisionContext(TEST_EPS);
 
+    private Line line = Line.fromPointAndDirection(Vector2D.of(0, 1), Vector2D.Unit.PLUS_X, TEST_PRECISION);
+
     @Test
-    public void testEndPoints() {
-        Vector2D p1 = Vector2D.of(-1, -7);
-        Vector2D p2 = Vector2D.of(7, -1);
-        Segment segment = new Segment(p1, p2, Line.fromPoints(p1, p2, TEST_PRECISION));
-        SubLine sub = new SubLine(segment);
-        List<Segment> segments = sub.getSegments();
-        Assert.assertEquals(1, segments.size());
-        Assert.assertEquals(0.0, Vector2D.of(-1, -7).distance(segments.get(0).getStart()), TEST_EPS);
-        Assert.assertEquals(0.0, Vector2D.of( 7, -1).distance(segments.get(0).getEnd()), TEST_EPS);
+    public void testCtor_lineOnly() {
+        // act
+        SubLine sub = new SubLine(line);
+
+        // assert
+        Assert.assertSame(line, sub.getLine());
+        Assert.assertSame(TEST_PRECISION, sub.getPrecision());
+
+        Assert.assertFalse(sub.isFull());
+        Assert.assertTrue(sub.isEmpty());
+        Assert.assertFalse(sub.isInfinite());
+        Assert.assertTrue(sub.isFinite());
     }
 
     @Test
-    public void testNoEndPoints() {
-        SubLine wholeLine = Line.fromPoints(Vector2D.of(-1, 7), Vector2D.of(7, 1), TEST_PRECISION).wholeHyperplane();
-        List<Segment> segments = wholeLine.getSegments();
-        Assert.assertEquals(1, segments.size());
-        Assert.assertTrue(Double.isInfinite(segments.get(0).getStart().getX()) &&
-                          segments.get(0).getStart().getX() < 0);
-        Assert.assertTrue(Double.isInfinite(segments.get(0).getStart().getY()) &&
-                          segments.get(0).getStart().getY() > 0);
-        Assert.assertTrue(Double.isInfinite(segments.get(0).getEnd().getX()) &&
-                          segments.get(0).getEnd().getX() > 0);
-        Assert.assertTrue(Double.isInfinite(segments.get(0).getEnd().getY()) &&
-                          segments.get(0).getEnd().getY() < 0);
+    public void testCtor_lineAndBoolean() {
+        // act
+        SubLine sub = new SubLine(line, true);
+
+        // assert
+        Assert.assertSame(line, sub.getLine());
+        Assert.assertSame(TEST_PRECISION, sub.getPrecision());
+
+        Assert.assertTrue(sub.isFull());
+        Assert.assertFalse(sub.isEmpty());
+        Assert.assertTrue(sub.isInfinite());
+        Assert.assertFalse(sub.isFinite());
     }
 
     @Test
-    public void testNoSegments() {
-        SubLine empty = new SubLine(Line.fromPoints(Vector2D.of(-1, -7), Vector2D.of(7, -1), TEST_PRECISION),
-                                    new RegionFactory<Vector1D>().getComplement(new IntervalsSet(TEST_PRECISION)));
-        List<Segment> segments = empty.getSegments();
+    public void testCtor_lineAndRegion() {
+        // arrange
+        RegionBSPTree1D tree = RegionBSPTree1D.full();
+
+        // act
+        SubLine sub = new SubLine(line, tree);
+
+        // assert
+        Assert.assertSame(line, sub.getLine());
+        Assert.assertSame(tree, sub.getSubspaceRegion());
+        Assert.assertSame(TEST_PRECISION, sub.getPrecision());
+
+        Assert.assertTrue(sub.isFull());
+        Assert.assertFalse(sub.isEmpty());
+        Assert.assertTrue(sub.isInfinite());
+        Assert.assertFalse(sub.isFinite());
+    }
+
+    @Test
+    public void testToConvex_full() {
+        // arrange
+        SubLine sub = new SubLine(line, true);
+
+        // act
+        List<Segment> segments = sub.toConvex();
+
+        // assert
+        Assert.assertEquals(1, segments.size());
+
+        Segment seg = segments.get(0);
+        Assert.assertTrue(seg.isFull());
+    }
+
+    @Test
+    public void testToConvex_empty() {
+        // arrange
+        SubLine sub = new SubLine(line, false);
+
+        // act
+        List<Segment> segments = sub.toConvex();
+
+        // assert
         Assert.assertEquals(0, segments.size());
     }
 
     @Test
-    public void testSeveralSegments() {
-        SubLine twoSubs = new SubLine(Line.fromPoints(Vector2D.of(-1, -7), Vector2D.of(7, -1), TEST_PRECISION),
-                                    new RegionFactory<Vector1D>().union(new IntervalsSet(1, 2, TEST_PRECISION),
-                                                                           new IntervalsSet(3, 4, TEST_PRECISION)));
-        List<Segment> segments = twoSubs.getSegments();
+    public void testToConvex_finiteAndInfiniteSegments() {
+        // arrange
+        SubLine sub = new SubLine(line, false);
+        RegionBSPTree1D tree = sub.getSubspaceRegion();
+        tree.add(Interval.max(-2.0, TEST_PRECISION));
+        tree.add(Interval.of(-1, 2, TEST_PRECISION));
+
+        // act
+        List<Segment> segments = sub.toConvex();
+
+        // assert
         Assert.assertEquals(2, segments.size());
+
+        Assert.assertNull(segments.get(0).getStartPoint());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(-2, 1), segments.get(0).getEndPoint(), TEST_EPS);
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(-1, 1), segments.get(1).getStartPoint(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(2, 1), segments.get(1).getEndPoint(), TEST_EPS);
     }
 
     @Test
-    public void testHalfInfiniteNeg() {
-        SubLine empty = new SubLine(Line.fromPoints(Vector2D.of(-1, -7), Vector2D.of(7, -1), TEST_PRECISION),
-                                    new IntervalsSet(Double.NEGATIVE_INFINITY, 0.0, TEST_PRECISION));
-        List<Segment> segments = empty.getSegments();
-        Assert.assertEquals(1, segments.size());
-        Assert.assertTrue(Double.isInfinite(segments.get(0).getStart().getX()) &&
-                          segments.get(0).getStart().getX() < 0);
-        Assert.assertTrue(Double.isInfinite(segments.get(0).getStart().getY()) &&
-                          segments.get(0).getStart().getY() < 0);
-        Assert.assertEquals(0.0, Vector2D.of(3, -4).distance(segments.get(0).getEnd()), TEST_EPS);
+    public void testAdd_lineSegment() {
+        // arrange
+        Line line = Line.fromPointAndAngle(Vector2D.of(0, 1), Geometry.ZERO_PI, TEST_PRECISION);
+        Line otherLine = Line.fromPointAndAngle(Vector2D.of(0, 1), 1e-11, TEST_PRECISION);
+
+        SubLine subline = new SubLine(line);
+
+        // act
+        subline.add(Segment.fromInterval(line, 2, 4));
+        subline.add(Segment.fromInterval(otherLine, 1, 3));
+        subline.add(Segment.fromPoints(Vector2D.of(-4, 1), Vector2D.of(-1, 1), TEST_PRECISION));
+
+        // assert
+        Assert.assertFalse(subline.isFull());
+        Assert.assertFalse(subline.isEmpty());
+
+        List<Segment> segments = subline.toConvex();
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(-4, 1), segments.get(0).getStartPoint(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(-1, 1), segments.get(0).getEndPoint(), TEST_EPS);
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1, 1), segments.get(1).getStartPoint(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(4, 1), segments.get(1).getEndPoint(), TEST_EPS);
     }
 
     @Test
-    public void testHalfInfinitePos() {
-        SubLine empty = new SubLine(Line.fromPoints(Vector2D.of(-1, -7), Vector2D.of(7, -1), TEST_PRECISION),
-                                    new IntervalsSet(0.0, Double.POSITIVE_INFINITY, TEST_PRECISION));
-        List<Segment> segments = empty.getSegments();
-        Assert.assertEquals(1, segments.size());
-        Assert.assertEquals(0.0, Vector2D.of(3, -4).distance(segments.get(0).getStart()), TEST_EPS);
-        Assert.assertTrue(Double.isInfinite(segments.get(0).getEnd().getX()) &&
-                          segments.get(0).getEnd().getX() > 0);
-        Assert.assertTrue(Double.isInfinite(segments.get(0).getEnd().getY()) &&
-                          segments.get(0).getEnd().getY() > 0);
+    public void testAdd_subLine() {
+        // arrange
+        Line line = Line.fromPointAndAngle(Vector2D.of(0, 1), Geometry.ZERO_PI, TEST_PRECISION);
+
+        SubLine a = new SubLine(line);
+        RegionBSPTree1D aTree = a.getSubspaceRegion();
+        aTree.add(Interval.max(-3, TEST_PRECISION));
+        aTree.add(Interval.of(1, 2, TEST_PRECISION));
+
+        SubLine b = new SubLine(line);
+        RegionBSPTree1D bTree = b.getSubspaceRegion();
+        bTree.add(Interval.of(2, 4, TEST_PRECISION));
+        bTree.add(Interval.of(-4, -2, TEST_PRECISION));
+
+        SubLine subline = new SubLine(line);
+
+        int aTreeCount = aTree.count();
+        int bTreeCount = bTree.count();
+
+        // act
+        subline.add(a);
+        subline.add(b);
+
+        // assert
+        Assert.assertFalse(subline.isFull());
+        Assert.assertFalse(subline.isEmpty());
+
+        List<Segment> segments = subline.toConvex();
+
+        Assert.assertEquals(2, segments.size());
+
+        Assert.assertNull(segments.get(0).getStartPoint());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(-2, 1), segments.get(0).getEndPoint(), TEST_EPS);
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1, 1), segments.get(1).getStartPoint(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(4, 1), segments.get(1).getEndPoint(), TEST_EPS);
+
+        Assert.assertEquals(aTreeCount, aTree.count());
+        Assert.assertEquals(bTreeCount, bTree.count());
     }
 
     @Test
-    public void testIntersectionInsideInside() {
-        SubLine sub1 = new SubLine(Vector2D.of(1, 1), Vector2D.of(3, 1), TEST_PRECISION);
-        SubLine sub2 = new SubLine(Vector2D.of(2, 0), Vector2D.of(2, 2), TEST_PRECISION);
-        Assert.assertEquals(0.0, Vector2D.of(2, 1).distance(sub1.intersection(sub2, true)), TEST_EPS);
-        Assert.assertEquals(0.0, Vector2D.of(2, 1).distance(sub1.intersection(sub2, false)), TEST_EPS);
+    public void testAdd_argumentsFromDifferentLine() {
+        // arrange
+        Line line = Line.fromPointAndAngle(Vector2D.of(0, 1), Geometry.ZERO_PI, TEST_PRECISION);
+        Line otherLine = Line.fromPointAndAngle(Vector2D.of(0, 1), 1e-2, TEST_PRECISION);
+
+        SubLine subline = new SubLine(line);
+
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            subline.add(Segment.fromInterval(otherLine, 0, 1));
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            subline.add(new SubLine(otherLine));
+        }, GeometryException.class);
     }
 
     @Test
-    public void testIntersectionInsideBoundary() {
-        SubLine sub1 = new SubLine(Vector2D.of(1, 1), Vector2D.of(3, 1), TEST_PRECISION);
-        SubLine sub2 = new SubLine(Vector2D.of(2, 0), Vector2D.of(2, 1), TEST_PRECISION);
-        Assert.assertEquals(0.0, Vector2D.of(2, 1).distance(sub1.intersection(sub2, true)), TEST_EPS);
-        Assert.assertNull(sub1.intersection(sub2, false));
+    public void testSplit_both_anglePositive() {
+        // arrange
+        RegionBSPTree1D subRegion = RegionBSPTree1D.empty();
+        subRegion.add(Interval.of(0,  2, TEST_PRECISION));
+        subRegion.add(Interval.of(3,  4, TEST_PRECISION));
+
+        Line line = Line.fromPointAndAngle(Vector2D.ZERO, Geometry.ZERO_PI, TEST_PRECISION);
+        SubLine subline = new SubLine(line, subRegion);
+
+        Line splitter = Line.fromPointAndAngle(Vector2D.of(1, 0), 0.1 * Geometry.PI, TEST_PRECISION);
+
+        // act
+        Split<SubLine> split = subline.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        List<Segment> minusSegments = split.getMinus().toConvex();
+        Assert.assertEquals(1, minusSegments.size());
+        checkFiniteSegment(minusSegments.get(0), Vector2D.ZERO, Vector2D.of(1, 0));
+
+        List<Segment> plusSegments = split.getPlus().toConvex();
+        Assert.assertEquals(2, plusSegments.size());
+        checkFiniteSegment(plusSegments.get(0), Vector2D.of(1, 0), Vector2D.of(2, 0));
+        checkFiniteSegment(plusSegments.get(1), Vector2D.of(3, 0), Vector2D.of(4, 0));
     }
 
     @Test
-    public void testIntersectionInsideOutside() {
-        SubLine sub1 = new SubLine(Vector2D.of(1, 1), Vector2D.of(3, 1), TEST_PRECISION);
-        SubLine sub2 = new SubLine(Vector2D.of(2, 0), Vector2D.of(2, 0.5), TEST_PRECISION);
-        Assert.assertNull(sub1.intersection(sub2, true));
-        Assert.assertNull(sub1.intersection(sub2, false));
+    public void testSplit_both_angleNegative() {
+        // arrange
+        RegionBSPTree1D subRegion = RegionBSPTree1D.empty();
+        subRegion.add(Interval.of(0,  2, TEST_PRECISION));
+        subRegion.add(Interval.of(3,  4, TEST_PRECISION));
+
+        Line line = Line.fromPointAndAngle(Vector2D.ZERO, Geometry.ZERO_PI, TEST_PRECISION);
+        SubLine subline = new SubLine(line, subRegion);
+
+        Line splitter = Line.fromPointAndAngle(Vector2D.of(1, 0), -0.9 * Geometry.PI, TEST_PRECISION);
+
+        // act
+        Split<SubLine> split = subline.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        List<Segment> minusSegments = split.getMinus().toConvex();
+        Assert.assertEquals(2, minusSegments.size());
+        checkFiniteSegment(minusSegments.get(0), Vector2D.of(1, 0), Vector2D.of(2, 0));
+        checkFiniteSegment(minusSegments.get(1), Vector2D.of(3, 0), Vector2D.of(4, 0));
+
+        List<Segment> plusSegments = split.getPlus().toConvex();
+        Assert.assertEquals(1, plusSegments.size());
+        checkFiniteSegment(plusSegments.get(0), Vector2D.ZERO, Vector2D.of(1, 0));
     }
 
     @Test
-    public void testIntersectionBoundaryBoundary() {
-        SubLine sub1 = new SubLine(Vector2D.of(1, 1), Vector2D.of(2, 1), TEST_PRECISION);
-        SubLine sub2 = new SubLine(Vector2D.of(2, 0), Vector2D.of(2, 1), TEST_PRECISION);
-        Assert.assertEquals(0.0, Vector2D.of(2, 1).distance(sub1.intersection(sub2, true)), TEST_EPS);
-        Assert.assertNull(sub1.intersection(sub2, false));
+    public void testSplit_intersection_plusOnly() {
+        // arrange
+        RegionBSPTree1D subRegion = RegionBSPTree1D.empty();
+        subRegion.add(Interval.of(0,  2, TEST_PRECISION));
+        subRegion.add(Interval.of(3,  4, TEST_PRECISION));
+
+        Line line = Line.fromPointAndAngle(Vector2D.ZERO, Geometry.ZERO_PI, TEST_PRECISION);
+        SubLine subline = new SubLine(line, subRegion);
+
+        Line splitter = Line.fromPointAndAngle(Vector2D.of(-1, 0), 0.1 * Geometry.PI, TEST_PRECISION);
+
+        // act
+        Split<SubLine> split = subline.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.PLUS, split.getLocation());
+
+        Assert.assertNull(split.getMinus());
+        Assert.assertSame(subline, split.getPlus());
     }
 
     @Test
-    public void testIntersectionBoundaryOutside() {
-        SubLine sub1 = new SubLine(Vector2D.of(1, 1), Vector2D.of(2, 1), TEST_PRECISION);
-        SubLine sub2 = new SubLine(Vector2D.of(2, 0), Vector2D.of(2, 0.5), TEST_PRECISION);
-        Assert.assertNull(sub1.intersection(sub2, true));
-        Assert.assertNull(sub1.intersection(sub2, false));
+    public void testSplit_intersection_minusOnly() {
+        // arrange
+        RegionBSPTree1D subRegion = RegionBSPTree1D.empty();
+        subRegion.add(Interval.of(0,  2, TEST_PRECISION));
+        subRegion.add(Interval.of(3,  4, TEST_PRECISION));
+
+        Line line = Line.fromPointAndAngle(Vector2D.ZERO, Geometry.ZERO_PI, TEST_PRECISION);
+        SubLine subline = new SubLine(line, subRegion);
+
+        Line splitter = Line.fromPointAndAngle(Vector2D.of(10, 0), 0.1 * Geometry.PI, TEST_PRECISION);
+
+        // act
+        Split<SubLine> split = subline.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.MINUS, split.getLocation());
+
+        Assert.assertSame(subline, split.getMinus());
+        Assert.assertNull(split.getPlus());
     }
 
     @Test
-    public void testIntersectionOutsideOutside() {
-        SubLine sub1 = new SubLine(Vector2D.of(1, 1), Vector2D.of(1.5, 1), TEST_PRECISION);
-        SubLine sub2 = new SubLine(Vector2D.of(2, 0), Vector2D.of(2, 0.5), TEST_PRECISION);
-        Assert.assertNull(sub1.intersection(sub2, true));
-        Assert.assertNull(sub1.intersection(sub2, false));
+    public void testSplit_parallel_plus() {
+        // arrange
+        RegionBSPTree1D subRegion = RegionBSPTree1D.empty();
+        subRegion.add(Interval.of(0,  2, TEST_PRECISION));
+        subRegion.add(Interval.of(3,  4, TEST_PRECISION));
+
+        Line line = Line.fromPointAndAngle(Vector2D.ZERO, Geometry.ZERO_PI, TEST_PRECISION);
+        SubLine subline = new SubLine(line, subRegion);
+
+        Line splitter = Line.fromPointAndAngle(Vector2D.of(0, 1), Geometry.ZERO_PI, TEST_PRECISION);
+
+        // act
+        Split<SubLine> split = subline.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.PLUS, split.getLocation());
+
+        Assert.assertNull(split.getMinus());
+        Assert.assertSame(subline, split.getPlus());
     }
 
     @Test
-    public void testIntersectionParallel() {
-        final SubLine sub1 = new SubLine(Vector2D.of(0, 1), Vector2D.of(0, 2), TEST_PRECISION);
-        final SubLine sub2 = new SubLine(Vector2D.of(66, 3), Vector2D.of(66, 4), TEST_PRECISION);
-        Assert.assertNull(sub1.intersection(sub2, true));
-        Assert.assertNull(sub1.intersection(sub2, false));
+    public void testSplit_parallel_minus() {
+        // arrange
+        RegionBSPTree1D subRegion = RegionBSPTree1D.empty();
+        subRegion.add(Interval.of(0,  2, TEST_PRECISION));
+        subRegion.add(Interval.of(3,  4, TEST_PRECISION));
+
+        Line line = Line.fromPointAndAngle(Vector2D.ZERO, Geometry.ZERO_PI, TEST_PRECISION);
+        SubLine subline = new SubLine(line, subRegion);
+
+        Line splitter = Line.fromPointAndAngle(Vector2D.of(0, -1), Geometry.ZERO_PI, TEST_PRECISION);
+
+        // act
+        Split<SubLine> split = subline.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.MINUS, split.getLocation());
+
+        Assert.assertSame(subline, split.getMinus());
+        Assert.assertNull(split.getPlus());
     }
 
+    @Test
+    public void testSplit_coincident_sameDirection() {
+        // arrange
+        RegionBSPTree1D subRegion = RegionBSPTree1D.empty();
+        subRegion.add(Interval.of(0,  2, TEST_PRECISION));
+        subRegion.add(Interval.of(3,  4, TEST_PRECISION));
+
+        Line line = Line.fromPointAndAngle(Vector2D.ZERO, Geometry.ZERO_PI, TEST_PRECISION);
+        SubLine subline = new SubLine(line, subRegion);
+
+        Line splitter = Line.fromPointAndAngle(Vector2D.ZERO, Geometry.ZERO_PI, TEST_PRECISION);
+
+        // act
+        Split<SubLine> split = subline.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.NEITHER, split.getLocation());
+
+        Assert.assertNull(split.getMinus());
+        Assert.assertNull(split.getPlus());
+    }
+
+    @Test
+    public void testSplit_coincident_oppositeDirection() {
+        // arrange
+        RegionBSPTree1D subRegion = RegionBSPTree1D.empty();
+        subRegion.add(Interval.of(0,  2, TEST_PRECISION));
+        subRegion.add(Interval.of(3,  4, TEST_PRECISION));
+
+        Line line = Line.fromPointAndAngle(Vector2D.ZERO, Geometry.ZERO_PI, TEST_PRECISION);
+        SubLine subline = new SubLine(line, subRegion);
+
+        Line splitter = Line.fromPointAndAngle(Vector2D.ZERO, Geometry.PI, TEST_PRECISION);
+
+        // act
+        Split<SubLine> split = subline.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.NEITHER, split.getLocation());
+
+        Assert.assertNull(split.getMinus());
+        Assert.assertNull(split.getPlus());
+    }
+
+    @Test
+    public void testTransform() {
+        // arrange
+        AffineTransformMatrix2D mat = AffineTransformMatrix2D
+                .createRotation(Vector2D.of(0, 1), Geometry.HALF_PI)
+                .scale(Vector2D.of(3, 2));
+
+        SubLine subline = new SubLine(Line.fromPointAndAngle(Vector2D.ZERO, Geometry.ZERO_PI, TEST_PRECISION));
+        subline.getSubspaceRegion().add(Interval.of(0, 1, TEST_PRECISION));
+        subline.getSubspaceRegion().add(Interval.min(3, TEST_PRECISION));
+
+        // act
+        SubLine transformed = subline.transform(mat);
+
+        // assert
+        Assert.assertNotSame(subline, transformed);
+
+        List<Segment> originalSegments = subline.toConvex();
+        Assert.assertEquals(2, originalSegments.size());
+        checkFiniteSegment(originalSegments.get(0), Vector2D.ZERO, Vector2D.Unit.PLUS_X);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(3, 0), originalSegments.get(1).getStartPoint(), TEST_EPS);
+        Assert.assertNull(originalSegments.get(1).getEndPoint());
+
+        List<Segment> transformedSegments = transformed.toConvex();
+        Assert.assertEquals(2, transformedSegments.size());
+        checkFiniteSegment(transformedSegments.get(0), Vector2D.of(3, 2), Vector2D.of(3, 4));
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(3, 8), transformedSegments.get(1).getStartPoint(), TEST_EPS);
+        Assert.assertNull(transformedSegments.get(1).getEndPoint());
+    }
+
+    @Test
+    public void testTransform_reflection() {
+        // arrange
+        AffineTransformMatrix2D mat = AffineTransformMatrix2D.createScale(Vector2D.of(-1, 2));
+
+        SubLine subline = new SubLine(Line.fromPointAndAngle(Vector2D.of(0, 1), Geometry.ZERO_PI, TEST_PRECISION));
+        subline.getSubspaceRegion().add(Interval.of(0, 1, TEST_PRECISION));
+
+        // act
+        SubLine transformed = subline.transform(mat);
+
+        // assert
+        Assert.assertNotSame(subline, transformed);
+
+        List<Segment> originalSegments = subline.toConvex();
+        Assert.assertEquals(1, originalSegments.size());
+        checkFiniteSegment(originalSegments.get(0), Vector2D.of(0, 1), Vector2D.of(1, 1));
+
+        List<Segment> transformedSegments = transformed.toConvex();
+        Assert.assertEquals(1, transformedSegments.size());
+        checkFiniteSegment(transformedSegments.get(0), Vector2D.of(0, 2), Vector2D.of(-1, 2));
+    }
+
+    @Test
+    public void testBuilder_instanceMethod() {
+        // arrange
+        Line line = Line.fromPointAndAngle(Vector2D.of(0, 1), Geometry.ZERO_PI, TEST_PRECISION);
+        SubLineBuilder builder = new SubLine(line).builder();
+
+        // act
+        SubLine subline = builder.build();
+
+        // assert
+        Assert.assertFalse(subline.isFull());
+        Assert.assertTrue(subline.isEmpty());
+
+        List<Segment> segments = subline.toConvex();
+        Assert.assertEquals(0, segments.size());
+
+        Assert.assertSame(line, subline.getLine());
+        Assert.assertSame(line, subline.getHyperplane());
+        Assert.assertSame(TEST_PRECISION, subline.getPrecision());
+    }
+
+    @Test
+    public void testBuilder_createEmpty() {
+        // arrange
+        Line line = Line.fromPointAndAngle(Vector2D.of(0, 1), Geometry.ZERO_PI, TEST_PRECISION);
+
+        SubLineBuilder builder = new SubLineBuilder(line);
+
+        // act
+        SubLine subline = builder.build();
+
+        // assert
+        Assert.assertFalse(subline.isFull());
+        Assert.assertTrue(subline.isEmpty());
+
+        List<Segment> segments = subline.toConvex();
+        Assert.assertEquals(0, segments.size());
+    }
+
+    @Test
+    public void testBuilder_addConvex() {
+        // arrange
+        Line line = Line.fromPointAndAngle(Vector2D.of(0, 1), Geometry.ZERO_PI, TEST_PRECISION);
+        Line otherLine = Line.fromPointAndAngle(Vector2D.of(0, 1), 1e-11, TEST_PRECISION);
+
+        SubLineBuilder builder = new SubLineBuilder(line);
+
+        // act
+        builder.add(Segment.fromInterval(line, 2, 4));
+        builder.add(Segment.fromInterval(otherLine, 1, 3));
+        builder.add(Segment.fromPoints(Vector2D.of(-4, 1), Vector2D.of(-1, 1), TEST_PRECISION));
+
+        SubLine subline = builder.build();
+
+        // assert
+        Assert.assertFalse(subline.isFull());
+        Assert.assertFalse(subline.isEmpty());
+
+        List<Segment> segments = subline.toConvex();
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(-4, 1), segments.get(0).getStartPoint(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(-1, 1), segments.get(0).getEndPoint(), TEST_EPS);
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1, 1), segments.get(1).getStartPoint(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(4, 1), segments.get(1).getEndPoint(), TEST_EPS);
+    }
+
+    @Test
+    public void testBuilder_addNonConvex() {
+        // arrange
+        Line line = Line.fromPointAndAngle(Vector2D.of(0, 1), Geometry.ZERO_PI, TEST_PRECISION);
+
+        SubLine a = new SubLine(line);
+        RegionBSPTree1D aTree = a.getSubspaceRegion();
+        aTree.add(Interval.max(-3, TEST_PRECISION));
+        aTree.add(Interval.of(1, 2, TEST_PRECISION));
+
+        SubLine b = new SubLine(line);
+        RegionBSPTree1D bTree = b.getSubspaceRegion();
+        bTree.add(Interval.of(2, 4, TEST_PRECISION));
+        bTree.add(Interval.of(-4, -2, TEST_PRECISION));
+
+        SubLineBuilder builder = new SubLineBuilder(line);
+
+        int aTreeCount = aTree.count();
+        int bTreeCount = bTree.count();
+
+        // act
+        builder.add(a);
+        builder.add(b);
+
+        SubLine subline = builder.build();
+
+        // assert
+        Assert.assertFalse(subline.isFull());
+        Assert.assertFalse(subline.isEmpty());
+
+        List<Segment> segments = subline.toConvex();
+
+        Assert.assertEquals(2, segments.size());
+
+        Assert.assertNull(segments.get(0).getStartPoint());
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(-2, 1), segments.get(0).getEndPoint(), TEST_EPS);
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1, 1), segments.get(1).getStartPoint(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(4, 1), segments.get(1).getEndPoint(), TEST_EPS);
+
+        Assert.assertEquals(aTreeCount, aTree.count());
+        Assert.assertEquals(bTreeCount, bTree.count());
+    }
+
+    @Test
+    public void testBuilder_argumentsFromDifferentLine() {
+        // arrange
+        Line line = Line.fromPointAndAngle(Vector2D.of(0, 1), Geometry.ZERO_PI, TEST_PRECISION);
+        Line otherLine = Line.fromPointAndAngle(Vector2D.of(0, 1), 1e-2, TEST_PRECISION);
+
+        SubLineBuilder builder = new SubLineBuilder(line);
+
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            builder.add(Segment.fromInterval(otherLine, 0, 1));
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            builder.add(new SubLine(otherLine));
+        }, GeometryException.class);
+    }
+
+    @Test
+    public void testBuilder_unknownSubLineType() {
+        // arrange
+        Line line = Line.fromPointAndAngle(Vector2D.of(0, 1), Geometry.ZERO_PI, TEST_PRECISION);
+
+        AbstractSubLine unknownType = new AbstractSubLine(line) {
+
+            /** Serializable UID */
+            private static final long serialVersionUID = 20190729L;
+
+            @Override
+            public boolean isInfinite() {
+                return false;
+            }
+
+            @Override
+            public boolean isFinite() {
+                return true;
+            }
+
+            @Override
+            public List<? extends ConvexSubHyperplane<Vector2D>> toConvex() {
+                return null;
+            }
+
+            @Override
+            public HyperplaneBoundedRegion<Vector1D> getSubspaceRegion() {
+                return null;
+            }
+
+            @Override
+            public Split<? extends SubHyperplane<Vector2D>> split(Hyperplane<Vector2D> splitter) {
+                return null;
+            }
+
+            @Override
+            public SubHyperplane<Vector2D> transform(Transform<Vector2D> transform) {
+                return null;
+            }
+        };
+
+        SubLineBuilder builder = new SubLineBuilder(line);
+
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            builder.add(unknownType);
+        }, IllegalArgumentException.class);
+    }
+
+    @Test
+    public void testToString() {
+        // arrange
+        SubLine sub = new SubLine(line);
+
+        // act
+        String str = sub.toString();
+
+        // assert
+        Assert.assertTrue(str.contains("SubLine[lineOrigin= "));
+        Assert.assertTrue(str.contains(", lineDirection= "));
+        Assert.assertTrue(str.contains(", region= "));
+    }
+
+    private static void checkFiniteSegment(Segment segment, Vector2D start, Vector2D end) {
+        Assert.assertFalse(segment.isInfinite());
+
+        EuclideanTestUtils.assertCoordinatesEqual(start, segment.getStartPoint(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(end, segment.getEndPoint(), TEST_EPS);
+    }
 }
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/Vector2DTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/Vector2DTest.java
index 20c3faf..0bd84fb 100644
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/Vector2DTest.java
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/Vector2DTest.java
@@ -16,6 +16,7 @@
  */
 package org.apache.commons.geometry.euclidean.twod;
 
+import java.util.Comparator;
 import java.util.regex.Pattern;
 
 import org.apache.commons.geometry.core.Geometry;
@@ -65,6 +66,25 @@
     }
 
     @Test
+    public void testCoordinateAscendingOrder() {
+        // arrange
+        Comparator<Vector2D> cmp = Vector2D.COORDINATE_ASCENDING_ORDER;
+
+        // act/assert
+        Assert.assertEquals(0, cmp.compare(Vector2D.of(1, 2), Vector2D.of(1, 2)));
+
+        Assert.assertEquals(-1, cmp.compare(Vector2D.of(0, 2), Vector2D.of(1, 2)));
+        Assert.assertEquals(-1, cmp.compare(Vector2D.of(1, 1), Vector2D.of(1, 2)));
+
+        Assert.assertEquals(1, cmp.compare(Vector2D.of(2, 2), Vector2D.of(1, 2)));
+        Assert.assertEquals(1, cmp.compare(Vector2D.of(1, 3), Vector2D.of(1, 2)));
+
+        Assert.assertEquals(-1, cmp.compare(Vector2D.of(1, 3), null));
+        Assert.assertEquals(1, cmp.compare(null, Vector2D.of(1, 2)));
+        Assert.assertEquals(0, cmp.compare(null, null));
+    }
+
+    @Test
     public void testCoordinates() {
         // arrange
         Vector2D v = Vector2D.of(1, 2);
@@ -125,6 +145,24 @@
     }
 
     @Test
+    public void testFinite() {
+        // act/assert
+        Assert.assertTrue(Vector2D.ZERO.isFinite());
+        Assert.assertTrue(Vector2D.of(1, 1).isFinite());
+
+        Assert.assertFalse(Vector2D.of(0, Double.NEGATIVE_INFINITY).isFinite());
+        Assert.assertFalse(Vector2D.of(Double.NEGATIVE_INFINITY, 0).isFinite());
+        Assert.assertFalse(Vector2D.of(0, Double.POSITIVE_INFINITY).isFinite());
+        Assert.assertFalse(Vector2D.of(Double.POSITIVE_INFINITY, 0).isFinite());
+
+        Assert.assertFalse(Vector2D.of(0, Double.NaN).isFinite());
+        Assert.assertFalse(Vector2D.of(Double.NEGATIVE_INFINITY, Double.NaN).isFinite());
+        Assert.assertFalse(Vector2D.of(Double.NaN, Double.NEGATIVE_INFINITY).isFinite());
+        Assert.assertFalse(Vector2D.of(Double.POSITIVE_INFINITY, Double.NaN).isFinite());
+        Assert.assertFalse(Vector2D.of(Double.NaN, Double.POSITIVE_INFINITY).isFinite());
+    }
+
+    @Test
     public void testGetZero() {
         // act/assert
         checkVector(Vector2D.of(1.0, 1.0).getZero(), 0, 0);
@@ -809,20 +847,20 @@
         Vector2D vec = Vector2D.of(1, -2);
 
         // act/assert
-        Assert.assertTrue(vec.equals(vec, smallEps));
-        Assert.assertTrue(vec.equals(vec, largeEps));
+        Assert.assertTrue(vec.eq(vec, smallEps));
+        Assert.assertTrue(vec.eq(vec, largeEps));
 
-        Assert.assertTrue(vec.equals(Vector2D.of(1.0000007, -2.0000009), smallEps));
-        Assert.assertTrue(vec.equals(Vector2D.of(1.0000007, -2.0000009), largeEps));
+        Assert.assertTrue(vec.eq(Vector2D.of(1.0000007, -2.0000009), smallEps));
+        Assert.assertTrue(vec.eq(Vector2D.of(1.0000007, -2.0000009), largeEps));
 
-        Assert.assertFalse(vec.equals(Vector2D.of(1.004, -2), smallEps));
-        Assert.assertFalse(vec.equals(Vector2D.of(1, -2.004), smallEps));
-        Assert.assertTrue(vec.equals(Vector2D.of(1.004, -2.004), largeEps));
+        Assert.assertFalse(vec.eq(Vector2D.of(1.004, -2), smallEps));
+        Assert.assertFalse(vec.eq(Vector2D.of(1, -2.004), smallEps));
+        Assert.assertTrue(vec.eq(Vector2D.of(1.004, -2.004), largeEps));
 
-        Assert.assertFalse(vec.equals(Vector2D.of(1, -3), smallEps));
-        Assert.assertFalse(vec.equals(Vector2D.of(2, -2), smallEps));
-        Assert.assertFalse(vec.equals(Vector2D.of(1, -3), largeEps));
-        Assert.assertFalse(vec.equals(Vector2D.of(2, -2), largeEps));
+        Assert.assertFalse(vec.eq(Vector2D.of(1, -3), smallEps));
+        Assert.assertFalse(vec.eq(Vector2D.of(2, -2), smallEps));
+        Assert.assertFalse(vec.eq(Vector2D.of(1, -3), largeEps));
+        Assert.assertFalse(vec.eq(Vector2D.of(2, -2), largeEps));
     }
 
     @Test
diff --git a/commons-geometry-euclidean/src/test/resources/org/apache/commons/geometry/euclidean/threed/issue-1211.bsp b/commons-geometry-euclidean/src/test/resources/org/apache/commons/geometry/euclidean/threed/issue-1211.bsp
deleted file mode 100644
index 2803ac6..0000000
--- a/commons-geometry-euclidean/src/test/resources/org/apache/commons/geometry/euclidean/threed/issue-1211.bsp
+++ /dev/null
@@ -1,14 +0,0 @@
-PolyhedronsSet
- plus  internal -1.0  0.0  0.0 -1.0  0.0  0.0
-   minus internal  0.0  1.0  0.0  0.0  1.0  0.0
-     minus internal  0.0  0.0  1.0  0.0  0.0  1.0
-       minus internal  0.0  0.0 -1.0  0.0  0.0 -1.0
-         minus internal  0.0 -1.0  0.0  0.0 -1.0  0.0
-           minus internal  1.0  0.0  0.0  1.0  0.0  0.0
-             minus leaf true
-             plus  leaf false
-           plus  leaf false
-         plus  leaf false
-       plus  leaf false
-     plus  leaf false
-   plus  leaf false
diff --git a/commons-geometry-euclidean/src/test/resources/org/apache/commons/geometry/euclidean/threed/pentomino-N-bad-orientation.ply b/commons-geometry-euclidean/src/test/resources/org/apache/commons/geometry/euclidean/threed/pentomino-N-bad-orientation.ply
deleted file mode 100644
index 4109576..0000000
--- a/commons-geometry-euclidean/src/test/resources/org/apache/commons/geometry/euclidean/threed/pentomino-N-bad-orientation.ply
+++ /dev/null
@@ -1,40 +0,0 @@
-ply
-format ascii 1.0
-comment this file represents the 'N' pentomino
-comment it has been created manually
-comment the shape has a reversed orientation for facet 3
-element vertex 16
-property double x
-property double y
-property double z
-element face 12
-property list uchar uint vertex_indices
-end_header
-0.0 0.0 0.0
-1.0 0.0 0.0
-1.0 1.0 0.0
-2.0 1.0 0.0
-2.0 4.0 0.0
-1.0 4.0 0.0
-1.0 2.0 0.0
-0.0 2.0 0.0
-0.0 0.0 1.0
-1.0 0.0 1.0
-1.0 1.0 1.0
-2.0 1.0 1.0
-2.0 4.0 1.0
-1.0 4.0 1.0
-1.0 2.0 1.0
-0.0 2.0 1.0
-5  8  9 10 14 15
-5 10 11 12 13 14
-5  7  6  2  1  0
-5  2  3  4  5  6
-4  0  1  9  8
-4  1  2 10  9
-4  2  3 11 10
-4  3  4 12 11
-4  4  5 13 12
-4  5  6 14 13
-4  6  7 15 14
-4  7  0  8 15
\ No newline at end of file
diff --git a/commons-geometry-euclidean/src/test/resources/org/apache/commons/geometry/euclidean/threed/pentomino-N-hole.ply b/commons-geometry-euclidean/src/test/resources/org/apache/commons/geometry/euclidean/threed/pentomino-N-hole.ply
deleted file mode 100644
index e40a025..0000000
--- a/commons-geometry-euclidean/src/test/resources/org/apache/commons/geometry/euclidean/threed/pentomino-N-hole.ply
+++ /dev/null
@@ -1,39 +0,0 @@
-ply
-format ascii 1.0
-comment this file represents the 'N' pentomino
-comment it has been created manually
-comment the shape has a missing face between vertices 0, 1, 9, 8
-element vertex 16
-property double x
-property double y
-property double z
-element face 11
-property list uchar uint vertex_indices
-end_header
-0.0 0.0 0.0
-1.0 0.0 0.0
-1.0 1.0 0.0
-2.0 1.0 0.0
-2.0 4.0 0.0
-1.0 4.0 0.0
-1.0 2.0 0.0
-0.0 2.0 0.0
-0.0 0.0 1.0
-1.0 0.0 1.0
-1.0 1.0 1.0
-2.0 1.0 1.0
-2.0 4.0 1.0
-1.0 4.0 1.0
-1.0 2.0 1.0
-0.0 2.0 1.0
-5  8  9 10 14 15
-5 10 11 12 13 14
-5  7  6  2  1  0
-5  6  5  4  3  2
-4  1  2 10  9
-4  2  3 11 10
-4  3  4 12 11
-4  4  5 13 12
-4  5  6 14 13
-4  6  7 15 14
-4  7  0  8 15
\ No newline at end of file
diff --git a/commons-geometry-euclidean/src/test/resources/org/apache/commons/geometry/euclidean/threed/pentomino-N-out-of-plane.ply b/commons-geometry-euclidean/src/test/resources/org/apache/commons/geometry/euclidean/threed/pentomino-N-out-of-plane.ply
deleted file mode 100644
index c345eda..0000000
--- a/commons-geometry-euclidean/src/test/resources/org/apache/commons/geometry/euclidean/threed/pentomino-N-out-of-plane.ply
+++ /dev/null
@@ -1,40 +0,0 @@
-ply
-format ascii 1.0
-comment this file represents the 'N' pentomino
-comment it has been created manually
-comment the shape is distorted with edge 7 moved, so associated facets are not planar
-element vertex 16
-property double x
-property double y
-property double z
-element face 12
-property list uchar uint vertex_indices
-end_header
-0.0 0.0 0.0
-1.0 0.0 0.0
-1.0 1.0 0.0
-2.0 1.0 0.0
-2.0 4.0 0.0
-1.0 4.0 0.0
-1.0 2.0 0.0
-0.0 2.0 0.5
-0.0 0.0 1.0
-1.0 0.0 1.0
-1.0 1.0 1.0
-2.0 1.0 1.0
-2.0 4.0 1.0
-1.0 4.0 1.0
-1.0 2.0 1.0
-0.0 2.0 1.0
-5  8  9 10 14 15
-5 10 11 12 13 14
-5  7  6  2  1  0
-5  6  5  4  3  2
-4  0  1  9  8
-4  1  2 10  9
-4  2  3 11 10
-4  3  4 12 11
-4  4  5 13 12
-4  5  6 14 13
-4  6  7 15 14
-4  7  0  8 15
\ No newline at end of file
diff --git a/commons-geometry-euclidean/src/test/resources/org/apache/commons/geometry/euclidean/threed/pentomino-N-too-close.ply b/commons-geometry-euclidean/src/test/resources/org/apache/commons/geometry/euclidean/threed/pentomino-N-too-close.ply
deleted file mode 100644
index 1701540..0000000
--- a/commons-geometry-euclidean/src/test/resources/org/apache/commons/geometry/euclidean/threed/pentomino-N-too-close.ply
+++ /dev/null
@@ -1,86 +0,0 @@
-ply
-format ascii 1.0
-comment this file should trigger an error as it contains several vertices at the same location
-comment the file was originally created using blender http://www.blender.org
-element vertex 52
-property float x
-property float y
-property float z
-property float nx
-property float ny
-property float nz
-element face 20
-property list uchar uint vertex_indices
-end_header
-0.000000 0.000000 0.000000 1.000000 0.000000 0.000000
-0.000000 1.000000 0.000000 1.000000 0.000000 0.000000
-0.000000 1.000000 1.000000 1.000000 0.000000 0.000000
-0.000000 0.000000 1.000000 1.000000 0.000000 0.000000
-0.000000 1.000000 0.000000 0.000000 1.000000 0.000000
--2.000000 1.000000 0.000000 0.000000 1.000000 0.000000
--2.000000 1.000000 1.000000 0.000000 1.000000 0.000000
-0.000000 1.000000 1.000000 0.000000 1.000000 0.000000
--2.000000 1.000000 1.000000 0.000000 0.000000 0.000000
-1.000000 1.000000 1.000000 0.000000 0.000000 0.000000
-0.000000 1.000000 1.000000 0.000000 0.000000 0.000000
--2.000000 2.000000 1.000000 0.000000 0.000000 -1.000000
-1.000000 2.000000 1.000000 0.000000 0.000000 -1.000000
-1.000000 1.000000 1.000000 0.000000 0.000000 -1.000000
--2.000000 1.000000 1.000000 -0.000000 -0.000000 -1.000000
--2.000000 2.000000 0.000000 0.000000 -1.000000 -0.000000
-1.000000 2.000000 0.000000 0.000000 -1.000000 -0.000000
-1.000000 2.000000 1.000000 0.000000 -1.000000 -0.000000
--2.000000 2.000000 1.000000 0.000000 -1.000000 -0.000000
-0.000000 0.000000 0.000000 -0.000000 0.000000 1.000000
-1.000000 1.000000 0.000000 -0.000000 0.000000 1.000000
-0.000000 1.000000 0.000000 -0.000000 0.000000 1.000000
-2.000000 0.000000 0.000000 -0.000000 0.000000 1.000000
-2.000000 1.000000 0.000000 -0.000000 0.000000 1.000000
-2.000000 1.000000 0.000000 -1.000000 0.000000 -0.000000
-2.000000 0.000000 0.000000 -1.000000 0.000000 -0.000000
-2.000000 0.000000 1.000000 -1.000000 0.000000 -0.000000
-2.000000 1.000000 1.000000 -1.000000 0.000000 -0.000000
-2.000000 0.000000 0.000000 0.000000 1.000000 0.000000
-0.000000 0.000000 0.000000 0.000000 1.000000 0.000000
-0.000000 0.000000 1.000000 0.000000 1.000000 0.000000
-2.000000 0.000000 1.000000 0.000000 1.000000 0.000000
--2.000000 1.000000 0.000000 1.000000 0.000000 0.000000
--2.000000 2.000000 0.000000 1.000000 0.000000 0.000000
--2.000000 2.000000 1.000000 1.000000 0.000000 0.000000
--2.000000 1.000000 1.000000 1.000000 0.000000 0.000000
-1.000000 1.000000 0.000000 0.000000 -1.000000 -0.000000
-2.000000 1.000000 0.000000 0.000000 -1.000000 -0.000000
-2.000000 1.000000 1.000000 0.000000 -1.000000 -0.000000
-1.000000 1.000000 1.000000 0.000000 -1.000000 -0.000000
-1.000000 2.000000 0.000000 -1.000000 0.000000 -0.000000
-1.000000 1.000000 0.000000 -1.000000 0.000000 -0.000000
-1.000000 1.000000 1.000000 -1.000000 0.000000 -0.000000
-1.000000 2.000000 1.000000 -1.000000 0.000000 -0.000000
--2.000000 1.000000 0.000000 -0.000000 0.000000 1.000000
-1.000000 2.000000 0.000000 -0.000000 0.000000 1.000000
--2.000000 2.000000 0.000000 -0.000000 0.000000 1.000000
-0.000000 0.000000 1.000000 -0.000000 0.000000 -1.000000
-2.000000 1.000000 1.000000 -0.000000 0.000000 -1.000000
-2.000000 0.000000 1.000000 -0.000000 0.000000 -1.000000
-2.000000 1.000000 1.000000 0.000000 0.000000 0.000000
-0.000000 1.000000 1.000000 -0.000000 -0.000000 -1.000000
-4 0 1 2 3
-4 4 5 6 7
-3 8 9 10
-3 11 12 13
-3 14 11 13
-4 15 16 17 18
-3 19 20 21
-3 22 23 20
-3 19 22 20
-4 24 25 26 27
-4 28 29 30 31
-4 32 33 34 35
-4 36 37 38 39
-4 40 41 42 43
-3 44 45 46
-3 21 20 45
-3 44 21 45
-3 47 48 49
-3 10 9 50
-3 47 51 48
diff --git a/commons-geometry-euclidean/src/test/resources/org/apache/commons/geometry/euclidean/threed/pentomino-N.ply b/commons-geometry-euclidean/src/test/resources/org/apache/commons/geometry/euclidean/threed/pentomino-N.ply
deleted file mode 100644
index 4efbf20..0000000
--- a/commons-geometry-euclidean/src/test/resources/org/apache/commons/geometry/euclidean/threed/pentomino-N.ply
+++ /dev/null
@@ -1,39 +0,0 @@
-ply
-format ascii 1.0
-comment this file represents the 'N' pentomino
-comment it has been created manually
-element vertex 16
-property double x
-property double y
-property double z
-element face 12
-property list uchar uint vertex_indices
-end_header
-0.0 0.0 0.0
-1.0 0.0 0.0
-1.0 1.0 0.0
-2.0 1.0 0.0
-2.0 4.0 0.0
-1.0 4.0 0.0
-1.0 2.0 0.0
-0.0 2.0 0.0
-0.0 0.0 1.0
-1.0 0.0 1.0
-1.0 1.0 1.0
-2.0 1.0 1.0
-2.0 4.0 1.0
-1.0 4.0 1.0
-1.0 2.0 1.0
-0.0 2.0 1.0
-5  8  9 10 14 15
-5 10 11 12 13 14
-5  7  6  2  1  0
-5  6  5  4  3  2
-4  0  1  9  8
-4  1  2 10  9
-4  2  3 11 10
-4  3  4 12 11
-4  4  5 13 12
-4  5  6 14 13
-4  6  7 15 14
-4  7  0  8 15
\ No newline at end of file
diff --git a/commons-geometry-hull/src/main/java/org/apache/commons/geometry/euclidean/twod/hull/AbstractConvexHullGenerator2D.java b/commons-geometry-hull/src/main/java/org/apache/commons/geometry/euclidean/twod/hull/AbstractConvexHullGenerator2D.java
index 3ec1621..1f5d3f2 100644
--- a/commons-geometry-hull/src/main/java/org/apache/commons/geometry/euclidean/twod/hull/AbstractConvexHullGenerator2D.java
+++ b/commons-geometry-hull/src/main/java/org/apache/commons/geometry/euclidean/twod/hull/AbstractConvexHullGenerator2D.java
@@ -27,7 +27,7 @@
  */
 abstract class AbstractConvexHullGenerator2D implements ConvexHullGenerator2D {
 
-    /** Default epsilon vlaue. */
+    /** Default epsilon value. */
     private static final double DEFAULT_EPSILON = 1e-10;
 
     /** Precision context used to compare floating point numbers. */
@@ -58,7 +58,8 @@
      * added as hull vertices
      * @param precision precision context used to compare floating point numbers
      */
-    protected AbstractConvexHullGenerator2D(final boolean includeCollinearPoints, final DoublePrecisionContext precision) {
+    protected AbstractConvexHullGenerator2D(final boolean includeCollinearPoints,
+            final DoublePrecisionContext precision) {
         this.includeCollinearPoints = includeCollinearPoints;
         this.precision = precision;
     }
diff --git a/commons-geometry-hull/src/main/java/org/apache/commons/geometry/euclidean/twod/hull/ConvexHull2D.java b/commons-geometry-hull/src/main/java/org/apache/commons/geometry/euclidean/twod/hull/ConvexHull2D.java
index 7dc7b26..a0656db 100644
--- a/commons-geometry-hull/src/main/java/org/apache/commons/geometry/euclidean/twod/hull/ConvexHull2D.java
+++ b/commons-geometry-hull/src/main/java/org/apache/commons/geometry/euclidean/twod/hull/ConvexHull2D.java
@@ -17,10 +17,12 @@
 package org.apache.commons.geometry.euclidean.twod.hull;
 
 import java.io.Serializable;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
 
-import org.apache.commons.geometry.core.partitioning.Region;
-import org.apache.commons.geometry.core.partitioning.RegionFactory;
 import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.twod.ConvexArea;
 import org.apache.commons.geometry.euclidean.twod.Line;
 import org.apache.commons.geometry.euclidean.twod.Segment;
 import org.apache.commons.geometry.euclidean.twod.Vector2D;
@@ -127,7 +129,7 @@
                 this.lineSegments = new Segment[1];
                 final Vector2D p1 = vertices[0];
                 final Vector2D p2 = vertices[1];
-                this.lineSegments[0] = new Segment(p1, p2, Line.fromPoints(p1, p2, precision));
+                this.lineSegments[0] = Segment.fromPoints(p1, p2, precision);
             } else {
                 this.lineSegments = new Segment[size];
                 Vector2D firstPoint = null;
@@ -138,13 +140,11 @@
                         firstPoint = point;
                         lastPoint = point;
                     } else {
-                        this.lineSegments[index++] =
-                                new Segment(lastPoint, point, Line.fromPoints(lastPoint, point, precision));
+                        this.lineSegments[index++] = Segment.fromPoints(lastPoint, point, precision);
                         lastPoint = point;
                     }
                 }
-                this.lineSegments[index] =
-                        new Segment(lastPoint, firstPoint, Line.fromPoints(lastPoint, firstPoint, precision));
+                this.lineSegments[index] = Segment.fromPoints(lastPoint, firstPoint, precision);
             }
         }
         return lineSegments;
@@ -152,16 +152,15 @@
 
     /** {@inheritDoc} */
     @Override
-    public Region<Vector2D> createRegion() {
+    public ConvexArea createRegion() {
         if (vertices.length < 3) {
-            throw new IllegalStateException("Region generation requires at least 3 vertices but found only " + vertices.length);
+            throw new IllegalStateException("Region generation requires at least 3 vertices but found only " +
+                    vertices.length);
         }
-        final RegionFactory<Vector2D> factory = new RegionFactory<>();
-        final Segment[] segments = retrieveLineSegments();
-        final Line[] lineArray = new Line[segments.length];
-        for (int i = 0; i < segments.length; i++) {
-            lineArray[i] = segments[i].getLine();
-        }
-        return factory.buildConvex(lineArray);
+
+        List<Line> bounds = Arrays.asList(retrieveLineSegments()).stream()
+            .map(Segment::getLine).collect(Collectors.toList());
+
+        return ConvexArea.fromBounds(bounds);
     }
 }
diff --git a/commons-geometry-hull/src/main/java/org/apache/commons/geometry/euclidean/twod/hull/MonotoneChain.java b/commons-geometry-hull/src/main/java/org/apache/commons/geometry/euclidean/twod/hull/MonotoneChain.java
index 98d019d..9a45066 100644
--- a/commons-geometry-hull/src/main/java/org/apache/commons/geometry/euclidean/twod/hull/MonotoneChain.java
+++ b/commons-geometry-hull/src/main/java/org/apache/commons/geometry/euclidean/twod/hull/MonotoneChain.java
@@ -118,7 +118,7 @@
         }
 
         // special case: if the lower and upper hull may contain only 1 point if all are identical
-        if (hullVertices.isEmpty() && ! lowerHull.isEmpty()) {
+        if (hullVertices.isEmpty() && !lowerHull.isEmpty()) {
             hullVertices.add(lowerHull.get(0));
         }
 
@@ -147,7 +147,7 @@
             final Vector2D p1 = hull.get(size - 2);
             final Vector2D p2 = hull.get(size - 1);
 
-            final double offset = Line.fromPoints(p1, p2, precision).getOffset(point);
+            final double offset = Line.fromPoints(p1, p2, precision).offset(point);
             if (precision.eqZero(offset)) {
                 // the point is collinear to the line (p1, p2)
 
diff --git a/commons-geometry-hull/src/main/java/org/apache/commons/geometry/hull/ConvexHull.java b/commons-geometry-hull/src/main/java/org/apache/commons/geometry/hull/ConvexHull.java
index 118a640..0d4ed2a 100644
--- a/commons-geometry-hull/src/main/java/org/apache/commons/geometry/hull/ConvexHull.java
+++ b/commons-geometry-hull/src/main/java/org/apache/commons/geometry/hull/ConvexHull.java
@@ -19,7 +19,7 @@
 import java.io.Serializable;
 
 import org.apache.commons.geometry.core.Point;
-import org.apache.commons.geometry.core.partitioning.Region;
+import org.apache.commons.geometry.core.Region;
 
 /**
  * This class represents a convex hull.
diff --git a/commons-geometry-hull/src/test/java/org/apache/commons/geometry/euclidean/twod/hull/ConvexHullGenerator2DAbstractTest.java b/commons-geometry-hull/src/test/java/org/apache/commons/geometry/euclidean/twod/hull/ConvexHullGenerator2DAbstractTest.java
index fff9971..1ced9c1 100644
--- a/commons-geometry-hull/src/test/java/org/apache/commons/geometry/euclidean/twod/hull/ConvexHullGenerator2DAbstractTest.java
+++ b/commons-geometry-hull/src/test/java/org/apache/commons/geometry/euclidean/twod/hull/ConvexHullGenerator2DAbstractTest.java
@@ -22,8 +22,8 @@
 import java.util.Collections;
 import java.util.List;
 
-import org.apache.commons.geometry.core.partitioning.Region;
-import org.apache.commons.geometry.core.partitioning.Region.Location;
+import org.apache.commons.geometry.core.Region;
+import org.apache.commons.geometry.core.RegionLocation;
 import org.apache.commons.geometry.euclidean.twod.Vector2D;
 import org.apache.commons.numbers.arrays.LinearCombination;
 import org.apache.commons.numbers.core.Precision;
@@ -348,7 +348,7 @@
         Assert.assertEquals(perimeter, hullRegion.getBoundarySize(), 1.0e-12);
 
         for (int i = 0; i < referenceHull.length; ++i) {
-            Assert.assertEquals(Location.BOUNDARY, hullRegion.checkPoint(referenceHull[i]));
+            Assert.assertEquals(RegionLocation.BOUNDARY, hullRegion.classify(referenceHull[i]));
         }
 
     }
@@ -425,10 +425,10 @@
         final Region<Vector2D> region = hull.createRegion();
 
         for (final Vector2D p : points) {
-            Location location = region.checkPoint(p);
-            Assert.assertTrue(location != Location.OUTSIDE);
+            RegionLocation location = region.classify(p);
+            Assert.assertTrue(location != RegionLocation.OUTSIDE);
 
-            if (location == Location.BOUNDARY && includesCollinearPoints) {
+            if (location == RegionLocation.BOUNDARY && includesCollinearPoints) {
                 Assert.assertTrue(hullVertices.contains(p));
             }
         }
diff --git a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/oned/AngularInterval.java b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/oned/AngularInterval.java
new file mode 100644
index 0000000..c77630b
--- /dev/null
+++ b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/oned/AngularInterval.java
@@ -0,0 +1,630 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.oned;
+
+import java.io.Serializable;
+import java.text.MessageFormat;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.function.BiFunction;
+
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.exception.GeometryException;
+import org.apache.commons.geometry.core.partitioning.Hyperplane;
+import org.apache.commons.geometry.core.partitioning.HyperplaneBoundedRegion;
+import org.apache.commons.geometry.core.partitioning.HyperplaneLocation;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+
+/** Class representing an angular interval of size greater than zero to {@code 2pi}. The interval is
+ * defined by two azimuth angles: a min and a max. The interval starts at the min azimuth angle and
+ * contains all points in the direction of increasing azimuth angles up to max.
+ *
+ * <p>Instances of this class are guaranteed to be immutable.</p>
+ */
+public class AngularInterval implements HyperplaneBoundedRegion<Point1S>, Serializable {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20190817L;
+
+    /** The minimum boundary of the interval. */
+    private final CutAngle minBoundary;
+
+    /** The maximum boundary of the interval. */
+    private final CutAngle maxBoundary;
+
+    /** Point halfway between the min and max boundaries. */
+    private final Point1S midpoint;
+
+    /** Construct a new instance representing the angular region between the given
+     * min and max azimuth boundaries. The arguments must be either all finite or all
+     * null (to indicate the full space). If the boundaries are finite, then the min
+     * boundary azimuth value must be numerically less than the max boundary. Callers are
+     * responsible for enforcing these constraints. No validation is performed.
+     * @param minBoundary minimum boundary for the interval
+     * @param maxBoundary maximum boundary for the interval
+     */
+    private AngularInterval(final CutAngle minBoundary, final CutAngle maxBoundary) {
+
+        this.minBoundary = minBoundary;
+        this.maxBoundary = maxBoundary;
+        this.midpoint = (minBoundary != null && maxBoundary != null) ?
+                Point1S.of(0.5 * (minBoundary.getAzimuth() + maxBoundary.getAzimuth())) :
+                null;
+    }
+
+    /** Get the minimum azimuth angle for the interval, or {@code 0}
+     * if the interval is full.
+     * @return the minimum azimuth angle for the interval or {@code 0}
+     *      if the interval represents the full space.
+     */
+    public double getMin() {
+        return (minBoundary != null) ?
+                minBoundary.getAzimuth() :
+                Geometry.ZERO_PI;
+    }
+
+    /** Get the minimum boundary for the interval, or null if the
+     * interval represents the full space.
+     * @return the minimum point for the interval or null if
+     *      the interval represents the full space
+     */
+    public CutAngle getMinBoundary() {
+        return minBoundary;
+    }
+
+    /** Get the maximum azimuth angle for the interval, or {@code 2pi} if
+     * the interval represents the full space.
+     * @return the maximum azimuth angle for the interval or {@code 2pi} if
+     *      the interval represents the full space.
+     */
+    public double getMax() {
+        return (maxBoundary != null) ?
+                maxBoundary.getAzimuth() :
+                Geometry.TWO_PI;
+    }
+
+    /** Get the maximum point for the interval. This will be null if the
+     * interval represents the full space.
+     * @return the maximum point for the interval or null if
+     *      the interval represents the full space
+     */
+    public CutAngle getMaxBoundary() {
+        return maxBoundary;
+    }
+
+    /** Get the midpoint of the interval or null if the interval represents
+     *  the full space.
+     * @return the midpoint of the interval or null if the interval represents
+     *      the full space
+     * @see #getBarycenter()
+     */
+    public Point1S getMidPoint() {
+        return midpoint;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isFull() {
+        // minBoundary and maxBoundary are either both null or both not null
+        return minBoundary == null;
+    }
+
+    /** {@inheritDoc}
+     *
+     * <p>This method always returns false.</p>
+     */
+    @Override
+    public boolean isEmpty() {
+        return false;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public double getSize() {
+        return getMax() - getMin();
+    }
+
+    /** {@inheritDoc}
+     *
+     * <p>This method simply returns 0 because boundaries in one dimension do not
+     *  have any size.</p>
+     */
+    @Override
+    public double getBoundarySize() {
+        return 0;
+    }
+
+    /** {@inheritDoc}
+     *
+     * <p>This method is an alias for {@link #getMidPoint()}.</p>
+     * @see #getMidPoint()
+     */
+    @Override
+    public Point1S getBarycenter() {
+        return getMidPoint();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public RegionLocation classify(final Point1S pt) {
+        if (!isFull()) {
+            final HyperplaneLocation minLoc = minBoundary.classify(pt);
+            final HyperplaneLocation maxLoc = maxBoundary.classify(pt);
+
+            final boolean wraps = wrapsZero();
+
+            if ((!wraps && (minLoc == HyperplaneLocation.PLUS || maxLoc == HyperplaneLocation.PLUS)) ||
+                    (wraps && minLoc == HyperplaneLocation.PLUS && maxLoc == HyperplaneLocation.PLUS)) {
+                return RegionLocation.OUTSIDE;
+            } else if (minLoc == HyperplaneLocation.ON || maxLoc == HyperplaneLocation.ON) {
+                return RegionLocation.BOUNDARY;
+            }
+        }
+        return RegionLocation.INSIDE;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Point1S project(final Point1S pt) {
+        if (!isFull()) {
+            final double minDist = minBoundary.getPoint().distance(pt);
+            final double maxDist = maxBoundary.getPoint().distance(pt);
+
+            return (minDist <= maxDist) ?
+                    minBoundary.getPoint() :
+                    maxBoundary.getPoint();
+        }
+        return null;
+    }
+
+    /** Return true if the interval wraps around the zero/{@code 2pi} point. In this
+     * case, the max boundary azimuth is less than that of the min boundary when both
+     * values are normalized to the range {@code [0, 2pi)}.
+     * @return true if the interval wraps around the zero/{@code 2pi} point
+     */
+    public boolean wrapsZero() {
+        if (!isFull()) {
+            final double minNormAz = minBoundary.getPoint().getNormalizedAzimuth();
+            final double maxNormAz = maxBoundary.getPoint().getNormalizedAzimuth();
+
+            return maxNormAz < minNormAz;
+        }
+        return false;
+    }
+
+    /** Return a new instance transformed by the argument. If the transformed size
+     * of the interval is greater than or equal to 2pi, then an interval representing
+     * the full space is returned.
+     * @param transform transform to apply
+     * @return a new instance transformed by the argument
+     */
+    public AngularInterval transform(final Transform<Point1S> transform) {
+        return AngularInterval.transform(this, transform, AngularInterval::of);
+    }
+
+    /** {@inheritDoc}
+    *
+    * <p>This method returns instances of {@link RegionBSPTree1S} instead of
+    * {@link AngularInterval} since it is possible for a convex angular interval
+    * to be split into disjoint regions by a single hyperplane. These disjoint
+    * regions cannot be represented by this class and require the use of a BSP
+    * tree.</p>
+    *
+    * @see RegionBSPTree1S#split(Hyperplane)
+    */
+    @Override
+    public Split<RegionBSPTree1S> split(final Hyperplane<Point1S> splitter) {
+        return toTree().split(splitter);
+    }
+
+    /** Return a {@link RegionBSPTree1S} instance representing the same region
+     * as this instance.
+     * @return a BSP tree representing the same region as this instance
+     */
+    public RegionBSPTree1S toTree() {
+        return RegionBSPTree1S.fromInterval(this);
+    }
+
+    /** Return a list of convex intervals comprising this region.
+     * @return a list of convex intervals comprising this region
+     * @see Convex
+     */
+    public List<AngularInterval.Convex> toConvex() {
+        if (isConvex(minBoundary, maxBoundary)) {
+            return Collections.singletonList(new Convex(minBoundary, maxBoundary));
+        }
+
+        final CutAngle midPos = CutAngle.createPositiveFacing(midpoint, minBoundary.getPrecision());
+        final CutAngle midNeg = CutAngle.createNegativeFacing(midpoint, maxBoundary.getPrecision());
+
+        return Arrays.asList(
+                    new Convex(minBoundary, midPos),
+                    new Convex(midNeg, maxBoundary)
+                );
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String toString() {
+        final StringBuilder sb = new StringBuilder();
+        sb.append(this.getClass().getSimpleName())
+            .append("[min= ")
+            .append(getMin())
+            .append(", max= ")
+            .append(getMax())
+            .append(']');
+
+        return sb.toString();
+    }
+
+    /** Return an instance representing the full space. The returned instance contains all
+     * possible azimuth angles.
+     * @return an interval representing the full space
+     */
+    public static AngularInterval.Convex full() {
+        return Convex.FULL;
+    }
+
+    /** Return an instance representing the angular interval between the given min and max azimuth
+     * values. The max value is adjusted to be numerically above the min value, even if the resulting
+     * azimuth value is greater than or equal to {@code 2pi}. An instance representing the full space
+     * is returned if either point is infinite or min and max are equivalent as evaluated by the
+     * given precision context.
+     * @param min min azimuth value
+     * @param max max azimuth value
+     * @param precision precision precision context used to compare floating point values
+     * @return a new instance resulting the angular region between the given min and max azimuths
+     * @throws GeometryException if either azimuth is infinite or NaN
+     */
+    public static AngularInterval of(final double min, final double max, final DoublePrecisionContext precision) {
+        return of(Point1S.of(min), Point1S.of(max), precision);
+    }
+
+    /** Return an instance representing the angular interval between the given min and max azimuth
+     * points. The max point is adjusted to be numerically above the min point, even if the resulting
+     * azimuth value is greater than or equal to {@code 2pi}. An instance representing the full space
+     * is returned if either point is infinite or min and max are equivalent as evaluated by the
+     * given precision context.
+     * @param min min azimuth value
+     * @param max max azimuth value
+     * @param precision precision precision context used to compare floating point values
+     * @return a new instance resulting the angular region between the given min and max points
+     * @throws GeometryException if either azimuth is infinite or NaN
+     */
+    public static AngularInterval of(final Point1S min, final Point1S max, final DoublePrecisionContext precision) {
+        return createInterval(min, max, precision, AngularInterval::new, Convex.FULL);
+    }
+
+    /** Return an instance representing the angular interval between the given oriented points.
+     * The negative-facing point is used as the minimum boundary and the positive-facing point is
+     * adjusted to be above the minimum. The arguments can be given in any order. The full space
+     * is returned if the points are equivalent or are oriented in the same direction.
+     * @param a first oriented point
+     * @param b second oriented point
+     * @return an instance representing the angular interval between the given oriented points
+     * @throws GeometryException if either argument is infinite or NaN
+     */
+    public static AngularInterval of(final CutAngle a, final CutAngle b) {
+        return createInterval(a, b, AngularInterval::new, Convex.FULL);
+    }
+
+    /** Internal method to create an interval between the given min and max points. The max point
+     * is adjusted to be numerically above the min point, even if the resulting
+     * azimuth value is greater than or equal to {@code 2pi}. The full instance argument
+     * is returned if either point is infinite or min and max are equivalent as evaluated by the
+     * given precision context.
+     * @param min min azimuth value
+     * @param max max azimuth value
+     * @param precision precision precision context used to compare floating point values
+     * @param factory factory object used to create new instances; this object is passed the validated
+     *      min (negative-facing) cut and the max (positive-facing) cut, in that order
+     * @param <T> Angular interval implementation type
+     * @param fullSpace instance returned if the interval should represent the full space
+     * @return a new instance resulting the angular region between the given min and max points
+     * @throws GeometryException if either azimuth is infinite or NaN
+     */
+    private static <T extends AngularInterval> T createInterval(final Point1S min, final Point1S max,
+            final DoublePrecisionContext precision, final BiFunction<CutAngle, CutAngle, T> factory,
+            final T fullSpace) {
+
+        validateIntervalValues(min, max);
+
+        // return the full space if either point is infinite or the points are equivalent
+        if (min.eq(max, precision)) {
+            return fullSpace;
+        }
+
+        final Point1S adjustedMax = max.above(min);
+
+        return factory.apply(
+                    CutAngle.createNegativeFacing(min, precision),
+                    CutAngle.createPositiveFacing(adjustedMax, precision)
+                );
+    }
+
+    /** Internal method to create a new interval instance from the given cut angles.
+     * The negative-facing point is used as the minimum boundary and the positive-facing point is
+     * adjusted to be above the minimum. The arguments can be given in any order. The full space
+     * argument is returned if the points are equivalent or are oriented in the same direction.
+     * @param a first cut point
+     * @param b second cut point
+     * @param factory factory object used to create new instances; this object is passed the validated
+     *      min (negative-facing) cut and the max (positive-facing) cut, in that order
+     * @param fullSpace instance returned if the interval should represent the full space
+     * @param <T> Angular interval implementation type
+     * @return a new interval instance created from the given cut angles
+     * @throws GeometryException if either argument is infinite or NaN
+     */
+    private static <T extends AngularInterval> T createInterval(final CutAngle a, final CutAngle b,
+            final BiFunction<CutAngle, CutAngle, T> factory, final T fullSpace) {
+
+        final Point1S aPoint = a.getPoint();
+        final Point1S bPoint = b.getPoint();
+
+        validateIntervalValues(aPoint, bPoint);
+
+        if (a.isPositiveFacing() == b.isPositiveFacing() ||
+                aPoint.eq(bPoint, a.getPrecision()) ||
+                bPoint.eq(aPoint, b.getPrecision())) {
+            // points are equivalent or facing in the same direction
+            return fullSpace;
+        }
+
+        final CutAngle min = a.isPositiveFacing() ? b : a;
+        final CutAngle max = a.isPositiveFacing() ? a : b;
+        final CutAngle adjustedMax = CutAngle.createPositiveFacing(
+                max.getPoint().above(min.getPoint()),
+                max.getPrecision());
+
+        return factory.apply(min, adjustedMax);
+    }
+
+    /** Validate that the given points can be used to specify an angular interval.
+     * @param a first point
+     * @param b second point
+     * @throws GeometryException if either point is infinite NaN
+     */
+    private static void validateIntervalValues(final Point1S a, final Point1S b) {
+        if (!a.isFinite() || !b.isFinite()) {
+            throw new GeometryException(MessageFormat.format("Invalid angular interval: [{0}, {1}]",
+                    a.getAzimuth(), b.getAzimuth()));
+        }
+    }
+
+    /** Return true if the given cut angles define a convex region. By convention, the
+     * precision context from the min cut is used for the floating point comparison.
+     * @param min min (negative-facing) cut angle
+     * @param max max (positive-facing) cut angle
+     * @return true if the given cut angles define a convex region
+     */
+    private static boolean isConvex(final CutAngle min, final CutAngle max) {
+        if (min != null && max != null) {
+            final double dist = max.getAzimuth() - min.getAzimuth();
+            final DoublePrecisionContext precision = min.getPrecision();
+            return precision.lte(dist, Geometry.PI);
+        }
+
+        return true;
+    }
+
+    /** Internal transform method that transforms the given instance, using the factory
+     * method to create a new instance if needed.
+     * @param interval interval to transform
+     * @param transform transform to apply
+     * @param factory object used to create new instances
+     * @param <T> Angular interval implementation type
+     * @return a transformed instance
+     */
+    private static <T extends AngularInterval> T transform(final T interval,
+            final Transform<Point1S> transform,
+            final BiFunction<CutAngle, CutAngle, T> factory) {
+
+        if (!interval.isFull()) {
+            final CutAngle tMin = interval.getMinBoundary().transform(transform);
+            final CutAngle tMax = interval.getMaxBoundary().transform(transform);
+
+            return factory.apply(tMin, tMax);
+        }
+
+        return interval;
+    }
+
+    /** Class representing an angular interval with the additional property that the
+     * region is convex. By convex, it is meant that the shortest path between any
+     * two points in the region is also contained entirely in the region. If there is
+     * a tie for shortest path, then it is sufficient that at least one lie entirely
+     * within the region. For spherical 1D space, this means that the angular interval
+     * is either completely full or has a length less than or equal to {@code pi}.
+     */
+    public static final class Convex extends AngularInterval {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20191012L;
+
+        /** Interval instance representing the full space. */
+        private static final Convex FULL = new Convex(null, null);
+
+        /** Construct a new convex instance from its boundaries and midpoint. No validation
+         * of the argument is performed. Callers are responsible for ensuring that the size
+         * of interval is less than or equal to {@code pi}.
+         * @param minBoundary minimum boundary for the interval
+         * @param maxBoundary maximum boundary for the interval
+         * @throws GeometryException if the interval is not convex
+         */
+        private Convex(final CutAngle minBoundary, final CutAngle maxBoundary) {
+            super(minBoundary, maxBoundary);
+
+            if (!isConvex(minBoundary, maxBoundary)) {
+                throw new GeometryException(MessageFormat.format("Interval is not convex: [{0}, {1}]",
+                        minBoundary.getAzimuth(), maxBoundary.getAzimuth()));
+            }
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public List<AngularInterval.Convex> toConvex() {
+            return Collections.singletonList(this);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public Convex transform(final Transform<Point1S> transform) {
+            return AngularInterval.transform(this, transform, Convex::of);
+        }
+
+        /** Split the instance along a circle diameter.The diameter is defined by the given split point and
+         * its reversed antipodal point.
+         * @param splitter split point defining one side of the split diameter
+         * @return result of the split operation
+         */
+        public Split<Convex> splitDiameter(final CutAngle splitter) {
+
+            final CutAngle opposite = CutAngle.fromPointAndDirection(
+                    splitter.getPoint().antipodal(),
+                    !splitter.isPositiveFacing(),
+                    splitter.getPrecision());
+
+            if (isFull()) {
+                final Convex minus = Convex.of(splitter, opposite);
+                final Convex plus = Convex.of(splitter.reverse(), opposite.reverse());
+
+                return new Split<>(minus, plus);
+            }
+
+            final CutAngle minBoundary = getMinBoundary();
+            final CutAngle maxBoundary = getMaxBoundary();
+
+            final Point1S posPole = Point1S.of(splitter.getPoint().getAzimuth() + Geometry.HALF_PI);
+
+            final int minLoc = minBoundary.getPrecision().compare(Geometry.HALF_PI,
+                    posPole.distance(minBoundary.getPoint()));
+            final int maxLoc = maxBoundary.getPrecision().compare(Geometry.HALF_PI,
+                    posPole.distance(maxBoundary.getPoint()));
+
+            final boolean positiveFacingSplit = splitter.isPositiveFacing();
+
+            // assume a positive orientation of the splitter for region location
+            // purposes and adjust later
+            Convex pos = null;
+            Convex neg = null;
+
+            if (minLoc > 0) {
+                // min is on the pos side
+
+                if (maxLoc >= 0) {
+                    // max is directly on the splitter or on the pos side
+                    pos = this;
+                } else {
+                    // min is on the pos side and max is on the neg side
+                    final CutAngle posCut = positiveFacingSplit ?
+                            opposite.reverse() :
+                            opposite;
+                    pos = Convex.of(minBoundary, posCut);
+
+                    final CutAngle negCut = positiveFacingSplit ?
+                            opposite :
+                            opposite.reverse();
+                    neg = Convex.of(negCut, maxBoundary);
+                }
+            } else if (minLoc < 0) {
+                // min is on the neg side
+
+                if (maxLoc <= 0) {
+                    // max is directly on the splitter or on the neg side
+                    neg = this;
+                } else {
+                    // min is on the neg side and max is on the pos side
+                    final CutAngle posCut = positiveFacingSplit ?
+                            splitter.reverse() :
+                            splitter;
+                    pos = Convex.of(maxBoundary, posCut);
+
+                    final CutAngle negCut = positiveFacingSplit ?
+                            splitter :
+                            splitter.reverse();
+                    neg = Convex.of(negCut, minBoundary);
+                }
+            } else {
+                // min is directly on the splitter; determine whether it was on the main split
+                // point or its antipodal point
+                if (splitter.getPoint().distance(minBoundary.getPoint()) < Geometry.HALF_PI) {
+                    // on main splitter; interval will be located on pos side of split
+                    pos = this;
+                } else {
+                    // on antipodal point; interval will be located on neg side of split
+                    neg = this;
+                }
+            }
+
+            // adjust for the actual orientation of the splitter
+            final Convex minus = positiveFacingSplit ? neg : pos;
+            final Convex plus = positiveFacingSplit ? pos : neg;
+
+            return new Split<>(minus, plus);
+        }
+
+        /** Return an instance representing the convex angular interval between the given min and max azimuth
+         * values. The max value is adjusted to be numerically above the min value, even if the resulting
+         * azimuth value is greater than or equal to {@code 2pi}. An instance representing the full space
+         * is returned if either point is infinite or min and max are equivalent as evaluated by the
+         * given precision context.
+         * @param min min azimuth value
+         * @param max max azimuth value
+         * @param precision precision precision context used to compare floating point values
+         * @return a new instance resulting the angular region between the given min and max azimuths
+         * @throws GeometryException if either azimuth is infinite or NaN, or the given angular
+         *      interval is not convex (meaning it has a size of greater than {@code pi})
+         */
+        public static Convex of(final double min, final double max, final DoublePrecisionContext precision) {
+            return of(Point1S.of(min), Point1S.of(max), precision);
+        }
+
+        /** Return an instance representing the convex angular interval between the given min and max azimuth
+         * points. The max point is adjusted to be numerically above the min point, even if the resulting
+         * azimuth value is greater than or equal to {@code 2pi}. An instance representing the full space
+         * is returned if either point is infinite or min and max are equivalent as evaluated by the
+         * given precision context.
+         * @param min min azimuth value
+         * @param max max azimuth value
+         * @param precision precision precision context used to compare floating point values
+         * @return a new instance resulting the angular region between the given min and max points
+         * @throws GeometryException if either azimuth is infinite or NaN, or the given angular
+         *      interval is not convex (meaning it has a size of greater than {@code pi})
+         */
+        public static Convex of(final Point1S min, final Point1S max, final DoublePrecisionContext precision) {
+            return createInterval(min, max, precision, Convex::new, Convex.FULL);
+        }
+
+        /** Return an instance representing the convex angular interval between the given oriented points.
+         * The negative-facing point is used as the minimum boundary and the positive-facing point is
+         * adjusted to be above the minimum. The arguments can be given in any order. The full space
+         * is returned if the points are equivalent or are oriented in the same direction.
+         * @param a first oriented point
+         * @param b second oriented point
+         * @return an instance representing the angular interval between the given oriented points
+         * @throws GeometryException if either azimuth is infinite or NaN, or the given angular
+         *      interval is not convex (meaning it has a size of greater than {@code pi})
+         */
+        public static Convex of(final CutAngle a, final CutAngle b) {
+            return createInterval(a, b, Convex::new, Convex.FULL);
+        }
+    }
+}
diff --git a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/oned/Arc.java b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/oned/Arc.java
deleted file mode 100644
index ba2b34d..0000000
--- a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/oned/Arc.java
+++ /dev/null
@@ -1,133 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.oned;
-
-import org.apache.commons.geometry.core.Geometry;
-import org.apache.commons.geometry.core.partitioning.Region.Location;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.numbers.angle.PlaneAngleRadians;
-import org.apache.commons.numbers.core.Precision;
-
-
-/** This class represents an arc on a circle.
- * @see ArcsSet
- */
-public class Arc {
-
-    /** The lower angular bound of the arc. */
-    private final double lower;
-
-    /** The upper angular bound of the arc. */
-    private final double upper;
-
-    /** Middle point of the arc. */
-    private final double middle;
-
-    /** Precision context used to determine floating point equality. */
-    private final DoublePrecisionContext precision;
-
-    /** Simple constructor.
-     * <p>
-     * If either {@code lower} is equals to {@code upper} or
-     * the interval exceeds \( 2 \pi \), the arc is considered
-     * to be the full circle and its initial defining boundaries
-     * will be forgotten. {@code lower} is not allowed to be
-     * greater than {@code upper} (an exception is thrown in this case).
-     * {@code lower} will be canonicalized between 0 and \( 2 \pi \), and
-     * upper shifted accordingly, so the {@link #getInf()} and {@link #getSup()}
-     * may not return the value used at instance construction.
-     * </p>
-     * @param lower lower angular bound of the arc
-     * @param upper upper angular bound of the arc
-     * @param precision precision context used to compare floating point values
-     * @exception IllegalArgumentException if lower is greater than upper
-     */
-    public Arc(final double lower, final double upper, final DoublePrecisionContext precision)
-        throws IllegalArgumentException {
-        this.precision = precision;
-        if (Precision.equals(lower, upper, 0) || (upper - lower) >= Geometry.TWO_PI) {
-            // the arc must cover the whole circle
-            this.lower  = 0;
-            this.upper  = Geometry.TWO_PI;
-            this.middle = Math.PI;
-        } else  if (lower <= upper) {
-            this.lower  = PlaneAngleRadians.normalizeBetweenZeroAndTwoPi(lower);
-            this.upper  = this.lower + (upper - lower);
-            this.middle = 0.5 * (this.lower + this.upper);
-        } else {
-            throw new IllegalArgumentException("Endpoints do not specify an interval: [" + lower + ", " +  upper + "]");
-        }
-    }
-
-    /** Get the lower angular bound of the arc.
-     * @return lower angular bound of the arc,
-     * always between 0 and \( 2 \pi \)
-     */
-    public double getInf() {
-        return lower;
-    }
-
-    /** Get the upper angular bound of the arc.
-     * @return upper angular bound of the arc,
-     * always between {@link #getInf()} and {@link #getInf()} \( + 2 \pi \)
-     */
-    public double getSup() {
-        return upper;
-    }
-
-    /** Get the angular size of the arc.
-     * @return angular size of the arc
-     */
-    public double getSize() {
-        return upper - lower;
-    }
-
-    /** Get the barycenter of the arc.
-     * @return barycenter of the arc
-     */
-    public double getBarycenter() {
-        return middle;
-    }
-
-    /** Get the object used to determine floating point equality for this region.
-     * @return the floating point precision context for the instance
-     */
-    public DoublePrecisionContext getPrecision() {
-        return precision;
-    }
-
-    /** Check a point with respect to the arc.
-     * @param point point to check
-     * @return a code representing the point status: either {@link
-     * Location#INSIDE}, {@link Location#OUTSIDE} or {@link Location#BOUNDARY}
-     */
-    public Location checkPoint(final double point) {
-        final double normalizedPoint = PlaneAngleRadians.normalize(point, middle);
-
-        final int lowerCmp = precision.compare(normalizedPoint, lower);
-        final int upperCmp = precision.compare(normalizedPoint, upper);
-
-        if (lowerCmp < 0 || upperCmp > 0) {
-            return Location.OUTSIDE;
-        } else if (lowerCmp > 0 && upperCmp < 0) {
-            return Location.INSIDE;
-        } else {
-            return (precision.compare(getSize(), Geometry.TWO_PI) >= 0) ? Location.INSIDE : Location.BOUNDARY;
-        }
-    }
-
-}
diff --git a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/oned/ArcsSet.java b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/oned/ArcsSet.java
deleted file mode 100644
index 2e8cc23..0000000
--- a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/oned/ArcsSet.java
+++ /dev/null
@@ -1,925 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.oned;
-
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Iterator;
-import java.util.List;
-import java.util.NoSuchElementException;
-
-import org.apache.commons.geometry.core.Geometry;
-import org.apache.commons.geometry.core.internal.GeometryInternalError;
-import org.apache.commons.geometry.core.partitioning.AbstractRegion;
-import org.apache.commons.geometry.core.partitioning.BSPTree;
-import org.apache.commons.geometry.core.partitioning.BoundaryProjection;
-import org.apache.commons.geometry.core.partitioning.Side;
-import org.apache.commons.geometry.core.partitioning.SubHyperplane;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.numbers.angle.PlaneAngleRadians;
-import org.apache.commons.numbers.core.Precision;
-
-/** This class represents a region of a circle: a set of arcs.
- * <p>
- * Note that due to the wrapping around \(2 \pi\), barycenter is
- * ill-defined here. It was defined only in order to fulfill
- * the requirements of the {@link
- * org.apache.commons.geometry.core.partitioning.Region Region}
- * interface, but its use is discouraged.
- * </p>
- */
-public class ArcsSet extends AbstractRegion<S1Point, S1Point> implements Iterable<double[]> {
-
-    /** Build an arcs set representing the whole circle.
-     * @param precision precision context used to compare floating point values
-     */
-    public ArcsSet(final DoublePrecisionContext precision) {
-        super(precision);
-    }
-
-    /** Build an arcs set corresponding to a single arc.
-     * <p>
-     * If either {@code lower} is equals to {@code upper} or
-     * the interval exceeds \( 2 \pi \), the arc is considered
-     * to be the full circle and its initial defining boundaries
-     * will be forgotten. {@code lower} is not allowed to be greater
-     * than {@code upper} (an exception is thrown in this case).
-     * </p>
-     * @param lower lower bound of the arc
-     * @param upper upper bound of the arc
-     * @param precision precision context used to compare floating point values
-     * @exception IllegalArgumentException if lower is greater than upper
-     */
-    public ArcsSet(final double lower, final double upper, final DoublePrecisionContext precision) {
-        super(buildTree(lower, upper, precision), precision);
-    }
-
-    /** Build an arcs set from an inside/outside BSP tree.
-     * <p>The leaf nodes of the BSP tree <em>must</em> have a
-     * {@code Boolean} attribute representing the inside status of
-     * the corresponding cell (true for inside cells, false for outside
-     * cells). In order to avoid building too many small objects, it is
-     * recommended to use the predefined constants
-     * {@code Boolean.TRUE} and {@code Boolean.FALSE}</p>
-     * @param tree inside/outside BSP tree representing the arcs set
-     * @param precision precision context used to compare floating point values
-     * @exception IllegalArgumentException if the tree leaf nodes are not
-     * consistent across the \( 0, 2 \pi \) crossing
-     */
-    public ArcsSet(final BSPTree<S1Point> tree, final DoublePrecisionContext precision) {
-        super(tree, precision);
-        check2PiConsistency();
-    }
-
-    /** Build an arcs set from a Boundary REPresentation (B-rep).
-     * <p>The boundary is provided as a collection of {@link
-     * SubHyperplane sub-hyperplanes}. Each sub-hyperplane has the
-     * interior part of the region on its minus side and the exterior on
-     * its plus side.</p>
-     * <p>The boundary elements can be in any order, and can form
-     * several non-connected sets (like for example polygons with holes
-     * or a set of disjoints polyhedrons considered as a whole). In
-     * fact, the elements do not even need to be connected together
-     * (their topological connections are not used here). However, if the
-     * boundary does not really separate an inside open from an outside
-     * open (open having here its topological meaning), then subsequent
-     * calls to the {@link
-     * org.apache.commons.geometry.core.partitioning.Region#checkPoint(org.apache.commons.geometry.core.Point)
-     * checkPoint} method will not be meaningful anymore.</p>
-     * <p>If the boundary is empty, the region will represent the whole
-     * space.</p>
-     * @param boundary collection of boundary elements
-     * @param precision precision context used to compare floating point values
-     * @exception IllegalArgumentException if the tree leaf nodes are not
-     * consistent across the \( 0, 2 \pi \) crossing
-     */
-    public ArcsSet(final Collection<SubHyperplane<S1Point>> boundary, final DoublePrecisionContext precision) {
-        super(boundary, precision);
-        check2PiConsistency();
-    }
-
-    /** Build an inside/outside tree representing a single arc.
-     * @param lower lower angular bound of the arc
-     * @param upper upper angular bound of the arc
-     * @param precision precision context used to compare floating point values
-     * @return the built tree
-     * @exception IllegalArgumentException if lower is greater than upper
-     */
-    private static BSPTree<S1Point> buildTree(final double lower, final double upper,
-                                               final DoublePrecisionContext precision) {
-
-        if (Precision.equals(lower, upper, 0) || (upper - lower) >= Geometry.TWO_PI) {
-            // the tree must cover the whole circle
-            return new BSPTree<>(Boolean.TRUE);
-        } else  if (lower > upper) {
-            throw new IllegalArgumentException("Endpoints do not specify an interval: [" + lower + ", " +  upper + "]");
-        }
-
-        // this is a regular arc, covering only part of the circle
-        final double normalizedLower = PlaneAngleRadians.normalizeBetweenZeroAndTwoPi(lower);
-        final double normalizedUpper = normalizedLower + (upper - lower);
-        final SubHyperplane<S1Point> lowerCut =
-                new LimitAngle(S1Point.of(normalizedLower), false, precision).wholeHyperplane();
-
-        if (normalizedUpper <= Geometry.TWO_PI) {
-            // simple arc starting after 0 and ending before 2 \pi
-            final SubHyperplane<S1Point> upperCut =
-                    new LimitAngle(S1Point.of(normalizedUpper), true, precision).wholeHyperplane();
-            return new BSPTree<>(lowerCut,
-                                         new BSPTree<S1Point>(Boolean.FALSE),
-                                         new BSPTree<>(upperCut,
-                                                               new BSPTree<S1Point>(Boolean.FALSE),
-                                                               new BSPTree<S1Point>(Boolean.TRUE),
-                                                               null),
-                                         null);
-        } else {
-            // arc wrapping around 2 \pi
-            final SubHyperplane<S1Point> upperCut =
-                    new LimitAngle(S1Point.of(normalizedUpper - Geometry.TWO_PI), true, precision).wholeHyperplane();
-            return new BSPTree<>(lowerCut,
-                                         new BSPTree<>(upperCut,
-                                                               new BSPTree<S1Point>(Boolean.FALSE),
-                                                               new BSPTree<S1Point>(Boolean.TRUE),
-                                                               null),
-                                         new BSPTree<S1Point>(Boolean.TRUE),
-                                         null);
-        }
-
-    }
-
-    /** Check consistency.
-    * @exception IllegalArgumentException if the tree leaf nodes are not
-    * consistent across the \( 0, 2 \pi \) crossing
-    */
-    private void check2PiConsistency() {
-
-        // start search at the tree root
-        BSPTree<S1Point> root = getTree(false);
-        if (root.getCut() == null) {
-            return;
-        }
-
-        // find the inside/outside state before the smallest internal node
-        final Boolean stateBefore = (Boolean) getFirstLeaf(root).getAttribute();
-
-        // find the inside/outside state after the largest internal node
-        final Boolean stateAfter = (Boolean) getLastLeaf(root).getAttribute();
-
-        if (stateBefore ^ stateAfter) {
-            throw new IllegalArgumentException("Inconsistent state at 2\\u03c0 wrapping");
-        }
-
-    }
-
-    /** Get the first leaf node of a tree.
-     * @param root tree root
-     * @return first leaf node (i.e. node corresponding to the region just after 0.0 radians)
-     */
-    private BSPTree<S1Point> getFirstLeaf(final BSPTree<S1Point> root) {
-
-        if (root.getCut() == null) {
-            return root;
-        }
-
-        // find the smallest internal node
-        BSPTree<S1Point> smallest = null;
-        for (BSPTree<S1Point> n = root; n != null; n = previousInternalNode(n)) {
-            smallest = n;
-        }
-
-        return leafBefore(smallest);
-
-    }
-
-    /** Get the last leaf node of a tree.
-     * @param root tree root
-     * @return last leaf node (i.e. node corresponding to the region just before \( 2 \pi \) radians)
-     */
-    private BSPTree<S1Point> getLastLeaf(final BSPTree<S1Point> root) {
-
-        if (root.getCut() == null) {
-            return root;
-        }
-
-        // find the largest internal node
-        BSPTree<S1Point> largest = null;
-        for (BSPTree<S1Point> n = root; n != null; n = nextInternalNode(n)) {
-            largest = n;
-        }
-
-        return leafAfter(largest);
-
-    }
-
-    /** Get the node corresponding to the first arc start.
-     * @return smallest internal node (i.e. first after 0.0 radians, in trigonometric direction),
-     * or null if there are no internal nodes (i.e. the set is either empty or covers the full circle)
-     */
-    private BSPTree<S1Point> getFirstArcStart() {
-
-        // start search at the tree root
-        BSPTree<S1Point> node = getTree(false);
-        if (node.getCut() == null) {
-            return null;
-        }
-
-        // walk tree until we find the smallest internal node
-        node = getFirstLeaf(node).getParent();
-
-        // walk tree until we find an arc start
-        while (node != null && !isArcStart(node)) {
-            node = nextInternalNode(node);
-        }
-
-        return node;
-
-    }
-
-    /** Check if an internal node corresponds to the start angle of an arc.
-     * @param node internal node to check
-     * @return true if the node corresponds to the start angle of an arc
-     */
-    private boolean isArcStart(final BSPTree<S1Point> node) {
-
-        if ((Boolean) leafBefore(node).getAttribute()) {
-            // it has an inside cell before it, it may end an arc but not start it
-            return false;
-        }
-
-        if (!(Boolean) leafAfter(node).getAttribute()) {
-            // it has an outside cell after it, it is a dummy cut away from real arcs
-            return false;
-        }
-
-        // the cell has an outside before and an inside after it
-        // it is the start of an arc
-        return true;
-
-    }
-
-    /** Check if an internal node corresponds to the end angle of an arc.
-     * @param node internal node to check
-     * @return true if the node corresponds to the end angle of an arc
-     */
-    private boolean isArcEnd(final BSPTree<S1Point> node) {
-
-        if (!(Boolean) leafBefore(node).getAttribute()) {
-            // it has an outside cell before it, it may start an arc but not end it
-            return false;
-        }
-
-        if ((Boolean) leafAfter(node).getAttribute()) {
-            // it has an inside cell after it, it is a dummy cut in the middle of an arc
-            return false;
-        }
-
-        // the cell has an inside before and an outside after it
-        // it is the end of an arc
-        return true;
-
-    }
-
-    /** Get the next internal node.
-     * @param node current internal node
-     * @return next internal node in trigonometric order, or null
-     * if this is the last internal node
-     */
-    private BSPTree<S1Point> nextInternalNode(BSPTree<S1Point> node) {
-
-        if (childAfter(node).getCut() != null) {
-            // the next node is in the sub-tree
-            return leafAfter(node).getParent();
-        }
-
-        // there is nothing left deeper in the tree, we backtrack
-        while (isAfterParent(node)) {
-            node = node.getParent();
-        }
-        return node.getParent();
-
-    }
-
-    /** Get the previous internal node.
-     * @param node current internal node
-     * @return previous internal node in trigonometric order, or null
-     * if this is the first internal node
-     */
-    private BSPTree<S1Point> previousInternalNode(BSPTree<S1Point> node) {
-
-        if (childBefore(node).getCut() != null) {
-            // the next node is in the sub-tree
-            return leafBefore(node).getParent();
-        }
-
-        // there is nothing left deeper in the tree, we backtrack
-        while (isBeforeParent(node)) {
-            node = node.getParent();
-        }
-        return node.getParent();
-
-    }
-
-    /** Find the leaf node just before an internal node.
-     * @param node internal node at which the sub-tree starts
-     * @return leaf node just before the internal node
-     */
-    private BSPTree<S1Point> leafBefore(BSPTree<S1Point> node) {
-
-        node = childBefore(node);
-        while (node.getCut() != null) {
-            node = childAfter(node);
-        }
-
-        return node;
-
-    }
-
-    /** Find the leaf node just after an internal node.
-     * @param node internal node at which the sub-tree starts
-     * @return leaf node just after the internal node
-     */
-    private BSPTree<S1Point> leafAfter(BSPTree<S1Point> node) {
-
-        node = childAfter(node);
-        while (node.getCut() != null) {
-            node = childBefore(node);
-        }
-
-        return node;
-
-    }
-
-    /** Check if a node is the child before its parent in trigonometric order.
-     * @param node child node considered
-     * @return true is the node has a parent end is before it in trigonometric order
-     */
-    private boolean isBeforeParent(final BSPTree<S1Point> node) {
-        final BSPTree<S1Point> parent = node.getParent();
-        if (parent == null) {
-            return false;
-        } else {
-            return node == childBefore(parent);
-        }
-    }
-
-    /** Check if a node is the child after its parent in trigonometric order.
-     * @param node child node considered
-     * @return true is the node has a parent end is after it in trigonometric order
-     */
-    private boolean isAfterParent(final BSPTree<S1Point> node) {
-        final BSPTree<S1Point> parent = node.getParent();
-        if (parent == null) {
-            return false;
-        } else {
-            return node == childAfter(parent);
-        }
-    }
-
-    /** Find the child node just before an internal node.
-     * @param node internal node at which the sub-tree starts
-     * @return child node just before the internal node
-     */
-    private BSPTree<S1Point> childBefore(BSPTree<S1Point> node) {
-        if (isDirect(node)) {
-            // smaller angles are on minus side, larger angles are on plus side
-            return node.getMinus();
-        } else {
-            // smaller angles are on plus side, larger angles are on minus side
-            return node.getPlus();
-        }
-    }
-
-    /** Find the child node just after an internal node.
-     * @param node internal node at which the sub-tree starts
-     * @return child node just after the internal node
-     */
-    private BSPTree<S1Point> childAfter(BSPTree<S1Point> node) {
-        if (isDirect(node)) {
-            // smaller angles are on minus side, larger angles are on plus side
-            return node.getPlus();
-        } else {
-            // smaller angles are on plus side, larger angles are on minus side
-            return node.getMinus();
-        }
-    }
-
-    /** Check if an internal node has a direct limit angle.
-     * @param node internal node to check
-     * @return true if the limit angle is direct
-     */
-    private boolean isDirect(final BSPTree<S1Point> node) {
-        return ((LimitAngle) node.getCut().getHyperplane()).isDirect();
-    }
-
-    /** Get the limit angle of an internal node.
-     * @param node internal node to check
-     * @return limit angle
-     */
-    private double getAngle(final BSPTree<S1Point> node) {
-        return ((LimitAngle) node.getCut().getHyperplane()).getLocation().getAzimuth();
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public ArcsSet buildNew(final BSPTree<S1Point> tree) {
-        return new ArcsSet(tree, getPrecision());
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    protected void computeGeometricalProperties() {
-        if (getTree(false).getCut() == null) {
-            setBarycenter(S1Point.NaN);
-            setSize(((Boolean) getTree(false).getAttribute()) ? Geometry.TWO_PI : 0);
-        } else {
-            double size = 0.0;
-            double sum  = 0.0;
-            for (final double[] a : this) {
-                final double length = a[1] - a[0];
-                size += length;
-                sum  += length * (a[0] + a[1]);
-            }
-            setSize(size);
-            if (Precision.equals(size, Geometry.TWO_PI, 0)) {
-                setBarycenter(S1Point.NaN);
-            } else if (size >= Precision.SAFE_MIN) {
-                setBarycenter(S1Point.of(sum / (2 * size)));
-            } else {
-                final LimitAngle limit = (LimitAngle) getTree(false).getCut().getHyperplane();
-                setBarycenter(limit.getLocation());
-            }
-        }
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public BoundaryProjection<S1Point> projectToBoundary(final S1Point point) {
-
-        // get position of test point
-        final double alpha = point.getAzimuth();
-
-        boolean wrapFirst = false;
-        double first      = Double.NaN;
-        double previous   = Double.NaN;
-        for (final double[] a : this) {
-
-            if (Double.isNaN(first)) {
-                // remember the first angle in case we need it later
-                first = a[0];
-            }
-
-            if (!wrapFirst) {
-                if (alpha < a[0]) {
-                    // the test point lies between the previous and the current arcs
-                    // offset will be positive
-                    if (Double.isNaN(previous)) {
-                        // we need to wrap around the circle
-                        wrapFirst = true;
-                    } else {
-                        final double previousOffset = alpha - previous;
-                        final double currentOffset  = a[0] - alpha;
-                        if (previousOffset < currentOffset) {
-                            return new BoundaryProjection<>(point, S1Point.of(previous), previousOffset);
-                        } else {
-                            return new BoundaryProjection<>(point, S1Point.of(a[0]), currentOffset);
-                        }
-                    }
-                } else if (alpha <= a[1]) {
-                    // the test point lies within the current arc
-                    // offset will be negative
-                    final double offset0 = a[0] - alpha;
-                    final double offset1 = alpha - a[1];
-                    if (offset0 < offset1) {
-                        return new BoundaryProjection<>(point, S1Point.of(a[1]), offset1);
-                    } else {
-                        return new BoundaryProjection<>(point, S1Point.of(a[0]), offset0);
-                    }
-                }
-            }
-            previous = a[1];
-        }
-
-        if (Double.isNaN(previous)) {
-
-            // there are no points at all in the arcs set
-            return new BoundaryProjection<>(point, null, Geometry.TWO_PI);
-
-        } else {
-
-            // the test point if before first arc and after last arc,
-            // somewhere around the 0/2 \pi crossing
-            if (wrapFirst) {
-                // the test point is between 0 and first
-                final double previousOffset = alpha - (previous - Geometry.TWO_PI);
-                final double currentOffset  = first - alpha;
-                if (previousOffset < currentOffset) {
-                    return new BoundaryProjection<>(point, S1Point.of(previous), previousOffset);
-                } else {
-                    return new BoundaryProjection<>(point, S1Point.of(first), currentOffset);
-                }
-            } else {
-                // the test point is between last and 2\pi
-                final double previousOffset = alpha - previous;
-                final double currentOffset  = first + Geometry.TWO_PI - alpha;
-                if (previousOffset < currentOffset) {
-                    return new BoundaryProjection<>(point, S1Point.of(previous), previousOffset);
-                } else {
-                    return new BoundaryProjection<>(point, S1Point.of(first), currentOffset);
-                }
-            }
-
-        }
-
-    }
-
-    /** Build an ordered list of arcs representing the instance.
-     * <p>This method builds this arcs set as an ordered list of
-     * {@link Arc Arc} elements. An empty tree will build an empty list
-     * while a tree representing the whole circle will build a one
-     * element list with bounds set to \( 0 and 2 \pi \).</p>
-     * @return a new ordered list containing {@link Arc Arc} elements
-     */
-    public List<Arc> asList() {
-        final List<Arc> list = new ArrayList<>();
-        for (final double[] a : this) {
-            list.add(new Arc(a[0], a[1], getPrecision()));
-        }
-        return list;
-    }
-
-    /** {@inheritDoc}
-     * <p>
-     * The iterator returns the limit angles pairs of sub-arcs in trigonometric order.
-     * </p>
-     * <p>
-     * The iterator does <em>not</em> support the optional {@code remove} operation.
-     * </p>
-     */
-    @Override
-    public Iterator<double[]> iterator() {
-        return new SubArcsIterator();
-    }
-
-    /** Local iterator for sub-arcs. */
-    private class SubArcsIterator implements Iterator<double[]> {
-
-        /** Start of the first arc. */
-        private final BSPTree<S1Point> firstStart;
-
-        /** Current node. */
-        private BSPTree<S1Point> current;
-
-        /** Sub-arc no yet returned. */
-        private double[] pending;
-
-        /** Simple constructor.
-         */
-        SubArcsIterator() {
-
-            firstStart = getFirstArcStart();
-            current    = firstStart;
-
-            if (firstStart == null) {
-                // all the leaf tree nodes share the same inside/outside status
-                if ((Boolean) getFirstLeaf(getTree(false)).getAttribute()) {
-                    // it is an inside node, it represents the full circle
-                    pending = new double[] {
-                        0, Geometry.TWO_PI
-                    };
-                } else {
-                    pending = null;
-                }
-            } else {
-                selectPending();
-            }
-        }
-
-        /** Walk the tree to select the pending sub-arc.
-         */
-        private void selectPending() {
-
-            // look for the start of the arc
-            BSPTree<S1Point> start = current;
-            while (start != null && !isArcStart(start)) {
-                start = nextInternalNode(start);
-            }
-
-            if (start == null) {
-                // we have exhausted the iterator
-                current = null;
-                pending = null;
-                return;
-            }
-
-            // look for the end of the arc
-            BSPTree<S1Point> end = start;
-            while (end != null && !isArcEnd(end)) {
-                end = nextInternalNode(end);
-            }
-
-            if (end != null) {
-
-                // we have identified the arc
-                pending = new double[] {
-                    getAngle(start), getAngle(end)
-                };
-
-                // prepare search for next arc
-                current = end;
-
-            } else {
-
-                // the final arc wraps around 2\pi, its end is before the first start
-                end = firstStart;
-                while (end != null && !isArcEnd(end)) {
-                    end = previousInternalNode(end);
-                }
-                if (end == null) {
-                    // this should never happen
-                    throw new GeometryInternalError();
-                }
-
-                // we have identified the last arc
-                pending = new double[] {
-                    getAngle(start), getAngle(end) + Geometry.TWO_PI
-                };
-
-                // there won't be any other arcs
-                current = null;
-
-            }
-
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public boolean hasNext() {
-            return pending != null;
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public double[] next() {
-            if (pending == null) {
-                throw new NoSuchElementException();
-            }
-            final double[] next = pending;
-            selectPending();
-            return next;
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public void remove() {
-            throw new UnsupportedOperationException();
-        }
-
-    }
-
-    /** Compute the relative position of the instance with respect
-     * to an arc.
-     * <p>
-     * The {@link Side#MINUS} side of the arc is the one covered by the arc.
-     * </p>
-     * @param arc arc to check instance against
-     * @return one of {@link Side#PLUS}, {@link Side#MINUS}, {@link Side#BOTH}
-     * or {@link Side#HYPER}
-     * @deprecated as of 3.6, replaced with {@link #split(Arc)}.{@link Split#getSide()}
-     */
-    @Deprecated
-    public Side side(final Arc arc) {
-        return split(arc).getSide();
-    }
-
-    /** Split the instance in two parts by an arc.
-     * @param arc splitting arc
-     * @return an object containing both the part of the instance
-     * on the plus side of the arc and the part of the
-     * instance on the minus side of the arc
-     */
-    public Split split(final Arc arc) {
-
-        final List<Double> minus = new ArrayList<>();
-        final List<Double>  plus = new ArrayList<>();
-
-        final double reference = Geometry.PI + arc.getInf();
-        final double arcLength = arc.getSup() - arc.getInf();
-
-        for (final double[] a : this) {
-            final double syncedStart = PlaneAngleRadians.normalize(a[0], reference) - arc.getInf();
-            final double arcOffset   = a[0] - syncedStart;
-            final double syncedEnd   = a[1] - arcOffset;
-            if (syncedStart < arcLength) {
-                // the start point a[0] is in the minus part of the arc
-                minus.add(a[0]);
-                if (syncedEnd > arcLength) {
-                    // the end point a[1] is past the end of the arc
-                    // so we leave the minus part and enter the plus part
-                    final double minusToPlus = arcLength + arcOffset;
-                    minus.add(minusToPlus);
-                    plus.add(minusToPlus);
-                    if (syncedEnd > Geometry.TWO_PI) {
-                        // in fact the end point a[1] goes far enough that we
-                        // leave the plus part of the arc and enter the minus part again
-                        final double plusToMinus = Geometry.TWO_PI + arcOffset;
-                        plus.add(plusToMinus);
-                        minus.add(plusToMinus);
-                        minus.add(a[1]);
-                    } else {
-                        // the end point a[1] is in the plus part of the arc
-                        plus.add(a[1]);
-                    }
-                } else {
-                    // the end point a[1] is in the minus part of the arc
-                    minus.add(a[1]);
-                }
-            } else {
-                // the start point a[0] is in the plus part of the arc
-                plus.add(a[0]);
-                if (syncedEnd > Geometry.TWO_PI) {
-                    // the end point a[1] wraps around to the start of the arc
-                    // so we leave the plus part and enter the minus part
-                    final double plusToMinus = Geometry.TWO_PI + arcOffset;
-                    plus.add(plusToMinus);
-                    minus.add(plusToMinus);
-                    if (syncedEnd > Geometry.TWO_PI + arcLength) {
-                        // in fact the end point a[1] goes far enough that we
-                        // leave the minus part of the arc and enter the plus part again
-                        final double minusToPlus = Geometry.TWO_PI + arcLength + arcOffset;
-                        minus.add(minusToPlus);
-                        plus.add(minusToPlus);
-                        plus.add(a[1]);
-                    } else {
-                        // the end point a[1] is in the minus part of the arc
-                        minus.add(a[1]);
-                    }
-                } else {
-                    // the end point a[1] is in the plus part of the arc
-                    plus.add(a[1]);
-                }
-            }
-        }
-
-        return new Split(createSplitPart(plus), createSplitPart(minus));
-
-    }
-
-    /** Add an arc limit to a BSP tree under construction.
-     * @param tree BSP tree under construction
-     * @param alpha arc limit
-     * @param isStart if true, the limit is the start of an arc
-     */
-    private void addArcLimit(final BSPTree<S1Point> tree, final double alpha, final boolean isStart) {
-
-        final LimitAngle limit = new LimitAngle(S1Point.of(alpha), !isStart, getPrecision());
-        final BSPTree<S1Point> node = tree.getCell(limit.getLocation(), getPrecision());
-        if (node.getCut() != null) {
-            // this should never happen
-            throw new GeometryInternalError();
-        }
-
-        node.insertCut(limit);
-        node.setAttribute(null);
-        node.getPlus().setAttribute(Boolean.FALSE);
-        node.getMinus().setAttribute(Boolean.TRUE);
-
-    }
-
-    /** Create a split part.
-     * <p>
-     * As per construction, the list of limit angles is known to have
-     * an even number of entries, with start angles at even indices and
-     * end angles at odd indices.
-     * </p>
-     * @param limits limit angles of the split part
-     * @return split part (may be null)
-     */
-    private ArcsSet createSplitPart(final List<Double> limits) {
-        if (limits.isEmpty()) {
-            return null;
-        } else {
-
-            // collapse close limit angles
-            for (int i = 0; i < limits.size(); ++i) {
-                final int    j  = (i + 1) % limits.size();
-                final double lA = limits.get(i);
-                final double lB = PlaneAngleRadians.normalize(limits.get(j), lA);
-                if (getPrecision().eq(lB, lA)) {
-                    // the two limits are too close to each other, we remove both of them
-                    if (j > 0) {
-                        // regular case, the two entries are consecutive ones
-                        limits.remove(j);
-                        limits.remove(i);
-                        i = i - 1;
-                    } else {
-                        // special case, i the the last entry and j is the first entry
-                        // we have wrapped around list end
-                        final double lEnd   = limits.remove(limits.size() - 1);
-                        final double lStart = limits.remove(0);
-                        if (limits.isEmpty()) {
-                            // the ends were the only limits, is it a full circle or an empty circle?
-                            if (lEnd - lStart > Geometry.PI) {
-                                // it was full circle
-                                return new ArcsSet(new BSPTree<S1Point>(Boolean.TRUE), getPrecision());
-                            } else {
-                                // it was an empty circle
-                                return null;
-                            }
-                        } else {
-                            // we have removed the first interval start, so our list
-                            // currently starts with an interval end, which is wrong
-                            // we need to move this interval end to the end of the list
-                            limits.add(limits.remove(0) + Geometry.TWO_PI);
-                        }
-                    }
-                }
-            }
-
-            // build the tree by adding all angular sectors
-            BSPTree<S1Point> tree = new BSPTree<>(Boolean.FALSE);
-            for (int i = 0; i < limits.size() - 1; i += 2) {
-                addArcLimit(tree, limits.get(i),     true);
-                addArcLimit(tree, limits.get(i + 1), false);
-            }
-
-            if (tree.getCut() == null) {
-                // we did not insert anything
-                return null;
-            }
-
-            return new ArcsSet(tree, getPrecision());
-
-        }
-    }
-
-    /** Class holding the results of the {@link #split split} method.
-     */
-    public static class Split {
-
-        /** Part of the arcs set on the plus side of the splitting arc. */
-        private final ArcsSet plus;
-
-        /** Part of the arcs set on the minus side of the splitting arc. */
-        private final ArcsSet minus;
-
-        /** Build a Split from its parts.
-         * @param plus part of the arcs set on the plus side of the
-         * splitting arc
-         * @param minus part of the arcs set on the minus side of the
-         * splitting arc
-         */
-        private Split(final ArcsSet plus, final ArcsSet minus) {
-            this.plus  = plus;
-            this.minus = minus;
-        }
-
-        /** Get the part of the arcs set on the plus side of the splitting arc.
-         * @return part of the arcs set on the plus side of the splitting arc
-         */
-        public ArcsSet getPlus() {
-            return plus;
-        }
-
-        /** Get the part of the arcs set on the minus side of the splitting arc.
-         * @return part of the arcs set on the minus side of the splitting arc
-         */
-        public ArcsSet getMinus() {
-            return minus;
-        }
-
-        /** Get the side of the split arc with respect to its splitter.
-         * @return {@link Side#PLUS} if only {@link #getPlus()} returns non-null,
-         * {@link Side#MINUS} if only {@link #getMinus()} returns non-null,
-         * {@link Side#BOTH} if both {@link #getPlus()} and {@link #getMinus()}
-         * return non-null or {@link Side#HYPER} if both {@link #getPlus()} and
-         * {@link #getMinus()} return null
-         */
-        public Side getSide() {
-            if (plus != null) {
-                if (minus != null) {
-                    return Side.BOTH;
-                } else {
-                    return Side.PLUS;
-                }
-            } else if (minus != null) {
-                return Side.MINUS;
-            } else {
-                return Side.HYPER;
-            }
-        }
-    }
-}
diff --git a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/oned/CutAngle.java b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/oned/CutAngle.java
new file mode 100644
index 0000000..40b538e
--- /dev/null
+++ b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/oned/CutAngle.java
@@ -0,0 +1,518 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.oned;
+
+import java.io.Serializable;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.exception.GeometryException;
+import org.apache.commons.geometry.core.internal.Equivalency;
+import org.apache.commons.geometry.core.partitioning.AbstractHyperplane;
+import org.apache.commons.geometry.core.partitioning.ConvexSubHyperplane;
+import org.apache.commons.geometry.core.partitioning.Hyperplane;
+import org.apache.commons.geometry.core.partitioning.HyperplaneLocation;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.SubHyperplane;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+
+/** Class representing an oriented point in 1-dimensional spherical space,
+ * meaning an azimuth angle and a direction (increasing or decreasing angles)
+ * along the circle.
+ *
+ * <p>Hyperplanes split the spaces they are embedded in into three distinct parts:
+ * the hyperplane itself, a plus side and a minus side. However, since spherical
+ * space wraps around, a single oriented point is not sufficient to partition the space;
+ * any point could be classified as being on the plus or minus side of a hyperplane
+ * depending on the direction that the circle is traversed. The approach taken in this
+ * class to address this issue is to (1) define a second, implicit cut point at {@code 0pi} and
+ * (2) define the domain of hyperplane points (for partitioning purposes) to be the
+ * range {@code [0, 2pi)}. Each hyperplane then splits the space into the intervals
+ * {@code [0, x]} and {@code [x, 2pi)}, where {@code x} is the location of the hyperplane.
+ * One way to visualize this is to picture the circle as a cake that has already been
+ * cut at {@code 0pi}. Each hyperplane then specifies the location of the second
+ * cut of the cake, with the plus and minus sides being the pieces thus cut.
+ * </p>
+ *
+ * <p>Note that with the hyperplane partitioning rules described above, the hyperplane
+ * at {@code 0pi} is unique in that it has the entire space on one side (minus the hyperplane
+ * itself) and no points whatsoever on the other. This is very different from hyperplanes in
+ * Euclidean space, which always have infinitely many points on both sides.</p>
+ *
+ * <p>Instances of this class are guaranteed to be immutable.</p>
+ */
+public final class CutAngle extends AbstractHyperplane<Point1S>
+    implements Equivalency<CutAngle>, Serializable {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20190817L;
+
+    /** Hyperplane location as a point. */
+    private final Point1S point;
+
+    /** Hyperplane direction. */
+    private final boolean positiveFacing;
+
+    /** Simple constructor.
+     * @param point location of the hyperplane
+     * @param positiveFacing if true, the hyperplane will point in a positive angular
+     *      direction; otherwise, it will point in a negative direction
+     * @param precision precision context used to compare floating point values
+     */
+    private CutAngle(final Point1S point, final boolean positiveFacing,
+            final DoublePrecisionContext precision) {
+        super(precision);
+
+        this.point = point;
+        this.positiveFacing = positiveFacing;
+    }
+
+    /** Get the location of the hyperplane as a point.
+     * @return the hyperplane location as a point
+     * @see #getAzimuth()
+     */
+    public Point1S getPoint() {
+        return point;
+    }
+
+    /** Get the location of the hyperplane as a single value. This is
+     * equivalent to {@code cutAngle.getPoint().getAzimuth()}.
+     * @return the location of the hyperplane as a single value.
+     * @see #getPoint()
+     * @see Point1S#getAzimuth()
+     */
+    public double getAzimuth() {
+        return point.getAzimuth();
+    }
+
+    /** Get the location of the hyperplane as a single value, normalized
+     * to the range {@code [0, 2pi)}. This is equivalent to
+     * {@code cutAngle.getPoint().getNormalizedAzimuth()}.
+     * @return the location of the hyperplane, normalized to the range
+     *      {@code [0, 2pi)}
+     * @see #getPoint()
+     * @see Point1S#getNormalizedAzimuth()
+     */
+    public double getNormalizedAzimuth() {
+        return point.getNormalizedAzimuth();
+    }
+
+    /** Return true if the hyperplane is oriented with its plus
+     * side pointing toward increasing angles.
+     * @return true if the hyperplane is facing in the direction
+     *      of increasing angles
+     */
+    public boolean isPositiveFacing() {
+        return positiveFacing;
+    }
+
+    /** {@inheritDoc}
+     *
+     * <p>The instances are considered equivalent if they
+     * <ol>
+     *    <li>have equal precision contexts,</li>
+     *    <li>have equivalent point locations as evaluated by the precision
+     *          context (points separated by multiples of 2pi are considered equivalent), and
+     *    <li>point in the same direction.</li>
+     * </ol>
+     * @see Point1S#eq(Point1S, DoublePrecisionContext)
+     */
+    @Override
+    public boolean eq(final CutAngle other) {
+        if (this == other) {
+            return true;
+        }
+
+        final DoublePrecisionContext precision = getPrecision();
+
+        return precision.equals(other.getPrecision()) &&
+                point.eq(other.point, precision) &&
+                positiveFacing == other.positiveFacing;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public double offset(final Point1S pt) {
+        final double dist = pt.getNormalizedAzimuth() - this.point.getNormalizedAzimuth();
+        return positiveFacing ? +dist : -dist;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public HyperplaneLocation classify(final Point1S pt) {
+        final DoublePrecisionContext precision = getPrecision();
+
+        final Point1S compPt = Point1S.ZERO.eq(pt, precision) ?
+                Point1S.ZERO :
+                pt;
+
+        final double offsetValue = offset(compPt);
+        final int cmp = precision.sign(offsetValue);
+
+        if (cmp > 0) {
+            return HyperplaneLocation.PLUS;
+        } else if (cmp < 0) {
+            return HyperplaneLocation.MINUS;
+        }
+
+        return HyperplaneLocation.ON;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Point1S project(final Point1S pt) {
+        return this.point;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public CutAngle reverse() {
+        return new CutAngle(point, !positiveFacing, getPrecision());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public CutAngle transform(final Transform<Point1S> transform) {
+        final Point1S tPoint = transform.apply(point);
+        final boolean tPositiveFacing = transform.preservesOrientation() == positiveFacing;
+
+        return CutAngle.fromPointAndDirection(tPoint, tPositiveFacing, getPrecision());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean similarOrientation(final Hyperplane<Point1S> other) {
+        return positiveFacing == ((CutAngle) other).positiveFacing;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public SubCutAngle span() {
+        return new SubCutAngle(this);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public int hashCode() {
+        return Objects.hash(point, positiveFacing, getPrecision());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean equals(final Object obj) {
+        if (this == obj) {
+            return true;
+        } else if (!(obj instanceof CutAngle)) {
+            return false;
+        }
+
+        final CutAngle other = (CutAngle) obj;
+        return Objects.equals(getPrecision(), other.getPrecision()) &&
+                Objects.equals(point, other.point) &&
+                positiveFacing == other.positiveFacing;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String toString() {
+        final StringBuilder sb = new StringBuilder();
+        sb.append(this.getClass().getSimpleName())
+            .append("[point= ")
+            .append(point)
+            .append(", positiveFacing= ")
+            .append(isPositiveFacing())
+            .append(']');
+
+        return sb.toString();
+    }
+
+    /** Create a new instance from the given azimuth and direction.
+     * @param azimuth azimuth value in radians
+     * @param positiveFacing if true, the instance's plus side will be oriented to point toward increasing
+     *      angular values; if false, it will point toward decreasing angular value
+     * @param precision precision context used to determine floating point equality
+     * @return a new instance
+     */
+    public static CutAngle fromAzimuthAndDirection(final double azimuth, final boolean positiveFacing,
+            final DoublePrecisionContext precision) {
+        return fromPointAndDirection(Point1S.of(azimuth), positiveFacing, precision);
+    }
+
+    /** Create a new instance from the given point and direction.
+     * @param point point representing the location of the hyperplane
+     * @param positiveFacing if true, the instance's plus side will be oriented to point toward increasing
+     *      angular values; if false, it will point toward decreasing angular value
+     * @param precision precision context used to determine floating point equality
+     * @return a new instance
+     */
+    public static CutAngle fromPointAndDirection(final Point1S point, final boolean positiveFacing,
+            final DoublePrecisionContext precision) {
+        return new CutAngle(point, positiveFacing, precision);
+    }
+
+    /** Create a new instance at the given azimuth, oriented so that the plus side of the hyperplane points
+     * toward increasing angular values.
+     * @param azimuth azimuth value in radians
+     * @param precision precision precision context used to determine floating point equality
+     * @return a new instance
+     */
+    public static CutAngle createPositiveFacing(final double azimuth, final DoublePrecisionContext precision) {
+        return createPositiveFacing(Point1S.of(azimuth), precision);
+    }
+
+    /** Create a new instance at the given point, oriented so that the plus side of the hyperplane points
+     * toward increasing angular values.
+     * @param point point representing the location of the hyperplane
+     * @param precision precision precision context used to determine floating point equality
+     * @return a new instance
+     */
+    public static CutAngle createPositiveFacing(final Point1S point, final DoublePrecisionContext precision) {
+        return fromPointAndDirection(point, true, precision);
+    }
+
+    /** Create a new instance at the given azimuth, oriented so that the plus side of the hyperplane points
+     * toward decreasing angular values.
+     * @param azimuth azimuth value in radians
+     * @param precision precision precision context used to determine floating point equality
+     * @return a new instance
+     */
+    public static CutAngle createNegativeFacing(final double azimuth, final DoublePrecisionContext precision) {
+        return createNegativeFacing(Point1S.of(azimuth), precision);
+    }
+
+    /** Create a new instance at the given point, oriented so that the plus side of the hyperplane points
+     * toward decreasing angular values.
+     * @param point point representing the location of the hyperplane
+     * @param precision precision precision context used to determine floating point equality
+     * @return a new instance
+     */
+    public static CutAngle createNegativeFacing(final Point1S point, final DoublePrecisionContext precision) {
+        return fromPointAndDirection(point, false, precision);
+    }
+
+    /** {@link ConvexSubHyperplane} implementation for spherical 1D space. Since there are no subspaces in 1D,
+     * this is effectively a stub implementation, its main use being to allow for the correct functioning of
+     * partitioning code.
+     */
+    public static class SubCutAngle implements ConvexSubHyperplane<Point1S>, Serializable {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20190825L;
+
+        /** The underlying hyperplane for this instance. */
+        private final CutAngle hyperplane;
+
+        /** Simple constructor.
+         * @param hyperplane underlying hyperplane instance
+         */
+        public SubCutAngle(final CutAngle hyperplane) {
+            this.hyperplane = hyperplane;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public CutAngle getHyperplane() {
+            return hyperplane;
+        }
+
+        /** {@inheritDoc}
+        *
+        * <p>This method simply returns false.</p>
+        */
+        @Override
+        public boolean isFull() {
+            return false;
+        }
+
+        /** {@inheritDoc}
+        *
+        * <p>This method simply returns false.</p>
+        */
+        @Override
+        public boolean isEmpty() {
+            return false;
+        }
+
+        /** {@inheritDoc}
+         *
+         * <p>This method simply returns false.</p>
+         */
+        @Override
+        public boolean isInfinite() {
+            return false;
+        }
+
+        /** {@inheritDoc}
+        *
+        * <p>This method simply returns true.</p>
+        */
+        @Override
+        public boolean isFinite() {
+            return true;
+        }
+
+        /** {@inheritDoc}
+         *
+         *  <p>This method simply returns {@code 0}.</p>
+         */
+        @Override
+        public double getSize() {
+            return 0;
+        }
+
+        /** {@inheritDoc}
+         *
+         * <p>This method returns {@link RegionLocation#BOUNDARY} if the
+         * point is on the hyperplane and {@link RegionLocation#OUTSIDE}
+         * otherwise.</p>
+         */
+        @Override
+        public RegionLocation classify(Point1S point) {
+            if (hyperplane.contains(point)) {
+                return RegionLocation.BOUNDARY;
+            }
+
+            return RegionLocation.OUTSIDE;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public Point1S closest(Point1S point) {
+            return hyperplane.project(point);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public Split<SubCutAngle> split(final Hyperplane<Point1S> splitter) {
+            final HyperplaneLocation side = splitter.classify(hyperplane.getPoint());
+
+            SubCutAngle minus = null;
+            SubCutAngle plus = null;
+
+            if (side == HyperplaneLocation.MINUS) {
+                minus = this;
+            } else if (side == HyperplaneLocation.PLUS) {
+                plus = this;
+            }
+
+            return new Split<>(minus, plus);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public List<SubCutAngle> toConvex() {
+            return Arrays.asList(this);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public SubCutAngle transform(final Transform<Point1S> transform) {
+            return getHyperplane().transform(transform).span();
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public SubCutAngleBuilder builder() {
+            return new SubCutAngleBuilder(this);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public SubCutAngle reverse() {
+            return new SubCutAngle(hyperplane.reverse());
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public String toString() {
+            final StringBuilder sb = new StringBuilder();
+            sb.append(this.getClass().getSimpleName())
+                .append("[hyperplane= ")
+                .append(hyperplane)
+                .append(']');
+
+            return sb.toString();
+        }
+    }
+
+    /** {@link SubHyperplane.Builder} implementation for spherical 1D space. This is effectively
+     * a stub implementation since there are no subspaces of 1D space. Its primary use is to allow
+     * for the correct functioning of partitioning code.
+     */
+    public static final class SubCutAngleBuilder implements SubHyperplane.Builder<Point1S>, Serializable {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20190825L;
+
+        /** Base subhyperplane for the builder. */
+        private final SubCutAngle base;
+
+        /** Construct a new instance using the given base subhyperplane.
+         * @param base base subhyperplane for the instance
+         */
+        private SubCutAngleBuilder(final SubCutAngle base) {
+            this.base = base;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void add(final SubHyperplane<Point1S> sub) {
+            validateHyperplane(sub);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void add(final ConvexSubHyperplane<Point1S> sub) {
+            validateHyperplane(sub);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public SubCutAngle build() {
+            return base;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public String toString() {
+            final StringBuilder sb = new StringBuilder();
+            sb.append(this.getClass().getSimpleName())
+                .append("[base= ")
+                .append(base)
+                .append(']');
+
+            return sb.toString();
+        }
+
+        /** Validate the given subhyperplane lies on the same hyperplane.
+         * @param sub subhyperplane to validate
+         */
+        private void validateHyperplane(final SubHyperplane<Point1S> sub) {
+            final CutAngle baseHyper = base.getHyperplane();
+            final CutAngle inputHyper = (CutAngle) sub.getHyperplane();
+
+            if (!baseHyper.eq(inputHyper)) {
+                throw new GeometryException("Argument is not on the same " +
+                        "hyperplane. Expected " + baseHyper + " but was " +
+                        inputHyper);
+            }
+        }
+    }
+}
diff --git a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/oned/LimitAngle.java b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/oned/LimitAngle.java
deleted file mode 100644
index 554c483..0000000
--- a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/oned/LimitAngle.java
+++ /dev/null
@@ -1,133 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.oned;
-
-import org.apache.commons.geometry.core.partitioning.Hyperplane;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-
-/** This class represents a 1D oriented hyperplane on the circle.
- * <p>An hyperplane on the 1-sphere is an angle with an orientation.</p>
- * <p>Instances of this class are guaranteed to be immutable.</p>
- */
-public class LimitAngle implements Hyperplane<S1Point> {
-
-    /** Angle location. */
-    private final S1Point location;
-
-    /** Orientation. */
-    private final boolean direct;
-
-    /** Precision context used to compare floating point numbers. */
-    private final DoublePrecisionContext precision;
-
-    /** Simple constructor.
-     * @param location location of the hyperplane
-     * @param direct if true, the plus side of the hyperplane is towards
-     * angles greater than {@code location}
-     * @param precision precision context used to compare floating point values
-     */
-    public LimitAngle(final S1Point location, final boolean direct, final DoublePrecisionContext precision) {
-        this.location  = location;
-        this.direct    = direct;
-        this.precision = precision;
-    }
-
-    /** Copy the instance.
-     * <p>Since instances are immutable, this method directly returns
-     * the instance.</p>
-     * @return the instance itself
-     */
-    @Override
-    public LimitAngle copySelf() {
-        return this;
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public double getOffset(final S1Point point) {
-        final double delta = point.getAzimuth() - location.getAzimuth();
-        return direct ? delta : -delta;
-    }
-
-    /** Check if the hyperplane orientation is direct.
-     * @return true if the plus side of the hyperplane is towards
-     * angles greater than hyperplane location
-     */
-    public boolean isDirect() {
-        return direct;
-    }
-
-    /** Get the reverse of the instance.
-     * <p>Get a limit angle with reversed orientation with respect to the
-     * instance. A new object is built, the instance is untouched.</p>
-     * @return a new limit angle, with orientation opposite to the instance orientation
-     */
-    public LimitAngle getReverse() {
-        return new LimitAngle(location, !direct, precision);
-    }
-
-    /** Build a region covering the whole hyperplane.
-     * <p>Since this class represent zero dimension spaces which does
-     * not have lower dimension sub-spaces, this method returns a dummy
-     * implementation of a {@link
-     * org.apache.commons.geometry.core.partitioning.SubHyperplane SubHyperplane}.
-     * This implementation is only used to allow the {@link
-     * org.apache.commons.geometry.core.partitioning.SubHyperplane
-     * SubHyperplane} class implementation to work properly, it should
-     * <em>not</em> be used otherwise.</p>
-     * @return a dummy sub hyperplane
-     */
-    @Override
-    public SubLimitAngle wholeHyperplane() {
-        return new SubLimitAngle(this, null);
-    }
-
-    /** Build a region covering the whole space.
-     * @return a region containing the instance (really an {@link
-     * ArcsSet IntervalsSet} instance)
-     */
-    @Override
-    public ArcsSet wholeSpace() {
-        return new ArcsSet(precision);
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public boolean sameOrientationAs(final Hyperplane<S1Point> other) {
-        return !(direct ^ ((LimitAngle) other).direct);
-    }
-
-    /** Get the hyperplane location on the circle.
-     * @return the hyperplane location
-     */
-    public S1Point getLocation() {
-        return location;
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public S1Point project(S1Point point) {
-        return location;
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public DoublePrecisionContext getPrecision() {
-        return precision;
-    }
-
-}
diff --git a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/oned/Point1S.java b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/oned/Point1S.java
new file mode 100644
index 0000000..4af43fa
--- /dev/null
+++ b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/oned/Point1S.java
@@ -0,0 +1,393 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.oned;
+
+import java.io.Serializable;
+import java.util.Comparator;
+import java.util.Objects;
+
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.Point;
+import org.apache.commons.geometry.core.exception.GeometryValueException;
+import org.apache.commons.geometry.core.internal.SimpleTupleFormat;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.twod.PolarCoordinates;
+import org.apache.commons.geometry.euclidean.twod.Vector2D;
+import org.apache.commons.numbers.angle.PlaneAngle;
+import org.apache.commons.numbers.angle.PlaneAngleRadians;
+
+/** This class represents a point on the 1-sphere, or in other words, an
+ * azimuth angle on a circle. The value of the azimuth angle is not normalized
+ * by default, meaning that instances can be constructed representing negative
+ * values or values greater than {@code 2pi}. However, instances separated by a
+ * multiple of {@code 2pi} are considered equivalent for most methods, with the
+ * exceptions being {@link #equals(Object)} and {@link #hashCode()}, where the
+ * azimuth values must match exactly in order for instances to be considered
+ * equal.
+ *
+ * <p>Instances of this class are guaranteed to be immutable.</p>
+ */
+public final class Point1S implements Point<Point1S>, Serializable {
+
+    /** A point with coordinates set to {@code 0*pi}. */
+    public static final Point1S ZERO = Point1S.of(Geometry.ZERO_PI);
+
+    /** A point with coordinates set to {@code pi}. */
+    public static final Point1S PI = Point1S.of(Geometry.PI);
+
+    // CHECKSTYLE: stop ConstantName
+    /** A point with all coordinates set to NaN. */
+    public static final Point1S NaN = Point1S.of(Double.NaN);
+    // CHECKSTYLE: resume ConstantName
+
+    /** Comparator that sorts points by normalized azimuth in ascending order.
+     * Points are only considered equal if their normalized azimuths match exactly.
+     * Null arguments are evaluated as being greater than non-null arguments.
+     * @see #getNormalizedAzimuth()
+     */
+    public static final Comparator<Point1S> NORMALIZED_AZIMUTH_ASCENDING_ORDER = (a, b) -> {
+        int cmp = 0;
+
+        if (a != null && b != null) {
+            cmp = Double.compare(a.getNormalizedAzimuth(), b.getNormalizedAzimuth());
+        } else if (a != null) {
+            cmp = -1;
+        } else if (b != null) {
+            cmp = 1;
+        }
+
+        return cmp;
+    };
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20180710L;
+
+    /** Azimuthal angle in radians. */
+    private final double azimuth;
+
+    /** Normalized azimuth value in the range {@code [0, 2pi)}. */
+    private final double normalizedAzimuth;
+
+    /** Build a point from its internal components.
+     * @param azimuth azimuth angle
+     * @param normalizedAzimuth azimuth angle normalized to the range {@code [0, 2pi)}
+     */
+    private Point1S(final double azimuth, final double normalizedAzimuth) {
+        this.azimuth  = azimuth;
+        this.normalizedAzimuth = normalizedAzimuth;
+    }
+
+    /** Get the azimuth angle in radians. This value is not normalized and
+     * can be any floating point number.
+     * @return azimuth angle
+     * @see Point1S#of(double)
+     */
+    public double getAzimuth() {
+        return azimuth;
+    }
+
+    /** Get the azimuth angle normalized to the range {@code [0, 2pi)}.
+     * @return the azimuth angle normalized to the range {@code [0, 2pi)}.
+     */
+    public double getNormalizedAzimuth() {
+        return normalizedAzimuth;
+    }
+
+    /** Get the normalized vector corresponding to this azimuth angle in 2D Euclidean space.
+     * @return normalized vector
+     */
+    public Vector2D getVector() {
+        if (isFinite()) {
+            return PolarCoordinates.toCartesian(1, azimuth);
+        }
+
+        return null;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public int getDimension() {
+        return 1;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isNaN() {
+        return Double.isNaN(azimuth);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isInfinite() {
+        return !isNaN() && Double.isInfinite(azimuth);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isFinite() {
+        return Double.isFinite(azimuth);
+    }
+
+    /** {@inheritDoc}
+     *
+     * <p>The returned value is the shortest angular distance between
+     * the two points, in the range {@code [0, pi]}.</p>
+     */
+    @Override
+    public double distance(final Point1S point) {
+        return distance(this, point);
+    }
+
+    /** Return the signed distance (angular separation) between this instance and the
+     * given point in the range {@code [-pi, pi)}. If {@code p1} is the current instance,
+     * {@code p2} the given point, and {@code d} the signed distance, then
+     * {@code p1.getAzimuth() + d} is an angle equivalent to {@code p2.getAzimuth()}.
+     * @param point point to compute the signed distance to
+     * @return the signed distance between this instance and the given point in the range
+     *      {@code [-pi, pi)}
+     */
+    public double signedDistance(final Point1S point) {
+        return signedDistance(this, point);
+    }
+
+    /** Return an equivalent point with an azimuth value at or above the given base.
+     * The returned point has an azimuth value in the range {@code [base, base + 2pi)}.
+     * @param base point to place this instance's azimuth value above
+     * @return a point equivalent to the current instance but with an azimuth
+     *      value in the range {@code [base, base + 2pi)}
+     * @throws GeometryValueException if the azimuth value is NaN or infinite and
+     *      cannot be normalized
+     */
+    public Point1S above(final Point1S base) {
+        return normalize(base.getAzimuth() + Geometry.PI);
+    }
+
+    /** Return an equivalent point with an azimuth value strictly below the given base.
+     * The returned point has an azimuth value in the range {@code [base - 2pi, base)}.
+     * @param base point to place this instance's azimuth value below
+     * @return a point equivalent to the current instance but with an azimuth
+     *      value in the range {@code [base - 2pi, base)}
+     * @throws GeometryValueException if the azimuth value is NaN or infinite and
+     *      cannot be normalized
+     */
+    public Point1S below(final Point1S base) {
+        return normalize(base.getAzimuth() - Geometry.PI);
+    }
+
+    /** Normalize this point around the given center point. The azimuth value of
+     * the returned point is in the range {@code [center - pi, center + pi)}.
+     * @param center point to center this instance around
+     * @return a point equivalent to this instance but with an azimuth value
+     *      in the range {@code [center - pi, center + pi)}.
+     * @throws GeometryValueException if the azimuth value is NaN or infinite and
+     *      cannot be normalized
+     */
+    public Point1S normalize(final Point1S center) {
+        return normalize(center.getAzimuth());
+    }
+
+    /** Return an equivalent point with an azimuth value normalized around the given center
+     * angle. The azimuth value of the returned point is in the range
+     * {@code [center - pi, center + pi)}.
+     * @param center angle to center this instance around
+     * @return a point equivalent to this instance but with an azimuth value
+     *      in the range {@code [center - pi, center + pi)}.
+     * @throws GeometryValueException if the azimuth value is NaN or infinite and
+     *      cannot be normalized
+     */
+    public Point1S normalize(final double center) {
+        if (isFinite()) {
+            final double az = PlaneAngleRadians.normalize(azimuth, center);
+            return new Point1S(az, normalizedAzimuth);
+        }
+        throw new GeometryValueException("Cannot normalize azimuth value: " + azimuth);
+    }
+
+    /** Get the point exactly opposite this point on the circle, {@code pi} distance away.
+     * The azimuth of the antipodal point is in the range {@code [0, 2pi)}.
+     * @return the point exactly opposite this point on the circle
+     */
+    public Point1S antipodal() {
+        double az = normalizedAzimuth + Geometry.PI;
+        if (az >= Geometry.TWO_PI) {
+            az -= Geometry.TWO_PI;
+        }
+
+        return Point1S.of(az);
+    }
+
+    /** Return true if this instance is equivalent to the argument. The points are
+     * considered equivalent if the shortest angular distance between them is equal to
+     * zero as evaluated by the given precision context. This means that points that differ
+     * in azimuth by multiples of {@code 2pi} are considered equivalent.
+     * @param other point to compare with
+     * @param precision precision context used for floating point comparisons
+     * @return true if this instance is equivalent to the argument
+     */
+    public boolean eq(final Point1S other, final DoublePrecisionContext precision) {
+        final double dist = signedDistance(other);
+        return precision.eqZero(dist);
+    }
+
+    /**
+     * Get a hashCode for the point. Points normally must have exactly the
+     * same azimuth angles in order to have the same hash code. Points
+     * will angles that differ by multiples of {@code 2pi} will not
+     * necessarily have the same hash code.
+     *
+     * <p>All NaN values have the same hash code.</p>
+     *
+     * @return a hash code value for this object
+     */
+    @Override
+    public int hashCode() {
+        if (isNaN()) {
+            return 542;
+        }
+        return 1759 * Objects.hash(azimuth, normalizedAzimuth);
+    }
+
+    /** Test for the exact equality of two points on the 1-sphere.
+     *
+     * <p>If all coordinates of the given points are exactly the same, and none are
+     * <code>Double.NaN</code>, the points are considered to be equal. Points with
+     * azimuth values separated by multiples of {@code 2pi} are <em>not</em> considered
+     * equal.</p>
+     *
+     * <p><code>NaN</code> coordinates are considered to affect globally the vector
+     * and be equals to each other - i.e, if either (or all) coordinates of the
+     * point are equal to <code>Double.NaN</code>, the point is equal to
+     * {@link #NaN}.</p>
+     *
+     * @param other Object to test for equality to this
+     * @return true if two points on the 1-sphere objects are exactly equal, false if
+     *         object is null, not an instance of Point1S, or
+     *         not equal to this Point1S instance
+     *
+     */
+    @Override
+    public boolean equals(final Object other) {
+        if (this == other) {
+            return true;
+        }
+
+        if (other instanceof Point1S) {
+            final Point1S rhs = (Point1S) other;
+
+            if (rhs.isNaN()) {
+                return this.isNaN();
+            }
+
+            return Double.compare(azimuth, rhs.azimuth) == 0 &&
+                    Double.compare(normalizedAzimuth, rhs.normalizedAzimuth) == 0;
+        }
+
+        return false;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String toString() {
+        return SimpleTupleFormat.getDefault().format(getAzimuth());
+    }
+
+    /** Create a new point instance from the given azimuth angle.
+     * @param azimuth azimuth angle in radians
+     * @return point instance with the given azimuth angle
+     * @see #getAzimuth()
+     */
+    public static Point1S of(final double azimuth) {
+        final double normalizedAzimuth = PolarCoordinates.normalizeAzimuth(azimuth);
+
+        return new Point1S(azimuth, normalizedAzimuth);
+    }
+
+    /** Create a new point instance from the given azimuth angle.
+     * @param azimuth azimuth azimuth angle in radians
+     * @return point instance with the given azimuth angle
+     * @see #getAzimuth()
+     */
+    public static Point1S of(final PlaneAngle azimuth) {
+        return of(azimuth.toRadians());
+    }
+
+    /** Create a new point instance from the given Euclidean 2D vector. The returned point
+     * will have an azimuth value equal to the angle between the positive x-axis and the
+     * given vector, measured in a counter-clockwise direction.
+     * @param vector 3D vector to create the point from
+     * @return a new point instance with an azimuth value equal to the angle between the given
+     *      vector and the positive x-axis, measured in a counter-clockwise direction
+     */
+    public static Point1S from(final Vector2D vector) {
+        final PolarCoordinates polar = PolarCoordinates.fromCartesian(vector);
+        final double az = polar.getAzimuth();
+
+        return new Point1S(az, az);
+    }
+
+    /** Create a new point instance containing an azimuth value equal to that of the
+     * given set of polar coordinates.
+     * @param polar polar coordinates to convert to a point
+     * @return a new point instance containing an azimuth value equal to that of
+     *      the given set of polar coordinates.
+     */
+    public static Point1S from(final PolarCoordinates polar) {
+        final double az = polar.getAzimuth();
+
+        return new Point1S(az, az);
+    }
+
+    /** Parse the given string and returns a new point instance. The expected string
+     * format is the same as that returned by {@link #toString()}.
+     * @param str the string to parse
+     * @return point instance represented by the string
+     * @throws IllegalArgumentException if the given string has an invalid format
+     */
+    public static Point1S parse(final String str) {
+        return SimpleTupleFormat.getDefault().parse(str, az -> Point1S.of(az));
+    }
+
+    /** Compute the signed shortest distance (angular separation) between two points. The return
+     * value is in the range {@code [-pi, pi)} and is such that {@code p1.getAzimuth() + d}
+     * (where {@code d} is the signed distance) is an angle equivalent to {@code p2.getAzimuth()}.
+     * @param p1 first point
+     * @param p2 second point
+     * @return the signed angular separation between p1 and p2, in the range {@code [-pi, pi)}.
+     */
+    public static double signedDistance(final Point1S p1, final Point1S p2) {
+        double dist = p2.normalizedAzimuth - p1.normalizedAzimuth;
+        if (dist < -Geometry.PI) {
+            dist += Geometry.TWO_PI;
+        }
+        if (dist >= Geometry.PI) {
+            dist -= Geometry.TWO_PI;
+        }
+        return dist;
+    }
+
+    /** Compute the shortest distance (angular separation) between two points. The returned
+     * value is in the range {@code [0, pi]}. This method is equal to the absolute value of
+     * the {@link #signedDistance(Point1S, Point1S) signed distance}.
+     * @param p1 first point
+     * @param p2 second point
+     * @return the angular separation between p1 and p2, in the range {@code [0, pi]}.
+     * @see #signedDistance(Point1S, Point1S)
+     */
+    public static double distance(final Point1S p1, final Point1S p2) {
+        return Math.abs(signedDistance(p1, p2));
+    }
+}
diff --git a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/oned/RegionBSPTree1S.java b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/oned/RegionBSPTree1S.java
new file mode 100644
index 0000000..2c17465
--- /dev/null
+++ b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/oned/RegionBSPTree1S.java
@@ -0,0 +1,511 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.oned;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Objects;
+
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.partitioning.Hyperplane;
+import org.apache.commons.geometry.core.partitioning.HyperplaneLocation;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.SubHyperplane;
+import org.apache.commons.geometry.core.partitioning.bsp.AbstractBSPTree;
+import org.apache.commons.geometry.core.partitioning.bsp.AbstractRegionBSPTree;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+
+/** BSP tree representing regions in 1D spherical space.
+ */
+public class RegionBSPTree1S extends AbstractRegionBSPTree<Point1S, RegionBSPTree1S.RegionNode1S> {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20190817L;
+
+    /** Comparator used to sort BoundaryPairs by ascending azimuth.  */
+    private static final Comparator<BoundaryPair> BOUNDARY_PAIR_COMPARATOR = (BoundaryPair a, BoundaryPair b) -> {
+        return Double.compare(a.getMinValue(), b.getMinValue());
+    };
+
+    /** Create a new, empty instance.
+     */
+    public RegionBSPTree1S() {
+        this(false);
+    }
+
+    /** Create a new region. If {@code full} is true, then the region will
+     * represent the entire circle. Otherwise, it will be empty.
+     * @param full whether or not the region should contain the entire
+     *      circle or be empty
+     */
+    public RegionBSPTree1S(boolean full) {
+        super(full);
+    }
+
+    /** Return a deep copy of this instance.
+     * @return a deep copy of this instance.
+     * @see #copy(org.apache.commons.geometry.core.partitioning.bsp.BSPTree)
+     */
+    public RegionBSPTree1S copy() {
+        RegionBSPTree1S result = RegionBSPTree1S.empty();
+        result.copy(this);
+
+        return result;
+    }
+
+    /** Add an interval to this region. The resulting region will be the
+     * union of the interval and the region represented by this instance.
+     * @param interval the interval to add
+     */
+    public void add(final AngularInterval interval) {
+        union(fromInterval(interval));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Point1S project(final Point1S pt) {
+        final BoundaryProjector1S projector = new BoundaryProjector1S(pt);
+        accept(projector);
+
+        return projector.getProjected();
+    }
+
+    /** {@inheritDoc}
+     *
+     * <p>Each interval of the region is transformed individually and the
+     * results are unioned. If the size of any transformed interval is greater
+     * than or equal to 2pi, then the region is set to the full space.</p>
+     */
+    @Override
+    public void transform(final Transform<Point1S> transform) {
+        if (!isFull() && !isEmpty()) {
+            // transform each interval individually to handle wrap-around
+            final List<AngularInterval> intervals = toIntervals();
+
+            setEmpty();
+
+            for (AngularInterval interval : intervals) {
+                union(interval.transform(transform).toTree());
+            }
+        }
+    }
+
+    /** {@inheritDoc}
+     *
+     * <p>It is important to note that split operations occur according to the rules of the
+     * {@link CutAngle} hyperplane class. In this class, the continuous circle is viewed
+     * as a non-circular segment of the number line in the range {@code [0, 2pi)}. Hyperplanes
+     * are placed along this line and partition it into the segments {@code [0, x]}
+     * and {@code [x, 2pi)}, where {@code x} is the location of the hyperplane. For example,
+     * a positive-facing {@link CutAngle} instance with an azimuth of {@code 0.5pi} has
+     * a minus side consisting of the angles {@code [0, 0.5pi]} and a plus side consisting of
+     * the angles {@code [0.5pi, 2pi)}. Similarly, a positive-facing {@link CutAngle} with
+     * an azimuth of {@code 0pi} has a plus side of {@code [0, 2pi)} (the full space) and
+     * a minus side that is completely empty (since no points exist in our domain that are
+     * less than zero). These rules can result in somewhat non-intuitive behavior at times.
+     * For example, splitting a non-empty region with a hyperplane at {@code 0pi} is
+     * essentially a no-op, since the region will either lie entirely on the plus or minus
+     * side of the hyperplane (depending on the hyperplane's orientation) regardless of the actual
+     * content of the region. In these situations, a copy of the tree is returned on the
+     * appropriate side of the split.</p>
+     *
+     * @see CutAngle
+     * @see #splitDiameter(CutAngle)
+     */
+    @Override
+    public Split<RegionBSPTree1S> split(final Hyperplane<Point1S> splitter) {
+        // Handle the special case where the cut is on the azimuth equivalent to zero;
+        // In this case, it is not possible for any points to lie between it and zero.
+        if (!isEmpty() && splitter.classify(Point1S.ZERO) == HyperplaneLocation.ON) {
+            CutAngle cut = (CutAngle) splitter;
+            if (cut.isPositiveFacing()) {
+                return new Split<>(null, copy());
+            } else {
+                return new Split<>(copy(), null);
+            }
+        }
+
+        return split(splitter, RegionBSPTree1S.empty(), RegionBSPTree1S.empty());
+    }
+
+    /** Split the instance along a circle diameter.The diameter is defined by the given
+     * split point and its reversed antipodal point.
+     * @param splitter split point defining one side of the split diameter
+     * @return result of the split operation
+     */
+    public Split<RegionBSPTree1S> splitDiameter(final CutAngle splitter) {
+
+        final CutAngle opposite = CutAngle.fromPointAndDirection(
+                splitter.getPoint().antipodal(),
+                !splitter.isPositiveFacing(),
+                splitter.getPrecision());
+
+        final double plusPoleOffset = splitter.isPositiveFacing() ?
+                +Geometry.HALF_PI :
+                -Geometry.HALF_PI;
+        final Point1S plusPole = Point1S.of(splitter.getAzimuth() + plusPoleOffset);
+
+        final boolean zeroOnPlusSide = splitter.getPrecision()
+                .lte(plusPole.distance(Point1S.ZERO), Geometry.HALF_PI);
+
+        Split<RegionBSPTree1S> firstSplit = split(splitter);
+        Split<RegionBSPTree1S> secondSplit = split(opposite);
+
+        RegionBSPTree1S minus = RegionBSPTree1S.empty();
+        RegionBSPTree1S plus = RegionBSPTree1S.empty();
+
+        if (zeroOnPlusSide) {
+            // zero wrap-around needs to be handled on the plus side of the split
+            safeUnion(plus, firstSplit.getPlus());
+            safeUnion(plus, secondSplit.getPlus());
+
+            minus = firstSplit.getMinus();
+            if (minus != null) {
+                minus = minus.split(opposite).getMinus();
+            }
+        } else {
+            // zero wrap-around needs to be handled on the minus side of the split
+            safeUnion(minus, firstSplit.getMinus());
+            safeUnion(minus, secondSplit.getMinus());
+
+            plus = firstSplit.getPlus();
+            if (plus != null) {
+                plus = plus.split(opposite).getPlus();
+            }
+        }
+
+        return new Split<>(
+                (minus != null && !minus.isEmpty()) ? minus : null,
+                (plus != null && !plus.isEmpty()) ? plus : null);
+    }
+
+
+    /** Convert the region represented by this tree into a list of separate
+     * {@link AngularInterval}s, arranged in order of ascending min value.
+     * @return list of {@link AngularInterval}s representing this region in order of
+     *      ascending min value
+     */
+    public List<AngularInterval> toIntervals() {
+        if (isFull()) {
+            return Collections.singletonList(AngularInterval.full());
+        }
+
+        final List<BoundaryPair> insideBoundaryPairs = new ArrayList<>();
+        for (RegionNode1S node : this) {
+            if (node.isInside()) {
+                insideBoundaryPairs.add(getNodeBoundaryPair(node));
+            }
+        }
+
+        insideBoundaryPairs.sort(BOUNDARY_PAIR_COMPARATOR);
+
+        int boundaryPairCount = insideBoundaryPairs.size();
+
+        // Find the index of the first boundary pair that is not connected to pair before it.
+        // This will be our start point for merging intervals together.
+        int startOffset = 0;
+        if (boundaryPairCount > 1) {
+            BoundaryPair current = null;
+            BoundaryPair previous = insideBoundaryPairs.get(boundaryPairCount - 1);
+
+            for (int i = 0; i < boundaryPairCount; ++i, previous = current) {
+                current = insideBoundaryPairs.get(i);
+
+                if (!Objects.equals(current.getMin(), previous.getMax())) {
+                    startOffset = i;
+                    break;
+                }
+            }
+        }
+
+        // Go through the pairs starting at the start offset and create intervals
+        // for each set of adjacent pairs.
+        final List<AngularInterval> intervals = new ArrayList<>();
+
+        BoundaryPair start = null;
+        BoundaryPair end = null;
+        BoundaryPair current = null;
+
+        for (int i = 0; i < boundaryPairCount; ++i) {
+            current = insideBoundaryPairs.get((i + startOffset) % boundaryPairCount);
+
+            if (start == null) {
+                start = current;
+                end = current;
+            } else if (Objects.equals(end.getMax(), current.getMin())) {
+                // these intervals should be merged
+                end = current;
+            } else {
+                // these intervals should be separate
+                intervals.add(createInterval(start, end));
+
+                // queue up the next pair
+                start = current;
+                end = current;
+            }
+        }
+
+        if (start != null && end != null) {
+            intervals.add(createInterval(start, end));
+        }
+
+        return intervals;
+    }
+
+    /** Create an interval instance from the min boundary from the start boundary pair and
+     * the max boundary from the end boundary pair. The hyperplane directions are adjusted
+     * as needed.
+     * @param start starting boundary pair
+     * @param end ending boundary pair
+     * @return an interval created from the min boundary of the given start pair and the
+     *      max boundary from the given end pair
+     */
+    private AngularInterval createInterval(final BoundaryPair start, final BoundaryPair end) {
+        CutAngle min = start.getMin();
+        CutAngle max = end.getMax();
+
+        DoublePrecisionContext precision = (min != null) ? min.getPrecision() : max.getPrecision();
+
+        // flip the hyperplanes if needed since there's no
+        // guarantee that the inside will be on the minus side
+        // of the hyperplane (for example, if the region is complemented)
+
+        if (min != null) {
+            if (min.isPositiveFacing()) {
+                min = min.reverse();
+            }
+        } else {
+            min = CutAngle.createNegativeFacing(Geometry.ZERO_PI, precision);
+        }
+
+        if (max != null) {
+            if (!max.isPositiveFacing()) {
+                max = max.reverse();
+            }
+        } else {
+            max = CutAngle.createPositiveFacing(Geometry.TWO_PI, precision);
+        }
+
+        return AngularInterval.of(min, max);
+    }
+
+    /** Return the min/max boundary pair for the convex region represented by the given node.
+     * @param node the node to compute the interval for
+     * @return the min/max boundary pair for the convex region represented by the given node
+     */
+    private BoundaryPair getNodeBoundaryPair(final RegionNode1S node) {
+        CutAngle min = null;
+        CutAngle max = null;
+
+        CutAngle pt;
+        RegionNode1S child = node;
+        RegionNode1S parent;
+
+        while ((min == null || max == null) && (parent = child.getParent()) != null) {
+            pt = (CutAngle) parent.getCutHyperplane();
+
+            if ((pt.isPositiveFacing() && child.isMinus()) ||
+                    (!pt.isPositiveFacing() && child.isPlus())) {
+
+                if (max == null) {
+                    max = pt;
+                }
+            } else if (min == null) {
+                min = pt;
+            }
+
+            child = parent;
+        }
+
+        return new BoundaryPair(min, max);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected RegionSizeProperties<Point1S> computeRegionSizeProperties() {
+        if (isFull()) {
+            return new RegionSizeProperties<>(Geometry.TWO_PI, null);
+        }
+
+        double size = 0;
+        double scaledBarycenterSum = 0;
+
+        double intervalSize;
+
+        for (AngularInterval interval : toIntervals()) {
+            intervalSize = interval.getSize();
+
+            size += intervalSize;
+            scaledBarycenterSum += intervalSize * interval.getBarycenter().getNormalizedAzimuth();
+        }
+
+        final Point1S barycenter = size > 0 ?
+                Point1S.of(scaledBarycenterSum / size) :
+                null;
+
+        return new RegionSizeProperties<>(size, barycenter);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected RegionNode1S createNode() {
+        return new RegionNode1S(this);
+    }
+
+    /** Return a new, empty BSP tree.
+     * @return a new, empty BSP tree.
+     */
+    public static RegionBSPTree1S empty() {
+        return new RegionBSPTree1S(false);
+    }
+
+    /** Return a new, full BSP tree. The returned tree represents the
+     * full space.
+     * @return a new, full BSP tree.
+     */
+    public static RegionBSPTree1S full() {
+        return new RegionBSPTree1S(true);
+    }
+
+    /** Return a new BSP tree representing the same region as the given angular interval.
+     * @param interval the input interval
+     * @return a new BSP tree representing the same region as the given angular interval
+     */
+    public static RegionBSPTree1S fromInterval(final AngularInterval interval) {
+        final CutAngle minBoundary = interval.getMinBoundary();
+        final CutAngle maxBoundary = interval.getMaxBoundary();
+
+        final RegionBSPTree1S tree = full();
+
+        if (minBoundary != null) {
+            tree.insert(minBoundary.span());
+        }
+
+        if (maxBoundary != null) {
+            tree.insert(maxBoundary.span());
+        }
+
+        return tree;
+    }
+
+    /** Perform a union operation with {@code target} and {@code input}, storing the result
+     * in {@code target}; does nothing if {@code input} is null.
+     * @param target target tree
+     * @param input input tree
+     */
+    private static void safeUnion(final RegionBSPTree1S target, final RegionBSPTree1S input) {
+        if (input != null) {
+            target.union(input);
+        }
+    }
+
+    /** BSP tree node for one dimensional spherical space.
+     */
+    public static final class RegionNode1S extends AbstractRegionBSPTree.AbstractRegionNode<Point1S, RegionNode1S> {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20190922L;
+
+        /** Simple constructor.
+         * @param tree the owning tree instance
+         */
+        protected RegionNode1S(final AbstractBSPTree<Point1S, RegionNode1S> tree) {
+            super(tree);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected RegionNode1S getSelf() {
+            return this;
+        }
+    }
+
+    /** Internal class containing pairs of interval boundaries.
+     */
+    private static final class BoundaryPair {
+
+        /** The min boundary. */
+        private final CutAngle min;
+
+        /** The max boundary. */
+        private final CutAngle max;
+
+        /** Simple constructor.
+         * @param min min boundary hyperplane
+         * @param max max boundary hyperplane
+         */
+        BoundaryPair(final CutAngle min, final CutAngle max) {
+            this.min = min;
+            this.max = max;
+        }
+
+        /** Get the minimum boundary hyperplane.
+         * @return the minimum boundary hyperplane.
+         */
+        public CutAngle getMin() {
+            return min;
+        }
+
+        /** Get the maximum boundary hyperplane.
+         * @return the maximum boundary hyperplane.
+         */
+        public CutAngle getMax() {
+            return max;
+        }
+
+        /** Get the minumum value of the interval or zero if no minimum value exists.
+         * @return the minumum value of the interval or zero
+         *      if no minimum value exists.
+         */
+        public double getMinValue() {
+            return (min != null) ? min.getNormalizedAzimuth() : 0;
+        }
+    }
+
+    /** Class used to project points onto the region boundary.
+     */
+    private static final class BoundaryProjector1S extends BoundaryProjector<Point1S, RegionNode1S> {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20190926L;
+
+        /** Simple constructor.
+         * @param point the point to project onto the region's boundary
+         */
+        BoundaryProjector1S(final Point1S point) {
+            super(point);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected boolean isPossibleClosestCut(final SubHyperplane<Point1S> cut, final Point1S target,
+                final double minDist) {
+            // since the space wraps around, consider any cut as possibly being the closest
+            return true;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected Point1S disambiguateClosestPoint(final Point1S target, final Point1S a, final Point1S b) {
+            // prefer the point with the smaller normalize azimuth value
+            return a.getNormalizedAzimuth() < b.getNormalizedAzimuth() ? a : b;
+        }
+    }
+}
diff --git a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/oned/S1Point.java b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/oned/S1Point.java
deleted file mode 100644
index c13083d..0000000
--- a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/oned/S1Point.java
+++ /dev/null
@@ -1,177 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.oned;
-
-import java.io.Serializable;
-
-import org.apache.commons.geometry.core.Point;
-import org.apache.commons.geometry.core.internal.SimpleTupleFormat;
-import org.apache.commons.geometry.euclidean.twod.PolarCoordinates;
-import org.apache.commons.geometry.euclidean.twod.Vector2D;
-
-/** This class represents a point on the 1-sphere.
- * <p>Instances of this class are guaranteed to be immutable.</p>
- */
-public final class S1Point implements Point<S1Point>, Serializable {
-
-    // CHECKSTYLE: stop ConstantName
-    /** A point with all coordinates set to NaN. */
-    public static final S1Point NaN = new S1Point(Double.NaN);
-    // CHECKSTYLE: resume ConstantName
-
-    /** Serializable UID. */
-    private static final long serialVersionUID = 20180710L;
-
-    /** Azimuthal angle in radians. */
-    private final double azimuth;
-
-    /** Corresponding 2D normalized vector. */
-    private final Vector2D vector;
-
-    /** Build a point from its internal components.
-     * @param azimuth azimuthal angle
-     */
-    private S1Point(final double azimuth) {
-        this.azimuth  = PolarCoordinates.normalizeAzimuth(azimuth);
-        this.vector = Double.isFinite(azimuth) ? PolarCoordinates.toCartesian(1.0, azimuth) : Vector2D.NaN;
-    }
-
-    /** Get the azimuthal angle in radians.
-     * @return azimuthal angle
-     * @see S1Point#of(double)
-     */
-    public double getAzimuth() {
-        return azimuth;
-    }
-
-    /** Get the corresponding normalized vector in the 2D Euclidean space.
-     * @return normalized vector
-     */
-    public Vector2D getVector() {
-        return vector;
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public int getDimension() {
-        return 1;
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public boolean isNaN() {
-        return Double.isNaN(azimuth);
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public boolean isInfinite() {
-        return !isNaN() && Double.isInfinite(azimuth);
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public double distance(final S1Point point) {
-        return distance(this, point);
-    }
-
-    /** Compute the distance (angular separation) between two points.
-     * @param p1 first vector
-     * @param p2 second vector
-     * @return the angular separation between p1 and p2
-     */
-    public static double distance(S1Point p1, S1Point p2) {
-        return p1.vector.angle(p2.vector);
-    }
-
-    /**
-     * Test for the equality of two points on the 2-sphere.
-     * <p>
-     * If all coordinates of two points are exactly the same, and none are
-     * <code>Double.NaN</code>, the two points are considered to be equal.
-     * </p>
-     * <p>
-     * <code>NaN</code> coordinates are considered to affect globally the vector
-     * and be equals to each other - i.e, if either (or all) coordinates of the
-     * 2D vector are equal to <code>Double.NaN</code>, the 2D vector is equal to
-     * {@link #NaN}.
-     * </p>
-     *
-     * @param other Object to test for equality to this
-     * @return true if two points on the 2-sphere objects are equal, false if
-     *         object is null, not an instance of S2Point, or
-     *         not equal to this S2Point instance
-     *
-     */
-    @Override
-    public boolean equals(Object other) {
-        if (this == other) {
-            return true;
-        }
-
-        if (other instanceof S1Point) {
-            final S1Point rhs = (S1Point) other;
-            if (rhs.isNaN()) {
-                return this.isNaN();
-            }
-
-            return azimuth == rhs.azimuth;
-        }
-
-        return false;
-    }
-
-    /**
-     * Get a hashCode for the 2D vector.
-     * <p>
-     * All NaN values have the same hash code.</p>
-     *
-     * @return a hash code value for this object
-     */
-    @Override
-    public int hashCode() {
-        if (isNaN()) {
-            return 542;
-        }
-        return 1759 * Double.hashCode(azimuth);
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public String toString() {
-        return SimpleTupleFormat.getDefault().format(getAzimuth());
-    }
-
-    /** Creates a new point instance from the given azimuthal coordinate value.
-     * @param azimuth azimuthal angle in radians
-     * @return point instance with the given azimuth coordinate value
-     * @see #getAzimuth()
-     */
-    public static S1Point of(double azimuth) {
-        return new S1Point(azimuth);
-    }
-
-    /** Parses the given string and returns a new point instance. The expected string
-     * format is the same as that returned by {@link #toString()}.
-     * @param str the string to parse
-     * @return point instance represented by the string
-     * @throws IllegalArgumentException if the given string has an invalid format
-     */
-    public static S1Point parse(String str) {
-        return SimpleTupleFormat.getDefault().parse(str, S1Point::new);
-    }
-}
diff --git a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/oned/SubLimitAngle.java b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/oned/SubLimitAngle.java
deleted file mode 100644
index b51a36f..0000000
--- a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/oned/SubLimitAngle.java
+++ /dev/null
@@ -1,65 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.oned;
-
-import org.apache.commons.geometry.core.partitioning.AbstractSubHyperplane;
-import org.apache.commons.geometry.core.partitioning.Hyperplane;
-import org.apache.commons.geometry.core.partitioning.Region;
-
-/** This class represents sub-hyperplane for {@link LimitAngle}.
- * <p>Instances of this class are guaranteed to be immutable.</p>
- */
-public class SubLimitAngle extends AbstractSubHyperplane<S1Point, S1Point> {
-
-    /** Simple constructor.
-     * @param hyperplane underlying hyperplane
-     * @param remainingRegion remaining region of the hyperplane
-     */
-    public SubLimitAngle(final Hyperplane<S1Point> hyperplane,
-                         final Region<S1Point> remainingRegion) {
-        super(hyperplane, remainingRegion);
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public double getSize() {
-        return 0;
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public boolean isEmpty() {
-        return false;
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    protected AbstractSubHyperplane<S1Point, S1Point> buildNew(final Hyperplane<S1Point> hyperplane,
-                                                                 final Region<S1Point> remainingRegion) {
-        return new SubLimitAngle(hyperplane, remainingRegion);
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public SplitSubHyperplane<S1Point> split(final Hyperplane<S1Point> hyperplane) {
-        final double global = hyperplane.getOffset(((LimitAngle) getHyperplane()).getLocation());
-        return (global < -1.0e-10) ?
-                                    new SplitSubHyperplane<>(null, this) :
-                                    new SplitSubHyperplane<>(this, null);
-    }
-
-}
diff --git a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/oned/Transform1S.java b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/oned/Transform1S.java
new file mode 100644
index 0000000..dd9ac00
--- /dev/null
+++ b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/oned/Transform1S.java
@@ -0,0 +1,236 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.oned;
+
+import java.io.Serializable;
+
+import org.apache.commons.geometry.core.Transform;
+
+/** Implementation of the {@link Transform} interface for spherical 1D points.
+ *
+ * <p>Similar to the Euclidean 1D
+ * {@link org.apache.commons.geometry.euclidean.oned.AffineTransformMatrix1D AffineTransformMatrix1D},
+ * this class performs transformations using an internal 1D affine transformation matrix. In the
+ * Euclidean case, the matrix contains a scale factor and a translation. Here, the matrix contains
+ * a scale/negation factor that takes the values -1 or +1, and a rotation value. This restriction on
+ * the allowed values in the matrix is required in order to fulfill the geometric requirements
+ * of the {@link Transform} interface. For example, if arbitrary scaling is allowed, the point {@code 0.5pi}
+ * could be scaled by 4 to {@code 2pi}, which is equivalent to {@code 0pi}. However, if the inverse scaling
+ * of {@code 1/4} is applied to {@code 0pi}, the result is {@code 0pi} and not {@code 0.5pi}. This breaks
+ * the {@link Transform} requirement that transforms be inversible.
+ * </p>
+ *
+ * <p>Instances of this class are guaranteed to be immutable.</p>
+ */
+public final class Transform1S implements Transform<Point1S>, Serializable {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20191001L;
+
+    /** Static instance representing the identity transform. */
+    private static final Transform1S IDENTITY = new Transform1S(1, 0);
+
+    /** Static instance that negates azimuth values. */
+    private static final Transform1S NEGATION = new Transform1S(-1, 0);
+
+    /** Value to scale the point azimuth by. This will only be +1/-1. */
+    private final double scale;
+
+    /** Value to rotate the point azimuth by. */
+    private final double rotate;
+
+    /** Construct a new instance from its transform components.
+     * @param scale scale value for the transform; must only be +1 or -1
+     * @param rotate rotation value
+     */
+    private Transform1S(final double scale, final double rotate) {
+        this.scale = scale;
+        this.rotate = rotate;
+    }
+
+    /** Return true if the transform negates the azimuth values of transformed
+     * points, regardless of any rotation applied subsequently.
+     * @return true if the transform negates the azimuth values of transformed
+     *      points
+     * @see #preservesOrientation()
+     */
+    public boolean isNegation() {
+        return scale <= 0;
+    }
+
+    /** Get the rotation value applied by this instance, in radians.
+     * @return the rotation value applied by this instance, in radians.
+     */
+    public double getRotation() {
+        return rotate;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Point1S apply(final Point1S pt) {
+        final double az = pt.getAzimuth();
+        final double resultAz = (az * scale) + rotate;
+
+        return Point1S.of(resultAz);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean preservesOrientation() {
+        return !isNegation();
+    }
+
+    /** Return a new transform created by pre-multiplying this instance by a transform
+     * producing a rotation with the given angle.
+     * @param angle angle to rotate, in radians
+     * @return a new transform created by pre-multiplying this instance by a transform
+     *      producing a rotation with the given angle
+     * @see #createRotation(double)
+     */
+    public Transform1S rotate(final double angle) {
+        return premultiply(createRotation(angle));
+    }
+
+    /** Return a new transform created by pre-multiplying this instance by a transform
+     * that negates azimuth values.
+     * @return a new transform created by pre-multiplying this instance by a transform
+     *      that negates azimuth values
+     */
+    public Transform1S negate() {
+        return premultiply(createNegation());
+    }
+
+    /** Multiply the underlying matrix of this instance by that of the argument, eg,
+     * {@code other * this}. The returned transform performs the equivalent of
+     * {@code other} followed by {@code this}.
+     * @param other transform to multiply with
+     * @return a new transform computed by multiplying the matrix of this
+     *      instance by that of the argument
+     */
+    public Transform1S multiply(final Transform1S other) {
+        return multiply(this, other);
+    }
+
+    /** Multiply the underlying matrix of the argument by that of this instance, eg,
+     * {@code this * other}. The returned transform performs the equivalent of {@code this}
+     * followed by {@code other}.
+     * @param other transform to multiply with
+     * @return a new transform computed by multiplying the matrix of the
+     *      argument by that of this instance
+     */
+    public Transform1S premultiply(final Transform1S other) {
+        return multiply(other, this);
+    }
+
+    /** Return a transform that is the inverse of the current instance. The returned transform
+     * will undo changes applied by this instance.
+     * @return a transform that is the inverse of the current instance
+     */
+    public Transform1S inverse() {
+        final double invScale = 1.0 / scale;
+
+        final double resultScale = invScale;
+        final double resultRotate = -(rotate * invScale);
+
+        return new Transform1S(resultScale, resultRotate);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public int hashCode() {
+        final int prime = 31;
+        int result = 1;
+
+        result = (result * prime) + Double.hashCode(scale);
+        result = (result * prime) + Double.hashCode(rotate);
+
+        return result;
+    }
+
+    /**
+     * Return true if the given object is an instance of {@link Transform1S}
+     * and all transform element values are exactly equal.
+     * @param obj object to test for equality with the current instance
+     * @return true if all transform elements are exactly equal; otherwise false
+     */
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (!(obj instanceof Transform1S)) {
+            return false;
+        }
+        final Transform1S other = (Transform1S) obj;
+
+        return Double.compare(scale, other.scale) == 0 &&
+                Double.compare(rotate, other.rotate) == 0;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String toString() {
+        final StringBuilder sb = new StringBuilder();
+
+        sb.append(this.getClass().getSimpleName())
+            .append("[negate= ")
+            .append(isNegation())
+            .append(", rotate= ")
+            .append(getRotation())
+            .append("]");
+
+        return sb.toString();
+    }
+
+    /** Return a transform instance representing the identity transform.
+     * @return a transform instance representing the identity transform
+     */
+    public static Transform1S identity() {
+        return IDENTITY;
+    }
+
+    /** Return a transform instance that negates azimuth values.
+     * @return a transform instance that negates azimuth values.
+     */
+    public static Transform1S createNegation() {
+        return NEGATION;
+    }
+
+    /** Return a transform instance that performs a rotation with the given
+     * angle.
+     * @param angle angle of the rotation, in radians
+     * @return a transform instance that performs a rotation with the given
+     *      angle
+     */
+    public static Transform1S createRotation(final double angle) {
+        return new Transform1S(1, angle);
+    }
+
+    /** Multiply two transforms together as matrices.
+     * @param a first transform
+     * @param b second transform
+     * @return the transform computed as {@code a x b}
+     */
+    private static Transform1S multiply(final Transform1S a, final Transform1S b) {
+
+        // calculate the matrix elements
+        final double resultScale = a.scale * b.scale;
+        final double resultRotate = (a.scale * b.rotate) + a.rotate;
+
+        return new Transform1S(resultScale, resultRotate);
+    }
+}
diff --git a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/AbstractGreatArcConnector.java b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/AbstractGreatArcConnector.java
new file mode 100644
index 0000000..c6fd050
--- /dev/null
+++ b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/AbstractGreatArcConnector.java
@@ -0,0 +1,303 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.twod;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+import org.apache.commons.geometry.euclidean.internal.AbstractPathConnector;
+import org.apache.commons.geometry.euclidean.threed.Vector3D;
+
+/** Abstract class for joining collections of great arcs into connected
+ * paths. This class is not thread-safe.
+ */
+public abstract class AbstractGreatArcConnector
+    extends AbstractPathConnector<AbstractGreatArcConnector.ConnectableGreatArc> {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20191107L;
+
+    /** Add an arc to the connector, leaving it unconnected until a later call to
+     * to {@link #connect(Iterable)} or {@link #connectAll()}.
+     * @param arc arc to add
+     * @see #connect(Iterable)
+     * @see #connectAll()
+     */
+    public void add(final GreatArc arc) {
+        addPathElement(new ConnectableGreatArc(arc));
+    }
+
+    /** Add a collection of arcs to the connector, leaving them unconnected
+     * until a later call to {@link #connect(Iterable)} or
+     * {@link #connectAll()}.
+     * @param arcs arcs to add
+     * @see #connect(Iterable)
+     * @see #connectAll()
+     * @see #add(GreatArc)
+     */
+    public void add(final Iterable<GreatArc> arcs) {
+        for (GreatArc segment : arcs) {
+            add(segment);
+        }
+    }
+
+    /** Add a collection of arcs to the connector and attempt to connect each new
+     * arc with existing ones. Connections made at this time will not be
+     * overwritten by subsequent calls to this or other connection methods,
+     * (eg, {@link #connectAll()}).
+     *
+     * <p>The connector is not reset by this call. Additional arc can still be added
+     * to the current set of paths.</p>
+     * @param arcs arcs to connect
+     * @see #connectAll()
+     */
+    public void connect(final Iterable<GreatArc> arcs) {
+        List<ConnectableGreatArc> newEntries = new ArrayList<>();
+
+        for (GreatArc segment : arcs) {
+            newEntries.add(new ConnectableGreatArc(segment));
+        }
+
+        connectPathElements(newEntries);
+    }
+
+    /** Add the given arcs to this instance and connect all current
+     * arc into paths. This call is equivalent to
+     * <pre>
+     *      connector.add(arcs);
+     *      List&lt;GreatArcPath&gt; result = connector.connectAll();
+     * </pre>
+     *
+     * <p>The connector is reset after this call. Further calls to
+     * add or connect arcs will result in new paths being generated.</p>
+     * @param arcs arcs to add
+     * @return the connected arc paths
+     * @see #add(Iterable)
+     * @see #connectAll()
+     */
+    public List<GreatArcPath> connectAll(final Iterable<GreatArc> arcs) {
+        add(arcs);
+        return connectAll();
+    }
+
+    /** Connect all current arcs into connected paths, returning the result as a
+     * list of arc paths.
+     *
+     * <p>The connector is reset after this call. Further calls to
+     * add or connect arcs will result in new paths being generated.</p>
+     * @return the connected line segments paths
+     */
+    public List<GreatArcPath> connectAll() {
+        final List<ConnectableGreatArc> roots = computePathRoots();
+        final List<GreatArcPath> paths = new ArrayList<>(roots.size());
+
+        for (ConnectableGreatArc root : roots) {
+            paths.add(toPath(root));
+        }
+
+        return paths;
+    }
+
+    /** Convert the linked list of path elements starting at the argument
+     * into a {@link GreatArcPath}.
+     * @param root root of a connected path linked list
+     * @return a great arc path representing the linked list path
+     */
+    private GreatArcPath toPath(final ConnectableGreatArc root) {
+        final GreatArcPath.Builder builder = GreatArcPath.builder(null);
+
+        builder.append(root.getArc());
+
+        ConnectableGreatArc current = root.getNext();
+
+        while (current != null && current != root) {
+            builder.append(current.getArc());
+            current = current.getNext();
+        }
+
+        return builder.build();
+    }
+
+    /** Internal class for connecting {@link GreatArc}s into {@link GreatArcPath}s.
+     */
+    protected static class ConnectableGreatArc extends AbstractPathConnector.ConnectableElement<ConnectableGreatArc> {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20191107L;
+
+        /** Segment start point. This will be used to connect to other path elements. */
+        private final Point2S start;
+
+        /** Great arc for this instance. */
+        private final GreatArc arc;
+
+        /** Create a new instance with the given start point. This constructor is
+         * intended only for performing searches for other path elements.
+         * @param start start point
+         */
+        public ConnectableGreatArc(final Point2S start) {
+            this(start, null);
+        }
+
+        /** Create a new instance from the given arc.
+         * @param arc arc for the instance
+         */
+        public ConnectableGreatArc(final GreatArc arc) {
+            this(arc.getStartPoint(), arc);
+        }
+
+        /** Create a new instance with the given start point and arc.
+         * @param start start point
+         * @param arc arc for the instance
+         */
+        private ConnectableGreatArc(final Point2S start, final GreatArc arc) {
+            this.start = start;
+            this.arc = arc;
+        }
+
+        /** Get the arc for the instance.
+         * @return the arc for the instance
+         */
+        public GreatArc getArc() {
+            return arc;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean hasStart() {
+            return start != null;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean hasEnd() {
+            return arc.getEndPoint() != null;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean endPointsEq(final ConnectableGreatArc other) {
+            if (hasEnd() && other.hasEnd()) {
+                return arc.getEndPoint()
+                        .eq(other.arc.getEndPoint(), arc.getPrecision());
+            }
+
+            return false;
+        }
+
+        /** Return true if this instance has a size equivalent to zero.
+         * @return true if this instance has a size equivalent to zero.
+         */
+        public boolean hasZeroSize() {
+            return arc != null && arc.getPrecision().eqZero(arc.getSize());
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean canConnectTo(final ConnectableGreatArc next) {
+            final Point2S end = arc.getEndPoint();
+            final Point2S nextStart = next.start;
+
+            return end != null && nextStart != null &&
+                    end.eq(nextStart, arc.getPrecision());
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public double getRelativeAngle(final ConnectableGreatArc other) {
+            return arc.getCircle().angle(other.getArc().getCircle());
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public ConnectableGreatArc getConnectionSearchKey() {
+            return new ConnectableGreatArc(arc.getEndPoint());
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean shouldContinueConnectionSearch(final ConnectableGreatArc candidate,
+                final boolean ascending) {
+
+            if (candidate.hasStart()) {
+                final double candidatePolar = candidate.getArc().getStartPoint().getPolar();
+                final double thisPolar = arc.getEndPoint().getPolar();
+                final int cmp = arc.getPrecision().compare(candidatePolar, thisPolar);
+
+                return ascending ? cmp <= 0 : cmp >= 0;
+            }
+
+            return true;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public int compareTo(final ConnectableGreatArc other) {
+            int cmp = Point2S.POLAR_AZIMUTH_ASCENDING_ORDER.compare(start, other.start);
+
+            if (cmp == 0) {
+                // sort entries without arcs before ones with arcs
+                final boolean thisHasArc = arc != null;
+                final boolean otherHasArc = other.arc != null;
+
+                cmp = Boolean.compare(thisHasArc, otherHasArc);
+
+                if (cmp == 0 && thisHasArc) {
+                    // place point-like segments before ones with non-zero length
+                    cmp = Boolean.compare(this.hasZeroSize(), other.hasZeroSize());
+
+                    if (cmp == 0) {
+                        // sort by circle pole
+                        cmp = Vector3D.COORDINATE_ASCENDING_ORDER.compare(
+                                arc.getCircle().getPole(),
+                                other.arc.getCircle().getPole());
+                    }
+                }
+            }
+
+            return cmp;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public int hashCode() {
+            return Objects.hash(start, arc);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean equals(final Object obj) {
+            if (this == obj) {
+                return true;
+            }
+            if (obj == null || !this.getClass().equals(obj.getClass())) {
+                return false;
+            }
+
+            final ConnectableGreatArc other = (ConnectableGreatArc) obj;
+            return Objects.equals(this.start, other.start) &&
+                    Objects.equals(this.arc, other.arc);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected ConnectableGreatArc getSelf() {
+            return this;
+        }
+    }
+}
diff --git a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/AbstractSubGreatCircle.java b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/AbstractSubGreatCircle.java
new file mode 100644
index 0000000..05b02b4
--- /dev/null
+++ b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/AbstractSubGreatCircle.java
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.twod;
+
+import org.apache.commons.geometry.core.partitioning.AbstractEmbeddingSubHyperplane;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.spherical.oned.Point1S;
+import org.apache.commons.geometry.spherical.twod.SubGreatCircle.SubGreatCircleBuilder;
+
+/** Abstract base class for great circle subhyperplane implementations.
+ */
+abstract class AbstractSubGreatCircle
+    extends AbstractEmbeddingSubHyperplane<Point2S, Point1S, GreatCircle> {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20191005L;
+
+    /** The great circle defining this instance. */
+    private final GreatCircle circle;
+
+    /** Simple constructor.
+     * @param circle great circle defining this instance
+     */
+    AbstractSubGreatCircle(final GreatCircle circle) {
+        this.circle = circle;
+    }
+
+    /** Get the great circle defining this instance.
+     * @return the great circle defining this instance
+     * @see #getHyperplane()
+     */
+    public GreatCircle getCircle() {
+        return circle;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public GreatCircle getHyperplane() {
+        return circle;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public SubGreatCircleBuilder builder() {
+        return new SubGreatCircleBuilder(circle);
+    }
+
+    /** Return the object used to perform floating point comparisons, which is the
+     * same object used by the underlying {@link GreatCircle}.
+     * @return precision object used to perform floating point comparisons.
+     */
+    public DoublePrecisionContext getPrecision() {
+        return circle.getPrecision();
+    }
+}
diff --git a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/Circle.java b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/Circle.java
deleted file mode 100644
index dd3dda7..0000000
--- a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/Circle.java
+++ /dev/null
@@ -1,336 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.twod;
-
-import org.apache.commons.geometry.core.partitioning.Embedding;
-import org.apache.commons.geometry.core.partitioning.Hyperplane;
-import org.apache.commons.geometry.core.partitioning.SubHyperplane;
-import org.apache.commons.geometry.core.partitioning.Transform;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.geometry.euclidean.threed.Vector3D;
-import org.apache.commons.geometry.euclidean.threed.rotation.QuaternionRotation;
-import org.apache.commons.geometry.spherical.oned.Arc;
-import org.apache.commons.geometry.spherical.oned.ArcsSet;
-import org.apache.commons.geometry.spherical.oned.S1Point;
-
-/** This class represents an oriented great circle on the 2-sphere.
-
- * <p>An oriented circle can be defined by a center point. The circle
- * is the the set of points that are in the normal plan the center.</p>
-
- * <p>Since it is oriented the two spherical caps at its two sides are
- * unambiguously identified as a left cap and a right cap. This can be
- * used to identify the interior and the exterior in a simple way by
- * local properties only when part of a line is used to define part of
- * a spherical polygon boundary.</p>
- */
-public class Circle implements Hyperplane<S2Point>, Embedding<S2Point, S1Point> {
-
-    /** Pole or circle center. */
-    private Vector3D pole;
-
-    /** First axis in the equator plane, origin of the phase angles. */
-    private Vector3D x;
-
-    /** Second axis in the equator plane, in quadrature with respect to x. */
-    private Vector3D y;
-
-    /** Precision context used to determine floating point equality. */
-    private final DoublePrecisionContext precision;
-
-    /** Build a great circle from its pole.
-     * <p>The circle is oriented in the trigonometric direction around pole.</p>
-     * @param pole circle pole
-     * @param precision precision context used to compare floating point values
-     */
-    public Circle(final Vector3D pole, final DoublePrecisionContext precision) {
-        reset(pole);
-        this.precision = precision;
-    }
-
-    /** Build a great circle from two non-aligned points.
-     * <p>The circle is oriented from first to second point using the path smaller than \( \pi \).</p>
-     * @param first first point contained in the great circle
-     * @param second second point contained in the great circle
-     * @param precision precision context used to compare floating point values
-     */
-    public Circle(final S2Point first, final S2Point second, final DoublePrecisionContext precision) {
-        reset(first.getVector().cross(second.getVector()));
-        this.precision = precision;
-    }
-
-    /** Build a circle from its internal components.
-     * <p>The circle is oriented in the trigonometric direction around center.</p>
-     * @param pole circle pole
-     * @param x first axis in the equator plane
-     * @param y second axis in the equator plane
-     * @param precision precision context used to compare floating point values
-     */
-    private Circle(final Vector3D pole, final Vector3D x, final Vector3D y,
-            final DoublePrecisionContext precision) {
-        this.pole      = pole;
-        this.x         = x;
-        this.y         = y;
-        this.precision = precision;
-    }
-
-    /** Copy constructor.
-     * <p>The created instance is completely independent from the
-     * original instance, it is a deep copy.</p>
-     * @param circle circle to copy
-     */
-    public Circle(final Circle circle) {
-        this(circle.pole, circle.x, circle.y, circle.precision);
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public Circle copySelf() {
-        return new Circle(this);
-    }
-
-    /** Reset the instance as if built from a pole.
-     * <p>The circle is oriented in the trigonometric direction around pole.</p>
-     * @param newPole circle pole
-     */
-    public void reset(final Vector3D newPole) {
-        this.pole = newPole.normalize();
-        this.x    = newPole.orthogonal();
-        this.y    = newPole.cross(x).normalize();
-    }
-
-    /** Revert the instance.
-     */
-    public void revertSelf() {
-        // x remains the same
-        y    = y.negate();
-        pole = pole.negate();
-    }
-
-    /** Get the reverse of the instance.
-     * <p>Get a circle with reversed orientation with respect to the
-     * instance. A new object is built, the instance is untouched.</p>
-     * @return a new circle, with orientation opposite to the instance orientation
-     */
-    public Circle getReverse() {
-        return new Circle(pole.negate(), x, y.negate(), precision);
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public S2Point project(S2Point point) {
-        return toSpace(toSubSpace(point));
-    }
-
-    /** Get the object used to determine floating point equality for this region.
-     * @return the floating point precision context for the instance
-     */
-    @Override
-    public DoublePrecisionContext getPrecision() {
-        return precision;
-    }
-
-    /** {@inheritDoc}
-     * @see #getPhase(Vector3D)
-     */
-    @Override
-    public S1Point toSubSpace(final S2Point point) {
-        return S1Point.of(getPhase(point.getVector()));
-    }
-
-    /** Get the phase angle of a direction.
-     * <p>
-     * The direction may not belong to the circle as the
-     * phase is computed for the meridian plane between the circle
-     * pole and the direction.
-     * </p>
-     * @param direction direction for which phase is requested
-     * @return phase angle of the direction around the circle
-     * @see #toSubSpace(Point)
-     */
-    public double getPhase(final Vector3D direction) {
-        return Math.PI + Math.atan2(-direction.dot(y), -direction.dot(x));
-    }
-
-    /** {@inheritDoc}
-     * @see #getPointAt(double)
-     */
-    @Override
-    public S2Point toSpace(final S1Point point) {
-        return S2Point.ofVector(getPointAt(point.getAzimuth()));
-    }
-
-    /** Get a circle point from its phase around the circle.
-     * @param alpha phase around the circle
-     * @return circle point on the sphere
-     * @see #toSpace(Point)
-     * @see #getXAxis()
-     * @see #getYAxis()
-     */
-    public Vector3D getPointAt(final double alpha) {
-        return Vector3D.linearCombination(Math.cos(alpha), x, Math.sin(alpha), y);
-    }
-
-    /** Get the X axis of the circle.
-     * <p>
-     * This method returns the same value as {@link #getPointAt(double)
-     * getPointAt(0.0)} but it does not do any computation and always
-     * return the same instance.
-     * </p>
-     * @return an arbitrary x axis on the circle
-     * @see #getPointAt(double)
-     * @see #getYAxis()
-     * @see #getPole()
-     */
-    public Vector3D getXAxis() {
-        return x;
-    }
-
-    /** Get the Y axis of the circle.
-     * <p>
-     * This method returns the same value as {@link #getPointAt(double)
-     * getPointAt(0.5 * Math.PI)} but it does not do any computation and always
-     * return the same instance.
-     * </p>
-     * @return an arbitrary y axis point on the circle
-     * @see #getPointAt(double)
-     * @see #getXAxis()
-     * @see #getPole()
-     */
-    public Vector3D getYAxis() {
-        return y;
-    }
-
-    /** Get the pole of the circle.
-     * <p>
-     * As the circle is a great circle, the pole does <em>not</em>
-     * belong to it.
-     * </p>
-     * @return pole of the circle
-     * @see #getXAxis()
-     * @see #getYAxis()
-     */
-    public Vector3D getPole() {
-        return pole;
-    }
-
-    /** Get the arc of the instance that lies inside the other circle.
-     * @param other other circle
-     * @return arc of the instance that lies inside the other circle
-     */
-    public Arc getInsideArc(final Circle other) {
-        final double alpha  = getPhase(other.pole);
-        final double halfPi = 0.5 * Math.PI;
-        return new Arc(alpha - halfPi, alpha + halfPi, precision);
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public SubCircle wholeHyperplane() {
-        return new SubCircle(this, new ArcsSet(precision));
-    }
-
-    /** Build a region covering the whole space.
-     * @return a region containing the instance (really a {@link
-     * SphericalPolygonsSet SphericalPolygonsSet} instance)
-     */
-    @Override
-    public SphericalPolygonsSet wholeSpace() {
-        return new SphericalPolygonsSet(precision);
-    }
-
-    /** {@inheritDoc}
-     * @see #getOffset(Vector3D)
-     */
-    @Override
-    public double getOffset(final S2Point point) {
-        return getOffset(point.getVector());
-    }
-
-    /** Get the offset (oriented distance) of a direction.
-     * <p>The offset is defined as the angular distance between the
-     * circle center and the direction minus the circle radius. It
-     * is therefore 0 on the circle, positive for directions outside of
-     * the cone delimited by the circle, and negative inside the cone.</p>
-     * @param direction direction to check
-     * @return offset of the direction
-     * @see #getOffset(Point)
-     */
-    public double getOffset(final Vector3D direction) {
-        return pole.angle(direction) - 0.5 * Math.PI;
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public boolean sameOrientationAs(final Hyperplane<S2Point> other) {
-        final Circle otherC = (Circle) other;
-        return pole.dot(otherC.pole) >= 0.0;
-    }
-
-    /** Get a {@link org.apache.commons.geometry.core.partitioning.Transform
-     * Transform} embedding a 3D rotation.
-     * @param rotation rotation to use
-     * @return a new transform that can be applied to either {@link
-     * S2Point Point}, {@link Circle Line} or {@link
-     * org.apache.commons.geometry.core.partitioning.SubHyperplane
-     * SubHyperplane} instances
-     */
-    public static Transform<S2Point, S1Point> getTransform(final QuaternionRotation rotation) {
-        return new CircleTransform(rotation);
-    }
-
-    /** Class embedding a 3D rotation. */
-    private static class CircleTransform implements Transform<S2Point, S1Point> {
-
-        /** Underlying rotation. */
-        private final QuaternionRotation rotation;
-
-        /** Build a transform from a {@code Rotation}.
-         * @param rotation rotation to use
-         */
-        CircleTransform(final QuaternionRotation rotation) {
-            this.rotation = rotation;
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public S2Point apply(final S2Point point) {
-            return S2Point.ofVector(rotation.apply(point.getVector()));
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public Circle apply(final Hyperplane<S2Point> hyperplane) {
-            final Circle circle = (Circle) hyperplane;
-            return new Circle(rotation.apply(circle.pole),
-                              rotation.apply(circle.x),
-                              rotation.apply(circle.y),
-                              circle.precision);
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public SubHyperplane<S1Point> apply(final SubHyperplane<S1Point> sub,
-                                             final Hyperplane<S2Point> original,
-                                             final Hyperplane<S2Point> transformed) {
-            // as the circle is rotated, the limit angles are rotated too
-            return sub;
-        }
-
-    }
-
-}
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
new file mode 100644
index 0000000..763244c
--- /dev/null
+++ b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/ConvexArea2S.java
@@ -0,0 +1,305 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.twod;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.partitioning.AbstractConvexHyperplaneBoundedRegion;
+import org.apache.commons.geometry.core.partitioning.ConvexSubHyperplane;
+import org.apache.commons.geometry.core.partitioning.Hyperplane;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.threed.Vector3D;
+
+/** Class representing a convex area in 2D spherical space. The boundaries of this
+ * area, if any, are composed of convex great circle arcs.
+ */
+public final class ConvexArea2S extends AbstractConvexHyperplaneBoundedRegion<Point2S, GreatArc> {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20191021L;
+
+    /** Instance representing the full spherical area. */
+    private static final ConvexArea2S FULL = new ConvexArea2S(Collections.emptyList());
+
+    /** Constant containing the area of the full spherical space. */
+    private static final double FULL_SIZE = 4 * Geometry.PI;
+
+    /** Constant containing the area of half of the spherical space. */
+    private static final double HALF_SIZE = Geometry.TWO_PI;
+
+    /** 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.
+     * @param boundaries the boundaries of the convex area
+     */
+    private ConvexArea2S(final List<GreatArc> boundaries) {
+        super(boundaries);
+    }
+
+    /** Get a path instance representing the boundary of the area. The path is oriented
+     * so that the minus sides of the arcs lie on the inside of the area.
+     * @return the boundary path of the area
+     */
+    public GreatArcPath getBoundaryPath() {
+        final List<GreatArcPath> paths = InteriorAngleGreatArcConnector.connectMinimized(getBoundaries());
+        if (paths.isEmpty()) {
+            return GreatArcPath.empty();
+        }
+
+        return paths.get(0);
+    }
+
+    /** Get an array of interior angles for the area. An empty array is returned if there
+     * are no boundary intersections (ie, it has only one boundary or no boundaries at all).
+     *
+     * <p>The order of the angles corresponds with the order of the boundaries returned
+     * by {@link #getBoundaries()}: if {@code i} is an index into the boundaries list,
+     * then {@code angles[i]} is the angle between boundaries {@code i} and {@code (i+1) % boundariesSize}.</p>
+     * @return an array of interior angles for the area
+     */
+    public double[] getInteriorAngles() {
+        final List<GreatArc> arcs = getBoundaryPath().getArcs();
+        final int numSides = arcs.size();
+
+        if (numSides < 2) {
+            return new double[0];
+        }
+
+        final double[] angles = new double[numSides];
+
+        GreatArc current;
+        GreatArc next;
+        for (int i = 0; i < numSides; ++i) {
+            current = arcs.get(i);
+            next = arcs.get((i + 1) % numSides);
+
+            angles[i] = Geometry.PI - current.getCircle()
+                    .angle(next.getCircle(), current.getEndPoint());
+        }
+
+        return angles;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public double getSize() {
+        final int numSides = getBoundaries().size();
+
+        if (numSides == 0) {
+            return FULL_SIZE;
+        } else if (numSides == 1) {
+            return HALF_SIZE;
+        } else {
+            // use the extended version of Girard's theorem
+            // https://en.wikipedia.org/wiki/Spherical_trigonometry#Girard's_theorem
+            final double[] angles = getInteriorAngles();
+            final double sum = Arrays.stream(angles).sum();
+
+            return sum - ((angles.length - 2) * Geometry.PI);
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Point2S getBarycenter() {
+        List<GreatArc> arcs = getBoundaries();
+        int numSides = arcs.size();
+
+        if (numSides == 0) {
+            // full space; no barycenter
+            return null;
+        } else if (numSides == 1) {
+            // hemisphere; barycenter is the pole of the hemisphere
+            return arcs.get(0).getCircle().getPolePoint();
+        } else {
+            // 2 or more sides; use an extension of the approach outlined here:
+            // https://archive.org/details/centroidinertiat00broc
+            // In short, the barycenter is the sum of the pole vectors of each side
+            // multiplied by their arc lengths.
+            Vector3D barycenter = Vector3D.ZERO;
+
+            for (GreatArc arc : getBoundaries()) {
+                barycenter = Vector3D.linearCombination(
+                        1, barycenter,
+                        arc.getSize(), arc.getCircle().getPole());
+            }
+
+            return Point2S.from(barycenter);
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Split<ConvexArea2S> split(final Hyperplane<Point2S> splitter) {
+        return splitInternal(splitter, this, GreatArc.class, ConvexArea2S::new);
+    }
+
+    /** Return a new instance transformed by the argument.
+     * @param transform transform to apply
+     * @return a new instance transformed by the argument
+     */
+    public ConvexArea2S transform(final Transform<Point2S> transform) {
+        return transformInternal(transform, this, GreatArc.class, ConvexArea2S::new);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public GreatArc trim(final ConvexSubHyperplane<Point2S> convexSubHyperplane) {
+        return (GreatArc) super.trim(convexSubHyperplane);
+    }
+
+    /** Return a BSP tree instance representing the same region as the current instance.
+     * @return a BSP tree instance representing the same region as the current instance
+     */
+    public RegionBSPTree2S toTree() {
+        return RegionBSPTree2S.from(this);
+    }
+
+    /** Return an instance representing the full spherical 2D space.
+     * @return an instance representing the full spherical 2D space.
+     */
+    public static ConvexArea2S full() {
+        return FULL;
+    }
+
+    /** Construct a convex area by creating great circles between adjacent vertices. The vertices must be given
+     * in a counter-clockwise around order the interior of the shape. If the area is intended to be closed, the
+     * beginning point must be repeated at the end of the path.
+     * @param vertices vertices to use to construct the area
+     * @param precision precision context used to create new great circle instances
+     * @return a convex area constructed using great circles between adjacent vertices
+     * @see #fromVertexLoop(Collection, DoublePrecisionContext)
+     */
+    public static ConvexArea2S fromVertices(final Collection<Point2S> vertices,
+            final DoublePrecisionContext precision) {
+        return fromVertices(vertices, false, precision);
+    }
+
+    /** Construct a convex area by creating great circles between adjacent vertices. An implicit great circle is
+     * created between the last vertex given and the first one, if needed. The vertices must be given in a
+     * counter-clockwise around order the interior of the shape.
+     * @param vertices vertices to use to construct the area
+     * @param precision precision context used to create new great circles instances
+     * @return a convex area constructed using great circles between adjacent vertices
+     * @see #fromVertices(Collection, DoublePrecisionContext)
+     */
+    public static ConvexArea2S fromVertexLoop(final Collection<Point2S> vertices,
+            final DoublePrecisionContext precision) {
+        return fromVertices(vertices, true, precision);
+    }
+
+    /** Construct a convex area from great circles between adjacent vertices.
+     * @param vertices vertices to use to construct the area
+     * @param close if true, an additional great circle will be created between the last and first vertex
+     * @param precision precision context used to create new great circle instances
+     * @return a convex area constructed using great circles between adjacent vertices
+     */
+    public static ConvexArea2S fromVertices(final Collection<Point2S> vertices, final boolean close,
+            final DoublePrecisionContext precision) {
+
+        if (vertices.isEmpty()) {
+            return full();
+        }
+
+        final List<GreatCircle> circles = new ArrayList<>();
+
+        Point2S first = null;
+        Point2S prev = null;
+        Point2S cur = null;
+
+        for (Point2S vertex : vertices) {
+            cur = vertex;
+
+            if (first == null) {
+                first = cur;
+            }
+
+            if (prev != null && !cur.eq(prev, precision)) {
+                circles.add(GreatCircle.fromPoints(prev, cur, precision));
+            }
+
+            prev = cur;
+        }
+
+        if (close && cur != null && !cur.eq(first, precision)) {
+            circles.add(GreatCircle.fromPoints(cur, first, precision));
+        }
+
+        if (!vertices.isEmpty() && circles.isEmpty()) {
+            throw new IllegalStateException("Unable to create convex area: only a single unique vertex provided");
+        }
+
+        return fromBounds(circles);
+    }
+
+    /** Construct a convex area from an arc path. The area represents the intersection of all of the negative
+     * half-spaces of the great circles in the path. The boundaries of the returned area may therefore not match
+     * the arcs in the path.
+     * @param path path to construct the area from
+     * @return a convex area constructed from the great circles in the given path
+     */
+    public static ConvexArea2S fromPath(final GreatArcPath path) {
+        final List<GreatCircle> bounds = path.getArcs().stream()
+                .map(a -> a.getCircle())
+                .collect(Collectors.toList());
+
+        return fromBounds(bounds);
+    }
+
+    /** Create a convex area formed by the intersection of the negative half-spaces of the
+     * given bounding great circles. The returned instance represents the area that is on the
+     * minus side of all of the given circles. Note that this method does not support areas
+     * of zero size (ie, infinitely thin areas or points.)
+     * @param bounds great circles used to define the convex area
+     * @return a new convex area instance representing the area on the minus side of all
+     *      of the bounding great circles or an instance representing the full area if no
+     *      circles are given
+     * @throws org.apache.commons.geometry.core.exception.GeometryException if the given set of bounding great
+     *      circles do not form a convex area, meaning that there is no region that is on the minus side of all
+     *      of the bounding circles.
+     */
+    public static ConvexArea2S fromBounds(final GreatCircle... bounds) {
+        return fromBounds(Arrays.asList(bounds));
+    }
+
+    /** Create a convex area formed by the intersection of the negative half-spaces of the
+     * given bounding great circles. The returned instance represents the area that is on the
+     * minus side of all of the given circles. Note that this method does not support areas
+     * of zero size (ie, infinitely thin areas or points.)
+     * @param bounds great circles used to define the convex area
+     * @return a new convex area instance representing the area on the minus side of all
+     *      of the bounding great circles or an instance representing the full area if no
+     *      circles are given
+     * @throws org.apache.commons.geometry.core.exception.GeometryException if the given set of bounding great
+     *      circles do not form a convex area, meaning that there is no region that is on the minus side of all
+     *      of the bounding circles.
+     */
+    public static ConvexArea2S fromBounds(final Iterable<GreatCircle> bounds) {
+        final List<GreatArc> arcs = new ConvexRegionBoundaryBuilder<>(GreatArc.class).build(bounds);
+        return arcs.isEmpty() ?
+                full() :
+                new ConvexArea2S(arcs);
+    }
+}
diff --git a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/Edge.java b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/Edge.java
deleted file mode 100644
index fd0181e..0000000
--- a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/Edge.java
+++ /dev/null
@@ -1,222 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.twod;
-
-import java.util.List;
-
-import org.apache.commons.geometry.core.Geometry;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.geometry.euclidean.threed.Vector3D;
-import org.apache.commons.geometry.spherical.oned.Arc;
-import org.apache.commons.numbers.angle.PlaneAngleRadians;
-
-/** Spherical polygons boundary edge.
- * @see SphericalPolygonsSet#getBoundaryLoops()
- * @see Vertex
- */
-public class Edge {
-
-    /** Start vertex. */
-    private final Vertex start;
-
-    /** End vertex. */
-    private Vertex end;
-
-    /** Length of the arc. */
-    private final double length;
-
-    /** Circle supporting the edge. */
-    private final Circle circle;
-
-    /** Build an edge not contained in any node yet.
-     * @param start start vertex
-     * @param end end vertex
-     * @param length length of the arc (it can be greater than \( \pi \))
-     * @param circle circle supporting the edge
-     */
-    Edge(final Vertex start, final Vertex end, final double length, final Circle circle) {
-
-        this.start  = start;
-        this.end    = end;
-        this.length = length;
-        this.circle = circle;
-
-        // connect the vertices back to the edge
-        start.setOutgoing(this);
-        end.setIncoming(this);
-
-    }
-
-    /** Get start vertex.
-     * @return start vertex
-     */
-    public Vertex getStart() {
-        return start;
-    }
-
-    /** Get end vertex.
-     * @return end vertex
-     */
-    public Vertex getEnd() {
-        return end;
-    }
-
-    /** Get the length of the arc.
-     * @return length of the arc (can be greater than \( \pi \))
-     */
-    public double getLength() {
-        return length;
-    }
-
-    /** Get the circle supporting this edge.
-     * @return circle supporting this edge
-     */
-    public Circle getCircle() {
-        return circle;
-    }
-
-    /** Get an intermediate point.
-     * <p>
-     * The angle along the edge should normally be between 0 and {@link #getLength()}
-     * in order to remain within edge limits. However, there are no checks on the
-     * value of the angle, so user can rebuild the full circle on which an edge is
-     * defined if they want.
-     * </p>
-     * @param alpha angle along the edge, counted from {@link #getStart()}
-     * @return an intermediate point
-     */
-    public Vector3D getPointAt(final double alpha) {
-        return circle.getPointAt(alpha + circle.getPhase(start.getLocation().getVector()));
-    }
-
-    /** Connect the instance with a following edge.
-     * @param next edge following the instance
-     */
-    void setNextEdge(final Edge next) {
-        end = next.getStart();
-        end.setIncoming(this);
-        end.bindWith(getCircle());
-    }
-
-    /** Split the edge.
-     * <p>
-     * Once split, this edge is not referenced anymore by the vertices,
-     * it is replaced by the two or three sub-edges and intermediate splitting
-     * vertices are introduced to connect these sub-edges together.
-     * </p>
-     * @param splitCircle circle splitting the edge in several parts
-     * @param outsideList list where to put parts that are outside of the split circle
-     * @param insideList list where to put parts that are inside the split circle
-     */
-    void split(final Circle splitCircle,
-                       final List<Edge> outsideList, final List<Edge> insideList) {
-
-        // get the inside arc, synchronizing its phase with the edge itself
-        final double edgeStart        = circle.getPhase(start.getLocation().getVector());
-        final Arc    arc              = circle.getInsideArc(splitCircle);
-        final double arcRelativeStart = PlaneAngleRadians.normalize(arc.getInf(), edgeStart + Math.PI) - edgeStart;
-        final double arcRelativeEnd   = arcRelativeStart + arc.getSize();
-        final double unwrappedEnd     = arcRelativeEnd - Geometry.TWO_PI;
-
-        // build the sub-edges
-        final DoublePrecisionContext precision = circle.getPrecision();
-        Vertex previousVertex = start;
-        if (precision.compare(unwrappedEnd, length) >= 0) {
-
-            // the edge is entirely contained inside the circle
-            // we don't split anything
-            insideList.add(this);
-
-        } else {
-
-            // there are at least some parts of the edge that should be outside
-            // (even is they are later be filtered out as being too small)
-            double alreadyManagedLength = 0;
-            if (unwrappedEnd >= 0) {
-                // the start of the edge is inside the circle
-                previousVertex = addSubEdge(previousVertex,
-                                            new Vertex(S2Point.ofVector(circle.getPointAt(edgeStart + unwrappedEnd))),
-                                            unwrappedEnd, insideList, splitCircle);
-                alreadyManagedLength = unwrappedEnd;
-            }
-
-            if (precision.compare(arcRelativeStart, length) >= 0) {
-                // the edge ends while still outside of the circle
-                if (unwrappedEnd >= 0) {
-                    previousVertex = addSubEdge(previousVertex, end,
-                                                length - alreadyManagedLength, outsideList, splitCircle);
-                } else {
-                    // the edge is entirely outside of the circle
-                    // we don't split anything
-                    outsideList.add(this);
-                }
-            } else {
-                // the edge is long enough to enter inside the circle
-                previousVertex = addSubEdge(previousVertex,
-                                            new Vertex(S2Point.ofVector(circle.getPointAt(edgeStart + arcRelativeStart))),
-                                            arcRelativeStart - alreadyManagedLength, outsideList, splitCircle);
-                alreadyManagedLength = arcRelativeStart;
-
-                if (precision.compare(arcRelativeEnd, length) >= 0) {
-                    // the edge ends while still inside of the circle
-                    previousVertex = addSubEdge(previousVertex, end,
-                                                length - alreadyManagedLength, insideList, splitCircle);
-                } else {
-                    // the edge is long enough to exit outside of the circle
-                    previousVertex = addSubEdge(previousVertex,
-                                                new Vertex(S2Point.ofVector(circle.getPointAt(edgeStart + arcRelativeStart))),
-                                                arcRelativeStart - alreadyManagedLength, insideList, splitCircle);
-                    alreadyManagedLength = arcRelativeStart;
-                    previousVertex = addSubEdge(previousVertex, end,
-                                                length - alreadyManagedLength, outsideList, splitCircle);
-                }
-            }
-
-        }
-
-    }
-
-    /** Add a sub-edge to a list if long enough.
-     * <p>
-     * If the length of the sub-edge to add is smaller than the {@link Circle#getTolerance()}
-     * tolerance of the support circle, it will be ignored.
-     * </p>
-     * @param subStart start of the sub-edge
-     * @param subEnd end of the sub-edge
-     * @param subLength length of the sub-edge
-     * @param splitCircle circle splitting the edge in several parts
-     * @param list list where to put the sub-edge
-     * @return end vertex of the edge ({@code subEnd} if the edge was long enough and really
-     * added, {@code subStart} if the edge was too small and therefore ignored)
-     */
-    private Vertex addSubEdge(final Vertex subStart, final Vertex subEnd, final double subLength,
-                              final List<Edge> list, final Circle splitCircle) {
-
-        if (circle.getPrecision().eqZero(subLength)) {
-            // the edge is too short, we ignore it
-            return subStart;
-        }
-
-        // really add the edge
-        subEnd.bindWith(splitCircle);
-        final Edge edge = new Edge(subStart, subEnd, subLength, circle);
-        list.add(edge);
-        return subEnd;
-
-    }
-
-}
diff --git a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/EdgesBuilder.java b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/EdgesBuilder.java
deleted file mode 100644
index 6247eef..0000000
--- a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/EdgesBuilder.java
+++ /dev/null
@@ -1,170 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.twod;
-
-import java.util.ArrayList;
-import java.util.IdentityHashMap;
-import java.util.List;
-import java.util.Map;
-
-import org.apache.commons.geometry.core.partitioning.BSPTree;
-import org.apache.commons.geometry.core.partitioning.BSPTreeVisitor;
-import org.apache.commons.geometry.core.partitioning.BoundaryAttribute;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.geometry.euclidean.threed.Vector3D;
-import org.apache.commons.geometry.spherical.oned.Arc;
-import org.apache.commons.geometry.spherical.oned.ArcsSet;
-import org.apache.commons.geometry.spherical.oned.S1Point;
-
-/** Visitor building edges.
- */
-class EdgesBuilder implements BSPTreeVisitor<S2Point> {
-
-    /** Root of the tree. */
-    private final BSPTree<S2Point> root;
-
-    /** Precision context used to determine floating point equality. */
-    private final DoublePrecisionContext precision;
-
-    /** Built edges and their associated nodes. */
-    private final Map<Edge, BSPTree<S2Point>> edgeToNode;
-
-    /** Reversed map. */
-    private final Map<BSPTree<S2Point>, List<Edge>> nodeToEdgesList;
-
-    /** Simple constructor.
-     * @param root tree root
-     * @param precision precision context used to compare floating point values
-     */
-    EdgesBuilder(final BSPTree<S2Point> root, final DoublePrecisionContext precision) {
-        this.root            = root;
-        this.precision       = precision;
-        this.edgeToNode      = new IdentityHashMap<>();
-        this.nodeToEdgesList = new IdentityHashMap<>();
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public Order visitOrder(final BSPTree<S2Point> node) {
-        return Order.MINUS_SUB_PLUS;
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public void visitInternalNode(final BSPTree<S2Point> node) {
-        nodeToEdgesList.put(node, new ArrayList<Edge>());
-        @SuppressWarnings("unchecked")
-        final BoundaryAttribute<S2Point> attribute = (BoundaryAttribute<S2Point>) node.getAttribute();
-        if (attribute.getPlusOutside() != null) {
-            addContribution((SubCircle) attribute.getPlusOutside(), false, node);
-        }
-        if (attribute.getPlusInside() != null) {
-            addContribution((SubCircle) attribute.getPlusInside(), true, node);
-        }
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public void visitLeafNode(final BSPTree<S2Point> node) {
-    }
-
-    /** Add the contribution of a boundary edge.
-     * @param sub boundary facet
-     * @param reversed if true, the facet has the inside on its plus side
-     * @param node node to which the edge belongs
-     */
-    private void addContribution(final SubCircle sub, final boolean reversed,
-                                 final BSPTree<S2Point> node) {
-        final Circle circle  = (Circle) sub.getHyperplane();
-        final List<Arc> arcs = ((ArcsSet) sub.getRemainingRegion()).asList();
-        for (final Arc a : arcs) {
-            final Vertex start = new Vertex(circle.toSpace(S1Point.of(a.getInf())));
-            final Vertex end   = new Vertex(circle.toSpace(S1Point.of(a.getSup())));
-            start.bindWith(circle);
-            end.bindWith(circle);
-            final Edge edge;
-            if (reversed) {
-                edge = new Edge(end, start, a.getSize(), circle.getReverse());
-            } else {
-                edge = new Edge(start, end, a.getSize(), circle);
-            }
-            edgeToNode.put(edge, node);
-            nodeToEdgesList.get(node).add(edge);
-        }
-    }
-
-    /** Get the edge that should naturally follow another one.
-     * @param previous edge to be continued
-     * @return other edge, starting where the previous one ends (they
-     * have not been connected yet)
-     * @exception IllegalStateException if there is not a single other edge
-     */
-    private Edge getFollowingEdge(final Edge previous)
-        throws IllegalStateException {
-
-        // get the candidate nodes
-        final S2Point point = previous.getEnd().getLocation();
-        final List<BSPTree<S2Point>> candidates = root.getCloseCuts(point, precision.getMaxZero());
-
-        // the following edge we are looking for must start from one of the candidates nodes
-        double closest = precision.getMaxZero();
-        Edge following = null;
-        for (final BSPTree<S2Point> node : candidates) {
-            for (final Edge edge : nodeToEdgesList.get(node)) {
-                if (edge != previous && edge.getStart().getIncoming() == null) {
-                    final Vector3D edgeStart = edge.getStart().getLocation().getVector();
-                    final double gap         = point.getVector().angle(edgeStart);
-                    if (gap <= closest) {
-                        closest   = gap;
-                        following = edge;
-                    }
-                }
-            }
-        }
-
-        if (following == null) {
-            final Vector3D previousStart = previous.getStart().getLocation().getVector();
-            if (precision.eqZero(point.getVector().angle(previousStart))) {
-                // the edge connects back to itself
-                return previous;
-            }
-
-            // this should never happen
-            throw new IllegalStateException("An outline boundary loop is open");
-
-        }
-
-        return following;
-
-    }
-
-    /** Get the boundary edges.
-     * @return boundary edges
-     * @exception IllegalStateException if there is not a single other edge
-     */
-    public List<Edge> getEdges() {
-
-        // connect the edges
-        for (final Edge previous : edgeToNode.keySet()) {
-            previous.setNextEdge(getFollowingEdge(previous));
-        }
-
-        return new ArrayList<>(edgeToNode.keySet());
-
-    }
-
-}
diff --git a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/GreatArc.java b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/GreatArc.java
new file mode 100644
index 0000000..faee91c
--- /dev/null
+++ b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/GreatArc.java
@@ -0,0 +1,227 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.twod;
+
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.partitioning.ConvexSubHyperplane;
+import org.apache.commons.geometry.core.partitioning.Hyperplane;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.SplitLocation;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.spherical.oned.AngularInterval;
+import org.apache.commons.geometry.spherical.oned.CutAngle;
+import org.apache.commons.geometry.spherical.oned.Point1S;
+import org.apache.commons.geometry.spherical.oned.Transform1S;
+
+/** Class representing a single, <em>convex</em> angular interval in a {@link GreatCircle}. Convex
+ * angular intervals are those where the shortest path between all pairs of points in the
+ * interval are completely contained in the interval. In the case of paths that tie for the
+ * shortest length, it is sufficient that one of the paths is completely contained in the
+ * interval. In spherical 2D space, convex arcs either fill the entire great circle or have
+ * an angular size of less than or equal to {@code pi} radians.
+ *
+ * <p>Instances of this class are guaranteed to be immutable.</p>
+ */
+public final class GreatArc extends AbstractSubGreatCircle implements ConvexSubHyperplane<Point2S> {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20191005L;
+
+    /** The interval representing the region of the great circle contained in the arc.
+     */
+    private final AngularInterval.Convex interval;
+
+    /** Create a new instance from a great circle and the interval embedded in it.
+     * @param circle defining great circle instance
+     * @param interval convex angular interval embedded in the great circle
+     */
+    private GreatArc(final GreatCircle circle, final AngularInterval.Convex interval) {
+        super(circle);
+
+        this.interval = interval;
+    }
+
+    /** Return the start point of the arc, or null if the arc represents the full space.
+     * @return the start point of the arc, or null if the arc represents the full space.
+     */
+    public Point2S getStartPoint() {
+        if (!interval.isFull()) {
+            return getCircle().toSpace(interval.getMinBoundary().getPoint());
+        }
+
+        return null;
+    }
+
+    /** Return the end point of the arc, or null if the arc represents the full space.
+     * @return the end point of the arc, or null if the arc represents the full space.
+     */
+    public Point2S getEndPoint() {
+        if (!interval.isFull()) {
+            return getCircle().toSpace(interval.getMaxBoundary().getPoint());
+        }
+
+        return null;
+    }
+
+    /** Return the midpoint of the arc, or null if the arc represents the full space.
+     * @return the midpoint of the arc, or null if the arc represents the full space.
+     */
+    public Point2S getMidPoint() {
+        if (!interval.isFull()) {
+            return getCircle().toSpace(interval.getMidPoint());
+        }
+
+        return null;
+    }
+
+    /** Get the angular interval for the arc.
+     * @return the angular interval for the arc
+     * @see #getSubspaceRegion()
+     */
+    public AngularInterval.Convex getInterval() {
+        return interval;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public AngularInterval.Convex getSubspaceRegion() {
+        return getInterval();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public List<GreatArc> toConvex() {
+        return Collections.singletonList(this);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Split<GreatArc> split(final Hyperplane<Point2S> splitter) {
+        final GreatCircle splitterCircle = (GreatCircle) splitter;
+        final GreatCircle thisCircle = getCircle();
+
+        final Point2S intersection = splitterCircle.intersection(thisCircle);
+
+        GreatArc minus = null;
+        GreatArc plus = null;
+
+        if (intersection != null) {
+            // use a negative-facing cut angle to account for the fact that the great circle
+            // poles point to the minus side of the circle
+            final CutAngle subSplitter = CutAngle.createNegativeFacing(
+                    thisCircle.toSubspace(intersection), splitterCircle.getPrecision());
+
+            final Split<AngularInterval.Convex> subSplit = interval.splitDiameter(subSplitter);
+            final SplitLocation subLoc = subSplit.getLocation();
+
+            if (subLoc == SplitLocation.MINUS) {
+                minus = this;
+            } else if (subLoc == SplitLocation.PLUS) {
+                plus = this;
+            } else if (subLoc == SplitLocation.BOTH) {
+                minus = GreatArc.fromInterval(thisCircle, subSplit.getMinus());
+                plus = GreatArc.fromInterval(thisCircle, subSplit.getPlus());
+            }
+        }
+
+        return new Split<>(minus, plus);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public GreatArc transform(final Transform<Point2S> transform) {
+        return new GreatArc(getCircle().transform(transform), interval);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public GreatArc reverse() {
+        return new GreatArc(
+                getCircle().reverse(),
+                interval.transform(Transform1S.createNegation()));
+    }
+
+    /** Return a string representation of this great arc.
+     *
+     * <p>In order to keep the string representation short but useful, the exact format of the return
+     * value depends on the properties of the arc. See below for examples.
+     *
+     * <ul>
+     *      <li>Full arc
+     *          <ul>
+     *              <li>{@code GreatArc[full= true, circle= GreatCircle[pole= (0.0, 0.0, 1.0), x= (1.0, 0.0, 0.0), y= (0.0, 1.0, 0.0)]}</li>
+     *          </ul>
+     *      </li>
+     *      <li>Non-full arc
+     *          <ul>
+     *              <li>{@code GreatArc[start= (1.0, 1.5707963267948966), end= (2.0, 1.5707963267948966)}</li>
+     *          </ul>
+     *      </li>
+     * </ul>
+     */
+    @Override
+    public String toString() {
+        final StringBuilder sb = new StringBuilder();
+        sb.append(this.getClass().getSimpleName())
+            .append("[");
+
+        if (isFull()) {
+            sb.append("full= true, circle= ")
+                .append(getCircle());
+        } else {
+            sb.append("start= ")
+                .append(getStartPoint())
+                .append(", end= ")
+                .append(getEndPoint());
+        }
+
+        return sb.toString();
+    }
+
+    /** Construct an arc along the shortest path between the given points. The underlying
+     * great circle is oriented in the direction from {@code start} to {@code end}.
+     * @param start start point for the interval
+     * @param end end point point for the interval
+     * @param precision precision context used to compare floating point numbers
+     * @return an arc representing the shortest path between the given points
+     * @throws org.apache.commons.geometry.core.exception.GeometryException if either of the given points is
+     *      NaN or infinite, or if the given points are equal or antipodal as evaluated by the given precision context
+     * @see GreatCircle#fromPoints(Point2S, Point2S, org.apache.commons.geometry.core.precision.DoublePrecisionContext)
+     */
+    public static GreatArc fromPoints(final Point2S start, final Point2S end, final DoublePrecisionContext precision) {
+        final GreatCircle circle = GreatCircle.fromPoints(start, end, precision);
+
+        final Point1S subspaceStart = circle.toSubspace(start);
+        final Point1S subspaceEnd = circle.toSubspace(end);
+        final AngularInterval.Convex interval = AngularInterval.Convex.of(subspaceStart, subspaceEnd, precision);
+
+        return fromInterval(circle, interval);
+    }
+
+    /** Construct an arc from a great circle and an angular interval.
+     * @param circle circle defining the arc
+     * @param interval interval representing the portion of the circle contained
+     *      in the arc
+     * @return an arc created from the given great circle and interval
+     */
+    public static GreatArc fromInterval(final GreatCircle circle, final AngularInterval.Convex interval) {
+        return new GreatArc(circle, interval);
+    }
+}
diff --git a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/GreatArcPath.java b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/GreatArcPath.java
new file mode 100644
index 0000000..87cbd3b
--- /dev/null
+++ b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/GreatArcPath.java
@@ -0,0 +1,688 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.twod;
+
+import java.io.Serializable;
+import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+
+/** Class representing a connected sequence of {@link GreatArc} instances.
+ */
+public final class GreatArcPath implements Iterable<GreatArc>, Serializable {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20191028L;
+
+    /** Instance containing no arcs. */
+    private static final GreatArcPath EMPTY = new GreatArcPath(Collections.emptyList());
+
+    /** Arcs comprising the instance. */
+    private final List<GreatArc> arcs;
+
+    /** Simple constructor. No validation is performed on the input arc.
+     * @param arcs arcs for the path, in connection order
+     */
+    private GreatArcPath(final List<GreatArc> arcs) {
+        this.arcs = Collections.unmodifiableList(arcs);
+    }
+
+    /** Get the arcs in path.
+     * @return the arcs in the path
+     */
+    public List<GreatArc> getArcs() {
+        return arcs;
+    }
+
+    /** Get the start arc for the path or null if the path is empty.
+     * @return the start arc for the path or null if the path is empty
+     */
+    public GreatArc getStartArc() {
+        if (!isEmpty()) {
+            return arcs.get(0);
+        }
+        return null;
+    }
+
+    /** Get the end arc for the path or null if the path is empty.
+     * @return the end arc for the path or null if the path is empty
+     */
+    public GreatArc getEndArc() {
+        if (!isEmpty()) {
+            return arcs.get(arcs.size() - 1);
+        }
+        return null;
+    }
+
+    /** Get the start vertex for the path or null if the path is empty
+     * or consists of a single, full arc.
+     * @return the start vertex for the path
+     */
+    public Point2S getStartVertex() {
+        final GreatArc arc = getStartArc();
+        return (arc != null) ? arc.getStartPoint() : null;
+    }
+
+    /** Get the end vertex for the path or null if the path is empty
+     * or consists of a single, full arc.
+     * @return the end vertex for the path
+     */
+    public Point2S getEndVertex() {
+        final GreatArc arc = getEndArc();
+        return (arc != null) ? arc.getEndPoint() : null;
+    }
+
+    /** Get the vertices contained in the path in the order they appear.
+     * Closed paths contain the start vertex at the beginning of the list
+     * as well as the end.
+     * @return the vertices contained in the path in order they appear
+     */
+    public List<Point2S> getVertices() {
+        final List<Point2S> vertices = new ArrayList<>();
+
+        Point2S pt;
+
+        // add the start point, if present
+        pt = getStartVertex();
+        if (pt != null) {
+            vertices.add(pt);
+        }
+
+        // add end points
+        for (GreatArc arc : arcs) {
+            pt = arc.getEndPoint();
+            if (pt != null) {
+                vertices.add(pt);
+            }
+        }
+
+        return vertices;
+    }
+
+    /** Return true if the path does not contain any arcs.
+     * @return true if the path does not contain any arcs
+     */
+    public boolean isEmpty() {
+        return arcs.isEmpty();
+    }
+
+    /** Return true if the path is closed, meaning that the end
+     * point for the last arc is equal to the start point
+     * for the path.
+     * @return true if the end point for the last arc is
+     *      equal to the start point for the path
+     */
+    public boolean isClosed() {
+        final GreatArc endArc = getEndArc();
+
+        if (endArc != null) {
+            final Point2S start = getStartVertex();
+            final Point2S end = endArc.getEndPoint();
+
+            return start != null && end != null && start.eq(end, endArc.getPrecision());
+        }
+
+        return false;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Iterator<GreatArc> iterator() {
+        return arcs.iterator();
+    }
+
+    /** Construct a {@link RegionBSPTree2S} from the arcs in this instance.
+     * @return a bsp tree constructed from the arcs in this instance
+     */
+    public RegionBSPTree2S toTree() {
+        RegionBSPTree2S tree = RegionBSPTree2S.empty();
+        tree.insert(this);
+
+        return tree;
+    }
+
+    /** Return a string representation of this arc path instance.
+    *
+    * <p>In order to keep the string representation short but useful, the exact format of the return
+    * value depends on the properties of the path. See below for examples.
+    *
+    * <ul>
+    *      <li>Empty path
+    *          <ul>
+    *              <li>{@code GreatArcPath[empty= true]}</li>
+    *          </ul>
+    *      </li>
+    *      <li>Single, full arc
+    *          <ul>
+    *              <li>{@code GreatArcPath[full= true, circle= GreatCircle[pole= (0.0, 0.0, 1.0),
+    *              x= (0.0, 1.0, -0.0), y= (-1.0, 0.0, 0.0)]]}</li>
+    *          </ul>
+    *      </li>
+    *      <li>One or more non-full arcs
+    *          <ul>
+    *              <li>{@code GreatArcPath[vertices= [(0.0, 1.5707963267948966),
+    *              (1.5707963267948966, 1.5707963267948966)]]}</li>
+    *          </ul>
+    *      </li>
+    * </ul>
+    */
+    @Override
+    public String toString() {
+        final StringBuilder sb = new StringBuilder();
+        sb.append(this.getClass().getSimpleName())
+            .append('[');
+
+        if (isEmpty()) {
+            sb.append("empty= true");
+        } else if (arcs.size() == 1 && arcs.get(0).isFull()) {
+            sb.append("full= true, circle= ")
+                .append(arcs.get(0).getCircle());
+        } else {
+            sb.append("vertices= ")
+                .append(getVertices());
+        }
+
+        sb.append("]");
+
+        return sb.toString();
+    }
+
+    /** Construct a new path from the given arcs.
+     * @param arcs arc instance to use to construct the path
+     * @return a new instance constructed from the given arc instances
+     */
+    public static GreatArcPath fromArcs(final GreatArc... arcs) {
+        return fromArcs(Arrays.asList(arcs));
+    }
+
+    /** Construct a new path from the given arcs.
+     * @param arcs arc instance to use to construct the path
+     * @return a new instance constructed from the given arc instances
+     */
+    public static GreatArcPath fromArcs(final Collection<GreatArc> arcs) {
+        final Builder builder = builder(null);
+        for (GreatArc arc : arcs) {
+            builder.append(arc);
+        }
+
+        return builder.build();
+    }
+
+    /** Return a new path formed by connecting the given vertices. An additional arc is added
+     * from the last point to the first point to construct a loop, if the two points are not
+     * already considered equal by the given precision context. This method is equivalent
+     * to calling {@link #fromVertices(Collection, boolean, DoublePrecisionContext)
+     * fromPoints(points, true, precision)}.
+     * @param vertices the points to construct the path from
+     * @param precision precision precision context used to construct the arc instances for the
+     *      path
+     * @return a new path formed by connecting the given vertices
+     * @see #fromVertices(Collection, boolean, DoublePrecisionContext)
+     */
+    public static GreatArcPath fromVertexLoop(final Collection<Point2S> vertices,
+            final DoublePrecisionContext precision) {
+        return fromVertices(vertices, true, precision);
+    }
+
+    /** Return a new path formed by connecting the given vertices. No additional arc
+     * is inserted to connect the last point to the first. This method is equivalent
+     * to calling {@link #fromVertices(Collection, boolean, DoublePrecisionContext)
+     * fromPoint(points, false, precision)}.
+     * @param vertices the points to construct the path from
+     * @param precision precision context used to construct the arc instances for the
+     *      path
+     * @return a new path formed by connecting the given vertices
+     * @see #fromVertices(Collection, boolean, DoublePrecisionContext)
+     */
+    public static GreatArcPath fromVertices(final Collection<Point2S> vertices,
+            final DoublePrecisionContext precision) {
+        return fromVertices(vertices, false, precision);
+    }
+
+    /** Return a new path formed by connecting the given vertices.
+     * @param vertices the points to construct the path from
+     * @param close if true, then an additional arc will be added from the last point
+     *      to the first, if the points are not already considered equal by the given
+     *      precision context
+     * @param precision precision context used to construct the arc instances for the
+     *      path
+     * @return a new path formed by connecting the given points
+     */
+    public static GreatArcPath fromVertices(final Collection<Point2S> vertices, final boolean close,
+            final DoublePrecisionContext precision) {
+
+        return builder(precision)
+                .appendVertices(vertices)
+                .build(close);
+    }
+
+    /** Return a {@link Builder} instance configured with the given precision
+     * context. The precision context is used when building arcs from points
+     * and may be omitted if raw points are not used.
+     * @param precision precision context to use when building arcs from
+     *      raw points; may be null if raw points are not used.
+     * @return a new {@link Builder} instance
+     */
+    public static Builder builder(final DoublePrecisionContext precision) {
+        return new Builder(precision);
+    }
+
+    /** Get an instance containing no arcs.
+     * @return an instance containing no arcs
+     */
+    public static GreatArcPath empty() {
+        return EMPTY;
+    }
+
+    /** Class used to build arc paths.
+     */
+    public static final class Builder implements Serializable {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20191031L;
+
+        /** Arcs appended to the path. */
+        private List<GreatArc> appendedArcs = null;
+
+        /** Arcs prepended to the path. */
+        private List<GreatArc> prependedArcs = null;
+
+        /** Precision context used when creating arcs directly from points. */
+        private DoublePrecisionContext precision;
+
+        /** The current point at the start of the path. */
+        private Point2S startVertex;
+
+        /** The current point at the end of the path. */
+        private Point2S endVertex;
+
+        /** The precision context used when performing comparisons involving the current
+         * end point.
+         */
+        private DoublePrecisionContext endVertexPrecision;
+
+        /** Construct a new instance configured with the given precision context. The
+         * precision context is used when building arcs from points and
+         * may be omitted if raw points are not used.
+         * @param precision precision context to use when creating arcs
+         *      from points
+         */
+        private Builder(final DoublePrecisionContext precision) {
+            setPrecision(precision);
+        }
+
+        /** Set the precision context. This context is used only when creating arcs
+         * from appended or prepended points. It is not used when adding existing
+         * {@link GreatArc} instances since those contain their own precision contexts.
+         * @param builderPrecision precision context to use when creating arcs from points
+         * @return this instance
+         */
+        public Builder setPrecision(final DoublePrecisionContext builderPrecision) {
+            this.precision = builderPrecision;
+
+            return this;
+        }
+
+        /** Get the arc at the start of the path or null if it does not exist.
+         * @return the arc at the start of the path
+         */
+        public GreatArc getStartArc() {
+            GreatArc start = getLast(prependedArcs);
+            if (start == null) {
+                start = getFirst(appendedArcs);
+            }
+            return start;
+        }
+
+        /** Get the arc at the end of the path or null if it does not exist.
+         * @return the arc at the end of the path
+         */
+        public GreatArc getEndArc() {
+            GreatArc end = getLast(appendedArcs);
+            if (end == null) {
+                end = getFirst(prependedArcs);
+            }
+            return end;
+        }
+
+        /** Append an arc to the end of the path.
+         * @param arc arc to append to the path
+         * @return the current builder instance
+         * @throws IllegalStateException if the path contains a previous arc
+         *      and the end point of the previous arc is not equivalent to the
+         *      start point of the given arc
+         */
+        public Builder append(final GreatArc arc) {
+            validateArcsConnected(getEndArc(), arc);
+            appendInternal(arc);
+
+            return this;
+        }
+
+        /** Add a vertex to the end of this path. If the path already has an end vertex,
+         * then an arc is added between the previous end vertex and this vertex,
+         * using the configured precision context.
+         * @param vertex the vertex to add
+         * @return this instance
+         * @see #setPrecision(DoublePrecisionContext)
+         */
+        public Builder append(final Point2S vertex) {
+            final DoublePrecisionContext vertexPrecision = getAddPointPrecision();
+
+            if (endVertex == null) {
+                // make sure that we're not adding to a full arc
+                final GreatArc end = getEndArc();
+                if (end != null) {
+                    throw new IllegalStateException(
+                            MessageFormat.format("Cannot add point {0} after full arc: {1}", vertex, end));
+                }
+
+                // this is the first vertex added
+                startVertex = vertex;
+                endVertex = vertex;
+                endVertexPrecision = vertexPrecision;
+            } else if (!endVertex.eq(vertex, vertexPrecision)) {
+                // only add the vertex if its not equal to the end point
+                // of the last arc
+                appendInternal(GreatArc.fromPoints(endVertex, vertex, endVertexPrecision));
+            }
+
+            return this;
+        }
+
+        /** Convenience method for appending a collection of vertices to the path in a single
+         * method call.
+         * @param vertices the vertices to append
+         * @return this instance
+         * @see #append(Point2S)
+         */
+        public Builder appendVertices(final Collection<Point2S> vertices) {
+            for (Point2S vertex : vertices) {
+                append(vertex);
+            }
+
+            return this;
+        }
+
+        /** Convenience method for appending multiple vertices to the path at once.
+         * @param vertices the points to append
+         * @return this instance
+         * @see #append(Point2S)
+         */
+        public Builder appendVertices(final Point2S... vertices) {
+            return appendVertices(Arrays.asList(vertices));
+        }
+
+        /** Prepend an arc to the beginning of the path.
+         * @param arc arc to prepend to the path
+         * @return the current builder instance
+         * @throws IllegalStateException if the path contains a start arc
+         *      and the end point of the given arc is not equivalent to the
+         *      start point of the start arc
+         */
+        public Builder prepend(final GreatArc arc) {
+            validateArcsConnected(arc, getStartArc());
+            prependInternal(arc);
+
+            return this;
+        }
+
+        /** Add a vertex to the front of this path. If the path already has a start vertex,
+         * then an arc is added between this vertex and the previous start vertex,
+         * using the configured precision context.
+         * @param vertex the vertex to add
+         * @return this instance
+         * @see #setPrecision(DoublePrecisionContext)
+         */
+        public Builder prepend(final Point2S vertex) {
+            final DoublePrecisionContext vertexPrecision = getAddPointPrecision();
+
+            if (startVertex == null) {
+                // make sure that we're not adding to a full arc
+                final GreatArc start = getStartArc();
+                if (start != null) {
+                    throw new IllegalStateException(
+                            MessageFormat.format("Cannot add point {0} before full arc: {1}", vertex, start));
+                }
+
+                // this is the first vertex added
+                startVertex = vertex;
+                endVertex = vertex;
+                endVertexPrecision = vertexPrecision;
+            } else if (!vertex.eq(startVertex, vertexPrecision)) {
+                // only add if the vertex is not equal to the start
+                // point of the first arc
+                prependInternal(GreatArc.fromPoints(vertex, startVertex, vertexPrecision));
+            }
+
+            return this;
+        }
+
+        /** Convenience method for prepending a collection of vertices to the path in a single method call.
+         * The vertices are logically prepended as a single group, meaning that the first vertex
+         * in the given collection appears as the first vertex in the path after this method call.
+         * Internally, this means that the vertices are actually passed to the {@link #prepend(Point2S)}
+         * method in reverse order.
+         * @param vertices the points to prepend
+         * @return this instance
+         * @see #prepend(Point2S)
+         */
+        public Builder prependPoints(final Collection<Point2S> vertices) {
+            return prependPoints(vertices.toArray(new Point2S[0]));
+        }
+
+        /** Convenience method for prepending multiple vertices to the path in a single method call.
+         * The vertices are logically prepended as a single group, meaning that the first vertex
+         * in the given collection appears as the first vertex in the path after this method call.
+         * Internally, this means that the vertices are actually passed to the {@link #prepend(Point2S)}
+         * method in reverse order.
+         * @param vertices the vertices to prepend
+         * @return this instance
+         * @see #prepend(Point2S)
+         */
+        public Builder prependPoints(final Point2S... vertices) {
+            for (int i = vertices.length - 1; i >= 0; --i) {
+                prepend(vertices[i]);
+            }
+
+            return this;
+        }
+
+        /** Close the current path and build a new {@link GreatArcPath} instance. This method is equivalent
+         * to {@code builder.build(true)}.
+         * @return new closed path instance
+         */
+        public GreatArcPath close() {
+            return build(true);
+        }
+
+        /** Build a {@link GreatArcPath} instance from the configured path. This method is equivalent
+         * to {@code builder.build(false)}.
+         * @return new path instance
+         */
+        public GreatArcPath build() {
+            return build(false);
+        }
+
+        /** Build a {@link GreatArcPath} instance from the configured path.
+         * @param close if true, the path will be closed by adding an end point equivalent to the
+         *      start point
+         * @return new path instance
+         */
+        public GreatArcPath build(final boolean close) {
+            if (close) {
+                closePath();
+            }
+
+            // combine all of the arcs
+            List<GreatArc> result = null;
+
+            if (prependedArcs != null) {
+                result = prependedArcs;
+                Collections.reverse(result);
+            }
+
+            if (appendedArcs != null) {
+                if (result == null) {
+                    result = appendedArcs;
+                } else {
+                    result.addAll(appendedArcs);
+                }
+            }
+
+            if (result == null) {
+                result = Collections.emptyList();
+            }
+
+            if (result.isEmpty() && startVertex != null) {
+                throw new IllegalStateException(
+                        MessageFormat.format("Unable to create path; only a single point provided: {0}",
+                                startVertex));
+            }
+
+            // clear internal state
+            appendedArcs = null;
+            prependedArcs = null;
+
+            // build the final path instance, using the shared empty instance if
+            // no arcs are present
+            return result.isEmpty() ? empty() : new GreatArcPath(result);
+        }
+
+        /** Close the path by adding an end point equivalent to the path start point.
+         * @throws IllegalStateException if the path cannot be closed
+         */
+        private void closePath() {
+            final GreatArc end = getEndArc();
+
+            if (end != null) {
+                if (startVertex != null && endVertex != null) {
+                    if (!endVertex.eq(startVertex, endVertexPrecision)) {
+                        appendInternal(GreatArc.fromPoints(endVertex, startVertex, endVertexPrecision));
+                    }
+                } else {
+                    throw new IllegalStateException("Unable to close path: path is full");
+                }
+            }
+        }
+
+        /** Validate that the given arcs are connected, meaning that the end point of {@code previous}
+         * is equivalent to the start point of {@code next}. The arcs are considered valid if either
+         * arc is null.
+         * @param previous previous arc
+         * @param next next arc
+         * @throws IllegalStateException if previous and next are not null and the end point of previous
+         *      is not equivalent the start point of next
+         */
+        private void validateArcsConnected(final GreatArc previous, final GreatArc next) {
+            if (previous != null && next != null) {
+                final Point2S nextStartVertex = next.getStartPoint();
+                final Point2S previousEndVertex = previous.getEndPoint();
+                final DoublePrecisionContext previousPrecision = previous.getPrecision();
+
+                if (nextStartVertex == null || previousEndVertex == null ||
+                        !(nextStartVertex.eq(previousEndVertex, previousPrecision))) {
+
+                    throw new IllegalStateException(
+                            MessageFormat.format("Path arcs are not connected: previous= {0}, next= {1}",
+                                    previous, next));
+                }
+            }
+        }
+
+        /** Get the precision context used when adding raw points to the path. An exception is thrown
+         * if no precision has been specified.
+         * @return the precision context used when working with raw points
+         * @throws IllegalStateException if no precision context is configured
+         */
+        private DoublePrecisionContext getAddPointPrecision() {
+            if (precision == null) {
+                throw new IllegalStateException("Unable to create arc: no point precision specified");
+            }
+
+            return precision;
+        }
+
+        /** Append the given, validated arc to the path.
+         * @param arc validated arc to append
+         */
+        private void appendInternal(final GreatArc arc) {
+            if (appendedArcs == null) {
+                appendedArcs = new ArrayList<>();
+            }
+
+            if (appendedArcs.isEmpty() &&
+                    (prependedArcs == null || prependedArcs.isEmpty())) {
+                startVertex = arc.getStartPoint();
+            }
+
+            endVertex = arc.getEndPoint();
+            endVertexPrecision = arc.getPrecision();
+
+            appendedArcs.add(arc);
+        }
+
+        /** Prepend the given, validated arc to the path.
+         * @param arc validated arc to prepend
+         */
+        private void prependInternal(final GreatArc arc) {
+            if (prependedArcs == null) {
+                prependedArcs = new ArrayList<>();
+            }
+
+            startVertex = arc.getStartPoint();
+
+            if (prependedArcs.isEmpty() &&
+                    (appendedArcs == null || appendedArcs.isEmpty())) {
+                endVertex = arc.getEndPoint();
+                endVertexPrecision = arc.getPrecision();
+            }
+
+            prependedArcs.add(arc);
+        }
+
+        /** Get the first element in the list or null if the list is null
+         * or empty.
+         * @param list the list to return the first item from
+         * @return the first item from the given list or null if it does not exist
+         */
+        private GreatArc getFirst(final List<GreatArc> list) {
+            if (list != null && list.size() > 0) {
+                return list.get(0);
+            }
+            return null;
+        }
+
+        /** Get the last element in the list or null if the list is null
+         * or empty.
+         * @param list the list to return the last item from
+         * @return the last item from the given list or null if it does not exist
+         */
+        private GreatArc getLast(final List<GreatArc> list) {
+            if (list != null && list.size() > 0) {
+                return list.get(list.size() - 1);
+            }
+            return null;
+        }
+    }
+}
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
new file mode 100644
index 0000000..47f4dbd
--- /dev/null
+++ b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/GreatCircle.java
@@ -0,0 +1,446 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.twod;
+
+import java.util.Objects;
+
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.exception.GeometryException;
+import org.apache.commons.geometry.core.internal.Equivalency;
+import org.apache.commons.geometry.core.partitioning.AbstractHyperplane;
+import org.apache.commons.geometry.core.partitioning.EmbeddingHyperplane;
+import org.apache.commons.geometry.core.partitioning.Hyperplane;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.threed.Vector3D;
+import org.apache.commons.geometry.spherical.oned.AngularInterval;
+import org.apache.commons.geometry.spherical.oned.Point1S;
+
+/** Class representing a great circle on the 2-sphere. A great circle is the
+ * 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.
+ *
+ * <p>Instances of this class are guaranteed to be immutable.</p>
+ */
+public final class GreatCircle extends AbstractHyperplane<Point2S>
+    implements EmbeddingHyperplane<Point2S, Point1S>, Equivalency<GreatCircle> {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20190928L;
+
+    /** Pole or circle center. */
+    private final Vector3D.Unit pole;
+
+    /** First axis in the equator plane, origin of the azimuth angles. */
+    private final Vector3D.Unit u;
+
+    /** Second axis in the equator plane, in quadrature with respect to u. */
+    private final Vector3D.Unit v;
+
+    /** Simple constructor. Callers are responsible for ensuring the inputs are valid.
+     * @param pole pole vector of the great circle
+     * @param u u axis in the equator plane
+     * @param v v axis in the equator plane
+     * @param precision precision context used for floating point comparisons
+     */
+    private GreatCircle(final Vector3D.Unit pole, final Vector3D.Unit u, final Vector3D.Unit v,
+            final DoublePrecisionContext precision) {
+        super(precision);
+
+        this.pole = pole;
+        this.u = u;
+        this.v = v;
+    }
+
+    /** Get the pole of the great circle. This vector is perpendicular to the
+     * equator plane of the instance.
+     * @return pole of the great circle
+     */
+    public Vector3D.Unit getPole() {
+        return pole;
+    }
+
+    /** Get the spherical point located at the positive pole of the instance.
+     * @return the spherical point located at the positive pole of the instance
+     */
+    public Point2S getPolePoint() {
+        return Point2S.from(pole);
+    }
+
+    /** Get the u axis of the great circle. This vector is located in the equator plane and defines
+     * the {@code 0pi} location of the embedded subspace.
+     * @return u axis of the great circle
+     */
+    public Vector3D.Unit getU() {
+        return u;
+    }
+
+    /** Get the v axis of the great circle. This vector lies in the equator plane,
+     * perpendicular to the u-axis.
+     * @return v axis of the great circle
+     */
+    public Vector3D.Unit getV() {
+        return v;
+    }
+
+    /** Get the w (pole) axis of the great circle. The method is equivalent to {@code #getPole()}.
+     * @return the w (pole) axis of the great circle.
+     * @see #getPole()
+     */
+    public Vector3D.Unit getW() {
+        return getPole();
+    }
+
+    /** {@inheritDoc}
+     *
+     * <p>The returned offset values are in the range {@code [-pi/2, +pi/2]},
+     * with a point directly on the circle's pole vector having an offset of
+     * {@code -pi/2} and its antipodal point having an offset of {@code +pi/2}.
+     * Thus, the circle's pole vector points toward the <em>minus</em> side of
+     * the hyperplane.</p>
+     *
+     * @see #offset(Vector3D)
+     */
+    @Override
+    public double offset(final Point2S point) {
+        return offset(point.getVector());
+    }
+
+    /** Get the offset (oriented distance) of a direction.
+     *
+     * <p>The offset computed here is equal to the angle between the circle's
+     * pole and the given vector minus {@code pi/2}. Thus, the pole vector
+     * has an offset of {@code -pi/2}, a point on the circle itself has an
+     * offset of {@code 0}, and the negation of the pole vector has an offset
+     * of {@code +pi/2}.</p>
+     * @param vec vector to compute the offset for
+     * @return the offset (oriented distance) of a direction
+     */
+    public double offset(final Vector3D vec) {
+        return pole.angle(vec) - Geometry.HALF_PI;
+    }
+
+    /** Get the azimuth angle of a point relative to this great circle instance,
+     *  in the range {@code [0, 2pi)}.
+     * @param pt point to compute the azimuth for
+     * @return azimuth angle of the point in the range {@code [0, 2pi)}
+     */
+    public double azimuth(final Point2S pt) {
+        return azimuth(pt.getVector());
+    }
+
+    /** Get the azimuth angle of a vector in the range {@code [0, 2pi)}.
+     * The azimuth angle is the angle of the projection of the argument on the
+     * equator plane relative to the plane's u-axis. Since the vector is
+     * projected onto the equator plane, it does not need to belong to the circle.
+     * Vectors parallel to the great circle's pole do not have a defined azimuth angle.
+     * In these cases, the method follows the rules of the
+     * {@code Math#atan2(double, double)} method and returns {@code 0}.
+     * @param vector vector to compute the great circle azimuth of
+     * @return azimuth angle of the vector around the great circle in the range
+     *      {@code [0, 2pi)}
+     * @see #toSubspace(Point2S)
+     */
+    public double azimuth(final Vector3D vector) {
+        double az = Math.atan2(vector.dot(v), vector.dot(u));
+
+        // adjust range
+        if (az < 0) {
+            az += Geometry.TWO_PI;
+        }
+
+        return az;
+    }
+
+    /** Get the vector on the great circle with the given azimuth angle.
+     * @param azimuth azimuth angle in radians
+     * @return the point on the great circle with the given phase angle
+     */
+    public Vector3D vectorAt(final double azimuth) {
+        return Vector3D.linearCombination(Math.cos(azimuth), u, Math.sin(azimuth), v);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Point2S project(final Point2S point) {
+        final double az = azimuth(point.getVector());
+        return Point2S.from(vectorAt(az));
+    }
+
+    /** {@inheritDoc}
+     *
+     * <p>The returned instance has the same u-axis but opposite pole and v-axis
+     * as this instance.</p>
+     */
+    @Override
+    public GreatCircle reverse() {
+        return new GreatCircle(pole.negate(), u, v.negate(), getPrecision());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public GreatCircle transform(final Transform<Point2S> transform) {
+        final Point2S tu = transform.apply(Point2S.from(u));
+        final Point2S tv = transform.apply(Point2S.from(v));
+
+        return fromPoints(tu, tv, getPrecision());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean similarOrientation(final Hyperplane<Point2S> other) {
+        final GreatCircle otherCircle = (GreatCircle) other;
+        return pole.dot(otherCircle.pole) > 0.0;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public GreatArc span() {
+        return GreatArc.fromInterval(this, AngularInterval.full());
+    }
+
+    /** Create an arc on this circle between the given points.
+     * @param start start point
+     * @param end end point
+     * @return an arc on this circle between the given points
+     * @throws GeometryException if the specified interval is not
+     *      convex (ie, the angle between the points is greater than {@code pi}
+     */
+    public GreatArc arc(final Point2S start, final Point2S end) {
+        return arc(toSubspace(start), toSubspace(end));
+    }
+
+    /** Create an arc on this circle between the given subspace points.
+     * @param start start subspace point
+     * @param end end subspace point
+     * @return an arc on this circle between the given subspace points
+     * @throws GeometryException if the specified interval is not
+     *      convex (ie, the angle between the points is greater than {@code pi}
+     */
+    public GreatArc arc(final Point1S start, final Point1S end) {
+        return arc(start.getAzimuth(), end.getAzimuth());
+    }
+
+    /** Create an arc on this circle between the given subspace azimuth values.
+     * @param start start subspace azimuth
+     * @param end end subspace azimuth
+     * @return an arc on this circle between the given subspace azimuths
+     * @throws GeometryException if the specified interval is not
+     *      convex (ie, the angle between the points is greater than {@code pi}
+     */
+    public GreatArc arc(final double start, final double end) {
+        return arc(AngularInterval.Convex.of(start, end, getPrecision()));
+    }
+
+    /** Create an arc on this circle consisting of the given subspace interval.
+     * @param interval subspace interval
+     * @return an arc on this circle consisting of the given subspace interval
+     */
+    public GreatArc arc(final AngularInterval.Convex interval) {
+        return GreatArc.fromInterval(this, interval);
+    }
+
+    /** Return one of the two intersection points between this instance and the argument.
+     * If the circles occupy the same space (ie, their poles are parallel or anti-parallel),
+     * then null is returned. Otherwise, the intersection located at the cross product of
+     * the pole of this instance and that of the argument is returned (ie, {@code thisPole.cross(otherPole)}.
+     * The other intersection point of the pair is antipodal to this point.
+     * @param other circle to intersect with
+     * @return one of the two intersection points between this instance and the argument
+     */
+    public Point2S intersection(final GreatCircle other) {
+        final Vector3D cross = pole.cross(other.pole);
+        if (!cross.eq(Vector3D.ZERO, getPrecision())) {
+            return Point2S.from(cross);
+        }
+
+        return null;
+    }
+
+    /** Compute the angle between this great circle and the argument.
+     * The return value is the angle between the poles of the two circles,
+     * in the range {@code [0, pi]}.
+     * @param other great circle to compute the angle with
+     * @return the angle between this great circle and the argument in the
+     *      range {@code [0, pi]}
+     * @see #angle(GreatCircle, Point2S)
+     */
+    public double angle(final GreatCircle other) {
+        return pole.angle(other.pole);
+    }
+
+    /** Compute the angle between this great circle and the argument, measured
+     * at the intersection point closest to the given point. The value is computed
+     * as if a tangent line was drawn from each great circle at the intersection
+     * point closest to {@code pt}, and the angle required to rotate the tangent
+     * line representing the current instance to align with that of the given
+     * instance was measured. The return value lies in the range {@code [-pi, pi)} and
+     * has an absolute value equal to that returned by {@link #angle(GreatCircle)}, but
+     * possibly a different sign. If the given point is equidistant from both intersection
+     * points (as evaluated by this instance's precision context), then the point is assumed
+     * to be closest to the point opposite the cross product of the two poles.
+     * @param other great circle to compute the angle with
+     * @param pt point determining the circle intersection to compute the angle at
+     * @return the angle between this great circle and the argument as measured at the
+     *      intersection point closest to the given point; the value is in the range
+     *      {@code [-pi, pi)}
+     * @see #angle(GreatCircle)
+     */
+    public double angle(final GreatCircle other, final Point2S pt) {
+        final double theta = angle(other);
+        final Vector3D cross = pole.cross(other.pole);
+
+        return getPrecision().gt(pt.getVector().dot(cross), 0) ?
+                theta :
+                -theta;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Point1S toSubspace(final Point2S point) {
+        return Point1S.of(azimuth(point.getVector()));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Point2S toSpace(final Point1S point) {
+        return Point2S.from(vectorAt(point.getAzimuth()));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean eq(final GreatCircle other) {
+        if (this == other) {
+            return true;
+        }
+
+        final DoublePrecisionContext precision = getPrecision();
+
+        return precision.equals(other.getPrecision()) &&
+                pole.eq(other.pole, precision) &&
+                u.eq(other.u, precision) &&
+                v.eq(other.v, precision);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public int hashCode() {
+        return Objects.hash(pole, u, v, getPrecision());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj) {
+            return true;
+        } else if (!(obj instanceof GreatCircle)) {
+            return false;
+        }
+
+        GreatCircle other = (GreatCircle) obj;
+
+        return Objects.equals(this.pole, other.pole) &&
+                Objects.equals(this.u, other.u) &&
+                Objects.equals(this.v, other.v) &&
+                Objects.equals(this.getPrecision(), other.getPrecision());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String toString() {
+        final StringBuilder sb = new StringBuilder();
+        sb.append(getClass().getSimpleName())
+            .append("[pole= ")
+            .append(pole)
+            .append(", u= ")
+            .append(u)
+            .append(", v= ")
+            .append(v)
+            .append(']');
+
+        return sb.toString();
+    }
+
+    /** Create a great circle instance from its pole vector. An arbitrary u-axis is chosen.
+     * @param pole pole vector for the great circle
+     * @param precision precision context used to compare floating point values
+     * @return a great circle defined by the given pole vector
+     */
+    public static GreatCircle fromPole(final Vector3D pole, final DoublePrecisionContext precision) {
+        final Vector3D.Unit u = pole.orthogonal();
+        final Vector3D.Unit v = pole.cross(u).normalize();
+        return new GreatCircle(pole.normalize(), u, v, precision);
+    }
+
+    /** Create a great circle instance from its pole vector and a vector representing the u-axis
+     * in the equator plane. The u-axis vector defines the {@code 0pi} location for the embedded
+     * subspace.
+     * @param pole pole vector for the great circle
+     * @param u u-axis direction for the equator plane
+     * @param precision precision context used to compare floating point values
+     * @return a great circle defined by the given pole vector and u-axis direction
+     */
+    public static GreatCircle fromPoleAndU(final Vector3D pole, final Vector3D u,
+            final DoublePrecisionContext precision) {
+
+        final Vector3D.Unit unitPole = pole.normalize();
+        final Vector3D.Unit unitX = pole.orthogonal(u);
+        final Vector3D.Unit unitY = pole.cross(u).normalize();
+
+        return new GreatCircle(unitPole, unitX, unitY, precision);
+    }
+
+    /** Create a great circle instance from two points on the circle. The u-axis of the
+     * instance points to the location of the first point. The orientation of the circle
+     * is along the shortest path between the two points.
+     * @param a first point on the great circle
+     * @param b second point on the great circle
+     * @param precision precision context used to compare floating point values
+     * @return great circle instance containing the given points
+     * @throws GeometryException if either of the given points is NaN or infinite, or if the given points are
+     *      equal or antipodal as evaluated by the given precision context
+     */
+    public static GreatCircle fromPoints(final Point2S a, final Point2S b,
+            final DoublePrecisionContext precision) {
+
+        if (!a.isFinite() || !b.isFinite()) {
+            throw new GeometryException("Invalid points for great circle: " + a + ", " + b);
+        }
+
+        String err = null;
+
+        final double dist = a.distance(b);
+        if (precision.eqZero(dist)) {
+            err = "equal";
+        } else if (precision.eq(dist, Geometry.PI)) {
+            err = "antipodal";
+        }
+
+        if (err != null) {
+            throw new GeometryException("Cannot create great circle from points " + a + " and " + b +
+                    ": points are " + err);
+        }
+
+        final Vector3D.Unit u = a.getVector().normalize();
+        final Vector3D.Unit pole = u.cross(b.getVector()).normalize();
+        final Vector3D.Unit v = pole.cross(u).normalize();
+
+        return new GreatCircle(pole, u, v, precision);
+    }
+}
diff --git a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/InteriorAngleGreatArcConnector.java b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/InteriorAngleGreatArcConnector.java
new file mode 100644
index 0000000..3d3dec0
--- /dev/null
+++ b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/InteriorAngleGreatArcConnector.java
@@ -0,0 +1,127 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.twod;
+
+import java.util.Collection;
+import java.util.List;
+
+import org.apache.commons.geometry.core.Geometry;
+
+/** Great arc connector that selects between multiple connection options
+ * based on the resulting interior angle. An interior angle in this
+ * case is the angle created between an incoming arc and an outgoing arc
+ * as measured on the minus (interior) side of the incoming arc. If looking
+ * along the direction of the incoming arc, smaller interior angles
+ * point more to the left and larger ones point more to the right.
+ *
+ * <p>This class provides two concrete implementations: {@link Maximize} and
+ * {@link Minimize}, which choose connections with the largest or smallest interior
+ * angles respectively.
+ * </p>
+ */
+public abstract class InteriorAngleGreatArcConnector extends AbstractGreatArcConnector {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20191107L;
+
+    /** {@inheritDoc} */
+    @Override
+    protected ConnectableGreatArc selectConnection(final ConnectableGreatArc incoming,
+            final List<ConnectableGreatArc> outgoing) {
+
+        // search for the best connection
+        final GreatCircle circle = incoming.getArc().getCircle();
+
+        double selectedInteriorAngle = Double.POSITIVE_INFINITY;
+        ConnectableGreatArc selected = null;
+
+        for (ConnectableGreatArc candidate : outgoing) {
+            double interiorAngle = Geometry.PI - circle.angle(candidate.getArc().getCircle(),
+                    incoming.getArc().getEndPoint());
+
+            if (selected == null || isBetterAngle(interiorAngle, selectedInteriorAngle)) {
+                selectedInteriorAngle = interiorAngle;
+                selected = candidate;
+            }
+        }
+
+        return selected;
+    }
+
+    /** Return true if {@code newAngle} represents a better interior angle than {@code previousAngle}.
+     * @param newAngle the new angle under consideration
+     * @param previousAngle the previous best angle
+     * @return true if {@code newAngle} represents a better interior angle than {@code previousAngle}
+     */
+    protected abstract boolean isBetterAngle(double newAngle, double previousAngle);
+
+    /** Convenience method for connecting a set of arcs with interior angles maximized
+     * when possible. This method is equivalent to {@code new Maximize().connect(segments)}.
+     * @param arcs arcs to connect
+     * @return a list of connected arc paths
+     * @see Maximize
+     */
+    public static List<GreatArcPath> connectMaximized(final Collection<GreatArc> arcs) {
+        return new Maximize().connectAll(arcs);
+    }
+
+    /** Convenience method for connecting a set of line segments with interior angles minimized
+     * when possible. This method is equivalent to {@code new Minimize().connect(segments)}.
+     * @param arcs arcs to connect
+     * @return a list of connected arc paths
+     * @see Minimize
+     */
+    public static List<GreatArcPath> connectMinimized(final Collection<GreatArc> arcs) {
+        return new Minimize().connectAll(arcs);
+    }
+
+    /** Implementation of {@link InteriorAngleGreatArcConnector} that chooses arc
+     * connections that produce the largest interior angles. Another way to visualize this is
+     * that when presented multiple connection options for a given arc, this class will
+     * choose the option that points most to the right when viewed in the direction of the incoming
+     * arc.
+     */
+    public static class Maximize extends InteriorAngleGreatArcConnector {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20191107L;
+
+        /** {@inheritDoc} */
+        @Override
+        protected boolean isBetterAngle(double newAngle, double previousAngle) {
+            return newAngle > previousAngle;
+        }
+    }
+
+    /** Implementation of {@link InteriorAngleGreatArcConnector} that chooses arc
+     * connections that produce the smallest interior angles. Another way to visualize this is
+     * that when presented multiple connection options for a given arc, this class will
+     * choose the option that points most to the left when viewed in the direction of the incoming
+     * arc.
+     */
+    public static class Minimize extends InteriorAngleGreatArcConnector {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20191107L;
+
+        /** {@inheritDoc} */
+        @Override
+        protected boolean isBetterAngle(double newAngle, double previousAngle) {
+            return newAngle < previousAngle;
+        }
+    }
+}
diff --git a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/Point2S.java b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/Point2S.java
new file mode 100644
index 0000000..e741e5d
--- /dev/null
+++ b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/Point2S.java
@@ -0,0 +1,317 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.twod;
+
+import java.io.Serializable;
+import java.util.Comparator;
+
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.Point;
+import org.apache.commons.geometry.core.internal.SimpleTupleFormat;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.threed.SphericalCoordinates;
+import org.apache.commons.geometry.euclidean.threed.Vector3D;
+import org.apache.commons.geometry.euclidean.threed.rotation.QuaternionRotation;
+
+/** This class represents a point on the 2-sphere.
+ * <p>Instances of this class are guaranteed to be immutable.</p>
+ */
+public final class Point2S implements Point<Point2S>, Serializable {
+
+    /** +I (coordinates: ( azimuth = 0, polar = pi/2 )). */
+    public static final Point2S PLUS_I = new Point2S(0, 0.5 * Math.PI, Vector3D.Unit.PLUS_X);
+
+    /** +J (coordinates: ( azimuth = pi/2, polar = pi/2 ))). */
+    public static final Point2S PLUS_J = new Point2S(0.5 * Math.PI, 0.5 * Math.PI, Vector3D.Unit.PLUS_Y);
+
+    /** +K (coordinates: ( azimuth = any angle, polar = 0 )). */
+    public static final Point2S PLUS_K = new Point2S(0, 0, Vector3D.Unit.PLUS_Z);
+
+    /** -I (coordinates: ( azimuth = pi, polar = pi/2 )). */
+    public static final Point2S MINUS_I = new Point2S(Math.PI, 0.5 * Math.PI, Vector3D.Unit.MINUS_X);
+
+    /** -J (coordinates: ( azimuth = 3pi/2, polar = pi/2 )). */
+    public static final Point2S MINUS_J = new Point2S(1.5 * Math.PI, 0.5 * Math.PI, Vector3D.Unit.MINUS_Y);
+
+    /** -K (coordinates: ( azimuth = any angle, polar = pi )). */
+    public static final Point2S MINUS_K = new Point2S(0, Math.PI, Vector3D.Unit.MINUS_Z);
+
+    // CHECKSTYLE: stop ConstantName
+    /** A point with all coordinates set to NaN. */
+    public static final Point2S NaN = new Point2S(Double.NaN, Double.NaN, null);
+    // CHECKSTYLE: resume ConstantName
+
+    /** Comparator that sorts points in component-wise ascending order, first sorting
+     * by polar value and then by azimuth value. Points are only considered equal if
+     * their components match exactly. Null arguments are evaluated as being greater
+     * than non-null arguments.
+     */
+    public static final Comparator<Point2S> POLAR_AZIMUTH_ASCENDING_ORDER = (a, b) -> {
+        int cmp = 0;
+
+        if (a != null && b != null) {
+            cmp = Double.compare(a.getPolar(), b.getPolar());
+
+            if (cmp == 0) {
+                cmp = Double.compare(a.getAzimuth(), b.getAzimuth());
+            }
+        } else if (a != null) {
+            cmp = -1;
+        } else if (b != null) {
+            cmp = 1;
+        }
+
+        return cmp;
+    };
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20180710L;
+
+    /** Azimuthal angle in the x-y plane. */
+    private final double azimuth;
+
+    /** Polar angle. */
+    private final double polar;
+
+    /** Corresponding 3D normalized vector. */
+    private final Vector3D.Unit vector;
+
+    /** Build a point from its internal components.
+     * @param azimuth azimuthal angle in the x-y plane
+     * @param polar polar angle
+     * @param vector corresponding vector; if null, the vector is computed
+     */
+    private Point2S(final double azimuth, final double polar, final Vector3D.Unit vector) {
+        this.azimuth = SphericalCoordinates.normalizeAzimuth(azimuth);
+        this.polar = SphericalCoordinates.normalizePolar(polar);
+        this.vector = (vector != null) ?
+                vector :
+                computeVector(azimuth, polar);
+    }
+
+    /** Get the azimuth angle in the x-y plane in the range {@code [0, 2pi)}.
+     * @return azimuth angle in the x-y plane in the range {@code [0, 2pi)}.
+     * @see Point2S#of(double, double)
+     */
+    public double getAzimuth() {
+        return azimuth;
+    }
+
+    /** Get the polar angle in the range {@code [0, pi)}.
+     * @return polar angle in the range {@code [0, pi)}.
+     * @see Point2S#of(double, double)
+     */
+    public double getPolar() {
+        return polar;
+    }
+
+    /** Get the corresponding normalized vector in 3D Euclidean space.
+     * This value will be null if the spherical coordinates of the point
+     * are infinite or NaN.
+     * @return normalized vector
+     */
+    public Vector3D.Unit getVector() {
+        return vector;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public int getDimension() {
+        return 2;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isNaN() {
+        return Double.isNaN(azimuth) || Double.isNaN(polar);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isInfinite() {
+        return !isNaN() && (Double.isInfinite(azimuth) || Double.isInfinite(polar));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isFinite() {
+        return Double.isFinite(azimuth) && Double.isFinite(polar);
+    }
+
+    /** Get the point exactly opposite this point on the sphere. The returned
+     * point is {@code pi} distance away from the current instance.
+     * @return the point exactly opposite this point on the sphere
+     */
+    public Point2S antipodal() {
+        return new Point2S(-azimuth, Geometry.PI - polar, vector.negate());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public double distance(final Point2S point) {
+        return distance(this, point);
+    }
+
+    /** Spherically interpolate a point along the shortest arc between this point and
+     * the given point. The parameter {@code t} controls the interpolation and is expected
+     * to be in the range {@code [0, 1]}, with {@code 0} returning a point equivalent to the
+     * current instance {@code 1} returning a point equivalent to the given instance. If the
+     * points are antipodal, then an arbitrary arc is chosen from the infinite number available.
+     * @param other other point to interpolate with
+     * @param t interpolation parameter
+     * @return spherically interpolated point
+     * @see QuaternionRotation#slerp(QuaternionRotation)
+     * @see QuaternionRotation#createVectorRotation(Vector3D, Vector3D)
+     */
+    public Point2S slerp(final Point2S other, final double t) {
+        final QuaternionRotation start = QuaternionRotation.identity();
+        final QuaternionRotation end = QuaternionRotation.createVectorRotation(getVector(), other.getVector());
+
+        final QuaternionRotation quat = QuaternionRotation.of(start.slerp(end).apply(t));
+
+        return Point2S.from(quat.apply(getVector()));
+    }
+
+    /** Return true if this point should be considered equivalent to the argument using the
+     * given precision context. This will be true if the distance between the points is
+     * equivalent to zero as evaluated by the precision context.
+     * @param point point to compare with
+     * @param precision precision context used to perform floating point comparisons
+     * @return true if this point should be considered equivalent to the argument using the
+     *      given precision context
+     */
+    public boolean eq(final Point2S point, final DoublePrecisionContext precision) {
+        return precision.eqZero(distance(point));
+    }
+
+    /** Get a hashCode for the point.
+     * .
+     * <p>All NaN values have the same hash code.</p>
+     *
+     * @return a hash code value for this object
+     */
+    @Override
+    public int hashCode() {
+        if (isNaN()) {
+            return 542;
+        }
+        return 134 * (37 * Double.hashCode(azimuth) +  Double.hashCode(polar));
+    }
+
+    /** Test for the equality of two points.
+     *
+     * <p>If all spherical coordinates of two points are exactly the same, and none are
+     * <code>Double.NaN</code>, the two points are considered to be equal. Note
+     * that the comparison is made using the azimuth and polar coordinates only; the
+     * corresponding 3D vectors are not compared. This is significant at the poles,
+     * where an infinite number of points share the same underlying 3D vector but may
+     * have different spherical coordinates. For example, the points {@code (0, 0)}
+     * and {@code (1, 0)} (both located at a pole but with different azimuths) will
+     * <em>not</em> be considered equal by this method, even though they share the
+     * exact same underlying 3D vector.</p>
+     *
+     * <p>
+     * <code>NaN</code> coordinates are considered to affect the point globally
+     * and be equals to each other - i.e, if either (or all) coordinates of the
+     * point are equal to <code>Double.NaN</code>, the point is equal to
+     * {@link #NaN}.
+     * </p>
+     *
+     * @param other Object to test for equality to this
+     * @return true if two points on the 2-sphere objects are exactly equal, false if
+     *         object is null, not an instance of Point2S, or
+     *         not equal to this Point2S instance
+     */
+    @Override
+    public boolean equals(Object other) {
+        if (this == other) {
+            return true;
+        }
+        if (!(other instanceof Point2S)) {
+            return false;
+        }
+
+        final Point2S rhs = (Point2S) other;
+        if (rhs.isNaN()) {
+            return this.isNaN();
+        }
+
+        return Double.compare(azimuth, rhs.azimuth) == 0 &&
+                Double.compare(polar, rhs.polar) == 0;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String toString() {
+        return SimpleTupleFormat.getDefault().format(getAzimuth(), getPolar());
+    }
+
+    /** Build a vector from its spherical coordinates.
+     * @param azimuth azimuthal angle in the x-y plane
+     * @param polar polar angle
+     * @return point instance with the given coordinates
+     * @see #getAzimuth()
+     * @see #getPolar()
+     */
+    public static Point2S of(final double azimuth, final double polar) {
+        return new Point2S(azimuth, polar, null);
+    }
+
+    /** Build a point from its underlying 3D vector.
+     * @param vector 3D vector
+     * @return point instance with the coordinates determined by the given 3D vector
+     * @exception IllegalStateException if vector norm is zero
+     */
+    public static Point2S from(final Vector3D vector) {
+        final SphericalCoordinates coords = SphericalCoordinates.fromCartesian(vector);
+
+        return new Point2S(coords.getAzimuth(), coords.getPolar(), vector.normalize());
+    }
+
+    /** Parses the given string and returns a new point instance. The expected string
+     * format is the same as that returned by {@link #toString()}.
+     * @param str the string to parse
+     * @return point instance represented by the string
+     * @throws IllegalArgumentException if the given string has an invalid format
+     */
+    public static Point2S parse(final String str) {
+        return SimpleTupleFormat.getDefault().parse(str, Point2S::of);
+    }
+
+    /** Compute the distance (angular separation) between two points.
+     * @param p1 first vector
+     * @param p2 second vector
+     * @return the angular separation between p1 and p2
+     */
+    public static double distance(final Point2S p1, final Point2S p2) {
+        return p1.vector.angle(p2.vector);
+    }
+
+    /** Compute the 3D Euclidean vector associated with the given spherical coordinates.
+     * Null is returned if the coordinates are infinite or NaN.
+     * @param azimuth azimuth value
+     * @param polar polar value
+     * @return the 3D Euclidean vector associated with the given spherical coordinates
+     *      or null if either of the arguments are infinite or NaN.
+     */
+    private static Vector3D.Unit computeVector(final double azimuth, final double polar) {
+        if (Double.isFinite(azimuth) && Double.isFinite(polar)) {
+            return SphericalCoordinates.toCartesian(1, azimuth, polar).normalize();
+        }
+        return null;
+    }
+}
diff --git a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/PropertiesComputer.java b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/PropertiesComputer.java
deleted file mode 100644
index 4793547..0000000
--- a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/PropertiesComputer.java
+++ /dev/null
@@ -1,175 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.twod;
-
-import java.util.ArrayList;
-import java.util.List;
-
-import org.apache.commons.geometry.core.Geometry;
-import org.apache.commons.geometry.core.internal.GeometryInternalError;
-import org.apache.commons.geometry.core.partitioning.BSPTree;
-import org.apache.commons.geometry.core.partitioning.BSPTreeVisitor;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.geometry.euclidean.threed.Vector3D;
-
-/** Visitor computing geometrical properties.
- */
-class PropertiesComputer implements BSPTreeVisitor<S2Point> {
-
-    /** Precision context used to determine floating point equality. */
-    private final DoublePrecisionContext precision;
-
-    /** Summed area. */
-    private double summedArea;
-
-    /** Summed barycenter. */
-    private Vector3D summedBarycenter;
-
-    /** List of points strictly inside convex cells. */
-    private final List<Vector3D> convexCellsInsidePoints;
-
-    /** Simple constructor.
-     * @param precision precision context used to compare floating point values
-     */
-    PropertiesComputer(final DoublePrecisionContext precision) {
-        this.precision              = precision;
-        this.summedArea             = 0;
-        this.summedBarycenter       = Vector3D.ZERO;
-        this.convexCellsInsidePoints = new ArrayList<>();
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public Order visitOrder(final BSPTree<S2Point> node) {
-        return Order.MINUS_SUB_PLUS;
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public void visitInternalNode(final BSPTree<S2Point> node) {
-        // nothing to do here
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public void visitLeafNode(final BSPTree<S2Point> node) {
-        if ((Boolean) node.getAttribute()) {
-
-            // transform this inside leaf cell into a simple convex polygon
-            final SphericalPolygonsSet convex =
-                    new SphericalPolygonsSet(node.pruneAroundConvexCell(Boolean.TRUE,
-                                                                        Boolean.FALSE,
-                                                                        null),
-                            precision);
-
-            // extract the start of the single loop boundary of the convex cell
-            final List<Vertex> boundary = convex.getBoundaryLoops();
-            if (boundary.size() != 1) {
-                // this should never happen
-                throw new GeometryInternalError();
-            }
-
-            // compute the geometrical properties of the convex cell
-            final double area  = convexCellArea(boundary.get(0));
-            final Vector3D barycenter = convexCellBarycenter(boundary.get(0));
-            convexCellsInsidePoints.add(barycenter);
-
-            // add the cell contribution to the global properties
-            summedArea      += area;
-            summedBarycenter = Vector3D.linearCombination(1, summedBarycenter, area, barycenter);
-
-        }
-    }
-
-    /** Compute convex cell area.
-     * @param start start vertex of the convex cell boundary
-     * @return area
-     */
-    private double convexCellArea(final Vertex start) {
-
-        int n = 0;
-        double sum = 0;
-
-        // loop around the cell
-        for (Edge e = start.getOutgoing(); n == 0 || e.getStart() != start; e = e.getEnd().getOutgoing()) {
-
-            // find path interior angle at vertex
-            final Vector3D previousPole = e.getCircle().getPole();
-            final Vector3D nextPole     = e.getEnd().getOutgoing().getCircle().getPole();
-            final Vector3D point        = e.getEnd().getLocation().getVector();
-            double alpha = Math.atan2(nextPole.dot(point.cross(previousPole)),
-                                          - nextPole.dot(previousPole));
-            if (alpha < 0) {
-                alpha += Geometry.TWO_PI;
-            }
-            sum += alpha;
-            n++;
-        }
-
-        // compute area using extended Girard theorem
-        // see Spherical Trigonometry: For the Use of Colleges and Schools by I. Todhunter
-        // article 99 in chapter VIII Area Of a Spherical Triangle. Spherical Excess.
-        // book available from project Gutenberg at http://www.gutenberg.org/ebooks/19770
-        return sum - (n - 2) * Math.PI;
-
-    }
-
-    /** Compute convex cell barycenter.
-     * @param start start vertex of the convex cell boundary
-     * @return barycenter
-     */
-    private Vector3D convexCellBarycenter(final Vertex start) {
-
-        int n = 0;
-        Vector3D sumB = Vector3D.ZERO;
-
-        // loop around the cell
-        for (Edge e = start.getOutgoing(); n == 0 || e.getStart() != start; e = e.getEnd().getOutgoing()) {
-            sumB = Vector3D.linearCombination(1, sumB, e.getLength(), e.getCircle().getPole());
-            n++;
-        }
-
-        return sumB.normalize();
-
-    }
-
-    /** Get the area.
-     * @return area
-     */
-    public double getArea() {
-        return summedArea;
-    }
-
-    /** Get the barycenter.
-     * @return barycenter
-     */
-    public S2Point getBarycenter() {
-        if (summedBarycenter.normSq() == 0) {
-            return S2Point.NaN;
-        } else {
-            return S2Point.ofVector(summedBarycenter);
-        }
-    }
-
-    /** Get the points strictly inside convex cells.
-     * @return points strictly inside convex cells
-     */
-    public List<Vector3D> getConvexCellsInsidePoints() {
-        return convexCellsInsidePoints;
-    }
-
-}
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
new file mode 100644
index 0000000..a9640e1
--- /dev/null
+++ b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/RegionBSPTree2S.java
@@ -0,0 +1,303 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.twod;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.partitioning.Hyperplane;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.bsp.AbstractBSPTree;
+import org.apache.commons.geometry.core.partitioning.bsp.AbstractRegionBSPTree;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.threed.Vector3D;
+
+/** BSP tree representing regions in 2D spherical space.
+ */
+public class RegionBSPTree2S extends AbstractRegionBSPTree<Point2S, RegionBSPTree2S.RegionNode2S> {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20191005L;
+
+    /** Constant containing the area of the full spherical space. */
+    private static final double FULL_SIZE = 4 * Geometry.PI;
+
+    /** List of great arc path comprising the region boundary. */
+    private List<GreatArcPath> boundaryPaths;
+
+    /** Create a new, empty instance.
+     */
+    public RegionBSPTree2S() {
+        this(false);
+    }
+
+    /** Create a new region. If {@code full} is true, then the region will
+     * represent the entire 2-sphere. Otherwise, it will be empty.
+     * @param full whether or not the region should contain the entire
+     *      2-sphere or be empty
+     */
+    public RegionBSPTree2S(boolean full) {
+        super(full);
+    }
+
+    /** Return a deep copy of this instance.
+     * @return a deep copy of this instance.
+     * @see #copy(org.apache.commons.geometry.core.partitioning.bsp.BSPTree)
+     */
+    public RegionBSPTree2S copy() {
+        RegionBSPTree2S result = RegionBSPTree2S.empty();
+        result.copy(this);
+
+        return result;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Iterable<GreatArc> boundaries() {
+        return createBoundaryIterable(b -> (GreatArc) b);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public List<GreatArc> getBoundaries() {
+        return createBoundaryList(b -> (GreatArc) b);
+    }
+
+    /** Get the boundary of the region as a list of connected great arc paths. The
+     * arcs are oriented such that their minus (left) side lies on the interior of
+     * the region.
+     * @return great arc paths representing the region boundary
+     */
+    public List<GreatArcPath> getBoundaryPaths() {
+        if (boundaryPaths == null) {
+            boundaryPaths = Collections.unmodifiableList(computeBoundaryPaths());
+        }
+        return boundaryPaths;
+    }
+
+    /** Return a list of {@link ConvexArea2S}s representing the same region
+     * as this instance. One convex area is returned for each interior leaf
+     * node in the tree.
+     * @return a list of convex areas representing the same region as this
+     *      instance
+     */
+    public List<ConvexArea2S> toConvex() {
+        final List<ConvexArea2S> result = new ArrayList<>();
+
+        toConvexRecursive(getRoot(), ConvexArea2S.full(), result);
+
+        return result;
+    }
+
+    /** Recursive method to compute the convex areas of all inside leaf nodes in the subtree rooted at the given
+     * node. The computed convex areas are added to the given list.
+     * @param node root of the subtree to compute the convex areas for
+     * @param nodeArea the convex area for the current node; this will be split by the node's cut hyperplane to
+     *      form the convex areas for any child nodes
+     * @param result list containing the results of the computation
+     */
+    private void toConvexRecursive(final RegionNode2S node, final ConvexArea2S nodeArea,
+            final List<ConvexArea2S> result) {
+        if (node.isLeaf()) {
+            // base case; only add to the result list if the node is inside
+            if (node.isInside()) {
+                result.add(nodeArea);
+            }
+        } else {
+            // recurse
+            Split<ConvexArea2S> split = nodeArea.split(node.getCutHyperplane());
+
+            toConvexRecursive(node.getMinus(), split.getMinus(), result);
+            toConvexRecursive(node.getPlus(), split.getPlus(), result);
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Split<RegionBSPTree2S> split(final Hyperplane<Point2S> splitter) {
+        return split(splitter, empty(), empty());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Point2S project(final Point2S pt) {
+        // use our custom projector so that we can disambiguate points that are
+        // actually equidistant from the target point
+        final BoundaryProjector2S projector = new BoundaryProjector2S(pt);
+        accept(projector);
+
+        return projector.getProjected();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected RegionSizeProperties<Point2S> computeRegionSizeProperties() {
+        // handle simple cases
+        if (isFull()) {
+            return new RegionSizeProperties<>(FULL_SIZE, null);
+        } else if (isEmpty()) {
+            return new RegionSizeProperties<>(0, null);
+        }
+
+        List<ConvexArea2S> areas = toConvex();
+        DoublePrecisionContext precision = ((GreatArc) getRoot().getCut()).getPrecision();
+
+        double sizeSum = 0;
+        Vector3D barycenterVector = Vector3D.ZERO;
+
+        double size;
+        for (ConvexArea2S area : areas) {
+            size = area.getSize();
+
+            sizeSum += size;
+            barycenterVector = Vector3D.linearCombination(
+                    1, barycenterVector,
+                    size, area.getBarycenter().getVector());
+        }
+
+        Point2S barycenter = barycenterVector.eq(Vector3D.ZERO, precision) ?
+                null :
+                Point2S.from(barycenterVector);
+
+        return new RegionSizeProperties<>(sizeSum, barycenter);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected RegionNode2S createNode() {
+        return new RegionNode2S(this);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void invalidate() {
+        super.invalidate();
+
+        boundaryPaths = null;
+    }
+
+    /** Compute the region represented by the given node.
+     * @param node the node to compute the region for
+     * @return the region represented by the given node
+     */
+    private ConvexArea2S computeNodeRegion(final RegionNode2S node) {
+        ConvexArea2S area = ConvexArea2S.full();
+
+        RegionNode2S child = node;
+        RegionNode2S parent;
+
+        while ((parent = child.getParent()) != null) {
+            Split<ConvexArea2S> split = area.split(parent.getCutHyperplane());
+
+            area = child.isMinus() ? split.getMinus() : split.getPlus();
+
+            child = parent;
+        }
+
+        return area;
+    }
+
+    /** Compute the great arc paths comprising the region boundary.
+     * @return the great arc paths comprising the region boundary
+     */
+    private List<GreatArcPath> computeBoundaryPaths() {
+        final InteriorAngleGreatArcConnector connector = new InteriorAngleGreatArcConnector.Minimize();
+        return connector.connectAll(boundaries());
+    }
+
+    /** Return a new, empty BSP tree.
+     * @return a new, empty BSP tree.
+     */
+    public static RegionBSPTree2S empty() {
+        return new RegionBSPTree2S(false);
+    }
+
+    /** Return a new, full BSP tree. The returned tree represents the
+     * full space.
+     * @return a new, full BSP tree.
+     */
+    public static RegionBSPTree2S full() {
+        return new RegionBSPTree2S(true);
+    }
+
+    /** Construct a tree from a convex area.
+     * @param area the area to construct a tree from
+     * @return tree instance representing the same area as the given
+     *      convex area
+     */
+    public static RegionBSPTree2S from(final ConvexArea2S area) {
+        final RegionBSPTree2S tree = RegionBSPTree2S.full();
+        tree.insert(area.getBoundaries());
+
+        return tree;
+    }
+
+    /** BSP tree node for two dimensional spherical space.
+     */
+    public static final class RegionNode2S extends AbstractRegionBSPTree.AbstractRegionNode<Point2S, RegionNode2S> {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20191005L;
+
+        /** Simple constructor.
+         * @param tree tree owning the instance.
+         */
+        protected RegionNode2S(final AbstractBSPTree<Point2S, RegionNode2S> tree) {
+            super(tree);
+        }
+
+        /** Get the region represented by this node. The returned region contains
+         * the entire area contained in this node, regardless of the attributes of
+         * any child nodes.
+         * @return the region represented by this node
+         */
+        public ConvexArea2S getNodeRegion() {
+            return ((RegionBSPTree2S) getTree()).computeNodeRegion(this);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected RegionNode2S getSelf() {
+            return this;
+        }
+    }
+
+    /** Class used to project points onto the region boundary.
+     */
+    private static final class BoundaryProjector2S extends BoundaryProjector<Point2S, RegionNode2S> {
+
+        /** Serializable UID. */
+        private static final long serialVersionUID = 20191120L;
+
+        /** Simple constructor.
+         * @param point the point to project onto the region's boundary
+         */
+        BoundaryProjector2S(Point2S point) {
+            super(point);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected Point2S disambiguateClosestPoint(final Point2S target, final Point2S a, final Point2S b) {
+            // return the point with the smallest coordinate values
+            final int cmp = Point2S.POLAR_AZIMUTH_ASCENDING_ORDER.compare(a, b);
+            return cmp < 0 ? a : b;
+        }
+    }
+}
diff --git a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/S2Point.java b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/S2Point.java
deleted file mode 100644
index ca3bf3f..0000000
--- a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/S2Point.java
+++ /dev/null
@@ -1,228 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.twod;
-
-import java.io.Serializable;
-
-import org.apache.commons.geometry.core.Point;
-import org.apache.commons.geometry.core.internal.SimpleTupleFormat;
-import org.apache.commons.geometry.euclidean.threed.SphericalCoordinates;
-import org.apache.commons.geometry.euclidean.threed.Vector3D;
-
-/** This class represents a point on the 2-sphere.
- * <p>Instances of this class are guaranteed to be immutable.</p>
- */
-public final class S2Point implements Point<S2Point>, Serializable {
-
-    /** +I (coordinates: ( azimuth = 0, polar = pi/2 )). */
-    public static final S2Point PLUS_I = new S2Point(0, 0.5 * Math.PI, Vector3D.Unit.PLUS_X);
-
-    /** +J (coordinates: ( azimuth = pi/2, polar = pi/2 ))). */
-    public static final S2Point PLUS_J = new S2Point(0.5 * Math.PI, 0.5 * Math.PI, Vector3D.Unit.PLUS_Y);
-
-    /** +K (coordinates: ( azimuth = any angle, polar = 0 )). */
-    public static final S2Point PLUS_K = new S2Point(0, 0, Vector3D.Unit.PLUS_Z);
-
-    /** -I (coordinates: ( azimuth = pi, polar = pi/2 )). */
-    public static final S2Point MINUS_I = new S2Point(Math.PI, 0.5 * Math.PI, Vector3D.Unit.MINUS_X);
-
-    /** -J (coordinates: ( azimuth = 3pi/2, polar = pi/2 )). */
-    public static final S2Point MINUS_J = new S2Point(1.5 * Math.PI, 0.5 * Math.PI, Vector3D.Unit.MINUS_Y);
-
-    /** -K (coordinates: ( azimuth = any angle, polar = pi )). */
-    public static final S2Point MINUS_K = new S2Point(0, Math.PI, Vector3D.Unit.MINUS_Z);
-
-    // CHECKSTYLE: stop ConstantName
-    /** A point with all coordinates set to NaN. */
-    public static final S2Point NaN = new S2Point(Double.NaN, Double.NaN, Vector3D.NaN);
-    // CHECKSTYLE: resume ConstantName
-
-    /** Serializable UID. */
-    private static final long serialVersionUID = 20180710L;
-
-    /** Azimuthal angle in the x-y plane. */
-    private final double azimuth;
-
-    /** Polar angle. */
-    private final double polar;
-
-    /** Corresponding 3D normalized vector. */
-    private final Vector3D vector;
-
-    /** Build a point from its internal components.
-     * @param azimuth azimuthal angle in the x-y plane
-     * @param polar polar angle
-     * @param vector corresponding vector; if null, the vector is computed
-     */
-    private S2Point(final double azimuth, final double polar, final Vector3D vector) {
-        this.azimuth = SphericalCoordinates.normalizeAzimuth(azimuth);
-        this.polar = SphericalCoordinates.normalizePolar(polar);
-        this.vector = (vector != null) ? vector : SphericalCoordinates.toCartesian(1.0, azimuth, polar);
-    }
-
-    /** Get the azimuthal angle in the x-y plane in radians.
-     * @return azimuthal angle in the x-y plane
-     * @see S2Point#of(double, double)
-     */
-    public double getAzimuth() {
-        return azimuth;
-    }
-
-    /** Get the polar angle in radians.
-     * @return polar angle
-     * @see S2Point#of(double, double)
-     */
-    public double getPolar() {
-        return polar;
-    }
-
-    /** Get the corresponding normalized vector in the 3D Euclidean space.
-     * @return normalized vector
-     */
-    public Vector3D getVector() {
-        return vector;
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public int getDimension() {
-        return 2;
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public boolean isNaN() {
-        return Double.isNaN(azimuth) || Double.isNaN(polar);
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public boolean isInfinite() {
-        return !isNaN() && (Double.isInfinite(azimuth) || Double.isInfinite(polar));
-    }
-
-    /** Get the opposite of the instance.
-     * @return a new vector which is opposite to the instance
-     */
-    public S2Point negate() {
-        return new S2Point(-azimuth, Math.PI - polar, vector.negate());
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public double distance(final S2Point point) {
-        return distance(this, point);
-    }
-
-    /** Compute the distance (angular separation) between two points.
-     * @param p1 first vector
-     * @param p2 second vector
-     * @return the angular separation between p1 and p2
-     */
-    public static double distance(S2Point p1, S2Point p2) {
-        return p1.vector.angle(p2.vector);
-    }
-
-    /**
-     * Test for the equality of two points on the 2-sphere.
-     * <p>
-     * If all coordinates of two points are exactly the same, and none are
-     * <code>Double.NaN</code>, the two points are considered to be equal.
-     * </p>
-     * <p>
-     * <code>NaN</code> coordinates are considered to affect globally the vector
-     * and be equals to each other - i.e, if either (or all) coordinates of the
-     * 2D vector are equal to <code>Double.NaN</code>, the 2D vector is equal to
-     * {@link #NaN}.
-     * </p>
-     *
-     * @param other Object to test for equality to this
-     * @return true if two points on the 2-sphere objects are equal, false if
-     *         object is null, not an instance of S2Point, or
-     *         not equal to this S2Point instance
-     *
-     */
-    @Override
-    public boolean equals(Object other) {
-        if (this == other) {
-            return true;
-        }
-
-        if (other instanceof S2Point) {
-            final S2Point rhs = (S2Point) other;
-            if (rhs.isNaN()) {
-                return this.isNaN();
-            }
-
-            return (azimuth == rhs.azimuth) && (polar == rhs.polar);
-        }
-        return false;
-    }
-
-    /**
-     * Get a hashCode for the 2D vector.
-     * <p>
-     * All NaN values have the same hash code.</p>
-     *
-     * @return a hash code value for this object
-     */
-    @Override
-    public int hashCode() {
-        if (isNaN()) {
-            return 542;
-        }
-        return 134 * (37 * Double.hashCode(azimuth) +  Double.hashCode(polar));
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public String toString() {
-        return SimpleTupleFormat.getDefault().format(getAzimuth(), getPolar());
-    }
-
-    /** Build a vector from its spherical coordinates
-     * @param azimuth azimuthal angle in the x-y plane
-     * @param polar polar angle
-     * @return point instance with the given coordinates
-     * @see #getAzimuth()
-     * @see #getPolar()
-     */
-    public static S2Point of(final double azimuth, final double polar) {
-        return new S2Point(azimuth, polar, null);
-    }
-
-    /** Build a point from its underlying 3D vector
-     * @param vector 3D vector
-     * @return point instance with the coordinates determined by the given 3D vector
-     * @exception IllegalStateException if vector norm is zero
-     */
-    public static S2Point ofVector(final Vector3D vector) {
-        SphericalCoordinates coords = SphericalCoordinates.fromCartesian(vector);
-
-        return new S2Point(coords.getAzimuth(), coords.getPolar(), vector.normalize());
-    }
-
-    /** Parses the given string and returns a new point instance. The expected string
-     * format is the same as that returned by {@link #toString()}.
-     * @param str the string to parse
-     * @return point instance represented by the string
-     * @throws IllegalArgumentException if the given string has an invalid format
-     */
-    public static S2Point parse(String str) {
-        return SimpleTupleFormat.getDefault().parse(str, S2Point::of);
-    }
-}
diff --git a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/SphericalPolygonsSet.java b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/SphericalPolygonsSet.java
deleted file mode 100644
index 83df5a6..0000000
--- a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/SphericalPolygonsSet.java
+++ /dev/null
@@ -1,558 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.twod;
-
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Iterator;
-import java.util.List;
-
-import org.apache.commons.geometry.core.Geometry;
-import org.apache.commons.geometry.core.partitioning.AbstractRegion;
-import org.apache.commons.geometry.core.partitioning.BSPTree;
-import org.apache.commons.geometry.core.partitioning.BoundaryProjection;
-import org.apache.commons.geometry.core.partitioning.RegionFactory;
-import org.apache.commons.geometry.core.partitioning.SubHyperplane;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.geometry.enclosing.EnclosingBall;
-import org.apache.commons.geometry.enclosing.WelzlEncloser;
-import org.apache.commons.geometry.euclidean.threed.Vector3D;
-import org.apache.commons.geometry.euclidean.threed.enclosing.SphereGenerator;
-import org.apache.commons.geometry.euclidean.threed.rotation.QuaternionRotation;
-import org.apache.commons.geometry.spherical.oned.S1Point;
-
-/** This class represents a region on the 2-sphere: a set of spherical polygons.
- */
-public class SphericalPolygonsSet extends AbstractRegion<S2Point, S1Point> {
-
-    /** Boundary defined as an array of closed loops start vertices. */
-    private List<Vertex> loops;
-
-    /** Build a polygons set representing the whole real 2-sphere.
-     * @param precision precision context used to compare floating point values
-     */
-    public SphericalPolygonsSet(final DoublePrecisionContext precision) {
-        super(precision);
-    }
-
-    /** Build a polygons set representing a hemisphere.
-     * @param pole pole of the hemisphere (the pole is in the inside half)
-     * @param precision precision context used to compare floating point values
-     */
-    public SphericalPolygonsSet(final Vector3D pole, final DoublePrecisionContext precision) {
-        super(new BSPTree<>(new Circle(pole, precision).wholeHyperplane(),
-                                    new BSPTree<S2Point>(Boolean.FALSE),
-                                    new BSPTree<S2Point>(Boolean.TRUE),
-                                    null),
-              precision);
-    }
-
-    /** Build a polygons set representing a regular polygon.
-     * @param center center of the polygon (the center is in the inside half)
-     * @param meridian point defining the reference meridian for first polygon vertex
-     * @param outsideRadius distance of the vertices to the center
-     * @param n number of sides of the polygon
-     * @param precision precision context used to compare floating point values
-     */
-    public SphericalPolygonsSet(final Vector3D center, final Vector3D meridian,
-                                final double outsideRadius, final int n,
-                                final DoublePrecisionContext precision) {
-        this(precision, createRegularPolygonVertices(center, meridian, outsideRadius, n));
-    }
-
-    /** Build a polygons set from a BSP tree.
-     * <p>The leaf nodes of the BSP tree <em>must</em> have a
-     * {@code Boolean} attribute representing the inside status of
-     * the corresponding cell (true for inside cells, false for outside
-     * cells). In order to avoid building too many small objects, it is
-     * recommended to use the predefined constants
-     * {@code Boolean.TRUE} and {@code Boolean.FALSE}</p>
-     * @param tree inside/outside BSP tree representing the region
-     * @param precision precision context used to compare floating point values
-     */
-    public SphericalPolygonsSet(final BSPTree<S2Point> tree, final DoublePrecisionContext precision) {
-        super(tree, precision);
-    }
-
-    /** Build a polygons set from a Boundary REPresentation (B-rep).
-     * <p>The boundary is provided as a collection of {@link
-     * SubHyperplane sub-hyperplanes}. Each sub-hyperplane has the
-     * interior part of the region on its minus side and the exterior on
-     * its plus side.</p>
-     * <p>The boundary elements can be in any order, and can form
-     * several non-connected sets (like for example polygons with holes
-     * or a set of disjoint polygons considered as a whole). In
-     * fact, the elements do not even need to be connected together
-     * (their topological connections are not used here). However, if the
-     * boundary does not really separate an inside open from an outside
-     * open (open having here its topological meaning), then subsequent
-     * calls to the {@link
-     * org.apache.commons.geometry.core.partitioning.Region#checkPoint(org.apache.commons.geometry.core.Point)
-     * checkPoint} method will not be meaningful anymore.</p>
-     * <p>If the boundary is empty, the region will represent the whole
-     * space.</p>
-     * @param boundary collection of boundary elements, as a
-     * collection of {@link SubHyperplane SubHyperplane} objects
-     * @param precision precision context used to compare floating point values
-     */
-    public SphericalPolygonsSet(final Collection<SubHyperplane<S2Point>> boundary, final DoublePrecisionContext precision) {
-        super(boundary, precision);
-    }
-
-    /** Build a polygon from a simple list of vertices.
-     * <p>The boundary is provided as a list of points considering to
-     * represent the vertices of a simple loop. The interior part of the
-     * region is on the left side of this path and the exterior is on its
-     * right side.</p>
-     * <p>This constructor does not handle polygons with a boundary
-     * forming several disconnected paths (such as polygons with holes).</p>
-     * <p>For cases where this simple constructor applies, it is expected to
-     * be numerically more robust than the {@link #SphericalPolygonsSet(Collection, DoublePrecisionContext)
-     * general constructor} using {@link SubHyperplane subhyperplanes}.</p>
-     * <p>If the list is empty, the region will represent the whole
-     * space.</p>
-     * <p>
-     * Polygons with thin pikes or dents are inherently difficult to handle because
-     * they involve circles with almost opposite directions at some vertices. Polygons
-     * whose vertices come from some physical measurement with noise are also
-     * difficult because an edge that should be straight may be broken in lots of
-     * different pieces with almost equal directions. In both cases, computing the
-     * circles intersections is not numerically robust due to the almost 0 or almost
-     * &pi; angle. Such cases need to carefully adjust the {@code hyperplaneThickness}
-     * parameter. A too small value would often lead to completely wrong polygons
-     * with large area wrongly identified as inside or outside. Large values are
-     * often much safer. As a rule of thumb, a value slightly below the size of the
-     * most accurate detail needed is a good value for the {@code hyperplaneThickness}
-     * parameter.
-     * </p>
-     * @param precision precision context used to compare floating point values
-     * @param vertices vertices of the simple loop boundary
-     */
-    public SphericalPolygonsSet(final DoublePrecisionContext precision, final S2Point ... vertices) {
-        super(verticesToTree(precision, vertices), precision);
-    }
-
-    /** Build the vertices representing a regular polygon.
-     * @param center center of the polygon (the center is in the inside half)
-     * @param meridian point defining the reference meridian for first polygon vertex
-     * @param outsideRadius distance of the vertices to the center
-     * @param n number of sides of the polygon
-     * @return vertices array
-     */
-    private static S2Point[] createRegularPolygonVertices(final Vector3D center, final Vector3D meridian,
-                                                          final double outsideRadius, final int n) {
-        final S2Point[] array = new S2Point[n];
-        final QuaternionRotation r0 = QuaternionRotation.fromAxisAngle(center.cross(meridian),
-                                         outsideRadius);
-        array[0] = S2Point.ofVector(r0.apply(center));
-
-        final QuaternionRotation r = QuaternionRotation.fromAxisAngle(center, Geometry.TWO_PI / n);
-        for (int i = 1; i < n; ++i) {
-            array[i] = S2Point.ofVector(r.apply(array[i - 1].getVector()));
-        }
-
-        return array;
-    }
-
-    /** Build the BSP tree of a polygons set from a simple list of vertices.
-     * <p>The boundary is provided as a list of points considering to
-     * represent the vertices of a simple loop. The interior part of the
-     * region is on the left side of this path and the exterior is on its
-     * right side.</p>
-     * <p>This constructor does not handle polygons with a boundary
-     * forming several disconnected paths (such as polygons with holes).</p>
-     * <p>This constructor handles only polygons with edges strictly shorter
-     * than \( \pi \). If longer edges are needed, they need to be broken up
-     * in smaller sub-edges so this constraint holds.</p>
-     * <p>For cases where this simple constructor applies, it is expected to
-     * be numerically more robust than the {@link #PolygonsSet(Collection) general
-     * constructor} using {@link SubHyperplane subhyperplanes}.</p>
-     * @param precision precision context used to compare floating point values
-     * @param vertices vertices of the simple loop boundary
-     * @return the BSP tree of the input vertices
-     */
-    private static BSPTree<S2Point> verticesToTree(final DoublePrecisionContext precision,
-                                                    final S2Point ... vertices) {
-
-        final int n = vertices.length;
-        if (n == 0) {
-            // the tree represents the whole space
-            return new BSPTree<>(Boolean.TRUE);
-        }
-
-        // build the vertices
-        final Vertex[] vArray = new Vertex[n];
-        for (int i = 0; i < n; ++i) {
-            vArray[i] = new Vertex(vertices[i]);
-        }
-
-        // build the edges
-        List<Edge> edges = new ArrayList<>(n);
-        Vertex end = vArray[n - 1];
-        for (int i = 0; i < n; ++i) {
-
-            // get the endpoints of the edge
-            final Vertex start = end;
-            end = vArray[i];
-
-            // get the circle supporting the edge, taking care not to recreate it
-            // if it was already created earlier due to another edge being aligned
-            // with the current one
-            Circle circle = start.sharedCircleWith(end);
-            if (circle == null) {
-                circle = new Circle(start.getLocation(), end.getLocation(), precision);
-            }
-
-            // create the edge and store it
-            edges.add(new Edge(start, end,
-                               start.getLocation().getVector().angle(
-                                              end.getLocation().getVector()),
-                               circle));
-
-            // check if another vertex also happens to be on this circle
-            for (final Vertex vertex : vArray) {
-                if (vertex != start && vertex != end &&
-                    precision.eqZero(circle.getOffset(vertex.getLocation()))) {
-                    vertex.bindWith(circle);
-                }
-            }
-
-        }
-
-        // build the tree top-down
-        final BSPTree<S2Point> tree = new BSPTree<>();
-        insertEdges(precision, tree, edges);
-
-        return tree;
-
-    }
-
-    /** Recursively build a tree by inserting cut sub-hyperplanes.
-     * @param precision precision context used to compare floating point values
-     * @param node current tree node (it is a leaf node at the beginning
-     * of the call)
-     * @param edges list of edges to insert in the cell defined by this node
-     * (excluding edges not belonging to the cell defined by this node)
-     */
-    private static void insertEdges(final DoublePrecisionContext precision,
-                                    final BSPTree<S2Point> node,
-                                    final List<Edge> edges) {
-
-        // find an edge with an hyperplane that can be inserted in the node
-        int index = 0;
-        Edge inserted = null;
-        while (inserted == null && index < edges.size()) {
-            inserted = edges.get(index++);
-            if (!node.insertCut(inserted.getCircle())) {
-                inserted = null;
-            }
-        }
-
-        if (inserted == null) {
-            // no suitable edge was found, the node remains a leaf node
-            // we need to set its inside/outside boolean indicator
-            final BSPTree<S2Point> parent = node.getParent();
-            if (parent == null || node == parent.getMinus()) {
-                node.setAttribute(Boolean.TRUE);
-            } else {
-                node.setAttribute(Boolean.FALSE);
-            }
-            return;
-        }
-
-        // we have split the node by inserting an edge as a cut sub-hyperplane
-        // distribute the remaining edges in the two sub-trees
-        final List<Edge> outsideList = new ArrayList<>();
-        final List<Edge> insideList  = new ArrayList<>();
-        for (final Edge edge : edges) {
-            if (edge != inserted) {
-                edge.split(inserted.getCircle(), outsideList, insideList);
-            }
-        }
-
-        // recurse through lower levels
-        if (!outsideList.isEmpty()) {
-            insertEdges(precision, node.getPlus(), outsideList);
-        } else {
-            node.getPlus().setAttribute(Boolean.FALSE);
-        }
-        if (!insideList.isEmpty()) {
-            insertEdges(precision, node.getMinus(),  insideList);
-        } else {
-            node.getMinus().setAttribute(Boolean.TRUE);
-        }
-
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public SphericalPolygonsSet buildNew(final BSPTree<S2Point> tree) {
-        return new SphericalPolygonsSet(tree, getPrecision());
-    }
-
-    /** {@inheritDoc}
-     * @exception IllegalStateException if the tolerance setting does not allow to build
-     * a clean non-ambiguous boundary
-     */
-    @Override
-    protected void computeGeometricalProperties() {
-
-        final BSPTree<S2Point> tree = getTree(true);
-
-        if (tree.getCut() == null) {
-
-            // the instance has a single cell without any boundaries
-
-            if (tree.getCut() == null && (Boolean) tree.getAttribute()) {
-                // the instance covers the whole space
-                setSize(4 * Math.PI);
-                setBarycenter(S2Point.of(0, 0));
-            } else {
-                setSize(0);
-                setBarycenter(S2Point.NaN);
-            }
-
-        } else {
-
-            // the instance has a boundary
-            final PropertiesComputer pc = new PropertiesComputer(getPrecision());
-            tree.visit(pc);
-            setSize(pc.getArea());
-            setBarycenter(pc.getBarycenter());
-
-        }
-
-    }
-
-    /** Get the boundary loops of the polygon.
-     * <p>The polygon boundary can be represented as a list of closed loops,
-     * each loop being given by exactly one of its vertices. From each loop
-     * start vertex, one can follow the loop by finding the outgoing edge,
-     * then the end vertex, then the next outgoing edge ... until the start
-     * vertex of the loop (exactly the same instance) is found again once
-     * the full loop has been visited.</p>
-     * <p>If the polygon has no boundary at all, a zero length loop
-     * array will be returned.</p>
-     * <p>If the polygon is a simple one-piece polygon, then the returned
-     * array will contain a single vertex.
-     * </p>
-     * <p>All edges in the various loops have the inside of the region on
-     * their left side (i.e. toward their pole) and the outside on their
-     * right side (i.e. away from their pole) when moving in the underlying
-     * circle direction. This means that the closed loops obey the direct
-     * trigonometric orientation.</p>
-     * @return boundary of the polygon, organized as an unmodifiable list of loops start vertices.
-     * @exception IllegalStateException if the tolerance setting does not allow to build
-     * a clean non-ambiguous boundary
-     * @see Vertex
-     * @see Edge
-     */
-    public List<Vertex> getBoundaryLoops() {
-
-        if (loops == null) {
-            if (getTree(false).getCut() == null) {
-                loops = Collections.emptyList();
-            } else {
-
-                // sort the arcs according to their start point
-                final BSPTree<S2Point> root = getTree(true);
-                final EdgesBuilder visitor = new EdgesBuilder(root, getPrecision());
-                root.visit(visitor);
-                final List<Edge> edges = visitor.getEdges();
-
-
-                // convert the list of all edges into a list of start vertices
-                loops = new ArrayList<>();
-                while (!edges.isEmpty()) {
-
-                    // this is an edge belonging to a new loop, store it
-                    Edge edge = edges.get(0);
-                    final Vertex startVertex = edge.getStart();
-                    loops.add(startVertex);
-
-                    // remove all remaining edges in the same loop
-                    do {
-
-                        // remove one edge
-                        for (final Iterator<Edge> iterator = edges.iterator(); iterator.hasNext();) {
-                            if (iterator.next() == edge) {
-                                iterator.remove();
-                                break;
-                            }
-                        }
-
-                        // go to next edge following the boundary loop
-                        edge = edge.getEnd().getOutgoing();
-
-                    } while (edge.getStart() != startVertex);
-
-                }
-
-            }
-        }
-
-        return Collections.unmodifiableList(loops);
-
-    }
-
-    /** Get a spherical cap enclosing the polygon.
-     * <p>
-     * This method is intended as a first test to quickly identify points
-     * that are guaranteed to be outside of the region, hence performing a full
-     * {@link #checkPoint(org.apache.commons.geometry.core.Point) checkPoint}
-     * only if the point status remains undecided after the quick check. It is
-     * is therefore mostly useful to speed up computation for small polygons with
-     * complex shapes (say a country boundary on Earth), as the spherical cap will
-     * be small and hence will reliably identify a large part of the sphere as outside,
-     * whereas the full check can be more computing intensive. A typical use case is
-     * therefore:
-     * </p>
-     * <pre>{@code
-     *   // compute region, plus an enclosing spherical cap
-     *   SphericalPolygonsSet complexShape = ...;
-     *   EnclosingBall<S2Point, S2Point> cap = complexShape.getEnclosingCap();
-     *
-     *   // check lots of points
-     *   for (Vector3D p : points) {
-     *
-     *     final Location l;
-     *     if (cap.contains(p)) {
-     *       // we cannot be sure where the point is
-     *       // we need to perform the full computation
-     *       l = complexShape.checkPoint(v);
-     *     } else {
-     *       // no need to do further computation,
-     *       // we already know the point is outside
-     *       l = Location.OUTSIDE;
-     *     }
-     *
-     *     // use l ...
-     *
-     *   }
-     * }</pre>
-     * <p>
-     * In the special cases of empty or whole sphere polygons, special
-     * spherical caps are returned, with angular radius set to negative
-     * or positive infinity so the {@link
-     * EnclosingBall#contains(org.apache.commons.geometry.core.Point) ball.contains(point)}
-     * method return always false or true.
-     * </p>
-     * <p>
-     * This method is <em>not</em> guaranteed to return the smallest enclosing cap.
-     * </p>
-     * @return a spherical cap enclosing the polygon
-     */
-    public EnclosingBall<S2Point> getEnclosingCap() {
-
-        // handle special cases first
-        if (isEmpty()) {
-            return new EnclosingBall<>(S2Point.PLUS_K, Double.NEGATIVE_INFINITY);
-        }
-        if (isFull()) {
-            return new EnclosingBall<>(S2Point.PLUS_K, Double.POSITIVE_INFINITY);
-        }
-
-        // as the polygons is neither empty nor full, it has some boundaries and cut hyperplanes
-        final BSPTree<S2Point> root = getTree(false);
-        if (isEmpty(root.getMinus()) && isFull(root.getPlus())) {
-            // the polygon covers an hemisphere, and its boundary is one 2π long edge
-            final Circle circle = (Circle) root.getCut().getHyperplane();
-            return new EnclosingBall<>(S2Point.ofVector(circle.getPole()).negate(),
-                                                        0.5 * Math.PI);
-        }
-        if (isFull(root.getMinus()) && isEmpty(root.getPlus())) {
-            // the polygon covers an hemisphere, and its boundary is one 2π long edge
-            final Circle circle = (Circle) root.getCut().getHyperplane();
-            return new EnclosingBall<>(S2Point.ofVector(circle.getPole()),
-                                                        0.5 * Math.PI);
-        }
-
-        // gather some inside points, to be used by the encloser
-        final List<Vector3D> points = getInsidePoints();
-
-        // extract points from the boundary loops, to be used by the encloser as well
-        final List<Vertex> boundary = getBoundaryLoops();
-        for (final Vertex loopStart : boundary) {
-            int count = 0;
-            for (Vertex v = loopStart; count == 0 || v != loopStart; v = v.getOutgoing().getEnd()) {
-                ++count;
-                points.add(v.getLocation().getVector());
-            }
-        }
-
-        // find the smallest enclosing 3D sphere
-        final SphereGenerator generator = new SphereGenerator();
-        final WelzlEncloser<Vector3D> encloser =
-                new WelzlEncloser<>(getPrecision(), generator);
-        EnclosingBall<Vector3D> enclosing3D = encloser.enclose(points);
-        final Vector3D[] support3D = enclosing3D.getSupport();
-
-        // convert to 3D sphere to spherical cap
-        final double r = enclosing3D.getRadius();
-        final double h = enclosing3D.getCenter().norm();
-        if (getPrecision().eqZero(h)) {
-            // the 3D sphere is centered on the unit sphere and covers it
-            // fall back to a crude approximation, based only on outside convex cells
-            EnclosingBall<S2Point> enclosingS2 =
-                    new EnclosingBall<>(S2Point.PLUS_K, Double.POSITIVE_INFINITY);
-            for (Vector3D outsidePoint : getOutsidePoints()) {
-                final S2Point outsideS2 = S2Point.ofVector(outsidePoint);
-                final BoundaryProjection<S2Point> projection = projectToBoundary(outsideS2);
-                if (Math.PI - projection.getOffset() < enclosingS2.getRadius()) {
-                    enclosingS2 = new EnclosingBall<>(outsideS2.negate(),
-                                                                       Math.PI - projection.getOffset(),
-                                                                       projection.getProjected());
-                }
-            }
-            return enclosingS2;
-        }
-        final S2Point[] support = new S2Point[support3D.length];
-        for (int i = 0; i < support3D.length; ++i) {
-            support[i] = S2Point.ofVector(support3D[i]);
-        }
-
-        final EnclosingBall<S2Point> enclosingS2 =
-                new EnclosingBall<>(S2Point.ofVector(enclosing3D.getCenter()),
-                                                     Math.acos((1 + h * h - r * r) / (2 * h)),
-                                                     support);
-
-        return enclosingS2;
-
-    }
-
-    /** Gather some inside points.
-     * @return list of points known to be strictly in all inside convex cells
-     */
-    private List<Vector3D> getInsidePoints() {
-        final PropertiesComputer pc = new PropertiesComputer(getPrecision());
-        getTree(true).visit(pc);
-        return pc.getConvexCellsInsidePoints();
-    }
-
-    /** Gather some outside points.
-     * @return list of points known to be strictly in all outside convex cells
-     */
-    private List<Vector3D> getOutsidePoints() {
-        final SphericalPolygonsSet complement =
-                (SphericalPolygonsSet) new RegionFactory<S2Point>().getComplement(this);
-        final PropertiesComputer pc = new PropertiesComputer(getPrecision());
-        complement.getTree(true).visit(pc);
-        return pc.getConvexCellsInsidePoints();
-    }
-
-}
diff --git a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/SubCircle.java b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/SubCircle.java
deleted file mode 100644
index bc8edca..0000000
--- a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/SubCircle.java
+++ /dev/null
@@ -1,71 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.twod;
-
-import org.apache.commons.geometry.core.partitioning.AbstractSubHyperplane;
-import org.apache.commons.geometry.core.partitioning.Hyperplane;
-import org.apache.commons.geometry.core.partitioning.Region;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.geometry.spherical.oned.Arc;
-import org.apache.commons.geometry.spherical.oned.ArcsSet;
-import org.apache.commons.geometry.spherical.oned.S1Point;
-
-/** This class represents a sub-hyperplane for {@link Circle}.
- */
-public class SubCircle extends AbstractSubHyperplane<S2Point, S1Point> {
-
-    /** Simple constructor.
-     * @param hyperplane underlying hyperplane
-     * @param remainingRegion remaining region of the hyperplane
-     */
-    public SubCircle(final Hyperplane<S2Point> hyperplane,
-                     final Region<S1Point> remainingRegion) {
-        super(hyperplane, remainingRegion);
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    protected AbstractSubHyperplane<S2Point, S1Point> buildNew(final Hyperplane<S2Point> hyperplane,
-                                                                 final Region<S1Point> remainingRegion) {
-        return new SubCircle(hyperplane, remainingRegion);
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public SplitSubHyperplane<S2Point> split(final Hyperplane<S2Point> hyperplane) {
-
-        final Circle thisCircle   = (Circle) getHyperplane();
-        final Circle otherCircle  = (Circle) hyperplane;
-        final double angle = thisCircle.getPole().angle(otherCircle.getPole());
-        final DoublePrecisionContext precision = thisCircle.getPrecision();
-
-        if (precision.eqZero(angle) || precision.compare(angle, Math.PI) >= 0) {
-            // the two circles are aligned or opposite
-            return new SplitSubHyperplane<>(null, null);
-        } else {
-            // the two circles intersect each other
-            final Arc    arc          = thisCircle.getInsideArc(otherCircle);
-            final ArcsSet.Split split = ((ArcsSet) getRemainingRegion()).split(arc);
-            final ArcsSet plus        = split.getPlus();
-            final ArcsSet minus       = split.getMinus();
-            return new SplitSubHyperplane<>(plus  == null ? null : new SubCircle(thisCircle.copySelf(), plus),
-                                                    minus == null ? null : new SubCircle(thisCircle.copySelf(), minus));
-        }
-
-    }
-
-}
diff --git a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/SubGreatCircle.java b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/SubGreatCircle.java
new file mode 100644
index 0000000..2b873b4
--- /dev/null
+++ b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/SubGreatCircle.java
@@ -0,0 +1,233 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.twod;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.exception.GeometryException;
+import org.apache.commons.geometry.core.partitioning.ConvexSubHyperplane;
+import org.apache.commons.geometry.core.partitioning.Hyperplane;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.SplitLocation;
+import org.apache.commons.geometry.core.partitioning.SubHyperplane;
+import org.apache.commons.geometry.spherical.oned.CutAngle;
+import org.apache.commons.geometry.spherical.oned.RegionBSPTree1S;
+
+/** Class representing an arbitrary region of a great circle.
+ */
+public final class SubGreatCircle extends AbstractSubGreatCircle {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 20191005L;
+
+    /** The 1D region on the great circle. */
+    private final RegionBSPTree1S region;
+
+    /** Construct a new, empty subhyperplane for the given great circle.
+     * @param greatCircle great circle defining this instance
+     */
+    public SubGreatCircle(final GreatCircle greatCircle) {
+        this(greatCircle, false);
+    }
+
+    /** Construct a new sub-region for the given great circle. If {@code full}
+     * is true, then the region will cover the entire circle; otherwise,
+     * it will be empty.
+     * @param circle great circle that the sub-region will belong to
+     * @param full if true, the sub-region will cover the entire circle;
+     *      otherwise it will be empty
+     */
+    public SubGreatCircle(final GreatCircle circle, final boolean full) {
+        this(circle, new RegionBSPTree1S(full));
+    }
+
+    /** Construct a new instance from its defining great circle and subspace region.
+     * @param circle great circle that the sub-region will belong to
+     * @param region subspace region
+     */
+    public SubGreatCircle(final GreatCircle circle, final RegionBSPTree1S region) {
+        super(circle);
+
+        this.region = region;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public RegionBSPTree1S getSubspaceRegion() {
+        return region;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public SubGreatCircle transform(final Transform<Point2S> transform) {
+        final GreatCircle circle = getCircle().transform(transform);
+
+        return new SubGreatCircle(circle, region.copy());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public List<GreatArc> toConvex() {
+        return region.toIntervals().stream()
+                .flatMap(i -> i.toConvex().stream())
+                .map(i -> GreatArc.fromInterval(getCircle(), i))
+                .collect(Collectors.toList());
+    }
+
+    /** {@inheritDoc}
+     *
+     * <p>In all cases, the current instance is not modified. However, In order to avoid
+     * unnecessary copying, this method will use the current instance as the split value when
+     * the instance lies entirely on the plus or minus side of the splitter. For example, if
+     * this instance lies entirely on the minus side of the splitter, the subplane
+     * returned by {@link Split#getMinus()} will be this instance. Similarly, {@link Split#getPlus()}
+     * will return the current instance if it lies entirely on the plus side. Callers need to make
+     * special note of this, since this class is mutable.</p>
+     */
+    @Override
+    public Split<SubGreatCircle> split(final Hyperplane<Point2S> splitter) {
+
+        final GreatCircle splitterCircle = (GreatCircle) splitter;
+        final GreatCircle thisCircle = getCircle();
+
+        final Point2S intersection = splitterCircle.intersection(thisCircle);
+
+        SubGreatCircle minus = null;
+        SubGreatCircle plus = null;
+
+        if (intersection != null) {
+            final CutAngle subSplitter = CutAngle.createPositiveFacing(
+                    thisCircle.toSubspace(intersection), splitterCircle.getPrecision());
+
+            final Split<RegionBSPTree1S> subSplit = region.splitDiameter(subSplitter);
+            final SplitLocation subLoc = subSplit.getLocation();
+
+            if (subLoc == SplitLocation.MINUS) {
+                minus = this;
+            } else if (subLoc == SplitLocation.PLUS) {
+                plus = this;
+            } else if (subLoc == SplitLocation.BOTH) {
+                minus = new SubGreatCircle(thisCircle, subSplit.getMinus());
+                plus =  new SubGreatCircle(thisCircle, subSplit.getPlus());
+            }
+        }
+
+        return new Split<>(minus, plus);
+    }
+
+    /** Add an arc to this instance.
+     * @param arc arc to add
+     * @throws GeometryException if the given arc is not from
+     *      a great circle equivalent to this instance
+     */
+    public void add(final GreatArc arc) {
+        validateGreatCircle(arc.getCircle());
+
+        region.add(arc.getSubspaceRegion());
+    }
+
+    /** Add the region represented by the given subcircle to this instance.
+     * The argument is not modified.
+     * @param subcircle subcircle to add
+     * @throws GeometryException if the given subcircle is not from
+     *      a great circle equivalent to this instance
+     */
+    public void add(final SubGreatCircle subcircle) {
+        validateGreatCircle(subcircle.getCircle());
+
+        region.union(subcircle.getSubspaceRegion());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String toString() {
+        final StringBuilder sb = new StringBuilder();
+        sb.append(this.getClass().getSimpleName())
+            .append('[')
+            .append("circle= ")
+            .append(getCircle())
+            .append(", region= ")
+            .append(region)
+            .append(']');
+
+        return sb.toString();
+    }
+
+    /** Validate that the given great circle is equivalent to the circle
+     * defining this instance.
+     * @param inputCircle the great circle to validate
+     * @throws GeometryException if the argument is not equivalent
+     *      to the great circle for this instance
+     */
+    private void validateGreatCircle(final GreatCircle inputCircle) {
+        final GreatCircle circle = getCircle();
+
+        if (!circle.eq(inputCircle)) {
+            throw new GeometryException("Argument is not on the same " +
+                    "great circle. Expected " + circle + " but was " +
+                    inputCircle);
+        }
+    }
+
+    /** {@link Builder} implementation for subcircles.
+     */
+    public static final class SubGreatCircleBuilder implements SubHyperplane.Builder<Point2S> {
+
+        /** SubGreatCircle instance created by this builder. */
+        private final SubGreatCircle subcircle;
+
+        /** Construct a new instance for building regions for the given great circle.
+         * @param circle the underlying great circle for the region
+         */
+        public SubGreatCircleBuilder(final GreatCircle circle) {
+            this.subcircle = new SubGreatCircle(circle);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void add(final SubHyperplane<Point2S> sub) {
+            addInternal(sub);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public void add(final ConvexSubHyperplane<Point2S> sub) {
+            addInternal(sub);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public SubGreatCircle build() {
+            return subcircle;
+        }
+
+        /** Internal method for adding subhyperplanes to this builder.
+         * @param sub the subhyperplane to add; either convex or non-convex
+         */
+        private void addInternal(final SubHyperplane<Point2S> sub) {
+            if (sub instanceof GreatArc) {
+                subcircle.add((GreatArc) sub);
+            } else if (sub instanceof SubGreatCircle) {
+                subcircle.add((SubGreatCircle) sub);
+            } else {
+                throw new IllegalArgumentException("Unsupported subhyperplane type: " + sub.getClass().getName());
+            }
+        }
+    }
+}
diff --git a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/Transform2S.java b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/Transform2S.java
new file mode 100644
index 0000000..e6abe83
--- /dev/null
+++ b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/Transform2S.java
@@ -0,0 +1,264 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.twod;
+
+import java.io.Serializable;
+
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.euclidean.threed.AffineTransformMatrix3D;
+import org.apache.commons.geometry.euclidean.threed.Vector3D;
+import org.apache.commons.geometry.euclidean.threed.rotation.QuaternionRotation;
+
+/** Implementation of the {@link Transform} interface for spherical 2D points.
+ *
+ * <p>This class uses an {@link AffineTransformMatrix3D} to perform spherical point transforms
+ * in Euclidean 3D space.</p>
+ *
+ * <p>Instances of this class are guaranteed to be immutable.</p>
+ */
+public final class Transform2S implements Transform<Point2S>, Serializable {
+
+    /** Serializable UID. */
+    private static final long serialVersionUID = 1L;
+
+    /** Static instance representing the identity transform. */
+    private static final Transform2S IDENTITY = new Transform2S(AffineTransformMatrix3D.identity());
+
+    /** Static transform instance that reflects across the x-y plane. */
+    private static final AffineTransformMatrix3D XY_PLANE_REFLECTION = AffineTransformMatrix3D.createScale(1, 1, -1);
+
+    /** Euclidean transform matrix underlying the spherical transform. */
+    private final AffineTransformMatrix3D euclideanTransform;
+
+    /** Construct a new instance from its underlying Euclidean transform.
+     * @param euclideanTransform underlying Euclidean transform
+     */
+    private Transform2S(final AffineTransformMatrix3D euclideanTransform) {
+        this.euclideanTransform = euclideanTransform;
+    }
+
+    /** Get the Euclidean transform matrix underlying the spherical transform.
+     * @return the Euclidean transform matrix underlying the spherical transform
+     */
+    public AffineTransformMatrix3D getEuclideanTransform() {
+        return euclideanTransform;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Point2S apply(final Point2S pt) {
+        final Vector3D vec = pt.getVector();
+        return Point2S.from(euclideanTransform.apply(vec));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean preservesOrientation() {
+        return euclideanTransform.preservesOrientation();
+    }
+
+    /** Return a new instance representing the inverse transform operation
+     * of this instance.
+     * @return a transform representing the inverse of this instance
+     */
+    public Transform2S inverse() {
+        return new Transform2S(euclideanTransform.inverse());
+    }
+
+    /** Apply a rotation of {@code angle} radians around the given point to this instance.
+     * @param pt point to rotate around
+     * @param angle rotation angle in radians
+     * @return transform resulting from applying the specified rotation to this instance
+     */
+    public Transform2S rotate(final Point2S pt, final double angle) {
+        return premultiply(createRotation(pt, angle));
+    }
+
+    /** Apply a rotation of {@code angle} radians around the given 3D axis to this instance.
+     * @param axis 3D axis of rotation
+     * @param angle rotation angle in radians
+     * @return transform resulting from applying the specified rotation to this instance
+     */
+    public Transform2S rotate(final Vector3D axis, final double angle) {
+        return premultiply(createRotation(axis, angle));
+    }
+
+    /** Apply the given quaternion rotation to this instance.
+     * @param quaternion quaternion rotation to apply
+     * @return transform resulting from applying the specified rotation to this instance
+     */
+    public Transform2S rotate(final QuaternionRotation quaternion) {
+        return premultiply(createRotation(quaternion));
+    }
+
+    /** Apply a reflection across the equatorial plane defined by the given pole point
+     * to this instance.
+     * @param pole pole point defining the equatorial reflection plane
+     * @return transform resulting from applying the specified reflection to this instance
+     */
+    public Transform2S reflect(final Point2S pole) {
+        return premultiply(createReflection(pole));
+    }
+
+    /** Apply a reflection across the equatorial plane defined by the given pole vector
+     * to this instance.
+     * @param poleVector pole vector defining the equatorial reflection plane
+     * @return transform resulting from applying the specified reflection to this instance
+     */
+    public Transform2S reflect(final Vector3D poleVector) {
+        return premultiply(createReflection(poleVector));
+    }
+
+    /** Multiply the underlying Euclidean transform of this instance by that of the argument, eg,
+     * {@code other * this}. The returned transform performs the equivalent of
+     * {@code other} followed by {@code this}.
+     * @param other transform to multiply with
+     * @return a new transform computed by multiplying the matrix of this
+     *      instance by that of the argument
+     * @see AffineTransformMatrix3D#multiply(AffineTransformMatrix3D)
+     */
+    public Transform2S multiply(final Transform2S other) {
+        return multiply(this, other);
+    }
+
+    /** Multiply the underlying Euclidean transform matrix of the argument by that of this instance, eg,
+     * {@code this * other}. The returned transform performs the equivalent of {@code this}
+     * followed by {@code other}.
+     * @param other transform to multiply with
+     * @return a new transform computed by multiplying the matrix of the
+     *      argument by that of this instance
+     * @see AffineTransformMatrix3D#premultiply(AffineTransformMatrix3D)
+     */
+    public Transform2S premultiply(final Transform2S other) {
+        return multiply(other, this);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public int hashCode() {
+        return euclideanTransform.hashCode();
+    }
+
+    /**
+     * Return true if the given object is an instance of {@link Transform2S}
+     * and the underlying Euclidean transform matrices are exactly equal.
+     * @param obj object to test for equality with the current instance
+     * @return true if the underlying transform matrices are exactly equal
+     */
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (!(obj instanceof Transform2S)) {
+            return false;
+        }
+        final Transform2S other = (Transform2S) obj;
+
+        return euclideanTransform.equals(other.euclideanTransform);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String toString() {
+        final StringBuilder sb = new StringBuilder();
+
+        sb.append(this.getClass().getSimpleName())
+            .append("[euclideanTransform= ")
+            .append(getEuclideanTransform())
+            .append("]");
+
+        return sb.toString();
+    }
+
+    /** Return an instance representing the identity transform. This transform is guaranteed
+     * to return an <em>equivalent</em> (ie, co-located) point for any input point. However, the
+     * points are not guaranteed to contain exactly equal coordinates. For example, at the poles, an
+     * infinite number of points exist that vary only in the azimuth coordinate. When one of these
+     * points is transformed by this identity transform, the returned point may contain a different
+     * azimuth value from the input, but it will still represent the same location in space.
+     * @return an instance representing the identity transform
+     */
+    public static Transform2S identity() {
+        return IDENTITY;
+    }
+
+    /** Create a transform that rotates the given angle around {@code pt}.
+     * @param pt point to rotate around
+     * @param angle angle of rotation in radians
+     * @return a transform that rotates the given angle around {@code pt}
+     */
+    public static Transform2S createRotation(final Point2S pt, final double angle) {
+        return createRotation(pt.getVector(), angle);
+    }
+
+    /** Create a transform that rotates the given angle around {@code axis}.
+     * @param axis 3D axis of rotation
+     * @param angle angle of rotation in radians
+     * @return a transform that rotates the given angle {@code axis}
+     */
+    public static Transform2S createRotation(final Vector3D axis, final double angle) {
+        return createRotation(QuaternionRotation.fromAxisAngle(axis, angle));
+    }
+
+    /** Create a transform that performs the given 3D rotation.
+     * @param quaternion quaternion instance representing the 3D rotation
+     * @return a transform that performs the given 3D rotation
+     */
+    public static Transform2S createRotation(final QuaternionRotation quaternion) {
+        return new Transform2S(quaternion.toMatrix());
+    }
+
+    /** Create a transform that performs a reflection across the equatorial plane
+     * defined by the given pole point.
+     * @param pole pole point defining the equatorial reflection plane
+     * @return a transform that performs a reflection across the equatorial plane
+     *      defined by the given pole point
+     */
+    public static Transform2S createReflection(final Point2S pole) {
+        return createReflection(pole.getVector());
+    }
+
+    /** Create a transform that performs a reflection across the equatorial plane
+     * defined by the given pole point.
+     * @param poleVector pole vector defining the equatorial reflection plane
+     * @return a transform that performs a reflection across the equatorial plane
+     *      defined by the given pole point
+     */
+    public static Transform2S createReflection(final Vector3D poleVector) {
+        final QuaternionRotation quat = QuaternionRotation.createVectorRotation(poleVector, Vector3D.Unit.PLUS_Z);
+
+        final AffineTransformMatrix3D matrix = quat.toMatrix()
+                .premultiply(XY_PLANE_REFLECTION)
+                .premultiply(quat.inverse().toMatrix());
+
+        return new Transform2S(matrix);
+    }
+
+    /** Multiply the Euclidean transform matrices of the arguments together.
+     * @param a first transform
+     * @param b second transform
+     * @return the transform computed as {@code a x b}
+     */
+    private static Transform2S multiply(final Transform2S a, final Transform2S b) {
+
+        final AffineTransformMatrix3D aMat = a.euclideanTransform;
+        final AffineTransformMatrix3D bMat = b.euclideanTransform;
+
+        return new Transform2S(aMat.multiply(bMat));
+    }
+}
diff --git a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/Vertex.java b/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/Vertex.java
deleted file mode 100644
index cfa1840..0000000
--- a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/Vertex.java
+++ /dev/null
@@ -1,123 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.twod;
-
-import java.util.ArrayList;
-import java.util.List;
-
-/** Spherical polygons boundary vertex.
- * @see SphericalPolygonsSet#getBoundaryLoops()
- * @see Edge
- */
-public class Vertex {
-
-    /** Vertex location. */
-    private final S2Point location;
-
-    /** Incoming edge. */
-    private Edge incoming;
-
-    /** Outgoing edge. */
-    private Edge outgoing;
-
-    /** Circles bound with this vertex. */
-    private final List<Circle> circles;
-
-    /** Build a non-processed vertex not owned by any node yet.
-     * @param location vertex location
-     */
-    Vertex(final S2Point location) {
-        this.location = location;
-        this.incoming = null;
-        this.outgoing = null;
-        this.circles  = new ArrayList<>();
-    }
-
-    /** Get Vertex location.
-     * @return vertex location
-     */
-    public S2Point getLocation() {
-        return location;
-    }
-
-    /** Bind a circle considered to contain this vertex.
-     * @param circle circle to bind with this vertex
-     */
-    void bindWith(final Circle circle) {
-        circles.add(circle);
-    }
-
-    /** Get the common circle bound with both the instance and another vertex, if any.
-     * <p>
-     * When two vertices are both bound to the same circle, this means they are
-     * already handled by node associated with this circle, so there is no need
-     * to create a cut hyperplane for them.
-     * </p>
-     * @param vertex other vertex to check instance against
-     * @return circle bound with both the instance and another vertex, or null if the
-     * two vertices do not share a circle yet
-     */
-    Circle sharedCircleWith(final Vertex vertex) {
-        for (final Circle circle1 : circles) {
-            for (final Circle circle2 : vertex.circles) {
-                if (circle1 == circle2) {
-                    return circle1;
-                }
-            }
-        }
-        return null;
-    }
-
-    /** Set incoming edge.
-     * <p>
-     * The circle supporting the incoming edge is automatically bound
-     * with the instance.
-     * </p>
-     * @param incoming incoming edge
-     */
-    void setIncoming(final Edge incoming) {
-        this.incoming = incoming;
-        bindWith(incoming.getCircle());
-    }
-
-    /** Get incoming edge.
-     * @return incoming edge
-     */
-    public Edge getIncoming() {
-        return incoming;
-    }
-
-    /** Set outgoing edge.
-     * <p>
-     * The circle supporting the outgoing edge is automatically bound
-     * with the instance.
-     * </p>
-     * @param outgoing outgoing edge
-     */
-    void setOutgoing(final Edge outgoing) {
-        this.outgoing = outgoing;
-        bindWith(outgoing.getCircle());
-    }
-
-    /** Get outgoing edge.
-     * @return outgoing edge
-     */
-    public Edge getOutgoing() {
-        return outgoing;
-    }
-
-}
diff --git a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/SphericalTestUtils.java b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/SphericalTestUtils.java
index dcb2571..a1aac54 100644
--- a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/SphericalTestUtils.java
+++ b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/SphericalTestUtils.java
@@ -16,105 +16,69 @@
  */
 package org.apache.commons.geometry.spherical;
 
-import java.io.IOException;
-import java.text.ParseException;
-
-import org.apache.commons.geometry.core.partitioning.Hyperplane;
-import org.apache.commons.geometry.core.partitioning.TreeBuilder;
-import org.apache.commons.geometry.core.partitioning.TreeDumper;
+import org.apache.commons.geometry.core.Region;
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
 import org.apache.commons.geometry.euclidean.threed.Vector3D;
-import org.apache.commons.geometry.spherical.oned.ArcsSet;
-import org.apache.commons.geometry.spherical.oned.LimitAngle;
-import org.apache.commons.geometry.spherical.oned.S1Point;
-import org.apache.commons.geometry.spherical.twod.Circle;
-import org.apache.commons.geometry.spherical.twod.S2Point;
-import org.apache.commons.geometry.spherical.twod.SphericalPolygonsSet;
+import org.apache.commons.geometry.spherical.oned.Point1S;
+import org.apache.commons.geometry.spherical.twod.Point2S;
+import org.junit.Assert;
 
-/** Test utilities for spherical spaces.
+/** Class containing various test utilities for spherical space.
  */
 public class SphericalTestUtils {
 
-    /** Get a string representation of an {@link ArcsSet}.
-     * @param arcsSet region to dump
-     * @return string representation of the region
+    /** Assert that the given points are equal, using the specified tolerance value.
+     * @param expected
+     * @param actual
+     * @param tolerance
      */
-    public static String dump(final ArcsSet arcsSet) {
-        final TreeDumper<S1Point> visitor = new TreeDumper<S1Point>("ArcsSet") {
-
-            /** {@inheritDoc} */
-            @Override
-            protected void formatHyperplane(final Hyperplane<S1Point> hyperplane) {
-                final LimitAngle h = (LimitAngle) hyperplane;
-                getFormatter().format("%22.15e %b %s",
-                                      h.getLocation().getAzimuth(), h.isDirect(), h.getPrecision().toString());
-            }
-
-        };
-        arcsSet.getTree(false).visit(visitor);
-        return visitor.getDump();
+    public static void assertPointsEqual(Point1S expected, Point1S actual, double tolerance) {
+        String msg = "Expected point to equal " + expected + " but was " + actual + ";";
+        Assert.assertEquals(msg, expected.getAzimuth(), actual.getAzimuth(), tolerance);
     }
 
-    /** Get a string representation of a {@link SphericalPolygonsSet}.
-     * @param sphericalPolygonsSet region to dump
-     * @return string representation of the region
+    /** Assert that the given points are equal, using the specified tolerance value.
+     * @param expected
+     * @param actual
+     * @param tolerance
      */
-    public static String dump(final SphericalPolygonsSet sphericalPolygonsSet) {
-        final TreeDumper<S2Point> visitor = new TreeDumper<S2Point>("SphericalPolygonsSet") {
-
-            /** {@inheritDoc} */
-            @Override
-            protected void formatHyperplane(final Hyperplane<S2Point> hyperplane) {
-                final Circle h = (Circle) hyperplane;
-                getFormatter().format("%22.15e %22.15e %22.15e %s",
-                                      h.getPole().getX(), h.getPole().getY(), h.getPole().getZ(),
-                                      h.getPrecision().toString());
-            }
-
-        };
-        sphericalPolygonsSet.getTree(false).visit(visitor);
-        return visitor.getDump();
+    public static void assertPointsEqual(Point2S expected, Point2S actual, double tolerance) {
+        String msg = "Expected point to equal " + expected + " but was " + actual + ";";
+        Assert.assertEquals(msg, expected.getAzimuth(), actual.getAzimuth(), tolerance);
+        Assert.assertEquals(msg, expected.getPolar(), actual.getPolar(), tolerance);
     }
 
-    /** Parse a string representation of an {@link ArcsSet}.
-     * @param s string to parse
-     * @return parsed region
-     * @exception IOException if the string cannot be read
-     * @exception ParseException if the string cannot be parsed
+    /** Assert that the given points are equalivalent, using the specified tolerance value.
+     * @param expected
+     * @param actual
+     * @param tolerance
      */
-    public static ArcsSet parseArcsSet(final String s)
-        throws IOException, ParseException {
-        final TreeBuilder<S1Point> builder = new TreeBuilder<S1Point>("ArcsSet", s) {
-
-            /** {@inheritDoc} */
-            @Override
-            protected LimitAngle parseHyperplane()
-                throws ParseException {
-                return new LimitAngle(S1Point.of(getNumber()), getBoolean(), getPrecision());
-            }
-
-        };
-        return new ArcsSet(builder.getTree(), builder.getPrecision());
+    public static void assertPointsEq(Point2S expected, Point2S actual, double tolerance) {
+        String msg = "Expected point to be equivalent to " + expected + " but was " + actual + ";";
+        Assert.assertTrue(msg, expected.eq(actual, new EpsilonDoublePrecisionContext(tolerance)));
     }
 
-    /** Parse a string representation of a {@link SphericalPolygonsSet}.
-     * @param s string to parse
-     * @return parsed region
-     * @exception IOException if the string cannot be read
-     * @exception ParseException if the string cannot be parsed
+    /** Assert that the given vectors are equal, using the specified tolerance value.
+     * @param expected
+     * @param actual
+     * @param tolerance
      */
-    public static SphericalPolygonsSet parseSphericalPolygonsSet(final String s)
-        throws IOException, ParseException {
-        final TreeBuilder<S2Point> builder = new TreeBuilder<S2Point>("SphericalPolygonsSet", s) {
-
-            /** {@inheritDoc} */
-            @Override
-            public Circle parseHyperplane()
-                throws ParseException {
-                return new Circle(Vector3D.of(getNumber(), getNumber(), getNumber()), getPrecision());
-            }
-
-        };
-        return new SphericalPolygonsSet(builder.getTree(), builder.getPrecision());
+    public static void assertVectorsEqual(Vector3D expected, Vector3D actual, double tolerance) {
+        String msg = "Expected vector to equal " + expected + " but was " + actual + ";";
+        Assert.assertEquals(msg, expected.getX(), actual.getX(), tolerance);
+        Assert.assertEquals(msg, expected.getY(), actual.getY(), tolerance);
+        Assert.assertEquals(msg, expected.getZ(), actual.getZ(), tolerance);
     }
 
+    /** Assert that the given points lie in the specified location relative to the region.
+     * @param region region to test
+     * @param loc expected location of the given points
+     * @param pts points to test
+     */
+    public static void checkClassify(Region<Point2S> region, RegionLocation loc, Point2S ... pts) {
+        for (Point2S pt : pts) {
+            Assert.assertEquals("Unexpected location for point " + pt, loc, region.classify(pt));
+        }
+    }
 }
diff --git a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/oned/AngularIntervalTest.java b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/oned/AngularIntervalTest.java
new file mode 100644
index 0000000..853b297
--- /dev/null
+++ b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/oned/AngularIntervalTest.java
@@ -0,0 +1,894 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.oned;
+
+import java.util.List;
+
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.GeometryTestUtils;
+import org.apache.commons.geometry.core.Region;
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.exception.GeometryException;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.SplitLocation;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class AngularIntervalTest {
+
+    private static final double TEST_EPS = 1e-10;
+
+    private static final DoublePrecisionContext TEST_PRECISION =
+            new EpsilonDoublePrecisionContext(TEST_EPS);
+
+    @Test
+    public void testOf_doubles() {
+        // act/assert
+        checkInterval(AngularInterval.of(0, 1, TEST_PRECISION), 0, 1);
+        checkInterval(AngularInterval.of(1, 0, TEST_PRECISION), 1, Geometry.TWO_PI);
+        checkInterval(AngularInterval.of(-2, -1.5, TEST_PRECISION), -2, -1.5);
+        checkInterval(AngularInterval.of(-2, -2.5, TEST_PRECISION), -2, Geometry.TWO_PI - 2.5);
+
+        checkFull(AngularInterval.of(1, 1, TEST_PRECISION));
+        checkFull(AngularInterval.of(0, 1e-11, TEST_PRECISION));
+        checkFull(AngularInterval.of(0, -1e-11, TEST_PRECISION));
+        checkFull(AngularInterval.of(0, Geometry.TWO_PI, TEST_PRECISION));
+    }
+
+    @Test
+    public void testOf_doubles_invalidArgs() {
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            AngularInterval.of(Double.NEGATIVE_INFINITY, 0, TEST_PRECISION);
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            AngularInterval.of(0, Double.POSITIVE_INFINITY, TEST_PRECISION);
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            AngularInterval.of(Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY, TEST_PRECISION);
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            AngularInterval.of(Double.NaN, 0, TEST_PRECISION);
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            AngularInterval.of(0, Double.NaN, TEST_PRECISION);
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            AngularInterval.of(Double.NaN, Double.NaN, TEST_PRECISION);
+        }, GeometryException.class);
+    }
+
+    @Test
+    public void testOf_points() {
+        // act/assert
+        checkInterval(AngularInterval.of(Point1S.of(0), Point1S.of(1), TEST_PRECISION), 0, 1);
+        checkInterval(AngularInterval.of(Point1S.of(1), Point1S.of(0), TEST_PRECISION), 1, Geometry.TWO_PI);
+        checkInterval(AngularInterval.of(Point1S.of(-2), Point1S.of(-1.5), TEST_PRECISION), -2, -1.5);
+        checkInterval(AngularInterval.of(Point1S.of(-2), Point1S.of(-2.5), TEST_PRECISION), -2, Geometry.TWO_PI - 2.5);
+
+        checkFull(AngularInterval.of(Point1S.of(1), Point1S.of(1), TEST_PRECISION));
+        checkFull(AngularInterval.of(Point1S.of(0), Point1S.of(1e-11), TEST_PRECISION));
+        checkFull(AngularInterval.of(Point1S.of(0), Point1S.of(-1e-11), TEST_PRECISION));
+    }
+
+    @Test
+    public void testOf_points_invalidArgs() {
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            AngularInterval.of(Point1S.of(Double.NEGATIVE_INFINITY), Point1S.ZERO, TEST_PRECISION);
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            AngularInterval.of(Point1S.ZERO, Point1S.of(Double.POSITIVE_INFINITY), TEST_PRECISION);
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            AngularInterval.of(Point1S.of(Double.POSITIVE_INFINITY), Point1S.of(Double.NEGATIVE_INFINITY), TEST_PRECISION);
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            AngularInterval.of(Point1S.NaN, Point1S.ZERO, TEST_PRECISION);
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            AngularInterval.of(Point1S.ZERO, Point1S.NaN, TEST_PRECISION);
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            AngularInterval.of(Point1S.NaN, Point1S.NaN, TEST_PRECISION);
+        }, GeometryException.class);
+    }
+
+    @Test
+    public void testOf_orientedPoints() {
+        // arrange
+        DoublePrecisionContext precisionA = new EpsilonDoublePrecisionContext(1e-3);
+        DoublePrecisionContext precisionB = new EpsilonDoublePrecisionContext(1e-2);
+
+        CutAngle zeroPos = CutAngle.createPositiveFacing(Point1S.ZERO, precisionA);
+        CutAngle zeroNeg = CutAngle.createNegativeFacing(Point1S.ZERO, precisionA);
+
+        CutAngle piPos = CutAngle.createPositiveFacing(Point1S.PI, precisionA);
+        CutAngle piNeg = CutAngle.createNegativeFacing(Point1S.PI, precisionA);
+
+        CutAngle almostPiPos = CutAngle.createPositiveFacing(Point1S.of(Geometry.PI + 5e-3), precisionB);
+
+        // act/assert
+        checkInterval(AngularInterval.of(zeroNeg, piPos), 0, Geometry.PI);
+        checkInterval(AngularInterval.of(zeroPos, piNeg), Geometry.PI, Geometry.TWO_PI);
+
+        checkFull(AngularInterval.of(zeroPos, zeroNeg));
+        checkFull(AngularInterval.of(zeroPos, piPos));
+        checkFull(AngularInterval.of(piNeg, zeroNeg));
+
+        checkFull(AngularInterval.of(almostPiPos, piNeg));
+        checkFull(AngularInterval.of(piNeg, almostPiPos));
+    }
+
+    @Test
+    public void testOf_orientedPoints_invalidArgs() {
+        // arrange
+        CutAngle pt = CutAngle.createNegativeFacing(Point1S.ZERO, TEST_PRECISION);
+        CutAngle nan = CutAngle.createPositiveFacing(Point1S.NaN, TEST_PRECISION);
+
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            AngularInterval.of(pt, nan);
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            AngularInterval.of(nan, pt);
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            AngularInterval.of(nan, nan);
+        }, GeometryException.class);
+    }
+
+    @Test
+    public void testFull() {
+        // act
+        AngularInterval.Convex interval = AngularInterval.full();
+
+        // assert
+        checkFull(interval);
+    }
+
+    @Test
+    public void testClassify_full() {
+        // arrange
+        AngularInterval interval = AngularInterval.full();
+
+        // act/assert
+        for (double a = -2 * Geometry.PI; a >= 4 * Geometry.PI; a += 0.5) {
+            checkClassify(interval, RegionLocation.INSIDE, Point1S.of(a));
+        }
+    }
+
+    @Test
+    public void testClassify_almostFull() {
+        // arrange
+        AngularInterval interval = AngularInterval.of(1 + 2e-10, 1, TEST_PRECISION);
+
+        // act/assert
+        checkClassify(interval, RegionLocation.BOUNDARY,
+                Point1S.of(1 + 2e-10), Point1S.of(1 + 6e-11), Point1S.of(1));
+
+        checkClassify(interval, RegionLocation.INSIDE, Point1S.of(1 + 6e-11 + Geometry.PI));
+
+        for (double a = 1 + 1e-9; a >= 1 - 1e-9 + Geometry.TWO_PI; a += 0.5) {
+            checkClassify(interval, RegionLocation.INSIDE, Point1S.of(a));
+        }
+    }
+
+    @Test
+    public void testClassify_sizeableGap() {
+        // arrange
+        AngularInterval interval = AngularInterval.of(0.25, -0.25, TEST_PRECISION);
+
+        // act/assert
+        checkClassify(interval, RegionLocation.OUTSIDE,
+                Point1S.ZERO, Point1S.of(-0.2), Point1S.of(0.2));
+        checkClassify(interval, RegionLocation.BOUNDARY,
+                Point1S.of(-0.25), Point1S.of(0.2499999999999));
+        checkClassify(interval, RegionLocation.INSIDE,
+                Point1S.of(1), Point1S.PI, Point1S.of(-1));
+    }
+
+    @Test
+    public void testClassify_halfPi() {
+        // arrange
+        AngularInterval interval = AngularInterval.of(Geometry.HALF_PI, Geometry.MINUS_HALF_PI, TEST_PRECISION);
+
+        // act/assert
+        checkClassify(interval, RegionLocation.OUTSIDE,
+                Point1S.ZERO, Point1S.of(Geometry.HALF_PI - 0.1), Point1S.of(Geometry.MINUS_HALF_PI + 0.1));
+        checkClassify(interval, RegionLocation.BOUNDARY,
+                Point1S.of(Geometry.HALF_PI), Point1S.of(1.5 * Geometry.PI));
+        checkClassify(interval, RegionLocation.INSIDE,
+                Point1S.PI, Point1S.of(Geometry.HALF_PI + 0.1), Point1S.of(Geometry.MINUS_HALF_PI - 0.1));
+    }
+
+    @Test
+    public void testClassify_almostEmpty() {
+        // arrange
+        AngularInterval interval = AngularInterval.of(1, 1 + 2e-10, TEST_PRECISION);
+
+        // act/assert
+        checkClassify(interval, RegionLocation.BOUNDARY,
+                Point1S.of(1 + 2e-10), Point1S.of(1 + 6e-11), Point1S.of(1));
+
+        checkClassify(interval, RegionLocation.OUTSIDE, Point1S.of(1 + 6e-11 + Geometry.PI));
+
+        for (double a = 1 + 1e-9; a >= 1 - 1e-9 + Geometry.TWO_PI; a += 0.5) {
+            checkClassify(interval, RegionLocation.OUTSIDE, Point1S.of(a));
+        }
+    }
+
+    @Test
+    public void testProject_full() {
+        // arrange
+        AngularInterval interval = AngularInterval.full();
+
+        // act/assert
+        Assert.assertNull(interval.project(Point1S.ZERO));
+        Assert.assertNull(interval.project(Point1S.PI));
+    }
+
+    @Test
+    public void testProject() {
+        // arrange
+        AngularInterval interval = AngularInterval.of(1, 2, TEST_PRECISION);
+
+        // act/assert
+        Assert.assertEquals(1, interval.project(Point1S.ZERO).getAzimuth(), TEST_EPS);
+        Assert.assertEquals(1, interval.project(Point1S.of(1)).getAzimuth(), TEST_EPS);
+        Assert.assertEquals(1, interval.project(Point1S.of(1.5)).getAzimuth(), TEST_EPS);
+
+        Assert.assertEquals(2, interval.project(Point1S.of(2)).getAzimuth(), TEST_EPS);
+        Assert.assertEquals(2, interval.project(Point1S.PI).getAzimuth(), TEST_EPS);
+        Assert.assertEquals(2, interval.project(Point1S.of(1.4 + Geometry.PI)).getAzimuth(), TEST_EPS);
+
+        Assert.assertEquals(1, interval.project(Point1S.of(1.5 + Geometry.PI)).getAzimuth(), TEST_EPS);
+        Assert.assertEquals(1, interval.project(Point1S.of(1.6 + Geometry.PI)).getAzimuth(), TEST_EPS);
+    }
+
+    @Test
+    public void testTransform_full() {
+        // arrange
+        AngularInterval interval = AngularInterval.full();
+
+        Transform1S rotate = Transform1S.createRotation(Geometry.HALF_PI);
+        Transform1S invert = Transform1S.createNegation().rotate(Geometry.HALF_PI);
+
+        // act/assert
+        checkFull(interval.transform(rotate));
+        checkFull(interval.transform(invert));
+    }
+
+    @Test
+    public void testTransform() {
+        // arrange
+        AngularInterval interval = AngularInterval.of(Geometry.HALF_PI, Geometry.PI, TEST_PRECISION);
+
+        Transform1S rotate = Transform1S.createRotation(Geometry.HALF_PI);
+        Transform1S invert = Transform1S.createNegation().rotate(Geometry.HALF_PI);
+
+        // act/assert
+        checkInterval(interval.transform(rotate), Geometry.PI, 1.5 * Geometry.PI);
+        checkInterval(interval.transform(invert), -0.5 * Geometry.PI, Geometry.ZERO_PI);
+    }
+
+    @Test
+    public void testWrapsZero() {
+        // act/assert
+        Assert.assertFalse(AngularInterval.full().wrapsZero());
+        Assert.assertFalse(AngularInterval.of(0, Geometry.HALF_PI, TEST_PRECISION).wrapsZero());
+        Assert.assertFalse(AngularInterval.of(Geometry.HALF_PI, Geometry.PI , TEST_PRECISION).wrapsZero());
+        Assert.assertFalse(AngularInterval.of(Geometry.PI, 1.5 * Geometry.PI , TEST_PRECISION).wrapsZero());
+        Assert.assertFalse(AngularInterval.of(1.5 * Geometry.PI, Geometry.TWO_PI - 1e-5, TEST_PRECISION).wrapsZero());
+
+        Assert.assertTrue(AngularInterval.of(1.5 * Geometry.PI, Geometry.TWO_PI, TEST_PRECISION).wrapsZero());
+        Assert.assertTrue(AngularInterval.of(1.5 * Geometry.PI, 2.5 * Geometry.PI, TEST_PRECISION).wrapsZero());
+        Assert.assertTrue(AngularInterval.of(-2.5 * Geometry.PI, -1.5 * Geometry.PI, TEST_PRECISION).wrapsZero());
+    }
+
+    @Test
+    public void testToTree_full() {
+        // arrange
+        AngularInterval interval = AngularInterval.full();
+
+        // act
+        RegionBSPTree1S tree = interval.toTree();
+
+        // assert
+        Assert.assertTrue(tree.isFull());
+        Assert.assertFalse(tree.isEmpty());
+
+        checkClassify(tree, RegionLocation.INSIDE,
+                Point1S.ZERO, Point1S.of(Geometry.HALF_PI),
+                Point1S.PI, Point1S.of(Geometry.MINUS_HALF_PI));
+    }
+
+    @Test
+    public void testToTree_intervalEqualToPi() {
+        // arrange
+        AngularInterval interval = AngularInterval.of(Geometry.ZERO_PI, Geometry.PI, TEST_PRECISION);
+
+        // act
+        RegionBSPTree1S tree = interval.toTree();
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertFalse(tree.isEmpty());
+
+        checkClassify(tree, RegionLocation.BOUNDARY,
+                Point1S.ZERO, Point1S.PI);
+
+        checkClassify(tree, RegionLocation.INSIDE,
+                Point1S.of(1e-4), Point1S.of(0.25 * Geometry.PI),
+                Point1S.of(-1.25 * Geometry.PI), Point1S.of(Geometry.PI - 1e-4));
+
+        checkClassify(tree, RegionLocation.OUTSIDE,
+                Point1S.of(-1e-4), Point1S.of(-0.25 * Geometry.PI),
+                Point1S.of(1.25 * Geometry.PI), Point1S.of(-Geometry.PI + 1e-4));
+    }
+
+    @Test
+    public void testToTree_intervalLessThanPi() {
+        // arrange
+        AngularInterval interval = AngularInterval.of(Geometry.HALF_PI, Geometry.PI, TEST_PRECISION);
+
+        // act
+        RegionBSPTree1S tree = interval.toTree();
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertFalse(tree.isEmpty());
+
+        checkClassify(tree, RegionLocation.BOUNDARY,
+                Point1S.of(Geometry.HALF_PI), Point1S.PI);
+
+        checkClassify(tree, RegionLocation.INSIDE,
+                Point1S.of(0.51 * Geometry.PI), Point1S.of(0.75 * Geometry.PI),
+                Point1S.of(0.99 * Geometry.PI));
+
+        checkClassify(tree, RegionLocation.OUTSIDE,
+                Point1S.ZERO, Point1S.of(0.25 * Geometry.PI),
+                Point1S.of(1.25 * Geometry.PI), Point1S.of(1.75 * Geometry.PI));
+    }
+
+    @Test
+    public void testToTree_intervalGreaterThanPi() {
+        // arrange
+        AngularInterval interval = AngularInterval.of(Geometry.PI, Geometry.HALF_PI, TEST_PRECISION);
+
+        // act
+        RegionBSPTree1S tree = interval.toTree();
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertFalse(tree.isEmpty());
+
+        checkClassify(tree, RegionLocation.BOUNDARY,
+                Point1S.of(Geometry.HALF_PI), Point1S.PI);
+
+        checkClassify(tree, RegionLocation.INSIDE,
+                Point1S.ZERO, Point1S.of(0.25 * Geometry.PI),
+                Point1S.of(1.25 * Geometry.PI), Point1S.of(1.75 * Geometry.PI));
+
+        checkClassify(tree, RegionLocation.OUTSIDE,
+                Point1S.of(0.51 * Geometry.PI), Point1S.of(0.75 * Geometry.PI),
+                Point1S.of(0.99 * Geometry.PI));
+    }
+
+    @Test
+    public void testToConvex_lessThanPi() {
+        // arrange
+        AngularInterval interval = AngularInterval.of(0, Geometry.HALF_PI, TEST_PRECISION);
+
+        //act
+        List<AngularInterval.Convex> result = interval.toConvex();
+
+        // assert
+        Assert.assertEquals(1, result.size());
+        checkInterval(interval, 0, Geometry.HALF_PI);
+    }
+
+    @Test
+    public void testToConvex_equalToPi() {
+        // arrange
+        AngularInterval interval = AngularInterval.of(Geometry.PI, Geometry.TWO_PI, TEST_PRECISION);
+
+        //act
+        List<AngularInterval.Convex> result = interval.toConvex();
+
+        // assert
+        Assert.assertEquals(1, result.size());
+        checkInterval(interval, Geometry.PI, Geometry.TWO_PI);
+    }
+
+    @Test
+    public void testToConvex_overPi() {
+        // arrange
+        AngularInterval interval = AngularInterval.of(Geometry.PI, Geometry.HALF_PI, TEST_PRECISION);
+
+        // act
+        List<AngularInterval.Convex> result = interval.toConvex();
+
+        // assert
+        Assert.assertEquals(2, result.size());
+        checkInterval(result.get(0), Geometry.PI, 1.75 * Geometry.PI);
+        checkInterval(result.get(1), 1.75 * Geometry.PI, 2.5 * Geometry.PI);
+    }
+
+    @Test
+    public void testToConvex_overPi_splitAtZero() {
+        // arrange
+        AngularInterval interval = AngularInterval.of(1.25 * Geometry.PI, 2.75 * Geometry.PI, TEST_PRECISION);
+
+        // act
+        List<AngularInterval.Convex> result = interval.toConvex();
+
+        // assert
+        Assert.assertEquals(2, result.size());
+        checkInterval(result.get(0), 1.25 * Geometry.PI, Geometry.TWO_PI);
+        checkInterval(result.get(1), Geometry.TWO_PI, 2.75 * Geometry.PI);
+    }
+
+    @Test
+    public void testSplit_full() {
+        // arrange
+        AngularInterval interval = AngularInterval.full();
+        CutAngle pt = CutAngle.createNegativeFacing(Geometry.HALF_PI, TEST_PRECISION);
+
+        // act
+        Split<RegionBSPTree1S> split = interval.split(pt);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        RegionBSPTree1S minus = split.getMinus();
+        checkClassify(minus, RegionLocation.BOUNDARY, Point1S.of(Geometry.HALF_PI));
+        checkClassify(minus, RegionLocation.INSIDE,
+                Point1S.PI, Point1S.of(Geometry.MINUS_HALF_PI), Point1S.of(-0.25 * Geometry.PI));
+        checkClassify(minus, RegionLocation.OUTSIDE,
+                Point1S.ZERO, Point1S.of(0.25 * Geometry.PI));
+
+        RegionBSPTree1S plus = split.getPlus();
+        checkClassify(plus, RegionLocation.BOUNDARY, Point1S.of(Geometry.HALF_PI));
+        checkClassify(plus, RegionLocation.INSIDE,
+                Point1S.ZERO, Point1S.of(0.25 * Geometry.PI));
+        checkClassify(plus, RegionLocation.OUTSIDE,
+                Point1S.PI, Point1S.of(Geometry.MINUS_HALF_PI), Point1S.of(-0.25 * Geometry.PI));
+    }
+
+    @Test
+    public void testSplit_interval_both() {
+        // arrange
+        AngularInterval interval = AngularInterval.of(Geometry.HALF_PI, Geometry.PI, TEST_PRECISION);
+        CutAngle cut = CutAngle.createNegativeFacing(0.75 * Geometry.PI, TEST_PRECISION);
+
+        // act
+        Split<RegionBSPTree1S> split = interval.split(cut);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        RegionBSPTree1S minus = split.getMinus();
+        checkClassify(minus, RegionLocation.BOUNDARY, Point1S.of(Geometry.PI), cut.getPoint());
+        checkClassify(minus, RegionLocation.INSIDE, Point1S.of(0.8 * Geometry.PI));
+        checkClassify(minus, RegionLocation.OUTSIDE,
+                Point1S.ZERO, Point1S.of(Geometry.TWO_PI), Point1S.of(Geometry.MINUS_HALF_PI),
+                Point1S.of(0.7 * Geometry.PI));
+
+        RegionBSPTree1S plus = split.getPlus();
+        checkClassify(plus, RegionLocation.BOUNDARY, Point1S.of(Geometry.HALF_PI), cut.getPoint());
+        checkClassify(plus, RegionLocation.INSIDE, Point1S.of(0.6 * Geometry.PI));
+        checkClassify(plus, RegionLocation.OUTSIDE,
+                Point1S.ZERO, Point1S.of(Geometry.TWO_PI), Point1S.of(Geometry.MINUS_HALF_PI),
+                Point1S.of(0.8 * Geometry.PI));
+    }
+
+    @Test
+    public void testToString() {
+        // arrange
+        AngularInterval interval = AngularInterval.of(1, 2, TEST_PRECISION);
+
+        // act
+        String str = interval.toString();
+
+        // assert
+        Assert.assertTrue(str.contains("AngularInterval"));
+        Assert.assertTrue(str.contains("min= 1.0"));
+        Assert.assertTrue(str.contains("max= 2.0"));
+    }
+
+    @Test
+    public void testConvex_of_doubles() {
+        // act/assert
+        checkInterval(AngularInterval.Convex.of(0, 1, TEST_PRECISION), 0, 1);
+        checkInterval(AngularInterval.Convex.of(0, Geometry.PI, TEST_PRECISION), 0, Geometry.PI);
+        checkInterval(AngularInterval.Convex.of(Geometry.PI + 2, 1, TEST_PRECISION), Geometry.PI + 2, Geometry.TWO_PI + 1);
+        checkInterval(AngularInterval.Convex.of(-2, -1.5, TEST_PRECISION), -2, -1.5);
+
+        checkFull(AngularInterval.Convex.of(1, 1, TEST_PRECISION));
+        checkFull(AngularInterval.Convex.of(0, 1e-11, TEST_PRECISION));
+        checkFull(AngularInterval.Convex.of(0, -1e-11, TEST_PRECISION));
+        checkFull(AngularInterval.Convex.of(0, Geometry.TWO_PI, TEST_PRECISION));
+    }
+
+    @Test
+    public void testConvex_of_doubles_invalidArgs() {
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            AngularInterval.Convex.of(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, TEST_PRECISION);
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            AngularInterval.Convex.of(0, Geometry.PI + 1e-1, TEST_PRECISION);
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            AngularInterval.Convex.of(Geometry.HALF_PI, Geometry.MINUS_HALF_PI + 1, TEST_PRECISION);
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            AngularInterval.Convex.of(0, -0.5, TEST_PRECISION);
+        }, GeometryException.class);
+    }
+
+    @Test
+    public void testConvex_of_points() {
+        // act/assert
+        checkInterval(AngularInterval.Convex.of(Point1S.of(0), Point1S.of(1), TEST_PRECISION), 0, 1);
+        checkInterval(AngularInterval.Convex.of(Point1S.of(0), Point1S.of(Geometry.PI), TEST_PRECISION),
+                0, Geometry.PI);
+        checkInterval(AngularInterval.Convex.of(Point1S.of(Geometry.PI + 2), Point1S.of(1), TEST_PRECISION),
+                Geometry.PI + 2, Geometry.TWO_PI + 1);
+        checkInterval(AngularInterval.Convex.of(Point1S.of(-2), Point1S.of(-1.5), TEST_PRECISION), -2, -1.5);
+
+        checkFull(AngularInterval.Convex.of(Point1S.of(1), Point1S.of(1), TEST_PRECISION));
+        checkFull(AngularInterval.Convex.of(Point1S.of(0), Point1S.of(1e-11), TEST_PRECISION));
+        checkFull(AngularInterval.Convex.of(Point1S.of(0), Point1S.of(-1e-11), TEST_PRECISION));
+        checkFull(AngularInterval.Convex.of(Point1S.of(0), Point1S.of(Geometry.TWO_PI), TEST_PRECISION));
+    }
+
+    @Test
+    public void testConvex_of_points_invalidArgs() {
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            AngularInterval.Convex.of(Point1S.of(Double.NEGATIVE_INFINITY),
+                    Point1S.of(Double.POSITIVE_INFINITY), TEST_PRECISION);
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            AngularInterval.Convex.of(Point1S.of(0), Point1S.of(Geometry.PI + 1e-1), TEST_PRECISION);
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            AngularInterval.Convex.of(Point1S.of(Geometry.HALF_PI),
+                    Point1S.of(Geometry.MINUS_HALF_PI + 1), TEST_PRECISION);
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            AngularInterval.Convex.of(Point1S.of(0), Point1S.of(-0.5), TEST_PRECISION);
+        }, GeometryException.class);
+    }
+
+    @Test
+    public void testConvex_of_cutAngles() {
+        // arrange
+        DoublePrecisionContext precisionA = new EpsilonDoublePrecisionContext(1e-3);
+        DoublePrecisionContext precisionB = new EpsilonDoublePrecisionContext(1e-2);
+
+        CutAngle zeroPos = CutAngle.createPositiveFacing(Point1S.ZERO, precisionA);
+        CutAngle zeroNeg = CutAngle.createNegativeFacing(Point1S.ZERO, precisionA);
+
+        CutAngle piPos = CutAngle.createPositiveFacing(Point1S.PI, precisionA);
+        CutAngle piNeg = CutAngle.createNegativeFacing(Point1S.PI, precisionA);
+
+        CutAngle almostPiPos = CutAngle.createPositiveFacing(Point1S.of(Geometry.PI + 5e-3), precisionB);
+
+        // act/assert
+        checkInterval(AngularInterval.Convex.of(zeroNeg, piPos), 0, Geometry.PI);
+        checkInterval(AngularInterval.Convex.of(zeroPos, piNeg), Geometry.PI, Geometry.TWO_PI);
+
+        checkFull(AngularInterval.Convex.of(zeroPos, zeroNeg));
+        checkFull(AngularInterval.Convex.of(zeroPos, piPos));
+        checkFull(AngularInterval.Convex.of(piNeg, zeroNeg));
+
+        checkFull(AngularInterval.Convex.of(almostPiPos, piNeg));
+        checkFull(AngularInterval.Convex.of(piNeg, almostPiPos));
+    }
+
+    @Test
+    public void testConvex_of_cutAngles_invalidArgs() {
+        // arrange
+        CutAngle pt = CutAngle.createNegativeFacing(Point1S.ZERO, TEST_PRECISION);
+        CutAngle nan = CutAngle.createPositiveFacing(Point1S.NaN, TEST_PRECISION);
+
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            AngularInterval.Convex.of(pt, nan);
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            AngularInterval.Convex.of(nan, pt);
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            AngularInterval.Convex.of(nan, nan);
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            AngularInterval.Convex.of(
+                    CutAngle.createNegativeFacing(1, TEST_PRECISION),
+                    CutAngle.createPositiveFacing(0.5, TEST_PRECISION));
+        }, GeometryException.class);
+    }
+
+    @Test
+    public void testConvex_toConvex() {
+        // arrange
+        AngularInterval.Convex full = AngularInterval.full();
+        AngularInterval.Convex interval = AngularInterval.Convex.of(0, 1, TEST_PRECISION);
+
+        List<AngularInterval.Convex> result;
+
+        // act/assert
+        result = full.toConvex();
+        Assert.assertEquals(1, result.size());
+        Assert.assertSame(full, result.get(0));
+
+        result = interval.toConvex();
+        Assert.assertEquals(1, result.size());
+        Assert.assertSame(interval, result.get(0));
+    }
+
+    @Test
+    public void testSplitDiameter_full() {
+        // arrange
+        AngularInterval.Convex full = AngularInterval.full();
+        CutAngle splitter = CutAngle.createPositiveFacing(Point1S.of(Geometry.HALF_PI), TEST_PRECISION);
+
+        // act
+        Split<AngularInterval.Convex> split = full.splitDiameter(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        checkInterval(split.getMinus(), 1.5 * Geometry.PI, 2.5 * Geometry.PI);
+        checkInterval(split.getPlus(), 0.5 * Geometry.PI, 1.5 * Geometry.PI);
+    }
+
+    @Test
+    public void testSplitDiameter_full_splitOnZero() {
+        // arrange
+        AngularInterval.Convex full = AngularInterval.full();
+        CutAngle splitter = CutAngle.createNegativeFacing(Point1S.ZERO, TEST_PRECISION);
+
+        // act
+        Split<AngularInterval.Convex> split = full.splitDiameter(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        checkInterval(split.getMinus(), 0, Geometry.PI);
+        checkInterval(split.getPlus(), Geometry.PI, Geometry.TWO_PI);
+    }
+
+    @Test
+    public void testSplitDiameter_minus() {
+        // arrange
+        AngularInterval.Convex interval = AngularInterval.Convex.of(0.1, Geometry.HALF_PI, TEST_PRECISION);
+        CutAngle splitter = CutAngle.createNegativeFacing(Point1S.ZERO, TEST_PRECISION);
+
+        // act
+        Split<AngularInterval.Convex> split = interval.splitDiameter(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.MINUS, split.getLocation());
+
+        Assert.assertSame(interval, split.getMinus());
+        Assert.assertNull(split.getPlus());
+    }
+
+    @Test
+    public void testSplitDiameter_plus() {
+        // arrange
+        AngularInterval.Convex interval = AngularInterval.Convex.of(-0.4 * Geometry.PI, 0.4 * Geometry.PI, TEST_PRECISION);
+        CutAngle splitter = CutAngle.createNegativeFacing(Point1S.of(Geometry.HALF_PI), TEST_PRECISION);
+
+        // act
+        Split<AngularInterval.Convex> split = interval.splitDiameter(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.PLUS, split.getLocation());
+
+        Assert.assertNull(split.getMinus());
+        Assert.assertSame(interval, split.getPlus());
+    }
+
+    @Test
+    public void testSplitDiameter_both_negativeFacingSplitter() {
+        // arrange
+        AngularInterval.Convex interval = AngularInterval.Convex.of(Geometry.HALF_PI, Geometry.MINUS_HALF_PI, TEST_PRECISION);
+        CutAngle splitter = CutAngle.createNegativeFacing(Point1S.of(Geometry.PI), TEST_PRECISION);
+
+        // act
+        Split<AngularInterval.Convex> split = interval.splitDiameter(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        checkInterval(split.getMinus(), Geometry.PI, 1.5 * Geometry.PI);
+        checkInterval(split.getPlus(), Geometry.HALF_PI, Geometry.PI);
+    }
+
+    @Test
+    public void testSplitDiameter_both_positiveFacingSplitter() {
+        // arrange
+        AngularInterval.Convex interval = AngularInterval.Convex.of(Geometry.HALF_PI, Geometry.MINUS_HALF_PI, TEST_PRECISION);
+        CutAngle splitter = CutAngle.createPositiveFacing(Point1S.of(Geometry.PI), TEST_PRECISION);
+
+        // act
+        Split<AngularInterval.Convex> split = interval.splitDiameter(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        checkInterval(split.getMinus(), Geometry.HALF_PI, Geometry.PI);
+        checkInterval(split.getPlus(), Geometry.PI, 1.5 * Geometry.PI);
+    }
+
+    @Test
+    public void testSplitDiameter_both_antipodal_negativeFacingSplitter() {
+        // arrange
+        AngularInterval.Convex interval = AngularInterval.Convex.of(Geometry.HALF_PI, Geometry.MINUS_HALF_PI, TEST_PRECISION);
+        CutAngle splitter = CutAngle.createNegativeFacing(Point1S.ZERO, TEST_PRECISION);
+
+        // act
+        Split<AngularInterval.Convex> split = interval.splitDiameter(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        checkInterval(split.getMinus(), Geometry.HALF_PI, Geometry.PI);
+        checkInterval(split.getPlus(), Geometry.PI, 1.5 * Geometry.PI);
+    }
+
+    @Test
+    public void testSplitDiameter_both_antipodal_positiveFacingSplitter() {
+        // arrange
+        AngularInterval.Convex interval = AngularInterval.Convex.of(Geometry.HALF_PI, Geometry.MINUS_HALF_PI, TEST_PRECISION);
+        CutAngle splitter = CutAngle.createPositiveFacing(Point1S.ZERO, TEST_PRECISION);
+
+        // act
+        Split<AngularInterval.Convex> split = interval.splitDiameter(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        checkInterval(split.getMinus(), Geometry.PI, 1.5 * Geometry.PI);
+        checkInterval(split.getPlus(), Geometry.HALF_PI, Geometry.PI);
+    }
+
+    @Test
+    public void testSplitDiameter_splitOnBoundary_negativeFacing() {
+        // arrange
+        AngularInterval.Convex interval = AngularInterval.Convex.of(Geometry.HALF_PI, Geometry.MINUS_HALF_PI, TEST_PRECISION);
+        CutAngle splitter = CutAngle.createNegativeFacing(Point1S.of(Geometry.HALF_PI), TEST_PRECISION);
+
+        // act
+        Split<AngularInterval.Convex> split = interval.splitDiameter(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.MINUS, split.getLocation());
+
+        Assert.assertSame(interval, split.getMinus());
+        Assert.assertNull(split.getPlus());
+    }
+
+    @Test
+    public void testSplitDiameter_splitOnBoundary_positiveFacing() {
+        // arrange
+        AngularInterval.Convex interval = AngularInterval.Convex.of(0, Geometry.PI, TEST_PRECISION);
+        CutAngle splitter = CutAngle.createPositiveFacing(Point1S.of(Geometry.PI), TEST_PRECISION);
+
+        // act
+        Split<AngularInterval.Convex> split = interval.splitDiameter(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.MINUS, split.getLocation());
+
+        Assert.assertSame(interval, split.getMinus());
+        Assert.assertNull(split.getPlus());
+    }
+
+    @Test
+    public void testConvex_transform() {
+        // arrange
+        AngularInterval.Convex interval = AngularInterval.Convex.of(Geometry.HALF_PI, Geometry.PI, TEST_PRECISION);
+
+        Transform1S rotate = Transform1S.createRotation(Geometry.HALF_PI);
+        Transform1S invert = Transform1S.createNegation().rotate(Geometry.HALF_PI);
+
+        // act/assert
+        checkInterval(interval.transform(rotate), Geometry.PI, 1.5 * Geometry.PI);
+        checkInterval(interval.transform(invert), -0.5 * Geometry.PI, Geometry.ZERO_PI);
+    }
+
+    private static void checkFull(AngularInterval interval) {
+        Assert.assertTrue(interval.isFull());
+        Assert.assertFalse(interval.isEmpty());
+
+        Assert.assertNull(interval.getMinBoundary());
+        Assert.assertEquals(0, interval.getMin(), TEST_EPS);
+        Assert.assertNull(interval.getMaxBoundary());
+        Assert.assertEquals(Geometry.TWO_PI, interval.getMax(), TEST_EPS);
+
+        Assert.assertNull(interval.getBarycenter());
+        Assert.assertNull(interval.getMidPoint());
+
+        Assert.assertEquals(Geometry.TWO_PI, interval.getSize(), TEST_EPS);
+        Assert.assertEquals(0, interval.getBoundarySize(), TEST_EPS);
+
+        checkClassify(interval, RegionLocation.INSIDE, Point1S.ZERO, Point1S.of(Geometry.PI));
+    }
+
+    private static void checkInterval(AngularInterval interval, double min, double max) {
+
+        Assert.assertFalse(interval.isFull());
+        Assert.assertFalse(interval.isEmpty());
+
+        CutAngle minBoundary = interval.getMinBoundary();
+        Assert.assertEquals(min, minBoundary.getAzimuth(), TEST_EPS);
+        Assert.assertFalse(minBoundary.isPositiveFacing());
+
+        CutAngle maxBoundary = interval.getMaxBoundary();
+        Assert.assertEquals(max, maxBoundary.getAzimuth(), TEST_EPS);
+        Assert.assertTrue(maxBoundary.isPositiveFacing());
+
+        Assert.assertEquals(min, interval.getMin(), TEST_EPS);
+        Assert.assertEquals(max, interval.getMax(), TEST_EPS);
+
+        Assert.assertEquals(0.5 * (max + min), interval.getMidPoint().getAzimuth(), TEST_EPS);
+        Assert.assertSame(interval.getMidPoint(), interval.getBarycenter());
+
+        Assert.assertEquals(0, interval.getBoundarySize(), TEST_EPS);
+        Assert.assertEquals(max - min, interval.getSize(), TEST_EPS);
+
+        checkClassify(interval, RegionLocation.INSIDE, interval.getMidPoint());
+        checkClassify(interval, RegionLocation.BOUNDARY,
+                interval.getMinBoundary().getPoint(), interval.getMaxBoundary().getPoint());
+        checkClassify(interval, RegionLocation.OUTSIDE, Point1S.of(interval.getMidPoint().getAzimuth() + Geometry.PI));
+    }
+
+    private static void checkClassify(Region<Point1S> region, RegionLocation loc, Point1S ... pts) {
+        for (Point1S pt : pts) {
+            Assert.assertEquals("Unexpected location for point " + pt, loc, region.classify(pt));
+        }
+    }
+}
diff --git a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/oned/ArcTest.java b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/oned/ArcTest.java
deleted file mode 100644
index 358eac3..0000000
--- a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/oned/ArcTest.java
+++ /dev/null
@@ -1,93 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.oned;
-
-import org.apache.commons.geometry.core.Geometry;
-import org.apache.commons.geometry.core.partitioning.Region;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
-import org.apache.commons.numbers.core.Precision;
-import org.junit.Assert;
-import org.junit.Test;
-
-public class ArcTest {
-
-    private static final double TEST_EPS = 1e-10;
-
-    private static final DoublePrecisionContext TEST_PRECISION =
-            new EpsilonDoublePrecisionContext(TEST_EPS);
-
-    @Test
-    public void testArc() {
-        Arc arc = new Arc(2.3, 5.7, TEST_PRECISION);
-        Assert.assertEquals(3.4, arc.getSize(), TEST_EPS);
-        Assert.assertEquals(4.0, arc.getBarycenter(), TEST_EPS);
-        Assert.assertEquals(Region.Location.BOUNDARY, arc.checkPoint(2.3));
-        Assert.assertEquals(Region.Location.BOUNDARY, arc.checkPoint(5.7));
-        Assert.assertEquals(Region.Location.OUTSIDE,  arc.checkPoint(1.2));
-        Assert.assertEquals(Region.Location.OUTSIDE,  arc.checkPoint(8.5));
-        Assert.assertEquals(Region.Location.INSIDE,   arc.checkPoint(8.7));
-        Assert.assertEquals(Region.Location.INSIDE,   arc.checkPoint(3.0));
-        Assert.assertEquals(2.3, arc.getInf(), TEST_EPS);
-        Assert.assertEquals(5.7, arc.getSup(), TEST_EPS);
-        Assert.assertEquals(4.0, arc.getBarycenter(), TEST_EPS);
-        Assert.assertEquals(3.4, arc.getSize(), TEST_EPS);
-    }
-
-    @Test(expected=IllegalArgumentException.class)
-    public void testWrongInterval() {
-        new Arc(1.2, 0.0, TEST_PRECISION);
-    }
-
-    @Test
-    public void testTolerance() {
-        Assert.assertEquals(Region.Location.OUTSIDE,  new Arc(2.3, 5.7, createPrecision(1.0)).checkPoint(1.2));
-        Assert.assertEquals(Region.Location.BOUNDARY, new Arc(2.3, 5.7, createPrecision(1.2)).checkPoint(1.2));
-        Assert.assertEquals(Region.Location.OUTSIDE,  new Arc(2.3, 5.7, createPrecision(0.7)).checkPoint(6.5));
-        Assert.assertEquals(Region.Location.BOUNDARY, new Arc(2.3, 5.7, createPrecision(0.9)).checkPoint(6.5));
-        Assert.assertEquals(Region.Location.INSIDE,   new Arc(2.3, 5.7, createPrecision(0.6)).checkPoint(3.0));
-        Assert.assertEquals(Region.Location.BOUNDARY, new Arc(2.3, 5.7, createPrecision(0.8)).checkPoint(3.0));
-    }
-
-    @Test
-    public void testFullCircle() {
-        Arc arc = new Arc(9.0, 9.0, TEST_PRECISION);
-        // no boundaries on a full circle
-        Assert.assertEquals(Region.Location.INSIDE, arc.checkPoint(9.0));
-        Assert.assertEquals(.0, arc.getInf(), TEST_EPS);
-        Assert.assertEquals(Geometry.TWO_PI, arc.getSup(), TEST_EPS);
-        Assert.assertEquals(2.0 * Math.PI, arc.getSize(), TEST_EPS);
-        for (double alpha = -20.0; alpha <= 20.0; alpha += 0.1) {
-            Assert.assertEquals(Region.Location.INSIDE, arc.checkPoint(alpha));
-        }
-    }
-
-    @Test
-    public void testSmall() {
-        Arc arc = new Arc(1.0, Math.nextAfter(1.0, Double.POSITIVE_INFINITY), createPrecision(Precision.EPSILON));
-        Assert.assertEquals(2 * Precision.EPSILON, arc.getSize(), Precision.SAFE_MIN);
-        Assert.assertEquals(1.0, arc.getBarycenter(), Precision.EPSILON);
-    }
-
-    /** Create a {@link DoublePrecisionContext} with the given epsilon value.
-     * @param eps epsilon value
-     * @return new precision context
-     */
-    private static DoublePrecisionContext createPrecision(final double eps) {
-        return new EpsilonDoublePrecisionContext(eps);
-    }
-}
diff --git a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/oned/ArcsSetTest.java b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/oned/ArcsSetTest.java
deleted file mode 100644
index 1171665..0000000
--- a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/oned/ArcsSetTest.java
+++ /dev/null
@@ -1,599 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.oned;
-
-import java.util.ArrayList;
-import java.util.Iterator;
-import java.util.List;
-import java.util.NoSuchElementException;
-
-import org.apache.commons.geometry.core.Geometry;
-import org.apache.commons.geometry.core.partitioning.BSPTree;
-import org.apache.commons.geometry.core.partitioning.Region;
-import org.apache.commons.geometry.core.partitioning.Region.Location;
-import org.apache.commons.geometry.core.partitioning.RegionFactory;
-import org.apache.commons.geometry.core.partitioning.Side;
-import org.apache.commons.geometry.core.partitioning.SubHyperplane;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
-import org.apache.commons.numbers.core.Precision;
-import org.junit.Assert;
-import org.junit.Test;
-
-public class ArcsSetTest {
-
-    private static final double TEST_EPS = 1e-10;
-
-    private static final DoublePrecisionContext TEST_PRECISION =
-            new EpsilonDoublePrecisionContext(TEST_EPS);
-
-    @Test
-    public void testArc() {
-        ArcsSet set = new ArcsSet(2.3, 5.7, TEST_PRECISION);
-        Assert.assertEquals(3.4, set.getSize(), TEST_EPS);
-        Assert.assertSame(TEST_PRECISION, set.getPrecision());
-        Assert.assertEquals(Region.Location.BOUNDARY, set.checkPoint(S1Point.of(2.3)));
-        Assert.assertEquals(Region.Location.BOUNDARY, set.checkPoint(S1Point.of(5.7)));
-        Assert.assertEquals(Region.Location.OUTSIDE,  set.checkPoint(S1Point.of(1.2)));
-        Assert.assertEquals(Region.Location.OUTSIDE,  set.checkPoint(S1Point.of(8.5)));
-        Assert.assertEquals(Region.Location.INSIDE,   set.checkPoint(S1Point.of(8.7)));
-        Assert.assertEquals(Region.Location.INSIDE,   set.checkPoint(S1Point.of(3.0)));
-        Assert.assertEquals(1, set.asList().size());
-        Assert.assertEquals(2.3, set.asList().get(0).getInf(), TEST_EPS);
-        Assert.assertEquals(5.7, set.asList().get(0).getSup(), TEST_EPS);
-    }
-
-    @Test
-    public void testWrapAround2PiArc() {
-        ArcsSet set = new ArcsSet(5.7 - Geometry.TWO_PI, 2.3, TEST_PRECISION);
-        Assert.assertEquals(Geometry.TWO_PI - 3.4, set.getSize(), TEST_EPS);
-        Assert.assertSame(TEST_PRECISION, set.getPrecision());
-        Assert.assertEquals(Region.Location.BOUNDARY, set.checkPoint(S1Point.of(2.3)));
-        Assert.assertEquals(Region.Location.BOUNDARY, set.checkPoint(S1Point.of(5.7)));
-        Assert.assertEquals(Region.Location.INSIDE,   set.checkPoint(S1Point.of(1.2)));
-        Assert.assertEquals(Region.Location.INSIDE,   set.checkPoint(S1Point.of(8.5)));
-        Assert.assertEquals(Region.Location.OUTSIDE,  set.checkPoint(S1Point.of(8.7)));
-        Assert.assertEquals(Region.Location.OUTSIDE,  set.checkPoint(S1Point.of(3.0)));
-        Assert.assertEquals(1, set.asList().size());
-        Assert.assertEquals(5.7, set.asList().get(0).getInf(), TEST_EPS);
-        Assert.assertEquals(2.3 + Geometry.TWO_PI, set.asList().get(0).getSup(), TEST_EPS);
-    }
-
-    @Test
-    public void testSplitOver2Pi() {
-        ArcsSet set = new ArcsSet(TEST_PRECISION);
-        Arc     arc = new Arc(1.5 * Math.PI, 2.5 * Math.PI, TEST_PRECISION);
-        ArcsSet.Split split = set.split(arc);
-        for (double alpha = 0.0; alpha <= Geometry.TWO_PI; alpha += 0.01) {
-            S1Point p = S1Point.of(alpha);
-            if (alpha < 0.5 * Math.PI || alpha > 1.5 * Math.PI) {
-                Assert.assertEquals(Location.OUTSIDE, split.getPlus().checkPoint(p));
-                Assert.assertEquals(Location.INSIDE,  split.getMinus().checkPoint(p));
-            } else {
-                Assert.assertEquals(Location.INSIDE,  split.getPlus().checkPoint(p));
-                Assert.assertEquals(Location.OUTSIDE, split.getMinus().checkPoint(p));
-            }
-        }
-    }
-
-    @Test
-    public void testSplitAtEnd() {
-        ArcsSet set = new ArcsSet(TEST_PRECISION);
-        Arc     arc = new Arc(Math.PI, Geometry.TWO_PI, TEST_PRECISION);
-        ArcsSet.Split split = set.split(arc);
-        for (double alpha = 0.01; alpha < Geometry.TWO_PI; alpha += 0.01) {
-            S1Point p = S1Point.of(alpha);
-            if (alpha > Math.PI) {
-                Assert.assertEquals(Location.OUTSIDE, split.getPlus().checkPoint(p));
-                Assert.assertEquals(Location.INSIDE,  split.getMinus().checkPoint(p));
-            } else {
-                Assert.assertEquals(Location.INSIDE,  split.getPlus().checkPoint(p));
-                Assert.assertEquals(Location.OUTSIDE, split.getMinus().checkPoint(p));
-            }
-        }
-
-        S1Point zero = S1Point.of(0.0);
-        Assert.assertEquals(Location.BOUNDARY,  split.getPlus().checkPoint(zero));
-        Assert.assertEquals(Location.BOUNDARY,  split.getMinus().checkPoint(zero));
-
-        S1Point pi = S1Point.of(Math.PI);
-        Assert.assertEquals(Location.BOUNDARY,  split.getPlus().checkPoint(pi));
-        Assert.assertEquals(Location.BOUNDARY,  split.getMinus().checkPoint(pi));
-
-    }
-
-    @Test(expected=IllegalArgumentException.class)
-    public void testWrongInterval() {
-        new ArcsSet(1.2, 0.0, TEST_PRECISION);
-    }
-
-    @Test
-    public void testFullEqualEndPoints() {
-        ArcsSet set = new ArcsSet(1.0, 1.0, TEST_PRECISION);
-        Assert.assertSame(TEST_PRECISION, set.getPrecision());
-        Assert.assertEquals(Region.Location.INSIDE, set.checkPoint(S1Point.of(9.0)));
-        for (double alpha = -20.0; alpha <= 20.0; alpha += 0.1) {
-            Assert.assertEquals(Region.Location.INSIDE, set.checkPoint(S1Point.of(alpha)));
-        }
-        Assert.assertEquals(1, set.asList().size());
-        Assert.assertEquals(0.0, set.asList().get(0).getInf(), TEST_EPS);
-        Assert.assertEquals(2 * Math.PI, set.asList().get(0).getSup(), TEST_EPS);
-        Assert.assertEquals(2 * Math.PI, set.getSize(), TEST_EPS);
-    }
-
-    @Test
-    public void testFullCircle() {
-        ArcsSet set = new ArcsSet(TEST_PRECISION);
-        Assert.assertSame(TEST_PRECISION, set.getPrecision());
-        Assert.assertEquals(Region.Location.INSIDE, set.checkPoint(S1Point.of(9.0)));
-        for (double alpha = -20.0; alpha <= 20.0; alpha += 0.1) {
-            Assert.assertEquals(Region.Location.INSIDE, set.checkPoint(S1Point.of(alpha)));
-        }
-        Assert.assertEquals(1, set.asList().size());
-        Assert.assertEquals(0.0, set.asList().get(0).getInf(), TEST_EPS);
-        Assert.assertEquals(2 * Math.PI, set.asList().get(0).getSup(), TEST_EPS);
-        Assert.assertEquals(2 * Math.PI, set.getSize(), TEST_EPS);
-    }
-
-    @Test
-    public void testEmpty() {
-        ArcsSet empty = (ArcsSet) new RegionFactory<S1Point>().getComplement(new ArcsSet(TEST_PRECISION));
-        Assert.assertSame(TEST_PRECISION, empty.getPrecision());
-        Assert.assertEquals(0.0, empty.getSize(), TEST_EPS);
-        Assert.assertTrue(empty.asList().isEmpty());
-    }
-
-    @Test
-    public void testTiny() {
-        ArcsSet tiny = new ArcsSet(0.0, Precision.SAFE_MIN / 2, TEST_PRECISION);
-        Assert.assertSame(TEST_PRECISION, tiny.getPrecision());
-        Assert.assertEquals(Precision.SAFE_MIN / 2, tiny.getSize(), TEST_EPS);
-        Assert.assertEquals(1, tiny.asList().size());
-        Assert.assertEquals(0.0, tiny.asList().get(0).getInf(), TEST_EPS);
-        Assert.assertEquals(Precision.SAFE_MIN / 2, tiny.asList().get(0).getSup(), TEST_EPS);
-    }
-
-    @Test
-    public void testSpecialConstruction() {
-        List<SubHyperplane<S1Point>> boundary = new ArrayList<>();
-        boundary.add(new LimitAngle(S1Point.of(0.0), false, TEST_PRECISION).wholeHyperplane());
-        boundary.add(new LimitAngle(S1Point.of(Geometry.TWO_PI - 1.0e-11), true, TEST_PRECISION).wholeHyperplane());
-        ArcsSet set = new ArcsSet(boundary, TEST_PRECISION);
-        Assert.assertEquals(Geometry.TWO_PI, set.getSize(), TEST_EPS);
-        Assert.assertSame(TEST_PRECISION, set.getPrecision());
-        Assert.assertEquals(1, set.asList().size());
-        Assert.assertEquals(0.0, set.asList().get(0).getInf(), TEST_EPS);
-        Assert.assertEquals(Geometry.TWO_PI, set.asList().get(0).getSup(), TEST_EPS);
-    }
-
-    @Test
-    public void testDifference() {
-
-        ArcsSet a   = new ArcsSet(1.0, 6.0, TEST_PRECISION);
-        List<Arc> aList = a.asList();
-        Assert.assertEquals(1,   aList.size());
-        Assert.assertEquals(1.0, aList.get(0).getInf(), TEST_EPS);
-        Assert.assertEquals(6.0, aList.get(0).getSup(), TEST_EPS);
-
-        ArcsSet b   = new ArcsSet(3.0, 5.0, TEST_PRECISION);
-        List<Arc> bList = b.asList();
-        Assert.assertEquals(1,   bList.size());
-        Assert.assertEquals(3.0, bList.get(0).getInf(), TEST_EPS);
-        Assert.assertEquals(5.0, bList.get(0).getSup(), TEST_EPS);
-
-        ArcsSet aMb = (ArcsSet) new RegionFactory<S1Point>().difference(a, b);
-        for (int k = -2; k < 3; ++k) {
-            Assert.assertEquals(Location.OUTSIDE,  aMb.checkPoint(S1Point.of(0.0 + k * Geometry.TWO_PI)));
-            Assert.assertEquals(Location.OUTSIDE,  aMb.checkPoint(S1Point.of(0.9 + k * Geometry.TWO_PI)));
-            Assert.assertEquals(Location.BOUNDARY, aMb.checkPoint(S1Point.of(1.0 + k * Geometry.TWO_PI)));
-            Assert.assertEquals(Location.INSIDE,   aMb.checkPoint(S1Point.of(1.1 + k * Geometry.TWO_PI)));
-            Assert.assertEquals(Location.INSIDE,   aMb.checkPoint(S1Point.of(2.9 + k * Geometry.TWO_PI)));
-            Assert.assertEquals(Location.BOUNDARY, aMb.checkPoint(S1Point.of(3.0 + k * Geometry.TWO_PI)));
-            Assert.assertEquals(Location.OUTSIDE,  aMb.checkPoint(S1Point.of(3.1 + k * Geometry.TWO_PI)));
-            Assert.assertEquals(Location.OUTSIDE,  aMb.checkPoint(S1Point.of(4.9 + k * Geometry.TWO_PI)));
-            Assert.assertEquals(Location.BOUNDARY, aMb.checkPoint(S1Point.of(5.0 + k * Geometry.TWO_PI)));
-            Assert.assertEquals(Location.INSIDE,   aMb.checkPoint(S1Point.of(5.1 + k * Geometry.TWO_PI)));
-            Assert.assertEquals(Location.INSIDE,   aMb.checkPoint(S1Point.of(5.9 + k * Geometry.TWO_PI)));
-            Assert.assertEquals(Location.BOUNDARY, aMb.checkPoint(S1Point.of(6.0 + k * Geometry.TWO_PI)));
-            Assert.assertEquals(Location.OUTSIDE,  aMb.checkPoint(S1Point.of(6.1 + k * Geometry.TWO_PI)));
-            Assert.assertEquals(Location.OUTSIDE,  aMb.checkPoint(S1Point.of(6.2 + k * Geometry.TWO_PI)));
-        }
-
-        List<Arc> aMbList = aMb.asList();
-        Assert.assertEquals(2,   aMbList.size());
-        Assert.assertEquals(1.0, aMbList.get(0).getInf(), TEST_EPS);
-        Assert.assertEquals(3.0, aMbList.get(0).getSup(), TEST_EPS);
-        Assert.assertEquals(5.0, aMbList.get(1).getInf(), TEST_EPS);
-        Assert.assertEquals(6.0, aMbList.get(1).getSup(), TEST_EPS);
-
-
-    }
-
-    @Test
-    public void testIntersection() {
-
-        ArcsSet a   = (ArcsSet) new RegionFactory<S1Point>().union(new ArcsSet(1.0, 3.0, TEST_PRECISION),
-                                                                    new ArcsSet(5.0, 6.0, TEST_PRECISION));
-        List<Arc> aList = a.asList();
-        Assert.assertEquals(2,   aList.size());
-        Assert.assertEquals(1.0, aList.get(0).getInf(), TEST_EPS);
-        Assert.assertEquals(3.0, aList.get(0).getSup(), TEST_EPS);
-        Assert.assertEquals(5.0, aList.get(1).getInf(), TEST_EPS);
-        Assert.assertEquals(6.0, aList.get(1).getSup(), TEST_EPS);
-
-        ArcsSet b   = new ArcsSet(0.0, 5.5, TEST_PRECISION);
-        List<Arc> bList = b.asList();
-        Assert.assertEquals(1,   bList.size());
-        Assert.assertEquals(0.0, bList.get(0).getInf(), TEST_EPS);
-        Assert.assertEquals(5.5, bList.get(0).getSup(), TEST_EPS);
-
-        ArcsSet aMb = (ArcsSet) new RegionFactory<S1Point>().intersection(a, b);
-        for (int k = -2; k < 3; ++k) {
-            Assert.assertEquals(Location.OUTSIDE,  aMb.checkPoint(S1Point.of(0.0 + k * Geometry.TWO_PI)));
-            Assert.assertEquals(Location.BOUNDARY, aMb.checkPoint(S1Point.of(1.0 + k * Geometry.TWO_PI)));
-            Assert.assertEquals(Location.INSIDE,   aMb.checkPoint(S1Point.of(1.1 + k * Geometry.TWO_PI)));
-            Assert.assertEquals(Location.INSIDE,   aMb.checkPoint(S1Point.of(2.9 + k * Geometry.TWO_PI)));
-            Assert.assertEquals(Location.BOUNDARY, aMb.checkPoint(S1Point.of(3.0 + k * Geometry.TWO_PI)));
-            Assert.assertEquals(Location.OUTSIDE,  aMb.checkPoint(S1Point.of(3.1 + k * Geometry.TWO_PI)));
-            Assert.assertEquals(Location.OUTSIDE,  aMb.checkPoint(S1Point.of(4.9 + k * Geometry.TWO_PI)));
-            Assert.assertEquals(Location.BOUNDARY, aMb.checkPoint(S1Point.of(5.0 + k * Geometry.TWO_PI)));
-            Assert.assertEquals(Location.INSIDE,   aMb.checkPoint(S1Point.of(5.1 + k * Geometry.TWO_PI)));
-            Assert.assertEquals(Location.INSIDE,   aMb.checkPoint(S1Point.of(5.4 + k * Geometry.TWO_PI)));
-            Assert.assertEquals(Location.BOUNDARY, aMb.checkPoint(S1Point.of(5.5 + k * Geometry.TWO_PI)));
-            Assert.assertEquals(Location.OUTSIDE,  aMb.checkPoint(S1Point.of(5.6 + k * Geometry.TWO_PI)));
-            Assert.assertEquals(Location.OUTSIDE,  aMb.checkPoint(S1Point.of(6.2 + k * Geometry.TWO_PI)));
-        }
-
-        List<Arc> aMbList = aMb.asList();
-        Assert.assertEquals(2,   aMbList.size());
-        Assert.assertEquals(1.0, aMbList.get(0).getInf(), TEST_EPS);
-        Assert.assertEquals(3.0, aMbList.get(0).getSup(), TEST_EPS);
-        Assert.assertEquals(5.0, aMbList.get(1).getInf(), TEST_EPS);
-        Assert.assertEquals(5.5, aMbList.get(1).getSup(), TEST_EPS);
-
-
-    }
-
-    @Test
-    public void testMultiple() {
-        RegionFactory<S1Point> factory = new RegionFactory<>();
-        ArcsSet set = (ArcsSet)
-        factory.intersection(factory.union(factory.difference(new ArcsSet(1.0, 6.0, TEST_PRECISION),
-                                                              new ArcsSet(3.0, 5.0, TEST_PRECISION)),
-                                                              new ArcsSet(0.5, 2.0, TEST_PRECISION)),
-                                                              new ArcsSet(0.0, 5.5, TEST_PRECISION));
-        Assert.assertEquals(3.0, set.getSize(), TEST_EPS);
-        Assert.assertEquals(Region.Location.OUTSIDE,  set.checkPoint(S1Point.of(0.0)));
-        Assert.assertEquals(Region.Location.OUTSIDE,  set.checkPoint(S1Point.of(4.0)));
-        Assert.assertEquals(Region.Location.OUTSIDE,  set.checkPoint(S1Point.of(6.0)));
-        Assert.assertEquals(Region.Location.INSIDE,   set.checkPoint(S1Point.of(1.2)));
-        Assert.assertEquals(Region.Location.INSIDE,   set.checkPoint(S1Point.of(5.25)));
-        Assert.assertEquals(Region.Location.BOUNDARY, set.checkPoint(S1Point.of(0.5)));
-        Assert.assertEquals(Region.Location.BOUNDARY, set.checkPoint(S1Point.of(3.0)));
-        Assert.assertEquals(Region.Location.BOUNDARY, set.checkPoint(S1Point.of(5.0)));
-        Assert.assertEquals(Region.Location.BOUNDARY, set.checkPoint(S1Point.of(5.5)));
-
-        List<Arc> list = set.asList();
-        Assert.assertEquals(2, list.size());
-        Assert.assertEquals( 0.5, list.get(0).getInf(), TEST_EPS);
-        Assert.assertEquals( 3.0, list.get(0).getSup(), TEST_EPS);
-        Assert.assertEquals( 5.0, list.get(1).getInf(), TEST_EPS);
-        Assert.assertEquals( 5.5, list.get(1).getSup(), TEST_EPS);
-
-    }
-
-    @Test
-    public void testSinglePoint() {
-        ArcsSet set = new ArcsSet(1.0, Math.nextAfter(1.0, Double.POSITIVE_INFINITY), TEST_PRECISION);
-        Assert.assertEquals(2 * Precision.EPSILON, set.getSize(), Precision.SAFE_MIN);
-    }
-
-    @Test
-    public void testIteration() {
-        ArcsSet set = (ArcsSet) new RegionFactory<S1Point>().difference(new ArcsSet(1.0, 6.0, TEST_PRECISION),
-                                                                         new ArcsSet(3.0, 5.0, TEST_PRECISION));
-        Iterator<double[]> iterator = set.iterator();
-        try {
-            iterator.remove();
-            Assert.fail("an exception should have been thrown");
-        } catch (UnsupportedOperationException uoe) {
-            // expected
-        }
-
-        Assert.assertTrue(iterator.hasNext());
-        double[] a0 = iterator.next();
-        Assert.assertEquals(2, a0.length);
-        Assert.assertEquals(1.0, a0[0], TEST_EPS);
-        Assert.assertEquals(3.0, a0[1], TEST_EPS);
-
-        Assert.assertTrue(iterator.hasNext());
-        double[] a1 = iterator.next();
-        Assert.assertEquals(2, a1.length);
-        Assert.assertEquals(5.0, a1[0], TEST_EPS);
-        Assert.assertEquals(6.0, a1[1], TEST_EPS);
-
-        Assert.assertFalse(iterator.hasNext());
-        try {
-            iterator.next();
-            Assert.fail("an exception should have been thrown");
-        } catch (NoSuchElementException nsee) {
-            // expected
-        }
-
-    }
-
-    @Test
-    public void testEmptyTree() {
-        Assert.assertEquals(Geometry.TWO_PI, new ArcsSet(new BSPTree<S1Point>(Boolean.TRUE), TEST_PRECISION).getSize(), TEST_EPS);
-    }
-
-    @Test
-    public void testShiftedAngles() {
-        for (int k = -2; k < 3; ++k) {
-            SubLimitAngle l1  = new LimitAngle(S1Point.of(1.0 + k * Geometry.TWO_PI), false, TEST_PRECISION).wholeHyperplane();
-            SubLimitAngle l2  = new LimitAngle(S1Point.of(1.5 + k * Geometry.TWO_PI), true,  TEST_PRECISION).wholeHyperplane();
-            ArcsSet set = new ArcsSet(new BSPTree<>(l1,
-                                                            new BSPTree<S1Point>(Boolean.FALSE),
-                                                            new BSPTree<>(l2,
-                                                                                  new BSPTree<S1Point>(Boolean.FALSE),
-                                                                                  new BSPTree<S1Point>(Boolean.TRUE),
-                                                                                  null),
-                                                            null),
-                    TEST_PRECISION);
-            for (double alpha = 1.0e-6; alpha < Geometry.TWO_PI; alpha += 0.001) {
-                if (alpha < 1 || alpha > 1.5) {
-                    Assert.assertEquals(Location.OUTSIDE, set.checkPoint(S1Point.of(alpha)));
-                } else {
-                    Assert.assertEquals(Location.INSIDE,  set.checkPoint(S1Point.of(alpha)));
-                }
-            }
-        }
-
-    }
-
-    @Test(expected=IllegalArgumentException.class)
-    public void testInconsistentState() {
-        SubLimitAngle l1 = new LimitAngle(S1Point.of(1.0), false, TEST_PRECISION).wholeHyperplane();
-        SubLimitAngle l2 = new LimitAngle(S1Point.of(2.0), true,  TEST_PRECISION).wholeHyperplane();
-        SubLimitAngle l3 = new LimitAngle(S1Point.of(3.0), false, TEST_PRECISION).wholeHyperplane();
-        new ArcsSet(new BSPTree<>(l1,
-                                          new BSPTree<S1Point>(Boolean.FALSE),
-                                          new BSPTree<>(l2,
-                                                                new BSPTree<>(l3,
-                                                                                      new BSPTree<S1Point>(Boolean.FALSE),
-                                                                                      new BSPTree<S1Point>(Boolean.TRUE),
-                                                                                      null),
-                                                                new BSPTree<S1Point>(Boolean.TRUE),
-                                                                null),
-                                          null),
-                TEST_PRECISION);
-    }
-
-    @Test
-    public void testSide() {
-        ArcsSet set = (ArcsSet) new RegionFactory<S1Point>().difference(new ArcsSet(1.0, 6.0, TEST_PRECISION),
-                                                                         new ArcsSet(3.0, 5.0, TEST_PRECISION));
-        for (int k = -2; k < 3; ++k) {
-            Assert.assertEquals(Side.MINUS, set.split(new Arc(0.5 + k * Geometry.TWO_PI,
-                                                              6.1 + k * Geometry.TWO_PI,
-                                                              set.getPrecision())).getSide());
-            Assert.assertEquals(Side.PLUS,  set.split(new Arc(0.5 + k * Geometry.TWO_PI,
-                                                              0.8 + k * Geometry.TWO_PI,
-                                                              set.getPrecision())).getSide());
-            Assert.assertEquals(Side.PLUS,  set.split(new Arc(6.2 + k * Geometry.TWO_PI,
-                                                              6.3 + k * Geometry.TWO_PI,
-                                                              set.getPrecision())).getSide());
-            Assert.assertEquals(Side.PLUS,  set.split(new Arc(3.5 + k * Geometry.TWO_PI,
-                                                              4.5 + k * Geometry.TWO_PI,
-                                                              set.getPrecision())).getSide());
-            Assert.assertEquals(Side.BOTH,  set.split(new Arc(2.9 + k * Geometry.TWO_PI,
-                                                              4.5 + k * Geometry.TWO_PI,
-                                                              set.getPrecision())).getSide());
-            Assert.assertEquals(Side.BOTH,  set.split(new Arc(0.5 + k * Geometry.TWO_PI,
-                                                              1.2 + k * Geometry.TWO_PI,
-                                                              set.getPrecision())).getSide());
-            Assert.assertEquals(Side.BOTH,  set.split(new Arc(0.5 + k * Geometry.TWO_PI,
-                                                              5.9 + k * Geometry.TWO_PI,
-                                                              set.getPrecision())).getSide());
-        }
-    }
-
-    @Test
-    public void testSideEmbedded() {
-
-        ArcsSet s35 = new ArcsSet(3.0, 5.0, TEST_PRECISION);
-        ArcsSet s16 = new ArcsSet(1.0, 6.0, TEST_PRECISION);
-
-        Assert.assertEquals(Side.BOTH,  s16.split(new Arc(3.0, 5.0, TEST_PRECISION)).getSide());
-        Assert.assertEquals(Side.BOTH,  s16.split(new Arc(5.0, 3.0 + Geometry.TWO_PI, TEST_PRECISION)).getSide());
-        Assert.assertEquals(Side.MINUS, s35.split(new Arc(1.0, 6.0, TEST_PRECISION)).getSide());
-        Assert.assertEquals(Side.PLUS,  s35.split(new Arc(6.0, 1.0 + Geometry.TWO_PI, TEST_PRECISION)).getSide());
-
-    }
-
-    @Test
-    public void testSideOverlapping() {
-        ArcsSet s35 = new ArcsSet(3.0, 5.0, TEST_PRECISION);
-        ArcsSet s46 = new ArcsSet(4.0, 6.0, TEST_PRECISION);
-
-        Assert.assertEquals(Side.BOTH,  s46.split(new Arc(3.0, 5.0, TEST_PRECISION)).getSide());
-        Assert.assertEquals(Side.BOTH,  s46.split(new Arc(5.0, 3.0 + Geometry.TWO_PI, TEST_PRECISION)).getSide());
-        Assert.assertEquals(Side.BOTH, s35.split(new Arc(4.0, 6.0, TEST_PRECISION)).getSide());
-        Assert.assertEquals(Side.BOTH,  s35.split(new Arc(6.0, 4.0 + Geometry.TWO_PI, TEST_PRECISION)).getSide());
-    }
-
-    @Test
-    public void testSideHyper() {
-        ArcsSet sub = (ArcsSet) new RegionFactory<S1Point>().getComplement(new ArcsSet(TEST_PRECISION));
-        Assert.assertTrue(sub.isEmpty());
-        Assert.assertEquals(Side.HYPER,  sub.split(new Arc(2.0, 3.0, TEST_PRECISION)).getSide());
-    }
-
-    @Test
-    public void testSplitEmbedded() {
-
-        ArcsSet s35 = new ArcsSet(3.0, 5.0, TEST_PRECISION);
-        ArcsSet s16 = new ArcsSet(1.0, 6.0, TEST_PRECISION);
-
-        ArcsSet.Split split1 = s16.split(new Arc(3.0, 5.0, TEST_PRECISION));
-        ArcsSet split1Plus  = split1.getPlus();
-        ArcsSet split1Minus = split1.getMinus();
-        Assert.assertEquals(3.0, split1Plus.getSize(), TEST_EPS);
-        Assert.assertEquals(2,   split1Plus.asList().size());
-        Assert.assertEquals(1.0, split1Plus.asList().get(0).getInf(), TEST_EPS);
-        Assert.assertEquals(3.0, split1Plus.asList().get(0).getSup(), TEST_EPS);
-        Assert.assertEquals(5.0, split1Plus.asList().get(1).getInf(), TEST_EPS);
-        Assert.assertEquals(6.0, split1Plus.asList().get(1).getSup(), TEST_EPS);
-        Assert.assertEquals(2.0, split1Minus.getSize(), TEST_EPS);
-        Assert.assertEquals(1,   split1Minus.asList().size());
-        Assert.assertEquals(3.0, split1Minus.asList().get(0).getInf(), TEST_EPS);
-        Assert.assertEquals(5.0, split1Minus.asList().get(0).getSup(), TEST_EPS);
-
-        ArcsSet.Split split2 = s16.split(new Arc(5.0, 3.0 + Geometry.TWO_PI, TEST_PRECISION));
-        ArcsSet split2Plus  = split2.getPlus();
-        ArcsSet split2Minus = split2.getMinus();
-        Assert.assertEquals(2.0, split2Plus.getSize(), TEST_EPS);
-        Assert.assertEquals(1,   split2Plus.asList().size());
-        Assert.assertEquals(3.0, split2Plus.asList().get(0).getInf(), TEST_EPS);
-        Assert.assertEquals(5.0, split2Plus.asList().get(0).getSup(), TEST_EPS);
-        Assert.assertEquals(3.0, split2Minus.getSize(), TEST_EPS);
-        Assert.assertEquals(2,   split2Minus.asList().size());
-        Assert.assertEquals(1.0, split2Minus.asList().get(0).getInf(), TEST_EPS);
-        Assert.assertEquals(3.0, split2Minus.asList().get(0).getSup(), TEST_EPS);
-        Assert.assertEquals(5.0, split2Minus.asList().get(1).getInf(), TEST_EPS);
-        Assert.assertEquals(6.0, split2Minus.asList().get(1).getSup(), TEST_EPS);
-
-        ArcsSet.Split split3 = s35.split(new Arc(1.0, 6.0, TEST_PRECISION));
-        ArcsSet split3Plus  = split3.getPlus();
-        ArcsSet split3Minus = split3.getMinus();
-        Assert.assertNull(split3Plus);
-        Assert.assertEquals(2.0, split3Minus.getSize(), TEST_EPS);
-        Assert.assertEquals(1,   split3Minus.asList().size());
-        Assert.assertEquals(3.0, split3Minus.asList().get(0).getInf(), TEST_EPS);
-        Assert.assertEquals(5.0, split3Minus.asList().get(0).getSup(), TEST_EPS);
-
-        ArcsSet.Split split4 = s35.split(new Arc(6.0, 1.0 + Geometry.TWO_PI, TEST_PRECISION));
-        ArcsSet split4Plus  = split4.getPlus();
-        ArcsSet split4Minus = split4.getMinus();
-        Assert.assertEquals(2.0, split4Plus.getSize(), TEST_EPS);
-        Assert.assertEquals(1,   split4Plus.asList().size());
-        Assert.assertEquals(3.0, split4Plus.asList().get(0).getInf(), TEST_EPS);
-        Assert.assertEquals(5.0, split4Plus.asList().get(0).getSup(), TEST_EPS);
-        Assert.assertNull(split4Minus);
-
-    }
-
-    @Test
-    public void testSplitOverlapping() {
-
-        ArcsSet s35 = new ArcsSet(3.0, 5.0, TEST_PRECISION);
-        ArcsSet s46 = new ArcsSet(4.0, 6.0, TEST_PRECISION);
-
-        ArcsSet.Split split1 = s46.split(new Arc(3.0, 5.0, TEST_PRECISION));
-        ArcsSet split1Plus  = split1.getPlus();
-        ArcsSet split1Minus = split1.getMinus();
-        Assert.assertEquals(1.0, split1Plus.getSize(), TEST_EPS);
-        Assert.assertEquals(1,   split1Plus.asList().size());
-        Assert.assertEquals(5.0, split1Plus.asList().get(0).getInf(), TEST_EPS);
-        Assert.assertEquals(6.0, split1Plus.asList().get(0).getSup(), TEST_EPS);
-        Assert.assertEquals(1.0, split1Minus.getSize(), TEST_EPS);
-        Assert.assertEquals(1,   split1Minus.asList().size());
-        Assert.assertEquals(4.0, split1Minus.asList().get(0).getInf(), TEST_EPS);
-        Assert.assertEquals(5.0, split1Minus.asList().get(0).getSup(), TEST_EPS);
-
-        ArcsSet.Split split2 = s46.split(new Arc(5.0, 3.0 + Geometry.TWO_PI, TEST_PRECISION));
-        ArcsSet split2Plus  = split2.getPlus();
-        ArcsSet split2Minus = split2.getMinus();
-        Assert.assertEquals(1.0, split2Plus.getSize(), TEST_EPS);
-        Assert.assertEquals(1,   split2Plus.asList().size());
-        Assert.assertEquals(4.0, split2Plus.asList().get(0).getInf(), TEST_EPS);
-        Assert.assertEquals(5.0, split2Plus.asList().get(0).getSup(), TEST_EPS);
-        Assert.assertEquals(1.0, split2Minus.getSize(), TEST_EPS);
-        Assert.assertEquals(1,   split2Minus.asList().size());
-        Assert.assertEquals(5.0, split2Minus.asList().get(0).getInf(), TEST_EPS);
-        Assert.assertEquals(6.0, split2Minus.asList().get(0).getSup(), TEST_EPS);
-
-        ArcsSet.Split split3 = s35.split(new Arc(4.0, 6.0, TEST_PRECISION));
-        ArcsSet split3Plus  = split3.getPlus();
-        ArcsSet split3Minus = split3.getMinus();
-        Assert.assertEquals(1.0, split3Plus.getSize(), TEST_EPS);
-        Assert.assertEquals(1,   split3Plus.asList().size());
-        Assert.assertEquals(3.0, split3Plus.asList().get(0).getInf(), TEST_EPS);
-        Assert.assertEquals(4.0, split3Plus.asList().get(0).getSup(), TEST_EPS);
-        Assert.assertEquals(1.0, split3Minus.getSize(), TEST_EPS);
-        Assert.assertEquals(1,   split3Minus.asList().size());
-        Assert.assertEquals(4.0, split3Minus.asList().get(0).getInf(), TEST_EPS);
-        Assert.assertEquals(5.0, split3Minus.asList().get(0).getSup(), TEST_EPS);
-
-        ArcsSet.Split split4 = s35.split(new Arc(6.0, 4.0 + Geometry.TWO_PI, TEST_PRECISION));
-        ArcsSet split4Plus  = split4.getPlus();
-        ArcsSet split4Minus = split4.getMinus();
-        Assert.assertEquals(1.0, split4Plus.getSize(), TEST_EPS);
-        Assert.assertEquals(1,   split4Plus.asList().size());
-        Assert.assertEquals(4.0, split4Plus.asList().get(0).getInf(), TEST_EPS);
-        Assert.assertEquals(5.0, split4Plus.asList().get(0).getSup(), TEST_EPS);
-        Assert.assertEquals(1.0, split4Minus.getSize(), TEST_EPS);
-        Assert.assertEquals(1,   split4Minus.asList().size());
-        Assert.assertEquals(3.0, split4Minus.asList().get(0).getInf(), TEST_EPS);
-        Assert.assertEquals(4.0, split4Minus.asList().get(0).getSup(), TEST_EPS);
-
-    }
-
-    @Test
-    public void testFarSplit() {
-        ArcsSet set = new ArcsSet(Math.PI, 2.5 * Math.PI, TEST_PRECISION);
-        ArcsSet.Split split = set.split(new Arc(0.5 * Math.PI, 1.5 * Math.PI, TEST_PRECISION));
-        ArcsSet splitPlus  = split.getPlus();
-        ArcsSet splitMinus = split.getMinus();
-        Assert.assertEquals(1,   splitMinus.asList().size());
-        Assert.assertEquals(1.0 * Math.PI, splitMinus.asList().get(0).getInf(), TEST_EPS);
-        Assert.assertEquals(1.5 * Math.PI, splitMinus.asList().get(0).getSup(), TEST_EPS);
-        Assert.assertEquals(0.5 * Math.PI, splitMinus.getSize(), TEST_EPS);
-        Assert.assertEquals(1,   splitPlus.asList().size());
-        Assert.assertEquals(1.5 * Math.PI, splitPlus.asList().get(0).getInf(), TEST_EPS);
-        Assert.assertEquals(2.5 * Math.PI, splitPlus.asList().get(0).getSup(), TEST_EPS);
-        Assert.assertEquals(1.0 * Math.PI, splitPlus.getSize(), TEST_EPS);
-
-    }
-
-    @Test
-    public void testSplitWithinEpsilon() {
-        double epsilon = TEST_EPS;
-        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(epsilon);
-        double a = 6.25;
-        double b = a - 0.5 * epsilon;
-        ArcsSet set = new ArcsSet(a - 1, a, precision);
-        Arc arc = new Arc(b, b + Math.PI, precision);
-        ArcsSet.Split split = set.split(arc);
-        Assert.assertEquals(set.getSize(), split.getPlus().getSize(),  epsilon);
-        Assert.assertNull(split.getMinus());
-    }
-
-    @Test
-    public void testSideSplitConsistency() {
-        double  epsilon = 1.0e-6;
-        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(epsilon);
-        double  a       = 4.725;
-        ArcsSet set     = new ArcsSet(a, a + 0.5, precision);
-        Arc     arc     = new Arc(a + 0.5 * epsilon, a + 1, precision);
-        ArcsSet.Split split = set.split(arc);
-        Assert.assertNotNull(split.getMinus());
-        Assert.assertNull(split.getPlus());
-        Assert.assertEquals(Side.MINUS, set.split(arc).getSide());
-    }
-
-}
diff --git a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/oned/CutAngleTest.java b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/oned/CutAngleTest.java
new file mode 100644
index 0000000..2a241b6
--- /dev/null
+++ b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/oned/CutAngleTest.java
@@ -0,0 +1,596 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.oned;
+
+import java.util.List;
+
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.GeometryTestUtils;
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.exception.GeometryException;
+import org.apache.commons.geometry.core.partitioning.HyperplaneLocation;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.SubHyperplane;
+import org.apache.commons.geometry.core.partitioning.SubHyperplane.Builder;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
+import org.apache.commons.geometry.spherical.oned.CutAngle.SubCutAngle;
+import org.apache.commons.geometry.spherical.oned.CutAngle.SubCutAngleBuilder;
+import org.apache.commons.numbers.angle.PlaneAngleRadians;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class CutAngleTest {
+
+    private static final double TEST_EPS = 1e-10;
+
+    private static final DoublePrecisionContext TEST_PRECISION =
+            new EpsilonDoublePrecisionContext(TEST_EPS);
+
+    @Test
+    public void testFromAzimuthAndDirection() {
+        // act/assert
+        checkCutAngle(CutAngle.fromAzimuthAndDirection(Geometry.ZERO_PI, true, TEST_PRECISION),
+                Geometry.ZERO_PI, true);
+        checkCutAngle(CutAngle.fromAzimuthAndDirection(Geometry.PI, true, TEST_PRECISION),
+                Geometry.PI, true);
+        checkCutAngle(CutAngle.fromAzimuthAndDirection(Geometry.MINUS_HALF_PI, true, TEST_PRECISION),
+                Geometry.MINUS_HALF_PI, true);
+
+        checkCutAngle(CutAngle.fromAzimuthAndDirection(Geometry.ZERO_PI, false, TEST_PRECISION),
+                Geometry.ZERO_PI, false);
+        checkCutAngle(CutAngle.fromAzimuthAndDirection(Geometry.PI, false, TEST_PRECISION),
+                Geometry.PI, false);
+        checkCutAngle(CutAngle.fromAzimuthAndDirection(Geometry.MINUS_HALF_PI, false, TEST_PRECISION),
+                Geometry.MINUS_HALF_PI, false);
+    }
+
+    @Test
+    public void testFromPointAndDirection() {
+        // arrange
+        Point1S pt = Point1S.of(Geometry.MINUS_HALF_PI);
+
+        // act/assert
+        checkCutAngle(CutAngle.fromPointAndDirection(Point1S.ZERO, true, TEST_PRECISION),
+                Geometry.ZERO_PI, true);
+        checkCutAngle(CutAngle.fromPointAndDirection(Point1S.PI, true, TEST_PRECISION),
+                Geometry.PI, true);
+        checkCutAngle(CutAngle.fromPointAndDirection(pt, true, TEST_PRECISION),
+                Geometry.MINUS_HALF_PI, true);
+
+        checkCutAngle(CutAngle.fromPointAndDirection(Point1S.ZERO, false, TEST_PRECISION),
+                Geometry.ZERO_PI, false);
+        checkCutAngle(CutAngle.fromPointAndDirection(Point1S.PI, false, TEST_PRECISION),
+                Geometry.PI, false);
+        checkCutAngle(CutAngle.fromPointAndDirection(pt, false, TEST_PRECISION),
+                Geometry.MINUS_HALF_PI, false);
+    }
+
+    @Test
+    public void testCreatePositiveFacing() {
+        // act/assert
+        checkCutAngle(CutAngle.createPositiveFacing(Point1S.ZERO, TEST_PRECISION),
+                Geometry.ZERO_PI, true);
+        checkCutAngle(CutAngle.createPositiveFacing(Point1S.PI, TEST_PRECISION),
+                Geometry.PI, true);
+        checkCutAngle(CutAngle.createPositiveFacing(Geometry.MINUS_HALF_PI, TEST_PRECISION),
+                Geometry.MINUS_HALF_PI, true);
+    }
+
+    @Test
+    public void testCreateNegativeFacing() {
+        // act/assert
+        checkCutAngle(CutAngle.createNegativeFacing(Point1S.ZERO, TEST_PRECISION),
+                Geometry.ZERO_PI, false);
+        checkCutAngle(CutAngle.createNegativeFacing(Point1S.PI, TEST_PRECISION),
+                Geometry.PI, false);
+        checkCutAngle(CutAngle.createNegativeFacing(Geometry.MINUS_HALF_PI, TEST_PRECISION),
+                Geometry.MINUS_HALF_PI, false);
+    }
+
+    @Test
+    public void testOffset() {
+        // arrange
+        CutAngle zeroPos = CutAngle.createPositiveFacing(Geometry.ZERO_PI, TEST_PRECISION);
+        CutAngle zeroNeg = CutAngle.createNegativeFacing(Geometry.ZERO_PI, TEST_PRECISION);
+        CutAngle negPiPos = CutAngle.createPositiveFacing(-Geometry.PI, TEST_PRECISION);
+
+        CutAngle piNeg = CutAngle.createNegativeFacing(Geometry.PI, TEST_PRECISION);
+        CutAngle twoAndAHalfPiPos = CutAngle.createPositiveFacing(2.5 * Geometry.PI, TEST_PRECISION);
+
+        // act/assert
+        checkOffset(zeroPos, 0, 0);
+        checkOffset(zeroPos, Geometry.TWO_PI, 0);
+        checkOffset(zeroPos, 2.5 * Geometry.PI, Geometry.HALF_PI);
+        checkOffset(zeroPos, Geometry.PI, Geometry.PI);
+        checkOffset(zeroPos, 3.5 * Geometry.PI, 1.5 * Geometry.PI);
+
+        checkOffset(zeroNeg, 0, 0);
+        checkOffset(zeroNeg, Geometry.TWO_PI, 0);
+        checkOffset(zeroNeg, 2.5 * Geometry.PI, Geometry.MINUS_HALF_PI);
+        checkOffset(zeroNeg, Geometry.PI, -Geometry.PI);
+        checkOffset(zeroNeg, 3.5 * Geometry.PI, -1.5 * Geometry.PI);
+
+        checkOffset(negPiPos, 0, -Geometry.PI);
+        checkOffset(negPiPos, Geometry.TWO_PI, -Geometry.PI);
+        checkOffset(negPiPos, 2.5 * Geometry.PI, Geometry.MINUS_HALF_PI);
+        checkOffset(negPiPos, Geometry.PI, 0);
+        checkOffset(negPiPos, 3.5 * Geometry.PI, Geometry.HALF_PI);
+
+        checkOffset(piNeg, 0, Geometry.PI);
+        checkOffset(piNeg, Geometry.TWO_PI, Geometry.PI);
+        checkOffset(piNeg, 2.5 * Geometry.PI, Geometry.HALF_PI);
+        checkOffset(piNeg, Geometry.PI, 0);
+        checkOffset(piNeg, 3.5 * Geometry.PI, Geometry.MINUS_HALF_PI);
+
+        checkOffset(twoAndAHalfPiPos, 0, Geometry.MINUS_HALF_PI);
+        checkOffset(twoAndAHalfPiPos, Geometry.TWO_PI, Geometry.MINUS_HALF_PI);
+        checkOffset(twoAndAHalfPiPos, 2.5 * Geometry.PI, 0);
+        checkOffset(twoAndAHalfPiPos, Geometry.PI, Geometry.HALF_PI);
+        checkOffset(twoAndAHalfPiPos, 3.5 * Geometry.PI, Geometry.PI);
+    }
+
+    @Test
+    public void testClassify() {
+        // arrange
+        CutAngle zeroPos = CutAngle.createPositiveFacing(Geometry.ZERO_PI, TEST_PRECISION);
+        CutAngle zeroNeg = CutAngle.createNegativeFacing(Geometry.ZERO_PI, TEST_PRECISION);
+        CutAngle negPiPos = CutAngle.createPositiveFacing(-Geometry.PI, TEST_PRECISION);
+
+        // act/assert
+        checkClassify(zeroPos, HyperplaneLocation.ON,
+                0, 1e-16, -1e-16,
+                Geometry.TWO_PI - 1e-11, Geometry.TWO_PI + 1e-11);
+        checkClassify(zeroPos, HyperplaneLocation.PLUS,
+                0.5, 2.5 * Geometry.PI,
+                -0.5, Geometry.MINUS_HALF_PI);
+
+        checkClassify(zeroNeg, HyperplaneLocation.ON,
+                0, 1e-16, -1e-16,
+                Geometry.TWO_PI - 1e-11, Geometry.TWO_PI + 1e-11);
+        checkClassify(zeroNeg, HyperplaneLocation.MINUS,
+                0.5, 2.5 * Geometry.PI,
+                -0.5, Geometry.MINUS_HALF_PI);
+
+        checkClassify(negPiPos, HyperplaneLocation.ON, Geometry.PI, Geometry.PI + 1e-11);
+        checkClassify(negPiPos, HyperplaneLocation.MINUS, 0.5, 2.5 * Geometry.PI,
+                0, 1e-11, Geometry.TWO_PI, Geometry.TWO_PI - 1e-11);
+        checkClassify(negPiPos, HyperplaneLocation.PLUS, -0.5, Geometry.MINUS_HALF_PI);
+    }
+
+    @Test
+    public void testContains() {
+        // arrange
+        CutAngle pt = CutAngle.createNegativeFacing(Geometry.HALF_PI, TEST_PRECISION);
+
+        // act/assert
+        Assert.assertFalse(pt.contains(Point1S.ZERO));
+        Assert.assertFalse(pt.contains(Point1S.of(Geometry.TWO_PI)));
+
+        Assert.assertFalse(pt.contains(Point1S.of(Geometry.PI)));
+        Assert.assertFalse(pt.contains(Point1S.of(0.25 * Geometry.PI)));
+        Assert.assertFalse(pt.contains(Point1S.of(-0.25 * Geometry.PI)));
+
+        Assert.assertTrue(pt.contains(Point1S.of(Geometry.HALF_PI)));
+        Assert.assertTrue(pt.contains(Point1S.of(Geometry.HALF_PI + 1e-11)));
+        Assert.assertTrue(pt.contains(Point1S.of(2.5 * Geometry.PI)));
+        Assert.assertTrue(pt.contains(Point1S.of(-3.5 * Geometry.PI)));
+    }
+
+    @Test
+    public void testReverse() {
+        // arrange
+        CutAngle pt = CutAngle.createNegativeFacing(Geometry.HALF_PI, TEST_PRECISION);
+
+        // act
+        CutAngle result = pt.reverse();
+
+        // assert
+        checkCutAngle(result, Geometry.HALF_PI, true);
+        Assert.assertSame(TEST_PRECISION, result.getPrecision());
+
+        checkCutAngle(result.reverse(), Geometry.HALF_PI, false);
+    }
+
+    @Test
+    public void testProject() {
+        // arrange
+        CutAngle pt = CutAngle.createNegativeFacing(Geometry.HALF_PI, TEST_PRECISION);
+
+        // act/assert
+        for (double az = -Geometry.TWO_PI; az <= Geometry.TWO_PI; az += 0.2) {
+            Assert.assertEquals(Geometry.HALF_PI, pt.project(Point1S.of(az)).getAzimuth(), TEST_EPS);
+        }
+    }
+
+    @Test
+    public void testSimilarOrientation() {
+        // arrange
+        CutAngle a = CutAngle.createPositiveFacing(Geometry.ZERO_PI, TEST_PRECISION);
+        CutAngle b = CutAngle.createNegativeFacing(Geometry.ZERO_PI, TEST_PRECISION);
+        CutAngle c = CutAngle.createPositiveFacing(Geometry.MINUS_HALF_PI, TEST_PRECISION);
+
+        // act/assert
+        Assert.assertTrue(a.similarOrientation(a));
+        Assert.assertFalse(a.similarOrientation(b));
+        Assert.assertTrue(a.similarOrientation(c));
+    }
+
+    @Test
+    public void testTransform_rotate() {
+        // arrange
+        Transform1S transform = Transform1S.createRotation(Geometry.HALF_PI);
+
+        // act
+        checkCutAngle(CutAngle.fromPointAndDirection(Point1S.ZERO, true, TEST_PRECISION).transform(transform),
+                Geometry.HALF_PI, true);
+        checkCutAngle(CutAngle.fromPointAndDirection(Point1S.ZERO, false, TEST_PRECISION).transform(transform),
+                Geometry.HALF_PI, false);
+
+        checkCutAngle(CutAngle.fromPointAndDirection(Point1S.of(1.5 * Geometry.PI), true, TEST_PRECISION).transform(transform),
+                Geometry.TWO_PI, true);
+        checkCutAngle(CutAngle.fromPointAndDirection(Point1S.of(Geometry.MINUS_HALF_PI), false, TEST_PRECISION).transform(transform),
+                Geometry.ZERO_PI, false);
+    }
+
+    @Test
+    public void testTransform_negate() {
+        // arrange
+        Transform1S transform = Transform1S.createNegation();
+
+        // act
+        checkCutAngle(CutAngle.fromPointAndDirection(Point1S.ZERO, true, TEST_PRECISION).transform(transform),
+                Geometry.ZERO_PI, false);
+        checkCutAngle(CutAngle.fromPointAndDirection(Point1S.ZERO, false, TEST_PRECISION).transform(transform),
+                Geometry.ZERO_PI, true);
+
+        checkCutAngle(CutAngle.fromPointAndDirection(Point1S.of(1.5 * Geometry.PI), true, TEST_PRECISION).transform(transform),
+                -1.5 * Geometry.PI, false);
+        checkCutAngle(CutAngle.fromPointAndDirection(Point1S.of(Geometry.MINUS_HALF_PI), false, TEST_PRECISION).transform(transform),
+                Geometry.HALF_PI, true);
+    }
+
+    @Test
+    public void testSpan() {
+        // arrange
+        CutAngle pt = CutAngle.fromPointAndDirection(Point1S.of(1.0), false, TEST_PRECISION);
+
+        // act
+        SubCutAngle result = pt.span();
+
+        // assert
+        Assert.assertSame(pt, result.getHyperplane());
+    }
+
+    @Test
+    public void testEq() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-3);
+
+        CutAngle a = CutAngle.fromPointAndDirection(Point1S.ZERO, true, precision);
+
+        CutAngle b = CutAngle.fromPointAndDirection(Point1S.PI, true, precision);
+        CutAngle c = CutAngle.fromPointAndDirection(Point1S.ZERO, false, precision);
+        CutAngle d = CutAngle.fromPointAndDirection(Point1S.ZERO, true, TEST_PRECISION);
+
+        CutAngle e = CutAngle.fromPointAndDirection(Point1S.ZERO, true, precision);
+        CutAngle f = CutAngle.fromPointAndDirection(Point1S.of(Geometry.TWO_PI), true, precision);
+        CutAngle g = CutAngle.fromPointAndDirection(Point1S.of(1e-4), true, precision);
+        CutAngle h = CutAngle.fromPointAndDirection(Point1S.of(-1e-4), true, precision);
+
+        // act/assert
+        Assert.assertTrue(a.eq(a));
+
+        Assert.assertFalse(a.eq(b));
+        Assert.assertFalse(a.eq(c));
+        Assert.assertFalse(a.eq(d));
+
+        Assert.assertTrue(a.eq(e));
+        Assert.assertTrue(a.eq(f));
+        Assert.assertTrue(a.eq(g));
+        Assert.assertTrue(a.eq(h));
+    }
+
+    @Test
+    public void testHashCode() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-3);
+
+        CutAngle a = CutAngle.fromPointAndDirection(Point1S.ZERO, true, TEST_PRECISION);
+        CutAngle b = CutAngle.fromPointAndDirection(Point1S.PI, true, TEST_PRECISION);
+        CutAngle c = CutAngle.fromPointAndDirection(Point1S.ZERO, false, TEST_PRECISION);
+        CutAngle d = CutAngle.fromPointAndDirection(Point1S.ZERO, true, precision);
+        CutAngle e = CutAngle.fromPointAndDirection(Point1S.ZERO, true, TEST_PRECISION);
+
+        int hash = a.hashCode();
+
+        // act/assert
+        Assert.assertEquals(hash, a.hashCode());
+
+        Assert.assertNotEquals(hash, b.hashCode());
+        Assert.assertNotEquals(hash, c.hashCode());
+        Assert.assertNotEquals(hash, d.hashCode());
+
+        Assert.assertEquals(hash, e.hashCode());
+    }
+
+    @Test
+    public void testEquals() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-3);
+
+        CutAngle a = CutAngle.fromPointAndDirection(Point1S.ZERO, true, TEST_PRECISION);
+        CutAngle b = CutAngle.fromPointAndDirection(Point1S.PI, true, TEST_PRECISION);
+        CutAngle c = CutAngle.fromPointAndDirection(Point1S.ZERO, false, TEST_PRECISION);
+        CutAngle d = CutAngle.fromPointAndDirection(Point1S.ZERO, true, precision);
+        CutAngle e = CutAngle.fromPointAndDirection(Point1S.ZERO, true, TEST_PRECISION);
+
+        // act/assert
+        Assert.assertFalse(a.equals(null));
+        Assert.assertFalse(a.equals(new Object()));
+
+        Assert.assertTrue(a.equals(a));
+
+        Assert.assertFalse(a.equals(b));
+        Assert.assertFalse(a.equals(c));
+        Assert.assertFalse(a.equals(d));
+
+        Assert.assertTrue(a.equals(e));
+    }
+
+    @Test
+    public void testToString() {
+        // arrange
+        CutAngle pt = CutAngle.createPositiveFacing(Geometry.ZERO_PI, TEST_PRECISION);
+
+        // act
+        String str = pt.toString();
+
+        // assert
+        Assert.assertTrue(str.startsWith("CutAngle["));
+        Assert.assertTrue(str.contains("point= ") && str.contains("positiveFacing= "));
+    }
+
+    @Test
+    public void testSubHyperplane_split() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-3);
+
+        CutAngle pt = CutAngle.createPositiveFacing(-1.5, precision);
+        SubCutAngle sub = pt.span();
+
+        // act/assert
+        checkSplit(sub, CutAngle.createPositiveFacing(1.0, precision), false, true);
+        checkSplit(sub, CutAngle.createPositiveFacing(-1.5 + 1e-2, precision), true, false);
+
+        checkSplit(sub, CutAngle.createNegativeFacing(1.0, precision), true, false);
+        checkSplit(sub, CutAngle.createNegativeFacing(-1.5 + 1e-2, precision), false, true);
+
+        checkSplit(sub, CutAngle.createNegativeFacing(-1.5, precision), false, false);
+        checkSplit(sub, CutAngle.createNegativeFacing(-1.5 + 1e-4, precision), false, false);
+        checkSplit(sub, CutAngle.createNegativeFacing(-1.5 - 1e-4, precision), false, false);
+    }
+
+    private void checkSplit(SubCutAngle sub, CutAngle splitter, boolean minus, boolean plus) {
+        Split<SubCutAngle> split = sub.split(splitter);
+
+        Assert.assertSame(minus ? sub : null, split.getMinus());
+        Assert.assertSame(plus ? sub : null, split.getPlus());
+    }
+
+    @Test
+    public void testSubHyperplane_simpleMethods() {
+        // arrange
+        CutAngle pt = CutAngle.createPositiveFacing(0, TEST_PRECISION);
+        SubCutAngle sub = pt.span();
+
+        // act/assert
+        Assert.assertSame(pt, sub.getHyperplane());
+        Assert.assertFalse(sub.isFull());
+        Assert.assertFalse(sub.isEmpty());
+        Assert.assertFalse(sub.isInfinite());
+        Assert.assertTrue(sub.isFinite());
+        Assert.assertEquals(0.0, sub.getSize(), TEST_EPS);
+
+        List<SubCutAngle> list = sub.toConvex();
+        Assert.assertEquals(1, list.size());
+        Assert.assertSame(sub, list.get(0));
+    }
+
+    @Test
+    public void testSubHyperplane_classify() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-1);
+        CutAngle pt = CutAngle.createPositiveFacing(1, precision);
+        SubCutAngle sub = pt.span();
+
+        // act/assert
+        Assert.assertEquals(RegionLocation.BOUNDARY, sub.classify(Point1S.of(0.95)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, sub.classify(Point1S.of(1)));
+        Assert.assertEquals(RegionLocation.BOUNDARY, sub.classify(Point1S.of(1.05)));
+
+        Assert.assertEquals(RegionLocation.OUTSIDE, sub.classify(Point1S.of(1.11)));
+        Assert.assertEquals(RegionLocation.OUTSIDE, sub.classify(Point1S.of(0.89)));
+
+        Assert.assertEquals(RegionLocation.OUTSIDE, sub.classify(Point1S.of(-3)));
+        Assert.assertEquals(RegionLocation.OUTSIDE, sub.classify(Point1S.of(10)));
+    }
+
+    @Test
+    public void testSubHyperplane_contains() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-1);
+        CutAngle pt = CutAngle.createPositiveFacing(1, precision);
+        SubCutAngle sub = pt.span();
+
+        // act/assert
+        Assert.assertTrue(sub.contains(Point1S.of(0.95)));
+        Assert.assertTrue(sub.contains(Point1S.of(1)));
+        Assert.assertTrue(sub.contains(Point1S.of(1.05)));
+
+        Assert.assertFalse(sub.contains(Point1S.of(1.11)));
+        Assert.assertFalse(sub.contains(Point1S.of(0.89)));
+
+        Assert.assertFalse(sub.contains(Point1S.of(-3)));
+        Assert.assertFalse(sub.contains(Point1S.of(10)));
+    }
+
+    @Test
+    public void testSubHyperplane_closestContained() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-1);
+        CutAngle pt = CutAngle.createPositiveFacing(1, precision);
+        SubCutAngle sub = pt.span();
+
+        Point1S expected = Point1S.of(1);
+
+        // act/assert
+        Assert.assertEquals(expected, sub.closest(Point1S.ZERO));
+        Assert.assertEquals(expected, sub.closest(Point1S.of(Geometry.HALF_PI)));
+        Assert.assertEquals(expected, sub.closest(Point1S.PI));
+        Assert.assertEquals(expected, sub.closest(Point1S.of(Geometry.MINUS_HALF_PI)));
+        Assert.assertEquals(expected, sub.closest(Point1S.of(Geometry.TWO_PI)));
+    }
+
+    @Test
+    public void testSubHyperplane_transform() {
+        // arrange
+        CutAngle pt = CutAngle.fromPointAndDirection(Point1S.of(Geometry.HALF_PI), true, TEST_PRECISION);
+
+        Transform1S transform = Transform1S.createNegation().rotate(Geometry.PI);
+
+        // act
+        SubCutAngle result = pt.span().transform(transform);
+
+        // assert
+        checkCutAngle(result.getHyperplane(), Geometry.HALF_PI, false);
+    }
+
+    @Test
+    public void testSubHyperplane_reverse() {
+        // arrange
+        CutAngle pt = CutAngle.createPositiveFacing(2.0, TEST_PRECISION);
+        SubCutAngle sub = pt.span();
+
+        // act
+        SubCutAngle result = sub.reverse();
+
+        // assert
+        Assert.assertEquals(2.0, result.getHyperplane().getAzimuth(), TEST_EPS);
+        Assert.assertFalse(result.getHyperplane().isPositiveFacing());
+
+        Assert.assertEquals(sub.getHyperplane(), result.reverse().getHyperplane());
+    }
+
+    @Test
+    public void testSubHyperplane_toString() {
+        // arrange
+        CutAngle pt = CutAngle.createPositiveFacing(2, TEST_PRECISION);
+        SubCutAngle sub = pt.span();
+
+        // act
+        String str = sub.toString();
+
+        //assert
+        Assert.assertTrue(str.contains("SubCutAngle["));
+        Assert.assertTrue(str.contains("point= "));
+        Assert.assertTrue(str.contains("positiveFacing= "));
+    }
+
+    @Test
+    public void testBuilder() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-3);
+
+        CutAngle pt = CutAngle.createPositiveFacing(0, precision);
+        SubCutAngle sub = pt.span();
+
+        // act
+        Builder<Point1S> builder = sub.builder();
+
+        builder.add(sub);
+        builder.add(CutAngle.createPositiveFacing(1e-4, precision).span());
+        builder.add((SubHyperplane<Point1S>) sub);
+
+        SubHyperplane<Point1S> result = builder.build();
+
+        // assert
+        Assert.assertSame(sub, result);
+    }
+
+    @Test
+    public void testBuilder_invalidArgs() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-3);
+
+        CutAngle pt = CutAngle.createPositiveFacing(0, precision);
+        SubCutAngle sub = pt.span();
+
+        Builder<Point1S> builder = sub.builder();
+
+        // act/assert
+        GeometryTestUtils.assertThrows(
+                () -> builder.add(CutAngle.createPositiveFacing(2e-3, precision).span()),
+                GeometryException.class);
+        GeometryTestUtils.assertThrows(
+                () -> builder.add(CutAngle.createNegativeFacing(2e-3, precision).span()),
+                GeometryException.class);
+
+        GeometryTestUtils.assertThrows(
+                () -> builder.add((SubHyperplane<Point1S>) CutAngle.createPositiveFacing(2e-3, precision).span()),
+                GeometryException.class);
+    }
+
+    @Test
+    public void testBuilder_toString() {
+        // arrange
+        CutAngle pt = CutAngle.createPositiveFacing(2, TEST_PRECISION);
+        SubCutAngleBuilder builder = pt.span().builder();
+
+        // act
+        String str = builder.toString();
+
+        //assert
+        Assert.assertTrue(str.contains("SubCutAngleBuilder["));
+        Assert.assertTrue(str.contains("base= SubCutAngle["));
+        Assert.assertTrue(str.contains("point= "));
+        Assert.assertTrue(str.contains("positiveFacing= "));
+    }
+
+    private static void checkCutAngle(CutAngle angle, double az, boolean positiveFacing) {
+        checkCutAngle(angle, az, positiveFacing, TEST_PRECISION);
+    }
+
+    private static void checkCutAngle(CutAngle angle, double az, boolean positiveFacing, DoublePrecisionContext precision) {
+        Assert.assertEquals(az, angle.getAzimuth(), TEST_EPS);
+        Assert.assertEquals(PlaneAngleRadians.normalizeBetweenZeroAndTwoPi(az), angle.getNormalizedAzimuth(), TEST_EPS);
+        Assert.assertEquals(az, angle.getPoint().getAzimuth(), TEST_EPS);
+        Assert.assertEquals(positiveFacing, angle.isPositiveFacing());
+
+        Assert.assertSame(precision, angle.getPrecision());
+    }
+
+    private static void checkOffset(CutAngle pt, double az, double offset) {
+        Assert.assertEquals(offset, pt.offset(Point1S.of(az)), TEST_EPS);
+    }
+
+    private static void checkClassify(CutAngle pt, HyperplaneLocation loc, double ... azimuths) {
+        for (double az : azimuths) {
+            Assert.assertEquals("Unexpected location for azimuth " + az, loc, pt.classify(Point1S.of(az)));
+        }
+    }
+}
diff --git a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/oned/LimitAngleTest.java b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/oned/LimitAngleTest.java
deleted file mode 100644
index eb29564..0000000
--- a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/oned/LimitAngleTest.java
+++ /dev/null
@@ -1,45 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.oned;
-
-import org.apache.commons.geometry.core.Geometry;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
-import org.junit.Assert;
-import org.junit.Test;
-
-public class LimitAngleTest {
-
-    private static final double TEST_EPS = 1e-10;
-
-    private static final DoublePrecisionContext TEST_PRECISION =
-            new EpsilonDoublePrecisionContext(TEST_EPS);
-
-    @Test
-    public void testReversedLimit() {
-        for (int k = -2; k < 3; ++k) {
-            LimitAngle l  = new LimitAngle(S1Point.of(1.0 + k * Geometry.TWO_PI), false, TEST_PRECISION);
-            Assert.assertEquals(l.getLocation().getAzimuth(), l.getReverse().getLocation().getAzimuth(), TEST_EPS);
-            Assert.assertSame(l.getPrecision(), l.getReverse().getPrecision());
-            Assert.assertTrue(l.sameOrientationAs(l));
-            Assert.assertFalse(l.sameOrientationAs(l.getReverse()));
-            Assert.assertEquals(Geometry.TWO_PI, l.wholeSpace().getSize(), TEST_EPS);
-            Assert.assertEquals(Geometry.TWO_PI, l.getReverse().wholeSpace().getSize(), TEST_EPS);
-        }
-    }
-
-}
diff --git a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/oned/Point1STest.java b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/oned/Point1STest.java
new file mode 100644
index 0000000..f4a08e6
--- /dev/null
+++ b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/oned/Point1STest.java
@@ -0,0 +1,481 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.oned;
+
+import java.util.Comparator;
+
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.GeometryTestUtils;
+import org.apache.commons.geometry.core.exception.GeometryValueException;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.twod.PolarCoordinates;
+import org.apache.commons.geometry.euclidean.twod.Vector2D;
+import org.apache.commons.numbers.angle.PlaneAngle;
+import org.apache.commons.numbers.angle.PlaneAngleRadians;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class Point1STest {
+
+    private static final double TEST_EPS = 1e-10;
+
+    @Test
+    public void testConstants() {
+        // act/assert
+        Assert.assertEquals(0.0, Point1S.ZERO.getAzimuth(), TEST_EPS);
+        Assert.assertEquals(Math.PI, Point1S.PI.getAzimuth(), TEST_EPS);
+    }
+
+    @Test
+    public void testNormalizedAzimuthComparator() {
+        // arrange
+        Comparator<Point1S> comp = Point1S.NORMALIZED_AZIMUTH_ASCENDING_ORDER;
+
+        // act/assert
+        Assert.assertEquals(0, comp.compare(Point1S.of(1), Point1S.of(1)));
+        Assert.assertEquals(-1, comp.compare(Point1S.of(0), Point1S.of(1)));
+        Assert.assertEquals(1, comp.compare(Point1S.of(1), Point1S.of(0)));
+        Assert.assertEquals(1, comp.compare(Point1S.of(1), Point1S.of(0.1 + Geometry.TWO_PI)));
+
+        Assert.assertEquals(1, comp.compare(null, Point1S.of(0)));
+        Assert.assertEquals(-1, comp.compare(Point1S.of(0), null));
+        Assert.assertEquals(0, comp.compare(null, null));
+    }
+
+    @Test
+    public void testOf() {
+        // act/assert
+        checkPoint(Point1S.of(0), 0, 0);
+        checkPoint(Point1S.of(1), 1, 1);
+        checkPoint(Point1S.of(-1), -1, Geometry.TWO_PI - 1);
+
+        checkPoint(Point1S.of(PlaneAngle.ofDegrees(90)), Geometry.HALF_PI, Geometry.HALF_PI);
+        checkPoint(Point1S.of(PlaneAngle.ofTurns(0.5)), Geometry.PI, Geometry.PI);
+        checkPoint(Point1S.of(Geometry.MINUS_HALF_PI), Geometry.MINUS_HALF_PI, 1.5 * Geometry.PI);
+
+        double base = Geometry.HALF_PI;
+        for (int k = -3; k <= 3; ++k) {
+            double az = base + (k * Geometry.TWO_PI);
+            checkPoint(Point1S.of(az), az, base);
+        }
+    }
+
+    @Test
+    public void testFrom_vector() {
+        // act/assert
+        checkPoint(Point1S.from(Vector2D.of(2, 0)), Geometry.ZERO_PI);
+        checkPoint(Point1S.from(Vector2D.of(0, 0.1)), Geometry.HALF_PI);
+        checkPoint(Point1S.from(Vector2D.of(-0.5, 0)), Geometry.PI);
+        checkPoint(Point1S.from(Vector2D.of(0, -100)), 1.5 * Geometry.PI);
+    }
+
+    @Test
+    public void testFrom_polar() {
+        // act/assert
+        checkPoint(Point1S.from(PolarCoordinates.of(100, 0)), Geometry.ZERO_PI);
+        checkPoint(Point1S.from(PolarCoordinates.of(1, Geometry.HALF_PI)), Geometry.HALF_PI);
+        checkPoint(Point1S.from(PolarCoordinates.of(0.5, Geometry.PI)), Geometry.PI);
+        checkPoint(Point1S.from(PolarCoordinates.of(1e-4, Geometry.MINUS_HALF_PI)), 1.5 * Geometry.PI);
+    }
+
+    @Test
+    public void testFrom_polar_invalidAzimuths() {
+        // act/assert
+        checkPoint(Point1S.from(PolarCoordinates.of(100, Double.POSITIVE_INFINITY)), Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY);
+        checkPoint(Point1S.from(PolarCoordinates.of(100, Double.NEGATIVE_INFINITY)), Double.NEGATIVE_INFINITY, Double.NEGATIVE_INFINITY);
+        checkPoint(Point1S.from(PolarCoordinates.of(100, Double.NaN)), Double.NaN, Double.NaN);
+    }
+
+    @Test
+    public void testNaN() {
+        // act
+        Point1S pt = Point1S.of(Double.NaN);
+
+        // assert
+        Assert.assertTrue(pt.isNaN());
+        Assert.assertTrue(Point1S.NaN.isNaN());
+
+        Assert.assertTrue(Double.isNaN(pt.getAzimuth()));
+        Assert.assertTrue(Double.isNaN(pt.getNormalizedAzimuth()));
+        Assert.assertNull(pt.getVector());
+
+        Assert.assertTrue(Point1S.NaN.equals(pt));
+        Assert.assertFalse(Point1S.of(1.0).equals(Point1S.NaN));
+    }
+
+    @Test
+    public void testGetDimension() {
+        // arrange
+        Point1S p = Point1S.of(0.0);
+
+        // act/assert
+        Assert.assertEquals(1, p.getDimension());
+    }
+
+    @Test
+    public void testInfinite() {
+        // act/assert
+        Assert.assertTrue(Point1S.of(Double.POSITIVE_INFINITY).isInfinite());
+        Assert.assertTrue(Point1S.of(Double.NEGATIVE_INFINITY).isInfinite());
+
+        Assert.assertFalse(Point1S.NaN.isInfinite());
+        Assert.assertFalse(Point1S.of(1).isInfinite());
+    }
+
+    @Test
+    public void testFinite() {
+        // act/assert
+        Assert.assertTrue(Point1S.of(0).isFinite());
+        Assert.assertTrue(Point1S.of(1).isFinite());
+
+        Assert.assertFalse(Point1S.of(Double.POSITIVE_INFINITY).isFinite());
+        Assert.assertFalse(Point1S.of(Double.NEGATIVE_INFINITY).isFinite());
+        Assert.assertFalse(Point1S.NaN.isFinite());
+    }
+
+    @Test
+    public void testAntipodal() {
+        for (double az = -6 * Geometry.PI; az <= 6 * Geometry.PI; az += 0.1) {
+            // arrange
+            Point1S pt = Point1S.of(az);
+
+            // act
+            Point1S result = pt.antipodal();
+
+            // assert
+            Assert.assertTrue(result.getAzimuth() >= 0 && result.getAzimuth() < Geometry.TWO_PI);
+            Assert.assertEquals(Geometry.PI, pt.distance(result), TEST_EPS);
+        }
+    }
+
+    @Test
+    public void testHashCode() {
+        // act
+        Point1S a = Point1S.of(1.0);
+        Point1S b = Point1S.of(2.0);
+        Point1S c = Point1S.of(1.0);
+        Point1S d = Point1S.of(1.0 + Geometry.PI);
+
+        int hash = a.hashCode();
+
+        // assert
+        Assert.assertEquals(hash, a.hashCode());
+        Assert.assertNotEquals(hash, b.hashCode());
+        Assert.assertEquals(hash, c.hashCode());
+        Assert.assertNotEquals(hash, d.hashCode());
+
+        Assert.assertEquals(Point1S.NaN.hashCode(), Point1S.of(Double.NaN).hashCode());
+    }
+
+    @Test
+    public void testEquals() {
+        // act
+        Point1S a = Point1S.of(1.0);
+        Point1S b = Point1S.of(2.0);
+        Point1S c = Point1S.of(1.0 + Geometry.PI);
+        Point1S d = Point1S.of(1.0);
+        Point1S e = Point1S.of(Double.NaN);
+
+        // assert
+        Assert.assertFalse(a.equals(null));
+        Assert.assertFalse(a.equals(new Object()));
+
+        Assert.assertTrue(a.equals(a));
+
+        Assert.assertFalse(a.equals(b));
+        Assert.assertFalse(b.equals(a));
+
+        Assert.assertFalse(a.equals(c));
+        Assert.assertFalse(c.equals(a));
+
+        Assert.assertTrue(a.equals(d));
+        Assert.assertTrue(d.equals(a));
+
+        Assert.assertFalse(a.equals(e));
+        Assert.assertTrue(e.equals(Point1S.NaN));
+    }
+
+    @Test
+    public void testEq() {
+        // arrange
+        DoublePrecisionContext highPrecision = new EpsilonDoublePrecisionContext(1e-10);
+        DoublePrecisionContext lowPrecision = new EpsilonDoublePrecisionContext(1e-2);
+
+        Point1S a = Point1S.of(1);
+        Point1S b = Point1S.of(0.9999);
+        Point1S c = Point1S.of(1.00001);
+        Point1S d = Point1S.of(1 + (3 * Geometry.TWO_PI));
+
+        // act/assert
+        Assert.assertTrue(a.eq(a, highPrecision));
+        Assert.assertTrue(a.eq(a, lowPrecision));
+
+        Assert.assertFalse(a.eq(b, highPrecision));
+        Assert.assertTrue(a.eq(b, lowPrecision));
+
+        Assert.assertFalse(a.eq(c, highPrecision));
+        Assert.assertTrue(a.eq(c, lowPrecision));
+
+        Assert.assertTrue(a.eq(d, highPrecision));
+        Assert.assertTrue(a.eq(d, lowPrecision));
+    }
+
+    @Test
+    public void testEq_wrapAround() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-2);
+
+        Point1S a = Point1S.ZERO;
+        Point1S b = Point1S.of(1e-3);
+        Point1S c = Point1S.of(-1e-3);
+
+        // act/assert
+        Assert.assertTrue(a.eq(a, precision));
+
+        Assert.assertTrue(a.eq(b, precision));
+        Assert.assertTrue(b.eq(a, precision));
+
+        Assert.assertTrue(a.eq(c, precision));
+        Assert.assertTrue(c.eq(a, precision));
+    }
+
+    @Test
+    public void testDistance() {
+        // arrange
+        Point1S a = Point1S.of(0.0);
+        Point1S b = Point1S.of(Geometry.PI - 0.5);
+        Point1S c = Point1S.of(Geometry.PI);
+        Point1S d = Point1S.of(Geometry.PI + 0.5);
+        Point1S e = Point1S.of(4.0);
+
+        // act/assert
+        Assert.assertEquals(0.0, a.distance(a), TEST_EPS);
+        Assert.assertEquals(Geometry.PI - 0.5, a.distance(b), TEST_EPS);
+        Assert.assertEquals(Geometry.PI - 0.5, b.distance(a), TEST_EPS);
+
+        Assert.assertEquals(Geometry.PI, a.distance(c), TEST_EPS);
+        Assert.assertEquals(Geometry.PI, c.distance(a), TEST_EPS);
+
+        Assert.assertEquals(Geometry.PI - 0.5, a.distance(d), TEST_EPS);
+        Assert.assertEquals(Geometry.PI - 0.5, d.distance(a), TEST_EPS);
+
+        Assert.assertEquals(Geometry.TWO_PI - 4, a.distance(e), TEST_EPS);
+        Assert.assertEquals(Geometry.TWO_PI - 4, e.distance(a), TEST_EPS);
+    }
+
+    @Test
+    public void testSignedDistance() {
+        // arrange
+        Point1S a = Point1S.of(0.0);
+        Point1S b = Point1S.of(Geometry.PI - 0.5);
+        Point1S c = Point1S.of(Geometry.PI);
+        Point1S d = Point1S.of(Geometry.PI + 0.5);
+        Point1S e = Point1S.of(4.0);
+
+        // act/assert
+        Assert.assertEquals(0.0, a.signedDistance(a), TEST_EPS);
+        Assert.assertEquals(Geometry.PI - 0.5, a.signedDistance(b), TEST_EPS);
+        Assert.assertEquals(-Geometry.PI + 0.5, b.signedDistance(a), TEST_EPS);
+
+        Assert.assertEquals(-Geometry.PI, a.signedDistance(c), TEST_EPS);
+        Assert.assertEquals(-Geometry.PI, c.signedDistance(a), TEST_EPS);
+
+        Assert.assertEquals(-Geometry.PI + 0.5, a.signedDistance(d), TEST_EPS);
+        Assert.assertEquals(Geometry.PI - 0.5, d.signedDistance(a), TEST_EPS);
+
+        Assert.assertEquals(-Geometry.TWO_PI + 4, a.signedDistance(e), TEST_EPS);
+        Assert.assertEquals(Geometry.TWO_PI - 4, e.signedDistance(a), TEST_EPS);
+    }
+
+    @Test
+    public void testDistance_inRangeZeroToPi() {
+        for (double a = -4 * Geometry.PI; a < 4 * Geometry.PI; a += 0.1) {
+            for (double b = -4 * Geometry.PI; b < 4 * Geometry.PI; b += 0.1) {
+                // arrange
+                Point1S p1 = Point1S.of(a);
+                Point1S p2 = Point1S.of(b);
+
+                // act/assert
+                double d1 = p1.distance(p2);
+                Assert.assertTrue(d1 >= 0 && d1 <= Geometry.PI);
+
+                double d2 = p2.distance(p1);
+                Assert.assertTrue(d2 >= 0 && d2 <= Geometry.PI);
+            }
+        }
+    }
+
+    @Test
+    public void testNormalize() {
+        for (double az = -Geometry.TWO_PI; az < 2 * Geometry.TWO_PI; az += 0.2) {
+            // arrange
+            Point1S pt = Point1S.of(az);
+
+            double expectedPiNorm = PlaneAngleRadians.normalizeBetweenZeroAndTwoPi(az);
+            double expectedZeroNorm = PlaneAngleRadians.normalizeBetweenMinusPiAndPi(az);
+
+            // act
+            Point1S piNorm = pt.normalize(Point1S.PI);
+            Point1S zeroNorm = pt.normalize(Geometry.ZERO_PI);
+
+            // assert
+            Assert.assertEquals(expectedPiNorm, piNorm.getAzimuth(), TEST_EPS);
+            Assert.assertEquals(pt.getNormalizedAzimuth(), piNorm.getNormalizedAzimuth(), TEST_EPS);
+
+            Assert.assertEquals(expectedZeroNorm, zeroNorm.getAzimuth(), TEST_EPS);
+            Assert.assertEquals(pt.getNormalizedAzimuth(), zeroNorm.getNormalizedAzimuth(), TEST_EPS);
+        }
+    }
+
+    @Test
+    public void testNormalize_nonFinite() {
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            Point1S.of(Double.POSITIVE_INFINITY).normalize(Geometry.ZERO_PI);
+        }, GeometryValueException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            Point1S.of(Double.NEGATIVE_INFINITY).normalize(Geometry.ZERO_PI);
+        }, GeometryValueException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            Point1S.of(Double.NaN).normalize(Point1S.ZERO);
+        }, GeometryValueException.class);
+    }
+
+    @Test
+    public void testAbove() {
+        // arrange
+        Point1S p1 = Point1S.ZERO;
+        Point1S p2 = Point1S.of(PlaneAngle.ofDegrees(90));
+        Point1S p3 = Point1S.PI;
+        Point1S p4 = Point1S.of(PlaneAngle.ofDegrees(-90));
+        Point1S p5 = Point1S.of(Geometry.TWO_PI);
+
+        // act/assert
+        checkPoint(p1.above(p1), 0);
+        checkPoint(p2.above(p1), Geometry.HALF_PI);
+        checkPoint(p3.above(p1), Geometry.PI);
+        checkPoint(p4.above(p1), 1.5 * Geometry.PI);
+        checkPoint(p5.above(p1), 0);
+
+        checkPoint(p1.above(p3), Geometry.TWO_PI);
+        checkPoint(p2.above(p3), 2.5 * Geometry.PI);
+        checkPoint(p3.above(p3), Geometry.PI);
+        checkPoint(p4.above(p3), 1.5 * Geometry.PI);
+        checkPoint(p5.above(p3), Geometry.TWO_PI);
+    }
+
+    @Test
+    public void testAbove_nonFinite() {
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            Point1S.of(Double.POSITIVE_INFINITY).above(Point1S.ZERO);
+        }, GeometryValueException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            Point1S.of(Double.NEGATIVE_INFINITY).above(Point1S.ZERO);
+        }, GeometryValueException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            Point1S.of(Double.NaN).above(Point1S.ZERO);
+        }, GeometryValueException.class);
+    }
+
+    @Test
+    public void testBelow() {
+        // arrange
+        Point1S p1 = Point1S.ZERO;
+        Point1S p2 = Point1S.of(PlaneAngle.ofDegrees(90));
+        Point1S p3 = Point1S.PI;
+        Point1S p4 = Point1S.of(PlaneAngle.ofDegrees(-90));
+        Point1S p5 = Point1S.of(Geometry.TWO_PI);
+
+        // act/assert
+        checkPoint(p1.below(p1), -Geometry.TWO_PI);
+        checkPoint(p2.below(p1), -1.5 * Geometry.PI);
+        checkPoint(p3.below(p1), -Geometry.PI);
+        checkPoint(p4.below(p1), Geometry.MINUS_HALF_PI);
+        checkPoint(p5.below(p1), -Geometry.TWO_PI);
+
+        checkPoint(p1.below(p3), Geometry.ZERO_PI);
+        checkPoint(p2.below(p3), Geometry.HALF_PI);
+        checkPoint(p3.below(p3), -Geometry.PI);
+        checkPoint(p4.below(p3), Geometry.MINUS_HALF_PI);
+        checkPoint(p5.below(p3), Geometry.ZERO_PI);
+    }
+
+    @Test
+    public void testBelow_nonFinite() {
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            Point1S.of(Double.POSITIVE_INFINITY).below(Point1S.ZERO);
+        }, GeometryValueException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            Point1S.of(Double.NEGATIVE_INFINITY).below(Point1S.ZERO);
+        }, GeometryValueException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            Point1S.of(Double.NaN).below(Point1S.ZERO);
+        }, GeometryValueException.class);
+    }
+
+    @Test
+    public void testToString() {
+        // act/assert
+        Assert.assertEquals("(0.0)", Point1S.of(0.0).toString());
+        Assert.assertEquals("(1.0)", Point1S.of(1.0).toString());
+    }
+
+    @Test
+    public void testParse() {
+        // act/assert
+        checkPoint(Point1S.parse("(0)"), 0.0);
+        checkPoint(Point1S.parse("(1)"), 1.0);
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    public void testParse_failure() {
+        // act/assert
+        Point1S.parse("abc");
+    }
+
+    private static void checkPoint(Point1S pt, double az) {
+        checkPoint(pt, az, PlaneAngleRadians.normalizeBetweenZeroAndTwoPi(az));
+    }
+
+    private static void checkPoint(Point1S pt, double az, double normAz) {
+        Assert.assertEquals(az, pt.getAzimuth(), TEST_EPS);
+        Assert.assertEquals(normAz, pt.getNormalizedAzimuth(), TEST_EPS);
+
+        Assert.assertEquals(1, pt.getDimension());
+
+        Assert.assertEquals(Double.isFinite(az), pt.isFinite());
+        Assert.assertEquals(Double.isInfinite(az), pt.isInfinite());
+
+        Vector2D vec = pt.getVector();
+        if (pt.isFinite()) {
+            Assert.assertEquals(1, vec.norm(), TEST_EPS);
+            Assert.assertEquals(normAz, PolarCoordinates.fromCartesian(vec).getAzimuth(), TEST_EPS);
+        }
+        else {
+            Assert.assertNull(vec);
+        }
+    }
+}
diff --git a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/oned/RegionBSPTree1STest.java b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/oned/RegionBSPTree1STest.java
new file mode 100644
index 0000000..6ae23a7
--- /dev/null
+++ b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/oned/RegionBSPTree1STest.java
@@ -0,0 +1,923 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.oned;
+
+import java.util.List;
+
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.Region;
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.SplitLocation;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
+import org.apache.commons.numbers.angle.PlaneAngleRadians;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class RegionBSPTree1STest {
+
+    private static final double TEST_EPS = 1e-10;
+
+    private static final DoublePrecisionContext TEST_PRECISION =
+            new EpsilonDoublePrecisionContext(TEST_EPS);
+
+    private static final Transform1S HALF_PI_PLUS_AZ = Transform1S.createRotation(Geometry.HALF_PI);
+
+    private static final Transform1S PI_MINUS_AZ = Transform1S.createNegation().rotate(Geometry.PI);
+
+    @Test
+    public void testConstructor_default() {
+        // act
+        RegionBSPTree1S tree = new RegionBSPTree1S();
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertTrue(tree.isEmpty());
+
+        Assert.assertEquals(0, tree.getSize(), TEST_EPS);
+        Assert.assertEquals(0, tree.getBoundarySize(), TEST_EPS);
+        Assert.assertNull(tree.getBarycenter());
+    }
+
+    @Test
+    public void testConstructor_true() {
+        // act
+        RegionBSPTree1S tree = new RegionBSPTree1S(true);
+
+        // assert
+        Assert.assertTrue(tree.isFull());
+        Assert.assertFalse(tree.isEmpty());
+
+        Assert.assertEquals(Geometry.TWO_PI, tree.getSize(), TEST_EPS);
+        Assert.assertEquals(0, tree.getBoundarySize(), TEST_EPS);
+        Assert.assertNull(tree.getBarycenter());
+    }
+
+    @Test
+    public void testConstructor_false() {
+        // act
+        RegionBSPTree1S tree = new RegionBSPTree1S(false);
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertTrue(tree.isEmpty());
+
+        Assert.assertEquals(0, tree.getSize(), TEST_EPS);
+        Assert.assertEquals(0, tree.getBoundarySize(), TEST_EPS);
+        Assert.assertNull(tree.getBarycenter());
+    }
+
+    @Test
+    public void testFull() {
+        // act
+        RegionBSPTree1S tree = RegionBSPTree1S.full();
+
+        // assert
+        Assert.assertTrue(tree.isFull());
+        Assert.assertFalse(tree.isEmpty());
+
+        Assert.assertEquals(Geometry.TWO_PI, tree.getSize(), TEST_EPS);
+        Assert.assertEquals(0, tree.getBoundarySize(), TEST_EPS);
+        Assert.assertNull(tree.getBarycenter());
+    }
+
+    @Test
+    public void testEmpty() {
+        // act
+        RegionBSPTree1S tree = RegionBSPTree1S.empty();
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertTrue(tree.isEmpty());
+
+        Assert.assertEquals(0, tree.getSize(), TEST_EPS);
+        Assert.assertEquals(0, tree.getBoundarySize(), TEST_EPS);
+        Assert.assertNull(tree.getBarycenter());
+    }
+
+    @Test
+    public void testCopy() {
+        // arrange
+        RegionBSPTree1S orig = RegionBSPTree1S.fromInterval(AngularInterval.of(0, Geometry.PI, TEST_PRECISION));
+
+        // act
+        RegionBSPTree1S copy = orig.copy();
+
+        // assert
+        Assert.assertNotSame(orig, copy);
+
+        orig.setEmpty();
+
+        checkSingleInterval(copy, 0, Geometry.PI);
+    }
+
+    @Test
+    public void testFromInterval_full() {
+        // act
+        RegionBSPTree1S tree = RegionBSPTree1S.fromInterval(AngularInterval.full());
+
+        // assert
+        Assert.assertTrue(tree.isFull());
+    }
+
+    @Test
+    public void testFromInterval_nonFull() {
+        for (double theta = Geometry.ZERO_PI; theta <= Geometry.TWO_PI; theta += 0.2) {
+            // arrange
+            double min = theta;
+            double max = theta + Geometry.HALF_PI;
+
+            // act
+            RegionBSPTree1S tree = RegionBSPTree1S.fromInterval(AngularInterval.of(min, max, TEST_PRECISION));
+
+            checkSingleInterval(tree, min, max);
+
+            Assert.assertEquals(Geometry.HALF_PI, tree.getSize(), TEST_EPS);
+            Assert.assertEquals(0, tree.getBoundarySize(), TEST_EPS);
+            Assert.assertEquals(PlaneAngleRadians.normalizeBetweenZeroAndTwoPi(theta + (0.25 * Geometry.PI)),
+                    tree.getBarycenter().getNormalizedAzimuth(), TEST_EPS);
+        }
+    }
+
+    @Test
+    public void testClassify_full() {
+        // arrange
+        RegionBSPTree1S tree = RegionBSPTree1S.full();
+
+        // act/assert
+        for (double az = -Geometry.TWO_PI; az <= 2 * Geometry.TWO_PI; az += 0.2) {
+            checkClassify(tree, RegionLocation.INSIDE, az);
+        }
+    }
+
+    @Test
+    public void testClassify_empty() {
+        // arrange
+        RegionBSPTree1S tree = RegionBSPTree1S.empty();
+
+        // act/assert
+        for (double az = -Geometry.TWO_PI; az <= 2 * Geometry.TWO_PI; az += 0.2) {
+            checkClassify(tree, RegionLocation.OUTSIDE, az);
+        }
+    }
+
+    @Test
+    public void testClassify() {
+        // arrange
+        RegionBSPTree1S tree = RegionBSPTree1S.fromInterval(
+                AngularInterval.of(Geometry.MINUS_HALF_PI, Geometry.HALF_PI, TEST_PRECISION));
+
+        // act/assert
+        checkClassify(tree, RegionLocation.BOUNDARY,
+                Geometry.MINUS_HALF_PI, Geometry.HALF_PI,
+                Geometry.MINUS_HALF_PI - Geometry.TWO_PI, Geometry.HALF_PI + Geometry.TWO_PI);
+        checkClassify(tree, RegionLocation.INSIDE,
+                Geometry.ZERO_PI, 0.5, -0.5,
+                Geometry.TWO_PI, 0.5 + Geometry.TWO_PI, -0.5 - Geometry.TWO_PI);
+        checkClassify(tree, RegionLocation.OUTSIDE,
+                Geometry.PI, Geometry.PI + 0.5, Geometry.PI - 0.5,
+                Geometry.PI + Geometry.TWO_PI, Geometry.PI + 0.5 + Geometry.TWO_PI,
+                Geometry.PI - 0.5 + Geometry.TWO_PI);
+    }
+
+    @Test
+    public void testToIntervals_full() {
+        // arrange
+        RegionBSPTree1S tree = RegionBSPTree1S.full();
+
+        // act
+        List<AngularInterval> intervals = tree.toIntervals();
+
+        // assert
+        Assert.assertEquals(1, intervals.size());
+
+        AngularInterval interval = intervals.get(0);
+        Assert.assertTrue(interval.isFull());
+    }
+
+    @Test
+    public void testToIntervals_empty() {
+        // arrange
+        RegionBSPTree1S tree = RegionBSPTree1S.empty();
+
+        // act
+        List<AngularInterval> intervals = tree.toIntervals();
+
+        // assert
+        Assert.assertEquals(0, intervals.size());
+    }
+
+    @Test
+    public void testToIntervals_singleCut() {
+        // arrange
+        RegionBSPTree1S tree = RegionBSPTree1S.empty();
+
+        for (double theta = 0; theta <= Geometry.TWO_PI; theta += 0.2) {
+            // act/assert
+            tree.setEmpty();
+            tree.getRoot().cut(CutAngle.createPositiveFacing(theta, TEST_PRECISION));
+
+            checkSingleInterval(tree, 0, theta);
+
+            tree.setEmpty();
+            tree.getRoot().cut(CutAngle.createNegativeFacing(theta, TEST_PRECISION));
+
+            checkSingleInterval(tree, theta, Geometry.TWO_PI);
+        }
+    }
+
+    @Test
+    public void testToIntervals_wrapAround_joinedIntervalsOnPositiveSide() {
+        // arrange
+        RegionBSPTree1S tree = RegionBSPTree1S.empty();
+        tree.add(AngularInterval.of(0.25 * Geometry.PI, Geometry.HALF_PI, TEST_PRECISION));
+        tree.add(AngularInterval.of(1.5 * Geometry.PI, 0.25 * Geometry.PI, TEST_PRECISION));
+
+        // act
+        List<AngularInterval> intervals = tree.toIntervals();
+
+        // assert
+        Assert.assertEquals(1, intervals.size());
+
+        checkInterval(intervals.get(0), 1.5 * Geometry.PI, Geometry.HALF_PI);
+    }
+
+    @Test
+    public void testToIntervals_wrapAround_joinedIntervalsOnNegativeSide() {
+        // arrange
+        RegionBSPTree1S tree = RegionBSPTree1S.empty();
+        tree.add(AngularInterval.of(1.75 * Geometry.PI, Geometry.HALF_PI, TEST_PRECISION));
+        tree.add(AngularInterval.of(1.5 * Geometry.PI, 1.75 * Geometry.PI, TEST_PRECISION));
+
+        // act
+        List<AngularInterval> intervals = tree.toIntervals();
+
+        // assert
+        Assert.assertEquals(1, intervals.size());
+
+        checkInterval(intervals.get(0), 1.5 * Geometry.PI, Geometry.HALF_PI);
+    }
+
+    @Test
+    public void testToIntervals_multipleIntervals() {
+        // arrange
+        RegionBSPTree1S tree = RegionBSPTree1S.empty();
+        tree.add(AngularInterval.of(Geometry.MINUS_HALF_PI, Geometry.HALF_PI, TEST_PRECISION));
+        tree.add(AngularInterval.of(Geometry.PI - 0.5, Geometry.PI, TEST_PRECISION));
+        tree.add(AngularInterval.of(Geometry.PI, Geometry.PI + 0.5, TEST_PRECISION));
+
+        // act
+        List<AngularInterval> intervals = tree.toIntervals();
+
+        // assert
+        Assert.assertEquals(2, intervals.size());
+
+        checkInterval(intervals.get(0), Geometry.PI - 0.5, Geometry.PI + 0.5);
+        checkInterval(intervals.get(1), Geometry.MINUS_HALF_PI, Geometry.HALF_PI);
+    }
+
+    @Test
+    public void testToIntervals_multipleIntervals_complement() {
+        // arrange
+        RegionBSPTree1S tree = RegionBSPTree1S.empty();
+        tree.add(AngularInterval.of(Geometry.MINUS_HALF_PI, Geometry.HALF_PI, TEST_PRECISION));
+        tree.add(AngularInterval.of(Geometry.PI - 0.5, Geometry.PI, TEST_PRECISION));
+        tree.add(AngularInterval.of(Geometry.PI, Geometry.PI + 0.5, TEST_PRECISION));
+
+        tree.complement();
+
+        // act
+        List<AngularInterval> intervals = tree.toIntervals();
+
+        // assert
+        Assert.assertEquals(2, intervals.size());
+
+        checkInterval(intervals.get(0), Geometry.HALF_PI, Geometry.PI - 0.5);
+        checkInterval(intervals.get(1), Geometry.PI + 0.5, Geometry.MINUS_HALF_PI);
+    }
+
+    @Test
+    public void testSplit_empty() {
+        // arrange
+        RegionBSPTree1S tree = RegionBSPTree1S.empty();
+
+        // act/assert
+        Assert.assertEquals(SplitLocation.NEITHER,
+                tree.split(CutAngle.createPositiveFacing(0, TEST_PRECISION)).getLocation());
+        Assert.assertEquals(SplitLocation.NEITHER,
+                tree.split(CutAngle.createNegativeFacing(Geometry.HALF_PI, TEST_PRECISION)).getLocation());
+        Assert.assertEquals(SplitLocation.NEITHER,
+                tree.split(CutAngle.createPositiveFacing(Geometry.PI, TEST_PRECISION)).getLocation());
+        Assert.assertEquals(SplitLocation.NEITHER,
+                tree.split(CutAngle.createNegativeFacing(Geometry.MINUS_HALF_PI, TEST_PRECISION)).getLocation());
+        Assert.assertEquals(SplitLocation.NEITHER,
+                tree.split(CutAngle.createPositiveFacing(Geometry.TWO_PI, TEST_PRECISION)).getLocation());
+    }
+
+    @Test
+    public void testSplit_full() {
+        // arrange
+        RegionBSPTree1S tree = RegionBSPTree1S.full();
+
+        // act/assert
+        checkSimpleSplit(
+                tree.split(CutAngle.createPositiveFacing(1e-6, TEST_PRECISION)),
+                AngularInterval.of(0, 1e-6, TEST_PRECISION),
+                AngularInterval.of(1e-6, Geometry.TWO_PI, TEST_PRECISION)
+            );
+        checkSimpleSplit(
+                tree.split(CutAngle.createNegativeFacing(Geometry.HALF_PI, TEST_PRECISION)),
+                AngularInterval.of(Geometry.HALF_PI, Geometry.TWO_PI, TEST_PRECISION),
+                AngularInterval.of(0, Geometry.HALF_PI, TEST_PRECISION)
+            );
+        checkSimpleSplit(
+                tree.split(CutAngle.createPositiveFacing(Geometry.PI, TEST_PRECISION)),
+                AngularInterval.of(0, Geometry.PI, TEST_PRECISION),
+                AngularInterval.of(Geometry.PI, Geometry.TWO_PI, TEST_PRECISION)
+            );
+        checkSimpleSplit(
+                tree.split(CutAngle.createNegativeFacing(Geometry.MINUS_HALF_PI, TEST_PRECISION)),
+                AngularInterval.of(Geometry.MINUS_HALF_PI, Geometry.TWO_PI, TEST_PRECISION),
+                AngularInterval.of(0, Geometry.MINUS_HALF_PI, TEST_PRECISION)
+            );
+        checkSimpleSplit(
+                tree.split(CutAngle.createPositiveFacing(Geometry.TWO_PI - 1e-6, TEST_PRECISION)),
+                AngularInterval.of(0, Geometry.TWO_PI - 1e-6, TEST_PRECISION),
+                AngularInterval.of(Geometry.TWO_PI - 1e-6, Geometry.TWO_PI, TEST_PRECISION)
+            );
+    }
+
+    @Test
+    public void testSplit_full_cutEquivalentToZero() {
+        // arrange
+        RegionBSPTree1S tree = RegionBSPTree1S.full();
+
+        AngularInterval twoPi = AngularInterval.of(0, Geometry.TWO_PI, TEST_PRECISION);
+
+        // act/assert
+        checkSimpleSplit(
+                tree.split(CutAngle.createPositiveFacing(0, TEST_PRECISION)),
+                null,
+                twoPi
+            );
+        checkSimpleSplit(
+                tree.split(CutAngle.createNegativeFacing(0, TEST_PRECISION)),
+                twoPi,
+                null
+            );
+
+        checkSimpleSplit(
+                tree.split(CutAngle.createPositiveFacing(Geometry.TWO_PI - 1e-18, TEST_PRECISION)),
+                null,
+                twoPi
+            );
+        checkSimpleSplit(
+                tree.split(CutAngle.createNegativeFacing(Geometry.TWO_PI - 1e-18, TEST_PRECISION)),
+                twoPi,
+                null
+            );
+    }
+
+    @Test
+    public void testSplit_singleInterval() {
+        // arrange
+        AngularInterval interval = AngularInterval.of(Geometry.HALF_PI, Geometry.MINUS_HALF_PI, TEST_PRECISION);
+        RegionBSPTree1S tree = interval.toTree();
+
+        // act
+        checkSimpleSplit(
+                tree.split(CutAngle.createNegativeFacing(0, TEST_PRECISION)),
+                interval,
+                null
+            );
+        checkSimpleSplit(
+                tree.split(CutAngle.createNegativeFacing(-Geometry.TWO_PI, TEST_PRECISION)),
+                interval,
+                null
+            );
+
+        checkSimpleSplit(
+                tree.split(CutAngle.createPositiveFacing(Geometry.TWO_PI + Geometry.HALF_PI, TEST_PRECISION)),
+                null,
+                interval
+            );
+        checkSimpleSplit(
+                tree.split(CutAngle.createPositiveFacing(1.5 * Geometry.PI, TEST_PRECISION)),
+                interval,
+                null
+            );
+
+        checkSimpleSplit(
+                tree.split(CutAngle.createNegativeFacing(Geometry.PI, TEST_PRECISION)),
+                AngularInterval.of(Geometry.PI, Geometry.MINUS_HALF_PI, TEST_PRECISION),
+                AngularInterval.of(Geometry.HALF_PI, Geometry.PI, TEST_PRECISION)
+            );
+    }
+
+    @Test
+    public void testSplit_singleIntervalSplitIntoTwoIntervalsOnSameSide() {
+        // arrange
+        RegionBSPTree1S tree = AngularInterval.of(Geometry.MINUS_HALF_PI, Geometry.HALF_PI, TEST_PRECISION).toTree();
+
+        CutAngle cut = CutAngle.createPositiveFacing(0, TEST_PRECISION);
+
+        // act
+        Split<RegionBSPTree1S> split = tree.split(cut);
+
+        // assert
+        Assert.assertEquals(SplitLocation.PLUS, split.getLocation());
+
+        RegionBSPTree1S minus = split.getMinus();
+        Assert.assertNull(minus);
+
+        RegionBSPTree1S plus = split.getPlus();
+        List<AngularInterval> plusIntervals = plus.toIntervals();
+        Assert.assertEquals(1, plusIntervals.size());
+        checkInterval(plusIntervals.get(0), Geometry.MINUS_HALF_PI, Geometry.HALF_PI);
+    }
+
+    @Test
+    public void testSplit_multipleRegions() {
+        // arrange
+        RegionBSPTree1S tree = RegionBSPTree1S.empty();
+        tree.add(AngularInterval.of(Geometry.TWO_PI - 1, Geometry.HALF_PI, TEST_PRECISION));
+        tree.add(AngularInterval.of(Geometry.PI, Geometry.MINUS_HALF_PI, TEST_PRECISION));
+
+        CutAngle cut = CutAngle.createNegativeFacing(1, TEST_PRECISION);
+
+        // act
+        Split<RegionBSPTree1S> split = tree.split(cut);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        RegionBSPTree1S minus = split.getMinus();
+        List<AngularInterval> minusIntervals = minus.toIntervals();
+        Assert.assertEquals(3, minusIntervals.size());
+        checkInterval(minusIntervals.get(0), 1, Geometry.HALF_PI);
+        checkInterval(minusIntervals.get(1), Geometry.PI, Geometry.MINUS_HALF_PI);
+        checkInterval(minusIntervals.get(2), Geometry.TWO_PI - 1, 0);
+
+        RegionBSPTree1S plus = split.getPlus();
+        List<AngularInterval> plusIntervals = plus.toIntervals();
+        Assert.assertEquals(1, plusIntervals.size());
+        checkInterval(plusIntervals.get(0), 0, 1);
+    }
+
+    @Test
+    public void testSplitDiameter_full() {
+        // arrange
+        RegionBSPTree1S full = RegionBSPTree1S.full();
+        CutAngle splitter = CutAngle.createPositiveFacing(Geometry.HALF_PI, TEST_PRECISION);
+
+        // act
+        Split<RegionBSPTree1S> split = full.splitDiameter(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        RegionBSPTree1S minus = split.getMinus();
+        List<AngularInterval> minusIntervals = minus.toIntervals();
+        Assert.assertEquals(1, minusIntervals.size());
+        checkInterval(minusIntervals.get(0), 1.5 * Geometry.PI, 2.5 * Geometry.PI);
+
+        RegionBSPTree1S plus = split.getPlus();
+        List<AngularInterval> plusIntervals = plus.toIntervals();
+        Assert.assertEquals(1, plusIntervals.size());
+        checkInterval(plusIntervals.get(0), Geometry.HALF_PI, 1.5 * Geometry.PI);
+    }
+
+    @Test
+    public void testSplitDiameter_empty() {
+        // arrange
+        RegionBSPTree1S empty = RegionBSPTree1S.empty();
+        CutAngle splitter = CutAngle.createPositiveFacing(Geometry.HALF_PI, TEST_PRECISION);
+
+        // act
+        Split<RegionBSPTree1S> split = empty.splitDiameter(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.NEITHER, split.getLocation());
+
+        RegionBSPTree1S minus = split.getMinus();
+        Assert.assertNull(minus);
+
+        RegionBSPTree1S plus = split.getPlus();
+        Assert.assertNull(plus);
+    }
+
+    @Test
+    public void testSplitDiameter_minus_zeroOnMinusSide() {
+        // arrange
+        RegionBSPTree1S tree = AngularInterval.of(0, 1, TEST_PRECISION).toTree();
+        CutAngle splitter = CutAngle.createPositiveFacing(1, TEST_PRECISION);
+
+        // act
+        Split<RegionBSPTree1S>split = tree.splitDiameter(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.MINUS, split.getLocation());
+
+        RegionBSPTree1S minus = split.getMinus();
+        List<AngularInterval> minusIntervals = minus.toIntervals();
+        Assert.assertEquals(1, minusIntervals.size());
+        checkInterval(minusIntervals.get(0), 0, 1);
+
+        RegionBSPTree1S plus = split.getPlus();
+        Assert.assertNull(plus);
+    }
+
+    @Test
+    public void testSplitDiameter_minus_zeroOnPlusSide() {
+        // arrange
+        RegionBSPTree1S tree = AngularInterval.of(1, 2, TEST_PRECISION).toTree();
+        CutAngle splitter = CutAngle.createNegativeFacing(0, TEST_PRECISION);
+
+        // act
+        Split<RegionBSPTree1S>split = tree.splitDiameter(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.MINUS, split.getLocation());
+
+        RegionBSPTree1S minus = split.getMinus();
+        List<AngularInterval> minusIntervals = minus.toIntervals();
+        Assert.assertEquals(1, minusIntervals.size());
+        checkInterval(minusIntervals.get(0), 1, 2);
+
+        RegionBSPTree1S plus = split.getPlus();
+        Assert.assertNull(plus);
+    }
+
+    @Test
+    public void testSplitDiameter_plus_zeroOnMinusSide() {
+        // arrange
+        RegionBSPTree1S tree = RegionBSPTree1S.empty();
+        tree.add(AngularInterval.of(1, 1.1, TEST_PRECISION));
+        tree.add(AngularInterval.of(2, 2.1, TEST_PRECISION));
+
+        CutAngle splitter = CutAngle.createPositiveFacing(1, TEST_PRECISION);
+
+        // act
+        Split<RegionBSPTree1S>split = tree.splitDiameter(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.PLUS, split.getLocation());
+
+        RegionBSPTree1S minus = split.getMinus();
+        Assert.assertNull(minus);
+
+        RegionBSPTree1S plus = split.getPlus();
+        List<AngularInterval> plusIntervals = plus.toIntervals();
+        Assert.assertEquals(2, plusIntervals.size());
+        checkInterval(plusIntervals.get(0), 1, 1.1);
+        checkInterval(plusIntervals.get(1), 2, 2.1);
+    }
+
+    @Test
+    public void testSplitDiameter_plus_zeroOnPlusSide() {
+        // arrange
+        RegionBSPTree1S tree = RegionBSPTree1S.empty();
+        tree.add(AngularInterval.of(1, 1.1, TEST_PRECISION));
+        tree.add(AngularInterval.of(2, 2.1, TEST_PRECISION));
+
+        CutAngle splitter = CutAngle.createNegativeFacing(Geometry.PI - 1, TEST_PRECISION);
+
+        // act
+        Split<RegionBSPTree1S>split = tree.splitDiameter(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.PLUS, split.getLocation());
+
+        RegionBSPTree1S minus = split.getMinus();
+        Assert.assertNull(minus);
+
+        RegionBSPTree1S plus = split.getPlus();
+        List<AngularInterval> plusIntervals = plus.toIntervals();
+        Assert.assertEquals(2, plusIntervals.size());
+        checkInterval(plusIntervals.get(0), 1, 1.1);
+        checkInterval(plusIntervals.get(1), 2, 2.1);
+    }
+
+    @Test
+    public void testSplitDiameter_both_zeroOnMinusSide() {
+        // arrange
+        RegionBSPTree1S tree = RegionBSPTree1S.empty();
+        tree.add(AngularInterval.of(1, 1.1, TEST_PRECISION));
+        tree.add(AngularInterval.of(2, 3, TEST_PRECISION));
+
+        CutAngle splitter = CutAngle.createPositiveFacing(2.5, TEST_PRECISION);
+
+        // act
+        Split<RegionBSPTree1S>split = tree.splitDiameter(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        RegionBSPTree1S minus = split.getMinus();
+        List<AngularInterval> plusIntervals = minus.toIntervals();
+        Assert.assertEquals(2, plusIntervals.size());
+        checkInterval(plusIntervals.get(0), 1, 1.1);
+        checkInterval(plusIntervals.get(1), 2, 2.5);
+
+        RegionBSPTree1S plus = split.getPlus();
+        List<AngularInterval> minusIntervals = plus.toIntervals();
+        Assert.assertEquals(1, minusIntervals.size());
+        checkInterval(minusIntervals.get(0), 2.5, 3);
+    }
+
+    @Test
+    public void testSplitDiameter_both_zeroOnPlusSide() {
+        // arrange
+        RegionBSPTree1S tree = RegionBSPTree1S.empty();
+        tree.add(AngularInterval.of(1, 1.1, TEST_PRECISION));
+        tree.add(AngularInterval.of(2, 3, TEST_PRECISION));
+
+        CutAngle splitter = CutAngle.createNegativeFacing(2.5, TEST_PRECISION);
+
+        // act
+        Split<RegionBSPTree1S>split = tree.splitDiameter(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        RegionBSPTree1S minus = split.getMinus();
+        List<AngularInterval> minusIntervals = minus.toIntervals();
+        Assert.assertEquals(1, minusIntervals.size());
+        checkInterval(minusIntervals.get(0), 2.5, 3);
+
+        RegionBSPTree1S plus = split.getPlus();
+        List<AngularInterval> plusIntervals = plus.toIntervals();
+        Assert.assertEquals(2, plusIntervals.size());
+        checkInterval(plusIntervals.get(0), 1, 1.1);
+        checkInterval(plusIntervals.get(1), 2, 2.5);
+    }
+
+    @Test
+    public void testRegionProperties_singleInterval_wrapsZero() {
+        // arrange
+        RegionBSPTree1S tree = AngularInterval.of(Geometry.MINUS_HALF_PI, Geometry.PI,
+                TEST_PRECISION).toTree();
+
+        // act/assert
+        Assert.assertEquals(1.5 * Geometry.PI, tree.getSize(), TEST_EPS);
+        Assert.assertEquals(0, tree.getBoundarySize(), TEST_EPS);
+        Assert.assertEquals(0.25 * Geometry.PI, tree.getBarycenter().getAzimuth(), TEST_EPS);
+    }
+
+    @Test
+    public void testRegionProperties_singleInterval_doesNotWrap() {
+        // arrange
+        RegionBSPTree1S tree = AngularInterval.of(Geometry.HALF_PI, Geometry.TWO_PI,
+                TEST_PRECISION).toTree();
+
+        // act/assert
+        Assert.assertEquals(1.5 * Geometry.PI, tree.getSize(), TEST_EPS);
+        Assert.assertEquals(0, tree.getBoundarySize(), TEST_EPS);
+        Assert.assertEquals(1.25 * Geometry.PI, tree.getBarycenter().getAzimuth(), TEST_EPS);
+    }
+
+    @Test
+    public void testRegionProperties_multipleIntervals_sameSize() {
+        // arrange
+        RegionBSPTree1S tree = RegionBSPTree1S.empty();
+        tree.add(AngularInterval.of(0, 0.1, TEST_PRECISION));
+        tree.add(AngularInterval.of(0.2, 0.3, TEST_PRECISION));
+
+        // act/assert
+        Assert.assertEquals(0.2, tree.getSize(), TEST_EPS);
+        Assert.assertEquals(0, tree.getBoundarySize(), TEST_EPS);
+        Assert.assertEquals(0.15, tree.getBarycenter().getAzimuth(), TEST_EPS);
+    }
+
+    @Test
+    public void testRegionProperties_multipleIntervals_differentSizes() {
+        // arrange
+        RegionBSPTree1S tree = RegionBSPTree1S.empty();
+        tree.add(AngularInterval.of(0, 0.2, TEST_PRECISION));
+        tree.add(AngularInterval.of(0.3, 0.7, TEST_PRECISION));
+
+        // act/assert
+        Assert.assertEquals(0.6, tree.getSize(), TEST_EPS);
+        Assert.assertEquals(0, tree.getBoundarySize(), TEST_EPS);
+        Assert.assertEquals(2.2 / 6, tree.getBarycenter().getAzimuth(), TEST_EPS);
+    }
+
+    @Test
+    public void testTransform_fullAndEmpty() {
+        // arrange
+        RegionBSPTree1S full = RegionBSPTree1S.full();
+        RegionBSPTree1S empty = RegionBSPTree1S.empty();
+
+        // act
+        full.transform(PI_MINUS_AZ);
+        empty.transform(HALF_PI_PLUS_AZ);
+
+        // assert
+        Assert.assertTrue(full.isFull());
+        Assert.assertFalse(full.isEmpty());
+
+        Assert.assertFalse(empty.isFull());
+        Assert.assertTrue(empty.isEmpty());
+    }
+
+    @Test
+    public void testTransform_halfPiPlusAz() {
+        // arrange
+        RegionBSPTree1S tree = RegionBSPTree1S.empty();
+        tree.add(AngularInterval.of(-1, 1, TEST_PRECISION));
+        tree.add(AngularInterval.of(2, 3, TEST_PRECISION));
+
+        // act
+        tree.transform(HALF_PI_PLUS_AZ);
+
+        // assert
+        Assert.assertEquals(3, tree.getSize(), TEST_EPS);
+
+        List<AngularInterval> intervals = tree.toIntervals();
+
+        Assert.assertEquals(2, intervals.size());
+        checkInterval(intervals.get(0), Geometry.HALF_PI - 1, Geometry.HALF_PI + 1);
+        checkInterval(intervals.get(1), Geometry.HALF_PI + 2, Geometry.HALF_PI + 3);
+    }
+
+    @Test
+    public void testTransform_piMinusAz() {
+        // arrange
+        RegionBSPTree1S tree = RegionBSPTree1S.empty();
+        tree.add(AngularInterval.of(-1, 1, TEST_PRECISION));
+        tree.add(AngularInterval.of(2, 3, TEST_PRECISION));
+
+        // act
+        tree.transform(PI_MINUS_AZ);
+
+        // assert
+        Assert.assertEquals(3, tree.getSize(), TEST_EPS);
+
+        List<AngularInterval> intervals = tree.toIntervals();
+
+        Assert.assertEquals(2, intervals.size());
+        checkInterval(intervals.get(0), Geometry.PI - 3, Geometry.PI - 2);
+        checkInterval(intervals.get(1), Geometry.PI - 1, Geometry.PI + 1);
+    }
+
+    @Test
+    public void testProject_fullAndEmpty() {
+        // arrange
+        RegionBSPTree1S full = RegionBSPTree1S.full();
+        RegionBSPTree1S empty = RegionBSPTree1S.empty();
+
+        // act/assert
+        Assert.assertNull(full.project(Point1S.ZERO));
+        Assert.assertNull(full.project(Point1S.PI));
+
+        Assert.assertNull(empty.project(Point1S.ZERO));
+        Assert.assertNull(empty.project(Point1S.PI));
+    }
+
+    @Test
+    public void testProject_withIntervals() {
+        // arrange
+        RegionBSPTree1S tree = RegionBSPTree1S.empty();
+        tree.add(AngularInterval.of(Geometry.MINUS_HALF_PI, Geometry.HALF_PI, TEST_PRECISION));
+        tree.add(AngularInterval.of(Geometry.PI - 1, Geometry.PI + 1, TEST_PRECISION));
+
+        // act/assert
+        Assert.assertEquals(Geometry.MINUS_HALF_PI,
+                tree.project(Point1S.of(Geometry.MINUS_HALF_PI - 0.1)).getAzimuth(), TEST_EPS);
+        Assert.assertEquals(Geometry.MINUS_HALF_PI,
+                tree.project(Point1S.of(Geometry.MINUS_HALF_PI)).getAzimuth(), TEST_EPS);
+        Assert.assertEquals(Geometry.MINUS_HALF_PI,
+                tree.project(Point1S.of(Geometry.MINUS_HALF_PI + 0.1)).getAzimuth(), TEST_EPS);
+
+        Assert.assertEquals(Geometry.MINUS_HALF_PI, tree.project(Point1S.of(-0.1)).getAzimuth(), TEST_EPS);
+        Assert.assertEquals(Geometry.HALF_PI, tree.project(Point1S.ZERO).getAzimuth(), TEST_EPS);
+        Assert.assertEquals(Geometry.HALF_PI, tree.project(Point1S.of(0.1)).getAzimuth(), TEST_EPS);
+
+        Assert.assertEquals(Geometry.PI - 1,
+                tree.project(Point1S.of(Geometry.PI - 0.5)).getAzimuth(), TEST_EPS);
+        Assert.assertEquals(Geometry.PI + 1,
+                tree.project(Point1S.of(Geometry.PI + 0.5)).getAzimuth(), TEST_EPS);
+    }
+
+    @Test
+    public void testProject_equidistant() {
+        // arrange
+        RegionBSPTree1S tree = AngularInterval.of(1, 2, TEST_PRECISION).toTree();
+        RegionBSPTree1S treeComplement = tree.copy();
+        treeComplement.complement();
+
+        // act/assert
+        Assert.assertEquals(1, tree.project(Point1S.of(1.5)).getAzimuth(), TEST_EPS);
+        Assert.assertEquals(1, treeComplement.project(Point1S.of(1.5)).getAzimuth(), TEST_EPS);
+    }
+
+    @Test
+    public void testProject_intervalAroundZero_closerOnMinSide() {
+        // arrange
+        double start = -1;
+        double end = 0.5;
+        RegionBSPTree1S tree = AngularInterval.of(start, end, TEST_PRECISION).toTree();
+
+        // act/assert
+        Assert.assertEquals(end, tree.project(Point1S.of(-1.5 * Geometry.PI)).getAzimuth(), TEST_EPS);
+        Assert.assertEquals(start, tree.project(Point1S.of(-Geometry.PI)).getAzimuth(), TEST_EPS);
+        Assert.assertEquals(start, tree.project(Point1S.of(-0.5 * Geometry.PI)).getAzimuth(), TEST_EPS);
+        Assert.assertEquals(start, tree.project(Point1S.of(-1)).getAzimuth(), TEST_EPS);
+        Assert.assertEquals(start, tree.project(Point1S.of(-0.5)).getAzimuth(), TEST_EPS);
+        Assert.assertEquals(end, tree.project(Point1S.of(-0.25)).getAzimuth(), TEST_EPS);
+        Assert.assertEquals(end, tree.project(Point1S.of(-0.1)).getAzimuth(), TEST_EPS);
+        Assert.assertEquals(end, tree.project(Point1S.ZERO).getAzimuth(), TEST_EPS);
+        Assert.assertEquals(end, tree.project(Point1S.of(0.1)).getAzimuth(), TEST_EPS);
+        Assert.assertEquals(end, tree.project(Point1S.of(0.25)).getAzimuth(), TEST_EPS);
+        Assert.assertEquals(end, tree.project(Point1S.of(0.5)).getAzimuth(), TEST_EPS);
+        Assert.assertEquals(end, tree.project(Point1S.of(0.75)).getAzimuth(), TEST_EPS);
+    }
+
+    @Test
+    public void testProject_intervalAroundZero_closerOnMaxSide() {
+        // arrange
+        double start = -0.5;
+        double end = 1;
+        RegionBSPTree1S tree = AngularInterval.of(start, end, TEST_PRECISION).toTree();
+
+        // act/assert
+        Assert.assertEquals(end, tree.project(Point1S.of(-1.5 * Geometry.PI)).getAzimuth(), TEST_EPS);
+        Assert.assertEquals(end, tree.project(Point1S.of(-Geometry.PI)).getAzimuth(), TEST_EPS);
+        Assert.assertEquals(start, tree.project(Point1S.of(-0.5 * Geometry.PI)).getAzimuth(), TEST_EPS);
+        Assert.assertEquals(start, tree.project(Point1S.of(-1)).getAzimuth(), TEST_EPS);
+        Assert.assertEquals(start, tree.project(Point1S.of(-0.5)).getAzimuth(), TEST_EPS);
+        Assert.assertEquals(start, tree.project(Point1S.of(-0.25)).getAzimuth(), TEST_EPS);
+        Assert.assertEquals(start, tree.project(Point1S.of(-0.1)).getAzimuth(), TEST_EPS);
+        Assert.assertEquals(start, tree.project(Point1S.ZERO).getAzimuth(), TEST_EPS);
+        Assert.assertEquals(start, tree.project(Point1S.of(0.1)).getAzimuth(), TEST_EPS);
+        Assert.assertEquals(end, tree.project(Point1S.of(0.25)).getAzimuth(), TEST_EPS);
+        Assert.assertEquals(end, tree.project(Point1S.of(0.5)).getAzimuth(), TEST_EPS);
+        Assert.assertEquals(end, tree.project(Point1S.of(0.75)).getAzimuth(), TEST_EPS);
+    }
+
+    private static void checkSimpleSplit(Split<RegionBSPTree1S> split, AngularInterval minusInterval,
+            AngularInterval plusInterval) {
+
+        RegionBSPTree1S minus = split.getMinus();
+        if (minusInterval != null) {
+            Assert.assertNotNull("Expected minus region to not be null", minus);
+            checkSingleInterval(minus, minusInterval.getMin(), minusInterval.getMax());
+        }
+        else {
+            Assert.assertNull("Expected minus region to be null", minus);
+        }
+
+        RegionBSPTree1S plus = split.getPlus();
+        if (plusInterval != null) {
+            Assert.assertNotNull("Expected plus region to not be null", plus);
+            checkSingleInterval(plus, plusInterval.getMin(), plusInterval.getMax());
+        }
+        else {
+            Assert.assertNull("Expected plus region to be null", plus);
+        }
+    }
+
+    private static void checkSingleInterval(RegionBSPTree1S tree, double min, double max) {
+        List<AngularInterval> intervals = tree.toIntervals();
+
+        Assert.assertEquals("Expected a single interval in the tree", 1, intervals.size());
+
+        checkInterval(intervals.get(0), min, max);
+    }
+
+    private static void checkInterval(AngularInterval interval, double min, double max) {
+        double normalizedMin = PlaneAngleRadians.normalizeBetweenZeroAndTwoPi(min);
+        double normalizedMax = PlaneAngleRadians.normalizeBetweenZeroAndTwoPi(max);
+
+        if (TEST_PRECISION.eq(normalizedMin, normalizedMax)) {
+            Assert.assertTrue(interval.isFull());
+        }
+        else {
+            Assert.assertEquals(normalizedMin,
+                    interval.getMinBoundary().getPoint().getNormalizedAzimuth(), TEST_EPS);
+            Assert.assertEquals(normalizedMax,
+                    interval.getMaxBoundary().getPoint().getNormalizedAzimuth(), TEST_EPS);
+        }
+    }
+
+    private static void checkClassify(Region<Point1S> region, RegionLocation loc, double ... pts) {
+        for (double pt : pts) {
+            Assert.assertEquals("Unexpected location for point " + pt, loc, region.classify(Point1S.of(pt)));
+        }
+    }
+}
diff --git a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/oned/S1PointTest.java b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/oned/S1PointTest.java
deleted file mode 100644
index f67fc50..0000000
--- a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/oned/S1PointTest.java
+++ /dev/null
@@ -1,86 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.oned;
-
-import org.apache.commons.geometry.core.Geometry;
-import org.junit.Assert;
-import org.junit.Test;
-
-public class S1PointTest {
-
-    private static final double EPS = 1e-10;
-
-    @Test
-    public void testS1Point() {
-        for (int k = -2; k < 3; ++k) {
-            S1Point p = S1Point.of(1.0 + k * Geometry.TWO_PI);
-            Assert.assertEquals(Math.cos(1.0), p.getVector().getX(), EPS);
-            Assert.assertEquals(Math.sin(1.0), p.getVector().getY(), EPS);
-            Assert.assertFalse(p.isNaN());
-        }
-    }
-
-    @Test
-    public void testNaN() {
-        Assert.assertTrue(S1Point.NaN.isNaN());
-        Assert.assertTrue(S1Point.NaN.equals(S1Point.of(Double.NaN)));
-        Assert.assertFalse(S1Point.of(1.0).equals(S1Point.NaN));
-    }
-
-    @Test
-    public void testEquals() {
-        S1Point a = S1Point.of(1.0);
-        S1Point b = S1Point.of(1.0);
-        Assert.assertEquals(a.hashCode(), b.hashCode());
-        Assert.assertFalse(a == b);
-        Assert.assertTrue(a.equals(b));
-        Assert.assertTrue(a.equals(a));
-        Assert.assertFalse(a.equals('a'));
-    }
-
-    @Test
-    public void testDistance() {
-        S1Point a = S1Point.of(1.0);
-        S1Point b = S1Point.of(a.getAzimuth() + 0.5 * Math.PI);
-        Assert.assertEquals(0.5 * Math.PI, a.distance(b), 1.0e-10);
-    }
-
-    @Test
-    public void testToString() {
-        // act/assert
-        Assert.assertEquals("(0.0)", S1Point.of(0.0).toString());
-        Assert.assertEquals("(1.0)", S1Point.of(1.0).toString());
-    }
-
-    @Test
-    public void testParse() {
-        // act/assert
-        checkPoint(S1Point.parse("(0)"), 0.0);
-        checkPoint(S1Point.parse("(1)"), 1.0);
-    }
-
-    @Test(expected = IllegalArgumentException.class)
-    public void testParse_failure() {
-        // act/assert
-        S1Point.parse("abc");
-    }
-
-    private void checkPoint(S1Point p, double alpha) {
-        Assert.assertEquals(alpha, p.getAzimuth(), EPS);
-    }
-
-}
diff --git a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/oned/Transform1STest.java b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/oned/Transform1STest.java
new file mode 100644
index 0000000..7248ce7
--- /dev/null
+++ b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/oned/Transform1STest.java
@@ -0,0 +1,254 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.oned;
+
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.GeometryTestUtils;
+import org.apache.commons.geometry.spherical.SphericalTestUtils;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class Transform1STest {
+
+    private static final double TEST_EPS = 1e-10;
+
+    private static final Point1S ZERO = Point1S.ZERO;
+
+    private static final Point1S HALF_PI = Point1S.of(Geometry.HALF_PI);
+
+    private static final Point1S PI = Point1S.of(Geometry.PI);
+
+    private static final Point1S MINUS_HALF_PI = Point1S.of(Geometry.MINUS_HALF_PI);
+
+    @Test
+    public void testIdentity() {
+        // act
+        Transform1S t = Transform1S.identity();
+
+        // assert
+        Assert.assertTrue(t.preservesOrientation());
+        Assert.assertFalse(t.isNegation());
+        Assert.assertEquals(0, t.getRotation(), TEST_EPS);
+
+        SphericalTestUtils.assertPointsEqual(ZERO, t.apply(ZERO), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(HALF_PI, t.apply(HALF_PI), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(PI, t.apply(PI), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(MINUS_HALF_PI, t.apply(MINUS_HALF_PI), TEST_EPS);
+
+        checkInverse(t);
+    }
+
+    @Test
+    public void testRotate_positive() {
+        // arrange
+        Transform1S t = Transform1S.createRotation(Geometry.HALF_PI);
+
+        // act/assert
+        Assert.assertTrue(t.preservesOrientation());
+        Assert.assertFalse(t.isNegation());
+        Assert.assertEquals(Geometry.HALF_PI, t.getRotation(), TEST_EPS);
+
+        SphericalTestUtils.assertPointsEqual(HALF_PI, t.apply(ZERO), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(PI, t.apply(HALF_PI), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point1S.of(1.5 * Geometry.PI), t.apply(PI), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point1S.ZERO, t.apply(MINUS_HALF_PI), TEST_EPS);
+
+        checkInverse(t);
+    }
+
+    @Test
+    public void testRotate_negative() {
+        // arrange
+        Transform1S t = Transform1S.createRotation(-Geometry.HALF_PI);
+
+        // act/assert
+        Assert.assertTrue(t.preservesOrientation());
+        Assert.assertFalse(t.isNegation());
+        Assert.assertEquals(-Geometry.HALF_PI, t.getRotation(), TEST_EPS);
+
+        SphericalTestUtils.assertPointsEqual(MINUS_HALF_PI, t.apply(ZERO), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(ZERO, t.apply(HALF_PI), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(HALF_PI, t.apply(PI), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point1S.of(-Geometry.PI), t.apply(MINUS_HALF_PI), TEST_EPS);
+
+        checkInverse(t);
+    }
+
+    @Test
+    public void testNegate() {
+        // arrange
+        Transform1S t = Transform1S.createNegation();
+
+        // act/assert
+        Assert.assertFalse(t.preservesOrientation());
+        Assert.assertTrue(t.isNegation());
+        Assert.assertEquals(0, t.getRotation(), TEST_EPS);
+
+        SphericalTestUtils.assertPointsEqual(ZERO, t.apply(ZERO), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(MINUS_HALF_PI, t.apply(HALF_PI), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point1S.of(-Geometry.PI), t.apply(PI), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(HALF_PI, t.apply(MINUS_HALF_PI), TEST_EPS);
+
+        checkInverse(t);
+    }
+
+    @Test
+    public void testNegateThenRotate() {
+        // arrange
+        Transform1S t = Transform1S.createNegation().rotate(Geometry.HALF_PI);
+
+        // act/assert
+        Assert.assertFalse(t.preservesOrientation());
+        Assert.assertTrue(t.isNegation());
+        Assert.assertEquals(Geometry.HALF_PI, t.getRotation(), TEST_EPS);
+
+        SphericalTestUtils.assertPointsEqual(HALF_PI, t.apply(ZERO), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(ZERO, t.apply(HALF_PI), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(MINUS_HALF_PI, t.apply(PI), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(PI, t.apply(MINUS_HALF_PI), TEST_EPS);
+
+        checkInverse(t);
+    }
+
+    @Test
+    public void testRotateThenNegate() {
+        // arrange
+        Transform1S t = Transform1S.createRotation(Geometry.HALF_PI).negate();
+
+        // act/assert
+        Assert.assertFalse(t.preservesOrientation());
+        Assert.assertTrue(t.isNegation());
+        Assert.assertEquals(-Geometry.HALF_PI, t.getRotation(), TEST_EPS);
+
+        SphericalTestUtils.assertPointsEqual(MINUS_HALF_PI, t.apply(ZERO), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point1S.of(-Geometry.PI), t.apply(HALF_PI), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point1S.of(-1.5 * Geometry.PI), t.apply(PI), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(ZERO, t.apply(MINUS_HALF_PI), TEST_EPS);
+
+        checkInverse(t);
+    }
+
+    @Test
+    public void testMultiply() {
+        // arrange
+        Transform1S neg = Transform1S.identity().negate();
+        Transform1S rot = Transform1S.identity().rotate(Geometry.HALF_PI);
+
+        // act
+        Transform1S t = rot.multiply(neg);
+
+        // assert
+        Assert.assertFalse(t.preservesOrientation());
+        Assert.assertTrue(t.isNegation());
+        Assert.assertEquals(Geometry.HALF_PI, t.getRotation(), TEST_EPS);
+
+        SphericalTestUtils.assertPointsEqual(HALF_PI, t.apply(ZERO), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(ZERO, t.apply(HALF_PI), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(MINUS_HALF_PI, t.apply(PI), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(PI, t.apply(MINUS_HALF_PI), TEST_EPS);
+
+        checkInverse(t);
+    }
+
+    @Test
+    public void testPreultiply() {
+        // arrange
+        Transform1S neg = Transform1S.identity().negate();
+        Transform1S rot = Transform1S.identity().rotate(Geometry.HALF_PI);
+
+        // act
+        Transform1S t = neg.premultiply(rot);
+
+        // assert
+        Assert.assertFalse(t.preservesOrientation());
+        Assert.assertTrue(t.isNegation());
+        Assert.assertEquals(Geometry.HALF_PI, t.getRotation(), TEST_EPS);
+
+        SphericalTestUtils.assertPointsEqual(HALF_PI, t.apply(ZERO), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(ZERO, t.apply(HALF_PI), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(MINUS_HALF_PI, t.apply(PI), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(PI, t.apply(MINUS_HALF_PI), TEST_EPS);
+
+        checkInverse(t);
+    }
+
+    @Test
+    public void testHashCode() {
+        // arrange
+        Transform1S a = Transform1S.identity().negate().rotate(Geometry.HALF_PI);
+        Transform1S b = Transform1S.identity().rotate(Geometry.HALF_PI);
+        Transform1S c = Transform1S.identity().negate().rotate(-Geometry.HALF_PI);
+        Transform1S d = Transform1S.identity().negate().rotate(Geometry.HALF_PI);
+
+        // act/assert
+        Assert.assertTrue(a.equals(a));
+
+        Assert.assertFalse(a.equals(null));
+        Assert.assertFalse(a.equals(new Object()));
+
+        Assert.assertFalse(a.equals(b));
+        Assert.assertFalse(a.equals(c));
+
+        Assert.assertTrue(a.equals(d));
+        Assert.assertTrue(d.equals(a));
+    }
+
+    @Test
+    public void testEquals() {
+        // arrange
+        Transform1S a = Transform1S.identity().negate().rotate(Geometry.HALF_PI);
+        Transform1S b = Transform1S.identity().rotate(Geometry.HALF_PI);
+        Transform1S c = Transform1S.identity().negate().rotate(-Geometry.HALF_PI);
+        Transform1S d = Transform1S.identity().negate().rotate(Geometry.HALF_PI);
+
+        // act
+        int hash = a.hashCode();
+
+        // assert
+        Assert.assertEquals(hash, a.hashCode());
+
+        Assert.assertNotEquals(hash, b.hashCode());
+        Assert.assertNotEquals(hash, c.hashCode());
+
+        Assert.assertEquals(hash, d.hashCode());
+    }
+
+    @Test
+    public void testToString() {
+        // arrange
+        Transform1S t = Transform1S.identity().negate().rotate(1);
+
+        // act
+        String str = t.toString();
+
+        // assert
+        GeometryTestUtils.assertContains("Transform1S", str);
+        GeometryTestUtils.assertContains("negate= true", str);
+        GeometryTestUtils.assertContains("rotate= 1", str);
+    }
+
+    private static void checkInverse(Transform1S t) {
+        Transform1S inv = t.inverse();
+
+        for (double x = -Geometry.TWO_PI; x <= 2 * Geometry.TWO_PI; x += 0.2) {
+            Point1S pt = Point1S.of(x);
+
+            SphericalTestUtils.assertPointsEqual(pt, inv.apply(t.apply(pt)), TEST_EPS);
+            SphericalTestUtils.assertPointsEqual(pt, t.apply(inv.apply(pt)), TEST_EPS);
+        }
+    }
+}
diff --git a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/AbstractGreatArcPathConnectorTest.java b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/AbstractGreatArcPathConnectorTest.java
new file mode 100644
index 0000000..76ea922
--- /dev/null
+++ b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/AbstractGreatArcPathConnectorTest.java
@@ -0,0 +1,305 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.twod;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.threed.Vector3D;
+import org.apache.commons.geometry.spherical.SphericalTestUtils;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class AbstractGreatArcPathConnectorTest {
+
+    private static final double TEST_EPS = 1e-10;
+
+    private static final DoublePrecisionContext TEST_PRECISION =
+            new EpsilonDoublePrecisionContext(TEST_EPS);
+
+    private static final GreatCircle XY_PLANE = GreatCircle.fromPoleAndU(
+            Vector3D.Unit.PLUS_Z, Vector3D.Unit.PLUS_X, TEST_PRECISION);
+
+    private static final GreatCircle XZ_PLANE = GreatCircle.fromPoleAndU(
+            Vector3D.Unit.PLUS_Y, Vector3D.Unit.PLUS_X, TEST_PRECISION);
+
+    private TestConnector connector = new TestConnector();
+
+    @Test
+    public void testConnectAll_emptyCollection() {
+        // act
+        List<GreatArcPath> paths = connector.connectAll(Collections.emptyList());
+
+        // assert
+        Assert.assertEquals(0, paths.size());
+    }
+
+    @Test
+    public void testConnectAll_singleFullArc() {
+        // act
+        connector.add(Arrays.asList(XY_PLANE.span()));
+        List<GreatArcPath> paths = connector.connectAll();
+
+        // assert
+        Assert.assertEquals(1, paths.size());
+
+        GreatArcPath a = paths.get(0);
+        Assert.assertEquals(1, a.getArcs().size());
+        Assert.assertSame(XY_PLANE, a.getStartArc().getCircle());
+    }
+
+    @Test
+    public void testConnectAll_twoFullArcs() {
+        // act
+        connector.add(XZ_PLANE.span());
+        List<GreatArcPath> paths = connector.connectAll(Arrays.asList(XY_PLANE.span()));
+
+        // assert
+        Assert.assertEquals(2, paths.size());
+
+        GreatArcPath a = paths.get(0);
+        Assert.assertEquals(1, a.getArcs().size());
+        Assert.assertSame(XY_PLANE, a.getStartArc().getCircle());
+
+        GreatArcPath b = paths.get(1);
+        Assert.assertEquals(1, b.getArcs().size());
+        Assert.assertSame(XZ_PLANE, b.getStartArc().getCircle());
+    }
+
+    @Test
+    public void testConnectAll_singleLune() {
+        // arrange
+        GreatCircle upperBound = GreatCircle.fromPoleAndU(
+                Vector3D.of(0, 1, -1), Vector3D.Unit.PLUS_X , TEST_PRECISION);
+
+        connector.add(XY_PLANE.arc(0, Geometry.PI));
+        connector.add(upperBound.arc(Geometry.PI, 0));
+
+        // act
+        List<GreatArcPath> paths = connector.connectAll();
+
+        // assert
+        Assert.assertEquals(1, paths.size());
+
+        GreatArcPath a = paths.get(0);
+        Assert.assertEquals(2, a.getArcs().size());
+        Assert.assertSame(XY_PLANE, a.getStartArc().getCircle());
+        Assert.assertSame(upperBound, a.getEndArc().getCircle());
+    }
+
+    @Test
+    public void testConnectAll_singleLune_pathsNotOrientedCorrectly() {
+        // arrange
+        GreatCircle upperBound = GreatCircle.fromPoleAndU(
+                Vector3D.of(0, 1, -1), Vector3D.Unit.PLUS_X , TEST_PRECISION);
+
+        connector.add(XY_PLANE.arc(0, Geometry.PI));
+        connector.add(upperBound.arc(0, Geometry.PI));
+
+        // act
+        List<GreatArcPath> paths = connector.connectAll();
+
+        // assert
+        Assert.assertEquals(2, paths.size());
+
+        GreatArcPath a = paths.get(0);
+        Assert.assertEquals(1, a.getArcs().size());
+        Assert.assertSame(XY_PLANE, a.getStartArc().getCircle());
+
+        GreatArcPath b = paths.get(1);
+        Assert.assertEquals(1, b.getArcs().size());
+        Assert.assertSame(upperBound, b.getStartArc().getCircle());
+    }
+
+    @Test
+    public void testConnectAll_largeTriangle() {
+        // arrange
+        Point2S p1 = Point2S.PLUS_I;
+        Point2S p2 = Point2S.PLUS_J;
+        Point2S p3 = Point2S.PLUS_K;
+
+        // act
+        List<GreatArcPath> paths = connector.connectAll(Arrays.asList(
+                    GreatArc.fromPoints(p1, p2, TEST_PRECISION),
+                    GreatArc.fromPoints(p2, p3, TEST_PRECISION),
+                    GreatArc.fromPoints(p3, p1, TEST_PRECISION)
+                ));
+
+        // assert
+        Assert.assertEquals(1, paths.size());
+
+        GreatArcPath a = paths.get(0);
+        Assert.assertEquals(3, a.getArcs().size());
+
+        assertPathPoints(a, p3, p1, p2, p3);
+    }
+
+    @Test
+    public void testConnectAll_smallTriangleWithDisconnectedLuneAndArc() {
+        // arrange
+        Point2S p1 = Point2S.of(0, 0);
+        Point2S p2 = Point2S.of(0, 0.1 * Geometry.PI);
+        Point2S p3 = Point2S.of(0.1, 0.1 * Geometry.PI);
+
+        GreatArc luneEdge1 = GreatCircle.fromPoints(
+                    Point2S.PLUS_J,
+                    Point2S.MINUS_I,
+                    TEST_PRECISION)
+                .arc(0, Geometry.PI);
+        GreatArc luneEdge2 = GreatCircle.fromPoints(
+                    Point2S.MINUS_J,
+                    Point2S.of(Geometry.HALF_PI, 0.4 * Geometry.PI),
+                    TEST_PRECISION)
+                .arc(0, Geometry.PI);
+
+        GreatArc separateArc = GreatArc.fromPoints(
+                Point2S.of(Geometry.MINUS_HALF_PI, 0.7 * Geometry.PI),
+                Point2S.of(Geometry.MINUS_HALF_PI, 0.8 * Geometry.PI),
+                TEST_PRECISION);
+
+        // act
+        List<GreatArcPath> paths = connector.connectAll(Arrays.asList(
+                    luneEdge1,
+                    GreatArc.fromPoints(p2, p3, TEST_PRECISION),
+                    separateArc,
+                    GreatArc.fromPoints(p1, p2, TEST_PRECISION),
+                    GreatArc.fromPoints(p3, p1, TEST_PRECISION),
+                    luneEdge2
+                ));
+
+        // assert
+        Assert.assertEquals(3, paths.size());
+
+        GreatArcPath triangle = paths.get(0);
+        Assert.assertEquals(3, triangle.getArcs().size());
+        assertPathPoints(triangle, p1, p2, p3, p1);
+
+        GreatArcPath lune = paths.get(1);
+        Assert.assertEquals(2, lune.getArcs().size());
+        Assert.assertSame(luneEdge1, lune.getStartArc());
+        Assert.assertSame(luneEdge2, lune.getEndArc());
+
+        GreatArcPath separate = paths.get(2);
+        Assert.assertEquals(1, separate.getArcs().size());
+        Assert.assertSame(separateArc, separate.getStartArc());
+    }
+
+    @Test
+    public void testConnectAll_choosesBestPointLikeConnection() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-1);
+
+        Point2S p1 = Point2S.PLUS_I;
+        Point2S p2 = Point2S.of(1, Geometry.HALF_PI);
+        Point2S p3 = Point2S.of(1.001, 0.491 * Geometry.PI);
+        Point2S p4 = Point2S.of(1.001, 0.502 * Geometry.PI);
+
+        connector.add(GreatArc.fromPoints(p2, p3, TEST_PRECISION));
+        connector.add(GreatArc.fromPoints(p2, p4, TEST_PRECISION));
+        connector.add(GreatArc.fromPoints(p1, p2, precision));
+
+        // act
+        List<GreatArcPath> paths = connector.connectAll();
+
+        // assert
+        Assert.assertEquals(2, paths.size());
+
+        GreatArcPath a = paths.get(0);
+        Assert.assertEquals(2, a.getArcs().size());
+        assertPathPoints(a, p1, p2, p4);
+
+        GreatArcPath b = paths.get(1);
+        Assert.assertEquals(1, b.getArcs().size());
+        assertPathPoints(b, p2, p3);
+    }
+
+    @Test
+    public void testConnect() {
+        // arrange
+        GreatArc arcA = GreatArc.fromPoints(Point2S.PLUS_I, Point2S.PLUS_J, TEST_PRECISION);
+        GreatArc arcB = GreatArc.fromPoints(Point2S.PLUS_J, Point2S.MINUS_I, TEST_PRECISION);
+        GreatArc arcC = GreatArc.fromPoints(Point2S.PLUS_J, Point2S.PLUS_K, TEST_PRECISION);
+
+        // act
+        connector.connect(Arrays.asList(
+                    arcB,
+                    arcA
+                ));
+
+        connector.connect(Arrays.asList(arcC));
+
+        List<GreatArcPath> paths = connector.connectAll();
+
+        // assert
+        Assert.assertEquals(2, paths.size());
+
+        GreatArcPath a = paths.get(0);
+        Assert.assertEquals(2, a.getArcs().size());
+        assertPathPoints(a, Point2S.PLUS_I, Point2S.PLUS_J, Point2S.MINUS_I);
+
+        GreatArcPath b = paths.get(1);
+        Assert.assertEquals(1, b.getArcs().size());
+        assertPathPoints(b, Point2S.PLUS_J, Point2S.PLUS_K);
+    }
+
+    @Test
+    public void testConnectorCanBeReused() {
+        // arrange
+        GreatArc a = GreatArc.fromPoints(Point2S.PLUS_I, Point2S.PLUS_J, TEST_PRECISION);
+        GreatArc b = GreatArc.fromPoints(Point2S.MINUS_I, Point2S.MINUS_J, TEST_PRECISION);
+
+        // act
+        List<GreatArcPath> path1 = connector.connectAll(Arrays.asList(a));
+        List<GreatArcPath> path2 = connector.connectAll(Arrays.asList(b));
+
+        // assert
+        Assert.assertEquals(1, path1.size());
+        assertPathPoints(path1.get(0), Point2S.PLUS_I, Point2S.PLUS_J);
+
+        Assert.assertEquals(1, path2.size());
+        assertPathPoints(path2.get(0), Point2S.MINUS_I, Point2S.MINUS_J);
+    }
+
+    private static void assertPathPoints(GreatArcPath path, Point2S ... points) {
+        List<Point2S> expectedPoints = Arrays.asList(points);
+        List<Point2S> actualPoints = path.getVertices();
+
+        String msg = "Expected path points to equal " + expectedPoints + " but was " + actualPoints;
+        Assert.assertEquals(msg, expectedPoints.size(), actualPoints.size());
+
+        for (int i=0; i<expectedPoints.size(); ++i) {
+            SphericalTestUtils.assertPointsEq(expectedPoints.get(i), actualPoints.get(i), TEST_EPS);
+        }
+    }
+
+    private static class TestConnector extends AbstractGreatArcConnector {
+
+        private static final long serialVersionUID = 1L;
+
+        @Override
+        protected ConnectableGreatArc selectConnection(ConnectableGreatArc incoming,
+                List<ConnectableGreatArc> outgoing) {
+
+            // just choose the first element
+            return outgoing.get(0);
+        }
+    }
+}
diff --git a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/CircleTest.java b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/CircleTest.java
deleted file mode 100644
index b81a44b..0000000
--- a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/CircleTest.java
+++ /dev/null
@@ -1,191 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.twod;
-
-import org.apache.commons.geometry.core.Geometry;
-import org.apache.commons.geometry.core.partitioning.Transform;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
-import org.apache.commons.geometry.euclidean.threed.Vector3D;
-import org.apache.commons.geometry.euclidean.threed.rotation.QuaternionRotation;
-import org.apache.commons.geometry.spherical.oned.Arc;
-import org.apache.commons.geometry.spherical.oned.LimitAngle;
-import org.apache.commons.geometry.spherical.oned.S1Point;
-import org.apache.commons.geometry.spherical.oned.SubLimitAngle;
-import org.apache.commons.rng.UniformRandomProvider;
-import org.apache.commons.rng.sampling.UnitSphereSampler;
-import org.apache.commons.rng.simple.RandomSource;
-import org.junit.Assert;
-import org.junit.Test;
-
-
-public class CircleTest {
-
-    private static final double TEST_EPS = 1e-10;
-
-    private static final DoublePrecisionContext TEST_PRECISION =
-            new EpsilonDoublePrecisionContext(TEST_EPS);
-
-    @Test
-    public void testEquator() {
-        Circle circle = new Circle(Vector3D.of(0, 0, 1000), TEST_PRECISION).copySelf();
-        Assert.assertEquals(Vector3D.Unit.PLUS_Z, circle.getPole());
-        Assert.assertEquals(TEST_PRECISION, circle.getPrecision());
-        circle.revertSelf();
-        Assert.assertEquals(Vector3D.Unit.MINUS_Z, circle.getPole());
-        Assert.assertEquals(Vector3D.Unit.PLUS_Z, circle.getReverse().getPole());
-        Assert.assertEquals(Vector3D.Unit.MINUS_Z, circle.getPole());
-    }
-
-    @Test
-    public void testXY() {
-        Circle circle = new Circle(S2Point.of(1.2, 2.5), S2Point.of(-4.3, 0), TEST_PRECISION);
-        Assert.assertEquals(0.0, circle.getPointAt(0).distance(circle.getXAxis()), TEST_EPS);
-        Assert.assertEquals(0.0, circle.getPointAt(0.5 * Math.PI).distance(circle.getYAxis()), TEST_EPS);
-        Assert.assertEquals(0.5 * Math.PI, circle.getXAxis().angle(circle.getYAxis()), TEST_EPS);
-        Assert.assertEquals(0.5 * Math.PI, circle.getXAxis().angle(circle.getPole()), TEST_EPS);
-        Assert.assertEquals(0.5 * Math.PI, circle.getPole().angle(circle.getYAxis()), TEST_EPS);
-        Assert.assertEquals(0.0,
-                            circle.getPole().distance(circle.getXAxis().cross(circle.getYAxis())),
-                            TEST_EPS);
-    }
-
-    @Test
-    public void testReverse() {
-        Circle circle = new Circle(S2Point.of(1.2, 2.5), S2Point.of(-4.3, 0), TEST_PRECISION);
-        Circle reversed = circle.getReverse();
-        Assert.assertEquals(0.0, reversed.getPointAt(0).distance(reversed.getXAxis()), TEST_EPS);
-        Assert.assertEquals(0.0, reversed.getPointAt(0.5 * Math.PI).distance(reversed.getYAxis()), TEST_EPS);
-        Assert.assertEquals(0.5 * Math.PI, reversed.getXAxis().angle(reversed.getYAxis()), TEST_EPS);
-        Assert.assertEquals(0.5 * Math.PI, reversed.getXAxis().angle(reversed.getPole()), TEST_EPS);
-        Assert.assertEquals(0.5 * Math.PI, reversed.getPole().angle(reversed.getYAxis()), TEST_EPS);
-        Assert.assertEquals(0.0,
-                            reversed.getPole().distance(reversed.getXAxis().cross(reversed.getYAxis())),
-                            TEST_EPS);
-
-        Assert.assertEquals(0, circle.getXAxis().angle(reversed.getXAxis()), TEST_EPS);
-        Assert.assertEquals(Math.PI, circle.getYAxis().angle(reversed.getYAxis()), TEST_EPS);
-        Assert.assertEquals(Math.PI, circle.getPole().angle(reversed.getPole()), TEST_EPS);
-
-        Assert.assertTrue(circle.sameOrientationAs(circle));
-        Assert.assertFalse(circle.sameOrientationAs(reversed));
-    }
-
-    @Test
-    public void testPhase() {
-        Circle circle = new Circle(S2Point.of(1.2, 2.5), S2Point.of(-4.3, 0), TEST_PRECISION);
-        Vector3D p = Vector3D.of(1, 2, -4);
-        Vector3D samePhase = circle.getPointAt(circle.getPhase(p));
-        Assert.assertEquals(0.0,
-                            circle.getPole().cross(p).angle(
-                                           circle.getPole().cross(samePhase)),
-                            TEST_EPS);
-        Assert.assertEquals(0.5 * Math.PI, circle.getPole().angle(samePhase), TEST_EPS);
-        Assert.assertEquals(circle.getPhase(p), circle.getPhase(samePhase), TEST_EPS);
-        Assert.assertEquals(0.0, circle.getPhase(circle.getXAxis()), TEST_EPS);
-        Assert.assertEquals(0.5 * Math.PI, circle.getPhase(circle.getYAxis()), TEST_EPS);
-    }
-
-    @Test
-    public void testSubSpace() {
-        Circle circle = new Circle(S2Point.of(1.2, 2.5), S2Point.of(-4.3, 0), TEST_PRECISION);
-        Assert.assertEquals(0.0, circle.toSubSpace(S2Point.ofVector(circle.getXAxis())).getAzimuth(), TEST_EPS);
-        Assert.assertEquals(0.5 * Math.PI, circle.toSubSpace(S2Point.ofVector(circle.getYAxis())).getAzimuth(), TEST_EPS);
-        Vector3D p = Vector3D.of(1, 2, -4);
-        Assert.assertEquals(circle.getPhase(p), circle.toSubSpace(S2Point.ofVector(p)).getAzimuth(), TEST_EPS);
-    }
-
-    @Test
-    public void testSpace() {
-        Circle circle = new Circle(S2Point.of(1.2, 2.5), S2Point.of(-4.3, 0), TEST_PRECISION);
-        for (double alpha = 0; alpha < Geometry.TWO_PI; alpha += 0.1) {
-            Vector3D p = Vector3D.linearCombination(Math.cos(alpha), circle.getXAxis(),
-                                      Math.sin(alpha), circle.getYAxis());
-            Vector3D q = circle.toSpace(S1Point.of(alpha)).getVector();
-            Assert.assertEquals(0.0, p.distance(q), TEST_EPS);
-            Assert.assertEquals(0.5 * Math.PI, circle.getPole().angle(q), TEST_EPS);
-        }
-    }
-
-    @Test
-    public void testOffset() {
-        Circle circle = new Circle(Vector3D.Unit.PLUS_Z, TEST_PRECISION);
-        Assert.assertEquals(0.0,                circle.getOffset(S2Point.ofVector(Vector3D.Unit.PLUS_X)),  TEST_EPS);
-        Assert.assertEquals(0.0,                circle.getOffset(S2Point.ofVector(Vector3D.Unit.MINUS_X)), TEST_EPS);
-        Assert.assertEquals(0.0,                circle.getOffset(S2Point.ofVector(Vector3D.Unit.PLUS_Y)),  TEST_EPS);
-        Assert.assertEquals(0.0,                circle.getOffset(S2Point.ofVector(Vector3D.Unit.MINUS_Y)), TEST_EPS);
-        Assert.assertEquals(-0.5 * Math.PI, circle.getOffset(S2Point.ofVector(Vector3D.Unit.PLUS_Z)),  TEST_EPS);
-        Assert.assertEquals(0.5 * Math.PI, circle.getOffset(S2Point.ofVector(Vector3D.Unit.MINUS_Z)), TEST_EPS);
-
-    }
-
-    @Test
-    public void testInsideArc() {
-        UnitSphereSampler sphRandom = new UnitSphereSampler(3, RandomSource.create(RandomSource.WELL_1024_A,
-                                                                                   0xbfd34e92231bbcfel));
-        for (int i = 0; i < 100; ++i) {
-            Circle c1 = new Circle(Vector3D.of(sphRandom.nextVector()), TEST_PRECISION);
-            Circle c2 = new Circle(Vector3D.of(sphRandom.nextVector()), TEST_PRECISION);
-            checkArcIsInside(c1, c2);
-            checkArcIsInside(c2, c1);
-        }
-    }
-
-    private void checkArcIsInside(final Circle arcCircle, final Circle otherCircle) {
-        Arc arc = arcCircle.getInsideArc(otherCircle);
-        Assert.assertEquals(Math.PI, arc.getSize(), TEST_EPS);
-        for (double alpha = arc.getInf(); alpha < arc.getSup(); alpha += 0.1) {
-            Assert.assertTrue(otherCircle.getOffset(arcCircle.getPointAt(alpha)) <= 2.0e-15);
-        }
-        for (double alpha = arc.getSup(); alpha < arc.getInf() + Geometry.TWO_PI; alpha += 0.1) {
-            Assert.assertTrue(otherCircle.getOffset(arcCircle.getPointAt(alpha)) >= -2.0e-15);
-        }
-    }
-
-    @Test
-    public void testTransform() {
-        UniformRandomProvider random = RandomSource.create(RandomSource.WELL_1024_A,
-                                                           0x16992fc4294bf2f1l);
-        UnitSphereSampler sphRandom = new UnitSphereSampler(3, random);
-        for (int i = 0; i < 100; ++i) {
-
-            QuaternionRotation r = QuaternionRotation.fromAxisAngle(Vector3D.of(sphRandom.nextVector()),
-                                      Math.PI * random.nextDouble());
-            Transform<S2Point, S1Point> t = Circle.getTransform(r);
-
-            S2Point  p = S2Point.ofVector(Vector3D.of(sphRandom.nextVector()));
-            S2Point tp = t.apply(p);
-            Assert.assertEquals(0.0, r.apply(p.getVector()).distance(tp.getVector()), TEST_EPS);
-
-            Circle  c = new Circle(Vector3D.of(sphRandom.nextVector()), TEST_PRECISION);
-            Circle tc = (Circle) t.apply(c);
-            Assert.assertEquals(0.0, r.apply(c.getPole()).distance(tc.getPole()),   TEST_EPS);
-            Assert.assertEquals(0.0, r.apply(c.getXAxis()).distance(tc.getXAxis()), TEST_EPS);
-            Assert.assertEquals(0.0, r.apply(c.getYAxis()).distance(tc.getYAxis()), TEST_EPS);
-            Assert.assertSame(c.getPrecision(), ((Circle) t.apply(c)).getPrecision());
-
-            SubLimitAngle  sub = new LimitAngle(S1Point.of(Geometry.TWO_PI * random.nextDouble()),
-                                                random.nextBoolean(), TEST_PRECISION).wholeHyperplane();
-            Vector3D psub = c.getPointAt(((LimitAngle) sub.getHyperplane()).getLocation().getAzimuth());
-            SubLimitAngle tsub = (SubLimitAngle) t.apply(sub, c, tc);
-            Vector3D ptsub = tc.getPointAt(((LimitAngle) tsub.getHyperplane()).getLocation().getAzimuth());
-            Assert.assertEquals(0.0, r.apply(psub).distance(ptsub), TEST_EPS);
-
-        }
-    }
-
-}
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
new file mode 100644
index 0000000..d626f96
--- /dev/null
+++ b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/ConvexArea2STest.java
@@ -0,0 +1,795 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.twod;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.GeometryTestUtils;
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.SplitLocation;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.threed.Vector3D;
+import org.apache.commons.geometry.spherical.SphericalTestUtils;
+import org.apache.commons.geometry.spherical.oned.Point1S;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class ConvexArea2STest {
+
+    private static final double TEST_EPS = 1e-10;
+
+    private static final DoublePrecisionContext TEST_PRECISION =
+            new EpsilonDoublePrecisionContext(TEST_EPS);
+
+    // epsilon value for use when comparing computed barycenter locations;
+    // this must currently be set much higher than the other epsilon
+    private static final double BARYCENTER_EPS = 1e-2;
+
+    @Test
+    public void testFull() {
+        // act
+        ConvexArea2S area = ConvexArea2S.full();
+
+        // assert
+        Assert.assertTrue(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+        Assert.assertEquals(0, area.getBoundarySize(), TEST_EPS);
+        Assert.assertEquals(4 * Geometry.PI, area.getSize(), TEST_EPS);
+        Assert.assertNull(area.getBarycenter());
+
+        Assert.assertEquals(0, area.getBoundaries().size());
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.INSIDE,
+                Point2S.PLUS_I, Point2S.MINUS_I,
+                Point2S.PLUS_J, Point2S.MINUS_J,
+                Point2S.PLUS_K, Point2S.MINUS_K);
+    }
+
+    @Test
+    public void testFromBounds_empty() {
+        // act
+        ConvexArea2S area = ConvexArea2S.fromBounds();
+
+        // assert
+        Assert.assertTrue(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+        Assert.assertEquals(0, area.getBoundarySize(), TEST_EPS);
+        Assert.assertEquals(4 * Geometry.PI, area.getSize(), TEST_EPS);
+        Assert.assertNull(area.getBarycenter());
+
+        Assert.assertEquals(0, area.getBoundaries().size());
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.INSIDE,
+                Point2S.PLUS_I, Point2S.MINUS_I,
+                Point2S.PLUS_J, Point2S.MINUS_J,
+                Point2S.PLUS_K, Point2S.MINUS_K);
+    }
+
+    @Test
+    public void testFromBounds_singleBound() {
+        // arrange
+        GreatCircle circle = GreatCircle.fromPoints(Point2S.PLUS_K, Point2S.PLUS_I, TEST_PRECISION);
+
+        // act
+        ConvexArea2S area = ConvexArea2S.fromBounds(circle);
+
+        // assert
+        Assert.assertFalse(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+        Assert.assertEquals(2 * Geometry.PI, area.getBoundarySize(), TEST_EPS);
+        Assert.assertEquals(2 * Geometry.PI, area.getSize(), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.PLUS_J, area.getBarycenter(), TEST_EPS);
+        checkBarycenterConsistency(area);
+
+        Assert.assertEquals(1, area.getBoundaries().size());
+        GreatArc arc = area.getBoundaries().get(0);
+        Assert.assertTrue(arc.isFull());
+        SphericalTestUtils.assertPointsEq(Point2S.PLUS_J, arc.getCircle().getPolePoint(), TEST_EPS);
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.INSIDE, Point2S.PLUS_J);
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.BOUNDARY,
+                Point2S.PLUS_I, Point2S.MINUS_I,
+                Point2S.PLUS_K, Point2S.MINUS_K);
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.OUTSIDE, Point2S.MINUS_J);
+    }
+
+    @Test
+    public void testFromBounds_lune_intersectionAtPoles() {
+        // arrange
+        GreatCircle a = GreatCircle.fromPoints(Point2S.PLUS_K, Point2S.PLUS_I, TEST_PRECISION);
+        GreatCircle b = GreatCircle.fromPoints(
+                Point2S.of(0.25 * Geometry.PI, Geometry.HALF_PI), Point2S.PLUS_K, TEST_PRECISION);
+
+        // act
+        ConvexArea2S area = ConvexArea2S.fromBounds(a, b);
+
+        // assert
+        Assert.assertFalse(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+        Assert.assertEquals(2 * Geometry.PI, area.getBoundarySize(), TEST_EPS);
+        Assert.assertEquals(Geometry.HALF_PI, area.getSize(), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.of(0.125 * Geometry.PI, Geometry.HALF_PI), area.getBarycenter(), TEST_EPS);
+        checkBarycenterConsistency(area);
+
+        List<GreatArc> arcs = sortArcs(area.getBoundaries());
+        Assert.assertEquals(2, arcs.size());
+        checkArc(arcs.get(0), Point2S.PLUS_K, Point2S.MINUS_K);
+        checkArc(arcs.get(1), Point2S.MINUS_K, Point2S.PLUS_K);
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.INSIDE,
+                Point2S.of(0.125 * Geometry.PI, 0.1),
+                Point2S.of(0.125 * Geometry.PI, Geometry.HALF_PI),
+                Point2S.of(0.125 * Geometry.PI, Geometry.PI - 0.1));
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.BOUNDARY,
+                Point2S.PLUS_I, Point2S.of(0.25 * Geometry.PI, Geometry.HALF_PI),
+                Point2S.PLUS_K, Point2S.MINUS_K);
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.OUTSIDE,
+                Point2S.PLUS_J, Point2S.MINUS_J);
+    }
+
+    @Test
+    public void testFromBounds_lune_intersectionAtEquator() {
+        // arrange
+        GreatCircle a = GreatCircle.fromPoints(Point2S.PLUS_I, Point2S.PLUS_J, TEST_PRECISION);
+        GreatCircle b = GreatCircle.fromPoints(Point2S.PLUS_J, Point2S.PLUS_K, TEST_PRECISION);
+
+        // act
+        ConvexArea2S area = ConvexArea2S.fromBounds(a, b);
+
+        // assert
+        Assert.assertFalse(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+        Assert.assertEquals(2 * Geometry.PI, area.getBoundarySize(), TEST_EPS);
+        Assert.assertEquals(Geometry.PI, area.getSize(), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.of(0, 0.25 * Geometry.PI), area.getBarycenter(), TEST_EPS);
+        checkBarycenterConsistency(area);
+
+        List<GreatArc> arcs = sortArcs(area.getBoundaries());
+        Assert.assertEquals(2, arcs.size());
+        checkArc(arcs.get(0), Point2S.PLUS_J, Point2S.MINUS_J);
+        checkArc(arcs.get(1), Point2S.MINUS_J, Point2S.PLUS_J);
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.INSIDE,
+                Point2S.of(0, 0.25 * Geometry.PI),
+                Point2S.of(0.25, 0.4 * Geometry.PI),
+                Point2S.of(-0.25, 0.4 * Geometry.PI));
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.BOUNDARY,
+                Point2S.PLUS_I, Point2S.PLUS_K,
+                Point2S.PLUS_J, Point2S.MINUS_J);
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.OUTSIDE,
+                Point2S.MINUS_I, Point2S.MINUS_K,
+                Point2S.of(Geometry.PI, 0.25 * Geometry.PI),
+                Point2S.of(Geometry.PI, 0.75 * Geometry.PI));
+    }
+
+    @Test
+    public void testFromBounds_triangle_large() {
+        // arrange
+        GreatCircle a = GreatCircle.fromPole(Vector3D.Unit.PLUS_X, TEST_PRECISION);
+        GreatCircle b = GreatCircle.fromPole(Vector3D.Unit.PLUS_Y, TEST_PRECISION);
+        GreatCircle c = GreatCircle.fromPole(Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+
+        // act
+        ConvexArea2S area = ConvexArea2S.fromBounds(Arrays.asList(a, b, c));
+
+        // assert
+        Assert.assertFalse(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+        Assert.assertEquals(1.5 * Geometry.PI, area.getBoundarySize(), TEST_EPS);
+        Assert.assertEquals(Geometry.HALF_PI, area.getSize(), TEST_EPS);
+
+        Point2S expectedBarycenter = triangleBarycenter(Point2S.PLUS_I, Point2S.PLUS_J, Point2S.PLUS_K);
+        SphericalTestUtils.assertPointsEq(expectedBarycenter, area.getBarycenter(), TEST_EPS);
+
+        checkBarycenterConsistency(area);
+
+        List<GreatArc> arcs = sortArcs(area.getBoundaries());
+        Assert.assertEquals(3, arcs.size());
+        checkArc(arcs.get(0), Point2S.PLUS_K, Point2S.PLUS_I);
+        checkArc(arcs.get(1), Point2S.PLUS_I, Point2S.PLUS_J);
+        checkArc(arcs.get(2), Point2S.PLUS_J, Point2S.PLUS_K);
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.INSIDE,
+                Point2S.of(0.25 * Geometry.PI, 0.25 * Geometry.PI));
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.BOUNDARY,
+                Point2S.PLUS_I, Point2S.PLUS_J, Point2S.PLUS_K,
+                Point2S.of(0, 0.25 * Geometry.PI), Point2S.of(Geometry.HALF_PI, 0.304 * Geometry.PI),
+                Point2S.of(0.25 * Geometry.PI, Geometry.HALF_PI));
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.OUTSIDE,
+                Point2S.MINUS_I, Point2S.MINUS_J, Point2S.MINUS_K);
+    }
+
+    @Test
+    public void testFromBounds_triangle_small() {
+        // arrange
+        double azMin = 1.125 * Geometry.PI;
+        double azMax = 1.375 * Geometry.PI;
+        double azMid = 0.5 * (azMin + azMax);
+        double polarTop = 0.1;
+        double polarBottom = 0.25 * Geometry.PI;
+
+        Point2S p1 = Point2S.of(azMin, polarBottom);
+        Point2S p2 = Point2S.of(azMax, polarBottom);
+        Point2S p3 = Point2S.of(azMid, polarTop);
+
+        GreatCircle a = GreatCircle.fromPoints(p1, p2, TEST_PRECISION);
+        GreatCircle b = GreatCircle.fromPoints(p2, p3, TEST_PRECISION);
+        GreatCircle c = GreatCircle.fromPoints(p3, p1, TEST_PRECISION);
+
+        // act
+        ConvexArea2S area = ConvexArea2S.fromBounds(Arrays.asList(a, b, c));
+
+        // assert
+        Assert.assertFalse(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+        Assert.assertEquals(p1.distance(p2) + p2.distance(p3) + p3.distance(p1),
+                area.getBoundarySize(), TEST_EPS);
+        double size = Geometry.TWO_PI - a.angle(b) - b.angle(c) - c.angle(a);
+        Assert.assertEquals(size, area.getSize(), TEST_EPS);
+
+        Point2S expectedBarycenter = triangleBarycenter(p1, p2, p3);
+        SphericalTestUtils.assertPointsEq(expectedBarycenter, area.getBarycenter(), BARYCENTER_EPS);
+
+        checkBarycenterConsistency(area);
+
+        List<GreatArc> arcs = sortArcs(area.getBoundaries());
+        Assert.assertEquals(3, arcs.size());
+
+        checkArc(arcs.get(0), p3, p1);
+        checkArc(arcs.get(1), p1, p2);
+        checkArc(arcs.get(2), p2, p3);
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.INSIDE, Point2S.of(azMid, 0.11));
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.BOUNDARY,
+                p1, p2, p3, p1.slerp(p2, 0.2));
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.OUTSIDE,
+                Point2S.PLUS_I, Point2S.PLUS_J, Point2S.PLUS_K,
+                Point2S.MINUS_I, Point2S.MINUS_J, Point2S.MINUS_K);
+    }
+
+    @Test
+    public void testFromBounds_quad() {
+        // arrange
+        Point2S p1 = Point2S.of(0.2, 0.1);
+        Point2S p2 = Point2S.of(0.1, 0.2);
+        Point2S p3 = Point2S.of(0.2, 0.5);
+        Point2S p4 = Point2S.of(0.3, 0.2);
+
+        GreatCircle c1 = GreatCircle.fromPoints(p1, p2, TEST_PRECISION);
+        GreatCircle c2 = GreatCircle.fromPoints(p2, p3, TEST_PRECISION);
+        GreatCircle c3 = GreatCircle.fromPoints(p3, p4, TEST_PRECISION);
+        GreatCircle c4 = GreatCircle.fromPoints(p4, p1, TEST_PRECISION);
+
+        // act
+        ConvexArea2S area = ConvexArea2S.fromBounds(c1, c2, c3, c4);
+
+        // assert
+        Assert.assertFalse(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+        Assert.assertEquals(p1.distance(p2) + p2.distance(p3) + p3.distance(p4) + p4.distance(p1),
+                area.getBoundarySize(), TEST_EPS);
+
+        double size = 2 * Geometry.PI - c1.angle(c2) - c2.angle(c3) - c3.angle(c4) - c4.angle(c1);
+        Assert.assertEquals(size, area.getSize(), TEST_EPS);
+
+        checkBarycenterConsistency(area);
+
+        List<GreatArc> arcs = sortArcs(area.getBoundaries());
+        Assert.assertEquals(4, arcs.size());
+
+        checkArc(arcs.get(0), p1, p2);
+        checkArc(arcs.get(1), p2, p3);
+        checkArc(arcs.get(2), p4, p1);
+        checkArc(arcs.get(3), p3, p4);
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.INSIDE, Point2S.of(0.2, 0.11));
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.BOUNDARY,
+                p1, p2, p3, p4, p1.slerp(p2, 0.2));
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.OUTSIDE,
+                Point2S.PLUS_I, Point2S.PLUS_J, Point2S.PLUS_K,
+                Point2S.MINUS_I, Point2S.MINUS_J, Point2S.MINUS_K);
+    }
+
+    @Test
+    public void testFromPath_empty() {
+        // act
+        ConvexArea2S area = ConvexArea2S.fromPath(GreatArcPath.empty());
+
+        // assert
+        Assert.assertSame(ConvexArea2S.full(), area);
+    }
+
+    @Test
+    public void testFromPath() {
+        // arrange
+        GreatArcPath path = GreatArcPath.builder(TEST_PRECISION)
+                .append(Point2S.MINUS_I)
+                .append(Point2S.MINUS_K)
+                .append(Point2S.MINUS_J)
+                .close();
+
+        // act
+        ConvexArea2S area = ConvexArea2S.fromPath(path);
+
+        // assert
+        Assert.assertFalse(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+        Assert.assertEquals(1.5 * Geometry.PI, area.getBoundarySize(), TEST_EPS);
+        Assert.assertEquals(Geometry.HALF_PI, area.getSize(), TEST_EPS);
+
+        Point2S expectedBarycenter = triangleBarycenter(Point2S.MINUS_I, Point2S.MINUS_K, Point2S.MINUS_J);
+        SphericalTestUtils.assertPointsEq(expectedBarycenter, area.getBarycenter(), TEST_EPS);
+
+        checkBarycenterConsistency(area);
+
+        List<GreatArc> arcs = sortArcs(area.getBoundaries());
+        Assert.assertEquals(3, arcs.size());
+        checkArc(arcs.get(0), Point2S.MINUS_I, Point2S.MINUS_K);
+        checkArc(arcs.get(1), Point2S.MINUS_J, Point2S.MINUS_I);
+        checkArc(arcs.get(2), Point2S.MINUS_K, Point2S.MINUS_J);
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.INSIDE,
+                Point2S.of(1.25 * Geometry.PI, 0.75 * Geometry.PI));
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.BOUNDARY,
+                Point2S.MINUS_I, Point2S.MINUS_J, Point2S.MINUS_K);
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.OUTSIDE,
+                Point2S.PLUS_I, Point2S.PLUS_J, Point2S.PLUS_K);
+    }
+
+    @Test
+    public void testFromVertices_empty() {
+        // act
+        ConvexArea2S area = ConvexArea2S.fromVertices(Collections.emptyList(), TEST_PRECISION);
+
+        // assert
+        Assert.assertSame(ConvexArea2S.full(), area);
+    }
+
+    @Test
+    public void testFromVertices() {
+        // arrange
+        Point2S p1 = Point2S.PLUS_I;
+        Point2S p2 = Point2S.PLUS_J;
+        Point2S p3 = Point2S.PLUS_K;
+
+        // act
+        ConvexArea2S area = ConvexArea2S.fromVertices(Arrays.asList(p1, p2, p3), TEST_PRECISION);
+
+        // assert
+        Assert.assertFalse(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+        Assert.assertEquals(2 * Geometry.PI, area.getBoundarySize(), TEST_EPS);
+        Assert.assertEquals(Geometry.PI, area.getSize(), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.of(0, 0.25 * Geometry.PI), area.getBarycenter(), TEST_EPS);
+        checkBarycenterConsistency(area);
+
+        List<GreatArc> arcs = sortArcs(area.getBoundaries());
+        Assert.assertEquals(2, arcs.size());
+        checkArc(arcs.get(0), Point2S.PLUS_J, Point2S.MINUS_J);
+        checkArc(arcs.get(1), Point2S.MINUS_J, Point2S.PLUS_J);
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.INSIDE,
+                Point2S.of(-0.25 * Geometry.PI, 0.25 * Geometry.PI),
+                Point2S.of(0, 0.25 * Geometry.PI),
+                Point2S.of(0.25 * Geometry.PI, 0.25 * Geometry.PI));
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.BOUNDARY,
+                Point2S.PLUS_I, Point2S.PLUS_J,
+                Point2S.PLUS_K, Point2S.MINUS_J);
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.OUTSIDE,
+                Point2S.MINUS_I, Point2S.MINUS_K);
+    }
+
+    @Test
+    public void testFromVertices_lastVertexRepeated() {
+        // arrange
+        Point2S p1 = Point2S.PLUS_I;
+        Point2S p2 = Point2S.PLUS_J;
+        Point2S p3 = Point2S.PLUS_K;
+
+        // act
+        ConvexArea2S area = ConvexArea2S.fromVertices(Arrays.asList(p1, p2, p3, p1), TEST_PRECISION);
+
+        // assert
+        Assert.assertFalse(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+        Assert.assertEquals(1.5 * Geometry.PI, area.getBoundarySize(), TEST_EPS);
+        Assert.assertEquals(Geometry.HALF_PI, area.getSize(), TEST_EPS);
+
+        Point2S expectedBarycenter = triangleBarycenter(Point2S.PLUS_I, Point2S.PLUS_J, Point2S.PLUS_K);
+        SphericalTestUtils.assertPointsEq(expectedBarycenter, area.getBarycenter(), TEST_EPS);
+
+        checkBarycenterConsistency(area);
+
+        List<GreatArc> arcs = sortArcs(area.getBoundaries());
+        Assert.assertEquals(3, arcs.size());
+        checkArc(arcs.get(0), Point2S.PLUS_K, Point2S.PLUS_I);
+        checkArc(arcs.get(1), Point2S.PLUS_I, Point2S.PLUS_J);
+        checkArc(arcs.get(2), Point2S.PLUS_J, Point2S.PLUS_K);
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.INSIDE,
+                Point2S.of(0.25 * Geometry.PI, 0.25 * Geometry.PI));
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.BOUNDARY,
+                Point2S.PLUS_I, Point2S.PLUS_J, Point2S.PLUS_K,
+                Point2S.of(0, 0.25 * Geometry.PI), Point2S.of(Geometry.HALF_PI, 0.304 * Geometry.PI),
+                Point2S.of(0.25 * Geometry.PI, Geometry.HALF_PI));
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.OUTSIDE,
+                Point2S.MINUS_I, Point2S.MINUS_J, Point2S.MINUS_K);
+    }
+
+    @Test
+    public void testFromVertices_verticesRepeated() {
+        // arrange
+        Point2S p1 = Point2S.PLUS_I;
+        Point2S p2 = Point2S.PLUS_J;
+        Point2S p3 = Point2S.PLUS_K;
+
+        // act
+        ConvexArea2S area = ConvexArea2S.fromVertices(Arrays.asList(
+                p1, Point2S.of(1e-17, Geometry.HALF_PI), p2, p3, p3, p1), true, TEST_PRECISION);
+
+        // assert
+        Assert.assertEquals(Geometry.HALF_PI, area.getSize(), TEST_EPS);
+
+        Point2S expectedBarycenter = triangleBarycenter(Point2S.PLUS_I, Point2S.PLUS_J, Point2S.PLUS_K);
+        SphericalTestUtils.assertPointsEq(expectedBarycenter, area.getBarycenter(), TEST_EPS);
+
+        List<Point2S> vertices = area.getBoundaryPath().getVertices();
+        Assert.assertEquals(4, vertices.size());
+        SphericalTestUtils.assertPointsEq(Point2S.PLUS_K, vertices.get(0), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.PLUS_I, vertices.get(1), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.PLUS_J, vertices.get(2), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.PLUS_K, vertices.get(3), TEST_EPS);
+    }
+
+    @Test
+    public void testFromVertices_invalidArguments() {
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            ConvexArea2S.fromVertices(Arrays.asList(Point2S.PLUS_I), TEST_PRECISION);
+        }, IllegalStateException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            ConvexArea2S.fromVertices(Arrays.asList(Point2S.PLUS_I, Point2S.of(1e-16, Geometry.HALF_PI)), TEST_PRECISION);
+        }, IllegalStateException.class);
+    }
+
+    @Test
+    public void testFromVertexLoop() {
+        // arrange
+        Point2S p1 = Point2S.PLUS_I;
+        Point2S p2 = Point2S.PLUS_J;
+        Point2S p3 = Point2S.PLUS_K;
+
+        // act
+        ConvexArea2S area = ConvexArea2S.fromVertexLoop(Arrays.asList(p1, p2, p3), TEST_PRECISION);
+
+        // assert
+        Assert.assertFalse(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+        Assert.assertEquals(1.5 * Geometry.PI, area.getBoundarySize(), TEST_EPS);
+        Assert.assertEquals(Geometry.HALF_PI, area.getSize(), TEST_EPS);
+
+        Point2S expectedBarycenter = triangleBarycenter(Point2S.PLUS_I, Point2S.PLUS_J, Point2S.PLUS_K);
+        SphericalTestUtils.assertPointsEq(expectedBarycenter, area.getBarycenter(), TEST_EPS);
+
+        checkBarycenterConsistency(area);
+
+        List<GreatArc> arcs = sortArcs(area.getBoundaries());
+        Assert.assertEquals(3, arcs.size());
+        checkArc(arcs.get(0), Point2S.PLUS_K, Point2S.PLUS_I);
+        checkArc(arcs.get(1), Point2S.PLUS_I, Point2S.PLUS_J);
+        checkArc(arcs.get(2), Point2S.PLUS_J, Point2S.PLUS_K);
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.INSIDE,
+                Point2S.of(0.25 * Geometry.PI, 0.25 * Geometry.PI));
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.BOUNDARY,
+                Point2S.PLUS_I, Point2S.PLUS_J, Point2S.PLUS_K,
+                Point2S.of(0, 0.25 * Geometry.PI), Point2S.of(Geometry.HALF_PI, 0.304 * Geometry.PI),
+                Point2S.of(0.25 * Geometry.PI, Geometry.HALF_PI));
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.OUTSIDE,
+                Point2S.MINUS_I, Point2S.MINUS_J, Point2S.MINUS_K);
+    }
+
+    @Test
+    public void testFromVertexLoop_empty() {
+        // act
+        ConvexArea2S area = ConvexArea2S.fromVertexLoop(Collections.emptyList(), TEST_PRECISION);
+
+        // assert
+        Assert.assertSame(ConvexArea2S.full(), area);
+    }
+
+    @Test
+    public void testGetInteriorAngles_noAngles() {
+        // act/assert
+        Assert.assertEquals(0, ConvexArea2S.full().getInteriorAngles().length);
+        Assert.assertEquals(0, ConvexArea2S.fromBounds(GreatCircle.fromPole(Vector3D.Unit.PLUS_X, TEST_PRECISION))
+                .getInteriorAngles().length);
+    }
+
+    @Test
+    public void testGetInteriorAngles() {
+        // arrange
+        Point2S p1 = Point2S.PLUS_K;
+        Point2S p2 = Point2S.PLUS_I;
+        Point2S p4 = Point2S.PLUS_J;
+
+        GreatCircle base = GreatCircle.fromPoints(p2, p4, TEST_PRECISION);
+        GreatCircle c1 = base.transform(Transform2S.createRotation(p2, -0.2));
+        GreatCircle c2 = base.transform(Transform2S.createRotation(p4, 0.1));
+
+        Point2S p3 = c1.intersection(c2);
+
+        // act
+        ConvexArea2S area = ConvexArea2S.fromVertexLoop(Arrays.asList(p1, p2, p3, p4), TEST_PRECISION);
+
+        // assert
+        double[] angles = area.getInteriorAngles();
+        Assert.assertEquals(4, angles.length);
+        Assert.assertEquals(Geometry.HALF_PI + 0.2, angles[0], TEST_EPS);
+        Assert.assertEquals(Geometry.PI - c1.angle(c2), angles[1], TEST_EPS);
+        Assert.assertEquals(Geometry.HALF_PI + 0.1, angles[2], TEST_EPS);
+        Assert.assertEquals(Geometry.HALF_PI, angles[3], TEST_EPS);
+    }
+
+    @Test
+    public void testTransform() {
+        // arrange
+        Transform2S t = Transform2S.createReflection(Point2S.PLUS_J);
+        ConvexArea2S input = ConvexArea2S.fromVertexLoop(
+                Arrays.asList(Point2S.PLUS_I, Point2S.PLUS_J, Point2S.PLUS_K), TEST_PRECISION);
+
+        // act
+        ConvexArea2S area = input.transform(t);
+
+        // assert
+        Assert.assertFalse(area.isFull());
+        Assert.assertFalse(area.isEmpty());
+        Assert.assertEquals(1.5 * Geometry.PI, area.getBoundarySize(), TEST_EPS);
+        Assert.assertEquals(Geometry.HALF_PI, area.getSize(), TEST_EPS);
+
+        Point2S expectedBarycenter = triangleBarycenter(Point2S.MINUS_J, Point2S.PLUS_I, Point2S.PLUS_K);
+        SphericalTestUtils.assertPointsEq(expectedBarycenter, area.getBarycenter(), TEST_EPS);
+
+        checkBarycenterConsistency(area);
+
+        List<GreatArc> arcs = sortArcs(area.getBoundaries());
+        Assert.assertEquals(3, arcs.size());
+        checkArc(arcs.get(0), Point2S.PLUS_K, Point2S.MINUS_J);
+        checkArc(arcs.get(1), Point2S.PLUS_I, Point2S.PLUS_K);
+        checkArc(arcs.get(2), Point2S.MINUS_J, Point2S.PLUS_I);
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.INSIDE,
+                Point2S.of(-0.25 * Geometry.PI, 0.25 * Geometry.PI));
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.BOUNDARY,
+                Point2S.PLUS_I, Point2S.MINUS_J, Point2S.PLUS_K,
+                Point2S.of(0, 0.25 * Geometry.PI), Point2S.of(Geometry.MINUS_HALF_PI, 0.304 * Geometry.PI),
+                Point2S.of(-0.25 * Geometry.PI, Geometry.HALF_PI));
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.OUTSIDE,
+                Point2S.PLUS_J, Point2S.MINUS_I, Point2S.MINUS_K);
+    }
+
+    @Test
+    public void testTrim() {
+        // arrange
+        GreatCircle c1 = GreatCircle.fromPole(Vector3D.Unit.MINUS_X, TEST_PRECISION);
+        GreatCircle c2 = GreatCircle.fromPole(Vector3D.of(1, 1, 0), TEST_PRECISION);
+
+        GreatCircle slanted = GreatCircle.fromPole(Vector3D.of(-1, 0, 1), TEST_PRECISION);
+
+        ConvexArea2S area = ConvexArea2S.fromBounds(c1, c2);
+
+        // act/assert
+        checkArc(area.trim(GreatArc.fromPoints(Point2S.of(0.1, Geometry.HALF_PI), Point2S.MINUS_I, TEST_PRECISION)),
+                Point2S.PLUS_J, Point2S.of(0.75 * Geometry.PI, Geometry.HALF_PI));
+
+        checkArc(area.trim(GreatArc.fromPoints(Point2S.MINUS_I, Point2S.of(0.2, Geometry.HALF_PI), TEST_PRECISION)),
+                Point2S.of(0.75 * Geometry.PI, Geometry.HALF_PI), Point2S.PLUS_J);
+
+        checkArc(area.trim(GreatArc.fromPoints(Point2S.of(0.6 * Geometry.PI, 0.1), Point2S.of(0.7 * Geometry.PI, 0.8), TEST_PRECISION)),
+                Point2S.of(0.6 * Geometry.PI, 0.1), Point2S.of(0.7 * Geometry.PI, 0.8));
+
+        Assert.assertNull(area.trim(GreatArc.fromPoints(Point2S.MINUS_I, Point2S.MINUS_J, TEST_PRECISION)));
+
+        checkArc(area.trim(slanted.span()), c1.intersection(slanted), slanted.intersection(c2));
+    }
+
+    @Test
+    public void testSplit_both() {
+        // arrange
+        GreatCircle c1 = GreatCircle.fromPole(Vector3D.Unit.MINUS_X, TEST_PRECISION);
+        GreatCircle c2 = GreatCircle.fromPole(Vector3D.of(1, 1, 0), TEST_PRECISION);
+
+        ConvexArea2S area = ConvexArea2S.fromBounds(c1, c2);
+
+        GreatCircle splitter = GreatCircle.fromPole(Vector3D.of(-1, 0, 1), TEST_PRECISION);
+
+        // act
+        Split<ConvexArea2S> split = area.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        Point2S p1 = c1.intersection(splitter);
+        Point2S p2 = splitter.intersection(c2);
+
+        ConvexArea2S minus = split.getMinus();
+        assertPath(minus.getBoundaryPath(), Point2S.PLUS_K, p1, p2, Point2S.PLUS_K);
+
+        ConvexArea2S plus = split.getPlus();
+        assertPath(plus.getBoundaryPath(), p1, Point2S.MINUS_K, p2, p1);
+
+        Assert.assertEquals(area.getSize(), minus.getSize() + plus.getSize(), TEST_EPS);
+    }
+
+    @Test
+    public void testSplit_minus() {
+        // arrange
+        ConvexArea2S area = ConvexArea2S.fromVertexLoop(Arrays.asList(
+                    Point2S.PLUS_I, Point2S.PLUS_K, Point2S.MINUS_J
+                ), TEST_PRECISION);
+
+        GreatCircle splitter = GreatCircle.fromPole(Vector3D.of(0, -1, 1), TEST_PRECISION);
+
+        // act
+        Split<ConvexArea2S> split = area.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.MINUS, split.getLocation());
+
+        Assert.assertSame(area, split.getMinus());
+        Assert.assertNull(split.getPlus());
+    }
+
+    @Test
+    public void testSplit_plus() {
+        // arrange
+        ConvexArea2S area = ConvexArea2S.fromVertexLoop(Arrays.asList(
+                    Point2S.PLUS_I, Point2S.PLUS_K, Point2S.MINUS_J
+                ), TEST_PRECISION);
+
+        GreatCircle splitter = GreatCircle.fromPole(Vector3D.of(0, 1, -1), TEST_PRECISION);
+
+        // act
+        Split<ConvexArea2S> split = area.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.PLUS, split.getLocation());
+
+        Assert.assertNull(split.getMinus());
+        Assert.assertSame(area, split.getPlus());
+    }
+
+    @Test
+    public void testToTree() {
+        // arrange
+        ConvexArea2S area = ConvexArea2S.fromVertexLoop(Arrays.asList(
+                    Point2S.of(0.1, 0.1), Point2S.of(-0.4, 1),
+                    Point2S.of(0.15, 1.5), Point2S.of(0.3, 1.2),
+                    Point2S.of(0.1, 0.1)
+                ), TEST_PRECISION);
+
+        // act
+        RegionBSPTree2S tree = area.toTree();
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertFalse(tree.isEmpty());
+
+        Assert.assertEquals(area.getSize(), tree.getSize(), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(area.getBarycenter(), tree.getBarycenter(), TEST_EPS);
+    }
+
+    private static List<GreatArc> sortArcs(List<GreatArc> arcs) {
+        List<GreatArc> result = new ArrayList<>(arcs);
+
+        Collections.sort(result, (a, b) ->
+            Point2S.POLAR_AZIMUTH_ASCENDING_ORDER.compare(a.getStartPoint(), b.getStartPoint()));
+
+        return result;
+    }
+
+    private static Point2S triangleBarycenter(Point2S p1, Point2S p2, Point2S p3) {
+        // compute the barycenter using intersection mid point arcs
+        GreatCircle c1 = GreatCircle.fromPoints(p1, p2.slerp(p3, 0.5), TEST_PRECISION);
+        GreatCircle c2 = GreatCircle.fromPoints(p2, p1.slerp(p3, 0.5), TEST_PRECISION);
+
+        return c1.intersection(c2);
+    }
+
+    private static void checkArc(GreatArc arc, Point2S start, Point2S end) {
+        SphericalTestUtils.assertPointsEq(start, arc.getStartPoint(), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(end, arc.getEndPoint(), TEST_EPS);
+    }
+
+    private static void assertPath(GreatArcPath path, Point2S ... expectedVertices) {
+        List<Point2S> vertices = path.getVertices();
+
+        Assert.assertEquals(expectedVertices.length, vertices.size());
+        for (int i = 0; i < expectedVertices.length; ++i) {
+
+            if (!expectedVertices[i].eq(vertices.get(i), TEST_PRECISION)) {
+                String msg = "Unexpected point in path at index " + i + ". Expected " +
+                        Arrays.toString(expectedVertices) + " but received " + vertices;
+                Assert.fail(msg);
+            }
+        }
+    }
+
+    private static void checkBarycenterConsistency(ConvexArea2S area) {
+        Point2S barycenter = area.getBarycenter();
+        double size = area.getSize();
+
+        SphericalTestUtils.checkClassify(area, RegionLocation.INSIDE, barycenter);
+
+        GreatCircle circle = GreatCircle.fromPole(barycenter.getVector(), TEST_PRECISION);
+        for (double az = 0; az <= Geometry.TWO_PI; az += 0.2) {
+            Point2S pt = circle.toSpace(Point1S.of(az));
+            GreatCircle splitter = GreatCircle.fromPoints(barycenter, pt, TEST_PRECISION);
+
+            Split<ConvexArea2S> split = area.split(splitter);
+
+            Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+            ConvexArea2S minus = split.getMinus();
+            double minusSize = minus.getSize();
+            Point2S minusBc = minus.getBarycenter();
+
+            Vector3D weightedMinus = minusBc.getVector()
+                    .multiply(minus.getSize());
+
+            ConvexArea2S plus = split.getPlus();
+            double plusSize = plus.getSize();
+            Point2S plusBc = plus.getBarycenter();
+
+            Vector3D weightedPlus = plusBc.getVector()
+                    .multiply(plus.getSize());
+            Point2S computedBarycenter = Point2S.from(weightedMinus.add(weightedPlus));
+
+            Assert.assertEquals(size, minusSize + plusSize, TEST_EPS);
+            SphericalTestUtils.assertPointsEq(barycenter, computedBarycenter, BARYCENTER_EPS);
+        }
+    }
+}
diff --git a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/GreatArcPathTest.java b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/GreatArcPathTest.java
new file mode 100644
index 0000000..7911f9d
--- /dev/null
+++ b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/GreatArcPathTest.java
@@ -0,0 +1,641 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.twod;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.GeometryTestUtils;
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.threed.Vector3D;
+import org.apache.commons.geometry.spherical.SphericalTestUtils;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class GreatArcPathTest {
+
+    private static final double TEST_EPS = 1e-10;
+
+    private static final DoublePrecisionContext TEST_PRECISION =
+            new EpsilonDoublePrecisionContext(TEST_EPS);
+
+    @Test
+    public void testEmpty() {
+        // act
+        GreatArcPath path = GreatArcPath.empty();
+
+        // assert
+        Assert.assertTrue(path.isEmpty());
+        Assert.assertFalse(path.isClosed());
+
+        Assert.assertNull(path.getStartVertex());
+        Assert.assertNull(path.getEndVertex());
+
+        Assert.assertNull(path.getStartArc());
+        Assert.assertNull(path.getEndArc());
+
+        Assert.assertEquals(0, path.getArcs().size());
+        Assert.assertEquals(0, path.getVertices().size());
+    }
+
+    @Test
+    public void testFromVertices_boolean_empty() {
+        // act
+        GreatArcPath path = GreatArcPath.fromVertices(Collections.emptyList(), true, TEST_PRECISION);
+
+        // assert
+        Assert.assertTrue(path.isEmpty());
+
+        Assert.assertNull(path.getStartVertex());
+        Assert.assertNull(path.getEndVertex());
+
+        Assert.assertNull(path.getStartArc());
+        Assert.assertNull(path.getEndArc());
+
+        Assert.assertEquals(0, path.getArcs().size());
+        Assert.assertEquals(0, path.getVertices().size());
+    }
+
+    @Test
+    public void testFromVertices_boolean_notClosed() {
+        // arrange
+        List<Point2S> points = Arrays.asList(
+                Point2S.PLUS_I,
+                Point2S.PLUS_K,
+                Point2S.PLUS_J);
+
+        // act
+        GreatArcPath path = GreatArcPath.fromVertices(points, false, TEST_PRECISION);
+
+        // assert
+        Assert.assertFalse(path.isEmpty());
+        Assert.assertFalse(path.isClosed());
+
+        SphericalTestUtils.assertPointsEq(Point2S.PLUS_I, path.getStartVertex(), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.PLUS_J, path.getEndVertex(), TEST_EPS);
+
+        List<GreatArc> arcs = path.getArcs();
+        Assert.assertEquals(2, arcs.size());
+        assertArc(arcs.get(0), Point2S.PLUS_I, Point2S.PLUS_K);
+        assertArc(arcs.get(1), Point2S.PLUS_K, Point2S.PLUS_J);
+
+        assertPoints(points, path.getVertices());
+    }
+
+    @Test
+    public void testFromVertices_boolean_closed() {
+        // arrange
+        List<Point2S> points = Arrays.asList(
+                Point2S.PLUS_I,
+                Point2S.PLUS_K,
+                Point2S.PLUS_J);
+
+        // act
+        GreatArcPath path = GreatArcPath.fromVertices(points, true, TEST_PRECISION);
+
+        // assert
+        Assert.assertFalse(path.isEmpty());
+        Assert.assertTrue(path.isClosed());
+
+        SphericalTestUtils.assertPointsEq(Point2S.PLUS_I, path.getStartVertex(), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.PLUS_I, path.getEndVertex(), TEST_EPS);
+
+        List<GreatArc> arcs = path.getArcs();
+        Assert.assertEquals(3, arcs.size());
+        assertArc(arcs.get(0), Point2S.PLUS_I, Point2S.PLUS_K);
+        assertArc(arcs.get(1), Point2S.PLUS_K, Point2S.PLUS_J);
+        assertArc(arcs.get(2), Point2S.PLUS_J, Point2S.PLUS_I);
+
+        assertPoints(Arrays.asList(
+                Point2S.PLUS_I,
+                Point2S.PLUS_K,
+                Point2S.PLUS_J,
+                Point2S.PLUS_I), path.getVertices());
+    }
+
+    @Test
+    public void testFromVertices_boolean_closed_pointsConsideredEqual() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-2);
+
+        Point2S almostPlusI = Point2S.of(1e-4, Geometry.HALF_PI);
+
+        List<Point2S> points = Arrays.asList(
+                Point2S.PLUS_I,
+                Point2S.PLUS_K,
+                Point2S.PLUS_J,
+                almostPlusI);
+
+        // act
+        GreatArcPath path = GreatArcPath.fromVertices(points, true, precision);
+
+        // assert
+        Assert.assertFalse(path.isEmpty());
+        Assert.assertTrue(path.isClosed());
+
+        SphericalTestUtils.assertPointsEq(Point2S.PLUS_I, path.getStartVertex(), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(almostPlusI, path.getEndVertex(), TEST_EPS);
+
+        List<GreatArc> arcs = path.getArcs();
+        Assert.assertEquals(3, arcs.size());
+        assertArc(arcs.get(0), Point2S.PLUS_I, Point2S.PLUS_K);
+        assertArc(arcs.get(1), Point2S.PLUS_K, Point2S.PLUS_J);
+        assertArc(arcs.get(2), Point2S.PLUS_J, almostPlusI);
+
+        assertPoints(Arrays.asList(
+                Point2S.PLUS_I,
+                Point2S.PLUS_K,
+                Point2S.PLUS_J,
+                almostPlusI), path.getVertices());
+    }
+
+    @Test
+    public void testFromVertices() {
+        // arrange
+        List<Point2S> points = Arrays.asList(
+                Point2S.MINUS_I,
+                Point2S.MINUS_J,
+                Point2S.PLUS_I);
+
+        // act
+        GreatArcPath path = GreatArcPath.fromVertices(points, TEST_PRECISION);
+
+        // assert
+        Assert.assertFalse(path.isEmpty());
+        Assert.assertFalse(path.isClosed());
+
+        SphericalTestUtils.assertPointsEq(Point2S.MINUS_I, path.getStartVertex(), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.PLUS_I, path.getEndVertex(), TEST_EPS);
+
+        List<GreatArc> arcs = path.getArcs();
+        Assert.assertEquals(2, arcs.size());
+        assertArc(arcs.get(0), Point2S.MINUS_I, Point2S.MINUS_J);
+        assertArc(arcs.get(1), Point2S.MINUS_J, Point2S.PLUS_I);
+
+        assertPoints(points, path.getVertices());
+    }
+
+    @Test
+    public void testFromVertexLoop() {
+        // arrange
+        List<Point2S> points = Arrays.asList(
+                Point2S.MINUS_I,
+                Point2S.MINUS_J,
+                Point2S.MINUS_K);
+
+        // act
+        GreatArcPath path = GreatArcPath.fromVertexLoop(points, TEST_PRECISION);
+
+        // assert
+        Assert.assertFalse(path.isEmpty());
+        Assert.assertTrue(path.isClosed());
+
+        SphericalTestUtils.assertPointsEq(Point2S.MINUS_I, path.getStartVertex(), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.MINUS_I, path.getEndVertex(), TEST_EPS);
+
+        List<GreatArc> arcs = path.getArcs();
+        Assert.assertEquals(3, arcs.size());
+        assertArc(arcs.get(0), Point2S.MINUS_I, Point2S.MINUS_J);
+        assertArc(arcs.get(1), Point2S.MINUS_J, Point2S.MINUS_K);
+        assertArc(arcs.get(2), Point2S.MINUS_K, Point2S.MINUS_I);
+
+        assertPoints(Arrays.asList(
+                Point2S.MINUS_I,
+                Point2S.MINUS_J,
+                Point2S.MINUS_K,
+                Point2S.MINUS_I), path.getVertices());
+    }
+
+    @Test
+    public void testFromArcs() {
+        // arrange
+        Point2S ptA = Point2S.PLUS_I;
+        Point2S ptB = Point2S.of(1, Geometry.HALF_PI);
+        Point2S ptC = Point2S.of(1, Geometry.HALF_PI - 1);
+        Point2S ptD = Point2S.of(2, Geometry.HALF_PI - 1);
+
+        GreatArc a = GreatArc.fromPoints(ptA, ptB, TEST_PRECISION);
+        GreatArc b = GreatArc.fromPoints(ptB, ptC, TEST_PRECISION);
+        GreatArc c = GreatArc.fromPoints(ptC, ptD, TEST_PRECISION);
+
+        // act
+        GreatArcPath path = GreatArcPath.fromArcs(a, b, c);
+
+        // assert
+        Assert.assertFalse(path.isEmpty());
+        Assert.assertFalse(path.isClosed());
+
+        SphericalTestUtils.assertPointsEq(ptA, path.getStartVertex(), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(ptD, path.getEndVertex(), TEST_EPS);
+
+        List<GreatArc> arcs = path.getArcs();
+        Assert.assertEquals(3, arcs.size());
+        assertArc(arcs.get(0), ptA, ptB);
+        assertArc(arcs.get(1), ptB, ptC);
+        assertArc(arcs.get(2), ptC, ptD);
+
+        assertPoints(Arrays.asList(ptA, ptB, ptC, ptD), path.getVertices());
+    }
+
+    @Test
+    public void testFromArcs_full() {
+        // arrange
+        GreatArc fullArc = GreatCircle.fromPole(Vector3D.Unit.PLUS_X, TEST_PRECISION).span();
+
+        // act
+        GreatArcPath path = GreatArcPath.fromArcs(fullArc);
+
+        // assert
+        Assert.assertFalse(path.isEmpty());
+        Assert.assertFalse(path.isClosed());
+
+        Assert.assertSame(fullArc, path.getStartArc());
+        Assert.assertSame(fullArc, path.getEndArc());
+
+        Assert.assertNull(path.getStartVertex());
+        Assert.assertNull(path.getEndVertex());
+
+        List<GreatArc> arcs = path.getArcs();
+        Assert.assertEquals(1, arcs.size());
+
+        Assert.assertSame(fullArc, arcs.get(0));
+    }
+
+    @Test
+    public void testIterator() {
+        // arrange
+        GreatArc a = GreatArc.fromPoints(Point2S.PLUS_I, Point2S.PLUS_J, TEST_PRECISION);
+        GreatArc b = GreatArc.fromPoints(Point2S.PLUS_J, Point2S.PLUS_K, TEST_PRECISION);
+
+        GreatArcPath path = GreatArcPath.fromArcs(Arrays.asList(a, b));
+
+        List<GreatArc> arcs = new ArrayList<>();
+
+        // act
+        for (GreatArc arc : path) {
+            arcs.add(arc);
+        }
+
+        // assert
+        Assert.assertEquals(arcs, Arrays.asList(a, b));
+    }
+
+    @Test
+    public void testToTree_empty() {
+        // act
+        RegionBSPTree2S tree = GreatArcPath.empty().toTree();
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertTrue(tree.isEmpty());
+    }
+
+    @Test
+    public void testToTree_halfSpace() {
+        // arrange
+        GreatArcPath path = GreatArcPath.builder(TEST_PRECISION)
+                .append(Point2S.PLUS_I)
+                .append(Point2S.PLUS_J)
+                .build();
+
+        // act
+        RegionBSPTree2S tree = path.toTree();
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertFalse(tree.isEmpty());
+
+        Assert.assertEquals(Geometry.TWO_PI, tree.getSize(), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.PLUS_K, tree.getBarycenter(), TEST_EPS);
+
+        SphericalTestUtils.checkClassify(tree, RegionLocation.INSIDE, Point2S.PLUS_K);
+        SphericalTestUtils.checkClassify(tree, RegionLocation.OUTSIDE, Point2S.MINUS_K);
+    }
+
+    @Test
+    public void testToTree_triangle() {
+        // arrange
+        GreatArcPath path = GreatArcPath.builder(TEST_PRECISION)
+                .append(Point2S.PLUS_I)
+                .append(Point2S.PLUS_J)
+                .append(Point2S.PLUS_K)
+                .close();
+
+        // act
+        RegionBSPTree2S tree = path.toTree();
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertFalse(tree.isEmpty());
+
+        Assert.assertEquals(Geometry.HALF_PI, tree.getSize(), TEST_EPS);
+
+        Point2S bc = Point2S.from(Point2S.PLUS_I.getVector()
+                .add(Point2S.PLUS_J.getVector())
+                .add(Point2S.PLUS_K.getVector()));
+
+        SphericalTestUtils.assertPointsEq(bc, tree.getBarycenter(), TEST_EPS);
+
+        SphericalTestUtils.checkClassify(tree, RegionLocation.INSIDE, Point2S.of(0.5, 0.5));
+        SphericalTestUtils.checkClassify(tree, RegionLocation.OUTSIDE,
+                Point2S.MINUS_K, Point2S.MINUS_I, Point2S.MINUS_J);
+    }
+
+    @Test
+    public void testBuilder_append() {
+        // arrange
+        Point2S a = Point2S.PLUS_I;
+        Point2S b = Point2S.PLUS_J;
+        Point2S c = Point2S.PLUS_K;
+        Point2S d = Point2S.of(-1, Geometry.HALF_PI);
+        Point2S e = Point2S.of(0, 0.6 * Geometry.PI);
+
+        GreatArcPath.Builder builder = GreatArcPath.builder(TEST_PRECISION);
+
+        // act
+        GreatArcPath path = builder.append(GreatArc.fromPoints(a, b, TEST_PRECISION))
+            .appendVertices(c, d)
+            .append(e)
+            .append(GreatArc.fromPoints(e, a, TEST_PRECISION))
+            .build();
+
+        // assert
+        Assert.assertFalse(path.isEmpty());
+        Assert.assertTrue(path.isClosed());
+
+        SphericalTestUtils.assertPointsEq(a, path.getStartVertex(), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(a, path.getEndVertex(), TEST_EPS);
+
+        List<GreatArc> arcs = path.getArcs();
+        Assert.assertEquals(5, arcs.size());
+        assertArc(arcs.get(0), a, b);
+        assertArc(arcs.get(1), b, c);
+        assertArc(arcs.get(2), c, d);
+        assertArc(arcs.get(3), d, e);
+        assertArc(arcs.get(4), e, a);
+
+        assertPoints(Arrays.asList(a, b, c, d, e, a), path.getVertices());
+    }
+
+    @Test
+    public void testBuilder_prepend() {
+        // arrange
+        Point2S a = Point2S.PLUS_I;
+        Point2S b = Point2S.PLUS_J;
+        Point2S c = Point2S.PLUS_K;
+        Point2S d = Point2S.of(-1, Geometry.HALF_PI);
+        Point2S e = Point2S.of(0, 0.6 * Geometry.PI);
+
+        GreatArcPath.Builder builder = GreatArcPath.builder(TEST_PRECISION);
+
+        // act
+        GreatArcPath path = builder.prepend(GreatArc.fromPoints(e, a, TEST_PRECISION))
+            .prependPoints(Arrays.asList(c, d))
+            .prepend(b)
+            .prepend(GreatArc.fromPoints(a, b, TEST_PRECISION))
+            .build();
+
+        // assert
+        Assert.assertFalse(path.isEmpty());
+        Assert.assertTrue(path.isClosed());
+
+        SphericalTestUtils.assertPointsEq(a, path.getStartVertex(), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(a, path.getEndVertex(), TEST_EPS);
+
+        List<GreatArc> arcs = path.getArcs();
+        Assert.assertEquals(5, arcs.size());
+        assertArc(arcs.get(0), a, b);
+        assertArc(arcs.get(1), b, c);
+        assertArc(arcs.get(2), c, d);
+        assertArc(arcs.get(3), d, e);
+        assertArc(arcs.get(4), e, a);
+
+        assertPoints(Arrays.asList(a, b, c, d, e, a), path.getVertices());
+    }
+
+    @Test
+    public void testBuilder_appendAndPrepend_points() {
+        // arrange
+        Point2S a = Point2S.PLUS_I;
+        Point2S b = Point2S.PLUS_J;
+        Point2S c = Point2S.PLUS_K;
+        Point2S d = Point2S.of(-1, Geometry.HALF_PI);
+        Point2S e = Point2S.of(0, 0.6 * Geometry.PI);
+
+        GreatArcPath.Builder builder = GreatArcPath.builder(TEST_PRECISION);
+
+        // act
+        GreatArcPath path = builder.prepend(a)
+                .append(b)
+                .prepend(e)
+                .append(c)
+                .prepend(d)
+                .build();
+
+        // assert
+        Assert.assertFalse(path.isEmpty());
+        Assert.assertFalse(path.isClosed());
+
+        SphericalTestUtils.assertPointsEq(d, path.getStartVertex(), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(c, path.getEndVertex(), TEST_EPS);
+
+        List<GreatArc> arcs = path.getArcs();
+        Assert.assertEquals(4, arcs.size());
+        assertArc(arcs.get(0), d, e);
+        assertArc(arcs.get(1), e, a);
+        assertArc(arcs.get(2), a, b);
+        assertArc(arcs.get(3), b, c);
+
+        assertPoints(Arrays.asList(d, e, a, b, c), path.getVertices());
+    }
+
+    @Test
+    public void testBuilder_appendAndPrepend_mixedArguments() {
+        // arrange
+        Point2S a = Point2S.PLUS_I;
+        Point2S b = Point2S.PLUS_J;
+        Point2S c = Point2S.PLUS_K;
+        Point2S d = Point2S.of(-1, Geometry.HALF_PI);
+        Point2S e = Point2S.of(0, 0.6 * Geometry.PI);
+
+        GreatArcPath.Builder builder = GreatArcPath.builder(TEST_PRECISION);
+
+        // act
+        GreatArcPath path = builder.append(GreatArc.fromPoints(a, b, TEST_PRECISION))
+                .prepend(GreatArc.fromPoints(e, a, TEST_PRECISION))
+                .append(c)
+                .prepend(d)
+                .append(GreatArc.fromPoints(c, d, TEST_PRECISION))
+                .build();
+
+        // assert
+        Assert.assertFalse(path.isEmpty());
+        Assert.assertTrue(path.isClosed());
+
+        SphericalTestUtils.assertPointsEq(d, path.getStartVertex(), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(d, path.getEndVertex(), TEST_EPS);
+
+        List<GreatArc> arcs = path.getArcs();
+        Assert.assertEquals(5, arcs.size());
+        assertArc(arcs.get(0), d, e);
+        assertArc(arcs.get(1), e, a);
+        assertArc(arcs.get(2), a, b);
+        assertArc(arcs.get(3), b, c);
+        assertArc(arcs.get(4), c, d);
+
+        assertPoints(Arrays.asList(d, e, a, b, c, d), path.getVertices());
+    }
+
+    @Test
+    public void testBuilder_points_noPrecisionGiven() {
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            GreatArcPath.builder(null)
+                .append(Point2S.PLUS_I)
+                .append(Point2S.PLUS_J);
+        }, IllegalStateException.class, "Unable to create arc: no point precision specified");
+
+        GeometryTestUtils.assertThrows(() -> {
+            GreatArcPath.builder(null)
+                .prepend(Point2S.PLUS_I)
+                .prepend(Point2S.PLUS_J);
+        }, IllegalStateException.class, "Unable to create arc: no point precision specified");
+    }
+
+    @Test
+    public void testBuilder_arcsNotConnected() {
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            GreatArcPath.builder(TEST_PRECISION)
+                .append(Point2S.PLUS_I)
+                .append(Point2S.PLUS_J)
+                .append(GreatArc.fromPoints(Point2S.PLUS_K, Point2S.MINUS_J, TEST_PRECISION));
+        }, IllegalStateException.class, Pattern.compile("^Path arcs are not connected.*"));
+
+        GeometryTestUtils.assertThrows(() -> {
+            GreatArcPath.builder(TEST_PRECISION)
+                .prepend(Point2S.PLUS_I)
+                .prepend(Point2S.PLUS_J)
+                .prepend(GreatArc.fromPoints(Point2S.PLUS_K, Point2S.MINUS_J, TEST_PRECISION));
+        }, IllegalStateException.class, Pattern.compile("^Path arcs are not connected.*"));
+    }
+
+    @Test
+    public void testBuilder_addToFullArc() {
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            GreatArcPath.builder(TEST_PRECISION)
+                .append(GreatCircle.fromPoints(Point2S.PLUS_I, Point2S.PLUS_J, TEST_PRECISION).span())
+                .append(Point2S.PLUS_J);
+        }, IllegalStateException.class, Pattern.compile("^Cannot add point .* after full arc.*"));
+
+        GeometryTestUtils.assertThrows(() -> {
+            GreatArcPath.builder(TEST_PRECISION)
+                .prepend(GreatCircle.fromPoints(Point2S.PLUS_I, Point2S.PLUS_J, TEST_PRECISION).span())
+                .prepend(Point2S.PLUS_J);
+        }, IllegalStateException.class, Pattern.compile("^Cannot add point .* before full arc.*"));
+    }
+
+    @Test
+    public void testBuilder_onlySinglePointGiven() {
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            GreatArcPath.builder(TEST_PRECISION)
+                .append(Point2S.PLUS_J)
+                .build();
+        }, IllegalStateException.class, Pattern.compile("^Unable to create path; only a single point provided.*"));
+
+        GeometryTestUtils.assertThrows(() -> {
+            GreatArcPath.builder(TEST_PRECISION)
+                .prepend(Point2S.PLUS_J)
+                .build();
+        }, IllegalStateException.class,  Pattern.compile("^Unable to create path; only a single point provided.*"));
+    }
+
+    @Test
+    public void testBuilder_cannotClose() {
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            GreatArcPath.builder(TEST_PRECISION)
+                .append(GreatCircle.fromPoints(Point2S.PLUS_I, Point2S.PLUS_J, TEST_PRECISION).span())
+                .close();
+        }, IllegalStateException.class, "Unable to close path: path is full");
+    }
+
+    @Test
+    public void testToString_empty() {
+        // arrange
+        GreatArcPath path = GreatArcPath.empty();
+
+        // act
+        String str = path.toString();
+
+        // assert
+        Assert.assertEquals("GreatArcPath[empty= true]", str);
+    }
+
+    @Test
+    public void testToString_singleFullArc() {
+        // arrange
+        GreatArcPath path = GreatArcPath.fromArcs(GreatCircle.fromPole(Vector3D.Unit.PLUS_Z, TEST_PRECISION).span());
+
+        // act
+        String str = path.toString();
+
+        // assert
+        GeometryTestUtils.assertContains("GreatArcPath[full= true, circle= GreatCircle[", str);
+    }
+
+    @Test
+    public void testToString_nonFullArcs() {
+        // arrange
+        GreatArcPath path = GreatArcPath.builder(TEST_PRECISION)
+                .append(Point2S.PLUS_I)
+                .append(Point2S.PLUS_J)
+                .build();
+
+        // act
+        String str = path.toString();
+
+        // assert
+        GeometryTestUtils.assertContains("ArcPath[vertices= [", str);
+    }
+
+    private static void assertArc(GreatArc arc, Point2S start, Point2S end) {
+        SphericalTestUtils.assertPointsEq(start, arc.getStartPoint(), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(end, arc.getEndPoint(), TEST_EPS);
+    }
+
+    private static void assertPoints(Collection<Point2S> expected, Collection<Point2S> actual) {
+        Assert.assertEquals(expected.size(), actual.size());
+
+        Iterator<Point2S> expIt = expected.iterator();
+        Iterator<Point2S> actIt = actual.iterator();
+
+        while (expIt.hasNext() && actIt.hasNext()) {
+            SphericalTestUtils.assertPointsEq(expIt.next(), actIt.next(), TEST_EPS);
+        }
+    }
+}
diff --git a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/GreatArcTest.java b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/GreatArcTest.java
new file mode 100644
index 0000000..0263555
--- /dev/null
+++ b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/GreatArcTest.java
@@ -0,0 +1,383 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.twod;
+
+import java.util.List;
+
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.GeometryTestUtils;
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.exception.GeometryException;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.SplitLocation;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.threed.Vector3D;
+import org.apache.commons.geometry.spherical.SphericalTestUtils;
+import org.apache.commons.geometry.spherical.oned.AngularInterval;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class GreatArcTest {
+
+    private static final double TEST_EPS = 1e-10;
+
+    private static final DoublePrecisionContext TEST_PRECISION =
+            new EpsilonDoublePrecisionContext(TEST_EPS);
+
+    @Test
+    public void testFromInterval_full() {
+        // act
+        GreatArc arc = GreatArc.fromInterval(
+                GreatCircle.fromPoints(Point2S.PLUS_I, Point2S.PLUS_J, TEST_PRECISION),
+                AngularInterval.full());
+
+        // assert
+        Assert.assertTrue(arc.isFull());
+        Assert.assertFalse(arc.isEmpty());
+        Assert.assertTrue(arc.isFinite());
+        Assert.assertFalse(arc.isInfinite());
+
+        Assert.assertNull(arc.getStartPoint());
+        Assert.assertNull(arc.getEndPoint());
+
+        Assert.assertEquals(Geometry.TWO_PI, arc.getSize(), TEST_EPS);
+
+        for (double az = 0; az < Geometry.TWO_PI; az += 0.1) {
+            checkClassify(arc, RegionLocation.INSIDE, Point2S.of(az, Geometry.HALF_PI));
+        }
+
+        checkClassify(arc, RegionLocation.OUTSIDE,
+                Point2S.PLUS_K, Point2S.of(0, Geometry.HALF_PI + 0.1),
+                Point2S.MINUS_K, Point2S.of(0, Geometry.HALF_PI - 0.1));
+    }
+
+    @Test
+    public void testFromInterval_partial() {
+        // arrange
+        GreatArc arc = GreatArc.fromInterval(
+                GreatCircle.fromPoints(Point2S.PLUS_J, Point2S.PLUS_K, TEST_PRECISION),
+                AngularInterval.Convex.of(Geometry.HALF_PI, 1.5 * Geometry.PI, TEST_PRECISION));
+
+        // assert
+        Assert.assertFalse(arc.isFull());
+        Assert.assertFalse(arc.isEmpty());
+        Assert.assertTrue(arc.isFinite());
+        Assert.assertFalse(arc.isInfinite());
+
+        checkArc(arc, Point2S.PLUS_K, Point2S.MINUS_K);
+    }
+
+    @Test
+    public void testFromPoints() {
+        // arrange
+        Point2S start = Point2S.PLUS_I;
+        Point2S end = Point2S.MINUS_K;
+
+        // act
+        GreatArc arc = GreatArc.fromPoints(start, end, TEST_PRECISION);
+
+        // assert
+        Assert.assertFalse(arc.isFull());
+        Assert.assertFalse(arc.isEmpty());
+        Assert.assertTrue(arc.isFinite());
+        Assert.assertFalse(arc.isInfinite());
+
+        SphericalTestUtils.assertVectorsEqual(Vector3D.Unit.PLUS_Y, arc.getCircle().getPole(), TEST_EPS);
+
+        checkArc(arc, start, end);
+
+        checkClassify(arc, RegionLocation.INSIDE, Point2S.of(0, 0.75 * Geometry.PI));
+        checkClassify(arc, RegionLocation.BOUNDARY, start, end);
+        checkClassify(arc, RegionLocation.OUTSIDE,
+                Point2S.of(0, 0.25 * Geometry.PI), Point2S.of(Geometry.PI, 0.75 * Geometry.PI),
+                Point2S.of(Geometry.PI, 0.25 * Geometry.PI));
+    }
+
+    @Test
+    public void testFromPoints_almostPi() {
+        // arrange
+        Point2S start = Point2S.PLUS_J;
+        Point2S end = Point2S.of(1.5 * Geometry.PI, Geometry.HALF_PI - 1e-5);
+
+        // act
+        GreatArc arc = GreatArc.fromPoints(start, end, TEST_PRECISION);
+
+        // assert
+        Assert.assertFalse(arc.isFull());
+        Assert.assertFalse(arc.isEmpty());
+        Assert.assertTrue(arc.isFinite());
+        Assert.assertFalse(arc.isInfinite());
+
+        SphericalTestUtils.assertVectorsEqual(Vector3D.Unit.PLUS_X, arc.getCircle().getPole(), TEST_EPS);
+
+        checkArc(arc, start, end);
+
+        checkClassify(arc, RegionLocation.INSIDE, Point2S.PLUS_K);
+        checkClassify(arc, RegionLocation.BOUNDARY, start, end);
+        checkClassify(arc, RegionLocation.OUTSIDE, Point2S.MINUS_K);
+    }
+
+    @Test
+    public void testFromPoints_usesShortestPath() {
+        // act/assert
+        SphericalTestUtils.assertVectorsEqual(
+                Vector3D.Unit.MINUS_Y,
+                GreatArc.fromPoints(
+                        Point2S.PLUS_I,
+                        Point2S.of(Geometry.PI, Geometry.HALF_PI - 1e-5),
+                        TEST_PRECISION).getCircle().getPole(), TEST_EPS);
+
+        SphericalTestUtils.assertVectorsEqual(
+                Vector3D.Unit.PLUS_Y,
+                GreatArc.fromPoints(
+                        Point2S.PLUS_I,
+                        Point2S.of(Geometry.PI, Geometry.HALF_PI + 1e-5),
+                        TEST_PRECISION).getCircle().getPole(), TEST_EPS);
+    }
+
+    @Test
+    public void testFromPoints_invalidPoints() {
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            GreatArc.fromPoints(Point2S.PLUS_I, Point2S.of(1e-12, Geometry.HALF_PI), TEST_PRECISION);
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            GreatArc.fromPoints(Point2S.PLUS_I, Point2S.MINUS_I, TEST_PRECISION);
+        }, GeometryException.class);
+    }
+
+    @Test
+    public void testToConvex() {
+        // arrange
+        GreatArc arc = GreatArc.fromInterval(
+                GreatCircle.fromPoints(Point2S.PLUS_J, Point2S.MINUS_I, TEST_PRECISION),
+                AngularInterval.Convex.of(Geometry.ZERO_PI, Geometry.PI, TEST_PRECISION));
+
+        // act
+        List<GreatArc> result = arc.toConvex();
+
+        // assert
+        Assert.assertEquals(1, result.size());
+        Assert.assertSame(arc, result.get(0));
+    }
+
+    @Test
+    public void testReverse_full() {
+        // arrange
+        GreatArc arc = GreatArc.fromInterval(
+                GreatCircle.fromPoints(Point2S.PLUS_J, Point2S.MINUS_I, TEST_PRECISION),
+                AngularInterval.full());
+
+        // act
+        GreatArc result = arc.reverse();
+
+        // assert
+        checkGreatCircle(result.getCircle(), Vector3D.Unit.MINUS_Z, Vector3D.Unit.PLUS_Y);
+
+        Assert.assertTrue(result.isFull());
+    }
+
+    @Test
+    public void testReverse() {
+        // arrange
+        GreatArc arc = GreatArc.fromInterval(
+                GreatCircle.fromPoints(Point2S.PLUS_J, Point2S.MINUS_I, TEST_PRECISION),
+                AngularInterval.Convex.of(Geometry.HALF_PI, Geometry.PI, TEST_PRECISION));
+
+        // act
+        GreatArc result = arc.reverse();
+
+        // assert
+        checkGreatCircle(result.getCircle(), Vector3D.Unit.MINUS_Z, Vector3D.Unit.PLUS_Y);
+
+        checkArc(result, Point2S.MINUS_J, Point2S.MINUS_I);
+    }
+
+    @Test
+    public void testTransform() {
+        // arrange
+        GreatArc arc = GreatCircle.fromPoints(Point2S.PLUS_K, Point2S.MINUS_I, TEST_PRECISION)
+                .arc(Geometry.PI, Geometry.MINUS_HALF_PI);
+
+        Transform2S t = Transform2S.createRotation(Point2S.PLUS_I, Geometry.HALF_PI)
+                .reflect(Point2S.of(-0.25 * Geometry.PI,  Geometry.HALF_PI));
+
+        // act
+        GreatArc result = arc.transform(t);
+
+        // assert
+        checkArc(result, Point2S.PLUS_I, Point2S.PLUS_J);
+    }
+
+    @Test
+    public void testSplit_full() {
+        // arrange
+        GreatArc arc = GreatCircle.fromPoints(Point2S.PLUS_I, Point2S.PLUS_J, TEST_PRECISION).span();
+        GreatCircle splitter = GreatCircle.fromPole(Vector3D.of(-1, 0, 1), TEST_PRECISION);
+
+        // act
+        Split<GreatArc> split = arc.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        GreatArc minus = split.getMinus();
+        Assert.assertSame(arc.getCircle(), minus.getCircle());
+        checkArc(minus, Point2S.PLUS_J, Point2S.MINUS_J);
+        checkClassify(minus, RegionLocation.OUTSIDE, Point2S.PLUS_I);
+        checkClassify(minus, RegionLocation.INSIDE, Point2S.MINUS_I);
+
+        GreatArc plus = split.getPlus();
+        Assert.assertSame(arc.getCircle(), plus.getCircle());
+        checkArc(plus, Point2S.MINUS_J, Point2S.PLUS_J);
+        checkClassify(plus, RegionLocation.INSIDE, Point2S.PLUS_I);
+        checkClassify(plus, RegionLocation.OUTSIDE, Point2S.MINUS_I);
+    }
+
+    @Test
+    public void testSplit_both() {
+        // arrange
+        GreatArc arc = GreatCircle.fromPoints(Point2S.PLUS_J, Point2S.PLUS_K, TEST_PRECISION)
+                .arc(Geometry.HALF_PI, Geometry.PI);
+        GreatCircle splitter = GreatCircle.fromPole(Vector3D.of(0, 1, 1), TEST_PRECISION);
+
+        // act
+        Split<GreatArc> split = arc.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        GreatArc minus = split.getMinus();
+        Assert.assertSame(arc.getCircle(), minus.getCircle());
+        checkArc(minus, Point2S.of(0, 0), Point2S.of(1.5 * Geometry.PI, 0.25 * Geometry.PI));
+
+        GreatArc plus = split.getPlus();
+        Assert.assertSame(arc.getCircle(), plus.getCircle());
+        checkArc(plus, Point2S.of(1.5 * Geometry.PI, 0.25 * Geometry.PI), Point2S.MINUS_J);
+    }
+
+    @Test
+    public void testSplit_minus() {
+        // arrange
+        GreatArc arc = GreatCircle.fromPoints(Point2S.PLUS_J, Point2S.PLUS_K, TEST_PRECISION)
+                .arc(Geometry.HALF_PI, Geometry.PI);
+        GreatCircle splitter = GreatCircle.fromPole(Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+
+
+        // act
+        Split<GreatArc> split = arc.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.MINUS, split.getLocation());
+
+        GreatArc minus = split.getMinus();
+        Assert.assertSame(arc, minus);
+
+        GreatArc plus = split.getPlus();
+        Assert.assertNull(plus);
+    }
+
+    @Test
+    public void testSplit_plus() {
+        // arrange
+        GreatArc arc = GreatCircle.fromPoints(Point2S.PLUS_J, Point2S.PLUS_K, TEST_PRECISION)
+                .arc(Geometry.HALF_PI, Geometry.PI);
+        GreatCircle splitter = GreatCircle.fromPole(Vector3D.Unit.from(-1, 0, -1), TEST_PRECISION);
+
+        // act
+        Split<GreatArc> split = arc.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.PLUS, split.getLocation());
+
+        GreatArc minus = split.getMinus();
+        Assert.assertNull(minus);
+
+        GreatArc plus = split.getPlus();
+        Assert.assertSame(arc, plus);
+    }
+
+    @Test
+    public void testSplit_parallelAndAntiparallel() {
+        // arrange
+        GreatArc arc = GreatCircle.fromPoints(Point2S.PLUS_I, Point2S.PLUS_J, TEST_PRECISION).span();
+
+        // act/assert
+        Assert.assertEquals(SplitLocation.NEITHER,
+                arc.split(GreatCircle.fromPole(Vector3D.Unit.PLUS_Z, TEST_PRECISION)).getLocation());
+        Assert.assertEquals(SplitLocation.NEITHER,
+                arc.split(GreatCircle.fromPole(Vector3D.Unit.MINUS_Z, TEST_PRECISION)).getLocation());
+    }
+
+    @Test
+    public void testToString_full() {
+        // arrange
+        GreatArc arc = GreatCircle.fromPoints(Point2S.PLUS_I, Point2S.PLUS_J, TEST_PRECISION).span();
+
+        // act
+        String str = arc.toString();
+
+        // assert
+        GeometryTestUtils.assertContains("GreatArc[", str);
+        GeometryTestUtils.assertContains("full= true", str);
+        GeometryTestUtils.assertContains("circle= GreatCircle[", str);
+    }
+
+    @Test
+    public void testToString_notFull() {
+        // arrange
+        GreatArc arc = GreatArc.fromInterval(
+                GreatCircle.fromPoints(Point2S.PLUS_I, Point2S.PLUS_J, TEST_PRECISION),
+                AngularInterval.Convex.of(1, 2, TEST_PRECISION));
+
+        // act
+        String str = arc.toString();
+
+        // assert
+        GeometryTestUtils.assertContains("GreatArc[", str);
+        GeometryTestUtils.assertContains("start= (", str);
+        GeometryTestUtils.assertContains("end= (", str);
+    }
+
+    private static void checkClassify(GreatArc arc, RegionLocation loc, Point2S ... pts) {
+        for (Point2S pt : pts) {
+            Assert.assertEquals("Unexpected location for point " + pt, loc, arc.classify(pt));
+        }
+    }
+
+    private static void checkArc(GreatArc arc, Point2S start, Point2S end) {
+        SphericalTestUtils.assertPointsEq(start, arc.getStartPoint(), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(end, arc.getEndPoint(), TEST_EPS);
+
+        checkClassify(arc, RegionLocation.BOUNDARY, start, end);
+
+        Point2S mid = arc.getCircle().toSpace(arc.getInterval().getMidPoint());
+
+        checkClassify(arc, RegionLocation.INSIDE, mid);
+        checkClassify(arc, RegionLocation.OUTSIDE, mid.antipodal());
+
+        Assert.assertEquals(start.distance(end), arc.getSize(), TEST_EPS);
+    }
+
+    private static void checkGreatCircle(GreatCircle circle, Vector3D pole, Vector3D x) {
+        SphericalTestUtils.assertVectorsEqual(pole, circle.getPole(), TEST_EPS);
+        SphericalTestUtils.assertVectorsEqual(x, circle.getU(), TEST_EPS);
+        SphericalTestUtils.assertVectorsEqual(pole.cross(x), circle.getV(), TEST_EPS);
+    }
+}
diff --git a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/GreatCircleTest.java b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/GreatCircleTest.java
new file mode 100644
index 0000000..6835862
--- /dev/null
+++ b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/GreatCircleTest.java
@@ -0,0 +1,753 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.twod;
+
+import java.util.regex.Pattern;
+
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.GeometryTestUtils;
+import org.apache.commons.geometry.core.exception.GeometryException;
+import org.apache.commons.geometry.core.partitioning.HyperplaneLocation;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.threed.Vector3D;
+import org.apache.commons.geometry.spherical.SphericalTestUtils;
+import org.apache.commons.geometry.spherical.oned.AngularInterval;
+import org.apache.commons.geometry.spherical.oned.Point1S;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class GreatCircleTest {
+
+    private static final double TEST_EPS = 1e-10;
+
+    private static final DoublePrecisionContext TEST_PRECISION =
+            new EpsilonDoublePrecisionContext(TEST_EPS);
+
+    private static final Vector3D.Unit X = Vector3D.Unit.PLUS_X;
+    private static final Vector3D.Unit Y = Vector3D.Unit.PLUS_Y;
+    private static final Vector3D.Unit Z = Vector3D.Unit.PLUS_Z;
+
+    @Test
+    public void testFromPole() {
+        // act/assert
+        checkGreatCircle(GreatCircle.fromPole(X, TEST_PRECISION), X, Z);
+        checkGreatCircle(GreatCircle.fromPole(Y, TEST_PRECISION), Y, Z.negate());
+        checkGreatCircle(GreatCircle.fromPole(Z, TEST_PRECISION), Z, Y);
+    }
+
+    @Test
+    public void testFromPoleAndXAxis() {
+        // act/assert
+        checkGreatCircle(GreatCircle.fromPoleAndU(X, Y, TEST_PRECISION), X, Y);
+        checkGreatCircle(GreatCircle.fromPoleAndU(X, Z, TEST_PRECISION), X, Z);
+        checkGreatCircle(GreatCircle.fromPoleAndU(Y, Z, TEST_PRECISION), Y, Z);
+    }
+
+    @Test
+    public void testFromPoints() {
+        // act/assert
+        checkGreatCircle(GreatCircle.fromPoints(
+                    Point2S.of(0, Geometry.HALF_PI),
+                    Point2S.of(Geometry.HALF_PI, Geometry.HALF_PI),
+                    TEST_PRECISION),
+                Z, X);
+
+        checkGreatCircle(GreatCircle.fromPoints(
+                Point2S.of(0, Geometry.HALF_PI),
+                Point2S.of(-0.1 * Geometry.PI, Geometry.HALF_PI),
+                TEST_PRECISION),
+            Z.negate(), X);
+
+        checkGreatCircle(GreatCircle.fromPoints(
+                Point2S.of(0, Geometry.HALF_PI),
+                Point2S.of(1.5 * Geometry.PI, Geometry.HALF_PI),
+                TEST_PRECISION),
+            Z.negate(), X);
+
+        checkGreatCircle(GreatCircle.fromPoints(
+                Point2S.of(0, 0),
+                Point2S.of(0, Geometry.HALF_PI),
+                TEST_PRECISION),
+            Y, Z);
+    }
+
+    @Test
+    public void testFromPoints_invalidPoints() {
+        // arrange
+        Point2S p1 = Point2S.of(0, Geometry.HALF_PI);
+        Point2S p2 = Point2S.of(Geometry.PI, Geometry.HALF_PI);
+
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            GreatCircle.fromPoints(p1, p1, TEST_PRECISION);
+        }, GeometryException.class, Pattern.compile("^.*points are equal$"));
+
+        GeometryTestUtils.assertThrows(() -> {
+            GreatCircle.fromPoints(p1, Point2S.of(1e-12, Geometry.HALF_PI), TEST_PRECISION);
+        }, GeometryException.class, Pattern.compile("^.*points are equal$"));
+
+        GeometryTestUtils.assertThrows(() -> {
+            GreatCircle.fromPoints(
+                    Point2S.from(Vector3D.Unit.PLUS_X),
+                    Point2S.from(Vector3D.Unit.MINUS_X),
+                    TEST_PRECISION);
+        }, GeometryException.class, Pattern.compile("^.*points are antipodal$"));
+
+        GeometryTestUtils.assertThrows(() -> {
+            GreatCircle.fromPoints(p1, Point2S.NaN, TEST_PRECISION);
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            GreatCircle.fromPoints(Point2S.NaN, p2, TEST_PRECISION);
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            GreatCircle.fromPoints(p1, Point2S.of(Double.POSITIVE_INFINITY, Geometry.HALF_PI), TEST_PRECISION);
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            GreatCircle.fromPoints(Point2S.of(Double.POSITIVE_INFINITY, Geometry.HALF_PI), p2, TEST_PRECISION);
+        }, GeometryException.class);
+    }
+
+    @Test
+    public void testOffset_point() {
+        // --- arrange
+        GreatCircle circle = GreatCircle.fromPoleAndU(
+                Vector3D.Unit.MINUS_X, Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+
+        // --- act/assert
+
+        // on circle
+        for (double polar = Geometry.MINUS_HALF_PI; polar <= Geometry.HALF_PI; polar += 0.1) {
+            Assert.assertEquals(0, circle.offset(Point2S.of(Geometry.HALF_PI, polar)), TEST_EPS);
+            Assert.assertEquals(0, circle.offset(Point2S.of(Geometry.MINUS_HALF_PI, polar)), TEST_EPS);
+        }
+
+        // +1/-1
+        Assert.assertEquals(-1, circle.offset(Point2S.of(Geometry.HALF_PI + 1, Geometry.HALF_PI)), TEST_EPS);
+        Assert.assertEquals(1, circle.offset(Point2S.of(Geometry.MINUS_HALF_PI + 1, Geometry.HALF_PI)), TEST_EPS);
+
+        // poles
+        Assert.assertEquals(Geometry.MINUS_HALF_PI, circle.offset(Point2S.of(Geometry.PI, Geometry.HALF_PI)), TEST_EPS);
+        Assert.assertEquals(Geometry.HALF_PI, circle.offset(Point2S.of(Geometry.ZERO_PI, Geometry.HALF_PI)), TEST_EPS);
+    }
+
+    @Test
+    public void testOffset_vector() {
+        // --- arrange
+        GreatCircle circle = GreatCircle.fromPoleAndU(
+                Vector3D.Unit.MINUS_X, Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+
+        // --- act/assert
+
+        // on circle
+        Assert.assertEquals(0, circle.offset(Vector3D.of(0, 1, 0)), TEST_EPS);
+        Assert.assertEquals(0, circle.offset(Vector3D.of(0, 0, 1)), TEST_EPS);
+        Assert.assertEquals(0, circle.offset(Vector3D.of(0, -1, 0)), TEST_EPS);
+        Assert.assertEquals(0, circle.offset(Vector3D.of(0, 0, -1)), TEST_EPS);
+
+        // +1/-1
+        Assert.assertEquals(-0.25 * Geometry.PI, circle.offset(Vector3D.of(-1, 1, 0)), TEST_EPS);
+        Assert.assertEquals(-0.25 * Geometry.PI, circle.offset(Vector3D.of(-1, 0, 1)), TEST_EPS);
+        Assert.assertEquals(-0.25 * Geometry.PI, circle.offset(Vector3D.of(-1, -1, 0)), TEST_EPS);
+        Assert.assertEquals(-0.25 * Geometry.PI, circle.offset(Vector3D.of(-1, 0, -1)), TEST_EPS);
+
+        Assert.assertEquals(0.25 * Geometry.PI, circle.offset(Vector3D.of(1, 1, 0)), TEST_EPS);
+        Assert.assertEquals(0.25 * Geometry.PI, circle.offset(Vector3D.of(1, 0, 1)), TEST_EPS);
+        Assert.assertEquals(0.25 * Geometry.PI, circle.offset(Vector3D.of(1, -1, 0)), TEST_EPS);
+        Assert.assertEquals(0.25 * Geometry.PI, circle.offset(Vector3D.of(1, 0, -1)), TEST_EPS);
+
+        // poles
+        Assert.assertEquals(Geometry.MINUS_HALF_PI, circle.offset(Vector3D.Unit.MINUS_X), TEST_EPS);
+        Assert.assertEquals(Geometry.HALF_PI, circle.offset(Vector3D.Unit.PLUS_X), TEST_EPS);
+    }
+
+    @Test
+    public void testAzimuth_point() {
+        // --- arrange
+        GreatCircle circle = GreatCircle.fromPoleAndU(
+                Vector3D.Unit.MINUS_X, Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+
+        // --- act/assert
+
+        // on circle
+        Assert.assertEquals(Geometry.HALF_PI, circle.azimuth(Point2S.from(Vector3D.of(0, 1, 0))), TEST_EPS);
+        Assert.assertEquals(Geometry.ZERO_PI, circle.azimuth(Point2S.from(Vector3D.of(0, 0, 1))), TEST_EPS);
+        Assert.assertEquals(1.5 * Geometry.PI, circle.azimuth(Point2S.from(Vector3D.of(0, -1, 0))), TEST_EPS);
+        Assert.assertEquals(Geometry.PI, circle.azimuth(Point2S.from(Vector3D.of(0, 0, -1))), TEST_EPS);
+
+        // +1/-1
+        Assert.assertEquals(Geometry.HALF_PI, circle.azimuth(Point2S.from(Vector3D.of(-1, 1, 0))), TEST_EPS);
+        Assert.assertEquals(Geometry.ZERO_PI, circle.azimuth(Point2S.from(Vector3D.of(-1, 0, 1))), TEST_EPS);
+        Assert.assertEquals(1.5 * Geometry.PI, circle.azimuth(Point2S.from(Vector3D.of(-1, -1, 0))), TEST_EPS);
+        Assert.assertEquals(Geometry.PI, circle.azimuth(Point2S.from(Vector3D.of(-1, 0, -1))), TEST_EPS);
+
+        Assert.assertEquals(Geometry.HALF_PI, circle.azimuth(Point2S.from(Vector3D.of(1, 1, 0))), TEST_EPS);
+        Assert.assertEquals(Geometry.ZERO_PI, circle.azimuth(Point2S.from(Vector3D.of(1, 0, 1))), TEST_EPS);
+        Assert.assertEquals(1.5 * Geometry.PI, circle.azimuth(Point2S.from(Vector3D.of(1, -1, 0))), TEST_EPS);
+        Assert.assertEquals(Geometry.PI, circle.azimuth(Point2S.from(Vector3D.of(1, 0, -1))), TEST_EPS);
+
+        // poles
+        Assert.assertEquals(0, circle.azimuth(Point2S.from(Vector3D.Unit.MINUS_X)), TEST_EPS);
+        Assert.assertEquals(0, circle.azimuth(Point2S.from(Vector3D.Unit.PLUS_X)), TEST_EPS);
+    }
+
+    @Test
+    public void testAzimuth_vector() {
+        // --- arrange
+        GreatCircle circle = GreatCircle.fromPoleAndU(
+                Vector3D.Unit.MINUS_X, Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+
+        // --- act/assert
+
+        // on circle
+        Assert.assertEquals(Geometry.HALF_PI, circle.azimuth(Vector3D.of(0, 1, 0)), TEST_EPS);
+        Assert.assertEquals(Geometry.ZERO_PI, circle.azimuth(Vector3D.of(0, 0, 1)), TEST_EPS);
+        Assert.assertEquals(1.5 * Geometry.PI, circle.azimuth(Vector3D.of(0, -1, 0)), TEST_EPS);
+        Assert.assertEquals(Geometry.PI, circle.azimuth(Vector3D.of(0, 0, -1)), TEST_EPS);
+
+        // +1/-1
+        Assert.assertEquals(Geometry.HALF_PI, circle.azimuth(Vector3D.of(-1, 1, 0)), TEST_EPS);
+        Assert.assertEquals(Geometry.ZERO_PI, circle.azimuth(Vector3D.of(-1, 0, 1)), TEST_EPS);
+        Assert.assertEquals(1.5 * Geometry.PI, circle.azimuth(Vector3D.of(-1, -1, 0)), TEST_EPS);
+        Assert.assertEquals(Geometry.PI, circle.azimuth(Vector3D.of(-1, 0, -1)), TEST_EPS);
+
+        Assert.assertEquals(Geometry.HALF_PI, circle.azimuth(Vector3D.of(1, 1, 0)), TEST_EPS);
+        Assert.assertEquals(Geometry.ZERO_PI, circle.azimuth(Vector3D.of(1, 0, 1)), TEST_EPS);
+        Assert.assertEquals(1.5 * Geometry.PI, circle.azimuth(Vector3D.of(1, -1, 0)), TEST_EPS);
+        Assert.assertEquals(Geometry.PI, circle.azimuth(Vector3D.of(1, 0, -1)), TEST_EPS);
+
+        // poles
+        Assert.assertEquals(0, circle.azimuth(Vector3D.Unit.MINUS_X), TEST_EPS);
+        Assert.assertEquals(0, circle.azimuth(Vector3D.Unit.PLUS_X), TEST_EPS);
+    }
+
+    @Test
+    public void testVectorAt() {
+        // arrange
+        GreatCircle circle = GreatCircle.fromPoleAndU(
+                Vector3D.Unit.MINUS_X, Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+
+        // act/assert
+        SphericalTestUtils.assertVectorsEqual(Vector3D.Unit.PLUS_Z, circle.vectorAt(Geometry.ZERO_PI), TEST_EPS);
+        SphericalTestUtils.assertVectorsEqual(Vector3D.Unit.PLUS_Y, circle.vectorAt(Geometry.HALF_PI), TEST_EPS);
+        SphericalTestUtils.assertVectorsEqual(Vector3D.Unit.MINUS_Z, circle.vectorAt(Geometry.PI), TEST_EPS);
+        SphericalTestUtils.assertVectorsEqual(Vector3D.Unit.MINUS_Y, circle.vectorAt(Geometry.MINUS_HALF_PI), TEST_EPS);
+        SphericalTestUtils.assertVectorsEqual(Vector3D.Unit.PLUS_Z, circle.vectorAt(Geometry.TWO_PI), TEST_EPS);
+    }
+
+    @Test
+    public void testProject() {
+        // arrange
+        GreatCircle circle = GreatCircle.fromPoleAndU(
+                Vector3D.Unit.MINUS_X, Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+
+        // act/assert
+        SphericalTestUtils.assertPointsEqual(Point2S.of(Geometry.HALF_PI, Geometry.HALF_PI),
+                circle.project(Point2S.of(Geometry.HALF_PI, Geometry.HALF_PI)), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point2S.of(Geometry.HALF_PI, Geometry.HALF_PI),
+                circle.project(Point2S.of(Geometry.HALF_PI + 1, Geometry.HALF_PI)), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point2S.of(Geometry.HALF_PI, Geometry.HALF_PI),
+                circle.project(Point2S.of(Geometry.HALF_PI - 1, Geometry.HALF_PI)), TEST_EPS);
+
+        SphericalTestUtils.assertPointsEqual(Point2S.of(Geometry.MINUS_HALF_PI, Geometry.HALF_PI),
+                circle.project(Point2S.of(Geometry.MINUS_HALF_PI, Geometry.HALF_PI)), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point2S.of(Geometry.MINUS_HALF_PI, Geometry.HALF_PI),
+                circle.project(Point2S.of(Geometry.MINUS_HALF_PI + 1, Geometry.HALF_PI)), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point2S.of(Geometry.MINUS_HALF_PI, Geometry.HALF_PI),
+                circle.project(Point2S.of(Geometry.MINUS_HALF_PI - 1, Geometry.HALF_PI)), TEST_EPS);
+    }
+
+    @Test
+    public void testProject_poles() {
+        // arrange
+        GreatCircle minusXCircle = GreatCircle.fromPoleAndU(
+                Vector3D.Unit.MINUS_X, Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+        GreatCircle plusZCircle = GreatCircle.fromPoleAndU(
+                Vector3D.Unit.PLUS_Z, Vector3D.Unit.MINUS_Y, TEST_PRECISION);
+
+        // act
+        SphericalTestUtils.assertPointsEqual(Point2S.of(Geometry.ZERO_PI, Geometry.ZERO_PI),
+                minusXCircle.project(Point2S.from(Vector3D.Unit.MINUS_X)), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point2S.of(Geometry.ZERO_PI, Geometry.ZERO_PI),
+                minusXCircle.project(Point2S.from(Vector3D.Unit.PLUS_X)), TEST_EPS);
+
+        SphericalTestUtils.assertPointsEqual(Point2S.of(1.5 * Geometry.PI, Geometry.HALF_PI),
+                plusZCircle.project(Point2S.from(Vector3D.Unit.PLUS_Z)), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point2S.of(1.5 * Geometry.PI, Geometry.HALF_PI),
+                plusZCircle.project(Point2S.from(Vector3D.Unit.MINUS_Z)), TEST_EPS);
+    }
+
+    @Test
+    public void testReverse() {
+        // arrange
+        GreatCircle circle = GreatCircle.fromPoleAndU(Vector3D.Unit.PLUS_Z, Vector3D.Unit.PLUS_X, TEST_PRECISION);
+
+        // act
+        GreatCircle reverse = circle.reverse();
+
+        // assert
+        checkGreatCircle(reverse, Vector3D.Unit.MINUS_Z, Vector3D.Unit.PLUS_X);
+    }
+
+    @Test
+    public void testTransform_rotateAroundPole() {
+        // arrange
+        GreatCircle circle = GreatCircle.fromPoints(
+                Point2S.of(0, Geometry.HALF_PI),
+                Point2S.of(1, Geometry.HALF_PI),
+                TEST_PRECISION);
+
+        Transform2S t = Transform2S.createRotation(circle.getPolePoint(), 0.25 * Geometry.PI);
+
+        // act
+        GreatCircle result = circle.transform(t);
+
+        // assert
+        Assert.assertNotSame(circle, result);
+        checkGreatCircle(result, Vector3D.Unit.PLUS_Z, Vector3D.Unit.from(1, 1, 0));
+    }
+
+    @Test
+    public void testTransform_rotateAroundNonPole() {
+        // arrange
+        GreatCircle circle = GreatCircle.fromPoints(
+                Point2S.of(0, Geometry.HALF_PI),
+                Point2S.of(1, Geometry.HALF_PI),
+                TEST_PRECISION);
+
+        Transform2S t = Transform2S.createRotation(Point2S.of(0, Geometry.HALF_PI), Geometry.HALF_PI);
+
+        // act
+        GreatCircle result = circle.transform(t);
+
+        // assert
+        Assert.assertNotSame(circle, result);
+        checkGreatCircle(result, Vector3D.Unit.MINUS_Y, Vector3D.Unit.PLUS_X);
+    }
+
+    @Test
+    public void testTransform_piMinusAzimuth() {
+        // arrange
+        GreatCircle circle = GreatCircle.fromPoints(
+                Point2S.of(0, Geometry.HALF_PI),
+                Point2S.of(1, Geometry.HALF_PI),
+                TEST_PRECISION);
+
+        Transform2S t = Transform2S.createReflection(Point2S.PLUS_J)
+                .rotate(Point2S.PLUS_K, Geometry.PI);
+
+        // act
+        GreatCircle result = circle.transform(t);
+
+        // assert
+        Assert.assertNotSame(circle, result);
+        checkGreatCircle(result, Vector3D.Unit.MINUS_Z, Vector3D.Unit.MINUS_X);
+    }
+
+    @Test
+    public void testSimilarOrientation() {
+        // arrange
+        GreatCircle a = GreatCircle.fromPole(Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+        GreatCircle b = GreatCircle.fromPole(Vector3D.Unit.PLUS_X, TEST_PRECISION);
+        GreatCircle c = GreatCircle.fromPole(Vector3D.Unit.MINUS_Z, TEST_PRECISION);
+        GreatCircle d = GreatCircle.fromPole(Vector3D.Unit.from(1, 1, -1), TEST_PRECISION);
+        GreatCircle e = GreatCircle.fromPole(Vector3D.Unit.from(1, 1, 1), TEST_PRECISION);
+
+        // act/assert
+        Assert.assertTrue(a.similarOrientation(a));
+
+        Assert.assertFalse(a.similarOrientation(b));
+        Assert.assertFalse(a.similarOrientation(c));
+        Assert.assertFalse(a.similarOrientation(d));
+
+        Assert.assertTrue(a.similarOrientation(e));
+    }
+
+    @Test
+    public void testSpan() {
+        // arrange
+        GreatCircle circle = GreatCircle.fromPoleAndU(Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+
+        // act
+        GreatArc span = circle.span();
+
+        // assert
+        Assert.assertSame(circle, span.getCircle());
+        Assert.assertTrue(span.getInterval().isFull());
+
+        Assert.assertNull(span.getStartPoint());
+        Assert.assertNull(span.getEndPoint());
+    }
+
+    @Test
+    public void testArc_points_2s() {
+        // arrange
+        GreatCircle circle = GreatCircle.fromPoleAndU(Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+
+        // act/assert
+        checkArc(circle.arc(Point2S.of(1, Geometry.HALF_PI), Point2S.of(0, 1)),
+                Point2S.of(Geometry.HALF_PI, Geometry.HALF_PI), Point2S.of(0, 0));
+
+        Assert.assertTrue(circle.arc(Point2S.PLUS_I, Point2S.PLUS_I).isFull());
+    }
+
+    @Test
+    public void testArc_points_1s() {
+        // arrange
+        GreatCircle circle = GreatCircle.fromPoleAndU(Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+
+        // act/assert
+        checkArc(circle.arc(Point1S.of(Geometry.PI), Point1S.of(1.5 * Geometry.PI)),
+                Point2S.of(0, Geometry.PI), Point2S.of(Geometry.HALF_PI, Geometry.HALF_PI));
+
+        Assert.assertTrue(circle.arc(Point1S.of(1), Point1S.of(1)).isFull());
+    }
+
+    @Test
+    public void testArc_azimuths() {
+        // arrange
+        GreatCircle circle = GreatCircle.fromPoleAndU(Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+
+        // act/assert
+        checkArc(circle.arc(Geometry.PI, 1.5 * Geometry.PI),
+                Point2S.of(0, Geometry.PI), Point2S.of(Geometry.HALF_PI, Geometry.HALF_PI));
+
+        Assert.assertTrue(circle.arc(1, 1).isFull());
+    }
+
+    @Test
+    public void testArc_interval() {
+        // arrange
+        GreatCircle circle = GreatCircle.fromPoleAndU(Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+        AngularInterval.Convex interval = AngularInterval.Convex.of(1, 2, TEST_PRECISION);
+
+        // act
+        GreatArc arc = circle.arc(interval);
+
+        // assert
+        Assert.assertSame(circle, arc.getCircle());
+        Assert.assertSame(interval, arc.getInterval());
+    }
+
+    @Test
+    public void testIntersection_parallel() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-3);
+
+        GreatCircle a = GreatCircle.fromPole(Vector3D.Unit.PLUS_X, precision);
+        GreatCircle b = GreatCircle.fromPole(Vector3D.Unit.PLUS_X, precision);
+        GreatCircle c = GreatCircle.fromPole(Vector3D.Unit.of(1, 1e-4, 1e-4), precision);
+        GreatCircle d = GreatCircle.fromPole(Vector3D.Unit.MINUS_X, precision);
+        GreatCircle e = GreatCircle.fromPole(Vector3D.Unit.of(-1, 1e-4, 1e-4), precision);
+
+        // act/assert
+        Assert.assertNull(a.intersection(b));
+        Assert.assertNull(a.intersection(c));
+        Assert.assertNull(a.intersection(d));
+        Assert.assertNull(a.intersection(e));
+    }
+
+    @Test
+    public void testIntersection() {
+        // arrange
+        GreatCircle a = GreatCircle.fromPole(Vector3D.Unit.PLUS_X, TEST_PRECISION);
+        GreatCircle b = GreatCircle.fromPole(Vector3D.Unit.PLUS_Y, TEST_PRECISION);
+        GreatCircle c = GreatCircle.fromPole(Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+
+        // act/assert
+        SphericalTestUtils.assertVectorsEqual(Vector3D.Unit.PLUS_Z,
+                a.intersection(b).getVector(), TEST_EPS);
+        SphericalTestUtils.assertVectorsEqual(Vector3D.Unit.MINUS_Z,
+                b.intersection(a).getVector(), TEST_EPS);
+
+        SphericalTestUtils.assertVectorsEqual(Vector3D.Unit.PLUS_X,
+                b.intersection(c).getVector(), TEST_EPS);
+        SphericalTestUtils.assertVectorsEqual(Vector3D.Unit.MINUS_X,
+                c.intersection(b).getVector(), TEST_EPS);
+    }
+
+    @Test
+    public void testAngle_withoutReferencePoint() {
+     // arrange
+        GreatCircle a = GreatCircle.fromPoints(Point2S.PLUS_I, Point2S.PLUS_J, TEST_PRECISION);
+        GreatCircle b = GreatCircle.fromPoints(Point2S.PLUS_J, Point2S.PLUS_I, TEST_PRECISION);
+        GreatCircle c = GreatCircle.fromPoints(Point2S.PLUS_I, Point2S.PLUS_K, TEST_PRECISION);
+        GreatCircle d = GreatCircle.fromPoints(Point2S.PLUS_J, Point2S.PLUS_K, TEST_PRECISION);
+        GreatCircle e = GreatCircle.fromPoleAndU(
+                Vector3D.Unit.of(1, 0, 1),
+                Vector3D.Unit.PLUS_Y,
+                TEST_PRECISION);
+
+        GreatCircle f = GreatCircle.fromPoleAndU(
+                Vector3D.Unit.of(1, 0, -1),
+                Vector3D.Unit.PLUS_Y,
+                TEST_PRECISION);
+
+        // act/assert
+        Assert.assertEquals(0, a.angle(a), TEST_EPS);
+        Assert.assertEquals(Geometry.PI, a.angle(b), TEST_EPS);
+
+        Assert.assertEquals(Geometry.HALF_PI, a.angle(c), TEST_EPS);
+        Assert.assertEquals(Geometry.HALF_PI, c.angle(a), TEST_EPS);
+
+        Assert.assertEquals(Geometry.HALF_PI, a.angle(d), TEST_EPS);
+        Assert.assertEquals(Geometry.HALF_PI, d.angle(a), TEST_EPS);
+
+        Assert.assertEquals(0.25 * Geometry.PI, a.angle(e), TEST_EPS);
+        Assert.assertEquals(0.25 * Geometry.PI, e.angle(a), TEST_EPS);
+
+        Assert.assertEquals(0.75 * Geometry.PI, a.angle(f), TEST_EPS);
+        Assert.assertEquals(0.75 * Geometry.PI, f.angle(a), TEST_EPS);
+    }
+
+    @Test
+    public void testAngle_withReferencePoint() {
+        // arrange
+        GreatCircle a = GreatCircle.fromPoints(Point2S.PLUS_I, Point2S.PLUS_J, TEST_PRECISION);
+        GreatCircle b = GreatCircle.fromPoints(Point2S.PLUS_J, Point2S.PLUS_I, TEST_PRECISION);
+        GreatCircle c = GreatCircle.fromPoints(Point2S.PLUS_I, Point2S.PLUS_K, TEST_PRECISION);
+        GreatCircle d = GreatCircle.fromPoints(Point2S.PLUS_J, Point2S.PLUS_K, TEST_PRECISION);
+        GreatCircle e = GreatCircle.fromPoleAndU(
+                Vector3D.Unit.of(1, 0, 1),
+                Vector3D.Unit.PLUS_Y,
+                TEST_PRECISION);
+
+        GreatCircle f = GreatCircle.fromPoleAndU(
+                Vector3D.Unit.of(1, 0, -1),
+                Vector3D.Unit.PLUS_Y,
+                TEST_PRECISION);
+
+        // act/assert
+        Assert.assertEquals(0, a.angle(a, Point2S.PLUS_J), TEST_EPS);
+        Assert.assertEquals(0, a.angle(a, Point2S.MINUS_J), TEST_EPS);
+
+        Assert.assertEquals(-Geometry.PI, a.angle(b, Point2S.PLUS_J), TEST_EPS);
+        Assert.assertEquals(-Geometry.PI, a.angle(b, Point2S.MINUS_J), TEST_EPS);
+
+        Assert.assertEquals(Geometry.HALF_PI, a.angle(c, Point2S.PLUS_I), TEST_EPS);
+        Assert.assertEquals(-Geometry.HALF_PI, a.angle(c, Point2S.MINUS_I), TEST_EPS);
+
+        Assert.assertEquals(-Geometry.HALF_PI, c.angle(a, Point2S.PLUS_I), TEST_EPS);
+        Assert.assertEquals(Geometry.HALF_PI, c.angle(a, Point2S.MINUS_I), TEST_EPS);
+
+        Assert.assertEquals(Geometry.HALF_PI, a.angle(d, Point2S.PLUS_J), TEST_EPS);
+        Assert.assertEquals(-Geometry.HALF_PI, a.angle(d, Point2S.MINUS_J), TEST_EPS);
+
+        Assert.assertEquals(-Geometry.HALF_PI, d.angle(a, Point2S.PLUS_J), TEST_EPS);
+        Assert.assertEquals(Geometry.HALF_PI, d.angle(a, Point2S.MINUS_J), TEST_EPS);
+
+        Assert.assertEquals(0.25 * Geometry.PI, a.angle(e, Point2S.PLUS_J), TEST_EPS);
+        Assert.assertEquals(-0.25 * Geometry.PI, a.angle(e, Point2S.MINUS_J), TEST_EPS);
+
+        Assert.assertEquals(-0.25 * Geometry.PI, e.angle(a, Point2S.PLUS_J), TEST_EPS);
+        Assert.assertEquals(0.25 * Geometry.PI, e.angle(a, Point2S.MINUS_J), TEST_EPS);
+
+        Assert.assertEquals(0.75 * Geometry.PI, a.angle(f, Point2S.PLUS_J), TEST_EPS);
+        Assert.assertEquals(-0.75 * Geometry.PI, a.angle(f, Point2S.MINUS_J), TEST_EPS);
+
+        Assert.assertEquals(-0.75 * Geometry.PI, f.angle(a, Point2S.PLUS_J), TEST_EPS);
+        Assert.assertEquals(0.75 * Geometry.PI, f.angle(a, Point2S.MINUS_J), TEST_EPS);
+    }
+
+    @Test
+    public void testAngle_withReferencePoint_pointEquidistanceFromIntersections() {
+        // arrange
+        GreatCircle a = GreatCircle.fromPoints(Point2S.PLUS_I, Point2S.PLUS_J, TEST_PRECISION);
+        GreatCircle b = GreatCircle.fromPoleAndU(
+                Vector3D.Unit.of(1, 0, 1),
+                Vector3D.Unit.PLUS_Y,
+                TEST_PRECISION);
+
+        // act/assert
+        Assert.assertEquals(-0.25 * Geometry.PI, a.angle(b, Point2S.PLUS_I), TEST_EPS);
+        Assert.assertEquals(-0.25 * Geometry.PI, a.angle(b, Point2S.MINUS_I), TEST_EPS);
+    }
+
+    @Test
+    public void testToSubspace() {
+        // arrange
+        GreatCircle circle = GreatCircle.fromPoleAndU(Vector3D.Unit.PLUS_Y, Vector3D.Unit.MINUS_Z, TEST_PRECISION);
+
+        // act/assert
+        SphericalTestUtils.assertPointsEqual(Point1S.ZERO,
+                circle.toSubspace(Point2S.from(Vector3D.Unit.MINUS_Z)), TEST_EPS);
+
+        SphericalTestUtils.assertPointsEqual(Point1S.of(0.25 * Geometry.PI),
+                circle.toSubspace(Point2S.from(Vector3D.of(-1, -1, -1))), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point1S.of(0.75 * Geometry.PI),
+                circle.toSubspace(Point2S.from(Vector3D.of(-1, 1, 1))), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point1S.of(1.25 * Geometry.PI),
+                circle.toSubspace(Point2S.from(Vector3D.of(1, -1, 1))), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point1S.of(1.75 * Geometry.PI),
+                circle.toSubspace(Point2S.from(Vector3D.of(1, 1, -1))), TEST_EPS);
+
+        SphericalTestUtils.assertPointsEqual(Point1S.ZERO,
+                circle.toSubspace(Point2S.from(Vector3D.Unit.PLUS_Y)), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point1S.ZERO,
+                circle.toSubspace(Point2S.from(Vector3D.Unit.MINUS_Y)), TEST_EPS);
+    }
+
+    @Test
+    public void testToSpace() {
+        // arrange
+        GreatCircle circle = GreatCircle.fromPoleAndU(Vector3D.Unit.PLUS_Y, Vector3D.Unit.MINUS_Z, TEST_PRECISION);
+
+        // act/assert
+        SphericalTestUtils.assertPointsEqual(Point2S.from(Vector3D.Unit.MINUS_Z),
+                circle.toSpace(Point1S.ZERO), TEST_EPS);
+
+        SphericalTestUtils.assertPointsEqual(Point2S.from(Vector3D.of(-1, 0, -1)),
+                circle.toSpace(Point1S.of(0.25 * Geometry.PI)), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point2S.from(Vector3D.of(-1, 0, 1)),
+                circle.toSpace(Point1S.of(0.75 * Geometry.PI)), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point2S.from(Vector3D.of(1, 0, 1)),
+                circle.toSpace(Point1S.of(1.25 * Geometry.PI)), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point2S.from(Vector3D.of(1, 0, -1)),
+                circle.toSpace(Point1S.of(1.75 * Geometry.PI)), TEST_EPS);
+    }
+
+    @Test
+    public void testEq() {
+        // arrange
+        double eps = 1e-3;
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(eps);
+
+        GreatCircle a = GreatCircle.fromPoleAndU(Vector3D.Unit.PLUS_Z, Vector3D.Unit.PLUS_X, precision);
+
+        GreatCircle b = GreatCircle.fromPoleAndU(Vector3D.Unit.MINUS_Z, Vector3D.Unit.PLUS_X, precision);
+        GreatCircle c = GreatCircle.fromPoleAndU(Vector3D.Unit.PLUS_Z, Vector3D.Unit.MINUS_X, precision);
+        GreatCircle d = GreatCircle.fromPoleAndU(Vector3D.Unit.PLUS_Z, Vector3D.Unit.PLUS_X, TEST_PRECISION);
+
+        GreatCircle e = GreatCircle.fromPoleAndU(Vector3D.of(1e-6, 0, 1), Vector3D.Unit.PLUS_X, precision);
+        GreatCircle f = GreatCircle.fromPoleAndU(Vector3D.Unit.PLUS_Z, Vector3D.of(1, 1e-6, 0), precision);
+        GreatCircle g = GreatCircle.fromPoleAndU(Vector3D.Unit.PLUS_Z, Vector3D.Unit.PLUS_X,
+                new EpsilonDoublePrecisionContext(eps));
+
+        // act/assert
+        Assert.assertTrue(a.eq(a));;
+
+        Assert.assertFalse(a.eq(b));
+        Assert.assertFalse(a.eq(c));
+        Assert.assertFalse(a.eq(d));
+
+        Assert.assertTrue(a.eq(e));
+        Assert.assertTrue(e.eq(a));
+
+        Assert.assertTrue(a.eq(f));
+        Assert.assertTrue(f.eq(a));
+
+        Assert.assertTrue(g.eq(e));
+        Assert.assertTrue(e.eq(g));
+    }
+
+    @Test
+    public void testHashCode() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-3);
+
+        GreatCircle a = GreatCircle.fromPoleAndU(Vector3D.Unit.PLUS_Z, Vector3D.Unit.PLUS_X, TEST_PRECISION);
+
+        GreatCircle b = GreatCircle.fromPoleAndU(Vector3D.of(0, 1, 1), Vector3D.Unit.PLUS_X, TEST_PRECISION);
+        GreatCircle c = GreatCircle.fromPoleAndU(Vector3D.Unit.PLUS_Z, Vector3D.Unit.MINUS_X, TEST_PRECISION);
+        GreatCircle d = GreatCircle.fromPoleAndU(Vector3D.Unit.PLUS_Z, Vector3D.Unit.PLUS_X, precision);
+
+        GreatCircle e = GreatCircle.fromPoleAndU(Vector3D.Unit.PLUS_Z, Vector3D.Unit.PLUS_X, TEST_PRECISION);
+
+        // act
+        int hash = a.hashCode();
+
+        // act/assert
+        Assert.assertEquals(hash, a.hashCode());
+
+        Assert.assertNotEquals(hash, b.hashCode());
+        Assert.assertNotEquals(hash, c.hashCode());
+        Assert.assertNotEquals(hash, d.hashCode());
+
+        Assert.assertEquals(hash, e.hashCode());
+    }
+
+    @Test
+    public void testEquals() {
+        // arrange
+        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-3);
+
+        GreatCircle a = GreatCircle.fromPoleAndU(Vector3D.Unit.PLUS_Z, Vector3D.Unit.PLUS_X, TEST_PRECISION);
+
+        GreatCircle b = GreatCircle.fromPoleAndU(Vector3D.Unit.MINUS_Z, Vector3D.Unit.PLUS_X, TEST_PRECISION);
+        GreatCircle c = GreatCircle.fromPoleAndU(Vector3D.Unit.PLUS_Z, Vector3D.Unit.MINUS_X, TEST_PRECISION);
+        GreatCircle d = GreatCircle.fromPoleAndU(Vector3D.Unit.PLUS_Z, Vector3D.Unit.PLUS_X, precision);
+
+        GreatCircle e = GreatCircle.fromPoleAndU(Vector3D.Unit.PLUS_Z, Vector3D.Unit.PLUS_X, TEST_PRECISION);
+
+        // act/assert
+        Assert.assertTrue(a.equals(a));
+
+        Assert.assertFalse(a.equals(null));
+        Assert.assertFalse(a.equals(new Object()));
+
+        Assert.assertFalse(a.equals(b));
+        Assert.assertFalse(a.equals(c));
+        Assert.assertFalse(a.equals(d));
+
+        Assert.assertTrue(a.equals(e));
+        Assert.assertTrue(e.equals(a));
+    }
+
+    @Test
+    public void testToString() {
+        // arrange
+        GreatCircle circle = GreatCircle.fromPoleAndU(Vector3D.Unit.PLUS_Z, Vector3D.Unit.PLUS_X, TEST_PRECISION);
+
+        // act
+        String str = circle.toString();
+
+        // assert
+        GeometryTestUtils.assertContains("GreatCircle[", str);
+        GeometryTestUtils.assertContains("pole= (0.0, 0.0, 1.0)", str);
+        GeometryTestUtils.assertContains("u= (1.0, 0.0, 0.0)", str);
+        GeometryTestUtils.assertContains("v= (0.0, 1.0, 0.0)", str);
+    }
+
+    private static void checkGreatCircle(GreatCircle circle, Vector3D pole, Vector3D u) {
+        SphericalTestUtils.assertVectorsEqual(pole, circle.getPole(), TEST_EPS);
+        SphericalTestUtils.assertVectorsEqual(pole, circle.getW(), TEST_EPS);
+        SphericalTestUtils.assertVectorsEqual(u, circle.getU(), TEST_EPS);
+        SphericalTestUtils.assertVectorsEqual(pole.cross(u), circle.getV(), TEST_EPS);
+
+        Point2S plusPolePt = Point2S.from(circle.getPole());
+        Point2S minusPolePt = Point2S.from(circle.getPole().negate());
+        Point2S origin = Point2S.from(circle.getU());
+
+        SphericalTestUtils.assertPointsEqual(plusPolePt, circle.getPolePoint(), TEST_EPS);
+
+        Assert.assertFalse(circle.contains(plusPolePt));
+        Assert.assertFalse(circle.contains(minusPolePt));
+        Assert.assertTrue(circle.contains(origin));
+
+        Assert.assertEquals(HyperplaneLocation.MINUS, circle.classify(plusPolePt));
+        Assert.assertEquals(HyperplaneLocation.PLUS, circle.classify(minusPolePt));
+        Assert.assertEquals(HyperplaneLocation.ON, circle.classify(origin));
+    }
+
+    private static void checkArc(GreatArc arc, Point2S start, Point2S end) {
+        SphericalTestUtils.assertPointsEq(start, arc.getStartPoint(), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(end, arc.getEndPoint(), TEST_EPS);
+    }
+}
diff --git a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/InteriorAngleGreatArcConnectorTest.java b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/InteriorAngleGreatArcConnectorTest.java
new file mode 100644
index 0000000..867ef64
--- /dev/null
+++ b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/InteriorAngleGreatArcConnectorTest.java
@@ -0,0 +1,221 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.twod;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.Consumer;
+
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
+import org.apache.commons.geometry.spherical.SphericalTestUtils;
+import org.apache.commons.geometry.spherical.twod.InteriorAngleGreatArcConnector.Maximize;
+import org.apache.commons.geometry.spherical.twod.InteriorAngleGreatArcConnector.Minimize;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class InteriorAngleGreatArcConnectorTest {
+
+    private static final double TEST_EPS = 1e-10;
+
+    private static final DoublePrecisionContext TEST_PRECISION =
+            new EpsilonDoublePrecisionContext(TEST_EPS);
+
+    @Test
+    public void testConnectAll_empty() {
+        runWithMaxAndMin(connector -> {
+            // arrange
+            List<GreatArc> arcs = new ArrayList<>();
+            connector.add(arcs);
+
+            // act
+            List<GreatArcPath> paths = connector.connectAll();
+
+            // assert
+            Assert.assertEquals(0, paths.size());
+        });
+    }
+
+    @Test
+    public void testConnectAll_singlePath() {
+        runWithMaxAndMin(connector -> {
+            // arrange
+            List<GreatArc> arcs = Arrays.asList(
+                        GreatArc.fromPoints(Point2S.PLUS_I, Point2S.PLUS_J, TEST_PRECISION)
+                    );
+            connector.add(arcs);
+
+            // act
+            List<GreatArcPath> paths = connector.connectAll();
+
+            // assert
+            Assert.assertEquals(1, paths.size());
+
+            GreatArcPath a = paths.get(0);
+            Assert.assertEquals(1, a.getArcs().size());
+            assertPathPoints(a, Point2S.PLUS_I, Point2S.PLUS_J);
+        });
+    }
+
+    @Test
+    public void testConnectAll_maximize_instance() {
+        // arrange
+        GreatArc a1 = GreatArc.fromPoints(Point2S.PLUS_K, Point2S.PLUS_I, TEST_PRECISION);
+        GreatArc a2 = GreatArc.fromPoints(Point2S.PLUS_I, Point2S.PLUS_J, TEST_PRECISION);
+        GreatArc a3 = GreatArc.fromPoints(Point2S.PLUS_J, Point2S.PLUS_K, TEST_PRECISION);
+
+        GreatArc b1 = GreatArc.fromPoints(Point2S.PLUS_K, Point2S.MINUS_I, TEST_PRECISION);
+        GreatArc b2 = GreatArc.fromPoints(Point2S.MINUS_I, Point2S.MINUS_J, TEST_PRECISION);
+        GreatArc b3 = GreatArc.fromPoints(Point2S.MINUS_J, Point2S.PLUS_K, TEST_PRECISION);
+
+        InteriorAngleGreatArcConnector connector = new InteriorAngleGreatArcConnector.Maximize();
+
+        // act
+        List<GreatArcPath> paths = connector.connectAll(Arrays.asList(b3, b1, a1, a3, b2, a2));
+
+        // assert
+        Assert.assertEquals(1, paths.size());
+
+        assertPathPoints(paths.get(0),
+                    Point2S.PLUS_K,
+                    Point2S.MINUS_I,
+                    Point2S.MINUS_J,
+                    Point2S.PLUS_K,
+                    Point2S.PLUS_I,
+                    Point2S.PLUS_J,
+                    Point2S.PLUS_K
+                );
+    }
+
+    @Test
+    public void testConnectAll_maximize_method() {
+        // arrange
+        GreatArc a1 = GreatArc.fromPoints(Point2S.PLUS_K, Point2S.PLUS_I, TEST_PRECISION);
+        GreatArc a2 = GreatArc.fromPoints(Point2S.PLUS_I, Point2S.PLUS_J, TEST_PRECISION);
+        GreatArc a3 = GreatArc.fromPoints(Point2S.PLUS_J, Point2S.PLUS_K, TEST_PRECISION);
+
+        GreatArc b1 = GreatArc.fromPoints(Point2S.PLUS_K, Point2S.MINUS_I, TEST_PRECISION);
+        GreatArc b2 = GreatArc.fromPoints(Point2S.MINUS_I, Point2S.MINUS_J, TEST_PRECISION);
+        GreatArc b3 = GreatArc.fromPoints(Point2S.MINUS_J, Point2S.PLUS_K, TEST_PRECISION);
+
+        // act
+        List<GreatArcPath> paths = InteriorAngleGreatArcConnector.connectMaximized(
+                Arrays.asList(b3, b1, a1, a3, b2, a2));
+
+        // assert
+        Assert.assertEquals(1, paths.size());
+
+        assertPathPoints(paths.get(0),
+                    Point2S.PLUS_K,
+                    Point2S.MINUS_I,
+                    Point2S.MINUS_J,
+                    Point2S.PLUS_K,
+                    Point2S.PLUS_I,
+                    Point2S.PLUS_J,
+                    Point2S.PLUS_K
+                );
+    }
+
+    @Test
+    public void testConnectAll_minimize_instance() {
+        // arrange
+        GreatArc a1 = GreatArc.fromPoints(Point2S.PLUS_K, Point2S.PLUS_I, TEST_PRECISION);
+        GreatArc a2 = GreatArc.fromPoints(Point2S.PLUS_I, Point2S.PLUS_J, TEST_PRECISION);
+        GreatArc a3 = GreatArc.fromPoints(Point2S.PLUS_J, Point2S.PLUS_K, TEST_PRECISION);
+
+        GreatArc b1 = GreatArc.fromPoints(Point2S.PLUS_K, Point2S.MINUS_I, TEST_PRECISION);
+        GreatArc b2 = GreatArc.fromPoints(Point2S.MINUS_I, Point2S.MINUS_J, TEST_PRECISION);
+        GreatArc b3 = GreatArc.fromPoints(Point2S.MINUS_J, Point2S.PLUS_K, TEST_PRECISION);
+
+        InteriorAngleGreatArcConnector connector = new InteriorAngleGreatArcConnector.Minimize();
+
+        // act
+        List<GreatArcPath> paths = connector.connectAll(Arrays.asList(b3, b1, a1, a3, b2, a2));
+
+        // assert
+        Assert.assertEquals(2, paths.size());
+
+        assertPathPoints(paths.get(0),
+                Point2S.PLUS_K,
+                Point2S.MINUS_I,
+                Point2S.MINUS_J,
+                Point2S.PLUS_K
+            );
+
+        assertPathPoints(paths.get(1),
+                    Point2S.PLUS_K,
+                    Point2S.PLUS_I,
+                    Point2S.PLUS_J,
+                    Point2S.PLUS_K
+                );
+    }
+
+    @Test
+    public void testConnectAll_minimize_method() {
+        // arrange
+        GreatArc a1 = GreatArc.fromPoints(Point2S.PLUS_K, Point2S.PLUS_I, TEST_PRECISION);
+        GreatArc a2 = GreatArc.fromPoints(Point2S.PLUS_I, Point2S.PLUS_J, TEST_PRECISION);
+        GreatArc a3 = GreatArc.fromPoints(Point2S.PLUS_J, Point2S.PLUS_K, TEST_PRECISION);
+
+        GreatArc b1 = GreatArc.fromPoints(Point2S.PLUS_K, Point2S.MINUS_I, TEST_PRECISION);
+        GreatArc b2 = GreatArc.fromPoints(Point2S.MINUS_I, Point2S.MINUS_J, TEST_PRECISION);
+        GreatArc b3 = GreatArc.fromPoints(Point2S.MINUS_J, Point2S.PLUS_K, TEST_PRECISION);
+
+        // act
+        List<GreatArcPath> paths = InteriorAngleGreatArcConnector.connectMinimized(
+                Arrays.asList(b3, b1, a1, a3, b2, a2));
+
+        // assert
+        Assert.assertEquals(2, paths.size());
+
+        assertPathPoints(paths.get(0),
+                Point2S.PLUS_K,
+                Point2S.MINUS_I,
+                Point2S.MINUS_J,
+                Point2S.PLUS_K
+            );
+
+        assertPathPoints(paths.get(1),
+                    Point2S.PLUS_K,
+                    Point2S.PLUS_I,
+                    Point2S.PLUS_J,
+                    Point2S.PLUS_K
+                );
+    }
+
+    /**
+     * Run the given consumer function twice, once with a Maximize instance and once with
+     * a Minimize instance.
+     */
+    private static void runWithMaxAndMin(Consumer<InteriorAngleGreatArcConnector> body) {
+        body.accept(new Maximize());
+        body.accept(new Minimize());
+    }
+
+    private static void assertPathPoints(GreatArcPath path, Point2S ... points) {
+        List<Point2S> expectedPoints = Arrays.asList(points);
+        List<Point2S> actualPoints = path.getVertices();
+
+        String msg = "Expected path points to equal " + expectedPoints + " but was " + actualPoints;
+        Assert.assertEquals(msg, expectedPoints.size(), actualPoints.size());
+
+        for (int i=0; i<expectedPoints.size(); ++i) {
+            SphericalTestUtils.assertPointsEq(expectedPoints.get(i), actualPoints.get(i), TEST_EPS);
+        }
+    }
+}
diff --git a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/Point2STest.java b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/Point2STest.java
new file mode 100644
index 0000000..fbcdab6
--- /dev/null
+++ b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/Point2STest.java
@@ -0,0 +1,346 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.twod;
+
+
+import java.util.Comparator;
+
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.threed.Vector3D;
+import org.apache.commons.geometry.spherical.SphericalTestUtils;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class Point2STest {
+
+    private static final double TEST_EPS = 1e-10;
+
+    @Test
+    public void testProperties() {
+        for (int k = -2; k < 3; ++k) {
+            // arrange
+            Point2S p = Point2S.of(1.0 + k * Geometry.TWO_PI, 1.4);
+
+            // act/assert
+            Assert.assertEquals(1.0, p.getAzimuth(), TEST_EPS);
+            Assert.assertEquals(1.4, p.getPolar(), TEST_EPS);
+
+            Assert.assertEquals(Math.cos(1.0) * Math.sin(1.4), p.getVector().getX(), TEST_EPS);
+            Assert.assertEquals(Math.sin(1.0) * Math.sin(1.4), p.getVector().getY(), TEST_EPS);
+            Assert.assertEquals(Math.cos(1.4), p.getVector().getZ(), TEST_EPS);
+
+            Assert.assertFalse(p.isNaN());
+        }
+    }
+
+    @Test
+    public void testAzimuthPolarComparator() {
+        // arrange
+        Comparator<Point2S> comp = Point2S.POLAR_AZIMUTH_ASCENDING_ORDER;
+
+        // act/assert
+        Assert.assertEquals(0, comp.compare(Point2S.of(1, 2), Point2S.of(1, 2)));
+        Assert.assertEquals(1, comp.compare(Point2S.of(1, 2), Point2S.of(2, 1)));
+        Assert.assertEquals(-1, comp.compare(Point2S.of(2, 1), Point2S.of(1, 2)));
+
+        Assert.assertEquals(-1, comp.compare(Point2S.of(1, 2), Point2S.of(1, 3)));
+        Assert.assertEquals(1, comp.compare(Point2S.of(1, 3), Point2S.of(1, 2)));
+
+        Assert.assertEquals(1, comp.compare(null, Point2S.of(1, 2)));
+        Assert.assertEquals(-1, comp.compare(Point2S.of(1, 2), null));
+        Assert.assertEquals(0, comp.compare(null, null));
+    }
+
+    @Test
+    public void testFrom_vector() {
+        // arrange
+        double quarterPi = 0.25 * Geometry.PI;
+
+        // act/assert
+        checkPoint(Point2S.from(Vector3D.of(1, 1, 0)), quarterPi, Geometry.HALF_PI);
+        checkPoint(Point2S.from(Vector3D.of(1, 0, 1)), 0, quarterPi);
+        checkPoint(Point2S.from(Vector3D.of(0, 1, 1)), Geometry.HALF_PI, quarterPi);
+
+        checkPoint(Point2S.from(Vector3D.of(1, -1, 0)), Geometry.TWO_PI - quarterPi, Geometry.HALF_PI);
+        checkPoint(Point2S.from(Vector3D.of(-1, 0, -1)), Geometry.PI, Geometry.PI - quarterPi);
+        checkPoint(Point2S.from(Vector3D.of(0, -1, -1)), Geometry.TWO_PI - Geometry.HALF_PI, Geometry.PI - quarterPi);
+    }
+
+    @Test
+    public void testNaN() {
+        // act/assert
+        Assert.assertTrue(Point2S.NaN.isNaN());
+        Assert.assertTrue(Point2S.NaN.equals(Point2S.of(Double.NaN, 1.0)));
+        Assert.assertFalse(Point2S.of(1.0, 1.3).equals(Point2S.NaN));
+        Assert.assertNull(Point2S.NaN.getVector());
+
+        Assert.assertEquals(Point2S.NaN.hashCode(), Point2S.of(Double.NaN, Double.NaN).hashCode());
+    }
+
+    @Test
+    public void testInfinite() {
+        // act/assert
+        Assert.assertTrue(Point2S.of(0, Double.POSITIVE_INFINITY).isInfinite());
+        Assert.assertTrue(Point2S.of(Double.POSITIVE_INFINITY, 0).isInfinite());
+
+        Assert.assertTrue(Point2S.of(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY).isInfinite());
+
+        Assert.assertFalse(Point2S.of(0, 0).isInfinite());
+        Assert.assertFalse(Point2S.of(1, 1).isInfinite());
+        Assert.assertFalse(Point2S.NaN.isInfinite());
+    }
+
+    @Test
+    public void testFinite() {
+        // act/assert
+        Assert.assertTrue(Point2S.of(0, 0).isFinite());
+        Assert.assertTrue(Point2S.of(1, 1).isFinite());
+
+        Assert.assertFalse(Point2S.of(0, Double.POSITIVE_INFINITY).isFinite());
+        Assert.assertFalse(Point2S.of(Double.POSITIVE_INFINITY, 0).isFinite());
+        Assert.assertFalse(Point2S.of(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY).isFinite());
+
+        Assert.assertFalse(Point2S.NaN.isFinite());
+    }
+
+    @Test
+    public void testDistance() {
+        // arrange
+        Point2S a = Point2S.of(1.0, 0.5 * Geometry.PI);
+        Point2S b = Point2S.of(a.getAzimuth() + 0.5 * Geometry.PI, a.getPolar());
+
+        // act/assert
+        Assert.assertEquals(0.5 * Geometry.PI, a.distance(b), 1.0e-10);
+        Assert.assertEquals(Geometry.PI, a.distance(a.antipodal()), 1.0e-10);
+        Assert.assertEquals(0.5 * Geometry.PI, Point2S.MINUS_I.distance(Point2S.MINUS_K), 1.0e-10);
+        Assert.assertEquals(0.0, Point2S.of(1.0, 0).distance(Point2S.of(2.0, 0)), 1.0e-10);
+    }
+
+    @Test
+    public void testSlerp_alongEquator() {
+        // arrange
+        Point2S p1 = Point2S.PLUS_I;
+        Point2S p2 = Point2S.PLUS_J;
+
+        // act/assert
+        SphericalTestUtils.assertPointsEq(p1, p1.slerp(p2, 0), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.of(0.25 * Geometry.HALF_PI, Geometry.HALF_PI), p1.slerp(p2, 0.25), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.of(0.5 * Geometry.HALF_PI, Geometry.HALF_PI), p1.slerp(p2, 0.5), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.of(0.75 * Geometry.HALF_PI, Geometry.HALF_PI), p1.slerp(p2, 0.75), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(p2, p1.slerp(p2, 1), TEST_EPS);
+
+        SphericalTestUtils.assertPointsEq(p2, p2.slerp(p1, 0), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.of(0.75 * Geometry.HALF_PI, Geometry.HALF_PI), p2.slerp(p1, 0.25), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.of(0.5 * Geometry.HALF_PI, Geometry.HALF_PI), p2.slerp(p1, 0.5), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.of(0.25 * Geometry.HALF_PI, Geometry.HALF_PI), p2.slerp(p1, 0.75), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(p1, p2.slerp(p1, 1), TEST_EPS);
+
+        SphericalTestUtils.assertPointsEq(Point2S.MINUS_I, p1.slerp(p2, 2), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.MINUS_J, p1.slerp(p2, -1), TEST_EPS);
+    }
+
+    @Test
+    public void testSlerp_alongMeridian() {
+        // arrange
+        Point2S p1 = Point2S.PLUS_J;
+        Point2S p2 = Point2S.PLUS_K;
+
+        // act/assert
+        SphericalTestUtils.assertPointsEq(p1, p1.slerp(p2, 0), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.of(Geometry.HALF_PI, 0.75 * Geometry.HALF_PI), p1.slerp(p2, 0.25), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.of(Geometry.HALF_PI, 0.5 * Geometry.HALF_PI), p1.slerp(p2, 0.5), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.of(Geometry.HALF_PI, 0.25 * Geometry.HALF_PI), p1.slerp(p2, 0.75), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(p2, p1.slerp(p2, 1), TEST_EPS);
+
+        SphericalTestUtils.assertPointsEq(p2, p2.slerp(p1, 0), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.of(Geometry.HALF_PI, 0.25 * Geometry.HALF_PI), p2.slerp(p1, 0.25), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.of(Geometry.HALF_PI, 0.5 * Geometry.HALF_PI), p2.slerp(p1, 0.5), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.of(Geometry.HALF_PI, 0.75 * Geometry.HALF_PI), p2.slerp(p1, 0.75), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(p1, p2.slerp(p1, 1), TEST_EPS);
+
+        SphericalTestUtils.assertPointsEq(Point2S.MINUS_J, p1.slerp(p2, 2), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.MINUS_K, p1.slerp(p2, -1), TEST_EPS);
+    }
+
+    @Test
+    public void testSlerp_samePoint() {
+        // arrange
+        Point2S p1 = Point2S.PLUS_I;
+
+        // act/assert
+        SphericalTestUtils.assertPointsEq(p1, p1.slerp(p1, 0), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(p1, p1.slerp(p1, 0.5), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(p1, p1.slerp(p1, 1), TEST_EPS);
+    }
+
+    @Test
+    public void testSlerp_antipodal() {
+        // arrange
+        Point2S p1 = Point2S.PLUS_I;
+        Point2S p2 = Point2S.MINUS_I;
+
+        // act/assert
+        SphericalTestUtils.assertPointsEq(p1, p1.slerp(p1, 0), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(p1, p1.slerp(p1, 1), TEST_EPS);
+
+        Point2S pt = p1.slerp(p2, 0.5);
+        Assert.assertEquals(p1.distance(pt), p2.distance(pt), TEST_EPS);
+    }
+
+    @Test
+    public void testAntipodal() {
+        for (double az = -6 * Geometry.PI; az <= 6 * Geometry.PI; az += 0.1) {
+            for (double p = 0; p <= Geometry.PI; p += 0.1) {
+                // arrange
+                Point2S pt = Point2S.of(az, p);
+
+                // act
+                Point2S result = pt.antipodal();
+
+                // assert
+                Assert.assertEquals(Geometry.PI, pt.distance(result), TEST_EPS);
+                Assert.assertEquals(-1, pt.getVector().dot(result.getVector()), TEST_EPS);
+            }
+        }
+    }
+
+    @Test
+    public void testDimension() {
+        // arrange
+        Point2S pt = Point2S.of(1, 2);
+
+        // act/assert
+        Assert.assertEquals(2, pt.getDimension());
+    }
+
+    @Test
+    public void testEq() {
+        // arrange
+        DoublePrecisionContext smallEps = new EpsilonDoublePrecisionContext(1e-5);
+        DoublePrecisionContext largeEps = new EpsilonDoublePrecisionContext(5e-1);
+
+        Point2S a = Point2S.of(1.0, 2.0);
+        Point2S b = Point2S.of(1.0, 2.01);
+        Point2S c = Point2S.of(1.01, 2.0);
+        Point2S d = Point2S.of(1.0, 2.0);
+        Point2S e = Point2S.of(3.0, 2.0);
+
+        // act/assert
+        Assert.assertTrue(a.eq(a, smallEps));
+        Assert.assertFalse(a.eq(b, smallEps));
+        Assert.assertFalse(a.eq(c, smallEps));
+        Assert.assertTrue(a.eq(d, smallEps));
+        Assert.assertFalse(a.eq(e, smallEps));
+
+        Assert.assertTrue(a.eq(a, largeEps));
+        Assert.assertTrue(a.eq(b, largeEps));
+        Assert.assertTrue(a.eq(c, largeEps));
+        Assert.assertTrue(a.eq(d, largeEps));
+        Assert.assertFalse(a.eq(e, largeEps));
+    }
+
+    @Test
+    public void testHashCode() {
+        // arrange
+        Point2S a = Point2S.of(1.0, 2.0);
+        Point2S b = Point2S.of(1.0, 3.0);
+        Point2S c = Point2S.of(4.0, 2.0);
+        Point2S d = Point2S.of(1.0, 2.0);
+
+        // act
+        int hash = a.hashCode();
+
+        // assert
+        Assert.assertEquals(hash, a.hashCode());
+
+        Assert.assertNotEquals(hash, b.hashCode());
+        Assert.assertNotEquals(hash, c.hashCode());
+
+        Assert.assertEquals(hash, d.hashCode());
+    }
+
+    @Test
+    public void testEquals() {
+        // arrange
+        Point2S a = Point2S.of(1.0, 2.0);
+        Point2S b = Point2S.of(1.0, 3.0);
+        Point2S c = Point2S.of(4.0, 2.0);
+        Point2S d = Point2S.of(1.0, 2.0);
+
+        // act/assert
+        Assert.assertFalse(a.equals(null));
+        Assert.assertFalse(a.equals(new Object()));
+
+        Assert.assertTrue(a.equals(a));
+
+        Assert.assertFalse(a.equals(b));
+        Assert.assertFalse(a.equals(c));
+
+        Assert.assertTrue(a.equals(d));
+        Assert.assertTrue(d.equals(a));
+    }
+
+    @Test
+    public void testEquals_poles() {
+        // arrange
+        Point2S a = Point2S.of(1.0, 0.0);
+        Point2S b = Point2S.of(0.0, 0.0);
+        Point2S c = Point2S.of(1.0, 0.0);
+
+        Point2S d = Point2S.of(-1.0, Geometry.PI);
+        Point2S e = Point2S.of(0.0, Geometry.PI);
+        Point2S f = Point2S.of(-1.0, Geometry.PI);
+
+        // act/assert
+        Assert.assertTrue(a.equals(a));
+        Assert.assertFalse(a.equals(b));
+        Assert.assertTrue(a.equals(c));
+
+        Assert.assertTrue(d.equals(d));
+        Assert.assertFalse(d.equals(e));
+        Assert.assertTrue(d.equals(f));
+    }
+
+    @Test
+    public void testToString() {
+        // act/assert
+        Assert.assertEquals("(0.0, 0.0)", Point2S.of(0.0, 0.0).toString());
+        Assert.assertEquals("(1.0, 2.0)", Point2S.of(1.0, 2.0).toString());
+    }
+
+    @Test
+    public void testParse() {
+        // act/assert
+        checkPoint(Point2S.parse("(0,0)"), 0.0, 0.0);
+        checkPoint(Point2S.parse("(1,2)"), 1.0, 2.0);
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    public void testParse_failure() {
+        // act/assert
+        Point2S.parse("abc");
+    }
+
+    private static void checkPoint(Point2S p, double az, double polar) {
+        String msg = "Expected (" + az + "," + polar + ") but was " + p;
+
+        Assert.assertEquals(msg, az, p.getAzimuth(), TEST_EPS);
+        Assert.assertEquals(msg, polar, p.getPolar(), 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
new file mode 100644
index 0000000..29b51b5
--- /dev/null
+++ b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/RegionBSPTree2STest.java
@@ -0,0 +1,714 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.twod;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.GeometryTestUtils;
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.SplitLocation;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.threed.Vector3D;
+import org.apache.commons.geometry.spherical.SphericalTestUtils;
+import org.apache.commons.geometry.spherical.oned.Point1S;
+import org.apache.commons.geometry.spherical.twod.RegionBSPTree2S.RegionNode2S;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class RegionBSPTree2STest {
+
+    private static final double TEST_EPS = 1e-10;
+
+    private static final DoublePrecisionContext TEST_PRECISION =
+            new EpsilonDoublePrecisionContext(TEST_EPS);
+
+    // epsilon value for use when comparing computed barycenter locations;
+    // this must currently be set much higher than the other epsilon
+    private static final double BARYCENTER_EPS = 1e-2;
+
+    private static final GreatCircle EQUATOR = GreatCircle.fromPoleAndU(
+            Vector3D.Unit.PLUS_Z, Vector3D.Unit.PLUS_X, TEST_PRECISION);
+
+    private static final GreatCircle X_MERIDIAN = GreatCircle.fromPoleAndU(
+            Vector3D.Unit.PLUS_Y, Vector3D.Unit.PLUS_X, TEST_PRECISION);
+
+    private static final GreatCircle Y_MERIDIAN = GreatCircle.fromPoleAndU(
+            Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
+
+    @Test
+    public void testCtor_booleanArg_true() {
+        // act
+        RegionBSPTree2S tree = new RegionBSPTree2S(true);
+
+        // assert
+        Assert.assertTrue(tree.isFull());
+        Assert.assertFalse(tree.isEmpty());
+        Assert.assertEquals(1, tree.count());
+    }
+
+    @Test
+    public void testCtor_booleanArg_false() {
+        // act
+        RegionBSPTree2S tree = new RegionBSPTree2S(false);
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertTrue(tree.isEmpty());
+        Assert.assertEquals(1, tree.count());
+    }
+
+    @Test
+    public void testCtor_default() {
+        // act
+        RegionBSPTree2S tree = new RegionBSPTree2S();
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertTrue(tree.isEmpty());
+        Assert.assertEquals(1, tree.count());
+    }
+
+    @Test
+    public void testFull_factoryMethod() {
+        // act
+        RegionBSPTree2S tree = RegionBSPTree2S.full();
+
+        // assert
+        Assert.assertTrue(tree.isFull());
+        Assert.assertFalse(tree.isEmpty());
+        Assert.assertEquals(1, tree.count());
+    }
+
+    @Test
+    public void testEmpty_factoryMethod() {
+        // act
+        RegionBSPTree2S tree = RegionBSPTree2S.empty();
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertTrue(tree.isEmpty());
+        Assert.assertEquals(1, tree.count());
+    }
+
+    @Test
+    public void testFromConvexArea() {
+        // arrange
+        ConvexArea2S area = ConvexArea2S.fromVertexLoop(Arrays.asList(
+                    Point2S.of(0.1, 0.1), Point2S.of(0, 0.5),
+                    Point2S.of(0.15, 0.75), Point2S.of(0.3, 0.5),
+                    Point2S.of(0.1, 0.1)
+                ), TEST_PRECISION);
+
+        // act
+        RegionBSPTree2S tree = RegionBSPTree2S.from(area);
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertFalse(tree.isEmpty());
+
+        Assert.assertEquals(area.getSize(), tree.getSize(), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(area.getBarycenter(), tree.getBarycenter(), TEST_EPS);
+    }
+
+    @Test
+    public void testCopy() {
+        // arrange
+        RegionBSPTree2S tree = new RegionBSPTree2S(true);
+        tree.getRoot().cut(EQUATOR);
+
+        // act
+        RegionBSPTree2S copy = tree.copy();
+
+        // assert
+        Assert.assertNotSame(tree, copy);
+        Assert.assertEquals(3, copy.count());
+    }
+
+    @Test
+    public void testBoundaries() {
+        // arrange
+        RegionBSPTree2S tree = RegionBSPTree2S.empty();
+        insertPositiveQuadrant(tree);
+
+        // act
+        List<GreatArc> arcs = new ArrayList<>();
+        tree.boundaries().forEach(arcs::add);
+
+        // assert
+        Assert.assertEquals(3, arcs.size());
+    }
+
+    @Test
+    public void testGetBoundaries() {
+        // arrange
+        RegionBSPTree2S tree = RegionBSPTree2S.empty();
+        insertPositiveQuadrant(tree);
+
+        // act
+        List<GreatArc> arcs = tree.getBoundaries();
+
+        // assert
+        Assert.assertEquals(3, arcs.size());
+    }
+
+    @Test
+    public void testGetBoundaryPaths_cachesResult() {
+        // arrange
+        RegionBSPTree2S tree = RegionBSPTree2S.empty();
+        insertPositiveQuadrant(tree);
+
+        // act
+        List<GreatArcPath> a = tree.getBoundaryPaths();
+        List<GreatArcPath> b = tree.getBoundaryPaths();
+
+        // assert
+        Assert.assertSame(a, b);
+    }
+
+    @Test
+    public void testGetBoundaryPaths_recomputesResultOnChange() {
+        // arrange
+        RegionBSPTree2S tree = RegionBSPTree2S.empty();
+        tree.insert(EQUATOR.span());
+
+        // act
+        List<GreatArcPath> a = tree.getBoundaryPaths();
+        tree.insert(X_MERIDIAN.span());
+        List<GreatArcPath> b = tree.getBoundaryPaths();
+
+        // assert
+        Assert.assertNotSame(a, b);
+    }
+
+    @Test
+    public void testGetBoundaryPaths_isUnmodifiable() {
+        // arrange
+        RegionBSPTree2S tree = RegionBSPTree2S.empty();
+        tree.insert(EQUATOR.span());
+
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            tree.getBoundaryPaths().add(GreatArcPath.empty());
+        }, UnsupportedOperationException.class);
+    }
+
+    @Test
+    public void testToConvex_full() {
+        // arrange
+        RegionBSPTree2S tree = RegionBSPTree2S.full();
+
+        // act
+        List<ConvexArea2S> result = tree.toConvex();
+
+        // assert
+        Assert.assertEquals(1, result.size());
+        Assert.assertTrue(result.get(0).isFull());
+    }
+
+    @Test
+    public void testToConvex_empty() {
+        // arrange
+        RegionBSPTree2S tree = RegionBSPTree2S.empty();
+
+        // act
+        List<ConvexArea2S> result = tree.toConvex();
+
+        // assert
+        Assert.assertEquals(0, result.size());
+    }
+
+    @Test
+    public void testToConvex_doubleLune() {
+        // arrange
+        RegionBSPTree2S tree = GreatArcPath.builder(TEST_PRECISION)
+                .append(EQUATOR.arc(0,  Geometry.PI))
+                .append(X_MERIDIAN.arc(Geometry.PI, 0))
+                .append(EQUATOR.reverse().arc(0, Geometry.PI))
+                .append(X_MERIDIAN.reverse().arc(Geometry.PI, 0))
+                .build()
+                .toTree();
+
+        // act
+        List<ConvexArea2S> result = tree.toConvex();
+
+        // assert
+        Assert.assertEquals(2, result.size());
+
+        double size = result.stream().collect(Collectors.summingDouble(a -> a.getSize()));
+        Assert.assertEquals(Geometry.TWO_PI, size, TEST_EPS);
+    }
+
+    @Test
+    public void testToConvex_doubleLune_comlement() {
+        // arrange
+        RegionBSPTree2S tree = GreatArcPath.builder(TEST_PRECISION)
+                .append(EQUATOR.arc(0,  Geometry.PI))
+                .append(X_MERIDIAN.arc(Geometry.PI, 0))
+                .append(EQUATOR.reverse().arc(0, Geometry.PI))
+                .append(X_MERIDIAN.reverse().arc(Geometry.PI, 0))
+                .build()
+                .toTree();
+
+        // act
+        List<ConvexArea2S> result = tree.toConvex();
+
+        // assert
+        Assert.assertEquals(2, result.size());
+
+        double size = result.stream().collect(Collectors.summingDouble(a -> a.getSize()));
+        Assert.assertEquals(Geometry.TWO_PI, size, TEST_EPS);
+    }
+
+    @Test
+    public void testProject() {
+        // arrange
+        RegionBSPTree2S tree = RegionBSPTree2S.empty();
+        tree.insert(EQUATOR.arc(0, Geometry.PI));
+        tree.insert(X_MERIDIAN.arc(Geometry.PI, 0));
+
+        // act/assert
+        SphericalTestUtils.assertPointsEq(Point2S.of(Geometry.HALF_PI, Geometry.HALF_PI),
+                tree.project(Point2S.of(Geometry.HALF_PI, Geometry.HALF_PI + 0.2)), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.PLUS_K,
+                tree.project(Point2S.of(Geometry.MINUS_HALF_PI, 0.2)), TEST_EPS);
+
+        SphericalTestUtils.assertPointsEq(Point2S.PLUS_I,
+                tree.project(Point2S.of(-0.5, Geometry.HALF_PI)), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.MINUS_I,
+                tree.project(Point2S.of(Geometry.PI + 0.5, Geometry.HALF_PI)), TEST_EPS);
+
+        SphericalTestUtils.assertPointsEq(Point2S.PLUS_K, tree.project(tree.getBarycenter()), TEST_EPS);
+    }
+
+    @Test
+    public void testProject_noBoundaries() {
+        // act/assert
+        Assert.assertNull(RegionBSPTree2S.empty().project(Point2S.PLUS_I));
+        Assert.assertNull(RegionBSPTree2S.full().project(Point2S.PLUS_I));
+    }
+
+    @Test
+    public void testGeometricProperties_full() {
+        // arrange
+        RegionBSPTree2S tree = RegionBSPTree2S.full();
+
+        // act/assert
+        Assert.assertEquals(4 * Geometry.PI, tree.getSize(), TEST_EPS);
+        Assert.assertNull(tree.getBarycenter());
+
+        Assert.assertEquals(0, tree.getBoundarySize(), TEST_EPS);
+
+        Assert.assertEquals(0, tree.getBoundaries().size());
+        Assert.assertEquals(0, tree.getBoundaryPaths().size());
+    }
+
+    @Test
+    public void testGeometricProperties_empty() {
+        // arrange
+        RegionBSPTree2S tree = RegionBSPTree2S.empty();
+
+        // act/assert
+        Assert.assertEquals(0, tree.getSize(), TEST_EPS);
+        Assert.assertNull(tree.getBarycenter());
+
+        Assert.assertEquals(0, tree.getBoundarySize(), TEST_EPS);
+
+        Assert.assertEquals(0, tree.getBoundaries().size());
+        Assert.assertEquals(0, tree.getBoundaryPaths().size());
+    }
+
+    @Test
+    public void testGeometricProperties_halfSpace() {
+        // arrange
+        RegionBSPTree2S tree = RegionBSPTree2S.full();
+        tree.getRoot().cut(EQUATOR);
+
+        // act/assert
+        Assert.assertEquals(Geometry.TWO_PI, tree.getSize(), TEST_EPS);
+        Assert.assertEquals(Geometry.TWO_PI, tree.getBoundarySize(), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.PLUS_K, tree.getBarycenter(), TEST_EPS);
+
+        checkBarycenterConsistency(tree);
+
+        List<GreatArc> arcs = tree.getBoundaries();
+        Assert.assertEquals(1, arcs.size());
+
+        GreatArc arc = arcs.get(0);
+        Assert.assertSame(EQUATOR, arc.getCircle());
+        Assert.assertNull(arc.getStartPoint());
+        Assert.assertNull(arc.getEndPoint());
+
+        List<GreatArcPath> paths = tree.getBoundaryPaths();
+        Assert.assertEquals(1, paths.size());
+
+        GreatArcPath path = paths.get(0);
+        Assert.assertEquals(1, path.getArcs().size());
+        Assert.assertTrue(path.getArcs().get(0).isFull());
+    }
+
+    @Test
+    public void testGeometricProperties_doubleLune() {
+        // act
+        RegionBSPTree2S tree = GreatArcPath.builder(TEST_PRECISION)
+                .append(EQUATOR.arc(0,  Geometry.PI))
+                .append(X_MERIDIAN.arc(Geometry.PI, 0))
+                .append(EQUATOR.reverse().arc(0, Geometry.PI))
+                .append(X_MERIDIAN.reverse().arc(Geometry.PI, 0))
+                .build()
+                .toTree();
+
+        // assert
+        Assert.assertEquals(2 * Geometry.PI, tree.getSize(), TEST_EPS);
+        Assert.assertEquals(4 * Geometry.PI, tree.getBoundarySize(), TEST_EPS);
+        Assert.assertNull(tree.getBarycenter());
+
+        List<GreatArcPath> paths = tree.getBoundaryPaths();
+        Assert.assertEquals(2, paths.size());
+
+        assertPath(paths.get(0), Point2S.PLUS_I, Point2S.MINUS_I, Point2S.PLUS_I);
+        assertPath(paths.get(1), Point2S.PLUS_I, Point2S.MINUS_I, Point2S.PLUS_I);
+
+        SphericalTestUtils.checkClassify(tree, RegionLocation.INSIDE,
+                Point2S.of(0.5 * Geometry.PI, 0.25 * Geometry.PI),
+                Point2S.of(1.5 * Geometry.PI, 0.75 * Geometry.PI));
+
+        SphericalTestUtils.checkClassify(tree, RegionLocation.OUTSIDE,
+                Point2S.of(0.5 * Geometry.PI, 0.75 * Geometry.PI),
+                Point2S.of(1.5 * Geometry.PI, 0.25 * Geometry.PI));
+    }
+
+    @Test
+    public void testGeometricProperties_quadrant() {
+        // act
+        RegionBSPTree2S tree = GreatArcPath.builder(TEST_PRECISION)
+                .appendVertices(Point2S.MINUS_K, Point2S.PLUS_I, Point2S.MINUS_J)
+                .close()
+                .toTree();
+
+        // assert
+        Assert.assertEquals(0.5 * Geometry.PI, tree.getSize(), TEST_EPS);
+        Assert.assertEquals(1.5 * Geometry.PI, tree.getBoundarySize(), TEST_EPS);
+
+        Point2S center = Point2S.from(Point2S.MINUS_K.getVector()
+                .add(Point2S.PLUS_I.getVector())
+                .add(Point2S.MINUS_J.getVector()));
+        SphericalTestUtils.assertPointsEq(center, tree.getBarycenter(), TEST_EPS);
+
+        checkBarycenterConsistency(tree);
+
+        List<GreatArcPath> paths = tree.getBoundaryPaths();
+        Assert.assertEquals(1, paths.size());
+
+        assertPath(paths.get(0), Point2S.MINUS_J, Point2S.MINUS_K, Point2S.PLUS_I, Point2S.MINUS_J);
+
+        SphericalTestUtils.checkClassify(tree, RegionLocation.INSIDE,
+                Point2S.of(1.75 * Geometry.PI, 0.75 * Geometry.PI));
+
+        SphericalTestUtils.checkClassify(tree, RegionLocation.OUTSIDE,
+                Point2S.PLUS_J, Point2S.PLUS_K, Point2S.MINUS_I);
+    }
+
+    @Test
+    public void testGeometricProperties_quadrant_complement() {
+        // arrange
+        RegionBSPTree2S tree = GreatArcPath.builder(TEST_PRECISION)
+                .appendVertices(Point2S.MINUS_K, Point2S.PLUS_I, Point2S.MINUS_J)
+                .close()
+                .toTree();
+
+        // act
+        tree.complement();
+
+        // assert
+        Assert.assertEquals(3.5 * Geometry.PI, tree.getSize(), TEST_EPS);
+        Assert.assertEquals(1.5 * Geometry.PI, tree.getBoundarySize(), TEST_EPS);
+
+//        Point2S center = Point2S.from(Point2S.MINUS_K.getVector()
+//                .add(Point2S.PLUS_I.getVector())
+//                .add(Point2S.MINUS_J.getVector()));
+//        SphericalTestUtils.assertPointsEq(center.antipodal(), tree.getBarycenter(), TEST_EPS);
+//
+//        checkBarycenterConsistency(tree);
+
+        List<GreatArcPath> paths = tree.getBoundaryPaths();
+        Assert.assertEquals(1, paths.size());
+
+        assertPath(paths.get(0), Point2S.MINUS_J, Point2S.PLUS_I, Point2S.MINUS_K, Point2S.MINUS_J);
+
+        SphericalTestUtils.checkClassify(tree, RegionLocation.OUTSIDE,
+                Point2S.of(1.75 * Geometry.PI, 0.75 * Geometry.PI));
+
+        SphericalTestUtils.checkClassify(tree, RegionLocation.INSIDE,
+                Point2S.PLUS_J, Point2S.PLUS_K, Point2S.MINUS_I);
+    }
+
+    @Test
+    public void testSplit_both() {
+        // arrange
+        GreatCircle c1 = GreatCircle.fromPole(Vector3D.Unit.MINUS_X, TEST_PRECISION);
+        GreatCircle c2 = GreatCircle.fromPole(Vector3D.of(1, 1, 0), TEST_PRECISION);
+
+        RegionBSPTree2S tree = ConvexArea2S.fromBounds(c1, c2).toTree();
+
+        GreatCircle splitter = GreatCircle.fromPole(Vector3D.of(-1, 0, 1), TEST_PRECISION);
+
+        // act
+        Split<RegionBSPTree2S> split = tree.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        Point2S p1 = c1.intersection(splitter);
+        Point2S p2 = splitter.intersection(c2);
+
+        RegionBSPTree2S minus = split.getMinus();
+        List<GreatArcPath> minusPaths = minus.getBoundaryPaths();
+        Assert.assertEquals(1, minusPaths.size());
+        assertPath(minusPaths.get(0), Point2S.PLUS_K, p1, p2, Point2S.PLUS_K);
+
+        RegionBSPTree2S plus = split.getPlus();
+        List<GreatArcPath> plusPaths = plus.getBoundaryPaths();
+        Assert.assertEquals(1, plusPaths.size());
+        assertPath(plusPaths.get(0), p1, Point2S.MINUS_K, p2, p1);
+
+        Assert.assertEquals(tree.getSize(), minus.getSize() + plus.getSize(), TEST_EPS);
+    }
+
+    @Test
+    public void testSplit_minus() {
+        // arrange
+        RegionBSPTree2S tree = ConvexArea2S.fromVertexLoop(Arrays.asList(
+                    Point2S.PLUS_I, Point2S.PLUS_K, Point2S.MINUS_J
+                ), TEST_PRECISION).toTree();
+
+        GreatCircle splitter = GreatCircle.fromPole(Vector3D.of(0, -1, 1), TEST_PRECISION);
+
+        // act
+        Split<RegionBSPTree2S> split = tree.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.MINUS, split.getLocation());
+
+        RegionBSPTree2S minus = split.getMinus();
+        Assert.assertNotSame(tree, minus);
+        Assert.assertEquals(tree.getSize(), minus.getSize(), TEST_EPS);
+
+        Assert.assertNull(split.getPlus());
+    }
+
+    @Test
+    public void testSplit_plus() {
+        // arrange
+        RegionBSPTree2S tree = ConvexArea2S.fromVertexLoop(Arrays.asList(
+                    Point2S.PLUS_I, Point2S.PLUS_K, Point2S.MINUS_J
+                ), TEST_PRECISION).toTree();
+
+        GreatCircle splitter = GreatCircle.fromPole(Vector3D.of(0, 1, -1), TEST_PRECISION);
+
+        // act
+        Split<RegionBSPTree2S> split = tree.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.PLUS, split.getLocation());
+
+        Assert.assertNull(split.getMinus());
+
+        RegionBSPTree2S plus = split.getPlus();
+        Assert.assertNotSame(tree, plus);
+        Assert.assertEquals(tree.getSize(), plus.getSize(), TEST_EPS);
+    }
+
+    @Test
+    public void testTransform() {
+        // arrange
+        Transform2S t = Transform2S.createReflection(Point2S.PLUS_J);
+        RegionBSPTree2S tree = ConvexArea2S.fromVertexLoop(
+                Arrays.asList(Point2S.PLUS_I, Point2S.PLUS_J, Point2S.PLUS_K), TEST_PRECISION).toTree();
+
+        // act
+        tree.transform(t);
+
+        // assert
+        Assert.assertFalse(tree.isFull());
+        Assert.assertFalse(tree.isEmpty());
+        Assert.assertEquals(1.5 * Geometry.PI, tree.getBoundarySize(), TEST_EPS);
+        Assert.assertEquals(Geometry.HALF_PI, tree.getSize(), TEST_EPS);
+
+        Point2S expectedBarycenter = triangleBarycenter(Point2S.MINUS_J, Point2S.PLUS_I, Point2S.PLUS_K);
+        SphericalTestUtils.assertPointsEq(expectedBarycenter, tree.getBarycenter(), TEST_EPS);
+
+        checkBarycenterConsistency(tree);
+
+        SphericalTestUtils.checkClassify(tree, RegionLocation.INSIDE,
+                Point2S.of(-0.25 * Geometry.PI, 0.25 * Geometry.PI));
+
+        SphericalTestUtils.checkClassify(tree, RegionLocation.BOUNDARY,
+                Point2S.PLUS_I, Point2S.MINUS_J, Point2S.PLUS_K,
+                Point2S.of(0, 0.25 * Geometry.PI), Point2S.of(Geometry.MINUS_HALF_PI, 0.304 * Geometry.PI),
+                Point2S.of(-0.25 * Geometry.PI, Geometry.HALF_PI));
+
+        SphericalTestUtils.checkClassify(tree, RegionLocation.OUTSIDE,
+                Point2S.PLUS_J, Point2S.MINUS_I, Point2S.MINUS_K);
+    }
+
+    @Test
+    public void testRegionNode_getNodeRegion() {
+        // arrange
+        RegionBSPTree2S tree = RegionBSPTree2S.empty();
+
+        RegionNode2S root = tree.getRoot();
+        RegionNode2S minus = root.cut(EQUATOR).getMinus();
+        RegionNode2S minusPlus = minus.cut(X_MERIDIAN).getPlus();
+
+        // act/assert
+        ConvexArea2S rootRegion = root.getNodeRegion();
+        Assert.assertEquals(4 * Geometry.PI, rootRegion.getSize(), TEST_EPS);
+        Assert.assertNull(rootRegion.getBarycenter());
+
+        ConvexArea2S minusRegion = minus.getNodeRegion();
+        Assert.assertEquals(2 * Geometry.PI, minusRegion.getSize(), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.PLUS_K, minusRegion.getBarycenter(), TEST_EPS);
+
+        ConvexArea2S minusPlusRegion = minusPlus.getNodeRegion();
+        Assert.assertEquals(Geometry.PI, minusPlusRegion.getSize(), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.of(1.5 * Geometry.PI, 0.25 * Geometry.PI),
+                minusPlusRegion.getBarycenter(), TEST_EPS);
+    }
+
+    @Test
+    public void testGeographicMap() {
+        // arrange
+        RegionBSPTree2S continental = latLongToTree(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 },
+                { 43.42280, -1.90135 }, { 43.03401, -1.50277 }, { 42.34338,  1.82679 }, { 42.47301,  2.98599 },
+                { 43.07520,  3.10041 }, { 43.39965,  4.55696 }, { 43.12889,  6.52924 }, { 43.69384,  7.43518 },
+                { 44.12790,  7.54959 }, { 45.02851,  6.74995 }, { 45.33309,  7.09665 }, { 46.42967,  6.50009 },
+                { 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 }
+            });
+        RegionBSPTree2S corsica = latLongToTree(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 }
+            });
+
+        // act
+        RegionBSPTree2S france = RegionBSPTree2S.empty();
+        france.union(continental, corsica);
+
+        // assert
+        Assert.assertEquals(0.6316801448267251, france.getBoundarySize(), TEST_EPS);
+        Assert.assertEquals(0.013964220234478741, france.getSize(), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.of(0.04368552749392928, 0.7590839905197961),
+                france.getBarycenter(), BARYCENTER_EPS);
+
+        checkBarycenterConsistency(france);
+    }
+
+    /**
+     * Insert convex subhyperplanes defining the positive quadrant area.
+     * @param tree
+     */
+    private static void insertPositiveQuadrant(RegionBSPTree2S tree) {
+        tree.insert(Arrays.asList(
+                EQUATOR.arc(Point2S.PLUS_I, Point2S.PLUS_J),
+                X_MERIDIAN.arc(Point2S.PLUS_K, Point2S.PLUS_I),
+                Y_MERIDIAN.arc(Point2S.PLUS_J, Point2S.PLUS_K)
+            ));
+    }
+
+    private static Point2S triangleBarycenter(Point2S p1, Point2S p2, Point2S p3) {
+        // compute the barycenter using intersection mid point arcs
+        GreatCircle c1 = GreatCircle.fromPoints(p1, p2.slerp(p3, 0.5), TEST_PRECISION);
+        GreatCircle c2 = GreatCircle.fromPoints(p2, p1.slerp(p3, 0.5), TEST_PRECISION);
+
+        return c1.intersection(c2);
+    }
+
+    private static void assertPath(GreatArcPath path, Point2S ... vertices) {
+        List<Point2S> expected = Arrays.asList(vertices);
+        List<Point2S> actual = path.getVertices();
+
+        if (expected.size() != actual.size()) {
+            Assert.fail("Unexpected path size. Expected path " + expected +
+                    " but was " + actual);
+        }
+
+        for (int i = 0; i < expected.size(); ++i) {
+            if (!expected.get(i).eq(actual.get(i), TEST_PRECISION)) {
+                Assert.fail("Unexpected path vertex at index " + i + ". Expected path " + expected +
+                        " but was " + actual);
+            }
+        }
+    }
+
+    private static RegionBSPTree2S latLongToTree(double[][] points) {
+        GreatArcPath.Builder pathBuilder = GreatArcPath.builder(TEST_PRECISION);
+
+        for (int i = 0; i < points.length; ++i) {
+            pathBuilder.append(latLongToPoint(points[i][0], points[i][1]));
+        }
+
+        return pathBuilder.close().toTree();
+    }
+
+    private static Point2S latLongToPoint(double latitude, double longitude) {
+        return Point2S.of(Math.toRadians(longitude), Math.toRadians(90.0 - latitude));
+    }
+
+    private static void checkBarycenterConsistency(RegionBSPTree2S region) {
+        Point2S barycenter = region.getBarycenter();
+        double size = region.getSize();
+
+        SphericalTestUtils.checkClassify(region, RegionLocation.INSIDE, barycenter);
+
+        GreatCircle circle = GreatCircle.fromPole(barycenter.getVector(), TEST_PRECISION);
+        for (double az = 0; az <= Geometry.TWO_PI; az += 0.2) {
+            Point2S pt = circle.toSpace(Point1S.of(az));
+            GreatCircle splitter = GreatCircle.fromPoints(barycenter, pt, TEST_PRECISION);
+
+            Split<RegionBSPTree2S> split = region.split(splitter);
+
+            Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+            RegionBSPTree2S minus = split.getMinus();
+            double minusSize = minus.getSize();
+            Point2S minusBc = minus.getBarycenter();
+
+            Vector3D weightedMinus = minusBc.getVector()
+                    .multiply(minus.getSize());
+
+            RegionBSPTree2S plus = split.getPlus();
+            double plusSize = plus.getSize();
+            Point2S plusBc = plus.getBarycenter();
+
+            Vector3D weightedPlus = plusBc.getVector()
+                    .multiply(plus.getSize());
+            Point2S computedBarycenter = Point2S.from(weightedMinus.add(weightedPlus));
+
+            Assert.assertEquals(size, minusSize + plusSize, TEST_EPS);
+            SphericalTestUtils.assertPointsEq(barycenter, computedBarycenter, BARYCENTER_EPS);
+        }
+    }
+}
diff --git a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/S2PointTest.java b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/S2PointTest.java
deleted file mode 100644
index 827f107..0000000
--- a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/S2PointTest.java
+++ /dev/null
@@ -1,93 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.twod;
-
-
-import org.apache.commons.geometry.core.Geometry;
-import org.junit.Assert;
-import org.junit.Test;
-
-public class S2PointTest {
-
-    private static final double EPS = 1e-10;
-
-    @Test
-    public void testS2Point() {
-        for (int k = -2; k < 3; ++k) {
-            S2Point p = S2Point.of(1.0 + k * Geometry.TWO_PI, 1.4);
-            Assert.assertEquals(1.0, p.getAzimuth(), EPS);
-            Assert.assertEquals(1.4, p.getPolar(), EPS);
-            Assert.assertEquals(Math.cos(1.0) * Math.sin(1.4), p.getVector().getX(), EPS);
-            Assert.assertEquals(Math.sin(1.0) * Math.sin(1.4), p.getVector().getY(), EPS);
-            Assert.assertEquals(Math.cos(1.4), p.getVector().getZ(), EPS);
-            Assert.assertFalse(p.isNaN());
-        }
-    }
-
-    @Test
-    public void testNaN() {
-        Assert.assertTrue(S2Point.NaN.isNaN());
-        Assert.assertTrue(S2Point.NaN.equals(S2Point.of(Double.NaN, 1.0)));
-        Assert.assertFalse(S2Point.of(1.0, 1.3).equals(S2Point.NaN));
-    }
-
-    @Test
-    public void testEquals() {
-        S2Point a = S2Point.of(1.0, 1.0);
-        S2Point b = S2Point.of(1.0, 1.0);
-        Assert.assertEquals(a.hashCode(), b.hashCode());
-        Assert.assertFalse(a == b);
-        Assert.assertTrue(a.equals(b));
-        Assert.assertTrue(a.equals(a));
-        Assert.assertFalse(a.equals('a'));
-    }
-
-    @Test
-    public void testDistance() {
-        S2Point a = S2Point.of(1.0, 0.5 * Math.PI);
-        S2Point b = S2Point.of(a.getAzimuth() + 0.5 * Math.PI, a.getPolar());
-        Assert.assertEquals(0.5 * Math.PI, a.distance(b), 1.0e-10);
-        Assert.assertEquals(Math.PI, a.distance(a.negate()), 1.0e-10);
-        Assert.assertEquals(0.5 * Math.PI, S2Point.MINUS_I.distance(S2Point.MINUS_K), 1.0e-10);
-        Assert.assertEquals(0.0, S2Point.of(1.0, 0).distance(S2Point.of(2.0, 0)), 1.0e-10);
-    }
-
-    @Test
-    public void testToString() {
-        // act/assert
-        Assert.assertEquals("(0.0, 0.0)", S2Point.of(0.0, 0.0).toString());
-        Assert.assertEquals("(1.0, 2.0)", S2Point.of(1.0, 2.0).toString());
-    }
-
-    @Test
-    public void testParse() {
-        // act/assert
-        checkPoint(S2Point.parse("(0,0)"), 0.0, 0.0);
-        checkPoint(S2Point.parse("(1,2)"), 1.0, 2.0);
-    }
-
-    @Test(expected = IllegalArgumentException.class)
-    public void testParse_failure() {
-        // act/assert
-        S2Point.parse("abc");
-    }
-
-    private void checkPoint(S2Point p, double theta, double phi) {
-        Assert.assertEquals(theta, p.getAzimuth(), EPS);
-        Assert.assertEquals(phi, p.getPolar(), EPS);
-    }
-}
diff --git a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/SphericalPolygonsSetTest.java b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/SphericalPolygonsSetTest.java
deleted file mode 100644
index c711051..0000000
--- a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/SphericalPolygonsSetTest.java
+++ /dev/null
@@ -1,570 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.twod;
-
-import java.util.ArrayList;
-import java.util.List;
-
-import org.apache.commons.geometry.core.Geometry;
-import org.apache.commons.geometry.core.partitioning.Region.Location;
-import org.apache.commons.geometry.core.partitioning.RegionFactory;
-import org.apache.commons.geometry.core.partitioning.SubHyperplane;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
-import org.apache.commons.geometry.enclosing.EnclosingBall;
-import org.apache.commons.geometry.euclidean.threed.Vector3D;
-import org.apache.commons.geometry.euclidean.threed.rotation.QuaternionRotation;
-import org.apache.commons.geometry.spherical.oned.ArcsSet;
-import org.apache.commons.geometry.spherical.oned.S1Point;
-import org.apache.commons.rng.sampling.UnitSphereSampler;
-import org.apache.commons.rng.simple.RandomSource;
-import org.junit.Assert;
-import org.junit.Test;
-
-public class SphericalPolygonsSetTest {
-
-    private static final double TEST_EPS = 1e-10;
-
-    private static final DoublePrecisionContext TEST_PRECISION =
-            new EpsilonDoublePrecisionContext(TEST_EPS);
-
-    @Test
-    public void testFullSphere() {
-        SphericalPolygonsSet full = new SphericalPolygonsSet(TEST_PRECISION);
-        UnitSphereSampler random =
-                new UnitSphereSampler(3, RandomSource.create(RandomSource.WELL_1024_A,
-                                                             0x852fd2a0ed8d2f6dl));
-        for (int i = 0; i < 1000; ++i) {
-            Vector3D v = Vector3D.of(random.nextVector());
-            Assert.assertEquals(Location.INSIDE, full.checkPoint(S2Point.ofVector(v)));
-        }
-        Assert.assertEquals(4 * Math.PI, new SphericalPolygonsSet(createPrecision(0.01), new S2Point[0]).getSize(), TEST_EPS);
-        Assert.assertEquals(0, new SphericalPolygonsSet(createPrecision(0.01), new S2Point[0]).getBoundarySize(), TEST_EPS);
-        Assert.assertEquals(0, full.getBoundaryLoops().size());
-        Assert.assertTrue(full.getEnclosingCap().getRadius() > 0);
-        Assert.assertTrue(Double.isInfinite(full.getEnclosingCap().getRadius()));
-    }
-
-    @Test
-    public void testEmpty() {
-        SphericalPolygonsSet empty =
-            (SphericalPolygonsSet) new RegionFactory<S2Point>().getComplement(new SphericalPolygonsSet(TEST_PRECISION));
-        UnitSphereSampler random =
-                new UnitSphereSampler(3, RandomSource.create(RandomSource.WELL_1024_A,
-                                                             0x76d9205d6167b6ddl));
-        for (int i = 0; i < 1000; ++i) {
-            Vector3D v = Vector3D.of(random.nextVector());
-            Assert.assertEquals(Location.OUTSIDE, empty.checkPoint(S2Point.ofVector(v)));
-        }
-        Assert.assertEquals(0, empty.getSize(), TEST_EPS);
-        Assert.assertEquals(0, empty.getBoundarySize(), TEST_EPS);
-        Assert.assertEquals(0, empty.getBoundaryLoops().size());
-        Assert.assertTrue(empty.getEnclosingCap().getRadius() < 0);
-        Assert.assertTrue(Double.isInfinite(empty.getEnclosingCap().getRadius()));
-    }
-
-    @Test
-    public void testSouthHemisphere() {
-        double tol = 0.01;
-        double sinTol = Math.sin(tol);
-        SphericalPolygonsSet south = new SphericalPolygonsSet(Vector3D.Unit.MINUS_Z, createPrecision(tol));
-        UnitSphereSampler random =
-                new UnitSphereSampler(3, RandomSource.create(RandomSource.WELL_1024_A,
-                                                             0x6b9d4a6ad90d7b0bl));
-        for (int i = 0; i < 1000; ++i) {
-            Vector3D v = Vector3D.of(random.nextVector());
-            if (v.getZ() < -sinTol) {
-                Assert.assertEquals(Location.INSIDE, south.checkPoint(S2Point.ofVector(v)));
-            } else if (v.getZ() > sinTol) {
-                Assert.assertEquals(Location.OUTSIDE, south.checkPoint(S2Point.ofVector(v)));
-            } else {
-                Assert.assertEquals(Location.BOUNDARY, south.checkPoint(S2Point.ofVector(v)));
-            }
-        }
-        Assert.assertEquals(1, south.getBoundaryLoops().size());
-
-        EnclosingBall<S2Point> southCap = south.getEnclosingCap();
-        Assert.assertEquals(0.0, S2Point.MINUS_K.distance(southCap.getCenter()), TEST_EPS);
-        Assert.assertEquals(0.5 * Math.PI, southCap.getRadius(), TEST_EPS);
-
-        EnclosingBall<S2Point> northCap =
-                ((SphericalPolygonsSet) new RegionFactory<S2Point>().getComplement(south)).getEnclosingCap();
-        Assert.assertEquals(0.0, S2Point.PLUS_K.distance(northCap.getCenter()), TEST_EPS);
-        Assert.assertEquals(0.5 * Math.PI, northCap.getRadius(), TEST_EPS);
-
-    }
-
-    @Test
-    public void testPositiveOctantByIntersection() {
-        double tol = 0.01;
-        double sinTol = Math.sin(tol);
-        DoublePrecisionContext precision = createPrecision(tol);
-        RegionFactory<S2Point> factory = new RegionFactory<>();
-        SphericalPolygonsSet plusX = new SphericalPolygonsSet(Vector3D.Unit.PLUS_X, precision);
-        SphericalPolygonsSet plusY = new SphericalPolygonsSet(Vector3D.Unit.PLUS_Y, precision);
-        SphericalPolygonsSet plusZ = new SphericalPolygonsSet(Vector3D.Unit.PLUS_Z, precision);
-        SphericalPolygonsSet octant =
-                (SphericalPolygonsSet) factory.intersection(factory.intersection(plusX, plusY), plusZ);
-        UnitSphereSampler random =
-                new UnitSphereSampler(3, RandomSource.create(RandomSource.WELL_1024_A,
-                                                             0x9c9802fde3cbcf25l));
-        for (int i = 0; i < 1000; ++i) {
-            Vector3D v = Vector3D.of(random.nextVector());
-            if ((v.getX() > sinTol) && (v.getY() > sinTol) && (v.getZ() > sinTol)) {
-                Assert.assertEquals(Location.INSIDE, octant.checkPoint(S2Point.ofVector(v)));
-            } else if ((v.getX() < -sinTol) || (v.getY() < -sinTol) || (v.getZ() < -sinTol)) {
-                Assert.assertEquals(Location.OUTSIDE, octant.checkPoint(S2Point.ofVector(v)));
-            } else {
-                Assert.assertEquals(Location.BOUNDARY, octant.checkPoint(S2Point.ofVector(v)));
-            }
-        }
-
-        List<Vertex> loops = octant.getBoundaryLoops();
-        Assert.assertEquals(1, loops.size());
-        boolean xPFound = false;
-        boolean yPFound = false;
-        boolean zPFound = false;
-        boolean xVFound = false;
-        boolean yVFound = false;
-        boolean zVFound = false;
-        Vertex first = loops.get(0);
-        int count = 0;
-        for (Vertex v = first; count == 0 || v != first; v = v.getOutgoing().getEnd()) {
-            ++count;
-            Edge e = v.getIncoming();
-            Assert.assertTrue(v == e.getStart().getOutgoing().getEnd());
-            xPFound = xPFound || e.getCircle().getPole().distance(Vector3D.Unit.PLUS_X) < TEST_EPS;
-            yPFound = yPFound || e.getCircle().getPole().distance(Vector3D.Unit.PLUS_Y) < TEST_EPS;
-            zPFound = zPFound || e.getCircle().getPole().distance(Vector3D.Unit.PLUS_Z) < TEST_EPS;
-            Assert.assertEquals(0.5 * Math.PI, e.getLength(), TEST_EPS);
-            xVFound = xVFound || v.getLocation().getVector().distance(Vector3D.Unit.PLUS_X) < TEST_EPS;
-            yVFound = yVFound || v.getLocation().getVector().distance(Vector3D.Unit.PLUS_Y) < TEST_EPS;
-            zVFound = zVFound || v.getLocation().getVector().distance(Vector3D.Unit.PLUS_Z) < TEST_EPS;
-        }
-        Assert.assertTrue(xPFound);
-        Assert.assertTrue(yPFound);
-        Assert.assertTrue(zPFound);
-        Assert.assertTrue(xVFound);
-        Assert.assertTrue(yVFound);
-        Assert.assertTrue(zVFound);
-        Assert.assertEquals(3, count);
-
-        Assert.assertEquals(0.0,
-                            octant.getBarycenter().distance(S2Point.ofVector(Vector3D.of(1, 1, 1))),
-                            TEST_EPS);
-        Assert.assertEquals(0.5 * Math.PI, octant.getSize(), TEST_EPS);
-
-        EnclosingBall<S2Point> cap = octant.getEnclosingCap();
-        Assert.assertEquals(0.0, octant.getBarycenter().distance(cap.getCenter()), TEST_EPS);
-        Assert.assertEquals(Math.acos(1.0 / Math.sqrt(3)), cap.getRadius(), TEST_EPS);
-
-        EnclosingBall<S2Point> reversedCap =
-                ((SphericalPolygonsSet) factory.getComplement(octant)).getEnclosingCap();
-        Assert.assertEquals(0, reversedCap.getCenter().distance(S2Point.ofVector(Vector3D.of(-1, -1, -1))), TEST_EPS);
-        Assert.assertEquals(Math.PI - Math.asin(1.0 / Math.sqrt(3)), reversedCap.getRadius(), TEST_EPS);
-
-    }
-
-    @Test
-    public void testPositiveOctantByVertices() {
-        double tol = 0.01;
-        double sinTol = Math.sin(tol);
-        SphericalPolygonsSet octant = new SphericalPolygonsSet(createPrecision(tol), S2Point.PLUS_I, S2Point.PLUS_J, S2Point.PLUS_K);
-        UnitSphereSampler random =
-                new UnitSphereSampler(3, RandomSource.create(RandomSource.WELL_1024_A,
-                                                             0xb8fc5acc91044308l));
-        for (int i = 0; i < 1000; ++i) {
-            Vector3D v = Vector3D.of(random.nextVector());
-            if ((v.getX() > sinTol) && (v.getY() > sinTol) && (v.getZ() > sinTol)) {
-                Assert.assertEquals(Location.INSIDE, octant.checkPoint(S2Point.ofVector(v)));
-            } else if ((v.getX() < -sinTol) || (v.getY() < -sinTol) || (v.getZ() < -sinTol)) {
-                Assert.assertEquals(Location.OUTSIDE, octant.checkPoint(S2Point.ofVector(v)));
-            } else {
-                Assert.assertEquals(Location.BOUNDARY, octant.checkPoint(S2Point.ofVector(v)));
-            }
-        }
-    }
-
-    @Test
-    public void testNonConvex() {
-        double tol = 0.01;
-        double sinTol = Math.sin(tol);
-        DoublePrecisionContext precision = createPrecision(tol);
-        RegionFactory<S2Point> factory = new RegionFactory<>();
-        SphericalPolygonsSet plusX = new SphericalPolygonsSet(Vector3D.Unit.PLUS_X, precision);
-        SphericalPolygonsSet plusY = new SphericalPolygonsSet(Vector3D.Unit.PLUS_Y, precision);
-        SphericalPolygonsSet plusZ = new SphericalPolygonsSet(Vector3D.Unit.PLUS_Z, precision);
-        SphericalPolygonsSet threeOctants =
-                (SphericalPolygonsSet) factory.difference(plusZ, factory.intersection(plusX, plusY));
-
-        UnitSphereSampler random =
-                new UnitSphereSampler(3, RandomSource.create(RandomSource.WELL_1024_A,
-                                                             0x9c9802fde3cbcf25l));
-        for (int i = 0; i < 1000; ++i) {
-            Vector3D v = Vector3D.of(random.nextVector());
-            if (((v.getX() < -sinTol) || (v.getY() < -sinTol)) && (v.getZ() > sinTol)) {
-                Assert.assertEquals(Location.INSIDE, threeOctants.checkPoint(S2Point.ofVector(v)));
-            } else if (((v.getX() > sinTol) && (v.getY() > sinTol)) || (v.getZ() < -sinTol)) {
-                Assert.assertEquals(Location.OUTSIDE, threeOctants.checkPoint(S2Point.ofVector(v)));
-            } else {
-                Assert.assertEquals(Location.BOUNDARY, threeOctants.checkPoint(S2Point.ofVector(v)));
-            }
-        }
-
-        List<Vertex> loops = threeOctants.getBoundaryLoops();
-        Assert.assertEquals(1, loops.size());
-        boolean xPFound = false;
-        boolean yPFound = false;
-        boolean zPFound = false;
-        boolean xVFound = false;
-        boolean yVFound = false;
-        boolean zVFound = false;
-        Vertex first = loops.get(0);
-        int count = 0;
-        double sumPoleX = 0;
-        double sumPoleY = 0;
-        double sumPoleZ = 0;
-        for (Vertex v = first; count == 0 || v != first; v = v.getOutgoing().getEnd()) {
-            ++count;
-            Edge e = v.getIncoming();
-            Assert.assertTrue(v == e.getStart().getOutgoing().getEnd());
-            if (e.getCircle().getPole().distance(Vector3D.Unit.MINUS_X) < TEST_EPS) {
-                xPFound = true;
-                sumPoleX += e.getLength();
-            } else if (e.getCircle().getPole().distance(Vector3D.Unit.MINUS_Y) < TEST_EPS) {
-                yPFound = true;
-                sumPoleY += e.getLength();
-            } else {
-                Assert.assertEquals(0.0, e.getCircle().getPole().distance(Vector3D.Unit.PLUS_Z), TEST_EPS);
-                zPFound = true;
-                sumPoleZ += e.getLength();
-            }
-            xVFound = xVFound || v.getLocation().getVector().distance(Vector3D.Unit.PLUS_X) < TEST_EPS;
-            yVFound = yVFound || v.getLocation().getVector().distance(Vector3D.Unit.PLUS_Y) < TEST_EPS;
-            zVFound = zVFound || v.getLocation().getVector().distance(Vector3D.Unit.PLUS_Z) < TEST_EPS;
-        }
-        Assert.assertTrue(xPFound);
-        Assert.assertTrue(yPFound);
-        Assert.assertTrue(zPFound);
-        Assert.assertTrue(xVFound);
-        Assert.assertTrue(yVFound);
-        Assert.assertTrue(zVFound);
-        Assert.assertEquals(0.5 * Math.PI, sumPoleX, TEST_EPS);
-        Assert.assertEquals(0.5 * Math.PI, sumPoleY, TEST_EPS);
-        Assert.assertEquals(1.5 * Math.PI, sumPoleZ, TEST_EPS);
-
-        Assert.assertEquals(1.5 * Math.PI, threeOctants.getSize(), TEST_EPS);
-
-    }
-
-    @Test
-    public void testModeratlyComplexShape() {
-        double tol = 0.01;
-        DoublePrecisionContext precision = createPrecision(tol);
-        List<SubHyperplane<S2Point>> boundary = new ArrayList<>();
-        boundary.add(create(Vector3D.Unit.MINUS_Y, Vector3D.Unit.PLUS_X,  Vector3D.Unit.PLUS_Z,  precision, 0.0, 0.5 * Math.PI));
-        boundary.add(create(Vector3D.Unit.MINUS_X, Vector3D.Unit.PLUS_Z,  Vector3D.Unit.PLUS_Y,  precision, 0.0, 0.5 * Math.PI));
-        boundary.add(create(Vector3D.Unit.PLUS_Z,  Vector3D.Unit.PLUS_Y,  Vector3D.Unit.MINUS_X, precision, 0.0, 0.5 * Math.PI));
-        boundary.add(create(Vector3D.Unit.MINUS_Y, Vector3D.Unit.MINUS_X, Vector3D.Unit.MINUS_Z, precision, 0.0, 0.5 * Math.PI));
-        boundary.add(create(Vector3D.Unit.MINUS_X, Vector3D.Unit.MINUS_Z, Vector3D.Unit.MINUS_Y, precision, 0.0, 0.5 * Math.PI));
-        boundary.add(create(Vector3D.Unit.PLUS_Z,  Vector3D.Unit.MINUS_Y, Vector3D.Unit.PLUS_X,  precision, 0.0, 0.5 * Math.PI));
-        SphericalPolygonsSet polygon = new SphericalPolygonsSet(boundary, precision);
-
-        Assert.assertEquals(Location.OUTSIDE, polygon.checkPoint(S2Point.ofVector(Vector3D.of( 1,  1,  1).normalize())));
-        Assert.assertEquals(Location.INSIDE,  polygon.checkPoint(S2Point.ofVector(Vector3D.of(-1,  1,  1).normalize())));
-        Assert.assertEquals(Location.INSIDE,  polygon.checkPoint(S2Point.ofVector(Vector3D.of(-1, -1,  1).normalize())));
-        Assert.assertEquals(Location.INSIDE,  polygon.checkPoint(S2Point.ofVector(Vector3D.of( 1, -1,  1).normalize())));
-        Assert.assertEquals(Location.OUTSIDE, polygon.checkPoint(S2Point.ofVector(Vector3D.of( 1,  1, -1).normalize())));
-        Assert.assertEquals(Location.OUTSIDE, polygon.checkPoint(S2Point.ofVector(Vector3D.of(-1,  1, -1).normalize())));
-        Assert.assertEquals(Location.INSIDE,  polygon.checkPoint(S2Point.ofVector(Vector3D.of(-1, -1, -1).normalize())));
-        Assert.assertEquals(Location.OUTSIDE, polygon.checkPoint(S2Point.ofVector(Vector3D.of( 1, -1, -1).normalize())));
-
-        Assert.assertEquals(Geometry.TWO_PI, polygon.getSize(), TEST_EPS);
-        Assert.assertEquals(3 * Math.PI, polygon.getBoundarySize(), TEST_EPS);
-
-        List<Vertex> loops = polygon.getBoundaryLoops();
-        Assert.assertEquals(1, loops.size());
-        boolean pXFound = false;
-        boolean mXFound = false;
-        boolean pYFound = false;
-        boolean mYFound = false;
-        boolean pZFound = false;
-        boolean mZFound = false;
-        Vertex first = loops.get(0);
-        int count = 0;
-        for (Vertex v = first; count == 0 || v != first; v = v.getOutgoing().getEnd()) {
-            ++count;
-            Edge e = v.getIncoming();
-            Assert.assertTrue(v == e.getStart().getOutgoing().getEnd());
-            pXFound = pXFound || v.getLocation().getVector().distance(Vector3D.Unit.PLUS_X)  < TEST_EPS;
-            mXFound = mXFound || v.getLocation().getVector().distance(Vector3D.Unit.MINUS_X) < TEST_EPS;
-            pYFound = pYFound || v.getLocation().getVector().distance(Vector3D.Unit.PLUS_Y)  < TEST_EPS;
-            mYFound = mYFound || v.getLocation().getVector().distance(Vector3D.Unit.MINUS_Y) < TEST_EPS;
-            pZFound = pZFound || v.getLocation().getVector().distance(Vector3D.Unit.PLUS_Z)  < TEST_EPS;
-            mZFound = mZFound || v.getLocation().getVector().distance(Vector3D.Unit.MINUS_Z) < TEST_EPS;
-            Assert.assertEquals(0.5 * Math.PI, e.getLength(), TEST_EPS);
-        }
-        Assert.assertTrue(pXFound);
-        Assert.assertTrue(mXFound);
-        Assert.assertTrue(pYFound);
-        Assert.assertTrue(mYFound);
-        Assert.assertTrue(pZFound);
-        Assert.assertTrue(mZFound);
-        Assert.assertEquals(6, count);
-
-    }
-
-    @Test
-    public void testSeveralParts() {
-        double tol = 0.01;
-        double sinTol = Math.sin(tol);
-        DoublePrecisionContext precision = createPrecision(tol);
-        List<SubHyperplane<S2Point>> boundary = new ArrayList<>();
-
-        // first part: +X, +Y, +Z octant
-        boundary.add(create(Vector3D.Unit.PLUS_Y,  Vector3D.Unit.PLUS_Z,  Vector3D.Unit.PLUS_X,  precision, 0.0, 0.5 * Math.PI));
-        boundary.add(create(Vector3D.Unit.PLUS_Z,  Vector3D.Unit.PLUS_X,  Vector3D.Unit.PLUS_Y,  precision, 0.0, 0.5 * Math.PI));
-        boundary.add(create(Vector3D.Unit.PLUS_X,  Vector3D.Unit.PLUS_Y,  Vector3D.Unit.PLUS_Z,  precision, 0.0, 0.5 * Math.PI));
-
-        // first part: -X, -Y, -Z octant
-        boundary.add(create(Vector3D.Unit.MINUS_Y, Vector3D.Unit.MINUS_X, Vector3D.Unit.MINUS_Z, precision, 0.0, 0.5 * Math.PI));
-        boundary.add(create(Vector3D.Unit.MINUS_X, Vector3D.Unit.MINUS_Z, Vector3D.Unit.MINUS_Y, precision, 0.0, 0.5 * Math.PI));
-        boundary.add(create(Vector3D.Unit.MINUS_Z, Vector3D.Unit.MINUS_Y, Vector3D.Unit.MINUS_X,  precision, 0.0, 0.5 * Math.PI));
-
-        SphericalPolygonsSet polygon = new SphericalPolygonsSet(boundary, precision);
-
-        UnitSphereSampler random =
-                new UnitSphereSampler(3, RandomSource.create(RandomSource.WELL_1024_A,
-                                                             0xcc5ce49949e0d3ecl));
-        for (int i = 0; i < 1000; ++i) {
-            Vector3D v = Vector3D.of(random.nextVector());
-            if ((v.getX() < -sinTol) && (v.getY() < -sinTol) && (v.getZ() < -sinTol)) {
-                Assert.assertEquals(Location.INSIDE, polygon.checkPoint(S2Point.ofVector(v)));
-            } else if ((v.getX() < sinTol) && (v.getY() < sinTol) && (v.getZ() < sinTol)) {
-                Assert.assertEquals(Location.BOUNDARY, polygon.checkPoint(S2Point.ofVector(v)));
-            } else if ((v.getX() > sinTol) && (v.getY() > sinTol) && (v.getZ() > sinTol)) {
-                Assert.assertEquals(Location.INSIDE, polygon.checkPoint(S2Point.ofVector(v)));
-            } else if ((v.getX() > -sinTol) && (v.getY() > -sinTol) && (v.getZ() > -sinTol)) {
-                Assert.assertEquals(Location.BOUNDARY, polygon.checkPoint(S2Point.ofVector(v)));
-            } else {
-                Assert.assertEquals(Location.OUTSIDE, polygon.checkPoint(S2Point.ofVector(v)));
-            }
-        }
-
-        Assert.assertEquals(Math.PI, polygon.getSize(), TEST_EPS);
-        Assert.assertEquals(3 * Math.PI, polygon.getBoundarySize(), TEST_EPS);
-
-        // there should be two separate boundary loops
-        Assert.assertEquals(2, polygon.getBoundaryLoops().size());
-
-    }
-
-    @Test
-    public void testPartWithHole() {
-        double tol = 0.01;
-        double alpha = 0.7;
-        DoublePrecisionContext precision = createPrecision(tol);
-        S2Point center = S2Point.ofVector(Vector3D.of(1, 1, 1));
-        SphericalPolygonsSet hexa = new SphericalPolygonsSet(center.getVector(), Vector3D.Unit.PLUS_Z, alpha, 6, precision);
-        SphericalPolygonsSet hole  = new SphericalPolygonsSet(precision,
-                                                              S2Point.of(Math.PI / 6, Math.PI / 3),
-                                                              S2Point.of(Math.PI / 3, Math.PI / 3),
-                                                              S2Point.of(Math.PI / 4, Math.PI / 6));
-        SphericalPolygonsSet hexaWithHole =
-                (SphericalPolygonsSet) new RegionFactory<S2Point>().difference(hexa, hole);
-
-        for (double phi = center.getPolar() - alpha + 0.1; phi < center.getPolar() + alpha - 0.1; phi += 0.07) {
-            Location l = hexaWithHole.checkPoint(S2Point.of(Math.PI / 4, phi));
-            if (phi < Math.PI / 6 || phi > Math.PI / 3) {
-                Assert.assertEquals(Location.INSIDE,  l);
-            } else {
-                Assert.assertEquals(Location.OUTSIDE, l);
-            }
-        }
-
-        // there should be two separate boundary loops
-        Assert.assertEquals(2, hexaWithHole.getBoundaryLoops().size());
-
-        Assert.assertEquals(hexa.getBoundarySize() + hole.getBoundarySize(), hexaWithHole.getBoundarySize(), TEST_EPS);
-        Assert.assertEquals(hexa.getSize() - hole.getSize(), hexaWithHole.getSize(), TEST_EPS);
-
-    }
-
-    @Test
-    public void testConcentricSubParts() {
-        double tol = 0.001;
-        DoublePrecisionContext precision = createPrecision(tol);
-        Vector3D center = Vector3D.of(1, 1, 1);
-        SphericalPolygonsSet hexaOut   = new SphericalPolygonsSet(center, Vector3D.Unit.PLUS_Z, 0.9,  6, precision);
-        SphericalPolygonsSet hexaIn    = new SphericalPolygonsSet(center, Vector3D.Unit.PLUS_Z, 0.8,  6, precision);
-        SphericalPolygonsSet pentaOut  = new SphericalPolygonsSet(center, Vector3D.Unit.PLUS_Z, 0.7,  5, precision);
-        SphericalPolygonsSet pentaIn   = new SphericalPolygonsSet(center, Vector3D.Unit.PLUS_Z, 0.6,  5, precision);
-        SphericalPolygonsSet quadriOut = new SphericalPolygonsSet(center, Vector3D.Unit.PLUS_Z, 0.5,  4, precision);
-        SphericalPolygonsSet quadriIn  = new SphericalPolygonsSet(center, Vector3D.Unit.PLUS_Z, 0.4,  4, precision);
-        SphericalPolygonsSet triOut    = new SphericalPolygonsSet(center, Vector3D.Unit.PLUS_Z, 0.25, 3, precision);
-        SphericalPolygonsSet triIn     = new SphericalPolygonsSet(center, Vector3D.Unit.PLUS_Z, 0.15, 3, precision);
-
-        RegionFactory<S2Point> factory = new RegionFactory<>();
-        SphericalPolygonsSet hexa   = (SphericalPolygonsSet) factory.difference(hexaOut,   hexaIn);
-        SphericalPolygonsSet penta  = (SphericalPolygonsSet) factory.difference(pentaOut,  pentaIn);
-        SphericalPolygonsSet quadri = (SphericalPolygonsSet) factory.difference(quadriOut, quadriIn);
-        SphericalPolygonsSet tri    = (SphericalPolygonsSet) factory.difference(triOut,    triIn);
-        SphericalPolygonsSet concentric =
-                (SphericalPolygonsSet) factory.union(factory.union(hexa, penta), factory.union(quadri, tri));
-
-        // there should be two separate boundary loops
-        Assert.assertEquals(8, concentric.getBoundaryLoops().size());
-
-        Assert.assertEquals(hexaOut.getBoundarySize()   + hexaIn.getBoundarySize()   +
-                            pentaOut.getBoundarySize()  + pentaIn.getBoundarySize()  +
-                            quadriOut.getBoundarySize() + quadriIn.getBoundarySize() +
-                            triOut.getBoundarySize()    + triIn.getBoundarySize(),
-                            concentric.getBoundarySize(), TEST_EPS);
-        Assert.assertEquals(hexaOut.getSize()   - hexaIn.getSize()   +
-                            pentaOut.getSize()  - pentaIn.getSize()  +
-                            quadriOut.getSize() - quadriIn.getSize() +
-                            triOut.getSize()    - triIn.getSize(),
-                            concentric.getSize(), TEST_EPS);
-
-        // we expect lots of sign changes as we traverse all concentric rings
-        double phi = S2Point.ofVector(center).getPolar();
-        Assert.assertEquals(+0.207, concentric.projectToBoundary(S2Point.of(-0.60,  phi)).getOffset(), 0.01);
-        Assert.assertEquals(-0.048, concentric.projectToBoundary(S2Point.of(-0.21,  phi)).getOffset(), 0.01);
-        Assert.assertEquals(+0.027, concentric.projectToBoundary(S2Point.of(-0.10,  phi)).getOffset(), 0.01);
-        Assert.assertEquals(-0.041, concentric.projectToBoundary(S2Point.of( 0.01,  phi)).getOffset(), 0.01);
-        Assert.assertEquals(+0.049, concentric.projectToBoundary(S2Point.of( 0.16,  phi)).getOffset(), 0.01);
-        Assert.assertEquals(-0.038, concentric.projectToBoundary(S2Point.of( 0.29,  phi)).getOffset(), 0.01);
-        Assert.assertEquals(+0.097, concentric.projectToBoundary(S2Point.of( 0.48,  phi)).getOffset(), 0.01);
-        Assert.assertEquals(-0.022, concentric.projectToBoundary(S2Point.of( 0.64,  phi)).getOffset(), 0.01);
-        Assert.assertEquals(+0.072, concentric.projectToBoundary(S2Point.of( 0.79,  phi)).getOffset(), 0.01);
-        Assert.assertEquals(-0.022, concentric.projectToBoundary(S2Point.of( 0.93,  phi)).getOffset(), 0.01);
-        Assert.assertEquals(+0.091, concentric.projectToBoundary(S2Point.of( 1.08,  phi)).getOffset(), 0.01);
-        Assert.assertEquals(-0.037, concentric.projectToBoundary(S2Point.of( 1.28,  phi)).getOffset(), 0.01);
-        Assert.assertEquals(+0.051, concentric.projectToBoundary(S2Point.of( 1.40,  phi)).getOffset(), 0.01);
-        Assert.assertEquals(-0.041, concentric.projectToBoundary(S2Point.of( 1.55,  phi)).getOffset(), 0.01);
-        Assert.assertEquals(+0.027, concentric.projectToBoundary(S2Point.of( 1.67,  phi)).getOffset(), 0.01);
-        Assert.assertEquals(-0.044, concentric.projectToBoundary(S2Point.of( 1.79,  phi)).getOffset(), 0.01);
-        Assert.assertEquals(+0.201, concentric.projectToBoundary(S2Point.of( 2.16,  phi)).getOffset(), 0.01);
-
-    }
-
-    @Test
-    public void testGeographicalMap() {
-
-        SphericalPolygonsSet continental = buildSimpleZone(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 },
-          { 43.42280, -1.90135 }, { 43.03401, -1.50277 }, { 42.34338,  1.82679 }, { 42.47301,  2.98599 },
-          { 43.07520,  3.10041 }, { 43.39965,  4.55696 }, { 43.12889,  6.52924 }, { 43.69384,  7.43518 },
-          { 44.12790,  7.54959 }, { 45.02851,  6.74995 }, { 45.33309,  7.09665 }, { 46.42967,  6.50009 },
-          { 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 }
-        });
-        SphericalPolygonsSet corsica = buildSimpleZone(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 }
-        });
-        RegionFactory<S2Point> factory = new RegionFactory<>();
-        SphericalPolygonsSet zone = (SphericalPolygonsSet) factory.union(continental, corsica);
-        EnclosingBall<S2Point> enclosing = zone.getEnclosingCap();
-        Vector3D enclosingCenter = enclosing.getCenter().getVector();
-
-        double step = Math.toRadians(0.1);
-        for (Vertex loopStart : zone.getBoundaryLoops()) {
-            int count = 0;
-            for (Vertex v = loopStart; count == 0 || v != loopStart; v = v.getOutgoing().getEnd()) {
-                ++count;
-                for (int i = 0; i < Math.ceil(v.getOutgoing().getLength() / step); ++i) {
-                    Vector3D p = v.getOutgoing().getPointAt(i * step);
-                    Assert.assertTrue(p.angle(enclosingCenter) <= enclosing.getRadius());
-                }
-            }
-        }
-
-        S2Point supportPointA = s2Point(48.68416, -4.59234);
-        S2Point supportPointB = s2Point(41.38000,  9.22975);
-        Assert.assertEquals(enclosing.getRadius(), supportPointA.distance(enclosing.getCenter()), TEST_EPS);
-        Assert.assertEquals(enclosing.getRadius(), supportPointB.distance(enclosing.getCenter()), TEST_EPS);
-        Assert.assertEquals(0.5 * supportPointA.distance(supportPointB), enclosing.getRadius(), TEST_EPS);
-        Assert.assertEquals(2, enclosing.getSupportSize());
-
-        EnclosingBall<S2Point> continentalInscribed =
-                ((SphericalPolygonsSet) factory.getComplement(continental)).getEnclosingCap();
-        Vector3D continentalCenter = continentalInscribed.getCenter().getVector();
-        Assert.assertEquals(2.2, Math.toDegrees(Math.PI - continentalInscribed.getRadius()), 0.1);
-        for (Vertex loopStart : continental.getBoundaryLoops()) {
-            int count = 0;
-            for (Vertex v = loopStart; count == 0 || v != loopStart; v = v.getOutgoing().getEnd()) {
-                ++count;
-                for (int i = 0; i < Math.ceil(v.getOutgoing().getLength() / step); ++i) {
-                    Vector3D p = v.getOutgoing().getPointAt(i * step);
-                    Assert.assertTrue(p.angle(continentalCenter) <= continentalInscribed.getRadius());
-                }
-            }
-        }
-
-        EnclosingBall<S2Point> corsicaInscribed =
-                ((SphericalPolygonsSet) factory.getComplement(corsica)).getEnclosingCap();
-        Vector3D corsicaCenter = corsicaInscribed.getCenter().getVector();
-        Assert.assertEquals(0.34, Math.toDegrees(Math.PI - corsicaInscribed.getRadius()), 0.01);
-        for (Vertex loopStart : corsica.getBoundaryLoops()) {
-            int count = 0;
-            for (Vertex v = loopStart; count == 0 || v != loopStart; v = v.getOutgoing().getEnd()) {
-                ++count;
-                for (int i = 0; i < Math.ceil(v.getOutgoing().getLength() / step); ++i) {
-                    Vector3D p = v.getOutgoing().getPointAt(i * step);
-                    Assert.assertTrue(p.angle(corsicaCenter) <= corsicaInscribed.getRadius());
-                }
-            }
-        }
-
-    }
-
-    private SubCircle create(Vector3D pole, Vector3D x, Vector3D y,
-                             DoublePrecisionContext precision, double ... limits) {
-        RegionFactory<S1Point> factory = new RegionFactory<>();
-        Circle circle = new Circle(pole, precision);
-        Circle phased =
-                (Circle) Circle.getTransform(QuaternionRotation.createBasisRotation(circle.getXAxis(), circle.getYAxis(), x, y)).apply(circle);
-        ArcsSet set = (ArcsSet) factory.getComplement(new ArcsSet(precision));
-        for (int i = 0; i < limits.length; i += 2) {
-            set = (ArcsSet) factory.union(set, new ArcsSet(limits[i], limits[i + 1], precision));
-        }
-        return new SubCircle(phased, set);
-    }
-
-    private SphericalPolygonsSet buildSimpleZone(double[][] points) {
-        final S2Point[] vertices = new S2Point[points.length];
-        for (int i = 0; i < points.length; ++i) {
-            vertices[i] = s2Point(points[i][0], points[i][1]);
-        }
-        return new SphericalPolygonsSet(TEST_PRECISION, vertices);
-    }
-
-    private S2Point s2Point(double latitude, double longitude) {
-        return S2Point.of(Math.toRadians(longitude), Math.toRadians(90.0 - latitude));
-    }
-
-    /** Create a {@link DoublePrecisionContext} with the given epsilon value.
-     * @param eps epsilon value
-     * @return new precision context
-     */
-    private static DoublePrecisionContext createPrecision(final double eps) {
-        return new EpsilonDoublePrecisionContext(eps);
-    }
-}
diff --git a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/SubCircleTest.java b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/SubCircleTest.java
deleted file mode 100644
index b4664a0..0000000
--- a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/SubCircleTest.java
+++ /dev/null
@@ -1,146 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.twod;
-
-import org.apache.commons.geometry.core.Geometry;
-import org.apache.commons.geometry.core.partitioning.RegionFactory;
-import org.apache.commons.geometry.core.partitioning.Side;
-import org.apache.commons.geometry.core.partitioning.SubHyperplane.SplitSubHyperplane;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
-import org.apache.commons.geometry.euclidean.threed.Vector3D;
-import org.apache.commons.geometry.euclidean.threed.rotation.QuaternionRotation;
-import org.apache.commons.geometry.spherical.oned.ArcsSet;
-import org.apache.commons.geometry.spherical.oned.S1Point;
-import org.junit.Assert;
-import org.junit.Test;
-
-public class SubCircleTest {
-
-    private static final double TEST_EPS = 1e-10;
-
-    private static final DoublePrecisionContext TEST_PRECISION =
-            new EpsilonDoublePrecisionContext(TEST_EPS);
-
-    @Test
-    public void testFullCircle() {
-        Circle circle = new Circle(Vector3D.Unit.PLUS_Z, TEST_PRECISION);
-        SubCircle set = circle.wholeHyperplane();
-        Assert.assertEquals(Geometry.TWO_PI, set.getSize(), TEST_EPS);
-        Assert.assertTrue(circle == set.getHyperplane());
-        Assert.assertTrue(circle != set.copySelf().getHyperplane());
-    }
-
-    @Test
-    public void testSide() {
-
-        Circle xzPlane = new Circle(Vector3D.Unit.PLUS_Y, TEST_PRECISION);
-
-        SubCircle sc1 = create(Vector3D.Unit.PLUS_Z, Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION, 1.0, 3.0, 5.0, 6.0);
-        Assert.assertEquals(Side.BOTH, sc1.split(xzPlane).getSide());
-
-        SubCircle sc2 = create(Vector3D.Unit.PLUS_Z, Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION, 1.0, 3.0);
-        Assert.assertEquals(Side.MINUS, sc2.split(xzPlane).getSide());
-
-        SubCircle sc3 = create(Vector3D.Unit.PLUS_Z, Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION, 5.0, 6.0);
-        Assert.assertEquals(Side.PLUS, sc3.split(xzPlane).getSide());
-
-        SubCircle sc4 = create(Vector3D.Unit.PLUS_Y, Vector3D.Unit.PLUS_Z, Vector3D.Unit.PLUS_X, TEST_PRECISION, 5.0, 6.0);
-        Assert.assertEquals(Side.HYPER, sc4.split(xzPlane).getSide());
-
-        SubCircle sc5 = create(Vector3D.Unit.MINUS_Y, Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Z, TEST_PRECISION, 5.0, 6.0);
-        Assert.assertEquals(Side.HYPER, sc5.split(xzPlane).getSide());
-
-    }
-
-    @Test
-    public void testSPlit() {
-
-        Circle xzPlane = new Circle(Vector3D.Unit.PLUS_Y, TEST_PRECISION);
-
-        SubCircle sc1 = create(Vector3D.Unit.PLUS_Z, Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION, 1.0, 3.0, 5.0, 6.0);
-        SplitSubHyperplane<S2Point> split1 = sc1.split(xzPlane);
-        ArcsSet plus1  = (ArcsSet) ((SubCircle) split1.getPlus()).getRemainingRegion();
-        ArcsSet minus1 = (ArcsSet) ((SubCircle) split1.getMinus()).getRemainingRegion();
-        Assert.assertEquals(1, plus1.asList().size());
-        Assert.assertEquals(5.0, plus1.asList().get(0).getInf(), TEST_EPS);
-        Assert.assertEquals(6.0, plus1.asList().get(0).getSup(), TEST_EPS);
-        Assert.assertEquals(1, minus1.asList().size());
-        Assert.assertEquals(1.0, minus1.asList().get(0).getInf(), TEST_EPS);
-        Assert.assertEquals(3.0, minus1.asList().get(0).getSup(), TEST_EPS);
-
-        SubCircle sc2 = create(Vector3D.Unit.PLUS_Z, Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION, 1.0, 3.0);
-        SplitSubHyperplane<S2Point> split2 = sc2.split(xzPlane);
-        Assert.assertNull(split2.getPlus());
-        ArcsSet minus2 = (ArcsSet) ((SubCircle) split2.getMinus()).getRemainingRegion();
-        Assert.assertEquals(1, minus2.asList().size());
-        Assert.assertEquals(1.0, minus2.asList().get(0).getInf(), TEST_EPS);
-        Assert.assertEquals(3.0, minus2.asList().get(0).getSup(), TEST_EPS);
-
-        SubCircle sc3 = create(Vector3D.Unit.PLUS_Z, Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION, 5.0, 6.0);
-        SplitSubHyperplane<S2Point> split3 = sc3.split(xzPlane);
-        ArcsSet plus3  = (ArcsSet) ((SubCircle) split3.getPlus()).getRemainingRegion();
-        Assert.assertEquals(1, plus3.asList().size());
-        Assert.assertEquals(5.0, plus3.asList().get(0).getInf(), TEST_EPS);
-        Assert.assertEquals(6.0, plus3.asList().get(0).getSup(), TEST_EPS);
-        Assert.assertNull(split3.getMinus());
-
-        SubCircle sc4 = create(Vector3D.Unit.PLUS_Y, Vector3D.Unit.PLUS_Z, Vector3D.Unit.PLUS_X, TEST_PRECISION, 5.0, 6.0);
-        SplitSubHyperplane<S2Point> split4 = sc4.split(xzPlane);
-        Assert.assertEquals(Side.HYPER, sc4.split(xzPlane).getSide());
-        Assert.assertNull(split4.getPlus());
-        Assert.assertNull(split4.getMinus());
-
-        SubCircle sc5 = create(Vector3D.Unit.MINUS_Y, Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Z, TEST_PRECISION, 5.0, 6.0);
-        SplitSubHyperplane<S2Point> split5 = sc5.split(xzPlane);
-        Assert.assertEquals(Side.HYPER, sc5.split(xzPlane).getSide());
-        Assert.assertNull(split5.getPlus());
-        Assert.assertNull(split5.getMinus());
-
-    }
-
-    @Test
-    public void testSideSplitConsistency() {
-
-        double tolerance = 1.0e-6;
-        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(tolerance);
-        Circle hyperplane = new Circle(Vector3D.of(9.738804529764676E-5, -0.6772824575010357, -0.7357230887208355),
-                precision);
-        SubCircle sub = new SubCircle(new Circle(Vector3D.of(2.1793884139073498E-4, 0.9790647032675541, -0.20354915700704285),
-                precision),
-                                      new ArcsSet(4.7121441684170700, 4.7125386635004760, precision));
-        SplitSubHyperplane<S2Point> split = sub.split(hyperplane);
-        Assert.assertNotNull(split.getMinus());
-        Assert.assertNull(split.getPlus());
-        Assert.assertEquals(Side.MINUS, sub.split(hyperplane).getSide());
-
-    }
-
-    private SubCircle create(Vector3D pole, Vector3D x, Vector3D y,
-                             DoublePrecisionContext precision, double ... limits) {
-        RegionFactory<S1Point> factory = new RegionFactory<>();
-        Circle circle = new Circle(pole, precision);
-        Circle phased =
-                (Circle) Circle.getTransform(QuaternionRotation.createBasisRotation(circle.getXAxis(), circle.getYAxis(), x, y)).apply(circle);
-        ArcsSet set = (ArcsSet) factory.getComplement(new ArcsSet(precision));
-        for (int i = 0; i < limits.length; i += 2) {
-            set = (ArcsSet) factory.union(set, new ArcsSet(limits[i], limits[i + 1], precision));
-        }
-        return new SubCircle(phased, set);
-    }
-
-}
diff --git a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/SubGreatCircleTest.java b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/SubGreatCircleTest.java
new file mode 100644
index 0000000..18ed3bd
--- /dev/null
+++ b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/SubGreatCircleTest.java
@@ -0,0 +1,529 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.twod;
+
+import java.util.List;
+
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.GeometryTestUtils;
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.exception.GeometryException;
+import org.apache.commons.geometry.core.partitioning.ConvexSubHyperplane;
+import org.apache.commons.geometry.core.partitioning.Hyperplane;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.SplitLocation;
+import org.apache.commons.geometry.core.partitioning.SubHyperplane;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.threed.Vector3D;
+import org.apache.commons.geometry.spherical.SphericalTestUtils;
+import org.apache.commons.geometry.spherical.oned.AngularInterval;
+import org.apache.commons.geometry.spherical.oned.RegionBSPTree1S;
+import org.apache.commons.geometry.spherical.twod.SubGreatCircle.SubGreatCircleBuilder;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class SubGreatCircleTest {
+
+    private static final double TEST_EPS = 1e-10;
+
+    private static final DoublePrecisionContext TEST_PRECISION =
+            new EpsilonDoublePrecisionContext(TEST_EPS);
+
+    private static final GreatCircle XY_CIRCLE = GreatCircle.fromPoleAndU(
+            Vector3D.Unit.PLUS_Z, Vector3D.Unit.PLUS_X, TEST_PRECISION);
+
+    @Test
+    public void testCtor_default() {
+        // act
+        SubGreatCircle sub = new SubGreatCircle(XY_CIRCLE);
+
+        // assert
+        Assert.assertFalse(sub.isFull());
+        Assert.assertTrue(sub.isEmpty());
+        Assert.assertTrue(sub.isFinite());
+        Assert.assertFalse(sub.isInfinite());
+
+        Assert.assertEquals(0, sub.getSize(), TEST_EPS);
+
+        for (double az = 0; az <= Geometry.TWO_PI; az += 0.5) {
+            for (double p = 0; p <= Geometry.PI; p += 0.5) {
+                checkClassify(sub, RegionLocation.OUTSIDE, Point2S.of(az, p));
+            }
+        }
+    }
+
+    @Test
+    public void testCtor_boolean_true() {
+        // act
+        SubGreatCircle sub = new SubGreatCircle(XY_CIRCLE, true);
+
+        // assert
+        Assert.assertTrue(sub.isFull());
+        Assert.assertFalse(sub.isEmpty());
+        Assert.assertTrue(sub.isFinite());
+        Assert.assertFalse(sub.isInfinite());
+
+        Assert.assertEquals(Geometry.TWO_PI, sub.getSize(), TEST_EPS);
+
+        for (double az = 0; az < Geometry.TWO_PI; az += 0.1) {
+            checkClassify(sub, RegionLocation.INSIDE, Point2S.of(az, Geometry.HALF_PI));
+        }
+
+        checkClassify(sub, RegionLocation.OUTSIDE,
+                Point2S.PLUS_K, Point2S.of(0, Geometry.HALF_PI + 0.1),
+                Point2S.MINUS_K, Point2S.of(0, Geometry.HALF_PI - 0.1));
+    }
+
+    @Test
+    public void testCtor_boolean_false() {
+        // act
+        SubGreatCircle sub = new SubGreatCircle(XY_CIRCLE, false);
+
+        // assert
+        Assert.assertFalse(sub.isFull());
+        Assert.assertTrue(sub.isEmpty());
+        Assert.assertTrue(sub.isFinite());
+        Assert.assertFalse(sub.isInfinite());
+
+        Assert.assertEquals(0, sub.getSize(), TEST_EPS);
+
+        for (double az = 0; az <= Geometry.TWO_PI; az += 0.5) {
+            for (double p = 0; p <= Geometry.PI; p += 0.5) {
+                checkClassify(sub, RegionLocation.OUTSIDE, Point2S.of(az, p));
+            }
+        }
+    }
+
+    @Test
+    public void testCtor_tree() {
+        // arrange
+        RegionBSPTree1S tree = RegionBSPTree1S.fromInterval(AngularInterval.of(1, 2, TEST_PRECISION));
+
+        // act
+        SubGreatCircle sub = new SubGreatCircle(XY_CIRCLE, tree);
+
+        // assert
+        Assert.assertFalse(sub.isFull());
+        Assert.assertFalse(sub.isEmpty());
+        Assert.assertTrue(sub.isFinite());
+        Assert.assertFalse(sub.isInfinite());
+
+        Assert.assertEquals(1, sub.getSize(), TEST_EPS);
+
+        checkClassify(sub, RegionLocation.INSIDE, Point2S.of(1.5, Geometry.HALF_PI));
+
+        checkClassify(sub, RegionLocation.BOUNDARY,
+                Point2S.of(1, Geometry.HALF_PI), Point2S.of(2, Geometry.HALF_PI));
+
+        checkClassify(sub, RegionLocation.OUTSIDE,
+                Point2S.of(0.5, Geometry.HALF_PI), Point2S.of(2.5, Geometry.HALF_PI),
+                Point2S.of(1.5, 1), Point2S.of(1.5, Geometry.PI - 1));
+    }
+
+    @Test
+    public void testTransform() {
+        // arrange
+        GreatCircle circle = GreatCircle.fromPoints(Point2S.PLUS_K, Point2S.MINUS_I, TEST_PRECISION);
+        RegionBSPTree1S region = RegionBSPTree1S.empty();
+        region.add(AngularInterval.of(Geometry.PI, Geometry.MINUS_HALF_PI, TEST_PRECISION));
+        region.add(AngularInterval.of(0, Geometry.HALF_PI, TEST_PRECISION));
+
+        Transform2S t = Transform2S.createRotation(Point2S.PLUS_I, Geometry.HALF_PI)
+                .reflect(Point2S.of(-0.25 * Geometry.PI,  Geometry.HALF_PI));
+
+        SubGreatCircle sub = new SubGreatCircle(circle, region);
+
+        // act
+        SubGreatCircle result = sub.transform(t);
+
+        // assert
+        List<GreatArc> arcs = result.toConvex();
+        Assert.assertEquals(2, arcs.size());
+
+        checkArc(arcs.get(0), Point2S.MINUS_I, Point2S.MINUS_J);
+        checkArc(arcs.get(1), Point2S.PLUS_I, Point2S.PLUS_J);
+    }
+
+    @Test
+    public void testSplit_full() {
+        // arrange
+        GreatCircle circle = GreatCircle.fromPoints(Point2S.PLUS_I, Point2S.PLUS_J, TEST_PRECISION);
+        SubGreatCircle sub = new SubGreatCircle(circle, true);
+
+        GreatCircle splitter = GreatCircle.fromPole(Vector3D.of(-1, 0, 1), TEST_PRECISION);
+
+        // act
+        Split<SubGreatCircle> split = sub.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        SubGreatCircle minus = split.getMinus();
+        Assert.assertSame(sub.getCircle(), minus.getCircle());
+
+        List<GreatArc> minusArcs = minus.toConvex();
+        Assert.assertEquals(1, minusArcs.size());
+        checkArc(minusArcs.get(0), Point2S.MINUS_J, Point2S.PLUS_J);
+
+        checkClassify(minus, RegionLocation.OUTSIDE, Point2S.MINUS_I);
+        checkClassify(minus, RegionLocation.INSIDE, Point2S.PLUS_I);
+
+        SubGreatCircle plus = split.getPlus();
+        Assert.assertSame(sub.getCircle(), plus.getCircle());
+
+        List<GreatArc> plusArcs = plus.toConvex();
+        Assert.assertEquals(1, plusArcs.size());
+        checkArc(plusArcs.get(0), Point2S.PLUS_J, Point2S.MINUS_J);
+
+        checkClassify(plus, RegionLocation.INSIDE, Point2S.MINUS_I);
+        checkClassify(plus, RegionLocation.OUTSIDE, Point2S.PLUS_I);
+    }
+
+    @Test
+    public void testSplit_empty() {
+        // arrange
+        GreatCircle circle = GreatCircle.fromPoints(Point2S.PLUS_I, Point2S.PLUS_J, TEST_PRECISION);
+        SubGreatCircle sub = new SubGreatCircle(circle, false);
+
+        GreatCircle splitter = GreatCircle.fromPole(Vector3D.of(-1, 0, 1), TEST_PRECISION);
+
+        // act
+        Split<SubGreatCircle> split = sub.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.NEITHER, split.getLocation());
+
+        SubGreatCircle minus = split.getMinus();
+        Assert.assertNull(minus);
+
+        SubGreatCircle plus = split.getPlus();
+        Assert.assertNull(plus);
+    }
+
+    @Test
+    public void testSplit_both() {
+        // arrange
+        GreatCircle circle = GreatCircle.fromPoints(Point2S.PLUS_J, Point2S.PLUS_K, TEST_PRECISION);
+
+        RegionBSPTree1S tree = RegionBSPTree1S.empty();
+        tree.add(AngularInterval.of(0, 1, TEST_PRECISION));
+        tree.add(AngularInterval.of(Geometry.HALF_PI, Geometry.PI, TEST_PRECISION));
+        tree.add(AngularInterval.of(Geometry.PI + 1, Geometry.PI + 2, TEST_PRECISION));
+
+        SubGreatCircle sub = new SubGreatCircle(circle, tree);
+
+        GreatCircle splitter = GreatCircle.fromPole(Vector3D.of(0, 1, 1), TEST_PRECISION);
+
+        // act
+        Split<SubGreatCircle> split = sub.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        SubGreatCircle minus = split.getMinus();
+        Assert.assertSame(sub.getCircle(), minus.getCircle());
+        List<GreatArc> minusArcs = minus.toConvex();
+        Assert.assertEquals(2, minusArcs.size());
+        checkArc(minusArcs.get(0), Point2S.of(1.5 * Geometry.PI, 0.25 * Geometry.PI), Point2S.MINUS_J);
+        checkArc(minusArcs.get(1), Point2S.of(1.5 * Geometry.PI, Geometry.HALF_PI + 1),
+                Point2S.of(0.5 * Geometry.PI, (1.5 * Geometry.PI) - 2));
+
+        SubGreatCircle plus = split.getPlus();
+        Assert.assertSame(sub.getCircle(), plus.getCircle());
+        List<GreatArc> plusArcs = plus.toConvex();
+        Assert.assertEquals(2, plusArcs.size());
+        checkArc(plusArcs.get(0), Point2S.of(Geometry.HALF_PI, Geometry.HALF_PI), Point2S.of(Geometry.HALF_PI, Geometry.HALF_PI - 1));
+        checkArc(plusArcs.get(1), Point2S.of(0, 0), Point2S.of(1.5 * Geometry.PI, 0.25 * Geometry.PI));
+    }
+
+    @Test
+    public void testSplit_minus() {
+        // arrange
+        GreatCircle circle = GreatCircle.fromPoints(Point2S.PLUS_J, Point2S.PLUS_K, TEST_PRECISION);
+        RegionBSPTree1S tree = AngularInterval.of(Geometry.HALF_PI, Geometry.PI, TEST_PRECISION).toTree();
+
+        SubGreatCircle sub = new SubGreatCircle(circle, tree);
+
+        GreatCircle splitter = GreatCircle.fromPole(Vector3D.Unit.from(-1, 0, -1), TEST_PRECISION);
+
+        // act
+        Split<SubGreatCircle> split = sub.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.MINUS, split.getLocation());
+
+        SubGreatCircle minus = split.getMinus();
+        Assert.assertSame(sub, minus);
+
+        SubGreatCircle plus = split.getPlus();
+        Assert.assertNull(plus);
+    }
+
+    @Test
+    public void testSplit_plus() {
+        // arrange
+        GreatCircle circle = GreatCircle.fromPoints(Point2S.PLUS_J, Point2S.PLUS_K, TEST_PRECISION);
+        RegionBSPTree1S tree = AngularInterval.of(Geometry.HALF_PI, Geometry.PI, TEST_PRECISION).toTree();
+
+        SubGreatCircle sub = new SubGreatCircle(circle, tree);
+
+        GreatCircle splitter = GreatCircle.fromPole(Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+
+        // act
+        Split<SubGreatCircle> split = sub.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.PLUS, split.getLocation());
+
+        SubGreatCircle minus = split.getMinus();
+        Assert.assertNull(minus);
+
+        SubGreatCircle plus = split.getPlus();
+        Assert.assertSame(sub, plus);
+    }
+
+    @Test
+    public void testSplit_parallelAndAntiparallel() {
+        // arrange
+        GreatCircle circle = GreatCircle.fromPoints(Point2S.PLUS_I, Point2S.PLUS_J, TEST_PRECISION);
+        RegionBSPTree1S tree = AngularInterval.of(Geometry.HALF_PI, Geometry.PI, TEST_PRECISION).toTree();
+
+        SubGreatCircle sub = new SubGreatCircle(circle, tree);
+
+        // act/assert
+        Assert.assertEquals(SplitLocation.NEITHER,
+                sub.split(GreatCircle.fromPole(Vector3D.Unit.PLUS_Z, TEST_PRECISION)).getLocation());
+        Assert.assertEquals(SplitLocation.NEITHER,
+                sub.split(GreatCircle.fromPole(Vector3D.Unit.MINUS_Z, TEST_PRECISION)).getLocation());
+    }
+
+    @Test
+    public void testAdd_arc() {
+        // arrange
+        GreatCircle circle = GreatCircle.fromPoints(Point2S.MINUS_K, Point2S.MINUS_J, TEST_PRECISION);
+        GreatCircle closeCircle = GreatCircle.fromPoints(Point2S.MINUS_K,
+                Point2S.of((1.5 * Geometry.PI) - 1e-11, Geometry.HALF_PI), TEST_PRECISION);
+
+        SubGreatCircle sub = new SubGreatCircle(circle);
+
+        // act
+        sub.add(circle.arc(Point2S.of(1.5 * Geometry.PI, 0.75 * Geometry.PI), Point2S.MINUS_J));
+        sub.add(closeCircle.arc(Point2S.PLUS_J, Point2S.of(1.5 * Geometry.PI, 0.75 * Geometry.PI)));
+
+        // assert
+        List<GreatArc> arcs = sub.toConvex();
+
+        Assert.assertEquals(1, arcs.size());
+        checkArc(arcs.get(0), Point2S.PLUS_J, Point2S.MINUS_J);
+    }
+
+    @Test(expected = GeometryException.class)
+    public void testAdd_arc_differentCircle() {
+        // arrange
+        GreatCircle circle = GreatCircle.fromPoints(Point2S.MINUS_K, Point2S.MINUS_J, TEST_PRECISION);
+        GreatCircle otherCircle = GreatCircle.fromPoints(Point2S.MINUS_K,
+                Point2S.of((1.5 * Geometry.PI) - 1e-2, Geometry.HALF_PI), TEST_PRECISION);
+
+        SubGreatCircle sub = new SubGreatCircle(circle);
+
+        // act/assert
+        sub.add(otherCircle.arc(Point2S.PLUS_J, Point2S.of(1.5 * Geometry.PI, 0.75 * Geometry.PI)));
+    }
+
+    @Test
+    public void testAdd_subGreatCircle() {
+        // arrange
+        GreatCircle circle = GreatCircle.fromPoints(Point2S.MINUS_K, Point2S.MINUS_J, TEST_PRECISION);
+        GreatCircle closeCircle = GreatCircle.fromPoints(Point2S.MINUS_K,
+                Point2S.of((1.5 * Geometry.PI) - 1e-11, Geometry.HALF_PI), TEST_PRECISION);
+
+        SubGreatCircle sub = new SubGreatCircle(circle);
+
+        RegionBSPTree1S regionA = RegionBSPTree1S.empty();
+        regionA.add(AngularInterval.of(Geometry.PI, 1.25 * Geometry.PI, TEST_PRECISION));
+        regionA.add(AngularInterval.of(0.25 * Geometry.PI, Geometry.HALF_PI, TEST_PRECISION));
+
+        RegionBSPTree1S regionB = RegionBSPTree1S.empty();
+        regionB.add(AngularInterval.of(1.5 * Geometry.PI, 0.25 * Geometry.PI, TEST_PRECISION));
+
+        // act
+        sub.add(new SubGreatCircle(circle, regionA));
+        sub.add(new SubGreatCircle(closeCircle, regionB));
+
+        // assert
+        List<GreatArc> arcs = sub.toConvex();
+
+        Assert.assertEquals(2, arcs.size());
+        checkArc(arcs.get(0), Point2S.of(Geometry.HALF_PI, 0), Point2S.of(Geometry.HALF_PI, 0.25 * Geometry.PI));
+        checkArc(arcs.get(1), Point2S.PLUS_J, Point2S.MINUS_J);
+    }
+
+    @Test(expected = GeometryException.class)
+    public void testAdd_subGreatCircle_otherCircle() {
+        // arrange
+        GreatCircle circle = GreatCircle.fromPoints(Point2S.MINUS_K, Point2S.MINUS_J, TEST_PRECISION);
+        GreatCircle otherCircle = GreatCircle.fromPoints(Point2S.MINUS_K,
+                Point2S.of((1.5 * Geometry.PI) - 1e-5, Geometry.HALF_PI), TEST_PRECISION);
+
+        SubGreatCircle sub = new SubGreatCircle(circle);
+
+        // act/assert
+        sub.add(new SubGreatCircle(otherCircle, RegionBSPTree1S.full()));
+    }
+
+    @Test
+    public void testBuilder() {
+        // arrange
+        GreatCircle circle = GreatCircle.fromPoints(Point2S.MINUS_K, Point2S.MINUS_J, TEST_PRECISION);
+
+        SubGreatCircle sub = new SubGreatCircle(circle);
+
+        RegionBSPTree1S region = RegionBSPTree1S.empty();
+        region.add(AngularInterval.of(Geometry.PI, 1.25 * Geometry.PI, TEST_PRECISION));
+        region.add(AngularInterval.of(0.25 * Geometry.PI, Geometry.HALF_PI, TEST_PRECISION));
+
+        // act
+        SubGreatCircleBuilder builder = sub.builder();
+
+        builder.add(new SubGreatCircle(circle, region));
+        builder.add(circle.arc(1.5 * Geometry.PI, 0.25 * Geometry.PI));
+
+        SubGreatCircle result = builder.build();
+
+        // assert
+        List<GreatArc> arcs = result.toConvex();
+
+        Assert.assertEquals(2, arcs.size());
+        checkArc(arcs.get(0), Point2S.of(Geometry.HALF_PI, 0), Point2S.of(Geometry.HALF_PI, 0.25 * Geometry.PI));
+        checkArc(arcs.get(1), Point2S.PLUS_J, Point2S.MINUS_J);
+    }
+
+    @Test
+    public void testBuilder_invalidArgs() {
+        // arrange
+        GreatCircle circle = GreatCircle.fromPoints(Point2S.MINUS_K, Point2S.MINUS_J, TEST_PRECISION);
+        GreatCircle otherCircle = GreatCircle.fromPoints(Point2S.PLUS_I, Point2S.PLUS_J, TEST_PRECISION);
+
+        SubGreatCircle sub = new SubGreatCircle(circle);
+
+        SubGreatCircleBuilder builder = sub.builder();
+
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            builder.add(otherCircle.span());
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            builder.add(new SubGreatCircle(otherCircle));
+        }, GeometryException.class);
+
+        GeometryTestUtils.assertThrows(() -> {
+            builder.add(new UnknownSubHyperplane());
+        }, IllegalArgumentException.class);
+    }
+
+    @Test
+    public void testToString() {
+        // arrange
+        GreatCircle circle = GreatCircle.fromPoints(Point2S.PLUS_I, Point2S.PLUS_J, TEST_PRECISION);
+        SubGreatCircle sub = new SubGreatCircle(circle);
+
+        // act
+        String str = sub.toString();
+
+        // assert
+        GeometryTestUtils.assertContains("SubGreatCircle[", str);
+        GeometryTestUtils.assertContains("circle= GreatCircle[", str);
+        GeometryTestUtils.assertContains("region= RegionBSPTree1S[", str);
+    }
+
+    private static void checkClassify(SubHyperplane<Point2S> sub, RegionLocation loc, Point2S ... pts) {
+        for (Point2S pt : pts) {
+            Assert.assertEquals("Unexpected location for point " + pt, loc, sub.classify(pt));
+        }
+    }
+
+    private static void checkArc(GreatArc arc, Point2S start, Point2S end) {
+        SphericalTestUtils.assertPointsEq(start, arc.getStartPoint(), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(end, arc.getEndPoint(), TEST_EPS);
+    }
+
+    private static class UnknownSubHyperplane implements SubHyperplane<Point2S> {
+
+        @Override
+        public Split<? extends SubHyperplane<Point2S>> split(Hyperplane<Point2S> splitter) {
+            return null;
+        }
+
+        @Override
+        public Hyperplane<Point2S> getHyperplane() {
+            return null;
+        }
+
+        @Override
+        public boolean isFull() {
+            return false;
+        }
+
+        @Override
+        public boolean isEmpty() {
+            return false;
+        }
+
+        @Override
+        public boolean isInfinite() {
+            return false;
+        }
+
+        @Override
+        public boolean isFinite() {
+            return false;
+        }
+
+        @Override
+        public double getSize() {
+            return 0;
+        }
+
+        @Override
+        public RegionLocation classify(Point2S point) {
+            return null;
+        }
+
+        @Override
+        public Point2S closest(Point2S point) {
+            return null;
+        }
+
+        @Override
+        public Builder<Point2S> builder() {
+            return null;
+        }
+
+        @Override
+        public SubHyperplane<Point2S> transform(Transform<Point2S> transform) {
+            return null;
+        }
+
+        @Override
+        public List<? extends ConvexSubHyperplane<Point2S>> toConvex() {
+            return null;
+        }
+    }
+}
diff --git a/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/Transform2STest.java b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/Transform2STest.java
new file mode 100644
index 0000000..2c5a396
--- /dev/null
+++ b/commons-geometry-spherical/src/test/java/org/apache/commons/geometry/spherical/twod/Transform2STest.java
@@ -0,0 +1,287 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.commons.geometry.spherical.twod;
+
+import org.apache.commons.geometry.core.Geometry;
+import org.apache.commons.geometry.core.GeometryTestUtils;
+import org.apache.commons.geometry.euclidean.threed.Vector3D;
+import org.apache.commons.geometry.euclidean.threed.rotation.QuaternionRotation;
+import org.apache.commons.geometry.spherical.SphericalTestUtils;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class Transform2STest {
+
+    private static final double TEST_EPS = 1e-10;
+
+    @Test
+    public void testIdentity() {
+        // act
+        Transform2S t = Transform2S.identity();
+
+        // assert
+        Assert.assertTrue(t.preservesOrientation());
+        Assert.assertArrayEquals(new double[] {
+                1, 0, 0, 0,
+                0, 1, 0, 0,
+                0, 0, 1, 0
+        }, t.getEuclideanTransform().toArray(), 0);
+
+        SphericalTestUtils.assertPointsEqual(Point2S.PLUS_I, t.apply(Point2S.PLUS_I), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point2S.PLUS_J, t.apply(Point2S.PLUS_J), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point2S.PLUS_K, t.apply(Point2S.PLUS_K), TEST_EPS);
+
+        checkInverse(t);
+    }
+
+    @Test
+    public void testRotation() {
+        // arrange
+        Transform2S aroundPole = Transform2S.createRotation(Point2S.PLUS_K, Geometry.HALF_PI);
+        Transform2S aroundX = Transform2S.createRotation(Vector3D.Unit.PLUS_X, -Geometry.HALF_PI);
+        Transform2S aroundY = Transform2S.createRotation(
+                QuaternionRotation.fromAxisAngle(Vector3D.Unit.PLUS_Y, Geometry.HALF_PI));
+
+        // act/assert
+        SphericalTestUtils.assertPointsEqual(Point2S.PLUS_J, aroundPole.apply(Point2S.PLUS_I), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point2S.MINUS_I, aroundPole.apply(Point2S.PLUS_J), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.PLUS_K, aroundPole.apply(Point2S.PLUS_K), TEST_EPS);
+        checkInverse(aroundPole);
+
+        SphericalTestUtils.assertPointsEqual(Point2S.PLUS_I, aroundX.apply(Point2S.PLUS_I), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.MINUS_K, aroundX.apply(Point2S.PLUS_J), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point2S.PLUS_J, aroundX.apply(Point2S.PLUS_K), TEST_EPS);
+        checkInverse(aroundX);
+
+        SphericalTestUtils.assertPointsEq(Point2S.MINUS_K, aroundY.apply(Point2S.PLUS_I), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point2S.PLUS_J, aroundY.apply(Point2S.PLUS_J), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point2S.PLUS_I, aroundY.apply(Point2S.PLUS_K), TEST_EPS);
+        checkInverse(aroundY);
+    }
+
+    @Test
+    public void testMultipleRotations() {
+        // act
+        Transform2S t = Transform2S.identity()
+                .rotate(Point2S.PLUS_K, Geometry.HALF_PI)
+                .rotate(Vector3D.Unit.PLUS_X, -Geometry.HALF_PI)
+                .rotate(QuaternionRotation.fromAxisAngle(Vector3D.Unit.PLUS_Y, Geometry.HALF_PI));
+
+        // assert
+        SphericalTestUtils.assertPointsEqual(Point2S.MINUS_I, t.apply(Point2S.PLUS_I), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.PLUS_K, t.apply(Point2S.PLUS_J), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point2S.PLUS_J, t.apply(Point2S.PLUS_K), TEST_EPS);
+
+        checkInverse(t);
+    }
+
+    @Test
+    public void testMultiply() {
+        // act
+        Transform2S t = Transform2S.identity()
+                .multiply(Transform2S.createRotation(Point2S.PLUS_K, Geometry.HALF_PI))
+                .multiply(Transform2S.createRotation(Point2S.PLUS_J, Geometry.HALF_PI));
+
+        // assert
+        SphericalTestUtils.assertPointsEq(Point2S.MINUS_K, t.apply(Point2S.PLUS_I), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point2S.MINUS_I, t.apply(Point2S.PLUS_J), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point2S.PLUS_J, t.apply(Point2S.PLUS_K), TEST_EPS);
+
+        checkInverse(t);
+    }
+
+    @Test
+    public void testPremultiply() {
+        // act
+        Transform2S t = Transform2S.identity()
+                .premultiply(Transform2S.createRotation(Point2S.PLUS_K, Geometry.HALF_PI))
+                .premultiply(Transform2S.createRotation(Point2S.PLUS_J, Geometry.HALF_PI));
+
+        // assert
+        SphericalTestUtils.assertPointsEqual(Point2S.PLUS_J, t.apply(Point2S.PLUS_I), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.PLUS_K, t.apply(Point2S.PLUS_J), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point2S.PLUS_I, t.apply(Point2S.PLUS_K), TEST_EPS);
+
+        checkInverse(t);
+    }
+
+    @Test
+    public void testReflection_point() {
+        // arrange
+        Point2S a = Point2S.of(1, 1);
+        Point2S b = Point2S.of(-1, 1);
+
+        Point2S c = Point2S.of(1, Geometry.PI - 1);
+        Point2S d = Point2S.of(-1, Geometry.PI - 1);
+
+        // act
+        Transform2S t = Transform2S.createReflection(Point2S.PLUS_I);
+
+        // assert
+        Assert.assertFalse(t.preservesOrientation());
+
+        SphericalTestUtils.assertPointsEqual(Point2S.MINUS_I, t.apply(Point2S.PLUS_I), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point2S.PLUS_J, t.apply(Point2S.PLUS_J), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.PLUS_K, t.apply(Point2S.PLUS_K), TEST_EPS);
+
+        SphericalTestUtils.assertPointsEqual(Point2S.of(Geometry.PI - 1, 1), t.apply(a), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point2S.of(Geometry.PI + 1, 1), t.apply(b), TEST_EPS);
+
+        SphericalTestUtils.assertPointsEqual(Point2S.of(Geometry.PI - 1, Geometry.PI - 1), t.apply(c), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point2S.of(Geometry.PI + 1, Geometry.PI - 1), t.apply(d), TEST_EPS);
+
+        checkInverse(t);
+    }
+
+    @Test
+    public void testReflection_vector() {
+        // arrange
+        Point2S a = Point2S.of(1, 1);
+        Point2S b = Point2S.of(-1, 1);
+
+        Point2S c = Point2S.of(1, Geometry.PI - 1);
+        Point2S d = Point2S.of(-1, Geometry.PI - 1);
+
+        // act
+        Transform2S t = Transform2S.createReflection(Vector3D.Unit.PLUS_Y);
+
+        // assert
+        Assert.assertFalse(t.preservesOrientation());
+
+        SphericalTestUtils.assertPointsEqual(Point2S.PLUS_I, t.apply(Point2S.PLUS_I), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point2S.MINUS_J, t.apply(Point2S.PLUS_J), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.PLUS_K, t.apply(Point2S.PLUS_K), TEST_EPS);
+
+        SphericalTestUtils.assertPointsEqual(b, t.apply(a), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(a, t.apply(b), TEST_EPS);
+
+        SphericalTestUtils.assertPointsEqual(d, t.apply(c), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(c, t.apply(d), TEST_EPS);
+
+        checkInverse(t);
+    }
+
+    @Test
+    public void testDoubleReflection() {
+        // arrange
+        Point2S a = Point2S.of(1, 1);
+        Point2S b = Point2S.of(-1, 1);
+
+        Point2S c = Point2S.of(1, Geometry.PI - 1);
+        Point2S d = Point2S.of(-1, Geometry.PI - 1);
+
+        // act
+        Transform2S t = Transform2S.identity()
+                .reflect(Point2S.PLUS_I)
+                .reflect(Vector3D.Unit.PLUS_Y);
+
+        // assert
+        Assert.assertTrue(t.preservesOrientation());
+
+        SphericalTestUtils.assertPointsEqual(Point2S.MINUS_I, t.apply(Point2S.PLUS_I), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point2S.MINUS_J, t.apply(Point2S.PLUS_J), TEST_EPS);
+        SphericalTestUtils.assertPointsEq(Point2S.PLUS_K, t.apply(Point2S.PLUS_K), TEST_EPS);
+
+        SphericalTestUtils.assertPointsEqual(Point2S.of(Geometry.PI + 1, 1), t.apply(a), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point2S.of(Geometry.PI - 1, 1), t.apply(b), TEST_EPS);
+
+        SphericalTestUtils.assertPointsEqual(Point2S.of(Geometry.PI + 1, Geometry.PI - 1), t.apply(c), TEST_EPS);
+        SphericalTestUtils.assertPointsEqual(Point2S.of(Geometry.PI - 1,  Geometry.PI - 1), t.apply(d), TEST_EPS);
+
+        checkInverse(t);
+    }
+
+    @Test
+    public void testHashcode() {
+        // arrange
+        Transform2S a = Transform2S.createRotation(Point2S.PLUS_I, Geometry.HALF_PI);
+        Transform2S b = Transform2S.createRotation(Point2S.PLUS_J, Geometry.HALF_PI);
+        Transform2S c = Transform2S.createRotation(Point2S.PLUS_I, Geometry.PI);
+        Transform2S d = Transform2S.createRotation(Point2S.PLUS_I, Geometry.HALF_PI);
+
+        // act
+        int hash = a.hashCode();
+
+        // assert
+        Assert.assertEquals(hash, a.hashCode());
+
+        Assert.assertNotEquals(hash, b.hashCode());
+        Assert.assertNotEquals(hash, c.hashCode());
+
+        Assert.assertEquals(hash, d.hashCode());
+    }
+
+    @Test
+    public void testEquals() {
+        // arrange
+        Transform2S a = Transform2S.createRotation(Point2S.PLUS_I, Geometry.HALF_PI);
+        Transform2S b = Transform2S.createRotation(Point2S.PLUS_J, Geometry.HALF_PI);
+        Transform2S c = Transform2S.createRotation(Point2S.PLUS_I, Geometry.PI);
+        Transform2S d = Transform2S.createRotation(Point2S.PLUS_I, Geometry.HALF_PI);
+
+        // act/assert
+        Assert.assertTrue(a.equals(a));
+
+        Assert.assertFalse(a.equals(null));
+        Assert.assertFalse(a.equals(new Object()));
+
+        Assert.assertFalse(a.equals(b));
+        Assert.assertFalse(a.equals(c));
+
+        Assert.assertTrue(a.equals(d));
+        Assert.assertTrue(d.equals(a));
+    }
+
+    @Test
+    public void testToString() {
+        // arrange
+        Transform2S t = Transform2S.identity();
+
+        // act
+        String str = t.toString();
+
+        // assert
+        GeometryTestUtils.assertContains("Transform2S", str);
+        GeometryTestUtils.assertContains("euclideanTransform= [", str);
+    }
+
+    private static void checkInverse(Transform2S t) {
+        Transform2S inv = t.inverse();
+
+        // test non-pole points
+        for (double az = -Geometry.TWO_PI; az <= 2 * Geometry.TWO_PI; az += 0.2) {
+            for (double p = 0.1; p < Geometry.PI; p += 0.2) {
+
+                Point2S pt = Point2S.of(az, p);
+
+                SphericalTestUtils.assertPointsEqual(pt, inv.apply(t.apply(pt)), TEST_EPS);
+                SphericalTestUtils.assertPointsEqual(pt, t.apply(inv.apply(pt)), TEST_EPS);
+            }
+        }
+
+        // test poles
+        SphericalTestUtils.assertVectorsEqual(Vector3D.Unit.PLUS_Z,
+                inv.apply(t.apply(Point2S.of(1, 0))).getVector(), TEST_EPS);
+        SphericalTestUtils.assertVectorsEqual(Vector3D.Unit.PLUS_Z,
+                t.apply(inv.apply(Point2S.of(-1, 0))).getVector(), TEST_EPS);
+
+        SphericalTestUtils.assertVectorsEqual(Vector3D.Unit.MINUS_Z,
+                inv.apply(t.apply(Point2S.of(1, Geometry.PI))).getVector(), TEST_EPS);
+        SphericalTestUtils.assertVectorsEqual(Vector3D.Unit.MINUS_Z,
+                t.apply(inv.apply(Point2S.of(-1, Geometry.PI))).getVector(), TEST_EPS);
+    }
+}
diff --git a/src/main/resources/checkstyle/checkstyle-suppressions.xml b/src/main/resources/checkstyle/checkstyle-suppressions.xml
index 96c8490..f352d2a 100644
--- a/src/main/resources/checkstyle/checkstyle-suppressions.xml
+++ b/src/main/resources/checkstyle/checkstyle-suppressions.xml
@@ -19,6 +19,13 @@
     "-//Checkstyle//DTD SuppressionFilter Configuration 1.2//EN"
     "https://checkstyle.org/dtds/suppressions_1_2.dtd">
 <suppressions>
+  <suppress checks="ParameterNumber" files=".*/internal/Matrices" />
+  <suppress checks="ParameterNumber" files=".*/oned/Vector1D" />
+  <suppress checks="ParameterNumber" files=".*/threed/AffineTransformMatrix3D" />
+  <suppress checks="ParameterNumber" files=".*/threed/Vector3D" />
+  <suppress checks="ParameterNumber" files=".*/threed/rotation/QuaternionRotation" />
+  <suppress checks="ParameterNumber" files=".*/twod/Vector2D" />
+
   <!-- Be more lenient on tests. -->
   <suppress checks="Javadoc" files=".*[/\\]test[/\\].*" />
   <suppress checks="MultipleStringLiterals" files=".*[/\\]test[/\\].*" />