Skip to content
Merged
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
38 changes: 38 additions & 0 deletions src/PoseLibrary.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,44 @@
return true;
}

bool PoseLibrary::applyPoseMasked(Ogre::Entity* entity,

Check warning on line 151 in src/PoseLibrary.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This function should be declared "const".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ46Yne0bgdyH0emsjfB&open=AZ46Yne0bgdyH0emsjfB&pullRequest=608
const QString& name,
const QSet<QString>& boneFilter)
{
assertMainThread();
if (!entity || name.isEmpty()) return false;
auto storeIt = m_byEntity.constFind(entity);
if (storeIt == m_byEntity.constEnd()) return false;
auto poseIt = storeIt->byName.constFind(name);
if (poseIt == storeIt->byName.constEnd()) return false;

auto* skel = skeletonOf(entity);

Check warning on line 162 in src/PoseLibrary.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make the type of this variable a pointer-to-const. The current type of "skel" is "class Ogre::SkeletonInstance *".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ46Yne0bgdyH0emsjfC&open=AZ46Yne0bgdyH0emsjfC&pullRequest=608
if (!skel) return false;

int boneCount = 0;
int filteredOut = 0;
int missingBones = 0;
for (auto it = poseIt->cbegin(); it != poseIt->cend(); ++it) {
// Filter check first — names not in the mask are skipped
// without consulting the skeleton, so "applies only to the
// jaw" is a single hash lookup per pose entry.
if (!boneFilter.contains(it.key())) { ++filteredOut; continue; }
const std::string boneName = it.key().toStdString();
if (!skel->hasBone(boneName)) { ++missingBones; continue; }
Ogre::Bone* bone = skel->getBone(boneName);
if (!bone) { ++missingBones; continue; }
bone->setPosition(it.value().translate);
bone->setOrientation(it.value().rotation);
bone->setScale(it.value().scale);
++boneCount;
}

SentryReporter::addBreadcrumb("scene.anim.pose",
QStringLiteral("apply pose '%1' masked (%2 bones, %3 filtered, %4 missing)")
.arg(name).arg(boneCount).arg(filteredOut).arg(missingBones));
return true;
}

bool PoseLibrary::deletePose(Ogre::Entity* entity, const QString& name)
{
assertMainThread();
Expand Down
13 changes: 13 additions & 0 deletions src/PoseLibrary.h
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ The MIT License
#include <QHash>
#include <QObject>
#include <QQmlEngine>
#include <QSet>
#include <QString>
#include <QStringList>
#include <QtQml/qqmlregistration.h>
Expand Down Expand Up @@ -76,6 +77,18 @@ class PoseLibrary : public QObject
/// Returns false when the pose name isn't found on `entity`.
bool applyPose(Ogre::Entity* entity, const QString& name);

/// Apply a saved pose to a SUBSET of the entity's bones —
/// only bones whose names appear in `boneFilter` are touched.
/// Use case: apply a facial expression without disturbing the
/// body pose, or apply an arm gesture without re-posing the
/// legs. Empty `boneFilter` is treated as "no bones at all"
/// (matches the strict-filter interpretation; pass the full
/// `applyPose` for "everything"). Returns false when the pose
/// name isn't found.
bool applyPoseMasked(Ogre::Entity* entity,
const QString& name,
const QSet<QString>& boneFilter);

/// Drop a saved pose. Returns false when the name doesn't exist.
bool deletePose(Ogre::Entity* entity, const QString& name);

Expand Down
65 changes: 65 additions & 0 deletions src/PoseLibrary_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,71 @@ TEST_F(PoseLibrarySceneTest, LoadLibraryWipesExistingPosesFirst) {
EXPECT_FALSE(lib->hasPose(entity, QStringLiteral("InMemoryOnly")));
}

// ─── Slice D5 — apply-with-mask ──────────────────────────────────────

TEST_F(PoseLibrarySceneTest, ApplyPoseMaskedTouchesOnlyListedBones) {
Ogre::Entity* entity = createAnimatedTestEntity("PoseLib_Masked");
ASSERT_NE(entity, nullptr);
auto* skel = entity->getSkeleton();
auto* lib = PoseLibrary::instance();

// Need at least 2 bones to verify the mask actually filters.
ASSERT_GE(skel->getNumBones(), 2);

// Save a pose where every bone is at +X=7.
for (unsigned short i = 0; i < skel->getNumBones(); ++i)
skel->getBone(i)->setPosition(Ogre::Vector3(7, 0, 0));
ASSERT_TRUE(lib->savePose(entity, QStringLiteral("AllSeven")));

// Move every bone back to origin so apply has something to do.
for (unsigned short i = 0; i < skel->getNumBones(); ++i)
skel->getBone(i)->setPosition(Ogre::Vector3::ZERO);

// Mask: only bone[0]. Apply should touch bone[0] only.
QSet<QString> mask;
mask.insert(QString::fromStdString(skel->getBone(0)->getName()));
EXPECT_TRUE(lib->applyPoseMasked(entity, QStringLiteral("AllSeven"), mask));

EXPECT_FLOAT_EQ(skel->getBone(0)->getPosition().x, 7.0f);
// Bone 1 wasn't in the mask — must still be at origin.
EXPECT_FLOAT_EQ(skel->getBone(1)->getPosition().x, 0.0f);
}

TEST_F(PoseLibrarySceneTest, ApplyPoseMaskedRejectsUnknownPoseAndMissingEntity) {
Ogre::Entity* entity = createAnimatedTestEntity("PoseLib_MaskedReject");
ASSERT_NE(entity, nullptr);
auto* lib = PoseLibrary::instance();

QSet<QString> empty;
// Unknown pose → false (even with empty mask).
EXPECT_FALSE(lib->applyPoseMasked(entity, QStringLiteral("NoSuch"), empty));
// Null entity → false.
EXPECT_FALSE(lib->applyPoseMasked(nullptr, QStringLiteral("Any"), empty));
// Empty pose name → false.
EXPECT_FALSE(lib->applyPoseMasked(entity, QString(), empty));
}

TEST_F(PoseLibrarySceneTest, ApplyPoseMaskedWithEmptyMaskAppliesNothing) {
Ogre::Entity* entity = createAnimatedTestEntity("PoseLib_MaskedEmpty");
ASSERT_NE(entity, nullptr);
auto* skel = entity->getSkeleton();
auto* lib = PoseLibrary::instance();

// Save a pose with bone[0] at +Z=4.
skel->getBone(0)->setPosition(Ogre::Vector3(0, 0, 4));
ASSERT_TRUE(lib->savePose(entity, QStringLiteral("Reference")));

// Move bone back to origin.
skel->getBone(0)->setPosition(Ogre::Vector3::ZERO);

// Empty mask = nothing to apply. Returns true (pose found
// successfully) but bone stays at origin — empty mask is the
// documented "no bones" interpretation, not "all bones".
QSet<QString> empty;
EXPECT_TRUE(lib->applyPoseMasked(entity, QStringLiteral("Reference"), empty));
EXPECT_FLOAT_EQ(skel->getBone(0)->getPosition().z, 0.0f);
}

// ─── Slice D4 — bone name flip + mirror pose ─────────────────────────

TEST(PoseLibraryStandalone, FlipBoneName_MixamoLowercaseSuffix) {
Expand Down
Loading