1 /*
2 * Licensed to the Apache Software Foundation (ASF) under one or more
3 * contributor license agreements. See the NOTICE file distributed with
4 * this work for additional information regarding copyright ownership.
5 * The ASF licenses this file to You under the Apache License, Version 2.0
6 * (the "License"); you may not use this file except in compliance with
7 * the License. You may obtain a copy of the License at
8 *
9 * http://www.apache.org/licenses/LICENSE-2.0
10 *
11 * Unless required by applicable law or agreed to in writing, software
12 * distributed under the License is distributed on an "AS IS" BASIS,
13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 * See the License for the specific language governing permissions and
15 * limitations under the License.
16 */
17 package org.apache.commons.geometry.io.euclidean.threed.txt;
18
19 import java.io.Reader;
20 import java.util.ArrayList;
21 import java.util.Arrays;
22 import java.util.List;
23
24 import org.apache.commons.geometry.euclidean.threed.Vector3D;
25 import org.apache.commons.geometry.io.core.internal.GeometryIOUtils;
26 import org.apache.commons.geometry.io.core.internal.SimpleTextParser;
27 import org.apache.commons.geometry.io.euclidean.threed.FacetDefinition;
28 import org.apache.commons.geometry.io.euclidean.threed.FacetDefinitionReader;
29 import org.apache.commons.geometry.io.euclidean.threed.SimpleFacetDefinition;
30
31 /** Facet definition reader implementation that reads an extremely simple
32 * text format. The format simply consists of sequences of decimal numbers
33 * defining the vertices of each facet, with one facet defined per line.
34 * Facet vertices are defined by listing their {@code x}, {@code y}, and {@code z}
35 * components in that order. The format can be described as follows:
36 * <p>
37 * <code>
38 * 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> ...
39 * </code>
40 * </p>
41 * <p>where the <em>p1</em> elements contain the coordinates of the first facet vertex,
42 * <em>p2</em> those of the second, and so on. At least 3 vertices are required for each
43 * facet but more can be specified as long as all {@code x, y, z} components are provided
44 * for each vertex. The facet normal is defined implicitly from the facet vertices using
45 * the right-hand rule (i.e. vertices are arranged counter-clockwise).</p>
46 *
47 * <p><strong>Delimiters</strong></p>
48 * <p>Vertex coordinate values may be separated by any character that is
49 * not a digit, alphabetic, '-' (minus), or '+' (plus). The character does
50 * not need to be consistent between (or even within) lines and does not
51 * need to be configured in the reader. This design provides configuration-free
52 * support for common formats such as CSV as well as other formats designed
53 * for human readability.</p>
54 *
55 * <p><strong>Comments</strong></p>
56 * <p>Comments are supported through use of the {@link #getCommentToken() comment token}
57 * property. Characters from the comment token through the end of the current line are
58 * discarded. Setting the comment token to null or the empty string disables comment parsing.
59 * The default comment token is {@value #DEFAULT_COMMENT_TOKEN}</p>
60 *
61 * <p><strong>Examples</strong></p>
62 * <p>The following examples demonstrate the definition of two facets,
63 * one with 3 vertices and one with 4 vertices, in different formats.</p>
64 * <p><em>CSV</em></p>
65 * <pre>
66 * 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0
67 * 1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 0.0, 1.0
68 * </pre>
69 * <p><em>Whitespace and semicolons</em></p>
70 * <pre>
71 * # line comment
72 * 0 0 0; 1 0 0; 1 1 0 # 3 vertices
73 * 1 0 0; 1 1 0; 1 1 1; 1 0 1 # 4 vertices
74 * </pre>
75 *
76 * @see TextFacetDefinitionWriter
77 */
78 public class TextFacetDefinitionReader implements FacetDefinitionReader {
79
80 /** Default comment token string. */
81 public static final String DEFAULT_COMMENT_TOKEN = "#";
82
83 /** Reader for accessing the character stream. */
84 private final Reader reader;
85
86 /** Parser used to parse text content. */
87 private final SimpleTextParser parser;
88
89 /** Comment token string; may be null. */
90 private String commentToken;
91
92 /** True if the instance has a non-null, non-empty comment token. */
93 private boolean hasCommentToken;
94
95 /** First character of the comment token. */
96 private int commentStartChar;
97
98 /** Construct a new instance that reads characters from the argument and uses
99 * the default comment token value of {@value TextFacetDefinitionReader#DEFAULT_COMMENT_TOKEN}.
100 * @param reader reader to read characters from
101 */
102 public TextFacetDefinitionReader(final Reader reader) {
103 this(reader, DEFAULT_COMMENT_TOKEN);
104 }
105
106 /** Construct a new instance with the given reader and comment token.
107 * @param reader reader to read characters from
108 * @param commentToken comment token string; set to null to disable comment parsing
109 * @throws IllegalArgumentException if {@code commentToken} is non-null and contains whitespace
110 */
111 public TextFacetDefinitionReader(final Reader reader, final String commentToken) {
112 this.reader = reader;
113 this.parser = new SimpleTextParser(reader);
114
115 setCommentTokenInternal(commentToken);
116 }
117
118 /** Get the comment token string. If not null or empty, any characters from
119 * this token to the end of the current line are discarded during parsing.
120 * @return comment token string; may be null
121 */
122 public String getCommentToken() {
123 return commentToken;
124 }
125
126 /** Set the comment token string. If not null or empty, any characters from this
127 * token to the end of the current line are discarded during parsing. Set to null
128 * or the empty string to disable comment parsing. Comment tokens may not contain
129 * whitespace.
130 * @param commentToken token to set
131 * @throws IllegalArgumentException if the argument is non-null and contains whitespace
132 */
133 public void setCommentToken(final String commentToken) {
134 setCommentTokenInternal(commentToken);
135 }
136
137 /** {@inheritDoc} */
138 @Override
139 public FacetDefinition readFacet() {
140 discardNonDataLines();
141 if (parser.hasMoreCharacters()) {
142 try {
143 return readFacetInternal();
144 } finally {
145 // advance to the next line even if parsing failed for the
146 // current line
147 parser.discardLine();
148 }
149 }
150 return null;
151 }
152
153 /** {@inheritDoc} */
154 @Override
155 public void close() {
156 GeometryIOUtils.closeUnchecked(reader);
157 }
158
159 /** Internal method to read a facet definition starting from the current parser
160 * position. Empty lines (including lines containing only comments) are discarded.
161 * @return facet definition or null if the end of input is reached
162 * @throws IllegalStateException if a data format error occurs
163 * @throws java.io.UncheckedIOException if an I/O error occurs
164 */
165 private FacetDefinition readFacetInternal() {
166 final Vector3D p1 = readVector();
167 discardNonData();
168 final Vector3D p2 = readVector();
169 discardNonData();
170 final Vector3D p3 = readVector();
171
172 final List<Vector3D> vertices;
173
174 discardNonData();
175 if (parser.hasMoreCharactersOnLine()) {
176 vertices = new ArrayList<>();
177 vertices.add(p1);
178 vertices.add(p2);
179 vertices.add(p3);
180
181 do {
182 vertices.add(readVector());
183 discardNonData();
184 } while (parser.hasMoreCharactersOnLine());
185 } else {
186 vertices = Arrays.asList(p1, p2, p3);
187 }
188
189 return new SimpleFacetDefinition(vertices);
190 }
191
192 /** Read a vector starting from the current parser position.
193 * @return vector read from the parser
194 * @throws IllegalStateException if a data format error occurs
195 * @throws java.io.UncheckedIOException if an I/O error occurs
196 */
197 private Vector3D readVector() {
198 final double x = readDouble();
199 discardNonData();
200 final double y = readDouble();
201 discardNonData();
202 final double z = readDouble();
203
204 return Vector3D.of(x, y, z);
205 }
206
207 /** Read a double starting from the current parser position.
208 * @return double value read from the parser
209 * @throws IllegalStateException if a data format error occurs
210 * @throws java.io.UncheckedIOException if an I/O error occurs
211 */
212 private double readDouble() {
213 return parser
214 .next(TextFacetDefinitionReader::isDataTokenPart)
215 .getCurrentTokenAsDouble();
216 }
217
218 /** Discard lines that do not contain any data. This includes empty lines
219 * and lines that only contain comments.
220 * @throws IllegalStateException if a data format error occurs
221 * @throws java.io.UncheckedIOException if an I/O error occurs
222 */
223 private void discardNonDataLines() {
224 parser.discardLineWhitespace();
225 while (parser.hasMoreCharacters() &&
226 (!parser.hasMoreCharactersOnLine() ||
227 foundComment())) {
228
229 parser
230 .discardLine()
231 .discardLineWhitespace();
232 }
233 }
234
235 /** Discard a sequence of non-data characters on the current line starting
236 * from the current parser position.
237 * @throws IllegalStateException if a data format error occurs
238 * @throws java.io.UncheckedIOException if an I/O error occurs
239 */
240 private void discardNonData() {
241 parser.discard(c ->
242 !SimpleTextParser.isNewLinePart(c) &&
243 !isDataTokenPart(c) &&
244 c != commentStartChar);
245
246 if (foundComment()) {
247 // discard everything to the end of the line but do
248 // not read the new line sequence
249 parser.discard(SimpleTextParser::isNotNewLinePart);
250 }
251 }
252
253 /** Return true if the parser is positioned at the start of the comment token.
254 * @return true if the parser is positioned at the start of the comment token.
255 * @throws IllegalStateException if a data format error occurs
256 * @throws java.io.UncheckedIOException if an I/O error occurs
257 */
258 private boolean foundComment() {
259 return hasCommentToken &&
260 commentToken.equals(parser.peek(commentToken.length()));
261 }
262
263 /** Internal method called to set the comment token state.
264 * @param commentTokenStr comment token to set
265 * @throws IllegalArgumentException if the argument is non-null and contains whitespace
266 */
267 private void setCommentTokenInternal(final String commentTokenStr) {
268 if (commentTokenStr != null && containsWhitespace(commentTokenStr)) {
269 throw new IllegalArgumentException("Comment token cannot contain whitespace; was [" +
270 commentTokenStr + "]");
271 }
272
273 this.commentToken = commentTokenStr;
274 this.hasCommentToken = commentTokenStr != null && commentTokenStr.length() > 0;
275 this.commentStartChar = this.hasCommentToken ?
276 commentTokenStr.charAt(0) :
277 -1;
278 }
279
280 /** Return true if the given character is considered as part of a data token
281 * for this reader.
282 * @param ch character to test
283 * @return true if {@code ch} is part of a data token
284 */
285 private static boolean isDataTokenPart(final int ch) {
286 // include all alphabetic characters in the data tokens, which will help
287 // to provide better error messages in case of failure (ie, tokens will
288 // be split more naturally)
289 return Character.isAlphabetic(ch) ||
290 SimpleTextParser.isDecimalPart(ch);
291 }
292
293 /** Return true if the given string contains any whitespace characters.
294 * @param str string to test
295 * @return true if {@code str} contains any whitespace characters
296 */
297 private static boolean containsWhitespace(final String str) {
298 for (final char ch : str.toCharArray()) {
299 if (Character.isWhitespace(ch)) {
300 return true;
301 }
302 }
303
304 return false;
305 }
306 }