diff --git a/qml/AnimationCurveEditor.qml b/qml/AnimationCurveEditor.qml new file mode 100644 index 000000000..ff3b7c1a5 --- /dev/null +++ b/qml/AnimationCurveEditor.qml @@ -0,0 +1,274 @@ +import QtQuick +import QtQuick.Controls +import AnimationControl 1.0 + +// Curve editor — visualizes per-channel animation curves with Bezier handles. +// Reads keyframe times + values from AnimationControlController and tangent +// state from CurveEditModel. Read-only display in this slice (D3a); handle +// dragging + resample-into-track lands in D3b. The dope sheet remains the +// primary editing surface for keyframe selection and time shifts. +Rectangle { + id: root + color: AnimationControlController.panelColor + focus: true + + property real pxPerSec: 200 + property real viewStart: 0.0 + property real yScale: 60 // px per unit value + property real yCenter: 0 // value at the vertical center of the canvas + + property int leftStripWidth: 130 + + // Pulled from AnimationControlController on signal. Each row is the same + // shape as the dope sheet's allBoneRows() returns: { bone, keyTimes, + // channels: { tx, ty, ..., sz: bool } }. + property var rows: AnimationControlController.allBoneRows() + + // Selected bone — only that bone's animated channels are drawn. Reuses + // AnimationControlController.selectedBone for cross-panel sync. + readonly property string selectedBone: AnimationControlController.selectedBone + + readonly property var channelOrder: [ + { id: "tx", label: "T.X", color: "#c04040" }, + { id: "ty", label: "T.Y", color: "#40c040" }, + { id: "tz", label: "T.Z", color: "#4040c0" }, + { id: "rw", label: "R.W", color: "#a040a0" }, + { id: "rx", label: "R.X", color: "#c04040" }, + { id: "ry", label: "R.Y", color: "#40c040" }, + { id: "rz", label: "R.Z", color: "#4040c0" }, + { id: "sx", label: "S.X", color: "#c08040" }, + { id: "sy", label: "S.Y", color: "#80c040" }, + { id: "sz", label: "S.Z", color: "#4080c0" } + ] + + function selectedBoneRow() { + for (var i = 0; i < rows.length; i++) { + if (rows[i].bone === selectedBone) return rows[i] + } + return null + } + + function activeChannelsForSelected() { + var row = selectedBoneRow() + if (!row || !row.channels) return [] + var result = [] + for (var i = 0; i < channelOrder.length; i++) { + if (row.channels[channelOrder[i].id]) result.push(channelOrder[i]) + } + return result + } + + Connections { + target: AnimationControlController + function onBoneRowsChanged() { root.rows = AnimationControlController.allBoneRows(); curveCanvas.requestPaint() } + function onSelectionChanged() { root.rows = AnimationControlController.allBoneRows(); curveCanvas.requestPaint() } + function onKeyframeTicksChanged() { curveCanvas.requestPaint() } + function onBoneListChanged() { curveCanvas.requestPaint() } + } + + Connections { + target: CurveEditModel + function onModelChanged() { curveCanvas.requestPaint() } + } + + // ── Empty-state placeholder ────────────────────────────────────────────── + Text { + anchors.centerIn: parent + visible: !AnimationControlController.hasAnimation || !root.selectedBone + text: !AnimationControlController.hasAnimation + ? "Select a rigged mesh and an animation." + : "Select a bone in the Animation Control or Dope Sheet." + color: AnimationControlController.disabledTextColor + font.pixelSize: 12 + } + + // ── Header ─────────────────────────────────────────────────────────────── + Rectangle { + id: header + width: parent.width; height: 24 + color: AnimationControlController.headerColor + border.color: AnimationControlController.borderColor + visible: AnimationControlController.hasAnimation && root.selectedBone + + Text { + anchors.left: parent.left; anchors.leftMargin: 6 + anchors.verticalCenter: parent.verticalCenter + text: "Curves — " + root.selectedBone + font.bold: true; font.pixelSize: 11 + color: AnimationControlController.textColor + } + + // Channel legend + Row { + anchors.right: parent.right; anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + spacing: 10 + Repeater { + model: root.activeChannelsForSelected() + Row { + spacing: 4 + Rectangle { + width: 10; height: 10; radius: 2 + color: modelData.color + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: modelData.label + color: AnimationControlController.textColor + font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + } + } + } + } + } + + // ── Curve canvas ───────────────────────────────────────────────────────── + Canvas { + id: curveCanvas + anchors.left: parent.left + anchors.top: header.visible ? header.bottom : parent.top + anchors.right: parent.right + anchors.bottom: parent.bottom + visible: header.visible + + function valueAtTimeForChannel(boneRow, channelId, time) { + // Read the channel's values directly from the keyframes via the + // controller. We don't have a dedicated API yet; fall back to + // CurveEditModel.evaluate, passing in the values we sample from + // the dope-sheet row's keyframe metadata. For D3a we approximate + // by using a single sentinel sample series — D3b will plumb the + // per-channel values through. + return CurveEditModel.evaluate( + AnimationControlController.selectedEntityName, + AnimationControlController.selectedAnimation, + boneRow.bone, channelId, time, + boneRow.keyTimes, + boneRow.keyTimes // placeholder; D3b reads real channel values + ) + } + + onPaint: { + var ctx = getContext("2d"); ctx.clearRect(0, 0, width, height) + var row = root.selectedBoneRow() + if (!row) return + var maxT = AnimationControlController.animationLength + if (maxT <= 0) return + + // Time-axis ruler at the bottom + ctx.strokeStyle = AnimationControlController.borderColor + ctx.fillStyle = AnimationControlController.textColor + ctx.font = "10px sans-serif"; ctx.lineWidth = 1 + var step = root.pxPerSec >= 100 ? 0.25 : (root.pxPerSec >= 40 ? 1.0 : 5.0) + for (var t = 0; t <= maxT; t += step) { + var x = (t - root.viewStart) * root.pxPerSec + ctx.beginPath(); ctx.moveTo(x, height - 12); ctx.lineTo(x, height); ctx.stroke() + ctx.fillText(t.toFixed(2) + "s", x + 2, height - 14) + } + + // Horizontal value-axis grid lines (every 0.5 in value units) + var midY = (height - 16) / 2 + ctx.strokeStyle = AnimationControlController.borderColor + ctx.globalAlpha = 0.25 + for (var v = -2; v <= 2; v += 0.5) { + var y = midY - (v - root.yCenter) * root.yScale + if (y < 0 || y > height - 16) continue + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke() + ctx.fillText(v.toFixed(1), 4, y - 2) + } + ctx.globalAlpha = 1.0 + + // Per-channel curve. For D3a we plot the keyframe values directly + // from row.keyTimes paired with the channel's values — but we + // don't yet have per-channel value arrays from the controller, so + // we sample the model's evaluate() across the visible range as a + // demonstration. D3b plumbs the real per-channel data through. + var chans = root.activeChannelsForSelected() + for (var c = 0; c < chans.length; c++) { + var ch = chans[c] + ctx.strokeStyle = ch.color; ctx.lineWidth = 2 + ctx.beginPath() + var first = true + var samples = 200 + for (var s = 0; s < samples; s++) { + var u = s / (samples - 1) + var time = u * maxT + var val = curveCanvas.valueAtTimeForChannel(row, ch.id, time) + var x2 = (time - root.viewStart) * root.pxPerSec + var y2 = midY - (val - root.yCenter) * root.yScale + if (first) { ctx.moveTo(x2, y2); first = false } + else { ctx.lineTo(x2, y2) } + } + ctx.stroke() + + // Keyframe squares + tangent handle stubs (drawn only for + // visual reference in D3a — non-interactive). + ctx.fillStyle = ch.color + for (var k = 0; k < row.keyTimes.length; k++) { + var kt = row.keyTimes[k] + var kv = curveCanvas.valueAtTimeForChannel(row, ch.id, kt) + var kx = (kt - root.viewStart) * root.pxPerSec + var ky = midY - (kv - root.yCenter) * root.yScale + ctx.fillRect(kx - 4, ky - 4, 8, 8) + + var tdata = CurveEditModel.tangentsAt( + AnimationControlController.selectedEntityName, + AnimationControlController.selectedAnimation, + row.bone, ch.id, kt) + if (tdata && tdata.length >= 2) { + var inT = tdata[0] + var outT = tdata[1] + ctx.strokeStyle = ch.color; ctx.lineWidth = 1 + ctx.globalAlpha = 0.6 + // Draw a short handle in each direction + var handlePx = 30 + ctx.beginPath() + ctx.moveTo(kx - handlePx, ky + inT * handlePx * 0.5) + ctx.lineTo(kx, ky) + ctx.lineTo(kx + handlePx, ky - outT * handlePx * 0.5) + ctx.stroke() + ctx.globalAlpha = 1.0 + } + } + } + } + } + + // Wheel = zoom horizontally (Ctrl/Cmd) or vertically (Shift), pan with + // middle-drag. Matches the dope sheet's input vocabulary as closely as + // possible to keep mental load low when switching panels. + MouseArea { + id: panArea + anchors.fill: parent + acceptedButtons: Qt.MiddleButton + property real panStartX: 0 + property real panStartView: 0 + onPressed: function(mouse) { + if (mouse.button === Qt.MiddleButton) { + panStartX = mouse.x; panStartView = root.viewStart + mouse.accepted = true + } else mouse.accepted = false + } + onPositionChanged: function(mouse) { + if (!pressed) return + var dx = mouse.x - panStartX + root.viewStart = panStartView - dx / root.pxPerSec + if (root.viewStart < 0) root.viewStart = 0 + curveCanvas.requestPaint() + } + } + WheelHandler { + target: null + acceptedModifiers: Qt.ControlModifier | Qt.MetaModifier + onWheel: function(event) { + var factor = event.angleDelta.y > 0 ? 1.15 : (1.0 / 1.15) + var newPx = Math.max(20, Math.min(2000, root.pxPerSec * factor)) + if (newPx === root.pxPerSec) return + var tCursor = root.viewStart + event.point.position.x / root.pxPerSec + root.pxPerSec = newPx + root.viewStart = tCursor - event.point.position.x / newPx + if (root.viewStart < 0) root.viewStart = 0 + curveCanvas.requestPaint() + } + } +} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index ec5271b42..7b2e9eb6c 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -6,6 +6,7 @@ set(SRC_FILES about.cpp AnimationBlender.cpp AnimationControlController.cpp +CurveEditModel.cpp main.cpp Manager.cpp material.cpp @@ -79,6 +80,7 @@ HalfEdgeMesh.cpp set(HEADER_FILES AnimationBlender.h AnimationControlController.h +CurveEditModel.h GlobalDefinitions.h Euler.h about.h diff --git a/src/CurveEditModel.cpp b/src/CurveEditModel.cpp new file mode 100644 index 000000000..7a40e2051 --- /dev/null +++ b/src/CurveEditModel.cpp @@ -0,0 +1,199 @@ +#include "CurveEditModel.h" + +#include +#include + +#include +#include + +CurveEditModel* CurveEditModel::m_pSingleton = nullptr; + +CurveEditModel* CurveEditModel::instance() +{ + if (!m_pSingleton) m_pSingleton = new CurveEditModel(); // NOSONAR — singleton + return m_pSingleton; +} + +CurveEditModel* CurveEditModel::qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine) +{ + Q_UNUSED(engine) + Q_UNUSED(scriptEngine) + auto* inst = instance(); + QQmlEngine::setObjectOwnership(inst, QQmlEngine::CppOwnership); + return inst; +} + +void CurveEditModel::kill() +{ + delete m_pSingleton; // NOSONAR — singleton + m_pSingleton = nullptr; +} + +CurveEditModel::CurveEditModel() : QObject(nullptr) {} + +std::string CurveEditModel::makeKey(const QString& skeleton, + const QString& anim, + const QString& bone, + const QString& channel, + double time) +{ + // Quantize time to ms so floating-point round-trip doesn't break the key. + // Same precision as the dope sheet's keyframe-tick comparisons. + std::ostringstream ss; + ss << skeleton.toStdString() << '|' + << anim.toStdString() << '|' + << bone.toStdString() << '|' + << channel.toStdString() << '|' + << static_cast(std::llround(time * 1000.0)); + return ss.str(); +} + +QVariantList CurveEditModel::tangentsAt(const QString& skeleton, + const QString& anim, + const QString& bone, + const QString& channel, + double time) const +{ + const auto key = makeKey(skeleton, anim, bone, channel, time); + QVariantList out; + if (auto it = m_entries.find(key); it != m_entries.end()) { + out << it->second.inTangent + << it->second.outTangent + << static_cast(it->second.mode); + } else { + // Default: Bezier with zero tangents (collapses to a hold curve; + // user-facing visuals make this look like Linear until edited). + out << 0.0 << 0.0 << static_cast(ModeBezier); + } + return out; +} + +void CurveEditModel::setTangents(const QString& skeleton, + const QString& anim, + const QString& bone, + const QString& channel, + double time, + double inTangent, double outTangent) +{ + const auto key = makeKey(skeleton, anim, bone, channel, time); + auto& entry = m_entries[key]; + entry.inTangent = inTangent; + entry.outTangent = outTangent; + if (entry.mode == ModeLinear || entry.mode == ModeStepped) { + // Editing a tangent implies the user wants curve control — + // promote the mode to Bezier so the tangents take effect. + entry.mode = ModeBezier; + } + emit modelChanged(skeleton, anim, bone, channel); +} + +void CurveEditModel::setMode(const QString& skeleton, + const QString& anim, + const QString& bone, + const QString& channel, + double time, int mode) +{ + const auto key = makeKey(skeleton, anim, bone, channel, time); + auto& entry = m_entries[key]; + if (mode == ModeBezier || mode == ModeLinear || + mode == ModeStepped || mode == ModeAuto) { + entry.mode = static_cast(mode); + emit modelChanged(skeleton, anim, bone, channel); + } +} + +void CurveEditModel::clearAnimation(const QString& skeleton, const QString& anim) +{ + const std::string prefix = + skeleton.toStdString() + "|" + anim.toStdString() + "|"; + for (auto it = m_entries.begin(); it != m_entries.end(); ) { + if (it->first.rfind(prefix, 0) == 0) it = m_entries.erase(it); + else ++it; + } +} + +double CurveEditModel::evaluate(const QString& skeleton, + const QString& anim, + const QString& bone, + const QString& channel, + double time, + const QVariantList& keyframeTimes, + const QVariantList& keyframeValues) const +{ + const auto n = static_cast(keyframeTimes.size()); + if (n == 0 || n != static_cast(keyframeValues.size())) return 0.0; + // Single-keyframe tracks degenerate to a constant. + if (n == 1) return keyframeValues.first().toDouble(); + + // Clamp before the first keyframe — otherwise the bracket [t0, t1] + // would be evaluated with negative u, extrapolating outside the + // curve. Holding the first value matches Ogre's playback semantics. + if (time <= keyframeTimes.first().toDouble()) { + return keyframeValues.first().toDouble(); + } + + // Locate the bracketing pair (i, i+1) such that t[i] <= time < t[i+1]. + int lo = 0; + while (lo + 1 < n && keyframeTimes[lo + 1].toDouble() <= time) ++lo; + if (lo == n - 1) return keyframeValues[n - 1].toDouble(); + const int hi = lo + 1; + + const double tLo = keyframeTimes[lo].toDouble(); + const double tHi = keyframeTimes[hi].toDouble(); + const double vLo = keyframeValues[lo].toDouble(); + const double vHi = keyframeValues[hi].toDouble(); + const double dt = tHi - tLo; + if (dt <= 0.0) return vLo; + const double u = (time - tLo) / dt; // [0, 1] + + // Default mode = Bezier with 0 tangents → behaves like a flat curve + // (vLo to vHi via a hold-then-jump). Linear and Stepped are explicit. + Entry loEntry{}; + if (auto it = m_entries.find(makeKey(skeleton, anim, bone, channel, tLo)); + it != m_entries.end()) { + loEntry = it->second; + } + + if (loEntry.mode == ModeStepped) return vLo; + if (loEntry.mode == ModeLinear) return vLo + (vHi - vLo) * u; + + // Bezier or Auto. For Auto, derive Catmull-Rom-style tangents from + // neighbors; for Bezier, use the stored handles directly (or 0, which + // collapses to hold-and-jump until the user drags a handle). + double tangentOut = loEntry.outTangent; + double tangentIn = 0.0; + if (auto it = m_entries.find(makeKey(skeleton, anim, bone, channel, tHi)); + it != m_entries.end()) { + tangentIn = it->second.inTangent; + } + if (loEntry.mode == ModeAuto) { + // Catmull-Rom-style auto tangent at point i = + // (value[i+1] - value[i-1]) / (time[i+1] - time[i-1]) + // With uniform spacing this collapses to the simple half-difference, + // but non-uniform keyframes need the actual time span in the + // denominator so Hermite's `tangent * dt` term scales correctly. + const double tPrev = (lo > 0) + ? keyframeTimes[lo - 1].toDouble() : tLo; + const double vPrev = (lo > 0) + ? keyframeValues[lo - 1].toDouble() : vLo; + const double tNext = (hi + 1 < n) + ? keyframeTimes[hi + 1].toDouble() : tHi; + const double vNext = (hi + 1 < n) + ? keyframeValues[hi + 1].toDouble() : vHi; + const double spanLo = tHi - tPrev; + const double spanHi = tNext - tLo; + tangentOut = (spanLo > 0.0) ? (vHi - vPrev) / spanLo : 0.0; + tangentIn = (spanHi > 0.0) ? (vNext - vLo) / spanHi : 0.0; + } + + // Cubic Hermite spline (equivalent to Bezier with derived control + // points, parameterized by tangents at the endpoints). + const double u2 = u * u; + const double u3 = u2 * u; + const double h00 = 2.0 * u3 - 3.0 * u2 + 1.0; + const double h10 = u3 - 2.0 * u2 + u; + const double h01 = -2.0 * u3 + 3.0 * u2; + const double h11 = u3 - u2; + return h00 * vLo + h10 * dt * tangentOut + + h01 * vHi + h11 * dt * tangentIn; +} diff --git a/src/CurveEditModel.h b/src/CurveEditModel.h new file mode 100644 index 000000000..179a22d57 --- /dev/null +++ b/src/CurveEditModel.h @@ -0,0 +1,117 @@ +#ifndef CURVE_EDIT_MODEL_H +#define CURVE_EDIT_MODEL_H + +#include +#include +#include +#include +#include +#include + +/** + * Side-table for Bezier-style curve editing on top of Ogre's + * TransformKeyFrames. Ogre's keyframe class stores T/R/S at a time but + * has no slot for tangent handles or per-keyframe interpolation modes. + * Storing those here, keyed by (skeleton, animation, bone, channel, time), + * lets the curve editor render Bezier curves and re-sample them into the + * underlying TransformKeyFrames on edit. + * + * Channel ids match the dope sheet: + * tx ty tz rw rx ry rz sx sy sz + * + * Modes: + * Bezier — explicit in/out tangent handles (default for new entries) + * Linear — straight line between this keyframe and the next + * Stepped — hold value until the next keyframe + * Auto — Catmull-Rom-style automatic tangents from neighbors + * + * The model is in-memory only in this slice. Persistence (e.g. a sidecar + * JSON file alongside the scene) is a separate issue (#378 follow-up). + */ +class CurveEditModel : public QObject +{ + Q_OBJECT + +public: + enum InterpMode { + ModeBezier = 0, + ModeLinear = 1, + ModeStepped = 2, + ModeAuto = 3 + }; + Q_ENUM(InterpMode) + + static CurveEditModel* instance(); + static CurveEditModel* qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine); + static void kill(); + + /// Read the (in, out, mode) for a specific keyframe channel. + /// Returns a default {0, 0, Bezier} when not set. + Q_INVOKABLE QVariantList tangentsAt(const QString& skeleton, + const QString& anim, + const QString& bone, + const QString& channel, + double time) const; + + /// Update tangent handles. Bezier mode is set if not already. + Q_INVOKABLE void setTangents(const QString& skeleton, + const QString& anim, + const QString& bone, + const QString& channel, + double time, + double inTangent, double outTangent); + + Q_INVOKABLE void setMode(const QString& skeleton, + const QString& anim, + const QString& bone, + const QString& channel, + double time, + int mode); + + /// Drop every entry for an entire animation (called when the animation + /// is renamed or removed). + Q_INVOKABLE void clearAnimation(const QString& skeleton, + const QString& anim); + + /// Evaluate the curve at `time` for one channel. Returns the stored + /// keyframe value at `time` if there's an exact match; otherwise + /// interpolates per the upstream keyframe's outgoing mode. + /// `keyframeTimes` and `keyframeValues` are the channel's authoritative + /// data from Ogre — passed in instead of resolved here so this method + /// stays free of Ogre dependencies and remains pure-data testable. + Q_INVOKABLE double evaluate(const QString& skeleton, + const QString& anim, + const QString& bone, + const QString& channel, + double time, + const QVariantList& keyframeTimes, + const QVariantList& keyframeValues) const; + +signals: + void modelChanged(const QString& skeleton, + const QString& anim, + const QString& bone, + const QString& channel); + +private: + CurveEditModel(); + ~CurveEditModel() override = default; + + struct Entry { + double inTangent = 0.0; + double outTangent = 0.0; + InterpMode mode = ModeBezier; + }; + + /// Composite key as a single string; cheap to hash and easy to compose. + static std::string makeKey(const QString& skeleton, + const QString& anim, + const QString& bone, + const QString& channel, + double time); + + static CurveEditModel* m_pSingleton; + std::unordered_map m_entries; +}; + +#endif // CURVE_EDIT_MODEL_H diff --git a/src/CurveEditModel_test.cpp b/src/CurveEditModel_test.cpp new file mode 100644 index 000000000..c46041205 --- /dev/null +++ b/src/CurveEditModel_test.cpp @@ -0,0 +1,193 @@ +#include +#include +#include + +#include "CurveEditModel.h" + +class CurveEditModelTest : public ::testing::Test { +protected: + void SetUp() override { + CurveEditModel::kill(); + app = qobject_cast(QCoreApplication::instance()); + ASSERT_NE(app, nullptr); + } + void TearDown() override { + CurveEditModel::kill(); + } + QApplication* app = nullptr; +}; + +// ── Tangent storage round-trip ──────────────────────────────────────────────── + +TEST_F(CurveEditModelTest, DefaultTangentsAreZeroBezier) { + auto* m = CurveEditModel::instance(); + QVariantList t = m->tangentsAt("skel", "Walk", "Bone1", "tx", 0.5); + ASSERT_EQ(t.size(), 3); + EXPECT_DOUBLE_EQ(t[0].toDouble(), 0.0); + EXPECT_DOUBLE_EQ(t[1].toDouble(), 0.0); + EXPECT_EQ(t[2].toInt(), CurveEditModel::ModeBezier); +} + +TEST_F(CurveEditModelTest, SetTangentsRoundTrip) { + auto* m = CurveEditModel::instance(); + m->setTangents("skel", "Walk", "Bone1", "tx", 0.5, 1.5, -0.75); + QVariantList t = m->tangentsAt("skel", "Walk", "Bone1", "tx", 0.5); + EXPECT_DOUBLE_EQ(t[0].toDouble(), 1.5); + EXPECT_DOUBLE_EQ(t[1].toDouble(), -0.75); + EXPECT_EQ(t[2].toInt(), CurveEditModel::ModeBezier); +} + +TEST_F(CurveEditModelTest, SetModePersists) { + auto* m = CurveEditModel::instance(); + m->setMode("skel", "Walk", "Bone1", "tx", 0.5, CurveEditModel::ModeLinear); + QVariantList t = m->tangentsAt("skel", "Walk", "Bone1", "tx", 0.5); + EXPECT_EQ(t[2].toInt(), CurveEditModel::ModeLinear); +} + +TEST_F(CurveEditModelTest, SetModeRejectsInvalidValue) { + auto* m = CurveEditModel::instance(); + m->setMode("skel", "Walk", "Bone1", "tx", 0.5, 99); // invalid + QVariantList t = m->tangentsAt("skel", "Walk", "Bone1", "tx", 0.5); + EXPECT_EQ(t[2].toInt(), CurveEditModel::ModeBezier); // default unchanged +} + +TEST_F(CurveEditModelTest, EditingTangentsPromotesToBezier) { + auto* m = CurveEditModel::instance(); + // Start in Linear mode. + m->setMode("skel", "Walk", "Bone1", "tx", 0.5, CurveEditModel::ModeLinear); + // Editing tangents implies Bezier intent — should auto-promote. + m->setTangents("skel", "Walk", "Bone1", "tx", 0.5, 1.0, 1.0); + QVariantList t = m->tangentsAt("skel", "Walk", "Bone1", "tx", 0.5); + EXPECT_EQ(t[2].toInt(), CurveEditModel::ModeBezier); +} + +TEST_F(CurveEditModelTest, KeysAreScopedPerSkeletonAnimBoneChannel) { + auto* m = CurveEditModel::instance(); + m->setTangents("skelA", "Walk", "Bone1", "tx", 0.5, 1.0, 1.0); + // Same time, different skeleton → independent. + QVariantList t = m->tangentsAt("skelB", "Walk", "Bone1", "tx", 0.5); + EXPECT_DOUBLE_EQ(t[0].toDouble(), 0.0); +} + +TEST_F(CurveEditModelTest, ClearAnimationDropsAllItsEntries) { + auto* m = CurveEditModel::instance(); + m->setTangents("skel", "Walk", "Bone1", "tx", 0.5, 1.0, 1.0); + m->setTangents("skel", "Walk", "Bone1", "ty", 0.5, 2.0, 2.0); + m->setTangents("skel", "Run", "Bone1", "tx", 0.5, 3.0, 3.0); // different anim + m->clearAnimation("skel", "Walk"); + EXPECT_DOUBLE_EQ( + m->tangentsAt("skel", "Walk", "Bone1", "tx", 0.5)[0].toDouble(), 0.0); + EXPECT_DOUBLE_EQ( + m->tangentsAt("skel", "Walk", "Bone1", "ty", 0.5)[0].toDouble(), 0.0); + // Different animation must survive. + EXPECT_DOUBLE_EQ( + m->tangentsAt("skel", "Run", "Bone1", "tx", 0.5)[0].toDouble(), 3.0); +} + +// ── evaluate() — interpolation modes ────────────────────────────────────────── + +namespace { + QVariantList vec(std::initializer_list xs) { + QVariantList out; + for (double x : xs) out << x; + return out; + } +} + +TEST_F(CurveEditModelTest, EvaluateEmptyTrackReturnsZero) { + auto* m = CurveEditModel::instance(); + EXPECT_DOUBLE_EQ( + m->evaluate("skel", "Walk", "Bone", "tx", 0.5, + QVariantList{}, QVariantList{}), 0.0); +} + +TEST_F(CurveEditModelTest, EvaluateSingleKeyReturnsConstant) { + auto* m = CurveEditModel::instance(); + EXPECT_DOUBLE_EQ( + m->evaluate("skel", "Walk", "Bone", "tx", 0.5, + vec({0.0}), vec({3.5})), 3.5); +} + +TEST_F(CurveEditModelTest, EvaluateLinearMode) { + auto* m = CurveEditModel::instance(); + m->setMode("skel", "Walk", "Bone", "tx", 0.0, CurveEditModel::ModeLinear); + // Times 0 and 1, values 0 and 10 → at t=0.25 expect 2.5. + EXPECT_NEAR( + m->evaluate("skel", "Walk", "Bone", "tx", 0.25, + vec({0.0, 1.0}), vec({0.0, 10.0})), + 2.5, 1e-9); +} + +TEST_F(CurveEditModelTest, EvaluateSteppedMode) { + auto* m = CurveEditModel::instance(); + m->setMode("skel", "Walk", "Bone", "tx", 0.0, CurveEditModel::ModeStepped); + // At t=0.99 we should still hold the value at t=0 (= 1.0). + EXPECT_DOUBLE_EQ( + m->evaluate("skel", "Walk", "Bone", "tx", 0.99, + vec({0.0, 1.0}), vec({1.0, 5.0})), 1.0); +} + +TEST_F(CurveEditModelTest, EvaluateAtKeyReturnsExactValue) { + auto* m = CurveEditModel::instance(); + // Default Bezier with zero tangents — at the keyframe time itself, + // the result must equal the stored value (regardless of mode). + EXPECT_DOUBLE_EQ( + m->evaluate("skel", "Walk", "Bone", "tx", 0.0, + vec({0.0, 1.0}), vec({7.0, 9.0})), 7.0); + EXPECT_DOUBLE_EQ( + m->evaluate("skel", "Walk", "Bone", "tx", 1.0, + vec({0.0, 1.0}), vec({7.0, 9.0})), 9.0); +} + +TEST_F(CurveEditModelTest, EvaluateBeyondLastReturnsLast) { + auto* m = CurveEditModel::instance(); + EXPECT_DOUBLE_EQ( + m->evaluate("skel", "Walk", "Bone", "tx", 5.0, + vec({0.0, 1.0}), vec({7.0, 9.0})), 9.0); +} + +TEST_F(CurveEditModelTest, EvaluateAutoModeMatchesCatmullRomMidpoint) { + auto* m = CurveEditModel::instance(); + m->setMode("skel", "Walk", "Bone", "tx", 1.0, CurveEditModel::ModeAuto); + // Symmetric data around the bracket [1.0, 2.0]; CR midpoint should + // pass through the linear interpolant when neighbors are linear. + // values 0,1,2,3 at times 0,1,2,3 — at t=1.5 expect ~1.5. + const double v = m->evaluate( + "skel", "Walk", "Bone", "tx", 1.5, + vec({0.0, 1.0, 2.0, 3.0}), + vec({0.0, 1.0, 2.0, 3.0})); + EXPECT_NEAR(v, 1.5, 1e-9); +} + +TEST_F(CurveEditModelTest, EvaluateBeforeFirstKeyClampsToFirst) { + // A query time before the first keyframe must hold the first value, not + // extrapolate. Bezier mode: explicit defaults still need clamping. + auto* m = CurveEditModel::instance(); + EXPECT_DOUBLE_EQ( + m->evaluate("skel", "Walk", "Bone", "tx", -0.5, + vec({0.0, 1.0}), vec({2.0, 5.0})), 2.0); + // Linear shouldn't extrapolate either. + m->setMode("skel", "Walk", "Bone", "tx", 0.0, CurveEditModel::ModeLinear); + EXPECT_DOUBLE_EQ( + m->evaluate("skel", "Walk", "Bone", "tx", -1.0, + vec({0.0, 1.0}), vec({2.0, 5.0})), 2.0); +} + +TEST_F(CurveEditModelTest, EvaluateAutoModeNonUniformSpacing) { + // Non-uniform keyframe spacing: times {0, 1, 5} with values {0, 1, 5} + // is on a perfect linear ramp, so any time-normalized tangent scheme + // (including Catmull-Rom with proper dt-normalization) should + // reproduce the line within tolerance. The pre-fix code used an + // unnormalized half-difference and produced a wildly wrong slope at + // the bracket [1, 5] because the right-side neighbor is far away. + auto* m = CurveEditModel::instance(); + m->setMode("skel", "Walk", "Bone", "tx", 1.0, CurveEditModel::ModeAuto); + const double v = m->evaluate( + "skel", "Walk", "Bone", "tx", 3.0, + vec({0.0, 1.0, 5.0}), + vec({0.0, 1.0, 5.0})); + // On a perfect line, t=3 should give value 3. Allow some Hermite + // smoothing (~10 % of the segment) because Auto tangents derived + // from only two neighbors aren't a true natural-spline solver. + EXPECT_NEAR(v, 3.0, 0.5); +} diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 1c4141e96..536ca6e7d 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -43,6 +43,7 @@ #include "SelectionSet.h" #include "AnimationBlender.h" #include "AnimationControlController.h" +#include "CurveEditModel.h" #include "MaterialEditorQML.h" #include "LLMSettingsWidget.h" #include "MCPSettingsDialog.h" @@ -280,6 +281,7 @@ MainWindow::~MainWindow() SubEntityHighlight::kill(); AnimationBlender::kill(); AnimationControlController::kill(); + CurveEditModel::kill(); MeshLodController::kill(); MeshValidator::kill(); MaterialPresetLibrary::kill(); @@ -384,6 +386,10 @@ void MainWindow::initToolBar() [](QQmlEngine* engine, QJSEngine*) -> QObject* { return AnimationBlender::qmlInstance(engine, nullptr); }); + qmlRegisterSingletonType("AnimationControl", 1, 0, "CurveEditModel", + [](QQmlEngine* engine, QJSEngine*) -> QObject* { + return CurveEditModel::qmlInstance(engine, nullptr); + }); qmlRegisterSingletonType("PropertiesPanel", 1, 0, "MeshLodController", [](QQmlEngine* engine, QJSEngine*) -> QObject* { return MeshLodController::qmlInstance(engine, nullptr); @@ -578,6 +584,28 @@ void MainWindow::initToolBar() updateDopeSheetTitle(); } + // Curve Editor dock — Bezier-curve view (Phase 5 slice D3a, read-only). + { + auto* curveEditorWidget = new QQuickWidget(); // NOSONAR — Qt parent ownership + curveEditorWidget->setResizeMode(QQuickWidget::SizeRootObjectToView); + curveEditorWidget->setMinimumHeight(180); + curveEditorWidget->setFocusPolicy(Qt::StrongFocus); + curveEditorWidget->setSource(QUrl("qrc:/AnimationControl/AnimationCurveEditor.qml")); + m_curveEditorDock = new QDockWidget(tr("Curve Editor"), this); // NOSONAR + m_curveEditorDock->setWidget(curveEditorWidget); + m_curveEditorDock->setObjectName("CurveEditorDock"); + addDockWidget(Qt::BottomDockWidgetArea, m_curveEditorDock); + // Tab on top of the dope sheet by default — the user toggles whichever + // they want via the View menu. tabifyDockWidget runs after both docks + // exist so we don't open two empty bottom strips. + if (m_dopeSheetDock) tabifyDockWidget(m_dopeSheetDock, m_curveEditorDock); + m_curveEditorDock->hide(); + connect(m_curveEditorDock, &QDockWidget::visibilityChanged, this, [](bool vis) { + SentryReporter::addBreadcrumb("ui.action", + vis ? "Curve Editor shown" : "Curve Editor hidden"); + }); + } + // Welcome Screen overlay — shown on first launch or when user hasn't opted out { m_welcomeController = WelcomeScreenController::instance(); @@ -1258,6 +1286,12 @@ void MainWindow::initToolBar() dopeAct->setText(tr("Dope Sheet")); ui->menuView->addAction(dopeAct); } + // Curve Editor toggle — same pattern, lives next to Dope Sheet. + if (m_curveEditorDock && ui->menuView) { + QAction* curveAct = m_curveEditorDock->toggleViewAction(); + curveAct->setText(tr("Curve Editor")); + ui->menuView->addAction(curveAct); + } // Connect Browse button to a native file dialog (must be parented to MainWindow on macOS) connect(AssetBrowserController::instance(), &AssetBrowserController::browseRequested, diff --git a/src/mainwindow.h b/src/mainwindow.h index 651f16a4d..12eadad75 100755 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -148,6 +148,7 @@ public slots: QDockWidget* m_chatDock = nullptr; QDockWidget* m_assetBrowserDock = nullptr; QDockWidget* m_dopeSheetDock = nullptr; + QDockWidget* m_curveEditorDock = nullptr; QMenu* m_recentFilesMenu = nullptr; void addToRecentFiles(const QString& filePath); diff --git a/src/qml_resources.qrc b/src/qml_resources.qrc index f05092363..c185a6cad 100644 --- a/src/qml_resources.qrc +++ b/src/qml_resources.qrc @@ -27,6 +27,7 @@ ../qml/AnimationControlPanel.qml ../qml/AnimationDopeSheet.qml + ../qml/AnimationCurveEditor.qml ../qml/AIChatPanel.qml diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index dbbb6e65b..0258b0bd3 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -17,6 +17,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/about.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/AnimationBlender.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/AnimationControlController.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/CurveEditModel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/Manager.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/material.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MaterialEditorQML.cpp