PolygonObjParser.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.Reader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.IntFunction;
import java.util.function.ToIntFunction;
import java.util.stream.Collectors;

import org.apache.commons.geometry.euclidean.internal.EuclideanUtils;
import org.apache.commons.geometry.euclidean.threed.Vector3D;
import org.apache.commons.geometry.io.core.internal.SimpleTextParser;

/** Low-level parser class for reading 3D polygon (face) data in the OBJ file format.
 * This class provides access to OBJ data structures but does not retain any of the
 * parsed data. For example, it is up to callers to store vertices as they are parsed
 * for later reference. This allows callers to determine what values are stored and in
 * what format.
 */
public class PolygonObjParser extends AbstractObjParser {

    /** Set containing OBJ keywords commonly used with files containing only polygon content. */
    private static final Set<String> STANDARD_POLYGON_KEYWORDS =
            Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
                        ObjConstants.VERTEX_KEYWORD,
                        ObjConstants.VERTEX_NORMAL_KEYWORD,
                        ObjConstants.TEXTURE_COORDINATE_KEYWORD,
                        ObjConstants.FACE_KEYWORD,

                        ObjConstants.OBJECT_KEYWORD,
                        ObjConstants.GROUP_KEYWORD,
                        ObjConstants.SMOOTHING_GROUP_KEYWORD,

                        ObjConstants.MATERIAL_LIBRARY_KEYWORD,
                        ObjConstants.USE_MATERIAL_KEYWORD
                    )));

    /** Number of vertex keywords encountered in the file so far. */
    private int vertexCount;

    /** Number of vertex normal keywords encountered in the file so far. */
    private int vertexNormalCount;

    /** Number of texture coordinate keywords encountered in the file so far. */
    private int textureCoordinateCount;

    /** If true, parsing will fail when non-polygon keywords are encountered in the OBJ content. */
    private boolean failOnNonPolygonKeywords;

    /** Construct a new instance for parsing OBJ content from the given reader.
     * @param reader reader to parser content from
     */
    public PolygonObjParser(final Reader reader) {
        this(new SimpleTextParser(reader));
    }

    /** Construct a new instance for parsing OBJ content from the given text parser.
     * @param parser text parser to read content from
     */
    public PolygonObjParser(final SimpleTextParser parser) {
        super(parser);
    }

    /** Get the number of {@link ObjConstants#VERTEX_KEYWORD vertex keywords} parsed
     * so far.
     * @return the number of vertex keywords parsed so far
     */
    public int getVertexCount() {
        return vertexCount;
    }

    /** Get the number of {@link ObjConstants#VERTEX_NORMAL_KEYWORD vertex normal keywords} parsed
     * so far.
     * @return the number of vertex normal keywords parsed so far
     */
    public int getVertexNormalCount() {
        return vertexNormalCount;
    }

    /** Get the number of {@link ObjConstants#TEXTURE_COORDINATE_KEYWORD texture coordinate keywords} parsed
     * so far.
     * @return the number of texture coordinate keywords parsed so far
     */
    public int getTextureCoordinateCount() {
        return textureCoordinateCount;
    }

    /** Return true if the instance is configured to throw an {@link IllegalStateException} when OBJ keywords
     * not commonly used with files containing only polygon data are encountered. The default value is {@code false},
     * meaning that no keyword validation is performed. When set to true, only the following keywords are
     * accepted:
     * <ul>
     *  <li>{@code v}</li>
     *  <li>{@code vn}</li>
     *  <li>{@code vt}</li>
     *  <li>{@code f}</li>
     *  <li>{@code o}</li>
     *  <li>{@code g}</li>
     *  <li>{@code s}</li>
     *  <li>{@code mtllib}</li>
     *  <li>{@code usemtl}</li>
     * </ul>
     * @return true if the instance is configured to fail when a non-polygon keyword is encountered
     */
    public boolean isFailOnNonPolygonKeywords() {
        return failOnNonPolygonKeywords;
    }

    /** Set the flag determining if the instance should throw an {@link IllegalStateException} when encountering
     * keywords not commonly used with OBJ files containing only polygon data. If true, only the following keywords
     * are accepted:
     * <ul>
     *  <li>{@code v}</li>
     *  <li>{@code vn}</li>
     *  <li>{@code vt}</li>
     *  <li>{@code f}</li>
     *  <li>{@code o}</li>
     *  <li>{@code g}</li>
     *  <li>{@code s}</li>
     *  <li>{@code mtllib}</li>
     *  <li>{@code usemtl}</li>
     * </ul>
     * If false, all keywords are accepted.
     * @param failOnNonPolygonKeywords new flag value
     */
    public void setFailOnNonPolygonKeywords(final boolean failOnNonPolygonKeywords) {
        this.failOnNonPolygonKeywords = failOnNonPolygonKeywords;
    }

    /** {@inheritDoc} */
    @Override
    protected void handleKeyword(final String keywordValue) {
        if (failOnNonPolygonKeywords && !STANDARD_POLYGON_KEYWORDS.contains(keywordValue)) {
            final String allowedKeywords = STANDARD_POLYGON_KEYWORDS.stream()
                    .sorted()
                    .collect(Collectors.joining(", "));

            throw getTextParser().tokenError("expected keyword to be one of [" + allowedKeywords +
                    "] but was [" + keywordValue + "]");
        }

        // update counts in order to validate face vertex attributes
        switch (keywordValue) {
        case ObjConstants.VERTEX_KEYWORD:
            ++vertexCount;
            break;
        case ObjConstants.VERTEX_NORMAL_KEYWORD:
            ++vertexNormalCount;
            break;
        case ObjConstants.TEXTURE_COORDINATE_KEYWORD:
            ++textureCoordinateCount;
            break;
        default:
            break;
        }
    }

    /** Read an OBJ face definition from the current line.
     * @return OBJ face definition read from the current line
     * @throws IllegalStateException if a face definition is not able to be parsed
     * @throws java.io.UncheckedIOException if an I/O error occurs
     */
    public Face readFace() {
        final List<VertexAttributes> vertices = new ArrayList<>();

        while (nextDataLineContent()) {
            vertices.add(readFaceVertex());
        }

        if (vertices.size() < EuclideanUtils.TRIANGLE_VERTEX_COUNT) {
            throw getTextParser().parseError(
                    "face must contain at least " + EuclideanUtils.TRIANGLE_VERTEX_COUNT +
                    " vertices but found only " + vertices.size());
        }

        discardDataLine();

        return new Face(vertices);
    }

    /** Read an OBJ face vertex definition from the current parser position.
     * @return OBJ face vertex definition
     * @throws IllegalStateException if a vertex definition is not able to be parsed
     * @throws java.io.UncheckedIOException if an I/O error occurs
     */
    private VertexAttributes readFaceVertex() {
        final SimpleTextParser parser = getTextParser();

        discardDataLineWhitespace();

        final int vertexIndex = readNormalizedVertexAttributeIndex("vertex", vertexCount);

        int textureIndex = -1;
        if (parser.peekChar() == ObjConstants.FACE_VERTEX_ATTRIBUTE_SEP_CHAR) {
            parser.discard(1);

            if (parser.peekChar() != ObjConstants.FACE_VERTEX_ATTRIBUTE_SEP_CHAR) {
                textureIndex = readNormalizedVertexAttributeIndex("texture", textureCoordinateCount);
            }
        }

        int normalIndex = -1;
        if (parser.peekChar() == ObjConstants.FACE_VERTEX_ATTRIBUTE_SEP_CHAR) {
            parser.discard(1);

            if (SimpleTextParser.isIntegerPart(parser.peekChar())) {
                normalIndex = readNormalizedVertexAttributeIndex("normal", vertexNormalCount);
            }
        }

        return new VertexAttributes(vertexIndex, textureIndex, normalIndex);
    }

    /** Read a vertex attribute index from the current parser position and normalize it to
     * be 0-based and positive.
     * @param type type of attribute being read; this value is used in error messages
     * @param available number of available values of the given type parsed from the content
     *      so far
     * @return 0-based positive attribute index
     * @throws IllegalStateException if the integer index cannot be parsed or the index is
     *      out of range for the number of parsed elements of the given type
     * @throws java.io.UncheckedIOException if an I/O error occurs
     */
    private int readNormalizedVertexAttributeIndex(final String type, final int available) {
        final SimpleTextParser parser = getTextParser();

        final int objIndex = parser
                .nextWithLineContinuation(ObjConstants.LINE_CONTINUATION_CHAR, SimpleTextParser::isIntegerPart)
                .getCurrentTokenAsInt();

        final int normalizedIndex = objIndex < 0 ?
                available + objIndex :
                objIndex - 1;

        if (normalizedIndex < 0 || normalizedIndex >= available) {
            final StringBuilder err = new StringBuilder();
            err.append(type)
                .append(" index ");

            if (available < 1) {
                err.append("cannot be used because no values of that type have been defined");
            } else {
                err.append("must evaluate to be within the range [1, ")
                    .append(available)
                    .append("] but was ")
                    .append(objIndex);
            }

            throw parser.tokenError(err.toString());
        }

        return normalizedIndex;
    }

    /** Class representing an OBJ face definition. Faces are defined with the format
     * <p>
     *  <code>
     *      f v<sub>1</sub>/vt<sub>1</sub>/vn<sub>1</sub> v<sub>2</sub>/vt<sub>2</sub>/vn<sub>2</sub> v<sub>3</sub>/vt<sub>3</sub>/vn<sub>3</sub> ...
     *  </code>
     * </p>
     * <p>where the {@code v} elements are indices into the model vertices, the {@code vt}
     * elements are indices into the model texture coordinates, and the {@code vn} elements
     * are indices into the model normal coordinates. Only the vertex indices are required.</p>
     *
     * <p>All vertex attribute indices are normalized to be 0-based and positive and all
     * faces are assumed to define geometrically valid convex polygons.</p>
     */
    public static final class Face {

        /** List of vertex attributes for the face. */
        private final List<VertexAttributes> vertexAttributes;

        /** Construct a new instance with the given vertex attributes.
         * @param vertexAttributes face vertex attributes
         */
        Face(final List<VertexAttributes> vertexAttributes) {
            this.vertexAttributes = Collections.unmodifiableList(vertexAttributes);
        }

        /** Get the list of vertex attributes for the instance.
         * @return list of vertex attribute
         */
        public List<VertexAttributes> getVertexAttributes() {
            return vertexAttributes;
        }

        /** Get a composite normal for the face by computing the sum of all defined vertex
         * normals and normalizing the result. Null is returned if no vertex normals are
         * defined or the defined normals sum to zero.
         * @param modelNormalFn function used to access normals parsed earlier in the model;
         *      callers are responsible for storing these values as they are parsed
         * @return composite face normal or null if no composite normal can be determined from the
         *      normals defined for the face
         */
        public Vector3D getDefinedCompositeNormal(final IntFunction<Vector3D> modelNormalFn) {
            Vector3D sum = Vector3D.ZERO;

            int normalIdx;
            for (final VertexAttributes vertex : vertexAttributes) {
                normalIdx = vertex.getNormalIndex();
                if (normalIdx > -1) {
                    sum = sum.add(modelNormalFn.apply(normalIdx));
                }
            }

            return sum.normalizeOrNull();
        }

        /** Compute a normal for the face using its first three vertices. The vertices will wind in a
         * counter-clockwise direction when viewed looking down the returned normal. Null is returned
         * if the normal could not be determined, which would be the case if the vertices lie in the
         * same line or two or more are equal.
         * @param modelVertexFn function used to access model vertices parsed earlier in the content;
         *      callers are responsible for storing these values as they are passed
         * @return a face normal computed from the first 3 vertices or null if a normal cannot
         *      be determined
         */
        public Vector3D computeNormalFromVertices(final IntFunction<Vector3D> modelVertexFn) {
            final Vector3D p0 = modelVertexFn.apply(vertexAttributes.get(0).getVertexIndex());
            final Vector3D p1 = modelVertexFn.apply(vertexAttributes.get(1).getVertexIndex());
            final Vector3D p2 = modelVertexFn.apply(vertexAttributes.get(2).getVertexIndex());

            return p0.vectorTo(p1).cross(p0.vectorTo(p2)).normalizeOrNull();
        }

        /** Get the vertex attributes for the face listed in the order that produces a counter-clockwise
         * winding of vertices when viewed looking down the given normal direction. If {@code normal}
         * is null, the original vertex sequence is used.
         * @param normal requested face normal; may be null
         * @param modelVertexFn function used to access model vertices parsed earlier in the content;
         *      callers are responsible for storing these values as they are passed
         * @return list of vertex attributes for the face, oriented to correspond with the given
         *      face normal
         */
        public List<VertexAttributes> getVertexAttributesCounterClockwise(final Vector3D normal,
                final IntFunction<Vector3D> modelVertexFn) {
            List<VertexAttributes> result = vertexAttributes;

            if (normal != null) {
                final Vector3D computedNormal = computeNormalFromVertices(modelVertexFn);
                if (computedNormal != null && normal.dot(computedNormal) < 0) {
                    // face is oriented the opposite way; reverse the order of the vertices
                    result = new ArrayList<>(vertexAttributes);
                    Collections.reverse(result);
                }
            }

            return result;
        }

        /** Get the face vertices in the order defined in the face definition.
         * @param modelVertexFn function used to access model vertices parsed earlier in the content;
         *      callers are responsible for storing these values as they are passed
         * @return face vertices in their defined ordering
         */
        public List<Vector3D> getVertices(final IntFunction<Vector3D> modelVertexFn) {
            return vertexAttributes.stream()
                    .map(v -> modelVertexFn.apply(v.getVertexIndex()))
                    .collect(Collectors.toList());
        }

        /** Get the face vertices in the order that produces a counter-clockwise winding when viewed
         * looking down the given normal.
         * @param normal requested face normal
         * @param modelVertexFn function used to access model vertices parsed earlier in the content;
         *      callers are responsible for storing these values as they are passed
         * @return face vertices in the order that produces a counter-clockwise winding when viewed
         *      looking down the given normal
         * @see #getVertexAttributesCounterClockwise(Vector3D, IntFunction)
         */
        public List<Vector3D> getVerticesCounterClockwise(final Vector3D normal,
                final IntFunction<Vector3D> modelVertexFn) {
            return getVertexAttributesCounterClockwise(normal, modelVertexFn).stream()
                    .map(v -> modelVertexFn.apply(v.getVertexIndex()))
                    .collect(Collectors.toList());
        }

        /** Get the vertex indices for the face.
         * @return vertex indices for the face
         */
        public int[] getVertexIndices() {
            return getIndices(VertexAttributes::getVertexIndex);
        }

        /** Get the texture indices for the face. The value {@code -1} is used if a texture index
         * is not set.
         * @return texture indices
         */
        public int[] getTextureIndices() {
            return getIndices(VertexAttributes::getTextureIndex);
        }

        /** Get the normal indices for the face. The value {@code -1} is used if a texture index
         * is not set.
         * @return normal indices
         */
        public int[] getNormalIndices() {
            return getIndices(VertexAttributes::getNormalIndex);
        }

        /** Get indices for the face, using the given function to extract the value from
         * the vertex attributes.
         * @param fn function used to extract the required value from each vertex attribute
         * @return extracted indices
         */
        private int[] getIndices(final ToIntFunction<VertexAttributes> fn) {
            final int len = vertexAttributes.size();
            final int[] indices = new int[len];

            for (int i = 0; i < len; ++i) {
                indices[i] = fn.applyAsInt(vertexAttributes.get(i));
            }

            return indices;
        }
    }

    /** Class representing a set of attributes for a face vertex. All index values are 0-based
     * and positive, in contrast with OBJ indices which are 1-based and support negative
     * values. If an index value is not given in the OBJ content, it is set to {@code -1}.
     */
    public static final class VertexAttributes {

        /** Vertex index. */
        private final int vertexIndex;

        /** Texture coordinate index. */
        private final int textureIndex;

        /** Vertex normal index. */
        private final int normalIndex;

        /** Construct a new instance with the given vertices.
         * @param vertexIndex vertex index
         * @param textureIndex texture index
         * @param normalIndex vertex normal index
         */
        VertexAttributes(final int vertexIndex, final int textureIndex, final int normalIndex) {
            this.vertexIndex = vertexIndex;
            this.textureIndex = textureIndex;
            this.normalIndex = normalIndex;
        }

        /** Get the vertex position index for this instance. This value is required and is guaranteed to
         * be a valid index into the list of vertex positions parsed so far in the OBJ content.
         * @return vertex index
         */
        public int getVertexIndex() {
            return vertexIndex;
        }

        /** Get the texture index for this instance or {@code -1} if not specified in the
         * OBJ content.
         * @return texture index or {@code -1} if not specified in the OBJ content.
         */
        public int getTextureIndex() {
            return textureIndex;
        }

        /** Get the normal index for this instance or {@code -1} if not specified in the
         * OBJ content.
         * @return normal index or {@code -1} if not specified in the OBJ content.
         */
        public int getNormalIndex() {
            return normalIndex;
        }
    }
}