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 }