001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.commons.geometry.io.euclidean.threed.obj;
018
019import java.io.Writer;
020import java.util.ArrayList;
021import java.util.Arrays;
022import java.util.HashMap;
023import java.util.Iterator;
024import java.util.LinkedHashMap;
025import java.util.List;
026import java.util.Map;
027import java.util.function.DoubleFunction;
028import java.util.stream.Stream;
029
030import org.apache.commons.geometry.euclidean.internal.EuclideanUtils;
031import org.apache.commons.geometry.euclidean.threed.BoundarySource3D;
032import org.apache.commons.geometry.euclidean.threed.PlaneConvexSubset;
033import org.apache.commons.geometry.euclidean.threed.Vector3D;
034import org.apache.commons.geometry.euclidean.threed.mesh.Mesh;
035import org.apache.commons.geometry.io.core.utils.AbstractTextFormatWriter;
036import org.apache.commons.geometry.io.euclidean.threed.FacetDefinition;
037
038/** Class for writing OBJ files containing 3D polygon geometries.
039 */
040public final class ObjWriter extends AbstractTextFormatWriter {
041
042    /** Space character. */
043    private static final char SPACE = ' ';
044
045    /** Number of vertices written to the output. */
046    private int vertexCount;
047
048    /** Number of normals written to the output. */
049    private int normalCount;
050
051    /** Create a new instance that writes output with the given writer.
052     * @param writer writer used to write output
053     */
054    public ObjWriter(final Writer writer) {
055        super(writer);
056    }
057
058    /** Get the number of vertices written to the output.
059     * @return the number of vertices written to the output.
060     */
061    public int getVertexCount() {
062        return vertexCount;
063    }
064
065    /** Get the number of vertex normals written to the output.
066     * @return the number of vertex normals written to the output.
067     */
068    public int getVertexNormalCount() {
069        return normalCount;
070    }
071
072    /** Write an OBJ comment with the given value.
073     * @param comment comment to write
074     * @throws java.io.UncheckedIOException if an I/O error occurs
075     */
076    public void writeComment(final String comment) {
077        for (final String line : comment.split("\\R")) {
078            write(ObjConstants.COMMENT_CHAR);
079            write(SPACE);
080            write(line);
081            writeNewLine();
082        }
083    }
084
085    /** Write an object name to the output. This is metadata for the file and
086     * does not affect the geometry, although it may affect how the file content
087     * is read by other programs.
088     * @param objectName the name to write
089     * @throws java.io.UncheckedIOException if an I/O error occurs
090     */
091    public void writeObjectName(final String objectName) {
092        writeKeywordLine(ObjConstants.OBJECT_KEYWORD, objectName);
093    }
094
095    /** Write a group name to the output. This is metadata for the file and
096     * does not affect the geometry, although it may affect how the file content
097     * is read by other programs.
098     * @param groupName the name to write
099     * @throws java.io.UncheckedIOException if an I/O error occurs
100     */
101    public void writeGroupName(final String groupName) {
102        writeKeywordLine(ObjConstants.GROUP_KEYWORD, groupName);
103    }
104
105    /** Write a vertex and return the 0-based index of the vertex in the output.
106     * @param vertex vertex to write
107     * @return 0-based index of the written vertex
108     * @throws java.io.UncheckedIOException if an I/O error occurs
109     */
110    public int writeVertex(final Vector3D vertex) {
111        return writeVertexLine(createVectorString(vertex));
112    }
113
114    /** Write a vertex normal and return the 0-based index of the normal in the output.
115     * @param normal normal to write
116     * @return 0-based index of the written normal
117     * @throws java.io.UncheckedIOException if an I/O error occurs
118     */
119    public int writeVertexNormal(final Vector3D normal) {
120        return writeVertexNormalLine(createVectorString(normal));
121    }
122
123    /** Write a face with the given 0-based vertex indices.
124     * @param vertexIndices 0-based vertex indices for the face
125     * @throws IllegalArgumentException if fewer than 3 vertex indices are given
126     * @throws IndexOutOfBoundsException if any vertex index is computed to be outside of
127     *      the bounds of the elements written so far
128     * @throws java.io.UncheckedIOException if an I/O error occurs
129     */
130    public void writeFace(final int... vertexIndices) {
131        writeFaceWithOffsets(0, vertexIndices, 0, null);
132    }
133
134    /** Write a face with the given 0-based vertex indices and 0-based normal index. The normal
135     * index is applied to all face vertices.
136     * @param vertexIndices 0-based vertex indices
137     * @param normalIndex 0-based normal index
138     * @throws IndexOutOfBoundsException if any vertex or normal index is computed to be outside of
139     *      the bounds of the elements written so far
140     * @throws java.io.UncheckedIOException if an I/O error occurs
141     */
142    public void writeFace(final int[] vertexIndices, final int normalIndex) {
143        final int[] normalIndices = new int[vertexIndices.length];
144        Arrays.fill(normalIndices, normalIndex);
145
146        writeFaceWithOffsets(0, vertexIndices, 0, normalIndices);
147    }
148
149    /** Write a face with the given vertex and normal indices. Indices are 0-based.
150     * The {@code normalIndices} argument may be null, but if present, must contain the
151     * same number of indices as {@code vertexIndices}.
152     * @param vertexIndices 0-based vertex indices; may not be null
153     * @param normalIndices 0-based normal indices; may be null but if present must contain
154     *      the same number of indices as {@code vertexIndices}
155     * @throws IllegalArgumentException if fewer than 3 vertex indices are given or {@code normalIndices}
156     *      is not null but has a different length than {@code vertexIndices}
157     * @throws IndexOutOfBoundsException if any vertex or normal index is computed to be outside of
158     *      the bounds of the elements written so far
159     * @throws java.io.UncheckedIOException if an I/O error occurs
160     */
161    public void writeFace(final int[] vertexIndices, final int[] normalIndices) {
162        writeFaceWithOffsets(0, vertexIndices, 0, normalIndices);
163    }
164
165    /** Write the boundaries present in the given boundary source using a {@link MeshBuffer}
166     * with an unlimited size.
167     * @param src boundary source containing the boundaries to write to the output
168     * @throws IllegalArgumentException if any boundary in the argument is infinite
169     * @throws java.io.UncheckedIOException if an I/O error occurs
170     * @see #meshBuffer(int)
171     * @see #writeMesh(Mesh)
172     */
173    public void writeBoundaries(final BoundarySource3D src) {
174        writeBoundaries(src, -1);
175    }
176
177    /** Write the boundaries present in the given boundary source using a {@link MeshBuffer} with
178     * the given {@code batchSize}.
179     * @param src boundary source containing the boundaries to write to the output
180     * @param batchSize batch size to use for the mesh buffer; pass {@code -1} to use a buffer
181     *      of unlimited size
182     * @throws IllegalArgumentException if any boundary in the argument is infinite
183     * @throws java.io.UncheckedIOException if an I/O error occurs
184     * @see #meshBuffer(int)
185     * @see #writeMesh(Mesh)
186     */
187    public void writeBoundaries(final BoundarySource3D src, final int batchSize) {
188        final MeshBuffer buffer = meshBuffer(batchSize);
189
190        try (Stream<PlaneConvexSubset> stream = src.boundaryStream()) {
191            final Iterator<PlaneConvexSubset> it = stream.iterator();
192            while (it.hasNext()) {
193                buffer.add(it.next());
194            }
195        }
196
197        buffer.flush();
198    }
199
200    /** Write a mesh to the output. All vertices and faces are written exactly as found. For example,
201     * if a vertex is duplicated in the argument, it will also be duplicated in the output.
202     * @param mesh the mesh to write
203     * @throws java.io.UncheckedIOException if an I/O error occurs
204     */
205    public void writeMesh(final Mesh<?> mesh) {
206        final int vertexOffset = vertexCount;
207
208        for (final Vector3D vertex : mesh.vertices()) {
209            writeVertex(vertex);
210        }
211
212        for (final Mesh.Face face : mesh.faces()) {
213            writeFaceWithOffsets(vertexOffset, face.getVertexIndices(), 0, null);
214        }
215    }
216
217    /** Create a new {@link MeshBuffer} instance with an unlimited batch size, meaning that
218     * no vertex definitions are duplicated in the mesh output. This produces the most compact
219     * mesh but at the most of higher memory usage during writing.
220     * @return new mesh buffer instance
221     */
222    public MeshBuffer meshBuffer() {
223        return meshBuffer(-1);
224    }
225
226    /** Create a new {@link MeshBuffer} instance with the given batch size. The batch size determines
227     * how many faces will be stored in the buffer before being flushed. Faces stored in the buffer
228     * share duplicate vertices, reducing the number of vertices required in the file. The {@code batchSize}
229     * is therefore a trade-off between higher memory usage (high batch size) and a higher probability of duplicate
230     * vertices present in the output (low batch size). A batch size of {@code -1} indicates an unlimited
231     * batch size.
232     * @param batchSize number of faces to store in the buffer before automatically flushing to the
233     *      output
234     * @return new mesh buffer instance
235     */
236    public MeshBuffer meshBuffer(final int batchSize) {
237        return new MeshBuffer(batchSize);
238    }
239
240    /** Write a face with the given offsets and indices. The offsets are added to each
241     * index before being written.
242     * @param vertexOffset vertex offset value
243     * @param vertexIndices 0-based vertex indices for the face
244     * @param normalOffset normal offset value
245     * @param normalIndices 0-based normal indices for the face; may be null if no normal are
246     *      defined for the face
247     * @throws IllegalArgumentException if fewer than 3 vertex indices are given or {@code normalIndices}
248     *      is not null but has a different length than {@code vertexIndices}
249     * @throws IndexOutOfBoundsException if any vertex or normal index is computed to be outside of
250     *      the bounds of the elements written so far
251     * @throws java.io.UncheckedIOException if an I/O error occurs
252     */
253    private void writeFaceWithOffsets(final int vertexOffset, final int[] vertexIndices,
254            final int normalOffset, final int[] normalIndices) {
255        if (vertexIndices.length < EuclideanUtils.TRIANGLE_VERTEX_COUNT) {
256            throw new IllegalArgumentException("Face must have more than " + EuclideanUtils.TRIANGLE_VERTEX_COUNT +
257                    " vertices; found " + vertexIndices.length);
258        } else if (normalIndices != null && normalIndices.length != vertexIndices.length) {
259            throw new IllegalArgumentException("Face normal index count must equal vertex index count; expected " +
260                    vertexIndices.length + " but was " + normalIndices.length);
261        }
262
263        write(ObjConstants.FACE_KEYWORD);
264
265        int vertexIdx;
266        int normalIdx;
267        for (int i = 0; i < vertexIndices.length; ++i) {
268            vertexIdx = vertexIndices[i] + vertexOffset;
269            if (vertexIdx < 0 || vertexIdx >= vertexCount) {
270                throw new IndexOutOfBoundsException("Vertex index out of bounds: " + vertexIdx);
271            }
272
273            write(SPACE);
274            write(vertexIdx + 1); // convert to OBJ 1-based convention
275
276            if (normalIndices != null) {
277                normalIdx = normalIndices[i] + normalOffset;
278                if (normalIdx < 0 || normalIdx >= normalCount) {
279                    throw new IndexOutOfBoundsException("Normal index out of bounds: " + normalIdx);
280                }
281
282                // two separator chars since there is no texture coordinate
283                write(ObjConstants.FACE_VERTEX_ATTRIBUTE_SEP_CHAR);
284                write(ObjConstants.FACE_VERTEX_ATTRIBUTE_SEP_CHAR);
285
286                write(normalIdx + 1); // convert to OBJ 1-based convention
287            }
288        }
289
290        writeNewLine();
291    }
292
293    /** Create the OBJ string representation of the given vector.
294     * @param vec vector to convert to a string
295     * @return string representation of the given vector
296     */
297    private String createVectorString(final Vector3D vec) {
298        final DoubleFunction<String> fmt = getDoubleFormat();
299
300        final StringBuilder sb = new StringBuilder();
301        sb.append(fmt.apply(vec.getX()))
302            .append(SPACE)
303            .append(fmt.apply(vec.getY()))
304            .append(SPACE)
305            .append(fmt.apply(vec.getZ()));
306
307        return sb.toString();
308    }
309
310    /** Write a vertex line containing the given string content.
311     * @param content vertex string content
312     * @return the 0-based index of the added vertex
313     * @throws java.io.UncheckedIOException if an I/O error occurs
314     */
315    private int writeVertexLine(final String content) {
316        writeKeywordLine(ObjConstants.VERTEX_KEYWORD, content);
317        return vertexCount++;
318    }
319
320    /** Write a vertex normal line containing the given string content.
321     * @param content vertex normal string content
322     * @return the 0-based index of the added vertex normal
323     * @throws java.io.UncheckedIOException if an I/O error occurs
324     */
325    private int writeVertexNormalLine(final String content) {
326        writeKeywordLine(ObjConstants.VERTEX_NORMAL_KEYWORD, content);
327        return normalCount++;
328    }
329
330    /** Write a line of content prefixed with the given OBJ keyword.
331     * @param keyword OBJ keyword
332     * @param content line content
333     * @throws java.io.UncheckedIOException if an I/O error occurs
334     */
335    private void writeKeywordLine(final String keyword, final String content) {
336        write(keyword);
337        write(SPACE);
338        write(content);
339        writeNewLine();
340    }
341
342    /** Class used to produce OBJ mesh content from sequences of facets. As facets are added to the buffer
343     * their vertices and normals are converted to OBJ vertex and normal definition strings. Vertices and normals
344     * that produce equal definition strings are shared among all of the facets in the buffer. This process
345     * converts the facet sequence into a compact mesh suitable for writing as OBJ file content.
346     *
347     * <p>Ideally, no vertices or normals would be duplicated in an OBJ file. However, when working with very large
348     * geometries it may not be desirable to store values in memory before writing to the output. This
349     * is where the {@code batchSize} property comes into play. The {@code batchSize} represents the maximum
350     * number of faces that the buffer will store before automatically flushing its contents to the output and
351     * resetting its state. This reduces the amount of memory used by the buffer at the cost of increasing the
352     * likelihood of duplicate vertices and/or normals in the output.</p>
353     */
354    public final class MeshBuffer {
355
356        /** Maximum number of faces that will be stored in the buffer before automatically flushing. */
357        private final int batchSize;
358
359        /** Map of vertex definition strings to their local index. */
360        private final Map<String, Integer> vertexMap = new LinkedHashMap<>();
361
362        /** Map of vertex normals to their local index. */
363        private final Map<String, Integer> normalMap = new LinkedHashMap<>();
364
365        /** List of local face vertex indices. */
366        private final List<int[]> faceVertices;
367
368        /** Map of local face indices to their local normal index. */
369        private final Map<Integer, Integer> faceToNormalMap = new HashMap<>();
370
371        /** Construct a new mesh buffer instance with the given batch size.
372         * @param batchSize batch size; set to -1 to indicate an unlimited size
373         */
374        MeshBuffer(final int batchSize) {
375            this.batchSize = batchSize;
376            this.faceVertices = batchSize > -1 ?
377                    new ArrayList<>(batchSize) :
378                    new ArrayList<>();
379        }
380
381        /** Add a facet to this buffer. If {@code batchSize} is greater than {@code -1} and the number
382         * of currently stored faces is greater than or equal to {@code batchSize}, then the buffer
383         * content is written to the output and the buffer state is reset.
384         * @param facet facet to add
385         * @throws java.io.UncheckedIOException if an I/O error occurs
386         */
387        public void add(final FacetDefinition facet) {
388            addFace(facet.getVertices(), facet.getNormal());
389        }
390
391        /** Add a boundary to this buffer. If {@code batchSize} is greater than {@code -1} and the number
392         * of currently stored faces is greater than or equal to {@code batchSize}, then the buffer
393         * content is written to the output and the buffer state is reset.
394         * @param boundary boundary to add
395         * @throws IllegalArgumentException if the boundary is infinite
396         * @throws java.io.UncheckedIOException if an I/O error occurs
397         */
398        public void add(final PlaneConvexSubset boundary) {
399            if (boundary.isInfinite()) {
400                throw new IllegalArgumentException("OBJ input geometry cannot be infinite: " + boundary);
401            } else if (!boundary.isEmpty()) {
402                addFace(boundary.getVertices(), null);
403            }
404        }
405
406        /** Add a vertex to the buffer.
407         * @param vertex vertex to add
408         * @return the index of the vertex in the buffer
409         */
410        public int addVertex(final Vector3D vertex) {
411            return addToMap(vertex, vertexMap);
412        }
413
414        /** Add a normal to the buffer.
415         * @param normal normal to add
416         * @return the index of the normal in the buffer
417         */
418        public int addNormal(final Vector3D normal) {
419            return addToMap(normal, normalMap);
420        }
421
422        /** Flush the buffer content to the output and reset its state.
423         * @throws java.io.UncheckedIOException if an I/O error occurs
424         */
425        public void flush() {
426            final int vertexOffset = vertexCount;
427            final int normalOffset = normalCount;
428
429            // write vertices
430            for (final String vertexStr : vertexMap.keySet()) {
431                writeVertexLine(vertexStr);
432            }
433
434            // write normals
435            for (final String normalStr : normalMap.keySet()) {
436                writeVertexNormalLine(normalStr);
437            }
438
439            // write faces
440            Integer normalIndex;
441            int[] normalIndices;
442            int faceIndex = 0;
443            for (final int[] vertexIndices : faceVertices) {
444                normalIndex = faceToNormalMap.get(faceIndex);
445                if (normalIndex != null) {
446                    normalIndices = new int[vertexIndices.length];
447                    Arrays.fill(normalIndices, normalIndex);
448                } else {
449                    normalIndices = null;
450                }
451
452                writeFaceWithOffsets(vertexOffset, vertexIndices, normalOffset, normalIndices);
453
454                ++faceIndex;
455            }
456
457            reset();
458        }
459
460        /** Convert the given vector to on OBJ definition string and add it to the
461         * map if not yet present. The mapped index of the vector is returned.
462         * @param vec vector to add
463         * @param map map to add the vector to
464         * @return the index the vector entry is mapped to
465         */
466        private int addToMap(final Vector3D vec, final Map<String, Integer> map) {
467            final String str = createVectorString(vec);
468
469            return map.computeIfAbsent(str, k -> map.size());
470        }
471
472        /** Add a face to the buffer. If {@code batchSize} is greater than {@code -1} and the number
473         * of currently stored faces is greater than or equal to {@code batchSize}, then the buffer
474         * content is written to the output and the buffer state is reset.
475         * @param vertices face vertices
476         * @param normal face normal; may be null
477         * @throws java.io.UncheckedIOException if an I/O error occurs
478         */
479        private void addFace(final List<Vector3D> vertices, final Vector3D normal) {
480            final int faceIndex = faceVertices.size();
481
482            final int[] vertexIndices = new int[vertices.size()];
483
484            int i = -1;
485            for (final Vector3D vertex : vertices) {
486                vertexIndices[++i] = addVertex(vertex);
487            }
488            faceVertices.add(vertexIndices);
489
490            if (normal != null) {
491                faceToNormalMap.put(faceIndex, addNormal(normal));
492            }
493
494            if (batchSize > -1 && faceVertices.size() >= batchSize) {
495                flush();
496            }
497        }
498
499        /** Reset the buffer state.
500         */
501        private void reset() {
502            vertexMap.clear();
503            normalMap.clear();
504            faceVertices.clear();
505            faceToNormalMap.clear();
506        }
507    }
508}