diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index fab37165..6a220869 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -690,6 +690,91 @@ static std::vector compactAiMesh(aiMesh* aiM) 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(si + 1); + std::vector 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(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, + const std::vector& 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 = "") { @@ -841,7 +926,14 @@ static aiScene* buildAiScene(const Ogre::Entity* entity) 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 remap = compactAiMesh(aiM); + remapAiMeshMorphTargets(aiM, remap, preCompactCount); } // --- Animations --- diff --git a/src/MeshImporterExporter_test.cpp b/src/MeshImporterExporter_test.cpp index d98f54cc..6905fc5b 100644 --- a/src/MeshImporterExporter_test.cpp +++ b/src/MeshImporterExporter_test.cpp @@ -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)