Skip to content
Closed
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
25 changes: 24 additions & 1 deletion qml/PropertiesPanel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -8493,7 +8493,7 @@ Rectangle {
// skewing every new clip is exactly the bug this fixes).
var gr = AnimationControlController.generateMotion(
genPromptIn.text, 0.0, useModelChk.checked,
0.0)
0.0, footPinChk.checked)
if (gr && gr.animation) {
lastGeneratedAnim = gr.animation
// Point the slider at the fresh clip at a neutral 0
Expand Down Expand Up @@ -8536,6 +8536,29 @@ Rectangle {
anchors.verticalCenter: parent.verticalCenter
}
}
// #856: foot-contact cleanup — ON by default (detect ground-contact
// spans + IK-pin the feet so they plant instead of skating).
Row {
spacing: 6
Rectangle {
id: footPinChk
property bool checked: true
width: 14; height: 14; radius: 2
anchors.verticalCenter: parent.verticalCenter
color: checked ? PropertiesPanelController.highlightColor
: PropertiesPanelController.inputColor
border.color: PropertiesPanelController.borderColor
Text { anchors.centerIn: parent; visible: parent.checked
text: "✓"; color: "white"; font.pixelSize: 10 }
MouseArea { anchors.fill: parent
onClicked: footPinChk.checked = !footPinChk.checked }
}
Text {
text: "Pin feet (contact cleanup)"
color: PropertiesPanelController.textColor; font.pixelSize: 10
anchors.verticalCenter: parent.verticalCenter
}
}
// ── Arm space (#854): Mixamo-style widen/tuck post-process ────────
// Targets `armSpaceAnim` — the last generated clip by default, or
// any animation the user picks via the per-row arm button below.
Expand Down
9 changes: 8 additions & 1 deletion src/AnimationControlController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1833,7 +1833,7 @@ double AnimationControlController::currentArmSpace(const QString& animName,

QVariantMap AnimationControlController::generateMotion(const QString& prompt,
double duration, bool useModel,
double armSpaceDeg)
double armSpaceDeg, bool footPin)
{
QVariantMap out;
out["ok"] = false;
Expand Down Expand Up @@ -1937,6 +1937,13 @@ QVariantMap AnimationControlController::generateMotion(const QString& prompt,
AnimationMerger::adjustArmSpace(skel.get(), animName,
static_cast<float>(armSpaceDeg));

// #856: foot-contact cleanup — ON by default (checkbox opts out).
if (footPin) {
const auto fp = AnimationMerger::pinFeet(skel.get(), animName);
if (fp.ok && fp.spans > 0)
out["footPinSpans"] = fp.spans;
}

entity->refreshAvailableAnimationState();
// Make the generated clip the ONLY enabled animation. Ogre AVERAGES all
// enabled animation states, so leaving the import's auto-enabled clip (or
Expand Down
5 changes: 4 additions & 1 deletion src/AnimationControlController.h
Original file line number Diff line number Diff line change
Expand Up @@ -343,10 +343,13 @@ class AnimationControlController : public QObject
/// (MotionGenerator/ONNX); it falls back to the template library automatically
/// when the model is unavailable or the action isn't in its vocab. Default
/// false = the reliable template-clip retarget.
/// `footPin` (default true) runs the #856 foot-contact cleanup on the
/// generated clip (contact detection + two-bone IK pinning).
Q_INVOKABLE QVariantMap generateMotion(const QString& prompt,
double duration = 0.0,
bool useModel = false,
double armSpaceDeg = 0.0);
double armSpaceDeg = 0.0,
bool footPin = true);

/// #854: Mixamo-style arm-space post-process on an EXISTING animation of
/// the selected entity. Positive `degrees` widens the arms away from the
Expand Down
236 changes: 235 additions & 1 deletion src/AnimationMerger.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#include "AnimationMerger.h"
#include "AutoRig.h"
#include "FootContact.h"
#include "MotionInbetween.h"
#include <OgreSkeleton.h>
#include <OgreSkeletonInstance.h>
Expand Down Expand Up @@ -1470,6 +1471,235 @@ bool AnimationMerger::adjustArmSpace(Ogre::Skeleton* skel,
return true;
}

namespace {
std::string footPinKey(const std::string& animName)
{
return "qtme.footpin." + animName;
}
} // namespace

AnimationMerger::FootPinResult AnimationMerger::pinFeet(
Ogre::Skeleton* skel, const std::string& animName, int blendFrames)
{
FootPinResult res;
if (!skel || !skel->hasAnimation(animName)) {
res.error = QStringLiteral("animation not found");
return res;
}
Ogre::Animation* anim = skel->getAnimation(animName);

const int nBones = static_cast<int>(skel->getNumBones());
std::vector<int> boneToCanon(static_cast<size_t>(nBones), -1);
for (int i = 0; i < nBones; ++i)
boneToCanon[static_cast<size_t>(i)] =
MotionInbetween::canonicalIndexForBone(QString::fromStdString(
skel->getBone(static_cast<unsigned short>(i))->getName()));
const TargetBindFrame tb = readTargetBindFrame(skel, boneToCanon);
// Bind-local POSITIONS (parent-relative) for the manual FK below — the
// skeleton is still in its reset pose after readTargetBindFrame.
std::vector<Ogre::Vector3> bindLocalPos(static_cast<size_t>(nBones));
for (int i = 0; i < nBones; ++i)
bindLocalPos[static_cast<size_t>(i)] =
skel->getBone(static_cast<unsigned short>(i))->getPosition();

// Leg chains: thigh / knee / foot roles per side (first bone per role).
struct Leg { int thigh, shin, foot; };
const Leg legs[2] = {
{tb.roleBoneIdx[15], tb.roleBoneIdx[16], tb.roleBoneIdx[17]}, // right
{tb.roleBoneIdx[19], tb.roleBoneIdx[20], tb.roleBoneIdx[21]}, // left
};

auto trackOf = [&](int bone) -> Ogre::NodeAnimationTrack* {
if (bone < 0 || !anim->hasNodeTrack(static_cast<unsigned short>(bone)))
return nullptr;
auto* t = anim->getNodeTrack(static_cast<unsigned short>(bone));
return (t && t->getNumKeyFrames() > 0) ? t : nullptr;
};

// Keyframe times come from the first available thigh track — generated
// clips write one keyframe per frame on every mapped bone.
Ogre::NodeAnimationTrack* timeSrc = trackOf(legs[0].thigh);
if (!timeSrc) timeSrc = trackOf(legs[1].thigh);
if (!timeSrc) {
res.error = QStringLiteral("no leg tracks on this rig/animation");
return res;
}
const int nk = static_cast<int>(timeSrc->getNumKeyFrames());
if (nk < 4) {
res.error = QStringLiteral("too few keyframes to detect contacts");
return res;
}
std::vector<float> times(static_cast<size_t>(nk));
for (int k = 0; k < nk; ++k)
times[static_cast<size_t>(k)] =
timeSrc->getNodeKeyFrame(static_cast<unsigned short>(k))->getTime();

// ---- manual FK over the tracks (pure math; the live skeleton — which
// may be a SkeletonInstance driving an on-screen entity — is untouched).
std::vector<std::vector<Ogre::Quaternion>> Wrot(
static_cast<size_t>(nk),
std::vector<Ogre::Quaternion>(static_cast<size_t>(nBones)));
std::vector<std::vector<Ogre::Vector3>> Wpos(
static_cast<size_t>(nk),
std::vector<Ogre::Vector3>(static_cast<size_t>(nBones)));
for (int k = 0; k < nk; ++k) {
const Ogre::TimeIndex ti =
anim->_getTimeIndex(times[static_cast<size_t>(k)]);
for (int i : tb.order) {
Ogre::Quaternion lrot = tb.bindLocal[static_cast<size_t>(i)];
Ogre::Vector3 lpos = bindLocalPos[static_cast<size_t>(i)];
if (auto* trk = trackOf(i)) {
Ogre::TransformKeyFrame kf(nullptr, 0.0f);
trk->getInterpolatedKeyFrame(ti, &kf);
lrot = lrot * kf.getRotation(); // applyToNode: rotate()
lpos = lpos + kf.getTranslate(); // translate(TS_PARENT)
}
const int pi = tb.parentIdx[static_cast<size_t>(i)];
if (pi >= 0) {
Wrot[static_cast<size_t>(k)][static_cast<size_t>(i)] =
Wrot[static_cast<size_t>(k)][static_cast<size_t>(pi)] * lrot;
Wpos[static_cast<size_t>(k)][static_cast<size_t>(i)] =
Wpos[static_cast<size_t>(k)][static_cast<size_t>(pi)]
+ Wrot[static_cast<size_t>(k)][static_cast<size_t>(pi)] * lpos;
} else {
Wrot[static_cast<size_t>(k)][static_cast<size_t>(i)] = lrot;
Wpos[static_cast<size_t>(k)][static_cast<size_t>(i)] = lpos;
}
}
}

// Detection runs in the CANONICAL frame (Ct maps rig world → X=left,
// Y=up, Z=forward) so "ground" is a horizontal plane regardless of the
// rig's own axes.
auto toCanon = [&](const Ogre::Vector3& p) { return tb.Ct * p; };
auto fromCanon = [&](const FootContact::V3& p) {
return tb.Ct.Inverse() * Ogre::Vector3(p[0], p[1], p[2]);
};
auto v3 = [](const Ogre::Vector3& p) {
return FootContact::V3{p.x, p.y, p.z};
};

for (const Leg& leg : legs) {
if (leg.thigh < 0 || leg.shin < 0 || leg.foot < 0)
continue;
auto* thighTrk = trackOf(leg.thigh);
auto* shinTrk = trackOf(leg.shin);
auto* footTrk = trackOf(leg.foot);
if (!thighTrk || !shinTrk
|| static_cast<int>(thighTrk->getNumKeyFrames()) != nk
|| static_cast<int>(shinTrk->getNumKeyFrames()) != nk
|| (footTrk && static_cast<int>(footTrk->getNumKeyFrames()) != nk))
continue; // mixed keyframe grids — not a generated clip
Comment on lines +1588 to +1592

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Return an error when no legs are eligible for pinning

For standalone --foot-pin/MCP pin_feet on existing clips, a leg whose tracks do not match the generated dense keyframe grid is skipped here, but pinFeet() still falls through to res.ok = true and callers export/report success. In that scenario both legs can be skipped and the user gets an unchanged output file with a successful “0 spans” result instead of a clear unsupported/no-op response; track whether any complete leg was processed and fail or report a skip when none are eligible.

Useful? React with 👍 / 👎.


std::vector<FootContact::V3> footC(static_cast<size_t>(nk));
for (int k = 0; k < nk; ++k)
footC[static_cast<size_t>(k)] = v3(toCanon(
Wpos[static_cast<size_t>(k)][static_cast<size_t>(leg.foot)]));
const float legLen =
(Wpos[0][static_cast<size_t>(leg.shin)]
- Wpos[0][static_cast<size_t>(leg.thigh)]).length()
+ (Wpos[0][static_cast<size_t>(leg.foot)]
- Wpos[0][static_cast<size_t>(leg.shin)]).length();
const auto spans = FootContact::detectContacts(footC, legLen);
if (spans.empty())
continue;
res.spans += static_cast<int>(spans.size());

for (const auto& span : spans) {
const FootContact::V3 anchor = footC[static_cast<size_t>(span.start)];
for (int k = span.start; k <= span.end; ++k) {
const float w = FootContact::blendWeight(span, k, blendFrames);
if (w <= 0.0f)
continue;
const size_t kc = static_cast<size_t>(k);
const Ogre::Vector3 hipW = Wpos[kc][static_cast<size_t>(leg.thigh)];
const Ogre::Vector3 kneeW = Wpos[kc][static_cast<size_t>(leg.shin)];
const Ogre::Vector3 footW = Wpos[kc][static_cast<size_t>(leg.foot)];
// pinned target: blend the current foot toward the anchor
const FootContact::V3 cur = footC[kc];
const FootContact::V3 tgtC{
cur[0] + (anchor[0] - cur[0]) * w,
cur[1] + (anchor[1] - cur[1]) * w,
cur[2] + (anchor[2] - cur[2]) * w};
const FootContact::V3 kneeNewC = FootContact::solveKnee(
v3(toCanon(hipW)), v3(toCanon(kneeW)), v3(toCanon(footW)),
tgtC);
const Ogre::Vector3 kneeNew = fromCanon(kneeNewC);
const Ogre::Vector3 tgt = fromCanon(tgtC);

Ogre::Vector3 dThighOld = kneeW - hipW;
Ogre::Vector3 dThighNew = kneeNew - hipW;
Ogre::Vector3 dShinOld = footW - kneeW;
Ogre::Vector3 dShinNew = tgt - kneeNew;
if (dThighOld.squaredLength() < 1e-12f
|| dThighNew.squaredLength() < 1e-12f
|| dShinOld.squaredLength() < 1e-12f
|| dShinNew.squaredLength() < 1e-12f)
continue;
dThighOld.normalise(); dThighNew.normalise();
dShinOld.normalise(); dShinNew.normalise();
const Ogre::Quaternion S1 = dThighOld.getRotationTo(dThighNew);
const Ogre::Quaternion S2 =
(S1 * dShinOld).getRotationTo(dShinNew);

// Rewrite the three keyframes as world premultipliers folded
// into parent-relative deltas (keyframes compose as
// local = bindLocal · kf; world = Wp · local).
const Ogre::Quaternion& WpT =
(tb.parentIdx[static_cast<size_t>(leg.thigh)] >= 0)
? Wrot[kc][static_cast<size_t>(
tb.parentIdx[static_cast<size_t>(leg.thigh)])]
: Ogre::Quaternion::IDENTITY;
const Ogre::Quaternion Wthigh =
Wrot[kc][static_cast<size_t>(leg.thigh)];
const Ogre::Quaternion WthighNew = S1 * Wthigh;
auto* kfT = thighTrk->getNodeKeyFrame(
static_cast<unsigned short>(k));
kfT->setRotation(
tb.bindLocal[static_cast<size_t>(leg.thigh)].Inverse()
* WpT.Inverse() * WthighNew);

const Ogre::Quaternion& WpS =
Wrot[kc][static_cast<size_t>(
tb.parentIdx[static_cast<size_t>(leg.shin)])];
const Ogre::Quaternion Wshin =
Wrot[kc][static_cast<size_t>(leg.shin)];
const Ogre::Quaternion WshinNew = S2 * S1 * Wshin;
auto* kfS = shinTrk->getNodeKeyFrame(
static_cast<unsigned short>(k));
kfS->setRotation(
tb.bindLocal[static_cast<size_t>(leg.shin)].Inverse()
* (S1 * WpS).Inverse() * WshinNew);

if (footTrk) {
// foot keeps its ORIGINAL world orientation (no toe pop
// inherited from the parent corrections)
const Ogre::Quaternion& WpF =
Wrot[kc][static_cast<size_t>(
tb.parentIdx[static_cast<size_t>(leg.foot)])];
auto* kfF = footTrk->getNodeKeyFrame(
static_cast<unsigned short>(k));
kfF->setRotation(
tb.bindLocal[static_cast<size_t>(leg.foot)].Inverse()
* (S2 * S1 * WpF).Inverse()
* Wrot[kc][static_cast<size_t>(leg.foot)]);
}
++res.keyframesAdjusted;
}
}
// setRotation does NOT invalidate the track caches (#854 lesson).
thighTrk->_keyFrameDataChanged();
shinTrk->_keyFrameDataChanged();
if (footTrk) footTrk->_keyFrameDataChanged();
}

if (skel->getNumBones() > 0)
skel->getBone(0)->getUserObjectBindings().setUserAny(
footPinKey(animName), Ogre::Any(true));
res.ok = true;
return res;
}

AnimationMerger::ApplyMotionResult AnimationMerger::applyMotionClip(
Ogre::Skeleton* skel,
const std::string& animName,
Expand Down Expand Up @@ -1570,9 +1800,13 @@ AnimationMerger::ApplyMotionResult AnimationMerger::applyMotionClip(
// stored angle from a prior generation of the same name would make a
// re-request of that same angle a no-op (delta 0) on the new keyframes —
// forget it.
if (skel->getNumBones() > 0)
if (skel->getNumBones() > 0) {
skel->getBone(0)->getUserObjectBindings().eraseUserAny(
armSpaceKey(animName));
// #856: same for the foot-pin marker — a regenerated clip is unpinned.
skel->getBone(0)->getUserObjectBindings().eraseUserAny(
"qtme.footpin." + animName);
}
Ogre::Animation* anim = skel->createAnimation(animName, length);
anim->setInterpolationMode(Ogre::Animation::IM_LINEAR);
anim->setRotationInterpolationMode(Ogre::Animation::RIM_LINEAR);
Expand Down
31 changes: 31 additions & 0 deletions src/AnimationMerger.h
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,37 @@ class AnimationMerger {
const std::string& oldAnim,
const std::string& newAnim);

/// Outcome of pinFeet.
struct FootPinResult {
bool ok = false;
QString error;
int spans = 0; ///< contact spans pinned (both feet)
int keyframesAdjusted = 0;
};

/// #856 — foot-contact cleanup. Retargeted clips slide/float feet on rigs
/// whose proportions differ from the source (the direction retarget
/// transfers bone DIRECTIONS, not world foot positions). Per foot role,
/// detect contact spans (foot near the clip's ground level AND nearly
/// stationary horizontally — FootContact::detectContacts, canonical-frame,
/// leg-length-scaled thresholds) and lock the foot's world position to its
/// span-start position with an analytic two-bone hip–knee–foot IK
/// (FootContact::solveKnee — keeps segment lengths and the pose's own
/// bend plane), blending in/out over `blendFrames` at span edges so knees
/// don't pop. Rewrites ONLY the thigh/shin/foot keyframes (foot keeps its
/// original world orientation); everything else untouched. Pure track
/// math — nothing is applied to the live skeleton.
///
/// Effectively idempotent: a second run detects the already-planted spans
/// and re-pins to the same targets (near-no-op). The application is
/// recorded on bone[0]'s UserObjectBindings ("qtme.footpin.<anim>") so a
/// UI can reflect state; applyMotionClip clears it on clip regeneration.
/// Designed for generated clips (dense uniform keyframes); sparse
/// authored clips get keyframe-rate detection (approximate).
static FootPinResult pinFeet(Ogre::Skeleton* skel,
const std::string& animName,
int blendFrames = 3);

/// Sample every (or one) skeletal animation of `entity` at `fps` and
/// express each canonical joint's world orientation per frame. Bone→role
/// mapping is MotionInbetween::canonicalIndexForBone — the same matcher
Expand Down
Loading