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
21 changes: 20 additions & 1 deletion qml/AnimationControlPanel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,7 @@ Column {
ToolBtn { label: "-KF"; enabled: AnimationControlController.canDeleteKeyframe; onClicked: AnimationControlController.deleteKeyframe() }
}

// ── Playback toolbar: loop toggle (speed lives next to Play button) ───
// ── Playback toolbar: loop toggle + auto-key (speed lives next to Play) ───
RowLayout {
width: parent.width; spacing: 6

Expand All @@ -447,6 +447,25 @@ Column {
}
}

Rectangle {
Layout.preferredWidth: 96; height: 22; radius: 3
color: AnimationControlController.autoKey
? "#c04040"
: (autoKeyMa.containsMouse ? Qt.lighter(AnimationControlController.buttonColor, 1.15)
: AnimationControlController.buttonColor)
border.color: AnimationControlController.borderColor; border.width: 1
Text {
anchors.centerIn: parent
text: AnimationControlController.autoKey ? "● Auto Key" : "Auto Key"
color: AnimationControlController.autoKey ? "white" : AnimationControlController.buttonTextColor
font.pixelSize: 11
}
MouseArea {
id: autoKeyMa; anchors.fill: parent; hoverEnabled: true
onClicked: AnimationControlController.autoKey = !AnimationControlController.autoKey
}
}

Item { Layout.fillWidth: true }
}

Expand Down
59 changes: 49 additions & 10 deletions src/AnimationControlController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,24 @@
emit loopRegionChanged();
}

void AnimationControlController::setAutoKey(bool on)
{
if (on == m_autoKey) return;
m_autoKey = on;
SentryReporter::addBreadcrumb("ui.action",
QString("AutoKey toggled %1").arg(on ? "on" : "off"));
emit autoKeyChanged();
}

void AnimationControlController::autoKeyOnTransform()
{
if (!m_autoKey) return;
if (!m_selectedTrack || !m_selectedEntity || m_selectedAnimation.empty()) return;
if (!m_selectedSkeleton || m_selectedBone.empty()) return;
SentryReporter::addBreadcrumb("ui.action", "AutoKey applied keyframe");
addKeyframe();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

double AnimationControlController::advanceTime(double currentTime, double dt) const
{
double next = currentTime + dt * m_playbackSpeed;
Expand Down Expand Up @@ -497,16 +515,37 @@
void AnimationControlController::addKeyframe()
{
if (!m_selectedTrack || !m_selectedEntity || m_selectedAnimation.empty()) return;

float time = m_sliderValue / 1000.0f;
Ogre::TransformKeyFrame* newKf = m_selectedTrack->createNodeKeyFrame(time);

Ogre::TransformKeyFrame interpKf(nullptr, time);
m_selectedTrack->getInterpolatedKeyFrame(
m_selectedEntity->getAnimationState(m_selectedAnimation)->getTimePosition(), &interpKf);
newKf->setTranslate(interpKf.getTranslate());
newKf->setRotation(interpKf.getRotation());
newKf->setScale(interpKf.getScale());
if (!m_selectedSkeleton || m_selectedBone.empty()) return;
if (!m_selectedSkeleton->hasBone(m_selectedBone)) return;

const float time = m_sliderValue / 1000.0f;

Check warning on line 521 in src/AnimationControlController.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

implicit conversion from 'int' to 'float' may lose precision

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ3sr282rVdAgs0OyPr7&open=AZ3sr282rVdAgs0OyPr7&pullRequest=384
// Reuse a keyframe at the same time if one already exists — the rest of
// this controller treats same-time collisions as invalid, and auto-key
// would otherwise stack duplicates on every drag-end at the same scrub
// time. Match the same epsilon used by deleteKeyframe (1 ms).
Ogre::TransformKeyFrame* newKf = nullptr;
constexpr float kKeyframeEpsilon = 0.001f;
for (unsigned short i = 0; i < m_selectedTrack->getNumKeyFrames(); ++i) {
auto* existing = static_cast<Ogre::TransformKeyFrame*>(m_selectedTrack->getKeyFrame(i));
if (std::fabs(existing->getTime() - time) <= kKeyframeEpsilon) {
newKf = existing;
break;
}
}
if (!newKf)
newKf = m_selectedTrack->createNodeKeyFrame(time);

// Capture the bone's current LOCAL TRS (relative to its initial bind pose),
// which is the format TransformKeyFrame stores. Previously this used
// getInterpolatedKeyFrame, which samples the existing animation curve at
// `time` and produces an identity-ish keyframe whenever the curve is flat
// there — the user-visible "blank registry" bug. With this change, hitting
// +KF after dragging the scene node (or, with #358's bone gizmo, the bone
// directly) captures the actual pose under the cursor at that scrub time.
const Ogre::Bone* bone = m_selectedSkeleton->getBone(m_selectedBone);
newKf->setTranslate(bone->getPosition() - bone->getInitialPosition());
newKf->setRotation(bone->getInitialOrientation().Inverse() * bone->getOrientation());
newKf->setScale(bone->getScale() / bone->getInitialScale());
Comment thread
coderabbitai[bot] marked this conversation as resolved.

refreshSliderTicks();
setAnimationFrame(m_sliderValue);
Expand Down
11 changes: 11 additions & 0 deletions src/AnimationControlController.h
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class AnimationControlController : public QObject
Q_PROPERTY(double loopStart READ loopStart WRITE setLoopStart NOTIFY loopRegionChanged)
Q_PROPERTY(double loopEnd READ loopEnd WRITE setLoopEnd NOTIFY loopRegionChanged)
Q_PROPERTY(bool loopRegionActive READ loopRegionActive WRITE setLoopRegionActive NOTIFY loopRegionChanged)
Q_PROPERTY(bool autoKey READ autoKey WRITE setAutoKey NOTIFY autoKeyChanged)

// Keyframe tick marks on the timeline (list of ms positions)
Q_PROPERTY(QVariantList keyframeTicks READ keyframeTicks NOTIFY keyframeTicksChanged)
Expand Down Expand Up @@ -109,16 +110,24 @@ class AnimationControlController : public QObject
double loopStart() const { return m_loopStart; }
double loopEnd() const { return m_loopEnd; }
bool loopRegionActive() const { return m_loopRegionActive; }
bool autoKey() const { return m_autoKey; }
void setPlaybackSpeed(double s);
void setLoopStart(double s);
void setLoopEnd(double s);
void setLoopRegionActive(bool on);
void setAutoKey(bool on);

// Compute the time after applying speed scaling and (optional) loop wrap.
// Used by MainWindow::frameRenderingQueued. `currentTime` and `dt` are
// in seconds; returns the new time position to assign back to the state.
double advanceTime(double currentTime, double dt) const;

/// Push a keyframe at the current scrub time on the active bone-track,
/// capturing the bone's current pose. No-op if autoKey is off, no
/// animation is selected, or no bone is selected. Called by
/// TransformOperator at the end of every transform commit.
void autoKeyOnTransform();

// Keyframe ticks
QVariantList keyframeTicks() const { return m_keyframeTicks; }
int selectedTick() const { return m_selectedTick; }
Expand Down Expand Up @@ -225,6 +234,7 @@ public slots:
void currentKeyframeChanged();
void playbackSpeedChanged();
void loopRegionChanged();
void autoKeyChanged();
/// Emitted when the dope-sheet view should refresh — track edits, clip
/// selection, or keyframe add/delete/move.
void boneRowsChanged();
Expand Down Expand Up @@ -268,6 +278,7 @@ public slots:
double m_loopStart = 0.0;
double m_loopEnd = 0.0;
bool m_loopRegionActive = false;
bool m_autoKey = false;
};

#endif // ANIMATIONCONTROLCONTROLLER_H
102 changes: 102 additions & 0 deletions src/AnimationControlController_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,85 @@ TEST_F(AnimationControlControllerTest, AddKeyframeIncreasesCount) {
EXPECT_EQ(track->getNumKeyFrames(), before + 1);
}

TEST_F(AnimationControlControllerTest, AddKeyframeCapturesBonePose) {
// Move the bone to a non-identity pose, then add a keyframe at a fresh
// scrub time. The new keyframe must capture the bone's current local
// TRS — not identity, not the curve interpolation.
ASSERT_TRUE(canLoadMeshFiles());
Ogre::Entity* entity = setupAnimatedEntity("ACC_AddKfPoseTest");
ASSERT_NE(entity, nullptr);

auto* ctrl = AnimationControlController::instance();
ctrl->updateAnimationTree();
ctrl->selectAnimation(QString::fromStdString(entity->getName()), "TestAnim");
ASSERT_FALSE(ctrl->boneNames().isEmpty());
QString boneName = ctrl->boneNames().first();
ctrl->selectBone(boneName);

// Manually offset the bone from its initial pose. addKeyframe should
// capture that offset rather than re-sampling the curve.
auto* skel = entity->getSkeleton();
Ogre::Bone* bone = skel->getBone(boneName.toStdString());
bone->setManuallyControlled(true);
bone->setPosition(bone->getInitialPosition() + Ogre::Vector3(2.5f, 0, 0));

ctrl->setSliderValue(750); // a time that has no existing keyframe
ctrl->addKeyframe();
app->processEvents();

// Find the keyframe at 0.75s and verify translate.x ≈ 2.5
auto* track = skel->getAnimation("TestAnim")
->_getNodeTrackList().begin()->second;
bool found = false;
for (unsigned short i = 0; i < track->getNumKeyFrames(); ++i) {
auto* kf = static_cast<Ogre::TransformKeyFrame*>(track->getKeyFrame(i));
if (std::fabs(kf->getTime() - 0.75f) < 0.001f) {
EXPECT_NEAR(kf->getTranslate().x, 2.5f, 1e-3);
found = true;
break;
}
}
EXPECT_TRUE(found);
}

TEST_F(AnimationControlControllerTest, AutoKeyOnTransformPushesKeyframeWhenEnabled) {
// With autoKey enabled, calling autoKeyOnTransform must add a keyframe
// at the current scrub time on the active bone-track. Without it, the
// call is a no-op.
ASSERT_TRUE(canLoadMeshFiles());
Ogre::Entity* entity = setupAnimatedEntity("ACC_AutoKeyTest");
ASSERT_NE(entity, nullptr);

auto* ctrl = AnimationControlController::instance();
ctrl->updateAnimationTree();
ctrl->selectAnimation(QString::fromStdString(entity->getName()), "TestAnim");
ctrl->selectBone(ctrl->boneNames().first());

auto* track = entity->getSkeleton()->getAnimation("TestAnim")
->_getNodeTrackList().begin()->second;
const int before = track->getNumKeyFrames();

// autoKey off → no-op.
ctrl->setAutoKey(false);
ctrl->setSliderValue(250);
ctrl->autoKeyOnTransform();
EXPECT_EQ(track->getNumKeyFrames(), before);

// autoKey on → adds a keyframe.
ctrl->setAutoKey(true);
ctrl->autoKeyOnTransform();
app->processEvents();
EXPECT_EQ(track->getNumKeyFrames(), before + 1);

// Re-firing at the same scrub time must NOT stack a duplicate — it
// updates the existing keyframe in place. Otherwise auto-key on a
// single drag would balloon the track on every redundant mouse-release.
ctrl->autoKeyOnTransform();
app->processEvents();
EXPECT_EQ(track->getNumKeyFrames(), before + 1);
ctrl->setAutoKey(false);
}

TEST_F(AnimationControlControllerTest, DeleteKeyframeDecreasesCount) {
ASSERT_TRUE(canLoadMeshFiles());

Expand Down Expand Up @@ -919,6 +998,29 @@ TEST_F(AnimationControlControllerPlaybackTest, PasteRejectsMalformedJson) {
EXPECT_EQ(ctrl->pasteKeyframesAt("{\"kind\":\"wrong.kind\"}", 0.0), 0);
}

TEST_F(AnimationControlControllerPlaybackTest, AutoKeyDefaultsOff) {
auto* ctrl = AnimationControlController::instance();
EXPECT_FALSE(ctrl->autoKey());
}

TEST_F(AnimationControlControllerPlaybackTest, AutoKeyToggleEmitsSignal) {
auto* ctrl = AnimationControlController::instance();
ctrl->setAutoKey(false);
QSignalSpy spy(ctrl, &AnimationControlController::autoKeyChanged);
ctrl->setAutoKey(true);
EXPECT_EQ(spy.count(), 1);
ctrl->setAutoKey(true);
EXPECT_EQ(spy.count(), 1);
ctrl->setAutoKey(false);
}

TEST_F(AnimationControlControllerPlaybackTest, AutoKeyOnTransformNoOpWithoutSelection) {
auto* ctrl = AnimationControlController::instance();
ctrl->setAutoKey(true);
EXPECT_NO_THROW(ctrl->autoKeyOnTransform());
ctrl->setAutoKey(false);
}

TEST_F(AnimationControlControllerTest, MoveKeyframesShiftsAllByDt) {
ASSERT_TRUE(canLoadMeshFiles());
Ogre::Entity* entity = setupAnimatedEntity("DopeSheet_BulkMoveTest");
Expand Down
9 changes: 9 additions & 0 deletions src/TransformOperator.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
#include "UndoManager.h"
#include "commands/TransformCommands.h"
#include "EditModeController.h"
#include "AnimationControlController.h"
#include <Ogre.h>

// TODO create a virtual class GizmoObject & add Rotation & Translation Gizmo to have only one interface
Expand Down Expand Up @@ -1551,6 +1552,7 @@ void TransformOperator::mouseReleaseEvent(QMouseEvent *e)
if((SelectionSet::getSingleton()->hasNodes()||SelectionSet::getSingleton()->hasEntities()) && (e->button() == Qt::LeftButton))
{
// Push undo command if a transform was performed on scene nodes
bool nodeTransformCommitted = false;
if (SelectionSet::getSingleton()->hasNodes() && !mUndoStartPositions.isEmpty())
{
auto nodes = SelectionSet::getSingleton()->getNodesSelectionList();
Expand Down Expand Up @@ -1621,12 +1623,19 @@ void TransformOperator::mouseReleaseEvent(QMouseEvent *e)
UndoManager::getSingleton()->push(new ScaleCommand(nodes, totalScale));
}
}

nodeTransformCommitted = changed;
}

mUndoStartPositions.clear();
mUndoStartOrientations.clear();
mUndoStartScales.clear();
mStartPoint = Ogre::Vector3::ZERO;

// Auto-key only when an actual transform was committed — plain clicks
// and zero-delta releases must not pollute tracks with duplicate keys.
if (nodeTransformCommitted)
AnimationControlController::instance()->autoKeyOnTransform();
}

if(m_pSelectionBox->isVisible())
Expand Down
Loading