diff --git a/CMakeLists.txt b/CMakeLists.txt index 6afc07885..82673fb99 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -13,7 +13,7 @@ cmake_minimum_required(VERSION 3.24.0) cmake_policy(SET CMP0005 NEW) cmake_policy(SET CMP0048 NEW) # manages project version -project(QtMeshEditor VERSION 2.17.1 LANGUAGES C CXX) +project(QtMeshEditor VERSION 2.18.0 LANGUAGES C CXX) message(STATUS "Building QtMeshEditor version ${PROJECT_VERSION}") set(QTMESHEDITOR_VERSION_STRING "\"${PROJECT_VERSION}\"") diff --git a/qml/AnimationControlPanel.qml b/qml/AnimationControlPanel.qml new file mode 100644 index 000000000..90ecacfa7 --- /dev/null +++ b/qml/AnimationControlPanel.qml @@ -0,0 +1,536 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import AnimationControl 1.0 + +Column { + id: root + width: parent ? parent.width : 300 + spacing: 6 + padding: 0 + + // ── KFTransformField — mirrors TransformField.qml but uses AnimationControl theme ── + component KFTransformField: Row { + id: kfRoot + property string label: "X" + property real val: 0.0 + property color labelColor: "#c04040" + property bool editable: false + property real step: 0.0001 + property int decimals: 4 + signal committed(real v) + + spacing: 2 + opacity: editable ? 1.0 : 0.45 + + Rectangle { + width: 16; height: 22; radius: 2 + color: kfRoot.labelColor + Text { anchors.centerIn: parent; text: kfRoot.label; color: "white"; font.pixelSize: 10; font.bold: true } + } + + Rectangle { + id: kfInputBg + width: kfRoot.width - 18; height: 22 + color: AnimationControlController.inputColor + border.color: kfIn.activeFocus ? kfRoot.labelColor : AnimationControlController.borderColor + border.width: 1; radius: 2 + + TextInput { + id: kfIn + anchors.left: parent.left; anchors.right: kfArrows.left + anchors.top: parent.top; anchors.bottom: parent.bottom; anchors.margins: 2 + text: kfRoot.val.toFixed(kfRoot.decimals) + color: AnimationControlController.textColor + font.pixelSize: 11 + verticalAlignment: TextInput.AlignVCenter + selectByMouse: true; clip: true + readOnly: !kfRoot.editable + validator: DoubleValidator { decimals: kfRoot.decimals + 1; notation: DoubleValidator.StandardNotation } + + onEditingFinished: { if (kfRoot.editable) { var v = parseFloat(text); if (!isNaN(v)) kfRoot.committed(v) } } + Keys.onUpPressed: { if (kfRoot.editable) { var v = parseFloat(text) + kfRoot.step; text = v.toFixed(kfRoot.decimals); kfRoot.committed(v) } } + Keys.onDownPressed: { if (kfRoot.editable) { var v = parseFloat(text) - kfRoot.step; text = v.toFixed(kfRoot.decimals); kfRoot.committed(v) } } + + Connections { + target: AnimationControlController + function onCurrentKeyframeChanged() { if (!kfIn.activeFocus) kfIn.text = kfRoot.val.toFixed(kfRoot.decimals) } + } + } + + Column { + id: kfArrows + anchors.right: parent.right; anchors.top: parent.top; anchors.bottom: parent.bottom + width: 14 + + Rectangle { + width: parent.width; height: parent.height / 2 + color: upMa.pressed ? Qt.darker(AnimationControlController.panelColor, 1.2) + : upMa.containsMouse ? Qt.lighter(AnimationControlController.panelColor, 1.2) + : AnimationControlController.panelColor + border.color: AnimationControlController.borderColor; border.width: 1 + Text { anchors.centerIn: parent; text: "\u25B2"; font.pixelSize: 6; color: AnimationControlController.textColor } + MouseArea { id: upMa; anchors.fill: parent; hoverEnabled: true; enabled: kfRoot.editable + onClicked: { var base = parseFloat(kfIn.text); if (!isNaN(base)) { var v = base + kfRoot.step; kfIn.text = v.toFixed(kfRoot.decimals); kfRoot.committed(v) } } + } + } + Rectangle { + width: parent.width; height: parent.height / 2 + color: downMa.pressed ? Qt.darker(AnimationControlController.panelColor, 1.2) + : downMa.containsMouse ? Qt.lighter(AnimationControlController.panelColor, 1.2) + : AnimationControlController.panelColor + border.color: AnimationControlController.borderColor; border.width: 1 + Text { anchors.centerIn: parent; text: "\u25BC"; font.pixelSize: 6; color: AnimationControlController.textColor } + MouseArea { id: downMa; anchors.fill: parent; hoverEnabled: true; enabled: kfRoot.editable + onClicked: { var base = parseFloat(kfIn.text); if (!isNaN(base)) { var v = base - kfRoot.step; kfIn.text = v.toFixed(kfRoot.decimals); kfRoot.committed(v) } } + } + } + } + } + } + + // ── Toolbar button ──────────────────────────────────────────────────────── + component ToolBtn: Rectangle { + property string label: "" + property bool enabled: true + signal clicked() + width: Math.max(28, lblT.implicitWidth + 10); height: 22; radius: 3 + color: maT.pressed ? Qt.darker(AnimationControlController.buttonColor, 1.3) + : maT.containsMouse ? Qt.lighter(AnimationControlController.buttonColor, 1.15) + : AnimationControlController.buttonColor + border.color: AnimationControlController.borderColor; border.width: 1 + opacity: enabled ? 1.0 : 0.4 + Text { id: lblT; anchors.centerIn: parent; text: parent.label; color: AnimationControlController.buttonTextColor; font.pixelSize: 11 } + MouseArea { id: maT; anchors.fill: parent; hoverEnabled: true; enabled: parent.enabled; onClicked: parent.clicked() } + } + + // ── Animation typeahead ─────────────────────────────────────────────────── + Text { text: "Animation:"; color: AnimationControlController.textColor; font.pixelSize: 11 } + + Item { + id: animSelector + width: parent.width; height: 24 + property bool dropdownOpen: false + + property var flatAnims: { + var list = [] + var tree = AnimationControlController.animationTree + for (var i = 0; i < tree.length; i++) { + var g = tree[i] + for (var j = 0; j < g.animations.length; j++) + list.push({ label: g.entity + " / " + g.animations[j], entity: g.entity, anim: g.animations[j] }) + } + return list + } + + property string currentLabel: { + var e = AnimationControlController.selectedEntityName + var a = AnimationControlController.selectedAnimation + return (e && a) ? (e + " / " + a) : "(none)" + } + + Connections { + target: AnimationControlController + function onSelectionChanged() { animSelector.currentLabel = Qt.binding(function() { + var e = AnimationControlController.selectedEntityName + var a = AnimationControlController.selectedAnimation + return (e && a) ? (e + " / " + a) : "(none)" + })} + function onAnimationTreeChanged() { animSelector.dropdownOpen = false } + } + + Rectangle { + anchors.fill: parent; radius: 3 + color: animSelectorMouse.pressed ? Qt.darker(AnimationControlController.buttonColor, 1.2) + : animSelectorMouse.containsMouse ? Qt.lighter(AnimationControlController.buttonColor, 1.1) + : AnimationControlController.buttonColor + border.color: animSelector.dropdownOpen ? AnimationControlController.highlightColor + : AnimationControlController.borderColor + border.width: 1 + + Row { + anchors.fill: parent; anchors.leftMargin: 8; anchors.rightMargin: 4; spacing: 4 + Text { + text: animSelector.currentLabel + color: AnimationControlController.buttonTextColor; font.pixelSize: 11 + elide: Text.ElideRight; anchors.verticalCenter: parent.verticalCenter + width: parent.width - 18 + } + Text { + text: animSelector.dropdownOpen ? "\u25B2" : "\u25BC" + color: AnimationControlController.buttonTextColor; font.pixelSize: 8 + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: animSelectorMouse; anchors.fill: parent; hoverEnabled: true + onClicked: { + animSelector.dropdownOpen = !animSelector.dropdownOpen + if (animSelector.dropdownOpen) { animFilter.text = ""; animFilter.forceActiveFocus() } + } + } + } + + Popup { + id: animDropdown + visible: animSelector.dropdownOpen + x: 0; y: animSelector.height + 2 + width: animSelector.width + height: Math.min(animListView.contentHeight + 30, 200) + padding: 0 + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent + onClosed: animSelector.dropdownOpen = false + + background: Rectangle { + color: AnimationControlController.inputColor + border.color: AnimationControlController.borderColor; border.width: 1; radius: 3 + } + + Column { + anchors.fill: parent; spacing: 0 + + Rectangle { + width: parent.width; height: 26 + color: AnimationControlController.panelColor + border.color: AnimationControlController.borderColor; border.width: 1; radius: 3 + + TextInput { + id: animFilter + anchors.fill: parent; anchors.margins: 4 + color: AnimationControlController.textColor; font.pixelSize: 11; clip: true + verticalAlignment: TextInput.AlignVCenter + + property var filtered: { + var q = text.toLowerCase() + var all = animSelector.flatAnims + if (q.length === 0) return all + var r = [] + for (var i = 0; i < all.length; i++) + if (all[i].label.toLowerCase().indexOf(q) >= 0) r.push(all[i]) + return r + } + + Keys.onEscapePressed: animSelector.dropdownOpen = false + Keys.onReturnPressed: { + if (filtered.length > 0) { + AnimationControlController.selectAnimation(filtered[0].entity, filtered[0].anim) + animSelector.dropdownOpen = false + } + } + } + + Text { + anchors.fill: parent; anchors.margins: 4 + text: "Type to filter..."; font.pixelSize: 11; font.italic: true + color: AnimationControlController.borderColor + visible: animFilter.text.length === 0 && !animFilter.activeFocus + verticalAlignment: Text.AlignVCenter + } + } + + ListView { + id: animListView + width: parent.width; height: parent.height - 26 + model: animFilter.filtered; clip: true + + delegate: Rectangle { + width: animListView.width; height: 22 + color: animDelegateMouse.containsMouse ? AnimationControlController.highlightColor : "transparent" + + Text { + anchors.left: parent.left; anchors.leftMargin: 8 + anchors.verticalCenter: parent.verticalCenter + text: modelData.label + color: animDelegateMouse.containsMouse ? "white" : AnimationControlController.textColor + font.pixelSize: 11; elide: Text.ElideRight + } + MouseArea { + id: animDelegateMouse; anchors.fill: parent; hoverEnabled: true + onClicked: { + AnimationControlController.selectAnimation(modelData.entity, modelData.anim) + animSelector.dropdownOpen = false + } + } + } + } + } + } + } + + // ── Bone typeahead ──────────────────────────────────────────────────────── + Text { text: "Bone:"; color: AnimationControlController.textColor; font.pixelSize: 11 } + + Item { + id: boneSelector + width: parent.width; height: 24 + property bool dropdownOpen: false + + Rectangle { + anchors.fill: parent; radius: 3 + color: boneSelectorMouse.pressed ? Qt.darker(AnimationControlController.buttonColor, 1.2) + : boneSelectorMouse.containsMouse ? Qt.lighter(AnimationControlController.buttonColor, 1.1) + : AnimationControlController.buttonColor + border.color: boneSelector.dropdownOpen ? AnimationControlController.highlightColor + : AnimationControlController.borderColor + border.width: 1 + + Row { + anchors.fill: parent; anchors.leftMargin: 8; anchors.rightMargin: 4; spacing: 4 + Text { + text: AnimationControlController.selectedBone || "(none)" + color: AnimationControlController.buttonTextColor; font.pixelSize: 11 + elide: Text.ElideRight; anchors.verticalCenter: parent.verticalCenter + width: parent.width - 18 + } + Text { + text: boneSelector.dropdownOpen ? "\u25B2" : "\u25BC" + color: AnimationControlController.buttonTextColor; font.pixelSize: 8 + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: boneSelectorMouse; anchors.fill: parent; hoverEnabled: true + onClicked: { + boneSelector.dropdownOpen = !boneSelector.dropdownOpen + if (boneSelector.dropdownOpen) { boneFilter.text = ""; boneFilter.forceActiveFocus() } + } + } + } + + Connections { + target: AnimationControlController + function onBoneListChanged() { boneSelector.dropdownOpen = false } + } + + Popup { + id: boneDropdown + visible: boneSelector.dropdownOpen + x: 0; y: boneSelector.height + 2 + width: boneSelector.width + height: Math.min(boneListView.contentHeight + 30, 160) + padding: 0 + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent + onClosed: boneSelector.dropdownOpen = false + + background: Rectangle { + color: AnimationControlController.inputColor + border.color: AnimationControlController.borderColor; border.width: 1; radius: 3 + } + + Column { + anchors.fill: parent; spacing: 0 + + Rectangle { + width: parent.width; height: 26 + color: AnimationControlController.panelColor + border.color: AnimationControlController.borderColor; border.width: 1; radius: 3 + + TextInput { + id: boneFilter + anchors.fill: parent; anchors.margins: 4 + color: AnimationControlController.textColor; font.pixelSize: 11; clip: true + verticalAlignment: TextInput.AlignVCenter + + property var filtered: { + var q = text.toLowerCase() + var bones = AnimationControlController.boneNames + if (q.length === 0) return bones + var r = [] + for (var i = 0; i < bones.length; i++) + if (bones[i].toLowerCase().indexOf(q) >= 0) r.push(bones[i]) + return r + } + + Keys.onEscapePressed: boneSelector.dropdownOpen = false + Keys.onReturnPressed: { + if (filtered.length > 0) { + AnimationControlController.selectBone(filtered[0]) + boneSelector.dropdownOpen = false + } + } + } + + Text { + anchors.fill: parent; anchors.margins: 4 + text: "Type to filter..."; font.pixelSize: 11; font.italic: true + color: AnimationControlController.borderColor + visible: boneFilter.text.length === 0 && !boneFilter.activeFocus + verticalAlignment: Text.AlignVCenter + } + } + + ListView { + id: boneListView + width: parent.width; height: parent.height - 26 + model: boneFilter.filtered; clip: true + + delegate: Rectangle { + width: boneListView.width; height: 22 + color: boneDelegateMouse.containsMouse ? AnimationControlController.highlightColor : "transparent" + + Text { + anchors.left: parent.left; anchors.leftMargin: 8 + anchors.verticalCenter: parent.verticalCenter + text: modelData + color: boneDelegateMouse.containsMouse ? "white" : AnimationControlController.textColor + font.pixelSize: 11; elide: Text.ElideRight + } + MouseArea { + id: boneDelegateMouse; anchors.fill: parent; hoverEnabled: true + onClicked: { AnimationControlController.selectBone(modelData); boneSelector.dropdownOpen = false } + } + } + } + } + } + } + + // ── Length + keyframe nav ───────────────────────────────────────────────── + RowLayout { + width: parent.width; spacing: 4 + + Text { text: "Length:"; color: AnimationControlController.textColor; font.pixelSize: 11; Layout.alignment: Qt.AlignVCenter } + + Rectangle { + Layout.preferredWidth: 58; height: 22; radius: 2 + color: AnimationControlController.hasAnimation ? AnimationControlController.inputColor + : Qt.darker(AnimationControlController.panelColor, 1.12) + border.color: AnimationControlController.borderColor; border.width: 1; Layout.alignment: Qt.AlignVCenter + + TextInput { + id: lengthInput + anchors.fill: parent; anchors.margins: 4 + text: AnimationControlController.animationLength.toFixed(3) + color: AnimationControlController.hasAnimation ? AnimationControlController.textColor + : AnimationControlController.disabledTextColor + font.pixelSize: 11; readOnly: !AnimationControlController.hasAnimation + selectByMouse: true; verticalAlignment: Text.AlignVCenter + validator: DoubleValidator { bottom: 0.001; decimals: 3 } + onEditingFinished: { var v = parseFloat(text); if (!isNaN(v) && v > 0) AnimationControlController.animationLength = v } + Connections { + target: AnimationControlController + function onAnimationLengthChanged() { if (!lengthInput.activeFocus) lengthInput.text = AnimationControlController.animationLength.toFixed(3) } + } + } + } + + Text { text: "s"; color: AnimationControlController.textColor; font.pixelSize: 11; Layout.alignment: Qt.AlignVCenter } + Item { Layout.fillWidth: true } + ToolBtn { label: "|<"; enabled: AnimationControlController.hasPrevKeyframe; onClicked: AnimationControlController.prevKeyframe() } + ToolBtn { label: ">|"; enabled: AnimationControlController.hasNextKeyframe; onClicked: AnimationControlController.nextKeyframe() } + ToolBtn { label: "+KF"; enabled: AnimationControlController.hasAnimation; onClicked: AnimationControlController.addKeyframe() } + ToolBtn { label: "-KF"; enabled: AnimationControlController.canDeleteKeyframe; onClicked: AnimationControlController.deleteKeyframe() } + } + + // ── Timeline ────────────────────────────────────────────────────────────── + RowLayout { + width: parent.width; height: 28; spacing: 4 + + Text { text: "0"; color: AnimationControlController.textColor; font.pixelSize: 10; Layout.alignment: Qt.AlignVCenter } + + Item { + Layout.fillWidth: true; height: 28 + + Slider { + id: timeSlider + anchors.fill: parent; from: 0; to: AnimationControlController.sliderMaximum; stepSize: 1 + value: AnimationControlController.sliderValue + onMoved: AnimationControlController.sliderValue = value + + background: Rectangle { + x: timeSlider.leftPadding; y: timeSlider.topPadding + timeSlider.availableHeight / 2 - height / 2 + width: timeSlider.availableWidth; height: 4; radius: 2 + color: AnimationControlController.inputColor + Rectangle { width: timeSlider.visualPosition * parent.width; height: parent.height; radius: 2; color: AnimationControlController.highlightColor } + } + handle: Rectangle { + x: timeSlider.leftPadding + timeSlider.visualPosition * (timeSlider.availableWidth - width) + y: timeSlider.topPadding + timeSlider.availableHeight / 2 - height / 2 + width: 12; height: 12; radius: 6 + color: AnimationControlController.highlightColor + border.color: Qt.lighter(AnimationControlController.highlightColor, 1.4); border.width: 1.5 + } + } + + Canvas { + id: tickCanvas; anchors.fill: parent; enabled: false + 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 + 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) + if (isSel) { + ctx.strokeStyle = "#ff4444"; ctx.lineWidth = 3 + ctx.beginPath(); ctx.moveTo(x, 4); ctx.lineTo(x, height); ctx.stroke() + ctx.fillStyle = "#ff4444" + ctx.beginPath(); ctx.moveTo(x - 5, 2); ctx.lineTo(x + 5, 2); ctx.lineTo(x, 8); ctx.closePath(); ctx.fill() + } else { + ctx.strokeStyle = "#ffcc00"; ctx.lineWidth = 1.5 + ctx.beginPath(); ctx.moveTo(x, 2); ctx.lineTo(x, height - 2); ctx.stroke() + } + } + } + Connections { + target: AnimationControlController + function onKeyframeTicksChanged() { tickCanvas.requestPaint() } + function onAnimationLengthChanged() { tickCanvas.requestPaint() } + function onThemeChanged() { tickCanvas.requestPaint() } + function onSliderValueChanged() { tickCanvas.requestPaint() } + } + } + } + + Text { text: AnimationControlController.animationLength.toFixed(2) + "s"; color: AnimationControlController.textColor; font.pixelSize: 10; Layout.alignment: Qt.AlignVCenter } + } + + // ── Keyframe T / S / R value sections ──────────────────────────────────── + Column { + visible: AnimationControlController.hasAnimation + width: parent.width; spacing: 6 + + // ── Translate ──────────────────────────────────────────────────── + Text { text: "Translate"; color: AnimationControlController.textColor; font.pixelSize: 11; font.bold: true } + Row { + spacing: 4; width: parent.width + KFTransformField { label: "X"; val: AnimationControlController.kfTransX; labelColor: "#c04040"; editable: AnimationControlController.onKeyframe; width: (parent.width - 8) / 3 + onCommitted: function(v) { AnimationControlController.setKfTransX(v) } } + KFTransformField { label: "Y"; val: AnimationControlController.kfTransY; labelColor: "#40c040"; editable: AnimationControlController.onKeyframe; width: (parent.width - 8) / 3 + onCommitted: function(v) { AnimationControlController.setKfTransY(v) } } + KFTransformField { label: "Z"; val: AnimationControlController.kfTransZ; labelColor: "#4040c0"; editable: AnimationControlController.onKeyframe; width: (parent.width - 8) / 3 + onCommitted: function(v) { AnimationControlController.setKfTransZ(v) } } + } + + // ── Scale ───────────────────────────────────────────────────────── + Text { text: "Scale"; color: AnimationControlController.textColor; font.pixelSize: 11; font.bold: true } + Row { + spacing: 4; width: parent.width + KFTransformField { label: "X"; val: AnimationControlController.kfScaleX; labelColor: "#c04040"; editable: AnimationControlController.onKeyframe; width: (parent.width - 8) / 3 + onCommitted: function(v) { AnimationControlController.setKfScaleX(v) } } + KFTransformField { label: "Y"; val: AnimationControlController.kfScaleY; labelColor: "#40c040"; editable: AnimationControlController.onKeyframe; width: (parent.width - 8) / 3 + onCommitted: function(v) { AnimationControlController.setKfScaleY(v) } } + KFTransformField { label: "Z"; val: AnimationControlController.kfScaleZ; labelColor: "#4040c0"; editable: AnimationControlController.onKeyframe; width: (parent.width - 8) / 3 + onCommitted: function(v) { AnimationControlController.setKfScaleZ(v) } } + } + + // ── Rotate (quaternion W X Y Z) ─────────────────────────────────── + Text { text: "Orientation"; color: AnimationControlController.textColor; font.pixelSize: 11; font.bold: true } + Row { + spacing: 4; width: parent.width + KFTransformField { label: "W"; val: AnimationControlController.kfRotW; labelColor: "#a040a0"; editable: AnimationControlController.onKeyframe; width: (parent.width - 8) / 3 + onCommitted: function(v) { AnimationControlController.setKfRotW(v) } } + KFTransformField { label: "X"; val: AnimationControlController.kfRotX; labelColor: "#c04040"; editable: AnimationControlController.onKeyframe; width: (parent.width - 8) / 3 + onCommitted: function(v) { AnimationControlController.setKfRotX(v) } } + KFTransformField { label: "Y"; val: AnimationControlController.kfRotY; labelColor: "#40c040"; editable: AnimationControlController.onKeyframe; width: (parent.width - 8) / 3 + onCommitted: function(v) { AnimationControlController.setKfRotY(v) } } + } + Row { + spacing: 4; width: parent.width + KFTransformField { label: "Z"; val: AnimationControlController.kfRotZ; labelColor: "#4040c0"; editable: AnimationControlController.onKeyframe; width: (parent.width - 8) / 3 + onCommitted: function(v) { AnimationControlController.setKfRotZ(v) } } + } + } +} diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 580375205..612ffeca8 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -2,6 +2,7 @@ import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 import PropertiesPanel 1.0 +import AnimationControl 1.0 Rectangle { id: root @@ -46,6 +47,15 @@ Rectangle { Component.onCompleted: content = animationComponent } + + // ---- Animation Control (keyframe editor) ---- + CollapsibleSection { + title: "Animation Control" + sectionVisible: AnimationControlController.hasAnimation + expanded: false + + Component.onCompleted: content = animControlComponent + } } } @@ -263,6 +273,16 @@ Rectangle { } } + // ---- Animation Control Content (keyframe editor) ---- + Component { + id: animControlComponent + + Loader { + width: parent ? parent.width : 300 + source: "qrc:/AnimationControl/AnimationControlPanel.qml" + } + } + // ---- Animation Content ---- Component { id: animationComponent diff --git a/src/AnimationControlController.cpp b/src/AnimationControlController.cpp new file mode 100644 index 000000000..d63117436 --- /dev/null +++ b/src/AnimationControlController.cpp @@ -0,0 +1,508 @@ +#include "AnimationControlController.h" +#include "SelectionSet.h" +#include "Manager.h" +#include +#include +#include +#include + +#include + +AnimationControlController* AnimationControlController::m_pSingleton = nullptr; + +AnimationControlController* AnimationControlController::instance() +{ + if (!m_pSingleton) + m_pSingleton = new AnimationControlController(); + return m_pSingleton; +} + +AnimationControlController* AnimationControlController::qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine) +{ + Q_UNUSED(engine); Q_UNUSED(scriptEngine); + auto* inst = instance(); + QQmlEngine::setObjectOwnership(inst, QQmlEngine::CppOwnership); + return inst; +} + +void AnimationControlController::kill() +{ + delete m_pSingleton; + m_pSingleton = nullptr; +} + +AnimationControlController::AnimationControlController() + : QObject(nullptr) +{ + connect(SelectionSet::getSingleton(), &SelectionSet::selectionChanged, + this, &AnimationControlController::updateAnimationTree); + + connect(qApp, &QApplication::paletteChanged, this, [this]() { + emit themeChanged(); + }); + + m_pollTimer = new QTimer(this); + connect(m_pollTimer, &QTimer::timeout, this, [this]() { + if (!m_selectedEntity || m_selectedAnimation.empty()) return; + if (!m_selectedEntity->hasAnimationState(m_selectedAnimation)) return; + + Ogre::AnimationState* state = m_selectedEntity->getAnimationState(m_selectedAnimation); + int newMs = static_cast(state->getTimePosition() * 1000); + if (newMs != m_sliderValue) { + m_sliderValue = newMs; + emit sliderValueChanged(); + setAnimationFrame(newMs); + } + }); + m_pollTimer->start(16); +} + +// ── Theme colors ────────────────────────────────────────────────────────────── + +QColor AnimationControlController::panelColor() const + { return QApplication::palette().color(QPalette::Window); } + +QColor AnimationControlController::headerColor() const + { return QApplication::palette().color(QPalette::Window).darker(110); } + +QColor AnimationControlController::textColor() const + { return QApplication::palette().color(QPalette::WindowText); } + +QColor AnimationControlController::borderColor() const + { return QApplication::palette().color(QPalette::Mid); } + +QColor AnimationControlController::inputColor() const + { return QApplication::palette().color(QPalette::Base); } + +QColor AnimationControlController::highlightColor() const + { return QApplication::palette().color(QPalette::Highlight); } + +QColor AnimationControlController::buttonColor() const + { return QApplication::palette().color(QPalette::Button); } + +QColor AnimationControlController::buttonTextColor() const + { return QApplication::palette().color(QPalette::ButtonText); } + +QColor AnimationControlController::disabledTextColor() const + { return QApplication::palette().color(QPalette::Disabled, QPalette::WindowText); } + +// ── Animation tree ──────────────────────────────────────────────────────────── + +void AnimationControlController::updateAnimationTree() +{ + // Save current selection to restore after rebuild + QString prevEntity = QString::fromStdString(m_selectedEntityName); + QString prevAnim = QString::fromStdString(m_selectedAnimation); + + m_animationTree.clear(); + for (Ogre::Entity* entity : SelectionSet::getSingleton()->getResolvedEntities()) { + Ogre::AnimationStateSet* set = entity->getAllAnimationStates(); + if (!set) continue; + + QStringList animNames; + for (const auto& pair : set->getAnimationStates()) + animNames << QString::fromStdString(pair.first); + + if (animNames.isEmpty()) continue; + + QVariantMap group; + group["entity"] = QString::fromStdString(entity->getName()); + group["animations"] = animNames; + m_animationTree.append(group); + } + emit animationTreeChanged(); + + // Try to restore selection + if (!prevEntity.isEmpty() && !prevAnim.isEmpty()) { + selectAnimation(prevEntity, prevAnim); + } else if (!m_animationTree.isEmpty()) { + // Auto-select first animation + auto first = m_animationTree.first().toMap(); + auto anims = first["animations"].toStringList(); + if (!anims.isEmpty()) + selectAnimation(first["entity"].toString(), anims.first()); + else + selectAnimation("", ""); + } else { + selectAnimation("", ""); + } +} + +void AnimationControlController::selectAnimation(const QString& entityName, const QString& animName) +{ + // Reset state + m_selectedEntity = nullptr; + m_selectedSkeleton = nullptr; + m_selectedTrack = nullptr; + m_currentKeyframe = nullptr; + m_selectedEntityName.clear(); + m_selectedAnimation.clear(); + m_selectedBone.clear(); + m_sliderValue = 0; + m_sliderMaximum = 0; + m_selectedTick = -1; + m_boneNames.clear(); + m_keyframeTicks.clear(); + + if (entityName.isEmpty() || animName.isEmpty()) { + emit selectionChanged(); + emit boneListChanged(); + emit sliderValueChanged(); + emit animationLengthChanged(); + emit keyframeTicksChanged(); + emit currentKeyframeChanged(); + return; + } + + // Find the entity + for (Ogre::Entity* entity : SelectionSet::getSingleton()->getResolvedEntities()) { + if (entity->getName() == entityName.toStdString()) { + m_selectedEntity = entity; + break; + } + } + if (!m_selectedEntity) { + emit selectionChanged(); + return; + } + + m_selectedEntityName = entityName.toStdString(); + m_selectedAnimation = animName.toStdString(); + m_selectedSkeleton = m_selectedEntity->getSkeleton(); + + if (m_selectedSkeleton && m_selectedSkeleton->hasAnimation(m_selectedAnimation)) { + Ogre::Animation* anim = m_selectedSkeleton->getAnimation(m_selectedAnimation); + m_sliderMaximum = static_cast(anim->getLength() * 1000); + } + + emit selectionChanged(); + emit animationLengthChanged(); + emit sliderValueChanged(); + + refreshBoneList(); +} + +// ── Bone list ───────────────────────────────────────────────────────────────── + +void AnimationControlController::refreshBoneList() +{ + m_boneNames.clear(); + m_selectedTrack = nullptr; + m_currentKeyframe = nullptr; + m_selectedBone.clear(); + + if (!m_selectedSkeleton || m_selectedAnimation.empty()) { + emit boneListChanged(); + emit keyframeTicksChanged(); + emit currentKeyframeChanged(); + return; + } + if (!m_selectedSkeleton->hasAnimation(m_selectedAnimation)) { + emit boneListChanged(); + emit keyframeTicksChanged(); + emit currentKeyframeChanged(); + return; + } + + Ogre::Animation* anim = m_selectedSkeleton->getAnimation(m_selectedAnimation); + for (const auto& pair : anim->_getNodeTrackList()) + m_boneNames << QString::fromStdString(pair.second->getAssociatedNode()->getName()); + + emit boneListChanged(); + + if (!m_boneNames.isEmpty()) + selectBone(m_boneNames.first()); + else { + emit keyframeTicksChanged(); + emit currentKeyframeChanged(); + } +} + +void AnimationControlController::selectBone(const QString& boneName) +{ + m_selectedTrack = nullptr; + m_currentKeyframe = nullptr; + m_selectedBone = boneName.toStdString(); + + // Clear all bone selection highlights + if (m_selectedSkeleton) { + for (unsigned short i = 0; i < m_selectedSkeleton->getNumBones(); ++i) + m_selectedSkeleton->getBone(i)->getUserObjectBindings() + .setUserAny("selected", Ogre::Any(false)); + } + + if (m_selectedSkeleton && !m_selectedAnimation.empty() + && m_selectedSkeleton->hasAnimation(m_selectedAnimation)) + { + Ogre::Animation* anim = m_selectedSkeleton->getAnimation(m_selectedAnimation); + for (const auto& pair : anim->_getNodeTrackList()) { + if (pair.second->getAssociatedNode()->getName() == m_selectedBone) { + m_selectedTrack = pair.second; + m_selectedSkeleton->getBone(m_selectedBone) + ->getUserObjectBindings().setUserAny("selected", Ogre::Any(true)); + break; + } + } + } + + emit boneListChanged(); + refreshSliderTicks(); + setAnimationFrame(m_sliderValue); +} + +// ── Timeline / slider ───────────────────────────────────────────────────────── + +void AnimationControlController::setSliderValue(int ms) +{ + if (ms == m_sliderValue && m_selectedEntity) { + // Still call setAnimationFrame to keep Ogre in sync on explicit user drags + } + m_sliderValue = ms; + emit sliderValueChanged(); + setAnimationFrame(ms); +} + +void AnimationControlController::setAnimationLength(double length) +{ + if (!m_selectedSkeleton || m_selectedAnimation.empty()) return; + if (!m_selectedSkeleton->hasAnimation(m_selectedAnimation)) return; + + Ogre::Animation* anim = m_selectedSkeleton->getAnimation(m_selectedAnimation); + anim->setLength(static_cast(length)); + m_sliderMaximum = static_cast(length * 1000); + + if (m_sliderValue > m_sliderMaximum) { + m_sliderValue = m_sliderMaximum; + emit sliderValueChanged(); + } + + if (m_selectedEntity && m_selectedEntity->hasAnimationState(m_selectedAnimation)) { + Ogre::AnimationState* state = m_selectedEntity->getAnimationState(m_selectedAnimation); + state->setLength(static_cast(length)); + state->setTimePosition(std::min(state->getTimePosition(), static_cast(length))); + m_selectedEntity->getAllAnimationStates()->_notifyDirty(); + } + + refreshSliderTicks(); + emit animationLengthChanged(); +} + +void AnimationControlController::setAnimationFrame(int ms) +{ + if (!m_selectedEntity || m_selectedAnimation.empty()) return; + if (!m_selectedEntity->hasAnimationState(m_selectedAnimation)) return; + + Ogre::AnimationState* state = m_selectedEntity->getAnimationState(m_selectedAnimation); + state->setTimePosition(ms / 1000.0f); + + if (!m_selectedTrack || m_selectedTrack->getNumKeyFrames() == 0) { + if (m_currentKeyframe) { + m_updatingValues = true; + m_currentKeyframe = nullptr; + m_selectedTick = -1; + m_updatingValues = false; + emit keyframeTicksChanged(); + emit currentKeyframeChanged(); + } + return; + } + + Ogre::KeyFrame* kf1 = nullptr; + Ogre::KeyFrame* kf2 = nullptr; + m_selectedTrack->getKeyFramesAtTime(ms / 1000.0f, &kf1, &kf2); + + Ogre::TransformKeyFrame* closest = nullptr; + if (kf1 && kf2) { + float d1 = std::fabs(kf1->getTime() - ms / 1000.0f); + float d2 = std::fabs(kf2->getTime() - ms / 1000.0f); + closest = static_cast(d1 <= d2 ? kf1 : kf2); + } else if (kf1) { + closest = static_cast(kf1); + } else if (kf2) { + closest = static_cast(kf2); + } + + bool tickChanged = (closest != m_currentKeyframe); + m_currentKeyframe = closest; + + if (m_currentKeyframe) { + int newTick = static_cast(m_currentKeyframe->getTime() * 1000); + if (newTick != m_selectedTick) { + m_selectedTick = newTick; + tickChanged = true; + } + } else { + m_selectedTick = -1; + tickChanged = true; + } + + if (tickChanged) emit keyframeTicksChanged(); + if (m_currentKeyframe) pushKeyframeValues(); +} + +void AnimationControlController::refreshSliderTicks() +{ + m_keyframeTicks.clear(); + m_selectedTick = -1; + + if (m_selectedTrack) { + for (unsigned short i = 0; i < m_selectedTrack->getNumKeyFrames(); ++i) + m_keyframeTicks.append(static_cast(m_selectedTrack->getKeyFrame(i)->getTime() * 1000)); + if (m_currentKeyframe) + m_selectedTick = static_cast(m_currentKeyframe->getTime() * 1000); + } + + emit keyframeTicksChanged(); +} + +// ── Keyframe editing ────────────────────────────────────────────────────────── + +bool AnimationControlController::hasPrevKeyframe() const +{ + if (!m_selectedTrack || m_selectedTrack->getNumKeyFrames() == 0) return false; + float time = m_sliderValue / 1000.0f; + for (int i = static_cast(m_selectedTrack->getNumKeyFrames()) - 1; i >= 0; --i) { + if (m_selectedTrack->getKeyFrame(i)->getTime() < time - 0.001f) return true; + } + return false; +} + +bool AnimationControlController::hasNextKeyframe() const +{ + if (!m_selectedTrack || m_selectedTrack->getNumKeyFrames() == 0) return false; + float time = m_sliderValue / 1000.0f; + for (unsigned short i = 0; i < m_selectedTrack->getNumKeyFrames(); ++i) { + if (m_selectedTrack->getKeyFrame(i)->getTime() > time + 0.001f) return true; + } + return false; +} + +void AnimationControlController::prevKeyframe() +{ + if (!m_selectedTrack || m_selectedTrack->getNumKeyFrames() == 0) return; + float time = m_sliderValue / 1000.0f; + Ogre::TransformKeyFrame* target = nullptr; + for (int i = static_cast(m_selectedTrack->getNumKeyFrames()) - 1; i >= 0; --i) { + auto* kf = static_cast(m_selectedTrack->getKeyFrame(i)); + if (kf->getTime() < time - 0.001f) { target = kf; break; } + } + if (!target) + target = static_cast(m_selectedTrack->getKeyFrame(0)); + setSliderValue(static_cast(target->getTime() * 1000)); +} + +void AnimationControlController::nextKeyframe() +{ + if (!m_selectedTrack || m_selectedTrack->getNumKeyFrames() == 0) return; + float time = m_sliderValue / 1000.0f; + Ogre::TransformKeyFrame* target = nullptr; + for (unsigned short i = 0; i < m_selectedTrack->getNumKeyFrames(); ++i) { + auto* kf = static_cast(m_selectedTrack->getKeyFrame(i)); + if (kf->getTime() > time + 0.001f) { target = kf; break; } + } + if (!target) + target = static_cast( + m_selectedTrack->getKeyFrame(m_selectedTrack->getNumKeyFrames() - 1)); + setSliderValue(static_cast(target->getTime() * 1000)); +} + +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()); + + refreshSliderTicks(); + setAnimationFrame(m_sliderValue); +} + +void AnimationControlController::deleteKeyframe() +{ + if (!m_selectedTrack || !m_currentKeyframe) return; + + float t = m_currentKeyframe->getTime(); + for (unsigned short i = 0; i < m_selectedTrack->getNumKeyFrames(); ++i) { + if (std::fabs(m_selectedTrack->getKeyFrame(i)->getTime() - t) < 0.001f) { + m_selectedTrack->removeKeyFrame(i); + break; + } + } + m_currentKeyframe = nullptr; + m_selectedTick = -1; + refreshSliderTicks(); + setAnimationFrame(m_sliderValue); +} + +void AnimationControlController::pushKeyframeValues() +{ + if (!m_currentKeyframe) return; + + m_updatingValues = true; + Ogre::Vector3 t = m_currentKeyframe->getTranslate(); + Ogre::Vector3 s = m_currentKeyframe->getScale(); + Ogre::Quaternion r = m_currentKeyframe->getRotation(); + m_kfTransX = t.x; m_kfTransY = t.y; m_kfTransZ = t.z; + m_kfScaleX = s.x; m_kfScaleY = s.y; m_kfScaleZ = s.z; + m_kfRotW = r.w; m_kfRotX = r.x; m_kfRotY = r.y; m_kfRotZ = r.z; + m_updatingValues = false; + emit currentKeyframeChanged(); +} + +void AnimationControlController::notifyOgreUpdate() +{ + if (!m_selectedEntity || m_selectedAnimation.empty()) return; + m_selectedEntity->getAllAnimationStates()->_notifyDirty(); + auto* state = m_selectedEntity->getAnimationState(m_selectedAnimation); + state->setTimePosition(state->getTimePosition()); +} + +// ── Keyframe value setters ──────────────────────────────────────────────────── + +#define KF_SET_TRANS(AXIS, FIELD) \ +void AnimationControlController::setKfTrans##AXIS(double v) { \ + if (m_updatingValues || !m_currentKeyframe) return; \ + Ogre::Vector3 t = m_currentKeyframe->getTranslate(); \ + t.FIELD = static_cast(v); \ + m_currentKeyframe->setTranslate(t); \ + m_kfTrans##AXIS = v; \ + notifyOgreUpdate(); \ +} + +#define KF_SET_SCALE(AXIS, FIELD) \ +void AnimationControlController::setKfScale##AXIS(double v) { \ + if (m_updatingValues || !m_currentKeyframe) return; \ + Ogre::Vector3 s = m_currentKeyframe->getScale(); \ + s.FIELD = static_cast(v); \ + m_currentKeyframe->setScale(s); \ + m_kfScale##AXIS = v; \ + notifyOgreUpdate(); \ +} + +#define KF_SET_ROT(AXIS, FIELD) \ +void AnimationControlController::setKfRot##AXIS(double v) { \ + if (m_updatingValues || !m_currentKeyframe) return; \ + Ogre::Quaternion r = m_currentKeyframe->getRotation(); \ + r.FIELD = static_cast(v); \ + m_currentKeyframe->setRotation(r); \ + m_kfRot##AXIS = v; \ + notifyOgreUpdate(); \ +} + +KF_SET_TRANS(X, x) +KF_SET_TRANS(Y, y) +KF_SET_TRANS(Z, z) +KF_SET_SCALE(X, x) +KF_SET_SCALE(Y, y) +KF_SET_SCALE(Z, z) +KF_SET_ROT(W, w) +KF_SET_ROT(X, x) +KF_SET_ROT(Y, y) +KF_SET_ROT(Z, z) diff --git a/src/AnimationControlController.h b/src/AnimationControlController.h new file mode 100644 index 000000000..282d7fbc7 --- /dev/null +++ b/src/AnimationControlController.h @@ -0,0 +1,191 @@ +#ifndef ANIMATIONCONTROLCONTROLLER_H +#define ANIMATIONCONTROLCONTROLLER_H + +#include +#include +#include +#include +#include +#include +#include + +namespace Ogre { + class Entity; + class NodeAnimationTrack; + class SkeletonInstance; + class TransformKeyFrame; +} + +class AnimationControlController : public QObject +{ + Q_OBJECT + + // Theme colors (same QPalette derivation as PropertiesPanelController) + Q_PROPERTY(QColor panelColor READ panelColor NOTIFY themeChanged) + Q_PROPERTY(QColor headerColor READ headerColor NOTIFY themeChanged) + Q_PROPERTY(QColor textColor READ textColor NOTIFY themeChanged) + Q_PROPERTY(QColor borderColor READ borderColor NOTIFY themeChanged) + Q_PROPERTY(QColor inputColor READ inputColor NOTIFY themeChanged) + Q_PROPERTY(QColor highlightColor READ highlightColor NOTIFY themeChanged) + Q_PROPERTY(QColor buttonColor READ buttonColor NOTIFY themeChanged) + Q_PROPERTY(QColor buttonTextColor READ buttonTextColor NOTIFY themeChanged) + Q_PROPERTY(QColor disabledTextColor READ disabledTextColor NOTIFY themeChanged) + + // Animation / bone selection + Q_PROPERTY(QVariantList animationTree READ animationTree NOTIFY animationTreeChanged) + Q_PROPERTY(QString selectedEntityName READ selectedEntityName NOTIFY selectionChanged) + Q_PROPERTY(QString selectedAnimation READ selectedAnimation NOTIFY selectionChanged) + Q_PROPERTY(QStringList boneNames READ boneNames NOTIFY boneListChanged) + Q_PROPERTY(QString selectedBone READ selectedBone NOTIFY boneListChanged) + + // Timeline + Q_PROPERTY(int sliderValue READ sliderValue WRITE setSliderValue NOTIFY sliderValueChanged) + Q_PROPERTY(int sliderMaximum READ sliderMaximum NOTIFY animationLengthChanged) + Q_PROPERTY(double animationLength READ animationLength WRITE setAnimationLength NOTIFY animationLengthChanged) + + // 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) + + // Keyframe editing state + Q_PROPERTY(bool onKeyframe READ onKeyframe NOTIFY currentKeyframeChanged) + Q_PROPERTY(bool canDeleteKeyframe READ canDeleteKeyframe NOTIFY currentKeyframeChanged) + Q_PROPERTY(bool hasPrevKeyframe READ hasPrevKeyframe NOTIFY keyframeTicksChanged) + Q_PROPERTY(bool hasNextKeyframe READ hasNextKeyframe NOTIFY keyframeTicksChanged) + Q_PROPERTY(bool hasAnimation READ hasAnimation NOTIFY selectionChanged) + + // Keyframe values (T/R/S for the closest keyframe to current time) + Q_PROPERTY(double kfTransX READ kfTransX NOTIFY currentKeyframeChanged) + Q_PROPERTY(double kfTransY READ kfTransY NOTIFY currentKeyframeChanged) + Q_PROPERTY(double kfTransZ READ kfTransZ NOTIFY currentKeyframeChanged) + Q_PROPERTY(double kfScaleX READ kfScaleX NOTIFY currentKeyframeChanged) + Q_PROPERTY(double kfScaleY READ kfScaleY NOTIFY currentKeyframeChanged) + Q_PROPERTY(double kfScaleZ READ kfScaleZ NOTIFY currentKeyframeChanged) + Q_PROPERTY(double kfRotW READ kfRotW NOTIFY currentKeyframeChanged) + Q_PROPERTY(double kfRotX READ kfRotX NOTIFY currentKeyframeChanged) + Q_PROPERTY(double kfRotY READ kfRotY NOTIFY currentKeyframeChanged) + Q_PROPERTY(double kfRotZ READ kfRotZ NOTIFY currentKeyframeChanged) + +public: + static AnimationControlController* instance(); + static AnimationControlController* qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine); + static void kill(); + + // Theme colors + QColor panelColor() const; + QColor headerColor() const; + QColor textColor() const; + QColor borderColor() const; + QColor inputColor() const; + QColor highlightColor() const; + QColor buttonColor() const; + QColor buttonTextColor() const; + QColor disabledTextColor() const; + + // Animation tree + QVariantList animationTree() const { return m_animationTree; } + QString selectedEntityName() const { return QString::fromStdString(m_selectedEntityName); } + QString selectedAnimation() const { return QString::fromStdString(m_selectedAnimation); } + + // Bone list + QStringList boneNames() const { return m_boneNames; } + QString selectedBone() const { return QString::fromStdString(m_selectedBone); } + + // Timeline + int sliderValue() const { return m_sliderValue; } + int sliderMaximum() const { return m_sliderMaximum; } + double animationLength() const { return m_sliderMaximum / 1000.0; } + void setSliderValue(int ms); + void setAnimationLength(double length); + + // Keyframe ticks + QVariantList keyframeTicks() const { return m_keyframeTicks; } + int selectedTick() const { return m_selectedTick; } + + // Keyframe state + bool onKeyframe() const { return m_currentKeyframe != nullptr; } + bool canDeleteKeyframe() const { return m_currentKeyframe != nullptr; } + bool hasPrevKeyframe() const; + bool hasNextKeyframe() const; + bool hasAnimation() const { return !m_selectedAnimation.empty(); } + + // Keyframe values + double kfTransX() const { return m_kfTransX; } + double kfTransY() const { return m_kfTransY; } + double kfTransZ() const { return m_kfTransZ; } + double kfScaleX() const { return m_kfScaleX; } + double kfScaleY() const { return m_kfScaleY; } + double kfScaleZ() const { return m_kfScaleZ; } + double kfRotW() const { return m_kfRotW; } + double kfRotX() const { return m_kfRotX; } + double kfRotY() const { return m_kfRotY; } + double kfRotZ() const { return m_kfRotZ; } + + // QML-invokable actions + Q_INVOKABLE void selectAnimation(const QString& entityName, const QString& animName); + Q_INVOKABLE void selectBone(const QString& boneName); + Q_INVOKABLE void addKeyframe(); + Q_INVOKABLE void deleteKeyframe(); + Q_INVOKABLE void prevKeyframe(); + Q_INVOKABLE void nextKeyframe(); + Q_INVOKABLE void setKfTransX(double v); + Q_INVOKABLE void setKfTransY(double v); + Q_INVOKABLE void setKfTransZ(double v); + Q_INVOKABLE void setKfScaleX(double v); + Q_INVOKABLE void setKfScaleY(double v); + Q_INVOKABLE void setKfScaleZ(double v); + Q_INVOKABLE void setKfRotW(double v); + Q_INVOKABLE void setKfRotX(double v); + Q_INVOKABLE void setKfRotY(double v); + Q_INVOKABLE void setKfRotZ(double v); + +public slots: + void updateAnimationTree(); + +signals: + void themeChanged(); + void animationTreeChanged(); + void selectionChanged(); + void boneListChanged(); + void sliderValueChanged(); + void animationLengthChanged(); + void keyframeTicksChanged(); + void currentKeyframeChanged(); + +private: + AnimationControlController(); + ~AnimationControlController() override = default; + + void setAnimationFrame(int ms); + void refreshBoneList(); + void refreshSliderTicks(); + void pushKeyframeValues(); + void notifyOgreUpdate(); + + static AnimationControlController* m_pSingleton; + + QTimer* m_pollTimer = nullptr; + + Ogre::Entity* m_selectedEntity = nullptr; + Ogre::SkeletonInstance* m_selectedSkeleton = nullptr; + Ogre::NodeAnimationTrack* m_selectedTrack = nullptr; + Ogre::TransformKeyFrame* m_currentKeyframe = nullptr; + std::string m_selectedEntityName; + std::string m_selectedAnimation; + std::string m_selectedBone; + + int m_sliderValue = 0; + int m_sliderMaximum = 0; + int m_selectedTick = -1; + + QVariantList m_animationTree; + QStringList m_boneNames; + QVariantList m_keyframeTicks; + + bool m_updatingValues = false; + 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; +}; + +#endif // ANIMATIONCONTROLCONTROLLER_H diff --git a/src/AnimationControlController_test.cpp b/src/AnimationControlController_test.cpp new file mode 100644 index 000000000..f29145522 --- /dev/null +++ b/src/AnimationControlController_test.cpp @@ -0,0 +1,512 @@ +#include +#include +#include +#include +#include +#include +#include "AnimationControlController.h" +#include "Manager.h" +#include "SelectionSet.h" +#include "TestHelpers.h" +#include +#include +#include +#include + +class AnimationControlControllerTest : public ::testing::Test { +protected: + void SetUp() override { + AnimationControlController::kill(); + Manager::kill(); + QThread::msleep(20); + + app = qobject_cast(QCoreApplication::instance()); + ASSERT_NE(app, nullptr); + + if (!tryInitOgre()) { + GTEST_SKIP() << "Skipping: Ogre initialization failed"; + } + createStandardOgreMaterials(); + } + + void TearDown() override { + SelectionSet::getSingleton()->clear(); + app->processEvents(); + AnimationControlController::kill(); + } + + // Helper: create animated entity and select it + Ogre::Entity* setupAnimatedEntity(const std::string& name) { + if (!canLoadMeshFiles()) return nullptr; + Ogre::Entity* entity = createAnimatedTestEntity(name); + if (!entity) return nullptr; + SelectionSet::getSingleton()->selectOne(entity->getParentSceneNode()); + app->processEvents(); + return entity; + } + + QApplication* app = nullptr; +}; + +// ── Singleton ────────────────────────────────────────────────────────────────── + +TEST_F(AnimationControlControllerTest, InstanceReturnsSameObject) { + auto* a = AnimationControlController::instance(); + auto* b = AnimationControlController::instance(); + EXPECT_EQ(a, b); +} + +TEST_F(AnimationControlControllerTest, KillResetsInstance) { + auto* a = AnimationControlController::instance(); + // Mutate state so we can verify the new instance starts fresh + a->setSliderValue(999); + EXPECT_EQ(a->sliderValue(), 999); + AnimationControlController::kill(); + // New instance must have default slider value (0), proving it was re-created + auto* b = AnimationControlController::instance(); + EXPECT_EQ(b->sliderValue(), 0); +} + +// ── Theme colors ─────────────────────────────────────────────────────────────── + +TEST_F(AnimationControlControllerTest, ThemeColorsAreValid) { + auto* ctrl = AnimationControlController::instance(); + EXPECT_TRUE(ctrl->panelColor().isValid()); + EXPECT_TRUE(ctrl->textColor().isValid()); + EXPECT_TRUE(ctrl->borderColor().isValid()); + EXPECT_TRUE(ctrl->inputColor().isValid()); + EXPECT_TRUE(ctrl->highlightColor().isValid()); + EXPECT_TRUE(ctrl->buttonColor().isValid()); + EXPECT_TRUE(ctrl->buttonTextColor().isValid()); + EXPECT_TRUE(ctrl->disabledTextColor().isValid()); +} + +// ── Initial state ────────────────────────────────────────────────────────────── + +TEST_F(AnimationControlControllerTest, InitialStateIsEmpty) { + auto* ctrl = AnimationControlController::instance(); + EXPECT_FALSE(ctrl->hasAnimation()); + EXPECT_FALSE(ctrl->onKeyframe()); + EXPECT_FALSE(ctrl->canDeleteKeyframe()); + EXPECT_FALSE(ctrl->hasPrevKeyframe()); + EXPECT_FALSE(ctrl->hasNextKeyframe()); + EXPECT_TRUE(ctrl->animationTree().isEmpty()); + EXPECT_TRUE(ctrl->boneNames().isEmpty()); + EXPECT_EQ(ctrl->sliderValue(), 0); + EXPECT_EQ(ctrl->sliderMaximum(), 0); +} + +// ── updateAnimationTree ──────────────────────────────────────────────────────── + +TEST_F(AnimationControlControllerTest, UpdateAnimationTreeWithNoSelectionIsEmpty) { + SelectionSet::getSingleton()->clear(); + auto* ctrl = AnimationControlController::instance(); + EXPECT_NO_THROW(ctrl->updateAnimationTree()); + EXPECT_TRUE(ctrl->animationTree().isEmpty()); +} + +TEST_F(AnimationControlControllerTest, UpdateAnimationTreeWithAnimatedEntityPopulatesTree) { + if (!canLoadMeshFiles()) GTEST_SKIP() << "No GL context"; + + Ogre::Entity* entity = setupAnimatedEntity("ACC_TreeTest"); + ASSERT_NE(entity, nullptr); + + auto* ctrl = AnimationControlController::instance(); + ctrl->updateAnimationTree(); + + EXPECT_FALSE(ctrl->animationTree().isEmpty()); + auto group = ctrl->animationTree().first().toMap(); + EXPECT_EQ(group["entity"].toString().toStdString(), entity->getName()); + EXPECT_FALSE(group["animations"].toStringList().isEmpty()); +} + +TEST_F(AnimationControlControllerTest, UpdateAnimationTreeEmitsSignal) { + auto* ctrl = AnimationControlController::instance(); + QSignalSpy spy(ctrl, &AnimationControlController::animationTreeChanged); + ctrl->updateAnimationTree(); + EXPECT_GE(spy.count(), 1); +} + +// ── selectAnimation ──────────────────────────────────────────────────────────── + +TEST_F(AnimationControlControllerTest, SelectAnimationWithEmptyNamesResetsState) { + auto* ctrl = AnimationControlController::instance(); + ctrl->selectAnimation("", ""); + EXPECT_FALSE(ctrl->hasAnimation()); + EXPECT_EQ(ctrl->sliderMaximum(), 0); + EXPECT_TRUE(ctrl->boneNames().isEmpty()); +} + +TEST_F(AnimationControlControllerTest, SelectAnimationSetsState) { + if (!canLoadMeshFiles()) GTEST_SKIP() << "No GL context"; + + Ogre::Entity* entity = setupAnimatedEntity("ACC_SelectTest"); + ASSERT_NE(entity, nullptr); + + auto* ctrl = AnimationControlController::instance(); + ctrl->updateAnimationTree(); + + ctrl->selectAnimation(QString::fromStdString(entity->getName()), "TestAnim"); + EXPECT_TRUE(ctrl->hasAnimation()); + EXPECT_EQ(ctrl->selectedAnimation(), "TestAnim"); + EXPECT_EQ(ctrl->selectedEntityName().toStdString(), entity->getName()); + EXPECT_EQ(ctrl->sliderMaximum(), 1000); // 1.0s * 1000 +} + +TEST_F(AnimationControlControllerTest, SelectAnimationPopulatesBoneList) { + if (!canLoadMeshFiles()) GTEST_SKIP() << "No GL context"; + + Ogre::Entity* entity = setupAnimatedEntity("ACC_BoneListTest"); + ASSERT_NE(entity, nullptr); + + auto* ctrl = AnimationControlController::instance(); + ctrl->updateAnimationTree(); + ctrl->selectAnimation(QString::fromStdString(entity->getName()), "TestAnim"); + + EXPECT_FALSE(ctrl->boneNames().isEmpty()); +} + +TEST_F(AnimationControlControllerTest, SelectAnimationEmitsSelectionChanged) { + if (!canLoadMeshFiles()) GTEST_SKIP() << "No GL context"; + + Ogre::Entity* entity = setupAnimatedEntity("ACC_SignalTest"); + ASSERT_NE(entity, nullptr); + + auto* ctrl = AnimationControlController::instance(); + ctrl->updateAnimationTree(); + + QSignalSpy spy(ctrl, &AnimationControlController::selectionChanged); + ctrl->selectAnimation(QString::fromStdString(entity->getName()), "TestAnim"); + EXPECT_GE(spy.count(), 1); +} + +// ── selectBone ───────────────────────────────────────────────────────────────── + +TEST_F(AnimationControlControllerTest, SelectBoneUpdatesSelectedBone) { + if (!canLoadMeshFiles()) GTEST_SKIP() << "No GL context"; + + Ogre::Entity* entity = setupAnimatedEntity("ACC_BoneSelectTest"); + ASSERT_NE(entity, nullptr); + + auto* ctrl = AnimationControlController::instance(); + ctrl->updateAnimationTree(); + ctrl->selectAnimation(QString::fromStdString(entity->getName()), "TestAnim"); + + ASSERT_FALSE(ctrl->boneNames().isEmpty()); + QString bone = ctrl->boneNames().first(); + ctrl->selectBone(bone); + EXPECT_EQ(ctrl->selectedBone(), bone); +} + +TEST_F(AnimationControlControllerTest, SelectBonePopulatesKeyframeTicks) { + if (!canLoadMeshFiles()) GTEST_SKIP() << "No GL context"; + + Ogre::Entity* entity = setupAnimatedEntity("ACC_TicksTest"); + 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()); + + // TestAnim has 3 keyframes — should have 3 ticks + EXPECT_EQ(ctrl->keyframeTicks().size(), 3); +} + +// ── Slider / timeline ────────────────────────────────────────────────────────── + +TEST_F(AnimationControlControllerTest, SetSliderValueUpdatesValue) { + if (!canLoadMeshFiles()) GTEST_SKIP() << "No GL context"; + + Ogre::Entity* entity = setupAnimatedEntity("ACC_SliderTest"); + 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()); + + ctrl->setSliderValue(500); + EXPECT_EQ(ctrl->sliderValue(), 500); +} + +TEST_F(AnimationControlControllerTest, SetAnimationLengthUpdatesSliderMaximum) { + if (!canLoadMeshFiles()) GTEST_SKIP() << "No GL context"; + + Ogre::Entity* entity = setupAnimatedEntity("ACC_LenTest"); + ASSERT_NE(entity, nullptr); + + auto* ctrl = AnimationControlController::instance(); + ctrl->updateAnimationTree(); + ctrl->selectAnimation(QString::fromStdString(entity->getName()), "TestAnim"); + + ctrl->setAnimationLength(2.0); + EXPECT_EQ(ctrl->sliderMaximum(), 2000); + EXPECT_NEAR(ctrl->animationLength(), 2.0, 0.001); +} + +// ── Keyframe navigation ──────────────────────────────────────────────────────── + +TEST_F(AnimationControlControllerTest, NextKeyframeAdvancesPosition) { + if (!canLoadMeshFiles()) GTEST_SKIP() << "No GL context"; + + Ogre::Entity* entity = setupAnimatedEntity("ACC_NextKfTest"); + 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()); + ctrl->setSliderValue(0); + + ctrl->nextKeyframe(); + EXPECT_GT(ctrl->sliderValue(), 0); +} + +TEST_F(AnimationControlControllerTest, PrevKeyframeGoesBack) { + if (!canLoadMeshFiles()) GTEST_SKIP() << "No GL context"; + + Ogre::Entity* entity = setupAnimatedEntity("ACC_PrevKfTest"); + 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()); + ctrl->setSliderValue(1000); + + ctrl->prevKeyframe(); + EXPECT_LT(ctrl->sliderValue(), 1000); +} + +TEST_F(AnimationControlControllerTest, HasNextKeyframeAtStart) { + if (!canLoadMeshFiles()) GTEST_SKIP() << "No GL context"; + + Ogre::Entity* entity = setupAnimatedEntity("ACC_HasNextTest"); + 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()); + ctrl->setSliderValue(0); + + EXPECT_TRUE(ctrl->hasNextKeyframe()); +} + +TEST_F(AnimationControlControllerTest, HasPrevKeyframeAtEnd) { + if (!canLoadMeshFiles()) GTEST_SKIP() << "No GL context"; + + Ogre::Entity* entity = setupAnimatedEntity("ACC_HasPrevTest"); + 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()); + ctrl->setSliderValue(1000); + + EXPECT_TRUE(ctrl->hasPrevKeyframe()); +} + +// ── Add / Delete keyframe ────────────────────────────────────────────────────── + +TEST_F(AnimationControlControllerTest, AddKeyframeIncreasesCount) { + if (!canLoadMeshFiles()) GTEST_SKIP() << "No GL context"; + + Ogre::Entity* entity = setupAnimatedEntity("ACC_AddKfTest"); + 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* skel = entity->getSkeleton(); + auto* anim = skel->getAnimation("TestAnim"); + auto* track = anim->_getNodeTrackList().begin()->second; + int before = track->getNumKeyFrames(); + + ctrl->setSliderValue(250); // between kf0 and kf1 + ctrl->addKeyframe(); + app->processEvents(); + + EXPECT_EQ(track->getNumKeyFrames(), before + 1); +} + +TEST_F(AnimationControlControllerTest, DeleteKeyframeDecreasesCount) { + if (!canLoadMeshFiles()) GTEST_SKIP() << "No GL context"; + + Ogre::Entity* entity = setupAnimatedEntity("ACC_DelKfTest"); + 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* skel = entity->getSkeleton(); + auto* anim = skel->getAnimation("TestAnim"); + auto* track = anim->_getNodeTrackList().begin()->second; + + ctrl->setSliderValue(500); // exact keyframe + app->processEvents(); + int before = track->getNumKeyFrames(); + + ctrl->deleteKeyframe(); + app->processEvents(); + + EXPECT_EQ(track->getNumKeyFrames(), before - 1); +} + +// ── Keyframe value setters ───────────────────────────────────────────────────── + +TEST_F(AnimationControlControllerTest, SetKfTransXUpdatesKeyframe) { + if (!canLoadMeshFiles()) GTEST_SKIP() << "No GL context"; + + Ogre::Entity* entity = setupAnimatedEntity("ACC_TransXTest"); + 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()); + ctrl->setSliderValue(500); + app->processEvents(); + + ASSERT_TRUE(ctrl->onKeyframe()); + ctrl->setKfTransX(99.0); + + auto* skel = entity->getSkeleton(); + auto* anim = skel->getAnimation("TestAnim"); + auto* track = anim->_getNodeTrackList().begin()->second; + for (unsigned short i = 0; i < track->getNumKeyFrames(); ++i) { + auto* kf = static_cast(track->getKeyFrame(i)); + if (std::fabs(kf->getTime() - 0.5f) < 0.01f) { + EXPECT_NEAR(kf->getTranslate().x, 99.0f, 0.01f); + return; + } + } + FAIL() << "Keyframe at t=0.5 not found"; +} + +TEST_F(AnimationControlControllerTest, SetKfScaleYUpdatesKeyframe) { + if (!canLoadMeshFiles()) GTEST_SKIP() << "No GL context"; + + Ogre::Entity* entity = setupAnimatedEntity("ACC_ScaleYTest"); + 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()); + ctrl->setSliderValue(500); + app->processEvents(); + + ASSERT_TRUE(ctrl->onKeyframe()); + ctrl->setKfScaleY(3.5); + + auto* track = entity->getSkeleton()->getAnimation("TestAnim") + ->_getNodeTrackList().begin()->second; + for (unsigned short i = 0; i < track->getNumKeyFrames(); ++i) { + auto* kf = static_cast(track->getKeyFrame(i)); + if (std::fabs(kf->getTime() - 0.5f) < 0.01f) { + EXPECT_NEAR(kf->getScale().y, 3.5f, 0.01f); + return; + } + } + FAIL() << "Keyframe at t=0.5 not found"; +} + +TEST_F(AnimationControlControllerTest, SetKfRotWUpdatesKeyframe) { + if (!canLoadMeshFiles()) GTEST_SKIP() << "No GL context"; + + Ogre::Entity* entity = setupAnimatedEntity("ACC_RotWTest"); + 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()); + ctrl->setSliderValue(500); + app->processEvents(); + + ASSERT_TRUE(ctrl->onKeyframe()); + ctrl->setKfRotW(0.707); + + auto* track = entity->getSkeleton()->getAnimation("TestAnim") + ->_getNodeTrackList().begin()->second; + for (unsigned short i = 0; i < track->getNumKeyFrames(); ++i) { + auto* kf = static_cast(track->getKeyFrame(i)); + if (std::fabs(kf->getTime() - 0.5f) < 0.01f) { + EXPECT_NEAR(kf->getRotation().w, 0.707f, 0.01f); + return; + } + } + FAIL() << "Keyframe at t=0.5 not found"; +} + +// ── No-op safety when no animation selected ─────────────────────────────────── + +TEST_F(AnimationControlControllerTest, AddKeyframeWithNoAnimationDoesNotCrash) { + auto* ctrl = AnimationControlController::instance(); + EXPECT_NO_THROW(ctrl->addKeyframe()); +} + +TEST_F(AnimationControlControllerTest, DeleteKeyframeWithNoAnimationDoesNotCrash) { + auto* ctrl = AnimationControlController::instance(); + EXPECT_NO_THROW(ctrl->deleteKeyframe()); +} + +TEST_F(AnimationControlControllerTest, NextKeyframeWithNoAnimationDoesNotCrash) { + auto* ctrl = AnimationControlController::instance(); + EXPECT_NO_THROW(ctrl->nextKeyframe()); +} + +TEST_F(AnimationControlControllerTest, PrevKeyframeWithNoAnimationDoesNotCrash) { + auto* ctrl = AnimationControlController::instance(); + EXPECT_NO_THROW(ctrl->prevKeyframe()); +} + +TEST_F(AnimationControlControllerTest, SetKfTransXWithNoKeyframeDoesNotCrash) { + auto* ctrl = AnimationControlController::instance(); + EXPECT_NO_THROW(ctrl->setKfTransX(1.0)); +} + +// ── Poll timer ───────────────────────────────────────────────────────────────── + +TEST_F(AnimationControlControllerTest, PollTimerDoesNotCrashWithNoAnimation) { + auto* ctrl = AnimationControlController::instance(); + // Let the 16ms poll timer fire several times via event-driven wait + QTest::qWait(100); + SUCCEED(); +} + +TEST_F(AnimationControlControllerTest, PollTimerDoesNotCrashWithAnimation) { + if (!canLoadMeshFiles()) GTEST_SKIP() << "No GL context"; + + Ogre::Entity* entity = setupAnimatedEntity("ACC_TimerTest"); + 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()); + + // Let the poll timer fire several times via event-driven wait + QTest::qWait(100); + SUCCEED(); +} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 50fcd89ff..173ed66fa 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -4,8 +4,7 @@ set(SRC_FILES about.cpp -animationcontrolwidget.cpp -animationcontrolslider.cpp +AnimationControlController.cpp main.cpp Manager.cpp material.cpp @@ -59,8 +58,7 @@ MaterialPresetLibrary.cpp ) set(HEADER_FILES -animationcontrolwidget.h -animationcontrolslider.h +AnimationControlController.h GlobalDefinitions.h Euler.h about.h diff --git a/src/animationcontrolslider_test.cpp b/src/animationcontrolslider_test.cpp index 743c2c32b..26f4e2822 100644 --- a/src/animationcontrolslider_test.cpp +++ b/src/animationcontrolslider_test.cpp @@ -1,251 +1,2 @@ -#include -#include -#include -#include "animationcontrolslider.h" - -// Test fixture for AnimationControlSlider class -class AnimationControlSliderTest : public ::testing::Test { -protected: - void SetUp() override { - app = qobject_cast(QCoreApplication::instance()); - ASSERT_NE(app, nullptr); - - slider = new AnimationControlSlider(); - slider->setRange(0, 100); - } - - void TearDown() override { - delete slider; - slider = nullptr; - } - - QApplication* app = nullptr; - AnimationControlSlider* slider = nullptr; -}; - -TEST_F(AnimationControlSliderTest, Constructor_DefaultSelectedTickIsMinusOne) { - // Verify that a freshly constructed slider has selectedTick == -1 - EXPECT_EQ(slider->selectedTick(), -1); -} - -TEST_F(AnimationControlSliderTest, SetRange_VerifyMinMax) { - // The fixture sets range to [0, 100] - EXPECT_EQ(slider->minimum(), 0); - EXPECT_EQ(slider->maximum(), 100); - - // Change range and verify - slider->setRange(10, 500); - EXPECT_EQ(slider->minimum(), 10); - EXPECT_EQ(slider->maximum(), 500); -} - -TEST_F(AnimationControlSliderTest, AddTick_SingleTickNoCrash) { - // Adding a single tick should not crash - EXPECT_NO_THROW(slider->addTick(50, Qt::blue)); -} - -TEST_F(AnimationControlSliderTest, AddMultipleTicks_NoCrash) { - // Adding multiple ticks should not crash - EXPECT_NO_THROW({ - slider->addTick(10, Qt::red); - slider->addTick(20, Qt::green); - slider->addTick(30, Qt::blue); - slider->addTick(40, Qt::yellow); - slider->addTick(50, Qt::cyan); - }); -} - -TEST_F(AnimationControlSliderTest, ClearTicks_ResetsSelectedTickToMinusOne) { - // Add some ticks and set a selected tick - slider->addTick(25, Qt::red); - slider->addTick(75, Qt::blue); - slider->setSelectedTick(25); - ASSERT_EQ(slider->selectedTick(), 25); - - // Clear ticks should reset selectedTick to -1 - slider->clearTicks(); - EXPECT_EQ(slider->selectedTick(), -1); -} - -TEST_F(AnimationControlSliderTest, SetSelectedTick_GetterReturnsCorrectValue) { - slider->setSelectedTick(50); - EXPECT_EQ(slider->selectedTick(), 50); -} - -TEST_F(AnimationControlSliderTest, SetSelectedTick_SameValueNoUnnecessaryUpdate) { - // Set to 42 once - slider->setSelectedTick(42); - EXPECT_EQ(slider->selectedTick(), 42); - - // Set to 42 again — should not change anything (guard condition in implementation) - slider->setSelectedTick(42); - EXPECT_EQ(slider->selectedTick(), 42); -} - -TEST_F(AnimationControlSliderTest, SetSelectedTick_DifferentValues) { - slider->setSelectedTick(10); - EXPECT_EQ(slider->selectedTick(), 10); - - slider->setSelectedTick(20); - EXPECT_EQ(slider->selectedTick(), 20); - - // Original value should not persist - EXPECT_NE(slider->selectedTick(), 10); -} - -TEST_F(AnimationControlSliderTest, ClearTicks_AfterSetSelectedTick_ResetsToMinusOne) { - // Set a selected tick first - slider->setSelectedTick(75); - ASSERT_EQ(slider->selectedTick(), 75); - - // Add ticks and then clear - slider->addTick(30, Qt::red); - slider->addTick(60, Qt::green); - slider->clearTicks(); - - // selectedTick should be reset - EXPECT_EQ(slider->selectedTick(), -1); -} - -TEST_F(AnimationControlSliderTest, PaintEvent_NoCrash) { - // Add ticks including a selected one to exercise all paint code paths - slider->addTick(10, Qt::red); - slider->addTick(50, Qt::green); - slider->addTick(90, Qt::blue); - slider->setSelectedTick(50); - - // Show widget and force a paint to trigger paintEvent - slider->resize(200, 30); - slider->show(); - if (app) app->processEvents(); - - EXPECT_NO_THROW({ - slider->repaint(); - if (app) app->processEvents(); - }); -} - -// ── setValue changes ───────────────────────────────────────────── - -TEST_F(AnimationControlSliderTest, SetValue_UpdatesSliderPosition) { - slider->setValue(42); - EXPECT_EQ(slider->value(), 42); -} - -TEST_F(AnimationControlSliderTest, SetValue_ClampsBelowMinimum) { - slider->setRange(10, 90); - slider->setValue(5); - EXPECT_EQ(slider->value(), 10); -} - -TEST_F(AnimationControlSliderTest, SetValue_ClampsAboveMaximum) { - slider->setRange(10, 90); - slider->setValue(100); - EXPECT_EQ(slider->value(), 90); -} - -TEST_F(AnimationControlSliderTest, SetValue_MultipleTimes) { - for (int i = 0; i <= 100; i += 10) { - slider->setValue(i); - EXPECT_EQ(slider->value(), i); - } -} - -TEST_F(AnimationControlSliderTest, SetValue_EmitsValueChanged) { - bool signalReceived = false; - int receivedValue = -1; - - QObject::connect(slider, &QSlider::valueChanged, [&](int val) { - signalReceived = true; - receivedValue = val; - }); - - slider->setValue(75); - if (app) app->processEvents(); - - EXPECT_TRUE(signalReceived); - EXPECT_EQ(receivedValue, 75); -} - -TEST_F(AnimationControlSliderTest, SetValue_SameValueDoesNotEmitSignal) { - slider->setValue(50); - if (app) app->processEvents(); - - int signalCount = 0; - QObject::connect(slider, &QSlider::valueChanged, [&](int) { - signalCount++; - }); - - // Setting the same value should not emit again - slider->setValue(50); - if (app) app->processEvents(); - - EXPECT_EQ(signalCount, 0); -} - -// ── Paint event with various states ────────────────────────────── - -TEST_F(AnimationControlSliderTest, PaintEvent_NoTicks_NoCrash) { - // No ticks added -- paint should still work - slider->resize(200, 30); - slider->show(); - if (app) app->processEvents(); - - EXPECT_NO_THROW({ - slider->repaint(); - if (app) app->processEvents(); - }); -} - -TEST_F(AnimationControlSliderTest, PaintEvent_SelectedTickOutOfRange_NoCrash) { - slider->addTick(50, Qt::red); - slider->setSelectedTick(999); // not matching any tick - - slider->resize(200, 30); - slider->show(); - if (app) app->processEvents(); - - EXPECT_NO_THROW({ - slider->repaint(); - if (app) app->processEvents(); - }); -} - -TEST_F(AnimationControlSliderTest, PaintEvent_ManyTicks_NoCrash) { - // Add many ticks to exercise the paint loop - for (int i = 0; i <= 100; i += 2) { - slider->addTick(i, QColor(i * 2, 255 - i * 2, 128)); - } - slider->setSelectedTick(50); - - slider->resize(200, 30); - slider->show(); - if (app) app->processEvents(); - - EXPECT_NO_THROW({ - slider->repaint(); - if (app) app->processEvents(); - }); -} - -// ── Tick management edge cases ─────────────────────────────────── - -TEST_F(AnimationControlSliderTest, AddTick_AtBoundaries) { - EXPECT_NO_THROW({ - slider->addTick(0, Qt::red); // at minimum - slider->addTick(100, Qt::blue); // at maximum - }); -} - -TEST_F(AnimationControlSliderTest, ClearTicks_WhenEmpty_NoCrash) { - // Clear when no ticks have been added - EXPECT_NO_THROW(slider->clearTicks()); - EXPECT_EQ(slider->selectedTick(), -1); -} - -TEST_F(AnimationControlSliderTest, ClearTicks_MultipleTimes_NoCrash) { - slider->addTick(10, Qt::red); - slider->clearTicks(); - slider->clearTicks(); // double clear - EXPECT_EQ(slider->selectedTick(), -1); -} +// AnimationControlSlider has been removed; the timeline is now a QML Canvas overlay. +// The slider tick functionality is covered indirectly by AnimationControlController_test.cpp. diff --git a/src/animationcontrolwidget_test.cpp b/src/animationcontrolwidget_test.cpp index 110b51f17..6a6958275 100644 --- a/src/animationcontrolwidget_test.cpp +++ b/src/animationcontrolwidget_test.cpp @@ -1,704 +1,2 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "animationcontrolwidget.h" -#include "Manager.h" -#include "SelectionSet.h" -#include "MeshImporterExporter.h" -#include -#include -#include -#include -#include -#include -#include "TestHelpers.h" - -class AnimationControlWidgetTest : public ::testing::Test { -protected: - QApplication* app = nullptr; - - void SetUp() override { - Manager::kill(); - QThread::msleep(50); - - app = qobject_cast(QCoreApplication::instance()); - ASSERT_NE(app, nullptr); - - if (!tryInitOgre()) { - GTEST_SKIP() << "Skipping: Ogre initialization failed"; - } - createStandardOgreMaterials(); - } - - void TearDown() override { - SelectionSet::getSingleton()->clear(); - if (app) app->processEvents(); - } - - // Helper: create animated entity and select its node - Ogre::Entity* setupAnimatedEntity(const std::string& name) { - if (!canLoadMeshFiles()) return nullptr; - Ogre::Entity* entity = createAnimatedTestEntity(name); - if (!entity) return nullptr; - Ogre::SceneNode* node = entity->getParentSceneNode(); - SelectionSet::getSingleton()->selectOne(node); - if (app) app->processEvents(); - return entity; - } -}; - -TEST_F(AnimationControlWidgetTest, ConstructionDoesNotCrash) { - AnimationControlWidget widget; - SUCCEED(); -} - -TEST_F(AnimationControlWidgetTest, UpdateAnimationTreeWithNoSelection) { - AnimationControlWidget widget; - SelectionSet::getSingleton()->clear(); - EXPECT_NO_THROW(widget.updateAnimationTree()); -} - -TEST_F(AnimationControlWidgetTest, UpdateAnimationTreeWithAnimatedEntity) { - if (!canLoadMeshFiles()) { - GTEST_SKIP() << "Skipping: Cannot load mesh files (no GL context)"; - } - - try { - AnimationControlWidget widget; - MeshImporterExporter::importer(QStringList{"./media/models/robot.mesh"}); - - auto entities = Manager::getSingleton()->getEntities(); - ASSERT_FALSE(entities.isEmpty()); - - Ogre::Entity* entity = nullptr; - for (auto* obj : entities) { - if (obj->getMovableType() == "Entity") { - entity = static_cast(obj); - break; - } - } - ASSERT_NE(entity, nullptr); - - Ogre::SceneNode* parentNode = entity->getParentSceneNode(); - ASSERT_NE(parentNode, nullptr); - SelectionSet::getSingleton()->selectOne(parentNode); - - EXPECT_NO_THROW(widget.updateAnimationTree()); - } catch (const Ogre::Exception& e) { - GTEST_SKIP() << "Skipping: Ogre exception (" << e.getFullDescription() << ")"; - } catch (...) { - GTEST_SKIP() << "Skipping: unknown exception"; - } -} - -// --- Tests using in-memory animated entities --- - -TEST_F(AnimationControlWidgetTest, UpdateAnimationTreeWithInMemoryEntity) { - if (!canLoadMeshFiles()) { - GTEST_SKIP() << "Skipping: Cannot create in-memory meshes (no GL context)"; - } - - AnimationControlWidget widget; - Ogre::Entity* entity = setupAnimatedEntity("AnimTreeTest"); - ASSERT_NE(entity, nullptr); - - widget.updateAnimationTree(); - QTreeWidget* tree = widget.findChild("treeWidget"); - ASSERT_NE(tree, nullptr); - - // Should have one top-level item (the entity) - EXPECT_EQ(tree->topLevelItemCount(), 1); - - // The top-level item should have one child (the "TestAnim" animation) - QTreeWidgetItem* entityItem = tree->topLevelItem(0); - EXPECT_GE(entityItem->childCount(), 1); - EXPECT_TRUE(entityItem->child(0)->text(0).contains("TestAnim")); -} - -TEST_F(AnimationControlWidgetTest, SelectAnimationPopulatesBoneList) { - if (!canLoadMeshFiles()) { - GTEST_SKIP() << "Skipping: Cannot create in-memory meshes (no GL context)"; - } - - AnimationControlWidget widget; - Ogre::Entity* entity = setupAnimatedEntity("BoneListTest"); - ASSERT_NE(entity, nullptr); - - widget.updateAnimationTree(); - - QTreeWidget* tree = widget.findChild("treeWidget"); - ASSERT_NE(tree, nullptr); - ASSERT_GE(tree->topLevelItemCount(), 1); - - // Select the animation child item to trigger bone list population - QTreeWidgetItem* animItem = tree->topLevelItem(0)->child(0); - ASSERT_NE(animItem, nullptr); - tree->setCurrentItem(animItem); - if (app) app->processEvents(); - - // Bone list should now have at least one bone (the "Child" bone from TestAnim track) - QListWidget* boneList = widget.findChild("boneList"); - ASSERT_NE(boneList, nullptr); - EXPECT_GE(boneList->count(), 1); -} - -TEST_F(AnimationControlWidgetTest, SelectAnimationUpdatesSliderRange) { - if (!canLoadMeshFiles()) { - GTEST_SKIP() << "Skipping: Cannot create in-memory meshes (no GL context)"; - } - - AnimationControlWidget widget; - Ogre::Entity* entity = setupAnimatedEntity("SliderRangeTest"); - ASSERT_NE(entity, nullptr); - - widget.updateAnimationTree(); - - QTreeWidget* tree = widget.findChild("treeWidget"); - QTreeWidgetItem* animItem = tree->topLevelItem(0)->child(0); - tree->setCurrentItem(animItem); - if (app) app->processEvents(); - - // Slider max should be 1000 (1.0 seconds * 1000) - auto* slider = widget.findChild("horizontalSlider"); - ASSERT_NE(slider, nullptr); - EXPECT_EQ(slider->maximum(), 1000); -} - -TEST_F(AnimationControlWidgetTest, SelectAnimationUpdatesLengthSpinBox) { - if (!canLoadMeshFiles()) { - GTEST_SKIP() << "Skipping: Cannot create in-memory meshes (no GL context)"; - } - - AnimationControlWidget widget; - Ogre::Entity* entity = setupAnimatedEntity("LenSpinTest"); - ASSERT_NE(entity, nullptr); - - widget.updateAnimationTree(); - - QTreeWidget* tree = widget.findChild("treeWidget"); - QTreeWidgetItem* animItem = tree->topLevelItem(0)->child(0); - tree->setCurrentItem(animItem); - if (app) app->processEvents(); - - auto* spinBox = widget.findChild("lengthSpinBox"); - ASSERT_NE(spinBox, nullptr); - EXPECT_NEAR(spinBox->value(), 1.0, 0.01); -} - -TEST_F(AnimationControlWidgetTest, SetAnimationFrameNoSelection) { - AnimationControlWidget widget; - // No selection, no crash - auto* slider = widget.findChild("horizontalSlider"); - ASSERT_NE(slider, nullptr); - EXPECT_NO_THROW(slider->setValue(500)); -} - -TEST_F(AnimationControlWidgetTest, SetAnimationFrameWithAnimation) { - if (!canLoadMeshFiles()) { - GTEST_SKIP() << "Skipping: Cannot create in-memory meshes (no GL context)"; - } - - AnimationControlWidget widget; - Ogre::Entity* entity = setupAnimatedEntity("SetFrameTest"); - ASSERT_NE(entity, nullptr); - - widget.updateAnimationTree(); - - QTreeWidget* tree = widget.findChild("treeWidget"); - tree->setCurrentItem(tree->topLevelItem(0)->child(0)); - if (app) app->processEvents(); - - // Move slider to middle (t=0.5s = 500ms), should be near keyframe[1] - auto* slider = widget.findChild("horizontalSlider"); - ASSERT_NE(slider, nullptr); - slider->setValue(500); - if (app) app->processEvents(); - - // Verify table shows keyframe values - auto* table = widget.findChild("tableWidget"); - ASSERT_NE(table, nullptr); - // Translation X should be near 0.5 (kf1 translate) - EXPECT_FALSE(table->item(0, 1)->text().isEmpty()); -} - -TEST_F(AnimationControlWidgetTest, SetAnimationFrameAtKeyframeZero) { - if (!canLoadMeshFiles()) { - GTEST_SKIP() << "Skipping: Cannot create in-memory meshes (no GL context)"; - } - - AnimationControlWidget widget; - Ogre::Entity* entity = setupAnimatedEntity("FrameZeroTest"); - ASSERT_NE(entity, nullptr); - - widget.updateAnimationTree(); - - QTreeWidget* tree = widget.findChild("treeWidget"); - tree->setCurrentItem(tree->topLevelItem(0)->child(0)); - if (app) app->processEvents(); - - auto* slider = widget.findChild("horizontalSlider"); - slider->setValue(0); - if (app) app->processEvents(); - - auto* table = widget.findChild("tableWidget"); - ASSERT_NE(table, nullptr); - // At t=0, translation should be (0,0,0) - EXPECT_FALSE(table->item(0, 1)->text().isEmpty()); -} - -TEST_F(AnimationControlWidgetTest, OnAddKeyframe) { - if (!canLoadMeshFiles()) { - GTEST_SKIP() << "Skipping: Cannot create in-memory meshes (no GL context)"; - } - - AnimationControlWidget widget; - Ogre::Entity* entity = setupAnimatedEntity("AddKfTest"); - ASSERT_NE(entity, nullptr); - - widget.updateAnimationTree(); - - QTreeWidget* tree = widget.findChild("treeWidget"); - tree->setCurrentItem(tree->topLevelItem(0)->child(0)); - if (app) app->processEvents(); - - // Move slider to t=0.25s (between kf0 and kf1) - auto* slider = widget.findChild("horizontalSlider"); - slider->setValue(250); - if (app) app->processEvents(); - - // Count existing keyframes via skeleton animation track - auto* skel = entity->getSkeleton(); - auto* anim = skel->getAnimation("TestAnim"); - auto& tracks = anim->_getNodeTrackList(); - ASSERT_FALSE(tracks.empty()); - auto* track = tracks.begin()->second; - int kfBefore = track->getNumKeyFrames(); - - // Click add keyframe - auto* addBtn = widget.findChild("addKeyframeButton"); - ASSERT_NE(addBtn, nullptr); - addBtn->click(); - if (app) app->processEvents(); - - EXPECT_EQ(track->getNumKeyFrames(), kfBefore + 1); -} - -TEST_F(AnimationControlWidgetTest, OnDeleteKeyframe) { - if (!canLoadMeshFiles()) { - GTEST_SKIP() << "Skipping: Cannot create in-memory meshes (no GL context)"; - } - - AnimationControlWidget widget; - Ogre::Entity* entity = setupAnimatedEntity("DeleteKfTest"); - ASSERT_NE(entity, nullptr); - - widget.updateAnimationTree(); - - QTreeWidget* tree = widget.findChild("treeWidget"); - tree->setCurrentItem(tree->topLevelItem(0)->child(0)); - if (app) app->processEvents(); - - // Move to exact keyframe position (t=0.5s = 500ms) - auto* slider = widget.findChild("horizontalSlider"); - slider->setValue(500); - if (app) app->processEvents(); - - auto* skel = entity->getSkeleton(); - auto* anim = skel->getAnimation("TestAnim"); - auto* track = anim->_getNodeTrackList().begin()->second; - int kfBefore = track->getNumKeyFrames(); - - // Click delete keyframe - auto* delBtn = widget.findChild("deleteKeyframeButton"); - ASSERT_NE(delBtn, nullptr); - delBtn->click(); - if (app) app->processEvents(); - - EXPECT_EQ(track->getNumKeyFrames(), kfBefore - 1); -} - -TEST_F(AnimationControlWidgetTest, OnPrevKeyframe) { - if (!canLoadMeshFiles()) { - GTEST_SKIP() << "Skipping: Cannot create in-memory meshes (no GL context)"; - } - - AnimationControlWidget widget; - Ogre::Entity* entity = setupAnimatedEntity("PrevKfTest"); - ASSERT_NE(entity, nullptr); - - widget.updateAnimationTree(); - - QTreeWidget* tree = widget.findChild("treeWidget"); - tree->setCurrentItem(tree->topLevelItem(0)->child(0)); - if (app) app->processEvents(); - - // Move slider to end (t=1.0s) - auto* slider = widget.findChild("horizontalSlider"); - slider->setValue(1000); - if (app) app->processEvents(); - - // Click prev keyframe — should go to kf at 0.5s - auto* prevBtn = widget.findChild("prevKeyframeButton"); - ASSERT_NE(prevBtn, nullptr); - prevBtn->click(); - if (app) app->processEvents(); - - EXPECT_EQ(slider->value(), 500); -} - -TEST_F(AnimationControlWidgetTest, OnNextKeyframe) { - if (!canLoadMeshFiles()) { - GTEST_SKIP() << "Skipping: Cannot create in-memory meshes (no GL context)"; - } - - AnimationControlWidget widget; - Ogre::Entity* entity = setupAnimatedEntity("NextKfTest"); - ASSERT_NE(entity, nullptr); - - widget.updateAnimationTree(); - - QTreeWidget* tree = widget.findChild("treeWidget"); - tree->setCurrentItem(tree->topLevelItem(0)->child(0)); - if (app) app->processEvents(); - - // Move slider to start (t=0.0s) - auto* slider = widget.findChild("horizontalSlider"); - slider->setValue(0); - if (app) app->processEvents(); - - // Click next keyframe — should go to kf at 0.5s - auto* nextBtn = widget.findChild("nextKeyframeButton"); - ASSERT_NE(nextBtn, nullptr); - nextBtn->click(); - if (app) app->processEvents(); - - EXPECT_EQ(slider->value(), 500); -} - -TEST_F(AnimationControlWidgetTest, OnAnimationLengthChanged) { - if (!canLoadMeshFiles()) { - GTEST_SKIP() << "Skipping: Cannot create in-memory meshes (no GL context)"; - } - - AnimationControlWidget widget; - Ogre::Entity* entity = setupAnimatedEntity("LenChangeTest"); - ASSERT_NE(entity, nullptr); - - widget.updateAnimationTree(); - - QTreeWidget* tree = widget.findChild("treeWidget"); - tree->setCurrentItem(tree->topLevelItem(0)->child(0)); - if (app) app->processEvents(); - - auto* slider = widget.findChild("horizontalSlider"); - auto* spinBox = widget.findChild("lengthSpinBox"); - ASSERT_NE(spinBox, nullptr); - - // Change length to 2.0 seconds - spinBox->setValue(2.0); - if (app) app->processEvents(); - - EXPECT_EQ(slider->maximum(), 2000); - - auto* maxLabel = widget.findChild("maxSliderLabel"); - if (maxLabel) { - EXPECT_EQ(maxLabel->text(), "2"); - } -} - -TEST_F(AnimationControlWidgetTest, OnKeyframeValueChangedTranslation) { - if (!canLoadMeshFiles()) { - GTEST_SKIP() << "Skipping: Cannot create in-memory meshes (no GL context)"; - } - - AnimationControlWidget widget; - Ogre::Entity* entity = setupAnimatedEntity("KfValueTest"); - ASSERT_NE(entity, nullptr); - - widget.updateAnimationTree(); - - QTreeWidget* tree = widget.findChild("treeWidget"); - tree->setCurrentItem(tree->topLevelItem(0)->child(0)); - if (app) app->processEvents(); - - // Move to keyframe at t=0.5s - auto* slider = widget.findChild("horizontalSlider"); - slider->setValue(500); - if (app) app->processEvents(); - - // Modify translation X in the table - auto* table = widget.findChild("tableWidget"); - ASSERT_NE(table, nullptr); - table->item(0, 1)->setText("99.5"); - if (app) app->processEvents(); - - // Verify the keyframe was updated - auto* skel = entity->getSkeleton(); - auto* anim = skel->getAnimation("TestAnim"); - auto* track = anim->_getNodeTrackList().begin()->second; - - // Find the keyframe at t=0.5 - for (unsigned short i = 0; i < track->getNumKeyFrames(); i++) { - auto* kf = static_cast(track->getKeyFrame(i)); - if (std::fabs(kf->getTime() - 0.5f) < 0.01f) { - EXPECT_NEAR(kf->getTranslate().x, 99.5f, 0.1f); - break; - } - } -} - -TEST_F(AnimationControlWidgetTest, UpdateTableEditabilityOnKeyframe) { - if (!canLoadMeshFiles()) { - GTEST_SKIP() << "Skipping: Cannot create in-memory meshes (no GL context)"; - } - - AnimationControlWidget widget; - Ogre::Entity* entity = setupAnimatedEntity("EditableTest"); - ASSERT_NE(entity, nullptr); - - widget.updateAnimationTree(); - - QTreeWidget* tree = widget.findChild("treeWidget"); - tree->setCurrentItem(tree->topLevelItem(0)->child(0)); - if (app) app->processEvents(); - - auto* table = widget.findChild("tableWidget"); - ASSERT_NE(table, nullptr); - - // Move to a keyframe position - auto* slider = widget.findChild("horizontalSlider"); - slider->setValue(500); - if (app) app->processEvents(); - - // Translation row (0), col 1 should be editable on keyframe - if (table->item(0, 1)) { - EXPECT_TRUE(table->item(0, 1)->flags() & Qt::ItemIsEditable); - } - - // Translation row (0), col 0 ("-") should NOT be editable even on keyframe - if (table->item(0, 0)) { - EXPECT_FALSE(table->item(0, 0)->flags() & Qt::ItemIsEditable); - } -} - -TEST_F(AnimationControlWidgetTest, DeleteKeyframeButtonDisabledByDefault) { - AnimationControlWidget widget; - - auto* delBtn = widget.findChild("deleteKeyframeButton"); - ASSERT_NE(delBtn, nullptr); - // Initially no keyframe selected, so delete should be disabled - EXPECT_FALSE(delBtn->isEnabled()); -} - -TEST_F(AnimationControlWidgetTest, TimerPlaybackDoesNotCrash) { - if (!canLoadMeshFiles()) { - GTEST_SKIP() << "Skipping: Cannot create in-memory meshes (no GL context)"; - } - - AnimationControlWidget widget; - Ogre::Entity* entity = setupAnimatedEntity("TimerTest"); - ASSERT_NE(entity, nullptr); - - widget.updateAnimationTree(); - - QTreeWidget* tree = widget.findChild("treeWidget"); - tree->setCurrentItem(tree->topLevelItem(0)->child(0)); - if (app) app->processEvents(); - - // Let the timer fire a few times (16ms interval) - QThread::msleep(50); - if (app) app->processEvents(); - QThread::msleep(50); - if (app) app->processEvents(); - - SUCCEED(); -} - -TEST_F(AnimationControlWidgetTest, MultipleAnimationsInTree) { - if (!canLoadMeshFiles()) { - GTEST_SKIP() << "Skipping: Cannot create in-memory meshes (no GL context)"; - } - - // Create entity with two animations - auto skel = Ogre::SkeletonManager::getSingleton().create( - "MultiAnimSkel", Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); - auto* rootBone = skel->createBone("Root", 0); - auto* childBone = skel->createBone("Child", 1); - childBone->setPosition(Ogre::Vector3(0, 1, 0)); - rootBone->addChild(childBone); - skel->setBindingPose(); - - // Animation 1 - auto* anim1 = skel->createAnimation("Walk", 1.0f); - auto* track1 = anim1->createNodeTrack(1); - track1->setAssociatedNode(childBone); - track1->createNodeKeyFrame(0.0f)->setTranslate(Ogre::Vector3::ZERO); - track1->createNodeKeyFrame(1.0f)->setTranslate(Ogre::Vector3(1, 0, 0)); - - // Animation 2 - auto* anim2 = skel->createAnimation("Run", 0.5f); - auto* track2 = anim2->createNodeTrack(1); - track2->setAssociatedNode(childBone); - track2->createNodeKeyFrame(0.0f)->setTranslate(Ogre::Vector3::ZERO); - track2->createNodeKeyFrame(0.5f)->setTranslate(Ogre::Vector3(2, 0, 0)); - - auto mesh = Ogre::MeshManager::getSingleton().createManual( - "MultiAnimMesh", Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); - auto* sub = mesh->createSubMesh(); - mesh->sharedVertexData = new Ogre::VertexData(); - auto* decl = mesh->sharedVertexData->vertexDeclaration; - decl->addElement(0, 0, Ogre::VET_FLOAT3, Ogre::VES_POSITION); - auto vbuf = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer( - decl->getVertexSize(0), 3, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); - float verts[] = {0,0,0, 1,0,0, 0,1,0}; - vbuf->writeData(0, sizeof(verts), verts); - mesh->sharedVertexData->vertexBufferBinding->setBinding(0, vbuf); - mesh->sharedVertexData->vertexCount = 3; - auto ibuf = Ogre::HardwareBufferManager::getSingleton().createIndexBuffer( - Ogre::HardwareIndexBuffer::IT_16BIT, 3, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); - uint16_t idx[] = {0, 1, 2}; - ibuf->writeData(0, sizeof(idx), idx); - sub->useSharedVertices = true; - sub->indexData->indexBuffer = ibuf; - sub->indexData->indexCount = 3; - Ogre::VertexBoneAssignment vba; - vba.boneIndex = 1; vba.weight = 1.0f; - for (unsigned short v = 0; v < 3; ++v) { vba.vertexIndex = v; mesh->addBoneAssignment(vba); } - mesh->_notifySkeleton(skel); - mesh->_setBounds(Ogre::AxisAlignedBox(-1,-1,-1,2,2,2)); - mesh->_setBoundingSphereRadius(3.0); - mesh->load(); - - auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); - // Entity name must match node name — getSelectedEntities() looks up - // entities via sceneMgr->hasEntity(node->getName()) - auto* node = Manager::getSingleton()->addSceneNode("MultiAnim"); - auto* entity = sceneMgr->createEntity("MultiAnim", mesh); - node->attachObject(entity); - SelectionSet::getSingleton()->selectOne(node); - if (app) app->processEvents(); - - AnimationControlWidget widget; - widget.updateAnimationTree(); - - QTreeWidget* tree = widget.findChild("treeWidget"); - ASSERT_NE(tree, nullptr); - ASSERT_GE(tree->topLevelItemCount(), 1); - - // Should have 2 animation children - QTreeWidgetItem* entityItem = tree->topLevelItem(0); - EXPECT_EQ(entityItem->childCount(), 2); - - // Find the "Run" animation by name (iteration order is not guaranteed) - QTreeWidgetItem* runItem = nullptr; - for (int i = 0; i < entityItem->childCount(); ++i) { - if (entityItem->child(i)->text(0).contains("Run")) { - runItem = entityItem->child(i); - break; - } - } - ASSERT_NE(runItem, nullptr) << "Could not find 'Run' animation in tree"; - tree->setCurrentItem(runItem); - if (app) app->processEvents(); - - auto* slider = widget.findChild("horizontalSlider"); - ASSERT_NE(slider, nullptr); - // "Run" anim length is 0.5s = 500ms - EXPECT_EQ(slider->maximum(), 500); -} - -TEST_F(AnimationControlWidgetTest, PrevKeyframeWrapsToFirst) { - if (!canLoadMeshFiles()) { - GTEST_SKIP() << "Skipping: Cannot create in-memory meshes (no GL context)"; - } - - AnimationControlWidget widget; - Ogre::Entity* entity = setupAnimatedEntity("PrevWrapTest"); - ASSERT_NE(entity, nullptr); - - widget.updateAnimationTree(); - - QTreeWidget* tree = widget.findChild("treeWidget"); - tree->setCurrentItem(tree->topLevelItem(0)->child(0)); - if (app) app->processEvents(); - - // Move slider to 0 (at first keyframe) - auto* slider = widget.findChild("horizontalSlider"); - slider->setValue(0); - if (app) app->processEvents(); - - // Clicking prev should still go to first keyframe (wrap/stay) - auto* prevBtn = widget.findChild("prevKeyframeButton"); - prevBtn->click(); - if (app) app->processEvents(); - - // Should be at first keyframe (t=0) - EXPECT_EQ(slider->value(), 0); -} - -TEST_F(AnimationControlWidgetTest, NextKeyframeWrapsToLast) { - if (!canLoadMeshFiles()) { - GTEST_SKIP() << "Skipping: Cannot create in-memory meshes (no GL context)"; - } - - AnimationControlWidget widget; - Ogre::Entity* entity = setupAnimatedEntity("NextWrapTest"); - ASSERT_NE(entity, nullptr); - - widget.updateAnimationTree(); - - QTreeWidget* tree = widget.findChild("treeWidget"); - tree->setCurrentItem(tree->topLevelItem(0)->child(0)); - if (app) app->processEvents(); - - // Move slider to end - auto* slider = widget.findChild("horizontalSlider"); - slider->setValue(1000); - if (app) app->processEvents(); - - // Clicking next should go to last keyframe - auto* nextBtn = widget.findChild("nextKeyframeButton"); - nextBtn->click(); - if (app) app->processEvents(); - - // Should be at last keyframe (t=1.0s = 1000ms) - EXPECT_EQ(slider->value(), 1000); -} - -TEST_F(AnimationControlWidgetTest, AnimationLengthClampsSlider) { - if (!canLoadMeshFiles()) { - GTEST_SKIP() << "Skipping: Cannot create in-memory meshes (no GL context)"; - } - - AnimationControlWidget widget; - Ogre::Entity* entity = setupAnimatedEntity("ClampTest"); - ASSERT_NE(entity, nullptr); - - widget.updateAnimationTree(); - - QTreeWidget* tree = widget.findChild("treeWidget"); - tree->setCurrentItem(tree->topLevelItem(0)->child(0)); - if (app) app->processEvents(); - - // Move slider near end - auto* slider = widget.findChild("horizontalSlider"); - slider->setValue(900); - if (app) app->processEvents(); - - // Shorten animation to 0.5s — slider should clamp - auto* spinBox = widget.findChild("lengthSpinBox"); - ASSERT_NE(spinBox, nullptr); - spinBox->setValue(0.5); - if (app) app->processEvents(); - - EXPECT_LE(slider->value(), 500); -} +// AnimationControlWidget has been replaced by AnimationControlController (QML-based). +// Tests have been migrated to AnimationControlController_test.cpp. diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 24fb9f4de..f598ab2fa 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -38,7 +38,7 @@ #include "AnimationWidget.h" #include "AnimationMerger.h" #include "SelectionSet.h" -#include "animationcontrolwidget.h" +#include "AnimationControlController.h" #include "MaterialEditorQML.h" #include "LLMSettingsWidget.h" #include "MCPSettingsDialog.h" @@ -52,6 +52,7 @@ #include "ModelDownloader.h" #include "UndoManager.h" #include "PropertiesPanelController.h" +#include #include #include @@ -226,7 +227,9 @@ MainWindow::~MainWindow() Manager* manager = Manager::getSingletonPtr(); if(manager && manager->getMainWindow() == this) { - // Only destroy if this MainWindow owns the Manager + // Destroy AnimationControlController before Manager: its poll timer holds + // raw Ogre pointers that become dangling once Manager is destroyed. + AnimationControlController::kill(); Manager::kill(); } } @@ -290,11 +293,15 @@ void MainWindow::initToolBar() m_propertiesPanel = new QQuickWidget(); m_propertiesPanel->setResizeMode(QQuickWidget::SizeRootObjectToView); - // Register PropertiesPanelController in this widget's engine + // Register QML singletons before setSource() so all imports resolve qmlRegisterSingletonType("PropertiesPanel", 1, 0, "PropertiesPanelController", [](QQmlEngine* engine, QJSEngine*) -> QObject* { return PropertiesPanelController::qmlInstance(engine, nullptr); }); + qmlRegisterSingletonType("AnimationControl", 1, 0, "AnimationControlController", + [](QQmlEngine* engine, QJSEngine*) -> QObject* { + return AnimationControlController::qmlInstance(engine, nullptr); + }); m_propertiesPanel->setSource(QUrl("qrc:/PropertiesPanel/PropertiesPanel.qml")); @@ -370,13 +377,9 @@ void MainWindow::initToolBar() auto pAnimationWidget = new AnimationWidget(this); pAnimationWidget->hide(); - // Animation Control Widget — bottom dock, auto-shown for animated entities - auto pAnimationControlWidget = new AnimationControlWidget(this); - addDockWidget(Qt::BottomDockWidgetArea, pAnimationControlWidget); - // Constrain height so it doesn't eat viewport space - resizeDocks({pAnimationControlWidget}, {180}, Qt::Vertical); - pAnimationControlWidget->setVisible(false); - connect(pAnimationWidget,SIGNAL(changeAnimationName(const std::string&)),pAnimationControlWidget,SLOT(updateAnimationTree())); + // Rename signal from AnimationWidget still triggers a tree refresh + connect(pAnimationWidget, SIGNAL(changeAnimationName(const std::string&)), + AnimationControlController::instance(), SLOT(updateAnimationTree())); connect(pAnimationWidget,SIGNAL(changeAnimationState(bool)),this,SLOT(setPlaying(bool))); @@ -388,23 +391,6 @@ void MainWindow::initToolBar() setPlaying(PropertiesPanelController::instance()->isPlaying()); }); - // Toggle Animation Control visibility from menu - connect(ui->actionAnimation_Control, &QAction::toggled, this, [pAnimationControlWidget, this](bool checked) { - if (checked && PropertiesPanelController::instance()->hasAnimations()) - pAnimationControlWidget->setVisible(true); - else if (!checked) - pAnimationControlWidget->setVisible(false); - }); - - // Auto-show/hide Animation Control based on selection (respects menu toggle) - connect(SelectionSet::getSingleton(), &SelectionSet::selectionChanged, this, [pAnimationControlWidget, this]() { - QTimer::singleShot(0, pAnimationControlWidget, [pAnimationControlWidget, this]() { - bool hasAnims = PropertiesPanelController::instance()->hasAnimations(); - bool toggled = ui->actionAnimation_Control->isChecked(); - pAnimationControlWidget->setVisible(hasAnims && toggled); - }); - }); - // Merge Animations button — enable/disable based on selection connect(SelectionSet::getSingleton(), &SelectionSet::selectionChanged, this, &MainWindow::updateMergeAnimationsButton); diff --git a/src/qml_resources.qrc b/src/qml_resources.qrc index 293166d97..71ff14835 100644 --- a/src/qml_resources.qrc +++ b/src/qml_resources.qrc @@ -22,4 +22,7 @@ ../qml/TransformField.qml ../qml/SceneTreeNode.qml + + ../qml/AnimationControlPanel.qml + \ No newline at end of file diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 45aee4896..2f26c8613 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -15,8 +15,7 @@ if(BUILD_TESTS) # Basic source files (excluding main.cpp for tests) set(TEST_SRC_FILES ${CMAKE_CURRENT_SOURCE_DIR}/../src/about.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/../src/animationcontrolwidget.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/../src/animationcontrolslider.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/AnimationControlController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/Manager.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/material.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MaterialEditorQML.cpp @@ -70,8 +69,7 @@ if(BUILD_TESTS) ) set(TEST_HEADER_FILES - ${CMAKE_CURRENT_SOURCE_DIR}/../src/animationcontrolwidget.h - ${CMAKE_CURRENT_SOURCE_DIR}/../src/animationcontrolslider.h + ${CMAKE_CURRENT_SOURCE_DIR}/../src/AnimationControlController.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/GlobalDefinitions.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/Euler.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/about.h diff --git a/ui_files/mainwindow.ui b/ui_files/mainwindow.ui index 05a2798fc..46c9e93d6 100755 --- a/ui_files/mainwindow.ui +++ b/ui_files/mainwindow.ui @@ -61,7 +61,6 @@ - @@ -319,17 +318,6 @@ Tools - - - true - - - true - - - Animation Control - - true