Skip to content
94 changes: 93 additions & 1 deletion src/MeshImporterExporter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,91 @@
return remap;
}

// Slice A4a: emit per-pose aiAnimMesh entries on an aiMesh, sourced
// from the Ogre mesh's pose list filtered to the matching submesh.
// `aiAnimMesh::mVertices` carries absolute deformed positions (base
// + per-vertex delta from `Ogre::Pose::getVertexOffsets()`); the
// vertex layout matches `aiM->mVertices` at the time of the call,
// so the caller must run this BEFORE `compactAiMesh` and then call
// `remapAiMeshMorphTargets` after to keep the arrays parallel.
//
// `new` allocations cross the Assimp C-API ownership boundary
// (aiScene's dtor frees mAnimMeshes), so smart pointers don't fit
// here — same convention every other aiScene field uses in this
// file.
static void attachMorphTargetsToAiMesh(aiMesh* aiM,
const Ogre::MeshPtr& mesh,
const Ogre::SubMesh* subMesh,
unsigned int si)
{
if (!aiM || aiM->mNumVertices == 0) return;
const Ogre::PoseList& poseList = mesh->getPoseList();
if (poseList.empty()) return;

const unsigned short targetHandle = subMesh->useSharedVertices
? 0
: static_cast<unsigned short>(si + 1);
std::vector<Ogre::Pose*> submeshPoses;
for (Ogre::Pose* p : poseList) {
if (p && p->getTarget() == targetHandle)
submeshPoses.push_back(p);
}
if (submeshPoses.empty()) return;

const unsigned int preCompactCount = aiM->mNumVertices;
aiM->mNumAnimMeshes = static_cast<unsigned int>(submeshPoses.size());
aiM->mAnimMeshes = new aiAnimMesh*[aiM->mNumAnimMeshes]; // NOSONAR — Assimp owns
for (size_t pi = 0; pi < submeshPoses.size(); ++pi) {
const Ogre::Pose* p = submeshPoses[pi];
auto* am = new aiAnimMesh(); // NOSONAR — Assimp owns
aiM->mAnimMeshes[pi] = am;
am->mName = aiString(p->getName());
am->mNumVertices = preCompactCount;
am->mVertices = new aiVector3D[preCompactCount]; // NOSONAR — Assimp owns
// Seed with base positions (vertices the pose doesn't touch
// keep their base location).
for (unsigned int v = 0; v < preCompactCount; ++v)
am->mVertices[v] = aiM->mVertices[v];
// Apply per-vertex deltas. Pose offsets are keyed by vertex
// index in submesh-local pre-compact space.
for (const auto& [vi, delta] : p->getVertexOffsets()) {
if (vi >= preCompactCount) continue;
am->mVertices[vi].x += delta.x;
am->mVertices[vi].y += delta.y;
am->mVertices[vi].z += delta.z;
}
// Non-zero default weight so post-process steps that filter
// "inactive" anim meshes don't drop the entry. Runtime weight
// is driven by the consuming app.
am->mWeight = 1.0f;
}
}

// Slice A4a: after compactAiMesh has reordered/deduped aiM's
// vertices, apply the same remap to every attached aiAnimMesh so
// the morph-target vertex arrays stay parallel to aiM->mVertices.
static void remapAiMeshMorphTargets(aiMesh* aiM,

Check warning on line 756 in src/MeshImporterExporter.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make the type of this parameter a pointer-to-const. The current type of "aiM" is "struct aiMesh *".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ42VjRDHyptNfSkIVqp&open=AZ42VjRDHyptNfSkIVqp&pullRequest=573
const std::vector<unsigned int>& remap,
unsigned int preCompactCount)
{
if (!aiM || aiM->mNumAnimMeshes == 0) return;
if (remap.empty() || preCompactCount == 0) return;
if (aiM->mNumVertices == preCompactCount) return; // nothing to compact

for (unsigned int ai = 0; ai < aiM->mNumAnimMeshes; ++ai) {
aiAnimMesh* am = aiM->mAnimMeshes[ai];
if (!am || !am->mVertices) continue;
auto* compact = new aiVector3D[aiM->mNumVertices]; // NOSONAR — Assimp owns
for (unsigned int oldIdx = 0; oldIdx < preCompactCount; ++oldIdx) {
if (remap[oldIdx] == UINT_MAX) continue;
compact[remap[oldIdx]] = am->mVertices[oldIdx];
}
delete[] am->mVertices; // NOSONAR — we own this allocation
am->mVertices = compact;
am->mNumVertices = aiM->mNumVertices;
}
}

// Convert an Ogre skeleton animation to an aiAnimation
static aiAnimation* buildAiAnimation(Ogre::Animation* ogreAnim, const std::string& bonePrefix = "")
{
Expand Down Expand Up @@ -841,7 +926,14 @@
if (hasSkeleton)
assignBoneWeights(aiM, subMesh, mesh, skeleton, boneHandleToName);

compactAiMesh(aiM);
// Morph targets / blend shapes (slice A4a). Pulled into helpers
// below to keep this loop's nesting level reasonable and the
// "build then compact" two-pass structure obvious.
const unsigned int preCompactCount = aiM->mNumVertices;
attachMorphTargetsToAiMesh(aiM, mesh, subMesh, si);

const std::vector<unsigned int> remap = compactAiMesh(aiM);
remapAiMeshMorphTargets(aiM, remap, preCompactCount);
}

// --- Animations ---
Expand Down
100 changes: 100 additions & 0 deletions src/MeshImporterExporter_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1655,6 +1655,106 @@ TEST_F(SceneSaveLoadTest, Exporter_DefaultNoStripAnimations_PreservesAnimations)
EXPECT_EQ(reimportedEntity->getMesh()->getSkeleton()->getNumAnimations(), originalAnimCount);
}

// ─── Morph A4a: glTF morph-target export round-trip ───────────────

TEST_F(SceneSaveLoadTest, Exporter_GltfWritesMorphTargetsIntoFile)
{
// Build an entity with two named morph targets — same shape the
// FBX importer produces (per-submesh Ogre::Pose + matching
// VAT_POSE Animation). The glTF exporter should walk every
// submesh's pose list, package them as aiAnimMesh entries on
// each aiMesh, and Assimp's glTF writer should land them as
// primitive.targets in the file. Asserting on the file directly
// (not via a full reimport) keeps this test focused on our
// exporter contract; reimport behavior depends on Assimp's
// glTF reader which has its own edge cases.
auto mesh = createInMemoryTriangleMesh("morph_rt_mesh");
ASSERT_NE(mesh, nullptr);

// Two poses targeting the shared vertex data (handle 0).
// createInMemoryTriangleMesh sets useSharedVertices=true on its
// only submesh, so morph poses live at handle 0, not handle 1.
{
Ogre::Pose* p = mesh->createPose(0, "JawOpen");
p->addVertex(0, Ogre::Vector3(0, -0.1f, 0));
}
{
Ogre::Pose* p = mesh->createPose(0, "Smile");
p->addVertex(1, Ogre::Vector3(0.05f, 0.02f, 0));
p->addVertex(2, Ogre::Vector3(-0.05f, 0.02f, 0));
}
// Matching animations so a reimport can drive them via
// AnimationState weights (same pattern MeshProcessor produces).
const auto& poseList = mesh->getPoseList();
for (unsigned short pi = 0; pi < poseList.size(); ++pi) {
Ogre::Animation* anim = mesh->createAnimation(poseList[pi]->getName(), 0.0f);
auto* track = anim->createVertexTrack(poseList[pi]->getTarget(), Ogre::VAT_POSE);
auto* kf = track->createVertexPoseKeyFrame(0.0f);
kf->addPoseReference(pi, 1.0f);
}

// The exporter looks up the entity via the SceneNode's name
// (see MeshImporterExporter::exporter line 2277). Use
// Manager::addSceneNode to get a stable name + matching entity.
auto* node = Manager::getSingleton()->addSceneNode("morph_rt_ent");
auto* sceneMgr = Manager::getSingleton()->getSceneMgr();
auto* entity = sceneMgr->createEntity(node->getName(), mesh->getName());
node->attachObject(entity);
ASSERT_EQ(mesh->getPoseList().size(), 2u);

QTemporaryDir tmpDir;
ASSERT_TRUE(tmpDir.isValid());
const QString outFile = tmpDir.path() + "/morph_rt.gltf";

const int result = MeshImporterExporter::exporter(node, outFile, "glTF 2.0 (*.gltf)");
ASSERT_EQ(result, 0);
ASSERT_TRUE(QFileInfo::exists(outFile));

// Parse the .gltf JSON and assert structurally: at least one
// mesh primitive has a non-empty `targets` array. Asserting on
// the file structure (not on a full reimport) keeps this test
// focused on the *exporter* contract we control — Assimp's
// reader pipeline has its own edge cases that belong with a
// separate end-to-end test.
//
// Note: Assimp 6.0's glTF2 exporter doesn't reliably emit
// `mesh.extras.targetNames` even when `aiAnimMesh::mName` is
// set; downstream tools that need names can recover them from
// our JSON sidecar (future slice) or by re-binding from the
// source mesh. The morph *geometry* — the value of this PR —
// is what we lock down here.
QFile gltf(outFile);
ASSERT_TRUE(gltf.open(QIODevice::ReadOnly));
QJsonDocument doc = QJsonDocument::fromJson(gltf.readAll());
gltf.close();
ASSERT_TRUE(doc.isObject());

const QJsonArray meshes = doc.object().value("meshes").toArray();
ASSERT_GT(meshes.size(), 0);

int primitivesWithTargets = 0;
int totalTargets = 0;
for (const QJsonValue& meshVal : meshes) {
const QJsonArray prims = meshVal.toObject().value("primitives").toArray();
for (const QJsonValue& primVal : prims) {
const QJsonArray targets = primVal.toObject().value("targets").toArray();
if (!targets.isEmpty()) {
primitivesWithTargets++;
totalTargets += targets.size();
}
}
}
EXPECT_GT(primitivesWithTargets, 0)
<< "Expected at least one mesh primitive with a non-empty `targets` array";
EXPECT_EQ(totalTargets, 2)
<< "Both morph targets should land in primitive.targets";

SelectionSet::getSingleton()->clearList();
Manager::getSingleton()->destroySceneNode(node);
Ogre::MeshManager::getSingleton().remove(mesh);
mesh.reset();
}

// ─── gltf short-alias format tests ─────────────────────────────────

TEST(MeshImporterExporterStandaloneTest, FormatFileURI_GltfShortAlias)
Expand Down
Loading