Skip to content

feat(t2m): curate library + Animation picker (#838) - #925

Merged
fernandotonon merged 13 commits into
masterfrom
feat/t2m-curate-library
Jul 23, 2026
Merged

feat(t2m): curate library + Animation picker (#838)#925
fernandotonon merged 13 commits into
masterfrom
feat/t2m-curate-library

Conversation

@fernandotonon

@fernandotonon fernandotonon commented Jul 23, 2026

Copy link
Copy Markdown
Owner

Summary

Stacked on #919 (locomotion cull gate). Two things, both driven by hands-on review of every library variant:

1. Library curation (build-motion-library-v5.py)

Dropped clips by license, bad retarget, and user review:

  • Mixamo-derived clips (MIXAMO_MARKERS) — a Sketchfab CC-BY upload doesn't clear Adobe's ToS on the underlying animation; not redistributable.
  • Review drop-list (REVIEW_DROP, matched by asset+anim so it survives rebuilds):
    • GIGI & KAI Fox rigs — tip/hunch/invert on every action.
    • Shar Pei dog — wrong body plan.
    • 4 Quaternius packs (Animated Men/Women Feb-2019, Alien Apr-2019, Knight Jul-2018) — mis-map the right upper-arm bone: it stays raised/out (mean up-Y +0.3…+0.7) the whole clip vs −0.7…−0.9 for a correct hanging arm. Matched by full pack name so the good "Man/Woman Animated" Oct/Dec-2017 packs are preserved.
  • fix_first_frame_flip — Mini Chibi Kid (and similar) export frame 0 with a rotated hip while the rest is upright (loop-seam artifact); detect + replace frame 0 with frame 1. Verified walk/run/jump open upright; the genuinely-horizontal death clip is left untouched.

Library 109 → 74 clips / 18 actions, zero Mixamo/Fox/dog. (swim/roll dropped with the arm-broken packs — re-scrape clean ones later.)

2. Animation picker

Replace the quality-weighted random pick with explicit browse-and-select:

  • AnimationPickerDialog.qml — searchable list of every clip with a human-readable label ("Walk (Man)", "Run (Zombie)", "Punch (Alien)") and a per-row Apply that retargets that exact clip onto the selected rig.
  • AnimationControlController::listMotionClips() → the list model; generateMotion(variantIndex) forces a specific clip (no model, no matchAmong).
  • Panel: "Browse animation library…" is now the primary button; the free-text field is relabelled as the experimental AI-model path only.
  • CLI: qtmesh anim <file> --generate <action> --variant N (the curation harness that produced the review sheets).

Verified live: picker lists 74 named clips and Apply retargets correctly.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added an animation library picker dialog with search, scrollable results, progress/outcome status, and one-click “Apply” to generate an animation.
    • Added a lower-body “descent” option that improves crouch/pickup/sit/crawl-type motions, with deterministic selection support via a library variant index.
    • Updated generation and export to carry per-clip vertical offset data (when available).
  • Bug Fixes
    • Improved motion-library curation and corrected first-frame rotation issues.
    • Enhanced retargeting accuracy to preserve natural upright posture and vertical movement during application.

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds a searchable QML animation-library picker and extends motion generation with explicit clip selection and optional vertical descent. Motion extraction, library parsing, CLI/MCP flows, retargeting, and tests now carry per-frame root-Y descent data.

Changes

Animation library and vertical descent

Layer / File(s) Summary
Clip metadata and extraction
scripts/build-motion-library-v5.py, src/AnimationMerger.*, src/MotionLibrary.*, src/*test.cpp
Clip filtering and first-frame correction are added; canonical extraction and JSON parsing now produce validated per-frame rootY data and classify vertical-descent actions.
Root descent retargeting
src/AnimationMerger.*, src/AnimationMerger_test.cpp
Bind-referenced and pure-local retargeting optionally lower root translations from negative rootY values scaled by target leg length; tests cover enabled, disabled, descending, and rising frames.
Generation API and integrations
src/AnimationControlController.*, src/CLIPipeline.*, src/MCPServer.cpp
Generation supports explicit clip indices, root-Y retiming, descent controls, CLI flags, MCP schema input, and canonical dump output.
Animation picker UI
qml/AnimationPickerDialog.qml, qml/PropertiesPanel.qml, src/qml_resources.qrc
A searchable picker lists clips and applies exact selections, while the properties panel exposes the dialog, propagates results, and adds a lower-body toggle.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PropertiesPanel
  participant AnimationPickerDialog
  participant AnimationControlController
  participant MotionLibrary
  participant AnimationMerger
  PropertiesPanel->>AnimationPickerDialog: open()
  AnimationPickerDialog->>AnimationControlController: listMotionClips()
  AnimationControlController->>MotionLibrary: load clips
  MotionLibrary-->>AnimationPickerDialog: clip metadata
  AnimationPickerDialog->>AnimationControlController: generateMotion(variantIndex, verticalDescent)
  AnimationControlController->>MotionLibrary: resolve selected clip and rootY
  AnimationControlController->>AnimationMerger: applyMotionClip(clipRootY, verticalDescent)
  AnimationMerger-->>AnimationControlController: applied animation result
  AnimationControlController-->>AnimationPickerDialog: success or error
  AnimationPickerDialog-->>PropertiesPanel: applied(animation, entity)
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% 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
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.
Title check ✅ Passed The title is concise and accurately summarizes the main changes: motion-library curation plus an animation picker.
Description check ✅ Passed The description is detailed and on-topic, but it omits the template’s explicit Technical Details, Features, and Bugfixes sections.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/t2m-curate-library

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.

@fernandotonon
fernandotonon force-pushed the feat/t2m-curate-library branch from b9dd171 to ec3b495 Compare July 23, 2026 00:35

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

ℹ️ 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 Outdated
Comment on lines +8093 to +8096
lastGeneratedAnim = anim
setArmSpaceTarget(anim, entity || "")
}
refreshAnimData()

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 Move picker callback into animation component scope

When using the new Browse animation library flow and clicking Apply, this applied handler runs in the top-level PropertiesPanel/Loader scope, but lastGeneratedAnim, setArmSpaceTarget, and refreshAnimData are declared inside the animationComponent instance below, not on root. A successful apply therefore hits a QML ReferenceError in this callback instead of wiring the generated clip into the panel/arm-space state; keep the connection in the animation component instance or expose a root-level method that can reach it.

Useful? React with 👍 / 👎.

@fernandotonon

Copy link
Copy Markdown
Owner Author

Fixed — good catch. The picker's applied handler was in the top-level Loader scope where lastGeneratedAnim/setArmSpaceTarget/refreshAnimData (declared in the animationComponent) don't resolve, so Apply threw a ReferenceError. The Loader now re-emits a root-level animationPicked(animation, entity) signal, and a Connections { target: root } inside the animation component does the wiring in-scope. Verified the app loads with no QML errors.

@fernandotonon
fernandotonon force-pushed the feat/t2m-curate-library branch from ec3b495 to 9b2d20d Compare July 23, 2026 01:09
Base automatically changed from feat/t2m-locomotion-cull-gate to master July 23, 2026 06:43
fernandotonon and others added 11 commits July 23, 2026 02:44
…x first-frame flip (#838)

User review of every template variant (rendered via the --variant curation
harness) identified clips to remove and a frame glitch to repair:

- License: drop ALL Mixamo-derived clips (MIXAMO_MARKERS). A Sketchfab
  uploader's CC-BY covers the upload, not Adobe's underlying animation — not
  redistributable in a standalone library. (Removes climb, which was
  Mixamo-only.)
- Review drop-list (REVIEW_DROP, matched by asset+anim so it survives
  rebuilds): GIGI & KAI Fox rigs (tip/hunch/invert on every reviewed action),
  Shar Pei dog (wrong body plan), Square Head "Loose" shake, Samurai dance.
- fix_first_frame_flip: Mini Chibi Kid (and similar) export frame 0 with a
  rotated hip while the rest is upright — a loop-seam artifact. Detect (hip
  up-Y flipped past horizontal on frame 0 but upright on 1&2) and replace
  frame 0 with frame 1. Verified: walk/run/jump now open upright; the
  genuinely-horizontal death clip is correctly left untouched.

Library 109 → 96 clips / 20 actions, zero residual Mixamo/Fox/dog sources.
Core actions still well-stocked (walk/run/jump 12, punch/death 11, idle 8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the random quality-weighted pick with an explicit, Mixamo-style
"browse and select" flow — the reliable path — while keeping the free-text
prompt for the experimental AI model only.

- AnimationPickerDialog.qml: a searchable list of every library clip with a
  human-readable label ("Walk (Zombie)" vs "Walk (Monkey D. Luffy)") and a
  per-row Apply that retargets that EXACT clip onto the selected rig.
- AnimationControlController::listMotionClips() → the list model
  {index, action, name, source, quality, frames}; name derives the most
  descriptive source segment (Quaternius pack name, else the asset).
- generateMotion() gains variantIndex: ≥0 forces the template path + that
  clip (no model, no matchAmong). Wired into the picker's Apply.
- PropertiesPanel Animations section: "Browse animation library…" is now the
  primary button; the text field is relabelled as the experimental AI path.
- CLI: `qtmesh anim <file> --generate <action> --variant N` (the curation
  harness that rendered the review sheets) — cmdAnimGenerate variantIndex.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User + measurement (mean upper-arm world up-Y) found four Quaternius packs
whose RIGHT upper-arm bone mis-maps onto the canonical skeleton — the arm
stays raised/out (mean up-Y positive ~+0.3..+0.7) the whole clip on every
action, while a correct hanging arm reads ~−0.7..−0.9. Add them to
REVIEW_DROP: "Animated Men Characters" + "Animated Women Characters" (Feb
2019), "Alien Animated" (Apr 2019), "Knight Character Animated" (Jul 2018).

NB: matched by FULL pack name — the good "Man/Woman Animated" (Oct/Dec 2017)
packs are DIFFERENT releases and are preserved (verified: 6 + 8 clips kept).
Side effect: swim (Alien-only) and roll (Knight-only) drop out — their only
sources were these packs; re-scrape clean versions later.

Library 90 → 74 clips / 18 actions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…prompt (#838)

Two bugs in the picker's first cut:
- AnimationPickerDialog imported only PropertiesPanel, so
  AnimationControlController (registered under the AnimationControl module)
  resolved to undefined and listMotionClips() returned nothing ("0
  animations"). Add `import AnimationControl 1.0`.
- generateMotion's empty-prompt guard fired before the variant-index check,
  so the picker's Apply (which passes "" prompt + an index) failed with
  "Enter a motion prompt". Skip the guard when variantIndex >= 0.

Both verified live: picker lists 74 named clips and Apply retargets the
selected clip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#838)

Review (chatgpt-codex P2): the animation picker's `applied` handler ran in
the top-level Loader scope, but lastGeneratedAnim / setArmSpaceTarget /
refreshAnimData are declared inside the animationComponent instance — so a
successful Apply threw a QML ReferenceError instead of wiring the clip into
the panel/arm-space state. Fix: the Loader now just re-emits a root-level
`animationPicked(animation, entity)` signal; a Connections{ target: root }
INSIDE the animation component handles it, where those members resolve.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Non-locomotion template clips (working/crawl/death/…) rendered "floating"
— the retarget locked the hip at the standing pose and discarded the
source's hip translation, so a crouch played in place at full height.

Extract a per-frame normalized hip-Y track (`rootY`, in source leg-lengths,
max hip→foot as the reference length) during canonical extraction, carry it
through --dump-canonical → the v5 library builder (windowed, re-based to
frame 0, descent clamped to one leg length), and apply it to the root bone's
keyframe Y, scaled by the TARGET rig's leg length.

The descent is applied in BOTH retarget branches: the bind-referenced path
(the one template clips with restDir actually take — root keyframe is a
parent-space delta, so the drop is Ct⁻¹·(0,−y,0) along canonical-up mapped
to the rig frame) and the legacy standing-pose transport (model / no-restDir
clips). Target leg length comes from the bind-pose derived hip→foot distance.

Descent-only: only the negative (lowering) component is applied, so it can
pull a floating crouch down but never lift a grounded pose — source rigs
vary in whether/which-sign they bake hip translation (many author the squat
purely in knee/hip joint rotations we already retarget). Scoped to
MotionLibrary::isVerticalDescentAction() (pickup/working/sit/crawl/death/
pray); locomotion keeps a flat root. Wired through GUI/CLI/MCP callers +
their retime paths.

Render-verified on Rumba (Mixamo): `working` hip Y drops 0.96→0.12 (0.85
leg-length crouch); `walk` stays flat (0.06 pelvic sway only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Curation: add "Rigged and Animated Humanoid" (OpenGameArt) to the
  REVIEW_DROP list — it retargets badly on BOTH Mixamo and UniRig skeletons
  (user review). Library drops from 63 → 51 clips (18 actions).

- Vertical descent is now user-controllable, since it helps most crouch/
  pickup/sit/crawl/death clips but a few author the squat purely in the
  joints (no hip translation) and over-sink with it on. Exposed as:
  * GUI "Lower body (crouch/pickup)" checkbox in the Generate-from-text
    section (default ON), passed as generateMotion's verticalDescent arg
  * CLI `--no-descent` on `qtmesh anim --generate`
  * MCP `vertical_descent` boolean on generate_motion (default true)
  The flag ANDs with isVerticalDescentAction(), so it only ever gates the
  non-locomotion actions; locomotion stays flat regardless.

Verified: working with descent ON drops hip Y 0.85; --no-descent keeps it
flat (0.06). "Rigged and Animated Humanoid" no longer appears in the library.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Raw hip-Y translation was unreliable per rig — some Quaternius clips read
the hip RISING during pickup/sit (rig-dependent axis), so descent-only
zeroed them ("pickup not going down"), while working over-sank at the −1.0
clamp ("too far down").

Replace it with a sign-safe CROUCH-DEPTH measure: the hip's height ABOVE the
foot along canonical +Y (always ≥ 0). Standing = the max across the clip;
each frame's rootY is its drop below that (≤ 0) in leg-lengths. A genuine
crouch always lowers the hip toward the planted foot regardless of rig axes,
so pickup/sit now correctly descend. Builder re-anchors the window to its
shallowest frame and applies a 0.6 gain (full-kneel hip-to-foot compression
is nearly a whole leg — 0.6 lands a believable depth).

Verified on Rumba: pickup hip-Y-range 0.06→0.38 (descends), death 0.39,
and the descent toggle (checkbox / --no-descent / vertical_descent) flips
pickup between 0.38 and 0.06 as expected.

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

Descent-supposed clips that never STAND (crawl, ground-sit) read ~0 crouch
depth because the reference "standing" height was the clip's own max hip
height — an always-low clip has no tall frame to measure the drop from, so
it floated upright in the center.

Anchor the crouch depth to the rig's BIND-pose (T-pose) hip-above-foot
height instead — an absolute upright reference. Now a crawl that opens and
stays low reads its true full depth (0.07 → 0.50 hip drop on Rumba); sit and
pickup likewise descend. Fall back to the clip max only if the clip ever
stands taller than the bind pose (non-upright bind). Builder no longer
re-anchors rootY to the window (that re-zeroed always-low clips); keeps the
0.6 display gain.

Curation: drop FNaf_DLC_moon_sun (jumpscare rig) and
Low_Poly_Zombie_Game_Animation (weak) per user review. Library now 46 clips.

Verified on Rumba: crawl/working/death hip-Y-range ~0.50, pickup 0.38, sit
0.31 — all descend; render confirms crawl stays low instead of floating.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "Lower body (crouch/pickup)" toggle lived only in the Generate-from-text
section, but applying a clip from the Browse-library PICKER hard-coded
verticalDescent=true — so toggling the panel checkbox had no effect on
picker-applied clips (the likely "checkbox is being ignored" report).

Give the picker its own "Lower body (crouch/pickup)" checkbox (default ON)
and pass it as generateMotion's verticalDescent arg, so descent is
controllable wherever the user applies from.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Samurai-dance clip snapped the model in half — its rig stacks the Spine2
joint BELOW its parent in bind pose, so that bone's canonical bind direction
points DOWN the hip→head axis. The aim-based retarget faithfully reproduced
a chest folded UNDER the hip.

Detect a spine-chain bone (abdomen/chest/neck/neck1, roles 1..4) whose bind
direction has a clearly-downward component along the hip→head axis
(dot < −0.2) and ZERO its restDir. The retarget's existing
`squaredLength() <= 1e-8` guard then skips that bone, so the target holds its
upright bind pose while the rest of the clip plays — the spine stays intact.

Verified on Rumba: the Samurai dance now reads as a coherent crouching dance
with an upright torso instead of a chest-below-hip fold.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@fernandotonon
fernandotonon force-pushed the feat/t2m-curate-library branch from ec7c225 to c0807c5 Compare July 23, 2026 06:44

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

Caution

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

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

4183-4185: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Expose exact clip selection through MCP.

The MCP path still requires prompt and always uses matchPrompt(), so callers cannot select a curated variant deterministically.

  • src/MCPServer.cpp#L4183-L4185: accept variant_index, allow it in place of prompt, force the template path, and bounds-check it before reading the clip.
  • src/MCPServer.cpp#L8859-L8869: publish variant_index in generate_motion and make prompt optional when an index is supplied.
🤖 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/MCPServer.cpp` around lines 4183 - 4185, Update src/MCPServer.cpp lines
4183-4185 in the generate_motion handling to accept variant_index as an
alternative to prompt, force the template-based selection path when it is
provided, and bounds-check the index before accessing the clip; retain prompt
validation when neither input is supplied. Update src/MCPServer.cpp lines
8859-8869 to expose variant_index in the generate_motion MCP schema and make
prompt optional when variant_index is present.
🧹 Nitpick comments (2)
qml/AnimationPickerDialog.qml (1)

210-227: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Busy indicator never actually renders — generateMotion runs synchronously between the set/reset of busyIndex.

dialog.busyIndex = idx and dialog.busyIndex = -1 bracket a single synchronous call with no yield to the event loop in between, so the "…" busy state on the Apply button (bound to busyIndex) never gets a chance to paint before the (potentially slow) retarget completes — the UI just appears to freeze with no feedback.

🛠️ Proposed fix — defer the call so the busy state can paint first
     function applyClip(idx, name) {
         if (dialog.busyIndex >= 0) return
         dialog.busyIndex = idx
         pickStatus.isError = false
         pickStatus.text = "Applying " + name + "…"
-        // variantIndex forces this exact clip (template path, no random pick).
-        // Pass the picker's own descent checkbox (`#838`) as the last arg.
-        var r = AnimationControlController.generateMotion("", 0.0, false, 0.0, true, idx,
-                                                          pickDescentChk.checked)
-        dialog.busyIndex = -1
-        if (r && r.ok) {
-            pickStatus.text = "Applied: " + name
-            dialog.applied(r.animation || "", r.entity || "")
-        } else {
-            pickStatus.isError = true
-            pickStatus.text = (r && r.error) ? r.error : "Failed to apply."
-        }
+        Qt.callLater(function() {
+            // variantIndex forces this exact clip (template path, no random pick).
+            // Pass the picker's own descent checkbox (`#838`) as the last arg.
+            var r = AnimationControlController.generateMotion("", 0.0, false, 0.0, true, idx,
+                                                              pickDescentChk.checked)
+            dialog.busyIndex = -1
+            if (r && r.ok) {
+                pickStatus.text = "Applied: " + name
+                dialog.applied(r.animation || "", r.entity || "")
+            } else {
+                pickStatus.isError = true
+                pickStatus.text = (r && r.error) ? r.error : "Failed to apply."
+            }
+        })
     }
🤖 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/AnimationPickerDialog.qml` around lines 210 - 227, Update applyClip so
the generateMotion call executes asynchronously after busyIndex is set, yielding
to the event loop so the busy state can render before processing begins. Keep
the existing success/error handling and reset busyIndex after generateMotion
completes, using the dialog’s established deferred-callback mechanism if
available.
scripts/build-motion-library-v5.py (1)

694-711: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Stale/contradictory comment on rootY windowing.

The block comment at Lines 694-697 says the sliced rootY is carry the per-frame hip Y offset, sliced to the SAME active window as the quats and re-based so frame 0 of the window reads ~0 (the retarget deltas the descent against its start frame), but the code that follows explicitly does the opposite: rootY is a crouch DEPTH vs the rig's BIND-pose standing height (absolute, ≤ 0 leg-lengths), so an always-low crawl/sit keeps its real depth — do NOT re-anchor to the window. Only the window-slice + 0.6 gain are applied — there is no re-basing to zero. A future maintainer reading only the top comment could "fix" this into re-anchoring the descent depth, silently breaking always-low clips (crawl/sit).

Update the top-level comment to match the actual (correct) behavior described below it.

📝 Proposed comment fix
-                        # `#838` vertical descent: carry the per-frame hip Y
-                        # offset, sliced to the SAME active window as the quats
-                        # and re-based so frame 0 of the window reads ~0 (the
-                        # retarget deltas the descent against its start frame).
+                        # `#838` vertical descent: carry the per-frame hip Y
+                        # offset, sliced to the SAME active window as the
+                        # quats. NOT re-based to the window start — rootY is
+                        # an absolute depth vs bind-pose standing height (see
+                        # below), so re-anchoring would zero out clips that
+                        # open already crouched.
                         ry = c.get("rootY")
🤖 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 `@scripts/build-motion-library-v5.py` around lines 694 - 711, Update the
top-level comment above the rootY handling in the active window processing to
remove the incorrect claim that rootY is re-based to zero. Describe that rootY
is sliced to the same window as the quaternions, preserves absolute crouch depth
relative to the bind-pose standing height, and receives the existing 0.6 display
gain.
🤖 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/AnimationControlController.cpp`:
- Around line 1840-1846: Update AnimationControlController::listMotionClips() to
emit a ui.action breadcrumb immediately before calling
MotionLibrary::ensureLibraryBlocking(). Record the picker-library operation,
then preserve the existing blocking library acquisition and early-return
behavior.

In `@src/AnimationMerger_test.cpp`:
- Around line 1346-1415: Update the skeleton setup in
VerticalDescentLowersRootDescentOnly so the right and left leg chains are
vertically aligned beneath Hips, removing their horizontal ±0.15 offsets while
preserving the existing arm offsets. Keep the foot positions and test
expectations unchanged so the bind-pose Euclidean leg length is exactly 1.0.

In `@src/AnimationMerger.cpp`:
- Around line 2667-2690: Update the vertical-descent measurement around
derivedYForCanon and the related target-position lookup to use target bind-pose
positions from readTargetBindFrame() and tb.bindPos. Ensure the helper resets
and updates the skeleton to bind pose before reading positions, then restores
the previously applied pose. Compute targetLegLen from bind-pose hip and foot
positions so crouched runtime poses cannot affect scaling.

In `@src/CLIPipeline.cpp`:
- Around line 2405-2410: Update the --variant parsing in the argument-processing
block to validate conversion with QString::toInt’s ok flag and reject negative
indices. For malformed or negative values, emit the existing usage/error
response and terminate with exit code 2; only assign generateVariant and
continue when the supplied value is a valid non-negative integer.

In `@src/MotionLibrary.cpp`:
- Around line 345-356: Add the canonical "crouch" action label to the kDescent
set in MotionLibrary::isVerticalDescentAction, preserving the existing
normalization and classification behavior for all other actions.

In `@src/MotionLibrary.h`:
- Around line 47-52: Update the rootY documentation in the optional
vertical-descent field comment to state that values preserve bind-pose-relative
hip depth rather than being re-based to the selected window’s first frame. Keep
the existing normalization, target-leg-length scaling, root-bone Y application,
and empty-value behavior descriptions unchanged.

---

Outside diff comments:
In `@src/MCPServer.cpp`:
- Around line 4183-4185: Update src/MCPServer.cpp lines 4183-4185 in the
generate_motion handling to accept variant_index as an alternative to prompt,
force the template-based selection path when it is provided, and bounds-check
the index before accessing the clip; retain prompt validation when neither input
is supplied. Update src/MCPServer.cpp lines 8859-8869 to expose variant_index in
the generate_motion MCP schema and make prompt optional when variant_index is
present.

---

Nitpick comments:
In `@qml/AnimationPickerDialog.qml`:
- Around line 210-227: Update applyClip so the generateMotion call executes
asynchronously after busyIndex is set, yielding to the event loop so the busy
state can render before processing begins. Keep the existing success/error
handling and reset busyIndex after generateMotion completes, using the dialog’s
established deferred-callback mechanism if available.

In `@scripts/build-motion-library-v5.py`:
- Around line 694-711: Update the top-level comment above the rootY handling in
the active window processing to remove the incorrect claim that rootY is
re-based to zero. Describe that rootY is sliced to the same window as the
quaternions, preserves absolute crouch depth relative to the bind-pose standing
height, and receives the existing 0.6 display gain.
🪄 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 Plus

Run ID: 8c18f21f-d1b9-40fa-aab1-3ff27e7bdd65

📥 Commits

Reviewing files that changed from the base of the PR and between ebd8531 and c0807c5.

📒 Files selected for processing (15)
  • qml/AnimationPickerDialog.qml
  • qml/PropertiesPanel.qml
  • scripts/build-motion-library-v5.py
  • src/AnimationControlController.cpp
  • src/AnimationControlController.h
  • src/AnimationMerger.cpp
  • src/AnimationMerger.h
  • src/AnimationMerger_test.cpp
  • src/CLIPipeline.cpp
  • src/CLIPipeline.h
  • src/MCPServer.cpp
  • src/MotionLibrary.cpp
  • src/MotionLibrary.h
  • src/MotionLibrary_test.cpp
  • src/qml_resources.qrc

Comment thread src/AnimationControlController.cpp
Comment thread src/AnimationMerger_test.cpp
Comment thread src/AnimationMerger.cpp
Comment thread src/CLIPipeline.cpp
Comment thread src/MotionLibrary.cpp
Comment thread src/MotionLibrary.h Outdated
fernandotonon and others added 2 commits July 23, 2026 03:30
- MotionLibrary::isVerticalDescentAction: include "crouch" (was missing, so
  crouch-labelled clips never descended).
- MotionLibrary.h: correct the rootY doc — it preserves BIND-pose-relative
  depth (not re-based to the window's first frame), so always-low clips stay
  down.
- applyMotionClip legacy path: compute targetLegLen from the BIND pose
  (readTargetBindFrame + tb.bindPos) instead of live derived positions, so a
  crouched runtime pose can't skew the descent scale (matches the
  bind-referenced path).
- AnimationMerger_test: make the descent-test legs purely vertical so the
  bind hip→foot distance is exactly 1.0 (a lateral offset made it ~1.011 and
  skewed the expected hip delta).
- CLIPipeline: reject non-numeric / negative --variant with exit code 2
  instead of silently selecting clip 0.
- AnimationControlController::listMotionClips: emit a ui.action breadcrumb
  before the blocking library load (picker instrumentation).
- MCP generate_motion: accept variant_index for deterministic clip selection
  (parity with CLI --variant + the GUI picker); prompt is optional when it's
  given; bounds-checked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
VerticalDescentLowersRootDescentOnly failed on Linux CI with "skeleton
resolved only 9/22 canonical joints — not a humanoid rig": the minimal
9-bone test skeleton didn't clear applyMotionClip's ~half-of-22 role-
resolution gate, so the retarget rejected it before any descent ran.

Build a full Mixamo-named humanoid (spine chain + collar/shoulder/elbow/hand
+ upleg/knee/foot per side, 19 bones) so >11 canonical roles resolve. Legs
stay purely vertical → bind hip→foot distance is exactly 1.0, keeping the
rootY=-0.5 → -0.5 hip-delta assertion exact.

Co-Authored-By: Claude Opus 4.8 (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)
src/MCPServer.cpp (1)

4184-4193: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use the required MCP breadcrumb category.

This MCP invocation is logged as ai.assist.text_to_motion; update it to ai.tool_call and include the selected variant/action without logging the prompt. As per coding guidelines, MCP invocations must use ai.tool_call breadcrumbs.

🤖 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/MCPServer.cpp` around lines 4184 - 4193, Update the breadcrumb emitted by
the MCP invocation around the variant/prompt validation in the relevant
text-to-motion handler: use the required category ai.tool_call instead of
ai.assist.text_to_motion, and record the selected variant/action while excluding
the prompt from logged data.

Source: Coding guidelines

🤖 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 `@src/MCPServer.cpp`:
- Around line 4184-4193: Update the breadcrumb emitted by the MCP invocation
around the variant/prompt validation in the relevant text-to-motion handler: use
the required category ai.tool_call instead of ai.assist.text_to_motion, and
record the selected variant/action while excluding the prompt from logged data.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1ccf577c-4b57-47fe-815d-c08356835053

📥 Commits

Reviewing files that changed from the base of the PR and between c0807c5 and d6811e9.

📒 Files selected for processing (7)
  • src/AnimationControlController.cpp
  • src/AnimationMerger.cpp
  • src/AnimationMerger_test.cpp
  • src/CLIPipeline.cpp
  • src/MCPServer.cpp
  • src/MotionLibrary.cpp
  • src/MotionLibrary.h
🚧 Files skipped from review as they are similar to previous changes (6)
  • src/MotionLibrary.cpp
  • src/MotionLibrary.h
  • src/AnimationMerger_test.cpp
  • src/CLIPipeline.cpp
  • src/AnimationControlController.cpp
  • src/AnimationMerger.cpp

@sonarqubecloud

Copy link
Copy Markdown

@fernandotonon fernandotonon changed the title feat(t2m): curate library + Mixamo-style animation picker (#838) feat(t2m): curate library + Animation picker (#838) Jul 23, 2026
@fernandotonon
fernandotonon merged commit 776d188 into master Jul 23, 2026
38 of 39 checks passed
@fernandotonon
fernandotonon deleted the feat/t2m-curate-library branch July 23, 2026 16:37
fernandotonon added a commit that referenced this pull request Aug 5, 2026
…ipping set (#838) (#941)

* feat(t2m): curate motion library — drop Mixamo + bad-source clips, fix first-frame flip (#838)

User review of every template variant (rendered via the --variant curation
harness) identified clips to remove and a frame glitch to repair:

- License: drop ALL Mixamo-derived clips (MIXAMO_MARKERS). A Sketchfab
  uploader's CC-BY covers the upload, not Adobe's underlying animation — not
  redistributable in a standalone library. (Removes climb, which was
  Mixamo-only.)
- Review drop-list (REVIEW_DROP, matched by asset+anim so it survives
  rebuilds): GIGI & KAI Fox rigs (tip/hunch/invert on every reviewed action),
  Shar Pei dog (wrong body plan), Square Head "Loose" shake, Samurai dance.
- fix_first_frame_flip: Mini Chibi Kid (and similar) export frame 0 with a
  rotated hip while the rest is upright — a loop-seam artifact. Detect (hip
  up-Y flipped past horizontal on frame 0 but upright on 1&2) and replace
  frame 0 with frame 1. Verified: walk/run/jump now open upright; the
  genuinely-horizontal death clip is correctly left untouched.

Library 109 → 96 clips / 20 actions, zero residual Mixamo/Fox/dog sources.
Core actions still well-stocked (walk/run/jump 12, punch/death 11, idle 8).

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

* feat(t2m): animation picker UI + --variant curation harness (#838)

Replace the random quality-weighted pick with an explicit, Mixamo-style
"browse and select" flow — the reliable path — while keeping the free-text
prompt for the experimental AI model only.

- AnimationPickerDialog.qml: a searchable list of every library clip with a
  human-readable label ("Walk (Zombie)" vs "Walk (Monkey D. Luffy)") and a
  per-row Apply that retargets that EXACT clip onto the selected rig.
- AnimationControlController::listMotionClips() → the list model
  {index, action, name, source, quality, frames}; name derives the most
  descriptive source segment (Quaternius pack name, else the asset).
- generateMotion() gains variantIndex: ≥0 forces the template path + that
  clip (no model, no matchAmong). Wired into the picker's Apply.
- PropertiesPanel Animations section: "Browse animation library…" is now the
  primary button; the text field is relabelled as the experimental AI path.
- CLI: `qtmesh anim <file> --generate <action> --variant N` (the curation
  harness that rendered the review sheets) — cmdAnimGenerate variantIndex.

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

* feat(t2m): drop 4 arm-broken Quaternius packs from library (#838)

User + measurement (mean upper-arm world up-Y) found four Quaternius packs
whose RIGHT upper-arm bone mis-maps onto the canonical skeleton — the arm
stays raised/out (mean up-Y positive ~+0.3..+0.7) the whole clip on every
action, while a correct hanging arm reads ~−0.7..−0.9. Add them to
REVIEW_DROP: "Animated Men Characters" + "Animated Women Characters" (Feb
2019), "Alien Animated" (Apr 2019), "Knight Character Animated" (Jul 2018).

NB: matched by FULL pack name — the good "Man/Woman Animated" (Oct/Dec 2017)
packs are DIFFERENT releases and are preserved (verified: 6 + 8 clips kept).
Side effect: swim (Alien-only) and roll (Knight-only) drop out — their only
sources were these packs; re-scrape clean versions later.

Library 90 → 74 clips / 18 actions.

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

* fix(t2m): animation picker — import AnimationControl + apply without prompt (#838)

Two bugs in the picker's first cut:
- AnimationPickerDialog imported only PropertiesPanel, so
  AnimationControlController (registered under the AnimationControl module)
  resolved to undefined and listMotionClips() returned nothing ("0
  animations"). Add `import AnimationControl 1.0`.
- generateMotion's empty-prompt guard fired before the variant-index check,
  so the picker's Apply (which passes "" prompt + an index) failed with
  "Enter a motion prompt". Skip the guard when variantIndex >= 0.

Both verified live: picker lists 74 named clips and Apply retargets the
selected clip.

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

* fix(t2m): picker apply callback in animation-component scope (review) (#838)

Review (chatgpt-codex P2): the animation picker's `applied` handler ran in
the top-level Loader scope, but lastGeneratedAnim / setArmSpaceTarget /
refreshAnimData are declared inside the animationComponent instance — so a
successful Apply threw a QML ReferenceError instead of wiring the clip into
the panel/arm-space state. Fix: the Loader now just re-emits a root-level
`animationPicked(animation, entity)` signal; a Connections{ target: root }
INSIDE the animation component handles it, where those members resolve.

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

* feat(anim): vertical root descent for crouch/pickup motions (#838)

Non-locomotion template clips (working/crawl/death/…) rendered "floating"
— the retarget locked the hip at the standing pose and discarded the
source's hip translation, so a crouch played in place at full height.

Extract a per-frame normalized hip-Y track (`rootY`, in source leg-lengths,
max hip→foot as the reference length) during canonical extraction, carry it
through --dump-canonical → the v5 library builder (windowed, re-based to
frame 0, descent clamped to one leg length), and apply it to the root bone's
keyframe Y, scaled by the TARGET rig's leg length.

The descent is applied in BOTH retarget branches: the bind-referenced path
(the one template clips with restDir actually take — root keyframe is a
parent-space delta, so the drop is Ct⁻¹·(0,−y,0) along canonical-up mapped
to the rig frame) and the legacy standing-pose transport (model / no-restDir
clips). Target leg length comes from the bind-pose derived hip→foot distance.

Descent-only: only the negative (lowering) component is applied, so it can
pull a floating crouch down but never lift a grounded pose — source rigs
vary in whether/which-sign they bake hip translation (many author the squat
purely in knee/hip joint rotations we already retarget). Scoped to
MotionLibrary::isVerticalDescentAction() (pickup/working/sit/crawl/death/
pray); locomotion keeps a flat root. Wired through GUI/CLI/MCP callers +
their retime paths.

Render-verified on Rumba (Mixamo): `working` hip Y drops 0.96→0.12 (0.85
leg-length crouch); `walk` stays flat (0.06 pelvic sway only).

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

* feat(anim): drop Rigged-Animated-Humanoid clips + descent toggle (#838)

- Curation: add "Rigged and Animated Humanoid" (OpenGameArt) to the
  REVIEW_DROP list — it retargets badly on BOTH Mixamo and UniRig skeletons
  (user review). Library drops from 63 → 51 clips (18 actions).

- Vertical descent is now user-controllable, since it helps most crouch/
  pickup/sit/crawl/death clips but a few author the squat purely in the
  joints (no hip translation) and over-sink with it on. Exposed as:
  * GUI "Lower body (crouch/pickup)" checkbox in the Generate-from-text
    section (default ON), passed as generateMotion's verticalDescent arg
  * CLI `--no-descent` on `qtmesh anim --generate`
  * MCP `vertical_descent` boolean on generate_motion (default true)
  The flag ANDs with isVerticalDescentAction(), so it only ever gates the
  non-locomotion actions; locomotion stays flat regardless.

Verified: working with descent ON drops hip Y 0.85; --no-descent keeps it
flat (0.06). "Rigged and Animated Humanoid" no longer appears in the library.

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

* fix(anim): sign-safe crouch-depth descent (#838)

Raw hip-Y translation was unreliable per rig — some Quaternius clips read
the hip RISING during pickup/sit (rig-dependent axis), so descent-only
zeroed them ("pickup not going down"), while working over-sank at the −1.0
clamp ("too far down").

Replace it with a sign-safe CROUCH-DEPTH measure: the hip's height ABOVE the
foot along canonical +Y (always ≥ 0). Standing = the max across the clip;
each frame's rootY is its drop below that (≤ 0) in leg-lengths. A genuine
crouch always lowers the hip toward the planted foot regardless of rig axes,
so pickup/sit now correctly descend. Builder re-anchors the window to its
shallowest frame and applies a 0.6 gain (full-kneel hip-to-foot compression
is nearly a whole leg — 0.6 lands a believable depth).

Verified on Rumba: pickup hip-Y-range 0.06→0.38 (descends), death 0.39,
and the descent toggle (checkbox / --no-descent / vertical_descent) flips
pickup between 0.38 and 0.06 as expected.

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

* fix(anim): absolute (bind-pose) crouch reference + drop FNaF/Zombie (#838)

Descent-supposed clips that never STAND (crawl, ground-sit) read ~0 crouch
depth because the reference "standing" height was the clip's own max hip
height — an always-low clip has no tall frame to measure the drop from, so
it floated upright in the center.

Anchor the crouch depth to the rig's BIND-pose (T-pose) hip-above-foot
height instead — an absolute upright reference. Now a crawl that opens and
stays low reads its true full depth (0.07 → 0.50 hip drop on Rumba); sit and
pickup likewise descend. Fall back to the clip max only if the clip ever
stands taller than the bind pose (non-upright bind). Builder no longer
re-anchors rootY to the window (that re-zeroed always-low clips); keeps the
0.6 display gain.

Curation: drop FNaf_DLC_moon_sun (jumpscare rig) and
Low_Poly_Zombie_Game_Animation (weak) per user review. Library now 46 clips.

Verified on Rumba: crawl/working/death hip-Y-range ~0.50, pickup 0.38, sit
0.31 — all descend; render confirms crawl stays low instead of floating.

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

* fix(anim): add descent checkbox to the animation picker too (#838)

The "Lower body (crouch/pickup)" toggle lived only in the Generate-from-text
section, but applying a clip from the Browse-library PICKER hard-coded
verticalDescent=true — so toggling the panel checkbox had no effect on
picker-applied clips (the likely "checkbox is being ignored" report).

Give the picker its own "Lower body (crouch/pickup)" checkbox (default ON)
and pass it as generateMotion's verticalDescent arg, so descent is
controllable wherever the user applies from.

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

* fix(anim): guard against inverted spine bones breaking the model (#838)

The Samurai-dance clip snapped the model in half — its rig stacks the Spine2
joint BELOW its parent in bind pose, so that bone's canonical bind direction
points DOWN the hip→head axis. The aim-based retarget faithfully reproduced
a chest folded UNDER the hip.

Detect a spine-chain bone (abdomen/chest/neck/neck1, roles 1..4) whose bind
direction has a clearly-downward component along the hip→head axis
(dot < −0.2) and ZERO its restDir. The retarget's existing
`squaredLength() <= 1e-8` guard then skips that bone, so the target holds its
upright bind pose while the rest of the clip plays — the spine stays intact.

Verified on Rumba: the Samurai dance now reads as a coherent crouching dance
with an upright torso instead of a chest-below-hip fold.

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

* fix(anim): address CodeRabbit review on #925 (#838)

- MotionLibrary::isVerticalDescentAction: include "crouch" (was missing, so
  crouch-labelled clips never descended).
- MotionLibrary.h: correct the rootY doc — it preserves BIND-pose-relative
  depth (not re-based to the window's first frame), so always-low clips stay
  down.
- applyMotionClip legacy path: compute targetLegLen from the BIND pose
  (readTargetBindFrame + tb.bindPos) instead of live derived positions, so a
  crouched runtime pose can't skew the descent scale (matches the
  bind-referenced path).
- AnimationMerger_test: make the descent-test legs purely vertical so the
  bind hip→foot distance is exactly 1.0 (a lateral offset made it ~1.011 and
  skewed the expected hip delta).
- CLIPipeline: reject non-numeric / negative --variant with exit code 2
  instead of silently selecting clip 0.
- AnimationControlController::listMotionClips: emit a ui.action breadcrumb
  before the blocking library load (picker instrumentation).
- MCP generate_motion: accept variant_index for deterministic clip selection
  (parity with CLI --variant + the GUI picker); prompt is optional when it's
  given; bounds-checked.

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

* test(anim): full humanoid skeleton for the descent test (#838)

VerticalDescentLowersRootDescentOnly failed on Linux CI with "skeleton
resolved only 9/22 canonical joints — not a humanoid rig": the minimal
9-bone test skeleton didn't clear applyMotionClip's ~half-of-22 role-
resolution gate, so the retarget rejected it before any descent ran.

Build a full Mixamo-named humanoid (spine chain + collar/shoulder/elbow/hand
+ upleg/knee/foot per side, 19 bones) so >11 canonical roles resolve. Legs
stay purely vertical → bind hip→foot distance is exactly 1.0, keeping the
rootY=-0.5 → -0.5 hip-delta assertion exact.

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

* feat(anim): map 3ds Max Biped bone names to canonical skeleton (#838)

The canonical bone matcher recognized Mixamo/CMU/generic naming but not the
3ds Max Biped convention ("Bip001 L UpperArm", "Bip001 R Thigh", …), where the
side is a standalone space-delimited L/R token and an armature "Bip001" prefix
leads every bone. After separator-stripping, "Bip001 L UpperArm" collapsed to
"bip001lupperarm" — sideOf() couldn't find the side, so every limb bone failed
to resolve and a full Biped humanoid mapped only 5/22 roles (spine + head only,
which need no side), below the retarget threshold.

bipedSideToken() reads the L/R token from the raw space-split name before
normalisation and folds it into a "left"/"right" word; normaliseBoneName()
also drops the "bipNNN" prefix. A 3ds Max Biped rig now resolves 19/22 roles.

Verified on real assets: Gregorio (16 work/gather clips — cut/build/farm/
fruit/dance/…) and a cyclops both jumped 5/22 → 19/22 and retarget cleanly
onto the Mixamo test rig; Gregorio's Cut renders as a coherent chopping swing.

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

* feat(t2m): drop animated uprightness gates; keep bind-frame biped check (#838)

Humanoid rigs legitimately LEAN, crouch, recline, throw the head back, or go
to the ground — the animated uprightness gates (spine-up, mid-clip topple,
thigh-down, neck/head-up) wrongly rejected those clips and skewed the quality
score toward artificially-upright takes. Remove them so non-upright HUMAN
motion is curated normally (e.g. the cyclops hunched attacks now pass).

KEEP the bind-frame non-biped gate: it checks the REST skeleton's torso is
vertical, filtering true non-humanoid body plans (quadruped / dino / spider)
that cannot retarget onto the 22-joint humanoid canonical and would render as
horizontal garbage. That's a body-plan guardrail, not a motion gate — proper
multi-body-plan support is tracked separately (task #24).

Verified: library 61→66 clips (cyclops attack1/attack2 recovered); Gregorio's
give-item clip retargets upright+coherent onto a standard humanoid (the tilt
seen earlier was the cyclops mesh's own hunched bind posture, not a pipeline
bug). Reinos Supremos sources renamed to the character names (Gregório /
Shitclops) via the corpus manifest.

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

* feat(anim): retarget-time foot grounding for crouch/work clips (#838)

Gregório's build/farm/cut/gather clips floated: the source keeps the feet
PLANTED (root at bind height), but the folded-leg crouch retargeted onto a
differently-proportioned rig with the hip locked at standing height left the
feet dangling. Source-derived rootY reads ~0 (feet don't move in the source),
so it couldn't ground them.

Add AnimationMerger::groundRootToFeet: a retarget-time post-pass that FKs the
TARGET skeleton per frame (like pinFeet), finds the lowest foot's world-Y, and
lowers the root by how far it floats above the bind ground plane so the foot
re-plants. Target-rig-based, so it grounds ANY crouch/kneel/work pose
regardless of source proportions; descent-only (never raises). Wired after
smooth-bake / before foot-pin in GUI/CLI/MCP, gated on
isVerticalDescentAction.

Also: add the Gregório ground-work labels (build/cut/farm/fruit/*give/gather
+ *loop variants) to the descent classifier; skip meaningless "tempmotion"/
"temp"/"untitled" source labels in the builder.

Verified: buildloop hip Y 0.88 → 0.44 and renders as a grounded kneeling
worker instead of floating.

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

* fix(anim): transport full hip orientation for crouch/work clips (#838)

Ground-work crouches (Gregório build/farm/cut) retargeted torso-VERTICAL and
"reaching into the air" — the pose should pitch the whole body FORWARD over
the hands. Cause: the bind-referenced retarget aims each bone by DIRECTION
(hip→abdomen), which for the hip captures the spine lean but drops the pelvis
PITCH. Since the hip is the FK-chain root, that lost DOF is exactly the
forward body tilt — so the crouch stayed upright ("won't rotate").

For descent-classified clips (doVerticalDescentBR), transport the hip's FULL
3-DOF source rotation (clip vs its own restWorld reference, canonical frame)
onto the target bind hip instead of the direction-aim. Scoped to descent
only: the full delta also carries whole-body FACING (yaw), which must stay
locked for locomotion so a walk doesn't spin — walk/run keep the aim-only
root path.

Verified on Rumba: buildloop now crouches with the torso pitched forward and
both hands reaching to the ground (feet planted via the grounding pass); walk
stays upright/striding, pickup unaffected.

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

* fix(anim): level canonical frame from bind for lean-reference clips (#838)

Gregório's kneel/crouch work clips (BuildLoop/FarmLoop/…) retargeted RECLINED
because the canonical leveling frame C was derived from the calm frame f*,
which for these clips is itself a deep lean (measured torso-up ~60° off
vertical). C baked that tilt in, so the extracted spine restDirs pointed
down/back (abdomen [0,0.66,-0.75]) and the whole retarget reclined.

When f*'s torso-up diverges >~20° from the BIND torso-up, derive C AND sample
the reference triple (restWorld/restDir) from the upright BIND pose instead —
consistent with the per-frame quats (C·raw·Cinv either way). Spine restDirs
now read clean +Y ([0,0.998,0.06]); verified in the viewport the kneel matches
the source (upright torso, knee down, hand to ground). Walk/pickup unaffected
(their f* is already upright, so the bind path isn't taken).

Also flush _keyFrameDataChanged() after every keyframe-writing pass
(applyMotionClip both branches, bakeAnimationAtFps) so a re-generate onto the
LIVE skeleton doesn't replay a stale interpolation cache — this is why the
live viewport lagged the exported result during debugging.

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

* feat(anim): finger animation transfer via direction-aiming (#838)

Transfer source finger animation onto a differently-structured target rig
(Biped Finger0-4 × 2seg → Mixamo Thumb/Index/… × 3seg). Copying finger
rotations mangled the hand — inspecting the real Gregório .skeleton (via
OgreXMLConverter) showed the Biped finger bones carry large, wildly-varying
per-segment bind rotations (300-350° about arbitrary axes), so a rotation
copy lands on the wrong axis. Instead use the body-retarget's DIRECTION
approach: store each finger segment's canonical-frame pointing direction
(segment→child) per frame, and aim the target finger bone's bind direction at
it — rig-axis-independent.

Pipeline: MotionInbetween::fingerRoleForBone classifies Biped + Mixamo finger
names into (side,finger,segment); extraction samples 30 finger slots;
--dump-canonical + the v5 builder carry a `fingers` block; MotionLibrary parses
it; AnimationMerger::applyFingerCurl aims + redistributes source segments
across the target's (2→3). Wired through GUI + CLI (MCP follow-up).

Verified on Gregório→Rumba: the four fingers curl into a correct fist. Known
follow-ups: the THUMB roll (its bend axis is ~perpendicular; direction-only
aim drops its roll) and a slight body-retarget hand/wrist-aim offset.

Also lands the finger + earlier work: _keyFrameDataChanged flushes so live
re-generate isn't stale, and entity->_initialise(true) so the live instance
picks up post-retarget track additions.

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

* feat(anim): V2 canonical skeleton — 52 joints with fingers as joints (#838)

Adds a parallel V2 canonical skeleton (canonicalJointCountV2()=52: the 22
V1 body joints byte-identical at 0..21, plus 30 finger joints at 22..51 in
fingerSlot order, 2 sides x 5 fingers x 3 segments) so finger animation can
live in the trained data model instead of a side-channel. New V2 API:
canonicalJointNameV2 / canonicalParentOfV2 (seg0 parents to rhand/lhand,
deeper to the previous segment) / canonicalChildOfV2 / fingerJointIndexV2 /
canonicalIndexForBoneV2 (body via the V1 matcher, fingers via
fingerRoleForBone). V1 entry points are untouched, so 22-joint libraries
and the shipped RMIB/t2m models keep working. Unit-tested (topology,
Mixamo + Biped matcher, V1 equivalence at 0..21).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(anim): motion-library schema v4, curation store, v2-first download (#838)

- Schema qtmesh-motion-library-v4: 52-joint clips (fingers folded in as
  canonical joints 22..51). The loader is joint-count-aware (jointCount()
  = 22 for v1..v3, 52 for v4) and still reads every older schema; per-clip
  quats/restWorld/restDir parse at the schema's width. Adds the optional
  fingerRestDir side-channel field for v1-era clips.
- Curation store (the ship gate): curationPath()/loadCuration()/
  saveCuration() persist the user-approved "good" clip sources in
  curation.json next to the library, keyed by the stable source string so
  stars survive library rebuilds.
- Download prefers the V2 file: libraryPath() returns motion-library-v2.json
  when present, and ensureLibraryBlocking() now tries the v2 filename first
  with a fallback to the v1 name (partial/404 bodies are removed). Old app
  builds only request the v1 name and their loader rejects v4, so hosting
  both is non-breaking. The curated 36-clip v4 library is live on the
  QtMeshEditor-models HF repo under motion/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(anim): V2 extraction + retarget — standard finger transport, axis leveling (#838)

Extraction (extractCanonicalClips gains v2=false):
- V2 mode emits 52-joint clips: finger world orientations sampled per frame
  (canonicalised like the body), restWorld/restDir grown to 52; fingerless
  rigs still pad to 52 so the v4 loader's width guard holds.
- Finger replication: rigs with one non-thumb finger bone driving all four
  (Quaternius Woman) copy the populated finger onto empty middle/ring/pinky.
- Soft-weighted up-leveling of the canonical frame (torso-up aggregated over
  all frames, weight max(0,u.y)^3): fixes rigs whose bind and animation live
  in different frames (woman on the X axis) without breaking one-sided
  clips (death) or crouches; reference-pose selection now detects the
  bind-vs-animation frame mismatch and references an animated frame.
- Finger REST directions captured per slot; nub/tip fallback for the last
  segment's pointing direction.

Retarget (applyMotionClip):
- V2 dispatch from clip width: 52-joint clips use the V2 matcher/topology
  (readTargetBindFrame gains v2), the humanoid gate stays against the 22
  body joints, twist caps/gains are bounds-safe past joint 21.
- Fingers ride the STANDARD hierarchical bind-referenced transport (the
  #411 change-of-basis): Drel = Dp^-1 Df applied onto the target's bind,
  carried by the target's animated hand. Exact by construction (0.2 deg
  measured parity vs the source), carries twist so nails stay put, and
  holds bind for source-empty segments (a transported identity against a
  curling parent used to counter-rotate the tip and straighten fingers).
- V1 applyFingerCurl stays for <=v3 libraries with the relative-bend +
  knuckle-line flexion + thumb-side fixes; v4 clips skip it (fingers are
  joints now, callers gate the side-channel on lib.jointCount()==22).

Also documents the measured limits: the aim's minimal-arc Qbase drops the
source's bind->reference roll (50-170 deg arm-chain loss on self-retarget,
cascading), and orientation-level fixes are unsound while the extraction
frame C and the retarget frame Ct disagree (up to ~57 deg/clip) — the
schema-v5 bindC frame link is the prescribed fix. QTMESH_EXTRACT_DEBUG
prints per-clip reference choices.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(anim): V2 generate wiring, curation API, ground-truth parity harness (#838)

- GUI + CLI generate paths feed the finger side-channel only for V1 (22-
  joint) libraries; v4 clips retarget fingers through the joint path (no
  double application). clipFingerRest plumbed for the V1 path.
- setClipApproved(source, bool): the picker's star writes the curation
  store; listMotionClips() reports each clip's approved state.
- CLI: --dump-canonical --v2 emits qtmesh-canonical-clips-v2 (52 joints,
  jointCount field, no redundant fingers side-channel).
- Parity harness upgrades on --apply-canonical: a GROUND-TRUTH self-parity
  report (plays the original and generated_parity on the same skeleton and
  compares raw per-bone world orientations — immune to the extraction's
  reference/leveling choices, which contaminate dump-based comparisons),
  and an IN-PROCESS re-extraction dump (--dump-canonical alongside) that
  skips the glTF export->import round-trip (measured ~25 deg arm-chain
  loss on its own).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ui): curation star + only-good filter in the animation picker (#838)

Per-row star toggles a clip's "good" mark (persisted via
setClipApproved -> curation.json, stable across library rebuilds), an
"Only good" checkbox filters the list to the approved set, and the header
shows the approved count. The approved set is what ships:
build-motion-library-v6.py --approved-only consumes the same file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(scripts): motion-library v6 builder — schema v4 + --approved-only ship gate (#838)

v6 builds the 52-joint (fingers-as-joints) library: dumps each corpus asset
with --dump-canonical --v2 into a .canonical.v2.json sidecar cache, accepts
rest arrays at the dump's own joint width, drops the legacy fingers side-
channel, and emits schema qtmesh-motion-library-v4. --curation/--approved-
only gate the output to the user-starred clips from the app's curation.json
(auto-discovered when the flag is omitted) so the curated set ships while
the rest of the corpus is iterated on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(anim): address PR #941 review findings (#838)

- rebind cached SkeletonInstance after entity->_initialise(true) in
  generateMotion (dangling m_selectedSkeleton on next scrub/keyframe)
- fingerRoleForBone: reject mid-word finger-name hits (ring in
  SpringBone/String/EarRing, index in IndexHelper) via separator-aware
  boundary + hand/side context; regression test
- fingerSlot(): bound side<=1 and finger<=4
- MotionLibrary: enforce 30-wide fingers rows + fingerRestDir at parse;
  applyFingerCurl guards row width; no V1 fallback after a V2 download
  TIMEOUT (only after fast 404); drop stale Q_UNUSED
- CLI --generate --duration: retime the V1 finger side-channel with the
  body (finger keys kept source duration); report parity-dump write failure
- v6 builder: quality completeness normalizes on the 22 body joints (52-wide
  V2 rest arrays saturated every clip to 1.0, defeating curation weighting);
  v6 docstring rewritten for V2/curation; v5+v6: REVIEW_DROP word-boundary
  match, dead spine_up_min removed, stale comments corrected
- QML: picker busy status defers the synchronous generateMotion so it
  paints; keyboard/Accessible support on browse button + pin-feet/descent
  checkboxes; doc fixes (quats size, clipFingerRest unused)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cli): integer counter in the parity sampling loop (Sonar S2193, #838)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cli): sample the parity loop's terminal keyframe (#838)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ui): default the animation picker to 'Only good' (#838)

Falls back to showing all clips when nothing is starred (fresh install /
un-curated library) so the list is never silently empty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (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