diff --git a/src/AnimationControlController.cpp b/src/AnimationControlController.cpp index bd14341d..6cf559a6 100644 --- a/src/AnimationControlController.cpp +++ b/src/AnimationControlController.cpp @@ -899,6 +899,56 @@ QVariantList AnimationControlController::allBoneRows() const return rows; } +QVariantList AnimationControlController::allMorphRows() const +{ + QVariantList rows; + auto* sel = SelectionSet::getSingleton(); + if (!sel) return rows; + const auto entities = sel->getResolvedEntities(); + if (entities.isEmpty() || !entities.first()) return rows; + Ogre::Entity* entity = entities.first(); + if (!entity) return rows; + Ogre::MeshPtr mesh = entity->getMesh(); + if (!mesh) return rows; + const Ogre::PoseList& poseList = mesh->getPoseList(); + if (poseList.empty()) return rows; + + // Each pose has a matching `Ogre::Animation` (see MeshProcessor: + // import-time one-animation-per-pose pattern). Read the keyframe + // times off the animation's VAT_POSE track; in A1 every animation + // carries a single t=0 keyframe, but future slices may add more. + for (const Ogre::Pose* pose : poseList) { + if (!pose) continue; + const Ogre::String poseName = pose->getName(); + if (poseName.empty()) continue; + + QVariantList keyTimes; + if (mesh->hasAnimation(poseName)) { + // The importer (MeshProcessor) groups same-named poses + // across submeshes into a single Animation with one + // VAT_POSE track per submesh. Pull only the track + // matching this pose's target handle — otherwise a + // shape that appears on body + head would show its + // diamonds twice in the dope sheet. + Ogre::Animation* anim = mesh->getAnimation(poseName); + const unsigned short handle = pose->getTarget(); + if (anim->hasVertexTrack(handle)) { + Ogre::VertexAnimationTrack* track = anim->getVertexTrack(handle); + if (track && track->getAnimationType() == Ogre::VAT_POSE) { + for (unsigned short i = 0; i < track->getNumKeyFrames(); ++i) + keyTimes.append(static_cast(track->getKeyFrame(i)->getTime())); + } + } + } + + QVariantMap row; + row[QStringLiteral("name")] = QString::fromStdString(poseName); + row[QStringLiteral("keyTimes")] = keyTimes; + rows.append(row); + } + return rows; +} + bool AnimationControlController::moveKeyframe(const QString& boneName, double oldTime, double newTime) { diff --git a/src/AnimationControlController.h b/src/AnimationControlController.h index 177fd960..9eccae33 100644 --- a/src/AnimationControlController.h +++ b/src/AnimationControlController.h @@ -196,6 +196,15 @@ class AnimationControlController : public QObject /// Returns an empty list when no animation is selected. Q_INVOKABLE QVariantList allBoneRows() const; + /// Slice A5: enumerate morph-target tracks for the selected + /// entity so the dope sheet can render them alongside bone + /// tracks. Each entry: `{ name: QString, keyTimes: [double] }`. + /// In A1's importer-emitted Animations, every pose's track has + /// a single keyframe at t=0; future slices may add per-time + /// keys when authoring lands. Returns empty when there's no + /// selection or the entity has no morph targets. + Q_INVOKABLE QVariantList allMorphRows() const; + /// Move a keyframe on `boneName`'s track from `oldTime` to `newTime` /// (both seconds). Match tolerance is 1 ms — same as the existing /// keyframe-tick comparison. Refuses the move if `newTime` collides diff --git a/src/AnimationControlController_test.cpp b/src/AnimationControlController_test.cpp index 53ac1ce3..4707cdf5 100644 --- a/src/AnimationControlController_test.cpp +++ b/src/AnimationControlController_test.cpp @@ -15,6 +15,13 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include class AnimationControlControllerTest : public ::testing::Test { protected: @@ -1763,3 +1770,70 @@ TEST_F(AnimationControlControllerTest, DeleteKeyframeIsUndoableViaQUndoStack) { app->processEvents(); EXPECT_EQ(track->getNumKeyFrames(), countBefore - 1); } + +// ── Slice A5: allMorphRows for dope sheet ───────────────────────────────── + +TEST_F(AnimationControlControllerTest, AllMorphRowsEmptyWhenNoSelection) { + SelectionSet::getSingleton()->clear(); + auto* ctrl = AnimationControlController::instance(); + EXPECT_TRUE(ctrl->allMorphRows().isEmpty()); +} + +TEST_F(AnimationControlControllerTest, AllMorphRowsEmptyForMeshWithoutPoses) { + // createAnimatedTestEntity has bones but no morph targets; + // allMorphRows must return an empty list (not crash). + ASSERT_TRUE(canLoadMeshFiles()); + Ogre::Entity* entity = setupAnimatedEntity("Morph_NoPoses"); + ASSERT_NE(entity, nullptr); + auto* ctrl = AnimationControlController::instance(); + EXPECT_TRUE(ctrl->allMorphRows().isEmpty()); +} + +TEST_F(AnimationControlControllerTest, AllMorphRowsListsPoseNamesAndKeyTimes) { + ASSERT_TRUE(canLoadMeshFiles()); + + // Build a fresh mesh + entity with two named poses + matching + // VAT_POSE animations — mirrors what MeshProcessor produces from + // an FBX blend shape. Pose targets handle 0 (shared-vertex + // submesh) to match createInMemoryTriangleMesh's layout. + auto mesh = createInMemoryTriangleMesh("Morph_AllRows"); + ASSERT_NE(mesh, nullptr); + { + 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)); + } + const auto& poseList = mesh->getPoseList(); + for (unsigned short pi = 0; pi < poseList.size(); ++pi) { + Ogre::Animation* a = mesh->createAnimation(poseList[pi]->getName(), 0.0f); + auto* track = a->createVertexTrack(poseList[pi]->getTarget(), Ogre::VAT_POSE); + auto* kf = track->createVertexPoseKeyFrame(0.0f); + kf->addPoseReference(pi, 1.0f); + } + + auto* node = Manager::getSingleton()->addSceneNode("Morph_AllRows_Node"); + auto* entity = Manager::getSingleton()->getSceneMgr()->createEntity(node->getName(), mesh->getName()); + node->attachObject(entity); + SelectionSet::getSingleton()->selectOne(node); + app->processEvents(); + + auto* ctrl = AnimationControlController::instance(); + QVariantList rows = ctrl->allMorphRows(); + ASSERT_EQ(rows.size(), 2); + QSet names; + for (const QVariant& v : rows) { + const QVariantMap row = v.toMap(); + names.insert(row["name"].toString()); + // Each pose's Animation has a single keyframe at t=0. + const QVariantList times = row["keyTimes"].toList(); + ASSERT_EQ(times.size(), 1); + EXPECT_NEAR(times.first().toDouble(), 0.0, 1e-6); + } + EXPECT_TRUE(names.contains(QStringLiteral("JawOpen"))); + EXPECT_TRUE(names.contains(QStringLiteral("Smile"))); +} + +