Skip to content

Commit 7bf0ca4

Browse files
cdtwiggfacebook-github-bot
authored andcommitted
Add polygon face support to MeshT (#1066)
Summary: MeshT currently only stores triangle faces (std::vector<Eigen::Vector3i> faces). Characters can have arbitrary polygon topology (quads, n-gons) which is lost during FBX loading when polygons are fan-triangulated. This adds a redundant polygon representation alongside the existing triangle faces so that polygon topology can be preserved. The existing faces field stays unchanged and all downstream code continues to work. Three new fields are added to MeshT: - polyFaces: packed polygon vertex indices (all polygons concatenated) - polyFaceSizes: number of vertices per polygon face - polyTexcoordFaces: packed polygon texcoord indices (shares polyFaceSizes) Also updates cast()/reset(), adds tests, updates compareMeshes(), adds Python bindings with validation, and regenerates .pyi stubs. Reviewed By: nickyhe-gemini Differential Revision: D94577296
1 parent 2e8a9b8 commit 7bf0ca4

File tree

5 files changed

+205
-4
lines changed

5 files changed

+205
-4
lines changed

momentum/math/mesh.cpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ MeshT<T2> MeshT<T>::cast() const {
6969
result.texcoords = this->texcoords;
7070
result.texcoord_faces = this->texcoord_faces;
7171
result.texcoord_lines = this->texcoord_lines;
72+
result.polyFaces = this->polyFaces;
73+
result.polyFaceSizes = this->polyFaceSizes;
74+
result.polyTexcoordFaces = this->polyTexcoordFaces;
7275
return result;
7376
}
7477

@@ -83,6 +86,9 @@ void MeshT<T>::reset() {
8386
texcoords.clear();
8487
texcoord_faces.clear();
8588
texcoord_lines.clear();
89+
polyFaces.clear();
90+
polyFaceSizes.clear();
91+
polyTexcoordFaces.clear();
8692
}
8793

8894
template MeshT<float> MeshT<float>::cast<float>() const;

momentum/math/mesh.h

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,26 @@ struct MeshT {
5656
/// Maps each line to its corresponding texture coordinates
5757
std::vector<std::vector<int32_t>> texcoord_lines;
5858

59+
/// Packed polygon face vertex indices (all polygons concatenated back-to-back).
60+
///
61+
/// For example, a quad followed by a triangle would be: [v0, v1, v2, v3, v4, v5, v6].
62+
/// This is an optional, redundant representation — the triangulated @ref faces field is always
63+
/// required and used by all downstream code. Polygon data preserves the original topology (quads,
64+
/// n-gons) from source files like FBX.
65+
std::vector<uint32_t> polyFaces;
66+
67+
/// Number of vertices in each polygon face.
68+
///
69+
/// For example, [4, 3] means the first polygon is a quad, the second is a triangle.
70+
/// May be empty if polygon data is not available; see @ref polyFaces.
71+
std::vector<uint32_t> polyFaceSizes;
72+
73+
/// Packed polygon face texture coordinate indices (same layout as polyFaces).
74+
///
75+
/// Uses the same polyFaceSizes array — polygon sizes are identical for geometry and texcoords.
76+
/// May be empty if texture coordinates are not available; see @ref polyFaces.
77+
std::vector<uint32_t> polyTexcoordFaces;
78+
5979
/// Compute vertex normals by averaging connected face normals.
6080
///
6181
/// This method calculates normals for each vertex by averaging the normals of all

momentum/test/character/character_helpers_gtest.cpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,12 @@ void compareMeshes(const Mesh_u& refMesh, const Mesh_u& mesh) {
100100
refMesh->confidence, testing::Pointwise(testing::DoubleNear(0.0001), mesh->confidence));
101101
EXPECT_THAT(
102102
refMesh->texcoord_faces, testing::Pointwise(IntExactPointwise(), mesh->texcoord_faces));
103+
// Only compare polygon data when both meshes have it (e.g. glTF doesn't populate poly fields)
104+
if (!refMesh->polyFaces.empty() && !mesh->polyFaces.empty()) {
105+
EXPECT_EQ(refMesh->polyFaces, mesh->polyFaces);
106+
EXPECT_EQ(refMesh->polyFaceSizes, mesh->polyFaceSizes);
107+
EXPECT_EQ(refMesh->polyTexcoordFaces, mesh->polyTexcoordFaces);
108+
}
103109
}
104110

105111
void compareBlendShapes(const BlendShape_const_p& refShapes, const BlendShape_const_p& shapes) {

momentum/test/math/mesh_test.cpp

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ TYPED_TEST(MeshTest, DefaultConstructor) {
3838
EXPECT_TRUE(mesh.texcoords.empty());
3939
EXPECT_TRUE(mesh.texcoord_faces.empty());
4040
EXPECT_TRUE(mesh.texcoord_lines.empty());
41+
EXPECT_TRUE(mesh.polyFaces.empty());
42+
EXPECT_TRUE(mesh.polyFaceSizes.empty());
43+
EXPECT_TRUE(mesh.polyTexcoordFaces.empty());
4144
}
4245

4346
// Test updateNormals with a tetrahedron
@@ -166,6 +169,11 @@ TYPED_TEST(MeshTest, Cast) {
166169

167170
mesh.confidence = {0.5, 0.6, 0.7};
168171

172+
// Add polygon data: a quad (v0,v1,v2,v3) + a triangle (v0,v1,v2)
173+
mesh.polyFaces = {0, 1, 2, 0, 0, 1, 2};
174+
mesh.polyFaceSizes = {4, 3};
175+
mesh.polyTexcoordFaces = {10, 11, 12, 13, 14, 15, 16};
176+
169177
// Cast to the same type (should be a copy)
170178
auto sameMesh = mesh.template cast<T>();
171179

@@ -179,6 +187,9 @@ TYPED_TEST(MeshTest, Cast) {
179187
EXPECT_TRUE(sameMesh.normals[i].isApprox(mesh.normals[i]));
180188
EXPECT_NEAR(sameMesh.confidence[i], mesh.confidence[i], Eps<T>(1e-5f, 1e-13));
181189
}
190+
EXPECT_EQ(sameMesh.polyFaces, mesh.polyFaces);
191+
EXPECT_EQ(sameMesh.polyFaceSizes, mesh.polyFaceSizes);
192+
EXPECT_EQ(sameMesh.polyTexcoordFaces, mesh.polyTexcoordFaces);
182193

183194
// Cast to the other type
184195
using OtherT = typename std::conditional<std::is_same<T, float>::value, double, float>::type;
@@ -195,6 +206,9 @@ TYPED_TEST(MeshTest, Cast) {
195206
// Use a more relaxed tolerance for float-to-double or double-to-float conversions
196207
EXPECT_NEAR(static_cast<T>(otherMesh.confidence[i]), mesh.confidence[i], Eps<T>(1e-4f, 1e-6));
197208
}
209+
EXPECT_EQ(otherMesh.polyFaces, mesh.polyFaces);
210+
EXPECT_EQ(otherMesh.polyFaceSizes, mesh.polyFaceSizes);
211+
EXPECT_EQ(otherMesh.polyTexcoordFaces, mesh.polyTexcoordFaces);
198212
}
199213

200214
// Test reset method
@@ -225,6 +239,10 @@ TYPED_TEST(MeshTest, Reset) {
225239

226240
mesh.texcoord_lines = {{0, 1}, {1, 2}, {2, 0}};
227241

242+
mesh.polyFaces = {0, 1, 2, 0, 0, 1, 2};
243+
mesh.polyFaceSizes = {4, 3};
244+
mesh.polyTexcoordFaces = {10, 11, 12, 13, 14, 15, 16};
245+
228246
// Reset the mesh
229247
mesh.reset();
230248

@@ -238,6 +256,9 @@ TYPED_TEST(MeshTest, Reset) {
238256
EXPECT_TRUE(mesh.texcoords.empty());
239257
EXPECT_TRUE(mesh.texcoord_faces.empty());
240258
EXPECT_TRUE(mesh.texcoord_lines.empty());
259+
EXPECT_TRUE(mesh.polyFaces.empty());
260+
EXPECT_TRUE(mesh.polyFaceSizes.empty());
261+
EXPECT_TRUE(mesh.polyTexcoordFaces.empty());
241262
}
242263

243264
// Test with a complex mesh
@@ -351,6 +372,9 @@ TYPED_TEST(MeshTest, EmptyMesh) {
351372
EXPECT_TRUE(castedMesh.texcoords.empty());
352373
EXPECT_TRUE(castedMesh.texcoord_faces.empty());
353374
EXPECT_TRUE(castedMesh.texcoord_lines.empty());
375+
EXPECT_TRUE(castedMesh.polyFaces.empty());
376+
EXPECT_TRUE(castedMesh.polyFaceSizes.empty());
377+
EXPECT_TRUE(castedMesh.polyTexcoordFaces.empty());
354378

355379
// Reset an empty mesh
356380
EXPECT_NO_THROW(mesh.reset());
@@ -462,3 +486,66 @@ TYPED_TEST(MeshTest, Lines) {
462486
mesh.reset();
463487
EXPECT_TRUE(mesh.lines.empty());
464488
}
489+
490+
// Test polygon face representation
491+
TYPED_TEST(MeshTest, PolygonFaces) {
492+
using T = TypeParam;
493+
using MeshType = typename TestFixture::MeshType;
494+
using Vector3 = Eigen::Vector3<T>;
495+
496+
MeshType mesh;
497+
498+
// Create a mesh with 5 vertices
499+
mesh.vertices = {
500+
Vector3(0, 0, 0), Vector3(1, 0, 0), Vector3(1, 1, 0), Vector3(0, 1, 0), Vector3(0.5, 0.5, 1)};
501+
502+
// Polygon representation: a quad (4 verts) + a triangle (3 verts)
503+
mesh.polyFaces = {0, 1, 2, 3, 0, 1, 4};
504+
mesh.polyFaceSizes = {4, 3};
505+
mesh.polyTexcoordFaces = {0, 1, 2, 3, 4, 5, 6};
506+
507+
// Verify sizes sum matches polyFaces length
508+
uint32_t totalSize = 0;
509+
for (auto s : mesh.polyFaceSizes) {
510+
totalSize += s;
511+
}
512+
EXPECT_EQ(totalSize, mesh.polyFaces.size());
513+
EXPECT_EQ(totalSize, mesh.polyTexcoordFaces.size());
514+
515+
// Cast and verify preservation
516+
using OtherT = typename std::conditional<std::is_same<T, float>::value, double, float>::type;
517+
auto otherMesh = mesh.template cast<OtherT>();
518+
EXPECT_EQ(otherMesh.polyFaces, mesh.polyFaces);
519+
EXPECT_EQ(otherMesh.polyFaceSizes, mesh.polyFaceSizes);
520+
EXPECT_EQ(otherMesh.polyTexcoordFaces, mesh.polyTexcoordFaces);
521+
522+
// Reset and verify cleared
523+
mesh.reset();
524+
EXPECT_TRUE(mesh.polyFaces.empty());
525+
EXPECT_TRUE(mesh.polyFaceSizes.empty());
526+
EXPECT_TRUE(mesh.polyTexcoordFaces.empty());
527+
}
528+
529+
// Test polygon faces with empty texcoord faces
530+
TYPED_TEST(MeshTest, PolygonFacesNoTexcoords) {
531+
using T = TypeParam;
532+
using MeshType = typename TestFixture::MeshType;
533+
using Vector3 = Eigen::Vector3<T>;
534+
535+
MeshType mesh;
536+
537+
mesh.vertices = {Vector3(0, 0, 0), Vector3(1, 0, 0), Vector3(0, 1, 0)};
538+
mesh.polyFaces = {0, 1, 2};
539+
mesh.polyFaceSizes = {3};
540+
// polyTexcoordFaces intentionally left empty
541+
542+
EXPECT_EQ(mesh.polyFaces.size(), 3u);
543+
EXPECT_EQ(mesh.polyFaceSizes.size(), 1u);
544+
EXPECT_TRUE(mesh.polyTexcoordFaces.empty());
545+
546+
// Cast preserves the empty texcoord faces
547+
auto castedMesh = mesh.template cast<T>();
548+
EXPECT_EQ(castedMesh.polyFaces, mesh.polyFaces);
549+
EXPECT_EQ(castedMesh.polyFaceSizes, mesh.polyFaceSizes);
550+
EXPECT_TRUE(castedMesh.polyTexcoordFaces.empty());
551+
}

pymomentum/geometry/mesh_pybind.cpp

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,52 @@ namespace pymomentum {
2929

3030
namespace {
3131

32+
void validateAndSetPolyData(
33+
mm::Mesh& mesh,
34+
std::vector<uint32_t> poly_faces,
35+
std::vector<uint32_t> poly_face_sizes,
36+
std::vector<uint32_t> poly_texcoord_faces,
37+
int nVerts,
38+
int nTextureCoords) {
39+
MT_THROW_IF(
40+
poly_faces.empty() != poly_face_sizes.empty(),
41+
"poly_faces and poly_face_sizes must both be empty or both be non-empty");
42+
if (!poly_face_sizes.empty()) {
43+
uint32_t totalSize = 0;
44+
for (const auto s : poly_face_sizes) {
45+
MT_THROW_IF(s < 3, "poly_face_sizes entries must be >= 3, got {}", s);
46+
totalSize += s;
47+
}
48+
MT_THROW_IF(
49+
totalSize != poly_faces.size(),
50+
"poly_face_sizes sum ({}) must equal poly_faces length ({})",
51+
totalSize,
52+
poly_faces.size());
53+
for (const auto idx : poly_faces) {
54+
MT_THROW_IF(
55+
idx >= static_cast<uint32_t>(nVerts),
56+
"poly_faces index ({}) exceeded vertex count ({})",
57+
idx,
58+
nVerts);
59+
}
60+
MT_THROW_IF(
61+
!poly_texcoord_faces.empty() && poly_texcoord_faces.size() != poly_faces.size(),
62+
"poly_texcoord_faces must be empty or same length as poly_faces ({} vs {})",
63+
poly_texcoord_faces.size(),
64+
poly_faces.size());
65+
for (const auto idx : poly_texcoord_faces) {
66+
MT_THROW_IF(
67+
idx >= static_cast<uint32_t>(nTextureCoords),
68+
"poly_texcoord_faces index ({}) exceeded texcoord count ({})",
69+
idx,
70+
nTextureCoords);
71+
}
72+
}
73+
mesh.polyFaces = std::move(poly_faces);
74+
mesh.polyFaceSizes = std::move(poly_face_sizes);
75+
mesh.polyTexcoordFaces = std::move(poly_texcoord_faces);
76+
}
77+
3278
mm::Mesh createMesh(
3379
const py::array_t<float>& vertices,
3480
const py::array_t<int>& faces,
@@ -38,7 +84,10 @@ mm::Mesh createMesh(
3884
const std::vector<float>& confidence,
3985
std::optional<py::array_t<float>> texcoords,
4086
std::optional<py::array_t<int>> texcoord_faces,
41-
const std::vector<std::vector<int32_t>>& texcoord_lines) {
87+
const std::vector<std::vector<int32_t>>& texcoord_lines,
88+
std::vector<uint32_t> poly_faces,
89+
std::vector<uint32_t> poly_face_sizes,
90+
std::vector<uint32_t> poly_texcoord_faces) {
4291
mm::Mesh mesh;
4392
MT_THROW_IF(vertices.ndim() != 2, "vertices must be a 2D array");
4493
MT_THROW_IF(vertices.shape(1) != 3, "vertices must have size n x 3");
@@ -110,6 +159,14 @@ mm::Mesh createMesh(
110159

111160
mesh.texcoord_lines = texcoord_lines;
112161

162+
validateAndSetPolyData(
163+
mesh,
164+
std::move(poly_faces),
165+
std::move(poly_face_sizes),
166+
std::move(poly_texcoord_faces),
167+
static_cast<int>(nVerts),
168+
nTextureCoords);
169+
113170
return mesh;
114171
}
115172

@@ -136,6 +193,9 @@ void registerMeshBindings(py::class_<mm::Mesh>& meshClass) {
136193
:param texcoords: Optional n x 2 array of texture coordinates.
137194
:param texcoord_faces: Optional n x 3 array of triangles in the texture map. Each triangle corresponds to a triangle on the mesh, but indices should refer to the texcoord array.
138195
:param texcoord_lines: Optional list of lines, where each line is a list of texture coordinate indices.
196+
:param poly_faces: Optional list of packed polygon face vertex indices (all polygons concatenated). The triangulated ``faces`` parameter is always required; polygon data is optional and preserves original topology.
197+
:param poly_face_sizes: Optional list of vertex counts per polygon face.
198+
:param poly_texcoord_faces: Optional list of packed polygon face texture coordinate indices (same layout as poly_faces).
139199
)",
140200
py::arg("vertices"),
141201
py::arg("faces"),
@@ -146,7 +206,10 @@ void registerMeshBindings(py::class_<mm::Mesh>& meshClass) {
146206
py::arg("confidence") = std::vector<float>{},
147207
py::arg("texcoords") = std::optional<py::array_t<float>>{},
148208
py::arg("texcoord_faces") = std::optional<py::array_t<int>>{},
149-
py::arg("texcoord_lines") = std::vector<std::vector<int32_t>>{})
209+
py::arg("texcoord_lines") = std::vector<std::vector<int32_t>>{},
210+
py::arg("poly_faces") = std::vector<uint32_t>{},
211+
py::arg("poly_face_sizes") = std::vector<uint32_t>{},
212+
py::arg("poly_texcoord_faces") = std::vector<uint32_t>{})
150213
.def_property_readonly(
151214
"n_vertices",
152215
[](const mm::Mesh& mesh) { return mesh.vertices.size(); },
@@ -188,6 +251,24 @@ void registerMeshBindings(py::class_<mm::Mesh>& meshClass) {
188251
"texcoord_lines",
189252
&mm::Mesh::texcoord_lines,
190253
"Texture coordinate indices for each line. ")
254+
.def_readonly(
255+
"poly_faces",
256+
&mm::Mesh::polyFaces,
257+
"Packed polygon face vertex indices (all polygons concatenated back-to-back). "
258+
"This is optional — the triangulated :attr:`faces` field is always required.")
259+
.def_readonly(
260+
"poly_face_sizes",
261+
&mm::Mesh::polyFaceSizes,
262+
"Number of vertices in each polygon face. May be empty if polygon data is not available.")
263+
.def_readonly(
264+
"poly_texcoord_faces",
265+
&mm::Mesh::polyTexcoordFaces,
266+
"Packed polygon face texture coordinate indices (same layout as :attr:`poly_faces`). "
267+
"May be empty if texture coordinates are not available.")
268+
.def_property_readonly(
269+
"n_poly_faces",
270+
[](const mm::Mesh& mesh) { return mesh.polyFaceSizes.size(); },
271+
":return: The number of polygon faces in the mesh.")
191272
.def(
192273
"self_intersections",
193274
[](const mm::Mesh& mesh) {
@@ -204,12 +285,13 @@ void registerMeshBindings(py::class_<mm::Mesh>& meshClass) {
204285
})
205286
.def("__repr__", [](const mm::Mesh& m) {
206287
return fmt::format(
207-
"Mesh(vertices={}, faces={}, has_normals={}, has_colors={}, has_texcoords={})",
288+
"Mesh(vertices={}, faces={}, has_normals={}, has_colors={}, has_texcoords={}, poly_faces={})",
208289
m.vertices.size(),
209290
m.faces.size(),
210291
!m.normals.empty() ? "True" : "False",
211292
!m.colors.empty() ? "True" : "False",
212-
!m.texcoords.empty() ? "True" : "False");
293+
!m.texcoords.empty() ? "True" : "False",
294+
m.polyFaceSizes.size());
213295
});
214296
}
215297

0 commit comments

Comments
 (0)