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 ...