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
124 changes: 123 additions & 1 deletion qml/AnimationControlPanel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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)
Expand All @@ -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
}
}
}
}
}
Expand Down
30 changes: 29 additions & 1 deletion qml/PropertiesPanel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
font.pixelSize: 11
}
}

// Per-entity groups
Expand Down
73 changes: 73 additions & 0 deletions src/AnimationControlController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,13 @@ void AnimationControlController::selectAnimation(const QString& entityName, cons
m_sliderMaximum = static_cast<int>(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();
Expand Down Expand Up @@ -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;
Expand Down
28 changes: 28 additions & 0 deletions src/AnimationControlController.h
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -151,6 +172,8 @@ public slots:
void animationLengthChanged();
void keyframeTicksChanged();
void currentKeyframeChanged();
void playbackSpeedChanged();
void loopRegionChanged();

private:
AnimationControlController();
Expand Down Expand Up @@ -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
Loading
Loading