diff --git a/qml/AnimationControlPanel.qml b/qml/AnimationControlPanel.qml index 19ebaaa97..28750b05f 100644 --- a/qml/AnimationControlPanel.qml +++ b/qml/AnimationControlPanel.qml @@ -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 @@ -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 } } diff --git a/src/AnimationControlController.cpp b/src/AnimationControlController.cpp index 1cefd14d5..9f657c062 100644 --- a/src/AnimationControlController.cpp +++ b/src/AnimationControlController.cpp @@ -348,6 +348,24 @@ void AnimationControlController::setLoopRegionActive(bool on) 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(); +} + double AnimationControlController::advanceTime(double currentTime, double dt) const { double next = currentTime + dt * m_playbackSpeed; @@ -497,16 +515,37 @@ void AnimationControlController::nextKeyframe() 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; + // 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(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()); refreshSliderTicks(); setAnimationFrame(m_sliderValue); diff --git a/src/AnimationControlController.h b/src/AnimationControlController.h index 0112f6667..6b2ff86b0 100644 --- a/src/AnimationControlController.h +++ b/src/AnimationControlController.h @@ -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) @@ -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; } @@ -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(); @@ -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 diff --git a/src/AnimationControlController_test.cpp b/src/AnimationControlController_test.cpp index a1354316a..8585742af 100644 --- a/src/AnimationControlController_test.cpp +++ b/src/AnimationControlController_test.cpp @@ -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(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()); @@ -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"); diff --git a/src/TransformOperator.cpp b/src/TransformOperator.cpp index c0ec86bcb..9f190135a 100755 --- a/src/TransformOperator.cpp +++ b/src/TransformOperator.cpp @@ -23,6 +23,7 @@ #include "UndoManager.h" #include "commands/TransformCommands.h" #include "EditModeController.h" +#include "AnimationControlController.h" #include // TODO create a virtual class GizmoObject & add Rotation & Translation Gizmo to have only one interface @@ -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(); @@ -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())