You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@ponymail.apache.org by hu...@apache.org on 2020/08/11 21:49:19 UTC

[incubator-ponymail-unit-tests] branch master updated: Seed corpus

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

humbedooh pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-ponymail-unit-tests.git


The following commit(s) were added to refs/heads/master by this push:
     new 8715cd2  Seed corpus
8715cd2 is described below

commit 8715cd2b06ef72bc1f82a90d27782e4a6d800d8e
Author: Daniel Gruno <hu...@apache.org>
AuthorDate: Tue Aug 11 23:47:11 2020 +0200

    Seed corpus
---
 corpus/commits_commons_apache_org_2020-07.mbox  | 177995 +++++++++++++++++++++
 corpus/commits_ponymail_apache_org_2016-06.mbox |  16450 ++
 corpus/nexus-html-only.mbox                     |    261 +
 corpus/tomcat-ancient-boundary.mbox             |    123 +
 corpus/users_httpd_apache_org_2020-07.mbox      |  19372 +++
 5 files changed, 214201 insertions(+)

diff --git a/corpus/commits_commons_apache_org_2020-07.mbox b/corpus/commits_commons_apache_org_2020-07.mbox
new file mode 100644
index 0000000..8ce1c81
--- /dev/null
+++ b/corpus/commits_commons_apache_org_2020-07.mbox
@@ -0,0 +1,177995 @@
+From commits-return-74042-archive-asf-public=cust-asf.ponee.io@commons.apache.org  Wed Jul  1 00:18:55 2020
+Return-Path: <co...@commons.apache.org>
+X-Original-To: archive-asf-public@cust-asf.ponee.io
+Delivered-To: archive-asf-public@cust-asf.ponee.io
+Received: from mail.apache.org (hermes.apache.org [207.244.88.153])
+	by mx-eu-01.ponee.io (Postfix) with SMTP id EEF6B180643
+	for <ar...@cust-asf.ponee.io>; Wed,  1 Jul 2020 02:18:54 +0200 (CEST)
+Received: (qmail 42266 invoked by uid 500); 1 Jul 2020 00:18:54 -0000
+Mailing-List: contact commits-help@commons.apache.org; run by ezmlm
+Precedence: bulk
+List-Help: <ma...@commons.apache.org>
+List-Unsubscribe: <ma...@commons.apache.org>
+List-Post: <ma...@commons.apache.org>
+List-Id: <commits.commons.apache.org>
+Reply-To: dev@commons.apache.org
+Delivered-To: mailing list commits@commons.apache.org
+Received: (qmail 42257 invoked by uid 99); 1 Jul 2020 00:18:54 -0000
+Received: from ec2-52-202-80-70.compute-1.amazonaws.com (HELO gitbox.apache.org) (52.202.80.70)
+    by apache.org (qpsmtpd/0.29) with ESMTP; Wed, 01 Jul 2020 00:18:54 +0000
+Received: by gitbox.apache.org (ASF Mail Server at gitbox.apache.org, from userid 33)
+	id 16AE9890B8; Wed,  1 Jul 2020 00:18:54 +0000 (UTC)
+Date: Wed, 01 Jul 2020 00:18:54 +0000
+To: "commits@commons.apache.org" <co...@commons.apache.org>
+Subject: [commons-text] branch master updated: More tests.
+MIME-Version: 1.0
+Content-Type: text/plain; charset=utf-8
+Content-Transfer-Encoding: 8bit
+Message-ID: <15...@gitbox.apache.org>
+From: ggregory@apache.org
+X-Git-Host: gitbox.apache.org
+X-Git-Repo: commons-text
+X-Git-Refname: refs/heads/master
+X-Git-Reftype: branch
+X-Git-Oldrev: 86c29d8062489ae53aff0a13df4ad97ae343ec29
+X-Git-Newrev: 995b42eb3693e30ae52b077ff7e24366d06988c4
+X-Git-Rev: 995b42eb3693e30ae52b077ff7e24366d06988c4
+X-Git-NotificationType: ref_changed_plus_diff
+X-Git-Multimail-Version: 1.5.dev
+Auto-Submitted: auto-generated
+
+This is an automated email from the ASF dual-hosted git repository.
+
+ggregory pushed a commit to branch master
+in repository https://gitbox.apache.org/repos/asf/commons-text.git
+
+
+The following commit(s) were added to refs/heads/master by this push:
+     new 995b42e  More tests.
+995b42e is described below
+
+commit 995b42eb3693e30ae52b077ff7e24366d06988c4
+Author: Gary Gregory <ga...@gmail.com>
+AuthorDate: Tue Jun 30 20:17:43 2020 -0400
+
+    More tests.
+---
+ .../apache/commons/text/StringSubstitutorTest.java | 50 ++++++++++++++++++++--
+ 1 file changed, 46 insertions(+), 4 deletions(-)
+
+diff --git a/src/test/java/org/apache/commons/text/StringSubstitutorTest.java b/src/test/java/org/apache/commons/text/StringSubstitutorTest.java
+index fc64254..a6ce466 100644
+--- a/src/test/java/org/apache/commons/text/StringSubstitutorTest.java
++++ b/src/test/java/org/apache/commons/text/StringSubstitutorTest.java
+@@ -76,10 +76,10 @@ public class StringSubstitutorTest {
+             assertFalse(substitutor.replaceIn((TextStringBuilder) null));
+             assertFalse(substitutor.replaceIn((TextStringBuilder) null, 0, 100));
+         } else {
+-            assertEquals(replaceTemplate, substitutor.replace(replaceTemplate));
+-            final TextStringBuilder bld = new TextStringBuilder(replaceTemplate);
+-            assertFalse(substitutor.replaceIn(bld));
+-            assertEquals(replaceTemplate, bld.toString());
++            assertEquals(replaceTemplate, replace(substitutor, replaceTemplate));
++            final TextStringBuilder builder = new TextStringBuilder(replaceTemplate);
++            assertFalse(substitutor.replaceIn(builder));
++            assertEquals(replaceTemplate, builder.toString());
+         }
+     }
+ 
+@@ -173,6 +173,10 @@ public class StringSubstitutorTest {
+     @BeforeEach
+     public void setUp() throws Exception {
+         values = new HashMap<>();
++        // shortest key and value.
++        values.put("a", "1");
++        values.put("b", "2");
++        // normal key and value.
+         values.put("animal", ACTUAL_ANIMAL);
+         values.put("target", ACTUAL_TARGET);
+     }
+@@ -718,10 +722,19 @@ public class StringSubstitutorTest {
+     }
+ 
+     /**
++     * Tests escaping.
++     */
++    @Test
++    public void testReplaceVariablesCount1Escaping5To4() throws IOException {
++        doTestReplace("$$$${animal}", "$$$$${animal}", false);
++    }
++
++    /**
+      * Tests simple key replace.
+      */
+     @Test
+     public void testReplaceVariablesCount2() throws IOException {
++        doTestReplace("12", "${a}${b}", false);
+         doTestReplace(ACTUAL_ANIMAL + ACTUAL_ANIMAL, "${animal}${animal}", false);
+         doTestReplace(ACTUAL_TARGET + ACTUAL_TARGET, "${target}${target}", false);
+         doTestReplace(ACTUAL_ANIMAL + ACTUAL_TARGET, "${animal}${target}", false);
+@@ -731,12 +744,36 @@ public class StringSubstitutorTest {
+      * Tests simple key replace.
+      */
+     @Test
++    public void testReplaceVariablesCount2NonAdjacent() throws IOException {
++        doTestReplace("1 2", "${a} ${b}", false);
++        doTestReplace(ACTUAL_ANIMAL + " " + ACTUAL_ANIMAL, "${animal} ${animal}", false);
++        doTestReplace(ACTUAL_ANIMAL + " " + ACTUAL_ANIMAL, "${animal} ${animal}", false);
++        doTestReplace(ACTUAL_ANIMAL + " " + ACTUAL_ANIMAL, "${animal} ${animal}", false);
++    }
++
++    /**
++     * Tests simple key replace.
++     */
++    @Test
+     public void testReplaceVariablesCount3() throws IOException {
++        doTestReplace("121", "${a}${b}${a}", false);
+         doTestReplace(ACTUAL_ANIMAL + ACTUAL_ANIMAL + ACTUAL_ANIMAL, "${animal}${animal}${animal}", false);
+         doTestReplace(ACTUAL_TARGET + ACTUAL_TARGET + ACTUAL_TARGET, "${target}${target}${target}", false);
+     }
+ 
+     /**
++     * Tests simple key replace.
++     */
++    @Test
++    public void testReplaceVariablesCount3NonAdjacent() throws IOException {
++        doTestReplace("1 2 1", "${a} ${b} ${a}", false);
++        doTestReplace(ACTUAL_ANIMAL + " " + ACTUAL_ANIMAL + " " + ACTUAL_ANIMAL, "${animal} ${animal} ${animal}",
++            false);
++        doTestReplace(ACTUAL_TARGET + " " + ACTUAL_TARGET + " " + ACTUAL_TARGET, "${target} ${target} ${target}",
++            false);
++    }
++
++    /**
+      * Tests interpolation with weird boundary patterns.
+      */
+     @Test
+@@ -748,9 +785,14 @@ public class StringSubstitutorTest {
+         doTestNoReplace("${\n}");
+         doTestNoReplace("${\b}");
+         doTestNoReplace("${");
++        // TODO this looks like a bug since a $ is removed but this is not a variable.
++        // doTestNoReplace("$${");
++        // doTestNoReplace("$$${");
+         doTestNoReplace("$}");
++        doTestNoReplace("$$}");
+         doTestNoReplace("}");
+         doTestNoReplace("${}$");
++        doTestNoReplace("${}$$");
+         doTestNoReplace("${${");
+         doTestNoReplace("${${}}");
+         doTestNoReplace("${$${}}");
+
+
+From commits-return-74043-archive-asf-public=cust-asf.ponee.io@commons.apache.org  Wed Jul  1 00:24:45 2020
+Return-Path: <co...@commons.apache.org>
+X-Original-To: archive-asf-public@cust-asf.ponee.io
+Delivered-To: archive-asf-public@cust-asf.ponee.io
+Received: from mail.apache.org (hermes.apache.org [207.244.88.153])
+	by mx-eu-01.ponee.io (Postfix) with SMTP id 44D65180643
+	for <ar...@cust-asf.ponee.io>; Wed,  1 Jul 2020 02:24:45 +0200 (CEST)
+Received: (qmail 60233 invoked by uid 500); 1 Jul 2020 00:24:44 -0000
+Mailing-List: contact commits-help@commons.apache.org; run by ezmlm
+Precedence: bulk
+List-Help: <ma...@commons.apache.org>
+List-Unsubscribe: <ma...@commons.apache.org>
+List-Post: <ma...@commons.apache.org>
+List-Id: <commits.commons.apache.org>
+Reply-To: dev@commons.apache.org
+Delivered-To: mailing list commits@commons.apache.org
+Received: (qmail 60224 invoked by uid 99); 1 Jul 2020 00:24:44 -0000
+Received: from ec2-52-202-80-70.compute-1.amazonaws.com (HELO gitbox.apache.org) (52.202.80.70)
+    by apache.org (qpsmtpd/0.29) with ESMTP; Wed, 01 Jul 2020 00:24:44 +0000
+Received: by gitbox.apache.org (ASF Mail Server at gitbox.apache.org, from userid 33)
+	id 04AF5890B8; Wed,  1 Jul 2020 00:24:43 +0000 (UTC)
+Date: Wed, 01 Jul 2020 00:24:43 +0000
+To: "commits@commons.apache.org" <co...@commons.apache.org>
+Subject: [commons-text] branch master updated: More tests.
+MIME-Version: 1.0
+Content-Type: text/plain; charset=utf-8
+Content-Transfer-Encoding: 8bit
+Message-ID: <15...@gitbox.apache.org>
+From: ggregory@apache.org
+X-Git-Host: gitbox.apache.org
+X-Git-Repo: commons-text
+X-Git-Refname: refs/heads/master
+X-Git-Reftype: branch
+X-Git-Oldrev: 995b42eb3693e30ae52b077ff7e24366d06988c4
+X-Git-Newrev: dc640d198cfa52ccae83b8b90dd256a86530bbc0
+X-Git-Rev: dc640d198cfa52ccae83b8b90dd256a86530bbc0
+X-Git-NotificationType: ref_changed_plus_diff
+X-Git-Multimail-Version: 1.5.dev
+Auto-Submitted: auto-generated
+
+This is an automated email from the ASF dual-hosted git repository.
+
+ggregory pushed a commit to branch master
+in repository https://gitbox.apache.org/repos/asf/commons-text.git
+
+
+The following commit(s) were added to refs/heads/master by this push:
+     new dc640d1  More tests.
+dc640d1 is described below
+
+commit dc640d198cfa52ccae83b8b90dd256a86530bbc0
+Author: Gary Gregory <ga...@gmail.com>
+AuthorDate: Tue Jun 30 20:24:37 2020 -0400
+
+    More tests.
+---
+ src/test/java/org/apache/commons/text/StringSubstitutorTest.java | 2 ++
+ 1 file changed, 2 insertions(+)
+
+diff --git a/src/test/java/org/apache/commons/text/StringSubstitutorTest.java b/src/test/java/org/apache/commons/text/StringSubstitutorTest.java
+index a6ce466..d75ed32 100644
+--- a/src/test/java/org/apache/commons/text/StringSubstitutorTest.java
++++ b/src/test/java/org/apache/commons/text/StringSubstitutorTest.java
+@@ -787,7 +787,9 @@ public class StringSubstitutorTest {
+         doTestNoReplace("${");
+         // TODO this looks like a bug since a $ is removed but this is not a variable.
+         // doTestNoReplace("$${");
++        // doTestNoReplace("$${a");
+         // doTestNoReplace("$$${");
++        // doTestNoReplace("$$${a");
+         doTestNoReplace("$}");
+         doTestNoReplace("$$}");
+         doTestNoReplace("}");
+
+
+From commits-return-74044-archive-asf-public=cust-asf.ponee.io@commons.apache.org  Wed Jul  1 02:58:34 2020
+Return-Path: <co...@commons.apache.org>
+X-Original-To: archive-asf-public@cust-asf.ponee.io
+Delivered-To: archive-asf-public@cust-asf.ponee.io
+Received: from mail.apache.org (hermes.apache.org [207.244.88.153])
+	by mx-eu-01.ponee.io (Postfix) with SMTP id 6B34F180643
+	for <ar...@cust-asf.ponee.io>; Wed,  1 Jul 2020 04:58:31 +0200 (CEST)
+Received: (qmail 79489 invoked by uid 500); 1 Jul 2020 02:58:30 -0000
+Mailing-List: contact commits-help@commons.apache.org; run by ezmlm
+Precedence: bulk
+List-Help: <ma...@commons.apache.org>
+List-Unsubscribe: <ma...@commons.apache.org>
+List-Post: <ma...@commons.apache.org>
+List-Id: <commits.commons.apache.org>
+Reply-To: dev@commons.apache.org
+Delivered-To: mailing list commits@commons.apache.org
+Received: (qmail 79475 invoked by uid 99); 1 Jul 2020 02:58:30 -0000
+Received: from ec2-52-202-80-70.compute-1.amazonaws.com (HELO gitbox.apache.org) (52.202.80.70)
+    by apache.org (qpsmtpd/0.29) with ESMTP; Wed, 01 Jul 2020 02:58:30 +0000
+Received: by gitbox.apache.org (ASF Mail Server at gitbox.apache.org, from userid 33)
+	id 269C3890B8; Wed,  1 Jul 2020 02:58:30 +0000 (UTC)
+Date: Wed, 01 Jul 2020 02:58:30 +0000
+To: "commits@commons.apache.org" <co...@commons.apache.org>
+Subject: [commons-geometry] branch master updated: GEOMETRY-95: adding 3D
+ mesh classes, PartitionedRegionBuilders, and Planes.extrude() methods;
+ expanding examples-io functionality
+MIME-Version: 1.0
+Content-Type: text/plain; charset=utf-8
+Content-Transfer-Encoding: 8bit
+Message-ID: <15...@gitbox.apache.org>
+From: mattjuntunen@apache.org
+X-Git-Host: gitbox.apache.org
+X-Git-Repo: commons-geometry
+X-Git-Refname: refs/heads/master
+X-Git-Reftype: branch
+X-Git-Oldrev: 4140682cf96dbce16c7965de75b9d045078007f8
+X-Git-Newrev: 2f77c164d5c5a15d8590443279baeccccbe9d917
+X-Git-Rev: 2f77c164d5c5a15d8590443279baeccccbe9d917
+X-Git-NotificationType: ref_changed_plus_diff
+X-Git-Multimail-Version: 1.5.dev
+Auto-Submitted: auto-generated
+
+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
+
+
+The following commit(s) were added to refs/heads/master by this push:
+     new 2f77c16  GEOMETRY-95: adding 3D mesh classes, PartitionedRegionBuilders, and Planes.extrude() methods; expanding examples-io functionality
+2f77c16 is described below
+
+commit 2f77c164d5c5a15d8590443279baeccccbe9d917
+Author: Matt Juntunen <ma...@apache.org>
+AuthorDate: Tue Jun 30 21:24:41 2020 -0400
+
+    GEOMETRY-95: adding 3D mesh classes, PartitionedRegionBuilders, and Planes.extrude() methods; expanding examples-io functionality
+---
+ .../bsp/AbstractPartitionedRegionBuilder.java      |  295 +++
+ .../bsp/AbstractPartitionedRegionBuilderTest.java  |  450 ++++
+ .../core/partitioning/test/TestRegionBSPTree.java  |    2 +-
+ .../euclidean/threed/BoundarySource3D.java         |   25 +-
+ .../threed/BoundarySourceBoundsBuilder3D.java      |    4 +-
+ .../geometry/euclidean/threed/Bounds3D.java        |    6 +-
+ .../geometry/euclidean/threed/ConvexVolume.java    |    3 +-
+ .../commons/geometry/euclidean/threed/Planes.java  |  241 ++
+ .../geometry/euclidean/threed/RegionBSPTree3D.java |  177 +-
+ .../geometry/euclidean/threed/mesh/Mesh.java       |  114 +
+ .../euclidean/threed/mesh/SimpleTriangleMesh.java  |  750 +++++++
+ .../euclidean/threed/mesh/TriangleMesh.java        |   54 +
+ .../euclidean/threed/mesh}/package-info.java       |    7 +-
+ .../geometry/euclidean/threed/shape/Sphere.java    |  156 +-
+ .../geometry/euclidean/twod/BoundarySource2D.java  |    9 +-
+ .../twod/BoundarySourceBoundsBuilder2D.java        |    4 +-
+ .../commons/geometry/euclidean/twod/Bounds2D.java  |    6 +-
+ .../geometry/euclidean/twod/RegionBSPTree2D.java   |  174 ++
+ .../euclidean/DocumentationExamplesTest.java       |   36 +-
+ .../geometry/euclidean/testio/TestOBJWriter.java   |  282 +++
+ .../geometry/euclidean/threed/Bounds3DTest.java    |   24 +-
+ .../geometry/euclidean/threed/PlanesTest.java      |  700 ++++++
+ .../euclidean/threed/RegionBSPTree3DTest.java      |  217 ++
+ .../threed/mesh/SimpleTriangleMeshTest.java        |  634 ++++++
+ .../euclidean/threed/shape/SphereTest.java         |   63 +
+ .../geometry/euclidean/twod/Bounds2DTest.java      |   18 +-
+ .../euclidean/twod/RegionBSPTree2DTest.java        |  153 ++
+ commons-geometry-examples/examples-io/pom.xml      |   18 +
+ .../commons/geometry/examples/io/Format3D.java     |  262 ---
+ .../commons/geometry/examples/io/package-info.java |    3 +-
+ .../examples/io/threed/AbstractModelIOHandler.java |  118 +
+ .../io/threed/DefaultModelIOHandlerRegistry.java}  |   25 +-
+ .../geometry/examples/io/threed/ModelIO.java       |  136 ++
+ .../examples/io/threed/ModelIOHandler.java         |   79 +
+ .../examples/io/threed/ModelIOHandlerRegistry.java |  162 ++
+ .../examples/io/threed/obj/OBJConstants.java       |   48 +
+ .../examples/io/threed/obj/OBJModelIOHandler.java  |   67 +
+ .../geometry/examples/io/threed/obj/OBJReader.java |  269 +++
+ .../geometry/examples/io/threed/obj/OBJWriter.java |  265 +++
+ .../examples/io/{ => threed/obj}/package-info.java |    6 +-
+ .../examples/io/{ => threed}/package-info.java     |    5 +-
+ .../threed/DefaultModelIOHandlerRegistryTest.java  |   86 +
+ .../io/threed/ModelIOHandlerRegistryTest.java      |  339 +++
+ .../geometry/examples/io/threed/ModelIOTest.java   |  120 +
+ .../io/threed/obj/OBJModelIOHandlerTest.java       |  258 +++
+ .../examples/io/threed/obj/OBJReaderTest.java      |  251 +++
+ .../examples/io/threed/obj/OBJWriterTest.java      |  360 +++
+ .../test/resources/models/cube-minus-sphere.obj    | 2354 ++++++++++++++++++++
+ .../geometry/examples/jmh/package-info.java        |    2 +-
+ .../resources/spotbugs/spotbugs-exclude-filter.xml |   26 +-
+ src/site/resources/images/cube-minus-sphere.png    |  Bin 0 -> 83639 bytes
+ src/site/xdoc/index.xml                            |   38 +-
+ 52 files changed, 9530 insertions(+), 371 deletions(-)
+
+diff --git a/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/bsp/AbstractPartitionedRegionBuilder.java b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/bsp/AbstractPartitionedRegionBuilder.java
+new file mode 100644
+index 0000000..4113d24
+--- /dev/null
++++ b/commons-geometry-core/src/main/java/org/apache/commons/geometry/core/partitioning/bsp/AbstractPartitionedRegionBuilder.java
+@@ -0,0 +1,295 @@
++/*
++ * Licensed to the Apache Software Foundation (ASF) under one or more
++ * contributor license agreements.  See the NOTICE file distributed with
++ * this work for additional information regarding copyright ownership.
++ * The ASF licenses this file to You under the Apache License, Version 2.0
++ * (the "License"); you may not use this file except in compliance with
++ * the License.  You may obtain a copy of the License at
++ *
++ *      http://www.apache.org/licenses/LICENSE-2.0
++ *
++ * Unless required by applicable law or agreed to in writing, software
++ * distributed under the License is distributed on an "AS IS" BASIS,
++ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
++ * See the License for the specific language governing permissions and
++ * limitations under the License.
++ */
++package org.apache.commons.geometry.core.partitioning.bsp;
++
++import java.util.ArrayList;
++import java.util.Collections;
++import java.util.Comparator;
++import java.util.HashSet;
++import java.util.List;
++import java.util.Set;
++import java.util.function.BiConsumer;
++
++import org.apache.commons.geometry.core.Point;
++import org.apache.commons.geometry.core.RegionLocation;
++import org.apache.commons.geometry.core.partitioning.HyperplaneConvexSubset;
++import org.apache.commons.geometry.core.partitioning.Split;
++import org.apache.commons.geometry.core.partitioning.bsp.AbstractBSPTree.SubtreeInitializer;
++import org.apache.commons.geometry.core.partitioning.bsp.AbstractRegionBSPTree.AbstractRegionNode;
++
++/** Class encapsulating logic for building regions by inserting boundaries into a BSP
++ * tree containing structural cuts, i.e. cuts where both sides of the cut have the same region
++ * location. This technique only produces accurate results when the inserted boundaries define
++ * the entire surface of the region. However, for valid input boundaries, significant performance
++ * improvements can be achieved due to the reduced height of the tree, especially where large
++ * numbers of boundaries are involved and/or the defined region is convex.
++ *
++ * <h2>Implementation Notes</h2>
++ *
++ * <p>This class constructs regions in two phases: (1) <em>partition insertion</em> and (2) <em>boundary insertion</em>.
++ * Instances begin in the <em>partition insertion</em> phase. Here, partitions can be inserted into the empty tree
++ * using the standard BSP insertion logic. The {@link RegionCutRule#INHERIT INHERIT} cut rule is used so that the
++ * represented region remains empty even as partitions are inserted.
++ * </p>
++ *
++ * <p>The instance moves into the <em>boundary insertion</em> phase when the caller inserts the first region boundary.
++ * Attempting to insert a partition after this point results in an {@code IllegalStateException}. This ensures that
++ * partitioning cuts are always located higher up the tree than boundary cuts.</p>
++ *
++ * <p>After all boundaries are inserted, the tree undergoes final processing to ensure that the region is consistent
++ * and that unnecessary nodes are removed.</p>
++ *
++ * <p>This class does not expose any public methods so that subclasses can present their own
++ * public API, tailored to the specific types being worked with. In particular, most subclasses
++ * will want to restrict the tree types used with the algorithm, which is difficult to implement
++ * cleanly at this level.</p>
++ * @param <P> Point implementation type
++ * @param <N> BSP tree node implementation type
++ */
++public abstract class AbstractPartitionedRegionBuilder<
++    P extends Point<P>,
++    N extends AbstractRegionNode<P, N>> {
++
++    /** Comparator for sorting nodes with the deepest nodes first. */
++    private static final Comparator<BSPTree.Node<?, ?>> DEEPEST_FIRST_ORDER =
++        (a, b) -> Integer.compare(b.depth(), a.depth());
++
++    /** Tree being constructed. */
++    private final AbstractRegionBSPTree<P, N> tree;
++
++    /** Subtree initializer for inserted boundaries. */
++    private final SubtreeInitializer<N> subtreeInit;
++
++    /** Flag indicating whether or not partitions may still be inserted into the tree. */
++    private boolean insertingPartitions = true;
++
++    /** Set of all internal nodes used as partitioning nodes. */
++    private final Set<N> partitionNodes = new HashSet<>();
++
++    /** Construct a new instance that builds a partitioned region in the given tree. The tree must
++     * be empty.
++     * @param tree tree to build the region in; must be empty
++     * @throws IllegalArgumentException if the tree is not empty
++     */
++    protected AbstractPartitionedRegionBuilder(final AbstractRegionBSPTree<P, N> tree) {
++        if (!tree.isEmpty()) {
++            throw new IllegalArgumentException("Tree must be empty");
++        }
++
++        this.tree = tree;
++        this.subtreeInit = tree.getSubtreeInitializer(RegionCutRule.MINUS_INSIDE);
++    }
++
++    /** Internal method to build and return the tree representing the final partitioned region.
++     * @return the partitioned region
++     */
++    protected AbstractRegionBSPTree<P, N> buildInternal() {
++        // condense to combine homogenous leaf nodes
++        tree.condense();
++
++        // propagate region interiors to partitioned nodes that have not received
++        // a boundary
++        if (propagateRegionInterior()) {
++            // condense again since some leaf nodes changed
++            tree.condense();
++        }
++
++        return tree;
++    }
++
++    /** Internal method to insert a partition into the tree.
++     * @param partition partition to insert
++     * @throws IllegalStateException if a boundary has previously been inserted
++     */
++    protected void insertPartitionInternal(final HyperplaneConvexSubset<P> partition) {
++        ensureInsertingPartitions();
++
++        tree.insert(partition, RegionCutRule.INHERIT);
++    }
++
++    /** Internal method to insert a region boundary into the tree.
++     * @param boundary boundary to insert
++     */
++    protected void insertBoundaryInternal(final HyperplaneConvexSubset<P> boundary) {
++        if (insertingPartitions) {
++            // switch to inserting boundaries; place all current internal nodes into
++            // a set for easy identification
++            for (final N node : tree.nodes()) {
++                if (node.isInternal()) {
++                    partitionNodes.add(node);
++                }
++            }
++
++            insertingPartitions = false;
++        }
++
++        insertBoundaryRecursive(tree.getRoot(), boundary, boundary.getHyperplane().span(),
++            (leaf, cut) -> tree.setNodeCut(leaf, cut, subtreeInit));
++    }
++
++    /** Insert a region boundary into the tree.
++     * @param node node to insert into
++     * @param insert the hyperplane convex subset to insert
++     * @param trimmed version of the hyperplane convex subset filling the entire space of {@code node}
++     * @param leafFn function to apply to leaf nodes
++     */
++    private void insertBoundaryRecursive(final N node, final HyperplaneConvexSubset<P> insert,
++            final HyperplaneConvexSubset<P> trimmed, final BiConsumer<N, HyperplaneConvexSubset<P>> leafFn) {
++        if (node.isLeaf()) {
++            leafFn.accept(node, trimmed);
++        } else {
++            final Split<? extends HyperplaneConvexSubset<P>> insertSplit =
++                    insert.split(node.getCutHyperplane());
++
++            final HyperplaneConvexSubset<P> minus = insertSplit.getMinus();
++            final HyperplaneConvexSubset<P> plus = insertSplit.getPlus();
++
++            if (minus == null && plus == null && isPartitionNode(node)) {
++                // the inserted boundary lies directly on a partition; proceed down the tree with the
++                // rest of the insertion algorithm but instead of cutting the final leaf nodes, just
++                // set the location
++
++                // remove this node from the set of partition nodes since this is now a boundary cut
++                partitionNodes.remove(node);
++
++                final boolean sameOrientation = node.getCutHyperplane().similarOrientation(insert.getHyperplane());
++                final N insertMinus = sameOrientation ? node.getMinus() : node.getPlus();
++                final N insertPlus = sameOrientation ? node.getPlus() : node.getMinus();
++
++                insertBoundaryRecursive(insertMinus, insert, trimmed,
++                    (leaf, cut) -> leaf.setLocation(RegionLocation.INSIDE));
++
++                insertBoundaryRecursive(insertPlus, insert, trimmed,
++                    (leaf, cut) -> leaf.setLocation(RegionLocation.OUTSIDE));
++
++            } else if (minus != null || plus != null) {
++                final Split<? extends HyperplaneConvexSubset<P>> trimmedSplit =
++                        trimmed.split(node.getCutHyperplane());
++
++                final HyperplaneConvexSubset<P> trimmedMinus = trimmedSplit.getMinus();
++                final HyperplaneConvexSubset<P> trimmedPlus = trimmedSplit.getPlus();
++
++                if (minus != null) {
++                    insertBoundaryRecursive(node.getMinus(), minus, trimmedMinus, leafFn);
++                }
++                if (plus != null) {
++                    insertBoundaryRecursive(node.getPlus(), plus, trimmedPlus, leafFn);
++                }
++            }
++        }
++    }
++
++    /** Propagate the region interior to partitioned leaf nodes that have not had a boundary
++     * inserted.
++     * @return true if any nodes were changed
++     */
++    private boolean propagateRegionInterior() {
++        List<N> outsidePartitionedLeaves = getOutsidePartitionedLeaves();
++        Collections.sort(outsidePartitionedLeaves, DEEPEST_FIRST_ORDER);
++
++        int changeCount = 0;
++
++        N parent;
++        N sibling;
++        for (final N leaf : outsidePartitionedLeaves) {
++            parent = leaf.getParent();
++
++            // check if the parent cut touches the inside anywhere on the side opposite of
++            // this leaf; if so, then this node should also be inside
++            sibling = leaf.isMinus() ?
++                    parent.getPlus() :
++                    parent.getMinus();
++
++            if (touchesInside(parent.getCut(), sibling)) {
++                leaf.setLocation(RegionLocation.INSIDE);
++
++                ++changeCount;
++            }
++        }
++
++        return changeCount > 0;
++    }
++
++    /** Return a list containing all outside leaf nodes that have a parent marked as a partition node.
++     * @return a list containing all outside leaf nodes that have a parent marked as a partition node
++     */
++    private List<N> getOutsidePartitionedLeaves() {
++        final List<N> result = new ArrayList<>();
++
++        final N root = tree.getRoot();
++        collectOutsidePartitionedLeavesRecursive(root, false, result);
++
++        return result;
++    }
++
++   /** Recursively collect all outside leaf nodes that have a parent marked as a partition node.
++    * @param node root of the subtree to collect nodes from
++    * @param parentIsPartitionNode true if the parent of {@code node} is a partition node
++    * @param result list of accumulated results
++    */
++    private void collectOutsidePartitionedLeavesRecursive(final N node, final boolean parentIsPartitionNode,
++            final List<N> result) {
++        if (node != null) {
++            if (parentIsPartitionNode && node.isOutside()) {
++                result.add(node);
++            }
++
++            final boolean partitionNode = isPartitionNode(node);
++
++            collectOutsidePartitionedLeavesRecursive(node.getMinus(), partitionNode, result);
++            collectOutsidePartitionedLeavesRecursive(node.getPlus(), partitionNode, result);
++        }
++    }
++
++    /** Return true if {@code sub} touches an inside leaf node anywhere in the subtree rooted at {@code node}.
++     * @param sub convex subset to check
++     * @param node root node of the subtree to test against
++     * @return true if {@code sub} touches an inside leaf node anywhere in the subtree rooted at {@code node}
++     */
++    private boolean touchesInside(final HyperplaneConvexSubset<P> sub, final N node) {
++        if (sub != null) {
++            if (node.isLeaf()) {
++                return node.isInside();
++            } else {
++                final Split<? extends HyperplaneConvexSubset<P>> split = sub.split(node.getCutHyperplane());
++
++                return touchesInside(split.getMinus(), node.getMinus()) ||
++                        touchesInside(split.getPlus(), node.getPlus());
++
++            }
++        }
++
++        return false;
++    }
++
++    /** Return true if the given node is marked as a partition node.
++     * @param node node to check
++     * @return true if the given node is marked as a partition node
++     */
++    private boolean isPartitionNode(final N node) {
++        return partitionNodes.contains(node);
++    }
++
++    /** Throw an exception if the instance is no longer accepting partitions.
++     * @throws IllegalStateException if the instance is no longer accepting partitions
++     */
++    private void ensureInsertingPartitions() {
++        if (!insertingPartitions) {
++            throw new IllegalStateException("Cannot insert partitions after boundaries have been inserted");
++        }
++    }
++}
+diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/bsp/AbstractPartitionedRegionBuilderTest.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/bsp/AbstractPartitionedRegionBuilderTest.java
+new file mode 100644
+index 0000000..6612c8c
+--- /dev/null
++++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/bsp/AbstractPartitionedRegionBuilderTest.java
+@@ -0,0 +1,450 @@
++/*
++ * Licensed to the Apache Software Foundation (ASF) under one or more
++ * contributor license agreements.  See the NOTICE file distributed with
++ * this work for additional information regarding copyright ownership.
++ * The ASF licenses this file to You under the Apache License, Version 2.0
++ * (the "License"); you may not use this file except in compliance with
++ * the License.  You may obtain a copy of the License at
++ *
++ *      http://www.apache.org/licenses/LICENSE-2.0
++ *
++ * Unless required by applicable law or agreed to in writing, software
++ * distributed under the License is distributed on an "AS IS" BASIS,
++ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
++ * See the License for the specific language governing permissions and
++ * limitations under the License.
++ */
++package org.apache.commons.geometry.core.partitioning.bsp;
++
++import java.util.Arrays;
++import java.util.List;
++
++import org.apache.commons.geometry.core.GeometryTestUtils;
++import org.apache.commons.geometry.core.RegionLocation;
++import org.apache.commons.geometry.core.partitioning.HyperplaneConvexSubset;
++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.Assert;
++import org.junit.Test;
++
++public class AbstractPartitionedRegionBuilderTest {
++
++    @Test
++    public void testCtor_invalidTree() {
++        // arrange
++        TestRegionBSPTree tree = new TestRegionBSPTree(true);
++
++        // act/assert
++        GeometryTestUtils.assertThrows(() -> {
++            new TestRegionBuilder(tree);
++        }, IllegalArgumentException.class, "Tree must be empty");
++    }
++
++    @Test
++    public void testBuildRegion_empty() {
++        // arrange
++        TestRegionBuilder builder = new TestRegionBuilder(new TestRegionBSPTree(false));
++
++        // act
++        TestRegionBSPTree tree = builder.build();
++
++        // assert
++        Assert.assertTrue(tree.isEmpty());
++        Assert.assertEquals(1, tree.count());
++        Assert.assertEquals(0, tree.height());
++    }
++
++    @Test
++    public void testInsertPartition_cannotInsertAfterBoundary() {
++        // arrange
++        TestRegionBuilder builder = new TestRegionBuilder(new TestRegionBSPTree(false));
++
++        builder.insertBoundary(new TestLineSegment(new TestPoint2D(0, 0), new TestPoint2D(1, 0)));
++
++        // act/assert
++        GeometryTestUtils.assertThrows(() -> {
++            builder.insertPartition(new TestLine(new TestPoint2D(0, 0), new TestPoint2D(1, 0)).span());
++        }, IllegalStateException.class, "Cannot insert partitions after boundaries have been inserted");
++    }
++
++    @Test
++    public void testBuildRegion_noPartitions_halfSpace() {
++        // arrange
++        TestRegionBuilder builder = new TestRegionBuilder(new TestRegionBSPTree(false));
++
++        // act
++        builder.insertBoundary(new TestLineSegment(new TestPoint2D(0, 0), new TestPoint2D(1, 0)));
++        TestRegionBSPTree tree = builder.build();
++
++        // assert
++        Assert.assertFalse(tree.isEmpty());
++        Assert.assertFalse(tree.isFull());
++
++        Assert.assertEquals(3, tree.count());
++        Assert.assertEquals(1, tree.height());
++
++        PartitionTestUtils.assertPointLocations(tree, RegionLocation.INSIDE,
++                new TestPoint2D(-5, 1), new TestPoint2D(0, 1), new TestPoint2D(5, 1));
++
++        PartitionTestUtils.assertPointLocations(tree, RegionLocation.BOUNDARY,
++                new TestPoint2D(-5, 0), new TestPoint2D(0, 0), new TestPoint2D(5, 0));
++
++        PartitionTestUtils.assertPointLocations(tree, RegionLocation.OUTSIDE,
++                new TestPoint2D(-5, -1), new TestPoint2D(0, -1), new TestPoint2D(5, -1));
++    }
++
++    @Test
++    public void testBuildRegion_boundaryOnPartition_sameOrientation() {
++     // arrange
++        TestRegionBuilder builder = new TestRegionBuilder(new TestRegionBSPTree(false));
++
++        // act
++        builder.insertPartition(new TestLine(new TestPoint2D(0, 0), new TestPoint2D(1, 0)).span());
++
++        builder.insertBoundary(new TestLineSegment(new TestPoint2D(0, 0), new TestPoint2D(1, 0)));
++        TestRegionBSPTree tree = builder.build();
++
++        // assert
++        Assert.assertFalse(tree.isEmpty());
++        Assert.assertFalse(tree.isFull());
++
++        PartitionTestUtils.assertPointLocations(tree, RegionLocation.INSIDE,
++                new TestPoint2D(-5, 1), new TestPoint2D(0, 1), new TestPoint2D(5, 1));
++
++        PartitionTestUtils.assertPointLocations(tree, RegionLocation.BOUNDARY,
++                new TestPoint2D(-5, 0), new TestPoint2D(0, 0), new TestPoint2D(5, 0));
++
++        PartitionTestUtils.assertPointLocations(tree, RegionLocation.OUTSIDE,
++                new TestPoint2D(-5, -1), new TestPoint2D(0, -1), new TestPoint2D(5, -1));
++    }
++
++    @Test
++    public void testBuildRegion_boundaryOnPartition_oppositeOrientation() {
++     // arrange
++        TestRegionBuilder builder = new TestRegionBuilder(new TestRegionBSPTree(false));
++
++        // act
++        builder.insertPartition(new TestLine(new TestPoint2D(1, 0), new TestPoint2D(0, 0)).span());
++
++        builder.insertBoundary(new TestLineSegment(new TestPoint2D(0, 0), new TestPoint2D(1, 0)));
++        TestRegionBSPTree tree = builder.build();
++
++        // assert
++        Assert.assertFalse(tree.isEmpty());
++        Assert.assertFalse(tree.isFull());
++
++        PartitionTestUtils.assertPointLocations(tree, RegionLocation.INSIDE,
++                new TestPoint2D(-5, 1), new TestPoint2D(0, 1), new TestPoint2D(5, 1));
++
++        PartitionTestUtils.assertPointLocations(tree, RegionLocation.BOUNDARY,
++                new TestPoint2D(-5, 0), new TestPoint2D(0, 0), new TestPoint2D(5, 0));
++
++        PartitionTestUtils.assertPointLocations(tree, RegionLocation.OUTSIDE,
++                new TestPoint2D(-5, -1), new TestPoint2D(0, -1), new TestPoint2D(5, -1));
++    }
++
++    @Test
++    public void testBuildRegion_boundaryOnPartition_multipleBoundaries_sameOrientation() {
++     // arrange
++        TestRegionBuilder builder = new TestRegionBuilder(new TestRegionBSPTree(false));
++
++        // act
++        builder.insertPartition(new TestLine(new TestPoint2D(0, 0), new TestPoint2D(1, 0)).span());
++
++        builder.insertBoundary(new TestLineSegment(new TestPoint2D(0, 1), new TestPoint2D(0, 0)));
++        builder.insertBoundary(new TestLineSegment(new TestPoint2D(0, 0), new TestPoint2D(1, 0)));
++        TestRegionBSPTree tree = builder.build();
++
++        // assert
++        Assert.assertFalse(tree.isEmpty());
++        Assert.assertFalse(tree.isFull());
++
++        PartitionTestUtils.assertPointLocations(tree, RegionLocation.INSIDE, new TestPoint2D(5, 1));
++
++        PartitionTestUtils.assertPointLocations(tree, RegionLocation.BOUNDARY,
++                new TestPoint2D(0, 5), new TestPoint2D(0, 0), new TestPoint2D(5, 0));
++
++        PartitionTestUtils.assertPointLocations(tree, RegionLocation.OUTSIDE,
++                new TestPoint2D(-5, 1), new TestPoint2D(-5, -1), new TestPoint2D(0, -1), new TestPoint2D(5, -1));
++    }
++
++    @Test
++    public void testBuildRegion_boundaryOnPartition_multipleBoundaries_oppositeOrientation() {
++     // arrange
++        TestRegionBuilder builder = new TestRegionBuilder(new TestRegionBSPTree(false));
++
++        // act
++        builder.insertPartition(new TestLine(new TestPoint2D(0, 0), new TestPoint2D(-1, 0)).span());
++
++        builder.insertBoundary(new TestLineSegment(new TestPoint2D(0, 1), new TestPoint2D(0, 0)));
++        builder.insertBoundary(new TestLineSegment(new TestPoint2D(0, 0), new TestPoint2D(1, 0)));
++        TestRegionBSPTree tree = builder.build();
++
++        // assert
++        Assert.assertFalse(tree.isEmpty());
++        Assert.assertFalse(tree.isFull());
++
++        PartitionTestUtils.assertPointLocations(tree, RegionLocation.INSIDE, new TestPoint2D(5, 1));
++
++        PartitionTestUtils.assertPointLocations(tree, RegionLocation.BOUNDARY,
++                new TestPoint2D(0, 5), new TestPoint2D(0, 0), new TestPoint2D(5, 0));
++
++        PartitionTestUtils.assertPointLocations(tree, RegionLocation.OUTSIDE,
++                new TestPoint2D(-5, 1), new TestPoint2D(-5, -1), new TestPoint2D(0, -1), new TestPoint2D(5, -1));
++    }
++
++    @Test
++    public void testBuildRegion_multipleBoundariesOnPartition() {
++        // arrange
++        TestRegionBuilder builder = new TestRegionBuilder(new TestRegionBSPTree(false));
++
++        // act
++        builder.insertPartition(new TestLine(new TestPoint2D(0, 0), new TestPoint2D(1, 0)).span());
++
++        builder.insertBoundary(new TestLineSegment(new TestPoint2D(0, 0), new TestPoint2D(1, 0)));
++        builder.insertBoundary(new TestLineSegment(new TestPoint2D(0, 1), new TestPoint2D(0, 0)));
++        builder.insertBoundary(new TestLineSegment(new TestPoint2D(0, -1), new TestPoint2D(0, 0)));
++        builder.insertBoundary(new TestLineSegment(new TestPoint2D(0, 0), new TestPoint2D(-1, 0)));
++
++        TestRegionBSPTree tree = builder.build();
++
++        // assert
++        Assert.assertFalse(tree.isEmpty());
++        Assert.assertFalse(tree.isFull());
++
++        PartitionTestUtils.assertPointLocations(tree, RegionLocation.INSIDE,
++                new TestPoint2D(1, 1), new TestPoint2D(-1, -1));
++
++        PartitionTestUtils.assertPointLocations(tree, RegionLocation.BOUNDARY,
++                new TestPoint2D(1, 0), new TestPoint2D(-1, 0), new TestPoint2D(0, 1), new TestPoint2D(0, -1));
++
++        PartitionTestUtils.assertPointLocations(tree, RegionLocation.OUTSIDE,
++                new TestPoint2D(-1, 1), new TestPoint2D(1, -1));
++    }
++
++    @Test
++    public void testBuildRegion_grid_halfSpace_boundaryOnPartition() {
++        // arrange
++        TestRegionBuilder builder = new TestRegionBuilder(new TestRegionBSPTree(false));
++
++        // act
++        insertGridRecursive(-2, 2, 5, builder);
++
++        builder.insertBoundary(new TestLineSegment(new TestPoint2D(0, 0), new TestPoint2D(1, 0)));
++        TestRegionBSPTree tree = builder.build();
++
++        // assert
++        Assert.assertFalse(tree.isEmpty());
++        Assert.assertFalse(tree.isFull());
++
++        PartitionTestUtils.assertPointLocations(tree, RegionLocation.INSIDE,
++                new TestPoint2D(-5, 1), new TestPoint2D(0, 1), new TestPoint2D(5, 1));
++
++        PartitionTestUtils.assertPointLocations(tree, RegionLocation.BOUNDARY,
++                new TestPoint2D(-5, 0), new TestPoint2D(0, 0), new TestPoint2D(5, 0));
++
++        PartitionTestUtils.assertPointLocations(tree, RegionLocation.OUTSIDE,
++                new TestPoint2D(-5, -1), new TestPoint2D(0, -1), new TestPoint2D(5, -1));
++    }
++
++    @Test
++    public void testBuildRegion_boundariesOnPartitionPropagateInsideCorrectly() {
++        // arrange
++        TestRegionBuilder builder = new TestRegionBuilder(new TestRegionBSPTree(false));
++
++        // act
++        builder.insertPartition(new TestLineSegment(new TestPoint2D(-1, 0), new TestPoint2D(1, 0)));
++        builder.insertPartition(new TestLineSegment(new TestPoint2D(0, -1), new TestPoint2D(0, 1)));
++
++        builder.insertBoundary(new TestLineSegment(new TestPoint2D(0, 0), new TestPoint2D(1, 0)));
++        builder.insertBoundary(new TestLineSegment(new TestPoint2D(1, 1), new TestPoint2D(1, 0)));
++        TestRegionBSPTree tree = builder.build();
++
++        // assert
++        Assert.assertFalse(tree.isEmpty());
++        Assert.assertFalse(tree.isFull());
++
++        PartitionTestUtils.assertPointLocations(tree, RegionLocation.INSIDE,
++                new TestPoint2D(2, 2), new TestPoint2D(5, 5));
++
++        PartitionTestUtils.assertPointLocations(tree, RegionLocation.BOUNDARY,
++                new TestPoint2D(1, 0), new TestPoint2D(1, 10), new TestPoint2D(10, 0));
++
++        PartitionTestUtils.assertPointLocations(tree, RegionLocation.OUTSIDE,
++                new TestPoint2D(-1, 1), new TestPoint2D(-10, 10),
++                new TestPoint2D(-1, -1), new TestPoint2D(1, -1));
++    }
++
++    @Test
++    public void testBuildRegion_grid_cube() {
++        // arrange
++        int maxCount = 5;
++
++        List<TestLineSegment> boundaries = Arrays.asList(
++                new TestLineSegment(new TestPoint2D(-1, -1), new TestPoint2D(1, -1)),
++                new TestLineSegment(new TestPoint2D(1, -1), new TestPoint2D(1, 1)),
++                new TestLineSegment(new TestPoint2D(1, 1), new TestPoint2D(-1, 1)),
++                new TestLineSegment(new TestPoint2D(-1, 1), new TestPoint2D(-1, -1))
++            );
++
++        for (int c = 0; c <= maxCount; ++c) {
++            TestRegionBuilder builder = new TestRegionBuilder(new TestRegionBSPTree(false));
++
++            // act
++            insertGridRecursive(-2, 2, c, builder);
++
++            for (TestLineSegment boundary : boundaries) {
++                builder.insertBoundary(boundary);
++            }
++
++            TestRegionBSPTree tree = builder.build();
++
++            // assert
++            Assert.assertFalse(tree.isEmpty());
++            Assert.assertFalse(tree.isFull());
++
++            PartitionTestUtils.assertPointLocations(tree, RegionLocation.INSIDE,
++                    new TestPoint2D(0, 0),
++                    new TestPoint2D(-0.5, -0.5), new TestPoint2D(0.5, -0.5),
++                    new TestPoint2D(0.5, 0.5), new TestPoint2D(-0.5, 0.5));
++
++            PartitionTestUtils.assertPointLocations(tree, RegionLocation.BOUNDARY,
++                    new TestPoint2D(-1, -1), new TestPoint2D(1, -1), new TestPoint2D(1, 1), new TestPoint2D(-1, 1),
++                    new TestPoint2D(-1, 0), new TestPoint2D(1, 0), new TestPoint2D(0, 1), new TestPoint2D(0, -1));
++
++            PartitionTestUtils.assertPointLocations(tree, RegionLocation.OUTSIDE,
++                    new TestPoint2D(-2, -2), new TestPoint2D(2, -2), new TestPoint2D(2, 2), new TestPoint2D(-2, 2),
++                    new TestPoint2D(-2, 0), new TestPoint2D(2, 0), new TestPoint2D(0, 2), new TestPoint2D(0, -2));
++        }
++    }
++
++    @Test
++    public void testBuildRegion_grid_diamond() {
++        // arrange
++        int maxCount = 5;
++
++        List<TestLineSegment> boundaries = Arrays.asList(
++                new TestLineSegment(new TestPoint2D(0, 1), new TestPoint2D(-1, 0)),
++                new TestLineSegment(new TestPoint2D(-1, 0), new TestPoint2D(0, -1)),
++                new TestLineSegment(new TestPoint2D(0, -1), new TestPoint2D(1, 0)),
++                new TestLineSegment(new TestPoint2D(1, 0), new TestPoint2D(0, 1))
++            );
++
++        for (int c = 0; c <= maxCount; ++c) {
++            TestRegionBuilder builder = new TestRegionBuilder(new TestRegionBSPTree(false));
++
++            // act
++            insertGridRecursive(-2, 2, c, builder);
++
++            for (TestLineSegment boundary : boundaries) {
++                builder.insertBoundary(boundary);
++            }
++
++            TestRegionBSPTree tree = builder.build();
++
++            // assert
++            Assert.assertFalse(tree.isEmpty());
++            Assert.assertFalse(tree.isFull());
++
++            PartitionTestUtils.assertPointLocations(tree, RegionLocation.INSIDE,
++                    new TestPoint2D(0, 0),
++                    new TestPoint2D(-0.25, -0.25), new TestPoint2D(0.25, -0.25),
++                    new TestPoint2D(0.25, 0.25), new TestPoint2D(-0.25, 0.25));
++
++            PartitionTestUtils.assertPointLocations(tree, RegionLocation.BOUNDARY,
++                    new TestPoint2D(-0.5, 0.5), new TestPoint2D(-0.5, -0.5), new TestPoint2D(0.5, -0.5), new TestPoint2D(0.5, 0.5),
++                    new TestPoint2D(-1, 0), new TestPoint2D(1, 0), new TestPoint2D(0, 1), new TestPoint2D(0, -1));
++
++            PartitionTestUtils.assertPointLocations(tree, RegionLocation.OUTSIDE,
++                    new TestPoint2D(-2, -2), new TestPoint2D(2, -2), new TestPoint2D(2, 2), new TestPoint2D(-2, 2),
++                    new TestPoint2D(-2, 0), new TestPoint2D(2, 0), new TestPoint2D(0, 2), new TestPoint2D(0, -2));
++        }
++    }
++
++    @Test
++    public void testBuildRegion_grid_horseshoe() {
++        // arrange
++        int maxCount = 5;
++
++        List<TestLineSegment> boundaries = Arrays.asList(
++                new TestLineSegment(new TestPoint2D(1, 0), new TestPoint2D(1, 1)),
++                new TestLineSegment(new TestPoint2D(1, 1), new TestPoint2D(3, 1)),
++                new TestLineSegment(new TestPoint2D(3, 1), new TestPoint2D(3, 2)),
++                new TestLineSegment(new TestPoint2D(3, 2), new TestPoint2D(-1, 2)),
++                new TestLineSegment(new TestPoint2D(-1, 2), new TestPoint2D(-1, -1)),
++                new TestLineSegment(new TestPoint2D(-1, -1), new TestPoint2D(3, -1)),
++                new TestLineSegment(new TestPoint2D(3, -1), new TestPoint2D(3, 0)),
++                new TestLineSegment(new TestPoint2D(3, 0), new TestPoint2D(1, 0))
++            );
++
++        for (int c = 0; c <= maxCount; ++c) {
++            TestRegionBuilder builder = new TestRegionBuilder(new TestRegionBSPTree(false));
++
++            // act
++            insertGridRecursive(-2, 2, c, builder);
++
++            for (TestLineSegment boundary : boundaries) {
++                builder.insertBoundary(boundary);
++            }
++
++            TestRegionBSPTree tree = builder.build();
++
++            // assert
++            Assert.assertFalse(tree.isEmpty());
++            Assert.assertFalse(tree.isFull());
++
++            PartitionTestUtils.assertPointLocations(tree, RegionLocation.INSIDE,
++                    new TestPoint2D(0, 0),
++                    new TestPoint2D(0, 1.5), new TestPoint2D(2, 1.5),
++                    new TestPoint2D(0, -0.5), new TestPoint2D(2, -0.5));
++
++            PartitionTestUtils.assertPointLocations(tree, RegionLocation.BOUNDARY,
++                    new TestPoint2D(1, 0), new TestPoint2D(1, 1), new TestPoint2D(3, 1), new TestPoint2D(3, 2),
++                    new TestPoint2D(-1, 2), new TestPoint2D(-1, -1), new TestPoint2D(3, -1), new TestPoint2D(3, 0),
++                    new TestPoint2D(1, 0.5), new TestPoint2D(2, 1), new TestPoint2D(3, 1.5), new TestPoint2D(1, 2),
++                    new TestPoint2D(-1, 0.5), new TestPoint2D(3, -0.5), new TestPoint2D(2, 0));
++
++            PartitionTestUtils.assertPointLocations(tree, RegionLocation.OUTSIDE,
++                    new TestPoint2D(2, 0.5), new TestPoint2D(4, 0.5), new TestPoint2D(4, 0), new TestPoint2D(4, 1.5),
++                    new TestPoint2D(1, 4), new TestPoint2D(1, -4), new TestPoint2D(-4, 0.5));
++        }
++    }
++
++    private static void insertGridRecursive(double min, double max, int count, TestRegionBuilder builder) {
++        if (count > 0) {
++            double center = (0.5 * (max - min)) + min;
++
++            builder.insertPartition(
++                    new TestLine(new TestPoint2D(center, center), new TestPoint2D(center + 1, center)).span());
++
++            builder.insertPartition(
++                    new TestLine(new TestPoint2D(center, center), new TestPoint2D(center, center + 1)).span());
++
++            insertGridRecursive(min, center, count - 1, builder);
++            insertGridRecursive(center, max, count - 1, builder);
++        }
++    }
++
++    private static class TestRegionBuilder
++        extends AbstractPartitionedRegionBuilder<TestPoint2D, TestRegionBSPTree.TestRegionNode> {
++
++        TestRegionBuilder(TestRegionBSPTree tree) {
++            super(tree);
++        }
++
++        public TestRegionBSPTree build() {
++            return (TestRegionBSPTree) buildInternal();
++        }
++
++        public void insertPartition(final HyperplaneConvexSubset<TestPoint2D> partition) {
++            insertPartitionInternal(partition);
++        }
++
++        public void insertBoundary(final HyperplaneConvexSubset<TestPoint2D> boundary) {
++            insertBoundaryInternal(boundary);
++        }
++    }
++}
+diff --git a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/test/TestRegionBSPTree.java b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/test/TestRegionBSPTree.java
+index 651c117..add45b6 100644
+--- a/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/test/TestRegionBSPTree.java
++++ b/commons-geometry-core/src/test/java/org/apache/commons/geometry/core/partitioning/test/TestRegionBSPTree.java
+@@ -52,7 +52,7 @@ public final class TestRegionBSPTree extends AbstractRegionBSPTree<TestPoint2D,
+     /** {@inheritDoc} */
+     @Override
+     protected RegionSizeProperties<TestPoint2D> computeRegionSizeProperties() {
+-     // return a set of stub values
++        // return a set of stub values
+         return new RegionSizeProperties<>(1234, new TestPoint2D(12, 34));
+     }
+ 
+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 7a4eb19..f7e7cf0 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
+@@ -22,18 +22,24 @@ import java.util.List;
+ import java.util.stream.Stream;
+ 
+ import org.apache.commons.geometry.core.partitioning.BoundarySource;
++import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+ import org.apache.commons.geometry.euclidean.threed.line.LineConvexSubset3D;
+ import org.apache.commons.geometry.euclidean.threed.line.LinecastPoint3D;
+ import org.apache.commons.geometry.euclidean.threed.line.Linecastable3D;
++import org.apache.commons.geometry.euclidean.threed.mesh.SimpleTriangleMesh;
++import org.apache.commons.geometry.euclidean.threed.mesh.TriangleMesh;
+ 
+ /** Extension of the {@link BoundarySource} interface for Euclidean 3D space.
+  */
+ public interface BoundarySource3D extends BoundarySource<PlaneConvexSubset>, Linecastable3D {
+ 
+-    /** Return a BSP tree constructed from the boundaries contained in this instance.
+-     * The default implementation creates a new, empty tree and inserts the
+-     * boundaries from this instance.
++    /** Return a BSP tree constructed from the boundaries contained in this instance. This is
++     * a convenience method for quickly constructing BSP trees and may produce unbalanced trees
++     * with unacceptable performance characteristics when used with large numbers of boundaries.
++     * In these cases, alternate tree construction approaches should be used, such as
++     * {@link RegionBSPTree3D.PartitionedRegionBuilder3D}.
+      * @return a BSP tree constructed from the boundaries in this instance
++     * @see RegionBSPTree3D#partitionedRegionBuilder()
+      */
+     default RegionBSPTree3D toTree() {
+         final RegionBSPTree3D tree = RegionBSPTree3D.empty();
+@@ -42,6 +48,15 @@ public interface BoundarySource3D extends BoundarySource<PlaneConvexSubset>, Lin
+         return tree;
+     }
+ 
++    /** Construct a triangle mesh from the boundaries in this instance.
++     * @param precision precision context used in boundaries generated by the resulting mesh
++     * @return a triangle mesh representing the boundaries in this instance
++     * @throws IllegalStateException if any boundary in this boundary source is infinite
++     */
++    default TriangleMesh toTriangleMesh(final DoublePrecisionContext precision) {
++        return SimpleTriangleMesh.from(this, precision);
++    }
++
+     /** 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
+@@ -66,8 +81,8 @@ public interface BoundarySource3D extends BoundarySource<PlaneConvexSubset>, Lin
+     }
+ 
+     /** 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.
++     * in the boundaries for this instance. Null is returned if any boundary is infinite
++     * or no vertices are found.
+      * @return the bounding box for this instance or null if no valid bounds could be determined
+      */
+     default Bounds3D getBounds() {
+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
+index b0164dd..8d10a83 100644
+--- 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
+@@ -25,7 +25,7 @@ import java.util.stream.Stream;
+  * the vertices of each boundary in turn. Null is returned if any boundaries are
+  * infinite or no vertices are present.
+  */
+-class BoundarySourceBoundsBuilder3D {
++final 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.
+@@ -50,7 +50,7 @@ class BoundarySourceBoundsBuilder3D {
+             }
+         }
+ 
+-        return builder.containsBounds() ?
++        return builder.hasBounds() ?
+                 builder.build() :
+                 null;
+     }
+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
+index d6cae6e..1edc2b3 100644
+--- 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
+@@ -255,7 +255,7 @@ public final class Bounds3D extends AbstractBounds<Vector3D, Bounds3D> {
+         /** 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() {
++        public boolean hasBounds() {
+             return Double.isFinite(minX) &&
+                     Double.isFinite(minY) &&
+                     Double.isFinite(minZ) &&
+@@ -269,13 +269,13 @@ public final class Bounds3D extends AbstractBounds<Vector3D, Bounds3D> {
+          * @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()
++         * @see #hasBounds()
+          */
+         public Bounds3D build() {
+             final Vector3D min = Vector3D.of(minX, minY, minZ);
+             final Vector3D max = Vector3D.of(maxX, maxY, maxZ);
+ 
+-            if (!containsBounds()) {
++            if (!hasBounds()) {
+                 if (Double.isInfinite(minX) && minX > 0 &&
+                         Double.isInfinite(maxX) && maxX < 0) {
+                     throw new IllegalStateException("Cannot construct bounds: no points given");
+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 19465ee..9726093 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
+@@ -123,8 +123,7 @@ public class ConvexVolume extends AbstractConvexHyperplaneBoundedRegion<Vector3D
+         return splitInternal(splitter, this, PlaneConvexSubset.class, ConvexVolume::new);
+     }
+ 
+-    /** Return a BSP tree representing the same region as this instance.
+-     */
++    /** {@inheritDoc} */
+     @Override
+     public RegionBSPTree3D toTree() {
+         return RegionBSPTree3D.from(getBoundaries(), true);
+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 0bfadaa..6fe0465 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
+@@ -32,8 +32,11 @@ 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.LineConvexSubset;
+ import org.apache.commons.geometry.euclidean.twod.Lines;
++import org.apache.commons.geometry.euclidean.twod.RegionBSPTree2D;
+ import org.apache.commons.geometry.euclidean.twod.Vector2D;
++import org.apache.commons.geometry.euclidean.twod.path.LinePath;
+ 
+ /** Class containing factory methods for constructing {@link Plane} and {@link PlaneSubset} instances.
+  */
+@@ -340,6 +343,61 @@ public final class Planes {
+         return polygons;
+     }
+ 
++    /** Get the boundaries of a 3D region created by extruding a polygon defined by a list of vertices. The ends
++     * ("top" and "bottom") of the extruded 3D region are flat while the sides follow the boundaries of the original
++     * 2D region.
++     * @param vertices vertices forming the 2D polygon to extrude
++     * @param plane plane to extrude the 2D polygon from
++     * @param extrusionVector vector to extrude the polygon vertices through
++     * @param precision precision context used to construct the 3D region boundaries
++     * @return the boundaries of the extruded 3D region
++     * @throws IllegalStateException if {@code vertices} contains only a single unique vertex
++     * @throws IllegalArgumentException if regions of non-zero size cannot be produced with the
++     *      given plane and extrusion vector. This occurs when the extrusion vector has zero length
++     *      or is orthogonal to the plane normal
++     * @see LinePath#fromVertexLoop(Collection, DoublePrecisionContext)
++     * @see #extrude(LinePath, EmbeddingPlane, Vector3D, DoublePrecisionContext)
++     */
++    public static List<PlaneConvexSubset> extrudeVertexLoop(final List<Vector2D> vertices,
++            final EmbeddingPlane plane, final Vector3D extrusionVector, final DoublePrecisionContext precision) {
++        final LinePath path = LinePath.fromVertexLoop(vertices, precision);
++        return extrude(path, plane, extrusionVector, precision);
++    }
++
++    /** Get the boundaries of the 3D region created by extruding a 2D line path. The ends ("top" and "bottom") of
++     * the extruded 3D region are flat while the sides follow the boundaries of the original 2D region. The path is
++     * converted to a BSP tree before extrusion.
++     * @param path path to extrude
++     * @param plane plane to extrude the path from
++     * @param extrusionVector vector to extrude the polygon points through
++     * @param precision precision precision context used to construct the 3D region boundaries
++     * @return the boundaries of the extruded 3D region
++     * @throws IllegalArgumentException if regions of non-zero size cannot be produced with the
++     *      given plane and extrusion vector. This occurs when the extrusion vector has zero length
++     *      or is orthogonal to the plane normal
++     * @see #extrude(RegionBSPTree2D, EmbeddingPlane, Vector3D, DoublePrecisionContext)
++     */
++    public static List<PlaneConvexSubset> extrude(final LinePath path, final EmbeddingPlane plane,
++            final Vector3D extrusionVector, final DoublePrecisionContext precision) {
++        return extrude(path.toTree(), plane, extrusionVector, precision);
++    }
++
++    /** Get the boundaries of the 3D region created by extruding a 2D region. The ends ("top" and "bottom") of
++     * the extruded 3D region are flat while the sides follow the boundaries of the original 2D region.
++     * @param region region to extrude
++     * @param plane plane to extrude the region from
++     * @param extrusionVector vector to extrude the region points through
++     * @param precision precision precision context used to construct the 3D region boundaries
++     * @return the boundaries of the extruded 3D region
++     * @throws IllegalArgumentException if regions of non-zero size cannot be produced with the
++     *      given plane and extrusion vector. This occurs when the extrusion vector has zero length
++     *      or is orthogonal to the plane normal
++     */
++    public static List<PlaneConvexSubset> extrude(final RegionBSPTree2D region, final EmbeddingPlane plane,
++            final Vector3D extrusionVector, final DoublePrecisionContext precision) {
++        return new PlaneRegionExtruder(plane, extrusionVector, precision).extrude(region);
++    }
++
+     /** 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.
+@@ -719,4 +777,187 @@ public final class Planes {
+             return new IllegalArgumentException("Points do not define a convex region: " + pts);
+         }
+     }
++
++    /** Class designed to create 3D regions by taking a 2D region and extruding from a base plane
++     * through an extrusion vector. The ends ("top" and "bottom") of the extruded 3D region are flat
++     * while the sides follow the boundaries of the original 2D region.
++     */
++    private static final class PlaneRegionExtruder {
++        /** Base plane to extrude from. */
++        private final EmbeddingPlane basePlane;
++
++        /** Extruded plane; this forms the end of the 3D region opposite the base plane. */
++        private final EmbeddingPlane extrudedPlane;
++
++        /** Vector to extrude along; the extruded plane is translated from the base plane by this amount. */
++        private final Vector3D extrusionVector;
++
++        /** True if the extrusion vector points to the plus side of the base plane. */
++        private final boolean extrudingOnPlusSide;
++
++        /** Precision context used to create boundaries. */
++        private final DoublePrecisionContext precision;
++
++        /** Construct a new instance that performs extrusions from {@code basePlane} along {@code extrusionVector}.
++         * @param basePlane base plane to extrude from
++         * @param extrusionVector vector to extrude along
++         * @param precision precision context used to construct boundaries
++         * @throws IllegalArgumentException if the given extrusion vector and plane produce regions
++         *      of zero size
++         */
++        PlaneRegionExtruder(final EmbeddingPlane basePlane, final Vector3D extrusionVector,
++                final DoublePrecisionContext precision) {
++
++            this.basePlane = basePlane;
++            this.extrudedPlane = basePlane.translate(extrusionVector);
++
++            if (basePlane.contains(extrudedPlane)) {
++                throw new IllegalArgumentException(
++                        "Extrusion vector produces regions of zero size: extrusionVector= " +
++                                extrusionVector + ",  plane= " + basePlane);
++            }
++
++            this.extrusionVector = extrusionVector;
++            this.extrudingOnPlusSide = basePlane.getNormal().dot(extrusionVector) > 0;
++
++            this.precision = precision;
++        }
++
++        /** Extrude the given 2D BSP tree using the configured base plane and extrusion vector.
++         * @param subspaceRegion region to extrude
++         * @return the boundaries of the extruded region
++         */
++        public List<PlaneConvexSubset> extrude(final RegionBSPTree2D subspaceRegion) {
++            List<PlaneConvexSubset> extrudedBoundaries = new ArrayList<>();
++
++            // add the boundaries
++            addEnds(subspaceRegion, extrudedBoundaries);
++            addSides(subspaceRegion, extrudedBoundaries);
++
++            return extrudedBoundaries;
++        }
++
++        /** Add the end ("top" and "bottom") of the extruded subspace region to the result list.
++         * @param subspaceRegion subspace region being extruded.
++         * @param result list to add the boundary results to
++         */
++        private void addEnds(final RegionBSPTree2D subspaceRegion, final List<PlaneConvexSubset> result) {
++            // add the base boundaries
++            final List<ConvexArea> baseAreas = subspaceRegion.toConvex();
++
++            final List<PlaneConvexSubset> baseList = new ArrayList<>(baseAreas.size());
++            final List<PlaneConvexSubset> extrudedList = new ArrayList<>(baseAreas.size());
++
++            final AffineTransformMatrix3D extrudeTransform = AffineTransformMatrix3D.createTranslation(extrusionVector);
++
++            PlaneConvexSubset base;
++            for (final ConvexArea area : baseAreas) {
++                base = subsetFromConvexArea(basePlane, area);
++                if (extrudingOnPlusSide) {
++                    base = base.reverse();
++                }
++
++                baseList.add(base);
++                extrudedList.add(base.transform(extrudeTransform).reverse());
++            }
++
++            result.addAll(baseList);
++            result.addAll(extrudedList);
++        }
++
++        /** Add the side boundaries of the extruded region to the result list.
++         * @param subspaceRegion subspace region being extruded.
++         * @param result list to add the boundary results to
++         */
++        private void addSides(final RegionBSPTree2D subspaceRegion, final List<PlaneConvexSubset> result) {
++            Vector2D subStartPt;
++            Vector2D subEndPt;
++
++            PlaneConvexSubset boundary;
++            for (final LinePath path : subspaceRegion.getBoundaryPaths()) {
++                for (final LineConvexSubset lineSubset : path.getElements()) {
++                    subStartPt = lineSubset.getStartPoint();
++                    subEndPt = lineSubset.getEndPoint();
++
++                    boundary = (subStartPt != null && subEndPt != null) ?
++                            extrudeSideFinite(basePlane.toSpace(subStartPt), basePlane.toSpace(subEndPt)) :
++                            extrudeSideInfinite(lineSubset);
++
++                    result.add(boundary);
++                }
++            }
++        }
++
++        /** Extrude a single, finite boundary forming one of the sides of the extruded region.
++         * @param startPt start point of the boundary
++         * @param endPt end point of the boundary
++         * @return the extruded region side boundary
++         */
++        private ConvexPolygon3D extrudeSideFinite(final Vector3D startPt, final Vector3D endPt) {
++            final Vector3D extrudedStartPt = startPt.add(extrusionVector);
++            final Vector3D extrudedEndPt = endPt.add(extrusionVector);
++
++            final List<Vector3D> vertices = extrudingOnPlusSide ?
++                    Arrays.asList(startPt, endPt, extrudedEndPt, extrudedStartPt) :
++                    Arrays.asList(startPt, extrudedStartPt, extrudedEndPt, endPt);
++
++            return convexPolygonFromVertices(vertices, precision);
++        }
++
++        /** Extrude a single, infinite boundary forming one of the sides of the extruded region.
++         * @param lineSubset line subset to extrude
++         * @return the extruded region side boundary
++         */
++        private PlaneConvexSubset extrudeSideInfinite(final LineConvexSubset lineSubset) {
++            final Vector2D subLinePt = lineSubset.getLine().getOrigin();
++            final Vector2D subLineDir = lineSubset.getLine().getDirection();
++
++            final Vector3D linePt = basePlane.toSpace(subLinePt);
++            final Vector3D lineDir = linePt.vectorTo(basePlane.toSpace(subLinePt.add(subLineDir)));
++
++            EmbeddingPlane sidePlane;
++            if (extrudingOnPlusSide) {
++                sidePlane = fromPointAndPlaneVectors(linePt, lineDir, extrusionVector, precision);
++            } else {
++                sidePlane = fromPointAndPlaneVectors(linePt, extrusionVector, lineDir, precision);
++            }
++
++            final Vector2D sideLineOrigin = sidePlane.toSubspace(linePt);
++            final Vector2D sideLineDir = sideLineOrigin.vectorTo(sidePlane.toSubspace(linePt.add(lineDir)));
++
++            final Vector2D extrudedSideLineOrigin = sidePlane.toSubspace(linePt.add(extrusionVector));
++
++            final Vector2D sideExtrusionDir = sidePlane.toSubspace(sidePlane.getOrigin().add(extrusionVector))
++                    .normalize();
++
++            // construct a list of lines forming the bounds of the extruded subspace region
++            final List<Line> lines = new ArrayList<>();
++
++            // add the top and bottom lines (original and extruded)
++            if (extrudingOnPlusSide) {
++                lines.add(Lines.fromPointAndDirection(sideLineOrigin, sideLineDir, precision));
++                lines.add(Lines.fromPointAndDirection(extrudedSideLineOrigin, sideLineDir.negate(), precision));
++            } else {
++                lines.add(Lines.fromPointAndDirection(sideLineOrigin, sideLineDir.negate(), precision));
++                lines.add(Lines.fromPointAndDirection(extrudedSideLineOrigin, sideLineDir, precision));
++            }
++
++            // if we have a point on the original line, then connect the two
++            final Vector2D startPt = lineSubset.getStartPoint();
++            final Vector2D endPt = lineSubset.getEndPoint();
++            if (startPt != null) {
++                lines.add(Lines.fromPointAndDirection(
++                        sidePlane.toSubspace(basePlane.toSpace(startPt)),
++                        extrudingOnPlusSide ? sideExtrusionDir.negate() : sideExtrusionDir,
++                        precision));
++            } else if (endPt != null) {
++                lines.add(Lines.fromPointAndDirection(
++                        sidePlane.toSubspace(basePlane.toSpace(endPt)),
++                        extrudingOnPlusSide ? sideExtrusionDir : sideExtrusionDir.negate(),
++                        precision));
++            }
++
++            return subsetFromConvexArea(sidePlane, ConvexArea.fromBounds(lines));
++        }
++    }
+ }
+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 141bdaf..a63797a 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
+@@ -26,9 +26,11 @@ 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.bsp.AbstractBSPTree;
++import org.apache.commons.geometry.core.partitioning.bsp.AbstractPartitionedRegionBuilder;
+ import org.apache.commons.geometry.core.partitioning.bsp.AbstractRegionBSPTree;
+ import org.apache.commons.geometry.core.partitioning.bsp.BSPTreeVisitor;
+ import org.apache.commons.geometry.core.partitioning.bsp.RegionCutBoundary;
++import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+ import org.apache.commons.geometry.euclidean.threed.line.Line3D;
+ import org.apache.commons.geometry.euclidean.threed.line.LineConvexSubset3D;
+ import org.apache.commons.geometry.euclidean.threed.line.LinecastPoint3D;
+@@ -49,7 +51,7 @@ public final class RegionBSPTree3D extends AbstractRegionBSPTree<Vector3D, Regio
+      * @param full whether or not the region should contain the entire
+      *      3D space or be empty
+      */
+-    public RegionBSPTree3D(boolean full) {
++    public RegionBSPTree3D(final boolean full) {
+         super(full);
+     }
+ 
+@@ -222,6 +224,14 @@ public final class RegionBSPTree3D extends AbstractRegionBSPTree<Vector3D, Regio
+         return tree;
+     }
+ 
++    /** Create a new {@link PartitionedRegionBuilder3D} instance which can be used to build balanced
++     * BSP trees from region boundaries.
++     * @return a new {@link PartitionedRegionBuilder3D} instance
++     */
++    public static PartitionedRegionBuilder3D partitionedRegionBuilder() {
++        return new PartitionedRegionBuilder3D();
++    }
++
+     /** BSP tree node for three dimensional Euclidean space.
+      */
+     public static final class RegionNode3D extends AbstractRegionBSPTree.AbstractRegionNode<Vector3D, RegionNode3D> {
+@@ -261,6 +271,171 @@ public final class RegionBSPTree3D extends AbstractRegionBSPTree<Vector3D, Regio
+         }
+     }
+ 
++    /** Class used to build regions in Euclidean 3D space by inserting boundaries into a BSP
++     * tree containing "partitions", i.e. structural cuts where both sides of the cut have the same region location.
++     * When partitions are chosen that effectively divide the region boundaries at each partition level, the
++     * constructed tree is shallower and more balanced than one constructed from the region boundaries alone,
++     * resulting in improved performance. For example, consider a mesh approximation of a sphere. The region is
++     * convex so each boundary has all of the other boundaries on its minus side; the plus sides are all empty.
++     * When these boundaries are inserted directly into a tree, the tree degenerates into a simple linked list of
++     * nodes with a height directly proportional to the number of boundaries. This means that many operations on the
++     * tree, such as inside/outside testing of points, involve iterating through each and every region boundary. In
++     * contrast, if a partition is first inserted that passes through the sphere center, the first BSP tree node
++     * contains region nodes on its plus <em>and</em> minus sides, cutting the height of the tree in half. Operations
++     * such as inside/outside testing are then able to skip half of the tree nodes with a single test on the
++     * root node, resulting in drastically improved performance. Insertion of additional partitions (using a grid
++     * layout, for example) can produce even shallower trees, although there is a point unique to each boundary set at
++     * which the addition of more partitions begins to decrease instead of increase performance.
++     *
++     * <h2>Usage</h2>
++     * <p>Usage of this class consists of two phases: (1) <em>partition insertion</em> and (2) <em>boundary
++     * insertion</em>. Instances begin in the <em>partition insertion</em> phase. Here, partitions can be inserted
++     * into the empty tree using {@link PartitionedRegionBuilder3D#insertPartition(PlaneConvexSubset) insertPartition}
++     * or similar methods. The {@link org.apache.commons.geometry.core.partitioning.bsp.RegionCutRule#INHERIT INHERIT}
++     * cut rule is used internally to insert the cut so the represented region remains empty even as partitions are
++     * inserted.
++     * </p>
++     *
++     * <p>The instance moves into the <em>boundary insertion</em> phase when the caller inserts the first region
++     * boundary, using {@link PartitionedRegionBuilder3D#insertBoundary(PlaneConvexSubset) insertBoundary} or
++     * similar methods. Attempting to insert a partition after this point results in an {@code IllegalStateException}.
++     * This ensures that partitioning cuts are always located higher up the tree than boundary cuts.</p>
++     *
++     * <p>After all boundaries are inserted, the {@link PartitionedRegionBuilder3D#build() build} method is used
++     * to perform final processing and return the computed tree.</p>
++     */
++    public static final class PartitionedRegionBuilder3D
++        extends AbstractPartitionedRegionBuilder<Vector3D, RegionNode3D> {
++
++        /** Construct a new builder instance.
++         */
++        private PartitionedRegionBuilder3D() {
++            super(RegionBSPTree3D.empty());
++        }
++
++        /** Insert a partition plane.
++         * @param partition partition to insert
++         * @return this instance
++         * @throws IllegalStateException if a boundary has previously been inserted
++         */
++        public PartitionedRegionBuilder3D insertPartition(final Plane partition) {
++            return insertPartition(partition.span());
++        }
++
++        /** Insert a plane convex subset as a partition.
++         * @param partition partition to insert
++         * @return this instance
++         * @throws IllegalStateException if a boundary has previously been inserted
++         */
++        public PartitionedRegionBuilder3D insertPartition(final PlaneConvexSubset partition) {
++            insertPartitionInternal(partition);
++
++            return this;
++        }
++
++        /** Insert a set of three axis aligned planes intersecting at the given point as partitions.
++         * The planes all contain the {@code center} point and have the normals {@code +x}, {@code +y},
++         * and {@code +z} in that order. If inserted into an empty tree, this will partition the space
++         * into 8 sections.
++         * @param center center point for the partitions; all 3 inserted planes intersect at this point
++         * @param precision precision context used to construct the planes
++         * @return this instance
++         * @throws IllegalStateException if a boundary has previously been inserted
++         */
++        public PartitionedRegionBuilder3D insertAxisAlignedPartitions(final Vector3D center,
++                final DoublePrecisionContext precision) {
++
++            insertPartition(Planes.fromPointAndNormal(center, Vector3D.Unit.PLUS_X, precision));
++            insertPartition(Planes.fromPointAndNormal(center, Vector3D.Unit.PLUS_Y, precision));
++            insertPartition(Planes.fromPointAndNormal(center, Vector3D.Unit.PLUS_Z, precision));
++
++            return this;
++        }
++
++        /** Insert a 3D grid of partitions. The partitions are constructed recursively: at each level a set of
++         * three axis-aligned partitioning planes are inserted using
++         * {@link #insertAxisAlignedPartitions(Vector3D, DoublePrecisionContext) insertAxisAlignedPartitions}.
++         * The algorithm then recurses using bounding boxes from the min point to the center and from the center
++         * point to the max. Note that this means no partitions are ever inserted directly on the boundaries of
++         * the given bounding box. This is intentional and done to allow this method to be called directly with the
++         * bounding box from a set of boundaries to be inserted without unnecessarily adding partitions that will
++         * never have region boundaries on both sides.
++         * @param bounds bounding box for the grid
++         * @param level recursion level for the grid; each level subdivides each grid cube into 8 sections, making the
++         *      total number of grid cubes equal to {@code 8 ^ level}
++         * @param precision precision context used to construct the partition planes
++         * @return this instance
++         * @throws IllegalStateException if a boundary has previously been inserted
++         */
++        public PartitionedRegionBuilder3D insertAxisAlignedGrid(final Bounds3D bounds, final int level,
++                final DoublePrecisionContext precision) {
++
++            insertAxisAlignedGridRecursive(bounds.getMin(), bounds.getMax(), level, precision);
++
++            return this;
++        }
++
++        /** Recursively insert axis-aligned grid partitions.
++         * @param min min point for the grid cube to partition
++         * @param max max point for the grid cube to partition
++         * @param level current recursion level
++         * @param precision precision context used to construct the partition planes
++         */
++        private void insertAxisAlignedGridRecursive(final Vector3D min, final Vector3D max, final int level,
++                final DoublePrecisionContext precision) {
++            if (level > 0) {
++                final Vector3D center = min.lerp(max, 0.5);
++
++                insertAxisAlignedPartitions(center, precision);
++
++                final int nextLevel = level - 1;
++                insertAxisAlignedGridRecursive(min, center, nextLevel, precision);
++                insertAxisAlignedGridRecursive(center, max, nextLevel, precision);
++            }
++        }
++
++        /** Insert a region boundary.
++         * @param boundary region boundary to insert
++         * @return this instance
++         */
++        public PartitionedRegionBuilder3D insertBoundary(final PlaneConvexSubset boundary) {
++            insertBoundaryInternal(boundary);
++
++            return this;
++        }
++
++        /** Insert a collection of region boundaries.
++         * @param boundaries boundaries to insert
++         * @return this instance
++         */
++        public PartitionedRegionBuilder3D insertBoundaries(final Iterable<? extends PlaneConvexSubset> boundaries) {
++            for (final PlaneConvexSubset boundary : boundaries) {
++                insertBoundaryInternal(boundary);
++            }
++
++            return this;
++        }
++
++        /** Insert all boundaries from the given source.
++         * @param boundarySrc source of boundaries to insert
++         * @return this instance
++         */
++        public PartitionedRegionBuilder3D insertBoundaries(final BoundarySource3D boundarySrc) {
++            try (Stream<PlaneConvexSubset> stream = boundarySrc.boundaryStream()) {
++                stream.forEach(this::insertBoundaryInternal);
++            }
++
++            return this;
++        }
++
++        /** Build and return the region BSP tree.
++         * @return the region BSP tree
++         */
++        public RegionBSPTree3D build() {
++            return (RegionBSPTree3D) buildInternal();
++        }
++    }
++
+     /** Class used to project points onto the 3D region boundary.
+      */
+     private static final class BoundaryProjector3D extends BoundaryProjector<Vector3D, RegionNode3D> {
+diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/mesh/Mesh.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/mesh/Mesh.java
+new file mode 100644
+index 0000000..d817d3c
+--- /dev/null
++++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/mesh/Mesh.java
+@@ -0,0 +1,114 @@
++/*
++ * 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.mesh;
++
++import java.util.List;
++
++import org.apache.commons.geometry.core.Transform;
++import org.apache.commons.geometry.euclidean.threed.BoundarySource3D;
++import org.apache.commons.geometry.euclidean.threed.ConvexPolygon3D;
++import org.apache.commons.geometry.euclidean.threed.Vector3D;
++
++/** Interface representing a 3D mesh data structure.
++ * @param <F> Mesh face implementation type
++ * @see <a href="https://en.wikipedia.org/wiki/Polygon_mesh">Polygon Mesh</a>
++ */
++public interface Mesh<F extends Mesh.Face> extends BoundarySource3D {
++
++    /** Get an iterable containing the vertices in the mesh.
++     * @return an iterable containing the vertices in the mesh
++     */
++    Iterable<Vector3D> vertices();
++
++    /** Get a list containing all vertices in the mesh.
++     * @return a list containing all vertices in the mesh
++     */
++    List<Vector3D> getVertices();
++
++    /** Get the number of vertices in the mesh.
++     * @return the number of vertices in the mesh
++     */
++    int getVertexCount();
++
++    /** Get an iterable containing all faces in the mesh.
++     * @return an iterable containing all faces in the mesh
++     */
++    Iterable<F> faces();
++
++    /** Get a list containing all faces in the mesh.
++     * @return a list containing all faces in the mesh
++     */
++    List<F> getFaces();
++
++    /** Get the number of faces in the mesh.
++     * @return the number of faces in the mesh
++     */
++    int getFaceCount();
++
++    /** Get a face from the mesh by its index.
++     * @param index the index of the mesh to retrieve
++     * @return the face at the given index
++     * @throws IndexOutOfBoundsException if the index is out of bounds
++     */
++    F getFace(int index);
++
++    /** Return a new, transformed mesh by applying the given transform to
++     * all vertices. Faces and vertex ordering are not affected.
++     * @param transform transform to apply
++     * @return a new, transformed mesh
++     */
++    Mesh<F> transform(Transform<Vector3D> transform);
++
++    /** Interface representing a single face in a mesh.
++     */
++    interface Face {
++
++        /** Get the 0-based index of the face in the mesh.
++         * @return the 0-based index of the face in the mesh
++         */
++        int getIndex();
++
++        /** Get an array containing the 0-based indices of the vertices defining
++         * this face. The indices are references to the vertex positions in
++         * the mesh vertex list.
++         * @return an array containing the indices of the vertices defining
++         *      this face
++         * @see Mesh#getVertices()
++         */
++        int[] getVertexIndices();
++
++        /** Get the vertices for the face.
++         * @return the vertices for the face
++         */
++        List<Vector3D> getVertices();
++
++        /** Return true if the vertices for this face define a convex polygon
++         * with non-zero size.
++         * @return true if the vertices for this face define a convex polygon
++         *      with non-zero size
++         */
++        boolean definesPolygon();
++
++        /** Get the 3D polygon defined by this face.
++         * @return the 3D polygon defined by this face
++         * @throws IllegalArgumentException if the vertices for the face do not
++         *      define a polygon
++         * @see #definesPolygon()
++         */
++        ConvexPolygon3D getPolygon();
++    }
++}
+diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/mesh/SimpleTriangleMesh.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/mesh/SimpleTriangleMesh.java
+new file mode 100644
+index 0000000..e10a9fd
+--- /dev/null
++++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/mesh/SimpleTriangleMesh.java
+@@ -0,0 +1,750 @@
++/*
++ * 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.mesh;
++
++import java.util.ArrayList;
++import java.util.Arrays;
++import java.util.Collection;
++import java.util.Collections;
++import java.util.Comparator;
++import java.util.Iterator;
++import java.util.List;
++import java.util.Map;
++import java.util.Objects;
++import java.util.TreeMap;
++import java.util.function.Function;
++import java.util.stream.Collectors;
++import java.util.stream.Stream;
++import java.util.stream.StreamSupport;
++
++import org.apache.commons.geometry.core.Transform;
++import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
++import org.apache.commons.geometry.euclidean.threed.BoundarySource3D;
++import org.apache.commons.geometry.euclidean.threed.Bounds3D;
++import org.apache.commons.geometry.euclidean.threed.PlaneConvexSubset;
++import org.apache.commons.geometry.euclidean.threed.Planes;
++import org.apache.commons.geometry.euclidean.threed.Triangle3D;
++import org.apache.commons.geometry.euclidean.threed.Vector3D;
++
++/** A simple implementation of the {@link TriangleMesh} interface. This class ensures that
++ * faces always contain 3 valid references into the vertex list but does not enforce that
++ * the referenced vertices are unique or that they define a triangle with non-zero size. For
++ * example, a mesh could contain a face with 3 vertices that are considered equivalent by the
++ * configured precision context. Attempting to call the {@link TriangleMesh.Face#getPolygon()}
++ * method on such a face results in an exception. The
++ * {@link TriangleMesh.Face#definesPolygon()} method can be used to determine if a face defines
++ * a valid triangle.
++ *
++ * <p>Instances of this class are guaranteed to be immutable.</p>
++ */
++public final class SimpleTriangleMesh implements TriangleMesh {
++
++    /** Vertices in the mesh. */
++    private final List<Vector3D> vertices;
++
++    /** Faces in the mesh. */
++    private final List<int[]> faces;
++
++    /** The bounds of the mesh. */
++    private final Bounds3D bounds;
++
++    /** Object used for floating point comparisons. */
++    private final DoublePrecisionContext precision;
++
++    /** Construct a new instance from a vertex list and set of faces. No validation is
++     * performed on the input.
++     * @param vertices vertex list
++     * @param faces face indices list
++     * @param bounds mesh bounds
++     * @param precision precision context used when creating face polygons
++     */
++    private SimpleTriangleMesh(final List<Vector3D> vertices, final List<int[]> faces, final Bounds3D bounds,
++            final DoublePrecisionContext precision) {
++        this.vertices = Collections.unmodifiableList(vertices);
++        this.faces = Collections.unmodifiableList(faces);
++        this.bounds = bounds;
++        this.precision = precision;
++    }
++
++    /** {@inheritDoc} */
++    @Override
++    public Iterable<Vector3D> vertices() {
++        return getVertices();
++    }
++
++    /** {@inheritDoc} */
++    @Override
++    public List<Vector3D> getVertices() {
++        return vertices;
++    }
++
++    /** {@inheritDoc} */
++    @Override
++    public int getVertexCount() {
++        return vertices.size();
++    }
++
++    /** {@inheritDoc} */
++    @Override
++    public Iterable<TriangleMesh.Face> faces() {
++        return () -> {
++            return new FaceIterator<Face>(Function.identity());
++        };
++    }
++
++    /** {@inheritDoc} */
++    @Override
++    public List<TriangleMesh.Face> getFaces() {
++        final int count = getFaceCount();
++
++        final List<Face> faceList = new ArrayList<>(count);
++        for (int i = 0; i < count; ++i) {
++            faceList.add(getFace(i));
++        }
++
++        return faceList;
++    }
++
++    /** {@inheritDoc} */
++    @Override
++    public int getFaceCount() {
++        return faces.size();
++    }
++
++    /** {@inheritDoc} */
++    @Override
++    public TriangleMesh.Face getFace(final int index) {
++        return new SimpleTriangleFace(index, faces.get(index));
++    }
++
++    /** {@inheritDoc} */
++    @Override
++    public Bounds3D getBounds() {
++        return bounds;
++    }
++
++    /** Get the precision context for the mesh. This context is used during construction of
++     * face {@link Triangle3D} instances.
++     * @return the precision context for the mesh
++     */
++    public DoublePrecisionContext getPrecision() {
++        return precision;
++    }
++
++    /** {@inheritDoc} */
++    @Override
++    public Stream<PlaneConvexSubset> boundaryStream() {
++        return createFaceStream(Face::getPolygon);
++    }
++
++    /** {@inheritDoc} */
++    @Override
++    public Stream<Triangle3D> triangleStream() {
++        return createFaceStream(Face::getPolygon);
++    }
++
++    /** {@inheritDoc} */
++    @Override
++    public SimpleTriangleMesh transform(final Transform<Vector3D> transform) {
++        // only the vertices and bounds are modified; the faces are the same
++        final Bounds3D.Builder boundsBuilder = Bounds3D.builder();
++        final List<Vector3D> tVertices = vertices.stream()
++                .map(transform)
++                .peek(boundsBuilder::add)
++                .collect(Collectors.toList());
++
++        final Bounds3D tBounds = boundsBuilder.hasBounds() ?
++                boundsBuilder.build() :
++                null;
++
++        return new SimpleTriangleMesh(tVertices, faces, tBounds, precision);
++    }
++
++    /** Return this instance if the given precision context is equal to the current precision context.
++     * Otherwise, create a new mesh with the given precision context but the same vertices, faces, and
++     * bounds.
++     * @param meshPrecision precision context to use when generating face polygons
++     * @return a mesh instance with the given precision context and the same mesh structure as the current
++     *      instance
++     */
++    @Override
++    public SimpleTriangleMesh toTriangleMesh(final DoublePrecisionContext meshPrecision) {
++        if (this.precision.equals(meshPrecision)) {
++            return this;
++        }
++
++        return new SimpleTriangleMesh(vertices, faces, bounds, meshPrecision);
++    }
++
++    /** {@inheritDoc} */
++    @Override
++    public String toString() {
++        final StringBuilder sb = new StringBuilder();
++        sb.append(getClass().getSimpleName())
++            .append("[vertexCount= ")
++            .append(getVertexCount())
++            .append(", faceCount= ")
++            .append(getFaceCount())
++            .append(", bounds= ")
++            .append(getBounds())
++            .append(']');
++
++        return sb.toString();
++    }
++
++    /** Create a stream containing the results of applying {@code fn} to each face in
++     * the mesh.
++     * @param <T> Stream element type
++     * @param fn function used to extract the stream values from each face
++     * @return a stream containing the results of applying {@code fn} to each face in
++     *      the mesh
++     */
++    private <T> Stream<T> createFaceStream(final Function<TriangleMesh.Face, T> fn) {
++        final Iterable<T> iterable = () -> new FaceIterator<>(fn);
++        return StreamSupport.stream(iterable.spliterator(), false);
++    }
++
++    /** Return a builder for creating new triangle mesh objects.
++     * @param precision precision object used for floating point comparisons
++     * @return a builder for creating new triangle mesh objects
++     */
++    public static Builder builder(final DoublePrecisionContext precision) {
++        return new Builder(precision);
++    }
++
++    /** Construct a new triangle mesh from the given vertices and face indices.
++     * @param vertices vertices for the mesh
++     * @param faces face indices for the mesh
++     * @param precision precision context used for floating point comparisons
++     * @return a new triangle mesh instance
++     * @throws IllegalArgumentException if any of the face index arrays does not have exactly 3 elements or
++     *       if any index is not a valid index into the vertex list
++     */
++    public static SimpleTriangleMesh from(final Vector3D[] vertices, int[][] faces,
++            final DoublePrecisionContext precision) {
++        return from(Arrays.asList(vertices), Arrays.asList(faces), precision);
++    }
++
++    /** Construct a new triangle mesh from the given vertices and face indices.
++     * @param vertices vertices for the mesh
++     * @param faces face indices for the mesh
++     * @param precision precision context used for floating point comparisons
++     * @return a new triangle mesh instance
++     * @throws IllegalArgumentException if any of the face index arrays does not have exactly 3 elements or
++     *       if any index is not a valid index into the vertex list
++     */
++    public static SimpleTriangleMesh from(final Collection<Vector3D> vertices, Collection<int[]> faces,
++            final DoublePrecisionContext precision) {
++        final Builder builder = builder(precision);
++
++        return builder.addVertices(vertices)
++                .addFaces(faces)
++                .build();
++    }
++
++    /** Construct a new mesh instance containing all triangles from the given boundary
++     * source. Equivalent vertices are reused wherever possible.
++     * @param boundarySrc boundary source to construct a mesh from
++     * @param precision precision context used for floating point comparisons
++     * @return new mesh instance containing all triangles from the given boundary
++     *      source
++     * @throws IllegalStateException if any boundary in the boundary source has infinite size and cannot
++     *      be converted to triangles
++     */
++    public static SimpleTriangleMesh from(final BoundarySource3D boundarySrc, final DoublePrecisionContext precision) {
++        final Builder builder = builder(precision);
++        try (Stream<Triangle3D> stream = boundarySrc.triangleStream()) {
++            stream.forEach(tri -> {
++                builder.addFaceUsingVertices(
++                        tri.getPoint1(),
++                        tri.getPoint2(),
++                        tri.getPoint3());
++            });
++        }
++
++        return builder.build();
++    }
++
++    /** Internal implementation of {@link TriangleMesh.Face}.
++     */
++    private final class SimpleTriangleFace implements TriangleMesh.Face {
++
++        /** The index of the face in the mesh. */
++        private final int index;
++
++        /** Vertex indices for the face. */
++        private final int[] vertexIndices;
++
++        SimpleTriangleFace(final int index, final int[] vertexIndices) {
++            this.index = index;
++            this.vertexIndices = vertexIndices;
++        }
++
++        /** {@inheritDoc} */
++        @Override
++        public int getIndex() {
++            return index;
++        }
++
++        /** {@inheritDoc} */
++        @Override
++        public int[] getVertexIndices() {
++            return vertexIndices.clone();
++        }
++
++        /** {@inheritDoc} */
++        @Override
++        public List<Vector3D> getVertices() {
++            return Arrays.asList(
++                    getPoint1(),
++                    getPoint2(),
++                    getPoint3());
++        }
++
++        /** {@inheritDoc} */
++        @Override
++        public Vector3D getPoint1() {
++            return vertices.get(vertexIndices[0]);
++        }
++
++        /** {@inheritDoc} */
++        @Override
++        public Vector3D getPoint2() {
++            return vertices.get(vertexIndices[1]);
++        }
++
++        /** {@inheritDoc} */
++        @Override
++        public Vector3D getPoint3() {
++            return vertices.get(vertexIndices[2]);
++        }
++
++        /** {@inheritDoc} */
++        @Override
++        public boolean definesPolygon() {
++            final Vector3D p1 = getPoint1();
++            final Vector3D v1 = p1.vectorTo(getPoint2());
++            final Vector3D v2 = p1.vectorTo(getPoint3());
++
++            return !precision.eqZero(v1.cross(v2).norm());
++        }
++
++        /** {@inheritDoc} */
++        @Override
++        public Triangle3D getPolygon() {
++            return Planes.triangleFromVertices(
++                    getPoint1(),
++                    getPoint2(),
++                    getPoint3(),
++                    precision);
++        }
++
++        /** {@inheritDoc} */
++        @Override
++        public String toString() {
++            final StringBuilder sb = new StringBuilder();
++            sb.append(getClass().getSimpleName())
++                .append("[index= ")
++                .append(getIndex())
++                .append(", vertexIndices= ")
++                .append(Arrays.toString(getVertexIndices()))
++                .append(", vertices= ")
++                .append(getVertices())
++                .append(']');
++
++            return sb.toString();
++        }
++    }
++
++    /** Internal class for iterating through the mesh faces and extracting a value from each.
++     * @param <T> Type returned by the iterator
++     */
++    private final class FaceIterator<T> implements Iterator<T> {
++
++        /** The current index of the iterator. */
++        private int index = 0;
++
++        /** Function to apply to each face in the mesh. */
++        private final Function<TriangleMesh.Face, T> fn;
++
++        /** Construct a new instance for iterating through the mesh faces and extracting
++         * a value from each.
++         * @param fn function to apply to each face in order to obtain the iterated value
++         */
++        FaceIterator(final Function<TriangleMesh.Face, T> fn) {
++            this.fn = fn;
++        }
++
++        /** {@inheritDoc} */
++        @Override
++        public boolean hasNext() {
++            return index < faces.size();
++        }
++
++        /** {@inheritDoc} */
++        @Override
++        public T next() {
++            final Face face = getFace(index++);
++            return fn.apply(face);
++        }
++    }
++
++    /** Builder class for creating mesh instances.
++     */
++    public static final class Builder {
++
++        /** List of vertices. */
++        private final ArrayList<Vector3D> vertices = new ArrayList<>();
++
++        /** Map of vertices to their first occurrence in the vertex list. */
++        private Map<Vector3D, Integer> vertexIndexMap;
++
++        /** List of face vertex indices. */
++        private final ArrayList<int[]> faces = new ArrayList<>();
++
++        /** Object used to construct the 3D bounds of the vertex list. */
++        private final Bounds3D.Builder boundsBuilder = Bounds3D.builder();
++
++        /** Precision context used for floating point comparisons; this value may be null
++         * if vertices are not to be combined in this builder.
++         */
++        private final DoublePrecisionContext precision;
++
++        /** Flag set to true once a mesh is constructed from this builder. */
++        private boolean built = false;
++
++        /** Construct a new builder.
++         * @param precision precision context used for floating point comparisons; may
++         *      be null if vertices are not to be combined in this builder.
++         */
++        private Builder(final DoublePrecisionContext precision) {
++            Objects.requireNonNull(precision, "Precision context must not be null");
++
++            this.precision = precision;
++        }
++
++        /** Use a vertex in the constructed mesh. If an equivalent vertex already exist, as determined
++         * by the configured {@link DoublePrecisionContext}, then the index of the previously added
++         * vertex is returned. Otherwise, the given vertex is added to the vertex list and the index
++         * of the new entry is returned. This is in contrast with the {@link #addVertex(Vector3D)},
++         * which always adds a new entry to the vertex list.
++         * @param vertex vertex to use
++         * @return the index of the added vertex or an equivalent vertex that was added previously
++         * @see #addVertex(Vector3D)
++         */
++        public int useVertex(final Vector3D vertex) {
++            final int nextIdx = vertices.size();
++            final int actualIdx = addToVertexIndexMap(vertex, nextIdx, getVertexIndexMap());
++
++            // add to the vertex list if not already present
++            if (actualIdx == nextIdx) {
++                addToVertexList(vertex);
++            }
++
++            return actualIdx;
++        }
++
++        /** Add a vertex directly to the vertex list, returning the index of the added vertex.
++         * The vertex is added regardless of whether or not an equivalent vertex already
++         * exists in the list. This is in contrast with the {@link #useVertex(Vector3D)} method,
++         * which only adds a new entry to the vertex list if an equivalent one does not
++         * already exist.
++         * @param vertex the vertex to append
++         * @return the index of the appended vertex in the vertex list
++         */
++        public int addVertex(final Vector3D vertex) {
++            final int idx = addToVertexList(vertex);
++
++            if (vertexIndexMap != null) {
++                // add to the map in order to keep it in sync
++                addToVertexIndexMap(vertex, idx, vertexIndexMap);
++            }
++
++            return idx;
++        }
++
++        /** Add a group of vertices directly to the vertex list. No equivalent vertices are reused.
++         * @param newVertices vertices to append
++         * @return this instance
++         * @see #addVertex(Vector3D)
++         */
++        public Builder addVertices(final Vector3D[] newVertices) {
++            return addVertices(Arrays.asList(newVertices));
++        }
++
++        /** Add a group of vertices directly to the vertex list. No equivalent vertices are reused.
++         * @param newVertices vertices to append
++         * @return this instance
++         * @see #addVertex(Vector3D)
++         */
++        public Builder addVertices(final Collection<Vector3D> newVertices) {
++            final int newSize = vertices.size() + newVertices.size();
++            ensureVertexCapacity(newSize);
++
++            for (final Vector3D vertex : newVertices) {
++                addVertex(vertex);
++            }
++
++            return this;
++        }
++
++        /** Ensure that this instance has enough capacity to store at least {@code numVertices}
++         * number of vertices without reallocating space. This can be used to help improve performance
++         * and memory usage when creating meshes with large numbers of vertices.
++         * @param numVertices the number of vertices to ensure that this instance can contain
++         * @return this instance
++         */
++        public Builder ensureVertexCapacity(final int numVertices) {
++            vertices.ensureCapacity(numVertices);
++            return this;
++        }
++
++        /** Get the current number of vertices in this mesh.
++         * @return the current number of vertices in this mesh
++         */
++        public int getVertexCount() {
++            return vertices.size();
++        }
++
++        /** Append a face to this mesh.
++         * @param index1 index of the first vertex in the face
++         * @param index2 index of the second vertex in the face
++         * @param index3 index of the third vertex in the face
++         * @return this instance
++         * @throws IllegalArgumentException if any of the arguments is not a valid index into
++         *      the current vertex list
++         */
++        public Builder addFace(final int index1, final int index2, final int index3) {
++            validateCanModify();
++
++            final int[] indices = {
++                validateVertexIndex(index1),
++                validateVertexIndex(index2),
++                validateVertexIndex(index3)
++            };
++
++            faces.add(indices);
++
++            return this;
++        }
++
++        /** Append a group of faces to this mesh.
++         * @param faceIndices faces to append
++         * @return this instance
++         * @throws IllegalArgumentException if any of the face index arrays does not have exactly 3 elements or
++         *       if any index is not a valid index into the current vertex list
++         */
++        public Builder addFaces(final int[][] faceIndices) {
++            return addFaces(Arrays.asList(faceIndices));
++        }
++
++        /** Append a group of faces to this mesh.
++         * @param faceIndices faces to append
++         * @return this instance
++         * @throws IllegalArgumentException if any of the face index arrays does not have exactly 3 elements or
++         *       if any index is not a valid index into the current vertex list
++         */
++        public Builder addFaces(final Collection<int[]> faceIndices) {
++            final int newSize = faces.size() + faceIndices.size();
++            ensureFaceCapacity(newSize);
++
++            for (final int[] face : faceIndices) {
++                if (face.length != 3) {
++                    throw new IllegalArgumentException("Face must contain 3 vertex indices; found " + face.length);
++                }
++
++                addFace(face[0], face[1], face[2]);
++            }
++
++            return this;
++        }
++
++        /** Add a face to this mesh, only adding vertices to the vertex list if equivalent vertices are
++         * not found.
++         * @param p1 first face vertex
++         * @param p2 second face vertex
++         * @param p3 third face vertex
++         * @return this instance
++         * @see #useVertex(Vector3D)
++         */
++        public Builder addFaceUsingVertices(final Vector3D p1, final Vector3D p2, final Vector3D p3) {
++            return addFace(
++                        useVertex(p1),
++                        useVertex(p2),
++                        useVertex(p3)
++                    );
++        }
++
++        /** Add a face and its vertices to this mesh. The vertices are always added to the vertex list,
++         * regardless of whether or not equivalent vertices exist in the vertex list.
++         * @param p1 first face vertex
++         * @param p2 second face vertex
++         * @param p3 third face vertex
++         * @return this instance
++         * @see #addVertex(Vector3D)
++         */
++        public Builder addFaceAndVertices(final Vector3D p1, final Vector3D p2, final Vector3D p3) {
++            return addFace(
++                        addVertex(p1),
++                        addVertex(p2),
++                        addVertex(p3)
++                    );
++        }
++
++        /** Ensure that this instance has enough capacity to store at least {@code numFaces}
++         * number of faces without reallocating space. This can be used to help improve performance
++         * and memory usage when creating meshes with large numbers of faces.
++         * @param numFaces the number of faces to ensure that this instance can contain
++         * @return this instance
++         */
++        public Builder ensureFaceCapacity(final int numFaces) {
++            faces.ensureCapacity(numFaces);
++            return this;
++        }
++
++        /** Get the current number of faces in this mesh.
++         * @return the current number of faces in this meshr
++         */
++        public int getFaceCount() {
++            return faces.size();
++        }
++
++        /** Build a triangle mesh containing the vertices and faces in this builder.
++         * @return a triangle mesh containing the vertices and faces in this builder
++         */
++        public SimpleTriangleMesh build() {
++            built = true;
++
++            final Bounds3D bounds = boundsBuilder.hasBounds() ?
++                    boundsBuilder.build() :
++                    null;
++
++            vertices.trimToSize();
++            faces.trimToSize();
++
++            return new SimpleTriangleMesh(
++                    vertices,
++                    faces,
++                    bounds,
++                    precision);
++        }
++
++        /** Get the vertex index map, creating and initializing it if needed.
++         * @return the vertex index map
++         */
++        private Map<Vector3D, Integer> getVertexIndexMap() {
++            if (vertexIndexMap == null) {
++                vertexIndexMap = new TreeMap<>(new FuzzyVectorComparator(precision));
++
++                // populate the index map
++                final int size = vertices.size();
++                for (int i = 0; i < size; ++i) {
++                    addToVertexIndexMap(vertices.get(i), i, vertexIndexMap);
++                }
++            }
++            return vertexIndexMap;
++        }
++
++        /** Add a vertex to the given vertex index map. The vertex is inserted and mapped to {@code targetidx}
++         *  if an equivalent vertex does not already exist. The index now associated with the given vertex
++         *  or its equivalent is returned.
++         * @param vertex vertex to add
++         * @param targetIdx the index to associate with the vertex if no equivalent vertex has already been
++         *      mapped
++         * @param map vertex index map
++         * @return the index now associated with the given vertex or its equivalent
++         */
++        private int addToVertexIndexMap(final Vector3D vertex, final int targetIdx, final Map<Vector3D, Integer> map) {
++            validateCanModify();
++
++            final Integer actualIdx = map.putIfAbsent(vertex, targetIdx);
++
++            return actualIdx != null ?
++                    actualIdx.intValue() :
++                    targetIdx;
++        }
++
++        /** Append the given vertex to the end of the vertex list. The index of the vertex is returned.
++         * @param vertex the vertex to append
++         * @return the index of the appended vertex
++         */
++        private int addToVertexList(final Vector3D vertex) {
++            validateCanModify();
++
++            boundsBuilder.add(vertex);
++
++            int idx = vertices.size();
++            vertices.add(vertex);
++
++            return idx;
++        }
++
++        /** Throw an exception if the given vertex index is not valid.
++         * @param idx vertex index to validate
++         * @return the validated index
++         * @throws IllegalArgumentException if the given index is not a valid index into
++         *      the vertices list
++         */
++        private int validateVertexIndex(final int idx) {
++            if (idx < 0 || idx >= vertices.size()) {
++                throw new IllegalArgumentException("Invalid vertex index: " + idx);
++            }
++
++            return idx;
++        }
++
++        /** Throw an exception if the builder has been used to construct a mesh instance
++         * and can no longer be modified.
++         */
++        private void validateCanModify() {
++            if (built) {
++                throw new IllegalStateException("Builder instance cannot be modified: mesh construction is complete");
++            }
++        }
++    }
++
++    /** Comparator used to sort vectors using non-strict ("fuzzy") comparisons.
++     * Vectors are considered equal if their values in all coordinate dimensions
++     * are equivalent as evaluated by the precision context.
++     */
++    private static final class FuzzyVectorComparator implements Comparator<Vector3D> {
++        /** Precision context to determine floating-point equality. */
++        private final DoublePrecisionContext precision;
++
++        /** Construct a new instance that uses the given precision context for
++         * floating point comparisons.
++         * @param precision precision context used for floating point comparisons
++         */
++        FuzzyVectorComparator(final DoublePrecisionContext precision) {
++            this.precision = precision;
++        }
++
++        /** {@inheritDoc} */
++        @Override
++        public int compare(final Vector3D a, final Vector3D b) {
++            int result = precision.compare(a.getX(), b.getX());
++            if (result == 0) {
++                result = precision.compare(a.getY(), b.getY());
++                if (result == 0) {
++                    result = precision.compare(a.getZ(), b.getZ());
++                }
++            }
++
++            return result;
++        }
++    }
++}
+diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/mesh/TriangleMesh.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/mesh/TriangleMesh.java
+new file mode 100644
+index 0000000..84b5f34
+--- /dev/null
++++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/mesh/TriangleMesh.java
+@@ -0,0 +1,54 @@
++/*
++ * 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.mesh;
++
++import org.apache.commons.geometry.core.Transform;
++import org.apache.commons.geometry.euclidean.threed.Triangle3D;
++import org.apache.commons.geometry.euclidean.threed.Vector3D;
++
++/** Interface representing a mesh composed entirely of triangular faces.
++ */
++public interface TriangleMesh extends Mesh<TriangleMesh.Face> {
++
++    /** {@inheritDoc} */
++    @Override
++    TriangleMesh transform(Transform<Vector3D> transform);
++
++    /** Interface representing a single triangular face in a mesh.
++     */
++    interface Face extends Mesh.Face {
++
++        /** Get the first vertex in the face.
++         * @return the first vertex in the face
++         */
++        Vector3D getPoint1();
++
++        /** Get the second vertex in the face.
++         * @return the second vertex in the face
++         */
++        Vector3D getPoint2();
++
++        /** Get the third vertex in the face.
++         * @return the third vertex in the face
++         */
++        Vector3D getPoint3();
++
++        /** {@inheritDoc} */
++        @Override
++        Triangle3D getPolygon();
++    }
++}
+diff --git a/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/package-info.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/mesh/package-info.java
+similarity index 79%
+copy from commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/package-info.java
+copy to commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/mesh/package-info.java
+index 62e0555..b48b6ae 100644
+--- a/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/package-info.java
++++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/mesh/package-info.java
+@@ -14,8 +14,7 @@
+  * See the License for the specific language governing permissions and
+  * limitations under the License.
+  */
+-
+-/**
+- * <h3>Persistent storage of shapes.</h3>
++/** This package contains types representing 3D mesh data structures.
++ * @see <a href="https://en.wikipedia.org/wiki/Polygon_mesh">Polygon Mesh</a>
+  */
+-package org.apache.commons.geometry.examples.io;
++package org.apache.commons.geometry.euclidean.threed.mesh;
+diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/shape/Sphere.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/shape/Sphere.java
+index b3309c8..9419119 100644
+--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/shape/Sphere.java
++++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/threed/shape/Sphere.java
+@@ -16,6 +16,7 @@
+  */
+ package org.apache.commons.geometry.euclidean.threed.shape;
+ 
++import java.text.MessageFormat;
+ import java.util.List;
+ import java.util.stream.Collectors;
+ import java.util.stream.Stream;
+@@ -32,11 +33,17 @@ 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.threed.line.Linecastable3D;
++import org.apache.commons.geometry.euclidean.threed.mesh.SimpleTriangleMesh;
++import org.apache.commons.geometry.euclidean.threed.mesh.TriangleMesh;
+ 
+ /** Class representing a 3 dimensional sphere in Euclidean space.
+  */
+ public final class Sphere extends AbstractNSphere<Vector3D> implements Linecastable3D {
+ 
++    /** Message used when requesting a sphere approximation with an invalid subdivision number. */
++    private static final String INVALID_SUBDIVISION_MESSAGE =
++        "Number of sphere approximation subdivisions must be greater than or equal to zero; was {0}";
++
+     /** Constant equal to {@code 4 * pi}. */
+     private static final double FOUR_PI = 4.0 * Math.PI;
+ 
+@@ -113,14 +120,60 @@ public final class Sphere extends AbstractNSphere<Vector3D> implements Linecasta
+      * @return a BSP tree containing an approximation of the sphere
+      * @throws IllegalArgumentException if {@code subdivisions} is less than zero
+      * @throws IllegalStateException if tree creation fails for the given subdivision count
++     * @see #toTriangleMesh(int)
+      */
+     public RegionBSPTree3D toTree(final int subdivisions) {
+         if (subdivisions < 0) {
+-            throw new IllegalArgumentException(
+-                    "Number of sphere approximation subdivisions must be greater than or equal to zero; was " +
+-                            subdivisions);
++            throw new IllegalArgumentException(MessageFormat.format(INVALID_SUBDIVISION_MESSAGE, subdivisions));
+         }
+-        return new SphereApproximationBuilder(this, subdivisions).build();
++        return new SphereTreeApproximationBuilder(this, subdivisions).build();
++    }
++
++    /** Build an approximation of this sphere using a {@link TriangleMesh}. The approximation is constructed by
++     * taking an octahedron (8-sided polyhedron with triangular faces) inscribed in the sphere and subdividing each
++     * triangular face {@code subdivisions} number of times, each time projecting the newly created vertices onto the
++     * sphere surface. Each triangle subdivision produces 4 triangles, meaning that the total number of triangles
++     * in the returned mesh is equal to \(8 \times 4^s\), where \(s\) is the number of subdivisions. For
++     * example, calling this method with {@code subdivisions} equal to {@code 3} will produce a mesh having
++     * \(8 \times 4^3 = 512\) triangular facets inserted. See the table below for other examples.
++     *
++     * <table>
++     *  <caption>Subdivisions to Triangle Counts</caption>
++     *  <thead>
++     *      <tr>
++     *          <th>Subdivisions</th>
++     *          <th>Triangles</th>
++     *      </tr>
++     *  </thead>
++     *  <tbody>
++     *      <tr><td>0</td><td>8</td></tr>
++     *      <tr><td>1</td><td>32</td></tr>
++     *      <tr><td>2</td><td>128</td></tr>
++     *      <tr><td>3</td><td>512</td></tr>
++     *      <tr><td>4</td><td>2048</td></tr>
++     *      <tr><td>5</td><td>8192</td></tr>
++     *  </tbody>
++     * </table>
++     *
++     * <p><strong>BSP Tree Conversion</strong></p>
++     * <p>Inserting the boundaries of a sphere mesh approximation directly into a BSP tree will invariably result
++     * in poor performance: since the region is convex the constructed BSP tree degenerates into a simple linked
++     * list of nodes. If a BSP tree is needed, users should prefer the {@link #toTree(int)} method, which creates
++     * balanced tree approximations directly, or the {@link RegionBSPTree3D.PartitionedRegionBuilder3D} class,
++     * which can be used to insert the mesh faces into a pre-partitioned tree.
++     * </p>
++     * @param subdivisions the number of triangle subdivisions to use when creating the mesh; the total number of
++     *      triangular faces in the returned mesh is equal to \(8 \times 4^s\), where \(s\) is the number
++     *      of subdivisions
++     * @return a triangle mesh approximation of the sphere
++     * @throws IllegalArgumentException if {@code subdivisions} is less than zero
++     * @see #toTree(int)
++     */
++    public TriangleMesh toTriangleMesh(final int subdivisions) {
++        if (subdivisions < 0) {
++            throw new IllegalArgumentException(MessageFormat.format(INVALID_SUBDIVISION_MESSAGE, subdivisions));
++        }
++        return new SphereMeshApproximationBuilder(this, subdivisions).build();
+     }
+ 
+     /** Get the intersections of the given line with this sphere. The returned list will
+@@ -192,10 +245,11 @@ public final class Sphere extends AbstractNSphere<Vector3D> implements Linecasta
+         return new Sphere(center, radius, precision);
+     }
+ 
+-    /** Internal class used to construct hyperplane-bounded approximations of spheres. The class begins with an
+-     * octahedron inscribed in the sphere and then subdivides each triangular face a specified number of times.
++    /** Internal class used to construct hyperplane-bounded approximations of spheres as BSP trees. The class
++     * begins with an octahedron inscribed in the sphere and then subdivides each triangular face a specified
++     * number of times.
+      */
+-    private static final class SphereApproximationBuilder {
++    private static final class SphereTreeApproximationBuilder {
+ 
+         /** Threshold used to determine when to stop inserting structural cuts and begin adding facets. */
+         private static final int PARTITION_THRESHOLD = 2;
+@@ -210,7 +264,7 @@ public final class Sphere extends AbstractNSphere<Vector3D> implements Linecasta
+          * @param sphere the sphere to create an approximation of
+          * @param subdivisions the number of triangle subdivisions to use in tree creation
+          */
+-        SphereApproximationBuilder(final Sphere sphere, final int subdivisions) {
++        SphereTreeApproximationBuilder(final Sphere sphere, final int subdivisions) {
+             this.sphere = sphere;
+             this.subdivisions = subdivisions;
+         }
+@@ -369,4 +423,90 @@ public final class Sphere extends AbstractNSphere<Vector3D> implements Linecasta
+             }
+         }
+     }
++
++    /** Internal class used to construct geodesic mesh sphere approximations. The class begins with an octahedron
++     * inscribed in the sphere and then subdivides each triangular face a specified number of times.
++     */
++    private static final class SphereMeshApproximationBuilder {
++
++        /** The sphere that an approximation is being created for. */
++        private final Sphere sphere;
++
++        /** The number of triangular subdivisions to use. */
++        private final int subdivisions;
++
++        /** Mesh builder object. */
++        private final SimpleTriangleMesh.Builder builder;
++
++        /** Construct a new builder for creating a mesh approximation of the given sphere.
++         * @param sphere the sphere to create an approximation of
++         * @param subdivisions the number of triangle subdivisions to use in mesh creation
++         */
++        SphereMeshApproximationBuilder(final Sphere sphere, final int subdivisions) {
++            this.sphere = sphere;
++            this.subdivisions = subdivisions;
++            this.builder = SimpleTriangleMesh.builder(sphere.getPrecision());
++        }
++
++        /** Build the mesh approximation of the configured sphere.
++         * @return the mesh approximation of the configured sphere
++         */
++        public SimpleTriangleMesh build() {
++            final Vector3D center = sphere.getCenter();
++            final double radius = sphere.getRadius();
++
++            // create the vertices for the octahedron
++            final double cx = center.getX();
++            final double cy = center.getY();
++            final double cz = center.getZ();
++
++            Vector3D maxX = Vector3D.of(cx + radius, cy, cz);
++            Vector3D minX = Vector3D.of(cx - radius, cy, cz);
++
++            Vector3D maxY = Vector3D.of(cx, cy + radius, cz);
++            Vector3D minY = Vector3D.of(cx, cy - radius, cz);
++
++            Vector3D maxZ = Vector3D.of(cx, cy, cz + radius);
++            Vector3D minZ = Vector3D.of(cx, cy, cz - radius);
++
++            addSubdivided(minX, minZ, minY, 0);
++            addSubdivided(minX, minY, maxZ, 0);
++
++            addSubdivided(minX, maxY, minZ, 0);
++            addSubdivided(minX, maxZ, maxY, 0);
++
++            addSubdivided(maxX, minY, minZ, 0);
++            addSubdivided(maxX, maxZ, minY, 0);
++
++            addSubdivided(maxX, minZ, maxY, 0);
++            addSubdivided(maxX, maxY, maxZ, 0);
++
++            return builder.build();
++        }
++
++        /** Recursively subdivide and add triangular faces between the given outer boundary points.
++         * @param p1 first point
++         * @param p2 second point
++         * @param p3 third point
++         * @param level recursion level; counts up
++         */
++        private void addSubdivided(final Vector3D p1, final Vector3D p2, final Vector3D p3, int level) {
++            if (level >= subdivisions) {
++                // base case
++                builder.addFaceUsingVertices(p1, p2, p3);
++            } else {
++                // subdivide
++                final int nextLevel = level + 1;
++
++                final Vector3D m1 = sphere.project(p1.lerp(p2, 0.5));
++                final Vector3D m2 = sphere.project(p2.lerp(p3, 0.5));
++                final Vector3D m3 = sphere.project(p3.lerp(p1, 0.5));
++
++                addSubdivided(p1, m1, m3, nextLevel);
++                addSubdivided(m1, p2, m2, nextLevel);
++                addSubdivided(m3, m2, p3, nextLevel);
++                addSubdivided(m1, m2, m3, nextLevel);
++            }
++        }
++    }
+ }
+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 ffca9a1..12cd0de 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
+@@ -26,10 +26,13 @@ import org.apache.commons.geometry.core.partitioning.BoundarySource;
+  */
+ public interface BoundarySource2D extends BoundarySource<LineConvexSubset>, Linecastable2D {
+ 
+-    /** Return a BSP tree constructed from the boundaries contained in this
+-     * instance. The default implementation creates a new, empty tree
+-     * and inserts the boundaries from this instance.
++    /** Return a BSP tree constructed from the boundaries contained in this instance. This is
++     * a convenience method for quickly constructing BSP trees and may produce unbalanced trees
++     * with unacceptable performance characteristics when used with large numbers of boundaries.
++     * In these cases, alternate tree construction approaches should be used, such as
++     * {@link RegionBSPTree2D.PartitionedRegionBuilder2D}.
+      * @return a BSP tree constructed from the boundaries in this instance
++     * @see RegionBSPTree2D#partitionedRegionBuilder()
+      */
+     default RegionBSPTree2D toTree() {
+         final RegionBSPTree2D tree = RegionBSPTree2D.empty();
+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
+index 44f2e6d..027e3aa 100644
+--- 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
+@@ -24,7 +24,7 @@ import java.util.stream.Stream;
+  * the vertices of each boundary in turn. Null is returned if any boundaries are
+  * infinite or no vertices are present.
+  */
+-class BoundarySourceBoundsBuilder2D {
++final 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.
+@@ -51,7 +51,7 @@ class BoundarySourceBoundsBuilder2D {
+             }
+         }
+ 
+-        return builder.containsBounds() ?
++        return builder.hasBounds() ?
+                 builder.build() :
+                 null;
+     }
+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
+index 0613d7f..d8cc84b 100644
+--- 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
+@@ -238,7 +238,7 @@ public final class Bounds2D extends AbstractBounds<Vector2D, Bounds2D> {
+         /** 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() {
++        public boolean hasBounds() {
+             return Double.isFinite(minX) &&
+                     Double.isFinite(minY) &&
+                     Double.isFinite(maxX) &&
+@@ -250,13 +250,13 @@ public final class Bounds2D extends AbstractBounds<Vector2D, Bounds2D> {
+          * @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()
++         * @see #hasBounds()
+          */
+         public Bounds2D build() {
+             final Vector2D min = Vector2D.of(minX, minY);
+             final Vector2D max = Vector2D.of(maxX, maxY);
+ 
+-            if (!containsBounds()) {
++            if (!hasBounds()) {
+                 if (Double.isInfinite(minX) && minX > 0 &&
+                         Double.isInfinite(maxX) && maxX < 0) {
+                     throw new IllegalStateException("Cannot construct bounds: no points given");
+diff --git a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/RegionBSPTree2D.java b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/RegionBSPTree2D.java
+index afa9347..610d31b 100644
+--- a/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/RegionBSPTree2D.java
++++ b/commons-geometry-euclidean/src/main/java/org/apache/commons/geometry/euclidean/twod/RegionBSPTree2D.java
+@@ -26,9 +26,11 @@ import java.util.stream.StreamSupport;
+ import org.apache.commons.geometry.core.partitioning.Hyperplane;
+ import org.apache.commons.geometry.core.partitioning.Split;
+ import org.apache.commons.geometry.core.partitioning.bsp.AbstractBSPTree;
++import org.apache.commons.geometry.core.partitioning.bsp.AbstractPartitionedRegionBuilder;
+ import org.apache.commons.geometry.core.partitioning.bsp.AbstractRegionBSPTree;
+ import org.apache.commons.geometry.core.partitioning.bsp.BSPTreeVisitor;
+ import org.apache.commons.geometry.core.partitioning.bsp.RegionCutBoundary;
++import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+ import org.apache.commons.geometry.euclidean.twod.path.InteriorAngleLinePathConnector;
+ import org.apache.commons.geometry.euclidean.twod.path.LinePath;
+ 
+@@ -306,6 +308,14 @@ public final class RegionBSPTree2D extends AbstractRegionBSPTree<Vector2D, Regio
+         return tree;
+     }
+ 
++    /** Create a new {@link PartitionedRegionBuilder2D} instance which can be used to build balanced
++     * BSP trees from region boundaries.
++     * @return a new {@link PartitionedRegionBuilder2D} instance
++     */
++    public static PartitionedRegionBuilder2D partitionedRegionBuilder() {
++        return new PartitionedRegionBuilder2D();
++    }
++
+     /** BSP tree node for two dimensional Euclidean space.
+      */
+     public static final class RegionNode2D extends AbstractRegionBSPTree.AbstractRegionNode<Vector2D, RegionNode2D> {
+@@ -345,6 +355,170 @@ public final class RegionBSPTree2D extends AbstractRegionBSPTree<Vector2D, Regio
+         }
+     }
+ 
++    /** Class used to build regions in Euclidean 2D space by inserting boundaries into a BSP
++     * tree containing "partitions", i.e. structural cuts where both sides of the cut have the same region location.
++     * When partitions are chosen that effectively divide the region boundaries at each partition level, the
++     * constructed tree is shallower and more balanced than one constructed from the region boundaries alone,
++     * resulting in improved performance. For example, consider a line segment approximation of a circle. The region is
++     * convex so each boundary has all of the other boundaries on its minus side; the plus sides are all empty.
++     * When these boundaries are inserted directly into a tree, the tree degenerates into a simple linked list of
++     * nodes with a height directly proportional to the number of boundaries. This means that many operations on the
++     * tree, such as inside/outside testing of points, involve iterating through each and every region boundary. In
++     * contrast, if a partition is first inserted that passes through the circle center, the first BSP tree node
++     * contains region nodes on its plus <em>and</em> minus sides, cutting the height of the tree in half. Operations
++     * such as inside/outside testing are then able to skip half of the tree nodes with a single test on the
++     * root node, resulting in drastically improved performance. Insertion of additional partitions (using a grid
++     * layout, for example) can produce even shallower trees, although there is a point unique to each boundary set at
++     * which the addition of more partitions begins to decrease instead of increase performance.
++     *
++     * <h2>Usage</h2>
++     * <p>Usage of this class consists of two phases: (1) <em>partition insertion</em> and (2) <em>boundary
++     * insertion</em>. Instances begin in the <em>partition insertion</em> phase. Here, partitions can be inserted
++     * into the empty tree using {@link PartitionedRegionBuilder2D#insertPartition(LineConvexSubset) insertPartition}
++     * or similar methods. The {@link org.apache.commons.geometry.core.partitioning.bsp.RegionCutRule#INHERIT INHERIT}
++     * cut rule is used internally to insert the cut so the represented region remains empty even as partitions are
++     * inserted.
++     * </p>
++     *
++     * <p>The instance moves into the <em>boundary insertion</em> phase when the caller inserts the first region
++     * boundary, using {@link PartitionedRegionBuilder2D#insertBoundary(LineConvexSubset) insertBoundary} or
++     * similar methods. Attempting to insert a partition after this point results in an {@code IllegalStateException}.
++     * This ensures that partitioning cuts are always located higher up the tree than boundary cuts.</p>
++     *
++     * <p>After all boundaries are inserted, the {@link PartitionedRegionBuilder2D#build() build} method is used
++     * to perform final processing and return the computed tree.</p>
++     */
++    public static final class PartitionedRegionBuilder2D
++        extends AbstractPartitionedRegionBuilder<Vector2D, RegionNode2D> {
++
++        /** Construct a new builder instance.
++         */
++        private PartitionedRegionBuilder2D() {
++            super(RegionBSPTree2D.empty());
++        }
++
++        /** Insert a partition line.
++         * @param partition partition to insert
++         * @return this instance
++         * @throws IllegalStateException if a boundary has previously been inserted
++         */
++        public PartitionedRegionBuilder2D insertPartition(final Line partition) {
++            return insertPartition(partition.span());
++        }
++
++        /** Insert a line convex subset as a partition.
++         * @param partition partition to insert
++         * @return this instance
++         * @throws IllegalStateException if a boundary has previously been inserted
++         */
++        public PartitionedRegionBuilder2D insertPartition(final LineConvexSubset partition) {
++            insertPartitionInternal(partition);
++
++            return this;
++        }
++
++        /** Insert two axis aligned lines intersecting at the given point as partitions.
++         * The lines each contain the {@code center} point and have the directions {@code +x} and {@code +y}
++         * in that order. If inserted into an empty tree, this will partition the space
++         * into 4 sections.
++         * @param center center point for the partitions; the inserted lines intersect at this point
++         * @param precision precision context used to construct the lines
++         * @return this instance
++         * @throws IllegalStateException if a boundary has previously been inserted
++         */
++        public PartitionedRegionBuilder2D insertAxisAlignedPartitions(final Vector2D center,
++                final DoublePrecisionContext precision) {
++
++            insertPartition(Lines.fromPointAndDirection(center, Vector2D.Unit.PLUS_X, precision));
++            insertPartition(Lines.fromPointAndDirection(center, Vector2D.Unit.PLUS_Y, precision));
++
++            return this;
++        }
++
++        /** Insert a grid of partitions. The partitions are constructed recursively: at each level two axis-aligned
++         * partitioning lines are inserted using
++         * {@link #insertAxisAlignedPartitions(Vector2D, DoublePrecisionContext) insertAxisAlignedPartitions}.
++         * The algorithm then recurses using bounding boxes from the min point to the center and from the center
++         * point to the max. Note that this means no partitions are ever inserted directly on the boundaries of
++         * the given bounding box. This is intentional and done to allow this method to be called directly with the
++         * bounding box from a set of boundaries to be inserted without unnecessarily adding partitions that will
++         * never have region boundaries on both sides.
++         * @param bounds bounding box for the grid
++         * @param level recursion level for the grid; each level subdivides each grid cube into 4 sections, making the
++         *      total number of grid cubes equal to {@code 4 ^ level}
++         * @param precision precision context used to construct the partition lines
++         * @return this instance
++         * @throws IllegalStateException if a boundary has previously been inserted
++         */
++        public PartitionedRegionBuilder2D insertAxisAlignedGrid(final Bounds2D bounds, final int level,
++                final DoublePrecisionContext precision) {
++
++            insertAxisAlignedGridRecursive(bounds.getMin(), bounds.getMax(), level, precision);
++
++            return this;
++        }
++
++        /** Recursively insert axis-aligned grid partitions.
++         * @param min min point for the grid square to partition
++         * @param max max point for the grid square to partition
++         * @param level current recursion level
++         * @param precision precision context used to construct the partition planes
++         */
++        private void insertAxisAlignedGridRecursive(final Vector2D min, final Vector2D max, final int level,
++                final DoublePrecisionContext precision) {
++            if (level > 0) {
++                final Vector2D center = min.lerp(max, 0.5);
++
++                insertAxisAlignedPartitions(center, precision);
++
++                final int nextLevel = level - 1;
++                insertAxisAlignedGridRecursive(min, center, nextLevel, precision);
++                insertAxisAlignedGridRecursive(center, max, nextLevel, precision);
++            }
++        }
++
++        /** Insert a region boundary.
++         * @param boundary region boundary to insert
++         * @return this instance
++         */
++        public PartitionedRegionBuilder2D insertBoundary(final LineConvexSubset boundary) {
++            insertBoundaryInternal(boundary);
++
++            return this;
++        }
++
++        /** Insert a collection of region boundaries.
++         * @param boundaries boundaries to insert
++         * @return this instance
++         */
++        public PartitionedRegionBuilder2D insertBoundaries(final Iterable<? extends LineConvexSubset> boundaries) {
++            for (final LineConvexSubset boundary : boundaries) {
++                insertBoundaryInternal(boundary);
++            }
++
++            return this;
++        }
++
++        /** Insert all boundaries from the given source.
++         * @param boundarySrc source of boundaries to insert
++         * @return this instance
++         */
++        public PartitionedRegionBuilder2D insertBoundaries(final BoundarySource2D boundarySrc) {
++            try (Stream<LineConvexSubset> stream = boundarySrc.boundaryStream()) {
++                stream.forEach(this::insertBoundaryInternal);
++            }
++
++            return this;
++        }
++
++        /** Build and return the region BSP tree.
++         * @return the region BSP tree
++         */
++        public RegionBSPTree2D build() {
++            return (RegionBSPTree2D) buildInternal();
++        }
++    }
++
+     /** Class used to project points onto the 2D region boundary.
+      */
+     private static final class BoundaryProjector2D extends BoundaryProjector<Vector2D, RegionNode2D> {
+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 84f6714..d5554d2 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
+@@ -16,6 +16,7 @@
+  */
+ package org.apache.commons.geometry.euclidean;
+ 
++import java.io.File;
+ import java.util.Arrays;
+ import java.util.List;
+ import java.util.stream.Collectors;
+@@ -28,6 +29,7 @@ import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
+ 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.testio.TestOBJWriter;
+ import org.apache.commons.geometry.euclidean.threed.AffineTransformMatrix3D;
+ import org.apache.commons.geometry.euclidean.threed.ConvexPolygon3D;
+ import org.apache.commons.geometry.euclidean.threed.Plane;
+@@ -39,8 +41,10 @@ import org.apache.commons.geometry.euclidean.threed.line.Line3D;
+ import org.apache.commons.geometry.euclidean.threed.line.LinecastPoint3D;
+ import org.apache.commons.geometry.euclidean.threed.line.Lines3D;
+ import org.apache.commons.geometry.euclidean.threed.line.Ray3D;
++import org.apache.commons.geometry.euclidean.threed.mesh.TriangleMesh;
+ import org.apache.commons.geometry.euclidean.threed.rotation.QuaternionRotation;
+ import org.apache.commons.geometry.euclidean.threed.shape.Parallelepiped;
++import org.apache.commons.geometry.euclidean.threed.shape.Sphere;
+ import org.apache.commons.geometry.euclidean.twod.AffineTransformMatrix2D;
+ import org.apache.commons.geometry.euclidean.twod.Line;
+ import org.apache.commons.geometry.euclidean.twod.LinecastPoint2D;
+@@ -67,28 +71,28 @@ public class DocumentationExamplesTest {
+         // construct a precision context to handle floating-point comparisons
+         DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-6);
+ 
+-        // create a binary space partitioning (BSP) tree representing the unit cube
+-        // centered on the origin
+-        RegionBSPTree3D region = Parallelepiped.builder(precision)
+-                .setPosition(Vector3D.ZERO)
+-                .build()
+-                .toTree();
++        // create a BSP tree representing the unit cube
++        RegionBSPTree3D tree = Parallelepiped.unitCube(precision).toTree();
++
++        // create a sphere centered on the origin
++        Sphere sphere = Sphere.from(Vector3D.ZERO, 0.65, precision);
+ 
+-        // create a rotated copy of the region
+-        RegionBSPTree3D copy = region.copy();
+-        copy.transform(QuaternionRotation.fromAxisAngle(Vector3D.Unit.PLUS_Z, 0.25 * Math.PI));
++        // subtract a BSP tree approximation of the sphere containing 512 facets
++        // from the cube, modifying the cube tree in place
++        tree.difference(sphere.toTree(3));
+ 
+-        // compute the intersection of the regions, storing the result back into the caller
+-        // (the result could also have been placed into a third region)
+-        region.intersection(copy);
++        // compute some properties of the resulting region
++        double size = tree.getSize(); // 0.11509505362599505
++        Vector3D centroid = tree.getCentroid(); // (0, 0, 0)
+ 
+-        // compute some properties of the intersection region
+-        double size = region.getSize(); // 0.8284271247461903
+-        Vector3D centroid = region.getCentroid(); // (0, 0, 0)
++        // convert to a triangle mesh for output to other programs
++        TriangleMesh mesh = tree.toTriangleMesh(precision);
+ 
+         // -----------
+-        Assert.assertEquals(0.8284271247461903, size, TEST_EPS);
++        Assert.assertEquals(0.11509505362599505, size, TEST_EPS);
+         EuclideanTestUtils.assertCoordinatesEqual(Vector3D.ZERO, centroid, TEST_EPS);
++
++        TestOBJWriter.write(mesh, new File("target/index-page-example.obj"));
+     }
+ 
+     @Test
+diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/testio/TestOBJWriter.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/testio/TestOBJWriter.java
+new file mode 100644
+index 0000000..f38f0da
+--- /dev/null
++++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/testio/TestOBJWriter.java
+@@ -0,0 +1,282 @@
++/*
++ * 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.testio;
++
++import java.io.File;
++import java.io.IOException;
++import java.io.UncheckedIOException;
++import java.io.Writer;
++import java.nio.charset.StandardCharsets;
++import java.nio.file.Files;
++import java.text.DecimalFormat;
++import java.util.Iterator;
++import java.util.List;
++import java.util.stream.Stream;
++
++import org.apache.commons.geometry.euclidean.threed.BoundarySource3D;
++import org.apache.commons.geometry.euclidean.threed.PlaneConvexSubset;
++import org.apache.commons.geometry.euclidean.threed.Vector3D;
++import org.apache.commons.geometry.euclidean.threed.mesh.Mesh;
++
++/** Class for writing OBJ files containing 3D mesh data. This class is primarily intended for use in
++ * debugging unit tests.
++ */
++public final class TestOBJWriter implements AutoCloseable {
++
++    /** Space character. */
++    private static final char SPACE = ' ';
++
++    /** The default maximum number of fraction digits in formatted numbers. */
++    private static final int DEFAULT_MAXIMUM_FRACTION_DIGITS = 6;
++
++    /** The default line separator value. This is not directly specified by the OBJ format
++     * but the value used here matches that
++     * <a href="https://docs.blender.org/manual/en/2.80/addons/io_scene_obj.html">used by Blender</a>.
++     */
++    private static final String DEFAULT_LINE_SEPARATOR = "\n";
++
++    /** Underlying writer instance. */
++    private Writer writer;
++
++    /** Line separator string. */
++    private String lineSeparator = DEFAULT_LINE_SEPARATOR;
++
++    /** Decimal formatter. */
++    private DecimalFormat decimalFormat;
++
++    /** Number of vertices written to the output. */
++    private int vertexCount = 0;
++
++    /** Create a new instance for writing to the given file.
++     * @param file file to write to
++     * @throws IOException if an IO operation fails
++     */
++    public TestOBJWriter(final File file) throws IOException {
++        this(Files.newBufferedWriter(file.toPath(), StandardCharsets.UTF_8));
++    }
++
++    /** Create a new instance that writes output with the given writer.
++     * @param writer writer used to write output
++     */
++    public TestOBJWriter(final Writer writer) {
++        this.writer = writer;
++
++        this.decimalFormat = new DecimalFormat();
++        this.decimalFormat.setMaximumFractionDigits(DEFAULT_MAXIMUM_FRACTION_DIGITS);
++    }
++
++    /** Get the current line separator. This value defaults to {@value #DEFAULT_LINE_SEPARATOR}.
++     * @return the current line separator
++     */
++    public String getLineSeparator() {
++        return lineSeparator;
++    }
++
++    /** Set the line separator.
++     * @param lineSeparator the line separator to use
++     */
++    public void setLineSeparator(final String lineSeparator) {
++        this.lineSeparator = lineSeparator;
++    }
++
++    /** Get the {@link DecimalFormat} instance used to format floating point output.
++     * @return the decimal format instance
++     */
++    public DecimalFormat getDecimalFormat() {
++        return decimalFormat;
++    }
++
++    /** Set the {@link DecimalFormat} instance used to format floatin point output.
++     * @param decimalFormat decimal format instance
++     */
++    public void setDecimalFormat(final DecimalFormat decimalFormat) {
++        this.decimalFormat = decimalFormat;
++    }
++
++    /** Write an OBJ comment with the given value.
++     * @param comment comment to write
++     * @throws IOException if an IO operation fails
++     */
++    public void writeComment(final String comment) throws IOException {
++        for (final String line : comment.split("\r?\n")) {
++            writer.write('#');
++            writer.write(SPACE);
++            writer.write(line);
++            writer.write(lineSeparator);
++        }
++    }
++
++    /** Write an object name to the output. This is metadata for the file and
++     * does not affect the geometry, although it may affect how the file content
++     * is read by other programs.
++     * @param objectName the name to write
++     */
++    public void writeObjectName(final String objectName) throws IOException {
++        writer.write('o');
++        writer.write(SPACE);
++        writer.write(objectName);
++        writer.write(lineSeparator);
++    }
++
++    /** Write a group name to the output. This is metadata for the file and
++     * does not affect the geometry, although it may affect how the file content
++     * is read by other programs.
++     * @param groupName the name to write
++     */
++    public void writeGroupName(final String groupName) throws IOException {
++        writer.write('g');
++        writer.write(SPACE);
++        writer.write(groupName);
++        writer.write(lineSeparator);
++    }
++
++    /** Write a vertex to the output. The OBJ 1-based index of the vertex is returned. This
++     * index can be used to reference the vertex in faces via {@link #writeFace(int...)}.
++     * @param vertex vertex to write
++     * @throws IOException if an IO operation fails
++     * @return the index of the written vertex in the OBJ 1-based convention
++     */
++    public int writeVertex(final Vector3D vertex) throws IOException {
++        writer.write('v');
++        writer.write(SPACE);
++        writer.write(decimalFormat.format(vertex.getX()));
++        writer.write(SPACE);
++        writer.write(decimalFormat.format(vertex.getY()));
++        writer.write(SPACE);
++        writer.write(decimalFormat.format(vertex.getZ()));
++        writer.write(lineSeparator);
++
++        return ++vertexCount;
++    }
++
++    /** Write a face with the given vertex indices, specified in the OBJ 1-based
++     * convention. Callers are responsible for ensuring that the indices are valid.
++     * @param vertexIndices vertex indices for the face, in the 1-based OBJ convention
++     */
++    public void writeFace(final int... vertexIndices) throws IOException {
++        writeFaceWithVertexOffset(0, vertexIndices);
++    }
++
++    /** Write the boundaries present in the given boundary source. If the argument is a {@link Mesh},
++     * it is written using {@link #writeMesh(Mesh)}. Otherwise, each boundary is written to the output
++     * separately.
++     * @param boundarySource boundary source containing the boundaries to write to the output
++     * @throws IllegalArgumentException if any boundary in the argument is infinite
++     */
++    public void writeBoundaries(final BoundarySource3D boundarySource) throws IOException {
++        if (boundarySource instanceof Mesh) {
++            writeMesh((Mesh<?>) boundarySource);
++        } else {
++            try (Stream<PlaneConvexSubset> stream = boundarySource.boundaryStream()) {
++                writeBoundaries(stream.iterator());
++            }
++        }
++    }
++
++    /** Write the boundaries in the argument to the output. Each boundary is written separately.
++     * @param it boundary iterator
++     * @throws IllegalArgumentException if any boundary in the argument is infinite
++     */
++    private void writeBoundaries(final Iterator<PlaneConvexSubset> it) throws IOException {
++        PlaneConvexSubset boundary;
++        List<Vector3D> vertices;
++        int[] vertexIndices;
++
++        while (it.hasNext()) {
++            boundary = it.next();
++            if (boundary.isInfinite()) {
++                throw new IllegalArgumentException("OBJ input geometry cannot be infinite: " + boundary);
++            }
++
++            vertices = boundary.getVertices();
++            vertexIndices = new int[vertices.size()];
++
++            for (int i = 0; i < vertexIndices.length; ++i) {
++                vertexIndices[i] = writeVertex(vertices.get(i));
++            }
++
++            writeFace(vertexIndices);
++        }
++    }
++
++    /** Write a mesh to the output.
++     * @param mesh the mesh to write
++     * @throws IOException if an IO operation fails
++     */
++    public void writeMesh(final Mesh<?> mesh) throws IOException {
++        final int vertexOffset = vertexCount + 1;
++
++        for (final Vector3D vertex : mesh.vertices()) {
++            writeVertex(vertex);
++        }
++
++        for (final Mesh.Face face : mesh.faces()) {
++            writeFaceWithVertexOffset(vertexOffset, face.getVertexIndices());
++        }
++    }
++
++    /** Write a face with the given vertex offset value and indices. The offset is added to each
++     * index before being written.
++     * @param vertexOffset vertex offset value
++     * @param vertexIndices vertex indices for the face
++     * @throws IOException if an IO operation fails
++     */
++    private void writeFaceWithVertexOffset(final int vertexOffset, final int... vertexIndices)
++            throws IOException {
++        if (vertexIndices.length < 3) {
++            throw new IllegalArgumentException("Face must have more than 3 vertices; found " + vertexIndices.length);
++        }
++
++        writer.write('f');
++
++        for (int i = 0; i < vertexIndices.length; ++i) {
++            writer.write(SPACE);
++            writer.write(String.valueOf(vertexIndices[i] + vertexOffset));
++        }
++
++        writer.write(lineSeparator);
++    }
++
++    /** {@inheritDoc} */
++    @Override
++    public void close() throws IOException {
++        if (writer != null) {
++            writer.close();
++        }
++        writer = null;
++    }
++
++    /** Convenience method for writing a boundary source to a file.
++     * @param src boundary source to write
++     * @param file file to write to
++     */
++    public static void write(final BoundarySource3D src, final File file) {
++        try (TestOBJWriter writer = new TestOBJWriter(file)) {
++            writer.writeBoundaries(src);
++        } catch (IOException exc) {
++            throw new UncheckedIOException(exc);
++        }
++    }
++
++    /** Convenience method for writing a boundary source to a file.
++     * @param src boundary source to write
++     * @param file file path to write to
++     */
++    public static void write(final BoundarySource3D src, final String file) {
++        write(src, new File(file));
++    }
++}
+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
+index 58919ee..f24dc49 100644
+--- 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
+@@ -500,23 +500,23 @@ public class Bounds3DTest {
+     }
+ 
+     @Test
+-    public void testBuilder_containsBounds() {
++    public void testBuilder_hasBounds() {
+         // act/assert
+-        Assert.assertFalse(Bounds3D.builder().containsBounds());
++        Assert.assertFalse(Bounds3D.builder().hasBounds());
+ 
+-        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.NaN, 1, 1)).hasBounds());
++        Assert.assertFalse(Bounds3D.builder().add(Vector3D.of(1, Double.NaN, 1)).hasBounds());
++        Assert.assertFalse(Bounds3D.builder().add(Vector3D.of(1, 1, Double.NaN)).hasBounds());
+ 
+-        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.POSITIVE_INFINITY, 1, 1)).hasBounds());
++        Assert.assertFalse(Bounds3D.builder().add(Vector3D.of(1, Double.POSITIVE_INFINITY, 1)).hasBounds());
++        Assert.assertFalse(Bounds3D.builder().add(Vector3D.of(1, 1, Double.POSITIVE_INFINITY)).hasBounds());
+ 
+-        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.assertFalse(Bounds3D.builder().add(Vector3D.of(Double.NEGATIVE_INFINITY, 1, 1)).hasBounds());
++        Assert.assertFalse(Bounds3D.builder().add(Vector3D.of(1, Double.NEGATIVE_INFINITY, 1)).hasBounds());
++        Assert.assertFalse(Bounds3D.builder().add(Vector3D.of(1, 1, Double.NEGATIVE_INFINITY)).hasBounds());
+ 
+-        Assert.assertTrue(Bounds3D.builder().add(Vector3D.ZERO).containsBounds());
++        Assert.assertTrue(Bounds3D.builder().add(Vector3D.ZERO).hasBounds());
+     }
+ 
+     private static void checkBounds(Bounds3D b, Vector3D min, Vector3D max) {
+diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/PlanesTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/PlanesTest.java
+index 6be9012..404bfae 100644
+--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/PlanesTest.java
++++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/PlanesTest.java
+@@ -31,7 +31,12 @@ import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
+ import org.apache.commons.geometry.euclidean.twod.ConvexArea;
+ import org.apache.commons.geometry.euclidean.twod.Line;
+ import org.apache.commons.geometry.euclidean.twod.LineConvexSubset;
++import org.apache.commons.geometry.euclidean.twod.Lines;
++import org.apache.commons.geometry.euclidean.twod.RegionBSPTree2D;
+ 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;
+ 
+@@ -670,6 +675,701 @@ public class PlanesTest {
+         }, IllegalArgumentException.class, baseMsg + "2");
+     }
+ 
++    @Test
++    public void testExtrudeVertexLoop_convex() {
++        // arrange
++        List<Vector2D> vertices = Arrays.asList(
++                Vector2D.of(2, 1),
++                Vector2D.of(3, 1),
++                Vector2D.of(2, 3)
++            );
++
++        EmbeddingPlane plane = Planes.fromPointAndPlaneVectors(Vector3D.of(0, 0, 1),
++                Vector3D.Unit.PLUS_Y, Vector3D.Unit.MINUS_X, TEST_PRECISION);
++        Vector3D extrusionVector = Vector3D.of(1, 0, 1);
++
++        // act
++        List<PlaneConvexSubset> boundaries = Planes.extrudeVertexLoop(vertices, plane, extrusionVector, TEST_PRECISION);
++
++        // assert
++        Assert.assertEquals(5, boundaries.size());
++
++        RegionBSPTree3D tree = RegionBSPTree3D.from(boundaries);
++
++        Assert.assertEquals(1, tree.getSize(), TEST_EPS);
++        EuclideanTestUtils.assertCoordinatesEqual(
++                Vector3D.of(-5.0 / 3.0, 7.0 / 3.0, 1).add(extrusionVector.multiply(0.5)), tree.getCentroid(), TEST_EPS);
++
++        EuclideanTestUtils.assertRegionLocation(tree, RegionLocation.INSIDE,
++                Vector3D.of(-1.5, 2.5, 1.25), tree.getCentroid());
++        EuclideanTestUtils.assertRegionLocation(tree, RegionLocation.BOUNDARY,
++                Vector3D.of(-2, 2, 1), Vector3D.of(-1, 2, 1), Vector3D.of(-1, 3, 1),
++                Vector3D.of(-1, 2, 2), Vector3D.of(0, 2, 2), Vector3D.of(0, 3, 2));
++
++        EuclideanTestUtils.assertRegionLocation(tree, RegionLocation.OUTSIDE,
++                Vector3D.of(-1.5, 2.5, 0.9), Vector3D.of(-1.5, 2.5, 2.1));
++    }
++
++    @Test
++    public void testExtrudeVertexLoop_nonConvex() {
++        // arrange
++        List<Vector2D> vertices = Arrays.asList(
++                Vector2D.of(1, 2),
++                Vector2D.of(1, -2),
++                Vector2D.of(4, -2),
++                Vector2D.of(4, -1),
++                Vector2D.of(2, -1),
++                Vector2D.of(2, 1),
++                Vector2D.of(4, 1),
++                Vector2D.of(4, 2),
++                Vector2D.of(1, 2)
++            );
++
++        EmbeddingPlane plane = Planes.fromPointAndPlaneVectors(Vector3D.of(0, 0, -1),
++                Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
++        Vector3D extrusionVector = Vector3D.of(0, 0, 2);
++
++        // act
++        List<PlaneConvexSubset> boundaries = Planes.extrudeVertexLoop(vertices, plane, extrusionVector, TEST_PRECISION);
++
++        // assert
++        Assert.assertEquals(14, boundaries.size());
++
++        RegionBSPTree3D tree = RegionBSPTree3D.from(boundaries);
++
++        Assert.assertEquals(16, tree.getSize(), TEST_EPS);
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(2.25, 0, 0), tree.getCentroid(), TEST_EPS);
++
++        EuclideanTestUtils.assertRegionLocation(tree, RegionLocation.INSIDE,
++                Vector3D.of(1.5, 0, 0), Vector3D.of(3, 1.5, 0), Vector3D.of(3, -1.5, 0));
++        EuclideanTestUtils.assertRegionLocation(tree, RegionLocation.BOUNDARY,
++                Vector3D.of(1.5, 0, -1), Vector3D.of(3, 1.5, -1), Vector3D.of(3, -1.5, -1),
++                Vector3D.of(1.5, 0, 1), Vector3D.of(3, 1.5, 1), Vector3D.of(3, -1.5, 1),
++                Vector3D.of(1, 0, 0), Vector3D.of(2.5, -2, 0), Vector3D.of(4, -1.5, 0),
++                Vector3D.of(3, -1, 0), Vector3D.of(2, 0, 0), Vector3D.of(3, 1, 0),
++                Vector3D.of(4, 1.5, 0), Vector3D.of(2.5, 2, 0));
++
++        EuclideanTestUtils.assertRegionLocation(tree, RegionLocation.OUTSIDE,
++                tree.getCentroid(), Vector3D.ZERO, Vector3D.of(5, 0, 0));
++    }
++
++    @Test
++    public void testExtrudeVertexLoop_noVertices() {
++        // arrange
++        List<Vector2D> vertices = new ArrayList<>();
++
++        EmbeddingPlane plane = Planes.fromPointAndPlaneVectors(Vector3D.of(0, 0, -1),
++                Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
++        Vector3D extrusionVector = Vector3D.of(0, 0, 2);
++
++        // act
++        List<PlaneConvexSubset> boundaries = Planes.extrudeVertexLoop(vertices, plane, extrusionVector, TEST_PRECISION);
++
++        // assert
++        Assert.assertEquals(0, boundaries.size());
++    }
++
++    @Test
++    public void testExtrudeVertexLoop_twoVertices_producesInfiniteRegion() {
++        // arrange
++        List<Vector2D> vertices = Arrays.asList(Vector2D.ZERO, Vector2D.of(1, 1));
++
++        EmbeddingPlane plane = Planes.fromPointAndPlaneVectors(Vector3D.of(0, 0, -1),
++                Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
++        Vector3D extrusionVector = Vector3D.of(0, 0, 2);
++
++        // act
++        List<PlaneConvexSubset> boundaries = Planes.extrudeVertexLoop(vertices, plane, extrusionVector, TEST_PRECISION);
++
++        // assert
++        Assert.assertEquals(3, boundaries.size());
++
++        PlaneConvexSubset bottom = boundaries.get(0);
++        Assert.assertTrue(bottom.isInfinite());
++        Assert.assertTrue(bottom.getPlane().contains(Vector3D.of(0, 0, -1)));
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 0, -1), bottom.getPlane().getNormal(), TEST_EPS);
++
++        PlaneConvexSubset top = boundaries.get(1);
++        Assert.assertTrue(top.isInfinite());
++        Assert.assertTrue(top.getPlane().contains(Vector3D.of(0, 0, 1)));
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 0, 1), top.getPlane().getNormal(), TEST_EPS);
++
++        PlaneConvexSubset side = boundaries.get(2);
++        Assert.assertTrue(side.isInfinite());
++        Assert.assertTrue(side.getPlane().contains(Vector3D.ZERO));
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, -1, 0).normalize(),
++                side.getPlane().getNormal(), TEST_EPS);
++
++        RegionBSPTree3D tree = RegionBSPTree3D.from(boundaries);
++        Assert.assertFalse(tree.isFull());
++        Assert.assertTrue(tree.isInfinite());
++
++        EuclideanTestUtils.assertRegionLocation(tree, RegionLocation.INSIDE,
++                Vector3D.of(0, 1, 0), Vector3D.of(-1, 0, 0), Vector3D.of(-2, -1, 0));
++
++        EuclideanTestUtils.assertRegionLocation(tree, RegionLocation.BOUNDARY,
++                Vector3D.of(1, 1, 0), Vector3D.of(0, 0, 0), Vector3D.of(-1, -1, 0));
++
++        EuclideanTestUtils.assertRegionLocation(tree, RegionLocation.OUTSIDE,
++                Vector3D.of(2, 1, 0), Vector3D.of(1, 0, 0), Vector3D.of(0, -1, 0));
++    }
++
++    @Test
++    public void testExtrudeVertexLoop_invalidVertexList() {
++        // arrange
++        EmbeddingPlane plane = Planes.fromPointAndPlaneVectors(Vector3D.of(0, 0, -1),
++                Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
++        Vector3D extrusionVector = Vector3D.of(0, 0, 2);
++
++        // act/assert
++        GeometryTestUtils.assertThrows(() -> {
++            Planes.extrudeVertexLoop(Arrays.asList(Vector2D.ZERO), plane, extrusionVector, TEST_PRECISION);
++        }, IllegalStateException.class);
++
++        GeometryTestUtils.assertThrows(() -> {
++            Planes.extrudeVertexLoop(Arrays.asList(Vector2D.ZERO, Vector2D.of(0, 1e-16)), plane,
++                    extrusionVector, TEST_PRECISION);
++        }, IllegalStateException.class);
++    }
++
++    @Test
++    public void testExtrudeVertexLoop_regionsConsistentBetweenExtrusionPlanes() {
++        // arrange
++        List<Vector2D> vertices = Arrays.asList(
++                Vector2D.of(1, 2),
++                Vector2D.of(1, -2),
++                Vector2D.of(4, -2),
++                Vector2D.of(4, -1),
++                Vector2D.of(2, -1),
++                Vector2D.of(2, 1),
++                Vector2D.of(4, 1),
++                Vector2D.of(4, 2),
++                Vector2D.of(1, 2)
++            );
++
++        RegionBSPTree2D subspaceTree = LinePath.fromVertexLoop(vertices, TEST_PRECISION).toTree();
++
++        double subspaceSize = subspaceTree.getSize();
++        Vector2D subspaceCentroid = subspaceTree.getCentroid();
++
++        double extrusionLength = 2;
++        double expectedSize = subspaceSize * extrusionLength;
++
++        Vector3D planePt = Vector3D.of(-1, 2, -3);
++
++        EuclideanTestUtils.permuteSkipZero(-2, 2, 1, (x, y, z) -> {
++            Vector3D normal = Vector3D.of(x, y, z);
++            EmbeddingPlane plane = Planes.fromPointAndNormal(planePt, normal, TEST_PRECISION).getEmbedding();
++
++            Vector3D baseCentroid = plane.toSpace(subspaceCentroid);
++
++            Vector3D plusExtrusionVector = normal.withNorm(extrusionLength);
++            Vector3D minusExtrusionVector = plusExtrusionVector.negate();
++
++            // act
++            RegionBSPTree3D extrudePlus = RegionBSPTree3D.from(
++                    Planes.extrudeVertexLoop(vertices, plane, plusExtrusionVector, TEST_PRECISION));
++            RegionBSPTree3D extrudeMinus = RegionBSPTree3D.from(
++                    Planes.extrudeVertexLoop(vertices, plane, minusExtrusionVector, TEST_PRECISION));
++
++            // assert
++            Assert.assertEquals(expectedSize, extrudePlus.getSize(), TEST_EPS);
++            EuclideanTestUtils.assertCoordinatesEqual(baseCentroid.add(plusExtrusionVector.multiply(0.5)),
++                    extrudePlus.getCentroid(), TEST_EPS);
++
++            Assert.assertEquals(expectedSize, extrudeMinus.getSize(), TEST_EPS);
++            EuclideanTestUtils.assertCoordinatesEqual(baseCentroid.add(minusExtrusionVector.multiply(0.5)),
++                    extrudeMinus.getCentroid(), TEST_EPS);
++        });
++    }
++
++    @Test
++    public void testExtrude_vertexLoop_clockwiseWinding() {
++        // arrange
++        List<Vector2D> vertices = Arrays.asList(
++            Vector2D.of(0, 1),
++            Vector2D.of(1, 0),
++            Vector2D.of(0, -1),
++            Vector2D.of(-1, 0));
++
++        EmbeddingPlane plane = Planes.fromPointAndPlaneVectors(Vector3D.of(0, 0, -1),
++                Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
++        Vector3D extrusionVector = Vector3D.of(0, 0, 2);
++
++        // act
++        List<PlaneConvexSubset> boundaries = Planes.extrudeVertexLoop(vertices, plane, extrusionVector, TEST_PRECISION);
++
++        // assert
++        RegionBSPTree3D resultTree = RegionBSPTree3D.from(boundaries);
++
++        Assert.assertTrue(resultTree.isInfinite());
++        EuclideanTestUtils.assertRegionLocation(resultTree, RegionLocation.INSIDE,
++                Vector3D.of(1, 1, 0), Vector3D.of(-1, 1, 0), Vector3D.of(-1, -1, 0), Vector3D.of(1, -1, 0));
++        EuclideanTestUtils.assertRegionLocation(resultTree, RegionLocation.OUTSIDE, Vector3D.ZERO);
++    }
++
++    @Test
++    public void testExtrude_linePath_emptyPath() {
++        // arrange
++        LinePath path = LinePath.empty();
++
++        EmbeddingPlane plane = Planes.fromPointAndPlaneVectors(Vector3D.of(0, 0, -1),
++                Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
++        Vector3D extrusionVector = Vector3D.of(0, 0, 2);
++
++        // act
++        List<PlaneConvexSubset> boundaries = Planes.extrude(path, plane, extrusionVector, TEST_PRECISION);
++
++        // assert
++        Assert.assertEquals(0, boundaries.size());
++    }
++
++    @Test
++    public void testExtrude_linePath_singleSegment_producesInfiniteRegion_extrudingOnMinus() {
++        // arrange
++        LinePath path = LinePath.builder(TEST_PRECISION)
++                .append(Vector2D.ZERO)
++                .append(Vector2D.of(1, 1))
++                .build();
++
++        EmbeddingPlane plane = Planes.fromPointAndPlaneVectors(Vector3D.of(0, 0, 1),
++                Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
++        Vector3D extrusionVector = Vector3D.of(0, 0, -2);
++
++        // act
++        List<PlaneConvexSubset> boundaries = Planes.extrude(path, plane, extrusionVector, TEST_PRECISION);
++
++        // assert
++        Assert.assertEquals(3, boundaries.size());
++
++        PlaneConvexSubset top = boundaries.get(0);
++        Assert.assertTrue(top.isInfinite());
++        Assert.assertTrue(top.getPlane().contains(Vector3D.of(0, 0, 1)));
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 0, 1), top.getPlane().getNormal(), TEST_EPS);
++
++        PlaneConvexSubset bottom = boundaries.get(1);
++        Assert.assertTrue(bottom.isInfinite());
++        Assert.assertTrue(bottom.getPlane().contains(Vector3D.of(0, 0, -1)));
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 0, -1), bottom.getPlane().getNormal(), TEST_EPS);
++
++        PlaneConvexSubset side = boundaries.get(2);
++        Assert.assertTrue(side.isInfinite());
++        Assert.assertTrue(side.getPlane().contains(Vector3D.ZERO));
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, -1, 0).normalize(),
++                side.getPlane().getNormal(), TEST_EPS);
++
++        RegionBSPTree3D tree = RegionBSPTree3D.from(boundaries);
++        Assert.assertFalse(tree.isFull());
++        Assert.assertTrue(tree.isInfinite());
++
++        EuclideanTestUtils.assertRegionLocation(tree, RegionLocation.INSIDE,
++                Vector3D.of(0, 1, 0), Vector3D.of(-1, 0, 0), Vector3D.of(-2, -1, 0));
++
++        EuclideanTestUtils.assertRegionLocation(tree, RegionLocation.BOUNDARY,
++                Vector3D.of(1, 1, 0), Vector3D.of(0, 0, 0), Vector3D.of(-1, -1, 0));
++
++        EuclideanTestUtils.assertRegionLocation(tree, RegionLocation.OUTSIDE,
++                Vector3D.of(2, 1, 0), Vector3D.of(1, 0, 0), Vector3D.of(0, -1, 0));
++    }
++
++    @Test
++    public void testExtrude_linePath_singleSegment_producesInfiniteRegion_extrudingOnPlus() {
++        // arrange
++        LinePath path = LinePath.builder(TEST_PRECISION)
++                .append(Vector2D.ZERO)
++                .append(Vector2D.of(1, 1))
++                .build();
++
++        EmbeddingPlane plane = Planes.fromPointAndPlaneVectors(Vector3D.of(0, 0, -1),
++                Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
++        Vector3D extrusionVector = Vector3D.of(0, 0, 2);
++
++        // act
++        List<PlaneConvexSubset> boundaries = Planes.extrude(path, plane, extrusionVector, TEST_PRECISION);
++
++        // assert
++        Assert.assertEquals(3, boundaries.size());
++
++        PlaneConvexSubset bottom = boundaries.get(0);
++        Assert.assertTrue(bottom.isInfinite());
++        Assert.assertTrue(bottom.getPlane().contains(Vector3D.of(0, 0, -1)));
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 0, -1), bottom.getPlane().getNormal(), TEST_EPS);
++
++        PlaneConvexSubset top = boundaries.get(1);
++        Assert.assertTrue(top.isInfinite());
++        Assert.assertTrue(top.getPlane().contains(Vector3D.of(0, 0, 1)));
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 0, 1), top.getPlane().getNormal(), TEST_EPS);
++
++        PlaneConvexSubset side = boundaries.get(2);
++        Assert.assertTrue(side.isInfinite());
++        Assert.assertTrue(side.getPlane().contains(Vector3D.ZERO));
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, -1, 0).normalize(),
++                side.getPlane().getNormal(), TEST_EPS);
++
++        RegionBSPTree3D tree = RegionBSPTree3D.from(boundaries);
++        Assert.assertFalse(tree.isFull());
++        Assert.assertTrue(tree.isInfinite());
++
++        EuclideanTestUtils.assertRegionLocation(tree, RegionLocation.INSIDE,
++                Vector3D.of(0, 1, 0), Vector3D.of(-1, 0, 0), Vector3D.of(-2, -1, 0));
++
++        EuclideanTestUtils.assertRegionLocation(tree, RegionLocation.BOUNDARY,
++                Vector3D.of(1, 1, 0), Vector3D.of(0, 0, 0), Vector3D.of(-1, -1, 0));
++
++        EuclideanTestUtils.assertRegionLocation(tree, RegionLocation.OUTSIDE,
++                Vector3D.of(2, 1, 0), Vector3D.of(1, 0, 0), Vector3D.of(0, -1, 0));
++    }
++
++    @Test
++    public void testExtrude_linePath_singleSpan_producesInfiniteRegion() {
++        // arrange
++        LinePath path = LinePath.from(Lines.fromPoints(Vector2D.ZERO, Vector2D.of(1, 1), TEST_PRECISION).span());
++
++        EmbeddingPlane plane = Planes.fromPointAndPlaneVectors(Vector3D.of(0, 0, -1),
++                Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
++        Vector3D extrusionVector = Vector3D.of(0, 0, 2);
++
++        // act
++        List<PlaneConvexSubset> boundaries = Planes.extrude(path, plane, extrusionVector, TEST_PRECISION);
++
++        // assert
++        Assert.assertEquals(3, boundaries.size());
++
++        PlaneConvexSubset bottom = boundaries.get(0);
++        Assert.assertTrue(bottom.isInfinite());
++        Assert.assertTrue(bottom.getPlane().contains(Vector3D.of(0, 0, -1)));
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 0, -1), bottom.getPlane().getNormal(), TEST_EPS);
++
++        PlaneConvexSubset top = boundaries.get(1);
++        Assert.assertTrue(top.isInfinite());
++        Assert.assertTrue(top.getPlane().contains(Vector3D.of(0, 0, 1)));
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 0, 1), top.getPlane().getNormal(), TEST_EPS);
++
++        PlaneConvexSubset side = boundaries.get(2);
++        Assert.assertTrue(side.isInfinite());
++        Assert.assertTrue(side.getPlane().contains(Vector3D.ZERO));
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, -1, 0).normalize(),
++                side.getPlane().getNormal(), TEST_EPS);
++
++        RegionBSPTree3D tree = RegionBSPTree3D.from(boundaries);
++        Assert.assertFalse(tree.isFull());
++        Assert.assertTrue(tree.isInfinite());
++
++        EuclideanTestUtils.assertRegionLocation(tree, RegionLocation.INSIDE,
++                Vector3D.of(0, 1, 0), Vector3D.of(-1, 0, 0), Vector3D.of(-2, -1, 0));
++
++        EuclideanTestUtils.assertRegionLocation(tree, RegionLocation.BOUNDARY,
++                Vector3D.of(1, 1, 0), Vector3D.of(0, 0, 0), Vector3D.of(-1, -1, 0));
++
++        EuclideanTestUtils.assertRegionLocation(tree, RegionLocation.OUTSIDE,
++                Vector3D.of(2, 1, 0), Vector3D.of(1, 0, 0), Vector3D.of(0, -1, 0));
++    }
++
++    @Test
++    public void testExtrude_linePath_intersectingInfiniteLines_extrudingOnPlus() {
++        // arrange
++        Vector2D intersectionPt = Vector2D.of(1, 0);
++
++        LinePath path = LinePath.from(
++                Lines.fromPointAndAngle(intersectionPt, 0, TEST_PRECISION).reverseRayTo(intersectionPt),
++                Lines.fromPointAndAngle(intersectionPt, PlaneAngleRadians.PI_OVER_TWO, TEST_PRECISION)
++                    .rayFrom(intersectionPt));
++
++        EmbeddingPlane plane = Planes.fromPointAndPlaneVectors(Vector3D.of(0, 0, -1),
++                Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
++        Vector3D extrusionVector = Vector3D.of(0, 0, 2);
++
++        // act
++        List<PlaneConvexSubset> boundaries = Planes.extrude(path, plane, extrusionVector, TEST_PRECISION);
++
++        // assert
++        Assert.assertEquals(4, boundaries.size());
++
++        RegionBSPTree3D tree = RegionBSPTree3D.from(boundaries);
++        Assert.assertFalse(tree.isFull());
++        Assert.assertTrue(tree.isInfinite());
++
++        EuclideanTestUtils.assertRegionLocation(tree, RegionLocation.INSIDE,
++                Vector3D.of(0, 1, 0), Vector3D.of(-1, 1, 0), Vector3D.of(0, 2, 0), Vector3D.of(-1, 2, 0));
++
++        EuclideanTestUtils.assertRegionLocation(tree, RegionLocation.BOUNDARY,
++                Vector3D.of(-1, 0, 0), Vector3D.of(0, 0, 0), Vector3D.of(1, 0, 0),
++                Vector3D.of(1, 1, 0), Vector3D.of(1, 2, 0), Vector3D.of(-2, 2, 1),
++                Vector3D.of(-2, 2, -1));
++
++        EuclideanTestUtils.assertRegionLocation(tree, RegionLocation.OUTSIDE,
++                Vector3D.of(-1, -1, 0), Vector3D.of(1, -1, 0), Vector3D.of(3, 1, 0), Vector3D.of(3, -1, 0),
++                Vector3D.of(-2, -2, -2), Vector3D.of(-2, -2, 2));
++    }
++
++    @Test
++    public void testExtrude_linePath_intersectingInfiniteLines_extrudingOnMinus() {
++        // arrange
++        Vector2D intersectionPt = Vector2D.of(1, 0);
++
++        LinePath path = LinePath.from(
++                Lines.fromPointAndAngle(intersectionPt, 0, TEST_PRECISION).reverseRayTo(intersectionPt),
++                Lines.fromPointAndAngle(intersectionPt, PlaneAngleRadians.PI_OVER_TWO, TEST_PRECISION)
++                    .rayFrom(intersectionPt));
++
++        EmbeddingPlane plane = Planes.fromPointAndPlaneVectors(Vector3D.of(0, 0, 1),
++                Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
++        Vector3D extrusionVector = Vector3D.of(0, 0, -2);
++
++        // act
++        List<PlaneConvexSubset> boundaries = Planes.extrude(path, plane, extrusionVector, TEST_PRECISION);
++
++        // assert
++        Assert.assertEquals(4, boundaries.size());
++
++        RegionBSPTree3D tree = RegionBSPTree3D.from(boundaries);
++        Assert.assertFalse(tree.isFull());
++        Assert.assertTrue(tree.isInfinite());
++
++        EuclideanTestUtils.assertRegionLocation(tree, RegionLocation.INSIDE,
++                Vector3D.of(0, 1, 0), Vector3D.of(-1, 1, 0), Vector3D.of(0, 2, 0), Vector3D.of(-1, 2, 0));
++
++        EuclideanTestUtils.assertRegionLocation(tree, RegionLocation.BOUNDARY,
++                Vector3D.of(-1, 0, 0), Vector3D.of(0, 0, 0), Vector3D.of(1, 0, 0),
++                Vector3D.of(1, 1, 0), Vector3D.of(1, 2, 0), Vector3D.of(-2, 2, 1),
++                Vector3D.of(-2, 2, -1));
++
++        EuclideanTestUtils.assertRegionLocation(tree, RegionLocation.OUTSIDE,
++                Vector3D.of(-1, -1, 0), Vector3D.of(1, -1, 0), Vector3D.of(3, 1, 0), Vector3D.of(3, -1, 0),
++                Vector3D.of(-2, -2, -2), Vector3D.of(-2, -2, 2));
++    }
++
++    @Test
++    public void testExtrude_linePath_infiniteNonConvex() {
++        // arrange
++        LinePath path = LinePath.builder(TEST_PRECISION)
++                .append(Vector2D.of(1, -5))
++                .append(Vector2D.of(1, 1))
++                .append(Vector2D.of(0, 0))
++                .append(Vector2D.of(-1, 1))
++                .append(Vector2D.of(-1, -5))
++                .build();
++
++        EmbeddingPlane plane = Planes.fromPointAndPlaneVectors(Vector3D.of(0, 0, 1),
++                Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
++        Vector3D extrusionVector = Vector3D.of(0, 0, -2);
++
++        // act
++        List<PlaneConvexSubset> boundaries = Planes.extrude(path, plane, extrusionVector, TEST_PRECISION);
++
++        // assert
++        Assert.assertEquals(8, boundaries.size());
++
++        RegionBSPTree3D tree = RegionBSPTree3D.from(boundaries);
++        Assert.assertFalse(tree.isFull());
++        Assert.assertTrue(tree.isInfinite());
++
++        EuclideanTestUtils.assertRegionLocation(tree, RegionLocation.INSIDE,
++                Vector3D.of(0, -1, 0), Vector3D.of(0, -100, 0));
++
++        EuclideanTestUtils.assertRegionLocation(tree, RegionLocation.BOUNDARY,
++                Vector3D.of(-1, 1, 0), Vector3D.of(0, 0, 0), Vector3D.of(1, 1, 0),
++                Vector3D.of(-1, -100, 0), Vector3D.of(1, -100, 0),
++                Vector3D.of(0, -100, 1), Vector3D.of(0, -100, -1));
++
++        EuclideanTestUtils.assertRegionLocation(tree, RegionLocation.OUTSIDE,
++                Vector3D.of(-2, 0, 0), Vector3D.of(2, 0, 0), Vector3D.of(0, 0.5, 0),
++                Vector3D.of(0, -100, -2), Vector3D.of(0, -100, 2));
++    }
++
++    @Test
++    public void testExtrude_linePath_clockwiseWinding() {
++        // arrange
++        LinePath path = LinePath.builder(TEST_PRECISION)
++                .append(Vector2D.of(0, 1))
++                .append(Vector2D.of(1, 0))
++                .append(Vector2D.of(0, -1))
++                .append(Vector2D.of(-1, 0))
++                .close();
++
++        EmbeddingPlane plane = Planes.fromPointAndPlaneVectors(Vector3D.of(0, 0, -1),
++                Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
++        Vector3D extrusionVector = Vector3D.of(0, 0, 2);
++
++        // act
++        List<PlaneConvexSubset> boundaries = Planes.extrude(path, plane, extrusionVector, TEST_PRECISION);
++
++        // assert
++        RegionBSPTree3D resultTree = RegionBSPTree3D.from(boundaries);
++
++        Assert.assertTrue(resultTree.isInfinite());
++        EuclideanTestUtils.assertRegionLocation(resultTree, RegionLocation.INSIDE,
++                Vector3D.of(1, 1, 0), Vector3D.of(-1, 1, 0), Vector3D.of(-1, -1, 0), Vector3D.of(1, -1, 0));
++        EuclideanTestUtils.assertRegionLocation(resultTree, RegionLocation.OUTSIDE, Vector3D.ZERO);
++    }
++
++    @Test
++    public void testExtrude_region_empty() {
++        // arrange
++        RegionBSPTree2D tree = RegionBSPTree2D.empty();
++
++        EmbeddingPlane plane = Planes.fromPointAndPlaneVectors(Vector3D.of(0, 0, 1),
++                Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
++        Vector3D extrusionVector = Vector3D.of(0, 0, -2);
++
++        // act
++        List<PlaneConvexSubset> boundaries = Planes.extrude(tree, plane, extrusionVector, TEST_PRECISION);
++
++        // assert
++        Assert.assertEquals(0, boundaries.size());
++    }
++
++    @Test
++    public void testExtrude_region_full() {
++        // arrange
++        RegionBSPTree2D tree = RegionBSPTree2D.full();
++
++        EmbeddingPlane plane = Planes.fromPointAndPlaneVectors(Vector3D.of(0, 0, 1),
++                Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
++        Vector3D extrusionVector = Vector3D.of(0, 0, -2);
++
++        // act
++        List<PlaneConvexSubset> boundaries = Planes.extrude(tree, plane, extrusionVector, TEST_PRECISION);
++
++        // assert
++        Assert.assertEquals(2, boundaries.size());
++
++        Assert.assertTrue(boundaries.get(0).isFull());
++        Assert.assertTrue(boundaries.get(1).isFull());
++
++        RegionBSPTree3D resultTree = RegionBSPTree3D.from(boundaries);
++
++        EuclideanTestUtils.assertRegionLocation(resultTree, RegionLocation.INSIDE,
++                Vector3D.of(1, 1, 0), Vector3D.of(-1, 1, 0), Vector3D.of(-1, -1, 0), Vector3D.of(1, -1, 0));
++
++        EuclideanTestUtils.assertRegionLocation(resultTree, RegionLocation.BOUNDARY,
++                Vector3D.of(1, 1, 1), Vector3D.of(-1, 1, 1), Vector3D.of(-1, -1, 1), Vector3D.of(1, -1, 1),
++                Vector3D.of(1, 1, -1), Vector3D.of(-1, 1, -1), Vector3D.of(-1, -1, -1), Vector3D.of(1, -1, -1));
++
++        EuclideanTestUtils.assertRegionLocation(resultTree, RegionLocation.OUTSIDE,
++                Vector3D.of(1, 1, 2), Vector3D.of(-1, 1, 2), Vector3D.of(-1, -1, 2), Vector3D.of(1, -1, 2),
++                Vector3D.of(1, 1, -2), Vector3D.of(-1, 1, -2), Vector3D.of(-1, -1, -2), Vector3D.of(1, -1, -2));
++    }
++
++    @Test
++    public void testExtrude_region_disjointRegions() {
++        // arrange
++        RegionBSPTree2D tree = RegionBSPTree2D.empty();
++        tree.insert(Parallelogram.axisAligned(Vector2D.ZERO, Vector2D.of(1, 1), TEST_PRECISION));
++        tree.insert(Parallelogram.axisAligned(Vector2D.of(2, 2), Vector2D.of(3, 3), TEST_PRECISION));
++
++        EmbeddingPlane plane = Planes.fromPointAndPlaneVectors(Vector3D.of(0, 0, 1),
++                Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
++        Vector3D extrusionVector = Vector3D.of(0, 0, -2);
++
++        // act
++        List<PlaneConvexSubset> boundaries = Planes.extrude(tree, plane, extrusionVector, TEST_PRECISION);
++
++        // assert
++        Assert.assertEquals(12, boundaries.size());
++
++        RegionBSPTree3D resultTree = RegionBSPTree3D.from(boundaries);
++
++        Assert.assertEquals(4, resultTree.getSize(), TEST_EPS);
++        Assert.assertEquals(20, resultTree.getBoundarySize(), TEST_EPS);
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1.5, 1.5, 0), resultTree.getCentroid(), TEST_EPS);
++
++        EuclideanTestUtils.assertRegionLocation(resultTree, RegionLocation.INSIDE,
++                Vector3D.of(0.5, 0.5, 0), Vector3D.of(2.5, 2.5, 0));
++
++        EuclideanTestUtils.assertRegionLocation(resultTree, RegionLocation.BOUNDARY,
++                Vector3D.ZERO, Vector3D.of(1, 1, 0), Vector3D.of(2, 2, 0), Vector3D.of(3, 3, 0),
++                Vector3D.of(0.5, 0.5, -1), Vector3D.of(0.5, 0.5, 1), Vector3D.of(2.5, 2.5, -1),
++                Vector3D.of(2.5, 2.5, 1));
++
++        EuclideanTestUtils.assertRegionLocation(resultTree, RegionLocation.OUTSIDE,
++                Vector3D.of(-1, -1, 0), Vector3D.of(1.5, 1.5, 0), Vector3D.of(4, 4, 0),
++                Vector3D.of(0.5, 0.5, -2), Vector3D.of(0.5, 0.5, 2), Vector3D.of(2.5, 2.5, -2),
++                Vector3D.of(2.5, 2.5, 2));
++    }
++
++    @Test
++    public void testExtrude_region_starWithCutout() {
++        // arrange
++        // NOTE: this is pretty messed-up looking star :-)
++        RegionBSPTree2D tree = RegionBSPTree2D.empty();
++        tree.insert(LinePath.builder(TEST_PRECISION)
++                .append(Vector2D.of(0, 4))
++                .append(Vector2D.of(-1.5, 1))
++                .append(Vector2D.of(-4, 1))
++                .append(Vector2D.of(-2, -1))
++                .append(Vector2D.of(-3, -4))
++                .append(Vector2D.of(0, -2))
++                .append(Vector2D.of(3, -4))
++                .append(Vector2D.of(2, -1))
++                .append(Vector2D.of(4, 1))
++                .append(Vector2D.of(1.5, 1))
++                .close());
++        tree.insert(LinePath.builder(TEST_PRECISION)
++                .append(Vector2D.of(0, 1))
++                .append(Vector2D.of(1, 0))
++                .append(Vector2D.of(0, -1))
++                .append(Vector2D.of(-1, 0))
++                .close());
++
++        EmbeddingPlane plane = Planes.fromPointAndPlaneVectors(Vector3D.of(0, 0, -1),
++                Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
++        Vector3D extrusionVector = Vector3D.of(0, 0, 2);
++
++        // act
++        List<PlaneConvexSubset> boundaries = Planes.extrude(tree, plane, extrusionVector, TEST_PRECISION);
++
++        // assert
++        RegionBSPTree3D resultTree = RegionBSPTree3D.from(boundaries);
++
++        Assert.assertTrue(resultTree.isFinite());
++        EuclideanTestUtils.assertRegionLocation(resultTree, RegionLocation.OUTSIDE, resultTree.getCentroid());
++    }
++
++    @Test
++    public void testExtrude_invalidExtrusionVector() {
++        // arrange
++        List<Vector2D> vertices = new ArrayList<>();
++        LinePath path = LinePath.empty();
++        RegionBSPTree2D tree = RegionBSPTree2D.empty();
++
++        EmbeddingPlane plane = Planes.fromPointAndPlaneVectors(Vector3D.of(0, 0, 1),
++                Vector3D.Unit.PLUS_X, Vector3D.Unit.PLUS_Y, TEST_PRECISION);
++
++        Pattern errorPattern = Pattern.compile("^Extrusion vector produces regions of zero size.*");
++
++        // act/assert
++        GeometryTestUtils.assertThrows(() -> {
++            Planes.extrudeVertexLoop(vertices, plane, Vector3D.of(1e-16, 0, 0), TEST_PRECISION);
++        }, IllegalArgumentException.class, errorPattern);
++        GeometryTestUtils.assertThrows(() -> {
++            Planes.extrudeVertexLoop(vertices, plane, Vector3D.of(4, 1e-16, 0), TEST_PRECISION);
++        }, IllegalArgumentException.class, errorPattern);
++        GeometryTestUtils.assertThrows(() -> {
++            Planes.extrudeVertexLoop(vertices, plane, Vector3D.of(1e-16, 5, 0), TEST_PRECISION);
++        }, IllegalArgumentException.class, errorPattern);
++
++        GeometryTestUtils.assertThrows(() -> {
++            Planes.extrude(path, plane, Vector3D.of(1e-16, 0, 0), TEST_PRECISION);
++        }, IllegalArgumentException.class, errorPattern);
++        GeometryTestUtils.assertThrows(() -> {
++            Planes.extrude(path, plane, Vector3D.of(4, 1e-16, 0), TEST_PRECISION);
++        }, IllegalArgumentException.class, errorPattern);
++        GeometryTestUtils.assertThrows(() -> {
++            Planes.extrude(path, plane, Vector3D.of(1e-16, 5, 0), TEST_PRECISION);
++        }, IllegalArgumentException.class, errorPattern);
++
++        GeometryTestUtils.assertThrows(() -> {
++            Planes.extrude(tree, plane, Vector3D.of(1e-16, 0, 0), TEST_PRECISION);
++        }, IllegalArgumentException.class, errorPattern);
++        GeometryTestUtils.assertThrows(() -> {
++            Planes.extrude(tree, plane, Vector3D.of(4, 1e-16, 0), TEST_PRECISION);
++        }, IllegalArgumentException.class, errorPattern);
++        GeometryTestUtils.assertThrows(() -> {
++            Planes.extrude(tree, plane, Vector3D.of(1e-16, 5, 0), TEST_PRECISION);
++        }, IllegalArgumentException.class, errorPattern);
++    }
++
+     private static void checkPlane(Plane plane, Vector3D origin, Vector3D u, Vector3D v) {
+         u = u.normalize();
+         v = v.normalize();
+diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/RegionBSPTree3DTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/RegionBSPTree3DTest.java
+index 8ea9999..94d562c 100644
+--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/RegionBSPTree3DTest.java
++++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/RegionBSPTree3DTest.java
+@@ -31,10 +31,12 @@ import org.apache.commons.geometry.core.partitioning.bsp.RegionCutRule;
+ import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+ import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
+ import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
++import org.apache.commons.geometry.euclidean.threed.RegionBSPTree3D.PartitionedRegionBuilder3D;
+ import org.apache.commons.geometry.euclidean.threed.RegionBSPTree3D.RegionNode3D;
+ import org.apache.commons.geometry.euclidean.threed.line.Line3D;
+ import org.apache.commons.geometry.euclidean.threed.line.LinecastPoint3D;
+ import org.apache.commons.geometry.euclidean.threed.line.Lines3D;
++import org.apache.commons.geometry.euclidean.threed.mesh.TriangleMesh;
+ import org.apache.commons.geometry.euclidean.threed.shape.Parallelepiped;
+ import org.apache.commons.geometry.euclidean.twod.path.LinePath;
+ import org.apache.commons.numbers.angle.PlaneAngleRadians;
+@@ -115,6 +117,159 @@ public class RegionBSPTree3DTest {
+     }
+ 
+     @Test
++    public void testPartitionedRegionBuilder_halfSpace() {
++        // act
++        RegionBSPTree3D tree = RegionBSPTree3D.partitionedRegionBuilder()
++                .insertPartition(
++                    Planes.fromPointAndNormal(Vector3D.ZERO, Vector3D.Unit.PLUS_Z, TEST_PRECISION))
++                .insertBoundary(
++                        Planes.fromPointAndNormal(Vector3D.ZERO, Vector3D.Unit.MINUS_Z, TEST_PRECISION).span())
++                .build();
++
++        // assert
++        Assert.assertFalse(tree.isFull());
++        Assert.assertTrue(tree.isInfinite());
++
++        EuclideanTestUtils.assertRegionLocation(tree, RegionLocation.INSIDE, Vector3D.of(0, 0, 1));
++        EuclideanTestUtils.assertRegionLocation(tree, RegionLocation.BOUNDARY, Vector3D.ZERO);
++        EuclideanTestUtils.assertRegionLocation(tree, RegionLocation.OUTSIDE, Vector3D.of(0, 0, -1));
++    }
++
++    @Test
++    public void testPartitionedRegionBuilder_cube() {
++        // arrange
++        Parallelepiped cube = Parallelepiped.unitCube(TEST_PRECISION);
++        List<PlaneConvexSubset> boundaries = cube.getBoundaries();
++
++        Vector3D lowerBound = Vector3D.of(-2, -2, -2);
++
++        int maxUpper = 5;
++        int maxLevel = 4;
++
++        // act/assert
++        Bounds3D bounds;
++        for (int u = 0; u <= maxUpper; ++u) {
++            for (int level = 0; level <= maxLevel; ++level) {
++                bounds = Bounds3D.from(lowerBound, Vector3D.of(u, u, u));
++
++                checkFinitePartitionedRegion(bounds, level, cube);
++                checkFinitePartitionedRegion(bounds, level, boundaries);
++            }
++        }
++    }
++
++    @Test
++    public void testPartitionedRegionBuilder_nonConvex() {
++        // arrange
++        RegionBSPTree3D src = Parallelepiped.unitCube(TEST_PRECISION).toTree();
++        src.union(Parallelepiped.axisAligned(Vector3D.ZERO, Vector3D.of(1, 1, 1), TEST_PRECISION).toTree());
++
++        List<PlaneConvexSubset> boundaries = src.getBoundaries();
++
++        Vector3D lowerBound = Vector3D.of(-2, -2, -2);
++
++        int maxUpper = 5;
++        int maxLevel = 4;
++
++        // act/assert
++        Bounds3D bounds;
++        for (int u = 0; u <= maxUpper; ++u) {
++            for (int level = 0; level <= maxLevel; ++level) {
++                bounds = Bounds3D.from(lowerBound, Vector3D.of(u, u, u));
++
++                checkFinitePartitionedRegion(bounds, level, src);
++                checkFinitePartitionedRegion(bounds, level, boundaries);
++            }
++        }
++    }
++
++    /** Check that a partitioned BSP tree behaves the same as a non-partitioned tree when
++     * constructed with the given boundary source.
++     * @param bounds
++     * @param level
++     * @param src
++     */
++    private void checkFinitePartitionedRegion(Bounds3D bounds, int level, BoundarySource3D src) {
++        // arrange
++        String msg = "Partitioned region check failed with bounds= " + bounds + " and level= " + level;
++
++        RegionBSPTree3D standard = RegionBSPTree3D.from(src.boundaryStream().collect(Collectors.toList()));
++
++        // act
++        RegionBSPTree3D partitioned = RegionBSPTree3D.partitionedRegionBuilder()
++                .insertAxisAlignedGrid(bounds, level, TEST_PRECISION)
++                .insertBoundaries(src)
++                .build();
++
++        // assert
++        Assert.assertEquals(msg, standard.getSize(), partitioned.getSize(), TEST_EPS);
++        Assert.assertEquals(msg, standard.getBoundarySize(), partitioned.getBoundarySize(), TEST_EPS);
++        EuclideanTestUtils.assertCoordinatesEqual(standard.getCentroid(), partitioned.getCentroid(), TEST_EPS);
++
++        RegionBSPTree3D diff = RegionBSPTree3D.empty();
++        diff.difference(partitioned, standard);
++        Assert.assertTrue(msg, diff.isEmpty());
++    }
++
++    /** Check that a partitioned BSP tree behaves the same as a non-partitioned tree when
++     * constructed with the given boundaries.
++     * @param bounds
++     * @param level
++     * @param boundaries
++     */
++    private void checkFinitePartitionedRegion(Bounds3D bounds, int level,
++            List<? extends PlaneConvexSubset> boundaries) {
++        // arrange
++        String msg = "Partitioned region check failed with bounds= " + bounds + " and level= " + level;
++
++        RegionBSPTree3D standard = RegionBSPTree3D.from(boundaries);
++
++        // act
++        RegionBSPTree3D partitioned = RegionBSPTree3D.partitionedRegionBuilder()
++                .insertAxisAlignedGrid(bounds, level, TEST_PRECISION)
++                .insertBoundaries(boundaries)
++                .build();
++
++        // assert
++        Assert.assertEquals(msg, standard.getSize(), partitioned.getSize(), TEST_EPS);
++        Assert.assertEquals(msg, standard.getBoundarySize(), partitioned.getBoundarySize(), TEST_EPS);
++        EuclideanTestUtils.assertCoordinatesEqual(standard.getCentroid(), partitioned.getCentroid(), TEST_EPS);
++
++        RegionBSPTree3D diff = RegionBSPTree3D.empty();
++        diff.difference(partitioned, standard);
++        Assert.assertTrue(msg, diff.isEmpty());
++    }
++
++    @Test
++    public void testPartitionedRegionBuilder_insertPartitionAfterBoundary() {
++        // arrange
++        PartitionedRegionBuilder3D builder = RegionBSPTree3D.partitionedRegionBuilder();
++        builder.insertBoundary(Planes.triangleFromVertices(
++                Vector3D.ZERO, Vector3D.of(1, 0, 0), Vector3D.of(0, 1, 0), TEST_PRECISION));
++
++        Plane partition = Planes.fromNormal(Vector3D.Unit.PLUS_Z, TEST_PRECISION);
++
++        String msg = "Cannot insert partitions after boundaries have been inserted";
++
++        // act/assert
++        GeometryTestUtils.assertThrows(() -> {
++            builder.insertPartition(partition);
++        }, IllegalStateException.class, msg);
++
++        GeometryTestUtils.assertThrows(() -> {
++            builder.insertPartition(partition.span());
++        }, IllegalStateException.class, msg);
++
++        GeometryTestUtils.assertThrows(() -> {
++            builder.insertAxisAlignedPartitions(Vector3D.ZERO, TEST_PRECISION);
++        }, IllegalStateException.class, msg);
++
++        GeometryTestUtils.assertThrows(() -> {
++            builder.insertAxisAlignedGrid(Bounds3D.from(Vector3D.ZERO, Vector3D.of(1, 1, 1)), 1, TEST_PRECISION);
++        }, IllegalStateException.class, msg);
++    }
++
++    @Test
+     public void testCopy() {
+         // arrange
+         RegionBSPTree3D tree = new RegionBSPTree3D(true);
+@@ -220,6 +375,68 @@ public class RegionBSPTree3DTest {
+     }
+ 
+     @Test
++    public void testToTriangleMesh() {
++        // arrange
++        RegionBSPTree3D tree = createRect(Vector3D.ZERO, Vector3D.of(1, 1, 1));
++
++        // act
++        TriangleMesh mesh = tree.toTriangleMesh(TEST_PRECISION);
++
++        // assert
++        Assert.assertEquals(8, mesh.getVertexCount());
++        Assert.assertEquals(12, mesh.getFaceCount());
++
++        Bounds3D bounds = mesh.getBounds();
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.ZERO, bounds.getMin(), TEST_EPS);
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 1, 1), bounds.getMax(), TEST_EPS);
++
++        RegionBSPTree3D otherTree = mesh.toTree();
++        Assert.assertEquals(1, otherTree.getSize(), TEST_EPS);
++        Assert.assertEquals(6, otherTree.getBoundarySize(), TEST_EPS);
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0.5, 0.5, 0.5), otherTree.getCentroid(), TEST_EPS);
++    }
++
++    @Test
++    public void testToTriangleMesh_empty() {
++        // arrange
++        RegionBSPTree3D tree = RegionBSPTree3D.empty();
++
++        // act
++        TriangleMesh mesh = tree.toTriangleMesh(TEST_PRECISION);
++
++        // assert
++        // no boundaries
++        Assert.assertEquals(0, mesh.getVertexCount());
++        Assert.assertEquals(0, mesh.getFaceCount());
++    }
++
++    @Test
++    public void testToTriangleMesh_full() {
++        // arrange
++        RegionBSPTree3D tree = RegionBSPTree3D.full();
++
++        // act
++        TriangleMesh mesh = tree.toTriangleMesh(TEST_PRECISION);
++
++        // assert
++        // no boundaries
++        Assert.assertEquals(0, mesh.getVertexCount());
++        Assert.assertEquals(0, mesh.getFaceCount());
++    }
++
++    @Test
++    public void testToTriangleMesh_infiniteBoundary() {
++        // arrange
++        RegionBSPTree3D tree = RegionBSPTree3D.empty();
++        tree.getRoot().insertCut(Planes.fromNormal(Vector3D.Unit.PLUS_Z, TEST_PRECISION));
++
++        // act/assert
++        GeometryTestUtils.assertThrows(() -> {
++            tree.toTriangleMesh(TEST_PRECISION);
++        }, IllegalStateException.class);
++    }
++
++    @Test
+     public void testGetBounds_hasBounds() {
+         // arrange
+         RegionBSPTree3D tree = createRect(Vector3D.ZERO, Vector3D.of(1, 1, 1));
+diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/mesh/SimpleTriangleMeshTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/mesh/SimpleTriangleMeshTest.java
+new file mode 100644
+index 0000000..321adfa
+--- /dev/null
++++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/mesh/SimpleTriangleMeshTest.java
+@@ -0,0 +1,634 @@
++/*
++ * 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.mesh;
++
++import java.util.ArrayList;
++import java.util.Arrays;
++import java.util.Collections;
++import java.util.List;
++import java.util.regex.Pattern;
++import java.util.stream.Collectors;
++
++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.AffineTransformMatrix3D;
++import org.apache.commons.geometry.euclidean.threed.BoundarySource3D;
++import org.apache.commons.geometry.euclidean.threed.Bounds3D;
++import org.apache.commons.geometry.euclidean.threed.Planes;
++import org.apache.commons.geometry.euclidean.threed.RegionBSPTree3D;
++import org.apache.commons.geometry.euclidean.threed.Triangle3D;
++import org.apache.commons.geometry.euclidean.threed.Vector3D;
++import org.apache.commons.geometry.euclidean.threed.shape.Parallelepiped;
++import org.junit.Assert;
++import org.junit.Test;
++
++public class SimpleTriangleMeshTest {
++
++    private static final double TEST_EPS = 1e-10;
++
++    private static final DoublePrecisionContext TEST_PRECISION =
++            new EpsilonDoublePrecisionContext(TEST_EPS);
++
++    @Test
++    public void testFrom_verticesAndFaces() {
++        // arrange
++        Vector3D[] vertices = {
++            Vector3D.ZERO,
++            Vector3D.of(1, 1, 0),
++            Vector3D.of(1, 1, 1),
++            Vector3D.of(0, 0, 1)
++        };
++
++        int[][] faceIndices = new int[][] {
++            {0, 1, 2},
++            {0, 2, 3}
++        };
++
++        // act
++        SimpleTriangleMesh mesh = SimpleTriangleMesh.from(vertices, faceIndices, TEST_PRECISION);
++
++        // assert
++        Assert.assertEquals(4, mesh.getVertexCount());
++        Assert.assertEquals(Arrays.asList(vertices), mesh.getVertices());
++
++        Assert.assertEquals(2, mesh.getFaceCount());
++
++        List<TriangleMesh.Face> faces = mesh.getFaces();
++        Assert.assertEquals(2, faces.size());
++
++        TriangleMesh.Face f1 = faces.get(0);
++        Assert.assertEquals(0, f1.getIndex());
++        Assert.assertArrayEquals(new int[] {0, 1, 2}, f1.getVertexIndices());
++        Assert.assertSame(vertices[0], f1.getPoint1());
++        Assert.assertSame(vertices[1], f1.getPoint2());
++        Assert.assertSame(vertices[2], f1.getPoint3());
++        Assert.assertEquals(Arrays.asList(vertices[0], vertices[1], vertices[2]), f1.getVertices());
++        Assert.assertTrue(f1.definesPolygon());
++
++        Triangle3D t1 = f1.getPolygon();
++        Assert.assertEquals(Arrays.asList(vertices[0], vertices[1], vertices[2]), t1.getVertices());
++
++        TriangleMesh.Face f2 = faces.get(1);
++        Assert.assertEquals(1, f2.getIndex());
++        Assert.assertArrayEquals(new int[] {0, 2, 3}, f2.getVertexIndices());
++        Assert.assertSame(vertices[0], f2.getPoint1());
++        Assert.assertSame(vertices[2], f2.getPoint2());
++        Assert.assertSame(vertices[3], f2.getPoint3());
++        Assert.assertEquals(Arrays.asList(vertices[0], vertices[2], vertices[3]), f2.getVertices());
++        Assert.assertTrue(f2.definesPolygon());
++
++        Triangle3D t2 = f2.getPolygon();
++        Assert.assertEquals(Arrays.asList(vertices[0], vertices[2], vertices[3]), t2.getVertices());
++
++        Bounds3D bounds = mesh.getBounds();
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.ZERO, bounds.getMin(), TEST_EPS);
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 1, 1), bounds.getMax(), TEST_EPS);
++
++        Assert.assertSame(TEST_PRECISION, mesh.getPrecision());
++    }
++
++    @Test
++    public void testFrom_verticesAndFaces_empty() {
++        // arrange
++        Vector3D[] vertices = {};
++
++        int[][] faceIndices = new int[][] {};
++
++        // act
++        SimpleTriangleMesh mesh = SimpleTriangleMesh.from(vertices, faceIndices, TEST_PRECISION);
++
++        // assert
++        Assert.assertEquals(0, mesh.getVertexCount());
++        Assert.assertEquals(0, mesh.getVertices().size());
++
++        Assert.assertEquals(0, mesh.getFaceCount());
++        Assert.assertEquals(0, mesh.getFaces().size());
++
++        Assert.assertNull(mesh.getBounds());
++
++        Assert.assertTrue(mesh.toTree().isEmpty());
++    }
++
++    @Test
++    public void testFrom_boundarySource() {
++        // arrange
++        BoundarySource3D src = Parallelepiped.axisAligned(Vector3D.ZERO, Vector3D.of(1, 1, 1), TEST_PRECISION);
++
++        // act
++        SimpleTriangleMesh mesh = SimpleTriangleMesh.from(src, TEST_PRECISION);
++
++        // assert
++        Assert.assertEquals(8, mesh.getVertexCount());
++
++        Vector3D p1 = Vector3D.of(0, 0, 0);
++        Vector3D p2 = Vector3D.of(0, 0, 1);
++        Vector3D p3 = Vector3D.of(0, 1, 0);
++        Vector3D p4 = Vector3D.of(0, 1, 1);
++
++        Vector3D p5 = Vector3D.of(1, 0, 0);
++        Vector3D p6 = Vector3D.of(1, 0, 1);
++        Vector3D p7 = Vector3D.of(1, 1, 0);
++        Vector3D p8 = Vector3D.of(1, 1, 1);
++
++        List<Vector3D> vertices = mesh.getVertices();
++        Assert.assertEquals(8, vertices.size());
++
++        Assert.assertTrue(vertices.contains(p1));
++        Assert.assertTrue(vertices.contains(p2));
++        Assert.assertTrue(vertices.contains(p3));
++        Assert.assertTrue(vertices.contains(p4));
++        Assert.assertTrue(vertices.contains(p5));
++        Assert.assertTrue(vertices.contains(p6));
++        Assert.assertTrue(vertices.contains(p7));
++        Assert.assertTrue(vertices.contains(p8));
++
++        Assert.assertEquals(12, mesh.getFaceCount());
++
++        RegionBSPTree3D tree = mesh.toTree();
++
++        Assert.assertEquals(1, tree.getSize(), TEST_EPS);
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0.5, 0.5, 0.5), tree.getCentroid(), TEST_EPS);
++
++        Assert.assertSame(TEST_PRECISION, mesh.getPrecision());
++    }
++
++    @Test
++    public void testFrom_boundarySource_empty() {
++        // act
++        SimpleTriangleMesh mesh = SimpleTriangleMesh.from(BoundarySource3D.from(Collections.emptyList()),
++                TEST_PRECISION);
++
++        // assert
++        Assert.assertEquals(0, mesh.getVertexCount());
++        Assert.assertEquals(0, mesh.getVertices().size());
++
++        Assert.assertEquals(0, mesh.getFaceCount());
++        Assert.assertEquals(0, mesh.getFaces().size());
++
++        Assert.assertNull(mesh.getBounds());
++
++        Assert.assertTrue(mesh.toTree().isEmpty());
++    }
++
++    @Test
++    public void testVertices_iterable() {
++        // arrange
++        List<Vector3D> vertices = Arrays.asList(
++            Vector3D.ZERO,
++            Vector3D.of(1, 0, 0),
++            Vector3D.of(0, 1, 0)
++        );
++
++        List<int[]> faceIndices = Arrays.asList(
++            new int[] {0, 1, 2}
++        );
++
++        SimpleTriangleMesh mesh = SimpleTriangleMesh.from(vertices, faceIndices, TEST_PRECISION);
++
++        // act
++        List<Vector3D> result = new ArrayList<>();
++        mesh.vertices().forEach(result::add);
++
++        // assert
++        Assert.assertEquals(vertices, result);
++    }
++
++    @Test
++    public void testFaces_iterable() {
++        // arrange
++        List<Vector3D> vertices = Arrays.asList(
++            Vector3D.ZERO,
++            Vector3D.of(1, 0, 0),
++            Vector3D.of(0, 1, 0),
++            Vector3D.of(0, 0, 1)
++        );
++
++        List<int[]> faceIndices = Arrays.asList(
++            new int[] {0, 1, 2},
++            new int[] {0, 2, 3}
++        );
++
++        SimpleTriangleMesh mesh = SimpleTriangleMesh.from(vertices, faceIndices, TEST_PRECISION);
++
++        // act
++        List<TriangleMesh.Face> result = new ArrayList<>();
++        mesh.faces().forEach(result::add);
++
++        // assert
++        Assert.assertEquals(2, result.size());
++
++        TriangleMesh.Face f1 = result.get(0);
++        Assert.assertEquals(0, f1.getIndex());
++        Assert.assertArrayEquals(new int[] {0, 1, 2}, f1.getVertexIndices());
++        Assert.assertSame(vertices.get(0), f1.getPoint1());
++        Assert.assertSame(vertices.get(1), f1.getPoint2());
++        Assert.assertSame(vertices.get(2), f1.getPoint3());
++        Assert.assertEquals(Arrays.asList(vertices.get(0), vertices.get(1), vertices.get(2)), f1.getVertices());
++        Assert.assertTrue(f1.definesPolygon());
++
++        TriangleMesh.Face f2 = result.get(1);
++        Assert.assertEquals(1, f2.getIndex());
++        Assert.assertArrayEquals(new int[] {0, 2, 3}, f2.getVertexIndices());
++        Assert.assertSame(vertices.get(0), f2.getPoint1());
++        Assert.assertSame(vertices.get(2), f2.getPoint2());
++        Assert.assertSame(vertices.get(3), f2.getPoint3());
++        Assert.assertEquals(Arrays.asList(vertices.get(0), vertices.get(2), vertices.get(3)), f2.getVertices());
++        Assert.assertTrue(f2.definesPolygon());
++    }
++
++    @Test
++    public void testTriangleStream() {
++        // arrange
++        List<Vector3D> vertices = Arrays.asList(
++            Vector3D.ZERO,
++            Vector3D.of(1, 0, 0),
++            Vector3D.of(0, 1, 0),
++            Vector3D.of(0, 0, 1)
++        );
++
++        List<int[]> faceIndices = Arrays.asList(
++            new int[] {0, 1, 2},
++            new int[] {0, 2, 3}
++        );
++
++        SimpleTriangleMesh mesh = SimpleTriangleMesh.from(vertices, faceIndices, TEST_PRECISION);
++
++        // act
++        List<Triangle3D> tris = mesh.triangleStream().collect(Collectors.toList());
++
++        // assert
++        Assert.assertEquals(2, tris.size());
++
++        Triangle3D t1 = tris.get(0);
++        Assert.assertSame(vertices.get(0), t1.getPoint1());
++        Assert.assertSame(vertices.get(1), t1.getPoint2());
++        Assert.assertSame(vertices.get(2), t1.getPoint3());
++
++        Triangle3D t2 = tris.get(1);
++        Assert.assertSame(vertices.get(0), t2.getPoint1());
++        Assert.assertSame(vertices.get(2), t2.getPoint2());
++        Assert.assertSame(vertices.get(3), t2.getPoint3());
++    }
++
++    @Test
++    public void testToTriangleMesh() {
++        // arrange
++        DoublePrecisionContext precision1 = new EpsilonDoublePrecisionContext(1e-1);
++        DoublePrecisionContext precision2 = new EpsilonDoublePrecisionContext(1e-2);
++        DoublePrecisionContext precision3 = new EpsilonDoublePrecisionContext(1e-1);
++
++        SimpleTriangleMesh mesh = SimpleTriangleMesh.from(Parallelepiped.unitCube(TEST_PRECISION), precision1);
++
++        // act/assert
++        Assert.assertSame(mesh, mesh.toTriangleMesh(precision1));
++
++        SimpleTriangleMesh other = mesh.toTriangleMesh(precision2);
++        Assert.assertSame(precision2, other.getPrecision());
++        Assert.assertEquals(mesh.getVertices(), other.getVertices());
++        Assert.assertEquals(12, other.getFaceCount());
++        for (int i = 0; i < 12; ++i) {
++            Assert.assertArrayEquals(mesh.getFace(i).getVertexIndices(), other.getFace(i).getVertexIndices());
++        }
++
++        Assert.assertSame(mesh, mesh.toTriangleMesh(precision3));
++    }
++
++    @Test
++    public void testFace_doesNotDefineTriangle() {
++        // arrange
++        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-1);
++        Vector3D[] vertices = new Vector3D[] {
++            Vector3D.ZERO,
++            Vector3D.of(0.01, -0.01, 0.01),
++            Vector3D.of(0.01, 0.01, 0.01),
++            Vector3D.of(1, 0, 0),
++            Vector3D.of(2, 0.01, 0)
++        };
++        int[][] faces = new int[][] {
++            {0, 1, 2},
++            {0, 3, 4}
++        };
++        SimpleTriangleMesh mesh = SimpleTriangleMesh.from(vertices, faces, precision);
++
++        // act/assert
++        Pattern msgPattern = Pattern.compile("^Points do not define a plane: .*");
++
++        Assert.assertFalse(mesh.getFace(0).definesPolygon());
++        GeometryTestUtils.assertThrows(() -> {
++            mesh.getFace(0).getPolygon();
++        }, IllegalArgumentException.class, msgPattern);
++
++        Assert.assertFalse(mesh.getFace(1).definesPolygon());
++        GeometryTestUtils.assertThrows(() -> {
++            mesh.getFace(1).getPolygon();
++        }, IllegalArgumentException.class, msgPattern);
++    }
++
++    @Test
++    public void testToTree_smallNumberOfFaces() {
++        // arrange
++        SimpleTriangleMesh mesh = SimpleTriangleMesh.from(Parallelepiped.unitCube(TEST_PRECISION), TEST_PRECISION);
++
++        // act
++        RegionBSPTree3D tree = mesh.toTree();
++
++        // assert
++        Assert.assertFalse(tree.isFull());
++        Assert.assertFalse(tree.isEmpty());
++        Assert.assertFalse(tree.isInfinite());
++        Assert.assertTrue(tree.isFinite());
++
++        Assert.assertEquals(1, tree.getSize(), 1);
++        Assert.assertEquals(6, tree.getBoundarySize(), 1);
++
++        Assert.assertEquals(6, tree.getRoot().height());
++    }
++
++    @Test
++    public void testToTree_largeNumberOfFaces() {
++        // arrange
++        // TODO
++    }
++
++    @Test
++    public void testTransform() {
++        // arrange
++        SimpleTriangleMesh mesh = SimpleTriangleMesh.from(Parallelepiped.unitCube(TEST_PRECISION), TEST_PRECISION);
++
++        AffineTransformMatrix3D t = AffineTransformMatrix3D.createScale(1, 2, 3)
++                .translate(0.5, 1, 1.5);
++
++        // act
++        SimpleTriangleMesh result = mesh.transform(t);
++
++        // assert
++        Assert.assertNotSame(mesh, result);
++
++        Assert.assertEquals(8, result.getVertexCount());
++        Assert.assertEquals(12, result.getFaceCount());
++
++        Bounds3D resultBounds = result.getBounds();
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.ZERO, resultBounds.getMin(), TEST_EPS);
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 2, 3), resultBounds.getMax(), TEST_EPS);
++
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0.5, 1, 1.5), result.toTree().getCentroid(), TEST_EPS);
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.ZERO, mesh.toTree().getCentroid(), TEST_EPS);
++    }
++
++    @Test
++    public void testTransform_empty() {
++        // arrange
++        SimpleTriangleMesh mesh = SimpleTriangleMesh.builder(TEST_PRECISION).build();
++
++        AffineTransformMatrix3D t = AffineTransformMatrix3D.createScale(1, 2, 3);
++
++        // act
++        SimpleTriangleMesh result = mesh.transform(t);
++
++        // assert
++        Assert.assertEquals(0, result.getVertexCount());
++        Assert.assertEquals(0, result.getFaceCount());
++
++        Assert.assertNull(result.getBounds());
++    }
++
++    @Test
++    public void testToString() {
++        // arrange
++        Triangle3D tri = Planes.triangleFromVertices(Vector3D.ZERO, Vector3D.of(1, 0, 0), Vector3D.of(0, 1, 0),
++                TEST_PRECISION);
++        SimpleTriangleMesh mesh = SimpleTriangleMesh.from(BoundarySource3D.from(tri), TEST_PRECISION);
++
++        // act
++        String str = mesh.toString();
++
++        // assert
++        GeometryTestUtils.assertContains("SimpleTriangleMesh[vertexCount= 3, faceCount= 1, bounds= Bounds3D[", str);
++    }
++
++    @Test
++    public void testFaceToString() {
++        // arrange
++        Triangle3D tri = Planes.triangleFromVertices(Vector3D.ZERO, Vector3D.of(1, 0, 0), Vector3D.of(0, 1, 0),
++                TEST_PRECISION);
++        SimpleTriangleMesh mesh = SimpleTriangleMesh.from(BoundarySource3D.from(tri), TEST_PRECISION);
++
++        // act
++        String str = mesh.getFace(0).toString();
++
++        // assert
++        GeometryTestUtils.assertContains("SimpleTriangleFace[index= 0, vertexIndices= [0, 1, 2], vertices= [(0", str);
++    }
++
++    @Test
++    public void testBuilder_mixedBuildMethods() {
++        // arrange
++        DoublePrecisionContext precision = new EpsilonDoublePrecisionContext(1e-1);
++        SimpleTriangleMesh.Builder builder = SimpleTriangleMesh.builder(precision);
++
++        // act
++        builder.addVertices(Arrays.asList(Vector3D.ZERO, Vector3D.of(1, 0, 0)));
++        builder.useVertex(Vector3D.of(0, 0, 1));
++        builder.addVertex(Vector3D.of(0, 1, 0));
++        builder.useVertex(Vector3D.of(1, 1, 1));
++
++        builder.addFace(0, 2, 1);
++        builder.addFaceUsingVertices(Vector3D.of(0.5, 0, 0), Vector3D.of(1.01, 0, 0), Vector3D.of(1, 1, 0.95));
++
++        SimpleTriangleMesh mesh = builder.build();
++
++        // assert
++        Assert.assertEquals(6, mesh.getVertexCount());
++        Assert.assertEquals(2, mesh.getFaceCount());
++
++        List<TriangleMesh.Face> faces = mesh.getFaces();
++        Assert.assertEquals(2, faces.size());
++
++        Assert.assertArrayEquals(new int[] {0, 2, 1},  faces.get(0).getVertexIndices());
++        Assert.assertArrayEquals(new int[] {5, 1, 4},  faces.get(1).getVertexIndices());
++    }
++
++    @Test
++    public void testBuilder_addVerticesAndFaces() {
++        // act
++        SimpleTriangleMesh mesh = SimpleTriangleMesh.builder(TEST_PRECISION)
++            .addVertices(new Vector3D[] {
++                Vector3D.ZERO,
++                Vector3D.of(1, 1, 0),
++                Vector3D.of(1, 1, 1),
++                Vector3D.of(0, 0, 1)
++            })
++            .addFaces(new int[][] {
++                {0, 1, 2},
++                {0, 2, 3}
++            })
++            .build();
++
++        // assert
++        Assert.assertEquals(4, mesh.getVertexCount());
++        Assert.assertEquals(2, mesh.getFaceCount());
++    }
++
++    @Test
++    public void testBuilder_invalidFaceIndices() {
++        // arrange
++        SimpleTriangleMesh.Builder builder = SimpleTriangleMesh.builder(TEST_PRECISION);
++        builder.useVertex(Vector3D.ZERO);
++        builder.useVertex(Vector3D.of(1, 0, 0));
++        builder.useVertex(Vector3D.of(0, 1, 0));
++
++        String msgBase = "Invalid vertex index: ";
++
++        // act/assert
++        GeometryTestUtils.assertThrows(() -> {
++            builder.addFace(-1, 1, 2);
++        }, IllegalArgumentException.class, msgBase + "-1");
++
++        GeometryTestUtils.assertThrows(() -> {
++            builder.addFace(0, 3, 2);
++        }, IllegalArgumentException.class, msgBase + "3");
++
++        GeometryTestUtils.assertThrows(() -> {
++            builder.addFace(0, 1, 4);
++        }, IllegalArgumentException.class, msgBase + "4");
++
++        GeometryTestUtils.assertThrows(() -> {
++            builder.addFaces(new int[][] {{-1, 1, 2}});
++        }, IllegalArgumentException.class, msgBase + "-1");
++
++        GeometryTestUtils.assertThrows(() -> {
++            builder.addFaces(new int[][] {{0, 3, 2}});
++        }, IllegalArgumentException.class, msgBase + "3");
++
++        GeometryTestUtils.assertThrows(() -> {
++            builder.addFaces(new int[][] {{0, 1, 4}});
++        }, IllegalArgumentException.class, msgBase + "4");
++    }
++
++    @Test
++    public void testBuilder_invalidFaceIndexCount() {
++        // arrange
++        SimpleTriangleMesh.Builder builder = SimpleTriangleMesh.builder(TEST_PRECISION);
++        builder.useVertex(Vector3D.ZERO);
++        builder.useVertex(Vector3D.of(1, 0, 0));
++        builder.useVertex(Vector3D.of(0, 1, 0));
++        builder.useVertex(Vector3D.of(0, 0, 1));
++
++        String msgBase = "Face must contain 3 vertex indices; found ";
++
++        // act/assert
++        GeometryTestUtils.assertThrows(() -> {
++            builder.addFaces(new int[][] {{}});
++        }, IllegalArgumentException.class, msgBase + "0");
++
++        GeometryTestUtils.assertThrows(() -> {
++            builder.addFaces(new int[][] {{0}});
++        }, IllegalArgumentException.class, msgBase + "1");
++
++        GeometryTestUtils.assertThrows(() -> {
++            builder.addFaces(new int[][] {{0, 1}});
++        }, IllegalArgumentException.class, msgBase + "2");
++
++        GeometryTestUtils.assertThrows(() -> {
++            builder.addFaces(new int[][] {{0, 1, 2, 3}});
++        }, IllegalArgumentException.class, msgBase + "4");
++    }
++
++    @Test
++    public void testBuilder_cannotModifyOnceBuilt() {
++        // arrange
++        SimpleTriangleMesh.Builder builder = SimpleTriangleMesh.builder(TEST_PRECISION)
++            .addVertices(new Vector3D[] {
++                Vector3D.ZERO,
++                Vector3D.of(1, 1, 0),
++                Vector3D.of(1, 1, 1),
++            })
++            .addFaces(new int[][] {
++                {0, 1, 2}
++            });
++        builder.build();
++
++        String msg = "Builder instance cannot be modified: mesh construction is complete";
++
++        // act/assert
++        GeometryTestUtils.assertThrows(() -> {
++            builder.useVertex(Vector3D.ZERO);
++        }, IllegalStateException.class, msg);
++
++        GeometryTestUtils.assertThrows(() -> {
++            builder.addVertex(Vector3D.ZERO);
++        }, IllegalStateException.class, msg);
++
++        GeometryTestUtils.assertThrows(() -> {
++            builder.addVertices(Arrays.asList(Vector3D.ZERO));
++        }, IllegalStateException.class, msg);
++
++        GeometryTestUtils.assertThrows(() -> {
++            builder.addVertices(new Vector3D[] {Vector3D.ZERO});
++        }, IllegalStateException.class, msg);
++
++        GeometryTestUtils.assertThrows(() -> {
++            builder.addFaceUsingVertices(Vector3D.ZERO, Vector3D.of(1, 0, 0), Vector3D.of(0, 1, 0));
++        }, IllegalStateException.class, msg);
++
++        GeometryTestUtils.assertThrows(() -> {
++            builder.addFace(0, 1, 2);
++        }, IllegalStateException.class, msg);
++
++        GeometryTestUtils.assertThrows(() -> {
++            builder.addFaces(Arrays.asList(new int[] {0, 1, 2}));
++        }, IllegalStateException.class, msg);
++
++        GeometryTestUtils.assertThrows(() -> {
++            builder.addFaces(new int[][] {{0, 1, 2}});
++        }, IllegalStateException.class, msg);
++    }
++
++    @Test
++    public void testBuilder_addFaceAndVertices_vs_addFaceUsingVertices() {
++        // arrange
++        SimpleTriangleMesh.Builder builder = SimpleTriangleMesh.builder(TEST_PRECISION);
++        Vector3D p1 = Vector3D.ZERO;
++        Vector3D p2 = Vector3D.of(1, 0, 0);
++        Vector3D p3 = Vector3D.of(0, 1, 0);
++
++        // act
++        builder.addFaceUsingVertices(p1, p2, p3);
++        builder.addFaceAndVertices(p1, p2, p3);
++        builder.addFaceUsingVertices(p1, p2, p3);
++
++        // assert
++        Assert.assertEquals(6, builder.getVertexCount());
++        Assert.assertEquals(3, builder.getFaceCount());
++
++        SimpleTriangleMesh mesh = builder.build();
++
++        Assert.assertEquals(6, mesh.getVertexCount());
++        Assert.assertEquals(3, mesh.getFaceCount());
++
++        TriangleMesh.Face f1 = mesh.getFace(0);
++        Assert.assertArrayEquals(new int[] {0, 1, 2}, f1.getVertexIndices());
++
++        TriangleMesh.Face f2 = mesh.getFace(1);
++        Assert.assertArrayEquals(new int[] {3, 4, 5}, f2.getVertexIndices());
++
++        TriangleMesh.Face f3 = mesh.getFace(2);
++        Assert.assertArrayEquals(new int[] {0, 1, 2}, f3.getVertexIndices());
++    }
++}
+diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/shape/SphereTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/shape/SphereTest.java
+index d241c41..3c75231 100644
+--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/shape/SphereTest.java
++++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/threed/shape/SphereTest.java
+@@ -27,6 +27,7 @@ import org.apache.commons.geometry.core.RegionLocation;
+ import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+ import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
+ import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
++import org.apache.commons.geometry.euclidean.threed.Bounds3D;
+ import org.apache.commons.geometry.euclidean.threed.PlaneConvexSubset;
+ import org.apache.commons.geometry.euclidean.threed.RegionBSPTree3D;
+ import org.apache.commons.geometry.euclidean.threed.SphericalCoordinates;
+@@ -36,6 +37,7 @@ 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.threed.line.Lines3D;
++import org.apache.commons.geometry.euclidean.threed.mesh.TriangleMesh;
+ import org.apache.commons.numbers.angle.PlaneAngleRadians;
+ import org.apache.commons.rng.UniformRandomProvider;
+ import org.apache.commons.rng.simple.RandomSource;
+@@ -445,6 +447,67 @@ public class SphereTest {
+     }
+ 
+     @Test
++    public void testToMesh_zeroSubdivisions() {
++        // arrange
++        Sphere s = Sphere.from(Vector3D.of(1, 2, 3), 2, TEST_PRECISION);
++
++        // act
++        TriangleMesh mesh = s.toTriangleMesh(0);
++
++        // assert
++        Assert.assertEquals(6, mesh.getVertexCount());
++        Assert.assertEquals(8, mesh.getFaceCount());
++
++        Bounds3D bounds = mesh.getBounds();
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(-1, 0, 1), bounds.getMin(), TEST_EPS);
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(3, 4, 5), bounds.getMax(), TEST_EPS);
++
++        Assert.assertTrue(mesh.toTree().isFinite());
++    }
++
++    @Test
++    public void testToMesh_manySubdivisions() {
++        // arrange
++        Sphere s = Sphere.from(Vector3D.of(1, 2, 3), 2, TEST_PRECISION);
++        int subdivisions = 5;
++
++        // act
++        TriangleMesh mesh = s.toTriangleMesh(subdivisions);
++
++        // assert
++        Assert.assertEquals((int) (8 * Math.pow(4, subdivisions)), mesh.getFaceCount());
++
++        Bounds3D bounds = mesh.getBounds();
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(-1, 0, 1), bounds.getMin(), TEST_EPS);
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(3, 4, 5), bounds.getMax(), TEST_EPS);
++
++        RegionBSPTree3D tree = RegionBSPTree3D.partitionedRegionBuilder()
++                .insertAxisAlignedGrid(bounds, 3, TEST_PRECISION)
++                .insertBoundaries(mesh)
++                .build();
++
++        Assert.assertTrue(tree.isFinite());
++
++        double approximationEps = 0.1;
++        Assert.assertEquals(s.getSize(), tree.getSize(), approximationEps);
++        Assert.assertEquals(s.getBoundarySize(), tree.getBoundarySize(), approximationEps);
++
++        EuclideanTestUtils.assertCoordinatesEqual(s.getCentroid(), tree.getCentroid(), TEST_EPS);
++    }
++
++    @Test
++    public void testToMesh_invalidArgs() {
++        // arrange
++        Sphere s = Sphere.from(Vector3D.of(2, 1, 3), 2, TEST_PRECISION);
++
++        // act/assert
++        GeometryTestUtils.assertThrows(() -> {
++            s.toTriangleMesh(-1);
++        }, IllegalArgumentException.class,
++                "Number of sphere approximation subdivisions must be greater than or equal to zero; was -1");
++    }
++
++    @Test
+     public void testHashCode() {
+         // arrange
+         DoublePrecisionContext otherPrecision = new EpsilonDoublePrecisionContext(1e-2);
+diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/Bounds2DTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/Bounds2DTest.java
+index 934a8c1..e0c3e6f 100644
+--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/Bounds2DTest.java
++++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/Bounds2DTest.java
+@@ -472,20 +472,20 @@ public class Bounds2DTest {
+     }
+ 
+     @Test
+-    public void testBuilder_containsBounds() {
++    public void testBuilder_hasBounds() {
+         // act/assert
+-        Assert.assertFalse(Bounds2D.builder().containsBounds());
++        Assert.assertFalse(Bounds2D.builder().hasBounds());
+ 
+-        Assert.assertFalse(Bounds2D.builder().add(Vector2D.of(Double.NaN, 1)).containsBounds());
+-        Assert.assertFalse(Bounds2D.builder().add(Vector2D.of(1, Double.NaN)).containsBounds());
++        Assert.assertFalse(Bounds2D.builder().add(Vector2D.of(Double.NaN, 1)).hasBounds());
++        Assert.assertFalse(Bounds2D.builder().add(Vector2D.of(1, Double.NaN)).hasBounds());
+ 
+-        Assert.assertFalse(Bounds2D.builder().add(Vector2D.of(Double.POSITIVE_INFINITY, 1)).containsBounds());
+-        Assert.assertFalse(Bounds2D.builder().add(Vector2D.of(1, Double.POSITIVE_INFINITY)).containsBounds());
++        Assert.assertFalse(Bounds2D.builder().add(Vector2D.of(Double.POSITIVE_INFINITY, 1)).hasBounds());
++        Assert.assertFalse(Bounds2D.builder().add(Vector2D.of(1, Double.POSITIVE_INFINITY)).hasBounds());
+ 
+-        Assert.assertFalse(Bounds2D.builder().add(Vector2D.of(Double.NEGATIVE_INFINITY, 1)).containsBounds());
+-        Assert.assertFalse(Bounds2D.builder().add(Vector2D.of(1, Double.NEGATIVE_INFINITY)).containsBounds());
++        Assert.assertFalse(Bounds2D.builder().add(Vector2D.of(Double.NEGATIVE_INFINITY, 1)).hasBounds());
++        Assert.assertFalse(Bounds2D.builder().add(Vector2D.of(1, Double.NEGATIVE_INFINITY)).hasBounds());
+ 
+-        Assert.assertTrue(Bounds2D.builder().add(Vector2D.ZERO).containsBounds());
++        Assert.assertTrue(Bounds2D.builder().add(Vector2D.ZERO).hasBounds());
+     }
+ 
+     private static void checkBounds(Bounds2D b, Vector2D min, Vector2D max) {
+diff --git a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/RegionBSPTree2DTest.java b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/RegionBSPTree2DTest.java
+index f621a48..5bb9799 100644
+--- a/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/RegionBSPTree2DTest.java
++++ b/commons-geometry-euclidean/src/test/java/org/apache/commons/geometry/euclidean/twod/RegionBSPTree2DTest.java
+@@ -32,6 +32,7 @@ import org.apache.commons.geometry.core.partitioning.bsp.RegionCutRule;
+ import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+ import org.apache.commons.geometry.core.precision.EpsilonDoublePrecisionContext;
+ import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
++import org.apache.commons.geometry.euclidean.twod.RegionBSPTree2D.PartitionedRegionBuilder2D;
+ import org.apache.commons.geometry.euclidean.twod.RegionBSPTree2D.RegionNode2D;
+ import org.apache.commons.geometry.euclidean.twod.path.LinePath;
+ import org.apache.commons.geometry.euclidean.twod.shape.Parallelogram;
+@@ -109,6 +110,158 @@ public class RegionBSPTree2DTest {
+     }
+ 
+     @Test
++    public void testPartitionedRegionBuilder_halfSpace() {
++        // act
++        RegionBSPTree2D tree = RegionBSPTree2D.partitionedRegionBuilder()
++                .insertPartition(
++                    Lines.fromPointAndDirection(Vector2D.ZERO, Vector2D.Unit.PLUS_X, TEST_PRECISION))
++                .insertBoundary(
++                    Lines.fromPointAndDirection(Vector2D.ZERO, Vector2D.Unit.MINUS_X, TEST_PRECISION).span())
++                .build();
++
++        // assert
++        Assert.assertFalse(tree.isFull());
++        Assert.assertTrue(tree.isInfinite());
++
++        EuclideanTestUtils.assertRegionLocation(tree, RegionLocation.INSIDE, Vector2D.of(0, -1));
++        EuclideanTestUtils.assertRegionLocation(tree, RegionLocation.BOUNDARY, Vector2D.ZERO);
++        EuclideanTestUtils.assertRegionLocation(tree, RegionLocation.OUTSIDE, Vector2D.of(0, 1));
++    }
++
++    @Test
++    public void testPartitionedRegionBuilder_square() {
++        // arrange
++        Parallelogram square = Parallelogram.unitSquare(TEST_PRECISION);
++        List<LineConvexSubset> boundaries = square.getBoundaries();
++
++        Vector2D lowerBound = Vector2D.of(-2, -2);
++
++        int maxUpper = 5;
++        int maxLevel = 4;
++
++        // act/assert
++        Bounds2D bounds;
++        for (int u = 0; u <= maxUpper; ++u) {
++            for (int level = 0; level <= maxLevel; ++level) {
++                bounds = Bounds2D.from(lowerBound, Vector2D.of(u, u));
++
++                checkFinitePartitionedRegion(bounds, level, square);
++                checkFinitePartitionedRegion(bounds, level, boundaries);
++            }
++        }
++    }
++
++    @Test
++    public void testPartitionedRegionBuilder_nonConvex() {
++        // arrange
++        RegionBSPTree2D src = Parallelogram.unitSquare(TEST_PRECISION).toTree();
++        src.union(Parallelogram.axisAligned(Vector2D.ZERO, Vector2D.of(1, 1), TEST_PRECISION).toTree());
++
++        List<LineConvexSubset> boundaries = src.getBoundaries();
++
++        Vector2D lowerBound = Vector2D.of(-2, -2);
++
++        int maxUpper = 5;
++        int maxLevel = 4;
++
++        // act/assert
++        Bounds2D bounds;
++        for (int u = 0; u <= maxUpper; ++u) {
++            for (int level = 0; level <= maxLevel; ++level) {
++                bounds = Bounds2D.from(lowerBound, Vector2D.of(u, u));
++
++                checkFinitePartitionedRegion(bounds, level, src);
++                checkFinitePartitionedRegion(bounds, level, boundaries);
++            }
++        }
++    }
++
++    /** Check that a partitioned BSP tree behaves the same as a non-partitioned tree when
++     * constructed with the given boundary source.
++     * @param bounds
++     * @param level
++     * @param src
++     */
++    private void checkFinitePartitionedRegion(Bounds2D bounds, int level, BoundarySource2D src) {
++        // arrange
++        String msg = "Partitioned region check failed with bounds= " + bounds + " and level= " + level;
++
++        RegionBSPTree2D standard = RegionBSPTree2D.from(src.boundaryStream().collect(Collectors.toList()));
++
++        // act
++        RegionBSPTree2D partitioned = RegionBSPTree2D.partitionedRegionBuilder()
++                .insertAxisAlignedGrid(bounds, level, TEST_PRECISION)
++                .insertBoundaries(src)
++                .build();
++
++        // assert
++        Assert.assertEquals(msg, standard.getSize(), partitioned.getSize(), TEST_EPS);
++        Assert.assertEquals(msg, standard.getBoundarySize(), partitioned.getBoundarySize(), TEST_EPS);
++        EuclideanTestUtils.assertCoordinatesEqual(standard.getCentroid(), partitioned.getCentroid(), TEST_EPS);
++
++        RegionBSPTree2D diff = RegionBSPTree2D.empty();
++        diff.difference(partitioned, standard);
++        Assert.assertTrue(msg, diff.isEmpty());
++    }
++
++    /** Check that a partitioned BSP tree behaves the same as a non-partitioned tree when
++     * constructed with the given boundaries.
++     * @param bounds
++     * @param level
++     * @param boundaries
++     */
++    private void checkFinitePartitionedRegion(Bounds2D bounds, int level,
++            List<? extends LineConvexSubset> boundaries) {
++        // arrange
++        String msg = "Partitioned region check failed with bounds= " + bounds + " and level= " + level;
++
++        RegionBSPTree2D standard = RegionBSPTree2D.from(boundaries);
++
++        // act
++        RegionBSPTree2D partitioned = RegionBSPTree2D.partitionedRegionBuilder()
++                .insertAxisAlignedGrid(bounds, level, TEST_PRECISION)
++                .insertBoundaries(boundaries)
++                .build();
++
++        // assert
++        Assert.assertEquals(msg, standard.getSize(), partitioned.getSize(), TEST_EPS);
++        Assert.assertEquals(msg, standard.getBoundarySize(), partitioned.getBoundarySize(), TEST_EPS);
++        EuclideanTestUtils.assertCoordinatesEqual(standard.getCentroid(), partitioned.getCentroid(), TEST_EPS);
++
++        RegionBSPTree2D diff = RegionBSPTree2D.empty();
++        diff.difference(partitioned, standard);
++        Assert.assertTrue(msg, diff.isEmpty());
++    }
++
++    @Test
++    public void testPartitionedRegionBuilder_insertPartitionAfterBoundary() {
++        // arrange
++        PartitionedRegionBuilder2D builder = RegionBSPTree2D.partitionedRegionBuilder();
++        builder.insertBoundary(Lines.segmentFromPoints(Vector2D.ZERO, Vector2D.of(1, 0), TEST_PRECISION));
++
++        Line partition = Lines.fromPointAndAngle(Vector2D.ZERO, 0, TEST_PRECISION);
++
++        String msg = "Cannot insert partitions after boundaries have been inserted";
++
++        // act/assert
++        GeometryTestUtils.assertThrows(() -> {
++            builder.insertPartition(partition);
++        }, IllegalStateException.class, msg);
++
++        GeometryTestUtils.assertThrows(() -> {
++            builder.insertPartition(partition.span());
++        }, IllegalStateException.class, msg);
++
++        GeometryTestUtils.assertThrows(() -> {
++            builder.insertAxisAlignedPartitions(Vector2D.ZERO, TEST_PRECISION);
++        }, IllegalStateException.class, msg);
++
++        GeometryTestUtils.assertThrows(() -> {
++            builder.insertAxisAlignedGrid(Bounds2D.from(Vector2D.ZERO, Vector2D.of(1, 1)), 1, TEST_PRECISION);
++        }, IllegalStateException.class, msg);
++    }
++
++    @Test
+     public void testCopy() {
+         // arrange
+         RegionBSPTree2D tree = new RegionBSPTree2D(true);
+diff --git a/commons-geometry-examples/examples-io/pom.xml b/commons-geometry-examples/examples-io/pom.xml
+index 779d131..d0bd46c 100644
+--- a/commons-geometry-examples/examples-io/pom.xml
++++ b/commons-geometry-examples/examples-io/pom.xml
+@@ -57,6 +57,24 @@
+       <artifactId>commons-geometry-euclidean</artifactId>
+     </dependency>
+ 
++    <dependency>
++      <groupId>org.apache.commons</groupId>
++      <artifactId>commons-geometry-core</artifactId>
++      <version>${project.version}</version>
++      <classifier>tests</classifier>
++      <type>test-jar</type>
++      <scope>test</scope>
++    </dependency>
++
++    <dependency>
++      <groupId>org.apache.commons</groupId>
++      <artifactId>commons-geometry-euclidean</artifactId>
++      <version>${project.version}</version>
++      <classifier>tests</classifier>
++      <type>test-jar</type>
++      <scope>test</scope>
++    </dependency>
++
+   </dependencies>
+ 
+ </project>
+diff --git a/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/Format3D.java b/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/Format3D.java
+deleted file mode 100644
+index 62276d1..0000000
+--- a/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/Format3D.java
++++ /dev/null
+@@ -1,262 +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.examples.io;
+-
+-import java.io.File;
+-import java.io.IOException;
+-import java.io.Writer;
+-import java.nio.file.Files;
+-import java.text.DecimalFormat;
+-import java.util.ArrayList;
+-import java.util.Comparator;
+-import java.util.List;
+-import java.util.Map;
+-import java.util.TreeMap;
+-import java.util.stream.Stream;
+-
+-import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+-import org.apache.commons.geometry.euclidean.threed.BoundarySource3D;
+-import org.apache.commons.geometry.euclidean.threed.PlaneConvexSubset;
+-import org.apache.commons.geometry.euclidean.threed.Triangle3D;
+-import org.apache.commons.geometry.euclidean.threed.Vector3D;
+-
+-/**
+- * Utility class for writing out 3D scenes in various file formats.
+- */
+-public final class Format3D {
+-    /** Utility class. */
+-    private Format3D() {}
+-
+-    /** Output format. */
+-    public enum Output {
+-        /** <a href="https://en.wikipedia.org/wiki/Wavefront_.obj_file">OBJ</a> format. */
+-        OBJ
+-    }
+-
+-    /**
+-     * Saves the representation to the given {@code file}.
+-     *
+-     * @param type Output format.
+-     * @param file File.
+-     * @param precision Precision.
+-     * @param src Shape boundaries.
+-     */
+-    public static void save(Output type,
+-                            String file,
+-                            DoublePrecisionContext precision,
+-                            BoundarySource3D src)
+-        throws IOException {
+-        save(type, new File(file), precision, src);
+-    }
+-
+-    /**
+-     * Saves the representation to the given {@code file}.
+-     *
+-     * @param type Output format.
+-     * @param file File.
+-     * @param precision Precision.
+-     * @param src Shape boundaries.
+-     */
+-    public static void save(Output type,
+-                            File file,
+-                            DoublePrecisionContext precision,
+-                            BoundarySource3D src)
+-        throws IOException {
+-        try (Writer w = Files.newBufferedWriter(file.toPath())) {
+-            w.write(create(type, precision, src));
+-        }
+-    }
+-
+-    /**
+-     * Creates a string representation.
+-     *
+-     * @param type Output format.
+-     * @param precision Precision.
+-     * @param src Shape boundaries.
+-     * @return a string in the given {@code format}.
+-     */
+-    public static String create(Output type,
+-                                DoublePrecisionContext precision,
+-                                BoundarySource3D src) {
+-        // Create mesh data (vertices and facets).
+-        final Mesh mesh = new Mesh(precision);
+-        try (Stream<PlaneConvexSubset> stream = src.boundaryStream()) {
+-            stream.forEach(mesh::add);
+-        }
+-
+-        // Create output.
+-        final StringBuilder out = new StringBuilder();
+-
+-        switch (type) {
+-        case OBJ:
+-            ObjWriter.write(mesh.getVertices(), mesh.getFacets(), out);
+-            break;
+-        default:
+-            throw new UnsupportedOperationException("Not implemented");
+-        }
+-
+-        return out.toString();
+-    }
+-
+-    /**
+-     * Imposes a strict sorting on 3D vertices.
+-     * If all of the components of two vertices are within tolerance of each
+-     * other, then the vertices are considered equal (in order to avoid
+-     * writing duplicate vertices).
+-     */
+-    private static class VertexComparator implements Comparator<Vector3D> {
+-        /** Precision context to deteremine floating-point equality. */
+-        private final DoublePrecisionContext precision;
+-
+-        /**
+-         * @param precision Precision.
+-         */
+-        VertexComparator(final DoublePrecisionContext precision) {
+-            this.precision = precision;
+-        }
+-
+-        /** {@inheritDoc} */
+-        @Override
+-        public int compare(final Vector3D a,
+-                           final Vector3D b) {
+-            int result = precision.compare(a.getX(), b.getX());
+-            if (result == 0) {
+-                result = precision.compare(a.getY(), b.getY());
+-                if (result == 0) {
+-                    result = precision.compare(a.getZ(), b.getZ());
+-                }
+-            }
+-
+-            return result;
+-        }
+-    }
+-
+-    /**
+-     * Extract vertices and facets from {@link BoundarySource3D} instances.
+-     */
+-    private static class Mesh {
+-        /** Map of vertices to their index in the vertices list. */
+-        private final Map<Vector3D, Integer> vertexIndexMap;
+-        /** List of unique vertices in the BSPTree boundary. */
+-        private final List<Vector3D> vertices;
+-        /** Triangular facets, each composed of 3 indices into {@link #vertices}. */
+-        private final List<int[]> facets;
+-
+-        /**
+-         * @param precision Precision.
+-         */
+-        Mesh(DoublePrecisionContext precision) {
+-            vertexIndexMap = new TreeMap<>(new VertexComparator(precision));
+-            vertices = new ArrayList<>();
+-            facets = new ArrayList<>();
+-        }
+-
+-        /**
+-         * @return the list of unique vertices found in the BSPTree.
+-         */
+-        List<Vector3D> getVertices() {
+-            return vertices;
+-        }
+-
+-        /**
+-         * @return the list of facets (composed of vertex indices) for the BSPTree.
+-         */
+-        List<int[]> getFacets() {
+-            return facets;
+-        }
+-
+-        /**
+-         * Adds a plane subset to this mesh.
+-         *
+-         * @param boundary Convex plane boundary.
+-         */
+-        private void add(final PlaneConvexSubset boundary) {
+-            if (!boundary.isEmpty()) {
+-                if (!boundary.isFinite()) {
+-                    throw new IllegalArgumentException("Cannot add infinite plane subset: " + boundary);
+-                }
+-
+-                for (final Triangle3D tri : boundary.toTriangles()) {
+-                    facets.add(new int[] {
+-                            getVertexIndex(tri.getPoint1()),
+-                            getVertexIndex(tri.getPoint2()),
+-                            getVertexIndex(tri.getPoint3())
+-                        });
+-                }
+-            }
+-        }
+-
+-        /**
+-         * @param vertex Vertex.
+-         * @return the index of the given vertex in the list of vertices.
+-         */
+-        private int getVertexIndex(final Vector3D vertex) {
+-            Integer idx = vertexIndexMap.get(vertex);
+-            if (idx == null) {
+-                idx = vertices.size();
+-                vertices.add(vertex);
+-                vertexIndexMap.put(vertex, idx);
+-            }
+-
+-            return idx.intValue();
+-        }
+-    }
+-
+-    /**
+-     * <a href="https://en.wikipedia.org/wiki/Wavefront_.obj_file">OBJ</a> format.
+-     */
+-    private static final class ObjWriter {
+-        /** Utility class. */
+-        private ObjWriter() {}
+-
+-        /**
+-         * @param vertices Vertices.
+-         * @param facets Facets.
+-         * @param out Output.
+-         */
+-        static void write(List<Vector3D> vertices,
+-                          List<int[]> facets,
+-                          StringBuilder out) {
+-            final String sp = " ";
+-            final String ls = System.lineSeparator();
+-            final DecimalFormat df = new DecimalFormat("0.######");
+-
+-            // Write vertices.
+-            for (final Vector3D v : vertices) {
+-                out.append("v")
+-                    .append(sp)
+-                    .append(df.format(v.getX()))
+-                    .append(sp)
+-                    .append(df.format(v.getY()))
+-                    .append(sp)
+-                    .append(df.format(v.getZ()))
+-                    .append(ls);
+-            }
+-
+-            // Write Facets.
+-            for (int[] f : facets) {
+-                out.append("f")
+-                    .append(sp);
+-                for (int idx : f) {
+-                    out.append(String.valueOf(idx + 1)) // "OBJ" indices are 1-based.
+-                        .append(sp);
+-                }
+-                out.append(ls);
+-            }
+-        }
+-    }
+-}
+diff --git a/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/package-info.java b/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/package-info.java
+index 62e0555..886a693 100644
+--- a/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/package-info.java
++++ b/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/package-info.java
+@@ -15,7 +15,6 @@
+  * limitations under the License.
+  */
+ 
+-/**
+- * <h3>Persistent storage of shapes.</h3>
++/** Persistent storage of shapes.
+  */
+ package org.apache.commons.geometry.examples.io;
+diff --git a/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/threed/AbstractModelIOHandler.java b/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/threed/AbstractModelIOHandler.java
+new file mode 100644
+index 0000000..06cfe3c
+--- /dev/null
++++ b/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/threed/AbstractModelIOHandler.java
+@@ -0,0 +1,118 @@
++/*
++ * Licensed to the Apache Software Foundation (ASF) under one or more
++ * contributor license agreements.  See the NOTICE file distributed with
++ * this work for additional information regarding copyright ownership.
++ * The ASF licenses this file to You under the Apache License, Version 2.0
++ * (the "License"); you may not use this file except in compliance with
++ * the License.  You may obtain a copy of the License at
++ *
++ *      http://www.apache.org/licenses/LICENSE-2.0
++ *
++ * Unless required by applicable law or agreed to in writing, software
++ * distributed under the License is distributed on an "AS IS" BASIS,
++ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
++ * See the License for the specific language governing permissions and
++ * limitations under the License.
++ */
++package org.apache.commons.geometry.examples.io.threed;
++
++import java.io.File;
++import java.io.IOException;
++import java.io.InputStream;
++import java.io.OutputStream;
++import java.io.UncheckedIOException;
++import java.nio.file.Files;
++
++import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
++import org.apache.commons.geometry.euclidean.threed.BoundarySource3D;
++
++/** Abstract base class for {@link ModelIOHandler} implementations.
++ */
++public abstract class AbstractModelIOHandler implements ModelIOHandler {
++
++    /** {@inheritDoc} */
++    @Override
++    public BoundarySource3D read(final String type, final File in, final DoublePrecisionContext precision) {
++        ensureTypeSupported(type);
++        try {
++            try (InputStream is = Files.newInputStream(in.toPath())) {
++                return readInternal(type, is, precision);
++            }
++        } catch (IOException exc) {
++            throw createUnchecked(exc);
++        }
++    }
++
++    /** {@inheritDoc} */
++    @Override
++    public BoundarySource3D read(final String type, final InputStream in, final DoublePrecisionContext precision) {
++        ensureTypeSupported(type);
++        try {
++            return readInternal(type, in, precision);
++        } catch (IOException exc) {
++            throw createUnchecked(exc);
++        }
++    }
++
++    /** {@inheritDoc} */
++    @Override
++    public void write(final BoundarySource3D model, final String type, final File out) {
++        ensureTypeSupported(type);
++        try {
++            try (OutputStream os = Files.newOutputStream(out.toPath())) {
++                writeInternal(model, type, os);
++            }
++        } catch (IOException exc) {
++            throw createUnchecked(exc);
++        }
++    }
++
++    /** {@inheritDoc} */
++    @Override
++    public void write(final BoundarySource3D model, final String type, OutputStream out) {
++        ensureTypeSupported(type);
++        try {
++            writeInternal(model, type, out);
++        } catch (IOException exc) {
++            throw createUnchecked(exc);
++        }
++    }
++
++    /** Throw an exception if the given type is not supported by this instance.
++     * @param type model type to check
++     */
++    private void ensureTypeSupported(final String type) {
++        if (!handlesType(type)) {
++            throw new IllegalArgumentException("File type is not supported by this handler: " + type);
++        }
++    }
++
++    /** Create an unchecked exception from the given checked exception.
++     * @param exc exception to wrap in an unchecked exception
++     * @return the unchecked exception
++     */
++    private UncheckedIOException createUnchecked(final IOException exc) {
++        final String msg = exc.getClass().getSimpleName() + ": " + exc.getMessage();
++        return new UncheckedIOException(msg, exc);
++    }
++
++    /** Internal class used to read a model. {@link IOException}s thrown from here are
++     * wrapped in {@link java.io.UncheckedIOException}s.
++     * @param type model type; guaranteed to be supported by this instance
++     * @param in input stream
++     * @param precision precision context used to construct the model
++     * @return 3D model
++     * @throws IOException if an IO operation fails
++     */
++    protected abstract BoundarySource3D readInternal(String type, InputStream in, DoublePrecisionContext precision)
++            throws IOException;
++
++    /** Internal class used to write a model. {@link IOException}s thrown from here are
++     * wrapped in {@link java.io.UncheckedIOException}s.
++     * @param model model to write
++     * @param type model type; guaranteed to be supported by this instance
++     * @param out input stream
++     * @throws IOException if an IO operation fails
++     */
++    protected abstract void writeInternal(BoundarySource3D model, String type, OutputStream out) throws IOException;
++}
+diff --git a/commons-geometry-examples/examples-jmh/src/main/java/org/apache/commons/geometry/examples/jmh/package-info.java b/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/threed/DefaultModelIOHandlerRegistry.java
+similarity index 59%
+copy from commons-geometry-examples/examples-jmh/src/main/java/org/apache/commons/geometry/examples/jmh/package-info.java
+copy to commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/threed/DefaultModelIOHandlerRegistry.java
+index ae4c5e1..0410b3d 100644
+--- a/commons-geometry-examples/examples-jmh/src/main/java/org/apache/commons/geometry/examples/jmh/package-info.java
++++ b/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/threed/DefaultModelIOHandlerRegistry.java
+@@ -14,13 +14,22 @@
+  * See the License for the specific language governing permissions and
+  * limitations under the License.
+  */
++package org.apache.commons.geometry.examples.io.threed;
+ 
+-/**
+- * <h3>Performance benchmarks</h3>
+- *
+- * <p>
+- * This package contains code to perform a
+- * <a href="http://openjdk.java.net/projects/code-tools/jmh">JMH</a> run.
+- * </p>
++import java.util.Arrays;
++
++import org.apache.commons.geometry.examples.io.threed.obj.OBJModelIOHandler;
++
++/** {@link ModelIOHandlerRegistry} subclass that registers known handlers on
++ * instantiation.
+  */
+-package org.apache.commons.geometry.examples.jmh;
++public class DefaultModelIOHandlerRegistry extends ModelIOHandlerRegistry {
++
++    /** Construct a new instance and register known handlers.
++     */
++    public DefaultModelIOHandlerRegistry() {
++        setHandlers(Arrays.asList(
++                new OBJModelIOHandler()
++            ));
++    }
++}
+diff --git a/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/threed/ModelIO.java b/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/threed/ModelIO.java
+new file mode 100644
+index 0000000..ea12fb4
+--- /dev/null
++++ b/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/threed/ModelIO.java
+@@ -0,0 +1,136 @@
++/*
++ * 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.examples.io.threed;
++
++import java.io.File;
++import java.io.InputStream;
++import java.io.OutputStream;
++
++import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
++import org.apache.commons.geometry.euclidean.threed.BoundarySource3D;
++
++/** Utility class containing constants and static convenience methods related to 3D model
++ * input and output.
++ */
++public final class ModelIO {
++
++    /** String representing the OBJ file format.
++     * @see <a href="https://en.wikipedia.org/wiki/Wavefront_.obj_file">Wavefront .obj file</a>
++     */
++    public static final String OBJ = "obj";
++
++    /** Singleton handler registry. */
++    private static final ModelIOHandlerRegistry HANDLER_REGISTRY = new DefaultModelIOHandlerRegistry();
++
++    /** Utility class; no instantiation. */
++    private ModelIO() {}
++
++    /** Get the {@link ModelIOHandlerRegistry} singleton instance.
++     * @return the {@link ModelIOHandlerRegistry} singleton instance
++     */
++    public static ModelIOHandlerRegistry getModelIOHandlerRegistry() {
++        return HANDLER_REGISTRY;
++    }
++
++    /** Read a 3D model from the given file, using the file extension as the model type. The call is delegated
++     * to the {@link ModelIOHandlerRegistry} singleton.
++     * @param in file to read
++     * @param precision precision context to use in model construction
++     * @return a 3D model represented as a boundary source
++     * @throws java.io.UncheckedIOException if an IO operation fails
++     * @throws IllegalArgumentException if the file does not have a file extension or the
++     *      file extension does not indicate a supported model type
++     * @see #getModelIOHandlerRegistry()
++     * @see ModelIOHandlerRegistry#read(File, DoublePrecisionContext)
++     */
++    public static BoundarySource3D read(final File in, final DoublePrecisionContext precision) {
++        return HANDLER_REGISTRY.read(in, precision);
++    }
++
++    /** Read a 3D model of the given type from the file. The call is delegated to the {@link ModelIOHandlerRegistry}
++     * singleton.
++     * @param type model input type
++     * @param in input file
++     * @param precision precision context to use in model construction
++     * @return a 3D model represented as a boundary source
++     * @throws java.io.UncheckedIOException if an IO operation fails
++     * @throws IllegalArgumentException if the model input type is not supported
++     * @see #getModelIOHandlerRegistry()
++     * @see ModelIOHandler#read(String, File, DoublePrecisionContext)
++     */
++    public static BoundarySource3D read(final String type, final File in, final DoublePrecisionContext precision) {
++        return HANDLER_REGISTRY.read(type, in, precision);
++    }
++
++    /** Read a 3D model of the given type from the input stream. The call is delegated to the
++     * {@link ModelIOHandlerRegistry} singleton.
++     * @param type model input type
++     * @param in input stream to read from
++     * @param precision precision context to use in model construction
++     * @return a 3D model represented as a boundary source
++     * @throws java.io.UncheckedIOException if an IO operation fails
++     * @throws IllegalArgumentException if the model input type is not supported
++     * @see #getModelIOHandlerRegistry()
++     * @see ModelIOHandler#read(String, InputStream, DoublePrecisionContext)
++     */
++    public static BoundarySource3D read(final String type, final InputStream in,
++            final DoublePrecisionContext precision) {
++        return HANDLER_REGISTRY.read(type, in, precision);
++    }
++
++    /** Write the model to the file. The file type is determined by the file extension of the target file.
++     * The call is delegated to the {@link ModelIOHandlerRegistry} singleton.
++     * @param model model to write
++     * @param out output file
++     * @throws java.io.UncheckedIOException if an IO operation fails
++     * @throws IllegalArgumentException if the file does not have a file extension or the
++     *      file extension does not indicate a supported model type
++     * @see #getModelIOHandlerRegistry()
++     * @see ModelIOHandlerRegistry#write(BoundarySource3D, File)
++     */
++    public static void write(final BoundarySource3D model, final File out) {
++        HANDLER_REGISTRY.write(model, out);
++    }
++
++    /** Write the model to the file using the specified file type. The call is delegated to the
++     * {@link ModelIOHandlerRegistry} singleton.
++     * @param model model to write
++     * @param type model file type
++     * @param out output file
++     * @throws java.io.UncheckedIOException if an IO operation fails
++     * @throws IllegalArgumentException if the file type is not supported
++     * @see #getModelIOHandlerRegistry()
++     * @see ModelIOHandler#write(BoundarySource3D, String, File)
++     */
++    public static void write(final BoundarySource3D model, final String type, final File out) {
++        HANDLER_REGISTRY.write(model, type, out);
++    }
++
++    /** Write the model to the output stream using the specific file type. The call is delegated to the
++     * {@link ModelIOHandlerRegistry} singleton.
++     * @param model model to write
++     * @param type model file type
++     * @param out output stream
++     * @throws java.io.UncheckedIOException if an IO operation fails
++     * @throws IllegalArgumentException if the file type is not supported
++     * @see #getModelIOHandlerRegistry()
++     * @see ModelIOHandler#write(BoundarySource3D, String, OutputStream)
++     */
++    public static void write(final BoundarySource3D model, final String type, final OutputStream out) {
++        HANDLER_REGISTRY.write(model, type, out);
++    }
++}
+diff --git a/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/threed/ModelIOHandler.java b/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/threed/ModelIOHandler.java
+new file mode 100644
+index 0000000..986fd0b
+--- /dev/null
++++ b/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/threed/ModelIOHandler.java
+@@ -0,0 +1,79 @@
++/*
++ * 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.examples.io.threed;
++
++import java.io.File;
++import java.io.InputStream;
++import java.io.OutputStream;
++
++import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
++import org.apache.commons.geometry.euclidean.threed.BoundarySource3D;
++
++/** Interface for classes that handle reading and writing of 3D model files types.
++ * For convenience and better compatibility with streams and functional programming,
++ * all IO methods throw {@link java.io.UncheckedIOException} instead of {@link java.io.IOException}.
++ *
++ * <p>Implementations of this interface are expected to be thread-safe.</p>
++ */
++public interface ModelIOHandler {
++
++    /** Return true if this instance handles 3D model files of the given type.
++     * @param type type 3D model type, indicated by file extension
++     * @return true if this instance can handle the 3D model file type
++     */
++    boolean handlesType(String type);
++
++    /** Read a 3D model represented as a {@link BoundarySource3D} from the given file.
++     * @param type the model file type
++     * @param in file to read
++     * @param precision precision context to use in model construction
++     * @return a 3D model represented as a boundary source
++     * @throws java.io.UncheckedIOException if an IO operation fails
++     * @throws IllegalArgumentException if the file type is not supported
++     */
++    BoundarySource3D read(String type, File in, DoublePrecisionContext precision);
++
++    /** Read a 3D model represented as a {@link BoundarySource3D} from the given input stream.
++     * The input stream is closed before method return.
++     * @param type the model input type
++     * @param in input stream
++     * @param precision precision context to use in model construction
++     * @return a 3D model represented as a boundary source
++     * @throws java.io.UncheckedIOException if an IO operation fails
++     * @throws IllegalArgumentException if the file type is not supported
++     */
++    BoundarySource3D read(String type, InputStream in, DoublePrecisionContext precision);
++
++    /** Write the model to the file using the specified file type.
++     * @param model model to write
++     * @param type the model file type
++     * @param out output file
++     * @throws java.io.UncheckedIOException if an IO operation fails
++     * @throws IllegalArgumentException if the file type is not supported
++     */
++    void write(BoundarySource3D model, String type, File out);
++
++    /** Write the model to the given output stream, using the specified model type.
++     * The output stream is closed before method return.
++     * @param model model to write
++     * @param type the model file type
++     * @param out output stream
++     * @throws java.io.UncheckedIOException if an IO operation fails
++     * @throws IllegalArgumentException if the file type is not supported
++     */
++    void write(BoundarySource3D model, String type, OutputStream out);
++}
+diff --git a/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/threed/ModelIOHandlerRegistry.java b/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/threed/ModelIOHandlerRegistry.java
+new file mode 100644
+index 0000000..66eae4a
+--- /dev/null
++++ b/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/threed/ModelIOHandlerRegistry.java
+@@ -0,0 +1,162 @@
++/*
++ * 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.examples.io.threed;
++
++import java.io.File;
++import java.io.InputStream;
++import java.io.OutputStream;
++import java.util.ArrayList;
++import java.util.Collections;
++import java.util.List;
++
++import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
++import org.apache.commons.geometry.euclidean.threed.BoundarySource3D;
++
++/** Object that holds an internal registry {@link ModelIOHandler} and delegates
++ * read/write operations to them as determined by their supported model types.
++ *
++ * <p>Instances of this class are thread-safe as long as all registered handlers are
++ * also thread-safe.</p>
++ */
++public class ModelIOHandlerRegistry implements ModelIOHandler {
++
++    /** Handler list. */
++    private final List<ModelIOHandler> handlers = new ArrayList<>();
++
++    /** Get the {@link ModelIOHandler} for the given type or null if no
++     * handler is found.
++     * @param type model type to retrieve the handler for
++     * @return the handler for the given type or null if no handler is found
++     */
++    public ModelIOHandler getHandlerForType(final String type) {
++        synchronized (handlers) {
++            for (final ModelIOHandler handler : handlers) {
++                if (handler.handlesType(type)) {
++                    return handler;
++                }
++            }
++
++            return null;
++        }
++    }
++
++    /** Get the list of registered {@link ModelIOHandler}s.
++     * @return the registered {@link ModelIOHandler}s
++     */
++    public List<ModelIOHandler> getHandlers() {
++        synchronized (handlers) {
++            return Collections.unmodifiableList(new ArrayList<>(handlers));
++        }
++    }
++
++    /** Set the list of registered {@link ModelIOHandler}s.
++     * @param newHandlers the new list of {@link ModelIOHandler}s.
++     */
++    public void setHandlers(final List<ModelIOHandler> newHandlers) {
++        synchronized (handlers) {
++            handlers.clear();
++
++            if (newHandlers != null) {
++                handlers.addAll(newHandlers);
++            }
++        }
++    }
++
++    /** {@inheritDoc} */
++    @Override
++    public boolean handlesType(final String type) {
++        return getHandlerForType(type) != null;
++    }
++
++    /** Read a 3D model from the given file, using the file extension as the model type.
++     * @param in file to read from
++     * @param precision precision context to use in model construction
++     * @return a 3D model represented as a boundary source
++     * @throws java.io.UncheckedIOException if an IO operation fails
++     * @throws IllegalArgumentException if the file does not have a file extension or the
++     *      file extension does not indicate a supported model type
++     */
++    public BoundarySource3D read(final File in, final DoublePrecisionContext precision) {
++        return read(getFileExtension(in), in, precision);
++    }
++
++    /** {@inheritDoc} */
++    @Override
++    public BoundarySource3D read(final String type, final File in, final DoublePrecisionContext precision) {
++        return requireHandlerForType(type).read(type, in, precision);
++    }
++
++    /** {@inheritDoc} */
++    @Override
++    public BoundarySource3D read(final String type, final InputStream in, final DoublePrecisionContext precision) {
++        return requireHandlerForType(type).read(type, in, precision);
++    }
++
++    /** Write the model to the file. The file type is determined by the file extension
++     * of the target file.
++     * @param model model to write
++     * @param out output file
++     * @throws java.io.UncheckedIOException if an IO operation fails
++     * @throws IllegalArgumentException if the file does not have a file extension or the
++     *      file extension does not indicate a supported model type
++     */
++    public void write(final BoundarySource3D model, final File out) {
++        write(model, getFileExtension(out), out);
++    }
++
++    /** {@inheritDoc} */
++    @Override
++    public void write(final BoundarySource3D model, final String type, final File out) {
++        requireHandlerForType(type).write(model, type, out);
++    }
++
++    /** {@inheritDoc} */
++    @Override
++    public void write(final BoundarySource3D model, final String type, final OutputStream out) {
++        requireHandlerForType(type).write(model, type, out);
++    }
++
++    /** Get the file extension of the given file, throwing an exception if one cannot be found.
++     * @param file the file to get the extension for
++     * @return the file extension
++     * @throws IllegalArgumentException if the file does not have a file extension
++     */
++    private String getFileExtension(final File file) {
++        final String name = file.getName();
++        final int idx = name.lastIndexOf('.');
++        if (idx > -1) {
++            return name.substring(idx + 1).toLowerCase();
++        }
++
++        throw new IllegalArgumentException("Cannot determine target file type: \"" + file +
++                "\" does not have a file extension");
++    }
++
++    /** Get the handler for the given type, throwing an exception if not found.
++     * @param type model type
++     * @return the handler for the given type
++     * @throws IllegalArgumentException if no handler for the type is found
++     */
++    private ModelIOHandler requireHandlerForType(final String type) {
++        final ModelIOHandler handler = getHandlerForType(type);
++        if (handler == null) {
++            throw new IllegalArgumentException("No handler found for type \"" + type + "\"");
++        }
++
++        return handler;
++    }
++}
+diff --git a/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/threed/obj/OBJConstants.java b/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/threed/obj/OBJConstants.java
+new file mode 100644
+index 0000000..bf08f0d
+--- /dev/null
++++ b/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/threed/obj/OBJConstants.java
+@@ -0,0 +1,48 @@
++/*
++ * 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.examples.io.threed.obj;
++
++import java.nio.charset.Charset;
++import java.nio.charset.StandardCharsets;
++
++/** Class containing constants for use with OBJ files.
++ */
++final class OBJConstants {
++    /** Default OBJ charset. */
++    static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
++
++    /** Character used to indicate the start of a comment line. */
++    static final char COMMENT_START_CHAR = '#';
++
++    /** Keyword used to indicate a vertex definition line. */
++    static final String VERTEX_KEYWORD = "v";
++
++    /** Keyword used to indicate a face definition line. */
++    static final String FACE_KEYWORD = "f";
++
++    /** Keyword used to indicate a geometry group. */
++    static final String GROUP_KEYWORD = "g";
++
++    /** Keyword used to associate a name with the following geometry. */
++    static final String OBJECT_KEYWORD = "o";
++
++    /** Character used to separate face vertex indices from texture and normal indices. */
++    static final char FACE_VALUE_SEP_CHAR = '/';
++
++    /** Utility class; no instantiation. */
++    private OBJConstants() {}
++}
+diff --git a/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/threed/obj/OBJModelIOHandler.java b/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/threed/obj/OBJModelIOHandler.java
+new file mode 100644
+index 0000000..5a5b5e5
+--- /dev/null
++++ b/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/threed/obj/OBJModelIOHandler.java
+@@ -0,0 +1,67 @@
++/*
++ * 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.examples.io.threed.obj;
++
++import java.io.BufferedWriter;
++import java.io.IOException;
++import java.io.InputStream;
++import java.io.InputStreamReader;
++import java.io.OutputStream;
++import java.io.OutputStreamWriter;
++import java.nio.charset.Charset;
++import java.nio.charset.StandardCharsets;
++
++import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
++import org.apache.commons.geometry.euclidean.threed.BoundarySource3D;
++import org.apache.commons.geometry.euclidean.threed.mesh.TriangleMesh;
++import org.apache.commons.geometry.examples.io.threed.AbstractModelIOHandler;
++import org.apache.commons.geometry.examples.io.threed.ModelIO;
++
++/** {@link org.apache.commons.geometry.examples.io.threed.ModelIOHandler ModelIOHandler}
++ * implementation for the OBJ file format.
++ */
++public class OBJModelIOHandler extends AbstractModelIOHandler {
++
++    /** Charset for use with OBJ files. */
++    private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
++
++    /** {@inheritDoc} */
++    @Override
++    public boolean handlesType(final String type) {
++        return ModelIO.OBJ.equalsIgnoreCase(type);
++    }
++
++    /** {@inheritDoc} */
++    @Override
++    protected TriangleMesh readInternal(final String type, final InputStream in,
++            final DoublePrecisionContext precision) throws IOException {
++        try (InputStreamReader reader = new InputStreamReader(in, DEFAULT_CHARSET)) {
++            final OBJReader objReader = new OBJReader();
++            return objReader.readTriangleMesh(reader, precision);
++        }
++    }
++
++    /** {@inheritDoc} */
++    @Override
++    protected void writeInternal(final BoundarySource3D model, final String type, final OutputStream out)
++            throws IOException {
++        try (OBJWriter objWriter = new OBJWriter(new BufferedWriter(
++                new OutputStreamWriter(out, DEFAULT_CHARSET)))) {
++            objWriter.writeBoundaries(model);
++        }
++    }
++}
+diff --git a/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/threed/obj/OBJReader.java b/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/threed/obj/OBJReader.java
+new file mode 100644
+index 0000000..4866270
+--- /dev/null
++++ b/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/threed/obj/OBJReader.java
+@@ -0,0 +1,269 @@
++/*
++ * 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.examples.io.threed.obj;
++
++import java.io.File;
++import java.io.IOException;
++import java.io.InputStreamReader;
++import java.io.Reader;
++import java.net.URL;
++import java.nio.file.Files;
++
++import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
++import org.apache.commons.geometry.euclidean.threed.Vector3D;
++import org.apache.commons.geometry.euclidean.threed.mesh.SimpleTriangleMesh;
++import org.apache.commons.geometry.euclidean.threed.mesh.TriangleMesh;
++
++/** Class for reading {@link TriangleMesh} objects from OBJ files. Only vertex and face definitions
++ * are read from the input; other OBJ keywords are ignored.
++ *
++ * <p>Instances of this class are <em>not</em> thread-safe.</p>
++ * @see <a href="https://en.wikipedia.org/wiki/Wavefront_.obj_file">Wavefront .obj file</a>
++ */
++public final class OBJReader {
++
++    /** Character buffer size. */
++    private static final int BUFFER_SIZE = 2048;
++
++    /** Builder object used to construct the mesh. */
++    private SimpleTriangleMesh.Builder meshBuilder;
++
++    /** Read a {@link TriangleMesh} from the given OBJ file. The file is read using the UTF-8 charset.
++     * @param file file to read from
++     * @param precision precision context to use in the created mesh
++     * @return a new mesh object
++     * @throws IOException if an IO operation fails
++     * @throws IllegalArgumentException if invalid OBj syntax is encountered in the input
++     */
++    public TriangleMesh readTriangleMesh(final File file, final DoublePrecisionContext precision) throws IOException {
++        try (Reader reader = Files.newBufferedReader(file.toPath(), OBJConstants.DEFAULT_CHARSET)) {
++            return readTriangleMesh(reader, precision);
++        }
++    }
++
++    /** Read a {@link TriangleMesh} from the given url representing an OBJ file. The input is read using the
++     * UTF-8 charset.
++     * @param url url to read from
++     * @param precision precision context to use in the created mesh
++     * @return a new mesh object
++     * @throws IOException if an IO operation fails
++     * @throws IllegalArgumentException if invalid OBj syntax is encountered in the input
++     */
++    public TriangleMesh readTriangleMesh(final URL url, final DoublePrecisionContext precision) throws IOException {
++        try (InputStreamReader reader = new InputStreamReader(url.openStream(), OBJConstants.DEFAULT_CHARSET)) {
++            return readTriangleMesh(reader, precision);
++        }
++    }
++
++    /** Read a {@link TriangleMesh} from the given reader. The reader is not closed.
++     * @param reader the reader to read input from
++     * @param precision precision context to use in the created mesh
++     * @return a new mesh object
++     * @throws IOException if an IO operation fails
++     * @throws IllegalArgumentException if invalid OBj syntax is encountered in the input
++     */
++    public TriangleMesh readTriangleMesh(final Reader reader, final DoublePrecisionContext precision)
++            throws IOException {
++        meshBuilder = SimpleTriangleMesh.builder(precision);
++
++        parse(reader);
++
++        final TriangleMesh mesh = meshBuilder.build();
++        meshBuilder = null;
++
++        return mesh;
++    }
++
++    /** Parse the input from the reader.
++     * @param reader reader to read from
++     * @throws IOException if an IO error occurs
++     * @throws IllegalArgumentException if invalid OBj syntax is encountered
++     */
++    private void parse(final Reader reader) throws IOException {
++        final char[] buffer = new char[BUFFER_SIZE];
++        final StringBuilder sb = new StringBuilder();
++
++        char ch;
++        int read;
++        while ((read = reader.read(buffer, 0, buffer.length)) > 0) {
++
++            for (int i = 0; i < read; ++i) {
++                ch = buffer[i];
++
++                if (ch == '\r' || ch == '\n') {
++                    if (sb.length() > 0) {
++                        parseLine(sb.toString());
++
++                        sb.delete(0, sb.length());
++                    }
++                } else {
++                    sb.append(ch);
++                }
++            }
++        }
++
++        if (sb.length() > 0) {
++            parseLine(sb.toString());
++        }
++    }
++
++    /** Parse a line read from the input.
++     * @param line line to parse
++     * @throws IllegalArgumentException if invalid OBj syntax is encountered
++     */
++    private void parseLine(final String line) {
++        // advance past any preceding whitespace
++        int startIdx = 0;
++        while (startIdx < line.length() && Character.isWhitespace(line.charAt(startIdx))) {
++            ++startIdx;
++        }
++
++        if (startIdx >= line.length() || line.charAt(startIdx) == OBJConstants.COMMENT_START_CHAR) {
++            return; // skip
++        }
++
++        final int idx = nextWhitespace(line, startIdx);
++        if (idx > -1) {
++            final String keyword = line.substring(startIdx, idx);
++            final String remainder = line.substring(idx + 1).trim();
++
++            // we're only interested in vertex and face lines; ignore everything else
++            if (OBJConstants.VERTEX_KEYWORD.equals(keyword)) {
++                parseVertexLine(remainder);
++            } else if (OBJConstants.FACE_KEYWORD.equals(keyword)) {
++                parseFaceLine(remainder);
++            }
++        }
++    }
++
++    /** Parse a vertex definition line.
++     * @param line line content, excluding the initial vertex keyword
++     * @throws IllegalArgumentException if invalid OBj syntax is encountered
++     */
++    private void parseVertexLine(final String line) {
++        final String[] parts = splitOnWhitespace(line);
++        if (parts.length < 3) {
++            throw new IllegalArgumentException(
++                    "Invalid vertex definition: at least 3 fields required but found only " + parts.length);
++        }
++
++        final double x = Double.parseDouble(parts[0]);
++        final double y = Double.parseDouble(parts[1]);
++        final double z = Double.parseDouble(parts[2]);
++
++        addVertex(Vector3D.of(x, y, z));
++    }
++
++    /** Add a vertex to the constructed mesh.
++     * @param vertex vertex to add
++     */
++    private void addVertex(final Vector3D vertex) {
++        meshBuilder.addVertex(vertex);
++    }
++
++    /** Parse a face definition line.
++     * @param line line content, excluding the initial face keyword
++     * @throws IllegalArgumentException if invalid OBj syntax is encountered
++     */
++    private void parseFaceLine(final String line) {
++        final String[] parts = splitOnWhitespace(line);
++        if (parts.length < 3) {
++            throw new IllegalArgumentException(
++                    "Invalid face definition: at least 3 fields required but found only " + parts.length);
++        }
++
++        // use a simple triangle fan if more than 3 vertices are given
++        final int startIdx = parseFaceVertexIndex(parts[0]);
++        int prevIdx = parseFaceVertexIndex(parts[1]);
++        int curIdx;
++        for (int i = 2; i < parts.length; ++i) {
++            curIdx = parseFaceVertexIndex(parts[i]);
++            addFace(startIdx, prevIdx, curIdx);
++
++            prevIdx = curIdx;
++        }
++    }
++
++    /** Parse the vertex index from a face vertex definition of the form {@code v/vt/vn},
++     * where {@code v} is the vertex index, {@code vt} is the vertex texture coordinate, and
++     * {@code vn} is the vertex normal index. The texture coordinate and normal are optional and
++     * are ignored by this class.
++     * @param str string to parse
++     * @return the face vertex index
++     */
++    private int parseFaceVertexIndex(final String str) {
++        final int sepIdx = str.indexOf(OBJConstants.FACE_VALUE_SEP_CHAR);
++        final String vertexIdxStr = sepIdx > -1 ?
++                str.substring(0, sepIdx) :
++                str;
++
++        return Integer.parseInt(vertexIdxStr);
++    }
++
++    /** Add a face to the constructed mesh.
++     * @param index1 first vertex index, in OBJ format
++     * @param index2 second vertex index, in OBJ format
++     * @param index3 third vertex index, in OBJ format
++     */
++    private void addFace(final int index1, final int index2, final int index3) {
++        meshBuilder.addFace(
++                adjustVertexIndex(index1),
++                adjustVertexIndex(index2),
++                adjustVertexIndex(index3));
++    }
++
++    /** Adjust a vertex index from the OBJ format to array index format. OBJ vertex indices
++     * are 1-based and are allowed to be negative to refer to indices added most recently. For
++     * example, index {@code 1} refers to the first added vertex and {@code -1} refers to the
++     * most recently added vertex.
++     * @param index index to adjust
++     * @return the adjusted 0-based index
++     */
++    private int adjustVertexIndex(final int index) {
++        if (index < 0) {
++            // relative index from end
++            return meshBuilder.getVertexCount() + index;
++        }
++
++        // convert from 1-based to 0-based
++        return index - 1;
++    }
++
++    /** Find the index of the next whitespace character in the string.
++     * @param str string to search
++     * @param startIdx index to begin the search
++     * @return the index of the next whitespace character or null if not found
++     */
++    private int nextWhitespace(final String str, final int startIdx) {
++        final int len = str.length();
++        for (int i = startIdx; i < len; ++i) {
++            if (Character.isWhitespace(str.charAt(i))) {
++                return i;
++            }
++        }
++
++        return -1;
++    }
++
++    /** Split the given string on whitespace characters.
++     * @param str string to split
++     * @return the split string sections
++     */
++    private String[] splitOnWhitespace(final String str) {
++        return str.split("\\s+");
++    }
++}
+diff --git a/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/threed/obj/OBJWriter.java b/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/threed/obj/OBJWriter.java
+new file mode 100644
+index 0000000..3754ab8
+--- /dev/null
++++ b/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/threed/obj/OBJWriter.java
+@@ -0,0 +1,265 @@
++/*
++ * 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.examples.io.threed.obj;
++
++import java.io.File;
++import java.io.IOException;
++import java.io.Writer;
++import java.nio.file.Files;
++import java.text.DecimalFormat;
++import java.util.Iterator;
++import java.util.List;
++import java.util.stream.Stream;
++
++import org.apache.commons.geometry.euclidean.threed.BoundarySource3D;
++import org.apache.commons.geometry.euclidean.threed.PlaneConvexSubset;
++import org.apache.commons.geometry.euclidean.threed.Vector3D;
++import org.apache.commons.geometry.euclidean.threed.mesh.Mesh;
++
++/** Class for writing OBJ files containing 3D mesh data.
++ */
++public final class OBJWriter implements AutoCloseable {
++
++    /** Space character. */
++    private static final char SPACE = ' ';
++
++    /** The default maximum number of fraction digits in formatted numbers. */
++    private static final int DEFAULT_MAXIMUM_FRACTION_DIGITS = 6;
++
++    /** The default line separator value. This is not directly specified by the OBJ format
++     * but the value used here matches that
++     * <a href="https://docs.blender.org/manual/en/2.80/addons/io_scene_obj.html">used by Blender</a>.
++     */
++    private static final String DEFAULT_LINE_SEPARATOR = "\n";
++
++    /** Underlying writer instance. */
++    private Writer writer;
++
++    /** Line separator string. */
++    private String lineSeparator = DEFAULT_LINE_SEPARATOR;
++
++    /** Decimal formatter. */
++    private DecimalFormat decimalFormat;
++
++    /** Number of vertices written to the output. */
++    private int vertexCount = 0;
++
++    /** Create a new instance for writing to the given file.
++     * @param file file to write to
++     * @throws IOException if an IO operation fails
++     */
++    public OBJWriter(final File file) throws IOException {
++        this(Files.newBufferedWriter(file.toPath(), OBJConstants.DEFAULT_CHARSET));
++    }
++
++    /** Create a new instance that writes output with the given writer.
++     * @param writer writer used to write output
++     */
++    public OBJWriter(final Writer writer) {
++        this.writer = writer;
++
++        this.decimalFormat = new DecimalFormat();
++        this.decimalFormat.setMaximumFractionDigits(DEFAULT_MAXIMUM_FRACTION_DIGITS);
++    }
++
++    /** Get the current line separator. This value defaults to {@value #DEFAULT_LINE_SEPARATOR}.
++     * @return the current line separator
++     */
++    public String getLineSeparator() {
++        return lineSeparator;
++    }
++
++    /** Set the line separator.
++     * @param lineSeparator the line separator to use
++     */
++    public void setLineSeparator(final String lineSeparator) {
++        this.lineSeparator = lineSeparator;
++    }
++
++    /** Get the {@link DecimalFormat} instance used to format floating point output.
++     * @return the decimal format instance
++     */
++    public DecimalFormat getDecimalFormat() {
++        return decimalFormat;
++    }
++
++    /** Set the {@link DecimalFormat} instance used to format floatin point output.
++     * @param decimalFormat decimal format instance
++     */
++    public void setDecimalFormat(final DecimalFormat decimalFormat) {
++        this.decimalFormat = decimalFormat;
++    }
++
++    /** Write an OBJ comment with the given value.
++     * @param comment comment to write
++     * @throws IOException if an IO operation fails
++     */
++    public void writeComment(final String comment) throws IOException {
++        for (final String line : comment.split("\r?\n")) {
++            writer.write(OBJConstants.COMMENT_START_CHAR);
++            writer.write(SPACE);
++            writer.write(line);
++            writer.write(lineSeparator);
++        }
++    }
++
++    /** Write an object name to the output. This is metadata for the file and
++     * does not affect the geometry, although it may affect how the file content
++     * is read by other programs.
++     * @param objectName the name to write
++     * @throws IOException if an IO operation fails
++     */
++    public void writeObjectName(final String objectName) throws IOException {
++        writer.write(OBJConstants.OBJECT_KEYWORD);
++        writer.write(SPACE);
++        writer.write(objectName);
++        writer.write(lineSeparator);
++    }
++
++    /** Write a group name to the output. This is metadata for the file and
++     * does not affect the geometry, although it may affect how the file content
++     * is read by other programs.
++     * @param groupName the name to write
++     * @throws IOException if an IO operation fails
++     */
++    public void writeGroupName(final String groupName) throws IOException {
++        writer.write(OBJConstants.GROUP_KEYWORD);
++        writer.write(SPACE);
++        writer.write(groupName);
++        writer.write(lineSeparator);
++    }
++
++    /** Write a vertex to the output. The OBJ 1-based index of the vertex is returned. This
++     * index can be used to reference the vertex in faces via {@link #writeFace(int...)}.
++     * @param vertex vertex to write
++     * @throws IOException if an IO operation fails
++     * @return the index of the written vertex in the OBJ 1-based convention
++     * @throws IOException if an IO operation fails
++     */
++    public int writeVertex(final Vector3D vertex) throws IOException {
++        writer.write(OBJConstants.VERTEX_KEYWORD);
++        writer.write(SPACE);
++        writer.write(decimalFormat.format(vertex.getX()));
++        writer.write(SPACE);
++        writer.write(decimalFormat.format(vertex.getY()));
++        writer.write(SPACE);
++        writer.write(decimalFormat.format(vertex.getZ()));
++        writer.write(lineSeparator);
++
++        return ++vertexCount;
++    }
++
++    /** Write a face with the given vertex indices, specified in the OBJ 1-based
++     * convention. Callers are responsible for ensuring that the indices are valid.
++     * @param vertexIndices vertex indices for the face, in the 1-based OBJ convention
++     * @throws IOException if an IO operation fails
++     */
++    public void writeFace(final int... vertexIndices) throws IOException {
++        writeFaceWithVertexOffset(0, vertexIndices);
++    }
++
++    /** Write the boundaries present in the given boundary source. If the argument is a {@link Mesh},
++     * it is written using {@link #writeMesh(Mesh)}. Otherwise, each boundary is written to the output
++     * separately.
++     * @param boundarySource boundary source containing the boundaries to write to the output
++     * @throws IllegalArgumentException if any boundary in the argument is infinite
++     * @throws IOException if an IO operation fails
++     */
++    public void writeBoundaries(final BoundarySource3D boundarySource) throws IOException {
++        if (boundarySource instanceof Mesh) {
++            writeMesh((Mesh<?>) boundarySource);
++        } else {
++            try (Stream<PlaneConvexSubset> stream = boundarySource.boundaryStream()) {
++                writeBoundaries(stream.iterator());
++            }
++        }
++    }
++
++    /** Write the boundaries in the argument to the output. Each boundary is written separately.
++     * @param it boundary iterator
++     * @throws IllegalArgumentException if any boundary in the argument is infinite
++     * @throws IOException if an IO operation fails
++     */
++    private void writeBoundaries(final Iterator<PlaneConvexSubset> it) throws IOException {
++        PlaneConvexSubset boundary;
++        List<Vector3D> vertices;
++        int[] vertexIndices;
++
++        while (it.hasNext()) {
++            boundary = it.next();
++            if (boundary.isInfinite()) {
++                throw new IllegalArgumentException("OBJ input geometry cannot be infinite: " + boundary);
++            }
++
++            vertices = boundary.getVertices();
++            vertexIndices = new int[vertices.size()];
++
++            for (int i = 0; i < vertexIndices.length; ++i) {
++                vertexIndices[i] = writeVertex(vertices.get(i));
++            }
++
++            writeFace(vertexIndices);
++        }
++    }
++
++    /** Write a mesh to the output.
++     * @param mesh the mesh to write
++     * @throws IOException if an IO operation fails
++     */
++    public void writeMesh(final Mesh<?> mesh) throws IOException {
++        final int vertexOffset = vertexCount + 1;
++
++        for (final Vector3D vertex : mesh.vertices()) {
++            writeVertex(vertex);
++        }
++
++        for (final Mesh.Face face : mesh.faces()) {
++            writeFaceWithVertexOffset(vertexOffset, face.getVertexIndices());
++        }
++    }
++
++    /** Write a face with the given vertex offset value and indices. The offset is added to each
++     * index before being written.
++     * @param vertexOffset vertex offset value
++     * @param vertexIndices vertex indices for the face
++     * @throws IOException if an IO operation fails
++     */
++    private void writeFaceWithVertexOffset(final int vertexOffset, final int... vertexIndices)
++            throws IOException {
++        if (vertexIndices.length < 3) {
++            throw new IllegalArgumentException("Face must have more than 3 vertices; found " + vertexIndices.length);
++        }
++
++        writer.write(OBJConstants.FACE_KEYWORD);
++
++        for (int i = 0; i < vertexIndices.length; ++i) {
++            writer.write(SPACE);
++            writer.write(String.valueOf(vertexIndices[i] + vertexOffset));
++        }
++
++        writer.write(lineSeparator);
++    }
++
++    /** {@inheritDoc} */
++    @Override
++    public void close() throws IOException {
++        if (writer != null) {
++            writer.close();
++        }
++        writer = null;
++    }
++}
+diff --git a/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/package-info.java b/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/threed/obj/package-info.java
+similarity index 79%
+copy from commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/package-info.java
+copy to commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/threed/obj/package-info.java
+index 62e0555..e37ecd2 100644
+--- a/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/package-info.java
++++ b/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/threed/obj/package-info.java
+@@ -15,7 +15,7 @@
+  * limitations under the License.
+  */
+ 
+-/**
+- * <h3>Persistent storage of shapes.</h3>
++/** Classes for working with the OBJ 3D model format.
++ * @see <a href="https://en.wikipedia.org/wiki/Wavefront_.obj_file">Wavefront .obj file</a>
+  */
+-package org.apache.commons.geometry.examples.io;
++package org.apache.commons.geometry.examples.io.threed.obj;
+diff --git a/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/package-info.java b/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/threed/package-info.java
+similarity index 88%
+copy from commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/package-info.java
+copy to commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/threed/package-info.java
+index 62e0555..bb4c43b 100644
+--- a/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/package-info.java
++++ b/commons-geometry-examples/examples-io/src/main/java/org/apache/commons/geometry/examples/io/threed/package-info.java
+@@ -15,7 +15,6 @@
+  * limitations under the License.
+  */
+ 
+-/**
+- * <h3>Persistent storage of shapes.</h3>
++/** Input/output functionality for 3D models.
+  */
+-package org.apache.commons.geometry.examples.io;
++package org.apache.commons.geometry.examples.io.threed;
+diff --git a/commons-geometry-examples/examples-io/src/test/java/org/apache/commons/geometry/examples/io/threed/DefaultModelIOHandlerRegistryTest.java b/commons-geometry-examples/examples-io/src/test/java/org/apache/commons/geometry/examples/io/threed/DefaultModelIOHandlerRegistryTest.java
+new file mode 100644
+index 0000000..1421bcb
+--- /dev/null
++++ b/commons-geometry-examples/examples-io/src/test/java/org/apache/commons/geometry/examples/io/threed/DefaultModelIOHandlerRegistryTest.java
+@@ -0,0 +1,86 @@
++/*
++ * 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.examples.io.threed;
++
++import java.io.ByteArrayInputStream;
++import java.io.ByteArrayOutputStream;
++import java.util.List;
++import java.util.stream.Collectors;
++
++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.BoundarySource3D;
++import org.apache.commons.geometry.euclidean.threed.Planes;
++import org.apache.commons.geometry.euclidean.threed.Triangle3D;
++import org.apache.commons.geometry.euclidean.threed.Vector3D;
++import org.junit.Assert;
++import org.junit.Test;
++
++public class DefaultModelIOHandlerRegistryTest {
++
++    private static final double TEST_EPS = 1e-10;
++
++    private static final DoublePrecisionContext TEST_PRECISION =
++            new EpsilonDoublePrecisionContext(TEST_EPS);
++
++    private DefaultModelIOHandlerRegistry registry = new DefaultModelIOHandlerRegistry();
++
++    @Test
++    public void testDefaultHandlers() {
++        // act
++        List<ModelIOHandler> handlers = registry.getHandlers();
++
++        // assert
++        Assert.assertEquals(1, handlers.size());
++    }
++
++    @Test
++    public void testSupportedTypes() {
++        // act/assert
++        Assert.assertTrue(registry.handlesType("obj"));
++        Assert.assertTrue(registry.handlesType("OBJ"));
++    }
++
++    @Test
++    public void testReadWrite_supportedTypes() {
++        // act/assert
++        checkWriteRead("obj");
++    }
++
++    private void checkWriteRead(String type) {
++        // arrange
++        BoundarySource3D model = BoundarySource3D.from(
++                Planes.triangleFromVertices(Vector3D.ZERO, Vector3D.of(1, 0, 0), Vector3D.of(0, 1, 0), TEST_PRECISION)
++            );
++
++        ByteArrayOutputStream out = new ByteArrayOutputStream();
++
++        // act
++        registry.write(model, type, out);
++        BoundarySource3D result = registry.read(type, new ByteArrayInputStream(out.toByteArray()), TEST_PRECISION);
++
++        // assert
++        List<Triangle3D> tris = result.triangleStream().collect(Collectors.toList());
++        Assert.assertEquals(1, tris.size());
++
++        Triangle3D tri = tris.get(0);
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.ZERO, tri.getPoint1(), TEST_EPS);
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 0, 0), tri.getPoint2(), TEST_EPS);
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 1, 0), tri.getPoint3(), TEST_EPS);
++    }
++}
+diff --git a/commons-geometry-examples/examples-io/src/test/java/org/apache/commons/geometry/examples/io/threed/ModelIOHandlerRegistryTest.java b/commons-geometry-examples/examples-io/src/test/java/org/apache/commons/geometry/examples/io/threed/ModelIOHandlerRegistryTest.java
+new file mode 100644
+index 0000000..b32c719
+--- /dev/null
++++ b/commons-geometry-examples/examples-io/src/test/java/org/apache/commons/geometry/examples/io/threed/ModelIOHandlerRegistryTest.java
+@@ -0,0 +1,339 @@
++/*
++ * 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.examples.io.threed;
++
++import java.io.ByteArrayInputStream;
++import java.io.ByteArrayOutputStream;
++import java.io.File;
++import java.io.InputStream;
++import java.io.OutputStream;
++import java.util.Arrays;
++import java.util.List;
++
++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.threed.BoundarySource3D;
++import org.junit.Assert;
++import org.junit.Test;
++
++public class ModelIOHandlerRegistryTest {
++
++    private static final double TEST_EPS = 1e-10;
++
++    private static final DoublePrecisionContext TEST_PRECISION =
++            new EpsilonDoublePrecisionContext(TEST_EPS);
++
++    private static final BoundarySource3D SRC_A = BoundarySource3D.from();
++
++    private static final BoundarySource3D SRC_B = BoundarySource3D.from();
++
++    private ModelIOHandlerRegistry registry = new ModelIOHandlerRegistry();
++
++    @Test
++    public void testGetSetHandlers() {
++        // arrange
++        StubHandler handlerA = new StubHandler("a", SRC_A);
++        StubHandler handlerB = new StubHandler("b", SRC_B);
++
++        List<ModelIOHandler> handlers = Arrays.asList(handlerA, handlerB);
++
++        // act
++        registry.setHandlers(handlers);
++
++        // assert
++        List<ModelIOHandler> resultHandlers = registry.getHandlers();
++        Assert.assertNotSame(handlers, resultHandlers);
++        Assert.assertEquals(2, resultHandlers.size());
++
++        Assert.assertSame(handlerA, resultHandlers.get(0));
++        Assert.assertSame(handlerB, resultHandlers.get(1));
++    }
++
++    @Test
++    public void testSetHandlers_null() {
++        // arrange
++        StubHandler handlerA = new StubHandler("a", SRC_A);
++        StubHandler handlerB = new StubHandler("b", SRC_B);
++
++        registry.setHandlers(Arrays.asList(handlerA, handlerB));
++
++        // act
++        registry.setHandlers(null);
++
++        // assert
++        Assert.assertEquals(0, registry.getHandlers().size());
++    }
++
++    @Test
++    public void testGetHandlerForType() {
++        // arrange
++        StubHandler handlerA = new StubHandler("a", SRC_A);
++        StubHandler handlerB = new StubHandler("b", SRC_B);
++
++        registry.setHandlers(Arrays.asList(handlerA, handlerB));
++
++        // act/assert
++        Assert.assertSame(handlerA, registry.getHandlerForType("a"));
++        Assert.assertSame(handlerB, registry.getHandlerForType("b"));
++
++        Assert.assertNull(registry.getHandlerForType(null));
++        Assert.assertNull(registry.getHandlerForType(""));
++        Assert.assertNull(registry.getHandlerForType(" "));
++        Assert.assertNull(registry.getHandlerForType("nope"));
++    }
++
++    @Test
++    public void testHandlesType() {
++        // arrange
++        StubHandler handlerA = new StubHandler("a", SRC_A);
++        StubHandler handlerB = new StubHandler("b", SRC_B);
++
++        registry.setHandlers(Arrays.asList(handlerA, handlerB));
++
++        // act/assert
++        Assert.assertTrue(registry.handlesType("a"));
++        Assert.assertTrue(registry.handlesType("b"));
++
++        Assert.assertFalse(registry.handlesType(null));
++        Assert.assertFalse(registry.handlesType(""));
++        Assert.assertFalse(registry.handlesType(" "));
++        Assert.assertFalse(registry.handlesType("nope"));
++    }
++
++    @Test
++    public void testRead_typeFromFileExtension() {
++        // arrange
++        StubHandler handlerA = new StubHandler("a", SRC_A);
++        StubHandler handlerB = new StubHandler("b", SRC_B);
++
++        registry.setHandlers(Arrays.asList(handlerA, handlerB));
++
++        File file = new File("file.B");
++
++        // act
++        BoundarySource3D src = registry.read(file, TEST_PRECISION);
++
++        // assert
++        Assert.assertSame(SRC_B, src);
++    }
++
++    @Test
++    public void testRead_typeFromFileExtension_unknownType() {
++        // arrange
++        File file = new File("file.B");
++
++        // act/assert
++        GeometryTestUtils.assertThrows(() -> {
++            registry.read(file, TEST_PRECISION);
++        }, IllegalArgumentException.class, "No handler found for type \"b\"");
++    }
++
++    @Test
++    public void testRead_typeFromFileExtension_noFileExtension() {
++        // arrange
++        File file = new File("file");
++
++        // act/assert
++        GeometryTestUtils.assertThrows(() -> {
++            registry.read(file, TEST_PRECISION);
++        }, IllegalArgumentException.class,
++                "Cannot determine target file type: \"file\" does not have a file extension");
++    }
++
++    @Test
++    public void testRead_typeAndFile() {
++        // arrange
++        StubHandler handlerA = new StubHandler("a", SRC_A);
++        StubHandler handlerB = new StubHandler("b", SRC_B);
++
++        registry.setHandlers(Arrays.asList(handlerA, handlerB));
++
++        // act
++        BoundarySource3D src = registry.read("a", new File("file"), TEST_PRECISION);
++
++        // assert
++        Assert.assertSame(SRC_A, src);
++    }
++
++    @Test
++    public void testRead_typeAndFile_unknownType() {
++        // arrange
++        File file = new File("file");
++
++        // act/assert
++        GeometryTestUtils.assertThrows(() -> {
++            registry.read("nope", file, TEST_PRECISION);
++        }, IllegalArgumentException.class, "No handler found for type \"nope\"");
++    }
++
++    @Test
++    public void testRead_typeAndInputStream() {
++        // arrange
++        StubHandler handlerA = new StubHandler("a", SRC_A);
++        StubHandler handlerB = new StubHandler("b", SRC_B);
++
++        registry.setHandlers(Arrays.asList(handlerA, handlerB));
++
++        // act
++        BoundarySource3D src = registry.read("a", new ByteArrayInputStream(new byte[0]), TEST_PRECISION);
++
++        // assert
++        Assert.assertSame(SRC_A, src);
++    }
++
++    @Test
++    public void testRead_typeAndInputStream_unknownType() {
++        // act/assert
++        GeometryTestUtils.assertThrows(() -> {
++            registry.read("nope", new ByteArrayInputStream(new byte[0]), TEST_PRECISION);
++        }, IllegalArgumentException.class, "No handler found for type \"nope\"");
++    }
++
++    @Test
++    public void testWrite_typeFromFileExtension() {
++        // arrange
++        StubHandler handlerA = new StubHandler("a", SRC_A);
++        StubHandler handlerB = new StubHandler("b", SRC_B);
++
++        registry.setHandlers(Arrays.asList(handlerA, handlerB));
++
++        File file = new File("file.B");
++
++        // act
++        registry.write(SRC_B, file);
++
++        // assert
++        Assert.assertNull(handlerA.outputBoundarySrc);
++        Assert.assertSame(SRC_B, handlerB.outputBoundarySrc);
++    }
++
++    @Test
++    public void testWrite_typeFromFileExtension_unknownType() {
++        // arrange
++        File file = new File("file.B");
++
++        // act/assert
++        GeometryTestUtils.assertThrows(() -> {
++            registry.write(SRC_A, file);
++        }, IllegalArgumentException.class, "No handler found for type \"b\"");
++    }
++
++    @Test
++    public void testWrite_typeFromFileExtension_noFileExtension() {
++        // arrange
++        File file = new File("file");
++
++        // act/assert
++        GeometryTestUtils.assertThrows(() -> {
++            registry.write(SRC_A, file);
++        }, IllegalArgumentException.class,
++                "Cannot determine target file type: \"file\" does not have a file extension");
++    }
++
++    @Test
++    public void testWrite_typeAndFile() {
++        // arrange
++        StubHandler handlerA = new StubHandler("a", SRC_A);
++        StubHandler handlerB = new StubHandler("b", SRC_B);
++
++        registry.setHandlers(Arrays.asList(handlerA, handlerB));
++
++        File file = new File("file.B");
++
++        // act
++        registry.write(SRC_B, "a", file);
++
++        // assert
++        Assert.assertSame(SRC_B, handlerA.outputBoundarySrc);
++        Assert.assertNull(handlerB.outputBoundarySrc);
++    }
++
++    @Test
++    public void testWrite_typeAndFile_unknownType() {
++        // arrange
++        File file = new File("file");
++
++        // act/assert
++        GeometryTestUtils.assertThrows(() -> {
++            registry.write(SRC_A, "nope", file);
++        }, IllegalArgumentException.class, "No handler found for type \"nope\"");
++    }
++
++    @Test
++    public void testWrite_typeAndOutputStream() {
++        // arrange
++        StubHandler handlerA = new StubHandler("a", SRC_A);
++        StubHandler handlerB = new StubHandler("b", SRC_B);
++
++        registry.setHandlers(Arrays.asList(handlerA, handlerB));
++
++        // act
++        registry.write(SRC_B, "a", new ByteArrayOutputStream());
++
++        // assert
++        Assert.assertSame(SRC_B, handlerA.outputBoundarySrc);
++        Assert.assertNull(handlerB.outputBoundarySrc);
++    }
++
++    @Test
++    public void testWrite_typeAndOutputStream_unknownType() {
++        // act/assert
++        GeometryTestUtils.assertThrows(() -> {
++            registry.write(SRC_A, "nope", new ByteArrayOutputStream());
++        }, IllegalArgumentException.class, "No handler found for type \"nope\"");
++    }
++
++    private static final class StubHandler implements ModelIOHandler {
++
++        private final String handlerType;
++
++        private final BoundarySource3D boundarySrc;
++
++        private BoundarySource3D outputBoundarySrc;
++
++        StubHandler(final String type, final BoundarySource3D boundarySrc) {
++            this.handlerType = type;
++            this.boundarySrc = boundarySrc;
++        }
++
++        @Override
++        public boolean handlesType(String type) {
++            return this.handlerType.equals(type);
++        }
++
++        @Override
++        public BoundarySource3D read(String type, File in, DoublePrecisionContext precision) {
++            return boundarySrc;
++        }
++
++        @Override
++        public BoundarySource3D read(String type, InputStream in, DoublePrecisionContext precision) {
++            return boundarySrc;
++        }
++
++        @Override
++        public void write(BoundarySource3D model, String type, File out) {
++            outputBoundarySrc = model;
++        }
++
++        @Override
++        public void write(BoundarySource3D model, String type, OutputStream out) {
++            outputBoundarySrc = model;
++        }
++    }
++}
+diff --git a/commons-geometry-examples/examples-io/src/test/java/org/apache/commons/geometry/examples/io/threed/ModelIOTest.java b/commons-geometry-examples/examples-io/src/test/java/org/apache/commons/geometry/examples/io/threed/ModelIOTest.java
+new file mode 100644
+index 0000000..c520634
+--- /dev/null
++++ b/commons-geometry-examples/examples-io/src/test/java/org/apache/commons/geometry/examples/io/threed/ModelIOTest.java
+@@ -0,0 +1,120 @@
++/*
++ * 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.examples.io.threed;
++
++import java.io.File;
++import java.io.IOException;
++import java.io.InputStream;
++import java.io.OutputStream;
++import java.nio.file.Files;
++import java.util.List;
++import java.util.stream.Collectors;
++
++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.BoundarySource3D;
++import org.apache.commons.geometry.euclidean.threed.Planes;
++import org.apache.commons.geometry.euclidean.threed.Triangle3D;
++import org.apache.commons.geometry.euclidean.threed.Vector3D;
++import org.junit.Assert;
++import org.junit.Rule;
++import org.junit.Test;
++import org.junit.rules.TemporaryFolder;
++
++public class ModelIOTest {
++
++    private static final double TEST_EPS = 1e-10;
++
++    private static final DoublePrecisionContext TEST_PRECISION =
++            new EpsilonDoublePrecisionContext(TEST_EPS);
++
++    @Rule
++    public TemporaryFolder tempFolder = new TemporaryFolder();
++
++    @Test
++    public void testGetHandler() {
++        // act
++        ModelIOHandlerRegistry registry = ModelIO.getModelIOHandlerRegistry();
++
++        // assert
++        Assert.assertTrue(registry instanceof DefaultModelIOHandlerRegistry);
++        Assert.assertSame(registry, ModelIO.getModelIOHandlerRegistry());
++    }
++
++    @Test
++    public void testWriteRead_typeFromFileExtension() throws IOException {
++        // act/assert
++        checkWriteRead(model -> {
++            File file = new File(tempFolder.getRoot(), "model.obj");
++
++            ModelIO.write(model, file);
++            return ModelIO.read(file, TEST_PRECISION);
++        });
++    }
++
++    @Test
++    public void testWriteRead_typeAndFile() throws IOException {
++        // act/assert
++        checkWriteRead(model -> {
++            File file = new File(tempFolder.getRoot(), "objmodel");
++
++            ModelIO.write(model, "OBJ", file);
++            return ModelIO.read("obj", file, TEST_PRECISION);
++        });
++    }
++
++    @Test
++    public void testWriteRead_typeAndStream() throws IOException {
++        // act/assert
++        checkWriteRead(model -> {
++            File file = new File(tempFolder.getRoot(), "objmodel");
++
++            try (OutputStream out = Files.newOutputStream(file.toPath())) {
++                ModelIO.write(model, "OBJ", out);
++            }
++
++            try (InputStream in = Files.newInputStream(file.toPath())) {
++                return ModelIO.read("OBJ", in, TEST_PRECISION);
++            }
++        });
++    }
++
++    @FunctionalInterface
++    private interface ModelIOFunction {
++        BoundarySource3D apply(BoundarySource3D model) throws IOException;
++    }
++
++    private void checkWriteRead(ModelIOFunction fn) throws IOException {
++        // arrange
++        BoundarySource3D model = BoundarySource3D.from(
++                Planes.triangleFromVertices(Vector3D.ZERO, Vector3D.of(1, 0, 0), Vector3D.of(0, 1, 0), TEST_PRECISION)
++            );
++
++        // act
++        BoundarySource3D result = fn.apply(model);
++
++        // assert
++        List<Triangle3D> tris = result.triangleStream().collect(Collectors.toList());
++        Assert.assertEquals(1, tris.size());
++
++        Triangle3D tri = tris.get(0);
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.ZERO, tri.getPoint1(), TEST_EPS);
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 0, 0), tri.getPoint2(), TEST_EPS);
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 1, 0), tri.getPoint3(), TEST_EPS);
++    }
++}
+diff --git a/commons-geometry-examples/examples-io/src/test/java/org/apache/commons/geometry/examples/io/threed/obj/OBJModelIOHandlerTest.java b/commons-geometry-examples/examples-io/src/test/java/org/apache/commons/geometry/examples/io/threed/obj/OBJModelIOHandlerTest.java
+new file mode 100644
+index 0000000..c980d95
+--- /dev/null
++++ b/commons-geometry-examples/examples-io/src/test/java/org/apache/commons/geometry/examples/io/threed/obj/OBJModelIOHandlerTest.java
+@@ -0,0 +1,258 @@
++/*
++ * 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.examples.io.threed.obj;
++
++import java.io.ByteArrayInputStream;
++import java.io.ByteArrayOutputStream;
++import java.io.File;
++import java.io.IOException;
++import java.io.InputStream;
++import java.io.OutputStream;
++import java.io.UncheckedIOException;
++import java.net.URL;
++import java.nio.file.Files;
++
++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.threed.BoundarySource3D;
++import org.apache.commons.geometry.euclidean.threed.Planes;
++import org.apache.commons.geometry.euclidean.threed.Vector3D;
++import org.apache.commons.geometry.euclidean.threed.mesh.TriangleMesh;
++import org.junit.Assert;
++import org.junit.Rule;
++import org.junit.Test;
++import org.junit.rules.TemporaryFolder;
++
++public class OBJModelIOHandlerTest {
++
++    private static final double TEST_EPS = 1e-10;
++
++    private static final DoublePrecisionContext TEST_PRECISION =
++            new EpsilonDoublePrecisionContext(TEST_EPS);
++
++    private static final String CUBE_MINUS_SPHERE_MODEL = "/models/cube-minus-sphere.obj";
++
++    private static final int CUBE_MINUS_SPHERE_VERTICES = 1688;
++
++    private static final int CUBE_MINUS_SPHERE_FACES = 728;
++
++    @Rule
++    public TemporaryFolder tempFolder = new TemporaryFolder();
++
++    private OBJModelIOHandler handler = new OBJModelIOHandler();
++
++    @Test
++    public void testHandlesType() {
++        // act/assert
++        Assert.assertFalse(handler.handlesType(null));
++        Assert.assertFalse(handler.handlesType(""));
++        Assert.assertFalse(handler.handlesType(" "));
++        Assert.assertFalse(handler.handlesType("abc"));
++        Assert.assertFalse(handler.handlesType("stl"));
++
++        Assert.assertTrue(handler.handlesType("obj"));
++        Assert.assertTrue(handler.handlesType("OBJ"));
++        Assert.assertTrue(handler.handlesType("oBj"));
++    }
++
++    @Test
++    public void testRead_fromFile() throws Exception {
++        // act
++        BoundarySource3D src = handler.read("obj", cubeMinusSphereFile(), TEST_PRECISION);
++
++        // assert
++        TriangleMesh mesh = (TriangleMesh) src;
++        Assert.assertEquals(CUBE_MINUS_SPHERE_VERTICES, mesh.getVertexCount());
++        Assert.assertEquals(CUBE_MINUS_SPHERE_FACES, mesh.getFaceCount());
++    }
++
++    @Test
++    public void testRead_fromFile_unsupportedType() throws Exception {
++        // arrange
++        File file = cubeMinusSphereFile();
++
++        // act/assert
++        GeometryTestUtils.assertThrows(() -> {
++            handler.read("stl", file, TEST_PRECISION);
++        }, IllegalArgumentException.class, "File type is not supported by this handler: stl");
++    }
++
++    @Test
++    public void testRead_fromFile_ioException() throws Exception {
++        // arrange
++        File file = new File("doesnotexist.obj");
++
++        // act/assert
++        GeometryTestUtils.assertThrows(() -> {
++            handler.read("obj", file, TEST_PRECISION);
++        }, UncheckedIOException.class);
++    }
++
++    @Test
++    public void testRead_fromStream() throws Exception {
++        // act
++        BoundarySource3D src;
++        try (InputStream in = Files.newInputStream(cubeMinusSphereFile().toPath())) {
++            src = handler.read("obj", cubeMinusSphereFile(), TEST_PRECISION);
++        }
++
++        // assert
++        TriangleMesh mesh = (TriangleMesh) src;
++        Assert.assertEquals(CUBE_MINUS_SPHERE_VERTICES, mesh.getVertexCount());
++        Assert.assertEquals(CUBE_MINUS_SPHERE_FACES, mesh.getFaceCount());
++    }
++
++    @Test
++    public void testRead_fromStream_unsupportedType() throws Exception {
++        // arrange
++        File file = cubeMinusSphereFile();
++
++        // act/assert
++        try (InputStream in = Files.newInputStream(file.toPath())) {
++            GeometryTestUtils.assertThrows(() -> {
++                handler.read("stl", in, TEST_PRECISION);
++            }, IllegalArgumentException.class, "File type is not supported by this handler: stl");
++        }
++    }
++
++    @Test
++    public void testRead_fromStream_ioException() throws Exception {
++        // act/assert
++        GeometryTestUtils.assertThrows(() -> {
++            handler.read("obj", new FailingInputStream(), TEST_PRECISION);
++        }, UncheckedIOException.class, "IOException: test");
++    }
++
++    @Test
++    public void testWrite_toFile() throws Exception {
++        // arrange
++        File out = tempFolder.newFile("out.obj");
++
++        BoundarySource3D src = BoundarySource3D.from(
++                Planes.triangleFromVertices(Vector3D.ZERO, Vector3D.of(1, 0, 0), Vector3D.of(0, 1, 0), TEST_PRECISION)
++            );
++
++        // act
++        handler.write(src, "OBJ", out);
++
++        // assert
++        TriangleMesh mesh = (TriangleMesh) handler.read("obj", out, TEST_PRECISION);
++        Assert.assertEquals(3, mesh.getVertexCount());
++        Assert.assertEquals(1, mesh.getFaceCount());
++    }
++
++    @Test
++    public void testWrite_toFile_unsupportedFormat() throws Exception {
++        // arrange
++        File out = tempFolder.newFile("out.obj");
++
++        BoundarySource3D src = BoundarySource3D.from(
++                Planes.triangleFromVertices(Vector3D.ZERO, Vector3D.of(1, 0, 0), Vector3D.of(0, 1, 0), TEST_PRECISION)
++            );
++
++        // act/assert
++        GeometryTestUtils.assertThrows(() -> {
++            handler.write(src, "stl", out);
++        }, IllegalArgumentException.class, "File type is not supported by this handler: stl");
++    }
++
++    @Test
++    public void testWrite_toFile_ioException() throws Exception {
++        // arrange
++        File out = tempFolder.newFolder("notafile");
++
++        BoundarySource3D src = BoundarySource3D.from(
++                Planes.triangleFromVertices(Vector3D.ZERO, Vector3D.of(1, 0, 0), Vector3D.of(0, 1, 0), TEST_PRECISION)
++            );
++
++        // act/assert
++        GeometryTestUtils.assertThrows(() -> {
++            handler.write(src, "OBJ", out);
++        }, UncheckedIOException.class);
++    }
++
++    @Test
++    public void testWrite_toStream() throws Exception {
++        // arrange
++        ByteArrayOutputStream out = new ByteArrayOutputStream();
++
++        BoundarySource3D src = BoundarySource3D.from(
++                Planes.triangleFromVertices(Vector3D.ZERO, Vector3D.of(1, 0, 0), Vector3D.of(0, 1, 0), TEST_PRECISION)
++            );
++
++        // act
++        handler.write(src, "OBJ", out);
++
++        // assert
++        TriangleMesh mesh = (TriangleMesh) handler.read("obj", new ByteArrayInputStream(out.toByteArray()),
++                TEST_PRECISION);
++        Assert.assertEquals(3, mesh.getVertexCount());
++        Assert.assertEquals(1, mesh.getFaceCount());
++    }
++
++    @Test
++    public void testWrite_toStream_unsupportedFormat() throws Exception {
++        // arrange
++        File file = tempFolder.newFile("out.obj");
++
++        BoundarySource3D src = BoundarySource3D.from(
++                Planes.triangleFromVertices(Vector3D.ZERO, Vector3D.of(1, 0, 0), Vector3D.of(0, 1, 0), TEST_PRECISION)
++            );
++
++        // act/assert
++        try (OutputStream out = Files.newOutputStream(file.toPath())) {
++            GeometryTestUtils.assertThrows(() -> {
++                handler.write(src, "stl", out);
++            }, IllegalArgumentException.class, "File type is not supported by this handler: stl");
++        }
++    }
++
++    @Test
++    public void testWrite_toStream_ioException() throws Exception {
++        // arrange
++        BoundarySource3D src = BoundarySource3D.from(
++                Planes.triangleFromVertices(Vector3D.ZERO, Vector3D.of(1, 0, 0), Vector3D.of(0, 1, 0), TEST_PRECISION)
++            );
++
++        // act/assert
++        GeometryTestUtils.assertThrows(() -> {
++            handler.write(src, "OBJ", new FailingOutputStream());
++        }, UncheckedIOException.class, "IOException: test");
++    }
++
++    private static File cubeMinusSphereFile() throws Exception {
++        URL url = OBJModelIOHandlerTest.class.getResource(CUBE_MINUS_SPHERE_MODEL);
++        return new File(url.toURI());
++    }
++
++    private static final class FailingInputStream extends InputStream {
++
++        @Override
++        public int read() throws IOException {
++            throw new IOException("test");
++        }
++    }
++
++    private static final class FailingOutputStream extends OutputStream {
++
++        @Override
++        public void write(int b) throws IOException {
++            throw new IOException("test");
++        }
++    }
++}
+diff --git a/commons-geometry-examples/examples-io/src/test/java/org/apache/commons/geometry/examples/io/threed/obj/OBJReaderTest.java b/commons-geometry-examples/examples-io/src/test/java/org/apache/commons/geometry/examples/io/threed/obj/OBJReaderTest.java
+new file mode 100644
+index 0000000..f38fdbd
+--- /dev/null
++++ b/commons-geometry-examples/examples-io/src/test/java/org/apache/commons/geometry/examples/io/threed/obj/OBJReaderTest.java
+@@ -0,0 +1,251 @@
++/*
++ * 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.examples.io.threed.obj;
++
++import java.io.File;
++import java.io.IOException;
++import java.io.StringReader;
++import java.io.UncheckedIOException;
++import java.net.URL;
++
++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.RegionBSPTree3D;
++import org.apache.commons.geometry.euclidean.threed.Triangle3D;
++import org.apache.commons.geometry.euclidean.threed.Vector3D;
++import org.apache.commons.geometry.euclidean.threed.mesh.TriangleMesh;
++import org.junit.Assert;
++import org.junit.Test;
++
++public class OBJReaderTest {
++
++    private static final double TEST_EPS = 1e-10;
++
++    private static final DoublePrecisionContext TEST_PRECISION =
++            new EpsilonDoublePrecisionContext(TEST_EPS);
++
++    private static final String CUBE_MINUS_SPHERE_MODEL = "/models/cube-minus-sphere.obj";
++
++    private static final int CUBE_MINUS_SPHERE_VERTICES = 1688;
++
++    private static final int CUBE_MINUS_SPHERE_FACES = 728;
++
++    private OBJReader reader = new OBJReader();
++
++    @Test
++    public void testReadMesh_emptyInput() throws Exception {
++        // act
++        TriangleMesh mesh = reader.readTriangleMesh(new StringReader(""), TEST_PRECISION);
++
++        // assert
++        Assert.assertEquals(0, mesh.getVertexCount());
++        Assert.assertEquals(0, mesh.getFaceCount());
++    }
++
++    @Test
++    public void testReadMesh_mixedVertexIndexTypesAndWhitespace() throws Exception {
++        // arrange
++        String input =
++            "#some comments  \n\r\n \n" +
++            " # some other comments\n" +
++            "v 0.0 0.0 0.0\n" +
++            "v 1e-1 0 0 \r\n" +
++            " v 0 1 0\n" +
++            "\tv\t0 0 1\r\n" +
++            "f 1 2 3\n" +
++            " f    -1   -2\t-3";
++
++        // act
++        TriangleMesh mesh = reader.readTriangleMesh(new StringReader(input), TEST_PRECISION);
++
++        // assert
++        Assert.assertEquals(4, mesh.getVertexCount());
++        Assert.assertEquals(2, mesh.getFaceCount());
++
++        Triangle3D t0 = mesh.getFace(0).getPolygon();
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.ZERO, t0.getPoint1(), TEST_EPS);
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0.1, 0, 0), t0.getPoint2(), TEST_EPS);
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 1, 0), t0.getPoint3(), TEST_EPS);
++
++        Triangle3D t1 = mesh.getFace(1).getPolygon();
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 0, 1), t1.getPoint1(), TEST_EPS);
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 1, 0), t1.getPoint2(), TEST_EPS);
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0.1, 0, 0), t1.getPoint3(), TEST_EPS);
++    }
++
++    @Test
++    public void testReadMesh_multipleFaceIndices_usesTriangleFan() throws Exception {
++        // arrange
++        String input =
++            "v 0 0 0\n" +
++            "v 1 0 0\n" +
++            "v 1 1 0\n" +
++            "v 0.5 1.5 0\n" +
++            "v 0 1 0\n" +
++            "f 1 2 3 -2 -1\n";
++
++        // act
++        TriangleMesh mesh = reader.readTriangleMesh(new StringReader(input), TEST_PRECISION);
++
++        // assert
++        Assert.assertEquals(5, mesh.getVertexCount());
++        Assert.assertEquals(3, mesh.getFaceCount());
++
++        Triangle3D t0 = mesh.getFace(0).getPolygon();
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.ZERO, t0.getPoint1(), TEST_EPS);
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 0, 0), t0.getPoint2(), TEST_EPS);
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 1, 0), t0.getPoint3(), TEST_EPS);
++
++        Triangle3D t1 = mesh.getFace(1).getPolygon();
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.ZERO, t1.getPoint1(), TEST_EPS);
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 1, 0), t1.getPoint2(), TEST_EPS);
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0.5, 1.5, 0), t1.getPoint3(), TEST_EPS);
++
++        Triangle3D t2 = mesh.getFace(2).getPolygon();
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.ZERO, t2.getPoint1(), TEST_EPS);
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0.5, 1.5, 0), t2.getPoint2(), TEST_EPS);
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 1, 0), t2.getPoint3(), TEST_EPS);
++    }
++
++    @Test
++    public void testReadMesh_ignoresUnsupportedContent() throws Exception {
++        // arrange
++        String input =
++            "mtllib abc.mtl\n" +
++            "nope\n" +
++            "v 0 0 0\n" +
++            "v 1 0 0\n" +
++            "v 0 1 0\n" +
++            "f 1/10/20 2//40 3//\n";
++
++        // act
++        TriangleMesh mesh = reader.readTriangleMesh(new StringReader(input), TEST_PRECISION);
++
++        // assert
++        Assert.assertEquals(3, mesh.getVertexCount());
++        Assert.assertEquals(1, mesh.getFaceCount());
++
++        Triangle3D t0 = mesh.getFace(0).getPolygon();
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.ZERO, t0.getPoint1(), TEST_EPS);
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 0, 0), t0.getPoint2(), TEST_EPS);
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 1, 0), t0.getPoint3(), TEST_EPS);
++    }
++
++    @Test
++    public void testReadMesh_invalidVertexDefinition() throws Exception {
++        // arrange
++        String badNumber =
++            "v abc 0 0\n" +
++            "v 1 0 0\n" +
++            "v 0 1 0\n" +
++            "f 1 2 3\n";
++
++        String notEnoughVertices =
++            "v 0 0\n" +
++            "v 1 0 0\n" +
++            "v 0 1 0\n" +
++            "f 1 2 3\n";
++
++        // act/assert
++        GeometryTestUtils.assertThrows(() -> {
++            try {
++                reader.readTriangleMesh(new StringReader(badNumber), TEST_PRECISION);
++            } catch (IOException exc) {
++                throw new UncheckedIOException(exc);
++            }
++        }, NumberFormatException.class);
++
++        GeometryTestUtils.assertThrows(() -> {
++            try {
++                reader.readTriangleMesh(new StringReader(notEnoughVertices), TEST_PRECISION);
++            } catch (IOException exc) {
++                throw new UncheckedIOException(exc);
++            }
++        }, IllegalArgumentException.class, "Invalid vertex definition: at least 3 fields required but found only 2");
++    }
++
++    @Test
++    public void testReadMesh_invalidFaceDefinition() throws Exception {
++        // arrange
++        String badNumber =
++            "v 0 0 0\n" +
++            "v 1 0 0\n" +
++            "v 0 1 0\n" +
++            "f 1 abc 3\n";
++
++        String notEnoughIndices =
++            "v 0 0 0\n" +
++            "v 1 0 0\n" +
++            "v 0 1 0\n" +
++            "f 1 2\n";
++
++        // act/assert
++        GeometryTestUtils.assertThrows(() -> {
++            try {
++                reader.readTriangleMesh(new StringReader(badNumber), TEST_PRECISION);
++            } catch (IOException exc) {
++                throw new UncheckedIOException(exc);
++            }
++        }, NumberFormatException.class);
++
++        GeometryTestUtils.assertThrows(() -> {
++            try {
++                reader.readTriangleMesh(new StringReader(notEnoughIndices), TEST_PRECISION);
++            } catch (IOException exc) {
++                throw new UncheckedIOException(exc);
++            }
++        }, IllegalArgumentException.class, "Invalid face definition: at least 3 fields required but found only 2");
++    }
++
++    @Test
++    public void testReadMesh_cubeMinusSphereFile() throws Exception {
++        // arrange
++        URL url = getClass().getResource(CUBE_MINUS_SPHERE_MODEL);
++        File file = new File(url.toURI());
++
++        // act
++        TriangleMesh mesh = reader.readTriangleMesh(file, TEST_PRECISION);
++
++        // assert
++        Assert.assertEquals(CUBE_MINUS_SPHERE_VERTICES, mesh.getVertexCount());
++        Assert.assertEquals(CUBE_MINUS_SPHERE_FACES, mesh.getFaceCount());
++
++        RegionBSPTree3D tree = RegionBSPTree3D.partitionedRegionBuilder()
++                .insertAxisAlignedGrid(mesh.getBounds(), 1, TEST_PRECISION)
++                .insertBoundaries(mesh)
++                .build();
++
++        double eps = 1e-5;
++        Assert.assertEquals(0.11509505362599505, tree.getSize(), eps);
++        EuclideanTestUtils.assertCoordinatesEqual(Vector3D.ZERO, tree.getCentroid(), TEST_EPS);
++    }
++
++    @Test
++    public void testReadMesh_cubeMinusSphereUrl() throws IOException {
++        // arrange
++        URL url = getClass().getResource(CUBE_MINUS_SPHERE_MODEL);
++
++        // act
++        TriangleMesh mesh = reader.readTriangleMesh(url, TEST_PRECISION);
++
++        // assert
++        Assert.assertEquals(CUBE_MINUS_SPHERE_VERTICES, mesh.getVertexCount());
++        Assert.assertEquals(CUBE_MINUS_SPHERE_FACES, mesh.getFaceCount());
++    }
++}
+diff --git a/commons-geometry-examples/examples-io/src/test/java/org/apache/commons/geometry/examples/io/threed/obj/OBJWriterTest.java b/commons-geometry-examples/examples-io/src/test/java/org/apache/commons/geometry/examples/io/threed/obj/OBJWriterTest.java
+new file mode 100644
+index 0000000..396cfcf
+--- /dev/null
++++ b/commons-geometry-examples/examples-io/src/test/java/org/apache/commons/geometry/examples/io/threed/obj/OBJWriterTest.java
+@@ -0,0 +1,360 @@
++/*
++ * 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.examples.io.threed.obj;
++
++import java.io.IOException;
++import java.io.StringWriter;
++import java.io.UncheckedIOException;
++import java.nio.file.Files;
++import java.nio.file.Path;
++import java.text.DecimalFormat;
++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.threed.BoundarySource3D;
++import org.apache.commons.geometry.euclidean.threed.Planes;
++import org.apache.commons.geometry.euclidean.threed.RegionBSPTree3D;
++import org.apache.commons.geometry.euclidean.threed.Vector3D;
++import org.apache.commons.geometry.euclidean.threed.mesh.SimpleTriangleMesh;
++import org.apache.commons.geometry.euclidean.threed.mesh.TriangleMesh;
++import org.apache.commons.geometry.euclidean.threed.shape.Parallelepiped;
++import org.apache.commons.geometry.euclidean.threed.shape.Sphere;
++import org.junit.Assert;
++import org.junit.Test;
++
++public class OBJWriterTest {
++
++    private static final double TEST_EPS = 1e-10;
++
++    private static final DoublePrecisionContext TEST_PRECISION =
++            new EpsilonDoublePrecisionContext(TEST_EPS);
++
++    @Test
++    public void testDefaults() throws IOException {
++        // arrange
++        StringWriter writer = new StringWriter();
++
++        // act/assert
++        try (OBJWriter meshWriter = new OBJWriter(writer)) {
++            Assert.assertEquals("\n", meshWriter.getLineSeparator());
++            Assert.assertEquals(6, meshWriter.getDecimalFormat().getMaximumFractionDigits());
++        }
++    }
++
++    @Test
++    public void testClose_calledMultipleTimes() throws IOException {
++        // arrange
++        StringWriter writer = new StringWriter();
++
++        // act/assert
++        try (OBJWriter meshWriter = new OBJWriter(writer)) {
++            meshWriter.close();
++            meshWriter.close();
++        }
++    }
++
++    @Test
++    public void testSetLineSeparator() throws IOException {
++        // arrange
++        StringWriter writer = new StringWriter();
++
++        // act
++        try (OBJWriter meshWriter = new OBJWriter(writer)) {
++            meshWriter.setLineSeparator("\r\n");
++
++            meshWriter.writeComment("line 1");
++            meshWriter.writeComment("line 2");
++            meshWriter.writeVertex(Vector3D.ZERO);
++        }
++
++        // assert
++        Assert.assertEquals(
++            "# line 1\r\n" +
++            "# line 2\r\n" +
++            "v 0 0 0\r\n", writer.getBuffer().toString());
++    }
++
++    @Test
++    public void testSetDecimalFormat() throws IOException {
++        // arrange
++        StringWriter writer = new StringWriter();
++
++        // act
++        try (OBJWriter meshWriter = new OBJWriter(writer)) {
++            meshWriter.setDecimalFormat(new DecimalFormat("00.0"));
++
++            meshWriter.writeVertex(Vector3D.of(1, 2, 3));
++        }
++
++        // assert
++        Assert.assertEquals("v 01.0 02.0 03.0\n", writer.getBuffer().toString());
++    }
++
++    @Test
++    public void testWriteComment() throws IOException {
++        // arrange
++        StringWriter writer = new StringWriter();
++
++        // act
++        try (OBJWriter meshWriter = new OBJWriter(writer)) {
++            meshWriter.writeComment("test");
++            meshWriter.writeComment(" a\r\n multi-line\ncomment");
++        }
++
++        // assert
++        Assert.assertEquals(
++            "# test\n" +
++            "#  a\n" +
++            "#  multi-line\n" +
++            "# comment\n", writer.getBuffer().toString());
++    }
++
++    @Test
++    public void testWriteObjectName() throws IOException {
++        // arrange
++        StringWriter writer = new StringWriter();
++
++        // act
++        try (OBJWriter meshWriter = new OBJWriter(writer)) {
++            meshWriter.writeObjectName("test-object");
++        }
++
++        // assert
++        Assert.assertEquals("o test-object\n", writer.getBuffer().toString());
++    }
++
++    @Test
++    public void testWriteGroupName() throws IOException {
++        // arrange
++        StringWriter writer = new StringWriter();
++
++        // act
++        try (OBJWriter meshWriter = new OBJWriter(writer)) {
++            meshWriter.writeGroupName("test-group");
++        }
++
++        // assert
++        Assert.assertEquals("g test-group\n", writer.getBuffer().toString());
++    }
++
++    @Test
++    public void testWriteVertex() throws IOException {
++        // arrange
++        StringWriter writer = new StringWriter();
++
++        // act
++        int index1;
++        int index2;
++        try (OBJWriter meshWriter = new OBJWriter(writer)) {
++            meshWriter.getDecimalFormat().setMaximumFractionDigits(1);
++
++            index1 = meshWriter.writeVertex(Vector3D.of(1.09, 2.1, 3.005));
++            index2 = meshWriter.writeVertex(Vector3D.of(0.06, 10, 12));
++        }
++
++        // assert
++        Assert.assertEquals(1, index1);
++        Assert.assertEquals(2, index2);
++        Assert.assertEquals(
++            "v 1.1 2.1 3\n" +
++            "v 0.1 10 12\n", writer.getBuffer().toString());
++    }
++
++    @Test
++    public void testWriteFace() throws IOException {
++        // arrange
++        StringWriter writer = new StringWriter();
++
++        // act
++        try (OBJWriter meshWriter = new OBJWriter(writer)) {
++            meshWriter.writeVertex(Vector3D.ZERO);
++            meshWriter.writeVertex(Vector3D.of(1, 0, 0));
++            meshWriter.writeVertex(Vector3D.of(1, 1, 0));
++            meshWriter.writeVertex(Vector3D.of(0, 1, 0));
++
++            meshWriter.writeFace(1, 2, 3);
++            meshWriter.writeFace(1, 2, 3, -1);
++        }
++
++        // assert
++        Assert.assertEquals(
++            "v 0 0 0\n" +
++            "v 1 0 0\n" +
++            "v 1 1 0\n" +
++            "v 0 1 0\n" +
++            "f 1 2 3\n" +
++            "f 1 2 3 -1\n", writer.getBuffer().toString());
++    }
++
++    @Test
++    public void testWriteFace_invalidVertexNumber() throws IOException {
++        // arrange
++        StringWriter writer = new StringWriter();
++
++        // act
++        GeometryTestUtils.assertThrows(() -> {
++            try (OBJWriter meshWriter = new OBJWriter(writer)) {
++                meshWriter.writeFace(1, 2);
++            } catch (IOException exc) {
++                throw new UncheckedIOException(exc);
++            }
++        }, IllegalArgumentException.class, "Face must have more than 3 vertices; found 2");
++    }
++
++    @Test
++    public void testWriteMesh() throws IOException {
++        // arrange
++        SimpleTriangleMesh mesh = SimpleTriangleMesh.builder(TEST_PRECISION)
++                .addFaceUsingVertices(Vector3D.ZERO, Vector3D.of(1, 0, 0), Vector3D.of(0, 1, 0))
++                .addFaceUsingVertices(Vector3D.ZERO, Vector3D.of(1, 0, 0), Vector3D.of(0, 0, 1))
++                .build();
++
++        StringWriter writer = new StringWriter();
++
++        // act
++        try (OBJWriter meshWriter = new OBJWriter(writer)) {
++            meshWriter.writeMesh(mesh);
++        }
++
++        // assert
++        Assert.assertEquals(
++            "v 0 0 0\n" +
++            "v 1 0 0\n" +
++            "v 0 1 0\n" +
++            "v 0 0 1\n" +
++            "f 1 2 3\n" +
++            "f 1 2 4\n", writer.getBuffer().toString());
++    }
++
++    @Test
++    public void testWriteBoundaries_meshArgument() throws IOException {
++        // arrange
++        SimpleTriangleMesh mesh = SimpleTriangleMesh.builder(TEST_PRECISION)
++                .addFaceUsingVertices(Vector3D.ZERO, Vector3D.of(1, 0, 0), Vector3D.of(0, 1, 0))
++                .addFaceUsingVertices(Vector3D.ZERO, Vector3D.of(1, 0, 0), Vector3D.of(0, 0, 1))
++                .build();
++
++        StringWriter writer = new StringWriter();
++
++        // act
++        try (OBJWriter meshWriter = new OBJWriter(writer)) {
++            meshWriter.writeBoundaries(mesh);
++        }
++
++        // assert
++        Assert.assertEquals(
++            "v 0 0 0\n" +
++            "v 1 0 0\n" +
++            "v 0 1 0\n" +
++            "v 0 0 1\n" +
++            "f 1 2 3\n" +
++            "f 1 2 4\n", writer.getBuffer().toString());
++    }
++
++    @Test
++    public void testWriteBoundaries_nonMeshArgument() throws IOException {
++        // arrange
++        BoundarySource3D src = BoundarySource3D.from(
++                    Planes.triangleFromVertices(Vector3D.ZERO, Vector3D.of(1, 0, 0), Vector3D.of(0, 1, 0), TEST_PRECISION),
++                    Planes.triangleFromVertices(Vector3D.ZERO, Vector3D.of(1, 0, 0), Vector3D.of(0, 0, 1), TEST_PRECISION)
++                );
++
++        StringWriter writer = new StringWriter();
++
++        // act
++        try (OBJWriter meshWriter = new OBJWriter(writer)) {
++            meshWriter.writeBoundaries(src);
++        }
++
++        // assert
++        Assert.assertEquals(
++            "v 0 0 0\n" +
++            "v 1 0 0\n" +
++            "v 0 1 0\n" +
++            "f 1 2 3\n" +
++            "v 0 0 0\n" +
++            "v 1 0 0\n" +
++            "v 0 0 1\n" +
++            "f 4 5 6\n", writer.getBuffer().toString());
++    }
++
++    @Test
++    public void testWriteBoundaries_infiniteBoundary() throws IOException {
++        // arrange
++        BoundarySource3D src = BoundarySource3D.from(
++                    Planes.triangleFromVertices(Vector3D.ZERO, Vector3D.of(1, 0, 0), Vector3D.of(0, 1, 0), TEST_PRECISION),
++                    Planes.fromPointAndNormal(Vector3D.ZERO, Vector3D.Unit.PLUS_Z, TEST_PRECISION).span()
++                );
++
++        StringWriter writer = new StringWriter();
++
++        // act/assert
++        GeometryTestUtils.assertThrows(() -> {
++            try (OBJWriter meshWriter = new OBJWriter(writer)) {
++                meshWriter.writeBoundaries(src);
++            } catch (IOException exc) {
++                throw new UncheckedIOException(exc);
++            }
++        }, IllegalArgumentException.class, Pattern.compile("^OBJ input geometry cannot be infinite: .*"));
++    }
++
++    @Test
++    public void testWriteToFile_boundaries() throws IOException {
++        // arrange
++        RegionBSPTree3D box = Parallelepiped.unitCube(TEST_PRECISION).toTree();
++        RegionBSPTree3D sphere = Sphere.from(Vector3D.ZERO, 0.6, TEST_PRECISION)
++                .toTree(3);
++
++        RegionBSPTree3D result = RegionBSPTree3D.empty();
++        result.difference(box, sphere);
++
++        TriangleMesh mesh = result.toTriangleMesh(TEST_PRECISION);
++
++        // act
++        Path out = Files.createTempFile("objTest", ".obj");
++        try (OBJWriter writer = new OBJWriter(out.toFile())) {
++            writer.writeComment("A test obj file\nWritten by " + OBJReaderTest.class.getName());
++
++            writer.writeBoundaries(mesh);
++        } finally {
++            Files.delete(out);
++        }
++    }
++
++    @Test
++    public void testWriteToFile_mesh() throws IOException {
++        // arrange
++        RegionBSPTree3D box = Parallelepiped.unitCube(TEST_PRECISION).toTree();
++        RegionBSPTree3D sphere = Sphere.from(Vector3D.ZERO, 0.6, TEST_PRECISION)
++                .toTree(3);
++
++        RegionBSPTree3D result = RegionBSPTree3D.empty();
++        result.difference(box, sphere);
++
++        // act
++        Path out = Files.createTempFile("objTest", ".obj");
++        try (OBJWriter writer = new OBJWriter(out.toFile())) {
++            writer.writeComment("A test obj file\nWritten by " + OBJReaderTest.class.getName());
++
++            writer.writeBoundaries(result);
++        } finally {
++            Files.delete(out);
++        }
++    }
++}
+diff --git a/commons-geometry-examples/examples-io/src/test/resources/models/cube-minus-sphere.obj b/commons-geometry-examples/examples-io/src/test/resources/models/cube-minus-sphere.obj
+new file mode 100644
+index 0000000..935e9be
+--- /dev/null
++++ b/commons-geometry-examples/examples-io/src/test/resources/models/cube-minus-sphere.obj
+@@ -0,0 +1,2354 @@
++# Blender v2.79 (sub 0) OBJ File: ''
++# www.blender.org
++o cube-minus-sphere
++v -0.500000 -0.000000 0.436807
++v -0.500000 -0.000000 0.500000
++v -0.500000 0.078561 0.421439
++v -0.500000 -0.000000 0.410416
++v -0.500000 -0.000000 0.436807
++v -0.500000 0.078561 0.421439
++v -0.500000 0.104617 0.395383
++v -0.500000 0.421439 0.078561
++v -0.500000 0.500000 0.000000
++v -0.500000 0.436807 0.000000
++v -0.500000 0.395383 0.104617
++v -0.500000 0.421439 0.078561
++v -0.500000 0.436807 0.000000
++v -0.500000 0.410416 0.000000
++v -0.500000 0.104617 0.395383
++v -0.500000 -0.000000 0.500000
++v -0.500000 0.500000 0.500000
++v -0.500000 0.500000 0.305224
++v -0.500000 0.271157 0.357407
++v -0.500000 0.500000 0.305224
++v -0.500000 0.500000 0.246928
++v -0.500000 0.148255 0.385432
++v -0.500000 0.271157 0.357407
++v -0.500000 0.500000 0.246928
++v -0.500000 0.500000 0.151764
++v -0.500000 0.137361 0.387917
++v -0.500000 0.148255 0.385432
++v -0.500000 0.155596 0.380556
++v -0.500000 0.290763 0.290763
++v -0.500000 0.500000 0.151764
++v -0.500000 0.500000 0.000000
++v -0.500000 0.452098 0.047902
++v -0.500000 0.385432 0.148255
++v -0.500000 0.452098 0.047902
++v -0.500000 0.395383 0.104617
++v -0.500000 0.380556 0.155596
++v -0.500000 0.385432 0.148255
++v -0.500000 0.387917 0.137361
++v -0.500000 0.268514 0.305543
++v -0.500000 0.290763 0.290763
++v -0.500000 0.305543 0.268514
++v -0.500000 0.436807 0.000000
++v -0.500000 0.500000 0.000000
++v -0.500000 0.421439 -0.078561
++v -0.500000 0.410416 0.000000
++v -0.500000 0.436807 0.000000
++v -0.500000 0.421439 -0.078561
++v -0.500000 0.395383 -0.104617
++v -0.500000 0.078561 -0.421439
++v -0.500000 0.000000 -0.500000
++v -0.500000 0.000000 -0.436807
++v -0.500000 0.104617 -0.395383
++v -0.500000 0.078561 -0.421439
++v -0.500000 0.000000 -0.436807
++v -0.500000 0.000000 -0.410416
++v -0.500000 0.395383 -0.104617
++v -0.500000 0.500000 0.000000
++v -0.500000 0.500000 -0.500000
++v -0.500000 0.305224 -0.500000
++v -0.500000 0.357407 -0.271157
++v -0.500000 0.305224 -0.500000
++v -0.500000 0.246928 -0.500000
++v -0.500000 0.385432 -0.148255
++v -0.500000 0.357407 -0.271157
++v -0.500000 0.246928 -0.500000
++v -0.500000 0.151764 -0.500000
++v -0.500000 0.387917 -0.137361
++v -0.500000 0.385432 -0.148255
++v -0.500000 0.380556 -0.155596
++v -0.500000 0.290763 -0.290763
++v -0.500000 0.151764 -0.500000
++v -0.500000 0.000000 -0.500000
++v -0.500000 0.047902 -0.452098
++v -0.500000 0.148255 -0.385432
++v -0.500000 0.047902 -0.452098
++v -0.500000 0.104617 -0.395383
++v -0.500000 0.155596 -0.380556
++v -0.500000 0.148255 -0.385432
++v -0.500000 0.137361 -0.387917
++v -0.500000 0.305543 -0.268514
++v -0.500000 0.290763 -0.290763
++v -0.500000 0.268514 -0.305543
++v -0.500000 -0.436807 -0.000000
++v -0.500000 -0.500000 -0.000000
++v -0.500000 -0.421439 0.078561
++v -0.500000 -0.410416 -0.000000
++v -0.500000 -0.436807 -0.000000
++v -0.500000 -0.421439 0.078561
++v -0.500000 -0.395383 0.104617
++v -0.500000 -0.078561 0.421439
++v -0.500000 -0.000000 0.500000
++v -0.500000 -0.000000 0.436807
++v -0.500000 -0.104617 0.395383
++v -0.500000 -0.078561 0.421439
++v -0.500000 -0.000000 0.436807
++v -0.500000 -0.000000 0.410416
++v -0.500000 -0.395383 0.104617
++v -0.500000 -0.500000 -0.000000
++v -0.500000 -0.500000 0.500000
++v -0.500000 -0.305224 0.500000
++v -0.500000 -0.357407 0.271157
++v -0.500000 -0.305224 0.500000
++v -0.500000 -0.246928 0.500000
++v -0.500000 -0.385432 0.148255
++v -0.500000 -0.357407 0.271157
++v -0.500000 -0.246928 0.500000
++v -0.500000 -0.151764 0.500000
++v -0.500000 -0.387917 0.137361
++v -0.500000 -0.385432 0.148255
++v -0.500000 -0.380556 0.155596
++v -0.500000 -0.290763 0.290763
++v -0.500000 -0.151764 0.500000
++v -0.500000 -0.000000 0.500000
++v -0.500000 -0.047902 0.452098
++v -0.500000 -0.148255 0.385432
++v -0.500000 -0.047902 0.452098
++v -0.500000 -0.104617 0.395383
++v -0.500000 -0.155596 0.380556
++v -0.500000 -0.148255 0.385432
++v -0.500000 -0.137361 0.387917
++v -0.500000 -0.305543 0.268514
++v -0.500000 -0.290763 0.290763
++v -0.500000 -0.268514 0.305543
++v -0.500000 0.000000 -0.436807
++v -0.500000 0.000000 -0.500000
++v -0.500000 -0.078561 -0.421439
++v -0.500000 0.000000 -0.410416
++v -0.500000 0.000000 -0.436807
++v -0.500000 -0.078561 -0.421439
++v -0.500000 -0.104617 -0.395383
++v -0.500000 -0.421439 -0.078561
++v -0.500000 -0.500000 -0.000000
++v -0.500000 -0.436807 -0.000000
++v -0.500000 -0.395383 -0.104617
++v -0.500000 -0.421439 -0.078561
++v -0.500000 -0.436807 -0.000000
++v -0.500000 -0.410416 -0.000000
++v -0.500000 -0.104617 -0.395383
++v -0.500000 0.000000 -0.500000
++v -0.500000 -0.500000 -0.500000
++v -0.500000 -0.500000 -0.305224
++v -0.500000 -0.271157 -0.357407
++v -0.500000 -0.500000 -0.305224
++v -0.500000 -0.500000 -0.246928
++v -0.500000 -0.148255 -0.385432
++v -0.500000 -0.271157 -0.357407
++v -0.500000 -0.500000 -0.246928
++v -0.500000 -0.500000 -0.151764
++v -0.500000 -0.137361 -0.387917
++v -0.500000 -0.148255 -0.385432
++v -0.500000 -0.155596 -0.380556
++v -0.500000 -0.290763 -0.290763
++v -0.500000 -0.500000 -0.151764
++v -0.500000 -0.500000 -0.000000
++v -0.500000 -0.452098 -0.047902
++v -0.500000 -0.385432 -0.148255
++v -0.500000 -0.452098 -0.047902
++v -0.500000 -0.395383 -0.104617
++v -0.500000 -0.380556 -0.155596
++v -0.500000 -0.385432 -0.148255
++v -0.500000 -0.387917 -0.137361
++v -0.500000 -0.268514 -0.305543
++v -0.500000 -0.290763 -0.290763
++v -0.500000 -0.305543 -0.268514
++v 0.500000 0.436807 0.000000
++v 0.500000 0.500000 0.000000
++v 0.500000 0.421439 0.078561
++v 0.500000 0.410416 0.000000
++v 0.500000 0.436807 0.000000
++v 0.500000 0.421439 0.078561
++v 0.500000 0.395383 0.104617
++v 0.500000 0.078561 0.421439
++v 0.500000 -0.000000 0.500000
++v 0.500000 -0.000000 0.436807
++v 0.500000 0.104617 0.395383
++v 0.500000 0.078561 0.421439
++v 0.500000 -0.000000 0.436807
++v 0.500000 -0.000000 0.410416
++v 0.500000 0.395383 0.104617
++v 0.500000 0.500000 0.000000
++v 0.500000 0.500000 0.500000
++v 0.500000 0.305224 0.500000
++v 0.500000 0.357407 0.271157
++v 0.500000 0.305224 0.500000
++v 0.500000 0.246928 0.500000
++v 0.500000 0.385432 0.148255
++v 0.500000 0.357407 0.271157
++v 0.500000 0.246928 0.500000
++v 0.500000 0.151764 0.500000
++v 0.500000 0.387917 0.137361
++v 0.500000 0.385432 0.148255
++v 0.500000 0.380556 0.155596
++v 0.500000 0.290763 0.290763
++v 0.500000 0.151764 0.500000
++v 0.500000 -0.000000 0.500000
++v 0.500000 0.047902 0.452098
++v 0.500000 0.148255 0.385432
++v 0.500000 0.047902 0.452098
++v 0.500000 0.104617 0.395383
++v 0.500000 0.155596 0.380556
++v 0.500000 0.148255 0.385432
++v 0.500000 0.137361 0.387917
++v 0.500000 0.305543 0.268514
++v 0.500000 0.290763 0.290763
++v 0.500000 0.268514 0.305543
++v 0.500000 0.000000 -0.436807
++v 0.500000 0.000000 -0.500000
++v 0.500000 0.078561 -0.421439
++v 0.500000 0.000000 -0.410416
++v 0.500000 0.000000 -0.436807
++v 0.500000 0.078561 -0.421439
++v 0.500000 0.104617 -0.395383
++v 0.500000 0.421439 -0.078561
++v 0.500000 0.500000 0.000000
++v 0.500000 0.436807 0.000000
++v 0.500000 0.395383 -0.104617
++v 0.500000 0.421439 -0.078561
++v 0.500000 0.436807 0.000000
++v 0.500000 0.410416 0.000000
++v 0.500000 0.104617 -0.395383
++v 0.500000 0.000000 -0.500000
++v 0.500000 0.500000 -0.500000
++v 0.500000 0.500000 -0.305224
++v 0.500000 0.271157 -0.357407
++v 0.500000 0.500000 -0.305224
++v 0.500000 0.500000 -0.246928
++v 0.500000 0.148255 -0.385432
++v 0.500000 0.271157 -0.357407
++v 0.500000 0.500000 -0.246928
++v 0.500000 0.500000 -0.151764
++v 0.500000 0.137361 -0.387917
++v 0.500000 0.148255 -0.385432
++v 0.500000 0.155596 -0.380556
++v 0.500000 0.290763 -0.290763
++v 0.500000 0.500000 -0.151764
++v 0.500000 0.500000 0.000000
++v 0.500000 0.452098 -0.047902
++v 0.500000 0.385432 -0.148255
++v 0.500000 0.452098 -0.047902
++v 0.500000 0.395383 -0.104617
++v 0.500000 0.380556 -0.155596
++v 0.500000 0.385432 -0.148255
++v 0.500000 0.387917 -0.137361
++v 0.500000 0.268514 -0.305543
++v 0.500000 0.290763 -0.290763
++v 0.500000 0.305543 -0.268514
++v 0.500000 -0.000000 0.436807
++v 0.500000 -0.000000 0.500000
++v 0.500000 -0.078561 0.421439
++v 0.500000 -0.000000 0.410416
++v 0.500000 -0.000000 0.436807
++v 0.500000 -0.078561 0.421439
++v 0.500000 -0.104617 0.395383
++v 0.500000 -0.421439 0.078561
++v 0.500000 -0.500000 -0.000000
++v 0.500000 -0.436807 -0.000000
++v 0.500000 -0.395383 0.104617
++v 0.500000 -0.421439 0.078561
++v 0.500000 -0.436807 -0.000000
++v 0.500000 -0.410416 -0.000000
++v 0.500000 -0.104617 0.395383
++v 0.500000 -0.000000 0.500000
++v 0.500000 -0.500000 0.500000
++v 0.500000 -0.500000 0.305224
++v 0.500000 -0.271157 0.357407
++v 0.500000 -0.500000 0.305224
++v 0.500000 -0.500000 0.246928
++v 0.500000 -0.148255 0.385432
++v 0.500000 -0.271157 0.357407
++v 0.500000 -0.500000 0.246928
++v 0.500000 -0.500000 0.151764
++v 0.500000 -0.137361 0.387917
++v 0.500000 -0.148255 0.385432
++v 0.500000 -0.155596 0.380556
++v 0.500000 -0.290763 0.290763
++v 0.500000 -0.500000 0.151764
++v 0.500000 -0.500000 -0.000000
++v 0.500000 -0.452098 0.047902
++v 0.500000 -0.385432 0.148255
++v 0.500000 -0.452098 0.047902
++v 0.500000 -0.395383 0.104617
++v 0.500000 -0.380556 0.155596
++v 0.500000 -0.385432 0.148255
++v 0.500000 -0.387917 0.137361
++v 0.500000 -0.268514 0.305543
++v 0.500000 -0.290763 0.290763
++v 0.500000 -0.305543 0.268514
++v 0.500000 -0.436807 -0.000000
++v 0.500000 -0.500000 -0.000000
++v 0.500000 -0.421439 -0.078561
++v 0.500000 -0.410416 -0.000000
++v 0.500000 -0.436807 -0.000000
++v 0.500000 -0.421439 -0.078561
++v 0.500000 -0.395383 -0.104617
++v 0.500000 -0.078561 -0.421439
++v 0.500000 0.000000 -0.500000
++v 0.500000 0.000000 -0.436807
++v 0.500000 -0.104617 -0.395383
++v 0.500000 -0.078561 -0.421439
++v 0.500000 0.000000 -0.436807
++v 0.500000 0.000000 -0.410416
++v 0.500000 -0.395383 -0.104617
++v 0.500000 -0.500000 -0.000000
++v 0.500000 -0.500000 -0.500000
++v 0.500000 -0.305224 -0.500000
++v 0.500000 -0.357407 -0.271157
++v 0.500000 -0.305224 -0.500000
++v 0.500000 -0.246928 -0.500000
++v 0.500000 -0.385432 -0.148255
++v 0.500000 -0.357407 -0.271157
++v 0.500000 -0.246928 -0.500000
++v 0.500000 -0.151764 -0.500000
++v 0.500000 -0.387917 -0.137361
++v 0.500000 -0.385432 -0.148255
++v 0.500000 -0.380556 -0.155596
++v 0.500000 -0.290763 -0.290763
++v 0.500000 -0.151764 -0.500000
++v 0.500000 0.000000 -0.500000
++v 0.500000 -0.047902 -0.452098
++v 0.500000 -0.148255 -0.385432
++v 0.500000 -0.047902 -0.452098
++v 0.500000 -0.104617 -0.395383
++v 0.500000 -0.155596 -0.380556
++v 0.500000 -0.148255 -0.385432
++v 0.500000 -0.137361 -0.387917
++v 0.500000 -0.305543 -0.268514
++v 0.500000 -0.290763 -0.290763
++v 0.500000 -0.268514 -0.305543
++v 0.410416 -0.500000 -0.000000
++v 0.500000 -0.500000 -0.000000
++v 0.395383 -0.500000 0.104617
++v 0.047902 -0.500000 0.452098
++v 0.000000 -0.500000 0.500000
++v 0.000000 -0.500000 0.481630
++v 0.104617 -0.500000 0.395383
++v 0.047902 -0.500000 0.452098
++v 0.000000 -0.500000 0.481630
++v 0.000000 -0.500000 0.410416
++v 0.500000 -0.500000 0.305224
++v 0.500000 -0.500000 0.500000
++v 0.305224 -0.500000 0.500000
++v 0.500000 -0.500000 0.246928
++v 0.500000 -0.500000 0.305224
++v 0.305224 -0.500000 0.500000
++v 0.151764 -0.500000 0.500000
++v 0.500000 -0.500000 0.151764
++v 0.500000 -0.500000 0.246928
++v 0.353438 -0.500000 0.353438
++v 0.393465 -0.500000 0.298360
++v 0.353438 -0.500000 0.353438
++v 0.151764 -0.500000 0.500000
++v 0.000000 -0.500000 0.500000
++v 0.022593 -0.500000 0.477407
++v 0.271157 -0.500000 0.357407
++v 0.022593 -0.500000 0.477407
++v 0.104617 -0.500000 0.395383
++v 0.500000 -0.500000 0.151764
++v 0.393465 -0.500000 0.298360
++v 0.271157 -0.500000 0.357407
++v 0.148255 -0.500000 0.385432
++v 0.155596 -0.500000 0.380556
++v 0.148255 -0.500000 0.385432
++v 0.137361 -0.500000 0.387917
++v 0.477407 -0.500000 0.022593
++v 0.500000 -0.500000 -0.000000
++v 0.500000 -0.500000 0.151764
++v 0.374938 -0.500000 0.234844
++v 0.452098 -0.500000 0.047902
++v 0.477407 -0.500000 0.022593
++v 0.374938 -0.500000 0.234844
++v 0.290763 -0.500000 0.290763
++v 0.395383 -0.500000 0.104617
++v 0.452098 -0.500000 0.047902
++v 0.385432 -0.500000 0.148255
++v 0.387917 -0.500000 0.137361
++v 0.385432 -0.500000 0.148255
++v 0.380556 -0.500000 0.155596
++v 0.305543 -0.500000 0.268514
++v 0.290763 -0.500000 0.290763
++v 0.268514 -0.500000 0.305543
++v 0.395383 -0.500000 -0.104617
++v 0.500000 -0.500000 -0.000000
++v 0.410416 -0.500000 -0.000000
++v 0.000000 -0.500000 -0.481630
++v 0.000000 -0.500000 -0.500000
++v 0.047902 -0.500000 -0.452098
++v 0.000000 -0.500000 -0.436807
++v 0.000000 -0.500000 -0.481630
++v 0.047902 -0.500000 -0.452098
++v 0.078561 -0.500000 -0.421439
++v 0.000000 -0.500000 -0.410416
++v 0.000000 -0.500000 -0.436807
++v 0.078561 -0.500000 -0.421439
++v 0.104617 -0.500000 -0.395383
++v 0.305224 -0.500000 -0.500000
++v 0.500000 -0.500000 -0.500000
++v 0.500000 -0.500000 -0.000000
++v 0.395383 -0.500000 -0.104617
++v 0.151764 -0.500000 -0.500000
++v 0.305224 -0.500000 -0.500000
++v 0.385432 -0.500000 -0.148255
++v 0.380556 -0.500000 -0.155596
++v 0.385432 -0.500000 -0.148255
++v 0.387917 -0.500000 -0.137361
++v 0.047902 -0.500000 -0.452098
++v 0.000000 -0.500000 -0.500000
++v 0.151764 -0.500000 -0.500000
++v 0.290763 -0.500000 -0.290763
++v 0.104617 -0.500000 -0.395383
++v 0.047902 -0.500000 -0.452098
++v 0.148255 -0.500000 -0.385432
++v 0.137361 -0.500000 -0.387917
++v 0.148255 -0.500000 -0.385432
++v 0.155596 -0.500000 -0.380556
++v 0.268514 -0.500000 -0.305543
++v 0.290763 -0.500000 -0.290763
++v 0.305543 -0.500000 -0.268514
++v -0.395383 -0.500000 0.104617
++v -0.500000 -0.500000 -0.000000
++v -0.410416 -0.500000 -0.000000
++v 0.000000 -0.500000 0.481630
++v 0.000000 -0.500000 0.500000
++v -0.047902 -0.500000 0.452098
++v 0.000000 -0.500000 0.436807
++v 0.000000 -0.500000 0.481630
++v -0.047902 -0.500000 0.452098
++v -0.078561 -0.500000 0.421439
++v 0.000000 -0.500000 0.410416
++v 0.000000 -0.500000 0.436807
++v -0.078561 -0.500000 0.421439
++v -0.104617 -0.500000 0.395383
++v -0.305224 -0.500000 0.500000
++v -0.500000 -0.500000 0.500000
++v -0.500000 -0.500000 -0.000000
++v -0.395383 -0.500000 0.104617
++v -0.151764 -0.500000 0.500000
++v -0.305224 -0.500000 0.500000
++v -0.385432 -0.500000 0.148255
++v -0.380556 -0.500000 0.155596
++v -0.385432 -0.500000 0.148255
++v -0.387917 -0.500000 0.137361
++v -0.047902 -0.500000 0.452098
++v 0.000000 -0.500000 0.500000
++v -0.151764 -0.500000 0.500000
++v -0.290763 -0.500000 0.290763
++v -0.104617 -0.500000 0.395383
++v -0.047902 -0.500000 0.452098
++v -0.148255 -0.500000 0.385432
++v -0.137361 -0.500000 0.387917
++v -0.148255 -0.500000 0.385432
++v -0.155596 -0.500000 0.380556
++v -0.268514 -0.500000 0.305543
++v -0.290763 -0.500000 0.290763
++v -0.305543 -0.500000 0.268514
++v -0.410416 -0.500000 -0.000000
++v -0.500000 -0.500000 -0.000000
++v -0.395383 -0.500000 -0.104617
++v -0.047902 -0.500000 -0.452098
++v 0.000000 -0.500000 -0.500000
++v 0.000000 -0.500000 -0.481630
++v -0.104617 -0.500000 -0.395383
++v -0.047902 -0.500000 -0.452098
++v 0.000000 -0.500000 -0.481630
++v 0.000000 -0.500000 -0.410416
++v -0.500000 -0.500000 -0.305224
++v -0.500000 -0.500000 -0.500000
++v -0.305224 -0.500000 -0.500000
++v -0.500000 -0.500000 -0.246928
++v -0.500000 -0.500000 -0.305224
++v -0.305224 -0.500000 -0.500000
++v -0.151764 -0.500000 -0.500000
++v -0.500000 -0.500000 -0.151764
++v -0.500000 -0.500000 -0.246928
++v -0.353438 -0.500000 -0.353438
++v -0.393465 -0.500000 -0.298360
++v -0.353438 -0.500000 -0.353438
++v -0.151764 -0.500000 -0.500000
++v 0.000000 -0.500000 -0.500000
++v -0.022593 -0.500000 -0.477407
++v -0.271157 -0.500000 -0.357407
++v -0.022593 -0.500000 -0.477407
++v -0.104617 -0.500000 -0.395383
++v -0.500000 -0.500000 -0.151764
++v -0.393465 -0.500000 -0.298360
++v -0.271157 -0.500000 -0.357407
++v -0.148255 -0.500000 -0.385432
++v -0.155596 -0.500000 -0.380556
++v -0.148255 -0.500000 -0.385432
++v -0.137361 -0.500000 -0.387917
++v -0.477407 -0.500000 -0.022593
++v -0.500000 -0.500000 -0.000000
++v -0.500000 -0.500000 -0.151764
++v -0.374938 -0.500000 -0.234844
++v -0.452098 -0.500000 -0.047902
++v -0.477407 -0.500000 -0.022593
++v -0.374938 -0.500000 -0.234844
++v -0.290763 -0.500000 -0.290763
++v -0.395383 -0.500000 -0.104617
++v -0.452098 -0.500000 -0.047902
++v -0.385432 -0.500000 -0.148255
++v -0.387917 -0.500000 -0.137361
++v -0.385432 -0.500000 -0.148255
++v -0.380556 -0.500000 -0.155596
++v -0.305543 -0.500000 -0.268514
++v -0.290763 -0.500000 -0.290763
++v -0.268514 -0.500000 -0.305543
++v 0.395383 0.500000 0.104617
++v 0.500000 0.500000 0.000000
++v 0.410416 0.500000 0.000000
++v 0.000000 0.500000 0.481630
++v 0.000000 0.500000 0.500000
++v 0.047902 0.500000 0.452098
++v 0.000000 0.500000 0.436807
++v 0.000000 0.500000 0.481630
++v 0.047902 0.500000 0.452098
++v 0.078561 0.500000 0.421439
++v 0.000000 0.500000 0.410416
++v 0.000000 0.500000 0.436807
++v 0.078561 0.500000 0.421439
++v 0.104617 0.500000 0.395383
++v 0.305224 0.500000 0.500000
++v 0.500000 0.500000 0.500000
++v 0.500000 0.500000 0.000000
++v 0.395383 0.500000 0.104617
++v 0.151764 0.500000 0.500000
++v 0.305224 0.500000 0.500000
++v 0.385432 0.500000 0.148255
++v 0.380556 0.500000 0.155596
++v 0.385432 0.500000 0.148255
++v 0.387917 0.500000 0.137361
++v 0.047902 0.500000 0.452098
++v 0.000000 0.500000 0.500000
++v 0.151764 0.500000 0.500000
++v 0.290763 0.500000 0.290763
++v 0.104617 0.500000 0.395383
++v 0.047902 0.500000 0.452098
++v 0.148255 0.500000 0.385432
++v 0.137361 0.500000 0.387917
++v 0.148255 0.500000 0.385432
++v 0.155596 0.500000 0.380556
++v 0.268514 0.500000 0.305543
++v 0.290763 0.500000 0.290763
++v 0.305543 0.500000 0.268514
++v 0.410416 0.500000 0.000000
++v 0.500000 0.500000 0.000000
++v 0.395383 0.500000 -0.104617
++v 0.047902 0.500000 -0.452098
++v 0.000000 0.500000 -0.500000
++v 0.000000 0.500000 -0.481630
++v 0.104617 0.500000 -0.395383
++v 0.047902 0.500000 -0.452098
++v 0.000000 0.500000 -0.481630
++v 0.000000 0.500000 -0.410416
++v 0.500000 0.500000 -0.305224
++v 0.500000 0.500000 -0.500000
++v 0.305224 0.500000 -0.500000
++v 0.500000 0.500000 -0.246928
++v 0.500000 0.500000 -0.305224
++v 0.305224 0.500000 -0.500000
++v 0.151764 0.500000 -0.500000
++v 0.500000 0.500000 -0.151764
++v 0.500000 0.500000 -0.246928
++v 0.353438 0.500000 -0.353438
++v 0.393465 0.500000 -0.298360
++v 0.353438 0.500000 -0.353438
++v 0.151764 0.500000 -0.500000
++v 0.000000 0.500000 -0.500000
++v 0.022593 0.500000 -0.477407
++v 0.271157 0.500000 -0.357407
++v 0.022593 0.500000 -0.477407
++v 0.104617 0.500000 -0.395383
++v 0.500000 0.500000 -0.151764
++v 0.393465 0.500000 -0.298360
++v 0.271157 0.500000 -0.357407
++v 0.148255 0.500000 -0.385432
++v 0.155596 0.500000 -0.380556
++v 0.148255 0.500000 -0.385432
++v 0.137361 0.500000 -0.387917
++v 0.477407 0.500000 -0.022593
++v 0.500000 0.500000 0.000000
++v 0.500000 0.500000 -0.151764
++v 0.374938 0.500000 -0.234844
++v 0.452098 0.500000 -0.047902
++v 0.477407 0.500000 -0.022593
++v 0.374938 0.500000 -0.234844
++v 0.290763 0.500000 -0.290763
++v 0.395383 0.500000 -0.104617
++v 0.452098 0.500000 -0.047902
++v 0.385432 0.500000 -0.148255
++v 0.387917 0.500000 -0.137361
++v 0.385432 0.500000 -0.148255
++v 0.380556 0.500000 -0.155596
++v 0.305543 0.500000 -0.268514
++v 0.290763 0.500000 -0.290763
++v 0.268514 0.500000 -0.305543
++v -0.410416 0.500000 0.000000
++v -0.500000 0.500000 0.000000
++v -0.395383 0.500000 0.104617
++v -0.047902 0.500000 0.452098
++v 0.000000 0.500000 0.500000
++v 0.000000 0.500000 0.481630
++v -0.104617 0.500000 0.395383
++v -0.047902 0.500000 0.452098
++v 0.000000 0.500000 0.481630
++v 0.000000 0.500000 0.410416
++v -0.500000 0.500000 0.305224
++v -0.500000 0.500000 0.500000
++v -0.305224 0.500000 0.500000
++v -0.500000 0.500000 0.246928
++v -0.500000 0.500000 0.305224
++v -0.305224 0.500000 0.500000
++v -0.151764 0.500000 0.500000
++v -0.500000 0.500000 0.151764
++v -0.500000 0.500000 0.246928
++v -0.353438 0.500000 0.353438
++v -0.393465 0.500000 0.298360
++v -0.353438 0.500000 0.353438
++v -0.151764 0.500000 0.500000
++v 0.000000 0.500000 0.500000
++v -0.022593 0.500000 0.477407
++v -0.271157 0.500000 0.357407
++v -0.022593 0.500000 0.477407
++v -0.104617 0.500000 0.395383
++v -0.500000 0.500000 0.151764
++v -0.393465 0.500000 0.298360
++v -0.271157 0.500000 0.357407
++v -0.148255 0.500000 0.385432
++v -0.155596 0.500000 0.380556
++v -0.148255 0.500000 0.385432
++v -0.137361 0.500000 0.387917
++v -0.477407 0.500000 0.022593
++v -0.500000 0.500000 0.000000
++v -0.500000 0.500000 0.151764
++v -0.374938 0.500000 0.234844
++v -0.452098 0.500000 0.047902
++v -0.477407 0.500000 0.022593
++v -0.374938 0.500000 0.234844
++v -0.290763 0.500000 0.290763
++v -0.395383 0.500000 0.104617
++v -0.452098 0.500000 0.047902
++v -0.385432 0.500000 0.148255
++v -0.387917 0.500000 0.137361
++v -0.385432 0.500000 0.148255
++v -0.380556 0.500000 0.155596
++v -0.305543 0.500000 0.268514
++v -0.290763 0.500000 0.290763
++v -0.268514 0.500000 0.305543
++v -0.395383 0.500000 -0.104617
++v -0.500000 0.500000 0.000000
++v -0.410416 0.500000 0.000000
++v 0.000000 0.500000 -0.481630
++v 0.000000 0.500000 -0.500000
++v -0.047902 0.500000 -0.452098
++v 0.000000 0.500000 -0.436807
++v 0.000000 0.500000 -0.481630
++v -0.047902 0.500000 -0.452098
++v -0.078561 0.500000 -0.421439
++v 0.000000 0.500000 -0.410416
++v 0.000000 0.500000 -0.436807
++v -0.078561 0.500000 -0.421439
++v -0.104617 0.500000 -0.395383
++v -0.305224 0.500000 -0.500000
++v -0.500000 0.500000 -0.500000
++v -0.500000 0.500000 0.000000
++v -0.395383 0.500000 -0.104617
++v -0.151764 0.500000 -0.500000
++v -0.305224 0.500000 -0.500000
++v -0.385432 0.500000 -0.148255
++v -0.380556 0.500000 -0.155596
++v -0.385432 0.500000 -0.148255
++v -0.387917 0.500000 -0.137361
++v -0.047902 0.500000 -0.452098
++v 0.000000 0.500000 -0.500000
++v -0.151764 0.500000 -0.500000
++v -0.290763 0.500000 -0.290763
++v -0.104617 0.500000 -0.395383
++v -0.047902 0.500000 -0.452098
++v -0.148255 0.500000 -0.385432
++v -0.137361 0.500000 -0.387917
++v -0.148255 0.500000 -0.385432
++v -0.155596 0.500000 -0.380556
++v -0.268514 0.500000 -0.305543
++v -0.290763 0.500000 -0.290763
++v -0.305543 0.500000 -0.268514
++v 0.395383 0.104617 -0.500000
++v 0.500000 0.000000 -0.500000
++v 0.410416 0.000000 -0.500000
++v 0.000000 0.481630 -0.500000
++v 0.000000 0.500000 -0.500000
++v 0.047902 0.452098 -0.500000
++v 0.000000 0.436807 -0.500000
++v 0.000000 0.481630 -0.500000
++v 0.047902 0.452098 -0.500000
++v 0.078561 0.421439 -0.500000
++v 0.000000 0.410416 -0.500000
++v 0.000000 0.436807 -0.500000
++v 0.078561 0.421439 -0.500000
++v 0.104617 0.395383 -0.500000
++v 0.305224 0.500000 -0.500000
++v 0.500000 0.500000 -0.500000
++v 0.500000 0.000000 -0.500000
++v 0.395383 0.104617 -0.500000
++v 0.151764 0.500000 -0.500000
++v 0.305224 0.500000 -0.500000
++v 0.385432 0.148255 -0.500000
++v 0.380556 0.155596 -0.500000
++v 0.385432 0.148255 -0.500000
++v 0.387917 0.137361 -0.500000
++v 0.047902 0.452098 -0.500000
++v 0.000000 0.500000 -0.500000
++v 0.151764 0.500000 -0.500000
++v 0.290763 0.290763 -0.500000
++v 0.104617 0.395383 -0.500000
++v 0.047902 0.452098 -0.500000
++v 0.148255 0.385432 -0.500000
++v 0.137361 0.387917 -0.500000
++v 0.148255 0.385432 -0.500000
++v 0.155596 0.380556 -0.500000
++v 0.268514 0.305543 -0.500000
++v 0.290763 0.290763 -0.500000
++v 0.305543 0.268514 -0.500000
++v 0.410416 0.000000 -0.500000
++v 0.500000 0.000000 -0.500000
++v 0.395383 -0.104617 -0.500000
++v 0.047902 -0.452098 -0.500000
++v 0.000000 -0.500000 -0.500000
++v 0.000000 -0.481630 -0.500000
++v 0.104617 -0.395383 -0.500000
++v 0.047902 -0.452098 -0.500000
++v 0.000000 -0.481630 -0.500000
++v 0.000000 -0.410416 -0.500000
++v 0.500000 -0.305224 -0.500000
++v 0.500000 -0.500000 -0.500000
++v 0.305224 -0.500000 -0.500000
++v 0.500000 -0.246928 -0.500000
++v 0.500000 -0.305224 -0.500000
++v 0.305224 -0.500000 -0.500000
++v 0.151764 -0.500000 -0.500000
++v 0.500000 -0.151764 -0.500000
++v 0.500000 -0.246928 -0.500000
++v 0.353438 -0.353438 -0.500000
++v 0.393465 -0.298360 -0.500000
++v 0.353438 -0.353438 -0.500000
++v 0.151764 -0.500000 -0.500000
++v 0.000000 -0.500000 -0.500000
++v 0.022593 -0.477407 -0.500000
++v 0.271157 -0.357407 -0.500000
++v 0.022593 -0.477407 -0.500000
++v 0.104617 -0.395383 -0.500000
++v 0.500000 -0.151764 -0.500000
++v 0.393465 -0.298360 -0.500000
++v 0.271157 -0.357407 -0.500000
++v 0.148255 -0.385432 -0.500000
++v 0.155596 -0.380556 -0.500000
++v 0.148255 -0.385432 -0.500000
++v 0.137361 -0.387917 -0.500000
++v 0.477407 -0.022593 -0.500000
++v 0.500000 0.000000 -0.500000
++v 0.500000 -0.151764 -0.500000
++v 0.374938 -0.234844 -0.500000
++v 0.452098 -0.047902 -0.500000
++v 0.477407 -0.022593 -0.500000
++v 0.374938 -0.234844 -0.500000
++v 0.290763 -0.290763 -0.500000
++v 0.395383 -0.104617 -0.500000
++v 0.452098 -0.047902 -0.500000
++v 0.385432 -0.148255 -0.500000
++v 0.387917 -0.137361 -0.500000
++v 0.385432 -0.148255 -0.500000
++v 0.380556 -0.155596 -0.500000
++v 0.305543 -0.268514 -0.500000
++v 0.290763 -0.290763 -0.500000
++v 0.268514 -0.305543 -0.500000
++v -0.410416 0.000000 -0.500000
++v -0.500000 0.000000 -0.500000
++v -0.395383 0.104617 -0.500000
++v -0.047902 0.452098 -0.500000
++v 0.000000 0.500000 -0.500000
++v 0.000000 0.481630 -0.500000
++v -0.104617 0.395383 -0.500000
++v -0.047902 0.452098 -0.500000
++v 0.000000 0.481630 -0.500000
++v 0.000000 0.410416 -0.500000
++v -0.500000 0.305224 -0.500000
++v -0.500000 0.500000 -0.500000
++v -0.305224 0.500000 -0.500000
++v -0.500000 0.246928 -0.500000
++v -0.500000 0.305224 -0.500000
++v -0.305224 0.500000 -0.500000
++v -0.151764 0.500000 -0.500000
++v -0.500000 0.151764 -0.500000
++v -0.500000 0.246928 -0.500000
++v -0.353438 0.353438 -0.500000
++v -0.393465 0.298360 -0.500000
++v -0.353438 0.353438 -0.500000
++v -0.151764 0.500000 -0.500000
++v 0.000000 0.500000 -0.500000
++v -0.022593 0.477407 -0.500000
++v -0.271157 0.357407 -0.500000
++v -0.022593 0.477407 -0.500000
++v -0.104617 0.395383 -0.500000
++v -0.500000 0.151764 -0.500000
++v -0.393465 0.298360 -0.500000
++v -0.271157 0.357407 -0.500000
++v -0.148255 0.385432 -0.500000
++v -0.155596 0.380556 -0.500000
++v -0.148255 0.385432 -0.500000
++v -0.137361 0.387917 -0.500000
++v -0.477407 0.022593 -0.500000
++v -0.500000 0.000000 -0.500000
++v -0.500000 0.151764 -0.500000
++v -0.374938 0.234844 -0.500000
++v -0.452098 0.047902 -0.500000
++v -0.477407 0.022593 -0.500000
++v -0.374938 0.234844 -0.500000
++v -0.290763 0.290763 -0.500000
++v -0.395383 0.104617 -0.500000
++v -0.452098 0.047902 -0.500000
++v -0.385432 0.148255 -0.500000
++v -0.387917 0.137361 -0.500000
++v -0.385432 0.148255 -0.500000
++v -0.380556 0.155596 -0.500000
++v -0.305543 0.268514 -0.500000
++v -0.290763 0.290763 -0.500000
++v -0.268514 0.305543 -0.500000
++v -0.395383 -0.104617 -0.500000
++v -0.500000 0.000000 -0.500000
++v -0.410416 0.000000 -0.500000
++v 0.000000 -0.481630 -0.500000
++v 0.000000 -0.500000 -0.500000
++v -0.047902 -0.452098 -0.500000
++v 0.000000 -0.436807 -0.500000
++v 0.000000 -0.481630 -0.500000
++v -0.047902 -0.452098 -0.500000
++v -0.078561 -0.421439 -0.500000
++v 0.000000 -0.410416 -0.500000
++v 0.000000 -0.436807 -0.500000
++v -0.078561 -0.421439 -0.500000
++v -0.104617 -0.395383 -0.500000
++v -0.305224 -0.500000 -0.500000
++v -0.500000 -0.500000 -0.500000
++v -0.500000 0.000000 -0.500000
++v -0.395383 -0.104617 -0.500000
++v -0.151764 -0.500000 -0.500000
++v -0.305224 -0.500000 -0.500000
++v -0.385432 -0.148255 -0.500000
++v -0.380556 -0.155596 -0.500000
++v -0.385432 -0.148255 -0.500000
++v -0.387917 -0.137361 -0.500000
++v -0.047902 -0.452098 -0.500000
++v 0.000000 -0.500000 -0.500000
++v -0.151764 -0.500000 -0.500000
++v -0.290763 -0.290763 -0.500000
++v -0.104617 -0.395383 -0.500000
++v -0.047902 -0.452098 -0.500000
++v -0.148255 -0.385432 -0.500000
++v -0.137361 -0.387917 -0.500000
++v -0.148255 -0.385432 -0.500000
++v -0.155596 -0.380556 -0.500000
++v -0.268514 -0.305543 -0.500000
++v -0.290763 -0.290763 -0.500000
++v -0.305543 -0.268514 -0.500000
++v 0.410416 -0.000000 0.500000
++v 0.500000 -0.000000 0.500000
++v 0.395383 0.104617 0.500000
++v 0.047902 0.452098 0.500000
++v 0.000000 0.500000 0.500000
++v 0.000000 0.481630 0.500000
++v 0.104617 0.395383 0.500000
++v 0.047902 0.452098 0.500000
++v 0.000000 0.481630 0.500000
++v 0.000000 0.410416 0.500000
++v 0.500000 0.305224 0.500000
++v 0.500000 0.500000 0.500000
++v 0.305224 0.500000 0.500000
++v 0.500000 0.246928 0.500000
++v 0.500000 0.305224 0.500000
++v 0.305224 0.500000 0.500000
++v 0.151764 0.500000 0.500000
++v 0.500000 0.151764 0.500000
++v 0.500000 0.246928 0.500000
++v 0.353438 0.353438 0.500000
++v 0.393465 0.298360 0.500000
++v 0.353438 0.353438 0.500000
++v 0.151764 0.500000 0.500000
++v 0.000000 0.500000 0.500000
++v 0.022593 0.477407 0.500000
++v 0.271157 0.357407 0.500000
++v 0.022593 0.477407 0.500000
++v 0.104617 0.395383 0.500000
++v 0.500000 0.151764 0.500000
++v 0.393465 0.298360 0.500000
++v 0.271157 0.357407 0.500000
++v 0.148255 0.385432 0.500000
++v 0.155596 0.380556 0.500000
++v 0.148255 0.385432 0.500000
++v 0.137361 0.387917 0.500000
++v 0.477407 0.022593 0.500000
++v 0.500000 -0.000000 0.500000
++v 0.500000 0.151764 0.500000
++v 0.374938 0.234844 0.500000
++v 0.452098 0.047902 0.500000
++v 0.477407 0.022593 0.500000
++v 0.374938 0.234844 0.500000
++v 0.290763 0.290763 0.500000
++v 0.395383 0.104617 0.500000
++v 0.452098 0.047902 0.500000
++v 0.385432 0.148255 0.500000
++v 0.387917 0.137361 0.500000
++v 0.385432 0.148255 0.500000
++v 0.380556 0.155596 0.500000
++v 0.305543 0.268514 0.500000
++v 0.290763 0.290763 0.500000
++v 0.268514 0.305543 0.500000
++v 0.395383 -0.104617 0.500000
++v 0.500000 -0.000000 0.500000
++v 0.410416 -0.000000 0.500000
++v 0.000000 -0.481630 0.500000
++v 0.000000 -0.500000 0.500000
++v 0.047902 -0.452098 0.500000
++v 0.000000 -0.436807 0.500000
++v 0.000000 -0.481630 0.500000
++v 0.047902 -0.452098 0.500000
++v 0.078561 -0.421439 0.500000
++v 0.000000 -0.410416 0.500000
++v 0.000000 -0.436807 0.500000
++v 0.078561 -0.421439 0.500000
++v 0.104617 -0.395383 0.500000
++v 0.305224 -0.500000 0.500000
++v 0.500000 -0.500000 0.500000
++v 0.500000 -0.000000 0.500000
++v 0.395383 -0.104617 0.500000
++v 0.151764 -0.500000 0.500000
++v 0.305224 -0.500000 0.500000
++v 0.385432 -0.148255 0.500000
++v 0.380556 -0.155596 0.500000
++v 0.385432 -0.148255 0.500000
++v 0.387917 -0.137361 0.500000
++v 0.047902 -0.452098 0.500000
++v 0.000000 -0.500000 0.500000
++v 0.151764 -0.500000 0.500000
++v 0.290763 -0.290763 0.500000
++v 0.104617 -0.395383 0.500000
++v 0.047902 -0.452098 0.500000
++v 0.148255 -0.385432 0.500000
++v 0.137361 -0.387917 0.500000
++v 0.148255 -0.385432 0.500000
++v 0.155596 -0.380556 0.500000
++v 0.268514 -0.305543 0.500000
++v 0.290763 -0.290763 0.500000
++v 0.305543 -0.268514 0.500000
++v -0.395383 0.104617 0.500000
++v -0.500000 -0.000000 0.500000
++v -0.410416 -0.000000 0.500000
++v 0.000000 0.481630 0.500000
++v 0.000000 0.500000 0.500000
++v -0.047902 0.452098 0.500000
++v 0.000000 0.436807 0.500000
++v 0.000000 0.481630 0.500000
++v -0.047902 0.452098 0.500000
++v -0.078561 0.421439 0.500000
++v 0.000000 0.410416 0.500000
++v 0.000000 0.436807 0.500000
++v -0.078561 0.421439 0.500000
++v -0.104617 0.395383 0.500000
++v -0.305224 0.500000 0.500000
++v -0.500000 0.500000 0.500000
++v -0.500000 -0.000000 0.500000
++v -0.395383 0.104617 0.500000
++v -0.151764 0.500000 0.500000
++v -0.305224 0.500000 0.500000
++v -0.385432 0.148255 0.500000
++v -0.380556 0.155596 0.500000
++v -0.385432 0.148255 0.500000
++v -0.387917 0.137361 0.500000
++v -0.047902 0.452098 0.500000
++v 0.000000 0.500000 0.500000
++v -0.151764 0.500000 0.500000
++v -0.290763 0.290763 0.500000
++v -0.104617 0.395383 0.500000
++v -0.047902 0.452098 0.500000
++v -0.148255 0.385432 0.500000
++v -0.137361 0.387917 0.500000
++v -0.148255 0.385432 0.500000
++v -0.155596 0.380556 0.500000
++v -0.268514 0.305543 0.500000
++v -0.290763 0.290763 0.500000
++v -0.305543 0.268514 0.500000
++v -0.410416 -0.000000 0.500000
++v -0.500000 -0.000000 0.500000
++v -0.395383 -0.104617 0.500000
++v -0.047902 -0.452098 0.500000
++v 0.000000 -0.500000 0.500000
++v 0.000000 -0.481630 0.500000
++v -0.104617 -0.395383 0.500000
++v -0.047902 -0.452098 0.500000
++v 0.000000 -0.481630 0.500000
++v 0.000000 -0.410416 0.500000
++v -0.500000 -0.305224 0.500000
++v -0.500000 -0.500000 0.500000
++v -0.305224 -0.500000 0.500000
++v -0.500000 -0.246928 0.500000
++v -0.500000 -0.305224 0.500000
++v -0.305224 -0.500000 0.500000
++v -0.151764 -0.500000 0.500000
++v -0.500000 -0.151764 0.500000
++v -0.500000 -0.246928 0.500000
++v -0.353438 -0.353438 0.500000
++v -0.393465 -0.298360 0.500000
++v -0.353438 -0.353438 0.500000
++v -0.151764 -0.500000 0.500000
++v 0.000000 -0.500000 0.500000
++v -0.022593 -0.477407 0.500000
++v -0.271157 -0.357407 0.500000
++v -0.022593 -0.477407 0.500000
++v -0.104617 -0.395383 0.500000
++v -0.500000 -0.151764 0.500000
++v -0.393465 -0.298360 0.500000
++v -0.271157 -0.357407 0.500000
++v -0.148255 -0.385432 0.500000
++v -0.155596 -0.380556 0.500000
++v -0.148255 -0.385432 0.500000
++v -0.137361 -0.387917 0.500000
++v -0.477407 -0.022593 0.500000
++v -0.500000 -0.000000 0.500000
++v -0.500000 -0.151764 0.500000
++v -0.374938 -0.234844 0.500000
++v -0.452098 -0.047902 0.500000
++v -0.477407 -0.022593 0.500000
++v -0.374938 -0.234844 0.500000
++v -0.290763 -0.290763 0.500000
++v -0.395383 -0.104617 0.500000
++v -0.452098 -0.047902 0.500000
++v -0.385432 -0.148255 0.500000
++v -0.387917 -0.137361 0.500000
++v -0.385432 -0.148255 0.500000
++v -0.380556 -0.155596 0.500000
++v -0.305543 -0.268514 0.500000
++v -0.290763 -0.290763 0.500000
++v -0.268514 -0.305543 0.500000
++v -0.387917 -0.137361 -0.500000
++v -0.395383 -0.104617 -0.500000
++v -0.459619 0.000000 -0.459619
++v -0.500000 -0.104617 -0.395383
++v -0.500000 -0.137361 -0.387917
++v -0.415741 -0.277161 -0.415741
++v -0.305543 -0.268514 -0.500000
++v -0.380556 -0.155596 -0.500000
++v -0.500000 -0.268514 -0.305543
++v -0.415741 -0.277161 -0.415741
++v -0.500000 -0.155596 -0.380556
++v -0.387917 -0.137361 -0.500000
++v -0.500000 -0.137361 -0.387917
++v -0.500000 -0.155596 -0.380556
++v -0.415741 -0.277161 -0.415741
++v -0.380556 -0.155596 -0.500000
++v -0.268514 -0.305543 -0.500000
++v -0.277161 -0.415741 -0.415741
++v -0.155596 -0.380556 -0.500000
++v -0.137361 -0.500000 -0.387917
++v -0.104617 -0.500000 -0.395383
++v 0.000000 -0.459619 -0.459619
++v -0.104617 -0.395383 -0.500000
++v -0.137361 -0.387917 -0.500000
++v -0.277161 -0.415741 -0.415741
++v -0.268514 -0.500000 -0.305543
++v -0.155596 -0.500000 -0.380556
++v -0.155596 -0.380556 -0.500000
++v -0.277161 -0.415741 -0.415741
++v -0.155596 -0.500000 -0.380556
++v -0.137361 -0.500000 -0.387917
++v -0.137361 -0.387917 -0.500000
++v -0.415741 -0.415741 -0.277161
++v -0.500000 -0.305543 -0.268514
++v -0.500000 -0.380556 -0.155596
++v -0.305543 -0.500000 -0.268514
++v -0.415741 -0.415741 -0.277161
++v -0.380556 -0.500000 -0.155596
++v -0.500000 -0.387917 -0.137361
++v -0.500000 -0.395383 -0.104617
++v -0.459619 -0.459619 -0.000000
++v -0.395383 -0.500000 -0.104617
++v -0.387917 -0.500000 -0.137361
++v -0.380556 -0.500000 -0.155596
++v -0.415741 -0.415741 -0.277161
++v -0.500000 -0.380556 -0.155596
++v -0.500000 -0.387917 -0.137361
++v -0.387917 -0.500000 -0.137361
++v -0.277161 -0.415741 -0.415741
++v -0.268514 -0.305543 -0.500000
++v -0.305543 -0.268514 -0.500000
++v -0.415741 -0.277161 -0.415741
++v -0.415741 -0.415741 -0.277161
++v -0.305543 -0.500000 -0.268514
++v -0.268514 -0.500000 -0.305543
++v -0.277161 -0.415741 -0.415741
++v -0.415741 -0.277161 -0.415741
++v -0.500000 -0.268514 -0.305543
++v -0.500000 -0.305543 -0.268514
++v -0.415741 -0.415741 -0.277161
++v -0.415741 -0.277161 -0.415741
++v -0.415741 -0.415741 -0.277161
++v -0.277161 -0.415741 -0.415741
++v -0.500000 0.000000 -0.410416
++v -0.500000 -0.104617 -0.395383
++v -0.459619 0.000000 -0.459619
++v -0.500000 -0.395383 -0.104617
++v -0.500000 -0.410416 -0.000000
++v -0.459619 -0.459619 -0.000000
++v -0.395383 -0.104617 -0.500000
++v -0.410416 0.000000 -0.500000
++v -0.459619 0.000000 -0.459619
++v 0.000000 -0.410416 -0.500000
++v -0.104617 -0.395383 -0.500000
++v 0.000000 -0.459619 -0.459619
++v -0.410416 -0.500000 -0.000000
++v -0.395383 -0.500000 -0.104617
++v -0.459619 -0.459619 -0.000000
++v -0.104617 -0.500000 -0.395383
++v 0.000000 -0.500000 -0.410416
++v 0.000000 -0.459619 -0.459619
++v -0.387917 -0.500000 0.137361
++v -0.395383 -0.500000 0.104617
++v -0.459619 -0.459619 -0.000000
++v -0.500000 -0.395383 0.104617
++v -0.500000 -0.387917 0.137361
++v -0.415741 -0.415741 0.277161
++v -0.305543 -0.500000 0.268514
++v -0.380556 -0.500000 0.155596
++v -0.500000 -0.305543 0.268514
++v -0.415741 -0.415741 0.277161
++v -0.500000 -0.380556 0.155596
++v -0.387917 -0.500000 0.137361
++v -0.500000 -0.387917 0.137361
++v -0.500000 -0.380556 0.155596
++v -0.415741 -0.415741 0.277161
++v -0.380556 -0.500000 0.155596
++v -0.268514 -0.500000 0.305543
++v -0.277161 -0.415741 0.415741
++v -0.155596 -0.500000 0.380556
++v -0.137361 -0.387917 0.500000
++v -0.104617 -0.395383 0.500000
++v 0.000000 -0.459619 0.459619
++v -0.104617 -0.500000 0.395383
++v -0.137361 -0.500000 0.387917
++v -0.277161 -0.415741 0.415741
++v -0.268514 -0.305543 0.500000
++v -0.155596 -0.380556 0.500000
++v -0.137361 -0.387917 0.500000
++v -0.137361 -0.500000 0.387917
++v -0.155596 -0.500000 0.380556
++v -0.277161 -0.415741 0.415741
++v -0.155596 -0.380556 0.500000
++v -0.415741 -0.277161 0.415741
++v -0.500000 -0.268514 0.305543
++v -0.500000 -0.155596 0.380556
++v -0.305543 -0.268514 0.500000
++v -0.415741 -0.277161 0.415741
++v -0.380556 -0.155596 0.500000
++v -0.500000 -0.137361 0.387917
++v -0.500000 -0.104617 0.395383
++v -0.459619 -0.000000 0.459619
++v -0.395383 -0.104617 0.500000
++v -0.387917 -0.137361 0.500000
++v -0.380556 -0.155596 0.500000
++v -0.415741 -0.277161 0.415741
++v -0.500000 -0.155596 0.380556
++v -0.500000 -0.137361 0.387917
++v -0.387917 -0.137361 0.500000
++v -0.277161 -0.415741 0.415741
++v -0.268514 -0.500000 0.305543
++v -0.305543 -0.500000 0.268514
++v -0.415741 -0.415741 0.277161
++v -0.415741 -0.277161 0.415741
++v -0.305543 -0.268514 0.500000
++v -0.268514 -0.305543 0.500000
++v -0.277161 -0.415741 0.415741
++v -0.415741 -0.415741 0.277161
++v -0.500000 -0.305543 0.268514
++v -0.500000 -0.268514 0.305543
++v -0.415741 -0.277161 0.415741
++v -0.415741 -0.415741 0.277161
++v -0.415741 -0.277161 0.415741
++v -0.277161 -0.415741 0.415741
++v -0.500000 -0.410416 -0.000000
++v -0.500000 -0.395383 0.104617
++v -0.459619 -0.459619 -0.000000
++v -0.500000 -0.104617 0.395383
++v -0.500000 -0.000000 0.410416
++v -0.459619 -0.000000 0.459619
++v -0.395383 -0.500000 0.104617
++v -0.410416 -0.500000 -0.000000
++v -0.459619 -0.459619 -0.000000
++v 0.000000 -0.500000 0.410416
++v -0.104617 -0.500000 0.395383
++v 0.000000 -0.459619 0.459619
++v -0.410416 -0.000000 0.500000
++v -0.395383 -0.104617 0.500000
++v -0.459619 -0.000000 0.459619
++v -0.104617 -0.395383 0.500000
++v 0.000000 -0.410416 0.500000
++v 0.000000 -0.459619 0.459619
++v -0.387917 0.500000 -0.137361
++v -0.395383 0.500000 -0.104617
++v -0.459619 0.459619 0.000000
++v -0.500000 0.395383 -0.104617
++v -0.500000 0.387917 -0.137361
++v -0.415741 0.415741 -0.277161
++v -0.305543 0.500000 -0.268514
++v -0.380556 0.500000 -0.155596
++v -0.500000 0.305543 -0.268514
++v -0.415741 0.415741 -0.277161
++v -0.500000 0.380556 -0.155596
++v -0.387917 0.500000 -0.137361
++v -0.500000 0.387917 -0.137361
++v -0.500000 0.380556 -0.155596
++v -0.415741 0.415741 -0.277161
++v -0.380556 0.500000 -0.155596
++v -0.268514 0.500000 -0.305543
++v -0.277161 0.415741 -0.415741
++v -0.155596 0.500000 -0.380556
++v -0.137361 0.387917 -0.500000
++v -0.104617 0.395383 -0.500000
++v 0.000000 0.459619 -0.459619
++v -0.104617 0.500000 -0.395383
++v -0.137361 0.500000 -0.387917
++v -0.277161 0.415741 -0.415741
++v -0.268514 0.305543 -0.500000
++v -0.155596 0.380556 -0.500000
++v -0.137361 0.387917 -0.500000
++v -0.137361 0.500000 -0.387917
++v -0.155596 0.500000 -0.380556
++v -0.277161 0.415741 -0.415741
++v -0.155596 0.380556 -0.500000
++v -0.415741 0.277161 -0.415741
++v -0.500000 0.268514 -0.305543
++v -0.500000 0.155596 -0.380556
++v -0.305543 0.268514 -0.500000
++v -0.415741 0.277161 -0.415741
++v -0.380556 0.155596 -0.500000
++v -0.500000 0.137361 -0.387917
++v -0.500000 0.104617 -0.395383
++v -0.459619 0.000000 -0.459619
++v -0.395383 0.104617 -0.500000
++v -0.387917 0.137361 -0.500000
++v -0.380556 0.155596 -0.500000
++v -0.415741 0.277161 -0.415741
++v -0.500000 0.155596 -0.380556
++v -0.500000 0.137361 -0.387917
++v -0.387917 0.137361 -0.500000
++v -0.277161 0.415741 -0.415741
++v -0.268514 0.500000 -0.305543
++v -0.305543 0.500000 -0.268514
++v -0.415741 0.415741 -0.277161
++v -0.415741 0.277161 -0.415741
++v -0.305543 0.268514 -0.500000
++v -0.268514 0.305543 -0.500000
++v -0.277161 0.415741 -0.415741
++v -0.415741 0.415741 -0.277161
++v -0.500000 0.305543 -0.268514
++v -0.500000 0.268514 -0.305543
++v -0.415741 0.277161 -0.415741
++v -0.415741 0.415741 -0.277161
++v -0.415741 0.277161 -0.415741
++v -0.277161 0.415741 -0.415741
++v -0.500000 0.410416 0.000000
++v -0.500000 0.395383 -0.104617
++v -0.459619 0.459619 0.000000
++v -0.500000 0.104617 -0.395383
++v -0.500000 0.000000 -0.410416
++v -0.459619 0.000000 -0.459619
++v -0.395383 0.500000 -0.104617
++v -0.410416 0.500000 0.000000
++v -0.459619 0.459619 0.000000
++v 0.000000 0.500000 -0.410416
++v -0.104617 0.500000 -0.395383
++v 0.000000 0.459619 -0.459619
++v -0.410416 0.000000 -0.500000
++v -0.395383 0.104617 -0.500000
++v -0.459619 0.000000 -0.459619
++v -0.104617 0.395383 -0.500000
++v 0.000000 0.410416 -0.500000
++v 0.000000 0.459619 -0.459619
++v -0.387917 0.137361 0.500000
++v -0.395383 0.104617 0.500000
++v -0.459619 -0.000000 0.459619
++v -0.500000 0.104617 0.395383
++v -0.500000 0.137361 0.387917
++v -0.415741 0.277161 0.415741
++v -0.305543 0.268514 0.500000
++v -0.380556 0.155596 0.500000
++v -0.500000 0.268514 0.305543
++v -0.415741 0.277161 0.415741
++v -0.500000 0.155596 0.380556
++v -0.387917 0.137361 0.500000
++v -0.500000 0.137361 0.387917
++v -0.500000 0.155596 0.380556
++v -0.415741 0.277161 0.415741
++v -0.380556 0.155596 0.500000
++v -0.268514 0.305543 0.500000
++v -0.277161 0.415741 0.415741
++v -0.155596 0.380556 0.500000
++v -0.137361 0.500000 0.387917
++v -0.104617 0.500000 0.395383
++v 0.000000 0.459619 0.459619
++v -0.104617 0.395383 0.500000
++v -0.137361 0.387917 0.500000
++v -0.277161 0.415741 0.415741
++v -0.268514 0.500000 0.305543
++v -0.155596 0.500000 0.380556
++v -0.155596 0.380556 0.500000
++v -0.277161 0.415741 0.415741
++v -0.155596 0.500000 0.380556
++v -0.137361 0.500000 0.387917
++v -0.137361 0.387917 0.500000
++v -0.415741 0.415741 0.277161
++v -0.500000 0.305543 0.268514
++v -0.500000 0.380556 0.155596
++v -0.305543 0.500000 0.268514
++v -0.415741 0.415741 0.277161
++v -0.380556 0.500000 0.155596
++v -0.500000 0.387917 0.137361
++v -0.500000 0.395383 0.104617
++v -0.459619 0.459619 0.000000
++v -0.395383 0.500000 0.104617
++v -0.387917 0.500000 0.137361
++v -0.380556 0.500000 0.155596
++v -0.415741 0.415741 0.277161
++v -0.500000 0.380556 0.155596
++v -0.500000 0.387917 0.137361
++v -0.387917 0.500000 0.137361
++v -0.277161 0.415741 0.415741
++v -0.268514 0.305543 0.500000
++v -0.305543 0.268514 0.500000
++v -0.415741 0.277161 0.415741
++v -0.415741 0.415741 0.277161
++v -0.305543 0.500000 0.268514
++v -0.268514 0.500000 0.305543
++v -0.277161 0.415741 0.415741
++v -0.415741 0.277161 0.415741
++v -0.500000 0.268514 0.305543
++v -0.500000 0.305543 0.268514
++v -0.415741 0.415741 0.277161
++v -0.415741 0.277161 0.415741
++v -0.415741 0.415741 0.277161
++v -0.277161 0.415741 0.415741
++v -0.500000 -0.000000 0.410416
++v -0.500000 0.104617 0.395383
++v -0.459619 -0.000000 0.459619
++v -0.500000 0.395383 0.104617
++v -0.500000 0.410416 0.000000
++v -0.459619 0.459619 0.000000
++v -0.395383 0.104617 0.500000
++v -0.410416 -0.000000 0.500000
++v -0.459619 -0.000000 0.459619
++v 0.000000 0.410416 0.500000
++v -0.104617 0.395383 0.500000
++v 0.000000 0.459619 0.459619
++v -0.410416 0.500000 0.000000
++v -0.395383 0.500000 0.104617
++v -0.459619 0.459619 0.000000
++v -0.104617 0.500000 0.395383
++v 0.000000 0.500000 0.410416
++v 0.000000 0.459619 0.459619
++v 0.387917 -0.500000 -0.137361
++v 0.395383 -0.500000 -0.104617
++v 0.459619 -0.459619 -0.000000
++v 0.500000 -0.395383 -0.104617
++v 0.500000 -0.387917 -0.137361
++v 0.415741 -0.415741 -0.277161
++v 0.305543 -0.500000 -0.268514
++v 0.380556 -0.500000 -0.155596
++v 0.500000 -0.305543 -0.268514
++v 0.415741 -0.415741 -0.277161
++v 0.500000 -0.380556 -0.155596
++v 0.387917 -0.500000 -0.137361
++v 0.500000 -0.387917 -0.137361
++v 0.500000 -0.380556 -0.155596
++v 0.415741 -0.415741 -0.277161
++v 0.380556 -0.500000 -0.155596
++v 0.268514 -0.500000 -0.305543
++v 0.277161 -0.415741 -0.415741
++v 0.155596 -0.500000 -0.380556
++v 0.137361 -0.387917 -0.500000
++v 0.104617 -0.395383 -0.500000
++v 0.000000 -0.459619 -0.459619
++v 0.104617 -0.500000 -0.395383
++v 0.137361 -0.500000 -0.387917
++v 0.277161 -0.415741 -0.415741
++v 0.268514 -0.305543 -0.500000
++v 0.155596 -0.380556 -0.500000
++v 0.137361 -0.387917 -0.500000
++v 0.137361 -0.500000 -0.387917
++v 0.155596 -0.500000 -0.380556
++v 0.277161 -0.415741 -0.415741
++v 0.155596 -0.380556 -0.500000
++v 0.415741 -0.277161 -0.415741
++v 0.500000 -0.268514 -0.305543
++v 0.500000 -0.155596 -0.380556
++v 0.305543 -0.268514 -0.500000
++v 0.415741 -0.277161 -0.415741
++v 0.380556 -0.155596 -0.500000
++v 0.500000 -0.137361 -0.387917
++v 0.500000 -0.104617 -0.395383
++v 0.459619 0.000000 -0.459619
++v 0.395383 -0.104617 -0.500000
++v 0.387917 -0.137361 -0.500000
++v 0.380556 -0.155596 -0.500000
++v 0.415741 -0.277161 -0.415741
++v 0.500000 -0.155596 -0.380556
++v 0.500000 -0.137361 -0.387917
++v 0.387917 -0.137361 -0.500000
++v 0.277161 -0.415741 -0.415741
++v 0.268514 -0.500000 -0.305543
++v 0.305543 -0.500000 -0.268514
++v 0.415741 -0.415741 -0.277161
++v 0.415741 -0.277161 -0.415741
++v 0.305543 -0.268514 -0.500000
++v 0.268514 -0.305543 -0.500000
++v 0.277161 -0.415741 -0.415741
++v 0.415741 -0.415741 -0.277161
++v 0.500000 -0.305543 -0.268514
++v 0.500000 -0.268514 -0.305543
++v 0.415741 -0.277161 -0.415741
++v 0.415741 -0.277161 -0.415741
++v 0.277161 -0.415741 -0.415741
++v 0.415741 -0.415741 -0.277161
++v 0.500000 -0.410416 -0.000000
++v 0.500000 -0.395383 -0.104617
++v 0.459619 -0.459619 -0.000000
++v 0.500000 -0.104617 -0.395383
++v 0.500000 0.000000 -0.410416
++v 0.459619 0.000000 -0.459619
++v 0.395383 -0.500000 -0.104617
++v 0.410416 -0.500000 -0.000000
++v 0.459619 -0.459619 -0.000000
++v 0.000000 -0.500000 -0.410416
++v 0.104617 -0.500000 -0.395383
++v 0.000000 -0.459619 -0.459619
++v 0.410416 0.000000 -0.500000
++v 0.395383 -0.104617 -0.500000
++v 0.459619 0.000000 -0.459619
++v 0.104617 -0.395383 -0.500000
++v 0.000000 -0.410416 -0.500000
++v 0.000000 -0.459619 -0.459619
++v 0.387917 -0.137361 0.500000
++v 0.395383 -0.104617 0.500000
++v 0.459619 -0.000000 0.459619
++v 0.500000 -0.104617 0.395383
++v 0.500000 -0.137361 0.387917
++v 0.415741 -0.277161 0.415741
++v 0.305543 -0.268514 0.500000
++v 0.380556 -0.155596 0.500000
++v 0.500000 -0.268514 0.305543
++v 0.415741 -0.277161 0.415741
++v 0.500000 -0.155596 0.380556
++v 0.387917 -0.137361 0.500000
++v 0.500000 -0.137361 0.387917
++v 0.500000 -0.155596 0.380556
++v 0.415741 -0.277161 0.415741
++v 0.380556 -0.155596 0.500000
++v 0.268514 -0.305543 0.500000
++v 0.277161 -0.415741 0.415741
++v 0.155596 -0.380556 0.500000
++v 0.137361 -0.500000 0.387917
++v 0.104617 -0.500000 0.395383
++v 0.000000 -0.459619 0.459619
++v 0.104617 -0.395383 0.500000
++v 0.137361 -0.387917 0.500000
++v 0.277161 -0.415741 0.415741
++v 0.268514 -0.500000 0.305543
++v 0.155596 -0.500000 0.380556
++v 0.155596 -0.380556 0.500000
++v 0.277161 -0.415741 0.415741
++v 0.155596 -0.500000 0.380556
++v 0.137361 -0.500000 0.387917
++v 0.137361 -0.387917 0.500000
++v 0.415741 -0.415741 0.277161
++v 0.500000 -0.305543 0.268514
++v 0.500000 -0.380556 0.155596
... 204248 lines suppressed ...