AbstractObjParser.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.util.ArrayList;
import java.util.List;

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

/** Abstract base class for OBJ parsing functionality.
 */
public abstract class AbstractObjParser {

    /** Text parser instance. */
    private final SimpleTextParser parser;

    /** The current (most recently parsed) keyword. */
    private String currentKeyword;

    /** Construct a new instance for parsing OBJ content from the given text parser.
     * @param parser text parser to read content from
     */
    protected AbstractObjParser(final SimpleTextParser parser) {
        this.parser = parser;
    }

    /** Get the current keyword, meaning the keyword most recently parsed via the {@link #nextKeyword()}
     * method. Null is returned if parsing has not started or the end of the content has been reached.
     * @return the current keyword or null if parsing has not started or the end
     *      of the content has been reached
     */
    public String getCurrentKeyword() {
        return currentKeyword;
    }

    /** Advance the parser to the next keyword, returning true if a keyword has been found
     * and false if the end of the content has been reached. Keywords consist of alphanumeric
     * strings placed at the beginning of lines. Comments and blank lines are ignored.
     * @return true if a keyword has been found and false if the end of content has been reached
     * @throws IllegalStateException if invalid content is found
     * @throws java.io.UncheckedIOException if an I/O error occurs
     */
    public boolean nextKeyword() {
        currentKeyword = null;

        // advance to the next line if not at the start of a line
        if (parser.getColumnNumber() != 1) {
            discardDataLine();
        }

        // search for the next keyword
        while (currentKeyword == null && parser.hasMoreCharacters()) {
            if (!nextDataLineContent() ||
                    parser.peekChar() == ObjConstants.COMMENT_CHAR) {
                // use a standard line discard here so we don't interpret line continuations
                // within comments; the interpreted OBJ content should be the same regardless
                // of the presence of comments
                parser.discardLine();
            } else if (parser.getColumnNumber() != 1) {
                throw parser.parseError("non-blank lines must begin with an OBJ keyword or comment character");
            } else if (!readKeyword()) {
                throw parser.unexpectedToken("OBJ keyword");
            } else {
                final String keywordValue = parser.getCurrentToken();

                handleKeyword(keywordValue);

                currentKeyword = keywordValue;

                // advance past whitespace to the next data value
                discardDataLineWhitespace();
            }
        }

        return currentKeyword != null;
    }

    /** Read the remaining content on the current data line, taking line continuation characters into
     * account.
     * @return remaining content on the current data line or null if the end of the content has
     *      been reached
     * @throws java.io.UncheckedIOException if an I/O error occurs
     */
    public String readDataLine() {
        parser.nextWithLineContinuation(
                ObjConstants.LINE_CONTINUATION_CHAR,
                SimpleTextParser::isNotNewLinePart)
            .discardNewLineSequence();

        return parser.getCurrentToken();
    }

    /** Discard remaining content on the current data line, taking line continuation characters into
     * account.
     * @throws java.io.UncheckedIOException if an I/O error occurs
     */
    public void discardDataLine() {
        parser.discardWithLineContinuation(
                ObjConstants.LINE_CONTINUATION_CHAR,
                SimpleTextParser::isNotNewLinePart)
            .discardNewLineSequence();
    }

    /** Read a whitespace-delimited 3D vector from the current data line.
     * @return vector vector read from the current line
     * @throws IllegalStateException if parsing fails
     * @throws java.io.UncheckedIOException if an I/O error occurs
     */
    public Vector3D readVector() {
        discardDataLineWhitespace();
        final double x = nextDouble();

        discardDataLineWhitespace();
        final double y = nextDouble();

        discardDataLineWhitespace();
        final double z = nextDouble();

        return Vector3D.of(x, y, z);
    }

    /** Read whitespace-delimited double values from the current data line.
     * @return double values read from the current line
     * @throws IllegalStateException if double values are not able to be parsed
     * @throws java.io.UncheckedIOException if an I/O error occurs
     */
    public double[] readDoubles() {
        final List<Double> list = new ArrayList<>();

        while (nextDataLineContent()) {
            list.add(nextDouble());
        }

        // convert to primitive array
        final double[] arr = new double[list.size()];
        for (int i = 0; i < list.size(); ++i) {
            arr[i] = list.get(i);
        }

        return arr;
    }

    /** Get the text parser for the instance.
     * @return text parser for the instance
     */
    protected SimpleTextParser getTextParser() {
        return parser;
    }

    /** Method called when a keyword is encountered in the parsed OBJ content. Subclasses should use
     * this method to validate the keyword and/or update any internal state.
     * @param keyword keyword encountered in the OBJ content
     * @throws IllegalStateException if the given keyword is invalid
     * @throws java.io.UncheckedIOException if an I/O error occurs
     */
    protected abstract void handleKeyword(String keyword);

    /** Discard whitespace on the current data line, taking line continuation characters into account.
     * @return text parser instance
     * @throws java.io.UncheckedIOException if an I/O error occurs
     */
    protected SimpleTextParser discardDataLineWhitespace() {
        return parser.discardWithLineContinuation(
                ObjConstants.LINE_CONTINUATION_CHAR,
                SimpleTextParser::isLineWhitespace);
    }

    /** Discard whitespace on the current data line and return true if any more characters
     * remain on the line.
     * @return true if more non-whitespace characters remain on the current data line
     * @throws java.io.UncheckedIOException if an I/O error occurs
     */
    protected boolean nextDataLineContent() {
        return discardDataLineWhitespace().hasMoreCharactersOnLine();
    }

    /** Get the next whitespace-delimited double on the current data line.
     * @return the next whitespace-delimited double on the current line
     * @throws IllegalStateException if a double value is not able to be parsed
     * @throws java.io.UncheckedIOException if an I/O error occurs
     */
    protected double nextDouble() {
        return parser.nextWithLineContinuation(ObjConstants.LINE_CONTINUATION_CHAR,
                SimpleTextParser::isNotWhitespace)
            .getCurrentTokenAsDouble();
    }

    /** Read a keyword consisting of alphanumeric characters from the current parser position and set it
     * as the current token. Returns true if a non-empty keyword was found.
     * @return true if a non-empty keyword was found.
     * @throws java.io.UncheckedIOException if an I/O error occurs
     */
    private boolean readKeyword() {
        return parser
                .nextWithLineContinuation(ObjConstants.LINE_CONTINUATION_CHAR, SimpleTextParser::isAlphanumeric)
                .hasNonEmptyToken();
    }
}