feat(morph): sub-slice A2 — Inspector Morph Targets subgroup - #570
Conversation
Second sub-slice of #518. A1 shipped the data path (importer + MorphAnimationManager + CLI list); A2 wires the QML Inspector surface that authors will actually use. ### What ships New "Morph Targets (N)" subgroup at the bottom of the Animations section in `qml/PropertiesPanel.qml`: - Bound to `MorphAnimationManager.morphTargetsForSelection()` — refreshes via the `morphTargetsChanged` signal when selection moves. - One row per target: name (elided), 0..1 slider (step 0.01), numeric readout. Slider `onMoved` writes back through `setWeightForSelection`, which drives the underlying Ogre `AnimationState` weight (the live preview path). - "Reset all" button — walks every target and sets weight to 0. - Filter / search text field, shown when target count > 6 (characters routinely ship 50+ shapes; flat list is hopeless). - Whole subgroup is `visible: targetCount > 0` so meshes without blend shapes don't show an empty header. Authoring (add new target from edit-mode delta, rename, delete) lands in A3. Export round-trip in A4. Dope-sheet integration in A5. MCP tools in A6. ### Manual smoke - Select an entity with blend shapes (any FBX from a DCC with facial rigs). The "Morph Targets (N)" subgroup appears at the bottom of the Animations section with one slider per shape. - Drag a slider — the mesh deforms in real time (the AnimationState weight drives the VAT_POSE track A1 created). - Click "Reset all" — every slider snaps back to 0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Warning Rate limit exceeded
You’ve run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. 📝 WalkthroughWalkthroughAdds a new "Morph Targets / Blend Shapes" subsection to the Animations panel in ChangesMorph Targets / Blend Shapes UI Subsection
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4d7146ec55
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| Connections { | ||
| target: MorphAnimationManager | ||
| function onMorphTargetsChanged() { | ||
| morphCol.targets = MorphAnimationManager.morphTargetsForSelection() | ||
| } |
There was a problem hiding this comment.
Refresh slider values when morph weights change
The new morph-target panel only re-fetches targets on morphTargetsChanged, but each slider value is derived from weightForSelection(modelData) and there is no handler for morphWeightChanged. As a result, when weights are changed outside direct drag interaction (for example clicking Reset all or any other code path that calls setWeightForSelection), the backend weight updates but the displayed slider/readout can stay stale until the delegates are recreated. This makes the inspector show incorrect state immediately after valid edits.
Useful? React with 👍 / 👎.
Codex P1 on PR #570: The morph slider's `value` was bound to a one-shot function call (`MorphAnimationManager.weightForSelection(modelData)`), evaluated once at delegate creation. Any weight write that doesn't move the slider directly — Reset all, MCP, future dope-sheet scrubs — would update the backend AnimationState but the visible slider + numeric readout stayed stale until the delegate was recreated. From the user's perspective, "Reset all" appeared to do nothing. Fix: track a `weightTick` counter that bumps on `morphTargetsChanged` and `morphWeightChanged`, then bind the slider's `value` to a comma-expression that reads the tick before calling the weight getter — Qt's binding machinery picks up the tick dependency and re-evaluates the function when it changes. Same pattern QML typically uses to force re-evaluation of function-bound bindings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🧹 Nitpick comments (1)
qml/PropertiesPanel.qml (1)
4331-4331: ⚡ Quick winConsider adding null-safety for the targets property.
While
MorphAnimationManager.morphTargetsForSelection()likely returns a valid array, adding a defensive default prevents potential crashes if it ever returns null or undefined:🛡️ Proposed fix
- property var targets: MorphAnimationManager.morphTargetsForSelection() + property var targets: MorphAnimationManager.morphTargetsForSelection() || []function onMorphTargetsChanged() { - morphCol.targets = MorphAnimationManager.morphTargetsForSelection() + morphCol.targets = MorphAnimationManager.morphTargetsForSelection() || [] morphCol.weightTick = morphCol.weightTick + 1 }Also applies to: 4345-4345
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@qml/PropertiesPanel.qml` at line 4331, The property declaration for targets uses MorphAnimationManager.morphTargetsForSelection() without null-safety; update the property initialization (both occurrences that reference property 'targets') to defensively default to an empty array when MorphAnimationManager.morphTargetsForSelection() returns null or undefined (e.g. use a null-coalescing or default expression around the call) so any consumers of 'targets' always get an array.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@qml/PropertiesPanel.qml`:
- Line 4331: The property declaration for targets uses
MorphAnimationManager.morphTargetsForSelection() without null-safety; update the
property initialization (both occurrences that reference property 'targets') to
defensively default to an empty array when
MorphAnimationManager.morphTargetsForSelection() returns null or undefined (e.g.
use a null-coalescing or default expression around the call) so any consumers of
'targets' always get an array.
CodeRabbit nit on PR #570. The manager currently always returns a QStringList, but contracts drift; the `|| []` fallback keeps the delegate Repeater binding safe if the getter ever starts returning null/undefined. Applied at both call sites (property initialiser + onMorphTargetsChanged handler). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
…576) * feat(morph): sub-slice A5 — dope sheet shows morph-weight tracks 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) <noreply@anthropic.com> * review(morph): A5 — filter morph keyTimes by pose target handle 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) <noreply@anthropic.com> * fix(morph): A5 — avoid overlap between bone ListView and morph band 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) <noreply@anthropic.com> * fix(morph): A5 — drop `onMorphWeightChanged` QML binding 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) <noreply@anthropic.com> * fix(morph): A5 — defer QML dope-sheet integration to a follow-up 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#578) * feat(morph): sub-slice A3 — authoring (add from edit, rename, delete) Last remaining sub-slice of #518. Closes the morph epic's authoring loop: users can save the current edit-mode geometry as a new morph target, rename existing targets, and delete unwanted ones. All three operations are undoable. ## What ships ### `commands/MorphCommands.{h,cpp}` — three QUndoCommand subclasses - `AddMorphTargetCommand` — create N same-named Ogre::Pose entries (one per submesh) plus a matching VAT_POSE Animation. Slices come in as sparse `{submeshHandle, {vertexIndex: Vector3f}}` maps so the command doesn't depend on EditableMesh — anything that produces deltas can drive it (current edit, MCP, future "fix mirror axis" rules…). - `DeleteMorphTargetCommand` — snapshot the same-named pose offsets at construction, drop the pose(s) + Animation + AnimationState on redo, rebuild from the snapshot on undo. - `RenameMorphTargetCommand` — same snapshot-and-rebuild pattern, just with a different name on the rebuild side. Ogre 14.5 doesn't expose Pose::setName, so destroy-and-recreate is the only path. ### `MorphAnimationManager` — three new Q_INVOKABLE methods - `addMorphTargetFromCurrentEdit(name)` — reads `EditModeController`'s live EditableMesh, diffs every submesh against the mesh's bind positions on the GPU buffer, builds the slice list, pushes an AddMorphTargetCommand. Falls back to no-op if not in edit mode, no vertices moved, or name collides. - `renameMorphTarget(old, new)` — name-trim, collision check, push. - `deleteMorphTarget(name)` — existence check, push. All three resolve the entity from SelectionSet's first entity (same pattern as the A1/A2 setters), push onto the shared `UndoManager` stack so Ctrl+Z reverses, and emit `morphTargetsChanged` so the Inspector re-fetches. ## Tests 8 new GTest cases on `MorphAnimationManager_test.cpp`: - Add command creates + undoes pose and Animation cleanly. - Delete command round-trips (poses restored on undo). - Rename command round-trips (old name restored on undo). - Rename rejects target-name collisions, no-op self-renames, blank names. - Delete rejects unknown / empty names; succeeds on a real target. - Add-from-edit rejects empty / whitespace names before edit-mode check. - Add-from-edit returns false when no EditableMesh is available (i.e. user isn't in edit mode). - All three methods reject when nothing is selected. The command path is what's actually under test — the manager wrappers are thin selection + name-validation glue. We keep direct command tests because that's where the undo / redo contract lives and it exercises the pose snapshot / rebuild path without needing an Inspector. ## What's deferred - **Inspector UI** (Add / Rename / Delete buttons in the Morph Targets subgroup) — small but lives in QML, and the last QML change in this epic (A5's dope-sheet integration) deterministically crashed unrelated tests for reasons I never fully isolated. Doing the UI in a separate PR keeps blast radius contained. - **MCP tools** `add_morph_target` / `rename_morph_target` / `delete_morph_target` — straightforward but adds surface; can land alongside the Inspector UI. - **CLI** `qtmesh morph --rename / --delete` — needs export-after-edit plumbing through the FBX/glTF morph exporters from A4a/A4b. #518 status after this slice: | Sub-slice | Status | |-|-| | A1 — Importer + manager + CLI list | shipped (#569) | | A2 — Inspector subgroup | shipped (#570) | | A3 — Authoring | **this PR** | | A4a — glTF export | shipped (#573) | | A4b — FBX export | shipped (#575) | | A5 — Controller API | shipped (#576) | | A5b — Dope-sheet UI | follow-up | | A6 — MCP tools | shipped (#571) | | A3b — Inspector / MCP / CLI authoring surface | follow-up | Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(build): add MorphCommands.cpp to tests/ source list Linker errors on the secondary test binaries (MaterialEditorQML_test, MaterialEditorQML_qml_test, MaterialEditorQML_perf_test) because they compile MorphAnimationManager.cpp (which now references the three MorphCommand constructors) but didn't pick up MorphCommands.cpp. src/CMakeLists.txt has it; tests/CMakeLists.txt was missing the line. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * review(morph): A3 — capture bind positions on EditableMesh load Addresses Codex P1 findings on PR #578: ## P1 #1 — diff against captured bind positions, not the live buffer `addMorphTargetFromCurrentEdit` was diffing `EditableMesh` against positions read from the entity's current GPU vertex buffer. But edit-mode ops (translate / rotate / scale) call `commitToEntity` during the gesture — both sides of the subtraction end up identical in the common flow, so `slice.offsets` stays empty and the command incorrectly returns no-op even after visible edits. Fix: snapshot the bind positions once at `EditableMesh::loadFromOgreMesh` time (`EditableSubMesh::originalPositions`). The snapshot is never mutated by edit-mode ops, so `vertices[i].position - originalPositions[i]` recovers the right delta regardless of how many commits have run since enter-edit-mode. ## P1 #2 — shared-vertex submeshes The old `readBindPositions` rejected any submesh with `useSharedVertices=true` (vertexData null in that case). Most Mixamo-style assets use shared vertices, so morph authoring silently skipped them. Fix is implicit in #1: `loadFromOgreMesh` already copies the shared vertex pool into each affected submesh's `vertices` array; the new `originalPositions` snapshot is built from that same source, so shared-vertex submeshes carry their own baseline. The GPU-buffer read path goes away entirely. ## CodeRabbit minor — null-assert SelectionSet in tests Added `ASSERT_NE(sel, nullptr)` before `sel->append(entity)` in the three new authoring tests, matching the slice-A1 pattern. If the singleton ever fails to initialize, we get a clear assertion failure instead of a null-deref crash. ## Tests - `LoadFromEntityTriangleMesh` extended to assert (a) the snapshot matches `vertices[].position` after load and (b) mutating `vertices[].position` does NOT touch `originalPositions`. Exercises the shared-vertex case (the triangle fixture uses shared verts). - New `AddMorphTargetUndoableViaUndoManager` exercises the full add → undo → redo loop through the shared `UndoManager`, with a hand-built slice that mirrors what `addMorphTargetFromCurrentEdit` will produce once edit-mode capture is wired up end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>



Second sub-slice of #518. A1 (#569) shipped the data path — importer +
MorphAnimationManager+qtmesh morph --list. A2 wires the QML Inspector surface authors will actually use.What ships
New Morph Targets (N) subgroup at the bottom of the Animations section in `qml/PropertiesPanel.qml`:
What's NOT in this PR (deferred to A3–A6)
list_morph_targets,set_morph_weight,add_morph_target,delete_morph_target.Manual smoke
Test plan
MorphAnimationManagerstill pass (this PR doesn't change the C++).🤖 Generated with Claude Code
Summary by CodeRabbit