Skip to content

feat(morph): sub-slice A2 — Inspector Morph Targets subgroup - #570

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

feat(morph): sub-slice A2 — Inspector Morph Targets subgroup#570
fernandotonon merged 3 commits into
masterfrom
feat/morph-slice-a2-inspector

Conversation

@fernandotonon

@fernandotonon fernandotonon commented May 17, 2026

Copy link
Copy Markdown
Owner

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`:

  • 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 A1 set up.
  • 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.

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

  • A3 (authoring): save edit-mode delta as new target, rename, delete, undo.
  • A4 (export): FBX / glTF write morph targets so round-trips preserve them.
  • A5 (dope sheet): morph-weight tracks appear alongside bone tracks.
  • A6 (MCP): list_morph_targets, set_morph_weight, add_morph_target, delete_morph_target.

Manual smoke

  1. Open the app, select an entity with blend shapes (any DCC-authored FBX with a facial rig).
  2. Expand the Animations section — a "Morph Targets (N)" panel appears at the bottom with one slider per shape.
  3. Drag a slider — the mesh deforms in real time. The slider value also drives `AnimationState.weight` so playback timelines respect it.
  4. Click Reset all — every slider snaps back to 0.

Test plan

  • CI: existing A1 tests on MorphAnimationManager still pass (this PR doesn't change the C++).
  • Manual: live preview matches DCC reference for the same shape weights.
  • Manual: filter narrows the slider list, "Reset all" clears every weight.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added Morph Targets / Blend Shapes subsection to the Animations panel, enabling precise control over morph animations
    • Adjust individual morph target weights using interactive sliders with real-time updates
    • "Reset all" button to instantly reset all morph targets to zero weight
    • Optional filter field to quickly search for and locate specific morph targets

Review Change Stack

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>
@coderabbitai

coderabbitai Bot commented May 17, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@fernandotonon has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 42 minutes and 38 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: 26c6f277-9fce-4aca-a3c7-cee48f37ccd5

📥 Commits

Reviewing files that changed from the base of the PR and between bcfa6ef and 8f9f86c.

📒 Files selected for processing (1)
  • qml/PropertiesPanel.qml
📝 Walkthrough

Walkthrough

Adds a new "Morph Targets / Blend Shapes" subsection to the Animations panel in PropertiesPanel.qml. The subsection manages morph target weights reactively via MorphAnimationManager, provides a "Reset all" button, optional filtering, and per-target sliders for interactive weight adjustment.

Changes

Morph Targets / Blend Shapes UI Subsection

Layer / File(s) Summary
State management and signal connections
qml/PropertiesPanel.qml
Establishes state for computed target list, filter text, and weightTick counter; connects to MorphAnimationManager signals to reactively update UI when targets or weights change.
UI header, filter, and target sliders
qml/PropertiesPanel.qml
Renders "Reset all" button, optional filter input, and a repeater showing one row per target with a slider bound to manager weight getter/setter and numeric readout.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

Possibly related PRs

  • fernandotonon/QtMeshEditor#569: The new QML UI drives and reacts to MorphAnimationManager (weightForSelection, setWeightForSelection, morphTargetsChanged, morphWeightChanged), which is implemented by the backend manager introduced in that PR.

Poem

🐰 A rabbit's delight in each morphing sight,
Blend shapes dancing left and right,
Weights slide smooth with tick-tock cheer,
Reset button whispers crystal clear.
UI blooms where targets appear! 🎨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: adding a Morph Targets subgroup to the Inspector UI as part of feature slice A2.
Description check ✅ Passed The description comprehensively covers the feature, technical details, manual testing steps, and deferred work, but lacks explicit sections matching the template structure (Summary and Technical Details headers).
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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-a2-inspector

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.

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

Copy link
Copy Markdown

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: 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".

Comment thread qml/PropertiesPanel.qml
Comment on lines +4335 to +4339
Connections {
target: MorphAnimationManager
function onMorphTargetsChanged() {
morphCol.targets = MorphAnimationManager.morphTargetsForSelection()
}

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 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>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
qml/PropertiesPanel.qml (1)

4331-4331: ⚡ Quick win

Consider 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.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 09b7f9a7-b853-4912-86b9-73b495fda04f

📥 Commits

Reviewing files that changed from the base of the PR and between d1e4c09 and bcfa6ef.

📒 Files selected for processing (1)
  • qml/PropertiesPanel.qml

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>
@sonarqubecloud

Copy link
Copy Markdown

@fernandotonon
fernandotonon merged commit 26c1c5f into master May 17, 2026
20 checks passed
@fernandotonon
fernandotonon deleted the feat/morph-slice-a2-inspector branch May 17, 2026 11:04
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