Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 53 additions & 16 deletions momentum/io/fbx/fbx_io.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,56 @@ void addMetaData(::fbxsdk::FbxNode* skeletonRootNode, const Character& character
}
}

void writePolygonsToFbxMesh(const Mesh& mesh, ::fbxsdk::FbxMesh* lMesh) {
if (!mesh.polyFaces.empty() && !mesh.polyFaceSizes.empty()) {
// Write original polygon topology
uint32_t offset = 0;
for (const auto polySize : mesh.polyFaceSizes) {
lMesh->BeginPolygon();
for (uint32_t i = 0; i < polySize; ++i) {
lMesh->AddPolygon(mesh.polyFaces[offset + i]);
}
lMesh->EndPolygon();
offset += polySize;
}
} else {
// Fall back to triangulated faces
for (const auto& face : mesh.faces) {
lMesh->BeginPolygon();
for (int i = 0; i < 3; i++) {
lMesh->AddPolygon(face[i]);
}
lMesh->EndPolygon();
}
}
}

void writeTextureUVIndicesToFbxMesh(
const Mesh& mesh,
::fbxsdk::FbxMesh* lMesh,
fbxsdk::FbxLayerElement::EType uvType) {
if (!mesh.polyFaces.empty() && !mesh.polyFaceSizes.empty()) {
uint32_t offset = 0;
int polyIdx = 0;
for (const auto polySize : mesh.polyFaceSizes) {
for (uint32_t i = 0; i < polySize; ++i) {
const uint32_t texIdx = mesh.polyTexcoordFaces.empty() ? mesh.polyFaces[offset + i]
: mesh.polyTexcoordFaces[offset + i];
lMesh->SetTextureUVIndex(polyIdx, i, texIdx, uvType);
}
offset += polySize;
polyIdx++;
}
} else {
for (int faceIdx = 0; faceIdx < static_cast<int>(mesh.texcoord_faces.size()); ++faceIdx) {
const auto& texcoords = mesh.texcoord_faces[faceIdx];
lMesh->SetTextureUVIndex(faceIdx, 0, texcoords[0], uvType);
lMesh->SetTextureUVIndex(faceIdx, 1, texcoords[1], uvType);
lMesh->SetTextureUVIndex(faceIdx, 2, texcoords[2], uvType);
}
}
}

void saveFbxCommon(
const filesystem::path& filename,
const Character& character,
Expand Down Expand Up @@ -687,7 +737,6 @@ void saveFbxCommon(
if (saveMesh && character.mesh != nullptr) {
// Add the mesh
const int numVertices = character.mesh.get()->vertices.size();
const int numFaces = character.mesh.get()->faces.size();
::fbxsdk::FbxNode* meshNode = ::fbxsdk::FbxNode::Create(scene, "body_mesh");
::fbxsdk::FbxMesh* lMesh = ::fbxsdk::FbxMesh::Create(scene, "mesh");
lMesh->SetControlPointCount(numVertices);
Expand All @@ -704,14 +753,7 @@ void saveFbxCommon(
lMesh->SetControlPointAt(point, normal, i);
}
// Add polygons to lMesh
for (int iFace = 0; iFace < numFaces; iFace++) {
lMesh->BeginPolygon();
for (int i = 0; i < 3; i++) { // We have tris for models. This could be extended for
// supporting Quads or npoly if needed.
lMesh->AddPolygon(character.mesh.get()->faces[iFace][i]);
}
lMesh->EndPolygon();
}
writePolygonsToFbxMesh(*character.mesh, lMesh);
lMesh->BuildMeshEdgeArray();
meshNode->SetNodeAttribute(lMesh);

Expand All @@ -733,13 +775,8 @@ void saveFbxCommon(
lMesh->AddTextureUV(::fbxsdk::FbxVector2(texcoords[0], 1.0f - texcoords[1]), uvType);
}

// Set UV indices for each face. We only have triangles.
int faceCount = 0;
for (const auto& texcoords : character.mesh->texcoord_faces) {
lMesh->SetTextureUVIndex(faceCount, 0, texcoords[0], uvType);
lMesh->SetTextureUVIndex(faceCount, 1, texcoords[1], uvType);
lMesh->SetTextureUVIndex(faceCount++, 2, texcoords[2], uvType);
}
// Set UV indices for each face.
writeTextureUVIndicesToFbxMesh(*character.mesh, lMesh, uvType);
}

// ---------------------------------------------
Expand Down
24 changes: 24 additions & 0 deletions momentum/io/fbx/openfbx_loader.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,27 @@ parseSkeleton(const ofbx::Object* sceneRoot, const std::string& skelRoot, Permis
return {skeleton, jointFbxNodes, locators, collision};
}

// Note: we intentionally omit reserve() here. This function is called once per sub-mesh in a loop,
// and reserve(size() + batchSize) defeats std::vector's geometric growth, causing O(K*N) copies
// instead of O(N) amortized. Letting the default 2x growth strategy handle it is more efficient.
void populatePolyFaceData(
Mesh& mesh,
const PolygonData& vertices,
size_t vertexOffset,
size_t textureCoordOffset) {
for (const auto& idx : vertices.indices) {
mesh.polyFaces.push_back(idx + static_cast<uint32_t>(vertexOffset));
}
for (size_t i = 0; i + 1 < vertices.offsets.size(); ++i) {
mesh.polyFaceSizes.push_back(vertices.offsets[i + 1] - vertices.offsets[i]);
}
if (!vertices.textureIndices.empty()) {
for (const auto& idx : vertices.textureIndices) {
mesh.polyTexcoordFaces.push_back(idx + static_cast<uint32_t>(textureCoordOffset));
}
}
}

void parseSkinnedModel(
const ofbx::Mesh* meshRoot,
const std::vector<const ofbx::Object*>& boneFbxNodes,
Expand Down Expand Up @@ -659,6 +680,9 @@ void parseSkinnedModel(
mesh.texcoord_faces.emplace_back(t + Eigen::Vector3i::Constant(textureCoordOffset));
}

// Populate polygon face data (preserves original topology)
populatePolyFaceData(mesh, vertices, vertexOffset, textureCoordOffset);

const auto* blendshapes = geometry->getBlendShape();
if (loadBlendShapes == LoadBlendShapes::Yes && blendshapes) {
if (!blendShape) {
Expand Down
6 changes: 6 additions & 0 deletions momentum/math/mesh.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ MeshT<T2> MeshT<T>::cast() const {
result.texcoords = this->texcoords;
result.texcoord_faces = this->texcoord_faces;
result.texcoord_lines = this->texcoord_lines;
result.polyFaces = this->polyFaces;
result.polyFaceSizes = this->polyFaceSizes;
result.polyTexcoordFaces = this->polyTexcoordFaces;
return result;
}

Expand All @@ -83,6 +86,9 @@ void MeshT<T>::reset() {
texcoords.clear();
texcoord_faces.clear();
texcoord_lines.clear();
polyFaces.clear();
polyFaceSizes.clear();
polyTexcoordFaces.clear();
}

template MeshT<float> MeshT<float>::cast<float>() const;
Expand Down
20 changes: 20 additions & 0 deletions momentum/math/mesh.h
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,26 @@ struct MeshT {
/// Maps each line to its corresponding texture coordinates
std::vector<std::vector<int32_t>> texcoord_lines;

/// Packed polygon face vertex indices (all polygons concatenated back-to-back).
///
/// For example, a quad followed by a triangle would be: [v0, v1, v2, v3, v4, v5, v6].
/// This is an optional, redundant representation — the triangulated @ref faces field is always
/// required and used by all downstream code. Polygon data preserves the original topology (quads,
/// n-gons) from source files like FBX.
std::vector<uint32_t> polyFaces;

/// Number of vertices in each polygon face.
///
/// For example, [4, 3] means the first polygon is a quad, the second is a triangle.
/// May be empty if polygon data is not available; see @ref polyFaces.
std::vector<uint32_t> polyFaceSizes;

/// Packed polygon face texture coordinate indices (same layout as polyFaces).
///
/// Uses the same polyFaceSizes array — polygon sizes are identical for geometry and texcoords.
/// May be empty if texture coordinates are not available; see @ref polyFaces.
std::vector<uint32_t> polyTexcoordFaces;

/// Compute vertex normals by averaging connected face normals.
///
/// This method calculates normals for each vertex by averaging the normals of all
Expand Down
6 changes: 6 additions & 0 deletions momentum/test/character/character_helpers_gtest.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ void compareMeshes(const Mesh_u& refMesh, const Mesh_u& mesh) {
refMesh->confidence, testing::Pointwise(testing::DoubleNear(0.0001), mesh->confidence));
EXPECT_THAT(
refMesh->texcoord_faces, testing::Pointwise(IntExactPointwise(), mesh->texcoord_faces));
// Only compare polygon data when both meshes have it (e.g. glTF doesn't populate poly fields)
if (!refMesh->polyFaces.empty() && !mesh->polyFaces.empty()) {
EXPECT_EQ(refMesh->polyFaces, mesh->polyFaces);
EXPECT_EQ(refMesh->polyFaceSizes, mesh->polyFaceSizes);
EXPECT_EQ(refMesh->polyTexcoordFaces, mesh->polyTexcoordFaces);
}
}

void compareBlendShapes(const BlendShape_const_p& refShapes, const BlendShape_const_p& shapes) {
Expand Down
87 changes: 87 additions & 0 deletions momentum/test/math/mesh_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ TYPED_TEST(MeshTest, DefaultConstructor) {
EXPECT_TRUE(mesh.texcoords.empty());
EXPECT_TRUE(mesh.texcoord_faces.empty());
EXPECT_TRUE(mesh.texcoord_lines.empty());
EXPECT_TRUE(mesh.polyFaces.empty());
EXPECT_TRUE(mesh.polyFaceSizes.empty());
EXPECT_TRUE(mesh.polyTexcoordFaces.empty());
}

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

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

// Add polygon data: a quad (v0,v1,v2,v3) + a triangle (v0,v1,v2)
mesh.polyFaces = {0, 1, 2, 0, 0, 1, 2};
mesh.polyFaceSizes = {4, 3};
mesh.polyTexcoordFaces = {10, 11, 12, 13, 14, 15, 16};

// Cast to the same type (should be a copy)
auto sameMesh = mesh.template cast<T>();

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

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

// Test reset method
Expand Down Expand Up @@ -225,6 +239,10 @@ TYPED_TEST(MeshTest, Reset) {

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

mesh.polyFaces = {0, 1, 2, 0, 0, 1, 2};
mesh.polyFaceSizes = {4, 3};
mesh.polyTexcoordFaces = {10, 11, 12, 13, 14, 15, 16};

// Reset the mesh
mesh.reset();

Expand All @@ -238,6 +256,9 @@ TYPED_TEST(MeshTest, Reset) {
EXPECT_TRUE(mesh.texcoords.empty());
EXPECT_TRUE(mesh.texcoord_faces.empty());
EXPECT_TRUE(mesh.texcoord_lines.empty());
EXPECT_TRUE(mesh.polyFaces.empty());
EXPECT_TRUE(mesh.polyFaceSizes.empty());
EXPECT_TRUE(mesh.polyTexcoordFaces.empty());
}

// Test with a complex mesh
Expand Down Expand Up @@ -351,6 +372,9 @@ TYPED_TEST(MeshTest, EmptyMesh) {
EXPECT_TRUE(castedMesh.texcoords.empty());
EXPECT_TRUE(castedMesh.texcoord_faces.empty());
EXPECT_TRUE(castedMesh.texcoord_lines.empty());
EXPECT_TRUE(castedMesh.polyFaces.empty());
EXPECT_TRUE(castedMesh.polyFaceSizes.empty());
EXPECT_TRUE(castedMesh.polyTexcoordFaces.empty());

// Reset an empty mesh
EXPECT_NO_THROW(mesh.reset());
Expand Down Expand Up @@ -462,3 +486,66 @@ TYPED_TEST(MeshTest, Lines) {
mesh.reset();
EXPECT_TRUE(mesh.lines.empty());
}

// Test polygon face representation
TYPED_TEST(MeshTest, PolygonFaces) {
using T = TypeParam;
using MeshType = typename TestFixture::MeshType;
using Vector3 = Eigen::Vector3<T>;

MeshType mesh;

// Create a mesh with 5 vertices
mesh.vertices = {
Vector3(0, 0, 0), Vector3(1, 0, 0), Vector3(1, 1, 0), Vector3(0, 1, 0), Vector3(0.5, 0.5, 1)};

// Polygon representation: a quad (4 verts) + a triangle (3 verts)
mesh.polyFaces = {0, 1, 2, 3, 0, 1, 4};
mesh.polyFaceSizes = {4, 3};
mesh.polyTexcoordFaces = {0, 1, 2, 3, 4, 5, 6};

// Verify sizes sum matches polyFaces length
uint32_t totalSize = 0;
for (auto s : mesh.polyFaceSizes) {
totalSize += s;
}
EXPECT_EQ(totalSize, mesh.polyFaces.size());
EXPECT_EQ(totalSize, mesh.polyTexcoordFaces.size());

// Cast and verify preservation
using OtherT = typename std::conditional<std::is_same<T, float>::value, double, float>::type;
auto otherMesh = mesh.template cast<OtherT>();
EXPECT_EQ(otherMesh.polyFaces, mesh.polyFaces);
EXPECT_EQ(otherMesh.polyFaceSizes, mesh.polyFaceSizes);
EXPECT_EQ(otherMesh.polyTexcoordFaces, mesh.polyTexcoordFaces);

// Reset and verify cleared
mesh.reset();
EXPECT_TRUE(mesh.polyFaces.empty());
EXPECT_TRUE(mesh.polyFaceSizes.empty());
EXPECT_TRUE(mesh.polyTexcoordFaces.empty());
}

// Test polygon faces with empty texcoord faces
TYPED_TEST(MeshTest, PolygonFacesNoTexcoords) {
using T = TypeParam;
using MeshType = typename TestFixture::MeshType;
using Vector3 = Eigen::Vector3<T>;

MeshType mesh;

mesh.vertices = {Vector3(0, 0, 0), Vector3(1, 0, 0), Vector3(0, 1, 0)};
mesh.polyFaces = {0, 1, 2};
mesh.polyFaceSizes = {3};
// polyTexcoordFaces intentionally left empty

EXPECT_EQ(mesh.polyFaces.size(), 3u);
EXPECT_EQ(mesh.polyFaceSizes.size(), 1u);
EXPECT_TRUE(mesh.polyTexcoordFaces.empty());

// Cast preserves the empty texcoord faces
auto castedMesh = mesh.template cast<T>();
EXPECT_EQ(castedMesh.polyFaces, mesh.polyFaces);
EXPECT_EQ(castedMesh.polyFaceSizes, mesh.polyFaceSizes);
EXPECT_TRUE(castedMesh.polyTexcoordFaces.empty());
}
Loading
Loading