From a4310b9a31a8464b0e2d2e6a9ee540d50830b9ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladim=C3=ADr=20Vondru=C5=A1?= Date: Mon, 27 May 2019 23:38:59 +0200 Subject: [PATCH] MeshTools: added generateSmoothNormals(). This was a *fun* algorithmic exercise. Seriously. --- doc/changelog.dox | 2 + src/Magnum/MeshTools/GenerateNormals.cpp | 113 +++++++++ src/Magnum/MeshTools/GenerateNormals.h | 50 +++- src/Magnum/MeshTools/Test/CMakeLists.txt | 2 +- .../MeshTools/Test/GenerateNormalsTest.cpp | 214 +++++++++++++++++- 5 files changed, 377 insertions(+), 4 deletions(-) diff --git a/doc/changelog.dox b/doc/changelog.dox index a2a0354e6..f628678f6 100644 --- a/doc/changelog.dox +++ b/doc/changelog.dox @@ -140,6 +140,8 @@ See also: - @ref MeshTools::generateFlatNormalsInto() alternative to @ref MeshTools::generateFlatNormals() that writes the output to an existing location +- @ref MeshTools::generateSmoothNormals() for generating weighted smooth + normals of indexed meshes - @ref MeshTools::compile(const Trade::MeshData3D&, CompileFlags) now accepts optional flags to control normal generation diff --git a/src/Magnum/MeshTools/GenerateNormals.cpp b/src/Magnum/MeshTools/GenerateNormals.cpp index 08691fb1e..eb51c40f8 100644 --- a/src/Magnum/MeshTools/GenerateNormals.cpp +++ b/src/Magnum/MeshTools/GenerateNormals.cpp @@ -85,4 +85,117 @@ std::pair, std::vector> generateFlatNormals(co } #endif +template void generateSmoothNormalsInto(const Containers::StridedArrayView1D& indices, const Containers::StridedArrayView1D& positions, const Containers::StridedArrayView1D& normals) { + CORRADE_ASSERT(indices.size() % 3 == 0, + "MeshTools::generateSmoothNormalsInto(): index count not divisible by 3", ); + CORRADE_ASSERT(normals.size() == positions.size(), + "MeshTools::generateSmoothNormalsInto(): bad output size, expected" << positions.size() << "but got" << normals.size(), ); + + if(indices.empty()) return; + + /* Gather count of triangles for every vertex. This abuses the output + storage to avoid extra allocations, zero-initialize it first to avoid + random memory getting used. */ + Containers::StridedArrayView1D triangleCount = + Containers::arrayCast(normals); + for(UnsignedInt& i: triangleCount) i = 0; + for(const T index: indices) { + CORRADE_ASSERT(index < positions.size(), "MeshTools::generateSmoothNormals(): index" << index << "out of bounds for" << positions.size() << "elements", ); + ++triangleCount[index]; + } + + /* Turn that into a running offset array: + triangleOffset[i + 1] - triangleOffset[i] is triangle count for vertex i + triangleOffset[i] is offset into an triangle ID array for vertex i */ + Containers::Array triangleOffset{Containers::NoInit, positions.size() + 1}; + triangleOffset[0] = 0; + for(std::size_t i = 0; i != triangleCount.size(); ++i) + triangleOffset[i + 1] = triangleOffset[i] + triangleCount[i]; + + CORRADE_INTERNAL_ASSERT(triangleOffset.back() == indices.size()); + + /* Gather triangle IDs for every vertex. For vertex i, + triangleIds[triangleOffset[i]] until triangleIds[triangleOffset[i + 1]] + contains IDs of triangles that contain it. */ + Containers::Array triangleIds{Containers::NoInit, indices.size()}; + for(std::size_t i = 0; i != indices.size(); ++i) { + const T triangleId = i/3; + const T vertexId = indices[i]; + + /* How many triangle IDs is still left to be written, which also means + the offset where we put the ID. Decrement that for the next run. */ + const std::size_t triangleIdsLeftForVertex = triangleCount[vertexId]--; + triangleIds[triangleOffset[vertexId + 1] - triangleIdsLeftForVertex] = triangleId; + } + + /* Now, triangleCount should be all zeros, we don't need it anymore and the + underlying `normals` array is ready to get filled with real output. */ + + /* For every vertex v, calculate normals from all faces it belongs to and + average them */ + for(std::size_t v = 0; v != positions.size(); ++v) { + /* normals are an external memory, ensure we accumulate from zero */ + normals[v] = Vector3{Math::ZeroInit}; + + /* Go through all triangles sharing this vertex */ + for(std::size_t t = triangleOffset[v]; t != triangleOffset[v + 1]; ++t) { + const std::size_t baseIndex = triangleIds[t]*3; + const T v0i = indices[baseIndex + 0]; + const T v1i = indices[baseIndex + 1]; + const T v2i = indices[baseIndex + 2]; + + /* Cross product is a vector in direction of the normal with length + equal to size of the parallelogram */ + const Vector3 cross = Math::cross(positions[v2i] - positions[v1i], + positions[v0i] - positions[v1i]); + + /* Angle between two sides of the triangle that share vertex `v`. + The shared vertex can be one of the three, so three ways to + calculate the angle */ + Vector3 a{Math::NoInit}, b{Math::NoInit}; + if(v == v0i) { + a = positions[v1i] - positions[v0i]; + b = positions[v2i] - positions[v0i]; + } else if(v == v1i) { + a = positions[v0i] - positions[v1i]; + b = positions[v2i] - positions[v1i]; + } else if(v == v2i) { + a = positions[v0i] - positions[v2i]; + b = positions[v1i] - positions[v2i]; + } else CORRADE_ASSERT_UNREACHABLE(); /* LCOV_EXCL_LINE */ + + /* The normal is cross.normalized(), we need to multiply it it by + surface area which is cross.length()/2. Since normalization is + division by length, multiplying it by length again will be a + no-op. Then, since all normals are divided by 2, it doesn't + change their ratio for the final normalization so we can omit + that as well. Finally we need to weight by the angle, and in + that case only the ratio is important as well, so it doesn't + matter if degrees or radians. */ + normals[v] += cross*Float(Math::angle(a.normalized(), b.normalized())); + } + + /* Normalize the accumulated direction */ + normals[v] = normals[v].normalized(); + } +} + +#ifndef DOXYGEN_GENERATING_OUTPUT +template void generateSmoothNormalsInto(const Containers::StridedArrayView1D&, const Containers::StridedArrayView1D&, const Containers::StridedArrayView1D&); +template void generateSmoothNormalsInto(const Containers::StridedArrayView1D&, const Containers::StridedArrayView1D&, const Containers::StridedArrayView1D&); +template void generateSmoothNormalsInto(const Containers::StridedArrayView1D&, const Containers::StridedArrayView1D&, const Containers::StridedArrayView1D&); +#endif + +template Containers::Array generateSmoothNormals(const Containers::StridedArrayView1D& indices, const Containers::StridedArrayView1D& positions) { + Containers::Array out{Containers::NoInit, positions.size()}; + generateSmoothNormalsInto(indices, positions, out); + return out; +} + +#ifndef DOXYGEN_GENERATING_OUTPUT +template Containers::Array generateSmoothNormals(const Containers::StridedArrayView1D&, const Containers::StridedArrayView1D&); +template Containers::Array generateSmoothNormals(const Containers::StridedArrayView1D&, const Containers::StridedArrayView1D&); +template Containers::Array generateSmoothNormals(const Containers::StridedArrayView1D&, const Containers::StridedArrayView1D&); +#endif + }} diff --git a/src/Magnum/MeshTools/GenerateNormals.h b/src/Magnum/MeshTools/GenerateNormals.h index eaaf4f8f9..76e155b68 100644 --- a/src/Magnum/MeshTools/GenerateNormals.h +++ b/src/Magnum/MeshTools/GenerateNormals.h @@ -26,7 +26,7 @@ */ /** @file - * @brief Function @ref Magnum::MeshTools::generateFlatNormals(), @ref Magnum::MeshTools::generateFlatNormalsInto() + * @brief Function @ref Magnum::MeshTools::generateFlatNormals(), @ref Magnum::MeshTools::generateFlatNormalsInto(), @ref Magnum::MeshTools::generateSmoothNormals(), @ref Magnum::MeshTools::generateSmoothNormalsInto() */ #include "Magnum/Magnum.h" @@ -52,7 +52,7 @@ Example usage: @snippet MagnumMeshTools.cpp generateFlatNormals -@see @ref generateFlatNormalsInto() +@see @ref generateFlatNormalsInto(), @ref generateSmoothNormals() */ MAGNUM_MESHTOOLS_EXPORT Containers::Array generateFlatNormals(const Containers::StridedArrayView1D& positions); @@ -64,6 +64,7 @@ MAGNUM_MESHTOOLS_EXPORT Containers::Array generateFlatNormals(const Con A variant of @ref generateFlatNormals() that fills existing memory instead of allocating a new array. The @p normals array is expected to have the same size as @p positions. +@see @ref generateSmoothNormalsInto() */ MAGNUM_MESHTOOLS_EXPORT void generateFlatNormalsInto(const Containers::StridedArrayView1D& positions, const Containers::StridedArrayView1D& normals); @@ -87,6 +88,51 @@ duplicates before returning. Expects that the position count is divisible by 3. CORRADE_DEPRECATED("use generateFlatNormals(const Containers::StridedArrayView1D&) instead") std::pair, std::vector> MAGNUM_MESHTOOLS_EXPORT generateFlatNormals(const std::vector& indices, const std::vector& positions); #endif +/** +@brief Generate smooth normals +@param indices Triangle face indices +@param positions Triangle vertex positions +@return Per-vertex normals + +Uses the @p indices array to discover adjacent triangles and then for each +vertex position calculates a normal averaged from all triangles that share it. +The normal is weighted according to adjacent triangle area and angle at given +vertex; hard edges are preserved where adjacent triangles don't share vertices. + +Implementation is based on the article +[Weighted Vertex Normals](http://www.bytehazard.com/articles/vertnorm.html) by +Martijn Buijs. +@see @ref generateSmoothNormalsInto(), @ref generateFlatNormals() +*/ +template MAGNUM_MESHTOOLS_EXPORT Containers::Array generateSmoothNormals(const Containers::StridedArrayView1D& indices, const Containers::StridedArrayView1D& positions); + +#if defined(CORRADE_TARGET_WINDOWS) && !defined(__MINGW32__) +extern template MAGNUM_MESHTOOLS_EXPORT Containers::Array generateSmoothNormals(const Containers::StridedArrayView1D&, const Containers::StridedArrayView1D&); +extern template MAGNUM_MESHTOOLS_EXPORT Containers::Array generateSmoothNormals(const Containers::StridedArrayView1D&, const Containers::StridedArrayView1D&); +extern template MAGNUM_MESHTOOLS_EXPORT Containers::Array generateSmoothNormals(const Containers::StridedArrayView1D&, const Containers::StridedArrayView1D&); +#endif + +/** +@brief Generate smooth normals into an existing array +@param[in] indices Triangle face indices +@param[in] positions Triangle vertex positions +@param[out] normals Where to put the generated normals + +A variant of @ref generateSmoothNormals() that fills existing memory instead of +allocating a new array. The @p normals array is expected to have the same size +as @p positions. Note that even with the output array this function isn't fully +allocation-free --- it still allocates two additional internal arrays for +adjacent face calculation. +@see @ref generateFlatNormalsInto() +*/ +template MAGNUM_MESHTOOLS_EXPORT void generateSmoothNormalsInto(const Containers::StridedArrayView1D& indices, const Containers::StridedArrayView1D& positions, const Containers::StridedArrayView1D& normals); + +#if defined(CORRADE_TARGET_WINDOWS) && !defined(__MINGW32__) +extern template MAGNUM_MESHTOOLS_EXPORT void generateSmoothNormalsInto(const Containers::StridedArrayView1D&, const Containers::StridedArrayView1D&, const Containers::StridedArrayView1D&); +extern template MAGNUM_MESHTOOLS_EXPORT void generateSmoothNormalsInto(const Containers::StridedArrayView1D&, const Containers::StridedArrayView1D&, const Containers::StridedArrayView1D&); +extern template MAGNUM_MESHTOOLS_EXPORT void generateSmoothNormalsInto(const Containers::StridedArrayView1D&, const Containers::StridedArrayView1D&, const Containers::StridedArrayView1D&); +#endif + }} #endif diff --git a/src/Magnum/MeshTools/Test/CMakeLists.txt b/src/Magnum/MeshTools/Test/CMakeLists.txt index 3661084b2..ce0d69c59 100644 --- a/src/Magnum/MeshTools/Test/CMakeLists.txt +++ b/src/Magnum/MeshTools/Test/CMakeLists.txt @@ -27,7 +27,7 @@ corrade_add_test(MeshToolsCombineIndexedArraysTest CombineIndexedArraysTest.cpp corrade_add_test(MeshToolsCompressIndicesTest CompressIndicesTest.cpp LIBRARIES MagnumMeshToolsTestLib) corrade_add_test(MeshToolsDuplicateTest DuplicateTest.cpp LIBRARIES Magnum) corrade_add_test(MeshToolsFlipNormalsTest FlipNormalsTest.cpp LIBRARIES MagnumMeshToolsTestLib) -corrade_add_test(MeshToolsGenerateNormalsTest GenerateNormalsTest.cpp LIBRARIES MagnumMeshToolsTestLib) +corrade_add_test(MeshToolsGenerateNormalsTest GenerateNormalsTest.cpp LIBRARIES MagnumMeshToolsTestLib MagnumPrimitives) corrade_add_test(MeshToolsInterleaveTest InterleaveTest.cpp LIBRARIES Magnum) corrade_add_test(MeshToolsRemoveDuplicatesTest RemoveDuplicatesTest.cpp LIBRARIES Magnum) corrade_add_test(MeshToolsSubdivideTest SubdivideTest.cpp LIBRARIES Magnum) diff --git a/src/Magnum/MeshTools/Test/GenerateNormalsTest.cpp b/src/Magnum/MeshTools/Test/GenerateNormalsTest.cpp index e8b596a96..5fb54d437 100644 --- a/src/Magnum/MeshTools/Test/GenerateNormalsTest.cpp +++ b/src/Magnum/MeshTools/Test/GenerateNormalsTest.cpp @@ -26,13 +26,17 @@ #include #include #include +#include #include #include #include #include +#include "Magnum/Math/Functions.h" #include "Magnum/Math/Vector3.h" #include "Magnum/MeshTools/GenerateNormals.h" +#include "Magnum/Primitives/Cylinder.h" +#include "Magnum/Trade/MeshData3D.h" namespace Magnum { namespace MeshTools { namespace Test { namespace { @@ -45,6 +49,13 @@ struct GenerateNormalsTest: TestSuite::Tester { #endif void flatWrongCount(); void flatIntoWrongSize(); + + void smoothTwoTriangles(); + void smoothCube(); + void smoothBeveledCube(); + void smoothCylinder(); + void smoothWrongCount(); + void smoothIntoWrongSize(); }; GenerateNormalsTest::GenerateNormalsTest() { @@ -53,7 +64,14 @@ GenerateNormalsTest::GenerateNormalsTest() { &GenerateNormalsTest::flatDeprecated, #endif &GenerateNormalsTest::flatWrongCount, - &GenerateNormalsTest::flatIntoWrongSize}); + &GenerateNormalsTest::flatIntoWrongSize, + + &GenerateNormalsTest::smoothTwoTriangles, + &GenerateNormalsTest::smoothCube, + &GenerateNormalsTest::smoothBeveledCube, + &GenerateNormalsTest::smoothCylinder, + &GenerateNormalsTest::smoothWrongCount, + &GenerateNormalsTest::smoothIntoWrongSize}); } /* Two vertices connected by one edge, each wound in another direction */ @@ -126,6 +144,200 @@ void GenerateNormalsTest::flatIntoWrongSize() { CORRADE_COMPARE(out.str(), "MeshTools::generateFlatNormalsInto(): bad output size, expected 6 but got 7\n"); } +void GenerateNormalsTest::smoothTwoTriangles() { + const UnsignedInt indices[]{0, 1, 2, 3, 4, 5}; + + /* Should generate the same output as flat normals */ + CORRADE_COMPARE_AS( + generateSmoothNormals(Containers::stridedArrayView(indices), TwoTriangles), + (Containers::Array{Containers::InPlaceInit, { + Vector3::zAxis(), + Vector3::zAxis(), + Vector3::zAxis(), + -Vector3::zAxis(), + -Vector3::zAxis(), + -Vector3::zAxis() + }}), TestSuite::Compare::Container); +} + +void GenerateNormalsTest::smoothCube() { + const Vector3 positions[] { + {-1.0f, -1.0f, 1.0f}, + { 1.0f, -1.0f, 1.0f}, + { 1.0f, 1.0f, 1.0f}, + {-1.0f, 1.0f, 1.0f}, + {-1.0f, 1.0f, -1.0f}, + { 1.0f, 1.0f, -1.0f}, + { 1.0f, -1.0f, -1.0f}, + {-1.0f, -1.0f, -1.0f}, + }; + + const UnsignedByte indices[] { + 0, 1, 2, 0, 2, 3, /* +Z */ + 1, 6, 5, 1, 5, 2, /* +X */ + 3, 2, 5, 3, 5, 4, /* +Y */ + 4, 5, 6, 4, 6, 7, /* -Z */ + 3, 4, 7, 3, 7, 0, /* -X */ + 7, 6, 1, 7, 1, 0 /* -Y */ + }; + + /* Normals should be the same as positions, only normalized */ + CORRADE_COMPARE_AS( + generateSmoothNormals(Containers::stridedArrayView(indices), positions), + (Containers::Array{Containers::InPlaceInit, { + positions[0]/Constants::sqrt3(), + positions[1]/Constants::sqrt3(), + positions[2]/Constants::sqrt3(), + positions[3]/Constants::sqrt3(), + positions[4]/Constants::sqrt3(), + positions[5]/Constants::sqrt3(), + positions[6]/Constants::sqrt3(), + positions[7]/Constants::sqrt3() + }}), TestSuite::Compare::Container); +} + + +constexpr Vector3 BeveledCubePositions[] { + {-1.0f, -0.6f, 1.1f}, + { 1.0f, -0.6f, 1.1f}, + { 1.0f, 0.6f, 1.1f}, /* +Z */ + {-1.0f, 0.6f, 1.1f}, + + { 1.1f, -0.6f, 1.0f}, + { 1.1f, -0.6f, -1.0f}, + { 1.1f, 0.6f, -1.0f}, /* +X */ + { 1.1f, 0.6f, 1.0f}, + + {-1.0f, 0.7f, 1.0f}, + { 1.0f, 0.7f, 1.0f}, + { 1.0f, 0.7f, -1.0f}, /* +Y */ + {-1.0f, 0.7f, -1.0f}, + + { 1.0f, -0.6f, -1.1f}, + {-1.0f, -0.6f, -1.1f}, + {-1.0f, 0.6f, -1.1f}, /* -Z */ + { 1.0f, 0.6f, -1.1f}, + + {-1.0f, -0.7f, -1.0f}, + { 1.0f, -0.7f, -1.0f}, + { 1.0f, -0.7f, 1.0f}, /* -Y */ + {-1.0f, -0.7f, 1.0f}, + + {-1.1f, -0.6f, -1.0f}, + {-1.1f, -0.6f, 1.0f}, + {-1.1f, 0.6f, 1.0f}, /* -X */ + {-1.1f, 0.6f, -1.0f} +}; + +constexpr UnsignedByte BeveledCubeIndices[] { + 0, 1, 2, 0, 2, 3, /* +Z */ + 4, 5, 6, 4, 6, 7, /* +X */ + 8, 9, 10, 8, 10, 11, /* +Y */ + 12, 13, 14, 12, 14, 15, /* -Z */ + 16, 17, 18, 16, 18, 19, /* -Y */ + 20, 21, 22, 20, 22, 23, /* -X */ + + 3, 2, 9, 3, 9, 8, /* +Z / +Y bevel */ + 7, 6, 10, 7, 10, 9, /* +X / +Y bevel */ + 15, 14, 11, 15, 11, 10, /* -Z / +Y bevel */ + 23, 22, 8, 23, 8, 11, /* -X / +Y bevel */ + + 19, 18, 1, 19, 1, 0, /* -Y / +Z bevel */ + 16, 19, 21, 16, 21, 20, /* -Y / -X bevel */ + 17, 16, 13, 17, 13, 12, /* -Y / -Z bevel */ + 18, 17, 5, 18, 5, 4, /* -Z / +X bevel */ + + 2, 1, 4, 2, 4, 7, /* +Z / +X bevel */ + 6, 5, 12, 6, 12, 15, /* +X / -Z bevel */ + 14, 13, 20, 14, 20, 23, /* -Z / -X bevel */ + 22, 21, 0, 22, 0, 3, /* -X / +X bevel */ + + 22, 3, 8, /* -X / +Z / +Y corner */ + 2, 7, 9, /* +Z / +X / +Y corner */ + 6, 15, 10, /* +X / -Z / +Y corner */ + 14, 23, 11, /* -Z / -X / +Y corner */ + + 0, 21, 19, /* +Z / -X / -Y corner */ + 20, 13, 16, /* -X / -Z / -Y corner */ + 12, 5, 17, /* -Z / +X / -Y corner */ + 4, 1, 18 /* +X / +Z / -Y corner */ +}; + +void GenerateNormalsTest::smoothBeveledCube() { + /* Data taken from Primitives::cubeSolid() and expanded a bit, with bevel + faces added */ + + /* Normals should be mirrored on the X/Y/Z plane and with a circular + symmetry around the Y axis, signs corresponding to position signs. */ + Vector3 z{0.0462723f, 0.0754969f, 0.996072f}; + Vector3 x{0.996072f, 0.0754969f, 0.0462723f}; + Vector3 y{0.0467958f, 0.997808f, 0.0467958f}; + CORRADE_COMPARE_AS(generateSmoothNormals( + Containers::stridedArrayView(BeveledCubeIndices), BeveledCubePositions), + (Containers::Array{Containers::InPlaceInit, { + z*Math::sign(BeveledCubePositions[ 0]), + z*Math::sign(BeveledCubePositions[ 1]), + z*Math::sign(BeveledCubePositions[ 2]), /* +Z */ + z*Math::sign(BeveledCubePositions[ 3]), + + x*Math::sign(BeveledCubePositions[ 4]), + x*Math::sign(BeveledCubePositions[ 5]), + x*Math::sign(BeveledCubePositions[ 6]), /* +X */ + x*Math::sign(BeveledCubePositions[ 7]), + + y*Math::sign(BeveledCubePositions[ 8]), + y*Math::sign(BeveledCubePositions[ 9]), + y*Math::sign(BeveledCubePositions[10]), /* +Y */ + y*Math::sign(BeveledCubePositions[11]), + + z*Math::sign(BeveledCubePositions[12]), + z*Math::sign(BeveledCubePositions[13]), + z*Math::sign(BeveledCubePositions[14]), /* -Z */ + z*Math::sign(BeveledCubePositions[15]), + + y*Math::sign(BeveledCubePositions[16]), + y*Math::sign(BeveledCubePositions[17]), + y*Math::sign(BeveledCubePositions[18]), /* -Y */ + y*Math::sign(BeveledCubePositions[19]), + + x*Math::sign(BeveledCubePositions[20]), + x*Math::sign(BeveledCubePositions[21]), + x*Math::sign(BeveledCubePositions[22]), /* -X */ + x*Math::sign(BeveledCubePositions[23]) + }}), TestSuite::Compare::Container); +} + +void GenerateNormalsTest::smoothCylinder() { + const Trade::MeshData3D data = Primitives::cylinderSolid(1, 5, 1.0f); + + /* Output should be exactly the same as the cylinder normals */ + CORRADE_COMPARE_AS(Containers::arrayView(generateSmoothNormals( + Containers::stridedArrayView(data.indices()), + Containers::stridedArrayView(data.positions(0)))), + Containers::arrayView(data.normals(0)), TestSuite::Compare::Container); +} + +void GenerateNormalsTest::smoothWrongCount() { + std::stringstream out; + Error redirectError{&out}; + + const UnsignedByte indices[7]{}; + const Vector3 positions[1]; + generateSmoothNormals(Containers::stridedArrayView(indices), positions); + CORRADE_COMPARE(out.str(), "MeshTools::generateSmoothNormalsInto(): index count not divisible by 3\n"); +} + +void GenerateNormalsTest::smoothIntoWrongSize() { + std::stringstream out; + Error redirectError{&out}; + + const UnsignedByte indices[6]{}; + const Vector3 positions[3]; + Vector3 normals[4]; + generateSmoothNormalsInto(Containers::stridedArrayView(indices), positions, normals); + CORRADE_COMPARE(out.str(), "MeshTools::generateSmoothNormalsInto(): bad output size, expected 3 but got 4\n"); +} + }}}} CORRADE_TEST_MAIN(Magnum::MeshTools::Test::GenerateNormalsTest)