feat(morph): sub-slice A6 — MCP tools for morph targets#571
Conversation
Surfaces the existing A1 `MorphAnimationManager` over MCP so AI agents + external tooling can inspect and drive morph weights. Two new tools: **`list_morph_targets`** (heavy) — args: `file`. Imports the file, enumerates `Ogre::Pose` entries, returns the de-duplicated target names across all imported entities. Uses the same safe import + RAII cleanup pattern `bake_vat` and `optimize_mesh` established, so the editor's live scene is unchanged on return. **`set_morph_weight`** (light) — args: `name`, `weight`. Drives the matching `AnimationState` weight on the first selected entity. Weight is clamped to `[0..1]` by the manager. Returns the clamped value back so the caller can see what landed. `SERVER_VERSION` bumped 1.8.0 → 1.9.0 for the new tool surface. Authoring tools (`add_morph_target`, `delete_morph_target`, `rename_morph_target`) wait for A3 — they need the in-memory edit-mode-delta-to-pose authoring path to land first. 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. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
✨ 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: 67e808e9d6
ℹ️ 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".
| try { | ||
| MeshImporterExporter::importer({filePath}); | ||
| } catch (const std::exception& e) { |
There was a problem hiding this comment.
Restore selection after transient morph import
list_morph_targets imports a mesh via MeshImporterExporter::importer, which creates scene nodes/entities through Manager::addSceneNode/createEntity and auto-selects them (SelectionSet::selectOne). This tool then destroys those imported nodes in cleanup but never restores the prior selection, so the selection set can retain destroyed-node pointers; subsequent selection-based calls (e.g. code paths that iterate selected nodes and read node->getName()) may dereference invalid memory, and the user’s original selection is lost even though the tool claims the live scene is unchanged.
Useful? React with 👍 / 👎.
Codex P1 on PR #571: `list_morph_targets` imports a mesh via `MeshImporterExporter::importer`, which auto-selects the new entities (Manager::addSceneNode + SelectionSet::append). The tool's cleanup then destroys those imported nodes — but never restored the prior selection, so the SelectionSet ended up holding dangling pointers to destroyed entities. Any subsequent selection-based code (iterating nodes, reading getName(), etc.) would dereference freed memory, and the user's original selection was lost even though the tool claims the live scene is unchanged. Fix: snapshot `getNodesSelectionList` / `getEntitiesSelectionList` / `getSubEntitiesSelectionList` before the import, then in the `ImportCleanup` dtor: 1. Destroy the imported scene nodes (existing behavior). 2. `selSet->clearList()` — wipes the importer-added entries (which now point at freed memory) without dereferencing them. 3. Re-append the snapshotted pre-import selection. `clearList()` (not `clear()`) is the API SelectionSet exposes exactly for this case: the contents have already been destroyed externally, so we can't safely walk them to bookkeep. The same hole likely exists in `bake_vat` and `optimize_mesh` (both predate this PR) — a follow-up will extract this pattern into a shared `TransientImportSession` helper and migrate all three tools. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
…ll transient-import tools (#572) * refactor(mcp): extract TransientImportSession; restore selection in all transient-import tools Three MCP tools (`apply_atlas`, `bake_vat`, `list_morph_targets`) needed the same pattern: - Snapshot the pre-import entity set + the user's selection. - Run `MeshImporterExporter::importer(...)`. - Diff to find the imported entities. - On scope exit: destroy the imported scene nodes AND restore the prior selection. PR #571 fixed the selection-restore hole in `list_morph_targets` only; `bake_vat` and `apply_atlas` had the same bug (`MeshImporterExporter::importer` auto-selects the new entities; my cleanup destroyed them, leaving the SelectionSet holding dangling pointers). Extract the whole pattern into a `TransientImportSession` RAII helper (anonymous namespace, MCPServer.cpp) and migrate the three tools to use it. Each tool's body drops from ~50 lines of boilerplate to: ```cpp TransientImportSession session(mgr); if (QString err = session.runImporter(filePath); !err.isEmpty()) return makeErrorResult(err); const auto& imported = session.importedEntities(); ``` The helper: - Filters `getEntities()` by `getMovableType() == "Entity"` — gizmos / brush rings / mask overlays would otherwise crash on the raw cast. - Uses `SelectionSet::clearList()` (not `clear()`) in the destructor — the importer-added selection entries point at entities we're about to destroy; `clear()` would dereference freed memory while bookkeeping. `toolOptimizeMesh` is intentionally NOT migrated in this PR — it passes an extra `animOnlySkeletons` out-parameter to `MeshImporterExporter::importer`, which the helper's `runImporter()` doesn't yet support. That's a separate small extension when we touch optimize-mesh next. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * review(mcp): TransientImportSession captures partial imports on throw CodeRabbit major on PR #572: If `MeshImporterExporter::importer()` throws partway through, the prior `runImporter()` returned the error without recording what the importer managed to create — leaving partial state (scene nodes, entities) in the live scene that the destructor never cleaned up. That broke the transient-import contract on the error path. Fix: extract the post-import "diff `getEntities()` against `m_beforeSet`" logic into `captureImportedEntities()` and call it from all three exit paths (success + both catch blocks). The helper dedups against `m_imported` so callers running it twice is safe. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- 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>



Sub-slice A6 of #518. Surfaces the existing A1 `MorphAnimationManager` over MCP so AI agents + external tooling can inspect and drive morph weights without going through the GUI.
What ships
`list_morph_targets` (heavy)
Args: `file` (path).
Imports the mesh, enumerates `Ogre::Pose` entries, returns the de-duplicated target names across all imported entities. Uses the same safe import + RAII cleanup pattern `bake_vat` and `optimize_mesh` established, so the editor's live scene is unchanged on return.
`set_morph_weight` (light)
Args: `name`, `weight` (number, clamped to [0..1]).
Drives the matching `AnimationState` weight on the first selected entity in the live editor. Returns the clamped value back. Errors when no entity is selected or the named target doesn't exist.
`SERVER_VERSION` 1.8.0 → 1.9.0
Clients can detect the new capability.
NOT in this PR — wait for A3 (authoring)
Manual smoke
```
$ echo '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"list_morph_targets","arguments":{"file":"face.fbx"}}}' \
| qtmesheditor --mcp
{ ... morphTargets: [...] }
```
Test plan
🤖 Generated with Claude Code