ObjWriterTest.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
 *
 *      https://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.StringWriter;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.util.Arrays;
import java.util.Locale;
import java.util.regex.Pattern;

import org.apache.commons.geometry.core.GeometryTestUtils;
import org.apache.commons.geometry.euclidean.threed.BoundarySource3D;
import org.apache.commons.geometry.euclidean.threed.Planes;
import org.apache.commons.geometry.euclidean.threed.Vector3D;
import org.apache.commons.geometry.euclidean.threed.mesh.SimpleTriangleMesh;
import org.apache.commons.geometry.io.euclidean.threed.SimpleFacetDefinition;
import org.apache.commons.numbers.core.Precision;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

class ObjWriterTest {

    private static final double TEST_EPS = 1e-10;

    private static final Precision.DoubleEquivalence TEST_PRECISION =
            Precision.doubleEquivalenceOfEpsilon(TEST_EPS);

    @Test
    void testPropertyDefaults() {
        // arrange
        final StringWriter writer = new StringWriter();

        // act/assert
        try (ObjWriter objWriter = new ObjWriter(writer)) {
            Assertions.assertEquals("\n", objWriter.getLineSeparator());
            Assertions.assertNotNull(objWriter.getDoubleFormat());
            Assertions.assertEquals(0, objWriter.getVertexCount());
            Assertions.assertEquals(0, objWriter.getVertexNormalCount());
        }
    }

    @Test
    void testClose_calledMultipleTimes() {
        // arrange
        final StringWriter writer = new StringWriter();

        // act/assert
        try (ObjWriter objWriter = new ObjWriter(writer)) {
            objWriter.close();
        }

        Assertions.assertEquals("", writer.toString());
    }

    @Test
    void testSetLineSeparator() {
        // arrange
        final StringWriter writer = new StringWriter();

        // act
        try (ObjWriter objWriter = new ObjWriter(writer)) {
            objWriter.setLineSeparator("\r\n");

            objWriter.writeComment("line 1");
            objWriter.writeComment("line 2");
            objWriter.writeVertex(Vector3D.ZERO);
        }

        // assert
        Assertions.assertEquals(
            "# line 1\r\n" +
            "# line 2\r\n" +
            "v 0.0 0.0 0.0\r\n", writer.getBuffer().toString());
    }

    @Test
    void testSetDecimalFormat() {
        // arrange
        final StringWriter writer = new StringWriter();
        final DecimalFormat fmt =
                new DecimalFormat("0.0", DecimalFormatSymbols.getInstance(Locale.ENGLISH));

        // act
        try (ObjWriter objWriter = new ObjWriter(writer)) {
            objWriter.setDoubleFormat(fmt::format);

            objWriter.writeVertex(Vector3D.of(1.09, 2.05, 3.06));
        }

        // assert
        Assertions.assertEquals("v 1.1 2.0 3.1\n", writer.getBuffer().toString());
    }

    @Test
    void testWriteComment() {
        // arrange
        final StringWriter writer = new StringWriter();

        // act
        try (ObjWriter objWriter = new ObjWriter(writer)) {
            objWriter.writeComment("test");
            objWriter.writeComment(" a\r\n multi-line\ncomment");
        }

        // assert
        Assertions.assertEquals(
            "# test\n" +
            "#  a\n" +
            "#  multi-line\n" +
            "# comment\n", writer.getBuffer().toString());
    }

    @Test
    void testWriteObjectName() {
        // arrange
        final StringWriter writer = new StringWriter();

        // act
        try (ObjWriter objWriter = new ObjWriter(writer)) {
            objWriter.writeObjectName("test-object");
        }

        // assert
        Assertions.assertEquals("o test-object\n", writer.getBuffer().toString());
    }

    @Test
    void testWriteGroupName() {
        // arrange
        final StringWriter writer = new StringWriter();

        // act
        try (ObjWriter objWriter = new ObjWriter(writer)) {
            objWriter.writeGroupName("test-group");
        }

        // assert
        Assertions.assertEquals("g test-group\n", writer.getBuffer().toString());
    }

    @Test
    void testWriteVertex() {
        // arrange
        final StringWriter writer = new StringWriter();

        // arrange
        final DecimalFormat fmt =
                new DecimalFormat("0.0", DecimalFormatSymbols.getInstance(Locale.ENGLISH));

        // act
        final int index1;
        final int index2;
        final int count;
        try (ObjWriter objWriter = new ObjWriter(writer)) {
            objWriter.setDoubleFormat(fmt::format);

            index1 = objWriter.writeVertex(Vector3D.of(1.09, 2.1, 3.005));
            index2 = objWriter.writeVertex(Vector3D.of(0.06, 10, 12));

            count = objWriter.getVertexCount();
        }

        // assert
        Assertions.assertEquals(0, index1);
        Assertions.assertEquals(1, index2);
        Assertions.assertEquals(2, count);
        Assertions.assertEquals(
            "v 1.1 2.1 3.0\n" +
            "v 0.1 10.0 12.0\n", writer.getBuffer().toString());
    }

    @Test
    void testWriteNormal() {
        // arrange
        final StringWriter writer = new StringWriter();
        final DecimalFormat fmt =
                new DecimalFormat("0.0", DecimalFormatSymbols.getInstance(Locale.ENGLISH));

        // act
        final int index1;
        final int index2;
        final int count;
        try (ObjWriter objWriter = new ObjWriter(writer)) {
            objWriter.setDoubleFormat(fmt::format);

            index1 = objWriter.writeVertexNormal(Vector3D.of(1.09, 2.1, 3.005));
            index2 = objWriter.writeVertexNormal(Vector3D.of(0.06, 10, 12));

            count = objWriter.getVertexNormalCount();
        }

        // assert
        Assertions.assertEquals(0, index1);
        Assertions.assertEquals(1, index2);
        Assertions.assertEquals(2, count);
        Assertions.assertEquals(
            "vn 1.1 2.1 3.0\n" +
            "vn 0.1 10.0 12.0\n", writer.getBuffer().toString());
    }

    @Test
    void testWriteFace() {
        // arrange
        final StringWriter writer = new StringWriter();

        // act
        try (ObjWriter objWriter = new ObjWriter(writer)) {
            objWriter.writeVertex(Vector3D.ZERO);
            objWriter.writeVertex(Vector3D.of(1, 0, 0));
            objWriter.writeVertex(Vector3D.of(1, 1, 0));
            objWriter.writeVertex(Vector3D.of(0, 1, 0));

            objWriter.writeFace(0, 1, 2);
            objWriter.writeFace(0, 1, 2, 3);
        }

        // assert
        Assertions.assertEquals(
            "v 0.0 0.0 0.0\n" +
            "v 1.0 0.0 0.0\n" +
            "v 1.0 1.0 0.0\n" +
            "v 0.0 1.0 0.0\n" +
            "f 1 2 3\n" +
            "f 1 2 3 4\n", writer.getBuffer().toString());
    }

    @Test
    void testWriteFace_withNormals() {
        // arrange
        final StringWriter writer = new StringWriter();

        // act
        try (ObjWriter objWriter = new ObjWriter(writer)) {
            objWriter.writeVertex(Vector3D.ZERO);
            objWriter.writeVertex(Vector3D.of(1, 0, 0));
            objWriter.writeVertex(Vector3D.of(1, 1, 0));
            objWriter.writeVertex(Vector3D.of(0, 1, 0));

            objWriter.writeVertexNormal(Vector3D.Unit.PLUS_Z);
            objWriter.writeVertexNormal(Vector3D.Unit.MINUS_Z);

            objWriter.writeFace(new int[] {0, 1, 2}, 0);
            objWriter.writeFace(new int[] {0, 1, 2, 3}, new int[] {1, 1, 1, 1});
        }

        // assert
        Assertions.assertEquals(
            "v 0.0 0.0 0.0\n" +
            "v 1.0 0.0 0.0\n" +
            "v 1.0 1.0 0.0\n" +
            "v 0.0 1.0 0.0\n" +
            "vn 0.0 0.0 1.0\n" +
            "vn 0.0 0.0 -1.0\n" +
            "f 1//1 2//1 3//1\n" +
            "f 1//2 2//2 3//2 4//2\n", writer.getBuffer().toString());
    }

    @Test
    void testWriteFace_invalidVertexNumber() {
        // arrange
        final StringWriter writer = new StringWriter();

        // act
        GeometryTestUtils.assertThrowsWithMessage(() -> {
            try (ObjWriter objWriter = new ObjWriter(writer)) {
                objWriter.writeFace(1, 2);
            }
        }, IllegalArgumentException.class, "Face must have more than 3 vertices; found 2");
    }

    @Test
    void testWriteFace_vertexIndexOutOfBounds() {
        // arrange
        final StringWriter writer = new StringWriter();

        // act/assert
        GeometryTestUtils.assertThrowsWithMessage(() -> {
            try (ObjWriter objWriter = new ObjWriter(writer)) {
                objWriter.writeVertex(Vector3D.ZERO);
                objWriter.writeVertex(Vector3D.of(1, 1, 1));

                objWriter.writeFace(0, 1, 2);
            }
        }, IndexOutOfBoundsException.class, "Vertex index out of bounds: 2");

        GeometryTestUtils.assertThrowsWithMessage(() -> {
            try (ObjWriter objWriter = new ObjWriter(writer)) {
                objWriter.writeVertex(Vector3D.ZERO);
                objWriter.writeVertex(Vector3D.of(1, 1, 1));

                objWriter.writeFace(0, -1, 1);
            }
        }, IndexOutOfBoundsException.class, "Vertex index out of bounds: -1");
    }

    @Test
    void testWriteFace_normalIndexOutOfBounds() {
        // arrange
        final StringWriter writer = new StringWriter();

        // act/assert
        GeometryTestUtils.assertThrowsWithMessage(() -> {
            try (ObjWriter objWriter = new ObjWriter(writer)) {
                objWriter.writeVertex(Vector3D.ZERO);
                objWriter.writeVertex(Vector3D.of(1, 1, 1));
                objWriter.writeVertex(Vector3D.of(0, 2, 0));

                objWriter.writeVertexNormal(Vector3D.Unit.PLUS_Z);

                objWriter.writeFace(new int[] {0, 1, 2}, 1);
            }
        }, IndexOutOfBoundsException.class, "Normal index out of bounds: 1");

        GeometryTestUtils.assertThrowsWithMessage(() -> {
            try (ObjWriter objWriter = new ObjWriter(writer)) {
                objWriter.writeVertex(Vector3D.ZERO);
                objWriter.writeVertex(Vector3D.of(1, 1, 1));
                objWriter.writeVertex(Vector3D.of(0, 2, 0));

                objWriter.writeVertexNormal(Vector3D.Unit.PLUS_Z);

                objWriter.writeFace(new int[] {0, 1, 2}, -1);
            }
        }, IndexOutOfBoundsException.class, "Normal index out of bounds: -1");
    }

    @Test
    void testWriteFace_invalidVertexAndNormalCountMismatch() {
        // arrange
        final StringWriter writer = new StringWriter();

        // act
        GeometryTestUtils.assertThrowsWithMessage(() -> {
            try (ObjWriter objWriter = new ObjWriter(writer)) {
                objWriter.writeFace(new int[] {0, 1, 2, 3}, new int[] {0, 1, 2});
            }
        }, IllegalArgumentException.class, "Face normal index count must equal vertex index count; expected 4 but was 3");
    }

    @Test
    void testWriteMesh() {
        // arrange
        final SimpleTriangleMesh mesh = SimpleTriangleMesh.builder(TEST_PRECISION)
                .addFaceUsingVertices(Vector3D.ZERO, Vector3D.of(1, 0, 0), Vector3D.of(0, 1, 0))
                .addFaceUsingVertices(Vector3D.ZERO, Vector3D.of(1, 0, 0), Vector3D.of(0, 0, 1))
                .build();

        final StringWriter writer = new StringWriter();

        // act
        try (ObjWriter objWriter = new ObjWriter(writer)) {
            objWriter.writeMesh(mesh);
        }

        // assert
        Assertions.assertEquals(
            "v 0.0 0.0 0.0\n" +
            "v 1.0 0.0 0.0\n" +
            "v 0.0 1.0 0.0\n" +
            "v 0.0 0.0 1.0\n" +
            "f 1 2 3\n" +
            "f 1 2 4\n", writer.getBuffer().toString());
    }

    @Test
    void testMeshBuffer() {
        // arrange
        final StringWriter writer = new StringWriter();

        try (ObjWriter objWriter = new ObjWriter(writer)) {
            ObjWriter.MeshBuffer buf = objWriter.meshBuffer();

            // act
            buf.add(new SimpleFacetDefinition(Arrays.asList(
                    Vector3D.ZERO, Vector3D.of(1, 0, 0), Vector3D.of(1, 1, 0)), Vector3D.Unit.MINUS_Z));
            buf.add(Planes.convexPolygonFromVertices(Arrays.asList(
                    Vector3D.ZERO, Vector3D.of(1, 1, 0), Vector3D.of(0, 1.5, 0)), TEST_PRECISION));
            buf.add(new SimpleFacetDefinition(Arrays.asList(
                    Vector3D.of(0, 1.5, 0), Vector3D.of(1, 1, 0), Vector3D.of(0, 2, 0)), Vector3D.Unit.PLUS_Z));

            buf.flush();
        }

        // assert
        Assertions.assertEquals(
            "v 0.0 0.0 0.0\n" +
            "v 1.0 0.0 0.0\n" +
            "v 1.0 1.0 0.0\n" +
            "v 0.0 1.5 0.0\n" +
            "v 0.0 2.0 0.0\n" +
            "vn 0.0 0.0 -1.0\n" +
            "vn 0.0 0.0 1.0\n" +
            "f 1//1 2//1 3//1\n" +
            "f 1 3 4\n" +
            "f 4//2 3//2 5//2\n", writer.getBuffer().toString());
    }

    @Test
    void testMeshBuffer_givenBatchSize() {
        // arrange
        final StringWriter writer = new StringWriter();

        try (ObjWriter objWriter = new ObjWriter(writer)) {
            ObjWriter.MeshBuffer buf = objWriter.meshBuffer(2);

            // act
            buf.add(new SimpleFacetDefinition(Arrays.asList(
                    Vector3D.ZERO, Vector3D.of(1, 0, 0), Vector3D.of(1, 1, 0)), Vector3D.Unit.MINUS_Z));
            buf.add(Planes.convexPolygonFromVertices(Arrays.asList(
                    Vector3D.ZERO, Vector3D.of(1, 1, 0), Vector3D.of(0, 1.5, 0)), TEST_PRECISION));
            buf.add(new SimpleFacetDefinition(Arrays.asList(
                    Vector3D.of(0, 1.5, 0), Vector3D.of(1, 1, 0), Vector3D.of(0, 2, 0)), Vector3D.Unit.PLUS_Z));

            buf.flush();
        }

        // assert
        Assertions.assertEquals(
            "v 0.0 0.0 0.0\n" +
            "v 1.0 0.0 0.0\n" +
            "v 1.0 1.0 0.0\n" +
            "v 0.0 1.5 0.0\n" +
            "vn 0.0 0.0 -1.0\n" +
            "f 1//1 2//1 3//1\n" +
            "f 1 3 4\n" +
            "v 0.0 1.5 0.0\n" +
            "v 1.0 1.0 0.0\n" +
            "v 0.0 2.0 0.0\n" +
            "vn 0.0 0.0 1.0\n" +
            "f 5//2 6//2 7//2\n", writer.getBuffer().toString());
    }

    @Test
    void testMeshBuffer_mixedWithDirectlyAddedFace() {
        // arrange
        final StringWriter writer = new StringWriter();

        try (ObjWriter objWriter = new ObjWriter(writer)) {
            ObjWriter.MeshBuffer buf = objWriter.meshBuffer(2);

            // act
            objWriter.writeVertex(Vector3D.ZERO);
            objWriter.writeVertex(Vector3D.Unit.MINUS_Y);
            objWriter.writeVertex(Vector3D.Unit.MINUS_X);
            objWriter.writeVertexNormal(Vector3D.Unit.PLUS_Z);
            objWriter.writeFace(new int[] {0, 1, 2}, 0);

            buf.add(new SimpleFacetDefinition(Arrays.asList(
                    Vector3D.ZERO, Vector3D.of(1, 0, 0), Vector3D.of(1, 1, 0)), Vector3D.Unit.MINUS_Z));
            buf.add(Planes.convexPolygonFromVertices(Arrays.asList(
                    Vector3D.ZERO, Vector3D.of(1, 1, 0), Vector3D.of(0, 1.5, 0)), TEST_PRECISION));
            buf.add(new SimpleFacetDefinition(Arrays.asList(
                    Vector3D.of(0, 1.5, 0), Vector3D.of(1, 1, 0), Vector3D.of(0, 2, 0)), Vector3D.Unit.PLUS_Z));

            buf.flush();

            objWriter.writeFace(objWriter.getVertexCount() - 1, 2, 1, 0);
        }

        // assert
        Assertions.assertEquals(
            "v 0.0 0.0 0.0\n" +
            "v 0.0 -1.0 0.0\n" +
            "v -1.0 0.0 0.0\n" +
            "vn 0.0 0.0 1.0\n" +
            "f 1//1 2//1 3//1\n" +
            "v 0.0 0.0 0.0\n" +
            "v 1.0 0.0 0.0\n" +
            "v 1.0 1.0 0.0\n" +
            "v 0.0 1.5 0.0\n" +
            "vn 0.0 0.0 -1.0\n" +
            "f 4//2 5//2 6//2\n" +
            "f 4 6 7\n" +
            "v 0.0 1.5 0.0\n" +
            "v 1.0 1.0 0.0\n" +
            "v 0.0 2.0 0.0\n" +
            "vn 0.0 0.0 1.0\n" +
            "f 8//3 9//3 10//3\n" +
            "f 10 3 2 1\n", writer.getBuffer().toString());
    }

    @Test
    void testWriteBoundaries_meshArgument() {
        // arrange
        final SimpleTriangleMesh mesh = SimpleTriangleMesh.builder(TEST_PRECISION)
                .addFaceUsingVertices(Vector3D.ZERO, Vector3D.of(1, 0, 0), Vector3D.of(0, 1, 0))
                .addFaceUsingVertices(Vector3D.ZERO, Vector3D.of(1, 0, 0), Vector3D.of(0, 0, 1))
                .build();

        final StringWriter writer = new StringWriter();

        // act
        try (ObjWriter objWriter = new ObjWriter(writer)) {
            objWriter.writeBoundaries(mesh);
        }

        // assert
        Assertions.assertEquals(
            "v 0.0 0.0 0.0\n" +
            "v 1.0 0.0 0.0\n" +
            "v 0.0 1.0 0.0\n" +
            "v 0.0 0.0 1.0\n" +
            "f 1 2 3\n" +
            "f 1 2 4\n", writer.getBuffer().toString());
    }

    @Test
    void testWriteBoundaries_nonMeshArgument() {
        // arrange
        final BoundarySource3D src = BoundarySource3D.of(
                    Planes.triangleFromVertices(Vector3D.ZERO, Vector3D.of(1, 0, 0), Vector3D.of(0, 1, 0), TEST_PRECISION),
                    Planes.triangleFromVertices(Vector3D.ZERO, Vector3D.of(1, 0, 0), Vector3D.of(0, 0, 1), TEST_PRECISION)
                );

        final StringWriter writer = new StringWriter();

        // act
        try (ObjWriter objWriter = new ObjWriter(writer)) {
            objWriter.writeBoundaries(src);
        }

        // assert
        Assertions.assertEquals(
            "v 0.0 0.0 0.0\n" +
            "v 1.0 0.0 0.0\n" +
            "v 0.0 1.0 0.0\n" +
            "v 0.0 0.0 1.0\n" +
            "f 1 2 3\n" +
            "f 1 2 4\n", writer.getBuffer().toString());
    }

    @Test
    void testWriteBoundaries_nonMeshArgument_smallBatchSize() {
        // arrange
        final BoundarySource3D src = BoundarySource3D.of(
                    Planes.triangleFromVertices(Vector3D.ZERO, Vector3D.of(1, 0, 0), Vector3D.of(0, 1, 0), TEST_PRECISION),
                    Planes.triangleFromVertices(Vector3D.ZERO, Vector3D.of(1, 0, 0), Vector3D.of(0, 0, 1), TEST_PRECISION)
                );

        final StringWriter writer = new StringWriter();

        // act
        try (ObjWriter objWriter = new ObjWriter(writer)) {
            objWriter.writeBoundaries(src, 1);
        }

        // assert
        Assertions.assertEquals(
            "v 0.0 0.0 0.0\n" +
            "v 1.0 0.0 0.0\n" +
            "v 0.0 1.0 0.0\n" +
            "f 1 2 3\n" +
            "v 0.0 0.0 0.0\n" +
            "v 1.0 0.0 0.0\n" +
            "v 0.0 0.0 1.0\n" +
            "f 4 5 6\n", writer.getBuffer().toString());
    }

    @Test
    void testWriteBoundaries_infiniteBoundary() {
        // arrange
        final BoundarySource3D src = BoundarySource3D.of(
                    Planes.triangleFromVertices(Vector3D.ZERO, Vector3D.of(1, 0, 0), Vector3D.of(0, 1, 0), TEST_PRECISION),
                    Planes.fromPointAndNormal(Vector3D.ZERO, Vector3D.Unit.PLUS_Z, TEST_PRECISION).span()
                );

        final StringWriter writer = new StringWriter();

        // act/assert
        GeometryTestUtils.assertThrowsWithMessage(() -> {
            try (ObjWriter objWriter = new ObjWriter(writer)) {
                objWriter.writeBoundaries(src);
            }
        }, IllegalArgumentException.class, Pattern.compile("^OBJ input geometry cannot be infinite: .*"));
    }
}