ObjWriter.java
- /*
- * 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.io.euclidean.threed.obj;
- import java.io.Writer;
- 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.function.DoubleFunction;
- import java.util.stream.Stream;
- import org.apache.commons.geometry.euclidean.internal.EuclideanUtils;
- 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;
- import org.apache.commons.geometry.io.core.utils.AbstractTextFormatWriter;
- import org.apache.commons.geometry.io.euclidean.threed.FacetDefinition;
- /** 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;
- /** Number of normals written to the output. */
- private int normalCount;
- /** Create a new instance that writes output with the given writer.
- * @param writer writer used to write output
- */
- public ObjWriter(final Writer writer) {
- super(writer);
- }
- /** Get the number of vertices written to the output.
- * @return the number of vertices written to the output.
- */
- public int getVertexCount() {
- return vertexCount;
- }
- /** Get the number of vertex normals written to the output.
- * @return the number of vertex normals written to the output.
- */
- public int getVertexNormalCount() {
- return normalCount;
- }
- /** Write an OBJ comment with the given value.
- * @param comment comment to write
- * @throws java.io.UncheckedIOException if an I/O error occurs
- */
- public void writeComment(final String comment) {
- for (final String line : comment.split("\\R")) {
- write(ObjConstants.COMMENT_CHAR);
- write(SPACE);
- write(line);
- writeNewLine();
- }
- }
- /** Write an object name to the output. This is metadata for the file and
- * does not affect the geometry, although it may affect how the file content
- * is read by other programs.
- * @param objectName the name to write
- * @throws java.io.UncheckedIOException if an I/O error occurs
- */
- public void writeObjectName(final String objectName) {
- writeKeywordLine(ObjConstants.OBJECT_KEYWORD, objectName);
- }
- /** Write a group name to the output. This is metadata for the file and
- * does not affect the geometry, although it may affect how the file content
- * is read by other programs.
- * @param groupName the name to write
- * @throws java.io.UncheckedIOException if an I/O error occurs
- */
- public void writeGroupName(final String groupName) {
- writeKeywordLine(ObjConstants.GROUP_KEYWORD, groupName);
- }
- /** Write a vertex and return the 0-based index of the vertex in the output.
- * @param vertex vertex to write
- * @return 0-based index of the written vertex
- * @throws java.io.UncheckedIOException if an I/O error occurs
- */
- public int writeVertex(final Vector3D vertex) {
- return writeVertexLine(createVectorString(vertex));
- }
- /** Write a vertex normal and return the 0-based index of the normal in the output.
- * @param normal normal to write
- * @return 0-based index of the written normal
- * @throws java.io.UncheckedIOException if an I/O error occurs
- */
- public int writeVertexNormal(final Vector3D normal) {
- return writeVertexNormalLine(createVectorString(normal));
- }
- /** Write a face with the given 0-based vertex indices.
- * @param vertexIndices 0-based vertex indices for the face
- * @throws IllegalArgumentException if fewer than 3 vertex indices are given
- * @throws IndexOutOfBoundsException if any vertex index is computed to be outside of
- * the bounds of the elements written so far
- * @throws java.io.UncheckedIOException if an I/O error occurs
- */
- public void writeFace(final int... vertexIndices) {
- writeFaceWithOffsets(0, vertexIndices, 0, null);
- }
- /** Write a face with the given 0-based vertex indices and 0-based normal index. The normal
- * index is applied to all face vertices.
- * @param vertexIndices 0-based vertex indices
- * @param normalIndex 0-based normal index
- * @throws IndexOutOfBoundsException if any vertex or normal index is computed to be outside of
- * the bounds of the elements written so far
- * @throws java.io.UncheckedIOException if an I/O error occurs
- */
- public void writeFace(final int[] vertexIndices, final int normalIndex) {
- final int[] normalIndices = new int[vertexIndices.length];
- Arrays.fill(normalIndices, normalIndex);
- writeFaceWithOffsets(0, vertexIndices, 0, normalIndices);
- }
- /** Write a face with the given vertex and normal indices. Indices are 0-based.
- * The {@code normalIndices} argument may be null, but if present, must contain the
- * same number of indices as {@code vertexIndices}.
- * @param vertexIndices 0-based vertex indices; may not be null
- * @param normalIndices 0-based normal indices; may be null but if present must contain
- * the same number of indices as {@code vertexIndices}
- * @throws IllegalArgumentException if fewer than 3 vertex indices are given or {@code normalIndices}
- * is not null but has a different length than {@code vertexIndices}
- * @throws IndexOutOfBoundsException if any vertex or normal index is computed to be outside of
- * the bounds of the elements written so far
- * @throws java.io.UncheckedIOException if an I/O error occurs
- */
- public void writeFace(final int[] vertexIndices, final int[] normalIndices) {
- writeFaceWithOffsets(0, vertexIndices, 0, normalIndices);
- }
- /** Write the boundaries present in the given boundary source using a {@link MeshBuffer}
- * with an unlimited size.
- * @param src boundary source containing the boundaries to write to the output
- * @throws IllegalArgumentException if any boundary in the argument is infinite
- * @throws java.io.UncheckedIOException if an I/O error occurs
- * @see #meshBuffer(int)
- * @see #writeMesh(Mesh)
- */
- public void writeBoundaries(final BoundarySource3D src) {
- writeBoundaries(src, -1);
- }
- /** Write the boundaries present in the given boundary source using a {@link MeshBuffer} with
- * the given {@code batchSize}.
- * @param src boundary source containing the boundaries to write to the output
- * @param batchSize batch size to use for the mesh buffer; pass {@code -1} to use a buffer
- * of unlimited size
- * @throws IllegalArgumentException if any boundary in the argument is infinite
- * @throws java.io.UncheckedIOException if an I/O error occurs
- * @see #meshBuffer(int)
- * @see #writeMesh(Mesh)
- */
- public void writeBoundaries(final BoundarySource3D src, final int batchSize) {
- final MeshBuffer buffer = meshBuffer(batchSize);
- try (Stream<PlaneConvexSubset> stream = src.boundaryStream()) {
- final Iterator<PlaneConvexSubset> it = stream.iterator();
- while (it.hasNext()) {
- buffer.add(it.next());
- }
- }
- buffer.flush();
- }
- /** Write a mesh to the output. All vertices and faces are written exactly as found. For example,
- * if a vertex is duplicated in the argument, it will also be duplicated in the output.
- * @param mesh the mesh to write
- * @throws java.io.UncheckedIOException if an I/O error occurs
- */
- public void writeMesh(final Mesh<?> mesh) {
- final int vertexOffset = vertexCount;
- for (final Vector3D vertex : mesh.vertices()) {
- writeVertex(vertex);
- }
- for (final Mesh.Face face : mesh.faces()) {
- writeFaceWithOffsets(vertexOffset, face.getVertexIndices(), 0, null);
- }
- }
- /** Create a new {@link MeshBuffer} instance with an unlimited batch size, meaning that
- * no vertex definitions are duplicated in the mesh output. This produces the most compact
- * mesh but at the most of higher memory usage during writing.
- * @return new mesh buffer instance
- */
- public MeshBuffer meshBuffer() {
- return meshBuffer(-1);
- }
- /** Create a new {@link MeshBuffer} instance with the given batch size. The batch size determines
- * how many faces will be stored in the buffer before being flushed. Faces stored in the buffer
- * share duplicate vertices, reducing the number of vertices required in the file. The {@code batchSize}
- * is therefore a trade-off between higher memory usage (high batch size) and a higher probability of duplicate
- * vertices present in the output (low batch size). A batch size of {@code -1} indicates an unlimited
- * batch size.
- * @param batchSize number of faces to store in the buffer before automatically flushing to the
- * output
- * @return new mesh buffer instance
- */
- public MeshBuffer meshBuffer(final int batchSize) {
- return new MeshBuffer(batchSize);
- }
- /** Write a face with the given offsets and indices. The offsets are added to each
- * index before being written.
- * @param vertexOffset vertex offset value
- * @param vertexIndices 0-based vertex indices for the face
- * @param normalOffset normal offset value
- * @param normalIndices 0-based normal indices for the face; may be null if no normal are
- * defined for the face
- * @throws IllegalArgumentException if fewer than 3 vertex indices are given or {@code normalIndices}
- * is not null but has a different length than {@code vertexIndices}
- * @throws IndexOutOfBoundsException if any vertex or normal index is computed to be outside of
- * the bounds of the elements written so far
- * @throws java.io.UncheckedIOException if an I/O error occurs
- */
- private void writeFaceWithOffsets(final int vertexOffset, final int[] vertexIndices,
- final int normalOffset, final int[] normalIndices) {
- if (vertexIndices.length < EuclideanUtils.TRIANGLE_VERTEX_COUNT) {
- throw new IllegalArgumentException("Face must have more than " + EuclideanUtils.TRIANGLE_VERTEX_COUNT +
- " vertices; found " + vertexIndices.length);
- } else if (normalIndices != null && normalIndices.length != vertexIndices.length) {
- throw new IllegalArgumentException("Face normal index count must equal vertex index count; expected " +
- vertexIndices.length + " but was " + normalIndices.length);
- }
- write(ObjConstants.FACE_KEYWORD);
- int vertexIdx;
- int normalIdx;
- for (int i = 0; i < vertexIndices.length; ++i) {
- vertexIdx = vertexIndices[i] + vertexOffset;
- if (vertexIdx < 0 || vertexIdx >= vertexCount) {
- throw new IndexOutOfBoundsException("Vertex index out of bounds: " + vertexIdx);
- }
- write(SPACE);
- write(vertexIdx + 1); // convert to OBJ 1-based convention
- if (normalIndices != null) {
- normalIdx = normalIndices[i] + normalOffset;
- if (normalIdx < 0 || normalIdx >= normalCount) {
- throw new IndexOutOfBoundsException("Normal index out of bounds: " + normalIdx);
- }
- // two separator chars since there is no texture coordinate
- write(ObjConstants.FACE_VERTEX_ATTRIBUTE_SEP_CHAR);
- write(ObjConstants.FACE_VERTEX_ATTRIBUTE_SEP_CHAR);
- write(normalIdx + 1); // convert to OBJ 1-based convention
- }
- }
- writeNewLine();
- }
- /** Create the OBJ string representation of the given vector.
- * @param vec vector to convert to a string
- * @return string representation of the given vector
- */
- private String createVectorString(final Vector3D vec) {
- final DoubleFunction<String> fmt = getDoubleFormat();
- final StringBuilder sb = new StringBuilder();
- sb.append(fmt.apply(vec.getX()))
- .append(SPACE)
- .append(fmt.apply(vec.getY()))
- .append(SPACE)
- .append(fmt.apply(vec.getZ()));
- return sb.toString();
- }
- /** Write a vertex line containing the given string content.
- * @param content vertex string content
- * @return the 0-based index of the added vertex
- * @throws java.io.UncheckedIOException if an I/O error occurs
- */
- private int writeVertexLine(final String content) {
- writeKeywordLine(ObjConstants.VERTEX_KEYWORD, content);
- return vertexCount++;
- }
- /** Write a vertex normal line containing the given string content.
- * @param content vertex normal string content
- * @return the 0-based index of the added vertex normal
- * @throws java.io.UncheckedIOException if an I/O error occurs
- */
- private int writeVertexNormalLine(final String content) {
- writeKeywordLine(ObjConstants.VERTEX_NORMAL_KEYWORD, content);
- return normalCount++;
- }
- /** Write a line of content prefixed with the given OBJ keyword.
- * @param keyword OBJ keyword
- * @param content line content
- * @throws java.io.UncheckedIOException if an I/O error occurs
- */
- private void writeKeywordLine(final String keyword, final String content) {
- write(keyword);
- write(SPACE);
- write(content);
- writeNewLine();
- }
- /** Class used to produce OBJ mesh content from sequences of facets. As facets are added to the buffer
- * their vertices and normals are converted to OBJ vertex and normal definition strings. Vertices and normals
- * that produce equal definition strings are shared among all of the facets in the buffer. This process
- * converts the facet sequence into a compact mesh suitable for writing as OBJ file content.
- *
- * <p>Ideally, no vertices or normals would be duplicated in an OBJ file. However, when working with very large
- * geometries it may not be desirable to store values in memory before writing to the output. This
- * is where the {@code batchSize} property comes into play. The {@code batchSize} represents the maximum
- * number of faces that the buffer will store before automatically flushing its contents to the output and
- * resetting its state. This reduces the amount of memory used by the buffer at the cost of increasing the
- * likelihood of duplicate vertices and/or normals in the output.</p>
- */
- public final class MeshBuffer {
- /** Maximum number of faces that will be stored in the buffer before automatically flushing. */
- private final int batchSize;
- /** Map of vertex definition strings to their local index. */
- private final Map<String, Integer> vertexMap = new LinkedHashMap<>();
- /** Map of vertex normals to their local index. */
- private final Map<String, Integer> normalMap = new LinkedHashMap<>();
- /** List of local face vertex indices. */
- private final List<int[]> faceVertices;
- /** Map of local face indices to their local normal index. */
- private final Map<Integer, Integer> faceToNormalMap = new HashMap<>();
- /** Construct a new mesh buffer instance with the given batch size.
- * @param batchSize batch size; set to -1 to indicate an unlimited size
- */
- MeshBuffer(final int batchSize) {
- this.batchSize = batchSize;
- this.faceVertices = batchSize > -1 ?
- new ArrayList<>(batchSize) :
- new ArrayList<>();
- }
- /** Add a facet to this buffer. If {@code batchSize} is greater than {@code -1} and the number
- * of currently stored faces is greater than or equal to {@code batchSize}, then the buffer
- * content is written to the output and the buffer state is reset.
- * @param facet facet to add
- * @throws java.io.UncheckedIOException if an I/O error occurs
- */
- public void add(final FacetDefinition facet) {
- addFace(facet.getVertices(), facet.getNormal());
- }
- /** Add a boundary to this buffer. If {@code batchSize} is greater than {@code -1} and the number
- * of currently stored faces is greater than or equal to {@code batchSize}, then the buffer
- * content is written to the output and the buffer state is reset.
- * @param boundary boundary to add
- * @throws IllegalArgumentException if the boundary is infinite
- * @throws java.io.UncheckedIOException if an I/O error occurs
- */
- public void add(final PlaneConvexSubset boundary) {
- if (boundary.isInfinite()) {
- throw new IllegalArgumentException("OBJ input geometry cannot be infinite: " + boundary);
- } else if (!boundary.isEmpty()) {
- addFace(boundary.getVertices(), null);
- }
- }
- /** Add a vertex to the buffer.
- * @param vertex vertex to add
- * @return the index of the vertex in the buffer
- */
- public int addVertex(final Vector3D vertex) {
- return addToMap(vertex, vertexMap);
- }
- /** Add a normal to the buffer.
- * @param normal normal to add
- * @return the index of the normal in the buffer
- */
- public int addNormal(final Vector3D normal) {
- return addToMap(normal, normalMap);
- }
- /** Flush the buffer content to the output and reset its state.
- * @throws java.io.UncheckedIOException if an I/O error occurs
- */
- public void flush() {
- final int vertexOffset = vertexCount;
- final int normalOffset = normalCount;
- // write vertices
- for (final String vertexStr : vertexMap.keySet()) {
- writeVertexLine(vertexStr);
- }
- // write normals
- for (final String normalStr : normalMap.keySet()) {
- writeVertexNormalLine(normalStr);
- }
- // write faces
- Integer normalIndex;
- int[] normalIndices;
- int faceIndex = 0;
- for (final int[] vertexIndices : faceVertices) {
- normalIndex = faceToNormalMap.get(faceIndex);
- if (normalIndex != null) {
- normalIndices = new int[vertexIndices.length];
- Arrays.fill(normalIndices, normalIndex);
- } else {
- normalIndices = null;
- }
- writeFaceWithOffsets(vertexOffset, vertexIndices, normalOffset, normalIndices);
- ++faceIndex;
- }
- reset();
- }
- /** Convert the given vector to on OBJ definition string and add it to the
- * map if not yet present. The mapped index of the vector is returned.
- * @param vec vector to add
- * @param map map to add the vector to
- * @return the index the vector entry is mapped to
- */
- private int addToMap(final Vector3D vec, final Map<String, Integer> map) {
- final String str = createVectorString(vec);
- return map.computeIfAbsent(str, k -> map.size());
- }
- /** Add a face to the buffer. If {@code batchSize} is greater than {@code -1} and the number
- * of currently stored faces is greater than or equal to {@code batchSize}, then the buffer
- * content is written to the output and the buffer state is reset.
- * @param vertices face vertices
- * @param normal face normal; may be null
- * @throws java.io.UncheckedIOException if an I/O error occurs
- */
- private void addFace(final List<Vector3D> vertices, final Vector3D normal) {
- final int faceIndex = faceVertices.size();
- final int[] vertexIndices = new int[vertices.size()];
- int i = -1;
- for (final Vector3D vertex : vertices) {
- vertexIndices[++i] = addVertex(vertex);
- }
- faceVertices.add(vertexIndices);
- if (normal != null) {
- faceToNormalMap.put(faceIndex, addNormal(normal));
- }
- if (batchSize > -1 && faceVertices.size() >= batchSize) {
- flush();
- }
- }
- /** Reset the buffer state.
- */
- private void reset() {
- vertexMap.clear();
- normalMap.clear();
- faceVertices.clear();
- faceToNormalMap.clear();
- }
- }
- }