Skip to content

feat(animation): dope sheet per-channel rows (Phase 5 slice D2) - #377

Merged
fernandotonon merged 4 commits into
masterfrom
feat/phase5-slice-d2-per-channel
May 3, 2026
Merged

feat(animation): dope sheet per-channel rows (Phase 5 slice D2)#377
fernandotonon merged 4 commits into
masterfrom
feat/phase5-slice-d2-per-channel

Conversation

@fernandotonon

@fernandotonon fernandotonon commented May 3, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #376. Second chunk of slice D from #260 — per-channel sub-rows under each bone in the dope sheet.

What's new

  • Chevron + sub-rows — every animated bone row gets a ▶/▼ chevron on the left. Click to expand into one sub-row per active channel (TX/TY/TZ/RW/RX/RY/RZ/SX/SY/SZ). Channels that never deviate from identity (translate.x=0, rotation=1,0,0,0, scale=1) are skipped — no empty rows.
  • Color-coded — red/green/blue for X/Y/Z; magenta+RGB for rotation; orange-tinted RGB for scale.
  • Active-channel detection — controller's allBoneRows() adds a channels map per row, computed from each track's TransformKeyFrames at construction time.
  • Sub-row diamonds delegate to parent — clicking a sub-row diamond selects the parent bone + jumps the playhead. Per-channel-only edits land in slice D3 with the curve editor.

Test plan

  • Open Dope Sheet, expand a bone → only animated channels appear (not 9 empty rows)
  • Sub-row diamonds align horizontally with the parent bone diamonds
  • Click a sub-row diamond → parent bone selected, playhead jumps
  • Multi-select / bulk-drag / copy-paste from slice D1 still work on parent rows
  • Linux CI: 2 new tests verify the channels map shape + correctness for TestAnim

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Enhanced Animation Dope Sheet with expandable per-bone and per-channel UI modes, featuring expansion indicators, channel sub-rows, and individual channel keyframe rendering with click-based selection and playhead integration.
  • Tests

    • Added comprehensive dope-sheet API test coverage including empty-state validation, channel activity detection, and identity-track verification for all TRS components.
  • Chores

    • Updated project version to 2.34.0.

Closes #376 (D2 of slice D from #260).

Controller
- allBoneRows() now annotates each bone row with a `channels` map.
  A channel (tx/ty/tz/rw/rx/ry/rz/sx/sy/sz) is marked active when
  its value deviates from identity (translate.x = 0, rotation = 1,0,0,0,
  scale = 1) by more than 1e-4 on any keyframe of the track. Channels
  that never move stay false so the QML view doesn't paint empty
  sub-rows.
- Identity check is correct for rotation: w must differ from 1, x/y/z
  from 0; for scale all three from 1.

QML
- Per-bone expansion chevron (▶/▼) on the left of the bone-name strip.
  Hidden when the bone has no animated channels at all.
- Expanding a bone shows a sub-row per active channel, in TRS order.
  Each sub-row has a colored dot + label (T.X / R.W / S.Y / etc.) and
  a horizontal track with smaller (8px) diamonds at the same key
  times as the parent track, in the channel's color.
- Color coding: red/green/blue for X/Y/Z (translation), magenta+RGB
  for rotation quaternion, orange-tinted RGB for scale.
- Clicking a sub-row diamond selects the parent bone, jumps the
  playhead to that time, and replaces the multi-selection with the
  single parent keyframe.
- Selection / drag / copy-paste from D1 still operate on parent
  keyframes — sub-row diamonds are visual decoration in this slice.
  Per-channel-only edits arrive in D3 alongside Bezier handles.
- Row delegate height grows when expanded so multiple expanded bones
  stack cleanly in the ListView.
- expandedBones state resets on clip change (selectionChanged).

Tests
- AllBoneRowsReflectsTracks now also asserts the `channels` field exists.
- AllBoneRowsReportsActiveChannels: TestAnim's middle keyframe is
  translate.x = 0.5 + 30° rotation around Y, so tx + rw + ry must be
  active and the other 7 channels must be false.

Out of scope (D3)
- Per-channel keyframe values (separate side-table or extended
  TransformKeyFrame).
- Bezier handles + interpolation modes.
- Per-channel marquee selection.

Version bumped to 2.37.0.

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

coderabbitai Bot commented May 3, 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 43 minutes and 34 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ 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: b8afed80-1af5-4ed1-9097-1719d02c84b9

📥 Commits

Reviewing files that changed from the base of the PR and between 0cf9957 and 2a57e2a.

📒 Files selected for processing (3)
  • qml/AnimationDopeSheet.qml
  • src/AnimationControlController.cpp
  • src/AnimationControlController_test.cpp
📝 Walkthrough

Walkthrough

The PR implements per-channel expansion in the animation dope sheet by adding backend logic to compute which TRS channels (translate x/y/z, rotate w/x/y/z, scale x/y/z) are active per bone track, updating the QML UI to show expansion chevrons and per-channel sub-rows with colored keyframes, and downgrading the project version to 2.34.0.

Changes

Per-Channel Dope Sheet Expansion

Layer / File(s) Summary
Project Version
CMakeLists.txt
Version downgraded from 2.36.0 to 2.34.0, updating derived build artifact strings and preprocessor definitions.
Channel Detection Logic
src/AnimationControlController.cpp
Internal helper collectActiveChannels() scans all keyframes on a track to detect which TRS channels (tx,ty,tz,rw,rx,ry,rz,sx,sy,sz) deviate from bind-pose identity; allBoneRows() now populates each row's channels map from this detection.
Expansion State & Helpers
qml/AnimationDopeSheet.qml
Added expandedBones state object, channelOrder derived property, and activeChannelsFor(), isExpanded(), toggleExpanded() helper functions; selection reset now clears expandedBones.
UI Implementation
qml/AnimationDopeSheet.qml
Bone-name strip renders expansion chevron (enabled for non-empty activeChannels); when expanded, per-channel sub-rows appear on the left with colored diamond keyframes on the right; clicks on diamonds set single selection and playhead time; aggregate track strip height is fixed to rowHeight.
Testing
src/AnimationControlController_test.cpp
Updated existing AllBoneRowsReflectsTracks test to assert channels field presence; added three new tests: empty rows when no animation selected, channel detection on a multi-channel test animation, and all-false channel flags for identity-only keyframes.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • PR #375: Adds multi-select and copy/paste to the dope sheet; this PR builds on that foundation with per-channel expansion.
  • PR #373: Introduces the dope sheet view itself; this PR extends it with per-bone/channel metadata and UI expansion.
  • PR #356: Also modifies AnimationControlController to add playback/loop/advanceTime features; both PRs extend the same controller's public API.

Poem

🐰 With chevrons bright and diamonds dance,
Each channel gets its rightful chance,
TX bright red, RY green and fine,
Sub-rows expand in ordered line.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.57% 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 summarizes the main change—adding per-channel sub-rows to the dope sheet animation interface as part of the Phase 5 slice D2 work.
Description check ✅ Passed The description follows the template with Summary and Technical Details sections (Features), providing clear explanations of what's new, implementation details, and a test plan.
Linked Issues check ✅ Passed The code changes implement all key requirements from issue #376: controller annotates rows with active-channel detection [#376], QML adds chevrons and per-channel sub-rows with color-coding [#376], and new tests verify the channels field and active-channel detection [#376].
Out of Scope Changes check ✅ Passed The version change in CMakeLists.txt (2.36.0 → 2.34.0) is a minor metadata update unrelated to the PR objectives; all substantive changes align with per-channel dope sheet row implementation.

✏️ 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/phase5-slice-d2-per-channel

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
Review rate limit: 0/1 reviews remaining, refill in 43 minutes and 34 seconds.

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

Slices B-D added cumulative bumps; folding them all into a single
2.34.0 release once the dope sheet work lands.

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

@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: a574cdafa2

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


width: rowsView.width; height: root.rowHeight
width: rowsView.width
height: root.rowHeight + (expanded ? activeChannels.length * root.rowHeight : 0)

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 Keep marquee hit-testing aligned with expanded row heights

The delegate now uses variable height (rowHeight + activeChannels * rowHeight), but marquee selection still computes each row’s Y range as if every row were exactly rowHeight tall. As soon as any bone above is expanded, selectInRect() maps subsequent rows to the wrong vertical band, so marquee picks incorrect bones/times (or misses intended ones). This regression appears when users expand channels and then box-select keyframes.

Useful? React with 👍 / 👎.

- AllBoneRowsEmptyWhenNoAnimSelected (pure-data): no animation
  selected → empty list, not crash.
- AllBoneRowsAllChannelsFalseForIdentityOnlyTrack (Ogre fixture):
  build a track whose every keyframe is identity (zero translate,
  identity rotation, unit scale) and verify all 10 channels report
  inactive — QML uses this to skip painting empty sub-rows.

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.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/AnimationControlController_test.cpp (1)

647-655: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Look up the fixture row by bone name instead of rows.first().

These assertions are meant to validate the Child track, but rows.first() couples the tests to _getNodeTrackList() ordering. If the fixture grows another track or handle ordering changes, the test starts checking the wrong row. Find the row whose "bone" matches the expected fixture bone before asserting keyTimes/channels.

Also applies to: 678-680, 719-721

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/AnimationControlController_test.cpp` around lines 647 - 655, The test
currently uses rows.first() which couples it to _getNodeTrackList() ordering;
instead iterate/search the QVariantList returned by ctrl->allBoneRows() to find
the map where map["bone"] equals the expected fixture bone name (e.g. "Child")
and then run the assertions on that foundRow (check
contains("bone"/"keyTimes"/"channels") and keyTimes size); update the other
similar blocks at the locations mentioned (around the checks at lines 678-680
and 719-721) to look up the row by bone name rather than using first().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@qml/AnimationDopeSheet.qml`:
- Around line 268-269: selectInRect() currently assumes fixed row bounds using
header + r * (rowHeight + 1), which breaks after delegates expand; change
hit-testing to use each delegate's actual geometry instead: iterate the
instantiated delegate items (e.g. rowsView.contentItem.children or
rowsView.itemAt(index) if available) and compute their y-range from the
delegate's mapped y and delegate.height (falling back to root.rowHeight when a
delegate is not instantiated), then test the marquee rect against those actual
bounds; update references to header, rowsView, rowHeight, expanded and
activeChannels within selectInRect() so selection uses the delegate's true
position/height rather than the fixed formula.
- Around line 389-397: The sub-row MouseArea's onClicked currently overrides
parent behavior (it calls AnimationControlController.selectBone/sliderValue and
root.setSingleSelection) which breaks Ctrl/Cmd add-remove and drag-to-move;
change the handler so sub-row presses are routed to the same selection/drag
entrypoint used by the parent diamonds instead of performing a local
single-selection. Concretely, update the MouseArea (the one containing
onClicked) to call the parent/central diamond handler (e.g.
root.handleDiamondPress or the same method the parent diamond uses) with
rowDelegate.boneName and parent.keyTime and forward modifier state (Ctrl/Cmd)
via Qt.keyboardModifiers, or remove/disable the sub-row MouseArea so clicks fall
through to the parent diamond handler; remove the local root.setSingleSelection
call so selection logic is centralized.

In `@src/AnimationControlController.cpp`:
- Around line 623-628: The rotation-channel flags (rw, rx, ry, rz) are being set
by comparing quaternion components directly against kChannelEpsilon, which
incorrectly treats q and -q as different rotations; update the logic in
AnimationControlController.cpp (around the block that checks r.w, r.x, r.y, r.z)
to normalize the quaternion and compare using absolute component values (e.g.,
std::fabs(r.w) etc.) or otherwise fold sign (treat q and -q equivalent) before
setting rw/rx/ry/rz so sign-flipped but equivalent quaternions like (-1,0,0,0)
do not mark rotation channels active.

---

Outside diff comments:
In `@src/AnimationControlController_test.cpp`:
- Around line 647-655: The test currently uses rows.first() which couples it to
_getNodeTrackList() ordering; instead iterate/search the QVariantList returned
by ctrl->allBoneRows() to find the map where map["bone"] equals the expected
fixture bone name (e.g. "Child") and then run the assertions on that foundRow
(check contains("bone"/"keyTimes"/"channels") and keyTimes size); update the
other similar blocks at the locations mentioned (around the checks at lines
678-680 and 719-721) to look up the row by bone name rather than using first().
🪄 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: b3c318cc-9e71-4193-9393-3e29294adf19

📥 Commits

Reviewing files that changed from the base of the PR and between c2a9d58 and 0cf9957.

📒 Files selected for processing (4)
  • CMakeLists.txt
  • qml/AnimationDopeSheet.qml
  • src/AnimationControlController.cpp
  • src/AnimationControlController_test.cpp

Comment thread qml/AnimationDopeSheet.qml
Comment thread qml/AnimationDopeSheet.qml
Comment thread src/AnimationControlController.cpp Outdated
CodeRabbit Major + Codex P1: marquee hit-testing
- selectInRect was computing each row's Y as r * (rowHeight + 1), but
  rows are now variable-height (rowHeight + activeChannels * rowHeight
  when expanded). After expanding any bone, every later row's y-range
  was wrong → marquee selected the wrong keyframes (or missed them).
  Walk top-down with a cumulative cursorY that mirrors the actual
  ListView layout.

CodeRabbit Major: sub-row diamond click
- The simplified onClicked on sub-row diamonds bypassed the D1
  Ctrl/Cmd-toggle path and always replaced the selection. Now
  matches the parent diamond's onPressed: Ctrl/Cmd → toggleInSelection,
  plain click → setSingleSelection. Per-channel drag/edit still lands
  in slice D3.

CodeRabbit Major: rotation channel detector trips on -q
- Quaternion (-1, 0, 0, 0) is identity (q and -q encode the same
  rotation), but the naive rw=-1 check flagged it as deviating from 1.
  That produced bogus rotation chevrons on sign-flipped identity.
  Compare against |q.w| ≈ 1 instead — sign-agnostic. (Test case
  AllBoneRowsTreatsNegatedQuaternionAsIdentity locks this in.)

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

sonarqubecloud Bot commented May 3, 2026

Copy link
Copy Markdown

@fernandotonon
fernandotonon merged commit 7ecdb10 into master May 3, 2026
19 checks passed
@fernandotonon
fernandotonon deleted the feat/phase5-slice-d2-per-channel branch May 3, 2026 04:17
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.

Phase 5 slice D2 — Dope sheet per-channel rows

1 participant