From bcb27c2b815c9c576b8d1e189c32ccbda1213a03 Mon Sep 17 00:00:00 2001 From: Fernando Date: Fri, 1 May 2026 02:36:30 -0400 Subject: [PATCH 1/4] feat(animation): playback speed, loop region, auto-key (Phase 5 slice A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the timeline polish from #260: - playbackSpeed (0.25–4×) global multiplier; ComboBox sits next to the Play button in the Inspector's Animations section. In-app only — scales dt before setTimePosition; keyframes/length untouched. - loopStart/loopEnd/loopRegionActive scoped to the entity+animation selected in the Animation Control panel. Other entities advance at native timing. Wraps via fmod so large overshoots fold back inside the region. Resets to [0, length] on each new selection. - autoKey toggle: end-of-drag in TransformOperator pushes a keyframe on the active bone via AnimationControlController::addKeyframe(). - QML: timeline canvas shades the loop region and renders blue in/out markers; two transparent drag handles let you reposition them. Tests: 14 new pure-data tests in a separate fixture (no Ogre needed) covering speed scaling, signal emission, loop wrap (basic, large overshoot, degenerate, inactive passthrough, clamping), and auto-key safety. 2 Ogre-fixture tests for the auto-key add-keyframe path and loop-region reset on selection (run on Linux CI). Co-Authored-By: Claude Opus 4.7 (1M context) --- qml/AnimationControlPanel.qml | 122 +++++++++++++++++ qml/PropertiesPanel.qml | 24 +++- src/AnimationControlController.cpp | 80 ++++++++++++ src/AnimationControlController.h | 37 ++++++ src/AnimationControlController_test.cpp | 167 ++++++++++++++++++++++++ src/TransformOperator.cpp | 5 + src/mainwindow.cpp | 22 +++- 7 files changed, 453 insertions(+), 4 deletions(-) diff --git a/qml/AnimationControlPanel.qml b/qml/AnimationControlPanel.qml index 2695ced76..28a53e036 100644 --- a/qml/AnimationControlPanel.qml +++ b/qml/AnimationControlPanel.qml @@ -424,6 +424,51 @@ Column { ToolBtn { label: "-KF"; enabled: AnimationControlController.canDeleteKeyframe; onClicked: AnimationControlController.deleteKeyframe() } } + // ── Playback toolbar: loop + auto-key (speed lives next to Play button) ─── + RowLayout { + width: parent.width; spacing: 6 + + Rectangle { + Layout.preferredWidth: 80; height: 22; radius: 3 + color: AnimationControlController.loopRegionActive + ? AnimationControlController.highlightColor + : (loopMa.containsMouse ? Qt.lighter(AnimationControlController.buttonColor, 1.15) + : AnimationControlController.buttonColor) + border.color: AnimationControlController.borderColor; border.width: 1 + Text { + anchors.centerIn: parent + text: AnimationControlController.loopRegionActive ? "Loop ON" : "Loop OFF" + color: AnimationControlController.loopRegionActive ? "white" : AnimationControlController.buttonTextColor + font.pixelSize: 11 + } + MouseArea { + id: loopMa; anchors.fill: parent; hoverEnabled: true + onClicked: AnimationControlController.loopRegionActive = !AnimationControlController.loopRegionActive + } + } + + Item { Layout.fillWidth: true } + + Rectangle { + Layout.preferredWidth: 84; 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 + } + } + } + // ── Timeline ────────────────────────────────────────────────────────────── RowLayout { width: parent.width; height: 28; spacing: 4 @@ -460,6 +505,23 @@ Column { var ctx = getContext("2d"); ctx.clearRect(0, 0, width, height) var maxMs = AnimationControlController.sliderMaximum; if (maxMs <= 0) return var pad = 13; var avail = width - pad * 2 + + // Loop region shading (drawn under keyframe ticks) + if (AnimationControlController.loopRegionActive) { + var ls = AnimationControlController.loopStart * 1000 + var le = AnimationControlController.loopEnd * 1000 + var lx = pad + (ls / maxMs) * avail + var rx = pad + (le / maxMs) * avail + ctx.fillStyle = "rgba(64, 192, 255, 0.18)" + ctx.fillRect(lx, 0, Math.max(0, rx - lx), height) + ctx.strokeStyle = "#40c0ff"; ctx.lineWidth = 2 + ctx.beginPath(); ctx.moveTo(lx, 0); ctx.lineTo(lx, height); ctx.stroke() + ctx.beginPath(); ctx.moveTo(rx, 0); ctx.lineTo(rx, height); ctx.stroke() + ctx.fillStyle = "#40c0ff" + ctx.beginPath(); ctx.moveTo(lx, 0); ctx.lineTo(lx + 6, 0); ctx.lineTo(lx, 6); ctx.closePath(); ctx.fill() + ctx.beginPath(); ctx.moveTo(rx, 0); ctx.lineTo(rx - 6, 0); ctx.lineTo(rx, 6); ctx.closePath(); ctx.fill() + } + var ticks = AnimationControlController.keyframeTicks; var selTk = AnimationControlController.selectedTick for (var i = 0; i < ticks.length; i++) { var x = pad + (ticks[i] / maxMs) * avail; var isSel = (ticks[i] === selTk) @@ -480,6 +542,66 @@ Column { function onAnimationLengthChanged() { tickCanvas.requestPaint() } function onThemeChanged() { tickCanvas.requestPaint() } function onSliderValueChanged() { tickCanvas.requestPaint() } + function onLoopRegionChanged() { tickCanvas.requestPaint() } + } + } + + // Drag handles for loop in/out points — only visible when loop is active. + // Sits on top of the slider so drags on the handle areas don't move the playhead. + Item { + anchors.fill: parent + visible: AnimationControlController.loopRegionActive + + property real pad: 13 + property real avail: width - pad * 2 + property real maxMs: Math.max(1, AnimationControlController.sliderMaximum) + + Rectangle { + id: loopStartHandle + width: 10; height: parent.height + x: parent.pad + (AnimationControlController.loopStart * 1000 / parent.maxMs) * parent.avail - width / 2 + color: "transparent" + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.SizeHorCursor + drag.target: parent + drag.axis: Drag.XAxis + drag.minimumX: parent.parent.pad - parent.width / 2 + drag.maximumX: parent.parent.pad + parent.parent.avail - parent.width / 2 + onPositionChanged: { + if (!drag.active) return + var px = parent.x + parent.width / 2 - parent.parent.pad + var t = (px / parent.parent.avail) * (parent.parent.maxMs / 1000.0) + if (t < 0) t = 0 + if (t > AnimationControlController.loopEnd) t = AnimationControlController.loopEnd + AnimationControlController.loopStart = t + } + } + } + + Rectangle { + id: loopEndHandle + width: 10; height: parent.height + x: parent.pad + (AnimationControlController.loopEnd * 1000 / parent.maxMs) * parent.avail - width / 2 + color: "transparent" + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.SizeHorCursor + drag.target: parent + drag.axis: Drag.XAxis + drag.minimumX: parent.parent.pad - parent.width / 2 + drag.maximumX: parent.parent.pad + parent.parent.avail - parent.width / 2 + onPositionChanged: { + if (!drag.active) return + var px = parent.x + parent.width / 2 - parent.parent.pad + var t = (px / parent.parent.avail) * (parent.parent.maxMs / 1000.0) + if (t < AnimationControlController.loopStart) t = AnimationControlController.loopStart + if (t > parent.parent.maxMs / 1000.0) t = parent.parent.maxMs / 1000.0 + AnimationControlController.loopEnd = t + } + } } } } diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index d4020334f..1a10fd77c 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -1585,7 +1585,7 @@ Rectangle { function onAnimationStateChanged() { refreshAnimData() } } - // Play/Pause button + // Play/Pause button + playback speed (applies to selected entity only) Row { spacing: 8 width: parent.width - 16 @@ -1607,6 +1607,28 @@ Rectangle { onClicked: PropertiesPanelController.playing = !PropertiesPanelController.playing } } + + Text { + text: "Speed:" + color: PropertiesPanelController.textColor; font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + } + + ComboBox { + id: speedCombo + width: 72; height: 26 + anchors.verticalCenter: parent.verticalCenter + model: ["0.25x", "0.5x", "1x", "2x", "4x"] + property var values: [0.25, 0.5, 1.0, 2.0, 4.0] + currentIndex: { + var s = AnimationControlController.playbackSpeed + for (var i = 0; i < values.length; i++) + if (Math.abs(values[i] - s) < 1e-6) return i + return 2 + } + onActivated: AnimationControlController.playbackSpeed = values[currentIndex] + font.pixelSize: 11 + } } // Per-entity groups diff --git a/src/AnimationControlController.cpp b/src/AnimationControlController.cpp index d63117436..4ea4bf3b0 100644 --- a/src/AnimationControlController.cpp +++ b/src/AnimationControlController.cpp @@ -175,6 +175,13 @@ void AnimationControlController::selectAnimation(const QString& entityName, cons m_sliderMaximum = static_cast(anim->getLength() * 1000); } + // Reset loop region to span the whole animation whenever a new clip is + // selected — users typically want fresh in/out points per clip. + m_loopStart = 0.0; + m_loopEnd = m_sliderMaximum / 1000.0; + m_loopRegionActive = false; + emit loopRegionChanged(); + emit selectionChanged(); emit animationLengthChanged(); emit sliderValueChanged(); @@ -287,6 +294,79 @@ void AnimationControlController::setAnimationLength(double length) emit animationLengthChanged(); } +// ── Playback speed / loop region / auto-key ─────────────────────────────────── + +void AnimationControlController::setPlaybackSpeed(double s) +{ + if (s < 0.0) s = 0.0; + if (qFuzzyCompare(s, m_playbackSpeed)) return; + m_playbackSpeed = s; + emit playbackSpeedChanged(); +} + +void AnimationControlController::setLoopStart(double s) +{ + if (s < 0.0) s = 0.0; + if (qFuzzyCompare(s, m_loopStart)) return; + m_loopStart = s; + if (m_loopEnd > 0.0 && m_loopStart > m_loopEnd) m_loopStart = m_loopEnd; + emit loopRegionChanged(); +} + +void AnimationControlController::setLoopEnd(double s) +{ + if (s < 0.0) s = 0.0; + if (qFuzzyCompare(s, m_loopEnd)) return; + m_loopEnd = s; + if (m_loopEnd > 0.0 && m_loopStart > m_loopEnd) m_loopStart = m_loopEnd; + emit loopRegionChanged(); +} + +void AnimationControlController::setLoopRegionActive(bool on) +{ + if (on == m_loopRegionActive) return; + m_loopRegionActive = on; + emit loopRegionChanged(); +} + +void AnimationControlController::setAutoKey(bool on) +{ + if (on == m_autoKey) return; + m_autoKey = on; + emit autoKeyChanged(); +} + +double AnimationControlController::advanceTime(double currentTime, double dt) const +{ + double next = currentTime + dt * m_playbackSpeed; + + // Apply loop region only when active and the region is well-formed. + if (m_loopRegionActive && m_loopEnd > m_loopStart) { + if (next > m_loopEnd) { + const double span = m_loopEnd - m_loopStart; + double over = next - m_loopEnd; + // Wrap any number of full passes back into the region. + if (span > 0.0) over = std::fmod(over, span); + next = m_loopStart + over; + } else if (next < m_loopStart) { + // Reverse wrap (negative speed); not exposed via UI today but kept + // symmetrical so callers don't trip if they ever get here. + const double span = m_loopEnd - m_loopStart; + double under = m_loopStart - next; + if (span > 0.0) under = std::fmod(under, span); + next = m_loopEnd - under; + } + } + return next; +} + +void AnimationControlController::autoKeyOnTransform() +{ + if (!m_autoKey) return; + if (!m_selectedTrack || !m_selectedEntity || m_selectedAnimation.empty()) return; + addKeyframe(); +} + void AnimationControlController::setAnimationFrame(int ms) { if (!m_selectedEntity || m_selectedAnimation.empty()) return; diff --git a/src/AnimationControlController.h b/src/AnimationControlController.h index 282d7fbc7..e6235198a 100644 --- a/src/AnimationControlController.h +++ b/src/AnimationControlController.h @@ -43,6 +43,13 @@ class AnimationControlController : public QObject Q_PROPERTY(int sliderMaximum READ sliderMaximum NOTIFY animationLengthChanged) Q_PROPERTY(double animationLength READ animationLength WRITE setAnimationLength NOTIFY animationLengthChanged) + // Playback controls (speed multiplier + loop in/out region) + Q_PROPERTY(double playbackSpeed READ playbackSpeed WRITE setPlaybackSpeed NOTIFY playbackSpeedChanged) + 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) Q_PROPERTY(int selectedTick READ selectedTick NOTIFY keyframeTicksChanged) @@ -98,6 +105,27 @@ class AnimationControlController : public QObject void setSliderValue(int ms); void setAnimationLength(double length); + // Playback speed / loop region / auto-key + double playbackSpeed() const { return m_playbackSpeed; } + 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 slider position for the active bone if + // auto-key is enabled. Called from TransformOperator end-of-drag. + void autoKeyOnTransform(); + // Keyframe ticks QVariantList keyframeTicks() const { return m_keyframeTicks; } int selectedTick() const { return m_selectedTick; } @@ -151,6 +179,9 @@ public slots: void animationLengthChanged(); void keyframeTicksChanged(); void currentKeyframeChanged(); + void playbackSpeedChanged(); + void loopRegionChanged(); + void autoKeyChanged(); private: AnimationControlController(); @@ -186,6 +217,12 @@ public slots: double m_kfTransX = 0, m_kfTransY = 0, m_kfTransZ = 0; double m_kfScaleX = 1, m_kfScaleY = 1, m_kfScaleZ = 1; double m_kfRotW = 1, m_kfRotX = 0, m_kfRotY = 0, m_kfRotZ = 0; + + double m_playbackSpeed = 1.0; + 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 cb63dd7d4..1ed57ab72 100644 --- a/src/AnimationControlController_test.cpp +++ b/src/AnimationControlController_test.cpp @@ -483,6 +483,173 @@ TEST_F(AnimationControlControllerTest, SetKfTransXWithNoKeyframeDoesNotCrash) { EXPECT_NO_THROW(ctrl->setKfTransX(1.0)); } +// ── Playback / loop / auto-key (pure-data — no Ogre needed) ─────────────────── +// +// These tests use a separate fixture that does NOT init Ogre, so they run on +// macOS too (where Ogre plugins fail to load for the test binary). + +class AnimationControlControllerPlaybackTest : public ::testing::Test { +protected: + void SetUp() override { + AnimationControlController::kill(); + app = qobject_cast(QCoreApplication::instance()); + ASSERT_NE(app, nullptr); + } + void TearDown() override { + AnimationControlController::kill(); + } + QApplication* app = nullptr; +}; + +TEST_F(AnimationControlControllerPlaybackTest, PlaybackSpeedDefaultsToOne) { + auto* ctrl = AnimationControlController::instance(); + EXPECT_DOUBLE_EQ(ctrl->playbackSpeed(), 1.0); +} + +TEST_F(AnimationControlControllerPlaybackTest, SetPlaybackSpeedClampsNegative) { + auto* ctrl = AnimationControlController::instance(); + ctrl->setPlaybackSpeed(-1.5); + EXPECT_DOUBLE_EQ(ctrl->playbackSpeed(), 0.0); +} + +TEST_F(AnimationControlControllerPlaybackTest, SetPlaybackSpeedEmitsSignalOnChange) { + auto* ctrl = AnimationControlController::instance(); + QSignalSpy spy(ctrl, &AnimationControlController::playbackSpeedChanged); + ctrl->setPlaybackSpeed(2.0); + EXPECT_EQ(spy.count(), 1); + ctrl->setPlaybackSpeed(2.0); // unchanged — no re-emit + EXPECT_EQ(spy.count(), 1); +} + +TEST_F(AnimationControlControllerPlaybackTest, AdvanceTimeScalesByPlaybackSpeed) { + auto* ctrl = AnimationControlController::instance(); + ctrl->setPlaybackSpeed(2.0); + EXPECT_DOUBLE_EQ(ctrl->advanceTime(0.0, 0.5), 1.0); + ctrl->setPlaybackSpeed(0.5); + EXPECT_DOUBLE_EQ(ctrl->advanceTime(0.0, 0.5), 0.25); + ctrl->setPlaybackSpeed(0.0); + EXPECT_DOUBLE_EQ(ctrl->advanceTime(0.5, 0.5), 0.5); +} + +TEST_F(AnimationControlControllerPlaybackTest, LoopRegionDefaultsInactive) { + auto* ctrl = AnimationControlController::instance(); + EXPECT_FALSE(ctrl->loopRegionActive()); + EXPECT_DOUBLE_EQ(ctrl->loopStart(), 0.0); + EXPECT_DOUBLE_EQ(ctrl->loopEnd(), 0.0); +} + +TEST_F(AnimationControlControllerPlaybackTest, LoopRegionInactivePassthrough) { + auto* ctrl = AnimationControlController::instance(); + ctrl->setLoopStart(0.2); + ctrl->setLoopEnd(0.8); + ctrl->setLoopRegionActive(false); + EXPECT_DOUBLE_EQ(ctrl->advanceTime(0.7, 0.5), 1.2); +} + +TEST_F(AnimationControlControllerPlaybackTest, LoopRegionWrapsAtEnd) { + auto* ctrl = AnimationControlController::instance(); + ctrl->setPlaybackSpeed(1.0); + ctrl->setLoopStart(0.2); + ctrl->setLoopEnd(0.8); + ctrl->setLoopRegionActive(true); + EXPECT_NEAR(ctrl->advanceTime(0.7, 0.2), 0.3, 1e-9); +} + +TEST_F(AnimationControlControllerPlaybackTest, LoopRegionWrapsLargeOverShoot) { + auto* ctrl = AnimationControlController::instance(); + ctrl->setPlaybackSpeed(1.0); + ctrl->setLoopStart(0.0); + ctrl->setLoopEnd(1.0); + ctrl->setLoopRegionActive(true); + EXPECT_NEAR(ctrl->advanceTime(0.9, 2.5), 0.4, 1e-9); +} + +TEST_F(AnimationControlControllerPlaybackTest, LoopRegionDegenerateNoWrap) { + auto* ctrl = AnimationControlController::instance(); + ctrl->setLoopStart(0.5); + ctrl->setLoopEnd(0.5); + ctrl->setLoopRegionActive(true); + EXPECT_DOUBLE_EQ(ctrl->advanceTime(0.6, 0.5), 1.1); +} + +TEST_F(AnimationControlControllerPlaybackTest, LoopStartClampsToEnd) { + auto* ctrl = AnimationControlController::instance(); + ctrl->setLoopEnd(0.5); + ctrl->setLoopStart(0.9); + EXPECT_LE(ctrl->loopStart(), ctrl->loopEnd()); +} + +TEST_F(AnimationControlControllerPlaybackTest, AutoKeyDefaultsOff) { + auto* ctrl = AnimationControlController::instance(); + EXPECT_FALSE(ctrl->autoKey()); +} + +TEST_F(AnimationControlControllerPlaybackTest, AutoKeyOnTransformNoOpWhenDisabled) { + auto* ctrl = AnimationControlController::instance(); + ctrl->setAutoKey(false); + EXPECT_NO_THROW(ctrl->autoKeyOnTransform()); +} + +TEST_F(AnimationControlControllerPlaybackTest, AutoKeyOnTransformNoOpWhenNoTrack) { + auto* ctrl = AnimationControlController::instance(); + ctrl->setAutoKey(true); + // No track selected → safely no-ops + EXPECT_NO_THROW(ctrl->autoKeyOnTransform()); +} + +TEST_F(AnimationControlControllerPlaybackTest, AutoKeyToggleEmitsSignal) { + auto* ctrl = AnimationControlController::instance(); + QSignalSpy spy(ctrl, &AnimationControlController::autoKeyChanged); + ctrl->setAutoKey(true); + EXPECT_EQ(spy.count(), 1); + ctrl->setAutoKey(true); // unchanged — no re-emit + EXPECT_EQ(spy.count(), 1); +} + +// ── Auto-key with a real Ogre track (Ogre-fixture) ─────────────────────────── + +TEST_F(AnimationControlControllerTest, AutoKeyOnTransformAddsKeyframeWhenEnabled) { + if (!canLoadMeshFiles()) GTEST_SKIP() << "No GL context"; + + Ogre::Entity* entity = setupAnimatedEntity("ACC_AutoKeyTest"); + ASSERT_NE(entity, nullptr); + + auto* ctrl = AnimationControlController::instance(); + ctrl->updateAnimationTree(); + ctrl->selectAnimation(QString::fromStdString(entity->getName()), "TestAnim"); + ASSERT_FALSE(ctrl->boneNames().isEmpty()); + ctrl->selectBone(ctrl->boneNames().first()); + + auto* track = entity->getSkeleton()->getAnimation("TestAnim") + ->_getNodeTrackList().begin()->second; + int before = track->getNumKeyFrames(); + + ctrl->setSliderValue(250); // between existing keyframes + ctrl->setAutoKey(true); + ctrl->autoKeyOnTransform(); + app->processEvents(); + + EXPECT_EQ(track->getNumKeyFrames(), before + 1); +} + +TEST_F(AnimationControlControllerTest, SelectAnimationResetsLoopRegion) { + if (!canLoadMeshFiles()) GTEST_SKIP() << "No GL context"; + + Ogre::Entity* entity = setupAnimatedEntity("ACC_LoopResetTest"); + ASSERT_NE(entity, nullptr); + + auto* ctrl = AnimationControlController::instance(); + ctrl->updateAnimationTree(); + ctrl->setLoopRegionActive(true); + ctrl->setLoopStart(0.3); + ctrl->setLoopEnd(0.7); + + ctrl->selectAnimation(QString::fromStdString(entity->getName()), "TestAnim"); + EXPECT_DOUBLE_EQ(ctrl->loopStart(), 0.0); + EXPECT_DOUBLE_EQ(ctrl->loopEnd(), 1.0); // animation length is 1.0s + EXPECT_FALSE(ctrl->loopRegionActive()); +} + // ── Poll timer ───────────────────────────────────────────────────────────────── TEST_F(AnimationControlControllerTest, PollTimerDoesNotCrashWithNoAnimation) { diff --git a/src/TransformOperator.cpp b/src/TransformOperator.cpp index c0ec86bcb..307dcaaa9 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 @@ -1627,6 +1628,10 @@ void TransformOperator::mouseReleaseEvent(QMouseEvent *e) mUndoStartOrientations.clear(); mUndoStartScales.clear(); mStartPoint = Ogre::Vector3::ZERO; + + // Auto-key: drop a keyframe on the active bone if enabled. + // The controller no-ops if auto-key is off or no bone is selected. + AnimationControlController::instance()->autoKeyOnTransform(); } if(m_pSelectionBox->isVisible()) diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 72d6c218e..03992e877 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1324,9 +1324,16 @@ bool MainWindow::frameStarted(const Ogre::FrameEvent &evt) bool MainWindow::frameRenderingQueued(const Ogre::FrameEvent &evt) { - // Advance time for every entity that has enabled animation states + // Advance time for every entity that has enabled animation states. + // Speed is global (scales dt for all states). The loop region applies only + // to the entity+animation selected in the Animation Control panel. if(isPlaying) { + auto* animCtrl = AnimationControlController::instance(); + const std::string activeEntity = animCtrl->selectedEntityName().toStdString(); + const std::string activeAnim = animCtrl->selectedAnimation().toStdString(); + const double dt = static_cast(evt.timeSinceLastFrame); + const double scaledDt = dt * animCtrl->playbackSpeed(); for(Ogre::SceneNode* node : Manager::getSingleton()->getSceneNodes()) { if(!node) continue; @@ -1336,12 +1343,21 @@ bool MainWindow::frameRenderingQueued(const Ogre::FrameEvent &evt) if(!obj || obj->getMovableType() != "Entity") continue; auto* ent = static_cast(obj); + const bool isActiveEntity = (!activeEntity.empty() && ent->getName() == activeEntity); Ogre::AnimationStateSet const* set = ent->getAllAnimationStates(); if(!set) continue; for(const auto& [key, value] : set->getAnimationStates()) { - if(value->getEnabled()) - value->addTime(evt.timeSinceLastFrame); + if(!value->getEnabled()) continue; + if (isActiveEntity && key == activeAnim) { + // Selected animation: speed + loop region wrap. + const double now = static_cast(value->getTimePosition()); + const double next = animCtrl->advanceTime(now, dt); + value->setTimePosition(static_cast(next)); + } else { + // All other animations: speed only, no loop region. + value->addTime(static_cast(scaledDt)); + } } } } From e7abdd47f8afb351f713f4717216180441911013 Mon Sep 17 00:00:00 2001 From: Fernando Date: Fri, 1 May 2026 02:42:40 -0400 Subject: [PATCH 2/4] fix(animation): preserve loop-handle bindings during drag Codex flagged that drag.target: parent on the loop-marker rectangles would mutate their x property directly, breaking the declarative bind to loopStart/loopEnd. After the first drag, controller-driven updates (new clip selected, length changed, external value change) could leave the handles visually out of sync. Drop drag.target and compute the new time from mouseX inside the MouseArea instead. The handle's x binding stays intact; the controller remains the single source of truth. Co-Authored-By: Claude Opus 4.7 (1M context) --- qml/AnimationControlPanel.qml | 54 ++++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/qml/AnimationControlPanel.qml b/qml/AnimationControlPanel.qml index 28a53e036..315adba98 100644 --- a/qml/AnimationControlPanel.qml +++ b/qml/AnimationControlPanel.qml @@ -548,7 +548,11 @@ Column { // Drag handles for loop in/out points — only visible when loop is active. // Sits on top of the slider so drags on the handle areas don't move the playhead. + // Handle x stays purely bound to the controller value; we compute the + // new time from mouseX deltas instead of dragging the visual item + // (drag.target on the rectangle would break the binding after release). Item { + id: loopHandlesLayer anchors.fill: parent visible: AnimationControlController.loopRegionActive @@ -556,24 +560,33 @@ Column { property real avail: width - pad * 2 property real maxMs: Math.max(1, AnimationControlController.sliderMaximum) + function pxToSec(px) { + var t = (px / avail) * (maxMs / 1000.0) + if (t < 0) t = 0 + if (t > maxMs / 1000.0) t = maxMs / 1000.0 + return t + } + Rectangle { id: loopStartHandle width: 10; height: parent.height - x: parent.pad + (AnimationControlController.loopStart * 1000 / parent.maxMs) * parent.avail - width / 2 + x: loopHandlesLayer.pad + + (AnimationControlController.loopStart * 1000 / loopHandlesLayer.maxMs) * loopHandlesLayer.avail + - width / 2 color: "transparent" MouseArea { anchors.fill: parent hoverEnabled: true cursorShape: Qt.SizeHorCursor - drag.target: parent - drag.axis: Drag.XAxis - drag.minimumX: parent.parent.pad - parent.width / 2 - drag.maximumX: parent.parent.pad + parent.parent.avail - parent.width / 2 - onPositionChanged: { - if (!drag.active) return - var px = parent.x + parent.width / 2 - parent.parent.pad - var t = (px / parent.parent.avail) * (parent.parent.maxMs / 1000.0) - if (t < 0) t = 0 + preventStealing: true + property bool dragging: false + onPressed: dragging = true + onReleased: dragging = false + onPositionChanged: function(mouse) { + if (!dragging) return + // Convert local mouseX to layer-space, then to seconds. + var layerX = loopStartHandle.x + mouse.x - loopHandlesLayer.pad + var t = loopHandlesLayer.pxToSec(layerX) if (t > AnimationControlController.loopEnd) t = AnimationControlController.loopEnd AnimationControlController.loopStart = t } @@ -583,22 +596,23 @@ Column { Rectangle { id: loopEndHandle width: 10; height: parent.height - x: parent.pad + (AnimationControlController.loopEnd * 1000 / parent.maxMs) * parent.avail - width / 2 + x: loopHandlesLayer.pad + + (AnimationControlController.loopEnd * 1000 / loopHandlesLayer.maxMs) * loopHandlesLayer.avail + - width / 2 color: "transparent" MouseArea { anchors.fill: parent hoverEnabled: true cursorShape: Qt.SizeHorCursor - drag.target: parent - drag.axis: Drag.XAxis - drag.minimumX: parent.parent.pad - parent.width / 2 - drag.maximumX: parent.parent.pad + parent.parent.avail - parent.width / 2 - onPositionChanged: { - if (!drag.active) return - var px = parent.x + parent.width / 2 - parent.parent.pad - var t = (px / parent.parent.avail) * (parent.parent.maxMs / 1000.0) + preventStealing: true + property bool dragging: false + onPressed: dragging = true + onReleased: dragging = false + onPositionChanged: function(mouse) { + if (!dragging) return + var layerX = loopEndHandle.x + mouse.x - loopHandlesLayer.pad + var t = loopHandlesLayer.pxToSec(layerX) if (t < AnimationControlController.loopStart) t = AnimationControlController.loopStart - if (t > parent.parent.maxMs / 1000.0) t = parent.parent.maxMs / 1000.0 AnimationControlController.loopEnd = t } } From 3e8e611ed0b436c71bcd82cfa2518ef76b2b9071 Mon Sep 17 00:00:00 2001 From: Fernando Date: Fri, 1 May 2026 14:07:31 -0400 Subject: [PATCH 3/4] chore(animation): drop auto-key toggle from slice A MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-key only makes sense once bones are directly manipulable in the viewport (click a bone → drag the gizmo). The original wiring fired on scene-node end-of-drag and sampled an interpolated pose, which is not what the toggle's name implies. Without a bone-gizmo there is no honest behavior to ship. Splitting it out into #358 (bone manipulation gizmo + auto-keyframe). Slice A keeps speed scaling + per-entity loop region, which work on their own. Removes: - autoKey property/setter/signal + autoKeyOnTransform() from controller - "Auto Key" toggle from AnimationControlPanel.qml - TransformOperator end-of-drag hook (and its include) - 5 auto-key tests (4 pure-data + 1 Ogre-fixture) Co-Authored-By: Claude Opus 4.7 (1M context) --- qml/AnimationControlPanel.qml | 21 +--------- src/AnimationControlController.cpp | 14 ------- src/AnimationControlController.h | 11 +---- src/AnimationControlController_test.cpp | 53 ------------------------- src/TransformOperator.cpp | 5 --- 5 files changed, 2 insertions(+), 102 deletions(-) diff --git a/qml/AnimationControlPanel.qml b/qml/AnimationControlPanel.qml index 315adba98..34937ff7f 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 + auto-key (speed lives next to Play button) ─── + // ── Playback toolbar: loop toggle (speed lives next to Play button) ─── RowLayout { width: parent.width; spacing: 6 @@ -448,25 +448,6 @@ Column { } Item { Layout.fillWidth: true } - - Rectangle { - Layout.preferredWidth: 84; 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 - } - } } // ── Timeline ────────────────────────────────────────────────────────────── diff --git a/src/AnimationControlController.cpp b/src/AnimationControlController.cpp index 4ea4bf3b0..830bfb23e 100644 --- a/src/AnimationControlController.cpp +++ b/src/AnimationControlController.cpp @@ -329,13 +329,6 @@ void AnimationControlController::setLoopRegionActive(bool on) emit loopRegionChanged(); } -void AnimationControlController::setAutoKey(bool on) -{ - if (on == m_autoKey) return; - m_autoKey = on; - emit autoKeyChanged(); -} - double AnimationControlController::advanceTime(double currentTime, double dt) const { double next = currentTime + dt * m_playbackSpeed; @@ -360,13 +353,6 @@ double AnimationControlController::advanceTime(double currentTime, double dt) co return next; } -void AnimationControlController::autoKeyOnTransform() -{ - if (!m_autoKey) return; - if (!m_selectedTrack || !m_selectedEntity || m_selectedAnimation.empty()) return; - addKeyframe(); -} - void AnimationControlController::setAnimationFrame(int ms) { if (!m_selectedEntity || m_selectedAnimation.empty()) return; diff --git a/src/AnimationControlController.h b/src/AnimationControlController.h index e6235198a..e299c7037 100644 --- a/src/AnimationControlController.h +++ b/src/AnimationControlController.h @@ -48,7 +48,6 @@ 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) @@ -105,27 +104,21 @@ class AnimationControlController : public QObject void setSliderValue(int ms); void setAnimationLength(double length); - // Playback speed / loop region / auto-key + // Playback speed / loop region double playbackSpeed() const { return m_playbackSpeed; } 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 slider position for the active bone if - // auto-key is enabled. Called from TransformOperator end-of-drag. - void autoKeyOnTransform(); - // Keyframe ticks QVariantList keyframeTicks() const { return m_keyframeTicks; } int selectedTick() const { return m_selectedTick; } @@ -181,7 +174,6 @@ public slots: void currentKeyframeChanged(); void playbackSpeedChanged(); void loopRegionChanged(); - void autoKeyChanged(); private: AnimationControlController(); @@ -222,7 +214,6 @@ 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 1ed57ab72..d66b5ce46 100644 --- a/src/AnimationControlController_test.cpp +++ b/src/AnimationControlController_test.cpp @@ -579,59 +579,6 @@ TEST_F(AnimationControlControllerPlaybackTest, LoopStartClampsToEnd) { EXPECT_LE(ctrl->loopStart(), ctrl->loopEnd()); } -TEST_F(AnimationControlControllerPlaybackTest, AutoKeyDefaultsOff) { - auto* ctrl = AnimationControlController::instance(); - EXPECT_FALSE(ctrl->autoKey()); -} - -TEST_F(AnimationControlControllerPlaybackTest, AutoKeyOnTransformNoOpWhenDisabled) { - auto* ctrl = AnimationControlController::instance(); - ctrl->setAutoKey(false); - EXPECT_NO_THROW(ctrl->autoKeyOnTransform()); -} - -TEST_F(AnimationControlControllerPlaybackTest, AutoKeyOnTransformNoOpWhenNoTrack) { - auto* ctrl = AnimationControlController::instance(); - ctrl->setAutoKey(true); - // No track selected → safely no-ops - EXPECT_NO_THROW(ctrl->autoKeyOnTransform()); -} - -TEST_F(AnimationControlControllerPlaybackTest, AutoKeyToggleEmitsSignal) { - auto* ctrl = AnimationControlController::instance(); - QSignalSpy spy(ctrl, &AnimationControlController::autoKeyChanged); - ctrl->setAutoKey(true); - EXPECT_EQ(spy.count(), 1); - ctrl->setAutoKey(true); // unchanged — no re-emit - EXPECT_EQ(spy.count(), 1); -} - -// ── Auto-key with a real Ogre track (Ogre-fixture) ─────────────────────────── - -TEST_F(AnimationControlControllerTest, AutoKeyOnTransformAddsKeyframeWhenEnabled) { - if (!canLoadMeshFiles()) GTEST_SKIP() << "No GL context"; - - Ogre::Entity* entity = setupAnimatedEntity("ACC_AutoKeyTest"); - ASSERT_NE(entity, nullptr); - - auto* ctrl = AnimationControlController::instance(); - ctrl->updateAnimationTree(); - ctrl->selectAnimation(QString::fromStdString(entity->getName()), "TestAnim"); - ASSERT_FALSE(ctrl->boneNames().isEmpty()); - ctrl->selectBone(ctrl->boneNames().first()); - - auto* track = entity->getSkeleton()->getAnimation("TestAnim") - ->_getNodeTrackList().begin()->second; - int before = track->getNumKeyFrames(); - - ctrl->setSliderValue(250); // between existing keyframes - ctrl->setAutoKey(true); - ctrl->autoKeyOnTransform(); - app->processEvents(); - - EXPECT_EQ(track->getNumKeyFrames(), before + 1); -} - TEST_F(AnimationControlControllerTest, SelectAnimationResetsLoopRegion) { if (!canLoadMeshFiles()) GTEST_SKIP() << "No GL context"; diff --git a/src/TransformOperator.cpp b/src/TransformOperator.cpp index 307dcaaa9..c0ec86bcb 100755 --- a/src/TransformOperator.cpp +++ b/src/TransformOperator.cpp @@ -23,7 +23,6 @@ #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 @@ -1628,10 +1627,6 @@ void TransformOperator::mouseReleaseEvent(QMouseEvent *e) mUndoStartOrientations.clear(); mUndoStartScales.clear(); mStartPoint = Ogre::Vector3::ZERO; - - // Auto-key: drop a keyframe on the active bone if enabled. - // The controller no-ops if auto-key is off or no bone is selected. - AnimationControlController::instance()->autoKeyOnTransform(); } if(m_pSelectionBox->isVisible()) From 6bcc2e1ee1d5aa99571e3140f6e530f32490842a Mon Sep 17 00:00:00 2001 From: Fernando Date: Fri, 1 May 2026 22:52:46 -0400 Subject: [PATCH 4/4] fix(animation): address SonarCloud + CodeRabbit review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mainwindow.cpp (Sonar S5350): animCtrl is now const auto*. - mainwindow.cpp (Sonar S5827 ×2): use auto for static_cast results where the type is already on the RHS. - AnimationControlController.cpp (Sonar S2681 ×2 / CodeRabbit nitpick): brace single-line ifs in setLoopStart / setLoopEnd, and clamp before the qFuzzyCompare bail-out so we don't emit loopRegionChanged when the request collapses to the existing value. - AnimationControlPanel.qml (CodeRabbit major): drop the hard-coded pad=13 / avail=width-26 in the timeline canvas and loop-handles layer; bind to timeSlider.leftPadding and timeSlider.availableWidth so the loop shading and drag handles track the slider groove across Qt styles, DPI settings, and platforms. - PropertiesPanel.qml (CodeRabbit minor): speed combobox now picks the nearest preset rather than silently showing 1× when the controller's value isn't an exact match. - AnimationControlController_test.cpp: align with master PR #355's new pattern — ASSERT_TRUE(canLoadMeshFiles()) instead of GTEST_SKIP() (CI now requires headless GL to work). Co-Authored-By: Claude Opus 4.7 (1M context) --- qml/AnimationControlPanel.qml | 11 ++++++++--- qml/PropertiesPanel.qml | 12 +++++++++--- src/AnimationControlController.cpp | 13 ++++++++++--- src/AnimationControlController_test.cpp | 2 +- src/mainwindow.cpp | 6 +++--- 5 files changed, 31 insertions(+), 13 deletions(-) diff --git a/qml/AnimationControlPanel.qml b/qml/AnimationControlPanel.qml index 34937ff7f..19ebaaa97 100644 --- a/qml/AnimationControlPanel.qml +++ b/qml/AnimationControlPanel.qml @@ -485,7 +485,10 @@ Column { onPaint: { var ctx = getContext("2d"); ctx.clearRect(0, 0, width, height) var maxMs = AnimationControlController.sliderMaximum; if (maxMs <= 0) return - var pad = 13; var avail = width - pad * 2 + // Use the slider's actual layout so loop shading + tick marks + // track the slider groove across Qt styles, DPI, and platforms. + var pad = timeSlider.leftPadding + var avail = timeSlider.availableWidth // Loop region shading (drawn under keyframe ticks) if (AnimationControlController.loopRegionActive) { @@ -537,8 +540,10 @@ Column { anchors.fill: parent visible: AnimationControlController.loopRegionActive - property real pad: 13 - property real avail: width - pad * 2 + // Bind to the slider's actual layout so handles stay aligned + // with the groove regardless of style/DPI. + property real pad: timeSlider.leftPadding + property real avail: timeSlider.availableWidth property real maxMs: Math.max(1, AnimationControlController.sliderMaximum) function pxToSec(px) { diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 1a10fd77c..bf058aa63 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -1620,11 +1620,17 @@ Rectangle { anchors.verticalCenter: parent.verticalCenter model: ["0.25x", "0.5x", "1x", "2x", "4x"] property var values: [0.25, 0.5, 1.0, 2.0, 4.0] + // Pick the nearest preset rather than silently falling back + // to 1× when the controller's value isn't an exact match. currentIndex: { var s = AnimationControlController.playbackSpeed - for (var i = 0; i < values.length; i++) - if (Math.abs(values[i] - s) < 1e-6) return i - return 2 + var bestIndex = 0 + var bestDiff = Math.abs(values[0] - s) + for (var i = 1; i < values.length; ++i) { + var diff = Math.abs(values[i] - s) + if (diff < bestDiff) { bestDiff = diff; bestIndex = i } + } + return bestIndex } onActivated: AnimationControlController.playbackSpeed = values[currentIndex] font.pixelSize: 11 diff --git a/src/AnimationControlController.cpp b/src/AnimationControlController.cpp index 830bfb23e..649726fb7 100644 --- a/src/AnimationControlController.cpp +++ b/src/AnimationControlController.cpp @@ -294,7 +294,7 @@ void AnimationControlController::setAnimationLength(double length) emit animationLengthChanged(); } -// ── Playback speed / loop region / auto-key ─────────────────────────────────── +// ── Playback speed / loop region ────────────────────────────────────────────── void AnimationControlController::setPlaybackSpeed(double s) { @@ -307,9 +307,14 @@ void AnimationControlController::setPlaybackSpeed(double s) void AnimationControlController::setLoopStart(double s) { if (s < 0.0) s = 0.0; + // Clamp first, then bail out if nothing actually changed — avoids + // emitting loopRegionChanged when the request collapses to the + // existing value after clamping. + if (m_loopEnd > 0.0 && s > m_loopEnd) { + s = m_loopEnd; + } if (qFuzzyCompare(s, m_loopStart)) return; m_loopStart = s; - if (m_loopEnd > 0.0 && m_loopStart > m_loopEnd) m_loopStart = m_loopEnd; emit loopRegionChanged(); } @@ -318,7 +323,9 @@ void AnimationControlController::setLoopEnd(double s) if (s < 0.0) s = 0.0; if (qFuzzyCompare(s, m_loopEnd)) return; m_loopEnd = s; - if (m_loopEnd > 0.0 && m_loopStart > m_loopEnd) m_loopStart = m_loopEnd; + if (m_loopEnd > 0.0 && m_loopStart > m_loopEnd) { + m_loopStart = m_loopEnd; + } emit loopRegionChanged(); } diff --git a/src/AnimationControlController_test.cpp b/src/AnimationControlController_test.cpp index d66b5ce46..9ce1089bb 100644 --- a/src/AnimationControlController_test.cpp +++ b/src/AnimationControlController_test.cpp @@ -580,7 +580,7 @@ TEST_F(AnimationControlControllerPlaybackTest, LoopStartClampsToEnd) { } TEST_F(AnimationControlControllerTest, SelectAnimationResetsLoopRegion) { - if (!canLoadMeshFiles()) GTEST_SKIP() << "No GL context"; + ASSERT_TRUE(canLoadMeshFiles()); Ogre::Entity* entity = setupAnimatedEntity("ACC_LoopResetTest"); ASSERT_NE(entity, nullptr); diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 03992e877..ea677a104 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1329,10 +1329,10 @@ bool MainWindow::frameRenderingQueued(const Ogre::FrameEvent &evt) // to the entity+animation selected in the Animation Control panel. if(isPlaying) { - auto* animCtrl = AnimationControlController::instance(); + const auto* animCtrl = AnimationControlController::instance(); const std::string activeEntity = animCtrl->selectedEntityName().toStdString(); const std::string activeAnim = animCtrl->selectedAnimation().toStdString(); - const double dt = static_cast(evt.timeSinceLastFrame); + const auto dt = static_cast(evt.timeSinceLastFrame); const double scaledDt = dt * animCtrl->playbackSpeed(); for(Ogre::SceneNode* node : Manager::getSingleton()->getSceneNodes()) { @@ -1351,7 +1351,7 @@ bool MainWindow::frameRenderingQueued(const Ogre::FrameEvent &evt) if(!value->getEnabled()) continue; if (isActiveEntity && key == activeAnim) { // Selected animation: speed + loop region wrap. - const double now = static_cast(value->getTimePosition()); + const auto now = static_cast(value->getTimePosition()); const double next = animCtrl->advanceTime(now, dt); value->setTimePosition(static_cast(next)); } else {