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
188 changes: 184 additions & 4 deletions src/AnimationMerger.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2053,14 +2053,30 @@
Ogre::SkeletonInstance* skel = entity->getSkeleton();
if (!skel) return false;

// Ankle reference: bones resolving to the canonical foot roles (17/21).
// Ankle reference: bones resolving to the canonical foot roles (17/21) —
// but only those that are actually LOW. Mis-labeled rigs (#969: UniRig
// A-pose arms named "*Foot_1") put foot-named bones at chest height;
// averaging those drags the "foot band" into the torso and the centroid
// test reads the backplate/cloak instead of the toes (false backward →
// every generated clip contorts). Gate each candidate to the lowest
// quarter of the skeleton's height.
float loB = 0, hiB = 0;

Check warning on line 2063 in src/AnimationMerger.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define each identifier in a dedicated statement.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AaBfNpCeNULrS1B0acJh&open=AaBfNpCeNULrS1B0acJh&pullRequest=969
for (unsigned short i = 0; i < skel->getNumBones(); ++i) {
const float y = skel->getBone(i)->_getDerivedPosition().y;
if (i == 0) { loB = hiB = y; }

Check warning on line 2066 in src/AnimationMerger.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract the assignment to "hiB" from this expression.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AaBfNpCeNULrS1B0acJi&open=AaBfNpCeNULrS1B0acJi&pullRequest=969
loB = std::min(loB, y); hiB = std::max(hiB, y);
}
const float skelH = std::max(1e-6f, hiB - loB);
Ogre::Vector3 ankleSum = Ogre::Vector3::ZERO;
int ankles = 0;
for (unsigned short i = 0; i < skel->getNumBones(); ++i) {
Ogre::Bone* b = skel->getBone(i);
const int c = MotionInbetween::canonicalIndexForBone(
QString::fromStdString(b->getName()));
if (c == 17 || c == 21) { ankleSum += b->_getDerivedPosition(); ++ankles; }
if (c != 17 && c != 21) continue;

Check warning on line 2076 in src/AnimationMerger.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the init-statement to declare "c" inside the if statement.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AaBfNpCeNULrS1B0acJg&open=AaBfNpCeNULrS1B0acJg&pullRequest=969
const Ogre::Vector3 p = b->_getDerivedPosition();
if (p.y > loB + 0.25f * skelH) continue; // "foot" at chest height
ankleSum += p; ++ankles;
}
if (ankles == 0) return false;
const Ogre::Vector3 ankle = ankleSum / static_cast<float>(ankles);
Expand Down Expand Up @@ -4268,8 +4284,172 @@
if (!canonSeen[c]) { canonSeen[c] = 1; ++distinct; }
}
}
if (cmuLibraryHandedness)
compensateCanonicalHandedness(skel, boneToCanon);
// ── #969 name-vs-anatomy consistency (side swap + arm/leg guard) ──
// Two real-world failure modes on generated (UniRig/AutoRig) rigs:
// (a) bone names put "Left*" on the anatomical right (the side swap);
// (b) whole ARM chains carry LEG names ("LeftUpLeg_1" at chest height —
// the pre-fix UniRig labeler classified A-pose arms, which descend,
// as legs), so leg tracks drive the arms.
// Both are resolved GEOMETRICALLY from the bind pose: up = +Y, forward =
// ±Z from the caller's mesh-derived yaw180 facing, trueLeft = up × fwd.
// Template/library clips only (cmuLibraryHandedness) — this block
// REPLACES compensateCanonicalHandedness for that path: one composed
// permutation instead of two independent ones that could cancel (the
// old compensator was facing-blind raw world-X and read the FIRST bone
// per role, so mis-named chest-height "legs" fed it garbage). The mocap
// path (cmuLibraryHandedness=false) keeps its historical mapping.
if (cmuLibraryHandedness) {
const Ogre::Vector3 up(0, 1, 0);
const Ogre::Vector3 fwd(0, 0, yaw180 ? -1.0f : 1.0f);
const Ogre::Vector3 trueLeft = up.crossProduct(fwd);

// BIND pose positions — the current pose may be mid-animation
// (crossed limbs would flip the vote). Same reset the old
// compensator used on this exact path.
skel->reset(true);
skel->_updateTransforms();
std::vector<Ogre::Vector3> bonePos(static_cast<size_t>(nBones));
for (int i = 0; i < nBones; ++i)
bonePos[i] = skel->getBone(static_cast<unsigned short>(i))
->_getDerivedPosition();

// Hip reference + body height (for the arm/leg altitude bands).
int hipIdx = -1;
float loY = 0, hiY = 0;

Check warning on line 4318 in src/AnimationMerger.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define each identifier in a dedicated statement.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AaBfNpCeNULrS1B0acJl&open=AaBfNpCeNULrS1B0acJl&pullRequest=969
for (int i = 0; i < nBones; ++i) {
if (boneToCanon[i] == 0 && hipIdx < 0) hipIdx = i;
loY = i ? std::min(loY, bonePos[i].y) : bonePos[i].y;
hiY = i ? std::max(hiY, bonePos[i].y) : bonePos[i].y;
}
const float bodyH = std::max(1e-6f, hiY - loY);

// ---- (b) arm/leg altitude guard --------------------------------
// A LEG-role bone bound well ABOVE the hips is a mis-named arm
// segment: strip it (remembering its segment for the rescue below).
// An ARM-role bone well BELOW the hips is stripped outright.
// seg: 0 = upper limb root, 1 = middle, 2 = tip; -1 = buttock/collar.
struct Rescue { int bone; int seg; };
std::vector<Rescue> rescue;
if (hipIdx >= 0) {
const float hipY = bonePos[hipIdx].y;
for (int i = 0; i < nBones; ++i) {
const int c = boneToCanon[i];
if (c < 0) continue;

Check failure on line 4337 in src/AnimationMerger.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not nest more than 3 if|for|do|while|switch statements.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AaBfRNCLOmx5S02lzSDq&open=AaBfRNCLOmx5S02lzSDq&pullRequest=969
const bool legRole = (c >= 14 && c <= 21);
const bool armRole = (c >= 6 && c <= 13);
if (legRole && bonePos[i].y > hipY + 0.10f * bodyH) {

Check failure on line 4340 in src/AnimationMerger.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not nest more than 3 if|for|do|while|switch statements.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AaBfRNCLOmx5S02lzSDr&open=AaBfRNCLOmx5S02lzSDr&pullRequest=969
int seg = -1;
if (c == 15 || c == 19) seg = 0;
else if (c == 16 || c == 20) seg = 1;
else if (c == 17 || c == 21) seg = 2;
rescue.push_back({i, seg});
boneToCanon[i] = -1;
} else if (armRole && bonePos[i].y < hipY - 0.10f * bodyH) {
boneToCanon[i] = -1; // "arm" below the hips — drop
}
}
}

// ---- (a) side check on the CLEANED mapping ---------------------
// Sum dot(namedLeft − namedRight, trueLeft) over the paired roles
// (weighted by separation, so a near-centred pair can't flip the
// vote). The expected sign is calibrated on the known-good Mixamo
// case as loaded by OUR importer: it measures side = -2.36, i.e. the
// fleet-norm rigs put named-left at MINUS up×fwd (the same "all
// imports mirror alike" #951 measured from the toes). A POSITIVE
// score is the odd one out (UniRig outputs) and triggers the swap.
static const int kPairs[][2] = {

Check warning on line 4361 in src/AnimationMerger.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this C-style array with "std::vector" (for dynamic size), or "std::array" (for static size)

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AaBfL52mRf5J7SqxQuif&open=AaBfL52mRf5J7SqxQuif&pullRequest=969
{19, 15}, // hips
{10, 6}, // collars
{11, 7}, // upper arms
{13, 9}, // hands
{21, 17}, // feet
};
Ogre::Vector3 rolePos[22];

Check warning on line 4368 in src/AnimationMerger.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this C-style array with "std::vector" (for dynamic size), or "std::array" (for static size)

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AaBfL52mRf5J7SqxQuig&open=AaBfL52mRf5J7SqxQuig&pullRequest=969
bool roleHas[22] = {};

Check warning on line 4369 in src/AnimationMerger.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this C-style array with "std::vector" (for dynamic size), or "std::array" (for static size)

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AaBfL52mRf5J7SqxQuih&open=AaBfL52mRf5J7SqxQuih&pullRequest=969
for (int i = 0; i < nBones; ++i) {
const int c = boneToCanon[i];
if (c < 0 || c >= 22 || roleHas[c]) continue;
rolePos[c] = bonePos[i];
roleHas[c] = true;
}
double side = 0.0;
for (const auto& pr : kPairs) {
if (!roleHas[pr[0]] || !roleHas[pr[1]]) continue;
side += (rolePos[pr[0]] - rolePos[pr[1]]).dotProduct(trueLeft);
}
// One-permutation convention: canonical LEFT roles must end on the
// bones at +trueLeft (anatomical left) — that is the state the old
// compensator produced on the validated Mixamo case (named-left at
// -trueLeft, one swap). So: swap when the named pairs sit at
// -trueLeft; an anatomically-named rig (UniRig) needs none.
constexpr double kExpectedSideSign = +1.0;
if (qEnvironmentVariableIsSet("QTMESH_T2M_SIDE_DEBUG"))
fprintf(stderr, "[t2m] side score %.4f (expected sign %+.0f)\n",
side, kExpectedSideSign);
const QByteArray forceSwap = qgetenv("QTMESH_T2M_SIDE_SWAP");
const bool swap = !forceSwap.isEmpty()
? (forceSwap != "0")
: (side != 0.0 && (side * kExpectedSideSign) < 0.0);
if (swap) {

Check warning on line 4394 in src/AnimationMerger.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the init-statement to declare "swap" inside the if statement.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AaBfNpCeNULrS1B0acJj&open=AaBfNpCeNULrS1B0acJj&pullRequest=969
auto mirrorRole = [canonN](int c) {
// V1 body pairs: 6..9 ↔ 10..13, 14..17 ↔ 18..21.
if (c >= 6 && c <= 9) return c + 4;
if (c >= 10 && c <= 13) return c - 4;
if (c >= 14 && c <= 17) return c + 4;
if (c >= 18 && c <= 21) return c - 4;
// V2 finger slots: 22 + (side*5 + finger)*3 + seg — flip side.
if (c >= 22 && c < canonN) {
const int f = c - 22;
return 22 + (f < 15 ? f + 15 : f - 15);
}
return c;
};
for (int i = 0; i < nBones; ++i)
if (boneToCanon[i] >= 0)

Check failure on line 4409 in src/AnimationMerger.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not nest more than 3 if|for|do|while|switch statements.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AaBfRNCLOmx5S02lzSDs&open=AaBfRNCLOmx5S02lzSDs&pullRequest=969
boneToCanon[i] = mirrorRole(boneToCanon[i]);
Comment on lines +4408 to +4410

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prevent handedness compensation from undoing the detected swap

When a forward-facing UniRig is detected, this loop maps its +X named-left bones to the right roles and its -X named-right bones to the left roles, but the default cmuLibraryHandedness=true path immediately calls compensateCanonicalHandedness. That function resets the skeleton, observes canonical-left at -X after this swap, and swaps body roles 6–21 back again. Consequently the default template callers retain the original mirrored body mapping (while V2 fingers remain swapped because the old compensator does not handle them); the new test misses this because it asserts only sideSwapApplied, not the resulting tracks.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed by composing into ONE permutation: the #969 block now fully replaces compensateCanonicalHandedness on the library path (the old call is gone), so there is no second swap to undo the first — and V2 finger sides ride the same decision. Convention anchored on the validated Mixamo case: canonical-left roles end on the bones at +trueLeft, which reproduces the old compensator's single swap there while anatomically-named rigs take none. Verified live on Mixamo (score -2.36 → one swap, unchanged behavior) and the UniRig orc (score +0.44 → none + arm rescue) (b9c1aef)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
res.sideSwapApplied = true;
fprintf(stderr,
"[t2m] bone naming is mirrored vs anatomy (side score "
"%.3f) — swapped L/R canonical roles to match geometry\n",
side);
}

// ---- (b, continued) rescue mis-named arm chains ----------------
// The stripped chest-height "leg" chains ARE the arms — remap each
// segment onto the matching arm role, side chosen geometrically in
// the POST-swap convention (fleet norm: left roles live at MINUS
// trueLeft). Only fills a side whose arm roles are entirely vacant,
// so a rig with real (correctly named) arms is never stomped.
if (!rescue.empty() && hipIdx >= 0) {
bool armTaken[2] = {false, false}; // 0 = right(7..9), 1 = left(11..13)

Check warning on line 4425 in src/AnimationMerger.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this C-style array with "std::vector" (for dynamic size), or "std::array" (for static size)

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AaBfNpCeNULrS1B0acJm&open=AaBfNpCeNULrS1B0acJm&pullRequest=969
for (int i = 0; i < nBones; ++i) {
const int c = boneToCanon[i];
if (c >= 7 && c <= 9) armTaken[0] = true;

Check failure on line 4428 in src/AnimationMerger.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not nest more than 3 if|for|do|while|switch statements.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AaBfRNCLOmx5S02lzSDt&open=AaBfRNCLOmx5S02lzSDt&pullRequest=969
if (c >= 11 && c <= 13) armTaken[1] = true;

Check failure on line 4429 in src/AnimationMerger.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not nest more than 3 if|for|do|while|switch statements.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AaBfRNCLOmx5S02lzSDu&open=AaBfRNCLOmx5S02lzSDu&pullRequest=969
}
static const int kArmSeg[2][3] = {{7, 8, 9}, {11, 12, 13}};

Check warning on line 4431 in src/AnimationMerger.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this C-style array with "std::vector" (for dynamic size), or "std::array" (for static size)

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AaBfNpCeNULrS1B0acJn&open=AaBfNpCeNULrS1B0acJn&pullRequest=969
int rescued = 0;
for (const Rescue& rc : rescue) {
if (rc.seg < 0) continue;

Check failure on line 4434 in src/AnimationMerger.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not nest more than 3 if|for|do|while|switch statements.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AaBfRNCLOmx5S02lzSDv&open=AaBfRNCLOmx5S02lzSDv&pullRequest=969
const float lat = (bonePos[rc.bone] - bonePos[hipIdx])
.dotProduct(trueLeft);
const int sideIdx = lat >= 0.0f ? 1 : 0; // left roles at +trueLeft
if (armTaken[sideIdx]) continue;

Check failure on line 4438 in src/AnimationMerger.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not nest more than 3 if|for|do|while|switch statements.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AaBfRNCLOmx5S02lzSDw&open=AaBfRNCLOmx5S02lzSDw&pullRequest=969
boneToCanon[rc.bone] = kArmSeg[sideIdx][rc.seg];
++rescued;
}
if (rescued > 0)
fprintf(stderr,
"[t2m] %d leg-named bone(s) bound at chest height — "
"remapped onto the vacant arm roles (mis-labeled "
"A-pose arms)\n", rescued);
}
}

// NB compensateCanonicalHandedness is intentionally NOT called here any
// more for the library path — the #969 block above composes the same
// decision (facing-aware, on the cleaned mapping, V2 fingers included).

res.canonicalJoints = distinct;
// Bones-per-role: rigs segment chains differently (Mixamo has Spine AND
Expand Down
2 changes: 2 additions & 0 deletions src/AnimationMerger.h
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,8 @@ class AnimationMerger {
float length = 0.0f; // seconds
bool refined = false; // RMIB refine pass ran (smoothed the motion)
bool usedModel = false; // RMIB model (vs spline) used in the refine pass
bool sideSwapApplied = false; // #969: bone names mirrored vs anatomy —
// L/R canonical roles were swapped
};

/// Apply a TEMPLATE MOTION CLIP (#411) onto a skeleton as a new animation.
Expand Down
97 changes: 97 additions & 0 deletions src/AnimationMerger_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,17 @@ class AnimationMergerTest : public ::testing::Test {

ASSERT_TRUE(tryInitOgre()) << "Ogre init failed (Xvfb/GL required in CI)";
createStandardOgreMaterials();
// #969: the synthetic rigs in this suite historically place named-
// LEFT bones at +X, which the new name-vs-anatomy side check reads
// as mirror-named (the fleet norm is named-left at -X) and would
// silently swap the L/R canonical roles under the side-specific
// assertions below. Pin the check off for the suite; the dedicated
// ApplyMotionClipDetectsMirroredSideNaming test re-enables it.
qputenv("QTMESH_T2M_SIDE_SWAP", "0");
}

void TearDown() override {
qunsetenv("QTMESH_T2M_SIDE_SWAP");
if (app)
app->processEvents();
}
Expand Down Expand Up @@ -1886,3 +1894,92 @@ TEST_F(AnimationMergerTest, VerticalDescentLowersRootDescentOnly)

sm->destroyEntity(ent);
}

TEST_F(AnimationMergerTest, ApplyMotionClipDetectsMirroredSideNaming)
{
// #969: rigs whose bone NAMES mirror their anatomy (UniRig outputs) must
// have their L/R canonical roles swapped; fleet-norm rigs must not.
// The check is geometric: named-left vs named-right positions against
// trueLeft = up × forward. Build the same full humanoid twice, with the
// side names on opposite lateral signs.
auto build = [&](const char* prefix, float leftX) {
auto skelRes = Ogre::SkeletonManager::getSingleton().create(
std::string(prefix) + "_skel",
Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME);
unsigned short h = 0;
auto bone = [&](const std::string& n, const Ogre::Vector3& p,
Ogre::Bone* par) {
auto* b = skelRes->createBone(n, h++);
b->setPosition(p);
if (par) par->addChild(b);
return b;
};
auto* hips = bone("Hips", {0, 1.0f, 0}, nullptr);
auto* spine = bone("Spine", {0, 0.2f, 0}, hips);
auto* chest = bone("Spine1", {0, 0.2f, 0}, spine);
auto* neck = bone("Neck", {0, 0.2f, 0}, chest);
bone("Head", {0, 0.15f, 0}, neck);
const float lx = leftX, rx = -leftX;
auto* rsh = bone("RightShoulder", {rx * 0.05f, 0.1f, 0}, chest);
auto* rarm = bone("RightArm", {rx * 0.15f, 0, 0}, rsh);
auto* rfa = bone("RightForeArm", {rx * 0.25f, 0, 0}, rarm);
bone("RightHand", {rx * 0.2f, 0, 0}, rfa);
auto* lsh = bone("LeftShoulder", {lx * 0.05f, 0.1f, 0}, chest);
auto* larm = bone("LeftArm", {lx * 0.15f, 0, 0}, lsh);
auto* lfa = bone("LeftForeArm", {lx * 0.25f, 0, 0}, larm);
bone("LeftHand", {lx * 0.2f, 0, 0}, lfa);
auto* rleg = bone("RightUpLeg", {rx * 0.1f, -0.25f, 0}, hips);
auto* rknee = bone("RightLeg", {0, -0.25f, 0}, rleg);
bone("RightFoot", {0, -0.5f, 0}, rknee);
auto* lleg = bone("LeftUpLeg", {lx * 0.1f, -0.25f, 0}, hips);
auto* lknee = bone("LeftLeg", {0, -0.25f, 0}, lleg);
bone("LeftFoot", {0, -0.5f, 0}, lknee);
skelRes->setBindingPose();
auto mesh = createInMemoryMesh(std::string(prefix) + "_mesh", skelRes);
return Manager::getSingleton()->getSceneMgr()->createEntity(
std::string(prefix) + "_ent", mesh);
};

// The fixture pins the side check off for the legacy suite — this test
// is ABOUT the check, so re-enable real detection.
qunsetenv("QTMESH_T2M_SIDE_SWAP");

// ONE composed permutation (replaces the old compensateCanonicalHandedness
// on the library path): canonical-LEFT roles must end on the bones at
// +trueLeft (anatomical left). A Mixamo-convention rig — named-left at
// MINUS up×fwd (negative X when facing +Z) — therefore gets exactly ONE
// swap (what the old compensator did on the validated case).
Ogre::Entity* norm = build("sidenorm", -1.0f);
ASSERT_NE(norm, nullptr);
const auto quats = identityClip(3);
const auto resNorm = AnimationMerger::applyMotionClip(
norm->getSkeleton(), "sideclip", quats, 30, /*worldFrame=*/true,
srcRestWorld(), false, 8, false, canonRestDirs());
ASSERT_TRUE(resNorm.ok) << resNorm.error.toStdString();
EXPECT_TRUE(resNorm.sideSwapApplied)
<< "Mixamo-convention naming takes the single composed swap";

// Anatomically-named rig (UniRig): named-left already at +trueLeft — the
// roles land correctly with NO permutation.
Ogre::Entity* mir = build("sidemir", +1.0f);
ASSERT_NE(mir, nullptr);
const auto resMir = AnimationMerger::applyMotionClip(
mir->getSkeleton(), "sideclip", quats, 30, /*worldFrame=*/true,
srcRestWorld(), false, 8, false, canonRestDirs());
ASSERT_TRUE(resMir.ok) << resMir.error.toStdString();
EXPECT_FALSE(resMir.sideSwapApplied)
<< "anatomically-named rig needs no permutation";

// yaw180 flips forward and therefore trueLeft: the SAME rig evaluated as
// backward-facing has its named-left at -trueLeft — swap expected.
const auto resMirYaw = AnimationMerger::applyMotionClip(
mir->getSkeleton(), "sideclip2", quats, 30, /*worldFrame=*/true,
srcRestWorld(), false, 8, /*yaw180=*/true, canonRestDirs());
ASSERT_TRUE(resMirYaw.ok) << resMirYaw.error.toStdString();
EXPECT_TRUE(resMirYaw.sideSwapApplied)
<< "backward-facing flips trueLeft — the swap is needed again";

auto* sm = Manager::getSingleton()->getSceneMgr();
sm->destroyEntity(norm);
sm->destroyEntity(mir);
}
39 changes: 36 additions & 3 deletions src/MotionLibrary.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@
}
clip.quality = static_cast<float>(
std::clamp(co.value("quality").toDouble(1.0), 0.0, 1.0));
clip.uprightness = meanChestLean(clip.quats);
if (clip.frames > 0 && !clip.action.isEmpty())
m_clips.push_back(std::move(clip));
}
Expand All @@ -253,6 +254,37 @@
return out;
}

float MotionLibrary::meanChestLean(
const std::vector<std::vector<std::array<float, 4>>>& quats)
{
// Chest = canonical joint 2. up' = q * (0,1,0) * q⁻¹; report mean up'.z.
double sum = 0.0;
int n = 0;
for (const auto& frame : quats) {
if (frame.size() <= 2)
continue;
const auto& q = frame[2];
const float x = q[0], y = q[1], z = q[2], w = q[3];

Check warning on line 267 in src/MotionLibrary.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define each identifier in a dedicated statement.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AaBfWupwSXgCwWlHSBXL&open=AaBfWupwSXgCwWlHSBXL&pullRequest=969
// z-component of the rotated +Y axis: 2(yz + wx)... careful with
// convention: R(q)·(0,1,0) = (2(xy − wz), 1 − 2(x² + z²), 2(yz + wx)).
sum += 2.0f * (y * z + w * x);
++n;
}
return n ? static_cast<float>(sum / n) : 0.0f;
}

double MotionLibrary::takeWeight(const QString& action, float quality,
float uprightness)
{
double w = static_cast<double>(quality) * quality; // the #855 rule
const QString a = action.toLower();
const bool locomotion = (a == QLatin1String("walk"))
|| (a == QLatin1String("run"));
if (locomotion && uprightness < -0.10f)

Check warning on line 283 in src/MotionLibrary.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the init-statement to declare "locomotion" inside the if statement.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AaBfWupwSXgCwWlHSBXM&open=AaBfWupwSXgCwWlHSBXM&pullRequest=969
w *= 0.02; // backpedal-look take: only picked when nothing else
return w;
}

int MotionLibrary::matchPrompt(const QString& prompt, QString* matchedAction) const
{
const QString p = prompt.toLower();
Expand All @@ -279,9 +311,10 @@
QList<double> weights;
weights.reserve(hits.size());
for (int i : hits) {
const double q = m_clips[static_cast<size_t>(i)].quality;
weights.append(q * q);
total += q * q;
const auto& c = m_clips[static_cast<size_t>(i)];
const double w = takeWeight(c.action, c.quality, c.uprightness);
weights.append(w);
total += w;
}
if (total <= 1e-9)
return hits.at(QRandomGenerator::global()->bounded(hits.size()));
Expand Down
Loading
Loading