diff --git a/qml/AnimationControlPanel.qml b/qml/AnimationControlPanel.qml index 2695ced76..19ebaaa97 100644 --- a/qml/AnimationControlPanel.qml +++ b/qml/AnimationControlPanel.qml @@ -424,6 +424,32 @@ Column { ToolBtn { label: "-KF"; enabled: AnimationControlController.canDeleteKeyframe; onClicked: AnimationControlController.deleteKeyframe() } } + // ── Playback toolbar: loop toggle (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 } + } + // ── Timeline ────────────────────────────────────────────────────────────── RowLayout { width: parent.width; height: 28; spacing: 4 @@ -459,7 +485,27 @@ 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) { + 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 +526,82 @@ 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. + // 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 + + // 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) { + 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: loopHandlesLayer.pad + + (AnimationControlController.loopStart * 1000 / loopHandlesLayer.maxMs) * loopHandlesLayer.avail + - width / 2 + color: "transparent" + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.SizeHorCursor + 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 + } + } + } + + Rectangle { + id: loopEndHandle + width: 10; height: parent.height + x: loopHandlesLayer.pad + + (AnimationControlController.loopEnd * 1000 / loopHandlesLayer.maxMs) * loopHandlesLayer.avail + - width / 2 + color: "transparent" + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.SizeHorCursor + 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 + AnimationControlController.loopEnd = t + } + } } } } diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index d4020334f..bf058aa63 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,34 @@ 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] + // 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 + 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 + } } // Per-entity groups diff --git a/src/AnimationControlController.cpp b/src/AnimationControlController.cpp index d63117436..649726fb7 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,72 @@ void AnimationControlController::setAnimationLength(double length) emit animationLengthChanged(); } +// ── Playback speed / loop region ────────────────────────────────────────────── + +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; + // 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; + 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(); +} + +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::setAnimationFrame(int ms) { if (!m_selectedEntity || m_selectedAnimation.empty()) return; diff --git a/src/AnimationControlController.h b/src/AnimationControlController.h index 282d7fbc7..e299c7037 100644 --- a/src/AnimationControlController.h +++ b/src/AnimationControlController.h @@ -43,6 +43,12 @@ 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) + // 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 +104,21 @@ class AnimationControlController : public QObject void setSliderValue(int ms); void setAnimationLength(double length); + // 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; } + void setPlaybackSpeed(double s); + void setLoopStart(double s); + void setLoopEnd(double s); + void setLoopRegionActive(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; + // Keyframe ticks QVariantList keyframeTicks() const { return m_keyframeTicks; } int selectedTick() const { return m_selectedTick; } @@ -151,6 +172,8 @@ public slots: void animationLengthChanged(); void keyframeTicksChanged(); void currentKeyframeChanged(); + void playbackSpeedChanged(); + void loopRegionChanged(); private: AnimationControlController(); @@ -186,6 +209,11 @@ 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; }; #endif // ANIMATIONCONTROLCONTROLLER_H diff --git a/src/AnimationControlController_test.cpp b/src/AnimationControlController_test.cpp index cb63dd7d4..9ce1089bb 100644 --- a/src/AnimationControlController_test.cpp +++ b/src/AnimationControlController_test.cpp @@ -483,6 +483,120 @@ 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(AnimationControlControllerTest, SelectAnimationResetsLoopRegion) { + ASSERT_TRUE(canLoadMeshFiles()); + + 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/mainwindow.cpp b/src/mainwindow.cpp index 72d6c218e..ea677a104 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) { + const auto* animCtrl = AnimationControlController::instance(); + const std::string activeEntity = animCtrl->selectedEntityName().toStdString(); + const std::string activeAnim = animCtrl->selectedAnimation().toStdString(); + const auto 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 auto 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)); + } } } }