Skip to content

feat(#411): text-to-motion model (experimental) + UniRig naming/retarget + anim UI - #783

Merged
fernandotonon merged 7 commits into
masterfrom
feat/text-to-motion-411
Jul 1, 2026
Merged

feat(#411): text-to-motion model (experimental) + UniRig naming/retarget + anim UI#783
fernandotonon merged 7 commits into
masterfrom
feat/text-to-motion-411

Conversation

@fernandotonon

@fernandotonon fernandotonon commented Jun 30, 2026

Copy link
Copy Markdown
Owner

Closes the #411 text-to-motion work, plus the rigging/retarget fixes it surfaced along the way. The shipped default stays the deterministic template-clip retarget; the trained model is an opt-in.

Text-to-motion model (experimental, opt-in)

  • MotionGenerator — ONNX consumer running a small from-scratch model that emits a 22-joint canonical clip, retargeted via the existing applyMotionClip path (template MotionLibrary is the automatic fallback).
  • Surfaced on all three: CLI qtmesh anim --generate "…" --model, MCP generate_motion {model:true}, GUI "Use trained model (experimental)" checkbox. ENABLE_ONNX-guarded; model downloads on first use.
  • Trained on permissive data only: clean dynamic single-action windows from CMU MoCap + Quaternius CC0 (AMASS/HumanML3D/KIT excluded). The key lever was data cleanliness — the raw set was 98% idle frames; filtering them is what produced coherent motion.
  • Offline dev scripts (not shipped): prep-t2m-clean.py, prep-quaternius-t2m.py, train-t2m-onnx{,-v2,-v3}.py.
  • Honest quality: action-dependent — locomotion (walk) is coherent; gestures (wave/run) drift slightly in late frames. Hence experimental + template default. Hosted at QtMeshEditor-t2m and QtMeshEditor-models/motion/.

UniRig naming + retarget (fixes 0/22-joints, mirrored, wrong-side)

  • UniRigPredictor::labelJointsAnatomically — names UniRig's positional joints (root/joint_N) anatomically from rest-pose geometry (spread-based axis detection + chain-direction arm/leg classification; honours body/hand part tokens; guarantees unique names).
  • applyMotionClip handedness compensation — swaps canonical L/R when the rig's left bone is on −X (CMU left = +X), so labels are anatomical and motion isn't mirrored. Roll-correction disabled for no-standing-pose rigs (UniRig).
  • AutoRig::rigPriorPartLabels inherits a bone's part from its nearest named ancestor → segmentation "Select by Part" uses the exact rig prior on UniRig skins (resolve 0% → 100%).

Animation UI

  • "Generate from text" moved into the Animations group; shows for any skeleton (not only animated meshes); list auto-refreshes after generate (no reselect).
  • Delete animation — per-clip trash icon with inline confirm (PropertiesPanelController::deleteAnimation).

Tests / docs

  • New/updated unit tests (MotionLibrary_test, UniRigPredictor_test); UnitTests target builds green locally. CMU/Quaternius licensing documented in THIRD_PARTY_AI_MODELS.md. Version 3.15.0 (doc-sync verified).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added experimental “Generate from text” motion generation for skeleton-based selections, with optional model-backed generation and duration retiming.
    • Enabled text-to-motion via prompt-to-template-library matching and automatic retargeting onto your rig; also exposed through CLI and MCP tooling.
  • Bug Fixes
    • Fixed GLTF/GLB re-import orientation issues.
    • Improved animation deletion with inline confirmation and safer UI refresh behavior.
  • Documentation
    • Updated CI/version references and expanded text-to-motion template library docs (including download/cache behavior).
  • Tests
    • Added motion library test coverage and hardened CI test execution to avoid unintended downloads.

…t + anim UI

Text-to-motion (#411) end-to-end, plus the rigging/retarget fixes it surfaced.

Model (experimental, opt-in):
- MotionGenerator: ONNX consumer running a from-scratch t2m model (trained on
  clean dynamic single-action CMU + Quaternius CC0 windows; AMASS/HumanML3D
  excluded). Emits a canonical clip → same applyMotionClip retarget as templates.
- Surfaced via `qtmesh anim --generate --model`, MCP generate_motion {model},
  and a GUI "Use trained model (experimental)" checkbox. Falls back to the
  template-clip library automatically when the model is unavailable or the action
  isn't in its vocab. ENABLE_ONNX-guarded; downloads on first use (HF).
- Training/data scripts (offline, not shipped): prep-t2m-clean.py (drops idle +
  multi-label windows — the key data-quality fix), prep-quaternius-t2m.py,
  train-t2m-onnx{,-v2,-v3}.py. Quality is action-dependent (locomotion coherent,
  gestures drift); template retarget stays the shipped default.

Retarget + UniRig (fixes the "0/22 joints", mirrored, wrong-side results):
- UniRigPredictor::labelJointsAnatomically — names UniRig's positional joints
  (root/joint_N) anatomically by rest-pose geometry (spread-based axis detection
  + chain-direction arm/leg classification); honours its body/hand part tokens
  when present. Unique names (Ogre createBone rejects dups).
- applyMotionClip: handedness compensation swaps canonical L/R when the rig's
  left bone is on −X (CMU left = +X) so labels are anatomical AND motion isn't
  mirrored. Roll-correction disabled for no-standing-pose rigs (UniRig).
- AutoRig::rigPriorPartLabels inherits a bone's part from its nearest named
  ancestor → segment "Select by Part" uses the exact rig prior on UniRig skins
  (resolve 0% → 100%).

Animation UI:
- Generate-from-text moved into the Animations group; shows for any skeleton
  (not only animated meshes); list auto-refreshes after generate (no reselect).
- Per-animation delete (trash icon + inline confirm) via
  PropertiesPanelController::deleteAnimation.

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

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ec351bf9-1415-4386-90da-0982b0cdd85c

📥 Commits

Reviewing files that changed from the base of the PR and between 6bb9864 and fe377d8.

📒 Files selected for processing (2)
  • src/MCPServer_test.cpp
  • src/test_main.cpp

📝 Walkthrough

Walkthrough

Adds an experimental MotionLibrary-backed text-to-motion flow with optional ONNX generation, skeleton retargeting, CLI/MCP/QML wiring, offline motion-library/training scripts, documentation updates, and related import, labeling, and version-reference changes.

Changes

Text-to-Motion Feature

Layer / File(s) Summary
Motion library contract and prompt matching
src/MotionLibrary.h, src/MotionLibrary.cpp, src/MotionLibrary_test.cpp
Defines MotionLibrary clip loading/parsing, prompt matching with synonyms, cache/download helpers, and tests for schema, rest-world handling, joint counts, and matching behavior.
Optional ONNX motion generator backend
src/MotionGenerator.h, src/MotionGenerator.cpp
Adds MotionGenerator path resolution, model/vocab download, prompt matching, and ONNX inference returning a MotionLibrary clip.
Build wiring for new motion components
src/CMakeLists.txt, tests/CMakeLists.txt
Adds MotionLibrary and MotionGenerator sources and headers to the build.
Skeleton retargeting via AnimationMerger
src/AnimationMerger.h, src/AnimationMerger.cpp
Adds ApplyMotionResult and applyMotionClip for canonical mapping, handedness correction, standing-pose harvesting, and optional refinement.
AnimationControlController.generateMotion API
src/AnimationControlController.h, src/AnimationControlController.cpp
Adds the QML-invokable generation API and status signal, selecting a model or library clip and applying it.
CLI generate mode
src/CLIPipeline.h, src/CLIPipeline.cpp
Adds cmdAnimGenerate and extends qtmesh anim parsing for --generate, --model, and --duration.
MCP generate_motion tool
src/MCPServer.h, src/MCPServer.cpp
Adds and registers generate_motion, including schema and heavy-tool classification.
QML generation UI and delete confirmation
qml/PropertiesPanel.qml, src/PropertiesPanelController.cpp, src/PropertiesPanelController.h
Adds a text-to-motion UI block, shows Animations for skeleton selections, and replaces delete with inline confirmation and controller-backed removal.
Text-to-motion documentation
CLAUDE.md, THIRD_PARTY_AI_MODELS.md
Documents example CLI usage and the MotionLibrary-based feature.

Offline Motion Data Prep and Training Scripts

Layer / File(s) Summary
Motion data preparation scripts
scripts/build-motion-library.py, scripts/prep-t2m-clean.py, scripts/prep-quaternius-t2m.py
Builds the motion library and preprocesses CMU/Quaternius motion data into cached training windows.
T2M training and ONNX export scripts
scripts/export-t2m-onnx.py, scripts/train-t2m-onnx.py, scripts/train-t2m-onnx-v2.py, scripts/train-t2m-onnx-v3.py
Adds training, evaluation, sampling, and ONNX export scripts for text-to-motion models.

Unrelated Fixes and Version Bump

Layer / File(s) Summary
glTF up-axis import fix
src/Assimp/Importer.cpp
Overrides Assimp metadata to force Y-up for .gltf and .glb.
AutoRig ancestor-based part labeling
src/AutoRig.cpp
Adds recursive part resolution and uses it for vertex labeling.
UniRigPredictor anatomical joint naming
src/UniRigPredictor.h, src/UniRigPredictor.cpp, src/UniRigPredictor_test.cpp
Adds joint part tracking, anatomical relabeling, and a canonical-resolution test.
Version bump to 3.15.0
CMakeLists.txt, README.md, website/src/hooks/useQtmeshActionRef.js
Updates project and CI version references from 3.14.0 to 3.15.0.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.27% 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 reflects the main changes: experimental text-to-motion, UniRig retargeting fixes, and animation UI updates.
Description check ✅ Passed It covers the required content areas with a clear summary and technical details, though it doesn't use the exact template headings.
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.
✨ 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/text-to-motion-411

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.

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

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/MotionGenerator.cpp Outdated
guard.setSingleShot(true);
QObject::connect(&guard, &QTimer::timeout, &loop, [&]{ ok = false; loop.quit(); });
guard.start(180000);
dl->startDownload(label, base + fileName, dest);

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 Pass the downloader arguments in the expected order

When the experimental model is first used and the files are not already cached, this call passes label as the URL and the real URL as the destination, while ModelDownloader::startDownload expects (url, destinationPath, modelName) like the other call sites. The completion/error signals are then emitted under the wrong model name, so the local event loop waits for the 180s guard and the model path never becomes available; this makes --model / model:true / the GUI checkbox effectively unable to download the model on first use.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Good catch — fixed in 163db2f. startDownload is (url, destinationPath, modelName); the call now passes (base+fileName, dest, label) to match the other consumers (MotionInbetween etc.), so the completion/error signals fire under the right name and first-use download resolves instead of timing out on the 180s guard.

Comment thread src/UniRigPredictor.cpp
// not (AnimationMerger::applyMotionClip) — so correct labels do NOT mirror
// the motion. Decoupling label-side from retarget-side is the fix for "labels
// flipped vs animation mirrored" being in tension.
auto side = [&](int i) { return -joints[i].pos[SIDE]; };

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 Keep UniRig left/right labels aligned with +X rigs

For Y-up T-pose/UniRig skeletons where the character's left side is on +X (the new unit test builds exactly that case, and CMU canonical left is also treated as +X in the retargeter), negating the side coordinate makes +X limbs evaluate as Right* when the limb code later checks side(...) >= 0. That reverses the anatomical bone names, so name-based canonical mapping and Select-by-Part labels are wrong even though the retarget swap may hide part of it during playback.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Addressed in 163db2f. The labeler intentionally names the character's LEFT on the −X side (glTF/Ogre Y-up, faces-+Z convention) and the retarget does its own handedness compensation against the CMU clip (CMU left=+X) so names and motion are decoupled — this is what fixed the real-rig mislabel + mirrored-motion. The unit test had a stale +X=left assumption; I flipped its synthetic geometry so −X limbs assert as Left (canon 13) and +X as Right (canon 9), matching actual behavior.

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

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

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

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

2140-2154: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add the missing --generate usage line.

The error now lists --generate, but the detailed usage block does not show the required prompt or optional --duration / --model flags.

📝 Proposed fix
         err() << "       qtmesh anim <file> --simplify [--preset {conservative|balanced|aggressive}] [--tolerance T] [--rotation-tolerance-deg D] [-o <output>] [--animation <name>]" << Qt::endl;
         err() << "                          (--tolerance T sets translation+scale tolerance in world units)" << Qt::endl;
         err() << "       qtmesh anim <file> --analyze [--json] [--preset ...] [--tolerance T] [--rotation-tolerance-deg D]" << Qt::endl;
         err() << "       qtmesh anim <file> --in-between --gap-frames N [--start-time S] [--end-time S] [--no-model] [-o <output>] [--animation <name>]" << Qt::endl;
+        err() << "       qtmesh anim <file> --generate <prompt> [--duration seconds] [--model] [--json] [-o <output>]" << Qt::endl;
         return 2;
     }
🤖 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/CLIPipeline.cpp` around lines 2140 - 2154, The usage block in
CLIPipeline’s animation command error handling is missing the `--generate` help
line even though it is listed in the error summary. Update the usage text near
the main `if (!listMode && !renameMode ... )` check to include a `qtmesh anim
<file> --generate` line, and make sure it documents the required prompt plus the
optional `--duration` and `--model` flags so the help output matches the
supported option set.
🟠 Major comments (20)
src/AnimationMerger.cpp-919-919 (1)

919-919: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Honor cmuRestWorld or fail fast for local clips.

The public API documents cmuRestWorld as the CMU↔target basis for v1/v2 local clips, but the implementation ignores it and treats local clips as clipQ(f, c) only. Callers passing rest-basis data get silently wrong retargeting.

Also applies to: 1048-1050

🤖 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/AnimationMerger.cpp` at line 919, The local-clip retargeting path in
AnimationMerger is ignoring the cmuRestWorld basis even though it is part of the
public API contract, so update the logic that consumes clipQ/clipT to actually
use cmuRestWorld when processing v1/v2 local clips, or explicitly fail fast if
that basis is not supported. Make the fix in the relevant AnimationMerger
retargeting routine(s) that read the clip data so callers passing rest-basis
transforms do not get silently incorrect results.
src/AnimationMerger.cpp-1130-1139 (1)

1130-1139: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Fill each decimated gap instead of the whole clip.

After decimateAnimation(), the animation has interior keys inside (0, length), and inbetweenAnimation() explicitly rejects non-gap windows with interior keyframes. This refine path can therefore leave the clip decimated/sparse while applyMotionClip() still returns ok = true.

Proposed fix
         // Decimate every track to keep every refineStride-th key (+ the last),
         // then in-between-fill the whole clip so RMIB regenerates the interior.
         decimateAnimation(skel, animName, refineStride);
-        const auto fill = inbetweenAnimation(
-            skel, animName, 0.0f, length, refineStride - 1, modelPath,
-            /*forceFallback=*/modelPath.isEmpty());
-        res.refined = fill.ok;
-        res.usedModel = fill.ok && fill.usedModel;
+        bool anyFilled = false;
+        bool anyUsedModel = false;
+        for (int start = 0; start < frames - 1; start += refineStride) {
+            const int end = std::min(start + refineStride, frames - 1);
+            const int gapFrames = end - start - 1;
+            if (gapFrames <= 0)
+                continue;
+
+            const auto fill = inbetweenAnimation(
+                skel, animName, start * dt, end * dt, gapFrames, modelPath,
+                /*forceFallback=*/modelPath.isEmpty());
+            anyFilled = anyFilled || fill.ok;
+            anyUsedModel = anyUsedModel || (fill.ok && fill.usedModel);
+        }
+        res.refined = anyFilled;
+        res.usedModel = anyUsedModel;
     }
🤖 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/AnimationMerger.cpp` around lines 1130 - 1139, The refine path in
AnimationMerger::applyMotionClip currently calls inbetweenAnimation() over the
full clip after decimateAnimation(), but that helper rejects windows containing
interior keys, so the clip can remain sparse while still reporting success.
Update the refine branch to iterate over each decimated gap and call
inbetweenAnimation() only on gap windows between kept keys (instead of [0,
length]), then aggregate the results into res.refined and res.usedModel so the
whole clip is actually refilled.
src/MotionLibrary.cpp-90-119 (1)

90-119: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Validate the declared joint names and order.

This loader ignores the top-level joints array entirely, even though the format comments require the 22 canonical CMU joints in a fixed order. A reordered or mislabeled library will still load and then retarget the wrong bones for every generated animation.

🤖 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/MotionLibrary.cpp` around lines 90 - 119, The MotionLibrary loader
currently ignores the top-level joints declaration, so validate the declared
joint names and fixed order before accepting clips. Update MotionLibrary::load
to read and compare the root-level "joints" array against the 22 canonical CMU
joints, and reject the file with m_error if the names or order do not match.
Keep the existing clip/frame parsing in place, but gate it on the joint schema
check so reordered or mislabeled libraries fail early.
src/MotionLibrary.cpp-77-86 (1)

77-86: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reject malformed quaternion entries instead of silently defaulting them.

Both cmuRestWorld and per-joint quats read q.at(0..3) without first checking that the JSON value is actually a 4-element array. Truncated or non-array entries are therefore accepted as zero/identity-ish quaternions, which breaks the library schema and feeds corrupted poses into retargeting.

Also applies to: 107-113

🤖 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/MotionLibrary.cpp` around lines 77 - 86, In MotionLibrary’s JSON parsing,
the cmuRestWorld loader (and the per-joint quats path mentioned in the comment)
currently assumes every entry is a 4-element array and reads q.at(0..3)
directly, which can silently accept malformed values. Update the parsing logic
in the relevant MotionLibrary methods to validate that each quaternion value is
actually an array of length 4 before constructing the quaternion, and
reject/skip the entry or fail parsing when it is not. Use the existing
cmuRestWorld and quats handling blocks as the place to add the guard so
truncated or non-array entries are never defaulted into m_cmuRestWorld or joint
poses.
src/MotionGenerator.cpp-176-183 (1)

176-183: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Enforce the exact 22-joint / [1,T,C] model contract before returning ok=true.

Right now any vocab JSON with C == J * 10 is accepted, even if J != 22, and the output tensor only needs to have at least T * C elements. A stale or mismatched model/vocab pair can therefore produce a MotionLibrary::Clip that violates the downstream retarget contract or is unpacked from the wrong layout.

Suggested contract checks
-        if (V == 0 || T <= 0 || C != J * 10) {
+        if (V == 0 || T <= 0 || J != 22 || C != J * 10) {
             r.error = QStringLiteral("t2m vocab json malformed"); return r;
         }
@@
-        if (total < static_cast<int64_t>(T) * C) {
-            r.error = QStringLiteral("t2m output smaller than expected"); return r;
+        if (shape.size() != 3 || shape[0] != 1 || shape[1] != T || shape[2] != C ||
+            total != static_cast<int64_t>(T) * C) {
+            r.error = QStringLiteral("t2m output shape mismatch"); return r;
         }

Also applies to: 249-253

🤖 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/MotionGenerator.cpp` around lines 176 - 183, The vocab/model validation
in MotionGenerator should enforce the exact downstream contract instead of only
checking C == J * 10. Update the checks in the motion loading path around the
existing vj parsing and the tensor-shape validation to require J == 22, C ==
220, T > 0, and an output tensor shape exactly matching [1,T,C] rather than
merely having enough elements. Use the existing
MotionGenerator::loadMotion-style validation and r.error handling so stale or
mismatched vocab/model pairs fail before ok=true is returned.
src/MotionLibrary.cpp-145-159 (1)

145-159: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Match whole tokens here, not raw substrings.

contains() makes prompts like white shirt match hitpunch, and grandstand match standidle. Because src/MotionGenerator.cpp duplicates the same matcher shape, these false positives affect both template and model flows. Tokenize the prompt or use word-boundary matching and share the helper.

🤖 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/MotionLibrary.cpp` around lines 145 - 159, The current matching in the
clip lookup path uses raw contains() checks, so it can falsely match substrings
instead of whole words. Update the matcher used by this logic around m_clips,
kSynonyms, and findAction to tokenize the prompt or use word-boundary matching,
and apply the same helper in MotionGenerator.cpp so both template and model
flows share identical whole-token matching behavior.
src/MotionGenerator.cpp-105-117 (1)

105-117: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Swap the startDownload() arguments here. ModelDownloader::startDownload() expects (url, destinationPath, modelName), but this call passes (label, base + fileName, dest). That makes the request URL the human-readable label and stores the destination path as the model name, so the name == label completion check will never match.

🤖 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/MotionGenerator.cpp` around lines 105 - 117, The call in
MotionGenerator::startDownload uses the arguments in the wrong order, so the
URL, destination path, and model name are being mixed up. Update the call site
in MotionGenerator.cpp to pass the download URL first, then the destination
path, then the model name, matching ModelDownloader::startDownload(url,
destinationPath, modelName) so the downloadCompleted/downloadError callbacks can
match on the correct name.
src/MCPServer.cpp-3378-3424 (1)

3378-3424: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Validate MCP duration before allocating retimed frames.

A client can send a huge or non-finite duration; line 3419 turns it into a vector size. Validate the JSON type and cap the accepted range, and reflect that bound in the tool schema description.

🛡️ Proposed fix
+#include <cmath>
+
-        const double duration = args.value("duration").toDouble(0.0);
+        const QJsonValue durationArg = args.value("duration");
+        if (!durationArg.isUndefined() && !durationArg.isDouble())
+            return makeErrorResult("Error: 'duration' must be a number.");
+        const double duration = durationArg.toDouble(0.0);
+        constexpr double kMaxGenerateDurationSeconds = 60.0;
+        if (!std::isfinite(duration) || duration < 0.0 || duration > kMaxGenerateDurationSeconds)
+            return makeErrorResult(QString("Error: 'duration' must be between 0 and %1 seconds.")
+                                       .arg(kMaxGenerateDurationSeconds, 0, 'f', 0));
-        props["duration"] = QJsonObject{{"type", "number"}, {"description", "Optional clip length in seconds (retimes the template). Default: the clip's native length."}};
+        props["duration"] = QJsonObject{{"type", "number"}, {"description", "Optional clip length in seconds, 0..60 (retimes the template). Default: the clip's native length."}};

Also applies to: 6623-6623

🤖 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 3378 - 3424, Validate the MCP `duration`
input in the request-handling path before it reaches the retiming logic in
`MCPServer.cpp` so a huge or non-finite value cannot become the `want` vector
size in the template clip branch. Update the argument parsing around
`args.value("duration")` to reject non-numeric/invalid values, clamp the
accepted range to a safe maximum, and return a clear error via `makeErrorResult`
when out of bounds. Also update the tool schema/description for this parameter
to document the allowed range so callers know the limit.
src/CLIPipeline.cpp-1922-1924 (1)

1922-1924: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Validate --duration before using it as a frame-count multiplier.

QString::toFloat() silently returns 0 on bad input, and very large values flow into std::vector retimed(want). Reject non-numeric, negative, non-finite, and unreasonable durations before dispatching.

🛡️ Proposed fix
+#include <cmath>
+
         if (arg == "--duration" && i + 1 < argc) {
-            generateDuration = QString(argv[++i]).toFloat();
+            bool ok = false;
+            generateDuration = QString::fromLocal8Bit(argv[++i]).toFloat(&ok);
+            constexpr float kMaxGenerateDurationSeconds = 60.0f;
+            if (!ok || !std::isfinite(generateDuration) || generateDuration < 0.0f
+                || generateDuration > kMaxGenerateDurationSeconds) {
+                err() << "Error: --duration must be a number between 0 and "
+                      << kMaxGenerateDurationSeconds << " seconds." << Qt::endl;
+                return 2;
+            }
             continue;
         }

Also applies to: 2079-2081

🤖 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/CLIPipeline.cpp` around lines 1922 - 1924, Validate the parsed --duration
value before it is used in CLIPipeline’s retiming logic, especially in the block
that computes want and constructs retimed. In the duration parsing path, reject
non-numeric, negative, non-finite, and unreasonably large values instead of
relying on QString::toFloat() defaults, and make sure the same validation
applies to both call sites that feed the frame-count multiplier logic. If the
value is invalid, fail early with a clear error before dispatching to the code
that uses duration in std::max and std::vector allocation.
src/AnimationControlController.cpp-1655-1728 (1)

1655-1728: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Validate duration before retiming allocates frames.

Line 1722 converts QML-controlled duration into a frame count and allocates retimed; NaN, infinity, or a huge value can overflow the cast or attempt an enormous allocation on the UI path.

🛡️ Proposed fix
+#include <cmath>
+
 QVariantMap AnimationControlController::generateMotion(const QString& prompt,
                                                        double duration, bool useModel)
 {
     QVariantMap out;
     out["ok"] = false;
     auto fail = [&](const QString& e) { out["error"] = e; emit generateMotionStatus(e, true); return out; };

     if (prompt.trimmed().isEmpty())
         return fail(QStringLiteral("Enter a motion prompt (e.g. \"walking\")."));
+    constexpr double kMaxGenerateDurationSeconds = 60.0;
+    if (!std::isfinite(duration) || duration < 0.0 || duration > kMaxGenerateDurationSeconds)
+        return fail(QStringLiteral("Duration must be between 0 and %1 seconds, or 0 for native length.")
+                        .arg(kMaxGenerateDurationSeconds, 0, 'f', 0));
🤖 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/AnimationControlController.cpp` around lines 1655 - 1728, Validate the
duration input in AnimationControlController::generateMotion before computing
the retimed frame count. Reject non-finite values and clamp overly large
durations to a safe maximum before the std::max/int cast and retimed allocation,
and return a failure through the existing fail lambda if the value is invalid.
Use the generateMotion, fail, and retimed paths to place the guard right before
the duration-to-frames conversion.
scripts/train-t2m-onnx.py-114-122 (1)

114-122: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use a branch-stable matrix-to-quaternion conversion.

This formula collapses rotations with trace near -1 because w approaches zero and the vector part is divided by 4*w. Exported motion can contain non-unit or near-zero quaternions for 180° rotations, which breaks retargeting.

🤖 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/train-t2m-onnx.py` around lines 114 - 122, The mat_to_quat helper
currently relies on a trace-based formula that becomes unstable near 180°
rotations because w approaches zero and x/y/z are divided by 4*w. Update the
conversion in mat_to_quat to use a branch-stable matrix-to-quaternion algorithm
(with per-case handling based on the largest diagonal term or equivalent stable
logic) so rotations with trace near -1 still produce valid unit quaternions for
export.
scripts/prep-t2m-clean.py-73-73 (1)

73-73: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use a platform-independent default cache path.

Line 73 defaults to /tmp/t2m_clean.npz, which fails on Windows environments without /tmp. Prefer tempfile.gettempdir() or require --out.

Portable default
 import argparse, csv, glob, os, re
+import tempfile
...
-    ap.add_argument("--out",default="/tmp/t2m_clean.npz")
+    ap.add_argument("--out", default=os.path.join(tempfile.gettempdir(), "t2m_clean.npz"))

As per retrieved learnings, “All code must compile and run on Windows, Linux (Ubuntu), and macOS.”

🤖 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/prep-t2m-clean.py` at line 73, The default output path in the
argument parser is hardcoded to a Unix-only temporary location, which breaks
portability. Update the --out default in the main argparse setup to use a
platform-independent temporary directory via tempfile.gettempdir() (or make
--out mandatory if that better fits the script), so prep-t2m-clean.py works
consistently across Windows, Linux, and macOS.

Sources: Learnings, Linters/SAST tools

scripts/train-t2m-onnx.py-59-59 (1)

59-59: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use a platform-independent default cache path.

Line 59 defaults to /tmp/t2m_preprocessed.npz, which is not portable to Windows. Use tempfile.gettempdir() or require --cache.

Portable default
 import json
 import os
+import tempfile
...
-    ap.add_argument("--cache", default="/tmp/t2m_preprocessed.npz")
+    ap.add_argument("--cache", default=os.path.join(tempfile.gettempdir(), "t2m_preprocessed.npz"))

As per retrieved learnings, “All code must compile and run on Windows, Linux (Ubuntu), and macOS.”

🤖 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/train-t2m-onnx.py` at line 59, The default cache path in the argument
setup is platform-specific and breaks portability on Windows. Update the
`ap.add_argument("--cache", ...)` logic in `train-t2m-onnx.py` to use a
temp-directory-based default such as `tempfile.gettempdir()` (or make `--cache`
required), so the script works consistently across Windows, Linux, and macOS.

Sources: Learnings, Linters/SAST tools

scripts/export-t2m-onnx.py-171-181 (1)

171-181: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Key the preprocessing cache by inputs, or validate cache metadata.

The comment says the cache is keyed by BVH dir and vocab, but Line 173 always uses t2m_preprocessed.npz beside --out. Re-running with different --bvh, --annotations, or VOCAB silently trains on stale data.

Metadata guard sketch
+    meta = {
+        "bvh": os.path.abspath(a.bvh),
+        "annotations": os.path.abspath(a.annotations),
+        "vocab": VOCAB,
+    }
     cache = os.path.join(os.path.dirname(a.out) or ".", "t2m_preprocessed.npz")
     if os.path.exists(cache):
         d = np.load(cache)
-        mo, tk = d["mo"], d["tk"]
+        if "meta" not in d or json.loads(str(d["meta"])) != meta:
+            raise SystemExit(f"stale preprocessing cache: remove {cache} or choose a new --out")
+        mo, tk = d["mo"], d["tk"]
...
-        np.savez(cache, mo=mo, tk=tk)
+        np.savez(cache, mo=mo, tk=tk, meta=json.dumps(meta))
🤖 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/export-t2m-onnx.py` around lines 171 - 181, The preprocessing cache
in the export script is using a fixed filename in the output directory, so it
can be reused across different BVH, annotation, or vocab inputs. Update the
caching logic around the `cache`/`np.load`/`np.savez` flow to either derive the
cache key from the active inputs used by `load_annotations()` and
`preprocess()`, or persist and validate cache metadata before loading. Keep the
`t2m_preprocessed.npz` path only if it is guarded by input checks so
`preprocess()` never consumes stale cached `mo` and `tk` data.
qml/PropertiesPanel.qml-5718-5738 (1)

5718-5738: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make the trained-model toggle keyboard/screen-reader reachable.

This custom checkbox never takes tab focus and exposes no Accessible role/state, so keyboard users cannot opt into model generation.

🤖 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` around lines 5718 - 5738, The custom trained-model
checkbox in PropertiesPanel.qml is not keyboard or screen-reader accessible
because the Rectangle-based control lacks focus handling and Accessible
metadata. Update the useModelChk control so it can receive tab focus and be
toggled via keyboard, and add the appropriate Accessible role/name/state to the
checkbox element and its current checked state. Keep the existing MouseArea
behavior, but make sure the same checked binding is exposed through the
accessible properties and keyboard interaction for the “Use trained model
(experimental)” option.
qml/PropertiesPanel.qml-651-653 (1)

651-653: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Don't gate the Animations section on the primary selection only.

animationData() renders per-entity groups for the whole selection, but hasSkeletonSelection() is documented as checking only the first resolved selection. With a mixed selection, one unrigged primary entity now hides the entire section even when another selected entity has a skeleton and animations.

🤖 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` around lines 651 - 653, The Animations section
visibility is incorrectly tied to
PropertiesPanelController.hasSkeletonSelection(), which only reflects the first
resolved selection and can hide the section for mixed selections. Update the
sectionVisible condition in PropertiesPanel.qml so it considers whether any
selected entity has animation data rather than only the primary selection, using
the existing animationData() / per-entity selection logic in this panel. Keep
the mode check through
root.modeToolSectionVisible(EditorModeController.AnimationMode, ...) but replace
the skeleton-only gate with a selection-wide animation availability check.
qml/PropertiesPanel.qml-6142-6184 (1)

6142-6184: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

The delete confirmation affordance is mouse-only.

The trash / ✓ / ✗ controls do not participate in tab order and are not exposed as buttons, so keyboard users cannot arm, confirm, or cancel animation deletion.

🤖 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` around lines 6142 - 6184, The trash/delete
confirmation UI in PropertiesPanel.qml is mouse-only and cannot be reached or
activated from the keyboard. Update the trashBtn, trashMouse, and the
confirm/cancel controls inside the Row to expose proper keyboard-accessible
button behavior and tab focus, so users can arm deletion with the trash control
and confirm or cancel via keyboard as well as mouse. Ensure the confirm and
cancel actions remain wired to PropertiesPanelController.deleteAnimation and the
confirming state transitions, but make the interactive elements participate in
tab order and expose accessible button semantics.
qml/PropertiesPanel.qml-5694-5709 (1)

5694-5709: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Let the busy state render before calling generateMotion().

generateMotion() is explicitly documented as blocking on the first-use library download. Because run() invokes it synchronously on the main thread and clears genBtnBusy right after, the panel never repaints into its busy state; the first run just looks frozen.

Proposed fix
                     function run() {
                         if (genBtnBusy || !genPromptIn.text.trim()) return
                         genBtnBusy = true
                         genStatus.text = useModelChk.checked
                             ? "Generating (experimental model)…"
                             : "Generating… (first use downloads the motion library)"
                         genStatus.isError = false
-                        AnimationControlController.generateMotion(genPromptIn.text, 0.0,
-                                                                  useModelChk.checked)
-                        genBtnBusy = false
-                        // generateMotion adds an AnimationState synchronously, but
-                        // it lives on AnimationControlController — the Inspector
-                        // list (PropertiesPanelController.animationData) won't know
-                        // until something re-queries. Refresh it directly so the
-                        // new clip appears without reselecting the entity.
-                        refreshAnimData()
+                        const prompt = genPromptIn.text
+                        const useModel = useModelChk.checked
+                        Qt.callLater(function() {
+                            try {
+                                AnimationControlController.generateMotion(prompt, 0.0, useModel)
+                            } finally {
+                                genBtnBusy = false
+                                // generateMotion adds an AnimationState synchronously, but
+                                // it lives on AnimationControlController — the Inspector
+                                // list (PropertiesPanelController.animationData) won't know
+                                // until something re-queries. Refresh it directly so the
+                                // new clip appears without reselecting the entity.
+                                refreshAnimData()
+                            }
+                        })
                     }
🤖 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` around lines 5694 - 5709, The busy state in run() is
being set and then immediately blocked by the synchronous
AnimationControlController.generateMotion() call, so the UI never repaints
before the first-use download stalls it. In PropertiesPanel.qml, update run() to
let the event loop process the genBtnBusy/genStatus changes before invoking
generateMotion(), and only clear genBtnBusy after the generation work finishes;
use the existing run() function and AnimationControlController.generateMotion()
call as the fix point.
src/UniRigPredictor.cpp-339-347 (1)

339-347: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

The left/right sign is inverted relative to the declared contract.

src/UniRigPredictor.h says left is +X, and the new test builds left limbs on +X, but side = -joints[i].pos[SIDE] makes +SIDE resolve as right. Once axis detection is corrected, this still flips every limb name unless the sign or the documented convention is updated in the same PR.

Suggested fix
-    auto side = [&](int i) { return -joints[i].pos[SIDE]; };
+    auto side = [&](int i) { return joints[i].pos[SIDE]; };
🤖 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/UniRigPredictor.cpp` around lines 339 - 347, The left/right mapping in
UniRigPredictor is inverted against the contract declared in UniRigPredictor.h
and the new axis-based tests. Update the side-name logic in
UniRigPredictor::predict (the local side lambda) so the sign matches the
documented convention for +X = left, and keep the naming rule consistent with
any axis-detection changes in the same PR. Ensure the LEFT/RIGHT labels produced
from joints[i].pos[SIDE] align with the header contract and do not flip limb
names when the detected axis is corrected.
src/UniRigPredictor.cpp-319-337 (1)

319-337: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Honor upAxis instead of redetecting it from spread.

This ignores the public upAxis contract on normal rigs. A tall +Y-up skeleton can easily have a larger Y span than arm span, so SIDE = largest span / U = second-largest picks the wrong axes and mislabels the whole skeleton even when the caller passed the correct up axis.

Suggested fix
-    int order[3] = {0, 1, 2};
-    std::sort(order, order + 3, [&](int a, int b){ return spanV[a] > spanV[b]; });
-    const int SIDE = order[0];   // widest — arms span
-    int U = order[1];            // second — head↔foot
-    // Degenerate guard: if the 2nd/3rd spans are near-equal and tiny, fall back
-    // to the passed-in up so a flat/odd rig doesn't pick noise.
-    if (spanV[order[1]] < 1e-4 && upAxis >= 0 && upAxis <= 2) U = upAxis;
+    int order[3] = {0, 1, 2};
+    std::sort(order, order + 3, [&](int a, int b){ return spanV[a] > spanV[b]; });
+    const int U = (upAxis >= 0 && upAxis <= 2) ? upAxis : order[1];
+    const int SIDE = (order[0] == U) ? order[1] : order[0];
🤖 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/UniRigPredictor.cpp` around lines 319 - 337, The axis-selection logic in
UniRigPredictor should honor the caller-provided upAxis instead of inferring it
from spread. Update the axis assignment block in the predictor so SIDE and FWD
are derived relative to the known up axis, rather than using std::sort on spanV
to pick the largest/second-largest spans. Keep the existing spread-based
fallback only for degenerate cases where upAxis is invalid or the rig is too
ambiguous, and make sure the main path in the UniRigPredictor axis detection
code uses upAxis as the contract.
🟡 Minor comments (7)
src/MotionGenerator.cpp-35-43 (1)

35-43: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Keep the model synonym map in one place.

This table has already drifted from src/MotionLibrary.cpp (hello, greet, spin, step, rest, jumping, etc. are missing here), so --model can reject or reinterpret prompts that the default template path accepts. Sharing one matcher would also fix the substring bug in both paths together.

Also applies to: 137-151

🤖 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/MotionGenerator.cpp` around lines 35 - 43, The synonym mapping in
MotionGenerator is duplicated and has drifted from the one used in
MotionLibrary, causing different prompt handling between the default template
path and --model. Refactor the lookup logic around the kSynonyms table and the
matcher used by MotionGenerator so both paths share a single source of truth for
synonym normalization, and update the matching behavior to eliminate the
substring-based misinterpretation in both places. Make sure the shared matcher
covers the missing entries referenced by MotionLibrary and is used consistently
wherever motion words are resolved.
src/CLIPipeline.cpp-1938-1941 (1)

1938-1941: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve the established no-skeleton CLI error text.

This new cmdAnim path returns the right exit code, but it bypasses the existing no-skeleton contract by printing a different message. Keep the exact Error: No skeleton found. line and add the text-to-motion guidance separately if needed. Based on learnings, “In src/CLIPipeline.cpp, ensure CLIPipeline::cmdAnim returns 1 (error) when no skeleton is found for the input model, and emits the message "Error: No skeleton found."” <retrieved_learnings>

💚 Proposed fix
     if (!entity) {
-        err() << "Error: " << filePath << " has no skinned mesh — text-to-motion "
-                 "needs a rigged humanoid skeleton to retarget onto." << Qt::endl;
+        err() << "Error: No skeleton found." << Qt::endl;
+        err() << "Text-to-motion needs a rigged humanoid skeleton to retarget onto."
+              << Qt::endl;
         return 1;
     }
🤖 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/CLIPipeline.cpp` around lines 1938 - 1941, The cmdAnim no-skeleton
handling in CLIPipeline::cmdAnim is using a new message instead of the
established CLI contract; change the error path so it still returns 1 and emits
the exact "Error: No skeleton found." line, then add any extra text-to-motion
guidance as a separate message if needed. Update the existing entity-null check
in CLIPipeline.cpp to preserve the legacy no-skeleton text while keeping the new
retargeting context around it.

Source: Learnings

src/CLIPipeline.cpp-2075-2078 (1)

2075-2078: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make --model order-independent.

Currently --model is only recognized after --generate; qtmesh anim file --model --generate walking silently falls back to templates.

💚 Proposed fix
-        if (arg == "--model" && generateMode) { generateUseModel = true; continue; }
+        if (arg == "--model") { generateUseModel = true; continue; }
     filePath = positional[0];
 
+    if (generateUseModel && !generateMode) {
+        err() << "Error: --model is only valid with --generate." << Qt::endl;
+        return 2;
+    }
+
     // `#411`: text-to-motion (template-clip MVP). Self-contained — load → match a
🤖 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/CLIPipeline.cpp` around lines 2075 - 2078, Make the anim subcommand’s
--model flag order-independent in CLIPipeline parsing. Right now the check
around generateMode only enables generateUseModel after --generate has already
been seen, so a later --generate still falls back to templates. Update the
argument handling in the CLIPipeline::run parsing loop so --model sets a
pending/latched state regardless of order, and apply it when --generate is
eventually parsed; use the existing generateMode and generateUseModel symbols to
keep the behavior consistent.
scripts/prep-quaternius-t2m.py-2-2 (1)

2-2: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Align the compact one-line style with Ruff.

Static analysis reports multiple E701 errors, but Line 2 only suppresses E702,E741. Expand the colon one-liners or explicitly suppress E701 for this offline script.

Also applies to: 54-60, 89-109, 118-138, 147-147

🤖 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/prep-quaternius-t2m.py` at line 2, The script still triggers Ruff
E701 on several one-line compound statements, but the current module-level noqa
only covers E702 and E741. Update the affected one-liners in
prep-quaternius-t2m.py by expanding them into multiline statements, or if the
compact style must remain, add E701 to the existing Ruff suppression near the
top of the file. Use the existing script blocks around the reported sections to
locate and adjust the one-line conditionals and loops consistently.

Source: Linters/SAST tools

scripts/prep-t2m-clean.py-2-2 (1)

2-2: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Align the compact one-line style with Ruff.

The file-level noqa does not include E701, but this file has many colon one-liners reported as Ruff errors. Either expand those branches or explicitly include E701 if this compact script style is intentional.

Also applies to: 50-50, 58-58, 85-88, 90-100, 111-119

🤖 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/prep-t2m-clean.py` at line 2, The top-level Ruff suppression in
prep-t2m-clean.py is missing E701, so the script’s colon one-liners still
trigger lint errors. Either refactor the compact branches and loops into
multi-line blocks throughout the affected logic, or, if the one-line style is
intentional, update the file-level noqa to include E701 so the style is
explicitly allowed. Use the existing script entrypoints and control-flow blocks
in prep-t2m-clean.py as the places to adjust.

Source: Linters/SAST tools

scripts/build-motion-library.py-2-2 (1)

2-2: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix or explicitly suppress the remaining Ruff errors.

Line 2 only ignores E702,E741, but static analysis still reports E701, E703, and S110 in this file, so lint-gated CI can fail even though the script runs.

Example cleanup
-        if j in world: return world[j]
+        if j in world:
+            return world[j]
...
-        if np.linalg.norm(axis) < 1e-6: axis = np.cross(a, [0, 1, 0])
+        if np.linalg.norm(axis) < 1e-6:
+            axis = np.cross(a, [0, 1, 0])
...
-    axis = np.cross(a, b); s = np.sqrt((1 + d) * 2);
+    axis = np.cross(a, b)
+    s = np.sqrt((1 + d) * 2)

Also applies to: 186-186, 226-229, 255-256

🤖 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.py` at line 2, The Ruff suppression at the top
of build-motion-library.py is incomplete, so the script can still fail lint
gates on E701, E703, and S110. Either clean up the remaining single-line
statements and any broad-exception usage in the affected top-level blocks, or
expand the explicit noqa coverage where those patterns remain. Use the existing
script sections around the cleanup/exception-handling logic to locate the
remaining violations and ensure all reported Ruff errors are addressed.

Source: Linters/SAST tools

CLAUDE.md-295-295 (1)

295-295: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the GUI file reference here.

This points contributors to qml/AnimationControlPanel.qml, but the generate-from-text UI added in this PR lives in qml/PropertiesPanel.qml.

🤖 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 `@CLAUDE.md` at line 295, Update the MotionLibrary / text-to-motion
documentation entry so the GUI reference points to qml/PropertiesPanel.qml
instead of qml/AnimationControlPanel.qml. Keep the rest of the feature
description intact, and make sure the “Generate from text” control is described
using the actual UI location introduced in this PR, alongside the existing
CLIPipeline::cmdAnimGenerate, MCPServer::toolGenerateMotion, and
generateMotionStatus references.
🧹 Nitpick comments (3)
src/PropertiesPanelController.cpp (1)

905-944: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a breadcrumb around animation deletion.

This is a new irreversible user action, but the delete path does not record it, which makes post-crash reconstruction much harder. Based on learnings, "Track all user-facing actions and significant operations with SentryReporter::addBreadcrumb(...) using the prescribed categories."

🤖 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/PropertiesPanelController.cpp` around lines 905 - 944, The
deleteAnimation flow in PropertiesPanelController is an irreversible user action
but currently leaves no trace for crash reconstruction. Add a
SentryReporter::addBreadcrumb(...) call at the start of
PropertiesPanelController::deleteAnimation, using the prescribed
user-action/significant-operation category and including the entityName and
animName context so the deletion can be correlated later.

Source: Learnings

src/UniRigPredictor_test.cpp (1)

384-442: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a second case for the new part/template path.

This test only covers the geometric fallback: every synthetic joint keeps the default part, so the new body/hand-driven naming path and the detokenize() part propagation can still regress unnoticed. Please add one case that sets body/hand parts explicitly, or drives labelJointsAnatomically() through a token stream that emits those part markers.

As per coding guidelines, src/**/*_test.cpp: Add Google Test unit tests for new functionality in src/ using the _test.cpp suffix.

🤖 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/UniRigPredictor_test.cpp` around lines 384 - 442, The current
UniRigPredictor test only exercises the geometric fallback in
labelJointsAnatomically and can miss regressions in the new part/template naming
path. Add a second Google Test case in this test file that explicitly sets
body/hand parts on the synthetic joints, or feeds labelJointsAnatomically a
token stream that propagates those markers through detokenize(), so the new
naming branch is covered. Use the existing
UniRigPredictor::labelJointsAnatomically and
MotionInbetween::canonicalIndexForBone symbols as the main reference points, and
keep the uniqueness check/resolution assertions so the new case verifies both
naming and canonical mapping.

Source: Coding guidelines

src/AutoRig.cpp (1)

644-679: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a focused AutoRig test for ancestor-part inheritance.

This changes the rig-prior resolve path and the fallback gate, but there’s no focused regression test here for an unnamed helper bone inheriting LeftArm/RightLeg from its nearest named ancestor. A small src/AutoRig_test.cpp case would lock the new behavior down before it silently falls back to model labeling again.

As per coding guidelines, src/**/*_test.cpp: Add Google Test unit tests for new functionality in src/ using the _test.cpp suffix.

🤖 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/AutoRig.cpp` around lines 644 - 679, Add a focused Google Test in
AutoRig_test.cpp for the ancestor-part inheritance path introduced in
AutoRig::rig-prior resolve logic. Create a regression case where an
unnamed/helper bone (for example a twist or joint_37-style bone) has a nearest
named ancestor mapped by MeshSegmenter::partForBoneName, and verify partOfBone
resolves the child to that ancestor’s part instead of Unknown. Also assert the
vertex labeling/resolution path updates resolved/labels correctly so the
fallback gate is exercised only when no named ancestor exists.

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f888ae2d-2c0b-4712-a772-cb2cabcb0d8a

📥 Commits

Reviewing files that changed from the base of the PR and between d82b2f7 and b070a37.

📒 Files selected for processing (35)
  • CLAUDE.md
  • CMakeLists.txt
  • README.md
  • THIRD_PARTY_AI_MODELS.md
  • qml/PropertiesPanel.qml
  • scripts/build-motion-library.py
  • scripts/export-t2m-onnx.py
  • scripts/prep-quaternius-t2m.py
  • scripts/prep-t2m-clean.py
  • scripts/train-t2m-onnx-v2.py
  • scripts/train-t2m-onnx-v3.py
  • scripts/train-t2m-onnx.py
  • src/AnimationControlController.cpp
  • src/AnimationControlController.h
  • src/AnimationMerger.cpp
  • src/AnimationMerger.h
  • src/Assimp/Importer.cpp
  • src/AutoRig.cpp
  • src/CLIPipeline.cpp
  • src/CLIPipeline.h
  • src/CMakeLists.txt
  • src/MCPServer.cpp
  • src/MCPServer.h
  • src/MotionGenerator.cpp
  • src/MotionGenerator.h
  • src/MotionLibrary.cpp
  • src/MotionLibrary.h
  • src/MotionLibrary_test.cpp
  • src/PropertiesPanelController.cpp
  • src/PropertiesPanelController.h
  • src/UniRigPredictor.cpp
  • src/UniRigPredictor.h
  • src/UniRigPredictor_test.cpp
  • tests/CMakeLists.txt
  • website/src/hooks/useQtmeshActionRef.js

Comment thread src/AnimationMerger.cpp
fernandotonon and others added 6 commits June 30, 2026 19:26
…ard, L/R test

- MotionGenerator: startDownload args were (label,url,dest); corrected to the
  expected (url,dest,modelName) — first-use model download was silently waiting
  on the 180s guard and never resolving (Codex P2).
- applyMotionClip: validate every clip frame carries all canonical joints before
  indexing, so a malformed/generated clip can't crash the retarget (CodeRabbit).
- UniRigPredictor_test: the labeler names −X as the character's LEFT (glTF/Ogre
  +Z-facing convention); flip the synthetic rig's limb sides to match so the
  test reflects actual behavior instead of a +X=left assumption (Codex P2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
UniRigPredictor.LabelsAnatomicallyResolveCanonicalJoints failed on a duplicate
bone name: joints the labeler doesn't classify keep their incoming name, and the
test seeded a shared placeholder — colliding names Ogre::Skeleton::createBone
rejects. Two fixes:
- labelJointsAnatomically now runs a FINAL UNIQUENESS PASS that forces any
  remaining collision to a unique joint_<i> (robust regardless of input names).
- the test seeds unique joint_N names like the real predictor (jointName).

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

unit-tests-linux still failed: the labeler's "widest span = SIDE" heuristic
picked the wrong axes on a finger-less T-pose (head↔foot Y-extent 1.85 exceeded
the arm span 1.30 → SIDE=Y, UP=X), so spine became a "limb" and arm/leg tips went
unlabelled (resolved 10/17, hands = -1). Replace with an anatomical signal that
holds with or without fingers: SIDE = the axis about which joints are most
MIRROR-SYMMETRIC (limbs come in L/R pairs → mean ≈ span mid), UP = the larger-
spread of the remaining two, FWD = the last. Verified: the synthetic test rig now
resolves the expected joints (test green locally) and the real cat/asd UniRig
dumps still detect Y/Z up correctly.

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

MCPServerProtocolTest.ProcessMessageToolsListDispatchesToHandler hung in CI
until the 20-min per-suite wall-clock cap SIGKILLed it (signal 9), leaving 26
tests unrun and failing unit-tests-linux (4884/4910). Deterministic on the
branch, green on master — a threshold effect.

Root cause: MCPServer::sendResponse does a single blocking write() of the full
response to the output fd, and these tests read only AFTER processMessage()
returns. tools/list now serialises ~89 verbose tool schemas (#411 generate_motion
plus the UV/HDR tools merged from master) well past the 64 KiB Linux pipe
capacity, so the write blocks mid-payload with nothing draining it — a classic
single-threaded pipe deadlock. Production is unaffected: the real MCP client
drains stdout continuously.

- Enlarge the test pipe to 1 MiB (F_SETPIPE_SZ on Linux, _pipe size on Windows)
  so any single response fits one buffered write.
- Set every AI-model *_NO_DOWNLOAD guard in test_main.cpp so no suite can ever
  block on a first-use model download (generate_motion's MotionGenerator /
  MotionLibrary ensure*Blocking() spin a QEventLoop on a real download otherwise).
  Respects a caller-provided value for intentional integration runs.

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

sonarqubecloud Bot commented Jul 1, 2026

Copy link
Copy Markdown

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