View Javadoc
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.euclidean.testio;
18  
19  import java.io.File;
20  import java.io.IOException;
21  import java.io.UncheckedIOException;
22  import java.io.Writer;
23  import java.nio.charset.StandardCharsets;
24  import java.nio.file.Files;
25  import java.text.DecimalFormat;
26  import java.util.Iterator;
27  import java.util.List;
28  import java.util.stream.Stream;
29  
30  import org.apache.commons.geometry.euclidean.threed.BoundarySource3D;
31  import org.apache.commons.geometry.euclidean.threed.PlaneConvexSubset;
32  import org.apache.commons.geometry.euclidean.threed.Vector3D;
33  import org.apache.commons.geometry.euclidean.threed.mesh.Mesh;
34  
35  /** Class for writing OBJ files containing 3D mesh data. This class is primarily intended for use in
36   * debugging unit tests.
37   */
38  public final class TestOBJWriter implements AutoCloseable {
39  
40      /** Space character. */
41      private static final char SPACE = ' ';
42  
43      /** The default maximum number of fraction digits in formatted numbers. */
44      private static final int DEFAULT_MAXIMUM_FRACTION_DIGITS = 6;
45  
46      /** The default line separator value. This is not directly specified by the OBJ format
47       * but the value used here matches that
48       * <a href="https://docs.blender.org/manual/en/2.80/addons/io_scene_obj.html">used by Blender</a>.
49       */
50      private static final String DEFAULT_LINE_SEPARATOR = "\n";
51  
52      /** Underlying writer instance. */
53      private Writer writer;
54  
55      /** Line separator string. */
56      private String lineSeparator = DEFAULT_LINE_SEPARATOR;
57  
58      /** Decimal formatter. */
59      private DecimalFormat decimalFormat;
60  
61      /** Number of vertices written to the output. */
62      private int vertexCount = 0;
63  
64      /** Create a new instance for writing to the given file.
65       * @param file file to write to
66       * @throws IOException if an IO operation fails
67       */
68      public TestOBJWriter(final File file) throws IOException {
69          this(Files.newBufferedWriter(file.toPath(), StandardCharsets.UTF_8));
70      }
71  
72      /** Create a new instance that writes output with the given writer.
73       * @param writer writer used to write output
74       */
75      public TestOBJWriter(final Writer writer) {
76          this.writer = writer;
77  
78          this.decimalFormat = new DecimalFormat();
79          this.decimalFormat.setMaximumFractionDigits(DEFAULT_MAXIMUM_FRACTION_DIGITS);
80      }
81  
82      /** Get the current line separator. This value defaults to {@value #DEFAULT_LINE_SEPARATOR}.
83       * @return the current line separator
84       */
85      public String getLineSeparator() {
86          return lineSeparator;
87      }
88  
89      /** Set the line separator.
90       * @param lineSeparator the line separator to use
91       */
92      public void setLineSeparator(final String lineSeparator) {
93          this.lineSeparator = lineSeparator;
94      }
95  
96      /** Get the {@link DecimalFormat} instance used to format floating point output.
97       * @return the decimal format instance
98       */
99      public DecimalFormat getDecimalFormat() {
100         return decimalFormat;
101     }
102 
103     /** Set the {@link DecimalFormat} instance used to format floatin point output.
104      * @param decimalFormat decimal format instance
105      */
106     public void setDecimalFormat(final DecimalFormat decimalFormat) {
107         this.decimalFormat = decimalFormat;
108     }
109 
110     /** Write an OBJ comment with the given value.
111      * @param comment comment to write
112      * @throws IOException if an IO operation fails
113      */
114     public void writeComment(final String comment) throws IOException {
115         for (final String line : comment.split("\r?\n")) {
116             writer.write('#');
117             writer.write(SPACE);
118             writer.write(line);
119             writer.write(lineSeparator);
120         }
121     }
122 
123     /** Write an object name to the output. This is metadata for the file and
124      * does not affect the geometry, although it may affect how the file content
125      * is read by other programs.
126      * @param objectName the name to write
127      */
128     public void writeObjectName(final String objectName) throws IOException {
129         writer.write('o');
130         writer.write(SPACE);
131         writer.write(objectName);
132         writer.write(lineSeparator);
133     }
134 
135     /** Write a group name to the output. This is metadata for the file and
136      * does not affect the geometry, although it may affect how the file content
137      * is read by other programs.
138      * @param groupName the name to write
139      */
140     public void writeGroupName(final String groupName) throws IOException {
141         writer.write('g');
142         writer.write(SPACE);
143         writer.write(groupName);
144         writer.write(lineSeparator);
145     }
146 
147     /** Write a vertex to the output. The OBJ 1-based index of the vertex is returned. This
148      * index can be used to reference the vertex in faces via {@link #writeFace(int...)}.
149      * @param vertex vertex to write
150      * @throws IOException if an IO operation fails
151      * @return the index of the written vertex in the OBJ 1-based convention
152      */
153     public int writeVertex(final Vector3D vertex) throws IOException {
154         writer.write('v');
155         writer.write(SPACE);
156         writer.write(decimalFormat.format(vertex.getX()));
157         writer.write(SPACE);
158         writer.write(decimalFormat.format(vertex.getY()));
159         writer.write(SPACE);
160         writer.write(decimalFormat.format(vertex.getZ()));
161         writer.write(lineSeparator);
162 
163         return ++vertexCount;
164     }
165 
166     /** Write a face with the given vertex indices, specified in the OBJ 1-based
167      * convention. Callers are responsible for ensuring that the indices are valid.
168      * @param vertexIndices vertex indices for the face, in the 1-based OBJ convention
169      */
170     public void writeFace(final int... vertexIndices) throws IOException {
171         writeFaceWithVertexOffset(0, vertexIndices);
172     }
173 
174     /** Write the boundaries present in the given boundary source. If the argument is a {@link Mesh},
175      * it is written using {@link #writeMesh(Mesh)}. Otherwise, each boundary is written to the output
176      * separately.
177      * @param boundarySource boundary source containing the boundaries to write to the output
178      * @throws IllegalArgumentException if any boundary in the argument is infinite
179      */
180     public void writeBoundaries(final BoundarySource3D boundarySource) throws IOException {
181         if (boundarySource instanceof Mesh) {
182             writeMesh((Mesh<?>) boundarySource);
183         } else {
184             try (Stream<PlaneConvexSubset> stream = boundarySource.boundaryStream()) {
185                 writeBoundaries(stream.iterator());
186             }
187         }
188     }
189 
190     /** Write the boundaries in the argument to the output. Each boundary is written separately.
191      * @param it boundary iterator
192      * @throws IllegalArgumentException if any boundary in the argument is infinite
193      */
194     private void writeBoundaries(final Iterator<PlaneConvexSubset> it) throws IOException {
195         PlaneConvexSubset boundary;
196         List<Vector3D> vertices;
197         int[] vertexIndices;
198 
199         while (it.hasNext()) {
200             boundary = it.next();
201             if (boundary.isInfinite()) {
202                 throw new IllegalArgumentException("OBJ input geometry cannot be infinite: " + boundary);
203             }
204 
205             vertices = boundary.getVertices();
206             vertexIndices = new int[vertices.size()];
207 
208             for (int i = 0; i < vertexIndices.length; ++i) {
209                 vertexIndices[i] = writeVertex(vertices.get(i));
210             }
211 
212             writeFace(vertexIndices);
213         }
214     }
215 
216     /** Write a mesh to the output.
217      * @param mesh the mesh to write
218      * @throws IOException if an IO operation fails
219      */
220     public void writeMesh(final Mesh<?> mesh) throws IOException {
221         final int vertexOffset = vertexCount + 1;
222 
223         for (final Vector3D vertex : mesh.vertices()) {
224             writeVertex(vertex);
225         }
226 
227         for (final Mesh.Face face : mesh.faces()) {
228             writeFaceWithVertexOffset(vertexOffset, face.getVertexIndices());
229         }
230     }
231 
232     /** Write a face with the given vertex offset value and indices. The offset is added to each
233      * index before being written.
234      * @param vertexOffset vertex offset value
235      * @param vertexIndices vertex indices for the face
236      * @throws IOException if an IO operation fails
237      */
238     private void writeFaceWithVertexOffset(final int vertexOffset, final int... vertexIndices)
239             throws IOException {
240         if (vertexIndices.length < 3) {
241             throw new IllegalArgumentException("Face must have more than 3 vertices; found " + vertexIndices.length);
242         }
243 
244         writer.write('f');
245 
246         for (final int vertexIndex : vertexIndices) {
247             writer.write(SPACE);
248             writer.write(String.valueOf(vertexIndex + vertexOffset));
249         }
250 
251         writer.write(lineSeparator);
252     }
253 
254     /** {@inheritDoc} */
255     @Override
256     public void close() throws IOException {
257         if (writer != null) {
258             writer.close();
259         }
260         writer = null;
261     }
262 
263     /** Convenience method for writing a boundary source to a file.
264      * @param src boundary source to write
265      * @param file file to write to
266      */
267     public static void write(final BoundarySource3D src, final File file) {
268         try (TestOBJWriter writer = new TestOBJWriter(file)) {
269             writer.writeBoundaries(src);
270         } catch (final IOException exc) {
271             throw new UncheckedIOException(exc);
272         }
273     }
274 
275     /** Convenience method for writing a boundary source to a file.
276      * @param src boundary source to write
277      * @param file file path to write to
278      */
279     public static void write(final BoundarySource3D src, final String file) {
280         write(src, new File(file));
281     }
282 }