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();
}
}
}