From c4913498b5cdc59826c9b6576fac359489fdc190 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 17 May 2026 17:28:00 -0400 Subject: [PATCH] =?UTF-8?q?feat(morph):=20A5b=20=E2=80=94=20read-only=20mo?= =?UTF-8?q?rph-target=20rows=20in=20the=20dope=20sheet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final sub-slice of #518. The C++ data API (`allMorphRows`) shipped in #576 as a Q_INVOKABLE on AnimationControlController; this PR wires it into the dope-sheet view. ## What ships - New `morphRows` property on the dope-sheet root, bound to `AnimationControlController.allMorphRows()`. Refreshed via the existing `onSelectionChanged` handler so a clip change rebuilds both bone and morph rows in lockstep. - `rowsView` (bone ListView) bottom anchor switches to `morphBand.top` when the morph band is visible. Bone-only entities → band collapses to `height=0` → bone list draws full height as before. - New `morphBand` Rectangle at the bottom: header row showing "Morph Targets (N)", then one row per pose with the target name on the left and `#88ccff` diamond markers at each keyframe time on the right. Diamonds share the bone-row timeline math (`pxPerSec`, `viewStart`) so they line up vertically. ## Why this is small and safe The original A5 (PR #576's first commit) deterministically crashed unrelated MainWindow / MCPServer visibility-toggle suites in CI. Root cause was never fully isolated but the suspicion was an `import PropertiesPanel 1.0` triggering a different QML singleton chain during MainWindow construction. This PR keeps the discipline that worked for A3b: - **No new QML imports.** `AnimationControl 1.0` was already imported. `allMorphRows()` was deliberately put on AnimationControlController (not MorphAnimationManager) for exactly this reason. - **No new `Connections` blocks.** The existing one already fires on the signals we care about. - **No MouseAreas, no selection, no drag** on morph diamonds — this is strictly read-only display. Interactive morph keyframes (selection, multi-select, drag, copy/paste) need their own controller surface and land in a separate PR. ## #518 status After this slice the issue is feature-complete and ready to close: | Sub-slice | Status | |-|-| | A1 — Importer + manager + CLI list | shipped (#569) | | A2 — Inspector subgroup | shipped (#570) | | A3 — Authoring data layer | shipped (#578) | | A3b — Inspector authoring UI | shipped (#579) | | A4a — glTF export | shipped (#573) | | A4b — FBX export | shipped (#575) | | A5 — Controller API | shipped (#576) | | A5b — Dope-sheet UI | **this PR** | | A6 — MCP tools | shipped (#571) | Co-Authored-By: Claude Opus 4.7 (1M context) --- qml/AnimationDopeSheet.qml | 114 ++++++++++++++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 1 deletion(-) diff --git a/qml/AnimationDopeSheet.qml b/qml/AnimationDopeSheet.qml index 6f9655e0..637f46b4 100644 --- a/qml/AnimationDopeSheet.qml +++ b/qml/AnimationDopeSheet.qml @@ -21,6 +21,17 @@ Rectangle { // Cached row data refreshed from the controller. property var rows: AnimationControlController.allBoneRows() + // Slice A5b: morph-target rows for the selected entity, sourced from + // AnimationControlController.allMorphRows() (a Q_INVOKABLE on the + // same singleton that supplies `allBoneRows()` — kept on + // AnimationControlController on purpose so this file doesn't + // need a second `import` and doesn't trigger a different QML + // singleton chain during MainWindow construction). Each row is + // `{ name, keyTimes }`. Renders below the bone rows as a fixed + // read-only band — full selection / move / copy interaction for + // morph tracks is a future 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 +156,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 = {} } @@ -256,7 +268,9 @@ Rectangle { id: rowsView anchors.left: parent.left; anchors.right: parent.right anchors.top: header.visible ? header.bottom : parent.top - anchors.bottom: parent.bottom + // Leave room for the morph band at the bottom when it's visible + // — otherwise the bone list would draw over it. + anchors.bottom: morphBand.visible ? morphBand.top : parent.bottom clip: true model: root.rows spacing: 1 @@ -693,4 +707,102 @@ Rectangle { event.accepted = true } } + + // ── Morph-target rows (slice A5b) ──────────────────────────────────────── + // Fixed-height read-only band anchored to the bottom. One row per + // morph target with diamond markers at each keyframe time, sharing + // the bone-track timeline (same `pxPerSec`, `viewStart`). When the + // entity has no morphs the band collapses to height=0 so bone-only + // assets look exactly the same as before. + // + // Keep this strictly read-only: no MouseAreas on the diamonds, no + // selection toggling, no drag — full interaction lives in a future + // slice. The point here is to make morph-weight animation visible + // alongside skeletal animation, which is the load-bearing piece of + // #518's "dope sheet integration" acceptance criterion. + Rectangle { + id: morphBand + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + visible: morphRowsRep.count > 0 + height: visible + ? (morphHeader.height + morphRowsRep.count * (root.rowHeight + 1) + 4) + : 0 + color: AnimationControlController.panelColor + border.color: AnimationControlController.borderColor + border.width: 1 + + Rectangle { + id: morphHeader + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: 16 + color: Qt.darker(AnimationControlController.panelColor, 1.15) + Text { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left; anchors.leftMargin: 6 + text: "Morph Targets (" + morphRowsRep.count + ")" + color: AnimationControlController.textColor + font.pixelSize: 10 + font.bold: true + } + } + + Column { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: morphHeader.bottom + anchors.topMargin: 2 + spacing: 1 + + Repeater { + id: morphRowsRep + model: root.morphRows + + Item { + width: parent.width + height: root.rowHeight + + Rectangle { + width: root.leftStripWidth; height: root.rowHeight + color: AnimationControlController.panelColor + border.color: AnimationControlController.borderColor + border.width: 1 + Text { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left; anchors.leftMargin: 8 + text: modelData.name + color: AnimationControlController.textColor + font.pixelSize: 10 + elide: Text.ElideRight + width: root.leftStripWidth - 12 + } + } + + // Right side: diamond per keyframe time. Sharing the + // bone-row timeline math so they line up vertically. + Item { + anchors.left: parent.left; anchors.leftMargin: root.leftStripWidth + anchors.right: parent.right + height: root.rowHeight + Repeater { + model: modelData.keyTimes + Rectangle { + property real keyTime: modelData + x: (keyTime - root.viewStart) * root.pxPerSec - width / 2 + anchors.verticalCenter: parent.verticalCenter + width: 10; height: 10 + rotation: 45 + color: "#88ccff" // visually distinct from bone keys (yellow) + border.color: AnimationControlController.borderColor + border.width: 1 + } + } + } + } + } + } + } }