Skip to content

feat(morph): sub-slice A1 — import detection + manager + CLI list#569

Merged
fernandotonon merged 3 commits into
masterfrom
feat/morph-slice-a1-import
May 17, 2026
Merged

feat(morph): sub-slice A1 — import detection + manager + CLI list#569
fernandotonon merged 3 commits into
masterfrom
feat/morph-slice-a1-import

Conversation

@fernandotonon
Copy link
Copy Markdown
Owner

@fernandotonon fernandotonon commented May 17, 2026

First sub-slice of #518 (parent epic: #517 / Slice A). The repo had no morph-target handling at all — no Ogre::Pose / MorphAnimationTrack usage anywhere in `src/`. This sub-slice lays the foundation the rest of #518 — Inspector sliders, dope-sheet wiring, authoring, export — will build on top of.

What ships

Importer (MeshProcessor)

  • aiMesh::mAnimMeshes (Assimp's blend-shape array) is now collected per submesh into a new MorphTargetData struct, with the same Z-up axis bake the base vertex pass uses.
  • At mesh-build time each target becomes an Ogre::Pose storing per-vertex deltas relative to the base mesh, attached to the owning submesh. Zero-delta vertices are skipped to keep the pose sparse.
  • One Ogre::Animation per pose (single VAT_POSE track, one keyframe at t=0 referencing that pose at influence 1.0). Animation name = pose name = the upstream morph-target name. This gives every pose its own Ogre::AnimationState weight.

MorphAnimationManager (QML_SINGLETON)

  • morphTargetsFor(entity) — enumerate names in mesh-pose-list order.
  • weight(entity, name) / setWeight(entity, name, w) — read/write a single morph weight via the matching AnimationState. Clamps to [0..1], enables the state, pins time to 0. Emits morphWeightChanged and morphTargetsChanged.
  • Selection-driven QML helpers (morphTargetsForSelection, weightForSelection, setWeightForSelection) ready for the future Inspector subgroup.
  • Sentry breadcrumbs scene.anim.morph.

CLI

`qtmesh morph --list [--json]` — load headlessly, print morph-target names. Other modes land in follow-up sub-slices once the authoring code is in place.

10 new tests

Standalone (no Ogre): singleton identity, null-entity handling, empty-name rejection.

Scene-fixture: builds a 3-vertex mesh with two named poses ("JawOpen", "Smile") + matching VAT_POSE animations — same shape MeshProcessor produces. Verifies pose-list ordering, default-zero weights, setWeight clamping, unknown-name rejection, morphWeightChanged signal, selection-driven QML helpers.

NOT in this PR — deferred to A2–A6

  • Inspector "Morph Targets" subgroup with sliders
  • Authoring (save edit-mode delta as new target, rename, delete)
  • Export round-trip (FBX/glTF preserves morph targets)
  • Dope sheet + curve editor recognise morph-weight tracks
  • MCP tools

Each becomes its own focused PR built on this foundation.

Test plan

  • CI Linux/Xvfb: all 10 new tests pass.
  • CI macOS/Windows: build clean.
  • Manual: `qtmesh morph media/models/robot.mesh --list` reports "No morph targets" (robot has none). With an FBX that has blend shapes, the names appear.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Added comprehensive morph targets and blend shapes support for imported 3D models
    • New CLI capability to discover and list available morph targets in assets
    • Interactive weight controls enabling real-time morph target blending adjustments in the UI
    • Seamless coordinate system handling for morph targets consistent with base mesh processing
  • Tests

    • Added test suite validating morph target import and control operations

Review Change Stack

First sub-slice of #518 / #517 Slice A. The repo had no morph-
target handling at all (no `Ogre::Pose` / `MorphAnimationTrack`
usage anywhere in `src/`); this lays the foundation the rest of
the slice — Inspector sliders, dope-sheet wiring, authoring,
export — will build on top of.

### What ships

**Importer (MeshProcessor):**
- `aiMesh::mAnimMeshes` (Assimp's blend-shape array) is now
  collected per submesh into a new `MorphTargetData` struct, with
  the same Z-up axis bake the base vertex pass uses.
- At mesh-build time, each target becomes an `Ogre::Pose` storing
  per-vertex deltas relative to the base mesh, attached to the
  owning submesh. Zero-delta vertices are skipped to keep the pose
  sparse.
- One `Ogre::Animation` per pose (single VAT_POSE track, one
  keyframe at t=0 referencing that pose at influence 1.0). Animation
  name = pose name = the upstream morph-target name. This gives
  every pose its own `Ogre::AnimationState` weight, which the
  manager drives.

**`MorphAnimationManager` (QML_SINGLETON):**
- `morphTargetsFor(entity)` — enumerate names in mesh-pose-list order.
- `weight(entity, name)` / `setWeight(entity, name, w)` — read/write
  a single morph weight via the matching AnimationState (clamps to
  [0..1], enables the state, pins time to 0). Emits
  `morphWeightChanged` and `morphTargetsChanged`.
- Selection-driven QML helpers (`morphTargetsForSelection`,
  `weightForSelection`, `setWeightForSelection`) for the future
  Inspector subgroup.
- Sentry breadcrumbs `scene.anim.morph`.

**CLI subcommand:**
- `qtmesh morph <file> --list [--json]` — load the file headlessly,
  print the morph-target names (or zero count when there are none).
  Other modes (`--set`, `--add`, `--delete`) land in follow-up
  sub-slices once the authoring code is in place.

### Tests (10)

Standalone (no Ogre): singleton, null-entity handling, empty-name rejection.

Scene-fixture: builds a 3-vertex mesh with two named poses ("JawOpen",
"Smile") + matching VAT_POSE animations — same shape MeshProcessor
produces. Verifies `morphTargetsFor` ordering, default-zero weights,
`setWeight` clamping + value retrieval, unknown-name rejection,
`morphWeightChanged` signal, selection-driven QML helpers.

### What's NOT in this PR (deferred to A2–A6)

- Inspector "Morph Targets" subgroup with sliders
- Authoring (save edit-mode delta as new target, rename, delete)
- Export round-trip (FBX/glTF preserves morph targets)
- Dope sheet + curve editor recognise morph-weight tracks
- MCP tools

Each becomes its own focused PR built on this foundation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 17, 2026

Warning

Rate limit exceeded

@fernandotonon has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 38 minutes and 10 seconds before requesting another review.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 11d0566c-9a04-4c17-bee2-091392e47b33

📥 Commits

Reviewing files that changed from the base of the PR and between 2b38a27 and 660e9fd.

📒 Files selected for processing (4)
  • src/Assimp/MeshProcessor.cpp
  • src/CLIPipeline.cpp
  • src/MorphAnimationManager.cpp
  • src/MorphAnimationManager_test.cpp
📝 Walkthrough

Walkthrough

PR introduces morph target/blend shape support: imports Assimp aiAnimMesh blend shapes into MorphTargetData structures with Z-up baking; converts them to Ogre Pose objects and VAT_POSE animations; implements MorphAnimationManager singleton to query and control per-entity morph weights; exposes via CLI list command; includes comprehensive tests and QML integration.

Changes

Morph Targets / Blend Shapes Import and Runtime Control

Layer / File(s) Summary
Morph target data model and Assimp import
src/Assimp/MeshProcessor.h, src/Assimp/MeshProcessor.cpp
MorphTargetData struct stores per-vertex morph positions; SubMeshData extended with morphTargets vector; processMesh imports Assimp aiAnimMesh blend shapes and applies Z-up coordinate baking; createMesh creates Ogre Pose objects with per-vertex deltas and VAT_POSE animation tracks keyed by pose names.
MorphAnimationManager interface and singleton
src/MorphAnimationManager.h, src/MorphAnimationManager.cpp
QML singleton class with lifecycle (instance/qmlInstance/kill); per-entity methods enumerate targets, read/set animation state weights; selection-convenience methods operate on first resolved entity; signals notify weight changes; implementation manages singleton, connects to SelectionSet, clamps weights to [0,1], forces animation time to 0.0, and emits breadcrumbs.
MorphAnimationManager tests
src/MorphAnimationManager_test.cpp
Test helper builds minimal Ogre mesh with two named poses and VAT_POSE animations; tests cover singleton, null-entity handling, target enumeration order, default weights, weight clamping, unknown target rejection, signal emission, and selection-driven API with missing-selection fallback.
CLI morph list command
src/CLIPipeline.h, src/CLIPipeline.cpp
New cmdMorph static handler parses morph <file> --list [--json], initializes headless Ogre, imports asset, collects deduplicated morph targets from all entities via MorphAnimationManager, outputs JSON or plain-text list; integrated into command dispatcher.
Build and application integration
src/CMakeLists.txt, tests/CMakeLists.txt, src/main.cpp, src/mainwindow.cpp
Add MorphAnimationManager.cpp to source lists; extend CLI mode detection to recognize morph subcommand; register MorphAnimationManager as QML singleton in PropertiesPanel import namespace.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

  • fernandotonon/QtMeshEditor#517: This PR directly implements "Slice A — Morph targets / blend shapes" with MeshProcessor morph import, Ogre Pose/VAT_POSE creation, and MorphAnimationManager.
  • fernandotonon/QtMeshEditor#518: This PR delivers the requested morph-target importer, MorphAnimationManager singleton, and CLI morph listing features.

Possibly related PRs

Poem

🐰 A morph target hops to life,
From Assimp's blend shapes, free of strife,
Ogre poses dance and sway,
QML weights guide the way,
Blend shapes bloom—our meshes thrive!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.82% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: introducing morph-target support (importer, manager, CLI list) as sub-slice A1 of a larger epic.
Description check ✅ Passed The description is comprehensive and well-structured, covering objectives, technical implementation, features shipped, test coverage, and deferred work, exceeding the template's basic requirements.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/morph-slice-a1-import

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0f75869d9e

ℹ️ 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".

Comment thread src/Assimp/MeshProcessor.cpp Outdated
Comment on lines +306 to +307
if (ogreMesh->hasAnimation(animName)) continue; // duplicate pose name; skip
Ogre::Animation* anim = ogreMesh->createAnimation(animName, /*length=*/0.0f);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Handle duplicate morph names across submeshes

Do not skip duplicate pose names here: when two submeshes both contain a morph target like Smile, only the first pose gets an animation track and later poses are left unreferenced. MorphAnimationManager::setWeight drives morphs via AnimationState name lookup, so in multi-submesh meshes this makes the same-named target deform only one submesh while the others never move.

Useful? React with 👍 / 👎.

Comment thread src/CLIPipeline.cpp Outdated
Comment on lines +4998 to +4999
entity = static_cast<Ogre::Entity*>(obj);
break;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge List morph targets from all imported entities

This loop exits after the first imported entity, so qtmesh morph <file> --list can report incomplete results (or No morph targets) for files that import multiple entities when later entities hold the blend shapes. The command claims to inspect the file, but currently only inspects whichever entity is encountered first.

Useful? React with 👍 / 👎.

Two findings from Codex on PR #569:

**P1 (MeshProcessor)** — duplicate pose names across submeshes
were dropped. Multi-submesh meshes where, e.g., body + head both
carry a "Smile" target would only get the first pose registered;
subsequent same-named poses were skipped via `hasAnimation`,
leaving the second submesh's pose unreferenced. Since
`MorphAnimationManager::setWeight` drives morphs via
`AnimationState`-name lookup, the same-named target would deform
only one submesh.

Group same-named poses into one Animation with one VAT_POSE
track per affected submesh. Each track has its own pose
reference at full influence. The Animation's single
AnimationState weight then drives all submeshes simultaneously,
which is exactly what users author when they paint a "Smile"
target across multiple meshes in a DCC.

Also defends the rare same-submesh duplicate case (same name on
the same submesh — content error) by reusing the existing
keyframe rather than failing the second `createVertexTrack`.

**P2 (CLI)** — `qtmesh morph <file> --list` exited after the
first imported entity, so files that import multiple entities
with blend shapes on the later entities reported false zeros.
Walk every entity and union the target names (de-duped, first-
sighting order preserved).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
src/MorphAnimationManager_test.cpp (1)

192-203: ⚡ Quick win

Tighten this test to verify “real change” semantics explicitly.

Right now it only proves at least one emission. Add an idempotent write assertion to ensure unchanged values do not emit.

Minimal assertion upgrade
 TEST_F(MorphAnimationManagerSceneTest, SetWeightEmitsSignalOnRealChange) {
@@
     auto* m = MorphAnimationManager::instance();
     QSignalSpy spy(m, &MorphAnimationManager::morphWeightChanged);
-    m->setWeight(entity, QStringLiteral("JawOpen"), 0.5f);
-    EXPECT_GE(spy.count(), 1);
+    EXPECT_TRUE(m->setWeight(entity, QStringLiteral("JawOpen"), 0.5f));
+    const int afterFirst = spy.count();
+    EXPECT_GE(afterFirst, 1);
+    EXPECT_TRUE(m->setWeight(entity, QStringLiteral("JawOpen"), 0.5f));
+    EXPECT_EQ(spy.count(), afterFirst);
+    EXPECT_TRUE(m->setWeight(entity, QStringLiteral("JawOpen"), 0.7f));
+    EXPECT_GT(spy.count(), afterFirst);
 }
🤖 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 `@src/MorphAnimationManager_test.cpp` around lines 192 - 203, The test
currently only checks that setting a weight emits at least one signal; tighten
it by asserting idempotent writes do not emit: after calling
MorphAnimationManager::instance()->setWeight(entity, QStringLiteral("JawOpen"),
0.5f) and verifying spy.count() >= 1, store the current spy.count(), call
setWeight again with the identical value (0.5f) and then assert spy.count()
remains equal to the stored value (no additional signal emitted). Use the
existing QSignalSpy (spy) and the same entity/"JawOpen" identifier to implement
this idempotent-write check.
🤖 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.

Inline comments:
In `@src/Assimp/MeshProcessor.cpp`:
- Around line 283-293: The code currently calls
ogreMesh->createPose(targetSubmesh, mt.name) before checking for non-zero vertex
deltas, which creates empty/no-op poses; change the logic in MeshProcessor.cpp
so you first scan mt.positions vs smd->vertices to detect at least one delta
with squaredLength() > 1e-12f (the same check used now), and only then call
ogreMesh->createPose(targetSubmesh, mt.name) and addVertex(...) for each
non-zero delta; alternatively accumulate the non-zero deltas/indices first and
create the Ogre::Pose only when the accumulator is non-empty, skipping creation
entirely for identical-to-base targets.

In `@src/CLIPipeline.cpp`:
- Line 1071: The new subcommand is wired via the cmd == "morph" branch calling
cmdMorph but is not listed in the CLI help; update the printUsage() function to
include the "morph" entry and a short description so it appears in --help output
(add "morph" to the commands list and any relevant usage/example text). Ensure
the help text references the cmdMorph behavior/arguments consistent with other
entries so users can discover and invoke the new subcommand.

In `@src/MorphAnimationManager_test.cpp`:
- Around line 108-111: The test currently passes because passing nullptr to
MorphAnimationManager::setWeight triggers the null-entity branch rather than
exercising empty-name validation; modify the test to use a real, scene-backed
valid entity (create or obtain an Entity/EntityItem from the test scene) and
then call MorphAnimationManager::setWeight(entityPtr, QString(), 0.5f) so that
the empty-name path in setWeight is exercised and rejected; reference
MorphAnimationManager::instance() and the setWeight(Entity*, QString, float)
overload when making this change.

In `@src/MorphAnimationManager.cpp`:
- Around line 25-41: Ensure MorphAnimationManager singleton lifecycle methods
enforce main-thread access: in MorphAnimationManager::instance(),
MorphAnimationManager::qmlInstance(), and MorphAnimationManager::kill() add a
check/assert that the current thread is the application's main thread (e.g.
compare QThread::currentThread() to qApp->thread() or
QCoreApplication::instance()->thread()) before touching s_instance; if the check
fails assert or log and return/early-exit as appropriate. Reference the methods
instance(), qmlInstance(), kill(), the static s_instance and the class
MorphAnimationManager when making the change.

---

Nitpick comments:
In `@src/MorphAnimationManager_test.cpp`:
- Around line 192-203: The test currently only checks that setting a weight
emits at least one signal; tighten it by asserting idempotent writes do not
emit: after calling MorphAnimationManager::instance()->setWeight(entity,
QStringLiteral("JawOpen"), 0.5f) and verifying spy.count() >= 1, store the
current spy.count(), call setWeight again with the identical value (0.5f) and
then assert spy.count() remains equal to the stored value (no additional signal
emitted). Use the existing QSignalSpy (spy) and the same entity/"JawOpen"
identifier to implement this idempotent-write check.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2036f987-8afb-49a6-a7c2-60ce27489d23

📥 Commits

Reviewing files that changed from the base of the PR and between 7d34fa7 and 2b38a27.

📒 Files selected for processing (11)
  • src/Assimp/MeshProcessor.cpp
  • src/Assimp/MeshProcessor.h
  • src/CLIPipeline.cpp
  • src/CLIPipeline.h
  • src/CMakeLists.txt
  • src/MorphAnimationManager.cpp
  • src/MorphAnimationManager.h
  • src/MorphAnimationManager_test.cpp
  • src/main.cpp
  • src/mainwindow.cpp
  • tests/CMakeLists.txt

Comment thread src/Assimp/MeshProcessor.cpp Outdated
Comment thread src/CLIPipeline.cpp
Comment thread src/MorphAnimationManager_test.cpp
Comment thread src/MorphAnimationManager.cpp
Five findings from CodeRabbit on PR #569 + one CI failure:

**CI** — `WeightDefaultIsZeroBeforeSet` failed: Ogre's
`AnimationState` defaults to weight=1.0 (disabled), but the
user-facing mental model is "untouched morph = 0". Change
`MorphAnimationManager::weight()` to return 0 when the state is
disabled — disabled ⇔ "not yet driving the pose". Tests + QML
sliders see the expected 0→1 range.

**Minor (MeshProcessor)** — `createPose` was called before
confirming any non-zero delta, leaving empty poses on the mesh
when an FBX exports an identical-to-base target. Lazy-create the
pose on first non-zero delta.

**Minor (CLI help)** — `printUsage()` listed neither the `vat`
(slice 3) nor the new `morph` subcommand. Add both with concise
descriptions.

**Minor (test)** — `EmptyNameRejected` only tested with a null
entity, so a regression where empty-name handling becomes
entity-dependent could slip through. Add a scene-fixture variant
that exercises the rejection with a valid live entity.

**Major (singleton thread safety)** — Per CLAUDE.md "All run on
the main thread", `instance()` / `qmlInstance()` / `kill()`
should assert main-thread access. Adds an `assertMainThread()`
helper using QCoreApplication's thread, called at each lifecycle
entry point.

**Nit (test)** — `SetWeightEmitsSignalOnRealChange` only proved
≥1 emission. Tighten to assert idempotent writes don't re-emit,
and different writes do.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sonarqubecloud
Copy link
Copy Markdown

@fernandotonon fernandotonon merged commit d1e4c09 into master May 17, 2026
20 checks passed
@fernandotonon fernandotonon deleted the feat/morph-slice-a1-import branch May 17, 2026 10:07
fernandotonon added a commit that referenced this pull request May 17, 2026
…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>
fernandotonon added a commit that referenced this pull request May 17, 2026
…#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>
fernandotonon added a commit that referenced this pull request May 17, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant