Browse Source

MeshTools: added generateSmoothNormals().

This was a *fun* algorithmic exercise. Seriously.
pull/229/head
Vladimír Vondruš 7 years ago
parent
commit
a4310b9a31
  1. 2
      doc/changelog.dox
  2. 113
      src/Magnum/MeshTools/GenerateNormals.cpp
  3. 50
      src/Magnum/MeshTools/GenerateNormals.h
  4. 2
      src/Magnum/MeshTools/Test/CMakeLists.txt
  5. 214
      src/Magnum/MeshTools/Test/GenerateNormalsTest.cpp

2
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

113
src/Magnum/MeshTools/GenerateNormals.cpp

@ -85,4 +85,117 @@ std::pair<std::vector<UnsignedInt>, std::vector<Vector3>> generateFlatNormals(co
}
#endif
template<class T> void generateSmoothNormalsInto(const Containers::StridedArrayView1D<const T>& indices, const Containers::StridedArrayView1D<const Vector3>& positions, const Containers::StridedArrayView1D<Vector3>& 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<UnsignedInt> triangleCount =
Containers::arrayCast<UnsignedInt>(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<UnsignedInt> 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<T> 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<UnsignedByte>(const Containers::StridedArrayView1D<const UnsignedByte>&, const Containers::StridedArrayView1D<const Vector3>&, const Containers::StridedArrayView1D<Vector3>&);
template void generateSmoothNormalsInto<UnsignedShort>(const Containers::StridedArrayView1D<const UnsignedShort>&, const Containers::StridedArrayView1D<const Vector3>&, const Containers::StridedArrayView1D<Vector3>&);
template void generateSmoothNormalsInto<UnsignedInt>(const Containers::StridedArrayView1D<const UnsignedInt>&, const Containers::StridedArrayView1D<const Vector3>&, const Containers::StridedArrayView1D<Vector3>&);
#endif
template<class T> Containers::Array<Vector3> generateSmoothNormals(const Containers::StridedArrayView1D<const T>& indices, const Containers::StridedArrayView1D<const Vector3>& positions) {
Containers::Array<Vector3> out{Containers::NoInit, positions.size()};
generateSmoothNormalsInto(indices, positions, out);
return out;
}
#ifndef DOXYGEN_GENERATING_OUTPUT
template Containers::Array<Vector3> generateSmoothNormals<UnsignedByte>(const Containers::StridedArrayView1D<const UnsignedByte>&, const Containers::StridedArrayView1D<const Vector3>&);
template Containers::Array<Vector3> generateSmoothNormals<UnsignedShort>(const Containers::StridedArrayView1D<const UnsignedShort>&, const Containers::StridedArrayView1D<const Vector3>&);
template Containers::Array<Vector3> generateSmoothNormals<UnsignedInt>(const Containers::StridedArrayView1D<const UnsignedInt>&, const Containers::StridedArrayView1D<const Vector3>&);
#endif
}}

50
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<Vector3> generateFlatNormals(const Containers::StridedArrayView1D<const Vector3>& positions);
@ -64,6 +64,7 @@ MAGNUM_MESHTOOLS_EXPORT Containers::Array<Vector3> 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<const Vector3>& positions, const Containers::StridedArrayView1D<Vector3>& normals);
@ -87,6 +88,51 @@ duplicates before returning. Expects that the position count is divisible by 3.
CORRADE_DEPRECATED("use generateFlatNormals(const Containers::StridedArrayView1D<const Vector3>&) instead") std::pair<std::vector<UnsignedInt>, std::vector<Vector3>> MAGNUM_MESHTOOLS_EXPORT generateFlatNormals(const std::vector<UnsignedInt>& indices, const std::vector<Vector3>& 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<class T> MAGNUM_MESHTOOLS_EXPORT Containers::Array<Vector3> generateSmoothNormals(const Containers::StridedArrayView1D<const T>& indices, const Containers::StridedArrayView1D<const Vector3>& positions);
#if defined(CORRADE_TARGET_WINDOWS) && !defined(__MINGW32__)
extern template MAGNUM_MESHTOOLS_EXPORT Containers::Array<Vector3> generateSmoothNormals<UnsignedByte>(const Containers::StridedArrayView1D<const UnsignedByte>&, const Containers::StridedArrayView1D<const Vector3>&);
extern template MAGNUM_MESHTOOLS_EXPORT Containers::Array<Vector3> generateSmoothNormals<UnsignedShort>(const Containers::StridedArrayView1D<const UnsignedShort>&, const Containers::StridedArrayView1D<const Vector3>&);
extern template MAGNUM_MESHTOOLS_EXPORT Containers::Array<Vector3> generateSmoothNormals<UnsignedInt>(const Containers::StridedArrayView1D<const UnsignedInt>&, const Containers::StridedArrayView1D<const Vector3>&);
#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<class T> MAGNUM_MESHTOOLS_EXPORT void generateSmoothNormalsInto(const Containers::StridedArrayView1D<const T>& indices, const Containers::StridedArrayView1D<const Vector3>& positions, const Containers::StridedArrayView1D<Vector3>& normals);
#if defined(CORRADE_TARGET_WINDOWS) && !defined(__MINGW32__)
extern template MAGNUM_MESHTOOLS_EXPORT void generateSmoothNormalsInto<UnsignedByte>(const Containers::StridedArrayView1D<const UnsignedByte>&, const Containers::StridedArrayView1D<const Vector3>&, const Containers::StridedArrayView1D<Vector3>&);
extern template MAGNUM_MESHTOOLS_EXPORT void generateSmoothNormalsInto<UnsignedShort>(const Containers::StridedArrayView1D<const UnsignedShort>&, const Containers::StridedArrayView1D<const Vector3>&, const Containers::StridedArrayView1D<Vector3>&);
extern template MAGNUM_MESHTOOLS_EXPORT void generateSmoothNormalsInto<UnsignedInt>(const Containers::StridedArrayView1D<const UnsignedInt>&, const Containers::StridedArrayView1D<const Vector3>&, const Containers::StridedArrayView1D<Vector3>&);
#endif
}}
#endif

2
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)

214
src/Magnum/MeshTools/Test/GenerateNormalsTest.cpp

@ -26,13 +26,17 @@
#include <sstream>
#include <vector>
#include <Corrade/Containers/Array.h>
#include <Corrade/Containers/ArrayViewStl.h>
#include <Corrade/Containers/StridedArrayView.h>
#include <Corrade/TestSuite/Tester.h>
#include <Corrade/TestSuite/Compare/Container.h>
#include <Corrade/Utility/DebugStl.h>
#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<Vector3>{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<Vector3>{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<Vector3>{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)

Loading…
Cancel
Save