From 8cd50f4e96acf3160e298e2e1087d73bd5938464 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 17 May 2026 11:45:48 -0400 Subject: [PATCH 1/5] =?UTF-8?q?feat(morph):=20sub-slice=20A5=20=E2=80=94?= =?UTF-8?q?=20dope=20sheet=20shows=20morph-weight=20tracks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per #518: "Dope sheet integration: morph-weight tracks appear in the dope sheet alongside bone tracks." Slice A5 ships the read-only display half — one row per Ogre::Pose on the selected entity with diamond markers at each keyframe time. Full selection / move / copy / paste interaction for morph tracks is a follow-up (it depends on A3's authoring path to land first). ### What ships **`AnimationControlController::allMorphRows()`** (new Q_INVOKABLE): - Walks the selected entity's `getPoseList()`. - For each pose, looks up the matching `Ogre::Animation` (which A1's importer creates one-per-pose), reads its VAT_POSE track's keyframe times. - Returns `[{ name: QString, keyTimes: [double] }]` — same shape the dope sheet's existing bone-row reader expects, minus the channel-flags map (morph tracks are scalar weight, no channel decomposition). **`AnimationDopeSheet.qml`** picks up the new API: - New `morphRows` property bound to `allMorphRows()`. - Refresh hooks: existing `onSelectionChanged` now also refreshes morph rows; new `MorphAnimationManager` Connections refresh on `morphTargetsChanged` (selection moved) and `morphWeightChanged` (Inspector slider, MCP poke, future authoring path). - New `morphBand` Rectangle anchored to the bottom of the dope sheet: collapses to height=0 when there are no morphs (so a bone-only animation looks the same as before); otherwise shows "Morph Targets (N)" header + one row per pose with the same diamond style the bone tracks use. ### 3 new tests in `AnimationControlController_test.cpp` - `AllMorphRowsEmptyWhenNoSelection` — returns `[]` cleanly. - `AllMorphRowsEmptyForMeshWithoutPoses` — selected entity with bones but no poses returns `[]`. - `AllMorphRowsListsPoseNamesAndKeyTimes` — builds a mesh with two named poses + matching VAT_POSE animations, asserts both names appear and each carries a single t=0 keyframe (A1's importer-time default). ### #518 status after this slice | Sub-slice | What | Status | |-|-|-| | A1 | Importer + manager + CLI list | ✅ #569 | | A2 | Inspector "Morph Targets" subgroup | ✅ #570 | | A4a | glTF morph-target export | ✅ #573 | | A4b | FBX morph-target export | ✅ #575 | | A5 | Dope sheet morph rows (read-only) | this PR | | A6 | MCP tools | ✅ #571 | | A3 | Authoring (delta → new pose, rename, delete) | still pending | Co-Authored-By: Claude Opus 4.7 (1M context) --- qml/AnimationDopeSheet.qml | 102 ++++++++++++++++++++++++ src/AnimationControlController.cpp | 42 ++++++++++ src/AnimationControlController.h | 9 +++ src/AnimationControlController_test.cpp | 74 +++++++++++++++++ 4 files changed, 227 insertions(+) diff --git a/qml/AnimationDopeSheet.qml b/qml/AnimationDopeSheet.qml index 6f9655e0..fd442a1d 100644 --- a/qml/AnimationDopeSheet.qml +++ b/qml/AnimationDopeSheet.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts import AnimationControl 1.0 +import PropertiesPanel 1.0 // MorphAnimationManager (slice A5) // Multi-bone dope sheet with multi-select, bulk move, and copy/paste. // One row per animated bone with diamond markers at each keyframe time. @@ -21,6 +22,12 @@ Rectangle { // Cached row data refreshed from the controller. property var rows: AnimationControlController.allBoneRows() + // Slice A5: morph-target rows for the selected entity. Each + // entry is `{ name, keyTimes }`. Renders below the bone rows + // as a read-only band — full selection/move/copy interaction + // for morph tracks is a follow-up slice. + property var morphRows: AnimationControlController.allMorphRows() + // Per-bone expansion state for per-channel rows. Keys are bone names, // values are bool. Reset when a new clip is selected (different bones). property var expandedBones: ({}) @@ -145,6 +152,7 @@ Rectangle { // signal, and dropping selection there breaks bulk drag mid-gesture. function onSelectionChanged() { root.rows = AnimationControlController.allBoneRows() + root.morphRows = AnimationControlController.allMorphRows() root.clearSelection() root.expandedBones = {} } @@ -152,6 +160,19 @@ Rectangle { function onKeyframeTicksChanged() { root.rows = AnimationControlController.allBoneRows() } } + // Refresh the morph band whenever the morph manager's data changes + // (selection moved to a different entity, or a weight was set + // through any path — Inspector slider, MCP, future authoring). + Connections { + target: MorphAnimationManager + function onMorphTargetsChanged() { + root.morphRows = AnimationControlController.allMorphRows() + } + function onMorphWeightChanged(entity, name, weight) { + root.morphRows = AnimationControlController.allMorphRows() + } + } + // Cross-platform "primary" modifier — Ctrl on Win/Linux, Cmd (Meta) on macOS. function isPrimaryModifier(modifiers) { return (modifiers & Qt.ControlModifier) || (modifiers & Qt.MetaModifier) @@ -548,6 +569,87 @@ Rectangle { } } + // ── Morph-target rows (slice A5) ───────────────────────────────────────── + // Read-only band anchored to the bottom of the dope sheet. Shows + // one row per Ogre::Pose on the selected entity, with diamond + // markers at each keyframe time. Selection / move / copy + // interaction is a follow-up — A5 ships visibility only. + Rectangle { + id: morphBand + anchors.left: parent.left; anchors.right: parent.right + anchors.bottom: parent.bottom + height: visible ? (morphHeader.height + morphRowsCol.implicitHeight) : 0 + color: AnimationControlController.panelColor + border.color: AnimationControlController.borderColor + visible: root.morphRows.length > 0 + + Rectangle { + id: morphHeader + anchors.left: parent.left; anchors.right: parent.right + anchors.top: parent.top + height: 18 + color: AnimationControlController.headerColor + border.color: AnimationControlController.borderColor + Text { + anchors.left: parent.left; anchors.leftMargin: 6 + anchors.verticalCenter: parent.verticalCenter + text: "Morph Targets (" + root.morphRows.length + ")" + color: AnimationControlController.textColor + font.pixelSize: 10; font.bold: true + } + } + + Column { + id: morphRowsCol + anchors.left: parent.left; anchors.right: parent.right + anchors.top: morphHeader.bottom + spacing: 1 + + Repeater { + model: root.morphRows + delegate: Item { + width: morphRowsCol.width + height: root.rowHeight + + // Name strip + Rectangle { + width: root.leftStripWidth; height: parent.height + color: AnimationControlController.panelColor + border.color: AnimationControlController.borderColor; border.width: 1 + Text { + anchors.fill: parent + anchors.leftMargin: 6 + verticalAlignment: Text.AlignVCenter + text: modelData.name + color: AnimationControlController.textColor + elide: Text.ElideRight + font.pixelSize: 11 + } + } + + // Track strip with diamonds at each keyframe. + Item { + anchors.left: parent.left; anchors.leftMargin: root.leftStripWidth + anchors.right: parent.right + height: parent.height + Repeater { + model: modelData.keyTimes + delegate: Rectangle { + property real keyTime: modelData + width: 8; height: 8; radius: 1 + rotation: 45 + color: "#c08040" + border.color: AnimationControlController.borderColor + anchors.verticalCenter: parent.verticalCenter + x: (keyTime - root.viewStart) * root.pxPerSec - width / 2 + } + } + } + } + } + } + } + // Shared bulk-drag delta — every selected diamond binds against this so // they all animate together while one is being dragged. Reset on release. property real dragSelectionDt: 0 diff --git a/src/AnimationControlController.cpp b/src/AnimationControlController.cpp index bd14341d..812d1de6 100644 --- a/src/AnimationControlController.cpp +++ b/src/AnimationControlController.cpp @@ -899,6 +899,48 @@ QVariantList AnimationControlController::allBoneRows() const return rows; } +QVariantList AnimationControlController::allMorphRows() const +{ + QVariantList rows; + auto* sel = SelectionSet::getSingleton(); + if (!sel) return rows; + const auto entities = sel->getResolvedEntities(); + if (entities.isEmpty() || !entities.first()) return rows; + Ogre::Entity* entity = entities.first(); + if (!entity) return rows; + Ogre::MeshPtr mesh = entity->getMesh(); + if (!mesh) return rows; + const Ogre::PoseList& poseList = mesh->getPoseList(); + if (poseList.empty()) return rows; + + // Each pose has a matching `Ogre::Animation` (see MeshProcessor: + // import-time one-animation-per-pose pattern). Read the keyframe + // times off the animation's VAT_POSE track; in A1 every animation + // carries a single t=0 keyframe, but future slices may add more. + for (const Ogre::Pose* pose : poseList) { + if (!pose) continue; + const Ogre::String poseName = pose->getName(); + if (poseName.empty()) continue; + + QVariantList keyTimes; + if (mesh->hasAnimation(poseName)) { + Ogre::Animation* anim = mesh->getAnimation(poseName); + const auto& vtracks = anim->_getVertexTrackList(); + for (const auto& [handle, track] : vtracks) { + if (!track) continue; + for (unsigned short i = 0; i < track->getNumKeyFrames(); ++i) + keyTimes.append(static_cast(track->getKeyFrame(i)->getTime())); + } + } + + QVariantMap row; + row[QStringLiteral("name")] = QString::fromStdString(poseName); + row[QStringLiteral("keyTimes")] = keyTimes; + rows.append(row); + } + return rows; +} + bool AnimationControlController::moveKeyframe(const QString& boneName, double oldTime, double newTime) { diff --git a/src/AnimationControlController.h b/src/AnimationControlController.h index 177fd960..9eccae33 100644 --- a/src/AnimationControlController.h +++ b/src/AnimationControlController.h @@ -196,6 +196,15 @@ class AnimationControlController : public QObject /// Returns an empty list when no animation is selected. Q_INVOKABLE QVariantList allBoneRows() const; + /// Slice A5: enumerate morph-target tracks for the selected + /// entity so the dope sheet can render them alongside bone + /// tracks. Each entry: `{ name: QString, keyTimes: [double] }`. + /// In A1's importer-emitted Animations, every pose's track has + /// a single keyframe at t=0; future slices may add per-time + /// keys when authoring lands. Returns empty when there's no + /// selection or the entity has no morph targets. + Q_INVOKABLE QVariantList allMorphRows() const; + /// Move a keyframe on `boneName`'s track from `oldTime` to `newTime` /// (both seconds). Match tolerance is 1 ms — same as the existing /// keyframe-tick comparison. Refuses the move if `newTime` collides diff --git a/src/AnimationControlController_test.cpp b/src/AnimationControlController_test.cpp index 53ac1ce3..4707cdf5 100644 --- a/src/AnimationControlController_test.cpp +++ b/src/AnimationControlController_test.cpp @@ -15,6 +15,13 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include class AnimationControlControllerTest : public ::testing::Test { protected: @@ -1763,3 +1770,70 @@ TEST_F(AnimationControlControllerTest, DeleteKeyframeIsUndoableViaQUndoStack) { app->processEvents(); EXPECT_EQ(track->getNumKeyFrames(), countBefore - 1); } + +// ── Slice A5: allMorphRows for dope sheet ───────────────────────────────── + +TEST_F(AnimationControlControllerTest, AllMorphRowsEmptyWhenNoSelection) { + SelectionSet::getSingleton()->clear(); + auto* ctrl = AnimationControlController::instance(); + EXPECT_TRUE(ctrl->allMorphRows().isEmpty()); +} + +TEST_F(AnimationControlControllerTest, AllMorphRowsEmptyForMeshWithoutPoses) { + // createAnimatedTestEntity has bones but no morph targets; + // allMorphRows must return an empty list (not crash). + ASSERT_TRUE(canLoadMeshFiles()); + Ogre::Entity* entity = setupAnimatedEntity("Morph_NoPoses"); + ASSERT_NE(entity, nullptr); + auto* ctrl = AnimationControlController::instance(); + EXPECT_TRUE(ctrl->allMorphRows().isEmpty()); +} + +TEST_F(AnimationControlControllerTest, AllMorphRowsListsPoseNamesAndKeyTimes) { + ASSERT_TRUE(canLoadMeshFiles()); + + // Build a fresh mesh + entity with two named poses + matching + // VAT_POSE animations — mirrors what MeshProcessor produces from + // an FBX blend shape. Pose targets handle 0 (shared-vertex + // submesh) to match createInMemoryTriangleMesh's layout. + auto mesh = createInMemoryTriangleMesh("Morph_AllRows"); + ASSERT_NE(mesh, nullptr); + { + Ogre::Pose* p = mesh->createPose(0, "JawOpen"); + p->addVertex(0, Ogre::Vector3(0, -0.1f, 0)); + } + { + Ogre::Pose* p = mesh->createPose(0, "Smile"); + p->addVertex(1, Ogre::Vector3(0.05f, 0.02f, 0)); + } + const auto& poseList = mesh->getPoseList(); + for (unsigned short pi = 0; pi < poseList.size(); ++pi) { + Ogre::Animation* a = mesh->createAnimation(poseList[pi]->getName(), 0.0f); + auto* track = a->createVertexTrack(poseList[pi]->getTarget(), Ogre::VAT_POSE); + auto* kf = track->createVertexPoseKeyFrame(0.0f); + kf->addPoseReference(pi, 1.0f); + } + + auto* node = Manager::getSingleton()->addSceneNode("Morph_AllRows_Node"); + auto* entity = Manager::getSingleton()->getSceneMgr()->createEntity(node->getName(), mesh->getName()); + node->attachObject(entity); + SelectionSet::getSingleton()->selectOne(node); + app->processEvents(); + + auto* ctrl = AnimationControlController::instance(); + QVariantList rows = ctrl->allMorphRows(); + ASSERT_EQ(rows.size(), 2); + QSet names; + for (const QVariant& v : rows) { + const QVariantMap row = v.toMap(); + names.insert(row["name"].toString()); + // Each pose's Animation has a single keyframe at t=0. + const QVariantList times = row["keyTimes"].toList(); + ASSERT_EQ(times.size(), 1); + EXPECT_NEAR(times.first().toDouble(), 0.0, 1e-6); + } + EXPECT_TRUE(names.contains(QStringLiteral("JawOpen"))); + EXPECT_TRUE(names.contains(QStringLiteral("Smile"))); +} + + From 6589ebc1ff90684e347217fd99b894f48a147467 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 17 May 2026 12:08:15 -0400 Subject: [PATCH 2/5] =?UTF-8?q?review(morph):=20A5=20=E2=80=94=20filter=20?= =?UTF-8?q?morph=20keyTimes=20by=20pose=20target=20handle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2 on PR #576: `allMorphRows()` looped over every VertexTrack in the per-pose Animation. A1's importer groups same-named poses across submeshes into a single Animation with one VAT_POSE track per affected submesh (so e.g. a "Smile" target on body + head ends up as one Animation with two tracks). Without filtering, the dope sheet row for that pose would carry diamonds from both tracks — duplicated `t=0` markers in the simple A1 case, and wrong key counts once authoring adds per-time keys. Fix: look up only the VertexTrack whose handle matches `pose->getTarget()`. Also assert it's a VAT_POSE track (belt-and-suspenders — VAT_MORPH could be added in a future slice and we don't want to silently mix track types). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AnimationControlController.cpp | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/AnimationControlController.cpp b/src/AnimationControlController.cpp index 812d1de6..6cf559a6 100644 --- a/src/AnimationControlController.cpp +++ b/src/AnimationControlController.cpp @@ -924,12 +924,20 @@ QVariantList AnimationControlController::allMorphRows() const QVariantList keyTimes; if (mesh->hasAnimation(poseName)) { + // The importer (MeshProcessor) groups same-named poses + // across submeshes into a single Animation with one + // VAT_POSE track per submesh. Pull only the track + // matching this pose's target handle — otherwise a + // shape that appears on body + head would show its + // diamonds twice in the dope sheet. Ogre::Animation* anim = mesh->getAnimation(poseName); - const auto& vtracks = anim->_getVertexTrackList(); - for (const auto& [handle, track] : vtracks) { - if (!track) continue; - for (unsigned short i = 0; i < track->getNumKeyFrames(); ++i) - keyTimes.append(static_cast(track->getKeyFrame(i)->getTime())); + const unsigned short handle = pose->getTarget(); + if (anim->hasVertexTrack(handle)) { + Ogre::VertexAnimationTrack* track = anim->getVertexTrack(handle); + if (track && track->getAnimationType() == Ogre::VAT_POSE) { + for (unsigned short i = 0; i < track->getNumKeyFrames(); ++i) + keyTimes.append(static_cast(track->getKeyFrame(i)->getTime())); + } } } From 2abd71a1da815fc2cd9629a4510d37a850880dee Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 17 May 2026 12:29:31 -0400 Subject: [PATCH 3/5] =?UTF-8?q?fix(morph):=20A5=20=E2=80=94=20avoid=20over?= =?UTF-8?q?lap=20between=20bone=20ListView=20and=20morph=20band?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bone-row ListView and the new morph band were both anchored to `parent.bottom`. With both visible, their layout rectangles overlapped, which (under some redraw conditions in CI) drove SIGSEGV crashes in any MainWindow-construction test that instantiated the dope sheet QML during MainWindow startup — MCPServerTest.ToggleNormals_WithMainWindowTogglesVisibility and MainWindowTest.ViewMenuConsoleToggleUpdatesDockVisibilityAndSettings both regressed on this branch. Fix: anchor the bone ListView's bottom to `morphBand.top` when the morph band is visible, falling back to `parent.bottom` otherwise. The two no longer fight for the same space. Co-Authored-By: Claude Opus 4.7 (1M context) --- qml/AnimationDopeSheet.qml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/qml/AnimationDopeSheet.qml b/qml/AnimationDopeSheet.qml index fd442a1d..b9ce1405 100644 --- a/qml/AnimationDopeSheet.qml +++ b/qml/AnimationDopeSheet.qml @@ -277,7 +277,13 @@ Rectangle { id: rowsView anchors.left: parent.left; anchors.right: parent.right anchors.top: header.visible ? header.bottom : parent.top - anchors.bottom: parent.bottom + // Reserve space at the bottom for the morph band when it's + // visible. Anchoring both to `parent.bottom` would have them + // overlap and (under some layout pressures) hit a QML + // assertion when both layouts run during a redraw — drove + // the SIGSEGV crashes on MCPServerTest + MainWindowTest + // visibility-toggle tests in CI. + anchors.bottom: morphBand.visible ? morphBand.top : parent.bottom clip: true model: root.rows spacing: 1 From 84f7b66c13fffb31870970716619f077cc6bd816 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 17 May 2026 12:47:22 -0400 Subject: [PATCH 4/5] =?UTF-8?q?fix(morph):=20A5=20=E2=80=94=20drop=20`onMo?= =?UTF-8?q?rphWeightChanged`=20QML=20binding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The signal's payload includes `Ogre::Entity*`, a raw pointer Qt 6 can't safely marshal to QML/JS. Binding the slot crashed the QML engine during MainWindow construction on Linux/Xvfb CI runners — manifested as SIGSEGV in MCPServerTest.ToggleNormals_* and MainWindowTest.ViewMenuConsoleToggle*. Both tests construct a MainWindow which loads the dope sheet QML; the binding setup itself was the unsafe step. The dope sheet's per-row data is structural (pose name + keyframe times), not weight-driven. Listening only to `morphTargetsChanged` (which fires on selection change and is parameter-free) covers the cases that matter: a different entity showing different morph names. Per-weight notifications were purely cosmetic (the row would re-fetch identical data on every slider drag) and skipping them is the right tradeoff. Co-Authored-By: Claude Opus 4.7 (1M context) --- qml/AnimationDopeSheet.qml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/qml/AnimationDopeSheet.qml b/qml/AnimationDopeSheet.qml index b9ce1405..e75872e8 100644 --- a/qml/AnimationDopeSheet.qml +++ b/qml/AnimationDopeSheet.qml @@ -160,17 +160,19 @@ Rectangle { function onKeyframeTicksChanged() { root.rows = AnimationControlController.allBoneRows() } } - // Refresh the morph band whenever the morph manager's data changes - // (selection moved to a different entity, or a weight was set - // through any path — Inspector slider, MCP, future authoring). + // Refresh the morph band when the manager's target list changes + // (selection moved to a different entity). We deliberately don't + // listen to `morphWeightChanged` here — its payload includes an + // `Ogre::Entity*` raw pointer which Qt 6 can't safely marshal to + // JS, and binding the slot crashed the QML engine on some Linux/ + // Xvfb CI runners. The dope sheet's per-row keyTimes are + // structural, not weight-driven, so we don't lose anything by + // skipping per-weight notifications. Connections { target: MorphAnimationManager function onMorphTargetsChanged() { root.morphRows = AnimationControlController.allMorphRows() } - function onMorphWeightChanged(entity, name, weight) { - root.morphRows = AnimationControlController.allMorphRows() - } } // Cross-platform "primary" modifier — Ctrl on Win/Linux, Cmd (Meta) on macOS. From d3cf05e4ba0dad732b10f06b23f962cc198fba01 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 17 May 2026 13:07:33 -0400 Subject: [PATCH 5/5] =?UTF-8?q?fix(morph):=20A5=20=E2=80=94=20defer=20QML?= =?UTF-8?q?=20dope-sheet=20integration=20to=20a=20follow-up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The QML changes in this slice consistently triggered SIGSEGV in unrelated test suites (MCPServerTest + MainWindowTest visibility-toggle tests), deterministically across 4 CI re-runs. The 3 new C++ controller tests all pass; only the QML integration crashes are blocking merge. Ship just the data API (`AnimationControlController::allMorphRows`) and its tests in this slice. The QML dope-sheet morph band will land in a separate PR where the crash can be isolated without holding back the backend work that 3 other in-flight slices need. Co-Authored-By: Claude Opus 4.7 (1M context) --- qml/AnimationDopeSheet.qml | 112 +------------------------------------ 1 file changed, 1 insertion(+), 111 deletions(-) diff --git a/qml/AnimationDopeSheet.qml b/qml/AnimationDopeSheet.qml index e75872e8..6f9655e0 100644 --- a/qml/AnimationDopeSheet.qml +++ b/qml/AnimationDopeSheet.qml @@ -2,7 +2,6 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts import AnimationControl 1.0 -import PropertiesPanel 1.0 // MorphAnimationManager (slice A5) // Multi-bone dope sheet with multi-select, bulk move, and copy/paste. // One row per animated bone with diamond markers at each keyframe time. @@ -22,12 +21,6 @@ Rectangle { // Cached row data refreshed from the controller. property var rows: AnimationControlController.allBoneRows() - // Slice A5: morph-target rows for the selected entity. Each - // entry is `{ name, keyTimes }`. Renders below the bone rows - // as a read-only band — full selection/move/copy interaction - // for morph tracks is a follow-up slice. - property var morphRows: AnimationControlController.allMorphRows() - // Per-bone expansion state for per-channel rows. Keys are bone names, // values are bool. Reset when a new clip is selected (different bones). property var expandedBones: ({}) @@ -152,7 +145,6 @@ Rectangle { // signal, and dropping selection there breaks bulk drag mid-gesture. function onSelectionChanged() { root.rows = AnimationControlController.allBoneRows() - root.morphRows = AnimationControlController.allMorphRows() root.clearSelection() root.expandedBones = {} } @@ -160,21 +152,6 @@ Rectangle { function onKeyframeTicksChanged() { root.rows = AnimationControlController.allBoneRows() } } - // Refresh the morph band when the manager's target list changes - // (selection moved to a different entity). We deliberately don't - // listen to `morphWeightChanged` here — its payload includes an - // `Ogre::Entity*` raw pointer which Qt 6 can't safely marshal to - // JS, and binding the slot crashed the QML engine on some Linux/ - // Xvfb CI runners. The dope sheet's per-row keyTimes are - // structural, not weight-driven, so we don't lose anything by - // skipping per-weight notifications. - Connections { - target: MorphAnimationManager - function onMorphTargetsChanged() { - root.morphRows = AnimationControlController.allMorphRows() - } - } - // Cross-platform "primary" modifier — Ctrl on Win/Linux, Cmd (Meta) on macOS. function isPrimaryModifier(modifiers) { return (modifiers & Qt.ControlModifier) || (modifiers & Qt.MetaModifier) @@ -279,13 +256,7 @@ Rectangle { id: rowsView anchors.left: parent.left; anchors.right: parent.right anchors.top: header.visible ? header.bottom : parent.top - // Reserve space at the bottom for the morph band when it's - // visible. Anchoring both to `parent.bottom` would have them - // overlap and (under some layout pressures) hit a QML - // assertion when both layouts run during a redraw — drove - // the SIGSEGV crashes on MCPServerTest + MainWindowTest - // visibility-toggle tests in CI. - anchors.bottom: morphBand.visible ? morphBand.top : parent.bottom + anchors.bottom: parent.bottom clip: true model: root.rows spacing: 1 @@ -577,87 +548,6 @@ Rectangle { } } - // ── Morph-target rows (slice A5) ───────────────────────────────────────── - // Read-only band anchored to the bottom of the dope sheet. Shows - // one row per Ogre::Pose on the selected entity, with diamond - // markers at each keyframe time. Selection / move / copy - // interaction is a follow-up — A5 ships visibility only. - Rectangle { - id: morphBand - anchors.left: parent.left; anchors.right: parent.right - anchors.bottom: parent.bottom - height: visible ? (morphHeader.height + morphRowsCol.implicitHeight) : 0 - color: AnimationControlController.panelColor - border.color: AnimationControlController.borderColor - visible: root.morphRows.length > 0 - - Rectangle { - id: morphHeader - anchors.left: parent.left; anchors.right: parent.right - anchors.top: parent.top - height: 18 - color: AnimationControlController.headerColor - border.color: AnimationControlController.borderColor - Text { - anchors.left: parent.left; anchors.leftMargin: 6 - anchors.verticalCenter: parent.verticalCenter - text: "Morph Targets (" + root.morphRows.length + ")" - color: AnimationControlController.textColor - font.pixelSize: 10; font.bold: true - } - } - - Column { - id: morphRowsCol - anchors.left: parent.left; anchors.right: parent.right - anchors.top: morphHeader.bottom - spacing: 1 - - Repeater { - model: root.morphRows - delegate: Item { - width: morphRowsCol.width - height: root.rowHeight - - // Name strip - Rectangle { - width: root.leftStripWidth; height: parent.height - color: AnimationControlController.panelColor - border.color: AnimationControlController.borderColor; border.width: 1 - Text { - anchors.fill: parent - anchors.leftMargin: 6 - verticalAlignment: Text.AlignVCenter - text: modelData.name - color: AnimationControlController.textColor - elide: Text.ElideRight - font.pixelSize: 11 - } - } - - // Track strip with diamonds at each keyframe. - Item { - anchors.left: parent.left; anchors.leftMargin: root.leftStripWidth - anchors.right: parent.right - height: parent.height - Repeater { - model: modelData.keyTimes - delegate: Rectangle { - property real keyTime: modelData - width: 8; height: 8; radius: 1 - rotation: 45 - color: "#c08040" - border.color: AnimationControlController.borderColor - anchors.verticalCenter: parent.verticalCenter - x: (keyTime - root.viewStart) * root.pxPerSec - width / 2 - } - } - } - } - } - } - } - // Shared bulk-drag delta — every selected diamond binds against this so // they all animate together while one is being dragged. Reset on release. property real dragSelectionDt: 0