You are viewing a plain text version of this content. The canonical link for it is here.
Posted to issues@commons.apache.org by GitBox <gi...@apache.org> on 2021/01/20 03:40:03 UTC

[GitHub] [commons-geometry] darkma773r opened a new pull request #130: GEOMETRY-115: IO Modules

darkma773r opened a new pull request #130:
URL: https://github.com/apache/commons-geometry/pull/130


   Adding modules
   - commons-geometry-core-io
   - commons-geometry-euclidean-io


----------------------------------------------------------------
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-geometry] darkma773r commented on pull request #130: GEOMETRY-115: IO Modules

Posted by GitBox <gi...@apache.org>.
darkma773r commented on pull request #130:
URL: https://github.com/apache/commons-geometry/pull/130#issuecomment-763995788


   Renamed modules and packages to have `io` portion first in order to avoid JPMS conflicts. (Based on discussion on dev mailing list.)


----------------------------------------------------------------
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-geometry] arturobernalg commented on pull request #130: GEOMETRY-115: IO Modules

Posted by GitBox <gi...@apache.org>.
arturobernalg commented on pull request #130:
URL: https://github.com/apache/commons-geometry/pull/130#issuecomment-763672458


   > Thank you for the detailed review, @arturobernalg!
   
   Great. Glad to help @darkma773r. Please let me know If I can help you white anything. 


----------------------------------------------------------------
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-geometry] darkma773r closed pull request #130: GEOMETRY-115: IO Modules

Posted by GitBox <gi...@apache.org>.
darkma773r closed pull request #130:
URL: https://github.com/apache/commons-geometry/pull/130


   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-geometry] arturobernalg commented on a change in pull request #130: GEOMETRY-115: IO Modules

Posted by GitBox <gi...@apache.org>.
arturobernalg commented on a change in pull request #130:
URL: https://github.com/apache/commons-geometry/pull/130#discussion_r560819598



##########
File path: commons-geometry-core-io/src/main/java/org/apache/commons/geometry/core/io/internal/SimpleTextParser.java
##########
@@ -0,0 +1,1207 @@
+/*
+ * 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.io.internal;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.IntConsumer;
+import java.util.function.IntPredicate;
+
+/** Class providing basic text parsing capabilities. The goals of this class are to
+ * (1) provide a simple, flexible API for performing common text parsing operations and
+ * (2) provide a mechanism for creating consistent and informative parsing errors.
+ * This class is not intended as a replacement for grammar-based parsers and/or lexers.
+ */
+public class SimpleTextParser {
+
+    /** Constant indicating that the end of the input has been reached. */
+    private static final int EOF = -1;
+
+    /** Carriage return character. */
+    private static final char CR = '\r';
+
+    /** Line feed character. */
+    private static final char LF = '\n';
+
+    /** Default value for the max string length property. */
+    private static final int DEFAULT_MAX_STRING_LENGTH = 1024;
+
+    /** Error message used when a string exceeds the configured maximum length. */
+    private static final String STRING_LENGTH_ERR_MSG = "String length exceeds maximum value of ";
+
+    /** Initial token position number. */
+    private static final int INITIAL_TOKEN_POS = -1;
+
+    /** Int consumer that does nothing. */
+    private static final IntConsumer NOOP_CONSUMER = ch -> { };
+
+    /** Current line number; line numbers start counting at 1. */
+    private int lineNumber = 1;
+
+    /** Current character column on the current line; column numbers start at 1.*/
+    private int columnNumber = 1;
+
+    /** Maximum length for strings returned by this instance. */
+    private int maxStringLength = DEFAULT_MAX_STRING_LENGTH;
+
+    /** The current token. */
+    private String currentToken;
+
+    /** The line number that the current token started on. */
+    private int currentTokenLineNumber = INITIAL_TOKEN_POS;
+
+    /** The character number that the current token started on. */
+    private int currentTokenColumnNumber = INITIAL_TOKEN_POS;
+
+    /** Flag used to indicate that at least one token has been read from the stream. */
+    private boolean hasSetToken = false;
+
+    /** Character read buffer used to access the character stream. */
+    private final CharReadBuffer buffer;
+
+    /** Construct a new instance that reads characters from the given reader. The
+     * reader will not be closed.
+     * @param reader reader instance to read characters from
+     */
+    public SimpleTextParser(final Reader reader) {
+        this(new CharReadBuffer(reader));
+    }
+
+    /** Construct a new instance that reads characters from the given character buffer.
+     * @param buffer read buffer to read characters from
+     */
+    public SimpleTextParser(final CharReadBuffer buffer) {
+        this.buffer = buffer;
+    }
+
+    /** Get the current line number. Line numbers start at 1.
+     * @return the current line number
+     */
+    public int getLineNumber() {
+        return lineNumber;
+    }
+
+    /** Set the current line number. This does not affect the character stream position,
+     * only the value returned by {@link #getLineNumber()}.
+     * @param lineNumber line number to set; line numbers start at 1
+     */
+    public void setLineNumber(final int lineNumber) {
+        this.lineNumber = lineNumber;
+    }
+
+    /** Get the current column number. This indicates the column position of the
+     * character that will returned by the next call to {@link #next()}. The first

Review comment:
       #next(int)}

##########
File path: commons-geometry-euclidean-io/src/main/java/org/apache/commons/geometry/euclidean/io/threed/obj/PolygonOBJParser.java
##########
@@ -0,0 +1,486 @@
+/*
+ * 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.io.threed.obj;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.function.IntFunction;
+import java.util.function.ToIntFunction;
+import java.util.stream.Collectors;
+
+import org.apache.commons.geometry.core.io.internal.SimpleTextParser;
+import org.apache.commons.geometry.euclidean.internal.Vectors;
+import org.apache.commons.geometry.euclidean.threed.Vector3D;
+
+/** Low-level parser class for reading 3D polygon (face) data in the OBJ file format.
+ * This class provides access to OBJ data structures but does not retain any of the
+ * parsed data. For example, it is up to callers to store vertices as they are parsed
+ * for later reference. This allows callers to determine what values are stored and in
+ * what format.
+ */
+public class PolygonOBJParser extends AbstractOBJParser {
+
+    /** Set containing OBJ keywords commonly used with files containing only polygon content. */
+    private static final Set<String> STANDARD_POLYGON_KEYWORDS =
+            Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
+                        OBJConstants.VERTEX_KEYWORD,
+                        OBJConstants.VERTEX_NORMAL_KEYWORD,
+                        OBJConstants.TEXTURE_COORDINATE_KEYWORD,
+                        OBJConstants.FACE_KEYWORD,
+
+                        OBJConstants.OBJECT_KEYWORD,
+                        OBJConstants.GROUP_KEYWORD,
+                        OBJConstants.SMOOTHING_GROUP_KEYWORD,
+
+                        OBJConstants.MATERIAL_LIBRARY_KEYWORD,
+                        OBJConstants.USE_MATERIAL_KEYWORD
+                    )));
+
+    /** Number of vertex keywords encountered in the file so far. */
+    private int vertexCount;
+
+    /** Number of vertex normal keywords encountered in the file so far. */
+    private int vertexNormalCount;
+
+    /** Number of texture coordinate keywords encountered in the file so far. */
+    private int textureCoordinateCount;
+
+    /** If true, parsing will fail when non-polygon keywords are encountered in the OBJ content. */
+    private boolean failOnNonPolygonKeywords = false;

Review comment:
       we can safely remove the redundant initializer

##########
File path: commons-geometry-euclidean-io/src/main/java/org/apache/commons/geometry/euclidean/io/threed/obj/OBJWriter.java
##########
@@ -0,0 +1,514 @@
+/*
+ * 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.io.threed.obj;
+
+import java.io.IOException;
+import java.io.Writer;
+import java.text.DecimalFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+
+import org.apache.commons.geometry.core.io.utils.AbstractTextFormatWriter;
+import org.apache.commons.geometry.euclidean.io.threed.FacetDefinition;
+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 polygon geometries.
+ */
+public final class OBJWriter extends AbstractTextFormatWriter {
+
+    /** Space character. */
+    private static final char SPACE = ' ';
+
+    /** Number of vertices written to the output. */
+    private int vertexCount = 0;

Review comment:
       we can safely remove the redundant initializer

##########
File path: commons-geometry-core-io/src/test/java/org/apache/commons/geometry/core/io/utils/AbstractTextFormatWriterTest.java
##########
@@ -0,0 +1,77 @@
+/*
+ * 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.io.utils;
+
+import java.io.IOException;
+import java.io.StringWriter;
+import java.io.Writer;
+import java.text.DecimalFormat;
+
+import org.apache.commons.geometry.core.io.test.CloseCountWriter;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class AbstractTextFormatWriterTest {
+
+    private StringWriter out = new StringWriter();

Review comment:
       we can use Final

##########
File path: commons-geometry-core-io/src/main/java/org/apache/commons/geometry/core/io/internal/GeometryIOUtils.java
##########
@@ -0,0 +1,213 @@
+/*
+ * 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.io.internal;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.Closeable;
+import java.io.FilterInputStream;
+import java.io.FilterOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.Reader;
+import java.io.UncheckedIOException;
+import java.io.Writer;
+import java.nio.charset.Charset;
+import java.util.stream.Stream;
+
+/** Class containing utility methods for IO operations.
+ */
+public final class GeometryIOUtils {
+
+    /** Utility class; no instantiation. */
+    private GeometryIOUtils() {}
+
+    /** Get the part of the file name after the last dot.
+     * @param fileName file name to get the extension for
+     * @return the extension of the file name, the empty string if no extension is found, or
+     *      null if the argument is null
+     */
+    public static String getFileExtension(final String fileName) {
+        if (fileName != null) {
+            final int idx = fileName.lastIndexOf('.');
+            if (idx > -1) {
+                return fileName.substring(idx + 1);
+            }
+
+            return "";
+        }
+
+        return null;
+    }
+
+    /** Create an unchecked exception from the given checked exception. The message of the
+     * returned exception contains the original exception's type and message.
+     * @param exc exception to wrap in an unchecked exception
+     * @return the unchecked exception
+     */
+    public static UncheckedIOException createUnchecked(final IOException exc) {
+        final String msg = exc.getClass().getSimpleName() + ": " + exc.getMessage();
+        return new UncheckedIOException(msg, exc);
+    }
+
+    /** Return an input stream that delegates all calls to the argument but does not
+     * close the argument when {@link InputStream#close() close()} is called.
+     * @param in input stream to wrap
+     * @return an input stream that delegates all calls to {@code in} except for {@code close()}
+     */
+    public static InputStream createCloseShieldInputStream(final InputStream in) {
+        return new CloseShieldInputStream(in);
+    }
+
+    /** Return an output stream that delegates all calls to the argument but does not close
+     * the argument when {@link OutputStream#close() close()} is called.
+     * @param out output stream to wrap
+     * @return an output stream that delegates all calls to {@code out} except for {@code close()}
+     */
+    public static OutputStream createCloseShieldOutputStream(final OutputStream out) {
+        return new CloseShieldOutputStream(out);
+    }
+
+    /** Return a buffered reader that reads characters of the given charset from {@code in} but
+     * does not close {@code in} when {@link Reader#close() close()} is called.
+     * @param in input stream to read from
+     * @param charset reader charset
+     * @return a buffered reader that reads characters from {@code in} but does not close it when
+     *      {@code close()} is called
+     */
+    public static Reader createCloseShieldReader(final InputStream in, final Charset charset) {
+        final InputStream shielded = createCloseShieldInputStream(in);
+        return new BufferedReader(new InputStreamReader(shielded, charset));
+    }
+
+    /** Return a buffered writer that writer characters of the given charset to {@code out} but
+     * does not close {@code out} when {@link Writer#close() close} is called.
+     * @param out output stream to write to
+     * @param charset writer charset
+     * @return a buffered writer that writes characters to {@code out} but does not close it
+     *      when {@code close()} is called
+     */
+    public static Writer createCloseShieldWriter(final OutputStream out, final Charset charset) {
+        final OutputStream shielded = createCloseShieldOutputStream(out);
+        return new BufferedWriter(new OutputStreamWriter(shielded, charset));
+    }
+
+    /** Pass a supplied {@link Closeable} instance to {@code function} and return the result.
+     * The {@code Closeable} instance returned by the supplier is closed if function execution
+     * fails, otherwise the instance is <em>not</em> closed.
+     * @param <T> Return type
+     * @param <C> Closeable type
+     * @param function function called with the supplied Closeable instance
+     * @param closeableSupplier supplier used to obtain a Closeable instance
+     * @return result of calling {@code function} with a supplied Closeable instance
+     * @throws IOException if an I/O error occurs
+     */
+    public static <T, C extends Closeable> T tryApplyCloseable(final IOFunction<C, T> function,
+            final IOSupplier<? extends C> closeableSupplier) throws IOException {
+        C closeable = null;
+        try {
+            closeable = closeableSupplier.get();
+            return function.apply(closeable);
+        } catch (IOException | RuntimeException exc) {

Review comment:
       Same comment

##########
File path: commons-geometry-core-io/src/main/java/org/apache/commons/geometry/core/io/internal/GeometryIOUtils.java
##########
@@ -0,0 +1,213 @@
+/*
+ * 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.io.internal;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.Closeable;
+import java.io.FilterInputStream;
+import java.io.FilterOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.Reader;
+import java.io.UncheckedIOException;
+import java.io.Writer;
+import java.nio.charset.Charset;
+import java.util.stream.Stream;
+
+/** Class containing utility methods for IO operations.
+ */
+public final class GeometryIOUtils {
+
+    /** Utility class; no instantiation. */
+    private GeometryIOUtils() {}
+
+    /** Get the part of the file name after the last dot.
+     * @param fileName file name to get the extension for
+     * @return the extension of the file name, the empty string if no extension is found, or
+     *      null if the argument is null
+     */
+    public static String getFileExtension(final String fileName) {
+        if (fileName != null) {
+            final int idx = fileName.lastIndexOf('.');
+            if (idx > -1) {
+                return fileName.substring(idx + 1);
+            }
+
+            return "";
+        }
+
+        return null;
+    }
+
+    /** Create an unchecked exception from the given checked exception. The message of the
+     * returned exception contains the original exception's type and message.
+     * @param exc exception to wrap in an unchecked exception
+     * @return the unchecked exception
+     */
+    public static UncheckedIOException createUnchecked(final IOException exc) {
+        final String msg = exc.getClass().getSimpleName() + ": " + exc.getMessage();
+        return new UncheckedIOException(msg, exc);
+    }
+
+    /** Return an input stream that delegates all calls to the argument but does not
+     * close the argument when {@link InputStream#close() close()} is called.
+     * @param in input stream to wrap
+     * @return an input stream that delegates all calls to {@code in} except for {@code close()}
+     */
+    public static InputStream createCloseShieldInputStream(final InputStream in) {
+        return new CloseShieldInputStream(in);
+    }
+
+    /** Return an output stream that delegates all calls to the argument but does not close
+     * the argument when {@link OutputStream#close() close()} is called.
+     * @param out output stream to wrap
+     * @return an output stream that delegates all calls to {@code out} except for {@code close()}
+     */
+    public static OutputStream createCloseShieldOutputStream(final OutputStream out) {
+        return new CloseShieldOutputStream(out);
+    }
+
+    /** Return a buffered reader that reads characters of the given charset from {@code in} but
+     * does not close {@code in} when {@link Reader#close() close()} is called.
+     * @param in input stream to read from
+     * @param charset reader charset
+     * @return a buffered reader that reads characters from {@code in} but does not close it when
+     *      {@code close()} is called
+     */
+    public static Reader createCloseShieldReader(final InputStream in, final Charset charset) {
+        final InputStream shielded = createCloseShieldInputStream(in);
+        return new BufferedReader(new InputStreamReader(shielded, charset));
+    }
+
+    /** Return a buffered writer that writer characters of the given charset to {@code out} but
+     * does not close {@code out} when {@link Writer#close() close} is called.
+     * @param out output stream to write to
+     * @param charset writer charset
+     * @return a buffered writer that writes characters to {@code out} but does not close it
+     *      when {@code close()} is called
+     */
+    public static Writer createCloseShieldWriter(final OutputStream out, final Charset charset) {
+        final OutputStream shielded = createCloseShieldOutputStream(out);
+        return new BufferedWriter(new OutputStreamWriter(shielded, charset));
+    }
+
+    /** Pass a supplied {@link Closeable} instance to {@code function} and return the result.
+     * The {@code Closeable} instance returned by the supplier is closed if function execution
+     * fails, otherwise the instance is <em>not</em> closed.
+     * @param <T> Return type
+     * @param <C> Closeable type
+     * @param function function called with the supplied Closeable instance
+     * @param closeableSupplier supplier used to obtain a Closeable instance
+     * @return result of calling {@code function} with a supplied Closeable instance
+     * @throws IOException if an I/O error occurs
+     */
+    public static <T, C extends Closeable> T tryApplyCloseable(final IOFunction<C, T> function,
+            final IOSupplier<? extends C> closeableSupplier) throws IOException {
+        C closeable = null;
+        try {
+            closeable = closeableSupplier.get();
+            return function.apply(closeable);
+        } catch (IOException | RuntimeException exc) {
+            if (closeable != null) {
+                try {
+                    closeable.close();
+                } catch (IOException suppressed) {
+                    exc.addSuppressed(suppressed);
+                }
+            }
+
+            throw exc;
+        }
+    }
+
+    /** Create a stream associated with an input stream. The input stream is closed when the
+     * stream is closed and also closed if stream creation fails. Any {@link IOException} thrown
+     * when the input stream is closed after the return of this method are wrapped with {@link UncheckedIOException}.
+     * @param <T> Stream element type
+     * @param <I> Input stream type
+     * @param streamFunction function accepting an input stream and returning a stream
+     * @param inputStreamSupplier supplier used to obtain the input stream
+     * @return stream associated with the input stream return by the supplier
+     * @throws IOException if an I/O error occurs during input stream and stream creation
+     */
+    public static <T, I extends InputStream> Stream<T> createCloseableStream(
+            final IOFunction<I, Stream<T>> streamFunction, final IOSupplier<? extends I> inputStreamSupplier)
+                throws IOException {
+        return tryApplyCloseable(
+                in -> streamFunction.apply(in).onClose(closeAsUncheckedRunnable(in)),
+                inputStreamSupplier);
+    }
+
+    /** Return a {@link Runnable} that calls {@link Closeable#getClass() close()} on the argument,
+     * wrapping any {@link IOException} with {@link UncheckedIOException}.
+     * @param closeable instance to be closed
+     * @return runnable that calls {@code close()) on the argument
+     */
+    private static Runnable closeAsUncheckedRunnable(final Closeable closeable) {
+        return () -> {
+            try {
+                closeable.close();
+            } catch (IOException exc) {

Review comment:
       We can use Final

##########
File path: commons-geometry-euclidean-io/src/main/java/org/apache/commons/geometry/euclidean/io/threed/IO3D.java
##########
@@ -0,0 +1,562 @@
+/*
+ * 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.io.threed;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URL;
+import java.nio.file.Path;
+import java.util.Collection;
+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.mesh.TriangleMesh;
+
+/** Utility class providing convenient access to 3D IO functionality. The static read and write functions delegate
+ * to a default {@link #getDefaultManager() DefaultBoundaryIOManager3D} instance. The default configuration should
+ * be suitable for most purposes. If customization is required, consider directly creating and configuring and a
+ * {@link BoundaryIOManager3D} instance instead.
+ *
+ * <p><strong>Examples</strong></p>
+ * <p>The example below reads an OBJ file as a stream of triangles, transforms each triangle, and writes the
+ * result to a CSV file.
+ * <pre>
+ * Path origFile = Paths.get("orig.obj");
+ * Path scaledFile = Paths.get("scaled.csv");
+ * AffineTransformMatrix3D transform = AffineTransformMatrix3D.createScale(2);
+ *
+ * // use the input triangle stream in a try-with-resources statement to ensure
+ * // all resources are properly closed.
+ * try (Stream&lt;Triangle3D&gt; stream = IO3D.triangles(origFile, precision)) {
+ *      IO3D.write(stream.map(t -> t.transform(transform)), scaledFile);
+ * }
+ * </pre>
+ * </p>
+ *
+ * @see DefaultBoundaryIOManager3D
+ */
+public final class IO3D {
+
+    /** 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";
+
+    /** String representing the simple text format described by
+     * {@link org.apache.commons.geometry.euclidean.io.threed.text.TextFacetDefinitionReader TextFacetDefinitionReader}
+     * and
+     * {@link org.apache.commons.geometry.euclidean.io.threed.text.TextFacetDefinitionWriter TextFacetDefinitionWriter}.
+     * This format describes facets by listing the coordinates of its vertices in order, with one facet
+     * described per line. Facets may have 3 or more vertices and do not need to all have the same
+     * number of vertices.
+     */
+    public static final String TXT = "txt";
+
+    /** String representing the CSV file format as described by
+     * {@link org.apache.commons.geometry.euclidean.io.threed.text.TextFacetDefinitionWriter#csvFormat(java.io.Writer)

Review comment:
       Fix JavaDoc. org.apache.commons.geometry.euclidean.io.threed.txt

##########
File path: commons-geometry-euclidean-io/src/main/java/org/apache/commons/geometry/euclidean/io/threed/txt/TextBoundaryWriteHandler3D.java
##########
@@ -0,0 +1,214 @@
+/*
+ * 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.io.threed.txt;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.Charset;
+import java.text.DecimalFormat;
+import java.util.Iterator;
+import java.util.stream.Stream;
+
+import org.apache.commons.geometry.core.io.internal.GeometryIOUtils;
+import org.apache.commons.geometry.euclidean.io.threed.AbstractBoundaryWriteHandler3D;
+import org.apache.commons.geometry.euclidean.io.threed.FacetDefinition;
+import org.apache.commons.geometry.euclidean.threed.PlaneConvexSubset;
+
+/** {@link BoundaryWriteHandler3D} implementation designed to write simple text data
+ * formats using {@link TextFacetDefinitionWriter}. Output is written using the UTF-8 charset
+ * by default.
+ * @see BoundaryWriteHandler3D

Review comment:
       idem

##########
File path: commons-geometry-euclidean-io/src/main/java/org/apache/commons/geometry/euclidean/io/threed/obj/OBJTriangleMeshReader.java
##########
@@ -0,0 +1,94 @@
+/*
+ * 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.io.threed.obj;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+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 OBJ content as a {@link TriangleMesh triangle mesh}.
+ */
+public class OBJTriangleMeshReader extends AbstractOBJPolygonReader {
+
+    /** Object used to construct the mesh. */
+    private final SimpleTriangleMesh.Builder meshBuilder;
+
+    /** List of normals discovered in the input. */
+    private final List<Vector3D> normals = new ArrayList<>();
+
+    /** Construct a new instance that reads OBJ content from the given reader.
+     * @param reader reader to read from
+     * @param precision precision context used to compare floating point numbers
+     */
+    public OBJTriangleMeshReader(final Reader reader, final DoublePrecisionContext precision) {
+        super(reader);
+
+        this.meshBuilder = SimpleTriangleMesh.builder(precision);
+    }
+
+    /** Return a {@link TriangleMesh triangle mesh} constructed from all of the OBJ content
+     * from the underlying reader. Non-triangle faces are converted to triangles using a simple
+     * triangle fan. All vertices present in the OBJ content are also present in the returned mesh,
+     * regardless of whether or not they are used in a face.
+     * @return triangle mesh containing all data from the OBJ content
+     * @throws IOException if an I/O or data format error occurs
+     */
+    public TriangleMesh readTriangleMesh() throws IOException {
+        PolygonOBJParser.Face face;
+        Vector3D definedNormal;
+        Iterator<PolygonOBJParser.VertexAttributes> attrs;
+        while ((face = readFace()) != null) {
+            // get the face attributes in the proper counter-clockwise orientation
+            definedNormal = face.getDefinedCompositeNormal(normals::get);
+            attrs = face.getCounterClockwiseVertexAttributes(definedNormal, meshBuilder::getVertex).iterator();
+
+            // add the face vertices using a triangle fan
+            int p0 = attrs.next().getVertexIndex();

Review comment:
       we can use final

##########
File path: commons-geometry-core-io/src/test/java/org/apache/commons/geometry/core/io/test/CloseCountOutputStream.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.core.io.test;
+
+import java.io.FilterOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+
+/** {@code OutputStream} that counts how many times the {@link #close()}
+ * method has been invoked.
+ */
+public class CloseCountOutputStream extends FilterOutputStream {
+
+    /** Number of times close() has been called on this instance. */
+    private int closeCount;
+
+    /** Construct a new instance that delegates all calls to the
+     * given output stream.
+     * @param in underlying output stream

Review comment:
       Out.  Fix Javadoc

##########
File path: commons-geometry-euclidean-io/src/main/java/org/apache/commons/geometry/euclidean/io/threed/AbstractBoundaryReadHandler3D.java
##########
@@ -0,0 +1,165 @@
+/*
+ * 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.io.threed;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.NoSuchElementException;
+import java.util.Spliterator;
+import java.util.Spliterators;
+import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
+
+import org.apache.commons.geometry.core.io.internal.GeometryIOUtils;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.threed.BoundaryList3D;
+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.mesh.SimpleTriangleMesh;
+import org.apache.commons.geometry.euclidean.threed.mesh.TriangleMesh;
+
+/** Abstract base class for {@link BoundaryReadHandler3D} implementations.
+ */
+public abstract class AbstractBoundaryReadHandler3D implements BoundaryReadHandler3D {
+
+    /** {@inheritDoc} */
+    @Override
+    public BoundarySource3D read(final InputStream in, final DoublePrecisionContext precision)
+            throws IOException {
+        // read the input as a simple list of boundaries
+        final List<PlaneConvexSubset> list = new ArrayList<>();
+
+        try (FacetDefinitionReader reader =
+                facetDefinitionReader(GeometryIOUtils.createCloseShieldInputStream(in))) {
+
+            FacetDefinition facet;
+            while ((facet = reader.readFacet()) != null) {
+                list.add(FacetDefinitions.toPolygon(facet, precision));
+            }
+        }
+
+        return new BoundaryList3D(list);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public TriangleMesh readTriangleMesh(final InputStream in, final DoublePrecisionContext precision)
+            throws IOException {
+        final SimpleTriangleMesh.Builder meshBuilder = SimpleTriangleMesh.builder(precision);
+
+        try (FacetDefinitionReader reader =
+                facetDefinitionReader(GeometryIOUtils.createCloseShieldInputStream(in))) {
+            FacetDefinition facet;
+            while ((facet = reader.readFacet()) != null) {
+                for (final Triangle3D tri : FacetDefinitions.toPolygon(facet, precision).toTriangles()) {
+                    meshBuilder.addFaceUsingVertices(
+                        tri.getPoint1(),
+                        tri.getPoint2(),
+                        tri.getPoint3()
+                    );
+                }
+            }
+        }
+
+        return meshBuilder.build();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Stream<PlaneConvexSubset> boundaries(final InputStream in, final DoublePrecisionContext precision)
+            throws IOException {
+        return facets(in)
+                .map(f -> FacetDefinitions.toPolygon(f, precision));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Stream<FacetDefinition> facets(final InputStream in) throws IOException {
+        final FacetDefinitionReader fdReader = facetDefinitionReader(in);
+        final FacetDefinitionReaderIterator it = new FacetDefinitionReaderIterator(fdReader);
+
+        return StreamSupport.stream(Spliterators.spliteratorUnknownSize(it, Spliterator.ORDERED), false);
+    }
+
+    /** Class exposing a {@link FacetDefinitionReader} as an iterator. {@link IOException}s are wrapped
+     * with {@link java.io.UncheckedIOException}.
+     */
+    static final class FacetDefinitionReaderIterator implements Iterator<FacetDefinition> {
+
+        /** Reader supplying the facets for iteration. */
+        private final FacetDefinitionReader reader;
+
+        /** Number of facets read from the reader. */
+        private int loadCount = 0;

Review comment:
       we can safely remove the redundant initializer

##########
File path: commons-geometry-euclidean-io/src/main/java/org/apache/commons/geometry/euclidean/io/threed/obj/OBJWriter.java
##########
@@ -0,0 +1,514 @@
+/*
+ * 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.io.threed.obj;
+
+import java.io.IOException;
+import java.io.Writer;
+import java.text.DecimalFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+
+import org.apache.commons.geometry.core.io.utils.AbstractTextFormatWriter;
+import org.apache.commons.geometry.euclidean.io.threed.FacetDefinition;
+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 polygon geometries.
+ */
+public final class OBJWriter extends AbstractTextFormatWriter {
+
+    /** Space character. */
+    private static final char SPACE = ' ';
+
+    /** Number of vertices written to the output. */
+    private int vertexCount = 0;
+
+    /** Number of normals written to the output. */
+    private int normalCount = 0;

Review comment:
       we can safely remove the redundant initializer

##########
File path: commons-geometry-core-io/src/main/java/org/apache/commons/geometry/core/io/internal/SimpleTextParser.java
##########
@@ -0,0 +1,1207 @@
+/*
+ * 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.io.internal;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.IntConsumer;
+import java.util.function.IntPredicate;
+
+/** Class providing basic text parsing capabilities. The goals of this class are to
+ * (1) provide a simple, flexible API for performing common text parsing operations and
+ * (2) provide a mechanism for creating consistent and informative parsing errors.
+ * This class is not intended as a replacement for grammar-based parsers and/or lexers.
+ */
+public class SimpleTextParser {
+
+    /** Constant indicating that the end of the input has been reached. */
+    private static final int EOF = -1;
+
+    /** Carriage return character. */
+    private static final char CR = '\r';
+
+    /** Line feed character. */
+    private static final char LF = '\n';
+
+    /** Default value for the max string length property. */
+    private static final int DEFAULT_MAX_STRING_LENGTH = 1024;
+
+    /** Error message used when a string exceeds the configured maximum length. */
+    private static final String STRING_LENGTH_ERR_MSG = "String length exceeds maximum value of ";
+
+    /** Initial token position number. */
+    private static final int INITIAL_TOKEN_POS = -1;
+
+    /** Int consumer that does nothing. */
+    private static final IntConsumer NOOP_CONSUMER = ch -> { };
+
+    /** Current line number; line numbers start counting at 1. */
+    private int lineNumber = 1;
+
+    /** Current character column on the current line; column numbers start at 1.*/
+    private int columnNumber = 1;
+
+    /** Maximum length for strings returned by this instance. */
+    private int maxStringLength = DEFAULT_MAX_STRING_LENGTH;
+
+    /** The current token. */
+    private String currentToken;
+
+    /** The line number that the current token started on. */
+    private int currentTokenLineNumber = INITIAL_TOKEN_POS;
+
+    /** The character number that the current token started on. */
+    private int currentTokenColumnNumber = INITIAL_TOKEN_POS;
+
+    /** Flag used to indicate that at least one token has been read from the stream. */
+    private boolean hasSetToken = false;
+
+    /** Character read buffer used to access the character stream. */
+    private final CharReadBuffer buffer;
+
+    /** Construct a new instance that reads characters from the given reader. The
+     * reader will not be closed.
+     * @param reader reader instance to read characters from
+     */
+    public SimpleTextParser(final Reader reader) {
+        this(new CharReadBuffer(reader));
+    }
+
+    /** Construct a new instance that reads characters from the given character buffer.
+     * @param buffer read buffer to read characters from
+     */
+    public SimpleTextParser(final CharReadBuffer buffer) {
+        this.buffer = buffer;
+    }
+
+    /** Get the current line number. Line numbers start at 1.
+     * @return the current line number
+     */
+    public int getLineNumber() {
+        return lineNumber;
+    }
+
+    /** Set the current line number. This does not affect the character stream position,
+     * only the value returned by {@link #getLineNumber()}.
+     * @param lineNumber line number to set; line numbers start at 1
+     */
+    public void setLineNumber(final int lineNumber) {
+        this.lineNumber = lineNumber;
+    }
+
+    /** Get the current column number. This indicates the column position of the
+     * character that will returned by the next call to {@link #next()}. The first
+     * character of each line has a column number of 1.
+     * @return the current column number; column numbers start at 1
+     */
+    public int getColumnNumber() {
+        return columnNumber;
+    }
+
+    /** Set the current column number. This does not affect the character stream position,
+     * only the value returned by {@link #getColumn()}.
+     * @param column the column number to set; column numbers start at 1
+     */
+    public void setColumnNumber(final int column) {
+        this.columnNumber = column;
+    }
+
+    /** Get the maximum length for strings returned by this instance. Operations
+     * that produce strings longer than this length will throw an exception.
+     * @return maximum length for strings returned by this instance
+     */
+    public int getMaxStringLength() {
+        return maxStringLength;
+    }
+
+    /** Set the maximum length for strings returned by this instance. Operations
+     * that produce strings longer than this length will throw an exception.
+     * @param maxStringLength maximum length for strings returned by this instance
+     * @throws IllegalArgumentException if the argument is less than zero
+     */
+    public void setMaxStringLength(final int maxStringLength) {
+        if (maxStringLength < 0) {
+            throw new IllegalArgumentException("Maximum string length cannot be less than zero; was " +
+                    maxStringLength);
+        }
+        this.maxStringLength = maxStringLength;
+    }
+
+    /** Get the current token. This is the most recent string read by one of the {@code nextXXX()}
+     * methods. This value will be null if no token has yet been read or if the end of content has
+     * been reached.
+     * @return the current token
+     * @see #next(int)
+     * @see #next(IntPredicate)
+     * @see #nextLine()
+     * @see #nextAlphanumeric()
+     */
+    public String getCurrentToken() {
+        return currentToken;
+    }
+
+    /** Return true if the current token is not null or empty.
+     * @return true if the current token is not null or empty
+     * @see #getCurrentToken()
+     */
+    public boolean hasNonEmptyToken() {
+        return currentToken != null && !currentToken.isEmpty();
+    }
+
+    /** Get the line number that the current token started on. This value will
+     * be -1 if no token has been read yet.
+     * @return current token starting line number or -1 if no token has been
+     *      read yet
+     * @see #getCurrentToken()
+     */
+    public int getCurrentTokenLineNumber() {
+        return currentTokenLineNumber;
+    }
+
+    /** Get the column position that the current token started on. This value will
+     * be -1 if no token has been read yet.
+     * @return current token column number or -1 if no oken has been read yet
+     * @see #getCurrentToken()
+     */
+    public int getCurrentTokenColumnNumber() {
+        return currentTokenColumnNumber;
+    }
+
+    /** Get the current token parsed as an integer.
+     * @return the current token parsed as an integer
+     * @throws IllegalStateException if no token has been read
+     * @throws IOException if the current token cannot be parsed as an integer
+     */
+    public int getCurrentTokenAsInt() throws IOException {
+        ensureHasSetToken();
+
+        Throwable cause = null;
+
+        if (currentToken != null) {
+            try {
+                return Integer.parseInt(currentToken);
+            } catch (NumberFormatException exc) {
+                cause = exc;
+            }
+        }
+
+        throw unexpectedToken("integer", cause);
+    }
+
+    /** Get the current token parsed as a double.
+     * @return the current token parsed as a double
+     * @throws IllegalStateException if no token has been read
+     * @throws IOException if the current token cannot be parsed as a double
+     */
+    public double getCurrentTokenAsDouble() throws IOException {
+        ensureHasSetToken();
+
+        Throwable cause = null;
+
+        if (currentToken != null) {
+            try {
+                return Double.parseDouble(currentToken);
+            } catch (NumberFormatException exc) {
+                cause = exc;
+            }
+        }
+
+        throw unexpectedToken("double", cause);
+    }
+
+    /** Return true if there are more characters to read from this instance.
+     * @return true if there are more characters to read from this instance
+     * @throws IOException if an I/O error occurs
+     */
+    public boolean hasMoreCharacters() throws IOException {
+        return buffer.hasMoreCharacters();
+    }
+
+    /** Return true if there are more characters to read on the current line.
+     * @return true if there are more characters to read on the current line
+     * @throws IOException if an I/O error occurs
+     */
+    public boolean hasMoreCharactersOnLine() throws IOException {
+        return hasMoreCharacters() && isNotNewLinePart(peekChar());
+    }
+
+    /** Read and return the next character in the stream and advance the parser position.
+     * This method updates the current line number and column number but does <strong>not</strong>
+     * set the {@link #getCurrentToken() current token}.
+     * @return the next character in the stream or -1 if the end of the stream has been
+     *      reached
+     * @throws IOException if an I/O error occurs
+     * @see #peekChar()
+     */
+    public int readChar() throws IOException {
+        final int value = buffer.read();
+        if (value == LF ||
+                (value == CR && peekChar() != LF)) {
+            ++lineNumber;
+            columnNumber = 1;
+        } else if (value != EOF) {
+            ++columnNumber;
+        }
+
+        return value;
+    }
+
+    /** Read a string containing at most {@code len} characters from the stream and
+     * set it as the current token. Characters are added to the string until the string
+     * has the specified length or the end of the stream is reached. The characters are
+     * consumed from the stream. The token is set to null if no more characters are available
+     * from the character stream when this method is called.
+     * @param len the maximum length of the extracted string
+     * @return this instance
+     * @throws IllegalArgumentException if {@code len} is less than 0 or greater than the
+     *      configured {@link #getMaxStringLength() maximum string length}
+     * @throws IOException if an I/O error occurs
+     * @see #getCurrentToken()
+     * @see #consume(int, IntConsumer)
+     */
+    public SimpleTextParser next(final int len) throws IOException {
+        validateRequestedStringLength(len);
+
+        final int line = getLineNumber();
+        final int col = getColumnNumber();
+
+        String token = null;
+        if (hasMoreCharacters()) {
+            final StringBuilder sb = new StringBuilder(len);
+
+            consume(len, ch -> {
+                sb.append((char) ch);
+            });
+
+            token = sb.toString();
+        }
+
+        setToken(line, col, token);
+
+        return this;
+    }
+
+    /** Read a string containing at most {@code len} characters from the stream and
+     * set it as the current token. This is similar to {@link #next(int)} but with the exception
+     * that new line sequences beginning with {@code lineContinuationChar} are skipped.
+     * @param lineContinuationChar character used to indicate skipped new line sequences
+     * @param len the maximum length of the extracted string
+     * @return this instance
+     * @throws IllegalArgumentException if {@code len} is less than 0 or greater than the
+     *      configured {@link #getMaxStringLength() maximum string length}
+     * @throws IOException if an I/O error occurs
+     * @see #getCurrentToken()
+     * @see #consumeWithLineContinuation(char, int, IntConsumer)
+     */
+    public SimpleTextParser nextWithLineContinuation(final char lineContinuationChar, final int len)
+            throws IOException {
+        validateRequestedStringLength(len);
+
+        final int line = getLineNumber();
+        final int col = getColumnNumber();
+
+        String token = null;
+        if (hasMoreCharacters()) {
+            final StringBuilder sb = new StringBuilder(len);
+
+            consumeWithLineContinuation(lineContinuationChar, len, ch -> {
+                sb.append((char) ch);
+            });
+
+            token = sb.toString();
+        }
+
+        setToken(line, col, token);
+
+        return this;
+    }
+
+    /** Read characters from the stream while the given predicate returns true and set the result
+     * as the current token. The next call to {@link #readChar()} will return either a character
+     * that fails the predicate test or -1 if the end of the stream has been reached.
+     * The token will be null if the end of the stream has been reached prior to the method call.
+     * @param pred predicate function passed characters read from the input; reading continues
+     *      until the predicate returns false
+     * @return this instance
+     * @throws IllegalStateException if the length of the produced string exceeds the configured
+     *      {@link #getMaxStringLength() maximum string length}
+     * @throws IOException if an I/O error occurs
+     * @see #getCurrentToken()
+     * @see #consume(IntPredicate, IntConsumer)
+     */
+    public SimpleTextParser next(final IntPredicate pred) throws IOException {
+        final int line = getLineNumber();
+        final int col = getColumnNumber();
+
+        String token = null;
+        if (hasMoreCharacters()) {
+            final StringBuilder sb = new StringBuilder();
+
+            consume(pred, ch -> {
+                sb.append((char) ch);
+                validateStringLength(sb.length());
+            });
+
+            token = sb.toString();
+        }
+
+        setToken(line, col, token);
+
+        return this;
+    }
+
+    /** Read characters from the stream while the given predicate returns true and set the result
+     * as the current token. This is similar to {@link #next(IntPredicate)} but with the exception
+     * that new line sequences prefixed with {@code lineContinuationChar} are skipped.
+     * @param lineContinuationChar character used to indicate skipped new line sequences
+     * @param pred predicate function passed characters read from the input; reading continues
+     *      until the predicate returns false
+     * @return this instance
+     * @throws IllegalStateException if the length of the produced string exceeds the configured
+     *      {@link #getMaxStringLength() maximum string length}
+     * @throws IOException if an I/O error occurs
+     * @see #getCurrentToken()
+     * @see #consume(IntPredicate, IntConsumer)
+     */
+    public SimpleTextParser nextWithLineContinuation(final char lineContinuationChar, final IntPredicate pred)
+            throws IOException {
+        final int line = getLineNumber();
+        final int col = getColumnNumber();
+
+        String token = null;
+        if (hasMoreCharacters()) {
+            final StringBuilder sb = new StringBuilder();
+
+            consumeWithLineContinuation(lineContinuationChar, pred, ch -> {
+                sb.append((char) ch);
+                validateStringLength(sb.length());
+            });
+
+            token = sb.toString();
+        }
+
+        setToken(line, col, token);
+
+        return this;
+    }
+
+    /** Read characters from the current parser position to the next new line sequence and
+     * set the result as the current token . The newline character sequence
+     * ('\r', '\n', or '\r\n') at the end of the line is consumed but is not included in the token.
+     * The token will be null if the end of the stream has been reached prior to the method call.
+     * @return this instance
+     * @throws IllegalStateException if the length of the produced string exceeds the configured
+     *      {@link #getMaxStringLength() maximum string length}
+     * @throws IOException if an I/O error occurs
+     * @see #getCurrentToken()
+     */
+    public SimpleTextParser nextLine() throws IOException {
+        next(SimpleTextParser::isNotNewLinePart);
+
+        discardNewLineSequence();
+
+        return this;
+    }
+
+    /** Read a sequence of alphanumeric characters starting from the current parser position
+     * and set the result as the current token. The token will be the empty string if the next
+     * character in the stream is not alphanumeric and will be null if the end of the stream has
+     * been reached prior to the method call.
+     * @return this instance
+     * @throws IllegalStateException if the length of the produced string exceeds the configured
+     *      {@link #getMaxStringLength() maximum string length}
+     * @throws IOException if an I/O error occurs
+     * @see #getCurrentToken()
+     */
+    public SimpleTextParser nextAlphanumeric() throws IOException {
+        return next(SimpleTextParser::isAlphanumeric);
+    }
+
+    /** Discard {@code len} number of characters from the character stream. The
+     * parser position is updated but the current token is not changed.
+     * @param len number of characters to discard
+     * @return this instance
+     * @throws IOException if an I/O error occurs
+     */
+    public SimpleTextParser discard(final int len) throws IOException {
+        return consume(len, NOOP_CONSUMER);
+    }
+
+    /** Discard {@code len} number of characters from the character stream. The
+     * parser position is updated but the current token is not changed. Lines beginning
+     * with {@code lineContinuationChar} are skipped.
+     * @param lineContinuationChar character used to indicate skipped new line sequences
+     * @param len number of characters to discard
+     * @return this instance
+     * @throws IOException if an I/O error occurs
+     */
+    public SimpleTextParser discardWithLineContinuation(final char lineContinuationChar,
+            final int len) throws IOException {
+        return consumeWithLineContinuation(lineContinuationChar, len, NOOP_CONSUMER);
+    }
+
+    /** Discard characters from the stream while the given predicate returns true. The next call
+     * to {@link #readChar()} will return either a character that fails the predicate test or -1
+     * if the end of the stream has been reached. The parser position is updated but the current
+     * token is not changed.
+     * @param pred predicate test for characters to discard
+     * @return this instance
+     * @throws IOException if an I/O error occurs
+     */
+    public SimpleTextParser discard(final IntPredicate pred) throws IOException {
+        return consume(pred, NOOP_CONSUMER);
+    }
+
+    /** Discard characters from the stream while the given predicate returns true. New line sequences
+     * beginning with {@code lineContinuationChar} are skipped. The next call o {@link #readChar()}
+     * will return either a character that fails the predicate test or -1 if the end of the stream
+     * has been reached. The parser position is updated but the current token is not changed.
+     * @param lineContinuationChar character used to indicate skipped new line sequences
+     * @param pred predicate test for characters to discard
+     * @return this instance
+     * @throws IOException if an I/O error occurs
+     */
+    public SimpleTextParser discardWithLineContinuation(final char lineContinuationChar,
+            final IntPredicate pred) throws IOException {
+        return consumeWithLineContinuation(lineContinuationChar, pred, NOOP_CONSUMER);
+    }
+
+    /** Discard a sequence of whitespace characters from the character stream starting from the
+     * current parser position. The next call to {@link #readChar()} will return either a non-whitespace
+     * character or -1 if the end of the stream has been reached. The parser position is updated
+     * but the current token is not changed.
+     * @return this instance
+     * @throws IOException if an I/O error occurs
+     */
+    public SimpleTextParser discardWhitespace() throws IOException {
+        return discard(SimpleTextParser::isWhitespace);
+    }
+
+    /** Discard the next whitespace characters on the current line. The next call to
+     * {@link #next()} will return either a non-whitespace character on the current line,

Review comment:
       #next(int)}

##########
File path: commons-geometry-euclidean-io/src/main/java/org/apache/commons/geometry/euclidean/io/threed/obj/OBJBoundaryWriteHandler3D.java
##########
@@ -0,0 +1,181 @@
+/*
+ * 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.io.threed.obj;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.Charset;
+import java.text.DecimalFormat;
+import java.util.Iterator;
+import java.util.stream.Stream;
+
+import org.apache.commons.geometry.core.io.internal.GeometryIOUtils;
+import org.apache.commons.geometry.euclidean.io.threed.AbstractBoundaryWriteHandler3D;
+import org.apache.commons.geometry.euclidean.io.threed.FacetDefinition;
+import org.apache.commons.geometry.euclidean.threed.BoundarySource3D;
+import org.apache.commons.geometry.euclidean.threed.PlaneConvexSubset;
+import org.apache.commons.geometry.euclidean.threed.mesh.Mesh;
+
+/** {@link BoundaryWriteHandler3D} implementation for writing OBJ content.

Review comment:
       Fix javadoc --> {@link org.apache.commons.geometry.euclidean.io.threed.BoundaryWriteHandler3D}

##########
File path: commons-geometry-euclidean-io/src/main/java/org/apache/commons/geometry/euclidean/io/threed/IO3D.java
##########
@@ -0,0 +1,562 @@
+/*
+ * 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.io.threed;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URL;
+import java.nio.file.Path;
+import java.util.Collection;
+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.mesh.TriangleMesh;
+
+/** Utility class providing convenient access to 3D IO functionality. The static read and write functions delegate
+ * to a default {@link #getDefaultManager() DefaultBoundaryIOManager3D} instance. The default configuration should
+ * be suitable for most purposes. If customization is required, consider directly creating and configuring and a
+ * {@link BoundaryIOManager3D} instance instead.
+ *
+ * <p><strong>Examples</strong></p>
+ * <p>The example below reads an OBJ file as a stream of triangles, transforms each triangle, and writes the
+ * result to a CSV file.
+ * <pre>
+ * Path origFile = Paths.get("orig.obj");
+ * Path scaledFile = Paths.get("scaled.csv");
+ * AffineTransformMatrix3D transform = AffineTransformMatrix3D.createScale(2);
+ *
+ * // use the input triangle stream in a try-with-resources statement to ensure
+ * // all resources are properly closed.
+ * try (Stream&lt;Triangle3D&gt; stream = IO3D.triangles(origFile, precision)) {
+ *      IO3D.write(stream.map(t -> t.transform(transform)), scaledFile);
+ * }
+ * </pre>
+ * </p>
+ *
+ * @see DefaultBoundaryIOManager3D
+ */
+public final class IO3D {
+
+    /** 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";
+
+    /** String representing the simple text format described by
+     * {@link org.apache.commons.geometry.euclidean.io.threed.text.TextFacetDefinitionReader TextFacetDefinitionReader}

Review comment:
       Fix JavaDoc. org.apache.commons.geometry.euclidean.io.threed.txt

##########
File path: commons-geometry-core-io/src/main/java/org/apache/commons/geometry/core/io/internal/SimpleTextParser.java
##########
@@ -0,0 +1,1207 @@
+/*
+ * 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.io.internal;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.IntConsumer;
+import java.util.function.IntPredicate;
+
+/** Class providing basic text parsing capabilities. The goals of this class are to
+ * (1) provide a simple, flexible API for performing common text parsing operations and
+ * (2) provide a mechanism for creating consistent and informative parsing errors.
+ * This class is not intended as a replacement for grammar-based parsers and/or lexers.
+ */
+public class SimpleTextParser {
+
+    /** Constant indicating that the end of the input has been reached. */
+    private static final int EOF = -1;
+
+    /** Carriage return character. */
+    private static final char CR = '\r';
+
+    /** Line feed character. */
+    private static final char LF = '\n';
+
+    /** Default value for the max string length property. */
+    private static final int DEFAULT_MAX_STRING_LENGTH = 1024;
+
+    /** Error message used when a string exceeds the configured maximum length. */
+    private static final String STRING_LENGTH_ERR_MSG = "String length exceeds maximum value of ";
+
+    /** Initial token position number. */
+    private static final int INITIAL_TOKEN_POS = -1;
+
+    /** Int consumer that does nothing. */
+    private static final IntConsumer NOOP_CONSUMER = ch -> { };
+
+    /** Current line number; line numbers start counting at 1. */
+    private int lineNumber = 1;
+
+    /** Current character column on the current line; column numbers start at 1.*/
+    private int columnNumber = 1;
+
+    /** Maximum length for strings returned by this instance. */
+    private int maxStringLength = DEFAULT_MAX_STRING_LENGTH;
+
+    /** The current token. */
+    private String currentToken;
+
+    /** The line number that the current token started on. */
+    private int currentTokenLineNumber = INITIAL_TOKEN_POS;
+
+    /** The character number that the current token started on. */
+    private int currentTokenColumnNumber = INITIAL_TOKEN_POS;
+
+    /** Flag used to indicate that at least one token has been read from the stream. */
+    private boolean hasSetToken = false;
+
+    /** Character read buffer used to access the character stream. */
+    private final CharReadBuffer buffer;
+
+    /** Construct a new instance that reads characters from the given reader. The
+     * reader will not be closed.
+     * @param reader reader instance to read characters from
+     */
+    public SimpleTextParser(final Reader reader) {
+        this(new CharReadBuffer(reader));
+    }
+
+    /** Construct a new instance that reads characters from the given character buffer.
+     * @param buffer read buffer to read characters from
+     */
+    public SimpleTextParser(final CharReadBuffer buffer) {
+        this.buffer = buffer;
+    }
+
+    /** Get the current line number. Line numbers start at 1.
+     * @return the current line number
+     */
+    public int getLineNumber() {
+        return lineNumber;
+    }
+
+    /** Set the current line number. This does not affect the character stream position,
+     * only the value returned by {@link #getLineNumber()}.
+     * @param lineNumber line number to set; line numbers start at 1
+     */
+    public void setLineNumber(final int lineNumber) {
+        this.lineNumber = lineNumber;
+    }
+
+    /** Get the current column number. This indicates the column position of the
+     * character that will returned by the next call to {@link #next()}. The first
+     * character of each line has a column number of 1.
+     * @return the current column number; column numbers start at 1
+     */
+    public int getColumnNumber() {
+        return columnNumber;
+    }
+
+    /** Set the current column number. This does not affect the character stream position,
+     * only the value returned by {@link #getColumn()}.
+     * @param column the column number to set; column numbers start at 1
+     */
+    public void setColumnNumber(final int column) {
+        this.columnNumber = column;
+    }
+
+    /** Get the maximum length for strings returned by this instance. Operations
+     * that produce strings longer than this length will throw an exception.
+     * @return maximum length for strings returned by this instance
+     */
+    public int getMaxStringLength() {
+        return maxStringLength;
+    }
+
+    /** Set the maximum length for strings returned by this instance. Operations
+     * that produce strings longer than this length will throw an exception.
+     * @param maxStringLength maximum length for strings returned by this instance
+     * @throws IllegalArgumentException if the argument is less than zero
+     */
+    public void setMaxStringLength(final int maxStringLength) {
+        if (maxStringLength < 0) {
+            throw new IllegalArgumentException("Maximum string length cannot be less than zero; was " +
+                    maxStringLength);
+        }
+        this.maxStringLength = maxStringLength;
+    }
+
+    /** Get the current token. This is the most recent string read by one of the {@code nextXXX()}
+     * methods. This value will be null if no token has yet been read or if the end of content has
+     * been reached.
+     * @return the current token
+     * @see #next(int)
+     * @see #next(IntPredicate)
+     * @see #nextLine()
+     * @see #nextAlphanumeric()
+     */
+    public String getCurrentToken() {
+        return currentToken;
+    }
+
+    /** Return true if the current token is not null or empty.
+     * @return true if the current token is not null or empty
+     * @see #getCurrentToken()
+     */
+    public boolean hasNonEmptyToken() {
+        return currentToken != null && !currentToken.isEmpty();
+    }
+
+    /** Get the line number that the current token started on. This value will
+     * be -1 if no token has been read yet.
+     * @return current token starting line number or -1 if no token has been
+     *      read yet
+     * @see #getCurrentToken()
+     */
+    public int getCurrentTokenLineNumber() {
+        return currentTokenLineNumber;
+    }
+
+    /** Get the column position that the current token started on. This value will
+     * be -1 if no token has been read yet.
+     * @return current token column number or -1 if no oken has been read yet
+     * @see #getCurrentToken()
+     */
+    public int getCurrentTokenColumnNumber() {
+        return currentTokenColumnNumber;
+    }
+
+    /** Get the current token parsed as an integer.
+     * @return the current token parsed as an integer
+     * @throws IllegalStateException if no token has been read
+     * @throws IOException if the current token cannot be parsed as an integer
+     */
+    public int getCurrentTokenAsInt() throws IOException {
+        ensureHasSetToken();
+
+        Throwable cause = null;
+
+        if (currentToken != null) {
+            try {
+                return Integer.parseInt(currentToken);
+            } catch (NumberFormatException exc) {

Review comment:
       We can use Final

##########
File path: commons-geometry-core-io/src/main/java/org/apache/commons/geometry/core/io/internal/GeometryIOUtils.java
##########
@@ -0,0 +1,213 @@
+/*
+ * 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.io.internal;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.Closeable;
+import java.io.FilterInputStream;
+import java.io.FilterOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.Reader;
+import java.io.UncheckedIOException;
+import java.io.Writer;
+import java.nio.charset.Charset;
+import java.util.stream.Stream;
+
+/** Class containing utility methods for IO operations.
+ */
+public final class GeometryIOUtils {
+
+    /** Utility class; no instantiation. */
+    private GeometryIOUtils() {}
+
+    /** Get the part of the file name after the last dot.
+     * @param fileName file name to get the extension for
+     * @return the extension of the file name, the empty string if no extension is found, or
+     *      null if the argument is null
+     */
+    public static String getFileExtension(final String fileName) {
+        if (fileName != null) {
+            final int idx = fileName.lastIndexOf('.');
+            if (idx > -1) {
+                return fileName.substring(idx + 1);
+            }
+
+            return "";
+        }
+
+        return null;
+    }
+
+    /** Create an unchecked exception from the given checked exception. The message of the
+     * returned exception contains the original exception's type and message.
+     * @param exc exception to wrap in an unchecked exception
+     * @return the unchecked exception
+     */
+    public static UncheckedIOException createUnchecked(final IOException exc) {
+        final String msg = exc.getClass().getSimpleName() + ": " + exc.getMessage();
+        return new UncheckedIOException(msg, exc);
+    }
+
+    /** Return an input stream that delegates all calls to the argument but does not
+     * close the argument when {@link InputStream#close() close()} is called.
+     * @param in input stream to wrap
+     * @return an input stream that delegates all calls to {@code in} except for {@code close()}
+     */
+    public static InputStream createCloseShieldInputStream(final InputStream in) {
+        return new CloseShieldInputStream(in);
+    }
+
+    /** Return an output stream that delegates all calls to the argument but does not close
+     * the argument when {@link OutputStream#close() close()} is called.
+     * @param out output stream to wrap
+     * @return an output stream that delegates all calls to {@code out} except for {@code close()}
+     */
+    public static OutputStream createCloseShieldOutputStream(final OutputStream out) {
+        return new CloseShieldOutputStream(out);
+    }
+
+    /** Return a buffered reader that reads characters of the given charset from {@code in} but
+     * does not close {@code in} when {@link Reader#close() close()} is called.
+     * @param in input stream to read from
+     * @param charset reader charset
+     * @return a buffered reader that reads characters from {@code in} but does not close it when
+     *      {@code close()} is called
+     */
+    public static Reader createCloseShieldReader(final InputStream in, final Charset charset) {
+        final InputStream shielded = createCloseShieldInputStream(in);
+        return new BufferedReader(new InputStreamReader(shielded, charset));
+    }
+
+    /** Return a buffered writer that writer characters of the given charset to {@code out} but
+     * does not close {@code out} when {@link Writer#close() close} is called.
+     * @param out output stream to write to
+     * @param charset writer charset
+     * @return a buffered writer that writes characters to {@code out} but does not close it
+     *      when {@code close()} is called
+     */
+    public static Writer createCloseShieldWriter(final OutputStream out, final Charset charset) {
+        final OutputStream shielded = createCloseShieldOutputStream(out);
+        return new BufferedWriter(new OutputStreamWriter(shielded, charset));
+    }
+
+    /** Pass a supplied {@link Closeable} instance to {@code function} and return the result.
+     * The {@code Closeable} instance returned by the supplier is closed if function execution
+     * fails, otherwise the instance is <em>not</em> closed.
+     * @param <T> Return type
+     * @param <C> Closeable type
+     * @param function function called with the supplied Closeable instance
+     * @param closeableSupplier supplier used to obtain a Closeable instance
+     * @return result of calling {@code function} with a supplied Closeable instance
+     * @throws IOException if an I/O error occurs
+     */
+    public static <T, C extends Closeable> T tryApplyCloseable(final IOFunction<C, T> function,
+            final IOSupplier<? extends C> closeableSupplier) throws IOException {
+        C closeable = null;
+        try {
+            closeable = closeableSupplier.get();
+            return function.apply(closeable);
+        } catch (IOException | RuntimeException exc) {
+            if (closeable != null) {
+                try {
+                    closeable.close();
+                } catch (IOException suppressed) {

Review comment:
       We can use Final

##########
File path: commons-geometry-euclidean-io/src/main/java/org/apache/commons/geometry/euclidean/io/threed/txt/TextBoundaryWriteHandler3D.java
##########
@@ -0,0 +1,214 @@
+/*
+ * 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.io.threed.txt;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.Charset;
+import java.text.DecimalFormat;
+import java.util.Iterator;
+import java.util.stream.Stream;
+
+import org.apache.commons.geometry.core.io.internal.GeometryIOUtils;
+import org.apache.commons.geometry.euclidean.io.threed.AbstractBoundaryWriteHandler3D;
+import org.apache.commons.geometry.euclidean.io.threed.FacetDefinition;
+import org.apache.commons.geometry.euclidean.threed.PlaneConvexSubset;
+
+/** {@link BoundaryWriteHandler3D} implementation designed to write simple text data

Review comment:
       fix javadoc org.apache.commons.geometry.euclidean.io.threed.BoundaryWriteHandler3D

##########
File path: commons-geometry-core-io/src/main/java/org/apache/commons/geometry/core/io/internal/SimpleTextParser.java
##########
@@ -0,0 +1,1207 @@
+/*
+ * 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.io.internal;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.IntConsumer;
+import java.util.function.IntPredicate;
+
+/** Class providing basic text parsing capabilities. The goals of this class are to
+ * (1) provide a simple, flexible API for performing common text parsing operations and
+ * (2) provide a mechanism for creating consistent and informative parsing errors.
+ * This class is not intended as a replacement for grammar-based parsers and/or lexers.
+ */
+public class SimpleTextParser {
+
+    /** Constant indicating that the end of the input has been reached. */
+    private static final int EOF = -1;
+
+    /** Carriage return character. */
+    private static final char CR = '\r';
+
+    /** Line feed character. */
+    private static final char LF = '\n';
+
+    /** Default value for the max string length property. */
+    private static final int DEFAULT_MAX_STRING_LENGTH = 1024;
+
+    /** Error message used when a string exceeds the configured maximum length. */
+    private static final String STRING_LENGTH_ERR_MSG = "String length exceeds maximum value of ";
+
+    /** Initial token position number. */
+    private static final int INITIAL_TOKEN_POS = -1;
+
+    /** Int consumer that does nothing. */
+    private static final IntConsumer NOOP_CONSUMER = ch -> { };
+
+    /** Current line number; line numbers start counting at 1. */
+    private int lineNumber = 1;
+
+    /** Current character column on the current line; column numbers start at 1.*/
+    private int columnNumber = 1;
+
+    /** Maximum length for strings returned by this instance. */
+    private int maxStringLength = DEFAULT_MAX_STRING_LENGTH;
+
+    /** The current token. */
+    private String currentToken;
+
+    /** The line number that the current token started on. */
+    private int currentTokenLineNumber = INITIAL_TOKEN_POS;
+
+    /** The character number that the current token started on. */
+    private int currentTokenColumnNumber = INITIAL_TOKEN_POS;
+
+    /** Flag used to indicate that at least one token has been read from the stream. */
+    private boolean hasSetToken = false;

Review comment:
       we can safely remove the redundant initializer 
   

##########
File path: commons-geometry-euclidean-io/src/main/java/org/apache/commons/geometry/euclidean/io/threed/IO3D.java
##########
@@ -0,0 +1,562 @@
+/*
+ * 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.io.threed;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URL;
+import java.nio.file.Path;
+import java.util.Collection;
+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.mesh.TriangleMesh;
+
+/** Utility class providing convenient access to 3D IO functionality. The static read and write functions delegate
+ * to a default {@link #getDefaultManager() DefaultBoundaryIOManager3D} instance. The default configuration should
+ * be suitable for most purposes. If customization is required, consider directly creating and configuring and a
+ * {@link BoundaryIOManager3D} instance instead.
+ *
+ * <p><strong>Examples</strong></p>
+ * <p>The example below reads an OBJ file as a stream of triangles, transforms each triangle, and writes the
+ * result to a CSV file.
+ * <pre>
+ * Path origFile = Paths.get("orig.obj");
+ * Path scaledFile = Paths.get("scaled.csv");
+ * AffineTransformMatrix3D transform = AffineTransformMatrix3D.createScale(2);
+ *
+ * // use the input triangle stream in a try-with-resources statement to ensure
+ * // all resources are properly closed.
+ * try (Stream&lt;Triangle3D&gt; stream = IO3D.triangles(origFile, precision)) {
+ *      IO3D.write(stream.map(t -> t.transform(transform)), scaledFile);
+ * }
+ * </pre>
+ * </p>
+ *
+ * @see DefaultBoundaryIOManager3D
+ */
+public final class IO3D {
+
+    /** 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";
+
+    /** String representing the simple text format described by
+     * {@link org.apache.commons.geometry.euclidean.io.threed.text.TextFacetDefinitionReader TextFacetDefinitionReader}
+     * and
+     * {@link org.apache.commons.geometry.euclidean.io.threed.text.TextFacetDefinitionWriter TextFacetDefinitionWriter}.

Review comment:
       idem

##########
File path: commons-geometry-euclidean-io/src/main/java/org/apache/commons/geometry/euclidean/io/threed/BoundaryReadHandler3D.java
##########
@@ -0,0 +1,72 @@
+/*
+ * 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.io.threed;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.stream.Stream;
+
+import org.apache.commons.geometry.core.io.BoundaryReadHandler;
+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.mesh.TriangleMesh;
+
+/** Basic interface for reading 3D geometric boundary representations
+ * (<a href="https://en.wikipedia.org/wiki/Boundary_representation">B-reps</a>) from a specific data storage
+ * format. Callers may prefer to access this functionality using the more convenient
+ * {@link BoundaryIOManager3D} class instead.
+ *
+ * <p><strong>Implementation note:</strong> implementations of this interface <em>must</em>
+ * be thread-safe.</p>
+ *
+ * @see <a href="https://en.wikipedia.org/wiki/Boundary_representations">Boundary representations</a>
+ * @see BoundaryWriteHandler3D
+ * @see BoundaryIOManager3D
+ */
+public interface BoundaryReadHandler3D extends BoundaryReadHandler<PlaneConvexSubset, BoundarySource3D> {
+
+    /** Return a {@link FacetDefinitionReader} for reading raw
+     * {@link org.apache.commons.geometry.euclidean.io.threed.facet.FacetDefinition facets} from the given

Review comment:
       Fix javadoc. --> org.apache.commons.geometry.euclidean.io.threed.FacetDefinition

##########
File path: commons-geometry-core-io/src/main/java/org/apache/commons/geometry/core/io/internal/SimpleTextParser.java
##########
@@ -0,0 +1,1207 @@
+/*
+ * 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.io.internal;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.IntConsumer;
+import java.util.function.IntPredicate;
+
+/** Class providing basic text parsing capabilities. The goals of this class are to
+ * (1) provide a simple, flexible API for performing common text parsing operations and
+ * (2) provide a mechanism for creating consistent and informative parsing errors.
+ * This class is not intended as a replacement for grammar-based parsers and/or lexers.
+ */
+public class SimpleTextParser {
+
+    /** Constant indicating that the end of the input has been reached. */
+    private static final int EOF = -1;
+
+    /** Carriage return character. */
+    private static final char CR = '\r';
+
+    /** Line feed character. */
+    private static final char LF = '\n';
+
+    /** Default value for the max string length property. */
+    private static final int DEFAULT_MAX_STRING_LENGTH = 1024;
+
+    /** Error message used when a string exceeds the configured maximum length. */
+    private static final String STRING_LENGTH_ERR_MSG = "String length exceeds maximum value of ";
+
+    /** Initial token position number. */
+    private static final int INITIAL_TOKEN_POS = -1;
+
+    /** Int consumer that does nothing. */
+    private static final IntConsumer NOOP_CONSUMER = ch -> { };
+
+    /** Current line number; line numbers start counting at 1. */
+    private int lineNumber = 1;
+
+    /** Current character column on the current line; column numbers start at 1.*/
+    private int columnNumber = 1;
+
+    /** Maximum length for strings returned by this instance. */
+    private int maxStringLength = DEFAULT_MAX_STRING_LENGTH;
+
+    /** The current token. */
+    private String currentToken;
+
+    /** The line number that the current token started on. */
+    private int currentTokenLineNumber = INITIAL_TOKEN_POS;
+
+    /** The character number that the current token started on. */
+    private int currentTokenColumnNumber = INITIAL_TOKEN_POS;
+
+    /** Flag used to indicate that at least one token has been read from the stream. */
+    private boolean hasSetToken = false;
+
+    /** Character read buffer used to access the character stream. */
+    private final CharReadBuffer buffer;
+
+    /** Construct a new instance that reads characters from the given reader. The
+     * reader will not be closed.
+     * @param reader reader instance to read characters from
+     */
+    public SimpleTextParser(final Reader reader) {
+        this(new CharReadBuffer(reader));
+    }
+
+    /** Construct a new instance that reads characters from the given character buffer.
+     * @param buffer read buffer to read characters from
+     */
+    public SimpleTextParser(final CharReadBuffer buffer) {
+        this.buffer = buffer;
+    }
+
+    /** Get the current line number. Line numbers start at 1.
+     * @return the current line number
+     */
+    public int getLineNumber() {
+        return lineNumber;
+    }
+
+    /** Set the current line number. This does not affect the character stream position,
+     * only the value returned by {@link #getLineNumber()}.
+     * @param lineNumber line number to set; line numbers start at 1
+     */
+    public void setLineNumber(final int lineNumber) {
+        this.lineNumber = lineNumber;
+    }
+
+    /** Get the current column number. This indicates the column position of the
+     * character that will returned by the next call to {@link #next()}. The first
+     * character of each line has a column number of 1.
+     * @return the current column number; column numbers start at 1
+     */
+    public int getColumnNumber() {
+        return columnNumber;
+    }
+
+    /** Set the current column number. This does not affect the character stream position,
+     * only the value returned by {@link #getColumn()}.
+     * @param column the column number to set; column numbers start at 1
+     */
+    public void setColumnNumber(final int column) {
+        this.columnNumber = column;
+    }
+
+    /** Get the maximum length for strings returned by this instance. Operations
+     * that produce strings longer than this length will throw an exception.
+     * @return maximum length for strings returned by this instance
+     */
+    public int getMaxStringLength() {
+        return maxStringLength;
+    }
+
+    /** Set the maximum length for strings returned by this instance. Operations
+     * that produce strings longer than this length will throw an exception.
+     * @param maxStringLength maximum length for strings returned by this instance
+     * @throws IllegalArgumentException if the argument is less than zero
+     */
+    public void setMaxStringLength(final int maxStringLength) {
+        if (maxStringLength < 0) {
+            throw new IllegalArgumentException("Maximum string length cannot be less than zero; was " +
+                    maxStringLength);
+        }
+        this.maxStringLength = maxStringLength;
+    }
+
+    /** Get the current token. This is the most recent string read by one of the {@code nextXXX()}
+     * methods. This value will be null if no token has yet been read or if the end of content has
+     * been reached.
+     * @return the current token
+     * @see #next(int)
+     * @see #next(IntPredicate)
+     * @see #nextLine()
+     * @see #nextAlphanumeric()
+     */
+    public String getCurrentToken() {
+        return currentToken;
+    }
+
+    /** Return true if the current token is not null or empty.
+     * @return true if the current token is not null or empty
+     * @see #getCurrentToken()
+     */
+    public boolean hasNonEmptyToken() {
+        return currentToken != null && !currentToken.isEmpty();
+    }
+
+    /** Get the line number that the current token started on. This value will
+     * be -1 if no token has been read yet.
+     * @return current token starting line number or -1 if no token has been
+     *      read yet
+     * @see #getCurrentToken()
+     */
+    public int getCurrentTokenLineNumber() {
+        return currentTokenLineNumber;
+    }
+
+    /** Get the column position that the current token started on. This value will
+     * be -1 if no token has been read yet.
+     * @return current token column number or -1 if no oken has been read yet
+     * @see #getCurrentToken()
+     */
+    public int getCurrentTokenColumnNumber() {
+        return currentTokenColumnNumber;
+    }
+
+    /** Get the current token parsed as an integer.
+     * @return the current token parsed as an integer
+     * @throws IllegalStateException if no token has been read
+     * @throws IOException if the current token cannot be parsed as an integer
+     */
+    public int getCurrentTokenAsInt() throws IOException {
+        ensureHasSetToken();
+
+        Throwable cause = null;
+
+        if (currentToken != null) {
+            try {
+                return Integer.parseInt(currentToken);
+            } catch (NumberFormatException exc) {
+                cause = exc;
+            }
+        }
+
+        throw unexpectedToken("integer", cause);
+    }
+
+    /** Get the current token parsed as a double.
+     * @return the current token parsed as a double
+     * @throws IllegalStateException if no token has been read
+     * @throws IOException if the current token cannot be parsed as a double
+     */
+    public double getCurrentTokenAsDouble() throws IOException {
+        ensureHasSetToken();
+
+        Throwable cause = null;
+
+        if (currentToken != null) {
+            try {
+                return Double.parseDouble(currentToken);
+            } catch (NumberFormatException exc) {
+                cause = exc;
+            }
+        }
+
+        throw unexpectedToken("double", cause);
+    }
+
+    /** Return true if there are more characters to read from this instance.
+     * @return true if there are more characters to read from this instance
+     * @throws IOException if an I/O error occurs
+     */
+    public boolean hasMoreCharacters() throws IOException {
+        return buffer.hasMoreCharacters();
+    }
+
+    /** Return true if there are more characters to read on the current line.
+     * @return true if there are more characters to read on the current line
+     * @throws IOException if an I/O error occurs
+     */
+    public boolean hasMoreCharactersOnLine() throws IOException {
+        return hasMoreCharacters() && isNotNewLinePart(peekChar());
+    }
+
+    /** Read and return the next character in the stream and advance the parser position.
+     * This method updates the current line number and column number but does <strong>not</strong>
+     * set the {@link #getCurrentToken() current token}.
+     * @return the next character in the stream or -1 if the end of the stream has been
+     *      reached
+     * @throws IOException if an I/O error occurs
+     * @see #peekChar()
+     */
+    public int readChar() throws IOException {
+        final int value = buffer.read();
+        if (value == LF ||
+                (value == CR && peekChar() != LF)) {
+            ++lineNumber;
+            columnNumber = 1;
+        } else if (value != EOF) {
+            ++columnNumber;
+        }
+
+        return value;
+    }
+
+    /** Read a string containing at most {@code len} characters from the stream and
+     * set it as the current token. Characters are added to the string until the string
+     * has the specified length or the end of the stream is reached. The characters are
+     * consumed from the stream. The token is set to null if no more characters are available
+     * from the character stream when this method is called.
+     * @param len the maximum length of the extracted string
+     * @return this instance
+     * @throws IllegalArgumentException if {@code len} is less than 0 or greater than the
+     *      configured {@link #getMaxStringLength() maximum string length}
+     * @throws IOException if an I/O error occurs
+     * @see #getCurrentToken()
+     * @see #consume(int, IntConsumer)
+     */
+    public SimpleTextParser next(final int len) throws IOException {
+        validateRequestedStringLength(len);
+
+        final int line = getLineNumber();
+        final int col = getColumnNumber();
+
+        String token = null;
+        if (hasMoreCharacters()) {
+            final StringBuilder sb = new StringBuilder(len);
+
+            consume(len, ch -> {
+                sb.append((char) ch);
+            });
+
+            token = sb.toString();
+        }
+
+        setToken(line, col, token);
+
+        return this;
+    }
+
+    /** Read a string containing at most {@code len} characters from the stream and
+     * set it as the current token. This is similar to {@link #next(int)} but with the exception
+     * that new line sequences beginning with {@code lineContinuationChar} are skipped.
+     * @param lineContinuationChar character used to indicate skipped new line sequences
+     * @param len the maximum length of the extracted string
+     * @return this instance
+     * @throws IllegalArgumentException if {@code len} is less than 0 or greater than the
+     *      configured {@link #getMaxStringLength() maximum string length}
+     * @throws IOException if an I/O error occurs
+     * @see #getCurrentToken()
+     * @see #consumeWithLineContinuation(char, int, IntConsumer)
+     */
+    public SimpleTextParser nextWithLineContinuation(final char lineContinuationChar, final int len)
+            throws IOException {
+        validateRequestedStringLength(len);
+
+        final int line = getLineNumber();
+        final int col = getColumnNumber();
+
+        String token = null;
+        if (hasMoreCharacters()) {
+            final StringBuilder sb = new StringBuilder(len);
+
+            consumeWithLineContinuation(lineContinuationChar, len, ch -> {
+                sb.append((char) ch);
+            });
+
+            token = sb.toString();
+        }
+
+        setToken(line, col, token);
+
+        return this;
+    }
+
+    /** Read characters from the stream while the given predicate returns true and set the result
+     * as the current token. The next call to {@link #readChar()} will return either a character
+     * that fails the predicate test or -1 if the end of the stream has been reached.
+     * The token will be null if the end of the stream has been reached prior to the method call.
+     * @param pred predicate function passed characters read from the input; reading continues
+     *      until the predicate returns false
+     * @return this instance
+     * @throws IllegalStateException if the length of the produced string exceeds the configured
+     *      {@link #getMaxStringLength() maximum string length}
+     * @throws IOException if an I/O error occurs
+     * @see #getCurrentToken()
+     * @see #consume(IntPredicate, IntConsumer)
+     */
+    public SimpleTextParser next(final IntPredicate pred) throws IOException {
+        final int line = getLineNumber();
+        final int col = getColumnNumber();
+
+        String token = null;
+        if (hasMoreCharacters()) {
+            final StringBuilder sb = new StringBuilder();
+
+            consume(pred, ch -> {
+                sb.append((char) ch);
+                validateStringLength(sb.length());
+            });
+
+            token = sb.toString();
+        }
+
+        setToken(line, col, token);
+
+        return this;
+    }
+
+    /** Read characters from the stream while the given predicate returns true and set the result
+     * as the current token. This is similar to {@link #next(IntPredicate)} but with the exception
+     * that new line sequences prefixed with {@code lineContinuationChar} are skipped.
+     * @param lineContinuationChar character used to indicate skipped new line sequences
+     * @param pred predicate function passed characters read from the input; reading continues
+     *      until the predicate returns false
+     * @return this instance
+     * @throws IllegalStateException if the length of the produced string exceeds the configured
+     *      {@link #getMaxStringLength() maximum string length}
+     * @throws IOException if an I/O error occurs
+     * @see #getCurrentToken()
+     * @see #consume(IntPredicate, IntConsumer)
+     */
+    public SimpleTextParser nextWithLineContinuation(final char lineContinuationChar, final IntPredicate pred)
+            throws IOException {
+        final int line = getLineNumber();
+        final int col = getColumnNumber();
+
+        String token = null;
+        if (hasMoreCharacters()) {
+            final StringBuilder sb = new StringBuilder();
+
+            consumeWithLineContinuation(lineContinuationChar, pred, ch -> {
+                sb.append((char) ch);
+                validateStringLength(sb.length());
+            });
+
+            token = sb.toString();
+        }
+
+        setToken(line, col, token);
+
+        return this;
+    }
+
+    /** Read characters from the current parser position to the next new line sequence and
+     * set the result as the current token . The newline character sequence
+     * ('\r', '\n', or '\r\n') at the end of the line is consumed but is not included in the token.
+     * The token will be null if the end of the stream has been reached prior to the method call.
+     * @return this instance
+     * @throws IllegalStateException if the length of the produced string exceeds the configured
+     *      {@link #getMaxStringLength() maximum string length}
+     * @throws IOException if an I/O error occurs
+     * @see #getCurrentToken()
+     */
+    public SimpleTextParser nextLine() throws IOException {
+        next(SimpleTextParser::isNotNewLinePart);
+
+        discardNewLineSequence();
+
+        return this;
+    }
+
+    /** Read a sequence of alphanumeric characters starting from the current parser position
+     * and set the result as the current token. The token will be the empty string if the next
+     * character in the stream is not alphanumeric and will be null if the end of the stream has
+     * been reached prior to the method call.
+     * @return this instance
+     * @throws IllegalStateException if the length of the produced string exceeds the configured
+     *      {@link #getMaxStringLength() maximum string length}
+     * @throws IOException if an I/O error occurs
+     * @see #getCurrentToken()
+     */
+    public SimpleTextParser nextAlphanumeric() throws IOException {
+        return next(SimpleTextParser::isAlphanumeric);
+    }
+
+    /** Discard {@code len} number of characters from the character stream. The
+     * parser position is updated but the current token is not changed.
+     * @param len number of characters to discard
+     * @return this instance
+     * @throws IOException if an I/O error occurs
+     */
+    public SimpleTextParser discard(final int len) throws IOException {
+        return consume(len, NOOP_CONSUMER);
+    }
+
+    /** Discard {@code len} number of characters from the character stream. The
+     * parser position is updated but the current token is not changed. Lines beginning
+     * with {@code lineContinuationChar} are skipped.
+     * @param lineContinuationChar character used to indicate skipped new line sequences
+     * @param len number of characters to discard
+     * @return this instance
+     * @throws IOException if an I/O error occurs
+     */
+    public SimpleTextParser discardWithLineContinuation(final char lineContinuationChar,
+            final int len) throws IOException {
+        return consumeWithLineContinuation(lineContinuationChar, len, NOOP_CONSUMER);
+    }
+
+    /** Discard characters from the stream while the given predicate returns true. The next call
+     * to {@link #readChar()} will return either a character that fails the predicate test or -1
+     * if the end of the stream has been reached. The parser position is updated but the current
+     * token is not changed.
+     * @param pred predicate test for characters to discard
+     * @return this instance
+     * @throws IOException if an I/O error occurs
+     */
+    public SimpleTextParser discard(final IntPredicate pred) throws IOException {
+        return consume(pred, NOOP_CONSUMER);
+    }
+
+    /** Discard characters from the stream while the given predicate returns true. New line sequences
+     * beginning with {@code lineContinuationChar} are skipped. The next call o {@link #readChar()}
+     * will return either a character that fails the predicate test or -1 if the end of the stream
+     * has been reached. The parser position is updated but the current token is not changed.
+     * @param lineContinuationChar character used to indicate skipped new line sequences
+     * @param pred predicate test for characters to discard
+     * @return this instance
+     * @throws IOException if an I/O error occurs
+     */
+    public SimpleTextParser discardWithLineContinuation(final char lineContinuationChar,
+            final IntPredicate pred) throws IOException {
+        return consumeWithLineContinuation(lineContinuationChar, pred, NOOP_CONSUMER);
+    }
+
+    /** Discard a sequence of whitespace characters from the character stream starting from the
+     * current parser position. The next call to {@link #readChar()} will return either a non-whitespace
+     * character or -1 if the end of the stream has been reached. The parser position is updated
+     * but the current token is not changed.
+     * @return this instance
+     * @throws IOException if an I/O error occurs
+     */
+    public SimpleTextParser discardWhitespace() throws IOException {
+        return discard(SimpleTextParser::isWhitespace);
+    }
+
+    /** Discard the next whitespace characters on the current line. The next call to
+     * {@link #next()} will return either a non-whitespace character on the current line,
+     * the newline character sequence (indicating the end of the line), or -1 (indicating the
+     * end of the stream). The parser position is updated but the current token is not changed.
+     * @return this instance
+     * @throws IOException if an I/O error occurs
+     */
+    public SimpleTextParser discardLineWhitespace() throws IOException {
+        return discard(SimpleTextParser::isLineWhitespace);
+    }
+
+    /** Discard the newline character sequence at the current reader position. The sequence
+     * is defined as one of "\r", "\n", or "\r\n". Does nothing if the reader is not positioned
+     * at a newline sequence. The parser position is updated but the current token is not changed.
+     * @return this instance
+     * @throws IOException if an I/O error occurs
+     */
+    public SimpleTextParser discardNewLineSequence() throws IOException {
+        final int value = peekChar();
+        if (value == LF) {
+            readChar();
+        } else if (value == CR) {
+            readChar();
+
+            if (peekChar() == LF) {
+                readChar();
+            }
+        }
+
+        return this;
+    }
+
+    /** Discard all remaining characters on the current line, including the terminating
+     * newline character sequence. The next call to {@link #readChar()} will return either the
+     * first character on the next line or -1 if the end of the stream has been reached.
+     * The parser position is updated but the current token is not changed.
+     * @return this instance
+     * @throws IOException if an I/O error occurs
+     */
+    public SimpleTextParser discardLine() throws IOException {
+        discard(SimpleTextParser::isNotNewLinePart);
+
+        discardNewLineSequence();
+
+        return this;
+    }
+
+    /** Consume characters from the stream and pass them to {@code consumer} while the given predicate
+     * returns true. The operation ends when the predicate returns false or the end of the stream is
+     * reached.
+     * @param pred predicate test for characters to consume
+     * @param consumer object to be passed each consumed character
+     * @return this instance
+     * @throws IOException if an I/O error occurs
+     */
+    public SimpleTextParser consume(final IntPredicate pred, final IntConsumer consumer) throws IOException {
+        int ch;
+        while ((ch = peekChar()) != EOF && pred.test(ch)) {
+            consumer.accept(readChar());
+        }
+
+        return this;
+    }
+
+    /** Consume at most {@code len} characters from the stream, passing each to the given consumer.
+     * This method is similar to {@link #consume(int, IntConsumer)} with the exception that new line
+     * sequences prefixed with {@code lineContinuationChar} are skipped.
+     * @param lineContinuationChar character used to indicate skipped new line sequences
+     * @param len number of characters to consume
+     * @param consumer function to be passed each consumed character
+     * @return this instance
+     * @throws IOException if an I/O error occurs
+     */
+    public SimpleTextParser consumeWithLineContinuation(final char lineContinuationChar,
+            final int len, final IntConsumer consumer) throws IOException {
+        int i = -1;
+        int ch;
+        while (++i < len && (ch = readChar()) != EOF) {
+            if (ch == lineContinuationChar && isNewLinePart(peekChar())) {
+                --i; // don't count the continuation char toward the total length
+                discardNewLineSequence();
+            } else {
+                consumer.accept(ch);
+            }
+        }
+
+        return this;
+    }
+
+    /** Consume at most {@code len} characters from the stream, passing each to the given consumer.
+     * The operation continues until {@code len} number of characters have been read or the end of
+     * the stream has been reached.
+     * @param len number of characters to consume
+     * @param consumer object to be passed each consumed character
+     * @return this instance
+     * @throws IOException if an I/O error occurs
+     */
+    public SimpleTextParser consume(final int len, final IntConsumer consumer) throws IOException {
+        int ch;
+        for (int i = 0; i < len; ++i) {
+            ch = readChar();
+            if (ch != EOF) {
+                consumer.accept(ch);
+            } else {
+                break;
+            }
+        }
+
+        return this;
+    }
+
+    /** Consume characters from the stream and pass them to {@code consumer} while the given predicate
+     * returns true. This method is similar to {@link #consume(IntPredicate, IntConsumer)} with the
+     * exception that new lines sequences beginning with {@code lineContinuationChar} are skipped.
+     * @param lineContinuationChar character used to indicate skipped new line sequences
+     * @param pred predicate test for characters to consume
+     * @param consumer object to be passed each consumed character
+     * @return this instance
+     * @throws IOException if an I/O error occurs
+     */
+    public SimpleTextParser consumeWithLineContinuation(final char lineContinuationChar,
+            final IntPredicate pred, final IntConsumer consumer) throws IOException {
+        int ch;
+        while ((ch = peekChar()) != EOF) {
+            if (ch == lineContinuationChar && isNewLinePart(buffer.charAt(1))) {
+                readChar();
+                discardNewLineSequence();
+            } else if (pred.test(ch)) {
+                consumer.accept(readChar());
+            } else {
+                break;
+            }
+        }
+
+        return this;
+    }
+
+    /** Return the next character in the stream but do not advance the parser position.
+     * @return the next character in the stream or -1 if the end of the stream has been
+     *      reached
+     * @throws IOException if an I/O error occurs
+     * @see #readChar()
+     */
+    public int peekChar() throws IOException {
+        return buffer.peek();
+    }
+
+    /** Return a string containing containing at most {@code len} characters from the stream but
+     * without changing the parser position. Characters are added to the string until the
+     * string has the specified length or the end of the stream is reached.
+     * @param len the maximum length of the returned string
+     * @return a string containing containing at most {@code len} characters from the stream
+     *      or null if the parser has already reached the end of the stream
+     * @throws IllegalArgumentException if {@code len} is less than 0 or greater than the
+     *      configured {@link #getMaxStringLength() maximum string length}
+     * @throws IOException if an I/O error occurs
+     * @see #next(int)
+     */
+    public String peek(final int len) throws IOException {
+        validateRequestedStringLength(len);
+
+        return buffer.peekString(len);
+    }
+
+    /** Read characters from the stream while the given predicate returns true but do not
+     * change the current token or advance the parser position.
+     * @param pred predicate function passed characters read from the input; reading continues
+     *      until the predicate returns false
+     * @return string containing characters matching {@code pred} or null if the parser has already
+     *      reached the end of the stream
+     * @throws IllegalStateException if the length of the produced string exceeds the configured
+     *      {@link #getMaxStringLength() maximum string length}
+     * @throws IOException if an I/O error occurs
+     * @see #getCurrentToken()
+     */
+    public String peek(final IntPredicate pred) throws IOException {
+        String token = null;
+
+        if (hasMoreCharacters()) {
+            final StringBuilder sb = new StringBuilder();
+
+            int i = -1;
+            int ch = buffer.charAt(++i);
+            while (ch != EOF && pred.test(ch)) {
+                sb.append((char) ch);
+
+                if (i > maxStringLength) {
+                    throw new IllegalStateException(STRING_LENGTH_ERR_MSG + maxStringLength);
+                }
+
+                ch = buffer.charAt(++i);
+            }
+
+            token = sb.toString();
+        }
+
+        return token;
+    }
+
+    /** Compare the {@link #getCurrentToken() current token} with the argument and throw an
+     * exception if they are not equal. The comparison is case-sensitive.
+     * @param expected expected token
+     * @return this instance
+     * @throws IllegalStateException if no token has been read
+     * @throws IOException if {@code expected} does not exactly equal the current token
+     */
+    public SimpleTextParser match(final String expected) throws IOException {
+        matchInternal(expected, true, true);
+        return this;
+    }
+
+    /** Compare the {@link #getCurrentToken() current token} with the argument and throw an
+     * exception if they are not equal. The comparison is <em>not</em> case-sensitive.
+     * @param expected expected token
+     * @return this instance
+     * @throws IllegalStateException if no token has been read
+     * @throws IOException if {@code expected} does not equal the current token (ignoring case)
+     */
+    public SimpleTextParser matchIgnoreCase(final String expected) throws IOException {
+        matchInternal(expected, false, true);
+        return this;
+    }
+
+    /** Return true if the {@link #getCurrentToken() current token} is equal to the argument.
+     * The comparison is case-sensitive.
+     * @param expected expected token
+     * @return true if the argument exactly equals the current token
+     * @throws IllegalStateException if no token has been read
+     * @throws IOException if an I/O error occurs
+     */
+    public boolean tryMatch(final String expected) throws IOException {
+        return matchInternal(expected, true, false);
+    }
+
+    /** Return true if the {@link #getCurrentToken() current token} is equal to the argument.
+     * The comparison is <em>not</em> case-sensitive.
+     * @param expected expected token
+     * @return true if the argument equals the current token (ignoring case)
+     * @throws IllegalStateException if no token has been read
+     * @throws IOException if an I/O error occurs
+     */
+    public boolean tryMatchIgnoreCase(final String expected) throws IOException {
+        return matchInternal(expected, false, false);
+    }
+
+    /** Internal method to compare the current token with the argument.
+     * @param expected expected token
+     * @param caseSensitive if the comparison should be case-sensitive
+     * @param throwOnFailure if an exception should be thrown if the argument is not
+     *      equal to the current token
+     * @return true if the argument is equal to the current token
+     * @throws IllegalStateException if no token has been read
+     * @throws IOException if {@code expected} does not match the current token and
+     *      {@code throwOnFailure} is true
+     */
+    private boolean matchInternal(final String expected, final boolean caseSensitive,
+            final boolean throwOnFailure) throws IOException {
+        ensureHasSetToken();
+
+        if (!stringsEqual(expected, currentToken, caseSensitive)) {
+            if (throwOnFailure) {
+                throw unexpectedToken("[" + expected + "]");
+            }
+
+            return false;
+        }
+
+        return true;
+    }
+
+    /** Return the index of the argument that exactly matches the {@link #getCurrentToken() current token}.
+     * An exception is thrown if no match is found. String comparisons are case-sensitive.
+     * @param expected strings to compare with the current token
+     * @return index of the argument that exactly matches the current token
+     * @throws IllegalStateException if no token has been read
+     * @throws IOException if no match is found among the arguments
+     */
+    public int choose(final String... expected) throws IOException {
+        return choose(Arrays.asList(expected));
+    }
+
+    /** Return the index of the argument that exactly matches the {@link #getCurrentToken() current token}.
+     * An exception is thrown if no match is found. String comparisons are case-sensitive.
+     * @param expected strings to compare with the current token
+     * @return index of the argument that exactly matches the current token
+     * @throws IllegalStateException if no token has been read
+     * @throws IOException if no match is found among the arguments
+     */
+    public int choose(final List<String> expected) throws IOException {
+        return chooseInternal(expected, true, true);
+    }
+
+    /** Return the index of the argument that matches the {@link #getCurrentToken() current token},
+     * ignoring case. An exception is thrown if no match is found. String comparisons are <em>not</em>
+     * case-sensitive.
+     * @param expected strings to compare with the current token
+     * @return index of the argument that matches the current token (ignoring case)
+     * @throws IllegalStateException if no token has been read
+     * @throws IOException if no match is found among the arguments
+     */
+    public int chooseIgnoreCase(final String... expected) throws IOException {
+        return chooseIgnoreCase(Arrays.asList(expected));
+    }
+
+    /** Return the index of the argument that matches the {@link #getCurrentToken() current token},
+     * ignoring case. An exception is thrown if no match is found. String comparisons are <em>not</em>
+     * case-sensitive.
+     * @param expected strings to compare with the current token
+     * @return index of the argument that matches the current token (ignoring case)
+     * @throws IllegalStateException if no token has been read
+     * @throws IOException if no match is found among the arguments
+     */
+    public int chooseIgnoreCase(final List<String> expected) throws IOException {
+        return chooseInternal(expected, false, true);
+    }
+
+    /** Return the index of the argument that exactly matches the {@link #getCurrentToken() current token}
+     * or -1 if no match is found. String comparisons are case-sensitive.
+     * @param expected strings to compare with the current token
+     * @return index of the argument that exactly matches the current token or -1 if
+     *      no match is found
+     * @throws IllegalStateException if no token has been read
+     * @throws IOException if an I/O error occurs
+     */
+    public int tryChoose(final String... expected) throws IOException {
+        return tryChoose(Arrays.asList(expected));
+    }
+
+    /** Return the index of the argument that exactly matches the {@link #getCurrentToken() current token}
+     * or -1 if no match is found. String comparisons are case-sensitive.
+     * @param expected strings to compare with the current token
+     * @return index of the argument that exactly matches the current token or -1 if
+     *      no match is found
+     * @throws IllegalStateException if no token has been read
+     * @throws IOException if an I/O error occurs
+     */
+    public int tryChoose(final List<String> expected) throws IOException {
+        return chooseInternal(expected, true, false);
+    }
+
+    /** Return the index of the argument that matches the {@link #getCurrentToken() current token}
+     * or -1 if no match is found. String comparisons are <em>not</em> case-sensitive.
+     * @param expected strings to compare with the current token
+     * @return index of the argument that matches the current token (ignoring case) or -1 if
+     *      no match is found
+     * @throws IllegalStateException if no token has been read
+     * @throws IOException if an I/O error occurs
+     */
+    public int tryChooseIgnoreCase(final String... expected) throws IOException {
+        return tryChooseIgnoreCase(Arrays.asList(expected));
+    }
+
+    /** Return the index of the argument that matches the {@link #getCurrentToken() current token}
+     * or -1 if no match is found. String comparisons are <em>not</em> case-sensitive.
+     * @param expected strings to compare with the current token
+     * @return index of the argument that matches the current token (ignoring case) or -1 if
+     *      no match is found
+     * @throws IllegalStateException if no token has been read
+     * @throws IOException is an I/O error occurs
+     */
+    public int tryChooseIgnoreCase(final List<String> expected) throws IOException {
+        return chooseInternal(expected, false, false);
+    }
+
+    /** Internal method to compare the current token with a list of possible strings. The index of
+     * the matching argument is returned.
+     * @param expected strings to compare with the current token
+     * @param caseSensitive if the comparisons should be case-sensitive
+     * @param throwOnFailure if an exception should be thrown if no match is found
+     * @return the index of the matching argument or -1 if no match is found
+     * @throws IllegalStateException if no token has been read
+     * @throws IOException if no match is found and {@code throwOnFailure} is true
+     */
+    private int chooseInternal(final List<String> expected, final boolean caseSensitive,
+            final boolean throwOnFailure) throws IOException {
+        ensureHasSetToken();
+
+        int i = 0;
+        for (final String str : expected) {
+            if (stringsEqual(str, currentToken, caseSensitive)) {
+                return i;
+            }
+
+            ++i;
+        }
+
+        if (throwOnFailure) {
+            throw unexpectedToken("one of " + expected);
+        }
+
+        return -1;
+    }
+
+    /** Get an exception indicating that the current token was unexpected. The returned
+     * exception contains a message with the line number and column of the current token and
+     * a description of its value.
+     * @param expected string describing what was expected
+     * @return exception indicating that the current token was unexpected
+     */
+    public IOException unexpectedToken(final String expected) {
+        return unexpectedToken(expected, null);
+    }
+
+    /** Get an exception indicating that the current token was unexpected. The returned
+     * exception contains a message with the line number and column of the current token and
+     * a description of its value.
+     * @param expected string describing what was expected
+     * @param cause cause of the error
+     * @return exception indicating that the current token was unexpected
+     */
+    public IOException unexpectedToken(final String expected, final Throwable cause) {
+
+        StringBuilder msg = new StringBuilder();
+        msg.append("expected ")
+            .append(expected)
+            .append(" but found ")
+            .append(getCurrentTokenDescription());
+
+        final int line = hasSetToken ? currentTokenLineNumber : lineNumber;
+        final int col = hasSetToken ? currentTokenColumnNumber : columnNumber;
+
+        return parseError(line, col, msg.toString(), cause);
+    }
+
+    /** Get an exception indicating an error during parsing at the current token position.
+     * @param msg error message
+     * @return an exception indicating an error during parsing at the current token position
+     */
+    public IOException tokenError(final String msg) {
+        return tokenError(msg, null);
+    }
+
+    /** Get an exception indicating an error during parsing at the current token position.
+     * @param msg error message
+     * @param cause the cause of the error; may be null
+     * @return an exception indicating an error during parsing at the current token position
+     */
+    public IOException tokenError(final String msg, final Throwable cause) {
+        final int line = hasSetToken ? currentTokenLineNumber : lineNumber;
+        final int col = hasSetToken ? currentTokenColumnNumber : columnNumber;
+
+        return parseError(line, col, msg, cause);
+    }
+
+    /** Return an exception indicating an error occurring at the current parser position.
+     * @param msg error message
+     * @return an exception indicating an error during parsing
+     */
+    public IOException parseError(final String msg) {
+        return parseError(msg, null);
+    }
+
+    /** Return an exception indicating an error occurring at the current parser position.
+     * @param msg error message
+     * @param cause the cause of the error; may be null
+     * @return an exception indicating an error during parsing
+     */
+    public IOException parseError(final String msg, final Throwable cause) {
+        return parseError(lineNumber, columnNumber, msg, cause);
+    }
+
+    /** Return an exception indicating an error during parsing.
+     * @param line line number of the error
+     * @param col column number of the error
+     * @param msg error message
+     * @return an exception indicating an error during parsing
+     */
+    public IOException parseError(final int line, final int col, final String msg) {
+        return parseError(line, col, msg, null);
+    }
+
+    /** Return an exception indicating an error during parsing.
+     * @param line line number of the error
+     * @param col column number of the error
+     * @param msg error message
+     * @param cause the cause of the error
+     * @return an exception indicating an error during parsing
+     */
+    public IOException parseError(final int line, final int col, final String msg,
+            final Throwable cause) {
+        final String fullMsg = String.format("Parsing failed at line %d, column %d: %s",
+                line, col, msg);
+        return createParseError(fullMsg, cause);
+    }
+
+    /** Construct a new parse exception instance with the given message and cause. Subclasses
+     *  may override this method to provide their own exception types.
+     * @param msg error message
+     * @param cause error cause
+     * @return a new parse exception instance
+     */
+    protected IOException createParseError(final String msg, final Throwable cause) {
+        return new ParseException(msg, cause);
+    }
+
+    /** Set the current token string and position.
+     * @param line line number for the start of the token
+     * @param col column number for the start of the token
+     * @param token token to set
+     */
+    private void setToken(final int line, final int col, final String token) {
+        currentTokenLineNumber = line;
+        currentTokenColumnNumber = col;
+        currentToken = token;
+
+        hasSetToken = true;
+    }
+
+    /** Get a user-friendly description of the current token.
+     * @return a user-friendly description of the current token.
+     */
+    private String getCurrentTokenDescription() {
+        if (currentToken == null || currentToken.isEmpty()) {
+            // attempt to return a more helpful message about the location
+            // of empty tokens by checking the buffer content; if this fails
+            // we'll ignore the error and continue with a more generic message
+            try {
+                if (!hasMoreCharacters()) {
+                    return "end of content";
+                } else if (currentToken != null) {
+                    if (!hasMoreCharactersOnLine()) {
+                        return "end of line";
+                    } else if (currentToken != null) {
+                        return "empty token followed by [" + peek(1) + "]";
+                    }
+                }
+            } catch (IOException exc) {

Review comment:
       We can use Final

##########
File path: commons-geometry-euclidean-io/src/main/java/org/apache/commons/geometry/euclidean/io/threed/AbstractBoundaryReadHandler3D.java
##########
@@ -0,0 +1,165 @@
+/*
+ * 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.io.threed;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.NoSuchElementException;
+import java.util.Spliterator;
+import java.util.Spliterators;
+import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
+
+import org.apache.commons.geometry.core.io.internal.GeometryIOUtils;
+import org.apache.commons.geometry.core.precision.DoublePrecisionContext;
+import org.apache.commons.geometry.euclidean.threed.BoundaryList3D;
+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.mesh.SimpleTriangleMesh;
+import org.apache.commons.geometry.euclidean.threed.mesh.TriangleMesh;
+
+/** Abstract base class for {@link BoundaryReadHandler3D} implementations.
+ */
+public abstract class AbstractBoundaryReadHandler3D implements BoundaryReadHandler3D {
+
+    /** {@inheritDoc} */
+    @Override
+    public BoundarySource3D read(final InputStream in, final DoublePrecisionContext precision)
+            throws IOException {
+        // read the input as a simple list of boundaries
+        final List<PlaneConvexSubset> list = new ArrayList<>();
+
+        try (FacetDefinitionReader reader =
+                facetDefinitionReader(GeometryIOUtils.createCloseShieldInputStream(in))) {
+
+            FacetDefinition facet;
+            while ((facet = reader.readFacet()) != null) {
+                list.add(FacetDefinitions.toPolygon(facet, precision));
+            }
+        }
+
+        return new BoundaryList3D(list);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public TriangleMesh readTriangleMesh(final InputStream in, final DoublePrecisionContext precision)
+            throws IOException {
+        final SimpleTriangleMesh.Builder meshBuilder = SimpleTriangleMesh.builder(precision);
+
+        try (FacetDefinitionReader reader =
+                facetDefinitionReader(GeometryIOUtils.createCloseShieldInputStream(in))) {
+            FacetDefinition facet;
+            while ((facet = reader.readFacet()) != null) {
+                for (final Triangle3D tri : FacetDefinitions.toPolygon(facet, precision).toTriangles()) {
+                    meshBuilder.addFaceUsingVertices(
+                        tri.getPoint1(),
+                        tri.getPoint2(),
+                        tri.getPoint3()
+                    );
+                }
+            }
+        }
+
+        return meshBuilder.build();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Stream<PlaneConvexSubset> boundaries(final InputStream in, final DoublePrecisionContext precision)
+            throws IOException {
+        return facets(in)
+                .map(f -> FacetDefinitions.toPolygon(f, precision));
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Stream<FacetDefinition> facets(final InputStream in) throws IOException {
+        final FacetDefinitionReader fdReader = facetDefinitionReader(in);
+        final FacetDefinitionReaderIterator it = new FacetDefinitionReaderIterator(fdReader);
+
+        return StreamSupport.stream(Spliterators.spliteratorUnknownSize(it, Spliterator.ORDERED), false);
+    }
+
+    /** Class exposing a {@link FacetDefinitionReader} as an iterator. {@link IOException}s are wrapped
+     * with {@link java.io.UncheckedIOException}.
+     */
+    static final class FacetDefinitionReaderIterator implements Iterator<FacetDefinition> {
+
+        /** Reader supplying the facets for iteration. */
+        private final FacetDefinitionReader reader;
+
+        /** Number of facets read from the reader. */
+        private int loadCount = 0;
+
+        /** Next facet to return from the instance; may be null. */
+        private FacetDefinition next;
+
+        /** Construct a new iterator instance that iterates through the facets available from the
+         * argument.
+         * @param reader read supplying facets for iteration
+         */
+        FacetDefinitionReaderIterator(final FacetDefinitionReader reader) {
+            this.reader = reader;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean hasNext() {
+            ensureLoaded();
+            return next != null;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public FacetDefinition next() {
+            if (!hasNext()) {
+                throw new NoSuchElementException();
+            }
+
+            final FacetDefinition result = next;
+            loadNext();
+
+            return result;
+        }
+
+        /** Ensure that the instance has attempted to load at least one facet from
+         * the underlying reader.
+         */
+        private void ensureLoaded() {
+            if (loadCount < 1) {
+                loadNext();
+            }
+        }
+
+        /** Load the next facet from the underlying reader. Any {@link IOException}s
+         * are wrapped with {@link java.io.UncheckedIOException}.
+         */
+        private void loadNext() {
+            ++loadCount;
+            try {
+                next = reader.readFacet();
+            } catch (IOException exc) {

Review comment:
       We can use final

##########
File path: commons-geometry-euclidean-io/src/main/java/org/apache/commons/geometry/euclidean/io/threed/txt/TextFacetDefinitionReader.java
##########
@@ -0,0 +1,298 @@
+/*
+ * 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.io.threed.txt;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.commons.geometry.core.io.internal.SimpleTextParser;
+import org.apache.commons.geometry.euclidean.io.threed.FacetDefinition;
+import org.apache.commons.geometry.euclidean.io.threed.FacetDefinitionReader;
+import org.apache.commons.geometry.euclidean.io.threed.SimpleFacetDefinition;
+import org.apache.commons.geometry.euclidean.threed.Vector3D;
+
+/** Facet definition reader implementation that reads an extremely simple
+ * text format. The format simply consists of sequences of decimal numbers
+ * defining the vertices of each facet, with one facet defined per line.
+ * Facet vertices are defined by listing their {@code x}, {@code y}, and {@code z}
+ * components in that order. The format can be described as follows:
+ * <pre>
+ *      p1<sub>x</sub> p1<sub>y</sub> p1<sub>z</sub> p2<sub>x</sub> p2<sub>y</sub> p2<sub>z</sub> p3<sub>x</sub> p3<sub>y</sub> p3<sub>z</sub> ...
+ * </pre>
+ * where the <em>p1</em> elements contain the coordinates of the first facet vertex,
+ * <em>p2</em> those of the second, and so on. At least 3 vertices are required for each
+ * facet but more can be specified as long as all {@code x, y, z} components are provided
+ * for each vertex. The facet normal is defined implicitly from the facet vertices using
+ * the right-hand rule (i.e. vertices are arranged counter-clockwise).
+ *
+ * <p><strong>Delimiters</strong></p>
+ * <p>Vertex coordinate values may be separated by any character that is
+ * not a digit, alphabetic, '-' (minus), or '+' (plus). The character does
+ * not need to be consistent between (or even within) lines and does not
+ * need to be configured in the reader. This design provides configuration-free
+ * support for common formats such as CSV as well as other formats designed
+ * for human readability.</p>
+ *
+ * <p><strong>Comments</strong></p>
+ * <p>Comments are supported through use of the {@link #getCommentToken() comment token}
+ * property. Characters from the comment token through the end of the current line are
+ * discarded. Setting the comment token to null or the empty string disables comment parsing.
+ * The default comment token is {@value #DEFAULT_COMMENT_TOKEN}</p>
+ *
+ * <p><strong>Examples</strong></p>
+ * <p>The following examples demonstrate the definition of two facets,
+ * one with 3 vertices and one with 4 vertices, in different formats.</p>
+ * <p><em>CSV</em></p>
+ * <pre>
+ *  0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0
+ *  1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 0.0, 1.0
+ * </pre>
+ * <p><em>Whitespace and semicolons</em></p>
+ * <pre>
+ *  # line comment
+ *  0 0 0; 1 0 0; 1 1 0 # 3 vertices
+ *  1 0 0; 1 1 0; 1 1 1; 1 0 1 # 4 vertices
+ * </pre>
+ *
+ * @see TextFacetDefinitionWriter
+ */
+public class TextFacetDefinitionReader implements FacetDefinitionReader {
+
+    /** Default comment token string. */
+    public static final String DEFAULT_COMMENT_TOKEN = "#";
+
+    /** Reader for accessing the character stream. */
+    private final Reader reader;
+
+    /** Parser used to parse text content. */
+    private final SimpleTextParser parser;
+
+    /** Comment token string; may be null. */
+    private String commentToken;
+
+    /** True if the instance has a non-null, non-empty comment token. */
+    private boolean hasCommentToken;
+
+    /** First character of the comment token. */
+    private int commentStartChar;
+
+    /** Construct a new instance that reads characters from the argument and uses
+     * the default comment token value of {@value TextFacetDefinitionReader#DEFAULT_COMMENT_TOKEN}.
+     * @param reader reader to read characters from
+     */
+    public TextFacetDefinitionReader(final Reader reader) {
+        this(reader, DEFAULT_COMMENT_TOKEN);
+    }
+
+    /** Construct a new instance with the given reader and comment token.
+     * @param reader reader to read characters from
+     * @param commentToken comment token string; set to null to disable comment parsing
+     * @throws IllegalArgumentException if {@code commentToken} is non-null and contains whitespace
+     */
+    public TextFacetDefinitionReader(final Reader reader, final String commentToken) {
+        this.reader = reader;
+        this.parser = new SimpleTextParser(reader);
+
+        setCommentTokenInternal(commentToken);
+    }
+
+    /** Get the comment token string. If not null or empty, any characters from
+     * this token to the end of the current line are discarded during parsing.
+     * @return comment token string; may be null
+     */
+    public String getCommentToken() {
+        return commentToken;
+    }
+
+    /** Set the comment token string. If not null or empty, any characters from this
+     * token to the end of the current line are discarded during parsing. Set to null
+     * or the empty string to disable comment parsing. Comment tokens may not contain
+     * whitespace.
+     * @param commentToken token to set
+     * @throws IllegalArgumentException if the argument is non-null and contains whitespace
+     */
+    public void setCommentToken(final String commentToken) {
+        setCommentTokenInternal(commentToken);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public FacetDefinition readFacet() throws IOException {
+        discardNonDataLines();
+        if (parser.hasMoreCharacters()) {
+            try {
+                return readFacetInternal();
+            } finally {
+                // advance to the next line even if parsing failed for the
+                // current line
+                parser.discardLine();
+            }
+        }
+        return null;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void close() throws IOException {
+        reader.close();
+    }
+
+    /** Internal method to read a facet definition starting from the current parser
+     * position. Empty lines (including lines containing only comments) are discarded.
+     * @return facet definition or null if the end of input is reached
+     * @throws IOException if an I/O or parsing error occurs
+     */
+    private FacetDefinition readFacetInternal() throws IOException {
+        final Vector3D p1 = readVector();
+        discardNonData();
+        final Vector3D p2 = readVector();
+        discardNonData();
+        final Vector3D p3 = readVector();
+
+        List<Vector3D> vertices;

Review comment:
       We can use final




----------------------------------------------------------------
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-geometry] darkma773r commented on pull request #130: GEOMETRY-115: IO Modules

Posted by GitBox <gi...@apache.org>.
darkma773r commented on pull request #130:
URL: https://github.com/apache/commons-geometry/pull/130#issuecomment-763604767


   Thank you for the detailed review, @arturobernalg!


----------------------------------------------------------------
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



[GitHub] [commons-geometry] darkma773r commented on pull request #130: GEOMETRY-115: IO Modules

Posted by GitBox <gi...@apache.org>.
darkma773r commented on pull request #130:
URL: https://github.com/apache/commons-geometry/pull/130#issuecomment-763995788


   Renamed modules and packages to have `io` portion first in order to avoid JPMS conflicts. (Based on discussion on dev mailing list.)


----------------------------------------------------------------
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

For queries about this service, please contact Infrastructure at:
users@infra.apache.org