You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@commons.apache.org by ma...@apache.org on 2020/05/27 11:29:25 UTC

[commons-geometry] 01/03: GEOMETRY-94: adding PlaneConvexSubset additional implementations; GEOMETRY-77: adding Bounds2D and Bounds3D classes

This is an automated email from the ASF dual-hosted git repository.

mattjuntunen pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-geometry.git

commit ed41804dc3f8273e483ddfabd331732e1ae2c550
Author: Matt Juntunen <ma...@apache.org>
AuthorDate: Sun May 24 12:17:37 2020 -0400

    GEOMETRY-94: adding PlaneConvexSubset additional implementations; GEOMETRY-77: adding Bounds2D and Bounds3D classes
---
 .../org/apache/commons/geometry/core/Region.java   |   2 +-
 .../commons/geometry/core/RegionEmbedding.java     |  11 +-
 .../geometry/core/internal/HyperplaneSubsets.java  |  89 +++
 .../AbstractRegionEmbeddingHyperplaneSubset.java   |  99 ---
 .../core/partitioning/HyperplaneSubset.java        |  27 +-
 .../commons/geometry/core/EmbeddingTest.java       |   8 +-
 .../commons/geometry/core/RegionEmbeddingTest.java | 116 ----
 .../core/internal/HyperplaneSubsetsTest.java       | 143 ++++
 .../AbstractConvexHyperplaneBoundedRegionTest.java |  10 +-
 .../core/partitioning/AbstractHyperplaneTest.java  |   4 +-
 ...bstractRegionEmbeddingHyperplaneSubsetTest.java | 219 -------
 .../bsp/AbstractBSPTreeMergeOperatorTest.java      |  10 +-
 .../core/partitioning/bsp/AbstractBSPTreeTest.java |  16 +-
 .../bsp/AbstractRegionBSPTreeBooleanTest.java      |  10 +-
 .../bsp/AbstractRegionBSPTreeTest.java             |  16 +-
 .../core/partitioning/bsp/BSPTreeVisitorTest.java  |   8 +-
 .../core/partitioning/bsp/MergeChecker.java        |   6 +-
 .../partitioning/bsp/RegionCutBoundaryTest.java    |   6 +-
 .../test/AttributeBSPTree.java                     |   2 +-
 .../test/PartitionTestUtils.java                   |   2 +-
 .../test/TestBSPTree.java                          |   2 +-
 .../{partition => partitioning}/test/TestLine.java |   2 +-
 .../test/TestLineSegment.java                      |   8 +-
 .../test/TestLineSegmentCollection.java            |  12 +-
 .../test/TestLineSegmentCollectionBuilder.java     |   2 +-
 .../test/TestPoint1D.java                          |   2 +-
 .../test/TestPoint2D.java                          |   2 +-
 .../test/TestRegionBSPTree.java                    |   2 +-
 .../test/TestTransform2D.java                      |   2 +-
 .../euclidean/threed/SphereGenerator.java          |   4 +-
 .../commons/geometry/euclidean/AbstractBounds.java | 159 +++++
 .../geometry/euclidean/oned/OrientedPoint.java     |  21 +-
 .../euclidean/threed/AbstractConvexPolygon3D.java  | 451 +++++++++++++
 .../threed/AbstractEmbeddedRegionPlaneSubset.java  | 137 ++++
 .../euclidean/threed/AbstractPlaneSubset.java      |  99 ++-
 .../euclidean/threed/BoundarySource3D.java         |  21 +
 .../threed/BoundarySourceBoundsBuilder3D.java      |  57 ++
 .../threed/BoundarySourceLinecaster3D.java         |  16 +-
 .../geometry/euclidean/threed/Bounds3D.java        | 290 +++++++++
 .../geometry/euclidean/threed/ConvexPolygon3D.java |  29 +-
 .../geometry/euclidean/threed/ConvexVolume.java    |  28 +-
 .../threed/EmbeddedAreaPlaneConvexSubset.java      | 117 ++++
 .../euclidean/threed/EmbeddedTreePlaneSubset.java  | 122 +++-
 .../geometry/euclidean/threed/EmbeddingPlane.java  | 333 ++++++++++
 .../commons/geometry/euclidean/threed/Plane.java   | 647 +++++++-----------
 .../euclidean/threed/PlaneConvexSubset.java        | 114 +---
 .../geometry/euclidean/threed/PlaneSubset.java     | 259 ++------
 .../commons/geometry/euclidean/threed/Planes.java  | 721 ++++++++++++++++----
 .../geometry/euclidean/threed/RegionBSPTree3D.java |  27 +-
 .../euclidean/threed/SimpleTriangle3D.java         | 110 ++++
 .../geometry/euclidean/threed/Triangle3D.java      |  59 ++
 .../geometry/euclidean/threed/Vector3D.java        | 181 +++++-
 .../threed/VertexListConvexPolygon3D.java          |  84 +++
 .../threed/line/EmbeddedTreeLineSubset3D.java      |  47 +-
 .../threed/line/LineSpanningSubset3D.java          |  19 +
 .../euclidean/threed/line/LineSubset3D.java        |  18 +-
 .../geometry/euclidean/threed/line/Ray3D.java      |  19 +
 .../euclidean/threed/line/ReverseRay3D.java        |  19 +
 .../geometry/euclidean/threed/line/Segment3D.java  |  16 +
 .../euclidean/threed/shape/Parallelepiped.java     |   2 +-
 .../geometry/euclidean/twod/BoundarySource2D.java  |   9 +
 .../twod/BoundarySourceBoundsBuilder2D.java        |  58 ++
 .../euclidean/twod/BoundarySourceLinecaster2D.java |  16 +-
 .../commons/geometry/euclidean/twod/Bounds2D.java  | 271 ++++++++
 .../geometry/euclidean/twod/ConvexArea.java        | 138 ++--
 .../euclidean/twod/EmbeddedTreeLineSubset.java     |  54 ++
 .../commons/geometry/euclidean/twod/Line.java      |   6 +-
 .../euclidean/twod/LineSpanningSubset.java         |  18 +
 .../geometry/euclidean/twod/LineSubset.java        |  28 +-
 .../commons/geometry/euclidean/twod/Lines.java     |  25 +-
 .../commons/geometry/euclidean/twod/Ray.java       |  81 ++-
 .../geometry/euclidean/twod/ReverseRay.java        |  99 +--
 .../commons/geometry/euclidean/twod/Segment.java   | 109 ++--
 .../commons/geometry/euclidean/twod/Vector2D.java  | 145 +++++
 .../geometry/euclidean/twod/path/LinePath.java     |  10 +-
 .../euclidean/DocumentationExamplesTest.java       |  37 +-
 .../geometry/euclidean/EuclideanTestUtils.java     |  43 ++
 .../geometry/euclidean/oned/OrientedPointTest.java |   3 +-
 ...ubsetTest.java => AbstractPlaneSubsetTest.java} | 106 ++-
 .../euclidean/threed/BoundarySource3DTest.java     |  12 +-
 .../threed/BoundarySourceBoundsBuilder3DTest.java  | 155 +++++
 .../threed/BoundarySourceLinecaster3DTest.java     |   8 +-
 .../geometry/euclidean/threed/Bounds3DTest.java    | 538 +++++++++++++++
 .../euclidean/threed/ConvexVolumeTest.java         | 105 ++-
 .../threed/EmbeddedAreaPlaneConvexSubsetTest.java  | 468 +++++++++++++
 .../threed/EmbeddedTreePlaneSubsetTest.java        | 415 +++++++++---
 .../euclidean/threed/EmbeddingPlaneTest.java       | 509 +++++++++++++++
 .../euclidean/threed/PlaneConvexSubsetTest.java    | 362 ++---------
 .../geometry/euclidean/threed/PlaneTest.java       | 442 ++++++-------
 .../geometry/euclidean/threed/PlanesTest.java      | 723 +++++++++++++++++++++
 .../euclidean/threed/RegionBSPTree3DTest.java      |  94 ++-
 .../euclidean/threed/SimpleTriangle3DTest.java     | 336 ++++++++++
 .../geometry/euclidean/threed/Vector3DTest.java    |  79 +++
 .../threed/VertexListConvexPolygon3DTest.java      | 393 +++++++++++
 .../threed/line/EmbeddedTreeLineSubset3DTest.java  | 107 ++-
 .../geometry/euclidean/threed/line/Line3DTest.java |   3 +
 .../geometry/euclidean/threed/line/Ray3DTest.java  |   9 +
 .../euclidean/threed/line/ReverseRay3DTest.java    |   9 +
 .../euclidean/threed/line/Segment3DTest.java       |  31 +
 .../twod/BoundarySourceBoundsBuilder2DTest.java    | 127 ++++
 .../geometry/euclidean/twod/Bounds2DTest.java      | 507 +++++++++++++++
 .../geometry/euclidean/twod/ConvexAreaTest.java    | 334 ++++------
 .../euclidean/twod/EmbeddedTreeLineSubsetTest.java |  53 +-
 .../euclidean/twod/LineSpanningSubsetTest.java     |   2 +
 .../geometry/euclidean/twod/LineSubsetTest.java    |  21 +
 .../commons/geometry/euclidean/twod/RayTest.java   |  32 +-
 .../euclidean/twod/RegionBSPTree2DTest.java        |  29 +-
 .../geometry/euclidean/twod/ReverseRayTest.java    |  33 +-
 .../geometry/euclidean/twod/SegmentTest.java       |  43 ++
 .../geometry/euclidean/twod/Vector2DTest.java      |  83 ++-
 .../geometry/euclidean/twod/path/LinePathTest.java |   8 +-
 .../commons/geometry/examples/io/Format3D.java     |  41 +-
 .../geometry/hull/euclidean/twod/ConvexHull2D.java |   2 +-
 .../commons/geometry/spherical/oned/CutAngle.java  |  19 +-
 .../geometry/spherical/twod/GreatCircleSubset.java |  64 +-
 .../geometry/spherical/oned/CutAngleTest.java      |   4 +-
 .../twod/EmbeddedTreeSubGreatCircleTest.java       |   5 +
 .../geometry/spherical/twod/GreatArcTest.java      |   2 +
 .../spherical/twod/GreatCircleSubsetTest.java      |   5 +
 .../checkstyle/checkstyle-suppressions.xml         |   1 +
 .../resources/spotbugs/spotbugs-exclude-filter.xml |  26 +-
 src/site/xdoc/userguide/index.xml                  |  48 +-
 122 files changed, 9891 insertions(+), 2716 deletions(-)

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
index f22e00c..308f83f 100644
--- 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
@@ -45,7 +45,7 @@ public interface Region<P extends Point<P>> extends Sized {
     /** Get the barycenter of the region or null if no barycenter exists or
      * one exists but is not unique. A barycenter will not exist for empty or
      * infinite regions.
-     * @return the barycenter of the region or null if none exists
+     * @return the barycenter of the region or null if no unique barycenter exists
      */
     P getBarycenter();
 
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/RegionEmbedding.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/RegionEmbedding.java
index b82b75a..feedb41 100644
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/RegionEmbedding.java
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/RegionEmbedding.java
@@ -23,16 +23,7 @@ package org.apache.commons.geometry.core;
  * @see Region
  */
 public interface RegionEmbedding<P extends Point<P>, S extends Point<S>>
-    extends Embedding<P, S>, Sized {
-
-    /** Get the size of the instance, which by default is the size of the embedded
-     * subspace region.
-     * @return the size of instance
-     */
-    @Override
-    default double getSize() {
-        return getSubspaceRegion().getSize();
-    }
+    extends Embedding<P, S> {
 
     /** Get the embedded subspace region.
      * @return the embedded subspace region
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/HyperplaneSubsets.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/HyperplaneSubsets.java
new file mode 100644
index 0000000..1f453a6
--- /dev/null
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/internal/HyperplaneSubsets.java
@@ -0,0 +1,89 @@
+/*
+ * 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 org.apache.commons.geometry.core.Point;
+import org.apache.commons.geometry.core.Region;
+import org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.partitioning.EmbeddingHyperplane;
+
+/** Utility methods for {@link org.apache.commons.geometry.core.partitioning.HyperplaneSubset}
+ * implementations.
+ */
+public final class HyperplaneSubsets {
+
+    /** Utility class; no instantiation. */
+    private HyperplaneSubsets() {
+    }
+
+    /** Classify a point against a region embedded in a hyperplane.
+     * @param <P> Point implementation class
+     * @param <S> Subspace point implementation class
+     * @param <H> Hyperplane implementation class
+     * @param <R> Region implementation class
+     * @param pt the point to classify
+     * @param hyperplane hyperplane containing the embedded region
+     * @param embeddedRegion embedded region to classify against
+     * @return the region location of the given point
+     */
+    public static <
+        P extends Point<P>,
+        S extends Point<S>,
+        H extends EmbeddingHyperplane<P, S>,
+        R extends Region<S>> RegionLocation classifyAgainstEmbeddedRegion(final P pt,
+                final H hyperplane, final R embeddedRegion) {
+
+        if (hyperplane.contains(pt)) {
+            final S subPoint = hyperplane.toSubspace(pt);
+
+            return embeddedRegion.classify(subPoint);
+        }
+
+        return RegionLocation.OUTSIDE;
+    }
+
+    /** Return the closest point to a given point in a region embedded in a hyperplane.
+     * @param <P> Point implementation class
+     * @param <S> Subspace point implementation class
+     * @param <H> Hyperplane implementation class
+     * @param <R> Region implementation class
+     * @param pt point to find the closest point to
+     * @param hyperplane hyperplane containing the embedded region
+     * @param embeddedRegion embedded region to find the closest point in
+     * @return the closest point to {@code pt} in the embedded region
+     */
+    public static <
+        P extends Point<P>,
+        S extends Point<S>,
+        H extends EmbeddingHyperplane<P, S>,
+        R extends Region<S>> P closestToEmbeddedRegion(final P pt,
+                final H hyperplane, final R embeddedRegion) {
+
+        final S subPt = hyperplane.toSubspace(pt);
+
+        if (embeddedRegion.contains(subPt)) {
+            return hyperplane.toSpace(subPt);
+        }
+
+        final S subProjected = embeddedRegion.project(subPt);
+        if (subProjected != null) {
+            return hyperplane.toSpace(subProjected);
+        }
+
+        return null;
+    }
+}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/AbstractRegionEmbeddingHyperplaneSubset.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/AbstractRegionEmbeddingHyperplaneSubset.java
deleted file mode 100644
index e72071a..0000000
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/AbstractRegionEmbeddingHyperplaneSubset.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;
-import org.apache.commons.geometry.core.Region;
-import org.apache.commons.geometry.core.RegionEmbedding;
-import org.apache.commons.geometry.core.RegionLocation;
-
-/** Abstract base class for hyperplane subset 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 AbstractRegionEmbeddingHyperplaneSubset<
-    P extends Point<P>,
-    S extends Point<S>,
-    H extends EmbeddingHyperplane<P, S>> implements HyperplaneSubset<P>, RegionEmbedding<P, S> {
-
-    /** {@inheritDoc} */
-    @Override
-    public boolean isFull() {
-        return getSubspaceRegion().isFull();
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public boolean isEmpty() {
-        return getSubspaceRegion().isEmpty();
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public S toSubspace(final P pt) {
-        return getHyperplane().toSubspace(pt);
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public P toSpace(final S pt) {
-        return getHyperplane().toSpace(pt);
-    }
-
-    /** {@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();
-
-    /** {@inheritDoc} */
-    @Override
-    public abstract HyperplaneBoundedRegion<S> getSubspaceRegion();
-}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/HyperplaneSubset.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/HyperplaneSubset.java
index 8d3f73b..580350c 100644
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/HyperplaneSubset.java
+++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/HyperplaneSubset.java
@@ -23,12 +23,26 @@ import org.apache.commons.geometry.core.RegionLocation;
 import org.apache.commons.geometry.core.Sized;
 import org.apache.commons.geometry.core.Transform;
 
-/** Interface representing a subset of the points lying on a hyperplane. Examples include
+/** Interface representing a subset of the points lying in a hyperplane. Examples include
  * rays and line segments in Euclidean 2D space and triangular facets in Euclidean 3D space.
  * Hyperplane subsets can have finite or infinite size and can represent contiguous regions
  * of the hyperplane (as in the examples aboves); multiple, disjoint regions; or the
  * {@link Hyperplane#span() entire hyperplane}.
  *
+ * <p>This interface is very similar to the {@link org.apache.commons.geometry.core.Region Region}
+ * interface but has slightly different semantics. Whereas {@code Region} instances represent sets
+ * of points that can expand through all of the dimensions of a space, {@code HyperplaneSubset} instances
+ * are constrained to their containing hyperplane and are more accurately defined as {@code Region}s
+ * of the {@code n-1} dimension subspace defined by the hyperplane. This makes the methods of this interface
+ * have slightly different meanings as compared to their {@code Region} counterparts. For example, consider
+ * a triangular facet in Euclidean 3D space. The {@link #getSize()} method of this hyperplane subset does
+ * not return the <em>volume</em> of the instance (which would be {@code 0}) as a regular 3D region would, but
+ * rather returns the <em>area</em> of the 2D polygon defined by the facet. Similarly, the {@link #classify(Point)}
+ * method returns {@link RegionLocation#INSIDE} for points that lie inside of the 2D polygon defined by the
+ * facet, instead of the {@link RegionLocation#BOUNDARY} value that would be expected if the facet was considered
+ * as a true 3D region with zero thickness.
+ * </p>
+ *
  * @param <P> Point implementation type
  * @see Hyperplane
  */
@@ -51,6 +65,13 @@ public interface HyperplaneSubset<P extends Point<P>> extends Splittable<P, Hype
      */
     boolean isEmpty();
 
+    /** Get the barycenter of the hyperplane subset or null if no barycenter exists or
+     * one exists but is not unique. A barycenter will not exist for empty or
+     * infinite hyperplane subsets.
+     * @return the barycenter of the hyperplane subset or null if no unique barycenter exists
+     */
+    P getBarycenter();
+
     /** Classify a point with respect to the subset region. The point is classified as follows:
      * <ul>
      *  <li>{@link RegionLocation#INSIDE INSIDE} - The point lies on the hyperplane
@@ -99,7 +120,9 @@ public interface HyperplaneSubset<P extends Point<P>> extends Splittable<P, Hype
      */
     HyperplaneSubset<P> transform(Transform<P> transform);
 
-    /** Convert this instance into a list of convex child subsets.
+    /** Convert this instance into a list of convex child subsets representing the same region.
+     * Implementations are not required to return an optimal convex subdivision of the current
+     * instance. They are free to return whatever subdivision is readily available.
      * @return a list of hyperplane convex subsets representing the same subspace
      *      region as this instance
      */
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
index 0274232..ec9a1d2 100644
--- 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
@@ -20,10 +20,10 @@ 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.apache.commons.geometry.core.partitioning.test.PartitionTestUtils;
+import org.apache.commons.geometry.core.partitioning.test.TestLine;
+import org.apache.commons.geometry.core.partitioning.test.TestPoint1D;
+import org.apache.commons.geometry.core.partitioning.test.TestPoint2D;
 import org.junit.Assert;
 import org.junit.Test;
 
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/RegionEmbeddingTest.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/RegionEmbeddingTest.java
deleted file mode 100644
index 667bea3..0000000
--- a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/RegionEmbeddingTest.java
+++ /dev/null
@@ -1,116 +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;
-
-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 RegionEmbeddingTest {
-
-    private static final double TEST_EPS = 1e-10;
-
-    @Test
-    public void testGetSize() {
-        // arrange
-        StubRegionEmbedding finite = new StubRegionEmbedding(2.0);
-        StubRegionEmbedding infinite = new StubRegionEmbedding(Double.POSITIVE_INFINITY);
-        StubRegionEmbedding nan = new StubRegionEmbedding(Double.NaN);
-
-        // act/assert
-        Assert.assertEquals(2.0, finite.getSize(), TEST_EPS);
-        Assert.assertTrue(finite.isFinite());
-        Assert.assertFalse(finite.isInfinite());
-
-        GeometryTestUtils.assertPositiveInfinity(infinite.getSize());
-        Assert.assertFalse(infinite.isFinite());
-        Assert.assertTrue(infinite.isInfinite());
-
-        Assert.assertTrue(Double.isNaN(nan.getSize()));
-        Assert.assertFalse(nan.isFinite());
-        Assert.assertFalse(nan.isInfinite());
-    }
-
-    private static class StubRegionEmbedding implements RegionEmbedding<TestPoint2D, TestPoint1D> {
-
-        private final StubRegion1D subspaceRegion;
-
-        StubRegionEmbedding(final double size) {
-            subspaceRegion = new StubRegion1D(size);
-        }
-
-        @Override
-        public TestPoint1D toSubspace(TestPoint2D pt) {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public TestPoint2D toSpace(TestPoint1D pt) {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public Region<TestPoint1D> getSubspaceRegion() {
-            return subspaceRegion;
-        }
-    }
-
-    private static class StubRegion1D implements Region<TestPoint1D> {
-
-        private final double size;
-
-        StubRegion1D(final double size) {
-            this.size = size;
-        }
-
-        @Override
-        public double getSize() {
-            return size;
-        }
-
-        @Override
-        public boolean isFull() {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public boolean isEmpty() {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public double getBoundarySize() {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public TestPoint1D getBarycenter() {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public RegionLocation classify(TestPoint1D pt) {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public TestPoint1D project(TestPoint1D pt) {
-            throw new UnsupportedOperationException();
-        }
-    }
-}
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/internal/HyperplaneSubsetsTest.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/internal/HyperplaneSubsetsTest.java
new file mode 100644
index 0000000..5eeeca5
--- /dev/null
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/internal/HyperplaneSubsetsTest.java
@@ -0,0 +1,143 @@
+/*
+ * 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 org.apache.commons.geometry.core.RegionLocation;
+import org.apache.commons.geometry.core.partitioning.test.PartitionTestUtils;
+import org.apache.commons.geometry.core.partitioning.test.TestLine;
+import org.apache.commons.geometry.core.partitioning.test.TestPoint1D;
+import org.apache.commons.geometry.core.partitioning.test.TestPoint2D;
+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.junit.Assert;
+import org.junit.Test;
+
+public class HyperplaneSubsetsTest {
+
+    @Test
+    public void testClassify() {
+        // arrange
+        TestLine line = TestLine.X_AXIS;
+        StubRegion1D region = new StubRegion1D();
+
+        // act/assert
+        Assert.assertEquals(RegionLocation.INSIDE,
+                HyperplaneSubsets.classifyAgainstEmbeddedRegion(new TestPoint2D(-1, 0), line, region));
+        Assert.assertEquals(RegionLocation.BOUNDARY,
+                HyperplaneSubsets.classifyAgainstEmbeddedRegion(new TestPoint2D(0, 0), line, region));
+
+        Assert.assertEquals(RegionLocation.OUTSIDE,
+                HyperplaneSubsets.classifyAgainstEmbeddedRegion(new TestPoint2D(0, 1), line, region));
+        Assert.assertEquals(RegionLocation.OUTSIDE,
+                HyperplaneSubsets.classifyAgainstEmbeddedRegion(new TestPoint2D(-1, 1), line, region));
+        Assert.assertEquals(RegionLocation.OUTSIDE,
+                HyperplaneSubsets.classifyAgainstEmbeddedRegion(new TestPoint2D(-1, -1), line, region));
+    }
+
+    @Test
+    public void testClosest() {
+        // arrange
+        TestLine line = TestLine.X_AXIS;
+        StubRegion1D region = new StubRegion1D();
+        StubRegion1D emptyRegion = new StubRegion1D(true);
+
+        // act/assert
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(-1, 0),
+                HyperplaneSubsets.closestToEmbeddedRegion(new TestPoint2D(-1, 0), line, region));
+
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(0, 0),
+                HyperplaneSubsets.closestToEmbeddedRegion(new TestPoint2D(0, 0), line, region));
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(0, 0),
+                HyperplaneSubsets.closestToEmbeddedRegion(new TestPoint2D(1, 0), line, region));
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(0, 0),
+                HyperplaneSubsets.closestToEmbeddedRegion(new TestPoint2D(1, 1), line, region));
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(0, 0),
+                HyperplaneSubsets.closestToEmbeddedRegion(new TestPoint2D(1, -1), line, region));
+
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(-1, 0),
+                HyperplaneSubsets.closestToEmbeddedRegion(new TestPoint2D(-1, 1), line, region));
+        PartitionTestUtils.assertPointsEqual(new TestPoint2D(-1, 0),
+                HyperplaneSubsets.closestToEmbeddedRegion(new TestPoint2D(-1, -1), line, region));
+
+        Assert.assertNull(HyperplaneSubsets.closestToEmbeddedRegion(TestPoint2D.ZERO, line, emptyRegion));
+    }
+
+    /** Stub region implementation. Negative numbers are on the inside of the region.
+     */
+    private static class StubRegion1D implements HyperplaneBoundedRegion<TestPoint1D> {
+
+        private final boolean empty;
+
+        StubRegion1D() {
+            this(false);
+        }
+
+        StubRegion1D(boolean empty) {
+            this.empty = empty;
+        }
+
+        @Override
+        public boolean isFull() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public boolean isEmpty() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public double getSize() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public double getBoundarySize() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public TestPoint1D getBarycenter() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public RegionLocation classify(TestPoint1D pt) {
+            if (!empty) {
+                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 empty ? null : new TestPoint1D(0);
+        }
+
+        @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/AbstractConvexHyperplaneBoundedRegionTest.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/AbstractConvexHyperplaneBoundedRegionTest.java
index ccebc49..8c5a6bb 100644
--- 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
@@ -25,11 +25,11 @@ 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.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.apache.commons.geometry.core.partitioning.test.PartitionTestUtils;
+import org.apache.commons.geometry.core.partitioning.test.TestLine;
+import org.apache.commons.geometry.core.partitioning.test.TestLineSegment;
+import org.apache.commons.geometry.core.partitioning.test.TestPoint2D;
+import org.apache.commons.geometry.core.partitioning.test.TestTransform2D;
 import org.junit.Assert;
 import org.junit.Test;
 
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
index 9b41cf0..54c9a7e 100644
--- 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
@@ -17,8 +17,8 @@
 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.test.TestLine;
+import org.apache.commons.geometry.core.partitioning.test.TestPoint2D;
 import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
 import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
 import org.junit.Assert;
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/AbstractRegionEmbeddingHyperplaneSubsetTest.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/AbstractRegionEmbeddingHyperplaneSubsetTest.java
deleted file mode 100644
index 4dc0863..0000000
--- a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/AbstractRegionEmbeddingHyperplaneSubsetTest.java
+++ /dev/null
@@ -1,219 +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.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 AbstractRegionEmbeddingHyperplaneSubsetTest {
-
-    @Test
-    public void testSimpleProperties() {
-        // arrange
-        StubHyperplaneSubset sub = new StubHyperplaneSubset(1.0);
-
-        // act/assert
-        Assert.assertTrue(sub.isFull());
-        Assert.assertTrue(sub.isEmpty());
-        Assert.assertEquals(1.0, sub.getSize(), PartitionTestUtils.EPS);
-
-        Assert.assertTrue(sub.isFinite());
-        Assert.assertFalse(sub.isInfinite());
-    }
-
-    @Test
-    public void testFiniteAndInfinite() {
-        // arrange
-        StubHyperplaneSubset finite = new StubHyperplaneSubset(1.0);
-        StubHyperplaneSubset inf = new StubHyperplaneSubset(Double.POSITIVE_INFINITY);
-        StubHyperplaneSubset nan = new StubHyperplaneSubset(Double.NaN);
-
-        // act/assert
-        Assert.assertTrue(finite.isFinite());
-        Assert.assertFalse(finite.isInfinite());
-
-        Assert.assertFalse(inf.isFinite());
-        Assert.assertTrue(inf.isInfinite());
-
-        Assert.assertFalse(nan.isFinite());
-        Assert.assertFalse(nan.isInfinite());
-    }
-
-    @Test
-    public void testSpaceConversions() {
-        // arrange
-        StubHyperplaneSubset sub = new StubHyperplaneSubset();
-
-        // act/assert
-        Assert.assertEquals(2.0, sub.toSubspace(new TestPoint2D(2.0, 3.0)).getX(), PartitionTestUtils.EPS);
-        PartitionTestUtils.assertPointsEqual(new TestPoint2D(2.0, 0.0), sub.toSpace(new TestPoint1D(2.0)));
-    }
-
-    @Test
-    public void testClassify() {
-        // arrange
-        StubHyperplaneSubset sub = new StubHyperplaneSubset();
-
-        // 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
-        StubHyperplaneSubset sub = new StubHyperplaneSubset();
-
-        // 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
-        StubHyperplaneSubset sub = new StubHyperplaneSubset();
-        sub.region.projected = null;
-
-        // act/assert
-        Assert.assertNull(sub.closest(new TestPoint2D(1, 1)));
-    }
-
-    private static class StubHyperplaneSubset
-        extends AbstractRegionEmbeddingHyperplaneSubset<TestPoint2D, TestPoint1D, TestLine> {
-
-        private final StubRegion1D region;
-
-        StubHyperplaneSubset() {
-            this(0);
-        }
-
-        StubHyperplaneSubset(final double size) {
-            this.region = new StubRegion1D(size);
-        }
-
-        @Override
-        public Builder<TestPoint2D> builder() {
-            return null;
-        }
-
-        @Override
-        public List<? extends HyperplaneConvexSubset<TestPoint2D>> toConvex() {
-            return null;
-        }
-
-        @Override
-        public TestLine getHyperplane() {
-            return TestLine.X_AXIS;
-        }
-
-        @Override
-        public HyperplaneBoundedRegion<TestPoint1D> getSubspaceRegion() {
-            return region;
-        }
-
-        @Override
-        public Split<StubHyperplaneSubset> split(Hyperplane<TestPoint2D> splitter) {
-            return null;
-        }
-
-        @Override
-        public HyperplaneSubset<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);
-
-        private final double size;
-
-        StubRegion1D(final double size) {
-            this.size = size;
-        }
-
-        @Override
-        public boolean isFull() {
-            return true;
-        }
-
-        @Override
-        public boolean isEmpty() {
-            return true;
-        }
-
-        @Override
-        public double getSize() {
-            return size;
-        }
-
-        @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/bsp/AbstractBSPTreeMergeOperatorTest.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/bsp/AbstractBSPTreeMergeOperatorTest.java
index 8d59095..01f9900 100644
--- 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
@@ -18,11 +18,11 @@ package org.apache.commons.geometry.core.partitioning.bsp;
 
 import java.util.stream.StreamSupport;
 
-import org.apache.commons.geometry.core.partition.test.AttributeBSPTree;
-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.partition.test.AttributeBSPTree.AttributeNode;
+import org.apache.commons.geometry.core.partitioning.test.AttributeBSPTree;
+import org.apache.commons.geometry.core.partitioning.test.PartitionTestUtils;
+import org.apache.commons.geometry.core.partitioning.test.TestLine;
+import org.apache.commons.geometry.core.partitioning.test.TestPoint2D;
+import org.apache.commons.geometry.core.partitioning.test.AttributeBSPTree.AttributeNode;
 import org.junit.Assert;
 import org.junit.Test;
 
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
index a79cab1..d70a1e9 100644
--- 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
@@ -27,16 +27,16 @@ import java.util.stream.Collectors;
 import java.util.stream.StreamSupport;
 
 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.BoundarySource;
 import org.apache.commons.geometry.core.partitioning.bsp.BSPTree.FindNodeCutRule;
+import org.apache.commons.geometry.core.partitioning.test.PartitionTestUtils;
+import org.apache.commons.geometry.core.partitioning.test.TestBSPTree;
+import org.apache.commons.geometry.core.partitioning.test.TestLine;
+import org.apache.commons.geometry.core.partitioning.test.TestLineSegment;
+import org.apache.commons.geometry.core.partitioning.test.TestLineSegmentCollection;
+import org.apache.commons.geometry.core.partitioning.test.TestPoint2D;
+import org.apache.commons.geometry.core.partitioning.test.TestTransform2D;
+import org.apache.commons.geometry.core.partitioning.test.TestBSPTree.TestNode;
 import org.junit.Assert;
 import org.junit.Test;
 
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/bsp/AbstractRegionBSPTreeBooleanTest.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/bsp/AbstractRegionBSPTreeBooleanTest.java
index 999c79d..dd58d62 100644
--- a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/bsp/AbstractRegionBSPTreeBooleanTest.java
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/bsp/AbstractRegionBSPTreeBooleanTest.java
@@ -19,11 +19,11 @@ package org.apache.commons.geometry.core.partitioning.bsp;
 import java.util.Arrays;
 import java.util.function.Supplier;
 
-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.TestRegionBSPTree;
+import org.apache.commons.geometry.core.partitioning.test.PartitionTestUtils;
+import org.apache.commons.geometry.core.partitioning.test.TestLine;
+import org.apache.commons.geometry.core.partitioning.test.TestLineSegment;
+import org.apache.commons.geometry.core.partitioning.test.TestPoint2D;
+import org.apache.commons.geometry.core.partitioning.test.TestRegionBSPTree;
 import org.junit.Test;
 
 public class AbstractRegionBSPTreeBooleanTest {
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
index 3bbf64f..59dd3b0 100644
--- 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
@@ -25,20 +25,20 @@ import java.util.function.Function;
 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.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.TestRegionBSPTree;
-import org.apache.commons.geometry.core.partition.test.TestRegionBSPTree.TestRegionNode;
-import org.apache.commons.geometry.core.partition.test.TestTransform2D;
 import org.apache.commons.geometry.core.partitioning.BoundarySource;
 import org.apache.commons.geometry.core.partitioning.HyperplaneConvexSubset;
 import org.apache.commons.geometry.core.partitioning.Split;
 import org.apache.commons.geometry.core.partitioning.SplitLocation;
 import org.apache.commons.geometry.core.partitioning.HyperplaneSubset;
 import org.apache.commons.geometry.core.partitioning.bsp.AbstractRegionBSPTree.RegionSizeProperties;
+import org.apache.commons.geometry.core.partitioning.test.PartitionTestUtils;
+import org.apache.commons.geometry.core.partitioning.test.TestLine;
+import org.apache.commons.geometry.core.partitioning.test.TestLineSegment;
+import org.apache.commons.geometry.core.partitioning.test.TestLineSegmentCollection;
+import org.apache.commons.geometry.core.partitioning.test.TestPoint2D;
+import org.apache.commons.geometry.core.partitioning.test.TestRegionBSPTree;
+import org.apache.commons.geometry.core.partitioning.test.TestTransform2D;
+import org.apache.commons.geometry.core.partitioning.test.TestRegionBSPTree.TestRegionNode;
 import org.junit.Assert;
 import org.junit.Before;
 import org.junit.Test;
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
index 4beadd8..73ae682 100644
--- 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
@@ -16,12 +16,12 @@
  */
 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.partition.test.TestLine;
-import org.apache.commons.geometry.core.partition.test.TestPoint2D;
 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.test.TestBSPTree;
+import org.apache.commons.geometry.core.partitioning.test.TestLine;
+import org.apache.commons.geometry.core.partitioning.test.TestPoint2D;
+import org.apache.commons.geometry.core.partitioning.test.TestBSPTree.TestNode;
 import org.junit.Assert;
 import org.junit.Test;
 
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/bsp/MergeChecker.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/bsp/MergeChecker.java
index 55c3611..feb91cd 100644
--- a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/bsp/MergeChecker.java
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/bsp/MergeChecker.java
@@ -23,9 +23,9 @@ import java.util.function.Consumer;
 import java.util.function.Supplier;
 
 import org.apache.commons.geometry.core.RegionLocation;
-import org.apache.commons.geometry.core.partition.test.PartitionTestUtils;
-import org.apache.commons.geometry.core.partition.test.TestPoint2D;
-import org.apache.commons.geometry.core.partition.test.TestRegionBSPTree;
+import org.apache.commons.geometry.core.partitioning.test.PartitionTestUtils;
+import org.apache.commons.geometry.core.partitioning.test.TestPoint2D;
+import org.apache.commons.geometry.core.partitioning.test.TestRegionBSPTree;
 import org.junit.Assert;
 
 /** Helper class with a fluent API used to construct assert conditions on tree merge operations.
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
index 5b305ce..9e2bb90 100644
--- 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
@@ -16,9 +16,9 @@
  */
 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.test.PartitionTestUtils;
+import org.apache.commons.geometry.core.partitioning.test.TestLineSegment;
+import org.apache.commons.geometry.core.partitioning.test.TestPoint2D;
 import org.junit.Assert;
 import org.junit.Test;
 
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/AttributeBSPTree.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/test/AttributeBSPTree.java
similarity index 98%
rename from commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/AttributeBSPTree.java
rename to commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/test/AttributeBSPTree.java
index 2b83824..c3b43fa 100644
--- a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/AttributeBSPTree.java
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/test/AttributeBSPTree.java
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.commons.geometry.core.partition.test;
+package org.apache.commons.geometry.core.partitioning.test;
 
 import org.apache.commons.geometry.core.Point;
 import org.apache.commons.geometry.core.partitioning.Hyperplane;
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/partitioning/test/PartitionTestUtils.java
similarity index 99%
rename from commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/PartitionTestUtils.java
rename to commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/test/PartitionTestUtils.java
index 2172224..8c4512b 100644
--- 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/partitioning/test/PartitionTestUtils.java
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.commons.geometry.core.partition.test;
+package org.apache.commons.geometry.core.partitioning.test;
 
 import java.util.Arrays;
 import java.util.List;
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/partitioning/test/TestBSPTree.java
similarity index 98%
rename from commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestBSPTree.java
rename to commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/test/TestBSPTree.java
index f976fd1..973fa09 100644
--- 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/partitioning/test/TestBSPTree.java
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.commons.geometry.core.partition.test;
+package org.apache.commons.geometry.core.partitioning.test;
 
 import org.apache.commons.geometry.core.partitioning.BoundarySource;
 import org.apache.commons.geometry.core.partitioning.HyperplaneConvexSubset;
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/partitioning/test/TestLine.java
similarity index 99%
rename from commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestLine.java
rename to commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/test/TestLine.java
index 0f4ba88..f5feb5f 100644
--- 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/partitioning/test/TestLine.java
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.commons.geometry.core.partition.test;
+package org.apache.commons.geometry.core.partitioning.test;
 
 import org.apache.commons.geometry.core.Transform;
 import org.apache.commons.geometry.core.partitioning.EmbeddingHyperplane;
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/partitioning/test/TestLineSegment.java
similarity index 98%
rename from commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestLineSegment.java
rename to commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/test/TestLineSegment.java
index 896b286..ab923b5 100644
--- 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/partitioning/test/TestLineSegment.java
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.commons.geometry.core.partition.test;
+package org.apache.commons.geometry.core.partitioning.test;
 
 import java.util.Arrays;
 import java.util.List;
@@ -141,6 +141,12 @@ public class TestLineSegment implements HyperplaneConvexSubset<TestPoint2D> {
 
     /** {@inheritDoc} */
     @Override
+    public TestPoint2D getBarycenter() {
+        return line.toSpace(0.5 * (end - start));
+    }
+
+    /** {@inheritDoc} */
+    @Override
     public RegionLocation classify(TestPoint2D point) {
         if (line.contains(point)) {
             final double value = line.toSubspaceValue(point);
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/partitioning/test/TestLineSegmentCollection.java
similarity index 96%
rename from commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestLineSegmentCollection.java
rename to commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/test/TestLineSegmentCollection.java
index a036d9d..4918fc3 100644
--- 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/partitioning/test/TestLineSegmentCollection.java
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.commons.geometry.core.partition.test;
+package org.apache.commons.geometry.core.partitioning.test;
 
 import java.util.ArrayList;
 import java.util.Collections;
@@ -22,10 +22,10 @@ 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.HyperplaneConvexSubset;
 import org.apache.commons.geometry.core.partitioning.Hyperplane;
-import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.core.partitioning.HyperplaneConvexSubset;
 import org.apache.commons.geometry.core.partitioning.HyperplaneSubset;
+import org.apache.commons.geometry.core.partitioning.Split;
 
 /** Class containing a collection line segments. This class should only be used for
  * testing purposes.
@@ -110,6 +110,12 @@ public class TestLineSegmentCollection implements HyperplaneSubset<TestPoint2D>
 
     /** {@inheritDoc} */
     @Override
+    public TestPoint2D getBarycenter() {
+        throw new UnsupportedOperationException();
+    }
+
+    /** {@inheritDoc} */
+    @Override
     public Split<TestLineSegmentCollection> split(Hyperplane<TestPoint2D> splitter) {
         final List<TestLineSegment> minusList = new ArrayList<>();
         final List<TestLineSegment> plusList = new ArrayList<>();
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/partitioning/test/TestLineSegmentCollectionBuilder.java
similarity index 98%
rename from commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestLineSegmentCollectionBuilder.java
rename to commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/test/TestLineSegmentCollectionBuilder.java
index 7589574..0e7adf8 100644
--- 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/partitioning/test/TestLineSegmentCollectionBuilder.java
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.commons.geometry.core.partition.test;
+package org.apache.commons.geometry.core.partitioning.test;
 
 import java.util.ArrayList;
 import java.util.LinkedList;
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/partitioning/test/TestPoint1D.java
similarity index 97%
rename from commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestPoint1D.java
rename to commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/test/TestPoint1D.java
index 59ce9a8..400b149 100644
--- 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/partitioning/test/TestPoint1D.java
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.commons.geometry.core.partition.test;
+package org.apache.commons.geometry.core.partitioning.test;
 
 import org.apache.commons.geometry.core.Point;
 
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/partitioning/test/TestPoint2D.java
similarity index 97%
rename from commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestPoint2D.java
rename to commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/test/TestPoint2D.java
index f3aebab..190e235 100644
--- 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/partitioning/test/TestPoint2D.java
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.commons.geometry.core.partition.test;
+package org.apache.commons.geometry.core.partitioning.test;
 
 import org.apache.commons.geometry.core.Point;
 
diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestRegionBSPTree.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/test/TestRegionBSPTree.java
similarity index 98%
rename from commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestRegionBSPTree.java
rename to commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/test/TestRegionBSPTree.java
index 5b46c41..651c117 100644
--- a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestRegionBSPTree.java
+++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/test/TestRegionBSPTree.java
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.commons.geometry.core.partition.test;
+package org.apache.commons.geometry.core.partitioning.test;
 
 import org.apache.commons.geometry.core.RegionLocation;
 import org.apache.commons.geometry.core.partitioning.HyperplaneConvexSubset;
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/partitioning/test/TestTransform2D.java
similarity index 97%
rename from commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partition/test/TestTransform2D.java
rename to commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/test/TestTransform2D.java
index 738e90b..e12e125 100644
--- 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/partitioning/test/TestTransform2D.java
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.commons.geometry.core.partition.test;
+package org.apache.commons.geometry.core.partitioning.test;
 
 import java.util.function.UnaryOperator;
 
diff --git a/commons-geometry-enclosing/src/main/java/org/apache/commons/geometry/enclosing/euclidean/threed/SphereGenerator.java b/commons-geometry-enclosing/src/main/java/org/apache/commons/geometry/enclosing/euclidean/threed/SphereGenerator.java
index 246a579..2b60cd4 100644
--- a/commons-geometry-enclosing/src/main/java/org/apache/commons/geometry/enclosing/euclidean/threed/SphereGenerator.java
+++ b/commons-geometry-enclosing/src/main/java/org/apache/commons/geometry/enclosing/euclidean/threed/SphereGenerator.java
@@ -24,7 +24,7 @@ import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
 import org.apache.commons.geometry.enclosing.EnclosingBall;
 import org.apache.commons.geometry.enclosing.SupportBallGenerator;
 import org.apache.commons.geometry.enclosing.euclidean.twod.DiskGenerator;
-import org.apache.commons.geometry.euclidean.threed.Plane;
+import org.apache.commons.geometry.euclidean.threed.EmbeddingPlane;
 import org.apache.commons.geometry.euclidean.threed.Planes;
 import org.apache.commons.geometry.euclidean.threed.Vector3D;
 import org.apache.commons.geometry.euclidean.twod.Vector2D;
@@ -62,7 +62,7 @@ public class SphereGenerator implements SupportBallGenerator<Vector3D> {
         }
         final Vector3D vC = support.get(2);
         if (support.size() < 4) {
-            final Plane p = Planes.fromPoints(vA, vB, vC, precision);
+            final EmbeddingPlane p = Planes.fromPoints(vA, vB, vC, precision).getEmbedding();
             final EnclosingBall<Vector2D> disk =
                     new DiskGenerator().ballOnSupport(Arrays.asList(p.toSubspace(vA),
                                                                     p.toSubspace(vB),
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/AbstractBounds.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/AbstractBounds.java
new file mode 100644
index 0000000..307d98b
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/AbstractBounds.java
@@ -0,0 +1,159 @@
+/*
+ * 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.partitioning.HyperplaneBoundedRegion;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+
+/** Base class representing an axis-aligned bounding box with minimum and maximum bounding points.
+ * @param <P> Point implementation type
+ * @param <B> Bounds implementation type
+ */
+public abstract class AbstractBounds<
+    P extends EuclideanVector<P>,
+    B extends AbstractBounds<P, B>> {
+
+    /** Minimum point. */
+    private final P min;
+
+    /** Maximum point. */
+    private final P max;
+
+    /** Simple constructor. Callers are responsible for ensuring that all coordinate values are finite and
+     * that all values in {@code min} are less than or equal to their corresponding values in {@code max}.
+     * No validation is performed.
+     * @param min minimum point
+     * @param max maximum point
+     */
+    protected AbstractBounds(final P min, final P max) {
+        this.min = min;
+        this.max = max;
+    }
+
+    /** Get the minimum point.
+     * @return the minimum point
+     */
+    public P getMin() {
+        return min;
+    }
+
+    /** Get the maximum point.
+     * @return the maximum point
+     */
+    public P getMax() {
+        return max;
+    }
+
+    /** Get the diagonal of the bounding box. The return value is a vector pointing from
+     * {@code min} to {@code max} and contains the size of the box along each coordinate axis.
+     * @return the diagonal vector of the bounding box
+     */
+    public P getDiagonal() {
+        return min.vectorTo(max);
+    }
+
+    /** Return the center of the bounding box.
+     * @return the center of the bounding box
+     */
+    public P getBarycenter() {
+        return min.lerp(max, 0.5);
+    }
+
+    /** Return true if the bounding box has non-zero size along each coordinate axis, as
+     * evaluated by the given precision context.
+     * @param precision precision context used for floating point comparisons
+     * @return true if the bounding box has non-zero size along each coordinate axis
+     */
+    public abstract boolean hasSize(DoublePrecisionContext precision);
+
+    /** Return true if the given point is strictly within or on the boundary of the bounding box.
+     * In other words, true if returned if <code>p<sub>t</sub> &gt;= min<sub>t</sub></code> and
+     * <code>p<sub>t</sub> &lt;= max<sub>t</sub></code> for each coordinate value <code>t</code>.
+     * Floating point comparisons are strict; values are considered equal only if they match exactly.
+     * @param pt the point to check
+     * @return true if the given point is strictly within or on the boundary of the instance
+     * @see #contains(EuclideanVector, DoublePrecisionContext)
+     */
+    public abstract boolean contains(P pt);
+
+    /** Return true if the given point is within or on the boundary of the bounding box, using the given
+     * precision context for floating point comparisons. This is similar to {@link #contains(EuclideanVector)}
+     * but allows points that may be strictly outside of the box due to floating point errors to be considered
+     * inside.
+     * @param pt the point to check
+     * @param precision precision context used to compare floating point values
+     * @return if the given point is within or on the boundary of the bounds, as determined
+     *      by the given precision context
+     * @see #contains(EuclideanVector, DoublePrecisionContext)
+     */
+    public abstract boolean contains(P pt, DoublePrecisionContext precision);
+
+    /** Return true if any point on the interior or boundary of this instance is also considered to be
+     * on the interior or boundary of the argument. Specifically, true is returned if
+     * <code>aMin<sub>t</sub> &lt;= bMax<sub>t</sub></code> and <code>aMax<sub>t</sub> &gt;= bMin<sub>t</sub></code>
+     * for all coordinate values {@code t}, where {@code a} is the current instance and {@code b} is the argument.
+     * Floating point comparisons are strict; values are considered equal only if they match exactly.
+     * @param other bounding box to intersect with
+     * @return true if the bounds intersect
+     */
+    public abstract boolean intersects(B other);
+
+    /** Return the intersection of this bounding box and the argument, or null if no intersection exists.
+     * Floating point comparisons are strict; values are considered equal only if they match exactly. Note
+     * this this method may return bounding boxes with zero size in one or more coordinate axes.
+     * @param other bounding box to intersect with
+     * @return the intersection of this instance and the argument, or null if no such intersection
+     *      exists
+     * @see #intersects(AbstractBounds)
+     */
+    public abstract B intersection(B other);
+
+    /** Return a hyperplane-bounded region containing the same points as this instance.
+     * @param precision precision context used for floating point comparisons in the returned
+     *      region instance
+     * @return a hyperplane-bounded region containing the same points as this instance
+     */
+    public abstract HyperplaneBoundedRegion<P> toRegion(DoublePrecisionContext precision);
+
+    /** Return true if the current instance and argument are considered equal as evaluated by the
+     * given precision context. Bounds are considered equal if they contain equivalent min and max
+     * points.
+     * @param other bounds to compare with
+     * @param precision precision context to compare floating point numbers
+     * @return true if this instance is equivalent to the argument, as evaluated by the given
+     *      precision context
+     * @see EuclideanVector#eq(EuclideanVector, DoublePrecisionContext)
+     */
+    public boolean eq(final B other, final DoublePrecisionContext precision) {
+        return min.eq(other.getMin(), precision) &&
+                max.eq(other.getMax(), precision);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String toString() {
+        final StringBuilder sb = new StringBuilder();
+        sb.append(getClass().getSimpleName())
+            .append("[min= ")
+            .append(min)
+            .append(", max= ")
+            .append(max)
+            .append(']');
+
+        return sb.toString();
+    }
+}
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 0463139..1e641b8 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
@@ -80,7 +80,7 @@ public final class OrientedPoint extends AbstractHyperplane<Vector1D>
     /** Get the direction of the hyperplane's plus side.
      * @return the hyperplane direction
      */
-    public Vector1D getDirection() {
+    public Vector1D.Unit getDirection() {
         return positiveFacing ? Vector1D.Unit.PLUS : Vector1D.Unit.MINUS;
     }
 
@@ -272,7 +272,7 @@ public final class OrientedPoint extends AbstractHyperplane<Vector1D>
 
         /** {@inheritDoc}
         *
-        * <p>This method simply returns false.</p>
+        * <p>This method always returns {@code false}.</p>
         */
         @Override
         public boolean isFull() {
@@ -281,7 +281,7 @@ public final class OrientedPoint extends AbstractHyperplane<Vector1D>
 
         /** {@inheritDoc}
         *
-        * <p>This method simply returns false.</p>
+        * <p>This method always returns {@code false}.</p>
         */
         @Override
         public boolean isEmpty() {
@@ -290,7 +290,7 @@ public final class OrientedPoint extends AbstractHyperplane<Vector1D>
 
         /** {@inheritDoc}
          *
-         * <p>This method simply returns false.</p>
+         * <p>This method always returns {@code false}.</p>
          */
         @Override
         public boolean isInfinite() {
@@ -299,7 +299,7 @@ public final class OrientedPoint extends AbstractHyperplane<Vector1D>
 
         /** {@inheritDoc}
         *
-        * <p>This method simply returns true.</p>
+        * <p>This method always returns {@code true}.</p>
         */
         @Override
         public boolean isFinite() {
@@ -308,7 +308,7 @@ public final class OrientedPoint extends AbstractHyperplane<Vector1D>
 
         /** {@inheritDoc}
          *
-         *  <p>This method simply returns {@code 0}.</p>
+         *  <p>This method always returns {@code 0}.</p>
          */
         @Override
         public double getSize() {
@@ -316,6 +316,15 @@ public final class OrientedPoint extends AbstractHyperplane<Vector1D>
         }
 
         /** {@inheritDoc}
+        *
+        *  <p>This method returns the point for the defining hyperplane.</p>
+        */
+        @Override
+        public Vector1D getBarycenter() {
+            return hyperplane.getPoint();
+        }
+
+        /** {@inheritDoc}
          *
          * <p>This method returns {@link RegionLocation#BOUNDARY} if the
          * point is on the hyperplane and {@link RegionLocation#OUTSIDE}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/AbstractConvexPolygon3D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/AbstractConvexPolygon3D.java
new file mode 100644
index 0000000..8d69812
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/AbstractConvexPolygon3D.java
@@ -0,0 +1,451 @@
+/*
+ * 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.Iterator;
+import java.util.List;
+
+import org.apache.commons.geometry.core.RegionLocation;
+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.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.internal.Vectors;
+import org.apache.commons.geometry.euclidean.threed.line.Lines3D;
+import org.apache.commons.geometry.euclidean.twod.ConvexArea;
+import org.apache.commons.geometry.euclidean.twod.Vector2D;
+
+/** Abstract base class for {@link ConvexPolygon3D} implementations.
+ */
+abstract class AbstractConvexPolygon3D extends AbstractPlaneSubset implements ConvexPolygon3D {
+
+    /** Plane containing the convex polygon. */
+    private final Plane plane;
+
+    /** Simple constructor.
+     * @param plane the plane containing the convex polygon
+     */
+    AbstractConvexPolygon3D(final Plane plane) {
+        this.plane = plane;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Plane getPlane() {
+        return plane;
+    }
+
+    /** {@inheritDoc}
+     *
+     *  <p>This method always returns {@code false}.</p>
+     */
+    @Override
+    public boolean isFull() {
+        return false;
+    }
+
+    /** {@inheritDoc}
+     *
+     *  <p>This method always returns {@code false}.</p>
+     */
+    @Override
+    public boolean isEmpty() {
+        return false;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public double getSize() {
+        // see http://geomalgorithms.com/a01-_area.html#3D-Planar-Polygons
+        final List<Vector3D> vertices = getVertices();
+
+        double crossSumX = 0.0;
+        double crossSumY = 0.0;
+        double crossSumZ = 0.0;
+
+        Vector3D prevPt = vertices.get(vertices.size() - 1);
+        Vector3D cross;
+        for (final Vector3D curPt : vertices) {
+            cross = prevPt.cross(curPt);
+
+            crossSumX += cross.getX();
+            crossSumY += cross.getY();
+            crossSumZ += cross.getZ();
+
+            prevPt = curPt;
+        }
+
+        return 0.5 * plane.getNormal().dot(Vector3D.of(crossSumX, crossSumY, crossSumZ));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Vector3D getBarycenter() {
+        final List<Vector3D> vertices = getVertices();
+
+        double areaSum = 0.0;
+        double scaledBarycenterSumX = 0.0;
+        double scaledBarycenterSumY = 0.0;
+        double scaledBarycenterSumZ = 0.0;
+
+        Iterator<Vector3D> it = vertices.iterator();
+
+        Vector3D startPt = it.next();
+
+        Vector3D prevPt = it.next();
+        Vector3D curPt;
+
+        Vector3D prevVec = startPt.vectorTo(prevPt);
+        Vector3D curVec = null;
+
+        double triArea;
+        Vector3D triBarycenter;
+        while (it.hasNext()) {
+            curPt = it.next();
+            curVec = startPt.vectorTo(curPt);
+
+            triArea = 0.5 * prevVec.cross(curVec).norm();
+            triBarycenter = Vector3D.centroid(startPt, prevPt, curPt);
+
+            areaSum += triArea;
+
+            scaledBarycenterSumX += triArea * triBarycenter.getX();
+            scaledBarycenterSumY += triArea * triBarycenter.getY();
+            scaledBarycenterSumZ += triArea * triBarycenter.getZ();
+
+            prevPt = curPt;
+            prevVec = curVec;
+        }
+
+        if (areaSum > 0.0) {
+            final double scale = 1 / areaSum;
+            return Vector3D.of(
+                        scale * scaledBarycenterSumX,
+                        scale * scaledBarycenterSumY,
+                        scale * scaledBarycenterSumZ
+                    );
+        }
+
+        // zero area, which means that the points are all linear; return the point midway between the
+        // min and max points
+        final Vector3D min = Vector3D.min(vertices);
+        final Vector3D max = Vector3D.max(vertices);
+
+        return min.lerp(max, 0.5);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Bounds3D getBounds() {
+        return Bounds3D.from(getVertices());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public RegionLocation classify(final Vector3D pt) {
+        if (plane.contains(pt)) {
+            final List<Vector3D> vertices = getVertices();
+            final DoublePrecisionContext precision = plane.getPrecision();
+
+            final Vector3D normal = plane.getNormal();
+            Vector3D edgeVec;
+            Vector3D edgePlusVec;
+            Vector3D testVec;
+
+            Vector3D offsetVec;
+            double offsetSign;
+            double offset;
+            int cmp;
+
+            boolean onBoundary = false;
+
+            Vector3D startVertex = vertices.get(vertices.size() - 1);
+            for (final Vector3D nextVertex : vertices) {
+
+                edgeVec = startVertex.vectorTo(nextVertex);
+                edgePlusVec = edgeVec.cross(normal);
+
+                testVec = startVertex.vectorTo(pt);
+
+                offsetVec = testVec.reject(edgeVec);
+                offsetSign = Math.signum(offsetVec.dot(edgePlusVec));
+                offset = offsetSign * offsetVec.norm();
+
+                cmp = precision.compare(offset, 0.0);
+                if (cmp > 0) {
+                    // the point is on the plus side (outside) of a boundary
+                    return RegionLocation.OUTSIDE;
+                } else if (cmp == 0) {
+                    onBoundary = true;
+                }
+
+                startVertex = nextVertex;
+            }
+
+            if (onBoundary) {
+                // the point is not on the outside of any boundaries and is directly on at least one
+                return RegionLocation.BOUNDARY;
+            }
+
+            // the point is on the inside of all boundaries
+            return RegionLocation.INSIDE;
+        }
+
+        // the point is not on the plane
+        return RegionLocation.OUTSIDE;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Vector3D closest(final Vector3D pt) {
+        final Vector3D normal = plane.getNormal();
+        final DoublePrecisionContext precision = plane.getPrecision();
+
+        final List<Vector3D> vertices = getVertices();
+
+        final Vector3D projPt = plane.project(pt);
+
+        Vector3D edgeVec;
+        Vector3D edgePlusVec;
+        Vector3D testVec;
+
+        Vector3D offsetVec;
+        double offsetSign;
+        double offset;
+        int cmp;
+
+        Vector3D boundaryVec;
+        double boundaryPointT;
+        Vector3D boundaryPoint = null;
+        double boundaryPointDistSq;
+
+        double closestBoundaryPointDistSq = Double.POSITIVE_INFINITY;
+        Vector3D closestBoundaryPoint = null;
+
+        Vector3D startVertex = vertices.get(vertices.size() - 1);
+        for (final Vector3D nextVertex : vertices) {
+
+            edgeVec = startVertex.vectorTo(nextVertex);
+            edgePlusVec = edgeVec.cross(normal);
+
+            testVec = startVertex.vectorTo(projPt);
+
+            offsetVec = testVec.reject(edgeVec);
+            offsetSign = Math.signum(offsetVec.dot(edgePlusVec));
+            offset = offsetSign * offsetVec.norm();
+
+            cmp = precision.compare(offset, 0.0);
+            if (cmp >= 0) {
+                // the point is on directly on the boundary or on its plus side; project the point onto the
+                // boundary, taking care to restrict the point to the actual extent of the boundary,
+                // and select the point with the shortest distance
+                boundaryVec = testVec.subtract(offsetVec);
+                boundaryPointT =
+                        Math.signum(boundaryVec.dot(edgeVec)) * (boundaryVec.norm() / Vectors.checkedNorm(edgeVec));
+                boundaryPointT = Math.max(0, Math.min(1, boundaryPointT));
+
+                boundaryPoint = startVertex.lerp(nextVertex, boundaryPointT);
+
+                boundaryPointDistSq = boundaryPoint.distanceSq(projPt);
+                if (boundaryPointDistSq < closestBoundaryPointDistSq) {
+                    closestBoundaryPointDistSq = boundaryPointDistSq;
+                    closestBoundaryPoint = boundaryPoint;
+                }
+            }
+
+            startVertex = nextVertex;
+        }
+
+        if (closestBoundaryPoint != null) {
+            // the point is on the outside of the polygon; return the closest point on the boundary
+            return closestBoundaryPoint;
+        }
+
+        // the projected point is on the inside of all boundaries and therefore on the inside of the subset
+        return projPt;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public PlaneConvexSubset.Embedded getEmbedded() {
+        final EmbeddingPlane embeddingPlane = plane.getEmbedding();
+        final List<Vector2D> subspaceVertices = embeddingPlane.toSubspace(getVertices());
+        final ConvexArea area = ConvexArea.convexPolygonFromVertices(subspaceVertices,
+                embeddingPlane.getPrecision());
+
+        return new EmbeddedAreaPlaneConvexSubset(embeddingPlane, area);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Split<PlaneConvexSubset> split(final Hyperplane<Vector3D> splitter) {
+        final Plane splitterPlane = (Plane) splitter;
+        final List<Vector3D> vertices = getVertices();
+
+        final int size = vertices.size();
+
+        int minusPlusTransitionIdx = -1;
+        Vector3D minusPlusInsertVertex = null;
+
+        int plusMinusTransitionIdx = -1;
+        Vector3D plusMinusInsertVertex = null;
+
+        int transitionCount = 0;
+
+        Vector3D curVertex;
+        HyperplaneLocation curLoc;
+
+        int lastSideIdx = -1;
+        Vector3D lastSideVertex = null;
+        HyperplaneLocation lastSideLoc = null;
+
+        int lastBoundaryIdx = -1;
+
+        for (int i = 0; i <= size || transitionCount == 1; ++i) {
+
+            curVertex = vertices.get(i % size);
+            curLoc = splitter.classify(curVertex);
+
+            if (lastSideLoc == HyperplaneLocation.MINUS && curLoc == HyperplaneLocation.PLUS) {
+                // transitioned from minus side to plus side
+                minusPlusTransitionIdx = Math.max(lastSideIdx, lastBoundaryIdx);
+                ++transitionCount;
+
+                if (lastBoundaryIdx < 0) {
+                    // no shared boundary point; compute a new vertex
+                    minusPlusInsertVertex = splitterPlane.intersection(
+                            Lines3D.fromPoints(lastSideVertex, curVertex, splitterPlane.getPrecision()));
+                }
+            } else if (lastSideLoc == HyperplaneLocation.PLUS && curLoc == HyperplaneLocation.MINUS) {
+                // transitioned from plus side to minus side
+                plusMinusTransitionIdx = Math.max(lastSideIdx, lastBoundaryIdx);
+                ++transitionCount;
+
+                if (lastBoundaryIdx < 0) {
+                    // no shared boundary point; compute a new vertex
+                    plusMinusInsertVertex = splitterPlane.intersection(
+                            Lines3D.fromPoints(lastSideVertex, curVertex, splitterPlane.getPrecision()));
+                }
+            }
+
+            if (curLoc == HyperplaneLocation.ON) {
+                lastBoundaryIdx = i;
+            } else {
+                lastBoundaryIdx = -1;
+
+                lastSideIdx = i;
+                lastSideVertex = curVertex;
+                lastSideLoc = curLoc;
+            }
+        }
+
+        if (minusPlusTransitionIdx > -1 && plusMinusTransitionIdx > -1) {
+            // we've split; compute the vertex list for each side
+            final List<Vector3D> minusVertices =  buildPolygonSplitVertexList(
+                    plusMinusTransitionIdx, plusMinusInsertVertex,
+                    minusPlusTransitionIdx, minusPlusInsertVertex, vertices);
+            final List<Vector3D> plusVertices = buildPolygonSplitVertexList(
+                    minusPlusTransitionIdx, minusPlusInsertVertex,
+                    plusMinusTransitionIdx, plusMinusInsertVertex, vertices);
+
+            // delegate back to the Planes factory methods to determine the concrete types
+            // for each side of the split
+            return new Split<>(
+                    Planes.fromConvexPlanarVertices(plane, minusVertices),
+                    Planes.fromConvexPlanarVertices(plane, plusVertices));
+
+        } else if (lastSideLoc == HyperplaneLocation.PLUS) {
+            // we lie entirely on the plus side of the splitter
+            return new Split<>(null, this);
+        } else if (lastSideLoc == HyperplaneLocation.MINUS) {
+            // we lie entirely on the minus side of the splitter
+            return new Split<>(this, null);
+        }
+
+        // we lie entirely on the splitter
+        return new Split<>(null, null);
+    }
+
+    /** Internal method for building a vertex list for one side of a split result. The method is
+     * designed to make the fewest allocations possible.
+     * @param enterIdx the index of the vertex from {@code vertices} immediately before the polygon transitioned
+     *      to being fully entered into this side of the split result. If no point from {@code vertices} lay
+     *      directly on the splitting plane while entering this side and a new vertex had to be computed for the
+     *      split result, then this index will be the last vertex on the opposite side of the split. If a vertex
+     *      did lie directly on the splitting plane, then this index will point to that vertex.
+     * @param newEnterPt the newly-computed point to be added as the first vertex in the split result; may
+     *      be null if no such point exists
+     * @param exitIdx the index of the vertex from {@code vertices} immediately before the polygon transitioned
+     *      to being fully exited from this side of the split result. If no point from {@code vertices} lay
+     *      directly on the splitting plane while exiting this side and a new vertex had to be computed for the
+     *      split result, then this index will be the last vertex on the this side of the split. If a vertex did
+     *      lie directly on the splitting plane, then this index will point to that vertex.
+     * @param newExitPt the newly-computed point to be added as the last vertex in the split result; may
+     *      be null if no such point exists
+     * @param vertices the original list of vertices that this split result originated from; this list is
+     *      not modified by this operation
+     * @return the list of vertices for the split result
+     */
+    private List<Vector3D> buildPolygonSplitVertexList(final int enterIdx, final Vector3D newEnterPt,
+            final int exitIdx, final Vector3D newExitPt, final List<Vector3D> vertices) {
+
+        final int size = vertices.size();
+
+        final boolean hasNewEnterPt = newEnterPt != null;
+        final boolean hasNewExitPt = newExitPt != null;
+
+        final int startIdx = (hasNewEnterPt ? enterIdx + 1 : enterIdx) % size;
+        final int endIdx = exitIdx % size;
+
+        final boolean hasWrappedIndices = endIdx < startIdx;
+
+        final int resultSize = (hasWrappedIndices ? endIdx + size : endIdx) - startIdx + 1;
+        final List<Vector3D> result = new ArrayList<>(resultSize);
+
+        if (hasNewEnterPt) {
+            result.add(newEnterPt);
+        }
+
+        if (hasWrappedIndices) {
+            result.addAll(vertices.subList(startIdx, size));
+            result.addAll(vertices.subList(0, endIdx + 1));
+        } else {
+            result.addAll(vertices.subList(startIdx, endIdx + 1));
+        }
+
+        if (hasNewExitPt) {
+            result.add(newExitPt);
+        }
+
+        return result;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String toString() {
+        final StringBuilder sb = new StringBuilder();
+        sb.append(getClass().getSimpleName())
+            .append("[normal= ")
+            .append(getPlane().getNormal())
+            .append(", vertices= ")
+            .append(getVertices())
+            .append(']');
+
+        return sb.toString();
+    }
+}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/AbstractEmbeddedRegionPlaneSubset.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/AbstractEmbeddedRegionPlaneSubset.java
new file mode 100644
index 0000000..b19f3c4
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/AbstractEmbeddedRegionPlaneSubset.java
@@ -0,0 +1,137 @@
+/*
+ * 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.RegionLocation;
+import org.apache.commons.geometry.core.internal.HyperplaneSubsets;
+import org.apache.commons.geometry.euclidean.twod.BoundarySource2D;
+import org.apache.commons.geometry.euclidean.twod.Bounds2D;
+import org.apache.commons.geometry.euclidean.twod.Vector2D;
+
+/** Base class for {@link PlaneSubset} implementations that use an embedded subspace region
+ * to define their plane subsets.
+ */
+abstract class AbstractEmbeddedRegionPlaneSubset extends AbstractPlaneSubset implements PlaneSubset.Embedded {
+
+    /** The plane containing the embedded region. */
+    private final EmbeddingPlane plane;
+
+    /** Construct a new instance in the given plane.
+     * @param plane plane containing the subset
+     */
+    AbstractEmbeddedRegionPlaneSubset(final EmbeddingPlane plane) {
+        this.plane = plane;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public EmbeddingPlane getPlane() {
+        return plane;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public EmbeddingPlane getHyperplane() {
+        return plane;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isFull() {
+        return getSubspaceRegion().isFull();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isEmpty() {
+        return getSubspaceRegion().isEmpty();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public double getSize() {
+        return getSubspaceRegion().getSize();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Vector3D getBarycenter() {
+        final Vector2D subspaceBarycenter = getSubspaceRegion().getBarycenter();
+        if (subspaceBarycenter != null) {
+            return getPlane().toSpace(subspaceBarycenter);
+        }
+        return null;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Vector3D toSpace(final Vector2D pt) {
+        return plane.toSpace(pt);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Vector2D toSubspace(final Vector3D pt) {
+        return plane.toSubspace(pt);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public RegionLocation classify(final Vector3D pt) {
+        return HyperplaneSubsets.classifyAgainstEmbeddedRegion(pt, plane, getSubspaceRegion());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Vector3D closest(final Vector3D pt) {
+        return HyperplaneSubsets.closestToEmbeddedRegion(pt, plane, getSubspaceRegion());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String toString() {
+        final StringBuilder sb = new StringBuilder();
+        sb.append(getClass().getSimpleName())
+            .append("[plane= ")
+            .append(getPlane())
+            .append(", subspaceRegion= ")
+            .append(getSubspaceRegion())
+            .append(']');
+
+        return sb.toString();
+    }
+
+    /** Compute 3D bounds from a subspace boundary source.
+     * @param src subspace boundary source
+     * @return 3D bounds from the given embedded subspace boundary source or null
+     *      if no valid bounds could be determined
+     */
+    protected Bounds3D getBoundsFromSubspace(final BoundarySource2D src) {
+        final Bounds2D subspaceBounds = src.getBounds();
+        if (subspaceBounds != null) {
+            final Vector3D min = plane.toSpace(subspaceBounds.getMin());
+            final Vector3D max = plane.toSpace(subspaceBounds.getMax());
+
+            return Bounds3D.builder()
+                    .add(min)
+                    .add(max)
+                    .build();
+        }
+
+        return null;
+    }
+}
diff --git a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/GreatCircleSubset.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/AbstractPlaneSubset.java
similarity index 54%
copy from commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/GreatCircleSubset.java
copy to commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/AbstractPlaneSubset.java
index 5fbf0f5..6e9d017 100644
--- a/commons-geometry-spherical/src/main/java/org/apache/commons/geometry/spherical/twod/GreatCircleSubset.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/AbstractPlaneSubset.java
@@ -14,102 +14,81 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.commons.geometry.spherical.twod;
+package org.apache.commons.geometry.euclidean.threed;
 
-import java.util.List;
 import java.util.Objects;
 
-import org.apache.commons.geometry.core.partitioning.AbstractRegionEmbeddingHyperplaneSubset;
 import org.apache.commons.geometry.core.partitioning.HyperplaneConvexSubset;
 import org.apache.commons.geometry.core.partitioning.HyperplaneSubset;
-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
-import org.apache.commons.geometry.spherical.oned.Point1S;
+import org.apache.commons.geometry.euclidean.threed.line.Line3D;
+import org.apache.commons.geometry.euclidean.threed.line.LineConvexSubset3D;
 
-/** Class representing a subset of the points in a great circle.
- * @see GreatCircles
+/** Abstract base class for {@link PlaneSubset} implementations.
  */
-public abstract class GreatCircleSubset
-    extends AbstractRegionEmbeddingHyperplaneSubset<Point2S, Point1S, GreatCircle> {
-    /** The great circle defining this instance. */
-    private final GreatCircle circle;
-
-    /** Simple constructor.
-     * @param circle great circle defining this instance
-     */
-    GreatCircleSubset(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;
-    }
+abstract class AbstractPlaneSubset implements PlaneSubset {
 
     /** {@inheritDoc} */
     @Override
-    public GreatCircle getHyperplane() {
-        return getCircle();
+    public Plane getHyperplane() {
+        return getPlane();
     }
 
     /** {@inheritDoc} */
     @Override
-    public abstract List<GreatArc> toConvex();
+    public HyperplaneSubset.Builder<Vector3D> builder() {
+        return new Builder(getPlane());
+    }
 
     /** {@inheritDoc} */
     @Override
-    public HyperplaneSubset.Builder<Point2S> builder() {
-        return new Builder(circle);
+    public Vector3D intersection(final Line3D line) {
+        return Planes.intersection(this, line);
     }
 
-    /** 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();
+    /** {@inheritDoc} */
+    @Override
+    public Vector3D intersection(LineConvexSubset3D lineSubset) {
+        return Planes.intersection(this, lineSubset);
     }
 
     /** Internal implementation of the {@link HyperplaneSubset.Builder} interface. In cases where only a single
      * convex subset is given to the builder, this class returns the convex subset instance directly. In all other
-     * cases, an {@link EmbeddedTreeGreatCircleSubset} is used to construct the final subset.
+     * cases, an {@link EmbeddedTreePlaneSubset} is used to construct the final subset.
      */
-    private static final class Builder implements HyperplaneSubset.Builder<Point2S> {
-        /** Great circle that a subset is being constructed for. */
-        private final GreatCircle circle;
+    private static final class Builder implements HyperplaneSubset.Builder<Vector3D> {
+        /** Plane that a subset is being constructed for. */
+        private final Plane plane;
 
         /** Embedded tree subset. */
-        private EmbeddedTreeGreatCircleSubset treeSubset;
+        private EmbeddedTreePlaneSubset treeSubset;
 
         /** Convex subset added as the first subset to the builder. This is returned directly if
          * no other subsets are added.
          */
-        private GreatArc convexSubset;
+        private PlaneConvexSubset convexSubset;
 
-        /** Create a new subset builder for the given great circle.
-         * @param circle great circle to build a subset for
+        /** Create a new subset builder for the given plane.
+         * @param plane plane to build a subset for
          */
-        Builder(final GreatCircle circle) {
-            this.circle = circle;
+        Builder(final Plane plane) {
+            this.plane = plane;
         }
 
         /** {@inheritDoc} */
         @Override
-        public void add(final HyperplaneSubset<Point2S> sub) {
+        public void add(final HyperplaneSubset<Vector3D> sub) {
             addInternal(sub);
         }
 
         /** {@inheritDoc} */
         @Override
-        public void add(final HyperplaneConvexSubset<Point2S> sub) {
+        public void add(final HyperplaneConvexSubset<Vector3D> sub) {
             addInternal(sub);
         }
 
         /** {@inheritDoc} */
         @Override
-        public GreatCircleSubset build() {
+        public PlaneSubset build() {
             // return the convex subset directly if that was all we were given
             if (convexSubset != null) {
                 return convexSubset;
@@ -120,13 +99,13 @@ public abstract class GreatCircleSubset
         /** Internal method for adding hyperplane subsets to this builder.
          * @param sub the hyperplane subset to add; may be either convex or non-convex
          */
-        private void addInternal(final HyperplaneSubset<Point2S> sub) {
+        private void addInternal(final HyperplaneSubset<Vector3D> sub) {
             Objects.requireNonNull(sub, "Hyperplane subset must not be null");
 
-            if (sub instanceof GreatArc) {
-                addConvexSubset((GreatArc) sub);
-            } else if (sub instanceof EmbeddedTreeGreatCircleSubset) {
-                addTreeSubset((EmbeddedTreeGreatCircleSubset) sub);
+            if (sub instanceof PlaneConvexSubset) {
+                addConvexSubset((PlaneConvexSubset) sub);
+            } else if (sub instanceof EmbeddedTreePlaneSubset) {
+                addTreeSubset((EmbeddedTreePlaneSubset) sub);
             } else {
                 throw new IllegalArgumentException("Unsupported hyperplane subset type: " + sub.getClass().getName());
             }
@@ -135,8 +114,8 @@ public abstract class GreatCircleSubset
         /** Add a convex subset to the builder.
          * @param convex convex subset to add
          */
-        private void addConvexSubset(final GreatArc convex) {
-            GreatCircles.validateGreatCirclesEquivalent(circle, convex.getCircle());
+        private void addConvexSubset(final PlaneConvexSubset convex) {
+            Planes.validatePlanesEquivalent(plane, convex.getPlane());
 
             if (treeSubset == null && convexSubset == null) {
                 convexSubset = convex;
@@ -148,7 +127,7 @@ public abstract class GreatCircleSubset
         /** Add an embedded tree subset to the builder.
          * @param tree embedded tree subset to add
          */
-        private void addTreeSubset(final EmbeddedTreeGreatCircleSubset tree) {
+        private void addTreeSubset(final EmbeddedTreePlaneSubset tree) {
             // no need to validate the line here since the add() method does that for us
             getTreeSubset().add(tree);
         }
@@ -156,9 +135,9 @@ public abstract class GreatCircleSubset
         /** Get the tree subset for the builder, creating it if needed.
          * @return the tree subset for the builder
          */
-        private EmbeddedTreeGreatCircleSubset getTreeSubset() {
+        private EmbeddedTreePlaneSubset getTreeSubset() {
             if (treeSubset == null) {
-                treeSubset = new EmbeddedTreeGreatCircleSubset(circle);
+                treeSubset = new EmbeddedTreePlaneSubset(plane.getEmbedding());
 
                 if (convexSubset != null) {
                     treeSubset.add(convexSubset);
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/BoundarySource3D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/BoundarySource3D.java
index 791d7f4..7a4eb19 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/BoundarySource3D.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/BoundarySource3D.java
@@ -19,6 +19,7 @@ package org.apache.commons.geometry.euclidean.threed;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.List;
+import java.util.stream.Stream;
 
 import org.apache.commons.geometry.core.partitioning.BoundarySource;
 import org.apache.commons.geometry.euclidean.threed.line.LineConvexSubset3D;
@@ -41,6 +42,17 @@ public interface BoundarySource3D extends BoundarySource<PlaneConvexSubset>, Lin
         return tree;
     }
 
+    /** Return the boundaries of this instance as a stream of {@link Triangle3D}
+     * instances. An {@link IllegalStateException} exception is thrown while reading
+     * from the stream if any boundary cannot be converted to a triangle (i.e. if it
+     * has infinite size).
+     * @return a stream of triangles representing the instance boundaries
+     * @see org.apache.commons.geometry.euclidean.threed.PlaneSubset#toTriangles()
+     */
+    default Stream<Triangle3D> triangleStream() {
+        return boundaryStream().flatMap(b -> b.toTriangles().stream());
+    }
+
     /** {@inheritDoc} */
     @Override
     default List<LinecastPoint3D> linecast(final LineConvexSubset3D subset) {
@@ -53,6 +65,15 @@ public interface BoundarySource3D extends BoundarySource<PlaneConvexSubset>, Lin
         return new BoundarySourceLinecaster3D(this).linecastFirst(subset);
     }
 
+    /** Get a {@link Bounds3D} object defining the axis-aligned box containing all vertices
+     * in the boundaries for this instance. Null is returned if any boundaries are infinite
+     * or no vertices were found.
+     * @return the bounding box for this instance or null if no valid bounds could be determined
+     */
+    default Bounds3D getBounds() {
+        return new BoundarySourceBoundsBuilder3D().getBounds(this);
+    }
+
     /** Return a {@link BoundarySource3D} instance containing the given boundaries.
      * @param boundaries boundaries to include in the boundary source
      * @return a boundary source containing the given boundaries
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/BoundarySourceBoundsBuilder3D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/BoundarySourceBoundsBuilder3D.java
new file mode 100644
index 0000000..b0164dd
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/BoundarySourceBoundsBuilder3D.java
@@ -0,0 +1,57 @@
+/*
+ * 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.Iterator;
+import java.util.stream.Stream;
+
+
+/** Class used to construct {@link Bounds3D} instances representing the min and
+ * max points present in a {@link BoundarySource3D}. The implementation examines
+ * the vertices of each boundary in turn. Null is returned if any boundaries are
+ * infinite or no vertices are present.
+ */
+class BoundarySourceBoundsBuilder3D {
+
+    /** Get a {@link Bounds3D} instance containing all vertices in the given boundary source.
+     * Null is returned if any encountered boundaries were not finite or no vertices were found.
+     * @param src the boundary source to compute the bounds of
+     * @return the bounds of the argument or null if no valid bounds could be determined
+     */
+    public Bounds3D getBounds(final BoundarySource3D src) {
+        final Bounds3D.Builder builder = Bounds3D.builder();
+
+        try (Stream<PlaneConvexSubset> stream = src.boundaryStream()) {
+            final Iterator<PlaneConvexSubset> it = stream.iterator();
+
+            PlaneConvexSubset boundary;
+            while (it.hasNext()) {
+                boundary = it.next();
+
+                if (!boundary.isFinite()) {
+                    return null;
+                }
+
+                builder.addAll(boundary.getVertices());
+            }
+        }
+
+        return builder.containsBounds() ?
+                builder.build() :
+                null;
+    }
+}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/BoundarySourceLinecaster3D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/BoundarySourceLinecaster3D.java
index 56caf89..3129756 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/BoundarySourceLinecaster3D.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/BoundarySourceLinecaster3D.java
@@ -46,20 +46,22 @@ final class BoundarySourceLinecaster3D implements Linecastable3D {
     /** {@inheritDoc} */
     @Override
     public List<LinecastPoint3D> linecast(final LineConvexSubset3D subset) {
-        final List<LinecastPoint3D> results =  getIntersectionStream(subset)
-                .collect(Collectors.toCollection(ArrayList::new));
+        try (Stream<LinecastPoint3D> stream = getIntersectionStream(subset)) {
 
-        LinecastPoint3D.sortAndFilter(results);
+            final List<LinecastPoint3D> results = stream.collect(Collectors.toCollection(ArrayList::new));
+            LinecastPoint3D.sortAndFilter(results);
 
-        return results;
+            return results;
+        }
     }
 
     /** {@inheritDoc} */
     @Override
     public LinecastPoint3D linecastFirst(final LineConvexSubset3D subset) {
-        return getIntersectionStream(subset)
-                .min(LinecastPoint3D.ABSCISSA_ORDER)
-                .orElse(null);
+        try (Stream<LinecastPoint3D> stream = getIntersectionStream(subset)) {
+            return stream.min(LinecastPoint3D.ABSCISSA_ORDER)
+                    .orElse(null);
+        }
     }
 
     /** Return a stream containing intersections between the boundary source and the
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Bounds3D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Bounds3D.java
new file mode 100644
index 0000000..d6cae6e
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Bounds3D.java
@@ -0,0 +1,290 @@
+/*
+ * 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.Objects;
+
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.AbstractBounds;
+import org.apache.commons.geometry.euclidean.threed.shape.Parallelepiped;
+
+/** Class containing minimum and maximum points defining a 3D axis-aligned bounding box. Unless otherwise
+ * noted, floating point comparisons used in this class are strict, meaning that values are considered equal
+ * if and only if they match exactly.
+ *
+ * <p>Instances of this class are guaranteed to be immutable.</p>
+ */
+public final class Bounds3D extends AbstractBounds<Vector3D, Bounds3D> {
+
+    /** Simple constructor. Callers are responsible for ensuring the min is not greater than max.
+     * @param min minimum point
+     * @param max maximum point
+     */
+    private Bounds3D(final Vector3D min, final Vector3D max) {
+        super(min, max);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean hasSize(final DoublePrecisionContext precision) {
+        final Vector3D diag = getDiagonal();
+
+        return !precision.eqZero(diag.getX()) &&
+                !precision.eqZero(diag.getY()) &&
+                !precision.eqZero(diag.getZ());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean contains(final Vector3D pt) {
+        final double x = pt.getX();
+        final double y = pt.getY();
+        final double z = pt.getZ();
+
+        final Vector3D min = getMin();
+        final Vector3D max = getMax();
+
+        return x >= min.getX() && x <= max.getX() &&
+                y >= min.getY() && y <= max.getY() &&
+                z >= min.getZ() && z <= max.getZ();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean contains(final Vector3D pt, final DoublePrecisionContext precision) {
+        final double x = pt.getX();
+        final double y = pt.getY();
+        final double z = pt.getZ();
+
+        final Vector3D min = getMin();
+        final Vector3D max = getMax();
+
+        return precision.gte(x, min.getX()) && precision.lte(x, max.getX()) &&
+                precision.gte(y, min.getY()) && precision.lte(y, max.getY()) &&
+                precision.gte(z, min.getZ()) && precision.lte(z, max.getZ());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean intersects(final Bounds3D other) {
+        final Vector3D aMin = getMin();
+        final Vector3D aMax = getMax();
+
+        final Vector3D bMin = other.getMin();
+        final Vector3D bMax = other.getMax();
+
+        return aMin.getX() <= bMax.getX() && aMax.getX() >= bMin.getX() &&
+                aMin.getY() <= bMax.getY() && aMax.getY() >= bMin.getY() &&
+                aMin.getZ() <= bMax.getZ() && aMax.getZ() >= bMin.getZ();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Bounds3D intersection(final Bounds3D other) {
+        if (intersects(other)) {
+            final Vector3D aMin = getMin();
+            final Vector3D aMax = getMax();
+
+            final Vector3D bMin = other.getMin();
+            final Vector3D bMax = other.getMax();
+
+            // get the max of the mins and the mins of the maxes
+            final double minX = Math.max(aMin.getX(), bMin.getX());
+            final double minY = Math.max(aMin.getY(), bMin.getY());
+            final double minZ = Math.max(aMin.getZ(), bMin.getZ());
+
+            final double maxX = Math.min(aMax.getX(), bMax.getX());
+            final double maxY = Math.min(aMax.getY(), bMax.getY());
+            final double maxZ = Math.min(aMax.getZ(), bMax.getZ());
+
+            return new Bounds3D(
+                    Vector3D.of(minX, minY, minZ),
+                    Vector3D.of(maxX, maxY, maxZ));
+        }
+
+        return null; // no intersection
+    }
+
+    /** {@inheritDoc}
+     *
+     * @throws IllegalArgumentException if any dimension of the bounding box is zero
+     *      as evaluated by the given precision context
+     */
+    @Override
+    public Parallelepiped toRegion(final DoublePrecisionContext precision) {
+        return Parallelepiped.axisAligned(getMin(), getMax(), precision);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public int hashCode() {
+        return Objects.hash(getMin(), getMax());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean equals(final Object obj) {
+        if (obj == this) {
+            return true;
+        } else if (!(obj instanceof Bounds3D)) {
+            return false;
+        }
+
+        final Bounds3D other = (Bounds3D) obj;
+
+        return getMin().equals(other.getMin()) &&
+                getMax().equals(other.getMax());
+    }
+
+    /** Construct a new instance from the given points.
+     * @param first first point
+     * @param more additional points
+     * @return a new instance containing the min and max coordinates values from the input points
+     */
+    public static Bounds3D from(final Vector3D first, final Vector3D... more) {
+        final Builder builder = builder();
+
+        builder.add(first);
+        builder.addAll(Arrays.asList(more));
+
+        return builder.build();
+    }
+
+    /** Construct a new instance from the given points.
+     * @param points input points
+     * @return a new instance containing the min and max coordinates values from the input points
+     */
+    public static Bounds3D from(final Iterable<Vector3D> points) {
+        final Builder builder = builder();
+
+        builder.addAll(points);
+
+        return builder.build();
+    }
+
+    /** Construct a new {@link Builder} instance for creating bounds.
+     * @return a new builder instance for creating bounds
+     */
+    public static Builder builder() {
+        return new Builder();
+    }
+
+    /** Class used to construct {@link Bounds3D} instances.
+     */
+    public static final class Builder {
+
+        /** Minimum x coordinate. */
+        private double minX = Double.POSITIVE_INFINITY;
+
+        /** Minimum y coordinate. */
+        private double minY = Double.POSITIVE_INFINITY;
+
+        /** Minimum z coordinate. */
+        private double minZ = Double.POSITIVE_INFINITY;
+
+        /** Maximum x coordinate. */
+        private double maxX = Double.NEGATIVE_INFINITY;
+
+        /** Maximum y coordinate. */
+        private double maxY = Double.NEGATIVE_INFINITY;
+
+        /** Maximum z coordinate. */
+        private double maxZ = Double.NEGATIVE_INFINITY;
+
+        /** Private constructor; instantiate through factory method. */
+        private Builder() { }
+
+        /** Add a point to this instance.
+         * @param pt point to add
+         * @return this instance
+         */
+        public Builder add(final Vector3D pt) {
+            final double x = pt.getX();
+            final double y = pt.getY();
+            final double z = pt.getZ();
+
+            minX = Math.min(x, minX);
+            minY = Math.min(y, minY);
+            minZ = Math.min(z, minZ);
+
+            maxX = Math.max(x, maxX);
+            maxY = Math.max(y, maxY);
+            maxZ = Math.max(z, maxZ);
+
+            return this;
+        }
+
+        /** Add a collection of points to this instance.
+         * @param pts points to add
+         * @return this instance
+         */
+        public Builder addAll(final Iterable<Vector3D> pts) {
+            for (final Vector3D pt : pts) {
+                add(pt);
+            }
+
+            return this;
+        }
+
+        /** Add the min and max points from the given bounds to this instance.
+         * @param bounds bounds containing the min and max points to add
+         * @return this instance
+         */
+        public Builder add(final Bounds3D bounds) {
+            add(bounds.getMin());
+            add(bounds.getMax());
+
+            return this;
+        }
+
+        /** Return true if this builder contains valid min and max coordinate values.
+         * @return true if this builder contains valid min and max coordinate values
+         */
+        public boolean containsBounds() {
+            return Double.isFinite(minX) &&
+                    Double.isFinite(minY) &&
+                    Double.isFinite(minZ) &&
+                    Double.isFinite(maxX) &&
+                    Double.isFinite(maxY) &&
+                    Double.isFinite(maxZ);
+        }
+
+        /** Create a new {@link Bounds3D} instance from the values in this builder.
+         * The builder can continue to be used to create other instances.
+         * @return a new bounds instance
+         * @throws IllegalStateException if no points were given to the builder or any of the computed
+         *      min and max coordinate values are NaN or infinite
+         * @see #containsBounds()
+         */
+        public Bounds3D build() {
+            final Vector3D min = Vector3D.of(minX, minY, minZ);
+            final Vector3D max = Vector3D.of(maxX, maxY, maxZ);
+
+            if (!containsBounds()) {
+                if (Double.isInfinite(minX) && minX > 0 &&
+                        Double.isInfinite(maxX) && maxX < 0) {
+                    throw new IllegalStateException("Cannot construct bounds: no points given");
+                }
+
+                throw new IllegalStateException("Invalid bounds: min= " + min + ", max= " + max);
+            }
+
+            return new Bounds3D(min, max);
+        }
+    }
+}
diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/RegionEmbedding.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/ConvexPolygon3D.java
similarity index 53%
copy from commons-geometry-core/src/main/java/org/apache/commons/geometry/core/RegionEmbedding.java
copy to commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/ConvexPolygon3D.java
index b82b75a..3c7b365 100644
--- a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/RegionEmbedding.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/ConvexPolygon3D.java
@@ -14,28 +14,19 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.commons.geometry.core;
+package org.apache.commons.geometry.euclidean.threed;
 
-/** Interface representing a geometric element that embeds a region in a subspace.
- * @param <P> Point type defining the embedding space.
- * @param <S> Point type defining the embedded subspace.
- * @see Embedding
- * @see Region
+import org.apache.commons.geometry.core.Transform;
+
+/** Interface representing a closed, finite convex polygon in Euclidean 3D space.
  */
-public interface RegionEmbedding<P extends Point<P>, S extends Point<S>>
-    extends Embedding<P, S>, Sized {
+public interface ConvexPolygon3D extends PlaneConvexSubset {
 
-    /** Get the size of the instance, which by default is the size of the embedded
-     * subspace region.
-     * @return the size of instance
-     */
+    /** {@inheritDoc} */
     @Override
-    default double getSize() {
-        return getSubspaceRegion().getSize();
-    }
+    ConvexPolygon3D reverse();
 
-    /** Get the embedded subspace region.
-     * @return the embedded subspace region
-     */
-    Region<S> getSubspaceRegion();
+    /** {@inheritDoc} */
+    @Override
+    ConvexPolygon3D transform(Transform<Vector3D> transform);
 }
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
index 57949c3..fb9e940 100644
--- 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
@@ -26,7 +26,6 @@ import org.apache.commons.geometry.core.partitioning.AbstractConvexHyperplaneBou
 import org.apache.commons.geometry.core.partitioning.Hyperplane;
 import org.apache.commons.geometry.core.partitioning.HyperplaneConvexSubset;
 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 plane convex subsets.
@@ -65,14 +64,11 @@ public class ConvexVolume extends AbstractConvexHyperplaneBoundedRegion<Vector3D
                 return Double.POSITIVE_INFINITY;
             }
 
-            final Plane plane = boundary.getPlane();
-            final ConvexArea subarea = boundary.getSubspaceRegion();
+            final Plane boundaryPlane = boundary.getPlane();
+            final double boundaryArea = boundary.getSize();
+            final Vector3D boundaryBarycenter = boundary.getBarycenter();
 
-            final Vector3D facetBarycenter = boundary.getHyperplane().toSpace(
-                    subarea.getBarycenter());
-
-
-            volumeSum += subarea.getSize() * facetBarycenter.dot(plane.getNormal());
+            volumeSum += boundaryArea * boundaryBarycenter.dot(boundaryPlane.getNormal());
         }
 
         return volumeSum / 3.0;
@@ -92,19 +88,17 @@ public class ConvexVolume extends AbstractConvexHyperplaneBoundedRegion<Vector3D
                 return null;
             }
 
-            final Plane plane = boundary.getPlane();
-            final ConvexArea subarea = boundary.getSubspaceRegion();
-
-            final Vector3D facetBarycenter = boundary.getHyperplane().toSpace(
-                    subarea.getBarycenter());
+            final Plane boundaryPlane = boundary.getPlane();
+            final double boundaryArea = boundary.getSize();
+            final Vector3D boundaryBarycenter = boundary.getBarycenter();
 
-            final double scaledVolume = subarea.getSize() * facetBarycenter.dot(plane.getNormal());
+            final double scaledVolume = boundaryArea * boundaryBarycenter.dot(boundaryPlane.getNormal());
 
             volumeSum += scaledVolume;
 
-            sumX += scaledVolume * facetBarycenter.getX();
-            sumY += scaledVolume * facetBarycenter.getY();
-            sumZ += scaledVolume * facetBarycenter.getZ();
+            sumX += scaledVolume * boundaryBarycenter.getX();
+            sumY += scaledVolume * boundaryBarycenter.getY();
+            sumZ += scaledVolume * boundaryBarycenter.getZ();
         }
 
         if (volumeSum > 0) {
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/EmbeddedAreaPlaneConvexSubset.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/EmbeddedAreaPlaneConvexSubset.java
new file mode 100644
index 0000000..2a11ce2
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/EmbeddedAreaPlaneConvexSubset.java
@@ -0,0 +1,117 @@
+/*
+ * 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.Transform;
+import org.apache.commons.geometry.core.partitioning.Hyperplane;
+import org.apache.commons.geometry.core.partitioning.Split;
+import org.apache.commons.geometry.euclidean.twod.AffineTransformMatrix2D;
+import org.apache.commons.geometry.euclidean.twod.ConvexArea;
+import org.apache.commons.geometry.euclidean.twod.Vector2D;
+
+/** Internal implementation of {@link PlaneConvexSubset} that uses an embedded
+ * {@link ConvexArea} to represent the subspace region. This class is capable of
+ * representing regions of infinite size.
+ */
+final class EmbeddedAreaPlaneConvexSubset extends AbstractEmbeddedRegionPlaneSubset
+    implements PlaneConvexSubset, PlaneConvexSubset.Embedded {
+
+    /** 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
+     */
+    EmbeddedAreaPlaneConvexSubset(final EmbeddingPlane plane, final ConvexArea area) {
+        super(plane);
+
+        this.area = area;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public PlaneConvexSubset.Embedded getEmbedded() {
+        return this;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public ConvexArea getSubspaceRegion() {
+        return area;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public List<Vector3D> getVertices() {
+        return getPlane().toSpace(area.getVertices());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Bounds3D getBounds() {
+        return getBoundsFromSubspace(area);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public List<Triangle3D> toTriangles() {
+        if (isInfinite()) {
+            throw new IllegalStateException("Cannot convert infinite plane subset to triangles: " + this);
+        }
+
+        final EmbeddingPlane plane = getPlane();
+        final List<Vector3D> vertices = plane.toSpace(area.getVertices());
+
+        return Planes.convexPolygonToTriangleFan(plane, vertices);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public EmbeddedAreaPlaneConvexSubset transform(final Transform<Vector3D> transform) {
+        final EmbeddingPlane.SubspaceTransform st = getPlane().subspaceTransform(transform);
+        final ConvexArea tArea = area.transform(st.getTransform());
+
+        return new EmbeddedAreaPlaneConvexSubset(st.getPlane().getEmbedding(), tArea);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public EmbeddedAreaPlaneConvexSubset reverse() {
+        final EmbeddingPlane plane = getPlane();
+        final EmbeddingPlane 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 EmbeddedAreaPlaneConvexSubset(rPlane, area.transform(transform));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Split<PlaneConvexSubset> split(final Hyperplane<Vector3D> splitter) {
+        // delegate back to the Planes factory method so that it has a chance to decide
+        // on the best possible implementation for the given area
+        return Planes.subspaceSplit((Plane) splitter, this,
+            (p, r) -> Planes.subsetFromConvexArea(p, (ConvexArea) r));
+    }
+}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/EmbeddedTreePlaneSubset.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/EmbeddedTreePlaneSubset.java
index 7b15ef6..f1d27e1 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/EmbeddedTreePlaneSubset.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/EmbeddedTreePlaneSubset.java
@@ -22,41 +22,45 @@ import java.util.List;
 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.precision.DoublePrecisionContext;
 import org.apache.commons.geometry.euclidean.twod.ConvexArea;
 import org.apache.commons.geometry.euclidean.twod.RegionBSPTree2D;
+import org.apache.commons.geometry.euclidean.twod.Vector2D;
+import org.apache.commons.geometry.euclidean.twod.rotation.Rotation2D;
 
 /** Class representing an arbitrary subset of a plane using a {@link RegionBSPTree2D}.
  * This class can represent convex, non-convex, finite, infinite, and empty regions.
  *
  * <p>This class is mutable and <em>not</em> thread safe.</p>
  */
-public final class EmbeddedTreePlaneSubset extends PlaneSubset {
+public final class EmbeddedTreePlaneSubset extends AbstractEmbeddedRegionPlaneSubset {
+
     /** The 2D region representing the area on the plane. */
     private final RegionBSPTree2D region;
 
     /** Construct a new, empty plane subset for the given plane.
-     * @param plane plane defining the subset
+     * @param plane plane containing the subset
      */
-    public EmbeddedTreePlaneSubset(final Plane plane) {
+    public EmbeddedTreePlaneSubset(final EmbeddingPlane plane) {
         this(plane, false);
     }
 
     /** Construct a new subset for the given plane. If {@code full}
      * is true, then the subset will cover the entire plane; otherwise,
      * it will be empty.
-     * @param plane plane defining the subset
+     * @param plane plane containing the subset
      * @param full if true, the subset will cover the entire space;
      *      otherwise it will be empty
      */
-    public EmbeddedTreePlaneSubset(final Plane plane, boolean full) {
+    public EmbeddedTreePlaneSubset(final EmbeddingPlane plane, boolean full) {
         this(plane, new RegionBSPTree2D(full));
     }
 
     /** Construct a new instance from its defining plane and subspace region.
-     * @param plane plane defining the subset
+     * @param plane plane containing the subset
      * @param region subspace region for the plane subset
      */
-    public EmbeddedTreePlaneSubset(final Plane plane, final RegionBSPTree2D region) {
+    public EmbeddedTreePlaneSubset(final EmbeddingPlane plane, final RegionBSPTree2D region) {
         super(plane);
 
         this.region = region;
@@ -64,19 +68,56 @@ public final class EmbeddedTreePlaneSubset extends PlaneSubset {
 
     /** {@inheritDoc} */
     @Override
+    public PlaneSubset.Embedded getEmbedded() {
+        return this;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public RegionBSPTree2D getSubspaceRegion() {
+        return region;
+    }
+
+    /** {@inheritDoc} */
+    @Override
     public List<PlaneConvexSubset> toConvex() {
         final List<ConvexArea> areas = region.toConvex();
 
-        final Plane plane = getPlane();
         final List<PlaneConvexSubset> facets = new ArrayList<>(areas.size());
 
         for (final ConvexArea area : areas) {
-            facets.add(Planes.subsetFromConvexArea(plane, area));
+            facets.add(Planes.subsetFromConvexArea(getPlane(), area));
         }
 
         return facets;
     }
 
+    /** {@inheritDoc} */
+    @Override
+    public List<Triangle3D> toTriangles() {
+        final EmbeddingPlane plane = getPlane();
+        final List<Triangle3D> triangles = new ArrayList<>();
+
+        List<Vector3D> vertices;
+        for (final ConvexArea area : region.toConvex()) {
+            if (area.isInfinite()) {
+                throw new IllegalStateException("Cannot convert infinite plane subset to triangles: " + this);
+            }
+
+            vertices = plane.toSpace(area.getVertices());
+
+            triangles.addAll(Planes.convexPolygonToTriangleFan(plane, vertices));
+        }
+
+        return triangles;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Bounds3D getBounds() {
+        return getBoundsFromSubspace(region);
+    }
+
     /** {@inheritDoc}
      *
      * <p>In all cases, the current instance is not modified. However, In order to avoid
@@ -89,19 +130,15 @@ public final class EmbeddedTreePlaneSubset extends PlaneSubset {
      */
     @Override
     public Split<EmbeddedTreePlaneSubset> split(final Hyperplane<Vector3D> splitter) {
-        return splitInternal(splitter, this, (p, r) -> new EmbeddedTreePlaneSubset(p, (RegionBSPTree2D) r));
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public RegionBSPTree2D getSubspaceRegion() {
-        return region;
+        return Planes.subspaceSplit((Plane) splitter, this,
+            (p, r) -> new EmbeddedTreePlaneSubset(p, (RegionBSPTree2D) r));
     }
 
     /** {@inheritDoc} */
     @Override
     public EmbeddedTreePlaneSubset transform(final Transform<Vector3D> transform) {
-        final Plane.SubspaceTransform subTransform = getPlane().subspaceTransform(transform);
+        final EmbeddingPlane.SubspaceTransform subTransform =
+                getPlane().getEmbedding().subspaceTransform(transform);
 
         final RegionBSPTree2D tRegion = RegionBSPTree2D.empty();
         tRegion.copy(region);
@@ -118,7 +155,16 @@ public final class EmbeddedTreePlaneSubset extends PlaneSubset {
     public void add(final PlaneConvexSubset subset) {
         Planes.validatePlanesEquivalent(getPlane(), subset.getPlane());
 
-        region.add(subset.getSubspaceRegion());
+        final PlaneConvexSubset.Embedded embedded = subset.getEmbedded();
+        final Rotation2D rot = getEmbeddedRegionRotation(embedded);
+
+        final ConvexArea subspaceArea = embedded.getSubspaceRegion();
+
+        final ConvexArea toAdd = rot != null ?
+                subspaceArea.transform(rot) :
+                subspaceArea;
+
+        region.add(toAdd);
     }
 
     /** Add a plane subset to this instance.
@@ -129,6 +175,44 @@ public final class EmbeddedTreePlaneSubset extends PlaneSubset {
     public void add(final EmbeddedTreePlaneSubset subset) {
         Planes.validatePlanesEquivalent(getPlane(), subset.getPlane());
 
-        region.union(subset.getSubspaceRegion());
+        final RegionBSPTree2D otherTree = subset.getSubspaceRegion();
+        final Rotation2D rot = getEmbeddedRegionRotation(subset);
+
+        RegionBSPTree2D regionToAdd;
+        if (rot != null) {
+            // we need to transform the subspace region before adding
+            regionToAdd = otherTree.copy();
+            regionToAdd.transform(rot);
+        } else {
+            regionToAdd = otherTree;
+        }
+
+        region.union(regionToAdd);
+    }
+
+    /** Construct a rotation transform used to transform the subspace of the given embedded region plane
+     * subset into the subspace of this instance. Returns null if no transform is needed. This method must only
+     * be called with embedded regions that share an equivalent plane with this instance, meaning that the
+     * planes have the same origin point and normal
+     * @param embedded the embedded region plane subset to compare with the current instance
+     * @return a rotation transform to convert from the subspace of the argument into the current subspace; returns
+     *      null if no such transform is needed
+     */
+    private Rotation2D getEmbeddedRegionRotation(final PlaneSubset.Embedded embedded) {
+        // check if we need to apply a rotation to the given embedded subspace
+        final EmbeddingPlane thisPlane = getPlane();
+        final EmbeddingPlane otherPlane = embedded.getPlane();
+
+        final DoublePrecisionContext precision = thisPlane.getPrecision();
+
+        final double uDot = thisPlane.getU().dot(otherPlane.getU());
+        if (!precision.eq(uDot, 1.0)) {
+            final Vector2D otherPlaneU = thisPlane.toSubspace(otherPlane.getOrigin().add(otherPlane.getU()));
+            final double angle = Math.atan2(otherPlaneU.getY(), otherPlaneU.getX());
+
+            return Rotation2D.of(angle);
+        }
+
+        return null;
     }
 }
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/EmbeddingPlane.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/EmbeddingPlane.java
new file mode 100644
index 0000000..055c050
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/EmbeddingPlane.java
@@ -0,0 +1,333 @@
+/*
+ * 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.Transform;
+import org.apache.commons.geometry.core.partitioning.EmbeddingHyperplane;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+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;
+
+/** Extension of the {@link Plane} class that supports embedding of 2D subspaces in the plane.
+ * This is accomplished by defining two additional vectors, {@link #getU() u} and {@link #getV() v},
+ * that define the {@code x} and {@code y} axes respectively of the embedded subspace. For completeness,
+ * an additional vector {@link #getW()} is defined, which is simply an alias for the plane normal.
+ * Together, the vectors {@code u}, {@code v}, and {@code w} form a right-handed orthonormal basis.
+ *
+ * <p>The additional {@code u} and {@code v} vectors are not required to fulfill the contract of
+ * {@link org.apache.commons.geometry.core.partitioning.Hyperplane Hyperplane}. Therefore, they
+ * are not considered when using instances of this type purely as a hyperplane. For example, the
+ * {@link Plane#eq(Plane, DoublePrecisionContext) eq} and
+ * {@link Plane#similarOrientation(org.apache.commons.geometry.core.partitioning.Hyperplane) similiarOrientation}
+ * methods do not consider them.</p>
+ */
+public final class EmbeddingPlane extends Plane implements EmbeddingHyperplane<Vector3D, Vector2D> {
+    /** First normalized vector of the plane frame (in plane). */
+    private final Vector3D.Unit u;
+
+    /** Second normalized vector of the plane frame (in plane). */
+    private final Vector3D.Unit v;
+
+    /** Construct a new instance from an orthonormal set of basis vectors and an origin offset.
+     * @param u first vector of the basis (in plane)
+     * @param v second vector of the basis (in plane)
+     * @param w third vector of the basis (plane normal)
+     * @param originOffset offset of the origin with respect to the plane.
+     * @param precision precision context used for floating point comparisons
+     */
+    EmbeddingPlane(final Vector3D.Unit u, final Vector3D.Unit v, final Vector3D.Unit w, double originOffset,
+            final DoublePrecisionContext precision) {
+        super(w, originOffset, precision);
+
+        this.u = u;
+        this.v = v;
+    }
+
+    /** Get the plane first canonical vector.
+     * <p>
+     * The frame defined by ({@link #getU u}, {@link #getV v},
+     * {@link #getW w}) is a right-handed orthonormalized frame).
+     * </p>
+     * @return normalized first canonical vector
+     * @see #getV
+     * @see #getW
+     * @see #getNormal
+     */
+    public Vector3D.Unit getU() {
+        return u;
+    }
+
+    /** Get the plane second canonical vector.
+     * <p>
+     * The frame defined by ({@link #getU u}, {@link #getV v},
+     * {@link #getW w}) is a right-handed orthonormalized frame).
+     * </p>
+     * @return normalized second canonical vector
+     * @see #getU
+     * @see #getW
+     * @see #getNormal
+     */
+    public Vector3D.Unit getV() {
+        return v;
+    }
+
+    /** Get the plane third canonical vector, ie, the plane normal. This
+     * method is simply an alias for {@link #getNormal()}.
+     * <p>
+     * The frame defined by {@link #getU() u}, {@link #getV() v},
+     * {@link #getW() w} is a right-handed orthonormalized frame.
+     * </p>
+     * @return normalized normal vector
+     * @see #getU()
+     * @see #getV()
+     * @see #getNormal()
+     */
+    public Vector3D.Unit getW() {
+        return getNormal();
+    }
+
+    /** Return the current instance.
+     */
+    @Override
+    public EmbeddingPlane getEmbedding() {
+        return this;
+    }
+
+    /** Transform a 3D space point into an in-plane point.
+     * @param point point of the space
+     * @return in-plane point
+     * @see #toSpace
+     */
+    @Override
+    public Vector2D toSubspace(final Vector3D point) {
+        return Vector2D.of(point.dot(u), point.dot(v));
+    }
+
+    /** Transform an in-plane point into a 3D space point.
+     * @param point in-plane point
+     * @return 3D space point
+     * @see #toSubspace(Vector3D)
+     */
+    @Override
+    public Vector3D toSpace(final Vector2D point) {
+        return Vector3D.linearCombination(
+                point.getX(), u,
+                point.getY(), v,
+                -getOriginOffset(), getNormal());
+    }
+
+    /** Get one point from the 3D-space.
+     * @param inPlane desired in-plane coordinates for the point in the plane
+     * @param offset  desired offset for the point
+     * @return one point in the 3D-space, with given coordinates and offset relative
+     *         to the plane
+     */
+    public Vector3D pointAt(final Vector2D inPlane, final double offset) {
+        return Vector3D.linearCombination(
+                inPlane.getX(), u,
+                inPlane.getY(), v,
+                offset - getOriginOffset(), getNormal());
+    }
+
+    /** Build a new reversed version of this plane, with opposite orientation.
+     * <p>
+     * The new plane frame is chosen in such a way that a 3D point that had
+     * {@code (x, y)} in-plane coordinates and {@code z} offset with respect to the
+     * plane and is unaffected by the change will have {@code (y, x)} in-plane
+     * coordinates and {@code -z} offset with respect to the new plane. This means
+     * that the {@code u} and {@code v} vectors returned by the {@link #getU} and
+     * {@link #getV} methods are exchanged, and the {@code w} vector returned by the
+     * {@link #getNormal} method is reversed.
+     * </p>
+     * @return a new reversed plane
+     */
+    @Override
+    public EmbeddingPlane reverse() {
+        return new EmbeddingPlane(v, u, getNormal().negate(), -getOriginOffset(), getPrecision());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public EmbeddingPlane transform(final Transform<Vector3D> transform) {
+        final Vector3D origin = getOrigin();
+        final Vector3D plusU = origin.add(u);
+        final Vector3D plusV = origin.add(v);
+
+        final Vector3D tOrigin = transform.apply(origin);
+        final Vector3D tPlusU = transform.apply(plusU);
+        final Vector3D tPlusV = transform.apply(plusV);
+
+        final Vector3D.Unit tU = tOrigin.directionTo(tPlusU);
+        final Vector3D.Unit tV = tOrigin.directionTo(tPlusV);
+        final Vector3D.Unit tW = tU.cross(tV).normalize();
+
+        final double tOriginOffset = -tOrigin.dot(tW);
+
+        return new EmbeddingPlane(tU, tV, tW, tOriginOffset, getPrecision());
+    }
+
+    /** Translate the plane by the specified amount.
+     * @param translation translation to apply
+     * @return a new plane
+     */
+    @Override
+    public EmbeddingPlane translate(final Vector3D translation) {
+        final Vector3D tOrigin = getOrigin().add(translation);
+
+        return Planes.fromPointAndPlaneVectors(tOrigin, u, v, getPrecision());
+    }
+
+    /** Rotate the plane around the specified point.
+     * @param center rotation center
+     * @param rotation 3-dimensional rotation
+     * @return a new rotated plane
+     */
+    @Override
+    public EmbeddingPlane rotate(final Vector3D center, final QuaternionRotation rotation) {
+        final Vector3D delta = getOrigin().subtract(center);
+        final Vector3D tOrigin = center.add(rotation.apply(delta));
+        final Vector3D.Unit tU = rotation.apply(u).normalize();
+        final Vector3D.Unit tV = rotation.apply(v).normalize();
+
+        return Planes.fromPointAndPlaneVectors(tOrigin, tU, tV, getPrecision());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public int hashCode() {
+        return Objects.hash(getNormal(), getOriginOffset(), u, v, getPrecision());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj) {
+            return true;
+        } else if (obj == null || obj.getClass() != EmbeddingPlane.class) {
+            return false;
+        }
+
+        final EmbeddingPlane other = (EmbeddingPlane) obj;
+
+        return Objects.equals(this.getNormal(), other.getNormal()) &&
+                Double.compare(this.getOriginOffset(), other.getOriginOffset()) == 0 &&
+                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("[origin= ")
+            .append(getOrigin())
+            .append(", u= ")
+            .append(u)
+            .append(", v= ")
+            .append(v)
+            .append(", w= ")
+            .append(getNormal())
+            .append(']');
+
+        return sb.toString();
+    }
+
+    /** 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 tOrigin = transform.apply(origin);
+        final Vector3D tPlusU = transform.apply(origin.add(u));
+        final Vector3D tPlusV = transform.apply(origin.add(v));
+
+        final EmbeddingPlane tPlane = Planes.fromPointAndPlaneVectors(
+                tOrigin,
+                tOrigin.vectorTo(tPlusU),
+                tOrigin.vectorTo(tPlusV),
+                getPrecision());
+
+        final Vector2D tSubspaceOrigin = tPlane.toSubspace(tOrigin);
+        final Vector2D tSubspaceU = tSubspaceOrigin.vectorTo(tPlane.toSubspace(tPlusU));
+        final Vector2D tSubspaceV = tSubspaceOrigin.vectorTo(tPlane.toSubspace(tPlusV));
+
+        final AffineTransformMatrix2D subspaceTransform =
+                AffineTransformMatrix2D.fromColumnVectors(tSubspaceU, tSubspaceV, tSubspaceOrigin);
+
+        return new SubspaceTransform(tPlane, subspaceTransform);
+    }
+
+    /** 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 {
+        /** The transformed plane. */
+        private final EmbeddingPlane 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 EmbeddingPlane plane, final AffineTransformMatrix2D transform) {
+            this.plane = plane;
+            this.transform = transform;
+        }
+
+        /** Get the transformed plane instance.
+         * @return the transformed plane instance
+         */
+        public EmbeddingPlane 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/Plane.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Plane.java
index 4601680..ae08eb6 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
@@ -20,344 +20,219 @@ import java.util.Objects;
 
 import org.apache.commons.geometry.core.Transform;
 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.line.Line3D;
 import org.apache.commons.geometry.euclidean.threed.line.Lines3D;
 import org.apache.commons.geometry.euclidean.threed.rotation.QuaternionRotation;
-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 plane in 3 dimensional Euclidean space.
+/** Class representing a plane in 3 dimensional Euclidean space. Each plane is defined by a
+ * {@link #getNormal() normal} and an {@link #getOriginOffset() origin offset}. If \(\vec{n}\) is the plane normal,
+ * \(d\) is the origin offset, and \(p\) and \(q\) are any points in the plane, then the following are true:
+ * <ul>
+ *  <li>\(\lVert \vec{n} \rVert\) = 1</li>
+ *  <li>\(\vec{n} \cdot (p - q) = 0\)</li>
+ *  <li>\(d = - (\vec{n} \cdot q)\)</li>
+ *  </ul>
+ *  In other words, the normal is a unit vector such that the dot product of the normal and the difference of
+ *  any two points in the plane is always equal to \(0\). Similarly, the {@code origin offset} is equal to the
+ *  negation of the dot product of the normal and any point in the plane. The projection of the origin onto the
+ *  plane (given by {@link #getOrigin()}), is computed as \(-d \vec{n}\).
+ *
+ * <p>Instances of this class are guaranteed to be immutable.</p>
  * @see Planes
  */
-public final class Plane extends AbstractHyperplane<Vector3D>
-    implements EmbeddingHyperplane<Vector3D, Vector2D> {
-    /** First normalized vector of the plane frame (in plane). */
-    private final Vector3D u;
-
-    /** Second normalized vector of the plane frame (in plane). */
-    private final Vector3D v;
+public class Plane extends AbstractHyperplane<Vector3D> implements Hyperplane<Vector3D> {
 
-    /** Normalized plane normal. */
-    private final Vector3D w;
+    /** Plane normal. */
+    private final Vector3D.Unit normal;
 
     /** Offset of the origin with respect to the plane. */
     private final double originOffset;
 
-    /**
-     * Constructor to build a new plane with the given values.
-     * Made private to prevent inheritance.
-     * @param u u vector (on plane)
-     * @param v v vector (on plane)
-     * @param w unit normal vector
-     * @param originOffset offset of the origin with respect to the plane.
+    /** Construct a plane from its component parts.
+     * @param normal unit normal vector
+     * @param originOffset offset of the origin with respect to the plane
      * @param precision precision context used to compare floating point values
      */
-    Plane(final Vector3D u, final Vector3D v, final Vector3D w, double originOffset,
+    Plane(final Vector3D.Unit normal, double originOffset,
             final DoublePrecisionContext precision) {
 
         super(precision);
 
-        this.u = u;
-        this.v = v;
-        this.w = w;
-
+        this.normal = normal;
         this.originOffset = originOffset;
     }
 
-    /**
-     * Get the orthogonal projection of the 3D-space origin in the plane.
+    /** Get the orthogonal projection of the 3D-space origin in the plane.
      * @return the origin point of the plane frame (point closest to the 3D-space
      *         origin)
      */
     public Vector3D getOrigin() {
-        return w.multiply(-originOffset);
+        return normal.multiply(-originOffset);
     }
 
-    /**
-     *  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.
+    /** 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.
      */
     public double getOriginOffset() {
         return originOffset;
     }
 
-    /**
-     * Get the plane first canonical vector.
-     * <p>
-     * The frame defined by ({@link #getU getU}, {@link #getV getV},
-     * {@link #getNormal getNormal}) is a right-handed orthonormalized frame).
-     * </p>
-     *
-     * @return normalized first canonical vector
-     * @see #getV
-     * @see #getNormal
+    /** Get the plane normal vector.
+     * @return plane normal vector
      */
-    public Vector3D getU() {
-        return u;
+    public Vector3D.Unit getNormal() {
+        return normal;
     }
 
-    /**
-     * Get the plane second canonical vector.
-     * <p>
-     * The frame defined by ({@link #getU getU}, {@link #getV getV},
-     * {@link #getNormal getNormal}) is a right-handed orthonormalized frame).
-     * </p>
-     *
-     * @return normalized second canonical vector
-     * @see #getU
-     * @see #getNormal
+    /** Return an {@link EmbeddingPlane} instance suitable for embedding 2D geometric objects
+     * into this plane. Returned instances are guaranteed to be equal between invocations.
+     * @return a plane instance suitable for embedding 2D subspaces
      */
-    public Vector3D getV() {
-        return v;
-    }
+    public EmbeddingPlane getEmbedding() {
+        final Vector3D.Unit u = normal.orthogonal();
+        final Vector3D.Unit v = normal.cross(u).normalize();
 
-    /**
-     * Get the normalized normal vector.
-     * <p>
-     * 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 #getNormal()
-     */
-    public Vector3D getW() {
-        return w;
-    }
-
-    /**
-     * Get the normalized normal vector. This method is an alias
-     * for {@link #getW()}.
-     * <p>
-     * 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 #getW()
-     */
-    public Vector3D getNormal() {
-        return getW();
+        return new EmbeddingPlane(u, v, normal, originOffset, getPrecision());
     }
 
     /** {@inheritDoc} */
     @Override
-    public Vector3D project(final Vector3D point) {
-        return toSpace(toSubspace(point));
+    public double offset(final Vector3D point) {
+        return point.dot(normal) + originOffset;
     }
 
-    /**
-     * Project a 3D line onto the plane.
-     * @param line the line to project
-     * @return the projection of the given line onto the plane.
+    /** 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 Line3D project(final Line3D line) {
-        final Vector3D direction = line.getDirection();
-        final Vector3D projection = w.multiply(direction.dot(w) * (1 / w.normSq()));
-
-        final Vector3D projectedLineDirection = direction.subtract(projection);
-        final Vector3D p1 = project(line.getOrigin());
-        final Vector3D p2 = p1.add(projectedLineDirection);
+    public double offset(final Line3D line) {
+        if (!isParallel(line)) {
+            return 0.0;
+        }
+        return offset(line.getOrigin());
+    }
 
-        return Lines3D.fromPoints(p1, p2, getPrecision());
+    /** 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 offset(final Plane plane) {
+        if (!isParallel(plane)) {
+            return 0.0;
+        }
+        return originOffset + (similarOrientation(plane) ? -plane.originOffset : plane.originOffset);
     }
 
-    /**
-     * Build a new reversed version of this plane, with opposite orientation.
-     * <p>
-     * The new plane frame is chosen in such a way that a 3D point that had
-     * {@code (x, y)} in-plane coordinates and {@code z} offset with respect to the
-     * plane and is unaffected by the change will have {@code (y, x)} in-plane
-     * coordinates and {@code -z} offset with respect to the new plane. This means
-     * that the {@code u} and {@code v} vectors returned by the {@link #getU} and
-     * {@link #getV} methods are exchanged, and the {@code w} vector returned by the
-     * {@link #getNormal} method is reversed.
-     * </p>
-     * @return a new reversed plane
+    /** Check if the instance contains a point.
+     * @param p point to check
+     * @return true if p belongs to the plane
      */
     @Override
-    public Plane reverse() {
-        return new Plane(v, u, w.negate(), -originOffset, getPrecision());
+    public boolean contains(final Vector3D p) {
+        return getPrecision().eqZero(offset(p));
     }
 
-    /**
-     * Transform a 3D space point into an in-plane point.
-     *
-     * @param point point of the space (must be a {@link Vector3D} instance)
-     * @return in-plane point
-     * @see #toSpace
+    /** Check if the instance contains a line.
+     * @param line line to check
+     * @return true if line is contained in this plane
      */
-    @Override
-    public Vector2D toSubspace(final Vector3D point) {
-        return Vector2D.of(point.dot(u), point.dot(v));
+    public boolean contains(final Line3D line) {
+        return isParallel(line) && contains(line.getOrigin());
     }
 
-    /**
-     * Transform an in-plane point into a 3D space point.
-     *
-     * @param point in-plane point (must be a {@link Vector2D} instance)
-     * @return 3D space point
-     * @see #toSubspace(Vector3D)
+    /** Check if the instance contains another plane. Planes are considered similar if they contain
+     * the same points. This does not mean they are equal since they can have opposite normals.
+     * @param plane plane to which the instance is compared
+     * @return true if the planes are similar
      */
-    @Override
-    public Vector3D toSpace(final Vector2D point) {
-        return Vector3D.linearCombination(point.getX(), u, point.getY(), v, -originOffset, w);
+    public boolean contains(final Plane plane) {
+        final double angle = normal.angle(plane.normal);
+        final DoublePrecisionContext precision = getPrecision();
+
+        return ((precision.eqZero(angle)) && precision.eq(originOffset, plane.originOffset)) ||
+                ((precision.eq(angle, Math.PI)) && precision.eq(originOffset, -plane.originOffset));
     }
 
     /** {@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 Planes.fromPoints(p1, p2, p3, getPrecision());
+    public Vector3D project(final Vector3D point) {
+        return getOrigin().add(point.reject(normal));
     }
 
-    /** 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)
+    /** Project a 3D line onto the plane.
+     * @param line the line to project
+     * @return the projection of the given line onto the plane.
      */
-    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 = Planes.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));
+    public Line3D project(final Line3D line) {
+        final Vector3D direction = line.getDirection();
+        final Vector3D projection = normal.multiply(direction.dot(normal) * (1 / normal.normSq()));
 
-        final AffineTransformMatrix2D subspaceTransform =
-                AffineTransformMatrix2D.fromColumnVectors(tSubspaceU, tSubspaceV, tSubspaceOrigin);
+        final Vector3D projectedLineDirection = direction.subtract(projection);
+        final Vector3D p1 = project(line.getOrigin());
+        final Vector3D p2 = p1.add(projectedLineDirection);
 
-        return new SubspaceTransform(tPlane, subspaceTransform);
+        return Lines3D.fromPoints(p1, p2, getPrecision());
     }
 
-    /**
-     * 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());
+    /** {@inheritDoc} */
+    @Override
+    public PlaneConvexSubset span() {
+        return Planes.subsetFromConvexArea(getEmbedding(), ConvexArea.full());
     }
 
-    /**
-     * 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
+    /** 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 Plane translate(final Vector3D translation) {
-        final Vector3D p = getOrigin().add(translation);
-        final Vector3D normal = this.w;
-        final Vector3D wTmp = normal.normalize();
-        final double originOffsetTmp = -p.dot(wTmp);
+    public boolean isParallel(final Line3D line) {
+        final double dot = normal.dot(line.getDirection());
 
-        return new Plane(this.u, this.v, wTmp, originOffsetTmp, getPrecision());
+        return getPrecision().eqZero(dot);
     }
 
-    /**
-     * Get one point from the 3D-space.
-     *
-     * @param inPlane desired in-plane coordinates for the point in the plane
-     * @param offset  desired offset for the point
-     * @return one point in the 3D-space, with given coordinates and offset relative
-     *         to the plane
+    /** Check if the plane is parallel to the instance.
+     * @param plane plane to check.
+     * @return true if the plane is parallel to the instance, false otherwise.
      */
-    public Vector3D pointAt(final Vector2D inPlane, final double offset) {
-        return Vector3D.linearCombination(inPlane.getX(), u, inPlane.getY(), v, offset - originOffset, w);
+    public boolean isParallel(final Plane plane) {
+        return getPrecision().eqZero(normal.cross(plane.normal).norm());
     }
 
-    /**
-     * Check if the instance contains another plane.
-     * <p>
-     * Planes are considered similar if they contain the same points. This does not
-     * mean they are equal since they can have opposite normals.
-     * </p>
-     *
-     * @param plane plane to which the instance is compared
-     * @return true if the planes are similar
-     */
-    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));
+    /** {@inheritDoc} */
+    @Override
+    public boolean similarOrientation(final Hyperplane<Vector3D> other) {
+        return (((Plane) other).normal).dot(normal) > 0;
     }
 
-    /**
-     * Get the intersection of a line with the instance.
-     *
+    /** Get the intersection of a line with this plane.
      * @param line line intersecting the instance
      * @return intersection point between between the line and the instance (null if
      *         the line is parallel to the instance)
      */
     public Vector3D intersection(final Line3D line) {
         final Vector3D direction = line.getDirection();
-        final double dot = w.dot(direction);
+        final double dot = normal.dot(direction);
+
         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);
+
+        final Vector3D point = line.pointAt(0);
+        final double k = -(originOffset + normal.dot(point)) / dot;
+
+        return Vector3D.linearCombination(
+                1.0, point,
+                k, direction);
     }
 
-    /**
-     * Get the line formed by the intersection of this instance with the given 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
@@ -370,148 +245,113 @@ public final class Plane extends AbstractHyperplane<Vector3D>
      *      if no such line exists
      */
     public Line3D intersection(final Plane other) {
-        final Vector3D direction = w.cross(other.w);
+        final Vector3D direction = normal.cross(other.normal);
+
         if (getPrecision().eqZero(direction.norm())) {
             return null;
         }
+
         final Vector3D point = intersection(this, other, Planes.fromNormal(direction, getPrecision()));
+
         return Lines3D.fromPointAndDirection(point, direction, getPrecision());
     }
 
-    /**
-     * 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 the three planes or null if no unique intersection point exists
+    /** Build a new reversed version of this plane, with opposite orientation.
+     * @return a new reversed plane
      */
-    public static Vector3D intersection(final Plane plane1, final Plane plane2, final Plane plane3) {
-
-        // coefficients of the three planes linear equations
-        final double a1 = plane1.w.getX();
-        final double b1 = plane1.w.getY();
-        final double c1 = plane1.w.getZ();
-        final double d1 = plane1.originOffset;
-
-        final double a2 = plane2.w.getX();
-        final double b2 = plane2.w.getY();
-        final double c2 = plane2.w.getZ();
-        final double d2 = plane2.originOffset;
-
-        final double a3 = plane3.w.getX();
-        final double b3 = plane3.w.getY();
-        final double c3 = plane3.w.getZ();
-        final double d3 = plane3.originOffset;
-
-        // 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);
-
-        // 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);
-
-    }
-
-    /** {@inheritDoc} */
     @Override
-    public PlaneConvexSubset span() {
-        return Planes.subsetFromConvexArea(this, ConvexArea.full());
+    public Plane reverse() {
+        return new Plane(normal.negate(), -originOffset, getPrecision());
     }
 
-    /**
-     * Check if the instance contains a point.
+    /** {@inheritDoc}
      *
-     * @param p point to check
-     * @return true if p belongs to the plane
+     * <p>Instances are transformed by selecting 3 representative points from the
+     * plane, transforming them, and constructing a new plane from the transformed points.
+     * Since the normal is not transformed directly, but rather is constructed new from the
+     * transformed points, the relative orientations of points in the plane are preserved,
+     * even for transforms that do not
+     * {@link Transform#preservesOrientation() preserve orientation}. The example below shows
+     * a plane being transformed by a non-orientation-preserving transform. The normal of the
+     * transformed plane retains its counterclockwise relationship to the points in the plane,
+     * in contrast with the normal that is transformed directly by the transform.
+     * </p>
+     * <pre>
+     * // construct a plane from 3 points; the normal will be selected such that the
+     * // points are ordered counterclockwise when looking down the plane normal.
+     * Vector3D p1 = Vector3D.of(0, 0, 0);
+     * Vector3D p2 = Vector3D.of(+1, 0, 0);
+     * Vector3D p3 = Vector3D.of(0, +1, 0);
+     *
+     * Plane plane = Planes.fromPoints(p1, p2, p3, precision); // normal is (0, 0, +1)
+     *
+     * // create a transform that negates all x-values; this transform does not
+     * // preserve orientation, i.e. it will convert a right-handed system into a left-handed
+     * // system and vice versa
+     * AffineTransformMatrix3D transform = AffineTransformMatrix3D.createScale(-1, 1,  1);
+     *
+     * // transform the plane
+     * Plane transformedPlane = plane.transform(transform);
+     *
+     * // the plane normal is oriented such that transformed points are still ordered
+     * // counterclockwise when looking down the plane normal; since the point (1, 0, 0) has
+     * // now become (-1, 0, 0), the normal has flipped to (0, 0, -1)
+     * transformedPlane.getNormal();
+     *
+     * // directly transform the original plane normal; the normal is unchanged by the transform
+     * // since the target space of the transform is left-handed
+     * AffineTransformMatrix3D normalTransform = transform.normalTransform();
+     * Vector3D directlyTransformedNormal = normalTransform.apply(plane.getNormal()); // (0, 0, +1)
+     * </pre>
      */
     @Override
-    public boolean contains(final Vector3D p) {
-        return getPrecision().eqZero(offset(p));
-    }
+    public Plane transform(final Transform<Vector3D> transform) {
+        // create 3 representation points lying on the plane, transform them,
+        // and use the transformed points to create a new plane
 
-    /**
-     * 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 Line3D line) {
-        return isParallel(line) && contains(line.getOrigin());
-    }
+        final Vector3D u = normal.orthogonal();
+        final Vector3D v = normal.cross(u);
 
-    /** 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 Line3D line) {
-        final double dot = w.dot(line.getDirection());
+        final Vector3D p1 = getOrigin();
+        final Vector3D p2 = p1.add(u);
+        final Vector3D p3 = p1.add(v);
 
-        return getPrecision().eqZero(dot);
-    }
+        final Vector3D t1 = transform.apply(p1);
+        final Vector3D t2 = transform.apply(p2);
+        final Vector3D t3 = transform.apply(p3);
 
-    /** Check, if the plane is parallel to the instance.
-     * @param plane plane to check.
-     * @return true if the plane is parallel to the instance, false otherwise.
-     */
-    public boolean isParallel(final Plane plane) {
-        return getPrecision().eqZero(w.cross(plane.w).norm());
+        return Planes.fromPoints(t1, t2, t3, getPrecision());
     }
 
-    /**
-     * 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.
+    /** Translate the plane by the specified amount.
+     * @param translation translation to apply
+     * @return a new plane
      */
-    public double offset(final Plane plane) {
-        if (!isParallel(plane)) {
-            return 0.0;
-        }
-        return originOffset + (similarOrientation(plane) ? -plane.originOffset : plane.originOffset);
+    public Plane translate(final Vector3D translation) {
+        final Vector3D tOrigin = getOrigin().add(translation);
+
+        return Planes.fromPointAndNormal(tOrigin, normal, getPrecision());
     }
 
-    /**
-     * 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.
+    /** Rotate the plane around the specified point.
+     * @param center rotation center
+     * @param rotation 3-dimensional rotation
+     * @return a new plane
      */
-    public double offset(final Line3D line) {
-        if (!isParallel(line)) {
-            return 0.0;
-        }
-        return offset(line.getOrigin());
-    }
+    public Plane rotate(final Vector3D center, final QuaternionRotation rotation) {
+        final Vector3D delta = getOrigin().subtract(center);
+        final Vector3D tOrigin = center.add(rotation.apply(delta));
 
-    /** {@inheritDoc} */
-    @Override
-    public double offset(final Vector3D point) {
-        return point.dot(w) + originOffset;
-    }
+        // we can directly apply the rotation to the normal since it will transform
+        // it properly (there is no translation or scaling involved)
+        final Vector3D.Unit tNormal = rotation.apply(normal).normalize();
 
-    /** {@inheritDoc} */
-    @Override
-    public boolean similarOrientation(final Hyperplane<Vector3D> other) {
-        return (((Plane) other).w).dot(w) > 0;
+        return Planes.fromPointAndNormal(tOrigin, tNormal, getPrecision());
     }
 
-
     /** Return true if this instance should be considered equivalent to the argument, using the
-     * given precision context for comparison. Instances are considered equivalent if they
-     * have equivalent {@code origin} points and {@code u} and {@code v} vectors.
+     * given precision context for comparison. Instances are considered equivalent if they contain
+     * the same points, which is determined by comparing the plane {@code origins} and {@code normals}.
      * @param other the point to compare with
      * @param precision precision context to use for the comparison
      * @return true if this instance should be considered equivalent to the argument
@@ -519,14 +359,13 @@ public final class Plane extends AbstractHyperplane<Vector3D>
      */
     public boolean eq(final Plane other, final DoublePrecisionContext precision) {
         return getOrigin().eq(other.getOrigin(), precision) &&
-                u.eq(other.u, precision) &&
-                v.eq(other.v, precision);
+                normal.eq(other.normal, precision);
     }
 
     /** {@inheritDoc} */
     @Override
     public int hashCode() {
-        return Objects.hash(u, v, w, originOffset, getPrecision());
+        return Objects.hash(normal, originOffset, getPrecision());
     }
 
     /** {@inheritDoc} */
@@ -534,15 +373,13 @@ public final class Plane extends AbstractHyperplane<Vector3D>
     public boolean equals(Object obj) {
         if (this == obj) {
             return true;
-        } else if (!(obj instanceof Plane)) {
+        } else if (obj == null || obj.getClass() != this.getClass()) {
             return false;
         }
 
         final Plane other = (Plane) obj;
 
-        return Objects.equals(this.u, other.u) &&
-                Objects.equals(this.v, other.v) &&
-                Objects.equals(this.w, other.w) &&
+        return Objects.equals(this.normal, other.normal) &&
                 Double.compare(this.originOffset, other.originOffset) == 0 &&
                 Objects.equals(this.getPrecision(), other.getPrecision());
     }
@@ -554,49 +391,53 @@ public final class Plane extends AbstractHyperplane<Vector3D>
         sb.append(getClass().getSimpleName())
             .append("[origin= ")
             .append(getOrigin())
-            .append(", u= ")
-            .append(u)
-            .append(", v= ")
-            .append(v)
-            .append(", w= ")
-            .append(w)
+            .append(", normal= ")
+            .append(normal)
             .append(']');
 
         return sb.toString();
     }
 
-    /** Class containing a transformed plane instance along with a subspace (2D) transform. The subspace
-     * transform produces the equivalent of the 3D transform in 2D.
+    /** 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 the three planes or null if no unique intersection point exists
      */
-    public static final class SubspaceTransform {
-        /** 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;
-        }
+    public static Vector3D intersection(final Plane plane1, final Plane plane2, final Plane plane3) {
 
-        /** Get the transformed plane instance.
-         * @return the transformed plane instance
-         */
-        public Plane getPlane() {
-            return plane;
-        }
+        // coefficients of the three planes linear equations
+        final double a1 = plane1.normal.getX();
+        final double b1 = plane1.normal.getY();
+        final double c1 = plane1.normal.getZ();
+        final double d1 = plane1.originOffset;
+
+        final double a2 = plane2.normal.getX();
+        final double b2 = plane2.normal.getY();
+        final double c2 = plane2.normal.getZ();
+        final double d2 = plane2.originOffset;
 
-        /** 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;
+        final double a3 = plane3.normal.getX();
+        final double b3 = plane3.normal.getY();
+        final double c3 = plane3.normal.getZ();
+        final double d3 = plane3.originOffset;
+
+        // 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);
+
+        // 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);
     }
 }
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/PlaneConvexSubset.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/PlaneConvexSubset.java
index 82573a5..26e9169 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/PlaneConvexSubset.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/PlaneConvexSubset.java
@@ -23,106 +23,60 @@ import org.apache.commons.geometry.core.Transform;
 import org.apache.commons.geometry.core.partitioning.Hyperplane;
 import org.apache.commons.geometry.core.partitioning.HyperplaneConvexSubset;
 import org.apache.commons.geometry.core.partitioning.Split;
-import org.apache.commons.geometry.euclidean.threed.Plane.SubspaceTransform;
-import org.apache.commons.geometry.euclidean.threed.line.Line3D;
-import org.apache.commons.geometry.euclidean.threed.line.LineConvexSubset3D;
-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 subset of points in a plane. The subset may be finite
- * or infinite.
- * @see Planes
+/** Interface representing a finite or infinite convex subset of points in a plane in Euclidean 3D
+ * space.
  */
-public final class PlaneConvexSubset extends PlaneSubset
-    implements HyperplaneConvexSubset<Vector3D>  {
-    /** 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
-     */
-    PlaneConvexSubset(final Plane plane, final ConvexArea area) {
-        super(plane);
-
-        this.area = area;
-    }
+public interface PlaneConvexSubset extends PlaneSubset, HyperplaneConvexSubset<Vector3D> {
 
     /** {@inheritDoc} */
     @Override
-    public List<PlaneConvexSubset> toConvex() {
-        return Collections.singletonList(this);
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    public PlaneConvexSubset 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 PlaneConvexSubset(rPlane, area.transform(transform));
-    }
+    PlaneConvexSubset reverse();
 
     /** {@inheritDoc} */
     @Override
-    public PlaneConvexSubset transform(final Transform<Vector3D> transform) {
-        final SubspaceTransform st = getPlane().subspaceTransform(transform);
-        final ConvexArea tArea = area.transform(st.getTransform());
-
-        return Planes.subsetFromConvexArea(st.getPlane(), tArea);
-    }
+    PlaneConvexSubset transform(Transform<Vector3D> transform);
 
     /** {@inheritDoc} */
     @Override
-    public ConvexArea getSubspaceRegion() {
-        return area;
-    }
+    Split<PlaneConvexSubset> split(Hyperplane<Vector3D> splitter);
 
     /** {@inheritDoc} */
     @Override
-    public Split<PlaneConvexSubset> split(final Hyperplane<Vector3D> splitter) {
-        return splitInternal(splitter, this, (p, r) -> new PlaneConvexSubset(p, (ConvexArea) r));
-    }
-
-    /** Get the unique intersection of this plane subset with the given line. Null is
-     * returned if no unique intersection point exists (ie, the line and plane are
-     * parallel or coincident) or the line does not intersect the plane subset.
-     * @param line line to intersect with this plane subset
-     * @return the unique intersection point between the line and this plane subset
-     *      or null if no such point exists.
-     * @see Plane#intersection(Line3D)
+    PlaneConvexSubset.Embedded getEmbedded();
+
+    /** Get the vertices for the convex subset in a counter-clockwise order as viewed looking down the plane
+     * normal. Each vertex in the returned list is unique. If the boundary of the subset is closed, the start
+     * vertex is <em>not</em> repeated at the end of the list.
+     *
+     * <p>It is important to note that, in general, the list of vertices returned by this method
+     * is not sufficient to completely characterize the subset. For example, a simple triangle
+     * has 3 vertices, but an infinite area constructed from two parallel lines and two lines that
+     * intersect between them will also have 3 vertices. It is also possible for non-empty subsets to
+     * contain no vertices at all. For example, a subset with no boundaries (representing the full
+     * plane), a subset with a single boundary (ie, a half-plane), or a subset with two parallel boundaries will
+     * not contain any vertices.</p>
+     * @return the list of vertices for the plane convex subset in a counter-clockwise order as viewed looking
+     *      down the plane normal
      */
-    public Vector3D intersection(final Line3D line) {
-        final Vector3D pt = getPlane().intersection(line);
-        return (pt != null && contains(pt)) ? pt : null;
-    }
+    List<Vector3D> getVertices();
 
-    /** Get the unique intersection of this plane subset with the given line subset. Null
-     * is returned if the underlying line and plane do not have a unique intersection
-     * point (ie, they are parallel or coincident) or the intersection point is unique
-     * but is not contained in both the line subset and plane subset.
-     * @param lineSubset line subset to intersect with
-     * @return the unique intersection point between this plane subset and the argument or
-     *      null if no such point exists.
-     * @see Plane#intersection(Line3D)
+    /** {@inheritDoc}
+     *
+     * <p>This method simply returns a singleton list containing this object.</p>
      */
-    public Vector3D intersection(final LineConvexSubset3D lineSubset) {
-        final Vector3D pt = intersection(lineSubset.getLine());
-        return (pt != null && lineSubset.contains(pt)) ? pt : null;
+    @Override
+    default List<PlaneConvexSubset> toConvex() {
+        return Collections.singletonList(this);
     }
 
-    /** Get the vertices for the plane subset. The vertices lie at the intersections of the
-     * 2D area bounding lines.
-     * @return the vertices for the plane subset
+    /** Interface used to represent plane convex subsets as embedded 2D subspace regions.
      */
-    public List<Vector3D> getVertices() {
-        return getPlane().toSpace(area.getVertices());
+    interface Embedded extends PlaneSubset.Embedded {
+
+        /** {@inheritDoc} */
+        @Override
+        ConvexArea getSubspaceRegion();
     }
 }
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/PlaneSubset.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/PlaneSubset.java
index 945f87d..0583cf0 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/PlaneSubset.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/PlaneSubset.java
@@ -17,236 +17,87 @@
 package org.apache.commons.geometry.euclidean.threed;
 
 import java.util.List;
-import java.util.Objects;
-import java.util.function.BiFunction;
 
-import org.apache.commons.geometry.core.partitioning.AbstractRegionEmbeddingHyperplaneSubset;
-import org.apache.commons.geometry.core.partitioning.Hyperplane;
+import org.apache.commons.geometry.core.RegionEmbedding;
 import org.apache.commons.geometry.core.partitioning.HyperplaneBoundedRegion;
-import org.apache.commons.geometry.core.partitioning.HyperplaneConvexSubset;
 import org.apache.commons.geometry.core.partitioning.HyperplaneSubset;
-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.line.Line3D;
-import org.apache.commons.geometry.euclidean.twod.Line;
-import org.apache.commons.geometry.euclidean.twod.Lines;
+import org.apache.commons.geometry.euclidean.threed.line.LineConvexSubset3D;
 import org.apache.commons.geometry.euclidean.twod.Vector2D;
 
-/** Class representing a subset of points in a 3D Euclidean space. For example, triangles
- * and other polygons in 3D are plane subsets. Instances may be finite or infinite.
+/** Interface representing a subset of points in a plane in Euclidean 3D space. Instances
+ * may represent finite, infinite, convex, non-convex, and/or disjoint regions of the plane.
  */
-public abstract class PlaneSubset
-    extends AbstractRegionEmbeddingHyperplaneSubset<Vector3D, Vector2D, Plane> {
-    /** The plane defining this instance. */
-    private final Plane plane;
+public interface PlaneSubset extends HyperplaneSubset<Vector3D> {
 
-    /** Construct a new instance based on the given plane.
-     * @param plane the plane defining the subset
-     */
-    PlaneSubset(final Plane plane) {
-        this.plane = plane;
-    }
-
-    /** Get the plane that this subset lies on. This method is an alias
-     * for {@link #getHyperplane()}.
-     * @return the plane that this subset lies on
+    /** Get the plane containing this subset. This is equivalent to {@link #getHyperplane()}.
+     * @return the plane containing this subset
      * @see #getHyperplane()
      */
-    public Plane getPlane() {
-        return getHyperplane();
-    }
+    Plane getPlane();
 
     /** {@inheritDoc} */
     @Override
-    public Plane getHyperplane() {
-        return plane;
-    }
+    Plane getHyperplane();
 
     /** {@inheritDoc} */
     @Override
-    public abstract List<PlaneConvexSubset> toConvex();
-
-    /** {@inheritDoc} */
-    @Override
-    public HyperplaneSubset.Builder<Vector3D> builder() {
-        return new Builder(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.
+    List<PlaneConvexSubset> toConvex();
+
+    /** Return a list of triangles representing the same subset region as this instance. An
+     * {@link IllegalStateException} is thrown if the subset has infinite size and therefore
+     * cannot be converted to triangles. If the subset has zero size (is empty), an empty list is
+     * returned.
+     * @return a list of triangles representing the same subset region as this instance
+     * @throws IllegalStateException if the subset has infinite size and therefore cannot
+     *      be converted to triangles
      */
-    public DoublePrecisionContext getPrecision() {
-        return plane.getPrecision();
-    }
+    List<Triangle3D> toTriangles();
 
-    /** {@inheritDoc} */
-    @Override
-    public String toString() {
-        final StringBuilder sb = new StringBuilder();
-        sb.append(getClass().getSimpleName())
-            .append("[plane= ")
-            .append(getPlane())
-            .append(", subspaceRegion= ")
-            .append(getSubspaceRegion())
-            .append(']');
-
-
-        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 hyperplane subset instances
-     * @param <T> Plane subset implementation type
-     * @return the result of the split operation
+    /** Get a {@link Bounds3D} object defining an axis-aligned bounding box containing all
+     * vertices for this subset. Null is returned if the subset is infinite or does not
+     * contain any vertices.
+     * @return the bounding box for this instance or null if no valid bounds could be determined
      */
-    protected <T extends PlaneSubset> 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 = Lines.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);
-        }
-    }
+    Bounds3D getBounds();
 
-    /** Internal implementation of the {@link HyperplaneSubset.Builder} interface. In cases where only a single
-     * convex subset is given to the builder, this class returns the convex subset instance directly. In all other
-     * cases, an {@link EmbeddedTreePlaneSubset} is used to construct the final subset.
+    /** Return an object containing the plane subset as an embedded 2D subspace region.
+     * @return an object containing the plane subset as an embedded 2D subspace region
      */
-    private static final class Builder implements HyperplaneSubset.Builder<Vector3D> {
-        /** Plane that a subset is being constructed for. */
-        private final Plane plane;
-
-        /** Embedded tree subset. */
-        private EmbeddedTreePlaneSubset treeSubset;
+    PlaneSubset.Embedded getEmbedded();
+
+    /** Get the unique intersection of this plane subset with the given line. Null is
+     * returned if no unique intersection point exists (ie, the line and plane are
+     * parallel or coincident) or the line does not intersect the plane subset.
+     * @param line line to intersect with this plane subset
+     * @return the unique intersection point between the line and this plane subset
+     *      or null if no such point exists.
+     * @see Plane#intersection(Line3D)
+     */
+    Vector3D intersection(Line3D line);
+
+    /** Get the unique intersection of this plane subset with the given line subset. Null
+     * is returned if the underlying line and plane do not have a unique intersection
+     * point (ie, they are parallel or coincident) or the intersection point is unique
+     * but is not contained in both the line subset and plane subset.
+     * @param lineSubset line subset to intersect with
+     * @return the unique intersection point between this plane subset and the argument or
+     *      null if no such point exists.
+     * @see Plane#intersection(Line3D)
+     */
+    Vector3D intersection(LineConvexSubset3D lineSubset);
 
-        /** Convex subset added as the first subset to the builder. This is returned directly if
-         * no other subsets are added.
-         */
-        private PlaneConvexSubset convexSubset;
+    /** Interface used to represent plane subsets as embedded 2D subspace regions.
+     */
+    interface Embedded extends RegionEmbedding<Vector3D, Vector2D> {
 
-        /** Create a new subset builder for the given plane.
-         * @param plane plane to build a subset for
+        /** Get the plane embedding the subspace region.
+         * @return the plane embedding the subspace region
          */
-        Builder(final Plane plane) {
-            this.plane = plane;
-        }
+        EmbeddingPlane getPlane();
 
         /** {@inheritDoc} */
         @Override
-        public void add(final HyperplaneSubset<Vector3D> sub) {
-            addInternal(sub);
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public void add(final HyperplaneConvexSubset<Vector3D> sub) {
-            addInternal(sub);
-        }
-
-        /** {@inheritDoc} */
-        @Override
-        public PlaneSubset build() {
-            // return the convex subset directly if that was all we were given
-            if (convexSubset != null) {
-                return convexSubset;
-            }
-            return getTreeSubset();
-        }
-
-        /** Internal method for adding hyperplane subsets to this builder.
-         * @param sub the hyperplane subset to add; may be either convex or non-convex
-         */
-        private void addInternal(final HyperplaneSubset<Vector3D> sub) {
-            Objects.requireNonNull(sub, "Hyperplane subset must not be null");
-
-            if (sub instanceof PlaneConvexSubset) {
-                addConvexSubset((PlaneConvexSubset) sub);
-            } else if (sub instanceof EmbeddedTreePlaneSubset) {
-                addTreeSubset((EmbeddedTreePlaneSubset) sub);
-            } else {
-                throw new IllegalArgumentException("Unsupported hyperplane subset type: " + sub.getClass().getName());
-            }
-        }
-
-        /** Add a convex subset to the builder.
-         * @param convex convex subset to add
-         */
-        private void addConvexSubset(final PlaneConvexSubset convex) {
-            Planes.validatePlanesEquivalent(plane, convex.getPlane());
-
-            if (treeSubset == null && convexSubset == null) {
-                convexSubset = convex;
-            } else {
-                getTreeSubset().add(convex);
-            }
-        }
-
-        /** Add an embedded tree subset to the builder.
-         * @param tree embedded tree subset to add
-         */
-        private void addTreeSubset(final EmbeddedTreePlaneSubset tree) {
-            // no need to validate the line here since the add() method does that for us
-            getTreeSubset().add(tree);
-        }
-
-        /** Get the tree subset for the builder, creating it if needed.
-         * @return the tree subset for the builder
-         */
-        private EmbeddedTreePlaneSubset getTreeSubset() {
-            if (treeSubset == null) {
-                treeSubset = new EmbeddedTreePlaneSubset(plane);
-
-                if (convexSubset != null) {
-                    treeSubset.add(convexSubset);
-
-                    convexSubset = null;
-                }
-            }
-
-            return treeSubset;
-        }
+        HyperplaneBoundedRegion<Vector2D> getSubspaceRegion();
     }
 }
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Planes.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Planes.java
index fcba291..9da6697 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Planes.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Planes.java
@@ -16,13 +16,23 @@
  */
 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.Iterator;
 import java.util.List;
+import java.util.function.BiFunction;
 
+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.line.Line3D;
+import org.apache.commons.geometry.euclidean.threed.line.LineConvexSubset3D;
 import org.apache.commons.geometry.euclidean.twod.ConvexArea;
+import org.apache.commons.geometry.euclidean.twod.Line;
+import org.apache.commons.geometry.euclidean.twod.Lines;
 import org.apache.commons.geometry.euclidean.twod.Vector2D;
 
 /** Class containing factory methods for constructing {@link Plane} and {@link PlaneSubset} instances.
@@ -33,8 +43,7 @@ public final class Planes {
     private Planes() {
     }
 
-    /**
-     * Build a plane from a point and two (on plane) vectors.
+    /** 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)
@@ -42,18 +51,17 @@ public final class Planes {
      * @return a new plane
      * @throws IllegalArgumentException if the norm of the given values is zero, NaN, or infinite.
      */
-    public static Plane fromPointAndPlaneVectors(final Vector3D p, final Vector3D u, final Vector3D v,
+    public static EmbeddingPlane 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 Vector3D.Unit uNorm = u.normalize();
+        final Vector3D.Unit vNorm = uNorm.orthogonal(v);
+        final Vector3D.Unit wNorm = uNorm.cross(vNorm).normalize();
         final double originOffset = -p.dot(wNorm);
 
-        return new Plane(uNorm, vNorm, wNorm, originOffset, precision);
+        return new EmbeddingPlane(uNorm, vNorm, wNorm, originOffset, precision);
     }
 
-    /**
-     * Build a plane from a normal.
+    /** 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
@@ -64,8 +72,7 @@ public final class Planes {
         return fromPointAndNormal(Vector3D.ZERO, normal, precision);
     }
 
-    /**
-     * Build a plane from a point and a normal.
+    /** Build a plane from a point and a normal.
      *
      * @param p point belonging to the plane
      * @param normal normal direction to the plane
@@ -75,17 +82,13 @@ public final class Planes {
      */
     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.Unit unitNormal = normal.normalize();
+        final double originOffset = -p.dot(unitNormal);
 
-        final Vector3D u = w.orthogonal();
-        final Vector3D v = w.cross(u);
-
-        return new Plane(u, v, w, originOffset, precision);
+        return new Plane(unitNormal, originOffset, precision);
     }
 
-    /**
-     * Build a plane from three points.
+    /** Build a plane from three points.
      * <p>
      * The plane is oriented in the direction of {@code (p2-p1) ^ (p3-p1)}
      * </p>
@@ -116,161 +119,607 @@ public final class Planes {
      *      points do not define a unique plane
      */
     public static Plane fromPoints(final Collection<Vector3D> pts, final DoublePrecisionContext precision) {
+        return new PlaneBuilder(pts, precision).build();
+    }
 
-        if (pts.size() < 3) {
-            throw new IllegalArgumentException("At least 3 points are required to define a plane; " +
-                    "argument contains only " + pts.size() + ".");
+    /** Create a new plane subset 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 PlaneConvexSubset subsetFromConvexArea(final EmbeddingPlane plane, final ConvexArea area) {
+        if (area.isFinite()) {
+            // prefer a vertex-based representation for finite areas
+            final List<Vector3D> vertices = plane.toSpace(area.getVertices());
+
+            return fromConvexPlanarVertices(plane, vertices);
         }
 
-        final Iterator<Vector3D> it = pts.iterator();
+        return new EmbeddedAreaPlaneConvexSubset(plane, area);
+    }
 
-        final Vector3D startPt = it.next();
+    /** Create a new convex polygon from the given sequence of vertices. The vertices must define a unique
+     * plane, meaning that at least 3 unique vertices must be given. The given sequence is assumed to be closed,
+     * ie that an edge exists between the last vertex and the first.
+     * @param pts collection of points defining the convex polygon
+     * @param precision precision context used to compare floating point values
+     * @return a new convex polygon defined by the given sequence of vertices
+     * @throws IllegalArgumentException if fewer than 3 vertices are given or the vertices do not define a
+     *       unique plane
+     * @see #fromPoints(Collection, DoublePrecisionContext)
+     */
+    public static ConvexPolygon3D convexPolygonFromVertices(final Collection<Vector3D> pts,
+            final DoublePrecisionContext precision) {
+        final List<Vector3D> vertices = new ArrayList<>(pts.size());
+        final Plane plane = new PlaneBuilder(pts, precision).buildForConvexPolygon(vertices);
+
+        // make sure that the first point is not repeated at the end
+        final Vector3D firstPt = vertices.get(0);
+        final Vector3D lastPt = vertices.get(vertices.size() - 1);
+        if (firstPt.eq(lastPt, precision)) {
+            vertices.remove(vertices.size() - 1);
+        }
 
-        Vector3D u = null;
-        Vector3D w = null;
+        if (vertices.size() == 3) {
+            return new SimpleTriangle3D(plane, vertices.get(0), vertices.get(1), vertices.get(2));
+        }
+        return new VertexListConvexPolygon3D(plane, vertices);
+    }
 
-        Vector3D currentPt;
-        Vector3D prevPt = startPt;
+    /** Construct a triangle from three vertices. The triangle plane is oriented such that the points
+     * are arranged in a counter-clockwise order when looking down the plane normal.
+     * @param p1 first vertex
+     * @param p2 second vertex
+     * @param p3 third vertex
+     * @param precision precision context used for floating point comparisons
+     * @return a triangle constructed from the three vertices
+     * @throws IllegalArgumentException if the points do not define a unique plane
+     */
+    public static Triangle3D triangleFromVertices(final Vector3D p1, final Vector3D p2, final Vector3D p3,
+            final DoublePrecisionContext precision) {
+        final Plane plane = fromPoints(p1, p2, p3, precision);
+        return new SimpleTriangle3D(plane, p1, p2, p3);
+    }
 
-        Vector3D currentVector = null;
-        Vector3D prevVector = null;
+    /** Construct a list of {@link Triangle3D} instances from a set of vertices and arrays of face indices.
+     * For example, the following code constructs a list of triangles forming a square pyramid.
+     * <pre>
+     * DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-10);
+     *
+     * Vector3D[] vertices = {
+     *      Vector3D.ZERO,
+     *      Vector3D.of(1, 0, 0),
+     *      Vector3D.of(1, 1, 0),
+     *      Vector3D.of(0, 1, 0),
+     *      Vector3D.of(0.5, 0.5, 4)
+     * };
+     *
+     * int[][] faceIndices = {
+     *      {0, 2, 1},
+     *      {0, 3, 2},
+     *      {0, 1, 4},
+     *      {1, 2, 4},
+     *      {2, 3, 4},
+     *      {3, 0, 4}
+     * };
+     *
+     * List&lt;Triangle3D&gt; triangles = Planes.indexedTriangles(vertices, faceIndices, TEST_PRECISION);
+     * </pre>
+     * @param vertices vertices available for use in triangle construction
+     * @param faceIndices array of indices for each triangular face; each entry in the array is an array of
+     *      3 index values into {@code vertices}, defining the 3 vertices that will be used to construct the
+     *      triangle
+     * @param precision precision context used for floating point comparisons
+     * @return a list of triangles constructed from the set of vertices and face indices
+     * @throws IllegalArgumentException if any face index array does not contain exactly 3 elements or a set
+     *      of 3 vertices do not define a plane
+     * @throws IndexOutOfBoundsException if any index into {@code vertices} is out of bounds
+     */
+    public static List<Triangle3D> indexedTriangles(final Vector3D[] vertices, final int[][] faceIndices,
+            final DoublePrecisionContext precision) {
+        return indexedTriangles(Arrays.asList(vertices), faceIndices, precision);
+    }
 
-        Vector3D cross = null;
-        double crossNorm;
-        double crossSumX = 0.0;
-        double crossSumY = 0.0;
-        double crossSumZ = 0.0;
+    /** Construct a list of {@link Triangle3D} instances from a set of vertices and arrays of face indices.
+     * @param vertices vertices available for use in triangle construction
+     * @param faceIndices array of indices for each triangular face; each entry in the array is an array of
+     *      3 index values into {@code vertices}, defining the 3 vertices that will be used to construct the
+     *      triangle
+     * @param precision precision context used for floating point comparisons
+     * @return a list of triangles constructed from the set of vertices and face indices
+     * @throws IllegalArgumentException if any face index array does not contain exactly 3 elements or a set
+     *      of 3 vertices do not define a plane
+     * @throws IndexOutOfBoundsException if any index into {@code vertices} is out of bounds
+     * @see #indexedTriangles(Vector3D[], int[][], DoublePrecisionContext)
+     */
+    public static List<Triangle3D> indexedTriangles(final List<Vector3D> vertices, final int[][] faceIndices,
+            final DoublePrecisionContext precision) {
 
-        boolean nonPlanar = false;
+        final int numFaces = faceIndices.length;
+        final List<Triangle3D> triangles = new ArrayList<>(numFaces);
 
-        while (it.hasNext()) {
-            currentPt = it.next();
+        int[] face;
+        for (int i = 0; i < numFaces; ++i) {
+            face = faceIndices[i];
+            if (face.length != 3) {
+                throw new IllegalArgumentException(MessageFormat.format(
+                        "Invalid number of vertex indices for face at index {0}: expected 3 but found {1}",
+                        i, face.length));
+            }
 
-            if (!currentPt.eq(prevPt, precision)) {
-                currentVector = startPt.vectorTo(currentPt);
+            triangles.add(triangleFromVertices(
+                        vertices.get(face[0]),
+                        vertices.get(face[1]),
+                        vertices.get(face[2]),
+                        precision
+                    ));
+        }
 
-                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;
-                        }
-                    }
-                }
+        return triangles;
+    }
 
-                prevVector = currentVector;
-                prevPt = currentPt;
+    /** Construct a list of {@link ConvexPolygon3D} instances from a set of vertices and arrays of face indices. Each
+     * face must contain at least 3 vertices but the number of vertices per face does not need to be constant.
+     * For example, the following code constructs a list of convex polygons forming a square pyramid.
+     * Note that the first face (the pyramid base) uses a different number of vertices than the other faces.
+     * <pre>
+     * DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-10);
+     *
+     * Vector3D[] vertices = {
+     *      Vector3D.ZERO,
+     *      Vector3D.of(1, 0, 0),
+     *      Vector3D.of(1, 1, 0),
+     *      Vector3D.of(0, 1, 0),
+     *      Vector3D.of(0.5, 0.5, 4)
+     * };
+     *
+     * int[][] faceIndices = {
+     *      {0, 3, 2, 1}, // square base
+     *      {0, 1, 4},
+     *      {1, 2, 4},
+     *      {2, 3, 4},
+     *      {3, 0, 4}
+     * };
+     *
+     * List&lt;ConvexPolygon3D&gt; polygons = Planes.indexedConvexPolygons(vertices, faceIndices, precision);
+     * </pre>
+     * @param vertices vertices available for use in convex polygon construction
+     * @param faceIndices array of indices for each triangular face; each entry in the array is an array of
+     *      at least 3 index values into {@code vertices}, defining the vertices that will be used to construct the
+     *      convex polygon
+     * @param precision precision context used for floating point comparisons
+     * @return a list of convex polygons constructed from the set of vertices and face indices
+     * @throws IllegalArgumentException if any face index array does not contain at least 3 elements or a set
+     *      of vertices do not define a planar convex polygon
+     * @throws IndexOutOfBoundsException if any index into {@code vertices} is out of bounds
+     */
+    public static List<ConvexPolygon3D> indexedConvexPolygons(final Vector3D[] vertices, final int[][] faceIndices,
+            final DoublePrecisionContext precision) {
+        return indexedConvexPolygons(Arrays.asList(vertices), faceIndices, precision);
+    }
+
+    /** Construct a list of {@link ConvexPolygon3D} instances from a set of vertices and arrays of face indices. Each
+     * face must contain at least 3 vertices but the number of vertices per face does not need to be constant.
+     * @param vertices vertices available for use in convex polygon construction
+     * @param faceIndices array of indices for each triangular face; each entry in the array is an array of
+     *      at least 3 index values into {@code vertices}, defining the vertices that will be used to construct the
+     *      convex polygon
+     * @param precision precision context used for floating point comparisons
+     * @return a list of convex polygons constructed from the set of vertices and face indices
+     * @throws IllegalArgumentException if any face index array does not contain at least 3 elements or a set
+     *      of vertices do not define a planar convex polygon
+     * @throws IndexOutOfBoundsException if any index into {@code vertices} is out of bounds
+     * @see #indexedConvexPolygons(Vector3D[], int[][], DoublePrecisionContext)
+     */
+    public static List<ConvexPolygon3D> indexedConvexPolygons(final List<Vector3D> vertices, final int[][] faceIndices,
+            final DoublePrecisionContext precision) {
+        final int numFaces = faceIndices.length;
+        final List<ConvexPolygon3D> polygons = new ArrayList<>(numFaces);
+        final List<Vector3D> faceVertices = new ArrayList<>();
+
+        int[] face;
+        for (int i = 0; i < numFaces; ++i) {
+            face = faceIndices[i];
+            if (face.length < 3) {
+                throw new IllegalArgumentException(MessageFormat.format(
+                        "Invalid number of vertex indices for face at index {0}: required at least 3 but found {1}",
+                        i, face.length));
+            }
+
+            for (int j = 0; j < face.length; ++j) {
+                faceVertices.add(vertices.get(face[j]));
             }
-        }
 
-        if (u == null || w == null || nonPlanar) {
-            throw new IllegalArgumentException("Points do not define a plane: " + pts);
+            polygons.add(convexPolygonFromVertices(
+                        faceVertices,
+                        precision
+                    ));
+
+            faceVertices.clear();
         }
 
-        if (w.dot(Vector3D.of(crossSumX, crossSumY, crossSumZ)) < 0) {
-            w = w.negate();
+        return polygons;
+    }
+
+    /** Get the unique intersection of the plane subset with the given line. Null is
+     * returned if no unique intersection point exists (ie, the line and plane are
+     * parallel or coincident) or the line does not intersect the plane subset.
+     * @param planeSubset plane subset to intersect with
+     * @param line line to intersect with this plane subset
+     * @return the unique intersection point between the line and this plane subset
+     *      or null if no such point exists.
+     */
+    static Vector3D intersection(final PlaneSubset planeSubset, final Line3D line) {
+        final Vector3D pt = planeSubset.getPlane().intersection(line);
+        return (pt != null && planeSubset.contains(pt)) ? pt : null;
+    }
+
+    /** Get the unique intersection of the plane subset with the given line subset. Null
+     * is returned if the underlying line and plane do not have a unique intersection
+     * point (ie, they are parallel or coincident) or the intersection point is unique
+     * but is not contained in both the line subset and plane subset.
+     * @param planeSubset plane subset to intersect with
+     * @param lineSubset line subset to intersect with
+     * @return the unique intersection point between this plane subset and the argument or
+     *      null if no such point exists.
+     */
+    static Vector3D intersection(final PlaneSubset planeSubset, final LineConvexSubset3D lineSubset) {
+        final Vector3D pt = intersection(planeSubset, lineSubset.getLine());
+        return (pt != null && lineSubset.contains(pt)) ? pt : null;
+    }
+
+    /** Validate that the actual plane contains the same points as the expected plane, throwing an exception if not.
+     * The subspace orientations of embedding planes are not considered.
+     * @param expected the expected plane
+     * @param actual the actual plane
+     * @throws IllegalArgumentException if the actual plane is not equivalent to the expected plane
+     */
+    static void validatePlanesEquivalent(final Plane expected, final Plane actual) {
+        if (!expected.eq(actual, expected.getPrecision())) {
+            throw new IllegalArgumentException("Arguments do not represent the same plane. Expected " +
+                    expected + " but was " + actual + ".");
         }
+    }
+
+    /** Generic split method that uses performs the split using the subspace region of the plane subset.
+     * @param splitter splitting hyperplane
+     * @param subset the plane subset being split
+     * @param factory function used to create new plane subset instances
+     * @param <T> Plane subset implementation type
+     * @return the result of the split operation
+     */
+    static <T extends PlaneSubset> Split<T> subspaceSplit(final Plane splitter, final T subset,
+            final BiFunction<EmbeddingPlane, HyperplaneBoundedRegion<Vector2D>, T> factory) {
+
+        final EmbeddingPlane thisPlane = subset.getPlane().getEmbedding();
+
+        final Line3D intersection = thisPlane.intersection(splitter);
+        if (intersection == null) {
+            return getNonIntersectingSplitResult(splitter, subset);
+        } else {
+            final EmbeddingPlane embeddingPlane = subset.getPlane().getEmbedding();
+
+            // the lines intersect; split the subregion
+            final Vector3D intersectionOrigin = intersection.getOrigin();
+            final Vector2D subspaceP1 = embeddingPlane.toSubspace(intersectionOrigin);
+            final Vector2D subspaceP2 = embeddingPlane.toSubspace(intersectionOrigin.add(intersection.getDirection()));
+
+            final Line subspaceSplitter = Lines.fromPoints(subspaceP1, subspaceP2, thisPlane.getPrecision());
+
+            final Split<? extends HyperplaneBoundedRegion<Vector2D>> split =
+                    subset.getEmbedded().getSubspaceRegion().split(subspaceSplitter);
+            final SplitLocation subspaceSplitLoc = split.getLocation();
+
+            if (SplitLocation.MINUS == subspaceSplitLoc) {
+                return new Split<>(subset, null);
+            } else if (SplitLocation.PLUS == subspaceSplitLoc) {
+                return new Split<>(null, subset);
+            }
 
-        final Vector3D v = w.cross(u);
-        final double originOffset = -startPt.dot(w);
+            final T minus = (split.getMinus() != null) ? factory.apply(thisPlane, split.getMinus()) : null;
+            final T plus = (split.getPlus() != null) ? factory.apply(thisPlane, split.getPlus()) : null;
 
-        return new Plane(u, v, w, originOffset, precision);
+            return new Split<>(minus, plus);
+        }
     }
 
-    /** Create a new plane subset 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
+    /** Get a split result for cases where the splitting plane and the plane containing the subset being split
+     * do not intersect. Callers are responsible for ensuring that the planes involved do not actually intersect.
+     * @param <T> Plane subset implementation type
+     * @param splitter plane performing the splitting
+     * @param subset subset being split
+     * @return the split result for the non-intersecting split
      */
-    public static PlaneConvexSubset subsetFromConvexArea(final Plane plane, final ConvexArea area) {
-        return new PlaneConvexSubset(plane, area);
+    private static <T extends PlaneSubset> Split<T> getNonIntersectingSplitResult(
+            final Plane splitter, final T subset) {
+        final Plane plane = subset.getPlane();
+
+        final double offset = splitter.offset(plane);
+        final int comp = plane.getPrecision().compare(offset, 0.0);
+
+        if (comp < 0) {
+            return new Split<>(subset, null);
+        } else if (comp > 0) {
+            return new Split<>(null, subset);
+        } else {
+            return new Split<>(null, null);
+        }
     }
 
-    /** Create a new plane subset 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 #subsetFromVertices(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 plane subset
-     * @param precision precision context used to compare floating point values
-     * @return a new plane subset defined by the given sequence of vertices
-     * @throws IllegalArgumentException if fewer than 3 vertices are given or the vertices do not define a
-     *       unique plane
-     * @see #subsetFromVertices(Collection, boolean, DoublePrecisionContext)
-     * @see #fromPoints(Collection, DoublePrecisionContext)
+    /** Construct a convex polygon 3D from a plane and a list of vertices lying in the plane. Callers are
+     * responsible for ensuring that the vertices lie in the plane and define a convex polygon.
+     * @param plane the plane containing the convex polygon
+     * @param vertices vertices defining the closed, convex polygon. The must must contain at least 3 unique
+     *      vertices and should not include the start vertex at the end of the list.
+     * @return a new convex polygon instance
+     * @throws IllegalArgumentException if the size of {@code vertices} if less than 3
      */
-    public static PlaneConvexSubset subsetFromVertexLoop(final Collection<Vector3D> pts,
-            final DoublePrecisionContext precision) {
-        return subsetFromVertices(pts, true, precision);
+    static ConvexPolygon3D fromConvexPlanarVertices(final Plane plane, final List<Vector3D> vertices) {
+        final int size = vertices.size();
+
+        if (size == 3) {
+            return new SimpleTriangle3D(plane, vertices.get(0), vertices.get(1), vertices.get(2));
+        }
+
+        return new VertexListConvexPolygon3D(plane, vertices);
     }
 
-    /** Create a new plane subset 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 plane subset
-     * @param precision precision context used to compare floating point values
-     * @return a new plane subset defined by the given sequence of vertices
-     * @throws IllegalArgumentException if fewer than 3 vertices are given or the vertices do not define a
-     *      unique plane
-     * @see #subsetFromVertexLoop(Collection, DoublePrecisionContext)
-     * @see #subsetFromVertices(Collection, boolean, DoublePrecisionContext)
-     * @see #fromPoints(Collection, DoublePrecisionContext)
+    /** Convert a convex polygon defined by a plane and list of points into a triangle fan.
+     * @param plane plane containing the convex polygon
+     * @param vertices vertices defining the convex polygon
+     * @return a triangle fan representing the same area as the convex polygon
+     * @throws IllegalArgumentException if fewer than 3 vertices are given
      */
-    public static PlaneConvexSubset subsetFromVertices(final Collection<Vector3D> pts,
-            final DoublePrecisionContext precision) {
-        return subsetFromVertices(pts, false, precision);
+    static List<Triangle3D> convexPolygonToTriangleFan(final Plane plane, final List<Vector3D> vertices) {
+        final int size = vertices.size();
+        if (size < 3) {
+            throw new IllegalArgumentException("Cannot create triangle fan: 3 or more vertices are required " +
+                    "but found only " + vertices.size());
+        }
+
+        final List<Triangle3D> triangles = new ArrayList<>(size - 2);
+
+        int fanIdx = findBestTriangleFanIndex(vertices);
+        int vertexIdx = (fanIdx + 1) % size;
+
+        Vector3D fanBase = vertices.get(fanIdx);
+        Vector3D vertexA = vertices.get(vertexIdx);
+        Vector3D vertexB;
+
+        vertexIdx = (vertexIdx + 1) % size;
+        while (vertexIdx != fanIdx) {
+            vertexB = vertices.get(vertexIdx);
+
+            // add directly as a triangle instance to avoid computation of the plane again
+            triangles.add(new SimpleTriangle3D(plane, fanBase, vertexA, vertexB));
+
+            vertexA = vertexB;
+            vertexIdx = (vertexIdx + 1) % size;
+        }
+
+        return triangles;
     }
 
-    /** Create a new plane subset 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 plane subset instance
-     * @throws IllegalArgumentException if fewer than 3 vertices are given or the vertices do not define a
-     *      unique plane
-     * @see #subsetFromVertexLoop(Collection, DoublePrecisionContext)
-     * @see #subsetFromVertices(Collection, boolean, DoublePrecisionContext)
-     * @see #fromPoints(Collection, DoublePrecisionContext)
+    /** Find the index of the best vertex to use as the base for a triangle fan split of the convex polygon
+     * defined by the given vertices. The best vertex is the one that forms the largest interior angle in the
+     * polygon since a split at that point will help prevent the creation of very thin triangles.
+     * @param vertices vertices defining the convex polygon; must not be empty
+     * @return the index of the best vertex to use as the base for a triangle fan split of the convex polygon
      */
-    public static PlaneConvexSubset subsetFromVertices(final Collection<Vector3D> pts, final boolean close,
-            final DoublePrecisionContext precision) {
+    private static int findBestTriangleFanIndex(final List<Vector3D> vertices) {
+        final Iterator<Vector3D> it = vertices.iterator();
+
+        Vector3D curPt = it.next();
+        Vector3D nextPt;
 
-        final Plane plane = Planes.fromPoints(pts, precision);
+        Vector3D lastVec = vertices.get(vertices.size() - 1).directionTo(curPt);
+        Vector3D incomingVec = lastVec;
+        Vector3D outgoingVec;
 
-        final List<Vector2D> subspacePts = plane.toSubspace(pts);
-        final ConvexArea area = ConvexArea.fromVertices(subspacePts, close, precision);
+        int bestIdx = 0;
+        double bestDot = -1.0;
 
-        return new PlaneConvexSubset(plane, area);
+        int idx = 0;
+        double dot;
+        while (it.hasNext()) {
+            nextPt = it.next();
+            outgoingVec = curPt.directionTo(nextPt);
+
+            dot = incomingVec.dot(outgoingVec);
+            if (dot > bestDot) {
+                bestIdx = idx;
+                bestDot = dot;
+            }
+
+            curPt = nextPt;
+            incomingVec = outgoingVec;
+
+            ++idx;
+        }
+
+        // handle the last vertex on its own
+        dot = incomingVec.dot(lastVec);
+        if (dot > bestDot) {
+            bestIdx = idx;
+        }
+
+        return bestIdx;
     }
 
-    /** Validate that the actual plane is equivalent to the expected plane, throwing an exception if not.
-     * @param expected the expected plane
-     * @param actual the actual plane
-     * @throws IllegalArgumentException if the actual plane is not equivalent to the expected plane
+    /** Internal helper class used to construct planes from sequences of points. Instances can be also be
+     * configured to collect lists of unique points found during plane construction and validate that the
+     * defined region is convex.
      */
-    static void validatePlanesEquivalent(final Plane expected, final Plane actual) {
-        if (!expected.eq(actual, expected.getPrecision())) {
-            throw new IllegalArgumentException("Arguments do not represent the same plane. Expected " +
-                    expected + " but was " + actual + ".");
+    private static final class PlaneBuilder {
+
+        /** The point sequence to build a plane for. */
+        private final Collection<Vector3D> pts;
+
+        /** Precision context used for floating point comparisons. */
+        private final DoublePrecisionContext precision;
+
+        /** The start point from the point sequence. */
+        private Vector3D startPt;
+
+        /** The previous point from the point sequence. */
+        private Vector3D prevPt;
+
+        /** The previous vector from the point sequence, preceeding from the {@code startPt} to {@code prevPt}. */
+        private Vector3D prevVector;
+
+        /** The computed {@code normal} vector for the plane. */
+        private Vector3D.Unit normal;
+
+        /** The x component of the sum of all cross products from adjacent vectors in the point sequence. */
+        private double crossSumX;
+
+        /** The y component of the sum of all cross products from adjacent vectors in the point sequence. */
+        private double crossSumY;
+
+        /** The z component of the sum of all cross products from adjacent vectors in the point sequence. */
+        private double crossSumZ;
+
+        /** If true, an exception will be thrown if the point sequence is discovered to be non-convex. */
+        private boolean requireConvex = false;
+
+        /** List that unique vertices discovered in the input sequence will be added to. */
+        private List<Vector3D> uniqueVertexOutput;
+
+        /** Construct a new build instance for the given point sequence and precision context.
+         * @param pts point sequence
+         * @param precision precision context used to perform floating point comparisons
+         */
+        PlaneBuilder(final Collection<Vector3D> pts, final DoublePrecisionContext precision) {
+            this.pts = pts;
+            this.precision = precision;
+        }
+
+        /** Build a plane from the configured point sequence.
+         * @return a plane built from the configured point sequence
+         * @throw IllegalArgumentException if the points do not define a plane
+         */
+        Plane build() {
+            if (pts.size() < 3) {
+                throw nonPlanar();
+            }
+
+            pts.forEach(this::processPoint);
+
+            return createPlane();
+        }
+
+        /** Build a plane from the configured point sequence, validating that the points form a convex region
+         * and adding all discovered unique points to the given list.
+         * @param vertexOutput list that unique points discovered in the point sequence will be added to
+         * @return a plane created from the configured point sequence
+         * @throw IllegalArgumentException if the points do not define a plane or the {@code requireConvex}
+         *      flag is true and the points do not define a convex area
+         */
+        Plane buildForConvexPolygon(final List<Vector3D> vertexOutput) {
+            this.requireConvex = true;
+            this.uniqueVertexOutput = vertexOutput;
+
+            Plane plane = build();
+
+            return plane;
+        }
+
+        /** Process a point from the point sequence.
+         * @param pt
+         * @throw IllegalArgumentException if the points do not define a plane or the {@code requireConvex}
+         *      flag is true and the points do not define a convex area
+         */
+        private void processPoint(final Vector3D pt) {
+            if (prevPt == null) {
+                startPt = pt;
+                prevPt = pt;
+
+                if (uniqueVertexOutput != null) {
+                    uniqueVertexOutput.add(pt);
+                }
+
+            } else if (!prevPt.eq(pt, precision)) { // skip duplicate points
+                final Vector3D vec = startPt.vectorTo(pt);
+
+                if (prevVector != null) {
+                    processCrossProduct(prevVector.cross(vec));
+                }
+
+                if (uniqueVertexOutput != null) {
+                    uniqueVertexOutput.add(pt);
+                }
+
+                prevPt = pt;
+                prevVector = vec;
+            }
+        }
+
+        /** Process the computed cross product of two vectors from the input point sequence. The vectors
+         * start at the first point in the sequence and point to adjacent points later in the sequence.
+         * @param cross the cross product of two vectors from the input point sequence
+         * @throw IllegalArgumentException if the points do not define a plane or the {@code requireConvex}
+         *      flag is true and the points do not define a convex area
+         */
+        private void processCrossProduct(final Vector3D cross) {
+            crossSumX += cross.getX();
+            crossSumY += cross.getY();
+            crossSumZ += cross.getZ();
+
+            final double crossNorm = cross.norm();
+
+            if (!precision.eqZero(crossNorm)) {
+                // the cross product has non-zero magnitude
+                if (normal == null) {
+                    // save the first non-zero cross product as our normal
+                    normal = cross.normalize();
+                } else {
+                    final double crossDot = normal.dot(cross) / crossNorm;
+
+                    // check non-planar before non-convex since the former is a more general type
+                    // of issue
+                    if (!precision.eq(1.0, Math.abs(crossDot))) {
+                        throw nonPlanar();
+                    } else if (requireConvex && crossDot < 0) {
+                        throw nonConvex();
+                    }
+                }
+            }
+        }
+
+        /** Construct the plane instance using the value gathered during point processing.
+         * @return the created plane instance
+         * @throw IllegalArgumentException if the point do not define a plane
+         */
+        private Plane createPlane() {
+            if (normal == null) {
+                throw nonPlanar();
+            }
+
+            // flip the normal if needed to match the overall orientation of the points
+            if (normal.dot(Vector3D.of(crossSumX, crossSumY, crossSumZ)) < 0) {
+                normal = normal.negate();
+            }
+
+            // construct the plane
+            final double originOffset = -startPt.dot(normal);
+
+            return new Plane(normal, originOffset, precision);
+        }
+
+        /** Return an exception with a message stating that the points given to this builder do not
+         * define a plane.
+         * @return an exception stating that the points do not define a plane
+         */
+        private IllegalArgumentException nonPlanar() {
+            return new IllegalArgumentException("Points do not define a plane: " + pts);
+        }
+
+        /** Return an exception with a message stating that the points given to this builder do not
+         * define a convex region.
+         * @return an exception stating that the points do not define a plane
+         */
+        private IllegalArgumentException nonConvex() {
+            return new IllegalArgumentException("Points do not define a convex region: " + pts);
         }
     }
 }
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
index 0739d67..66126fe 100644
--- 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
@@ -22,7 +22,6 @@ import java.util.stream.Stream;
 import java.util.stream.StreamSupport;
 
 import org.apache.commons.geometry.core.partitioning.Hyperplane;
-import org.apache.commons.geometry.core.partitioning.HyperplaneBoundedRegion;
 import org.apache.commons.geometry.core.partitioning.HyperplaneSubset;
 import org.apache.commons.geometry.core.partitioning.Split;
 import org.apache.commons.geometry.core.partitioning.bsp.AbstractBSPTree;
@@ -32,7 +31,6 @@ import org.apache.commons.geometry.core.partitioning.bsp.RegionCutBoundary;
 import org.apache.commons.geometry.euclidean.threed.line.Line3D;
 import org.apache.commons.geometry.euclidean.threed.line.LineConvexSubset3D;
 import org.apache.commons.geometry.euclidean.threed.line.LinecastPoint3D;
-import org.apache.commons.geometry.euclidean.twod.Vector2D;
 
 /** Binary space partitioning (BSP) tree representing a region in three dimensional
  * Euclidean space.
@@ -205,7 +203,7 @@ public final class RegionBSPTree3D extends AbstractRegionBSPTree<Vector3D, Regio
      * @return a new tree instance constructed from the given boundaries
      * @see #from(Iterable, boolean)
      */
-    public static RegionBSPTree3D from(final Iterable<PlaneConvexSubset> boundaries) {
+    public static RegionBSPTree3D from(final Iterable<? extends PlaneConvexSubset> boundaries) {
         return from(boundaries, false);
     }
 
@@ -216,7 +214,7 @@ public final class RegionBSPTree3D extends AbstractRegionBSPTree<Vector3D, Regio
      * @param full if true, the initial tree will contain the entire space
      * @return a new tree instance constructed from the given boundaries
      */
-    public static RegionBSPTree3D from(final Iterable<PlaneConvexSubset> boundaries, final boolean full) {
+    public static RegionBSPTree3D from(final Iterable<? extends PlaneConvexSubset> boundaries, final boolean full) {
         final RegionBSPTree3D tree = new RegionBSPTree3D(full);
         tree.insert(boundaries);
 
@@ -354,29 +352,26 @@ public final class RegionBSPTree3D extends AbstractRegionBSPTree<Vector3D, Regio
          */
         private void addBoundaryContribution(final HyperplaneSubset<Vector3D> boundary, boolean reverse) {
             final PlaneSubset boundarySubset = (PlaneSubset) boundary;
-            final HyperplaneBoundedRegion<Vector2D> base = boundarySubset.getSubspaceRegion();
 
-            final double area = base.getSize();
-            final Vector2D baseBarycenter = base.getBarycenter();
+            final Plane boundaryPlane = boundarySubset.getPlane();
+            final double boundaryArea = boundarySubset.getSize();
+            final Vector3D boundaryBarycenter = boundarySubset.getBarycenter();
 
-            if (Double.isInfinite(area)) {
+            if (Double.isInfinite(boundaryArea)) {
                 volumeSum = Double.POSITIVE_INFINITY;
-            } else if (baseBarycenter != null) {
-                final Plane plane = boundarySubset.getPlane();
-                final Vector3D facetBarycenter = plane.toSpace(base.getBarycenter());
-
+            } else if (boundaryBarycenter != null) {
                 // 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());
+                double scaledVolume = boundaryArea * boundaryBarycenter.dot(boundaryPlane.getNormal());
                 if (reverse) {
                     scaledVolume = -scaledVolume;
                 }
 
                 volumeSum += scaledVolume;
 
-                sumX += scaledVolume * facetBarycenter.getX();
-                sumY += scaledVolume * facetBarycenter.getY();
-                sumZ += scaledVolume * facetBarycenter.getZ();
+                sumX += scaledVolume * boundaryBarycenter.getX();
+                sumY += scaledVolume * boundaryBarycenter.getY();
+                sumZ += scaledVolume * boundaryBarycenter.getZ();
             }
         }
     }
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/SimpleTriangle3D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/SimpleTriangle3D.java
new file mode 100644
index 0000000..e5e8b39
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/SimpleTriangle3D.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.euclidean.threed;
+
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.commons.geometry.core.Transform;
+
+/** Simple implementation of {@link Triangle3D}.
+ *
+ * <p>Instances of this class are guaranteed to be immutable.</p>
+ */
+final class SimpleTriangle3D extends AbstractConvexPolygon3D implements Triangle3D {
+
+    /** First point in the triangle. */
+    private final Vector3D p1;
+
+    /** Second point in the triangle. */
+    private final Vector3D p2;
+
+    /** Third point in the triangle. */
+    private final Vector3D p3;
+
+    /** Construct a new instance from a plane and 3 points. Callers are responsible for ensuring that
+     * the points lie on the plane and define a triangle. No validation is performed.
+     * @param plane the plane containing the triangle
+     * @param p1 first point in the triangle
+     * @param p2 second point in the triangle
+     * @param p3 third point in the triangle
+     */
+    SimpleTriangle3D(final Plane plane, final Vector3D p1, final Vector3D p2, final Vector3D p3) {
+        super(plane);
+
+        this.p1 = p1;
+        this.p2 = p2;
+        this.p3 = p3;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Vector3D getPoint1() {
+        return p1;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Vector3D getPoint2() {
+        return p2;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Vector3D getPoint3() {
+        return p3;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public List<Vector3D> getVertices() {
+        return Arrays.asList(p1, p2, p3);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public double getSize() {
+        final Vector3D v1 = p1.vectorTo(p2);
+        final Vector3D v2 = p1.vectorTo(p3);
+        return 0.5 * v1.cross(v2).norm();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Vector3D getBarycenter() {
+        return Vector3D.centroid(p1, p2, p3);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public SimpleTriangle3D reverse() {
+        final Plane rPlane = getPlane().reverse();
+
+        return new SimpleTriangle3D(rPlane, p1, p3, p2); // reverse point ordering
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public SimpleTriangle3D transform(final Transform<Vector3D> transform) {
+        final Plane tPlane = getPlane().transform(transform);
+        final Vector3D t1 = transform.apply(p1);
+        final Vector3D t2 = transform.apply(p2);
+        final Vector3D t3 = transform.apply(p3);
+
+        return new SimpleTriangle3D(tPlane, t1, t2, t3);
+    }
+}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Triangle3D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Triangle3D.java
new file mode 100644
index 0000000..bf49a72
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/Triangle3D.java
@@ -0,0 +1,59 @@
+/*
+ * 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.Collections;
+import java.util.List;
+
+import org.apache.commons.geometry.core.Transform;
+
+/** Interface representing a triangle in Euclidean 3D space.
+ */
+public interface Triangle3D extends ConvexPolygon3D {
+
+    /** The first point in the triangle.
+     * @return the first point in the triangle
+     */
+    Vector3D getPoint1();
+
+    /** The second point in the triangle.
+     * @return the second point in the triangle
+     */
+    Vector3D getPoint2();
+
+    /** The third point in the triangle.
+     * @return the third point in the triangle
+     */
+    Vector3D getPoint3();
+
+    /** {@inheritDoc} */
+    @Override
+    Triangle3D reverse();
+
+    /** {@inheritDoc} */
+    @Override
+    Triangle3D transform(Transform<Vector3D> transform);
+
+    /** {@inheritDoc}
+    *
+    * <p>This method simply returns a singleton list containing this object.</p>
+    */
+    @Override
+    default List<Triangle3D> toTriangles() {
+        return Collections.singletonList(this);
+    }
+}
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 2982ea8..388eb98 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,7 +16,9 @@
  */
 package org.apache.commons.geometry.euclidean.threed;
 
+import java.util.Arrays;
 import java.util.Comparator;
+import java.util.Iterator;
 import java.util.function.UnaryOperator;
 
 import org.apache.commons.geometry.core.internal.DoubleFunction3N;
@@ -71,20 +73,20 @@ public class Vector3D extends MultiDimensionalEuclideanVector<Vector3D> {
         return cmp;
     };
 
-    /** Abscissa (first coordinate value). */
+    /** X coordinate value (abscissa). */
     private final double x;
 
-    /** Ordinate (second coordinate value). */
+    /** Y coordinate value (ordinate). */
     private final double y;
 
-    /** Height (third coordinate value). */
+    /** Z coordinate value (height). */
     private final double z;
 
     /** Simple constructor.
      * Build a vector from its coordinates
-     * @param x abscissa
-     * @param y ordinate
-     * @param z height
+     * @param x x coordinate value
+     * @param y y coordinate value
+     * @param z z coordinate value
      */
     private Vector3D(double x, double y, double z) {
         this.x = x;
@@ -92,22 +94,22 @@ public class Vector3D extends MultiDimensionalEuclideanVector<Vector3D> {
         this.z = z;
     }
 
-    /** Returns the abscissa (first coordinate) value of the instance.
-     * @return the abscissa
+    /** Return the x coordinate value (abscissa) of the instance.
+     * @return the x coordinate value
      */
     public double getX() {
         return x;
     }
 
-    /** Returns the ordinate (second coordinate) value of the instance.
-     * @return the ordinate
+    /** Return the y coordinate value (ordinate) of the instance.
+     * @return the y coordinate value
      */
     public double getY() {
         return y;
     }
 
-    /** Returns the height (third coordinate) value of the instance.
-     * @return the height
+    /** Returns the z coordinate value (height) of the instance.
+     * @return the z coordinate value
      */
     public double getZ() {
         return z;
@@ -481,9 +483,9 @@ public class Vector3D extends MultiDimensionalEuclideanVector<Vector3D> {
     }
 
     /** Returns a vector with the given coordinate values.
-     * @param x abscissa (first coordinate value)
-     * @param y abscissa (second coordinate value)
-     * @param z height (third coordinate value)
+     * @param x x coordinate value
+     * @param y y coordinate value
+     * @param z z coordinate value
      * @return vector instance
      */
     public static Vector3D of(final double x, final double y, final double z) {
@@ -512,6 +514,149 @@ public class Vector3D extends MultiDimensionalEuclideanVector<Vector3D> {
         return SimpleTupleFormat.getDefault().parse(str, Vector3D::new);
     }
 
+    /** Return a vector containing the maximum component values from all input vectors.
+     * @param first first vector
+     * @param more additional vectors
+     * @return a vector containing the maximum component values from all input vectors
+     */
+    public static Vector3D max(final Vector3D first, final Vector3D... more) {
+        return computeMax(first, Arrays.asList(more).iterator());
+    }
+
+    /** Return a vector containing the maximum component values from all input vectors.
+     * @param vecs input vectors
+     * @return a vector containing the maximum component values from all input vectors
+     * @throws IllegalArgumentException if the argument does not contain any vectors
+     */
+    public static Vector3D max(final Iterable<Vector3D> vecs) {
+        final Iterator<Vector3D> it = vecs.iterator();
+        if (!it.hasNext()) {
+            throw new IllegalArgumentException("Cannot compute vector max: no vectors given");
+        }
+
+        return computeMax(it.next(), it);
+    }
+
+    /** Internal method for computing a max vector.
+     * @param first first vector
+     * @param more iterator with additional vectors
+     * @return vector containing the maximum component values of all input vectors
+     */
+    private static Vector3D computeMax(final Vector3D first, final Iterator<Vector3D> more) {
+        double x = first.getX();
+        double y = first.getY();
+        double z = first.getZ();
+
+        Vector3D vec;
+        while (more.hasNext()) {
+            vec = more.next();
+
+            x = Math.max(x, vec.getX());
+            y = Math.max(y, vec.getY());
+            z = Math.max(z, vec.getZ());
+        }
+
+        return Vector3D.of(x, y, z);
+    }
+
+    /** Return a vector containing the minimum component values from all input vectors.
+     * @param first first vector
+     * @param more additional vectors
+     * @return a vector containing the minimum component values from all input vectors
+     */
+    public static Vector3D min(final Vector3D first, final Vector3D... more) {
+        return computeMin(first, Arrays.asList(more).iterator());
+    }
+
+    /** Return a vector containing the minimum component values from all input vectors.
+     * @param vecs input vectors
+     * @return a vector containing the minimum component values from all input vectors
+     * @throws IllegalArgumentException if the argument does not contain any vectors
+     */
+    public static Vector3D min(final Iterable<Vector3D> vecs) {
+        final Iterator<Vector3D> it = vecs.iterator();
+        if (!it.hasNext()) {
+            throw new IllegalArgumentException("Cannot compute vector min: no vectors given");
+        }
+
+        return computeMin(it.next(), it);
+    }
+
+    /** Internal method for computing a min vector.
+     * @param first first vector
+     * @param more iterator with additional vectors
+     * @return vector containing the minimum component values of all input vectors
+     */
+    private static Vector3D computeMin(final Vector3D first, final Iterator<Vector3D> more) {
+        double x = first.getX();
+        double y = first.getY();
+        double z = first.getZ();
+
+        Vector3D vec;
+        while (more.hasNext()) {
+            vec = more.next();
+
+            x = Math.min(x, vec.getX());
+            y = Math.min(y, vec.getY());
+            z = Math.min(z, vec.getZ());
+        }
+
+        return Vector3D.of(x, y, z);
+    }
+
+    /** Compute the centroid of the given points. The centroid is the arithmetic mean position of a set
+     * of points.
+     * @param first first point
+     * @param more additional points
+     * @return the centroid of the given points
+     */
+    public static Vector3D centroid(final Vector3D first, final Vector3D... more) {
+        return computeCentroid(first, Arrays.asList(more).iterator());
+    }
+
+    /** Compute the centroid of the given points. The centroid is the arithmetic mean position of a set
+     * of points.
+     * @param pts the points to compute the centroid of
+     * @return the centroid of the given points
+     * @throws IllegalArgumentException if the argument contains no points
+     */
+    public static Vector3D centroid(final Iterable<Vector3D> pts) {
+        final Iterator<Vector3D> it = pts.iterator();
+        if (!it.hasNext()) {
+            throw new IllegalArgumentException("Cannot compute centroid: no points given");
+        }
+
+        return computeCentroid(it.next(), it);
+    }
+
+    /** Internal method for computing the centroid of a set of points.
+     * @param first first point
+     * @param more iterator with additional points
+     * @return the centroid of the point set
+     */
+    private static Vector3D computeCentroid(final Vector3D first, final Iterator<Vector3D> more) {
+        double x = first.getX();
+        double y = first.getY();
+        double z = first.getZ();
+
+        int count = 1;
+
+        Vector3D pt;
+        while (more.hasNext()) {
+            pt = more.next();
+
+            x += pt.getX();
+            y += pt.getY();
+            z += pt.getZ();
+
+            ++count;
+        }
+
+        double invCount = 1.0 / count;
+
+        return new Vector3D(invCount * x, invCount * y, invCount * z);
+    }
+
     /** Returns a vector consisting of the linear combination of the inputs.
      * <p>
      * A linear combination is the sum of all of the inputs multiplied by their
@@ -615,9 +760,9 @@ public class Vector3D extends MultiDimensionalEuclideanVector<Vector3D> {
 
         /** Simple constructor. Callers are responsible for ensuring that the given
          * values represent a normalized vector.
-         * @param x abscissa (first coordinate value)
-         * @param y ordinate (second coordinate value)
-         * @param z height (third coordinate value)
+         * @param x x coordinate value
+         * @param y x coordinate value
+         * @param z x coordinate value
          */
         private Unit(final double x, final double y, final double z) {
             super(x, y, z);
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/VertexListConvexPolygon3D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/VertexListConvexPolygon3D.java
new file mode 100644
index 0000000..218561f
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/VertexListConvexPolygon3D.java
@@ -0,0 +1,84 @@
+/*
+ * 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.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.apache.commons.geometry.core.Transform;
+
+/** Internal {@link ConvexPolygon3D} implementation class that uses a list of vertices
+ * to represent the plane subset.
+ */
+final class VertexListConvexPolygon3D extends AbstractConvexPolygon3D implements ConvexPolygon3D {
+
+    /** Vertex loop defining the convex polygon. */
+    private final List<Vector3D> vertices;
+
+    /** Construct a new instance with the given plane and list of vertices. Callers are responsible
+     * for ensuring that the given vertices form a convex subset lying in {@code plane}. The list of
+     * vertices should not contain the duplicated first endpoint. No validation is performed.
+     * @param plane plane containing convex polygon
+     * @param vertices vertices defining the convex polygon
+     * @throw IllegalArgumentException if fewer than 3 vertices are given
+     */
+    VertexListConvexPolygon3D(final Plane plane, final List<Vector3D> vertices) {
+        super(plane);
+
+        // sanity check
+        if (vertices.size() < 3) {
+            throw new IllegalArgumentException("Convex polygon requires at least 3 points; found " + vertices.size());
+        }
+
+        this.vertices = Collections.unmodifiableList(vertices);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public List<Vector3D> getVertices() {
+        return vertices;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public List<Triangle3D> toTriangles() {
+        return Planes.convexPolygonToTriangleFan(getPlane(), vertices);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public VertexListConvexPolygon3D transform(final Transform<Vector3D> transform) {
+        final Plane tPlane = getPlane().transform(transform);
+        final List<Vector3D> tVertices = vertices.stream()
+                .map(transform)
+                .collect(Collectors.toList());
+
+        return new VertexListConvexPolygon3D(tPlane, tVertices);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public VertexListConvexPolygon3D reverse() {
+        final Plane rPlane = getPlane().reverse();
+        final List<Vector3D> rVertices = new ArrayList<>(vertices);
+        Collections.reverse(rVertices);
+
+        return new VertexListConvexPolygon3D(rPlane, rVertices);
+    }
+}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/line/EmbeddedTreeLineSubset3D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/line/EmbeddedTreeLineSubset3D.java
index 8d5188c..71e2fa1 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/line/EmbeddedTreeLineSubset3D.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/line/EmbeddedTreeLineSubset3D.java
@@ -22,6 +22,8 @@ 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.oned.Vector1D;
+import org.apache.commons.geometry.euclidean.threed.Bounds3D;
 import org.apache.commons.geometry.euclidean.threed.Vector3D;
 import org.apache.commons.geometry.euclidean.threed.line.Line3D.SubspaceTransform;
 
@@ -63,6 +65,45 @@ public final class EmbeddedTreeLineSubset3D extends LineSubset3D {
         this.region = region;
     }
 
+    /** {@inheritDoc} */
+    @Override
+    public double getSize() {
+        return region.getSize();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public RegionBSPTree1D getSubspaceRegion() {
+        return region;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Vector3D getBarycenter() {
+        final Vector1D subcenter = region.getBarycenter();
+        return subcenter != null ?
+                getLine().toSpace(subcenter) :
+                null;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Bounds3D getBounds() {
+        final double min = region.getMin();
+        final double max = region.getMax();
+
+        if (Double.isFinite(min) && Double.isFinite(max)) {
+            final Line3D line = getLine();
+
+            return Bounds3D.builder()
+                    .add(line.toSpace(min))
+                    .add(line.toSpace(max))
+                    .build();
+        }
+
+        return null;
+    }
+
     /** Transform this instance.
      * @param transform the transform to apply
      * @return a new, transformed instance
@@ -97,12 +138,6 @@ public final class EmbeddedTreeLineSubset3D extends LineSubset3D {
 
     /** {@inheritDoc} */
     @Override
-    public RegionBSPTree1D getSubspaceRegion() {
-        return region;
-    }
-
-    /** {@inheritDoc} */
-    @Override
     public String toString() {
         final Line3D line = getLine();
 
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/line/LineSpanningSubset3D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/line/LineSpanningSubset3D.java
index 373e74b..0c75a9a 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/line/LineSpanningSubset3D.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/line/LineSpanningSubset3D.java
@@ -19,6 +19,7 @@ package org.apache.commons.geometry.euclidean.threed.line;
 import java.text.MessageFormat;
 
 import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.euclidean.threed.Bounds3D;
 import org.apache.commons.geometry.euclidean.threed.Vector3D;
 
 /** Class representing the span of a line in 3D Euclidean space. This is the set of all points
@@ -98,6 +99,24 @@ final class LineSpanningSubset3D extends LineConvexSubset3D {
         return Double.POSITIVE_INFINITY;
     }
 
+    /** {@inheritDoc}
+     *
+     * <p>This method always returns {@code null}.</p>
+     */
+    @Override
+    public Vector3D getBarycenter() {
+        return null; // infinite; no center
+    }
+
+    /** {@inheritDoc}
+    *
+    * <p>This method always returns {@code null}.</p>
+    */
+    @Override
+    public Bounds3D getBounds() {
+        return null; // infinite; no bounds
+    }
+
     /** {@inheritDoc} */
     @Override
     public LineSpanningSubset3D transform(final Transform<Vector3D> transform) {
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/line/LineSubset3D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/line/LineSubset3D.java
index 0a27e98..0c3c110 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/line/LineSubset3D.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/line/LineSubset3D.java
@@ -17,14 +17,16 @@
 package org.apache.commons.geometry.euclidean.threed.line;
 
 import org.apache.commons.geometry.core.RegionEmbedding;
+import org.apache.commons.geometry.core.Sized;
 import org.apache.commons.geometry.core.partitioning.HyperplaneBoundedRegion;
 import org.apache.commons.geometry.euclidean.oned.Vector1D;
+import org.apache.commons.geometry.euclidean.threed.Bounds3D;
 import org.apache.commons.geometry.euclidean.threed.Vector3D;
 
 /** Class representing a subset of a line in 3D Euclidean space. For example, line segments,
  * rays, and disjoint combinations of the two are line subsets. Line subsets may be finite or infinite.
  */
-public abstract class LineSubset3D implements RegionEmbedding<Vector3D, Vector1D> {
+public abstract class LineSubset3D implements RegionEmbedding<Vector3D, Vector1D>, Sized {
     /** The line containing this instance. */
     private final Line3D line;
 
@@ -54,6 +56,20 @@ public abstract class LineSubset3D implements RegionEmbedding<Vector3D, Vector1D
         return line.toSubspace(pt);
     }
 
+    /** Get the center of the line subset, or null if the subset is empty of
+     * infinite.
+     * @return the center of the line subset, or null if the subset is empty of
+     *      infinite
+     */
+    public abstract Vector3D getBarycenter();
+
+    /** Get the 3D bounding box of the line subset or null if the subset is
+     * empty or infinite.
+     * @return the 3D bounding box the line subset or null if the subset is
+     *      empty or infinite
+     */
+    public abstract Bounds3D getBounds();
+
     /** Get the subspace region for the instance.
      * @return the subspace region for the instance
      */
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/line/Ray3D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/line/Ray3D.java
index 4975d50..1448efd 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/line/Ray3D.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/line/Ray3D.java
@@ -17,6 +17,7 @@
 package org.apache.commons.geometry.euclidean.threed.line;
 
 import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.euclidean.threed.Bounds3D;
 import org.apache.commons.geometry.euclidean.threed.Vector3D;
 
 /** Class representing a ray in 3D Euclidean space. A ray is a portion of a line consisting of
@@ -107,6 +108,24 @@ public final class Ray3D extends LineConvexSubset3D {
         return Double.POSITIVE_INFINITY;
     }
 
+    /** {@inheritDoc}
+     *
+     * <p>This method always returns {@code null}.</p>
+     */
+    @Override
+    public Vector3D getBarycenter() {
+        return null; // infinite; no center
+    }
+
+   /** {@inheritDoc}
+    *
+    * <p>This method always returns {@code null}.</p>
+    */
+    @Override
+    public Bounds3D getBounds() {
+        return null; // infinite; no bounds
+    }
+
     /** Get the direction of the ray. This is a convenience method for {@code ray.getLine().getDirection()}.
      * @return the direction of the ray
      */
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/line/ReverseRay3D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/line/ReverseRay3D.java
index 480efec..3fb8aa2 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/line/ReverseRay3D.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/line/ReverseRay3D.java
@@ -17,6 +17,7 @@
 package org.apache.commons.geometry.euclidean.threed.line;
 
 import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.euclidean.threed.Bounds3D;
 import org.apache.commons.geometry.euclidean.threed.Vector3D;
 
 /** Class representing a portion of a line in 3D Euclidean space that starts at infinity and
@@ -108,6 +109,24 @@ public final class ReverseRay3D extends LineConvexSubset3D {
         return end;
     }
 
+    /** {@inheritDoc}
+     *
+     * <p>This method always returns {@code null}.</p>
+     */
+    @Override
+    public Vector3D getBarycenter() {
+        return null; // infinite; no center
+    }
+
+   /** {@inheritDoc}
+    *
+    * <p>This method always returns {@code null}.</p>
+    */
+    @Override
+    public Bounds3D getBounds() {
+        return null; // infinite; no bounds
+    }
+
     /** {@inheritDoc} */
     @Override
     public ReverseRay3D transform(final Transform<Vector3D> transform) {
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/line/Segment3D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/line/Segment3D.java
index c351c43..62f0a4b 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/line/Segment3D.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/line/Segment3D.java
@@ -18,6 +18,7 @@ package org.apache.commons.geometry.euclidean.threed.line;
 
 import org.apache.commons.geometry.core.Transform;
 import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.threed.Bounds3D;
 import org.apache.commons.geometry.euclidean.threed.Vector3D;
 
 /** Class representing a line segment in 3D Euclidean space. A line segment is a portion of
@@ -108,6 +109,21 @@ public final class Segment3D extends LineConvexSubset3D {
 
     /** {@inheritDoc} */
     @Override
+    public Vector3D getBarycenter() {
+        return getLine().toSpace((0.5 * (end - start)) + start);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Bounds3D getBounds() {
+        return Bounds3D.builder()
+                .add(getStartPoint())
+                .add(getEndPoint())
+                .build();
+    }
+
+    /** {@inheritDoc} */
+    @Override
     public Segment3D transform(final Transform<Vector3D> transform) {
         final Vector3D t1 = transform.apply(getStartPoint());
         final Vector3D t2 = transform.apply(getEndPoint());
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/shape/Parallelepiped.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/shape/Parallelepiped.java
index fc99ec2..a2b5888 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/shape/Parallelepiped.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/shape/Parallelepiped.java
@@ -200,7 +200,7 @@ public final class Parallelepiped extends ConvexVolume {
                 Arrays.asList(pd, pc, pb, pa) :
                 Arrays.asList(pa, pb, pc, pd);
 
-        return Planes.subsetFromVertexLoop(loop, precision);
+        return Planes.convexPolygonFromVertices(loop, precision);
     }
 
     /** Ensure that the given points defining one side of a parallelepiped face are separated by a non-zero
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/BoundarySource2D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/BoundarySource2D.java
index 45cfe7e..ffca9a1 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/BoundarySource2D.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/BoundarySource2D.java
@@ -50,6 +50,15 @@ public interface BoundarySource2D extends BoundarySource<LineConvexSubset>, Line
         return new BoundarySourceLinecaster2D(this).linecastFirst(subset);
     }
 
+    /** Get a {@link Bounds2D} object defining the axis-aligned box containing all vertices
+     * in the boundaries for this instance. Null is returned if any boundaries are infinite
+     * or no vertices were found.
+     * @return the bounding box for this instance or null if no valid bounds could be determined
+     */
+    default Bounds2D getBounds() {
+        return new BoundarySourceBoundsBuilder2D().getBounds(this);
+    }
+
     /** Return a {@link BoundarySource2D} instance containing the given boundaries.
      * @param boundaries line subsets to include in the boundary source
      * @return a boundary source containing the given boundaries
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/BoundarySourceBoundsBuilder2D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/BoundarySourceBoundsBuilder2D.java
new file mode 100644
index 0000000..44f2e6d
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/BoundarySourceBoundsBuilder2D.java
@@ -0,0 +1,58 @@
+/*
+ * 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.Iterator;
+import java.util.stream.Stream;
+
+/** Class used to construct {@link Bounds2D} instances representing the min and
+ * max points present in a {@link BoundarySource2D}. The implementation examines
+ * the vertices of each boundary in turn. Null is returned if any boundaries are
+ * infinite or no vertices are present.
+ */
+class BoundarySourceBoundsBuilder2D {
+
+    /** Get a {@link Bounds3D} instance containing all vertices in the given boundary source.
+     * Null is returned if any encountered boundaries were not finite or no vertices were found.
+     * @param src boundary source to compute the bounds of
+     * @return the bounds of the argument or null if no valid bounds could be determined
+     */
+    public Bounds2D getBounds(final BoundarySource2D src) {
+
+        final Bounds2D.Builder builder = Bounds2D.builder();
+
+        try (Stream<LineConvexSubset> stream = src.boundaryStream()) {
+            final Iterator<LineConvexSubset> it = stream.iterator();
+
+            LineConvexSubset boundary;
+            while (it.hasNext()) {
+                boundary = it.next();
+
+                if (boundary.isInfinite()) {
+                    return null; // break out early
+                }
+
+                builder.add(boundary.getStartPoint());
+                builder.add(boundary.getEndPoint());
+            }
+        }
+
+        return builder.containsBounds() ?
+                builder.build() :
+                null;
+    }
+}
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/BoundarySourceLinecaster2D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/BoundarySourceLinecaster2D.java
index 69a265c..92b25d2 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/BoundarySourceLinecaster2D.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/BoundarySourceLinecaster2D.java
@@ -42,20 +42,22 @@ final class BoundarySourceLinecaster2D implements Linecastable2D {
     /** {@inheritDoc} */
     @Override
     public List<LinecastPoint2D> linecast(final LineConvexSubset subset) {
-        final List<LinecastPoint2D> results = getIntersectionStream(subset)
-                .collect(Collectors.toCollection(ArrayList::new));
+        try (Stream<LinecastPoint2D> stream = getIntersectionStream(subset)) {
 
-        LinecastPoint2D.sortAndFilter(results);
+            final List<LinecastPoint2D> results = stream.collect(Collectors.toCollection(ArrayList::new));
+            LinecastPoint2D.sortAndFilter(results);
 
-        return results;
+            return results;
+        }
     }
 
     /** {@inheritDoc} */
     @Override
     public LinecastPoint2D linecastFirst(final LineConvexSubset subset) {
-        return getIntersectionStream(subset)
-                .min(LinecastPoint2D.ABSCISSA_ORDER)
-                .orElse(null);
+        try (Stream<LinecastPoint2D> stream = getIntersectionStream(subset)) {
+            return stream.min(LinecastPoint2D.ABSCISSA_ORDER)
+                    .orElse(null);
+        }
     }
 
     /** Return a stream containing intersections between the boundary source and the
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/Bounds2D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/Bounds2D.java
new file mode 100644
index 0000000..0613d7f
--- /dev/null
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/Bounds2D.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.twod;
+
+import java.util.Arrays;
+import java.util.Objects;
+
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.AbstractBounds;
+import org.apache.commons.geometry.euclidean.twod.shape.Parallelogram;
+
+/** Class containing minimum and maximum points defining a 2D axis-aligned bounding box. Unless otherwise
+ * noted, floating point comparisons used in this class are strict, meaning that values are considered equal
+ * if and only if they match exactly.
+ *
+ * <p>Instances of this class are guaranteed to be immutable.</p>
+ */
+public final class Bounds2D extends AbstractBounds<Vector2D, Bounds2D> {
+
+    /** Simple constructor. Callers are responsible for ensuring the min is not greater than max.
+     * @param min minimum point
+     * @param max maximum point
+     */
+    private Bounds2D(final Vector2D min, final Vector2D max) {
+        super(min, max);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean hasSize(final DoublePrecisionContext precision) {
+        final Vector2D diag = getDiagonal();
+
+        return !precision.eqZero(diag.getX()) &&
+                !precision.eqZero(diag.getY());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean contains(final Vector2D pt) {
+        final double x = pt.getX();
+        final double y = pt.getY();
+
+        final Vector2D min = getMin();
+        final Vector2D max = getMax();
+
+        return x >= min.getX() && x <= max.getX() &&
+                y >= min.getY() && y <= max.getY();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean contains(final Vector2D pt, final DoublePrecisionContext precision) {
+        final double x = pt.getX();
+        final double y = pt.getY();
+
+        final Vector2D min = getMin();
+        final Vector2D max = getMax();
+
+        return precision.gte(x, min.getX()) && precision.lte(x, max.getX()) &&
+                precision.gte(y, min.getY()) && precision.lte(y, max.getY());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean intersects(final Bounds2D other) {
+        final Vector2D aMin = getMin();
+        final Vector2D aMax = getMax();
+
+        final Vector2D bMin = other.getMin();
+        final Vector2D bMax = other.getMax();
+
+        return aMin.getX() <= bMax.getX() && aMax.getX() >= bMin.getX() &&
+                aMin.getY() <= bMax.getY() && aMax.getY() >= bMin.getY();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Bounds2D intersection(final Bounds2D other) {
+        if (intersects(other)) {
+            final Vector2D aMin = getMin();
+            final Vector2D aMax = getMax();
+
+            final Vector2D bMin = other.getMin();
+            final Vector2D bMax = other.getMax();
+
+            // get the max of the mins and the mins of the maxes
+            final double minX = Math.max(aMin.getX(), bMin.getX());
+            final double minY = Math.max(aMin.getY(), bMin.getY());
+
+            final double maxX = Math.min(aMax.getX(), bMax.getX());
+            final double maxY = Math.min(aMax.getY(), bMax.getY());
+
+            return new Bounds2D(
+                    Vector2D.of(minX, minY),
+                    Vector2D.of(maxX, maxY));
+        }
+
+        return null; // no intersection
+    }
+
+    /** {@inheritDoc}
+     *
+     * @throws IllegalArgumentException if any dimension of the bounding box is zero
+     *      as evaluated by the given precision context
+     */
+    @Override
+    public Parallelogram toRegion(final DoublePrecisionContext precision) {
+        return Parallelogram.axisAligned(getMin(), getMax(), precision);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public int hashCode() {
+        return Objects.hash(getMin(), getMax());
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean equals(final Object obj) {
+        if (obj == this) {
+            return true;
+        } else if (!(obj instanceof Bounds2D)) {
+            return false;
+        }
+
+        final Bounds2D other = (Bounds2D) obj;
+
+        return getMin().equals(other.getMin()) &&
+                getMax().equals(other.getMax());
+    }
+
+    /** Construct a new instance from the given points.
+     * @param first first point
+     * @param more additional points
+     * @return a new instance containing the min and max coordinates values from the input points
+     */
+    public static Bounds2D from(final Vector2D first, final Vector2D... more) {
+        final Builder builder = builder();
+
+        builder.add(first);
+        builder.addAll(Arrays.asList(more));
+
+        return builder.build();
+    }
+
+    /** Construct a new instance from the given points.
+     * @param points input points
+     * @return a new instance containing the min and max coordinates values from the input points
+     */
+    public static Bounds2D from(final Iterable<Vector2D> points) {
+        final Builder builder = builder();
+
+        builder.addAll(points);
+
+        return builder.build();
+    }
+
+    /** Construct a new {@link Builder} instance for creating bounds.
+     * @return a new builder instance for creating bounds
+     */
+    public static Builder builder() {
+        return new Builder();
+    }
+
+    /** Class used to construct {@link Bounds2D} instances.
+     */
+    public static final class Builder {
+
+        /** Minimum x coordinate. */
+        private double minX = Double.POSITIVE_INFINITY;
+
+        /** Minimum y coordinate. */
+        private double minY = Double.POSITIVE_INFINITY;
+
+        /** Maximum x coordinate. */
+        private double maxX = Double.NEGATIVE_INFINITY;
+
+        /** Maximum y coordinate. */
+        private double maxY = Double.NEGATIVE_INFINITY;
+
+        /** Private constructor; instantiate through factory method. */
+        private Builder() { }
+
+        /** Add a point to this instance.
+         * @param pt point to add
+         * @return this instance
+         */
+        public Builder add(final Vector2D pt) {
+            final double x = pt.getX();
+            final double y = pt.getY();
+
+            minX = Math.min(x, minX);
+            minY = Math.min(y, minY);
+
+            maxX = Math.max(x, maxX);
+            maxY = Math.max(y, maxY);
+
+            return this;
+        }
+
+        /** Add a collection of points to this instance.
+         * @param pts points to add
+         * @return this instance
+         */
+        public Builder addAll(final Iterable<Vector2D> pts) {
+            for (final Vector2D pt : pts) {
+                add(pt);
+            }
+
+            return this;
+        }
+
+        /** Add the min and max points from the given bounds to this instance.
+         * @param bounds bounds containing the min and max points to add
+         * @return this instance
+         */
+        public Builder add(final Bounds2D bounds) {
+            add(bounds.getMin());
+            add(bounds.getMax());
+
+            return this;
+        }
+
+        /** Return true if this builder contains valid min and max coordinate values.
+         * @return true if this builder contains valid min and max coordinate values
+         */
+        public boolean containsBounds() {
+            return Double.isFinite(minX) &&
+                    Double.isFinite(minY) &&
+                    Double.isFinite(maxX) &&
+                    Double.isFinite(maxY);
+        }
+
+        /** Create a new {@link Bounds2D} instance from the values in this builder.
+         * The builder can continue to be used to create other instances.
+         * @return a new bounds instance
+         * @throws IllegalStateException if no points were given to the builder or any of the computed
+         *      min and max coordinate values are NaN or infinite
+         * @see #containsBounds()
+         */
+        public Bounds2D build() {
+            final Vector2D min = Vector2D.of(minX, minY);
+            final Vector2D max = Vector2D.of(maxX, maxY);
+
+            if (!containsBounds()) {
+                if (Double.isInfinite(minX) && minX > 0 &&
+                        Double.isInfinite(maxX) && maxX < 0) {
+                    throw new IllegalStateException("Cannot construct bounds: no points given");
+                }
+
+                throw new IllegalStateException("Invalid bounds: min= " + min + ", max= " + max);
+            }
+
+            return new Bounds2D(min, max);
+        }
+    }
+}
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
index 67d0566..0aecbeb 100644
--- 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
@@ -16,12 +16,10 @@
  */
 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 java.util.stream.Collectors;
 import java.util.stream.Stream;
 
 import org.apache.commons.geometry.core.Transform;
@@ -39,6 +37,9 @@ import org.apache.commons.geometry.euclidean.twod.path.LinePath;
 public class ConvexArea extends AbstractConvexHyperplaneBoundedRegion<Vector2D, LineConvexSubset>
     implements BoundarySource2D {
 
+    /** Error message used when attempting to construct a convex polygon from a non-convex line path. */
+    private static final String NON_CONVEX_PATH_ERROR = "Cannot construct convex polygon from non-convex path: ";
+
     /** Instance representing the full 2D plane. */
     private static final ConvexArea FULL = new ConvexArea(Collections.emptyList());
 
@@ -74,12 +75,18 @@ public class ConvexArea extends AbstractConvexHyperplaneBoundedRegion<Vector2D,
         return InteriorAngleLinePathConnector.connectMinimized(getBoundaries());
     }
 
-    /** Get the vertices defining the area. The vertices lie at the intersections of the
-     * area bounding lines. Note that it is possible for areas to contain no vertices at
-     * all. For example, an area with no boundaries (representing the full space), an area
-     * with a single boundary, or an area one with two parallel boundaries will not contain
-     * vertices.
-     * @return the vertices defining the area
+    /** Get the vertices for the area in a counter-clockwise order. Each vertex in the
+     * returned list is unique. If the boundary of the area is closed, the start vertex is
+     * <em>not</em> repeated at the end of the list.
+     *
+     * <p>It is important to note that, in general, the list of vertices returned by this method
+     * is not sufficient to completely characterize the area. For example, a simple triangle
+     * has 3 vertices, but an infinite area constructed from two parallel lines and two lines that
+     * intersect between them will also have 3 vertices. It is also possible for non-empty areas to
+     * contain no vertices at all. For example, an area with no boundaries (representing the full
+     * space), an area with a single boundary, or an area with two parallel boundaries will not
+     * contain any vertices.</p>
+     * @return the list of vertices for the area in a counter-clockwise order
      */
     public List<Vector2D> getVertices() {
         final List<LinePath> paths = getBoundaryPaths();
@@ -191,89 +198,74 @@ public class ConvexArea extends AbstractConvexHyperplaneBoundedRegion<Vector2D,
         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)
+    /** Construct a convex polygon from the given vertices.
+     * @param vertices vertices to use to construct the polygon
+     * @param precision precision context used for floating point comparisons
+     * @return a convex polygon constructed using the given vertices
+     * @throws IllegalStateException if {@code vertices} contains only a single unique vertex
+     * @throws IllegalArgumentException if the constructed path does not define a closed, convex polygon
+     * @see LinePath#fromVertexLoop(Collection, DoublePrecisionContext)
      */
-    public static ConvexArea fromVertices(final Collection<Vector2D> vertices,
+    public static ConvexArea convexPolygonFromVertices(final Collection<Vector2D> vertices,
             final DoublePrecisionContext precision) {
-        return fromVertices(vertices, false, precision);
+        return convexPolygonFromPath(LinePath.fromVertexLoop(vertices, 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)
+    /** Construct a convex polygon from a line path.
+     * @param path path to construct the polygon from
+     * @return a convex polygon constructed from the given line path
+     * @throws IllegalArgumentException if the path does not define a closed, convex polygon
      */
-    public static ConvexArea fromVertexLoop(final Collection<Vector2D> vertices,
-            final DoublePrecisionContext precision) {
-        return fromVertices(vertices, true, precision);
-    }
+    public static ConvexArea convexPolygonFromPath(final LinePath path) {
+        // ensure that the path is closed; this also ensures that we do not have any infinite elements
+        if (!path.isClosed()) {
+            throw new IllegalArgumentException("Cannot construct convex polygon from unclosed path: " + path);
+        }
 
-    /** 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<LineConvexSubset> elements = path.getElements();
+        if (elements.size() < 3) {
+            throw new IllegalArgumentException(
+                    "Cannot construct convex polygon from path with less than 3 elements: " + path);
         }
 
-        final List<Line> lines = new ArrayList<>();
+        // go through the elements and validate that the produced area is convex and finite;
+        // use the precision context from the first path element
+        LineConvexSubset startElement = elements.get(0);
+        Vector2D startVertex = startElement.getStartPoint();
+        DoublePrecisionContext precision = startElement.getPrecision();
 
-        Vector2D first = null;
-        Vector2D prev = null;
-        Vector2D cur = null;
+        Vector2D curVector;
+        Vector2D prevVector = null;
 
-        for (final Vector2D vertex : vertices) {
-            cur = vertex;
+        double signedArea;
+        double totalSignedArea = 0.0;
 
-            if (first == null) {
-                first = cur;
-            }
+        LineConvexSubset element;
 
-            if (prev != null && !cur.eq(prev, precision)) {
-                lines.add(Lines.fromPoints(prev, cur, precision));
-            }
+        // we can skip the last element since the we know that the path is closed, meaning that the
+        // last element's end point is equal to our start point
+        for (int i = 0; i < elements.size() - 1; ++i) {
+            element = elements.get(i);
 
-            prev = cur;
-        }
+            curVector = startVertex.vectorTo(element.getEndPoint());
 
-        if (close && cur != null && !cur.eq(first, precision)) {
-            lines.add(Lines.fromPoints(cur, first, precision));
-        }
+            if (prevVector != null) {
+                signedArea = prevVector.signedArea(curVector);
+                if (precision.lt(signedArea, 0.0)) {
+                    throw new IllegalArgumentException(NON_CONVEX_PATH_ERROR + path);
+                }
 
-        if (!vertices.isEmpty() && lines.isEmpty()) {
-            throw new IllegalStateException("Unable to create convex area: only a single unique vertex provided");
-        }
+                totalSignedArea += signedArea;
+            }
 
-        return fromBounds(lines);
-    }
+            prevVector = curVector;
+        }
 
-    /** Construct a convex area from a line subset 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 subsets in the path.
-     * @param path path to construct the area from
-     * @return a convex area constructed from the line subsets in the given path
-     */
-    public static ConvexArea fromPath(final LinePath path) {
-        final List<Line> lines = path.boundaryStream()
-                .map(LineConvexSubset::getLine)
-                .collect(Collectors.toList());
+        if (precision.lte(totalSignedArea, 0.0)) {
+            throw new IllegalArgumentException(NON_CONVEX_PATH_ERROR + path);
+        }
 
-        return fromBounds(lines);
+        return new ConvexArea(elements);
     }
 
     /** Create a convex area formed by the intersection of the negative half-spaces of the
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/EmbeddedTreeLineSubset.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/EmbeddedTreeLineSubset.java
index ab557dd..8a56114 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/EmbeddedTreeLineSubset.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/EmbeddedTreeLineSubset.java
@@ -21,6 +21,7 @@ import java.util.List;
 
 import org.apache.commons.geometry.core.RegionLocation;
 import org.apache.commons.geometry.core.Transform;
+import org.apache.commons.geometry.core.internal.HyperplaneSubsets;
 import org.apache.commons.geometry.core.partitioning.Hyperplane;
 import org.apache.commons.geometry.core.partitioning.Split;
 import org.apache.commons.geometry.core.partitioning.SplitLocation;
@@ -29,6 +30,7 @@ import org.apache.commons.geometry.euclidean.oned.Interval;
 import org.apache.commons.geometry.euclidean.oned.OrientedPoint;
 import org.apache.commons.geometry.euclidean.oned.OrientedPoints;
 import org.apache.commons.geometry.euclidean.oned.RegionBSPTree1D;
+import org.apache.commons.geometry.euclidean.oned.Vector1D;
 import org.apache.commons.geometry.euclidean.twod.Line.SubspaceTransform;
 
 /** Class representing an arbitrary subset of a line using a {@link RegionBSPTree1D}.
@@ -71,6 +73,58 @@ public final class EmbeddedTreeLineSubset extends LineSubset {
 
     /** {@inheritDoc} */
     @Override
+    public boolean isFull() {
+        return region.isFull();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean isEmpty() {
+        return region.isEmpty();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public double getSize() {
+        return region.getSize();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Vector2D getBarycenter() {
+        final Vector1D subspaceBarycenter = region.getBarycenter();
+        if (subspaceBarycenter != null) {
+            return getLine().toSpace(subspaceBarycenter);
+        }
+        return null;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Bounds2D getBounds() {
+        final double min = region.getMin();
+        final double max = region.getMax();
+
+        if (Double.isFinite(min) && Double.isFinite(max)) {
+            final Line line = getLine();
+
+            return Bounds2D.builder()
+                    .add(line.toSpace(min))
+                    .add(line.toSpace(max))
+                    .build();
+        }
+
+        return null;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Vector2D closest(final Vector2D pt) {
+        return HyperplaneSubsets.closestToEmbeddedRegion(pt, getLine(), region);
+    }
+
+    /** {@inheritDoc} */
+    @Override
     public EmbeddedTreeLineSubset transform(final Transform<Vector2D> transform) {
         final SubspaceTransform st = getLine().subspaceTransform(transform);
 
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 b7a0d93..7593089 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
@@ -63,7 +63,7 @@ public final class Line extends AbstractHyperplane<Vector2D>
     static final String TO_STRING_FORMAT = "{0}[origin= {1}, direction= {2}]";
 
     /** The direction of the line as a normalized vector. */
-    private final Vector2D direction;
+    private final Vector2D.Unit direction;
 
     /** The distance between the origin and the line. */
     private final double originOffset;
@@ -73,7 +73,7 @@ public final class Line extends AbstractHyperplane<Vector2D>
      * @param originOffset The signed distance between the line and the origin.
      * @param precision Precision context used to compare floating point numbers.
      */
-    Line(final Vector2D direction, final double originOffset, final DoublePrecisionContext precision) {
+    Line(final Vector2D.Unit direction, final double originOffset, final DoublePrecisionContext precision) {
         super(precision);
 
         this.direction = direction;
@@ -93,7 +93,7 @@ public final class Line extends AbstractHyperplane<Vector2D>
     /** Get the direction of the line.
      * @return the direction of the line
      */
-    public Vector2D getDirection() {
+    public Vector2D.Unit getDirection() {
         return direction;
     }
 
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/LineSpanningSubset.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/LineSpanningSubset.java
index ab16ae3..8c504f5 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/LineSpanningSubset.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/LineSpanningSubset.java
@@ -73,6 +73,15 @@ final class LineSpanningSubset extends LineConvexSubset {
     }
 
     /** {@inheritDoc}
+     *
+     * <p>This method always returns {@code null}.</p>
+     */
+    @Override
+    public Vector2D getBarycenter() {
+        return null;
+    }
+
+    /** {@inheritDoc}
     *
     * <p>This method always returns {@code null}.</p>
     */
@@ -108,6 +117,15 @@ final class LineSpanningSubset extends LineConvexSubset {
         return Double.POSITIVE_INFINITY;
     }
 
+    /** {@inheritDoc}
+    *
+    * <p>This method always returns {@code null}.</p>
+    */
+    @Override
+    public Bounds2D getBounds() {
+        return null; // infinite; no bounds
+    }
+
     /** {@inheritDoc} */
     @Override
     public LineSpanningSubset transform(final Transform<Vector2D> transform) {
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/LineSubset.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/LineSubset.java
index ba05313..2e8a2c3 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/LineSubset.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/LineSubset.java
@@ -19,8 +19,9 @@ package org.apache.commons.geometry.euclidean.twod;
 import java.util.List;
 import java.util.Objects;
 
+import org.apache.commons.geometry.core.RegionEmbedding;
 import org.apache.commons.geometry.core.RegionLocation;
-import org.apache.commons.geometry.core.partitioning.AbstractRegionEmbeddingHyperplaneSubset;
+import org.apache.commons.geometry.core.partitioning.HyperplaneBoundedRegion;
 import org.apache.commons.geometry.core.partitioning.HyperplaneConvexSubset;
 import org.apache.commons.geometry.core.partitioning.HyperplaneSubset;
 import org.apache.commons.geometry.core.partitioning.Split;
@@ -30,7 +31,7 @@ import org.apache.commons.geometry.euclidean.oned.Vector1D;
 /** Class representing a subset of points on a line in 2D Euclidean space. For example, line segments
  * and rays are line subsets. Line subsets may be finite or infinite.
  */
-public abstract class LineSubset extends AbstractRegionEmbeddingHyperplaneSubset<Vector2D, Vector1D, Line> {
+public abstract class LineSubset implements HyperplaneSubset<Vector2D>, RegionEmbedding<Vector2D, Vector1D> {
     /** The line containing this instance. */
     private final Line line;
 
@@ -58,8 +59,31 @@ public abstract class LineSubset extends AbstractRegionEmbeddingHyperplaneSubset
 
     /** {@inheritDoc} */
     @Override
+    public Vector1D toSubspace(final Vector2D pt) {
+        return line.toSubspace(pt);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Vector2D toSpace(final Vector1D pt) {
+        return line.toSpace(pt);
+    }
+
+    /** {@inheritDoc} */
+    @Override
     public abstract List<LineConvexSubset> toConvex();
 
+    /** Get a {@link Bounds2D} object defining an axis-aligned bounding box containing all
+     * vertices for this subset. Null is returned if the subset is infinite or does not
+     * contain any vertices.
+     * @return the bounding box for this instance or null if no valid bounds could be determined
+     */
+    public abstract Bounds2D getBounds();
+
+    /** {@inheritDoc} */
+    @Override
+    public abstract HyperplaneBoundedRegion<Vector1D> getSubspaceRegion();
+
     /** {@inheritDoc} */
     @Override
     public HyperplaneSubset.Builder<Vector2D> builder() {
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/Lines.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/Lines.java
index 1ac2ebe..f8d5251 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/Lines.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/Lines.java
@@ -57,7 +57,7 @@ public final class Lines {
             throw new IllegalArgumentException("Line direction cannot be zero");
         }
 
-        final Vector2D normalizedDir = dir.normalize();
+        final Vector2D.Unit normalizedDir = dir.normalize();
         final double originOffset = normalizedDir.signedArea(pt);
 
         return new Line(normalizedDir, originOffset, precision);
@@ -102,7 +102,10 @@ public final class Lines {
      * @throws IllegalArgumentException if any coordinate in {@code startPoint} is NaN or infinite
      */
     public static Ray rayFromPoint(final Line line, final Vector2D startPoint) {
-        return rayFromLocation(line, line.abscissa(startPoint));
+        if (!startPoint.isFinite()) {
+            throw new IllegalArgumentException("Invalid ray start point: " + startPoint);
+        }
+        return new Ray(line, line.project(startPoint));
     }
 
     /** Construct a ray starting at the given 1D location on {@code line} and continuing in the
@@ -117,8 +120,7 @@ public final class Lines {
         if (!Double.isFinite(startLocation)) {
             throw new IllegalArgumentException("Invalid ray start location: " + Double.toString(startLocation));
         }
-
-        return new Ray(line, startLocation);
+        return new Ray(line, line.toSpace(startLocation));
     }
 
     /** Construct a reverse ray from an end point and a line direction.
@@ -145,7 +147,10 @@ public final class Lines {
      * @throws IllegalArgumentException if any coordinate in {@code endPoint} is NaN or infinite
      */
     public static ReverseRay reverseRayFromPoint(final Line line, final Vector2D endPoint) {
-        return reverseRayFromLocation(line, line.abscissa(endPoint));
+        if (!endPoint.isFinite()) {
+            throw new IllegalArgumentException("Invalid reverse ray end point: " + endPoint);
+        }
+        return new ReverseRay(line, line.project(endPoint));
     }
 
     /** Construct a reverse ray starting at infinity and continuing in the direction of {@code line}
@@ -161,7 +166,7 @@ public final class Lines {
             throw new IllegalArgumentException("Invalid reverse ray end location: " + Double.toString(endLocation));
         }
 
-        return new ReverseRay(line, endLocation);
+        return new ReverseRay(line, line.toSpace(endLocation));
     }
 
     /** Construct a new line segment from two points. A new line is created for the segment and points in the
@@ -212,7 +217,7 @@ public final class Lines {
             final double min = Math.min(a, b);
             final double max = Math.max(a, b);
 
-            return new Segment(line, min, max);
+            return new Segment(line, line.toSpace(min), line.toSpace(max));
         }
 
         throw new IllegalArgumentException(
@@ -260,13 +265,13 @@ public final class Lines {
         if (hasMin) {
             if (hasMax) {
                 // has both
-                return new Segment(line, min, max);
+                return new Segment(line, line.toSpace(min), line.toSpace(max));
             }
             // min only
-            return new Ray(line, min);
+            return new Ray(line, line.toSpace(min));
         } else if (hasMax) {
             // max only
-            return new ReverseRay(line, max);
+            return new ReverseRay(line, line.toSpace(max));
         } else if (Double.isInfinite(min) && Double.isInfinite(max) && Double.compare(min, max) < 0) {
             return new LineSpanningSubset(line);
         }
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/Ray.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/Ray.java
index c817727..304b8a7 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/Ray.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/Ray.java
@@ -19,6 +19,7 @@ package org.apache.commons.geometry.euclidean.twod;
 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.precision.DoublePrecisionContext;
 
 /** Class representing a ray in 2D Euclidean space. A ray is a portion of a line consisting of
  * a single start point and extending to infinity along the direction of the line.
@@ -29,26 +30,18 @@ import org.apache.commons.geometry.core.partitioning.Split;
  */
 public final class Ray extends LineConvexSubset {
 
-    /** The start abscissa value for the ray. */
-    private final double start;
+    /** The start point for the ray. */
+    private final Vector2D startPoint;
 
-    /** Construct a ray from a line and a start point. The start point is projected
-     * onto the line. No validation is performed.
+    /** Construct a ray from a line and a start point. Callers are responsible for ensuring that the
+     * given point lies on the line. No validation is performed.
      * @param line line for the ray
      * @param startPoint start point for the ray
      */
     Ray(final Line line, final Vector2D startPoint) {
-        this(line, line.abscissa(startPoint));
-    }
-
-    /** Construct a ray from a line and a 1D start location. No validation is performed.
-     * @param line line for the ray
-     * @param start 1D start location
-     */
-    Ray(final Line line, final double start) {
         super(line);
 
-        this.start = start;
+        this.startPoint = startPoint;
     }
 
     /** {@inheritDoc}
@@ -87,15 +80,24 @@ public final class Ray extends LineConvexSubset {
         return Double.POSITIVE_INFINITY;
     }
 
+    /** {@inheritDoc}
+     *
+     * <p>This method always returns {@code null}.</p>
+     */
+    @Override
+    public Vector2D getBarycenter() {
+        return null;
+    }
+
     @Override
     public Vector2D getStartPoint() {
-        return getLine().toSpace(start);
+        return startPoint;
     }
 
     /** {@inheritDoc} */
     @Override
     public double getSubspaceStart() {
-        return start;
+        return getLine().abscissa(startPoint);
     }
 
     /** {@inheritDoc}
@@ -108,14 +110,23 @@ public final class Ray extends LineConvexSubset {
     }
 
     /** {@inheritDoc}
-    *
-    * <p>This method always returns {@link Double#POSITIVE_INFINITY}.</p>
-    */
+     *
+     * <p>This method always returns {@link Double#POSITIVE_INFINITY}.</p>
+     */
     @Override
     public double getSubspaceEnd() {
         return Double.POSITIVE_INFINITY;
     }
 
+    /** {@inheritDoc}
+     *
+     * <p>This method always returns {@code null}.</p>
+     */
+    @Override
+    public Bounds2D getBounds() {
+        return null; // infinite; no bounds
+    }
+
     /** Get the direction of the ray. This is a convenience method for {@code ray.getLine().getDirection()}.
      * @return the direction of the ray
      */
@@ -135,7 +146,7 @@ public final class Ray extends LineConvexSubset {
     /** {@inheritDoc} */
     @Override
     public ReverseRay reverse() {
-        return new ReverseRay(getLine().reverse(), -start);
+        return new ReverseRay(getLine().reverse(), startPoint);
     }
 
     /** {@inheritDoc} */
@@ -155,7 +166,7 @@ public final class Ray extends LineConvexSubset {
     /** {@inheritDoc} */
     @Override
     RegionLocation classifyAbscissa(double abscissa) {
-        int cmp = getPrecision().compare(abscissa, start);
+        int cmp = getPrecision().compare(abscissa, getSubspaceStart());
         if (cmp > 0) {
             return RegionLocation.INSIDE;
         } else if (cmp == 0) {
@@ -168,27 +179,33 @@ public final class Ray extends LineConvexSubset {
     /** {@inheritDoc} */
     @Override
     double closestAbscissa(double abscissa) {
-        return Math.max(start, abscissa);
+        return Math.max(getSubspaceStart(), abscissa);
     }
 
     /** {@inheritDoc} */
     @Override
     Split<LineConvexSubset> splitOnIntersection(final Line splitter, final Vector2D intersection) {
-
         final Line line = getLine();
-        final double splitAbscissa = line.abscissa(intersection);
+        final DoublePrecisionContext splitterPrecision = splitter.getPrecision();
 
-        LineConvexSubset low = null;
-        LineConvexSubset high = null;
+        final int startCmp = splitterPrecision.compare(splitter.offset(startPoint), 0.0);
+        final boolean pointsTowardPlus = splitter.getOffsetDirection().dot(line.getDirection()) >= 0.0;
 
-        int cmp = getPrecision().compare(splitAbscissa, start);
-        if (cmp > 0) {
-            low = new Segment(line, start, splitAbscissa);
-            high = new Ray(line, splitAbscissa);
-        } else {
-            high = this;
+        if (pointsTowardPlus && startCmp > -1) {
+            // entirely on plus side
+            return new Split<>(null, this);
+        } else if (!pointsTowardPlus && startCmp < 1) {
+            // entirely on minus side
+            return new Split<>(this, null);
         }
 
-        return createSplitResult(splitter, low, high);
+        // we're going to be split
+        final Segment splitSeg = new Segment(line, startPoint, intersection);
+        final Ray splitRay = new Ray(line, intersection);
+
+        final LineConvexSubset minus = (startCmp > 0) ? splitRay : splitSeg;
+        final LineConvexSubset plus = (startCmp > 0) ? splitSeg : splitRay;
+
+        return new Split<>(minus, plus);
     }
 }
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/ReverseRay.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/ReverseRay.java
index 766b912..6abec6a 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/ReverseRay.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/ReverseRay.java
@@ -19,6 +19,7 @@ package org.apache.commons.geometry.euclidean.twod;
 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.precision.DoublePrecisionContext;
 
 /** Class representing a portion of a line in 2D Euclidean space that starts at infinity and
  * continues in the direction of the line up to a single end point. This is equivalent to taking a
@@ -30,26 +31,18 @@ import org.apache.commons.geometry.core.partitioning.Split;
  */
 public final class ReverseRay extends LineConvexSubset {
 
-    /** The abscissa of the endpoint. */
-    private final double end;
+    /** The end point of the reverse ray. */
+    private final Vector2D endPoint;
 
-    /** Construct a new instance from the given line and end point. The end point is projected onto
-     * the line. No validation is performed.
+    /** Construct a new instance from the given line and end point. Callers are responsible for ensuring that
+     * the given end point lies on the line. No validation is performed.
      * @param line line for the instance
      * @param endPoint end point for the instance
      */
     ReverseRay(final Line line, final Vector2D endPoint) {
-        this(line, line.abscissa(endPoint));
-    }
-
-    /** Construct a new instance from the given line and 1D end location. No validation is performed.
-     * @param line line for the instance
-     * @param end end location for the instance
-     */
-    ReverseRay(final Line line, final double end) {
         super(line);
 
-        this.end = end;
+        this.endPoint = endPoint;
     }
 
     /** {@inheritDoc}
@@ -62,36 +55,45 @@ public final class ReverseRay extends LineConvexSubset {
     }
 
     /** {@inheritDoc}
-    *
-    * <p>This method always returns {@code true}.</p>
-    */
+     *
+     * <p>This method always returns {@code true}.</p>
+     */
     @Override
     public boolean isInfinite() {
         return true;
     }
 
     /** {@inheritDoc}
-    *
-    * <p>This method always returns {@code false}.</p>
-    */
+     *
+     * <p>This method always returns {@code false}.</p>
+     */
     @Override
     public boolean isFinite() {
         return false;
     }
 
     /** {@inheritDoc}
-    *
-    * <p>This method always returns {@link Double#POSITIVE_INFINITY}.</p>
-    */
+     *
+     * <p>This method always returns {@link Double#POSITIVE_INFINITY}.</p>
+     */
     @Override
     public double getSize() {
         return Double.POSITIVE_INFINITY;
     }
 
     /** {@inheritDoc}
-    *
-    * <p>This method always returns {@code null}.</p>
-    */
+     *
+     * <p>This method always returns {@code null}.</p>
+     */
+    @Override
+    public Vector2D getBarycenter() {
+        return null;
+    }
+
+    /** {@inheritDoc}
+     *
+     * <p>This method always returns {@code null}.</p>
+     */
     @Override
     public Vector2D getStartPoint() {
         return null;
@@ -109,13 +111,22 @@ public final class ReverseRay extends LineConvexSubset {
     /** {@inheritDoc} */
     @Override
     public Vector2D getEndPoint() {
-        return getLine().toSpace(end);
+        return endPoint;
     }
 
     /** {@inheritDoc} */
     @Override
     public double getSubspaceEnd() {
-        return end;
+        return getLine().abscissa(endPoint);
+    }
+
+    /** {@inheritDoc}
+    *
+    * <p>This method always returns {@code null}.</p>
+    */
+    @Override
+    public Bounds2D getBounds() {
+        return null; // infinite; no bounds
     }
 
     /** {@inheritDoc} */
@@ -130,7 +141,7 @@ public final class ReverseRay extends LineConvexSubset {
     /** {@inheritDoc} */
     @Override
     public Ray reverse() {
-        return new Ray(getLine().reverse(), -end);
+        return new Ray(getLine().reverse(), endPoint);
     }
 
     /** {@inheritDoc} */
@@ -150,7 +161,7 @@ public final class ReverseRay extends LineConvexSubset {
     /** {@inheritDoc} */
     @Override
     RegionLocation classifyAbscissa(double abscissa) {
-        int cmp = getPrecision().compare(abscissa, end);
+        int cmp = getPrecision().compare(abscissa, getSubspaceEnd());
         if (cmp < 0) {
             return RegionLocation.INSIDE;
         } else if (cmp == 0) {
@@ -163,27 +174,33 @@ public final class ReverseRay extends LineConvexSubset {
     /** {@inheritDoc} */
     @Override
     double closestAbscissa(double abscissa) {
-        return Math.min(end, abscissa);
+        return Math.min(getSubspaceEnd(), abscissa);
     }
 
     /** {@inheritDoc} */
     @Override
     protected Split<LineConvexSubset> splitOnIntersection(final Line splitter, final Vector2D intersection) {
-
         final Line line = getLine();
-        final double splitAbscissa = line.abscissa(intersection);
+        final DoublePrecisionContext splitterPrecision = splitter.getPrecision();
 
-        LineConvexSubset low = null;
-        LineConvexSubset high = null;
+        final int endCmp = splitterPrecision.compare(splitter.offset(endPoint), 0.0);
+        final boolean pointsTowardPlus = splitter.getOffsetDirection().dot(line.getDirection()) >= 0.0;
 
-        int cmp = getPrecision().compare(splitAbscissa, end);
-        if (cmp < 0) {
-            low = new ReverseRay(line, splitAbscissa);
-            high = new Segment(line, splitAbscissa, end);
-        } else {
-            low = this;
+        if (pointsTowardPlus && endCmp < 1) {
+            // entirely on minus side
+            return new Split<>(this, null);
+        } else if (!pointsTowardPlus && endCmp > -1) {
+            // entirely on plus side
+            return new Split<>(null, this);
         }
 
-        return createSplitResult(splitter, low, high);
+        // we're going to be split
+        final Segment splitSeg = new Segment(line, intersection, endPoint);
+        final ReverseRay splitRevRay = new ReverseRay(line, intersection);
+
+        final LineConvexSubset minus = (endCmp > 0) ? splitRevRay : splitSeg;
+        final LineConvexSubset plus = (endCmp > 0) ? splitSeg : splitRevRay;
+
+        return new Split<>(minus, plus);
     }
 }
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 0464ccd..8fa77b0 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
@@ -30,48 +30,39 @@ import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
  */
 public final class Segment extends LineConvexSubset {
 
-    /** Start abscissa for the segment. */
-    private final double start;
+    /** Start point for the segment. */
+    private final Vector2D startPoint;
 
-    /** End abscissa for the segment. */
-    private final double end;
+    /** End point for the segment. */
+    private final Vector2D endPoint;
 
-    /** Construct a new instance from a line and two points on the line. The points are projected onto
-     * the line and must be in order of increasing abscissa. No validation is performed.
+    /** Construct a new instance from a line and two points on the line. Callers are responsible for
+     * ensuring that the given points lie on the line and are in order of increasing abscissa.
+     * No validation is performed.
      * @param line line for the segment
      * @param startPoint segment start point
      * @param endPoint segment end point
      */
     Segment(final Line line, final Vector2D startPoint, final Vector2D endPoint) {
-        this(line, line.abscissa(startPoint), line.abscissa(endPoint));
-    }
-
-    /** Construct a new instance from a line and two abscissa locations on the line.
-     * The abscissa locations must be in increasing order. No validation is performed.
-     * @param line line for the segment
-     * @param start abscissa start location
-     * @param end abscissa end location
-     */
-    Segment(final Line line, final double start, final double end) {
         super(line);
 
-        this.start = start;
-        this.end = end;
+        this.startPoint = startPoint;
+        this.endPoint = endPoint;
     }
 
     /** {@inheritDoc}
-    *
-    * <p>This method always returns {@code false}.</p>
-    */
+     *
+     * <p>This method always returns {@code false}.</p>
+     */
     @Override
     public boolean isFull() {
         return false;
     }
 
     /** {@inheritDoc}
-    *
-    * <p>This method always returns {@code false}.</p>
-    */
+     *
+     * <p>This method always returns {@code false}.</p>
+     */
     @Override
     public boolean isInfinite() {
         return false;
@@ -89,31 +80,46 @@ public final class Segment extends LineConvexSubset {
     /** {@inheritDoc} */
     @Override
     public double getSize() {
-        return end - start;
+        return startPoint.distance(endPoint);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Vector2D getBarycenter() {
+        return startPoint.lerp(endPoint, 0.5);
     }
 
     /** {@inheritDoc} */
     @Override
     public Vector2D getStartPoint() {
-        return getLine().toSpace(start);
+        return startPoint;
     }
 
     /** {@inheritDoc} */
     @Override
     public double getSubspaceStart() {
-        return start;
+        return getLine().abscissa(startPoint);
     }
 
     /** {@inheritDoc} */
     @Override
     public Vector2D getEndPoint() {
-        return getLine().toSpace(end);
+        return endPoint;
     }
 
     /** {@inheritDoc} */
     @Override
     public double getSubspaceEnd() {
-        return end;
+        return getLine().abscissa(endPoint);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Bounds2D getBounds() {
+        return Bounds2D.builder()
+                .add(startPoint)
+                .add(endPoint)
+                .build();
     }
 
     /** {@inheritDoc} */
@@ -130,7 +136,7 @@ public final class Segment extends LineConvexSubset {
     /** {@inheritDoc} */
     @Override
     public Segment reverse() {
-        return new Segment(getLine().reverse(), -end, -start);
+        return new Segment(getLine().reverse(), endPoint, startPoint);
     }
 
     /** {@inheritDoc} */
@@ -151,9 +157,9 @@ public final class Segment extends LineConvexSubset {
     @Override
     RegionLocation classifyAbscissa(final double abscissa) {
         final DoublePrecisionContext precision = getPrecision();
-        int startCmp = precision.compare(abscissa, start);
+        int startCmp = precision.compare(abscissa, getSubspaceStart());
         if (startCmp > 0) {
-            int endCmp = precision.compare(abscissa, end);
+            int endCmp = precision.compare(abscissa, getSubspaceEnd());
             if (endCmp < 0) {
                 return RegionLocation.INSIDE;
             } else if (endCmp == 0) {
@@ -169,32 +175,37 @@ public final class Segment extends LineConvexSubset {
     /** {@inheritDoc} */
     @Override
     double closestAbscissa(final double abscissa) {
-        return Math.max(start, Math.min(end, abscissa));
+        return Math.max(getSubspaceStart(), Math.min(getSubspaceEnd(), abscissa));
     }
 
     /** {@inheritDoc} */
     @Override
     Split<LineConvexSubset> splitOnIntersection(final Line splitter, final Vector2D intersection) {
         final Line line = getLine();
-        final double splitAbscissa = line.abscissa(intersection);
 
-        Segment low = null;
-        Segment high = null;
+        final DoublePrecisionContext splitterPrecision = splitter.getPrecision();
 
-        final DoublePrecisionContext precision = getPrecision();
-        int startCmp = precision.compare(splitAbscissa, start);
-        if (startCmp <= 0) {
-            high = this;
-        }  else {
-            int endCmp = precision.compare(splitAbscissa, end);
-            if (endCmp >= 0) {
-                low = this;
-            } else {
-                low = new Segment(line, start, splitAbscissa);
-                high = new Segment(line, splitAbscissa, end);
-            }
+        final int startCmp = splitterPrecision.compare(splitter.offset(startPoint), 0.0);
+        final int endCmp = splitterPrecision.compare(splitter.offset(endPoint), 0.0);
+
+        if (startCmp == 0 && endCmp == 0) {
+            // the entire segment is directly on the splitter line
+            return new Split<>(null, null);
+        } else if (startCmp < 1 && endCmp < 1) {
+            // the entire segment is on the minus side
+            return new Split<>(this, null);
+        } else if (startCmp > -1 && endCmp > -1) {
+            // the entire segment is on the plus side
+            return new Split<>(null, this);
         }
 
-        return createSplitResult(splitter, low, high);
+        // we need to split the line
+        final Segment startSegment = new Segment(line, startPoint, intersection);
+        final Segment endSegment = new Segment(line, intersection, endPoint);
+
+        final Segment minus = (startCmp > 0) ? endSegment : startSegment;
+        final Segment plus = (startCmp > 0) ? startSegment : endSegment;
+
+        return new Split<>(minus, plus);
     }
 }
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 369a90e..2d9df38 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 java.util.Arrays;
 import java.util.Comparator;
+import java.util.Iterator;
 import java.util.function.UnaryOperator;
 
 import org.apache.commons.geometry.core.internal.DoubleFunction2N;
@@ -453,6 +455,143 @@ public class Vector2D extends MultiDimensionalEuclideanVector<Vector2D> {
         return SimpleTupleFormat.getDefault().parse(str, Vector2D::new);
     }
 
+    /** Return a vector containing the maximum component values from all input vectors.
+     * @param first first vector
+     * @param more additional vectors
+     * @return a vector containing the maximum component values from all input vectors
+     */
+    public static Vector2D max(final Vector2D first, final Vector2D... more) {
+        return computeMax(first, Arrays.asList(more).iterator());
+    }
+
+    /** Return a vector containing the maximum component values from all input vectors.
+     * @param vecs input vectors
+     * @return a vector containing the maximum component values from all input vectors
+     * @throws IllegalArgumentException if the argument does not contain any vectors
+     */
+    public static Vector2D max(final Iterable<Vector2D> vecs) {
+        final Iterator<Vector2D> it = vecs.iterator();
+        if (!it.hasNext()) {
+            throw new IllegalArgumentException("Cannot compute vector max: no vectors given");
+        }
+
+        return computeMax(it.next(), it);
+    }
+
+    /** Internal method for computing a max vector.
+     * @param first first vector
+     * @param more iterator with additional vectors
+     * @return vector containing the maximum component values of all input vectors
+     */
+    private static Vector2D computeMax(final Vector2D first, final Iterator<Vector2D> more) {
+        double x = first.getX();
+        double y = first.getY();
+
+        Vector2D vec;
+        while (more.hasNext()) {
+            vec = more.next();
+
+            x = Math.max(x, vec.getX());
+            y = Math.max(y, vec.getY());
+        }
+
+        return Vector2D.of(x, y);
+    }
+
+    /** Return a vector containing the minimum component values from all input vectors.
+     * @param first first vector
+     * @param more more vectors
+     * @return a vector containing the minimum component values from all input vectors
+     */
+    public static Vector2D min(final Vector2D first, final Vector2D... more) {
+        return computeMin(first, Arrays.asList(more).iterator());
+    }
+
+    /** Return a vector containing the minimum component values from all input vectors.
+     * @param vecs input vectors
+     * @return a vector containing the minimum component values from all input vectors
+     * @throws IllegalArgumentException if the argument does not contain any vectors
+     */
+    public static Vector2D min(final Iterable<Vector2D> vecs) {
+        final Iterator<Vector2D> it = vecs.iterator();
+        if (!it.hasNext()) {
+            throw new IllegalArgumentException("Cannot compute vector min: no vectors given");
+        }
+
+        return computeMin(it.next(), it);
+    }
+
+    /** Internal method for computing a min vector.
+     * @param first first vector
+     * @param more iterator with additional vectors
+     * @return vector containing the minimum component values of all input vectors
+     */
+    private static Vector2D computeMin(final Vector2D first, final Iterator<Vector2D> more) {
+        double x = first.getX();
+        double y = first.getY();
+
+        Vector2D vec;
+        while (more.hasNext()) {
+            vec = more.next();
+
+            x = Math.min(x, vec.getX());
+            y = Math.min(y, vec.getY());
+        }
+
+        return Vector2D.of(x, y);
+    }
+
+    /** Compute the centroid of the given points. The centroid is the arithmetic mean position of a set
+     * of points.
+     * @param first first point
+     * @param more additional points
+     * @return the centroid of the given points
+     */
+    public static Vector2D centroid(final Vector2D first, final Vector2D... more) {
+        return computeCentroid(first, Arrays.asList(more).iterator());
+    }
+
+    /** Compute the centroid of the given points. The centroid is the arithmetic mean position of a set
+     * of points.
+     * @param pts the points to compute the centroid of
+     * @return the centroid of the given points
+     * @throws IllegalArgumentException if the argument contains no points
+     */
+    public static Vector2D centroid(final Iterable<Vector2D> pts) {
+        final Iterator<Vector2D> it = pts.iterator();
+        if (!it.hasNext()) {
+            throw new IllegalArgumentException("Cannot compute centroid: no points given");
+        }
+
+        return computeCentroid(it.next(), it);
+    }
+
+    /** Internal method for computing the centroid of a set of points.
+     * @param first first point
+     * @param more iterator with additional points
+     * @return the centroid of the point set
+     */
+    private static Vector2D computeCentroid(final Vector2D first, final Iterator<Vector2D> more) {
+        double x = first.getX();
+        double y = first.getY();
+
+        int count = 1;
+
+        Vector2D pt;
+        while (more.hasNext()) {
+            pt = more.next();
+
+            x += pt.getX();
+            y += pt.getY();
+
+            ++count;
+        }
+
+        double invCount = 1.0 / count;
+
+        return new Vector2D(invCount * x, invCount * y);
+    }
+
     /** Returns a vector consisting of the linear combination of the inputs.
      * <p>
      * A linear combination is the sum of all of the inputs multiplied by their
@@ -602,6 +741,12 @@ public class Vector2D extends MultiDimensionalEuclideanVector<Vector2D> {
 
         /** {@inheritDoc} */
         @Override
+        public Vector2D.Unit orthogonal() {
+            return new Unit(-getY(), getX());
+        }
+
+        /** {@inheritDoc} */
+        @Override
         public Vector2D withNorm(final double mag) {
             return multiply(mag);
         }
diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/path/LinePath.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/path/LinePath.java
index 14c09d0..0b78b40 100644
--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/path/LinePath.java
+++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/path/LinePath.java
@@ -324,7 +324,7 @@ public class LinePath implements BoundarySource2D, Sized {
                     .append(", ");
             }
 
-            sb.append("vertices= ")
+            sb.append("vertexSequence= ")
                 .append(getVertexSequence());
 
             final LineConvexSubset endElement = getEnd();
@@ -392,6 +392,7 @@ public class LinePath implements BoundarySource2D, Sized {
      * @param precision precision context used to construct the line segment
      *      instances for the path
      * @return new closed path constructed from the given vertices
+     * @throws IllegalStateException if {@code vertices} contains only a single unique vertex
      * @see #fromVertices(Collection, boolean, DoublePrecisionContext)
      */
     public static LinePath fromVertexLoop(final Collection<Vector2D> vertices,
@@ -408,6 +409,7 @@ public class LinePath implements BoundarySource2D, Sized {
      * @param precision precision context used to construct the line segment
      *      instances for the path
      * @return new path constructed from the given vertices
+     * @throws IllegalStateException if {@code vertices} contains only a single unique vertex
      * @see #fromVertices(Collection, boolean, DoublePrecisionContext)
      */
     public static LinePath fromVertices(final Collection<Vector2D> vertices,
@@ -424,6 +426,7 @@ public class LinePath implements BoundarySource2D, Sized {
      * @param precision precision context used to construct the line segment
      *      instances for the path
      * @return new path constructed from the given vertices
+     * @throws IllegalStateException if {@code vertices} contains only a single unique vertex
      */
     public static LinePath fromVertices(final Collection<Vector2D> vertices,
             final boolean close, final DoublePrecisionContext precision) {
@@ -666,6 +669,7 @@ public class LinePath implements BoundarySource2D, Sized {
         /** Close the current path and build a new {@link LinePath} instance.  This method is equivalent
          * to {@code builder.build(true)}.
          * @return new closed path instance
+         * @throws IllegalStateException if the builder was given only a single unique vertex
          */
         public LinePath close() {
             return build(true);
@@ -674,6 +678,7 @@ public class LinePath implements BoundarySource2D, Sized {
         /** Build a {@link LinePath} instance from the configured path. This method is equivalent
          * to {@code builder.build(false)}.
          * @return new path instance
+         * @throws IllegalStateException if the builder was given only a single unique vertex
          */
         public LinePath build() {
             return build(false);
@@ -683,6 +688,7 @@ public class LinePath implements BoundarySource2D, Sized {
          * @param close if true, the path will be closed by adding an end point equivalent to the
          *      start point
          * @return new path instance
+         * @throws IllegalStateException if the builder was given only a single unique vertex
          */
         public LinePath build(final boolean close) {
             if (close) {
@@ -711,7 +717,7 @@ public class LinePath implements BoundarySource2D, Sized {
 
             if (result.isEmpty() && startVertex != null) {
                 throw new IllegalStateException(
-                        MessageFormat.format("Unable to create line path; only a single vertex provided: {0} ",
+                        MessageFormat.format("Unable to create line path; only a single unique vertex provided: {0} ",
                                 startVertex));
             }
 
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/DocumentationExamplesTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/DocumentationExamplesTest.java
index 9476733..0051704 100644
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/DocumentationExamplesTest.java
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/DocumentationExamplesTest.java
@@ -29,6 +29,7 @@ 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.threed.AffineTransformMatrix3D;
+import org.apache.commons.geometry.euclidean.threed.ConvexPolygon3D;
 import org.apache.commons.geometry.euclidean.threed.Plane;
 import org.apache.commons.geometry.euclidean.threed.PlaneConvexSubset;
 import org.apache.commons.geometry.euclidean.threed.Planes;
@@ -407,27 +408,27 @@ public class DocumentationExamplesTest {
     public void testRegionBSPTree3DExample() {
         DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-6);
 
-        // create the faces of a pyrmaid with a square base and its apex pointing along the
+        // create the faces of a pyramid with a square base and its apex pointing along the
         // positive z axis
-        Vector3D a1 = Vector3D.Unit.PLUS_Z;
-        Vector3D b1 = Vector3D.of(0.5, 0.5, 0.0);
-        Vector3D b2 = Vector3D.of(0.5, -0.5, 0.0);
-        Vector3D b3 = Vector3D.of(-0.5, -0.5, 0.0);
-        Vector3D b4 = Vector3D.of(-0.5, 0.5, 0.0);
-
-        Vector3D[][] faceIndices = {
-            {b1, a1, b2},
-            {b2, a1, b3},
-            {b3, a1, b4},
-            {b4, a1, b1},
-            {b1, b2, b3, b4}
+        Vector3D[] vertices = {
+            Vector3D.Unit.PLUS_Z,
+            Vector3D.of(0.5, 0.5, 0.0),
+            Vector3D.of(0.5, -0.5, 0.0),
+            Vector3D.of(-0.5, -0.5, 0.0),
+            Vector3D.of(-0.5, 0.5, 0.0)
         };
 
-        // convert the vertices to convex subplanes and insert into a bsp tree
-        RegionBSPTree3D tree = RegionBSPTree3D.empty();
-        Arrays.stream(faceIndices)
-            .map(vertices -> Planes.subsetFromVertexLoop(Arrays.asList(vertices), precision))
-            .forEach(tree::insert);
+        int[][] faceIndices = {
+            {1, 0, 2},
+            {2, 0, 3},
+            {3, 0, 4},
+            {4, 0, 1},
+            {1, 2, 3, 4}
+        };
+
+        // convert the vertices and faces to convex polygons and use to construct a BSP tree
+        List<ConvexPolygon3D> faces = Planes.indexedConvexPolygons(vertices, faceIndices, precision);
+        RegionBSPTree3D tree = RegionBSPTree3D.from(faces);
 
         // split the region through its barycenter along a diagonal of the base
         Plane cutter = Planes.fromPointAndNormal(tree.getBarycenter(), Vector3D.Unit.from(1, 1, 0), precision);
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 99d8f0e..33fa04c 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,9 +16,12 @@
  */
 package org.apache.commons.geometry.euclidean;
 
+import java.util.List;
+
 import org.apache.commons.geometry.core.Region;
 import org.apache.commons.geometry.core.RegionLocation;
 import org.apache.commons.geometry.core.partitioning.HyperplaneSubset;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
 import org.apache.commons.geometry.euclidean.oned.Vector1D;
 import org.apache.commons.geometry.euclidean.threed.Vector3D;
 import org.apache.commons.geometry.euclidean.twod.Vector2D;
@@ -186,6 +189,46 @@ public final class EuclideanTestUtils {
     }
 
     /**
+     * Assert that the given lists represent equivalent vertex loops. The loops must contain the same sequence
+     * of vertices but do not need to start at the same point.
+     * @param expected
+     * @param actual
+     * @param precision
+     */
+    public static void assertVertexLoopSequence(List<Vector3D> expected, List<Vector3D> actual,
+            DoublePrecisionContext precision) {
+        Assert.assertEquals("Vertex sequences have different sizes", expected.size(), actual.size());
+
+        if (expected.size() > 0) {
+
+            int offset = -1;
+            Vector3D start = expected.get(0);
+            for (int i = 0; i < actual.size(); ++i) {
+                if (actual.get(i).eq(start, precision)) {
+                    offset = i;
+                    break;
+                }
+            }
+
+            if (offset < 0) {
+                Assert.fail("Vertex loops do not share any points: expected " + expected + " but was " + actual);
+            }
+
+            Vector3D expectedVertex;
+            Vector3D actualVertex;
+            for (int i = 0; i < expected.size(); ++i) {
+                expectedVertex = expected.get(i);
+                actualVertex = actual.get((i + offset) % actual.size());
+
+                if (!expectedVertex.eq(actualVertex, precision)) {
+                    Assert.fail("Unexpected vertex at index " + i + ": expected " + expectedVertex +
+                            " but was " + actualVertex);
+                }
+            }
+        }
+    }
+
+    /**
      * Asserts that the given value is negative infinity..
      *
      * @param value
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 e9b19f9..249d89b 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
@@ -424,7 +424,7 @@ public class OrientedPointTest {
     @Test
     public void testSubset_simpleMethods() {
         // arrange
-        OrientedPoint pt = OrientedPoints.createPositiveFacing(0, TEST_PRECISION);
+        OrientedPoint pt = OrientedPoints.createPositiveFacing(2, TEST_PRECISION);
         HyperplaneConvexSubset<Vector1D> sub = pt.span();
 
         // act/assert
@@ -434,6 +434,7 @@ public class OrientedPointTest {
         Assert.assertFalse(sub.isInfinite());
         Assert.assertTrue(sub.isFinite());
         Assert.assertEquals(0.0, sub.getSize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector1D.of(2), sub.getBarycenter(), TEST_EPS);
 
         List<? extends HyperplaneConvexSubset<Vector1D>> list = sub.toConvex();
         Assert.assertEquals(1, list.size());
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/PlaneSubsetTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/AbstractPlaneSubsetTest.java
similarity index 77%
rename from commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/PlaneSubsetTest.java
rename to commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/AbstractPlaneSubsetTest.java
index 12308fe..846a95e 100644
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/PlaneSubsetTest.java
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/AbstractPlaneSubsetTest.java
@@ -23,25 +23,25 @@ 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.partitioning.Hyperplane;
-import org.apache.commons.geometry.core.partitioning.HyperplaneBoundedRegion;
 import org.apache.commons.geometry.core.partitioning.HyperplaneConvexSubset;
 import org.apache.commons.geometry.core.partitioning.HyperplaneSubset;
 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.twod.ConvexArea;
+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 PlaneSubsetTest {
+public class AbstractPlaneSubsetTest {
 
     private static final double TEST_EPS = 1e-10;
 
     private static final DoublePrecisionContext TEST_PRECISION =
             new EpsilonDoublePrecisionContext(TEST_EPS);
 
-    private static final Plane XY_PLANE = Planes.fromPointAndPlaneVectors(Vector3D.ZERO,
+    private static final EmbeddingPlane XY_PLANE = Planes.fromPointAndPlaneVectors(Vector3D.ZERO,
             Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
 
     @Test
@@ -67,7 +67,7 @@ public class PlaneSubsetTest {
     @Test
     public void testBuilder_addSingleConvex_returnsSameInstance() {
         // arrange
-        PlaneConvexSubset convex = Planes.subsetFromVertexLoop(Arrays.asList(
+        PlaneConvexSubset convex = Planes.convexPolygonFromVertices(Arrays.asList(
                     Vector3D.ZERO,
                     Vector3D.of(1, 0, 0),
                     Vector3D.of(0, 1, 0)
@@ -87,7 +87,7 @@ public class PlaneSubsetTest {
     @Test
     public void testBuilder_addSingleTreeSubset() {
         // arrange
-        ConvexArea area = ConvexArea.fromVertexLoop(Arrays.asList(
+        ConvexArea area = ConvexArea.convexPolygonFromVertices(Arrays.asList(
                     Vector2D.ZERO,
                     Vector2D.of(1, 0),
                     Vector2D.of(0, 1)
@@ -122,17 +122,17 @@ public class PlaneSubsetTest {
     @Test
     public void testBuilder_addMixed_convexFirst() {
         // arrange
-        Plane mainPlane = Planes.fromPointAndPlaneVectors(
+        EmbeddingPlane mainPlane = Planes.fromPointAndPlaneVectors(
                 Vector3D.of(0, 0, 1), Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
 
-        ConvexArea a = ConvexArea.fromVertexLoop(
+        ConvexArea a = ConvexArea.convexPolygonFromVertices(
                 Arrays.asList(Vector2D.ZERO, Vector2D.of(1, 0), Vector2D.of(1, 1).normalize()), TEST_PRECISION);
-        ConvexArea b = ConvexArea.fromVertexLoop(
+        ConvexArea b = ConvexArea.convexPolygonFromVertices(
                 Arrays.asList(Vector2D.ZERO, Vector2D.of(1, 1).normalize(), Vector2D.of(0, 1)), TEST_PRECISION);
-        ConvexArea c = ConvexArea.fromVertexLoop(
+        ConvexArea c = ConvexArea.convexPolygonFromVertices(
                 Arrays.asList(Vector2D.Unit.PLUS_X, Vector2D.of(1, 1), Vector2D.Unit.PLUS_Y), TEST_PRECISION);
 
-        Plane closePlane = Planes.fromPointAndPlaneVectors(
+        EmbeddingPlane closePlane = Planes.fromPointAndPlaneVectors(
                 Vector3D.of(1e-16, 0, 1), Vector3D.of(1, 1e-16, 0), Vector3D.Unit.PLUS_Y, TEST_PRECISION);
 
         // act
@@ -161,18 +161,18 @@ public class PlaneSubsetTest {
 
     @Test
     public void testBuilder_addMixed_treeSubsetFirst() {
-     // arrange
-        Plane mainPlane = Planes.fromPointAndPlaneVectors(
+        // arrange
+        EmbeddingPlane mainPlane = Planes.fromPointAndPlaneVectors(
                 Vector3D.of(0, 0, 1), Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
 
-        ConvexArea a = ConvexArea.fromVertexLoop(
+        ConvexArea a = ConvexArea.convexPolygonFromVertices(
                 Arrays.asList(Vector2D.ZERO, Vector2D.of(1, 0), Vector2D.of(1, 1).normalize()), TEST_PRECISION);
-        ConvexArea b = ConvexArea.fromVertexLoop(
+        ConvexArea b = ConvexArea.convexPolygonFromVertices(
                 Arrays.asList(Vector2D.ZERO, Vector2D.of(1, 1).normalize(), Vector2D.of(0, 1)), TEST_PRECISION);
-        ConvexArea c = ConvexArea.fromVertexLoop(
+        ConvexArea c = ConvexArea.convexPolygonFromVertices(
                 Arrays.asList(Vector2D.Unit.PLUS_X, Vector2D.of(1, 1), Vector2D.Unit.PLUS_Y), TEST_PRECISION);
 
-        Plane closePlane = Planes.fromPointAndPlaneVectors(
+        EmbeddingPlane closePlane = Planes.fromPointAndPlaneVectors(
                 Vector3D.of(1e-16, 0, 1), Vector3D.of(1, 1e-16, 0), Vector3D.Unit.PLUS_Y, TEST_PRECISION);
 
         // act
@@ -200,7 +200,7 @@ public class PlaneSubsetTest {
     }
 
     @Test
-    public void testBuilder_nullArguments() {
+    public void testBuilder_add_nullArguments() {
         // arrange
         HyperplaneSubset.Builder<Vector3D> builder = XY_PLANE.span().builder();
 
@@ -215,9 +215,9 @@ public class PlaneSubsetTest {
     }
 
     @Test
-    public void testBuilder_argumentsFromDifferentPlanes() {
+    public void testBuilder_add_argumentsFromDifferentPlanes() {
         // arrange
-        PlaneConvexSubset convex = Planes.subsetFromVertexLoop(Arrays.asList(
+        ConvexPolygon3D convex = Planes.convexPolygonFromVertices(Arrays.asList(
                 Vector3D.ZERO,
                 Vector3D.of(1, 0, 1),
                 Vector3D.of(0, 1, 1)
@@ -225,24 +225,27 @@ public class PlaneSubsetTest {
 
         HyperplaneSubset.Builder<Vector3D> builder = XY_PLANE.span().builder();
 
+        RegionBSPTree2D tree = RegionBSPTree2D.empty();
+        tree.add(convex.getEmbedded().getSubspaceRegion());
+
         // act/assert
         GeometryTestUtils.assertThrows(() -> {
             builder.add(convex);
         }, IllegalArgumentException.class);
 
         GeometryTestUtils.assertThrows(() -> {
-            builder.add(new EmbeddedTreePlaneSubset(convex.getPlane(), convex.getSubspaceRegion().toTree()));
+            builder.add(new EmbeddedTreePlaneSubset(convex.getPlane().getEmbedding(), tree));
         }, IllegalArgumentException.class);
     }
 
     @Test
-    public void testBuilder_addUnknownType() {
+    public void testBuilder_add_addUnknownType() {
         // arrange
         HyperplaneSubset.Builder<Vector3D> builder = XY_PLANE.span().builder();
 
         // act/assert
         GeometryTestUtils.assertThrows(() -> {
-            builder.add(new StubSubPlane(Planes.fromNormal(Vector3D.Unit.PLUS_Y, TEST_PRECISION)));
+            builder.add(new StubPlaneSubset(XY_PLANE));
         }, IllegalArgumentException.class);
     }
 
@@ -252,14 +255,46 @@ public class PlaneSubsetTest {
         }
     }
 
-    private static class StubSubPlane extends PlaneSubset implements HyperplaneSubset<Vector3D> {
+    private static class StubPlaneSubset extends AbstractPlaneSubset {
+
+        private final Plane plane;
 
-        StubSubPlane(Plane plane) {
-            super(plane);
+        StubPlaneSubset(final Plane plane) {
+            this.plane = plane;
         }
 
         @Override
-        public Split<? extends HyperplaneSubset<Vector3D>> split(Hyperplane<Vector3D> splitter) {
+        public Plane getPlane() {
+            return plane;
+        }
+
+        @Override
+        public List<PlaneConvexSubset> toConvex() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public List<Triangle3D> toTriangles() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public boolean isFull() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public boolean isEmpty() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public RegionLocation classify(Vector3D pt) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public Vector3D closest(Vector3D pt) {
             throw new UnsupportedOperationException();
         }
 
@@ -269,12 +304,27 @@ public class PlaneSubsetTest {
         }
 
         @Override
-        public List<PlaneConvexSubset> toConvex() {
+        public Split<? extends HyperplaneSubset<Vector3D>> split(Hyperplane<Vector3D> splitter) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public double getSize() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public Vector3D getBarycenter() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public PlaneSubset.Embedded getEmbedded() {
             throw new UnsupportedOperationException();
         }
 
         @Override
-        public HyperplaneBoundedRegion<Vector2D> getSubspaceRegion() {
+        public Bounds3D getBounds() {
             throw new UnsupportedOperationException();
         }
     }
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/BoundarySource3DTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/BoundarySource3DTest.java
index 55cc960..7c22343 100644
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/BoundarySource3DTest.java
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/BoundarySource3DTest.java
@@ -36,9 +36,9 @@ public class BoundarySource3DTest {
     @Test
     public void testToTree() {
         // act
-        PlaneConvexSubset a = Planes.subsetFromVertexLoop(
+        PlaneConvexSubset a = Planes.convexPolygonFromVertices(
                 Arrays.asList(Vector3D.ZERO, Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y), TEST_PRECISION);
-        PlaneConvexSubset b = Planes.subsetFromVertexLoop(
+        PlaneConvexSubset b = Planes.convexPolygonFromVertices(
                 Arrays.asList(Vector3D.ZERO, Vector3D.Unit.PLUS_Y, Vector3D.Unit.MINUS_Z), TEST_PRECISION);
 
         BoundarySource3D src = BoundarySource3D.from(a, b);
@@ -79,9 +79,9 @@ public class BoundarySource3DTest {
     @Test
     public void testFrom_varargs() {
         // act
-        PlaneConvexSubset a = Planes.subsetFromVertexLoop(
+        PlaneConvexSubset a = Planes.convexPolygonFromVertices(
                 Arrays.asList(Vector3D.ZERO, Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y), TEST_PRECISION);
-        PlaneConvexSubset b = Planes.subsetFromVertexLoop(
+        PlaneConvexSubset b = Planes.convexPolygonFromVertices(
                 Arrays.asList(Vector3D.ZERO, Vector3D.Unit.PLUS_Y, Vector3D.Unit.MINUS_Z), TEST_PRECISION);
 
         BoundarySource3D src = BoundarySource3D.from(a, b);
@@ -110,9 +110,9 @@ public class BoundarySource3DTest {
     @Test
     public void testFrom_list() {
         // act
-        PlaneConvexSubset a = Planes.subsetFromVertexLoop(
+        PlaneConvexSubset a = Planes.convexPolygonFromVertices(
                 Arrays.asList(Vector3D.ZERO, Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y), TEST_PRECISION);
-        PlaneConvexSubset b = Planes.subsetFromVertexLoop(
+        PlaneConvexSubset b = Planes.convexPolygonFromVertices(
                 Arrays.asList(Vector3D.ZERO, Vector3D.Unit.PLUS_Y, Vector3D.Unit.MINUS_Z), TEST_PRECISION);
 
         List<PlaneConvexSubset> input = new ArrayList<>();
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/BoundarySourceBoundsBuilder3DTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/BoundarySourceBoundsBuilder3DTest.java
new file mode 100644
index 0000000..30f86d5
--- /dev/null
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/BoundarySourceBoundsBuilder3DTest.java
@@ -0,0 +1,155 @@
+/*
+ * 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 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 BoundarySourceBoundsBuilder3DTest {
+
+    private static final double TEST_EPS = 1e-10;
+
+    private static final DoublePrecisionContext TEST_PRECISION =
+            new EpsilonDoublePrecisionContext(TEST_EPS);
+
+    @Test
+    public void testGetBounds_noBoundaries() {
+        // arrange
+        BoundarySource3D src = BoundarySource3D.from(new ArrayList<>());
+        BoundarySourceBoundsBuilder3D builder = new BoundarySourceBoundsBuilder3D();
+
+        // act
+        Bounds3D b = builder.getBounds(src);
+
+        // assert
+        Assert.assertNull(b);
+    }
+
+    @Test
+    public void testGetBounds_singleFiniteBoundary() {
+        // arrange
+        ConvexPolygon3D poly = Planes.convexPolygonFromVertices(Arrays.asList(
+                Vector3D.of(1, 1, 1),
+                Vector3D.of(1, 0, 2),
+                Vector3D.of(3, 4, 5)), TEST_PRECISION);
+
+        BoundarySource3D src = BoundarySource3D.from(poly);
+        BoundarySourceBoundsBuilder3D builder = new BoundarySourceBoundsBuilder3D();
+
+        // act
+        Bounds3D b = builder.getBounds(src);
+
+        // assert
+        checkBounds(b, Vector3D.of(1, 0, 1), Vector3D.of(3, 4, 5));
+        for (Vector3D pt : poly.getVertices()) {
+            Assert.assertTrue(b.contains(pt));
+        }
+    }
+
+    @Test
+    public void testGetBounds_multipleFiniteBoundaries() {
+        // arrange
+        ConvexPolygon3D poly1 = Planes.convexPolygonFromVertices(Arrays.asList(
+                Vector3D.of(1, 1, 1),
+                Vector3D.of(1, 0, 2),
+                Vector3D.of(3, 4, 5)), TEST_PRECISION);
+
+        ConvexPolygon3D poly2 = Planes.convexPolygonFromVertices(Arrays.asList(
+                Vector3D.of(-1, 1, 1),
+                Vector3D.of(1, 4, 4),
+                Vector3D.of(7, 4, 5)), TEST_PRECISION);
+
+        ConvexPolygon3D poly3 = Planes.convexPolygonFromVertices(Arrays.asList(
+                Vector3D.of(-2, 1, 1),
+                Vector3D.of(1, 7, 2),
+                Vector3D.of(5, 4, 10)), TEST_PRECISION);
+
+        BoundarySource3D src = BoundarySource3D.from(poly1, poly2, poly3);
+        BoundarySourceBoundsBuilder3D builder = new BoundarySourceBoundsBuilder3D();
+
+        // act
+        Bounds3D b = builder.getBounds(src);
+
+        // assert
+        checkBounds(b, Vector3D.of(-2, 0, 1), Vector3D.of(7, 7, 10));
+
+        src.boundaryStream().forEach(boundary -> {
+            for (Vector3D pt : boundary.getVertices()) {
+                Assert.assertTrue(b.contains(pt));
+            }
+        });
+    }
+
+    @Test
+    public void testGetBounds_singleInfiniteBoundary() {
+        // arrange
+        PlaneConvexSubset boundary = Planes.fromPointAndNormal(Vector3D.ZERO, Vector3D.Unit.PLUS_Z, TEST_PRECISION)
+                .span();
+        BoundarySource3D src = BoundarySource3D.from(boundary);
+        BoundarySourceBoundsBuilder3D builder = new BoundarySourceBoundsBuilder3D();
+
+        // act
+        Bounds3D b = builder.getBounds(src);
+
+        // assert
+        Assert.assertNull(b);
+    }
+
+    @Test
+    public void testGetBounds_mixedFiniteAndInfiniteBoundaries() {
+        // arrange
+        PlaneConvexSubset inf = Planes.fromPointAndNormal(Vector3D.ZERO, Vector3D.Unit.PLUS_Z, TEST_PRECISION)
+                .span()
+                .split(Planes.fromPointAndNormal(Vector3D.ZERO, Vector3D.Unit.PLUS_Y, TEST_PRECISION))
+                .getMinus();
+
+        ConvexPolygon3D poly1 = Planes.convexPolygonFromVertices(Arrays.asList(
+                Vector3D.of(1, 1, 1),
+                Vector3D.of(1, 0, 2),
+                Vector3D.of(3, 4, 5)), TEST_PRECISION);
+
+        ConvexPolygon3D poly2 = Planes.convexPolygonFromVertices(Arrays.asList(
+                Vector3D.of(-1, 1, 1),
+                Vector3D.of(1, 4, 4),
+                Vector3D.of(7, 4, 5)), TEST_PRECISION);
+
+        ConvexPolygon3D poly3 = Planes.convexPolygonFromVertices(Arrays.asList(
+                Vector3D.of(-2, 1, 1),
+                Vector3D.of(1, 7, 2),
+                Vector3D.of(5, 4, 10)), TEST_PRECISION);
+
+        BoundarySource3D src = BoundarySource3D.from(poly1, poly2, inf, poly3);
+        BoundarySourceBoundsBuilder3D builder = new BoundarySourceBoundsBuilder3D();
+
+        // act
+        Bounds3D b = builder.getBounds(src);
+
+        // assert
+        Assert.assertNull(b);
+    }
+
+    private static void checkBounds(Bounds3D b, Vector3D min, Vector3D max) {
+        EuclideanTestUtils.assertCoordinatesEqual(min, b.getMin(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(max, b.getMax(), TEST_EPS);
+    }
+}
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/BoundarySourceLinecaster3DTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/BoundarySourceLinecaster3DTest.java
index a38b244..c62bacd 100644
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/BoundarySourceLinecaster3DTest.java
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/BoundarySourceLinecaster3DTest.java
@@ -102,8 +102,8 @@ public class BoundarySourceLinecaster3DTest {
     public void testLinecast_line_removesDuplicatePoints() {
         // arrange
         BoundarySource3D src = BoundarySource3D.from(
-                    Planes.subsetFromVertexLoop(Arrays.asList(Vector3D.ZERO, Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y), TEST_PRECISION),
-                    Planes.subsetFromVertexLoop(Arrays.asList(Vector3D.ZERO, Vector3D.Unit.PLUS_Y, Vector3D.Unit.MINUS_X), TEST_PRECISION)
+                    Planes.convexPolygonFromVertices(Arrays.asList(Vector3D.ZERO, Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y), TEST_PRECISION),
+                    Planes.convexPolygonFromVertices(Arrays.asList(Vector3D.ZERO, Vector3D.Unit.PLUS_Y, Vector3D.Unit.MINUS_X), TEST_PRECISION)
                 );
         BoundarySourceLinecaster3D linecaster = new BoundarySourceLinecaster3D(src);
 
@@ -235,8 +235,8 @@ public class BoundarySourceLinecaster3DTest {
     public void testLinecast_segment_removesDuplicatePoints() {
         // arrange
         BoundarySource3D src = BoundarySource3D.from(
-                    Planes.subsetFromVertexLoop(Arrays.asList(Vector3D.ZERO, Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y), TEST_PRECISION),
-                    Planes.subsetFromVertexLoop(Arrays.asList(Vector3D.ZERO, Vector3D.Unit.PLUS_Y, Vector3D.Unit.MINUS_X), TEST_PRECISION)
+                    Planes.convexPolygonFromVertices(Arrays.asList(Vector3D.ZERO, Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y), TEST_PRECISION),
+                    Planes.convexPolygonFromVertices(Arrays.asList(Vector3D.ZERO, Vector3D.Unit.PLUS_Y, Vector3D.Unit.MINUS_X), TEST_PRECISION)
                 );
         BoundarySourceLinecaster3D linecaster = new BoundarySourceLinecaster3D(src);
 
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/Bounds3DTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/Bounds3DTest.java
new file mode 100644
index 0000000..7693870
--- /dev/null
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/Bounds3DTest.java
@@ -0,0 +1,538 @@
+/*
+ * 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.function.BiFunction;
+import java.util.function.ToDoubleFunction;
+import java.util.regex.Pattern;
+
+import org.apache.commons.geometry.core.GeometryTestUtils;
+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.shape.Parallelepiped;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class Bounds3DTest {
+
+    private static final double TEST_EPS = 1e-10;
+
+    private static final DoublePrecisionContext TEST_PRECISION =
+            new EpsilonDoublePrecisionContext(TEST_EPS);
+
+    private static final String NO_POINTS_MESSAGE = "Cannot construct bounds: no points given";
+
+    private static final Pattern INVALID_BOUNDS_PATTERN =
+            Pattern.compile("^Invalid bounds: min= \\([^\\)]+\\), max= \\([^\\)]+\\)");
+
+    @Test
+    public void testFrom_varargs_singlePoint() {
+        // arrange
+        Vector3D p1 = Vector3D.of(-1, 2, -3);
+
+        // act
+        Bounds3D b = Bounds3D.from(p1);
+
+        // assert
+        EuclideanTestUtils.assertCoordinatesEqual(p1, b.getMin(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(p1, b.getMax(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.ZERO, b.getDiagonal(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(p1, b.getBarycenter(), TEST_EPS);
+    }
+
+    @Test
+    public void testFrom_varargs_multiplePoints() {
+        // arrange
+        Vector3D p1 = Vector3D.of(1, 6, 7);
+        Vector3D p2 = Vector3D.of(0, 5, 11);
+        Vector3D p3 = Vector3D.of(3, 6, 8);
+
+        // act
+        Bounds3D b = Bounds3D.from(p1, p2, p3);
+
+        // assert
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 5, 7), b.getMin(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(3, 6, 11), b.getMax(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(3, 1, 4), b.getDiagonal(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1.5, 5.5, 9), b.getBarycenter(), TEST_EPS);
+    }
+
+    @Test
+    public void testFrom_iterable_singlePoint() {
+        // arrange
+        Vector3D p1 = Vector3D.of(-1, 2, -3);
+
+        // act
+        Bounds3D b = Bounds3D.from(Arrays.asList(p1));
+
+        // assert
+        EuclideanTestUtils.assertCoordinatesEqual(p1, b.getMin(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(p1, b.getMax(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.ZERO, b.getDiagonal(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(p1, b.getBarycenter(), TEST_EPS);
+    }
+
+    @Test
+    public void testFrom_iterable_multiplePoints() {
+        // arrange
+        Vector3D p1 = Vector3D.of(1, 6, 7);
+        Vector3D p2 = Vector3D.of(2, 5, 9);
+        Vector3D p3 = Vector3D.of(3, 4, 8);
+
+        // act
+        Bounds3D b = Bounds3D.from(Arrays.asList(p1, p2, p3));
+
+        // assert
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 4, 7), b.getMin(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(3, 6, 9), b.getMax(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(2, 2, 2), b.getDiagonal(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(2, 5, 8), b.getBarycenter(), TEST_EPS);
+    }
+
+    @Test
+    public void testFrom_iterable_noPoints() {
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            Bounds3D.from(new ArrayList<>());
+        }, IllegalStateException.class, NO_POINTS_MESSAGE);
+    }
+
+    @Test
+    public void testFrom_invalidBounds() {
+        // arrange
+        Vector3D good = Vector3D.of(1, 1, 1);
+
+        Vector3D nan = Vector3D.of(Double.NaN, 1, 1);
+        Vector3D posInf = Vector3D.of(1, Double.POSITIVE_INFINITY, 1);
+        Vector3D negInf = Vector3D.of(1, 1, Double.NEGATIVE_INFINITY);
+
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            Bounds3D.from(Vector3D.NaN);
+        }, IllegalStateException.class, INVALID_BOUNDS_PATTERN);
+
+        GeometryTestUtils.assertThrows(() -> {
+            Bounds3D.from(Vector3D.POSITIVE_INFINITY);
+        }, IllegalStateException.class, INVALID_BOUNDS_PATTERN);
+
+        GeometryTestUtils.assertThrows(() -> {
+            Bounds3D.from(Vector3D.NEGATIVE_INFINITY);
+        }, IllegalStateException.class, INVALID_BOUNDS_PATTERN);
+
+        GeometryTestUtils.assertThrows(() -> {
+            Bounds3D.from(good, nan);
+        }, IllegalStateException.class, INVALID_BOUNDS_PATTERN);
+
+        GeometryTestUtils.assertThrows(() -> {
+            Bounds3D.from(posInf, good);
+        }, IllegalStateException.class, INVALID_BOUNDS_PATTERN);
+
+        GeometryTestUtils.assertThrows(() -> {
+            Bounds3D.from(good, negInf, good);
+        }, IllegalStateException.class, INVALID_BOUNDS_PATTERN);
+    }
+
+    @Test
+    public void testHasSize() {
+        // arrange
+        DoublePrecisionContext low = new EpsilonDoublePrecisionContext(1e-2);
+        DoublePrecisionContext high = new EpsilonDoublePrecisionContext(1e-10);
+
+        Vector3D p1 = Vector3D.ZERO;
+
+        Vector3D p2 = Vector3D.of(1e-5, 1, 1);
+        Vector3D p3 = Vector3D.of(1, 1e-5, 1);
+        Vector3D p4 = Vector3D.of(1, 1, 1e-5);
+
+        Vector3D p5 = Vector3D.of(1, 1, 1);
+
+        // act/assert
+        Assert.assertFalse(Bounds3D.from(p1).hasSize(high));
+        Assert.assertFalse(Bounds3D.from(p1).hasSize(low));
+
+        Assert.assertTrue(Bounds3D.from(p1, p2).hasSize(high));
+        Assert.assertFalse(Bounds3D.from(p1, p2).hasSize(low));
+
+        Assert.assertTrue(Bounds3D.from(p1, p3).hasSize(high));
+        Assert.assertFalse(Bounds3D.from(p1, p3).hasSize(low));
+
+        Assert.assertTrue(Bounds3D.from(p1, p4).hasSize(high));
+        Assert.assertFalse(Bounds3D.from(p1, p4).hasSize(low));
+
+        Assert.assertTrue(Bounds3D.from(p1, p5).hasSize(high));
+        Assert.assertTrue(Bounds3D.from(p1, p5).hasSize(low));
+    }
+
+    @Test
+    public void testContains_strict() {
+        // arrange
+        Bounds3D b = Bounds3D.from(
+                Vector3D.of(0, 4, 8),
+                Vector3D.of(2, 6, 10));
+
+        // act/assert
+        assertContainsStrict(b, true,
+                b.getBarycenter(),
+                Vector3D.of(0, 4, 8), Vector3D.of(2, 6, 10),
+                Vector3D.of(1, 5, 9),
+                Vector3D.of(0, 5, 9), Vector3D.of(2, 5, 9),
+                Vector3D.of(1, 4, 9), Vector3D.of(1, 6, 9),
+                Vector3D.of(1, 5, 8), Vector3D.of(1, 5, 10));
+
+        assertContainsStrict(b, false,
+                Vector3D.ZERO,
+                Vector3D.of(-1, 5, 9), Vector3D.of(3, 5, 9),
+                Vector3D.of(1, 3, 9), Vector3D.of(1, 7, 9),
+                Vector3D.of(1, 5, 7), Vector3D.of(1, 5, 11),
+                Vector3D.of(-1e-15, 4, 8), Vector3D.of(2, 6 + 1e-15, 10), Vector3D.of(0, 4, 10 + 1e-15));
+    }
+
+    @Test
+    public void testContains_precision() {
+        // arrange
+        Bounds3D b = Bounds3D.from(
+                Vector3D.of(0, 4, 8),
+                Vector3D.of(2, 6, 10));
+
+        // act/assert
+        assertContainsWithPrecision(b, true,
+                b.getBarycenter(),
+                Vector3D.of(0, 4, 8), Vector3D.of(2, 6, 10),
+                Vector3D.of(1, 5, 9),
+                Vector3D.of(0, 5, 9), Vector3D.of(2, 5, 9),
+                Vector3D.of(1, 4, 9), Vector3D.of(1, 6, 9),
+                Vector3D.of(1, 5, 8), Vector3D.of(1, 5, 10),
+                Vector3D.of(-1e-15, 4, 8), Vector3D.of(2, 6 + 1e-15, 10), Vector3D.of(0, 4, 10 + 1e-15));
+
+        assertContainsWithPrecision(b, false,
+                Vector3D.ZERO,
+                Vector3D.of(-1, 5, 9), Vector3D.of(3, 5, 9),
+                Vector3D.of(1, 3, 9), Vector3D.of(1, 7, 9),
+                Vector3D.of(1, 5, 7), Vector3D.of(1, 5, 11));
+    }
+
+    @Test
+    public void testIntersects() {
+        // arrange
+        Bounds3D b = Bounds3D.from(Vector3D.ZERO, Vector3D.of(1, 1, 1));
+
+        // act/assert
+        checkIntersects(b, Vector3D::getX, (v, x) -> Vector3D.of(x, v.getY(), v.getZ()));
+        checkIntersects(b, Vector3D::getY, (v, y) -> Vector3D.of(v.getX(), y, v.getZ()));
+        checkIntersects(b, Vector3D::getZ, (v, z) -> Vector3D.of(v.getX(), v.getY(), z));
+    }
+
+    private void checkIntersects(Bounds3D b, ToDoubleFunction<Vector3D> getter,
+            BiFunction<Vector3D, Double, Vector3D> setter) {
+
+        Vector3D min = b.getMin();
+        Vector3D max = b.getMax();
+
+        double minValue = getter.applyAsDouble(min);
+        double maxValue = getter.applyAsDouble(max);
+        double midValue = (0.5 * (maxValue - minValue)) + minValue;
+
+        // check all possible interval relationships
+
+        // start below minValue
+        Assert.assertFalse(b.intersects(Bounds3D.from(
+                setter.apply(min, minValue - 2), setter.apply(max, minValue - 1))));
+
+        Assert.assertTrue(b.intersects(Bounds3D.from(
+                setter.apply(min, minValue - 2), setter.apply(max, minValue))));
+        Assert.assertTrue(b.intersects(Bounds3D.from(
+                setter.apply(min, minValue - 2), setter.apply(max, midValue))));
+        Assert.assertTrue(b.intersects(Bounds3D.from(
+                setter.apply(min, minValue - 2), setter.apply(max, maxValue))));
+        Assert.assertTrue(b.intersects(Bounds3D.from(
+                setter.apply(min, minValue - 2), setter.apply(max, maxValue + 1))));
+
+        // start on minValue
+        Assert.assertTrue(b.intersects(Bounds3D.from(
+                setter.apply(min, minValue), setter.apply(max, minValue))));
+        Assert.assertTrue(b.intersects(Bounds3D.from(
+                setter.apply(min, minValue), setter.apply(max, midValue))));
+        Assert.assertTrue(b.intersects(Bounds3D.from(
+                setter.apply(min, minValue), setter.apply(max, maxValue))));
+        Assert.assertTrue(b.intersects(Bounds3D.from(
+                setter.apply(min, minValue), setter.apply(max, maxValue + 1))));
+
+        // start on midValue
+        Assert.assertTrue(b.intersects(Bounds3D.from(
+                setter.apply(min, midValue), setter.apply(max, midValue))));
+        Assert.assertTrue(b.intersects(Bounds3D.from(
+                setter.apply(min, midValue), setter.apply(max, maxValue))));
+        Assert.assertTrue(b.intersects(Bounds3D.from(
+                setter.apply(min, midValue), setter.apply(max, maxValue + 1))));
+
+        // start on maxValue
+        Assert.assertTrue(b.intersects(Bounds3D.from(
+                setter.apply(min, maxValue), setter.apply(max, maxValue))));
+        Assert.assertTrue(b.intersects(Bounds3D.from(
+                setter.apply(min, maxValue), setter.apply(max, maxValue + 1))));
+
+        // start above maxValue
+        Assert.assertFalse(b.intersects(Bounds3D.from(
+                setter.apply(min, maxValue + 1), setter.apply(max, maxValue + 2))));
+    }
+
+    @Test
+    public void testIntersection() {
+        // -- arrange
+        Bounds3D b = Bounds3D.from(Vector3D.ZERO, Vector3D.of(1, 1, 1));
+
+        // -- act/assert
+
+        // move along x-axis
+        Assert.assertNull(b.intersection(Bounds3D.from(Vector3D.of(-2, 0, 0), Vector3D.of(-1, 1, 1))));
+        checkIntersection(b, Vector3D.of(-1, 0, 0), Vector3D.of(0, 1, 1),
+                Vector3D.of(0, 0, 0), Vector3D.of(0, 1, 1));
+        checkIntersection(b, Vector3D.of(-1, 0, 0), Vector3D.of(0.5, 1, 1),
+                Vector3D.of(0, 0, 0), Vector3D.of(0.5, 1, 1));
+        checkIntersection(b, Vector3D.of(-1, 0, 0), Vector3D.of(1, 1, 1),
+                Vector3D.of(0, 0, 0), Vector3D.of(1, 1, 1));
+        checkIntersection(b, Vector3D.of(-1, 0, 0), Vector3D.of(2, 1, 1),
+                Vector3D.of(0, 0, 0), Vector3D.of(1, 1, 1));
+        checkIntersection(b, Vector3D.of(0, 0, 0), Vector3D.of(2, 1, 1),
+                Vector3D.of(0, 0, 0), Vector3D.of(1, 1, 1));
+        checkIntersection(b, Vector3D.of(0.5, 0, 0), Vector3D.of(2, 1, 1),
+                Vector3D.of(0.5, 0, 0), Vector3D.of(1, 1, 1));
+        checkIntersection(b, Vector3D.of(1, 0, 0), Vector3D.of(2, 1, 1),
+                Vector3D.of(1, 0, 0), Vector3D.of(1, 1, 1));
+        Assert.assertNull(b.intersection(Bounds3D.from(Vector3D.of(2, 0, 0), Vector3D.of(3, 1, 1))));
+
+        // move along y-axis
+        Assert.assertNull(b.intersection(Bounds3D.from(Vector3D.of(0, -2, 0), Vector3D.of(1, -1, 1))));
+        checkIntersection(b, Vector3D.of(0, -1, 0), Vector3D.of(1, 0, 1),
+                Vector3D.of(0, 0, 0), Vector3D.of(1, 0, 1));
+        checkIntersection(b, Vector3D.of(0, -1, 0), Vector3D.of(1, 0.5, 1),
+                Vector3D.of(0, 0, 0), Vector3D.of(1, 0.5, 1));
+        checkIntersection(b, Vector3D.of(0, -1, 0), Vector3D.of(1, 1, 1),
+                Vector3D.of(0, 0, 0), Vector3D.of(1, 1, 1));
+        checkIntersection(b, Vector3D.of(0, -1, 0), Vector3D.of(1, 2, 1),
+                Vector3D.of(0, 0, 0), Vector3D.of(1, 1, 1));
+        checkIntersection(b, Vector3D.of(0, 0, 0), Vector3D.of(1, 2, 1),
+                Vector3D.of(0, 0, 0), Vector3D.of(1, 1, 1));
+        checkIntersection(b, Vector3D.of(0, 0.5, 0), Vector3D.of(1, 2, 1),
+                Vector3D.of(0, 0.5, 0), Vector3D.of(1, 1, 1));
+        checkIntersection(b, Vector3D.of(0, 1, 0), Vector3D.of(1, 2, 1),
+                Vector3D.of(0, 1, 0), Vector3D.of(1, 1, 1));
+        Assert.assertNull(b.intersection(Bounds3D.from(Vector3D.of(0, 2, 0), Vector3D.of(1, 3, 1))));
+
+        // move along z-axis
+        Assert.assertNull(b.intersection(Bounds3D.from(Vector3D.of(0, 0, -2), Vector3D.of(1, 1, -1))));
+        checkIntersection(b, Vector3D.of(0, 0, -1), Vector3D.of(1, 1, 0),
+                Vector3D.of(0, 0, 0), Vector3D.of(1, 1, 0));
+        checkIntersection(b, Vector3D.of(0, 0, -1), Vector3D.of(1, 1, 0.5),
+                Vector3D.of(0, 0, 0), Vector3D.of(1, 1, 0.5));
+        checkIntersection(b, Vector3D.of(0, 0, -1), Vector3D.of(1, 1, 1),
+                Vector3D.of(0, 0, 0), Vector3D.of(1, 1, 1));
+        checkIntersection(b, Vector3D.of(0, 0, -1), Vector3D.of(1, 1, 2),
+                Vector3D.of(0, 0, 0), Vector3D.of(1, 1, 1));
+        checkIntersection(b, Vector3D.of(0, 0, 0), Vector3D.of(1, 1, 2),
+                Vector3D.of(0, 0, 0), Vector3D.of(1, 1, 1));
+        checkIntersection(b, Vector3D.of(0, 0, 0.5), Vector3D.of(1, 1, 2),
+                Vector3D.of(0, 0, 0.5), Vector3D.of(1, 1, 1));
+        checkIntersection(b, Vector3D.of(0, 0, 1), Vector3D.of(1, 1, 2),
+                Vector3D.of(0, 0, 1), Vector3D.of(1, 1, 1));
+        Assert.assertNull(b.intersection(Bounds3D.from(Vector3D.of(0, 0, 2), Vector3D.of(1, 1, 3))));
+    }
+
+    private void checkIntersection(Bounds3D b, Vector3D a1, Vector3D a2, Vector3D r1, Vector3D r2) {
+        Bounds3D a = Bounds3D.from(a1, a2);
+        Bounds3D result = b.intersection(a);
+
+        checkBounds(result, r1, r2);
+    }
+
+    @Test
+    public void toRegion() {
+        // arrange
+        Bounds3D b = Bounds3D.from(
+                Vector3D.of(0, 4, 8),
+                Vector3D.of(2, 6, 10));
+
+        // act
+        Parallelepiped p = b.toRegion(TEST_PRECISION);
+
+        // assert
+        Assert.assertEquals(8, p.getSize(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 5, 9), p.getBarycenter(), TEST_EPS);
+    }
+
+    @Test
+    public void toRegion_boundingBoxTooSmall() {
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            Bounds3D.from(Vector3D.ZERO, Vector3D.of(1e-12, 1e-12, 1e-12))
+                .toRegion(TEST_PRECISION);
+        }, IllegalArgumentException.class);
+    }
+
+    @Test
+    public void testEq() {
+        // arrange
+        DoublePrecisionContext low = new EpsilonDoublePrecisionContext(1e-2);
+        DoublePrecisionContext high = new EpsilonDoublePrecisionContext(1e-10);
+
+        Bounds3D b1 = Bounds3D.from(Vector3D.of(1, 1, 1), Vector3D.of(2, 2, 2));
+
+        Bounds3D b2 = Bounds3D.from(Vector3D.of(1.1, 1, 1), Vector3D.of(2, 2, 2));
+        Bounds3D b3 = Bounds3D.from(Vector3D.of(1, 1, 1), Vector3D.of(1.9, 2, 2));
+
+        Bounds3D b4 = Bounds3D.from(Vector3D.of(1.001, 1.001, 1.001), Vector3D.of(2.001, 2.001, 2.001));
+
+        // act/assert
+        Assert.assertTrue(b1.eq(b1, low));
+
+        Assert.assertFalse(b1.eq(b2, low));
+        Assert.assertFalse(b1.eq(b3, low));
+
+        Assert.assertTrue(b1.eq(b4, low));
+        Assert.assertTrue(b4.eq(b1, low));
+
+        Assert.assertFalse(b1.eq(b4, high));
+        Assert.assertFalse(b4.eq(b1, high));
+    }
+
+    @Test
+    public void testHashCode() {
+        // arrange
+        Bounds3D b1 = Bounds3D.from(Vector3D.of(1, 1, 1), Vector3D.of(2, 2, 2));
+
+        Bounds3D b2 = Bounds3D.from(Vector3D.of(-2, 1, 1), Vector3D.of(2, 2, 2));
+        Bounds3D b3 = Bounds3D.from(Vector3D.of(1, 1, 1), Vector3D.of(3, 2, 2));
+        Bounds3D b4 = Bounds3D.from(Vector3D.of(1 + 1e-15, 1, 1), Vector3D.of(2, 2, 2));
+        Bounds3D b5 = Bounds3D.from(Vector3D.of(1, 1, 1), Vector3D.of(2 + 1e-15, 2, 2));
+
+        Bounds3D b6 = Bounds3D.from(Vector3D.of(1, 1, 1), Vector3D.of(2, 2, 2));
+
+        // act
+        int hash = b1.hashCode();
+
+        // assert
+        Assert.assertEquals(hash, b1.hashCode());
+
+        Assert.assertNotEquals(hash, b2.hashCode());
+        Assert.assertNotEquals(hash, b3.hashCode());
+        Assert.assertNotEquals(hash, b4.hashCode());
+        Assert.assertNotEquals(hash, b5.hashCode());
+
+        Assert.assertEquals(hash, b6.hashCode());
+    }
+
+    @Test
+    public void testEquals() {
+        // arrange
+        Bounds3D b1 = Bounds3D.from(Vector3D.of(1, 1, 1), Vector3D.of(2, 2, 2));
+
+        Bounds3D b2 = Bounds3D.from(Vector3D.of(-1, 1, 1), Vector3D.of(2, 2, 2));
+        Bounds3D b3 = Bounds3D.from(Vector3D.of(1, 1, 1), Vector3D.of(3, 2, 2));
+        Bounds3D b4 = Bounds3D.from(Vector3D.of(1 + 1e-15, 1, 1), Vector3D.of(2, 2, 2));
+        Bounds3D b5 = Bounds3D.from(Vector3D.of(1, 1, 1), Vector3D.of(2 + 1e-15, 2, 2));
+
+        Bounds3D b6 = Bounds3D.from(Vector3D.of(1, 1, 1), Vector3D.of(2, 2, 2));
+
+        // act/assert
+        Assert.assertTrue(b1.equals(b1));
+
+        Assert.assertFalse(b1.equals(null));
+        Assert.assertFalse(b1.equals(new Object()));
+
+        Assert.assertFalse(b1.equals(b2));
+        Assert.assertFalse(b1.equals(b3));
+        Assert.assertFalse(b1.equals(b4));
+        Assert.assertFalse(b1.equals(b5));
+
+        Assert.assertTrue(b1.equals(b6));
+    }
+
+    @Test
+    public void testToString() {
+        // arrange
+        Bounds3D b = Bounds3D.from(Vector3D.of(1, 1, 1), Vector3D.of(2, 2, 2));
+
+        // act
+        String str = b.toString();
+
+        // assert
+        GeometryTestUtils.assertContains("Bounds3D[min= (1", str);
+        GeometryTestUtils.assertContains(", max= (2", str);
+    }
+
+    @Test
+    public void testBuilder_addMethods() {
+        // arrange
+        Vector3D p1 = Vector3D.of(1, 10, 11);
+        Vector3D p2 = Vector3D.of(2, 9, 12);
+        Vector3D p3 = Vector3D.of(3, 8, 13);
+        Vector3D p4 = Vector3D.of(4, 7, 14);
+        Vector3D p5 = Vector3D.of(5, 6, 15);
+
+        // act
+        Bounds3D b = Bounds3D.builder()
+                .add(p1)
+                .addAll(Arrays.asList(p2, p3))
+                .add(Bounds3D.from(p4, p5))
+                .build();
+
+        // assert
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 6, 11), b.getMin(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(5, 10, 15), b.getMax(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(3, 8, 13), b.getBarycenter(), TEST_EPS);
+    }
+
+    @Test
+    public void testBuilder_containsBounds() {
+        // act/assert
+        Assert.assertFalse(Bounds3D.builder().containsBounds());
+
+        Assert.assertFalse(Bounds3D.builder().add(Vector3D.of(Double.NaN, 1, 1)).containsBounds());
+        Assert.assertFalse(Bounds3D.builder().add(Vector3D.of(1, Double.NaN, 1)).containsBounds());
+        Assert.assertFalse(Bounds3D.builder().add(Vector3D.of(1, 1, Double.NaN)).containsBounds());
+
+        Assert.assertFalse(Bounds3D.builder().add(Vector3D.of(Double.POSITIVE_INFINITY, 1, 1)).containsBounds());
+        Assert.assertFalse(Bounds3D.builder().add(Vector3D.of(1, Double.POSITIVE_INFINITY, 1)).containsBounds());
+        Assert.assertFalse(Bounds3D.builder().add(Vector3D.of(1, 1, Double.POSITIVE_INFINITY)).containsBounds());
+
+        Assert.assertFalse(Bounds3D.builder().add(Vector3D.of(Double.NEGATIVE_INFINITY, 1, 1)).containsBounds());
+        Assert.assertFalse(Bounds3D.builder().add(Vector3D.of(1, Double.NEGATIVE_INFINITY, 1)).containsBounds());
+        Assert.assertFalse(Bounds3D.builder().add(Vector3D.of(1, 1, Double.NEGATIVE_INFINITY)).containsBounds());
+
+        Assert.assertTrue(Bounds3D.builder().add(Vector3D.ZERO).containsBounds());
+    }
+
+    private static void checkBounds(Bounds3D b, Vector3D min, Vector3D max) {
+        EuclideanTestUtils.assertCoordinatesEqual(min, b.getMin(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(max, b.getMax(), TEST_EPS);
+    }
+
+    private static void assertContainsStrict(Bounds3D bounds, boolean contains, Vector3D... pts) {
+        for (Vector3D pt : pts) {
+            Assert.assertEquals("Unexpected location for point " + pt, contains, bounds.contains(pt));
+        }
+    }
+
+    private static void assertContainsWithPrecision(Bounds3D bounds, boolean contains, Vector3D... pts) {
+        for (Vector3D pt : pts) {
+            Assert.assertEquals("Unexpected location for point " + pt, contains, bounds.contains(pt, TEST_PRECISION));
+        }
+    }
+}
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
index b6220de..a0a83c1 100644
--- 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
@@ -18,6 +18,7 @@ package org.apache.commons.geometry.euclidean.threed;
 
 import java.util.Arrays;
 import java.util.List;
+import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 
 import org.apache.commons.geometry.core.GeometryTestUtils;
@@ -69,8 +70,9 @@ public class ConvexVolumeTest {
         Assert.assertEquals(1, boundaries.size());
 
         PlaneConvexSubset sp = boundaries.get(0);
-        Assert.assertEquals(0, sp.getSubspaceRegion().getBoundaries().size());
-        Assert.assertSame(plane, sp.getPlane());
+        Assert.assertEquals(0, sp.getEmbedded().getSubspaceRegion().getBoundaries().size());
+        EuclideanTestUtils.assertCoordinatesEqual(plane.getOrigin(), sp.getPlane().getOrigin(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(plane.getNormal(), sp.getPlane().getNormal(), TEST_EPS);
     }
 
     @Test
@@ -86,6 +88,97 @@ public class ConvexVolumeTest {
     }
 
     @Test
+    public void testTriangleStream_noBoundaries() {
+        // arrange
+        ConvexVolume full = ConvexVolume.full();
+
+        // act
+        List<Triangle3D> tris = full.triangleStream().collect(Collectors.toList());
+
+        // act/assert
+        Assert.assertEquals(0, tris.size());
+    }
+
+    @Test
+    public void testTriangleStream_infinite() {
+        // arrange
+        Pattern pattern = Pattern.compile("^Cannot convert infinite plane subset to triangles: .*");
+
+        ConvexVolume half = ConvexVolume.fromBounds(
+                Planes.fromNormal(Vector3D.Unit.MINUS_X, TEST_PRECISION)
+            );
+
+        ConvexVolume quadrant = ConvexVolume.fromBounds(
+                    Planes.fromNormal(Vector3D.Unit.MINUS_X, TEST_PRECISION),
+                    Planes.fromNormal(Vector3D.Unit.MINUS_Y, TEST_PRECISION),
+                    Planes.fromNormal(Vector3D.Unit.MINUS_Z, TEST_PRECISION)
+                );
+
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            half.triangleStream().collect(Collectors.toList());
+        }, IllegalStateException.class, pattern);
+
+        GeometryTestUtils.assertThrows(() -> {
+            quadrant.triangleStream().collect(Collectors.toList());
+        }, IllegalStateException.class, pattern);
+    }
+
+    @Test
+    public void testTriangleStream_finite() {
+        // arrange
+        Vector3D min = Vector3D.ZERO;
+        Vector3D max = Vector3D.of(1, 1, 1);
+
+        ConvexVolume box = ConvexVolume.fromBounds(
+                    Planes.fromPointAndNormal(min, Vector3D.Unit.MINUS_X, TEST_PRECISION),
+                    Planes.fromPointAndNormal(min, Vector3D.Unit.MINUS_Y, TEST_PRECISION),
+                    Planes.fromPointAndNormal(min, Vector3D.Unit.MINUS_Z, TEST_PRECISION),
+
+                    Planes.fromPointAndNormal(max, Vector3D.Unit.PLUS_X, TEST_PRECISION),
+                    Planes.fromPointAndNormal(max, Vector3D.Unit.PLUS_Y, TEST_PRECISION),
+                    Planes.fromPointAndNormal(max, Vector3D.Unit.PLUS_Z, TEST_PRECISION)
+                );
+
+        // act
+        List<Triangle3D> tris = box.triangleStream().collect(Collectors.toList());
+
+        // assert
+        Assert.assertEquals(12, tris.size());
+
+        Bounds3D.Builder boundsBuilder = Bounds3D.builder();
+        tris.forEach(t -> boundsBuilder.addAll(t.getVertices()));
+
+        Bounds3D bounds = boundsBuilder.build();
+        EuclideanTestUtils.assertCoordinatesEqual(min, bounds.getMin(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(max, bounds.getMax(), TEST_EPS);
+    }
+
+    @Test
+    public void testGetBounds_noBounds() {
+        // arrange
+        ConvexVolume full = ConvexVolume.full();
+        ConvexVolume halfFull = ConvexVolume.fromBounds(Planes.fromNormal(Vector3D.Unit.PLUS_Z, TEST_PRECISION));
+
+        // act/assert
+        Assert.assertNull(full.getBounds());
+        Assert.assertNull(halfFull.getBounds());
+    }
+
+    @Test
+    public void testGetBounds_hasBounds() {
+        // arrange
+        ConvexVolume vol = rect(Vector3D.of(1, 1, 1), 0.5, 1, 2);
+
+        // act
+        Bounds3D bounds = vol.getBounds();
+
+        // assert
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0.5, 0, -1), bounds.getMin(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1.5, 2, 3), bounds.getMax(), TEST_EPS);
+    }
+
+    @Test
     public void testToTree_full() {
         // arrange
         ConvexVolume volume = ConvexVolume.full();
@@ -188,7 +281,7 @@ public class ConvexVolumeTest {
         ConvexVolume vol = rect(Vector3D.ZERO, 0.5, 0.5, 0.5);
 
         PlaneConvexSubset subplane = Planes.subsetFromConvexArea(
-                Planes.fromNormal(Vector3D.Unit.PLUS_X, TEST_PRECISION), ConvexArea.full());
+                Planes.fromNormal(Vector3D.Unit.PLUS_X, TEST_PRECISION).getEmbedding(), ConvexArea.full());
 
         // act
         PlaneConvexSubset trimmed = vol.trim(subplane);
@@ -196,15 +289,13 @@ public class ConvexVolumeTest {
         // assert
         Assert.assertEquals(1, trimmed.getSize(), TEST_EPS);
 
-        List<Vector3D> vertices = trimmed.getPlane().toSpace(
-                trimmed.getSubspaceRegion().getBoundaryPaths().get(0).getVertexSequence());
+        List<Vector3D> vertices = trimmed.getVertices();
 
-        Assert.assertEquals(5, vertices.size());
+        Assert.assertEquals(4, 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
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/EmbeddedAreaPlaneConvexSubsetTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/EmbeddedAreaPlaneConvexSubsetTest.java
new file mode 100644
index 0000000..e40c52c
--- /dev/null
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/EmbeddedAreaPlaneConvexSubsetTest.java
@@ -0,0 +1,468 @@
+/*
+ * 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 java.util.regex.Pattern;
+
+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.threed.rotation.QuaternionRotation;
+import org.apache.commons.geometry.euclidean.twod.ConvexArea;
+import org.apache.commons.geometry.euclidean.twod.Lines;
+import org.apache.commons.geometry.euclidean.twod.Vector2D;
+import org.apache.commons.geometry.euclidean.twod.path.LinePath;
+import org.apache.commons.geometry.euclidean.twod.shape.Parallelogram;
+import org.apache.commons.numbers.angle.PlaneAngleRadians;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class EmbeddedAreaPlaneConvexSubsetTest {
+
+    private static final double TEST_EPS = 1e-10;
+
+    private static final DoublePrecisionContext TEST_PRECISION =
+            new EpsilonDoublePrecisionContext(TEST_EPS);
+
+    private static final EmbeddingPlane XY_PLANE_Z1 = Planes.fromPointAndPlaneVectors(Vector3D.of(0, 0, 1),
+            Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
+
+    @Test
+    public void testSpaceConversion() {
+        // arrange
+        EmbeddingPlane plane = Planes.fromPointAndPlaneVectors(Vector3D.of(1, 0, 0),
+                Vector3D.Unit.PLUS_Y, Vector3D.Unit.PLUS_Z, TEST_PRECISION);
+
+        EmbeddedAreaPlaneConvexSubset ps = new EmbeddedAreaPlaneConvexSubset(plane, ConvexArea.full());
+
+        // act/assert
+        EuclideanTestUtils.assertCoordinatesEqual(Vector2D.of(1, 2), ps.toSubspace(Vector3D.of(-5, 1, 2)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, -2, 4), ps.toSpace(Vector2D.of(-2, 4)), TEST_EPS);
+    }
+
+    @Test
+    public void testProperties_infinite() {
+        // arrange
+        ConvexArea area = ConvexArea.full();
+
+        // act
+        EmbeddedAreaPlaneConvexSubset ps = new EmbeddedAreaPlaneConvexSubset(XY_PLANE_Z1, area);
+
+        // assert
+        Assert.assertTrue(ps.isFull());
+        Assert.assertFalse(ps.isEmpty());
+        Assert.assertFalse(ps.isFinite());
+        Assert.assertTrue(ps.isInfinite());
+
+        GeometryTestUtils.assertPositiveInfinity(ps.getSize());
+
+        Assert.assertSame(XY_PLANE_Z1, ps.getPlane());
+        Assert.assertSame(area, ps.getSubspaceRegion());
+
+        Assert.assertEquals(0, ps.getVertices().size());
+    }
+
+    @Test
+    public void testProperties_finite() {
+        // arrange
+        ConvexArea area = ConvexArea.convexPolygonFromPath(LinePath.builder(TEST_PRECISION)
+                .appendVertices(Vector2D.ZERO, Vector2D.of(1, 0), Vector2D.of(0, 1))
+                .build(true));
+
+        // act
+        EmbeddedAreaPlaneConvexSubset ps = new EmbeddedAreaPlaneConvexSubset(XY_PLANE_Z1, area);
+
+        // assert
+        Assert.assertFalse(ps.isFull());
+        Assert.assertFalse(ps.isEmpty());
+        Assert.assertTrue(ps.isFinite());
+        Assert.assertFalse(ps.isInfinite());
+
+        Assert.assertEquals(0.5, ps.getSize(), TEST_EPS);
+
+        Assert.assertSame(XY_PLANE_Z1, ps.getPlane());
+        Assert.assertSame(area, ps.getSubspaceRegion());
+
+        EuclideanTestUtils.assertVertexLoopSequence(
+                Arrays.asList(Vector3D.of(0, 0, 1), Vector3D.of(1, 0, 1), Vector3D.of(0, 1, 1)),
+                ps.getVertices(), TEST_PRECISION);
+    }
+
+    @Test
+    public void testGetVertices_twoParallelLines() {
+        // arrange
+        EmbeddingPlane plane = Planes.fromNormal(Vector3D.Unit.PLUS_Z, TEST_PRECISION).getEmbedding();
+        PlaneConvexSubset sp = new EmbeddedAreaPlaneConvexSubset(plane, ConvexArea.fromBounds(
+                    Lines.fromPointAndAngle(Vector2D.of(0, 1), PlaneAngleRadians.PI, TEST_PRECISION),
+                    Lines.fromPointAndAngle(Vector2D.of(0, -1), 0.0, TEST_PRECISION)
+                ));
+
+        // act
+        List<Vector3D> vertices = sp.getVertices();
+
+        // assert
+        Assert.assertEquals(0, vertices.size());
+    }
+
+    @Test
+    public void testGetVertices_infiniteWithVertices() {
+        // arrange
+        EmbeddingPlane plane = Planes.fromPointAndPlaneVectors(Vector3D.of(0, 0, 1), Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
+        PlaneConvexSubset sp = new EmbeddedAreaPlaneConvexSubset(plane, ConvexArea.fromBounds(
+                    Lines.fromPointAndAngle(Vector2D.of(0, 1), PlaneAngleRadians.PI, TEST_PRECISION),
+                    Lines.fromPointAndAngle(Vector2D.of(0, -1), 0.0, TEST_PRECISION),
+                    Lines.fromPointAndAngle(Vector2D.of(1, 0), PlaneAngleRadians.PI_OVER_TWO, 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 testToTriangles_infinite() {
+        // arrange
+        Pattern pattern = Pattern.compile("^Cannot convert infinite plane subset to triangles: .*");
+
+        // act/assert
+        GeometryTestUtils.assertThrows(() -> {
+            new EmbeddedAreaPlaneConvexSubset(XY_PLANE_Z1, ConvexArea.full()).toTriangles();
+        }, IllegalStateException.class, pattern);
+
+        GeometryTestUtils.assertThrows(() -> {
+            ConvexArea area = ConvexArea.fromBounds(Lines.fromPointAndAngle(Vector2D.ZERO, 0, TEST_PRECISION));
+            EmbeddedAreaPlaneConvexSubset halfSpace = new EmbeddedAreaPlaneConvexSubset(XY_PLANE_Z1, area);
+
+            halfSpace.toTriangles();
+        }, IllegalStateException.class, pattern);
+
+        GeometryTestUtils.assertThrows(() -> {
+            ConvexArea area = ConvexArea.fromBounds(
+                    Lines.fromPointAndAngle(Vector2D.ZERO, 0, TEST_PRECISION),
+                    Lines.fromPointAndAngle(Vector2D.ZERO, 0.5 * Math.PI, TEST_PRECISION));
+
+            EmbeddedAreaPlaneConvexSubset halfSpaceWithVertices = new EmbeddedAreaPlaneConvexSubset(XY_PLANE_Z1, area);
+
+            halfSpaceWithVertices.toTriangles();
+        }, IllegalStateException.class, pattern);
+    }
+
+    @Test
+    public void testToTriangles_finite() {
+        // arrange
+        Vector3D p1 = Vector3D.of(0, 0, 1);
+        Vector3D p2 = Vector3D.of(1, 0, 1);
+        Vector3D p3 = Vector3D.of(2, 1, 1);
+        Vector3D p4 = Vector3D.of(1.5, 1, 1);
+
+        List<Vector2D> subPts = XY_PLANE_Z1.toSubspace(Arrays.asList(p1, p2, p3, p4));
+
+        EmbeddedAreaPlaneConvexSubset ps = new EmbeddedAreaPlaneConvexSubset(XY_PLANE_Z1,
+                ConvexArea.convexPolygonFromVertices(subPts, TEST_PRECISION));
+
+        // act
+        List<Triangle3D> tris = ps.toTriangles();
+
+        // assert
+        Assert.assertEquals(2, tris.size());
+
+        EuclideanTestUtils.assertVertexLoopSequence(Arrays.asList(p4, p1, p2),
+                tris.get(0).getVertices(), TEST_PRECISION);
+        EuclideanTestUtils.assertVertexLoopSequence(Arrays.asList(p4, p2, p3),
+                tris.get(1).getVertices(), TEST_PRECISION);
+    }
+
+    @Test
+    public void testClassify() {
+        // arrange
+        EmbeddedAreaPlaneConvexSubset ps = new EmbeddedAreaPlaneConvexSubset(XY_PLANE_Z1,
+                Parallelogram.builder(TEST_PRECISION)
+                    .setPosition(Vector2D.of(2, 3))
+                    .setScale(2, 2)
+                    .build());
+
+        // act/assert
+        checkPoints(ps, RegionLocation.INSIDE, Vector3D.of(2, 3, 1));
+        checkPoints(ps, RegionLocation.BOUNDARY,
+                Vector3D.of(1, 3, 1), Vector3D.of(3, 3, 1),
+                Vector3D.of(2, 2, 1), Vector3D.of(2, 4, 1));
+        checkPoints(ps, RegionLocation.OUTSIDE,
+                Vector3D.of(2, 3, 0), Vector3D.of(2, 3, 2),
+                Vector3D.of(0, 3, 1), Vector3D.of(4, 3, 1),
+                Vector3D.of(2, 1, 1), Vector3D.of(2, 5, 1));
+    }
+
+    @Test
+    public void testClosest() {
+        // arrange
+        EmbeddedAreaPlaneConvexSubset ps = new EmbeddedAreaPlaneConvexSubset(XY_PLANE_Z1,
+                Parallelogram.builder(TEST_PRECISION)
+                    .setPosition(Vector2D.of(2, 3))
+                    .setScale(2, 2)
+                    .build());
+
+        // act/assert
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(2, 3, 1), ps.closest(Vector3D.of(2, 3, 1)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(2, 3, 1), ps.closest(Vector3D.of(2, 3, 100)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 2, 1),
+                ps.closest(Vector3D.of(-100, -100, -100)), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(3, 3.5, 1),
+                ps.closest(Vector3D.of(100, 3.5, 100)), TEST_EPS);
+    }
+
+    @Test
+    public void testGetBounds_noBounds() {
+        // arrange
+        EmbeddingPlane plane = Planes.fromPointAndPlaneVectors(Vector3D.of(0, 0, 1),
+                Vector3D.Unit.PLUS_Y, Vector3D.Unit.MINUS_X, TEST_PRECISION);
+
+        EmbeddedAreaPlaneConvexSubset full = new EmbeddedAreaPlaneConvexSubset(plane, ConvexArea.full());
+        EmbeddedAreaPlaneConvexSubset halfPlane = new EmbeddedAreaPlaneConvexSubset(plane,
+                ConvexArea.fromBounds(Lines.fromPointAndAngle(Vector2D.ZERO, 0, TEST_PRECISION)));
+
+        // act/assert
+        Assert.assertNull(full.getBounds());
+        Assert.assertNull(halfPlane.getBounds());
+    }
+
+    @Test
+    public void testGetBounds_hasBounds() {
+        // arrange
+        EmbeddingPlane plane = Planes.fromPointAndPlaneVectors(Vector3D.of(0, 0, 1),
+                Vector3D.Unit.PLUS_Y, Vector3D.Unit.MINUS_X, TEST_PRECISION);
+
+        EmbeddedAreaPlaneConvexSubset ps = new EmbeddedAreaPlaneConvexSubset(plane,
+                ConvexArea.convexPolygonFromVertices(Arrays.asList(
+                    Vector2D.of(1, 1), Vector2D.of(2, 1), Vector2D.of(1, 2)
+                ), TEST_PRECISION));
+
+        // act
+        Bounds3D bounds = ps.getBounds();
+
+        // assert
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(-2, 1, 1), bounds.getMin(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(-1, 2, 1), bounds.getMax(), TEST_EPS);
+    }
+
+    @Test
+    public void testTransform() {
+        // arrange
+        AffineTransformMatrix3D t = AffineTransformMatrix3D.identity()
+                .rotate(QuaternionRotation.fromAxisAngle(Vector3D.Unit.PLUS_Y, -PlaneAngleRadians.PI_OVER_TWO))
+                .scale(1, 1, 2)
+                .translate(Vector3D.of(1, 0, 0));
+
+        EmbeddedAreaPlaneConvexSubset ps = new EmbeddedAreaPlaneConvexSubset(XY_PLANE_Z1,
+                Parallelogram.builder(TEST_PRECISION)
+                    .setPosition(Vector2D.of(2, 3))
+                    .setScale(2, 2)
+                    .build());
+
+        // act
+        EmbeddedAreaPlaneConvexSubset result = ps.transform(t);
+
+        // assert
+        Assert.assertFalse(result.isFull());
+        Assert.assertFalse(result.isEmpty());
+        Assert.assertTrue(result.isFinite());
+        Assert.assertFalse(result.isInfinite());
+
+        Assert.assertEquals(8, result.getSize(), TEST_EPS);
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.Unit.MINUS_X, result.getPlane().getNormal(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.Unit.PLUS_Z, result.getPlane().getU(), TEST_EPS);
+
+        EuclideanTestUtils.assertVertexLoopSequence(
+                Arrays.asList(Vector3D.of(0, 2, 2), Vector3D.of(0, 2, 6), Vector3D.of(0, 4, 6), Vector3D.of(0, 4, 2)),
+                result.getVertices(), TEST_PRECISION);
+    }
+
+    @Test
+    public void testReverse() {
+        // arrange
+        EmbeddedAreaPlaneConvexSubset ps = new EmbeddedAreaPlaneConvexSubset(XY_PLANE_Z1,
+                Parallelogram.builder(TEST_PRECISION)
+                    .setPosition(Vector2D.of(2, 3))
+                    .setScale(2, 2)
+                    .build());
+
+        // act
+        EmbeddedAreaPlaneConvexSubset result = ps.reverse();
+
+        // assert
+        Assert.assertFalse(result.isFull());
+        Assert.assertFalse(result.isEmpty());
+        Assert.assertTrue(result.isFinite());
+        Assert.assertFalse(result.isInfinite());
+
+        Assert.assertEquals(4, result.getSize(), TEST_EPS);
+
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.Unit.MINUS_Z, result.getPlane().getNormal(), TEST_EPS);
+        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.Unit.PLUS_Y, result.getPlane().getU(), TEST_EPS);
+
+        EuclideanTestUtils.assertVertexLoopSequence(
+                Arrays.asList(Vector3D.of(1, 4, 1), Vector3D.of(3, 4, 1), Vector3D.of(3, 2, 1), Vector3D.of(1, 2, 1)),
+                result.getVertices(), TEST_PRECISION);
+    }
+
+    @Test
+    public void testSplit_plus() {
+        // arrange
+        EmbeddedAreaPlaneConvexSubset ps = new EmbeddedAreaPlaneConvexSubset(XY_PLANE_Z1,
+                ConvexArea.convexPolygonFromVertices(Arrays.asList(Vector2D.ZERO, Vector2D.of(1, 0), Vector2D.of(0, 1)),
+                        TEST_PRECISION));
+
+        Plane splitter = Planes.fromPointAndNormal(Vector3D.ZERO, Vector3D.Unit.PLUS_X, TEST_PRECISION);
+
+        // act
+        Split<PlaneConvexSubset> split = ps.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.PLUS, split.getLocation());
+
+        Assert.assertNull(split.getMinus());
+        Assert.assertSame(ps, split.getPlus());
+    }
+
+    @Test
+    public void testSplit_minus() {
+        // arrange
+        EmbeddedAreaPlaneConvexSubset ps = new EmbeddedAreaPlaneConvexSubset(XY_PLANE_Z1,
+                ConvexArea.convexPolygonFromVertices(Arrays.asList(Vector2D.ZERO, Vector2D.of(1, 0), Vector2D.of(0, 1)),
+                        TEST_PRECISION));
+
+        Plane splitter = Planes.fromPointAndNormal(Vector3D.ZERO, Vector3D.Unit.MINUS_Z, TEST_PRECISION);
+
+        // act
+        Split<PlaneConvexSubset> split = ps.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.MINUS, split.getLocation());
+
+        Assert.assertSame(ps, split.getMinus());
+        Assert.assertNull(split.getPlus());
+    }
+
+    @Test
+    public void testSplit_both() {
+        // arrange
+        EmbeddedAreaPlaneConvexSubset ps = new EmbeddedAreaPlaneConvexSubset(XY_PLANE_Z1,
+                ConvexArea.convexPolygonFromVertices(Arrays.asList(Vector2D.ZERO, Vector2D.of(1, 0), Vector2D.of(0, 1)),
+                        TEST_PRECISION));
+
+        Plane splitter = Planes.fromPointAndNormal(Vector3D.ZERO, Vector3D.of(-1, 1, 0), TEST_PRECISION);
+
+        // act
+        Split<PlaneConvexSubset> split = ps.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        PlaneConvexSubset minus = split.getMinus();
+        EuclideanTestUtils.assertVertexLoopSequence(
+                Arrays.asList(Vector3D.of(0, 0, 1), Vector3D.of(1, 0, 1), Vector3D.of(0.5, 0.5, 1)),
+                minus.getVertices(), TEST_PRECISION);
+
+        PlaneConvexSubset plus = split.getPlus();
+        EuclideanTestUtils.assertVertexLoopSequence(
+                Arrays.asList(Vector3D.of(0, 0, 1), Vector3D.of(0.5, 0.5, 1), Vector3D.of(0, 1, 1)),
+                plus.getVertices(), TEST_PRECISION);
+    }
+
+    @Test
+    public void testSplit_neither() {
+        // arrange
+        EmbeddedAreaPlaneConvexSubset ps = new EmbeddedAreaPlaneConvexSubset(XY_PLANE_Z1,
+                ConvexArea.convexPolygonFromVertices(Arrays.asList(Vector2D.ZERO, Vector2D.of(1, 0), Vector2D.of(0, 1)),
+                        TEST_PRECISION));
+
+        Plane splitter = Planes.fromPointAndNormal(Vector3D.of(0, 0, 1), Vector3D.of(0, 1e-15, -1), TEST_PRECISION);
+
+        // act
+        Split<PlaneConvexSubset> split = ps.split(splitter);
+
+        // assert
+        Assert.assertEquals(SplitLocation.NEITHER, split.getLocation());
+
+        Assert.assertNull(split.getMinus());
+        Assert.assertNull(split.getPlus());
+    }
+
+    @Test
+    public void testSplit_usesVertexBasedSubsetsWhenPossible() {
+        // arrange
+        // create an infinite subset
+        EmbeddingPlane plane = Planes.fromPointAndPlaneVectors(Vector3D.ZERO,
+                Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
+        EmbeddedAreaPlaneConvexSubset ps = new EmbeddedAreaPlaneConvexSubset(plane, ConvexArea.fromBounds(
+                    Lines.fromPointAndAngle(Vector2D.ZERO, 0, TEST_PRECISION),
+                    Lines.fromPointAndAngle(Vector2D.of(1, 0), PlaneAngleRadians.PI_OVER_TWO, TEST_PRECISION),
+                    Lines.fromPointAndAngle(Vector2D.of(0, 1), -PlaneAngleRadians.PI_OVER_TWO, TEST_PRECISION)
+                ));
+
+        Plane splitter = Planes.fromPointAndNormal(Vector3D.of(0.5, 0.5, 0), Vector3D.of(-1, 1, 0), TEST_PRECISION);
+
+        // act
+        Split<PlaneConvexSubset> split = ps.split(splitter);
+
+        // assert
+        Assert.assertTrue(ps.isInfinite());
+
+        Assert.assertEquals(SplitLocation.BOTH, split.getLocation());
+
+        PlaneConvexSubset plus = split.getPlus();
+        Assert.assertNotNull(plus);
+        Assert.assertTrue(plus.isInfinite());
+        Assert.assertTrue(plus instanceof EmbeddedAreaPlaneConvexSubset);
+
+        PlaneConvexSubset minus = split.getMinus();
+        Assert.assertNotNull(minus);
+        Assert.assertFalse(minus.isInfinite());
+        Assert.assertTrue(minus instanceof SimpleTriangle3D);
+    }
+
+    @Test
+    public void testToString() {
+        // arrange
+        EmbeddedAreaPlaneConvexSubset ps = new EmbeddedAreaPlaneConvexSubset(XY_PLANE_Z1,
+                ConvexArea.convexPolygonFromVertices(Arrays.asList(Vector2D.ZERO, Vector2D.of(1, 0), Vector2D.of(0, 1)),
+                        TEST_PRECISION));
+
+        // act
+        String str = ps.toString();
+
+        // assert
+        GeometryTestUtils.assertContains("EmbeddedAreaPlaneConvexSubset[plane= EmbeddingPlane[", str);
+        GeometryTestUtils.assertContains("subspaceRegion= ConvexArea[", str);
+    }
+
+    private static void checkPoints(EmbeddedAreaPlaneConvexSubset ps, RegionLocation loc, Vector3D... pts) {
+        for (Vector3D pt : pts) {
+            Assert.assertEquals("Unexpected location for point " + pt, loc, ps.classify(pt));
+        }
+    }
+}
diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/EmbeddedTreePlaneSubsetTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/EmbeddedTreePlaneSubsetTest.java
index 5860ac3..41dca76 100644
--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/EmbeddedTreePlaneSubsetTest.java
+++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/EmbeddedTreePlaneSubsetTest.java
@@ -18,6 +18,7 @@ package org.apache.commons.geometry.euclidean.threed;
 
 import java.util.Arrays;
 import java.util.List;
+import java.util.regex.Pattern;
... 6899 lines suppressed ...