Skip to content

Mixamo-style arm-space post-process (#854)#867

Merged
fernandotonon merged 16 commits into
masterfrom
feat/arm-space-854
Jul 12, 2026
Merged

Mixamo-style arm-space post-process (#854)#867
fernandotonon merged 16 commits into
masterfrom
feat/arm-space-854

Conversation

@fernandotonon

@fernandotonon fernandotonon commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Closes #854. Mixamo-style "Character Arm-Space" — a user-controlled post-process that swings the arm chains outward (widen) or inward (tuck) to rescue arm-into-torso clipping / too-wide arms on rigs whose proportions differ from the source clip. No retarget-math changes.

Core: AnimationMerger::adjustArmSpace(skel, animName, degrees)

  • Rewrites only the shoulder (canonical 7/11) + collar (6/10, fractional) keyframes; elbows/hands follow through the hierarchy; legs/spine untouched.
  • Swings about the torso forward axis (target bind frame Ct, via the retarget's readTargetBindFrame helper), mirrored per side so +deg widens both arms.
  • Ogre keyframes are deltas applied onto the bind pose (applyToNode post-multiplies the reset bone), so a world swing S is folded into each keyframe as L·kf with L = Wbind⁻¹·S·Wbind.
  • Absolute + idempotent: a session-local (skeleton,anim)→angle map reverts the prior value before applying the new one (delta = new−stored). +45 leaves the clip at +45; only 0 restores the base pose (bit-near-exactly). currentArmSpace() exposes the tracked value.
  • applyMotionClip clears the entry when it regenerates a generated_* clip; migrateArmSpaceKey() moves it on rename (called from both AnimationMerger::renameAnimation and the GUI SkeletonTransform::renameAnimation).

Surfaces

  • CLI: --arm-space <deg> on --generate, plus standalone qtmesh anim <file> --arm-space <deg> --animation <name> -o out (works on ANY clip).
  • MCP: arm_space arg on generate_motion (response echoes arm_space_applied) + standalone adjust_arm_space tool (edits the master skeleton so output_path export includes the change).
  • GUI (Inspector → Animations): a live "Arm space" slider (−30…+45°) that updates the viewport while dragging even when the clip is paused, plus a per-row button to target any animation (slider seeded with the clip's real angle via currentArmSpace). Generation never bakes the slider value into new clips.
  • Sentry breadcrumbs: ui.action (GUI) / ai.tool_call (CLI + MCP).

Tests

AnimationMerger_test.cpp (fixture-based, real SkeletonInstance from a loaded Entity — a bare Skeleton SIGSEGVs): widen/tuck angle, mirrored per-side direction (sagittal reflection + X-sign, catches sign regressions), absolute/idempotent via currentArmSpace, non-arm-bone invariance, missing-animation no-op, and rename migration.

Review feedback addressed (CodeRabbit + Codex)

  • Stale arm-space cache cleared on clip regeneration (Codex P2).
  • MCP adjust_arm_space edits the master skeleton so exports include the change (Codex P2).
  • Breadcrumb categories corrected (ui.action / ai.tool_call).
  • MCP echoes arm_space_applied; two-branch entity-not-found message.
  • Docs corrected (absolute-angle semantics; session-map storage, not UserObjectBindings).

Related: epic #837, #839, PR #843 (retarget), #866 (curation), follow-ups #856/#857/#858.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added an Inspector “Arm space” slider with per-animation retargeting and an ↔ button for skeleton animations.
    • Extended motion generation with optional arm-space input, plus standalone CLI --arm-space post-adjust for existing animations.
    • Introduced an MCP adjust_arm_space tool with optional re-export support.
  • Bug Fixes
    • Arm-space adjustments are now absolute/idempotent, survive animation renames, clear on regeneration, and properly update edited interpolation data; missing animations fail safely.
  • Documentation
    • Updated arm-space post-process documentation and added CLI usage examples.
  • Tests
    • Added arm-space post-process tests covering widening/tucking, idempotency/reset, non-arm bone stability, missing-clip handling, and rename migration.

Generated animations still clip the arms into the torso or splay them too
wide on rigs whose proportions differ from the source clip. This adds a
user-controlled "arm space" swing, matching Mixamo's Character Arm-Space
slider — cheap, intuitive, and it rescues most cases without touching the
retarget math.

Core — AnimationMerger::adjustArmSpace(skel, animName, degrees):
- Rewrites ONLY the shoulder (canonical 7/11) + collar (6/10, fractional)
  keyframes; elbows/hands follow through the hierarchy, legs/spine are
  untouched. Positive widens both arms, negative tucks them in.
- Swings about the torso FORWARD axis (from the target bind frame Ct,
  reusing the retarget's readTargetBindFrame helper). Ogre keyframes are
  deltas applied onto the bind pose (NodeAnimationTrack::applyToNode
  post-multiplies the reset bone), so the world swing S is folded into each
  keyframe as L*kf with L = Wbind^-1 * S * Wbind — path-agnostic across the
  direction- and legacy-retarget outputs.
- ABSOLUTE + idempotent: a session-scoped (skeleton,anim)->angle map tracks
  the last-applied value and reverts it before applying the new one, so a
  slider maps straight to the value and 0 restores the clip bit-exactly
  (verified: +45 then -45 == base, max quat diff 0.0).

Surfaces:
- CLI: --arm-space on --generate, plus standalone
  `qtmesh anim <file> --arm-space <deg> --animation <name> -o out`.
- MCP: arm_space arg on generate_motion + a standalone adjust_arm_space tool.
- GUI: an "Arm space" slider (-30..+45 deg) in the Inspector Animations
  section, shown for a freshly generated clip, applied on release.
- Sentry breadcrumb ai.assist.text_to_motion (arm_space).

Unit tests (AnimationMerger_test.cpp): swing angle ~= requested, per-side
symmetry, idempotence (20 then 10 == 10; 0 restores), and non-arm bones
untouched. Verified end-to-end on the Hip Hop fox via CLI + isometric
renders (widen lifts the arms out, tuck pulls them in).

The same mechanism is the door for future motion-amplitude / hip-sway /
stance-width knobs (noted in the issue).

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

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds an absolute, idempotent arm-space post-process for generated and existing animations, exposed through the controller, Inspector slider, CLI, and MCP tools, with rename/regeneration state handling, unit tests, and documentation.

Changes

Arm-space animation controls

Layer / File(s) Summary
Arm-space keyframe adjustment
src/AnimationMerger.*, src/SkeletonTransform.cpp, src/AnimationMerger_test.cpp
Adds bind-frame-based arm and collar rotations, tracks absolute applied angles, migrates state across renames, resets regenerated clips, and tests geometry, reset behavior, unaffected bones, and invalid inputs.
Controller and Inspector integration
src/AnimationControlController.*, qml/PropertiesPanel.qml
Passes arm-space values during generation, exposes adjustment and current-value APIs, returns the generated entity, and adds targeted slider and row controls.
CLI generation and standalone adjustment
src/CLIPipeline.*, CLAUDE.md
Adds --arm-space to generation and existing-animation workflows, including validation, export behavior, examples, and post-process documentation.
MCP arm-space tools
src/MCPServer.*
Adds arm_space to motion generation and introduces adjust_arm_space with entity selection, validation, optional export, response reporting, and tool schema wiring.

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

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant PropertiesPanel
  participant AnimationControlController
  participant AnimationMerger
  participant OgreSkeleton
  User->>PropertiesPanel: Set Arm space slider
  PropertiesPanel->>AnimationControlController: adjustArmSpace(animation, degrees, entity)
  AnimationControlController->>AnimationMerger: adjustArmSpace(skeleton, animation, degrees)
  AnimationMerger->>OgreSkeleton: Update arm and collar keyframe rotations
  OgreSkeleton-->>AnimationControlController: Refresh animation states
  AnimationControlController-->>PropertiesPanel: Notify external animation edit
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.59% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: a Mixamo-style arm-space post-process for issue #854.
Description check ✅ Passed The description is detailed and covers the summary, technical changes, surfaces, tests, and follow-up notes, though it doesn't use the exact template headings.
Linked Issues check ✅ Passed The code matches #854: it adds arm-space post-processing, GUI/CLI/MCP surfaces, idempotent absolute behavior, and tests for direction and invariants.
Out of Scope Changes check ✅ Passed The changes stay focused on arm-space post-processing, docs, UI, and related plumbing with no clearly unrelated additions.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/arm-space-854

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: 908e7dfe6d

ℹ️ 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/AnimationMerger.cpp Outdated
Comment on lines +1323 to +1327
if (auto it = s_applied.find(key); it != s_applied.end())
stored = it->second;
const float delta = degrees - stored;
if (std::abs(delta) < 1e-4f)
return true; // already at target — nothing to do (still success)

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 Invalidate cached arm-space when a clip is regenerated

When the same action is generated again in one session, applyMotionClip removes and recreates the same generated_<action> animation, but this static cache still contains the angle from the previous clip. If the GUI slider or MCP call requests the same arm-space value again, delta becomes 0 and the function returns without rewriting the fresh keyframes, so the newly generated output is left unadjusted while reporting success. Clear the cache when replacing an animation or key it to the actual animation state rather than only (skeleton name, animation name).

Useful? React with 👍 / 👎.

Comment thread src/MCPServer.cpp Outdated
if (!entity)
return makeErrorResult("Error: no matching rigged entity.");

Ogre::SkeletonInstance* skel = entity->getSkeleton();

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 Adjust the master skeleton before exporting MCP output

For adjust_arm_space with output_path, this edits the entity's SkeletonInstance, but the exporter serializes the mesh's master skeleton (entity->getMesh()->getSkeleton() in MeshImporterExporter.cpp), matching what the other animation-edit MCP tools use. In that MCP export scenario the tool can return success while the written file still contains the unadjusted animation. Use the mesh skeleton here before calling AnimationMerger::adjustArmSpace so the exported asset includes the change.

Useful? React with 👍 / 👎.

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

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)

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

Add arm-space commands to built-in usage.

The usage output lists --generate but omits both --generate ... --arm-space and standalone --arm-space ... --animation. Users invoking qtmesh anim without a mode cannot discover this feature.

Proposed usage lines
+        err() << "       qtmesh anim <file> --generate \"<prompt>\" [--arm-space DEG] [-o <output>]" << Qt::endl;
+        err() << "       qtmesh anim <file> --arm-space DEG --animation <name> [-o <output>]" << Qt::endl;
🤖 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 2380 - 2395, Update the built-in usage
output in the mode validation block of CLIPipeline.cpp to document both
arm-space command forms: --generate with --arm-space and standalone --arm-space
with --animation. Keep the existing mode list and other usage entries unchanged,
and ensure the new lines show the required arguments and output option
conventions.
🧹 Nitpick comments (1)
src/AnimationControlController.h (1)

338-342: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Doc comment doesn't mention the new armSpaceDeg parameter.

The Doxygen comment above generateMotion documents prompt, duration, and useModel in detail but says nothing about the new armSpaceDeg parameter (its range, default, or that it triggers AnimationMerger::adjustArmSpace).

📝 Proposed doc addition
     /// `useModel` opts into the EXPERIMENTAL trained text-to-motion model
     /// (MotionGenerator/ONNX); it falls back to the template library automatically
     /// when the model is unavailable or the action isn't in its vocab. Default
     /// false = the reliable template-clip retarget.
+    /// `armSpaceDeg` (`#854`): optional Mixamo-style arm-space post-process applied
+    /// right after retargeting, same semantics as adjustArmSpace(). Default 0 = no
+    /// adjustment. Failures (e.g. no arm roles on this rig) are currently silent.
     Q_INVOKABLE QVariantMap generateMotion(const QString& prompt,
                                            double duration = 0.0,
                                            bool useModel = false,
                                            double armSpaceDeg = 0.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.h` around lines 338 - 342, Update the Doxygen
comment for AnimationControlController::generateMotion to document armSpaceDeg,
including its valid range, default value of 0.0, and that it triggers
AnimationMerger::adjustArmSpace.
🤖 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 `@CLAUDE.md`:
- Line 379: Correct the Arm-space documentation in CLAUDE.md to state that
applying +45° followed by −45° results in −45° absolute arm-space, not the base
pose; document that a subsequent 0° call restores the base clip while the
session-tracked state remains active.

In `@src/AnimationControlController.cpp`:
- Around line 1662-1689: Update AnimationControlController::adjustArmSpace to
record this GUI slider action under the ui.action breadcrumb category instead of
ai.assist.text_to_motion. Add or reuse a user-facing status signal analogous to
generateMotionStatus, and emit an appropriate error message on every
early-return failure, including adjustment failure, so callers receive feedback
even when they ignore the boolean result. Preserve the existing successful
refresh and notification flow.
- Around line 1792-1796: The applyMotionClip() regeneration path must invalidate
AnimationMerger::adjustArmSpace()’s cached angle for the recreated
generated_<action> animation before applying the current arm-space adjustment.
Update the cache reset or keying logic tied to the existing skeleton and
animName symbols so repeated generation with a non-zero armSpaceDeg always
adjusts the newly rebuilt clip.

In `@src/AnimationMerger_test.cpp`:
- Around line 1030-1031: Update the angle conversion expression in
AnimationMerger_test.cpp to remove the non-portable M_PI reference, using the
project’s shared pi constant if available or std::acos(-1.0f) as the fallback.
Preserve the existing degree conversion behavior.
- Around line 1034-1043: Strengthen the symmetry assertions in the test around
AnimationMerger::adjustArmSpace by validating the expected mirrored components
of lWide relative to lBase, not only the angular magnitude angL. Add checks for
the relevant vector signs/values on the symmetric rig so opposite arm directions
are enforced and a same-direction or sign-inverted rotation fails.
- Around line 975-976: Update the arm-space tests around
AnimationMergerArmSpaceTest so Ogre initialization and mesh-file availability
are validated before makeArmRig() invokes SkeletonManager::create(). Prefer
converting these tests to TEST_F(AnimationMergerTest, ...) so
AnimationMergerTest::SetUp() provides the guards, or add equivalent local
assertions to each affected test.

In `@src/AnimationMerger.cpp`:
- Around line 1320-1325: The static s_applied state in the animation adjustment
logic is neither persistent across CLI invocations nor isolated between skeleton
instances. Replace name-based static tracking with recoverable base/adjustment
state persisted alongside the exported animation, or explicitly make the API
live-instance-only and compute standalone CLI changes as deltas; ensure applying
an absolute value such as 0° restores a previously exported adjustment and
distinct skeleton instances cannot share state.

In `@src/AnimationMerger.h`:
- Around line 230-234: Update the documentation near
AnimationMerger::adjustArmSpace and its implementation to accurately describe
the session-local static map used to store the applied angle instead of
UserObjectBindings. Clarify that restoration state does not survive
export/reload, while preserving the documented absolute-target and idempotent
behavior within the current session.

In `@src/MCPServer.cpp`:
- Around line 4122-4127: Capture the boolean result of
AnimationMerger::adjustArmSpace in the embedded generate_motion post-process and
expose it as content["arm_space_applied"] in the MCP response. Ensure the field
reflects whether the requested arm-space adjustment succeeded, including false
when no adjustment is requested or the rig lacks applicable arm roles, while
preserving the existing success response.
- Around line 4173-4239: Update toolAdjustArmSpace to record MCP invocations
under the ai.tool_call breadcrumb category instead of ai.assist.text_to_motion.
Improve the missing-entity error branching so an omitted entity_name reports no
skinned mesh found, while a supplied but unmatched name identifies that entity.
Align the skeleton accessor used for AnimationMerger::adjustArmSpace with the
GUI/AnimationControlController::adjustArmSpace path, using the master mesh
skeleton as required.
- Line 652: Update SERVER_VERSION in MCPServer.h from 1.9.0 to the appropriate
bumped version to reflect the newly registered adjust_arm_space tool in
MCPServer::toolAdjustArmSpace, leaving the tool registration unchanged.

---

Outside diff comments:
In `@src/CLIPipeline.cpp`:
- Around line 2380-2395: Update the built-in usage output in the mode validation
block of CLIPipeline.cpp to document both arm-space command forms: --generate
with --arm-space and standalone --arm-space with --animation. Keep the existing
mode list and other usage entries unchanged, and ensure the new lines show the
required arguments and output option conventions.

---

Nitpick comments:
In `@src/AnimationControlController.h`:
- Around line 338-342: Update the Doxygen comment for
AnimationControlController::generateMotion to document armSpaceDeg, including
its valid range, default value of 0.0, and that it triggers
AnimationMerger::adjustArmSpace.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2667956f-465c-4379-9a7b-502f76f71675

📥 Commits

Reviewing files that changed from the base of the PR and between 14c2475 and 908e7df.

📒 Files selected for processing (11)
  • CLAUDE.md
  • qml/PropertiesPanel.qml
  • 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/MCPServer.h

Comment thread CLAUDE.md Outdated
Comment on lines +1662 to +1689
bool AnimationControlController::adjustArmSpace(const QString& animName,
double degrees)
{
Manager* mgr = Manager::getSingletonPtr();
if (!mgr) return false;
Ogre::Entity* entity = nullptr;
for (auto* e : mgr->getEntities()) {
if (!e || e->getMovableType() != "Entity" || !e->hasSkeleton()) continue;
if (m_selectedEntityName.empty()
|| e->getName() == m_selectedEntityName) { entity = e; break; }
}
if (!entity) return false;
Ogre::SkeletonPtr skel = entity->getMesh()->getSkeleton();
const std::string an = animName.toStdString();
if (!skel || !skel->hasAnimation(an)) return false;

SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.text_to_motion"),
QStringLiteral("GUI arm_space %1 deg").arg(degrees));
if (!AnimationMerger::adjustArmSpace(skel.get(), an,
static_cast<float>(degrees)))
return false;

// Refresh the viewport (the animation state's keyframes changed underneath
// it) exactly as the generate path does.
entity->refreshAvailableAnimationState();
notifyExternalAnimationEdit();
return true;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Sentry breadcrumb category mismatch, and silent failure with no user feedback.

Two issues in this new method:

  1. The breadcrumb uses QStringLiteral("ai.assist.text_to_motion"), but this is a GUI slider-release action, not text-to-motion generation. As per path instructions, **/*.{cpp,h}: "use ui.action for toolbar/menu clicks, ai.tool_call for MCP invocations, and file.import/file.export for I/O." Grouping arm-space slider events under the text-to-motion category will muddy Sentry breadcrumb trails for both features.
  2. Every early-return path (!mgr, !entity, !skel/!hasAnimation, !AnimationMerger::adjustArmSpace(...)) just returns false with no signal emitted. Unlike generateMotion, which has generateMotionStatus(message, isError) for user feedback, adjustArmSpace has no equivalent — and the QML caller (armSpaceSlider.onPressedChanged) doesn't check the return value either. So if the adjustment silently fails (e.g. rig has no arm roles, animation renamed), the user gets zero indication anything went wrong; the slider just looks like it worked.
🐛 Proposed fix (category rename shown; consider adding a status signal for `#2`)
-    SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.text_to_motion"),
+    SentryReporter::addBreadcrumb(QStringLiteral("ui.action"),
         QStringLiteral("GUI arm_space %1 deg").arg(degrees));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
bool AnimationControlController::adjustArmSpace(const QString& animName,
double degrees)
{
Manager* mgr = Manager::getSingletonPtr();
if (!mgr) return false;
Ogre::Entity* entity = nullptr;
for (auto* e : mgr->getEntities()) {
if (!e || e->getMovableType() != "Entity" || !e->hasSkeleton()) continue;
if (m_selectedEntityName.empty()
|| e->getName() == m_selectedEntityName) { entity = e; break; }
}
if (!entity) return false;
Ogre::SkeletonPtr skel = entity->getMesh()->getSkeleton();
const std::string an = animName.toStdString();
if (!skel || !skel->hasAnimation(an)) return false;
SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.text_to_motion"),
QStringLiteral("GUI arm_space %1 deg").arg(degrees));
if (!AnimationMerger::adjustArmSpace(skel.get(), an,
static_cast<float>(degrees)))
return false;
// Refresh the viewport (the animation state's keyframes changed underneath
// it) exactly as the generate path does.
entity->refreshAvailableAnimationState();
notifyExternalAnimationEdit();
return true;
}
bool AnimationControlController::adjustArmSpace(const QString& animName,
double degrees)
{
Manager* mgr = Manager::getSingletonPtr();
if (!mgr) return false;
Ogre::Entity* entity = nullptr;
for (auto* e : mgr->getEntities()) {
if (!e || e->getMovableType() != "Entity" || !e->hasSkeleton()) continue;
if (m_selectedEntityName.empty()
|| e->getName() == m_selectedEntityName) { entity = e; break; }
}
if (!entity) return false;
Ogre::SkeletonPtr skel = entity->getMesh()->getSkeleton();
const std::string an = animName.toStdString();
if (!skel || !skel->hasAnimation(an)) return false;
SentryReporter::addBreadcrumb(QStringLiteral("ui.action"),
QStringLiteral("GUI arm_space %1 deg").arg(degrees));
if (!AnimationMerger::adjustArmSpace(skel.get(), an,
static_cast<float>(degrees)))
return false;
// Refresh the viewport (the animation state's keyframes changed underneath
// it) exactly as the generate path does.
entity->refreshAvailableAnimationState();
notifyExternalAnimationEdit();
return true;
}
🤖 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 1662 - 1689, Update
AnimationControlController::adjustArmSpace to record this GUI slider action
under the ui.action breadcrumb category instead of ai.assist.text_to_motion. Add
or reuse a user-facing status signal analogous to generateMotionStatus, and emit
an appropriate error message on every early-return failure, including adjustment
failure, so callers receive feedback even when they ignore the boolean result.
Preserve the existing successful refresh and notification flow.

Source: Path instructions

Comment thread src/AnimationControlController.cpp
Comment thread src/AnimationMerger_test.cpp Outdated
Comment thread src/AnimationMerger_test.cpp Outdated
Comment thread src/AnimationMerger.cpp Outdated
Comment thread src/AnimationMerger.h Outdated
Comment thread src/MCPServer.cpp
{QStringLiteral("bake_animation_fps"), &MCPServer::toolBakeAnimationFps},
{QStringLiteral("motion_in_between"), &MCPServer::toolMotionInBetween},
{QStringLiteral("generate_motion"), &MCPServer::toolGenerateMotion},
{QStringLiteral("adjust_arm_space"), &MCPServer::toolAdjustArmSpace},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check whether SERVER_VERSION was bumped alongside the new adjust_arm_space tool.
rg -n "SERVER_VERSION" src/MCPServer.h src/MCPServer.cpp
git log -1 --format=%H -- src/MCPServer.h src/MCPServer.cpp
git diff HEAD~1 -- src/MCPServer.h src/MCPServer.cpp | rg -n "SERVER_VERSION"

Repository: fernandotonon/QtMeshEditor

Length of output: 350


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the MCP server version definition and the new tool registration context.
sed -n '410,445p' src/MCPServer.h
sed -n '630,675p' src/MCPServer.cpp

# Show the exact diff for the affected files, focusing on version and tool registry changes.
git diff --unified=40 HEAD~1 -- src/MCPServer.h src/MCPServer.cpp | sed -n '1,260p'

# Show whether SERVER_VERSION changed in the parent commit as well.
git show HEAD~1:src/MCPServer.h | sed -n '420,438p'

Repository: fernandotonon/QtMeshEditor

Length of output: 20289


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Current:"
git grep -n "SERVER_VERSION" HEAD -- src/MCPServer.h src/MCPServer.cpp

echo
echo "Parent:"
git show HEAD~1:src/MCPServer.h | grep -n "SERVER_VERSION"

echo
echo "Header diff around version:"
git diff --unified=5 HEAD~1 -- src/MCPServer.h | sed -n '1,120p'

Repository: fernandotonon/QtMeshEditor

Length of output: 1334


Bump SERVER_VERSION for adjust_arm_space. The tool registry changed, but src/MCPServer.h still advertises 1.9.0, so the MCP interface change isn’t reflected in the reported server version.

🤖 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` at line 652, Update SERVER_VERSION in MCPServer.h from
1.9.0 to the appropriate bumped version to reflect the newly registered
adjust_arm_space tool in MCPServer::toolAdjustArmSpace, leaving the tool
registration unchanged.

Source: Path instructions

Comment thread src/MCPServer.cpp Outdated
Comment on lines +4122 to +4127
// #854: optional Mixamo-style arm-space post-process.
const double armSpace = args.value("arm_space").toDouble(0.0);
if (std::abs(armSpace) > 1e-4)
AnimationMerger::adjustArmSpace(skel.get(), animName,
static_cast<float>(armSpace));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Return value of AnimationMerger::adjustArmSpace ignored in the embedded generate_motion post-process.

Same issue as the GUI's generateMotion (AnimationControlController.cpp): if the rig has no arm roles, adjustArmSpace fails silently here, yet the tool's success response (content["ok"] = true, no arm_space/armSpaceApplied field) gives an MCP caller no way to detect that their arm_space request had no effect — they'd have to separately call adjust_arm_space to find out. Worth at least echoing an arm_space_applied bool in the response content.

🤖 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 4122 - 4127, Capture the boolean result of
AnimationMerger::adjustArmSpace in the embedded generate_motion post-process and
expose it as content["arm_space_applied"] in the MCP response. Ensure the field
reflects whether the requested arm-space adjustment succeeded, including false
when no adjustment is requested or the rig lacks applicable arm roles, while
preserving the existing success response.

Comment thread src/MCPServer.cpp
fernandotonon and others added 3 commits July 11, 2026 23:11
…#854)

CI caught the arm-space unit suite SIGSEGV-ing: the tests drove a bare
Ogre::Skeleton (apply()/_updateTransforms() on an unloaded skeleton
crashes). Rebuilt them as TEST_F on the fixture, driving a real
SkeletonInstance from a loaded Entity (createInMemoryMesh) — the same
object the runtime uses. Coverage unchanged: widen/tuck angle, per-side
symmetry, idempotence + absolute (via currentArmSpace), non-arm-bone
invariance, missing-animation no-op.

GUI improvements from user testing:
- The slider value no longer bakes into generation — Generate always
  produces a CLEAN clip; arm-space is purely a post-adjustment. A leftover
  slider value was silently skewing every newly generated clip.
- The slider works on ANY animation, not just generated ones: each
  animation row has a "↔" button that targets the slider at that clip
  (seeded with the clip's real applied angle via the new
  AnimationMerger::currentArmSpace / controller getter). The edit sticks
  to its clip (exports widened); switching/​reselecting just detaches.
- Live update while dragging (absolute+idempotent, so re-applying per
  degree never accumulates), and it now re-poses the mesh even when the
  clip is PAUSED — _notifyDirty + re-stamp the state's time so a stopped
  clip refreshes immediately instead of freezing on the pre-edit pose.
- Generate prompt field: force-focus on press (mouse.accepted=false so
  selectByMouse still positions the caret) — after using the slider/arm
  buttons the field wouldn't re-focus.

Core adjustArmSpace unchanged; the applied-angle map moved to file scope
so currentArmSpace() can read it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Renaming a clip orphaned its arm-space widen/tuck value: the applied-angle
map is keyed by (skeleton, animation name), so after a rename
currentArmSpace() reported 0 for the renamed clip even though its keyframes
were still widened — and the next slider drag then computed an
absolute-from-0 delta and over-rotated.

Added AnimationMerger::migrateArmSpaceKey(skeletonName, old, new) and call
it from BOTH rename paths — AnimationMerger::renameAnimation (CLI/merge) and
SkeletonTransform::renameAnimation (the GUI Inspector double-click rename) —
right where the keyframes are copied to the new name. SkeletonInstance::
getName() returns the shared skeleton's name, so the key matches what
adjustArmSpace stored regardless of which path set it.

Unit test: adjust to 25°, rename, confirm the angle follows to the new name
(and the old key is cleared), and that a follow-up adjust to 0 on the new
name correctly restores the bind pose (proving the delta is computed from
25, not 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code review (CodeRabbit + Codex) on PR #867:

- Stale cache on regenerate (Codex P2): applyMotionClip now erases the
  arm-space map entry when it removes/recreates a generated_* clip, so
  re-requesting the same angle on the fresh keyframes isn't a delta-0 no-op.
- MCP export used the SkeletonInstance (Codex P2): adjust_arm_space now
  edits the mesh's MASTER skeleton — the exporter serializes the master,
  so output_path files now actually contain the adjustment.
- Breadcrumb categories: GUI adjustArmSpace → ui.action; CLI + MCP
  arm-space → ai.tool_call (per repo conventions; was text_to_motion).
- MCP generate_motion echoes arm_space_applied so a caller can tell the
  rig had no arm roles; adjust_arm_space now gives the two-branch
  entity-not-found message (omitted vs non-matching) like generate_motion.
- Docs: corrected the CLAUDE.md absolute-angle example (+45 then −45 = −45,
  only 0 restores base) and the header contract (session-local static map,
  not UserObjectBindings; not persisted; CLI-process caveat). Also documents
  the regenerate-clear, rename-migration, live/paused GUI, and any-clip
  targeting added since the first commit.
- Test: WidensAndTucksArms now asserts the arms swing to OPPOSITE sides
  (sagittal-mirror + per-side X sign), catching a sign regression that
  equal magnitudes alone would pass.

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

Copy link
Copy Markdown
Owner Author

Thanks for the thorough review — addressed in ceaedfa:

Codex P2s (both fixed):

  • Stale cache on regenerateapplyMotionClip now erases the arm-space map entry when it removes/recreates a generated_* clip, so a same-angle re-request isn't a delta-0 no-op on fresh keyframes.
  • MCP export used the SkeletonInstanceadjust_arm_space now edits the mesh's master skeleton (what the exporter serializes), so output_path files contain the adjustment.

CodeRabbit:

  • Breadcrumb categories → ui.action (GUI) and ai.tool_call (CLI + MCP).
  • CLAUDE.md absolute-angle example corrected (+45 then −45 = −45; only 0 restores base) and the header doc now describes the session-local static map (not UserObjectBindings) + the CLI-process caveat.
  • MCP generate_motion echoes arm_space_applied; adjust_arm_space gives the two-branch entity-not-found message.
  • Test now asserts the arms swing to opposite sides (sagittal mirror + per-side X sign), not just equal magnitudes.

Considered but kept as-is (by design):

  • Not persistent / not instance-safe (CodeRabbit Major, AnimationMerger.cpp:1325): the applied-angle map is intentionally session-local — arm-space is a live authoring nudge; export bakes the final keyframes, so there's no state to persist. The header doc now spells this out, including that a fresh CLI process sees stored == 0 (each CLI invocation treats the loaded clip as its own baseline). Making it durable would require writing sidecar/animation metadata for a transient UI control — out of scope for this slice.
  • Silent-failure status signal (CodeRabbit, adjustArmSpace): the GUI only exposes the slider when a valid rigged clip is targeted (the button gates on grp.hasSkeleton), so the "no arm roles" path isn't user-reachable there; the MCP tool already returns an explicit error, and generate_motion now echoes arm_space_applied. A dedicated armSpaceStatus signal felt like more surface than the reachable failure modes warrant.

@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

Caution

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

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

1336-1451: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Early "already at target" return skips the arm-role check, violating the documented contract.

adjustArmSpace returns true as soon as abs(delta) < 1e-4f (line ~1359), before boneToCanon/touchedAny are ever computed. But stored defaults to 0.0f for any (skeleton, animation) pair never touched this session, so calling adjustArmSpace(skel, anim, 0.0f) on a fresh clip — e.g. the first CLI/MCP call, or the GUI's own "seed the slider" call — computes delta == 0 and returns true unconditionally, even on a rig with zero arm roles. This contradicts the header's own contract: "Returns false (no-op) if the animation is missing or no arm role resolves on the rig." Downstream callers that branch on this return value (e.g. MCP's "reports whether arm roles were affected") would get a false-positive on non-humanoid rigs.

Move the boneToCanon computation (and a role-presence check) ahead of the early-return so it can still report false correctly regardless of delta.

🐛 Proposed fix
     const std::pair<std::string, std::string> key(skel->getName(), animName);
     float stored = 0.0f;
     if (auto it = g_armSpaceApplied.find(key); it != g_armSpaceApplied.end())
         stored = it->second;
     const float delta = degrees - stored;
-    if (std::abs(delta) < 1e-4f)
-        return true;   // already at target — nothing to do (still success)
-
-    // Bone → canonical role (same matcher as the retarget).
-    const int nBones = static_cast<int>(skel->getNumBones());
-    std::vector<int> boneToCanon(static_cast<size_t>(nBones), -1);
-    for (int i = 0; i < nBones; ++i)
-        boneToCanon[static_cast<size_t>(i)] =
-            MotionInbetween::canonicalIndexForBone(QString::fromStdString(
-                skel->getBone(static_cast<unsigned short>(i))->getName()));
+
+    // Bone → canonical role (same matcher as the retarget) — computed up
+    // front so the "already at target" fast path can still correctly
+    // report false on a rig with no arm role, regardless of delta.
+    const int nBones = static_cast<int>(skel->getNumBones());
+    std::vector<int> boneToCanon(static_cast<size_t>(nBones), -1);
+    for (int i = 0; i < nBones; ++i)
+        boneToCanon[static_cast<size_t>(i)] =
+            MotionInbetween::canonicalIndexForBone(QString::fromStdString(
+                skel->getBone(static_cast<unsigned short>(i))->getName()));
+    const bool hasArmRole = std::any_of(boneToCanon.begin(), boneToCanon.end(),
+        [](int c) { return c == 6 || c == 7 || c == 10 || c == 11; });
+    if (!hasArmRole)
+        return false;   // no arm role on this rig
+    if (std::abs(delta) < 1e-4f)
+        return true;   // already at target — nothing to do (still success)

Consider adding a test for this exact edge case (a rig with no shoulder/collar roles, calling adjustArmSpace(skel, "clip", 0.0f) on a fresh session) to lock in the fix.

🤖 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 1336 - 1451, Move the boneToCanon
computation in adjustArmSpace ahead of the delta early return, then verify that
at least one shoulder or collar role resolves before returning true for an
already-targeted value. Preserve false for rigs without arm roles, including
fresh zero-degree calls, while retaining the existing adjustment flow for valid
arm-role rigs.
🤖 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 `@qml/PropertiesPanel.qml`:
- Around line 7967-7986: Update setArmSpaceTarget so it assigns
armSpaceSlider.lastApplied to cur before assigning armSpaceSlider.value,
preventing the synchronous onValueChanged handler from treating retargeting as a
user adjustment. Preserve the existing target assignment and currentArmSpace
lookup behavior.

---

Outside diff comments:
In `@src/AnimationMerger.cpp`:
- Around line 1336-1451: Move the boneToCanon computation in adjustArmSpace
ahead of the delta early return, then verify that at least one shoulder or
collar role resolves before returning true for an already-targeted value.
Preserve false for rigs without arm roles, including fresh zero-degree calls,
while retaining the existing adjustment flow for valid arm-role rigs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9193305d-fcdf-468d-9b7b-a6732bf61140

📥 Commits

Reviewing files that changed from the base of the PR and between 908e7df and ceaedfa.

📒 Files selected for processing (10)
  • CLAUDE.md
  • qml/PropertiesPanel.qml
  • src/AnimationControlController.cpp
  • src/AnimationControlController.h
  • src/AnimationMerger.cpp
  • src/AnimationMerger.h
  • src/AnimationMerger_test.cpp
  • src/CLIPipeline.cpp
  • src/MCPServer.cpp
  • src/SkeletonTransform.cpp
✅ Files skipped from review due to trivial changes (1)
  • CLAUDE.md
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/AnimationControlController.h
  • src/MCPServer.cpp
  • src/CLIPipeline.cpp

Comment thread qml/PropertiesPanel.qml
Comment on lines +7967 to +7986
// #854: point the arm-space slider at a clip. The adjustment
// STICKS to that clip (so it exports widened) and is independent
// per clip. Generation always produces a CLEAN clip (slider value
// never bakes into it), and the slider is seeded with the target
// clip's ACTUAL current angle — so switching clips shows the truth
// and a fresh generate shows 0. Closing the panel / reselecting
// just detaches the slider (no revert; the clip keeps its edit).
function setArmSpaceTarget(animName, entity) {
armSpaceAnim = animName
armSpaceEntity = entity
var cur = AnimationControlController.currentArmSpace(animName, entity)
armSpaceSlider.value = cur
armSpaceSlider.lastApplied = cur
}
function detachArmSpace() {
armSpaceAnim = ""
armSpaceEntity = ""
armSpaceSlider.value = 0
armSpaceSlider.lastApplied = 0
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

setArmSpaceTarget triggers a spurious adjustArmSpace call when just retargeting the slider.

armSpaceSlider.value = cur is set before armSpaceSlider.lastApplied = cur. Since onValueChanged fires synchronously on the value assignment, it compares the new cur against the stale lastApplied (from whatever clip was previously targeted) and — if they differ — fires AnimationControlController.adjustArmSpace(...) even though this call is only meant to seed the slider with the clip's already-applied value. It's harmless in practice (the C++ side computes delta == 0 and no-ops), but it's a wasted round-trip plus an extra ui.action Sentry breadcrumb every time the user clicks the ↔ button or generates a new clip, none of which represents a real user edit. Compare with detachArmSpace() just below, which correctly clears armSpaceAnim before touching value.

🐛 Proposed fix
 function setArmSpaceTarget(animName, entity) {
     armSpaceAnim = animName
     armSpaceEntity = entity
     var cur = AnimationControlController.currentArmSpace(animName, entity)
-    armSpaceSlider.value = cur
     armSpaceSlider.lastApplied = cur
+    armSpaceSlider.value = cur
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// #854: point the arm-space slider at a clip. The adjustment
// STICKS to that clip (so it exports widened) and is independent
// per clip. Generation always produces a CLEAN clip (slider value
// never bakes into it), and the slider is seeded with the target
// clip's ACTUAL current angle — so switching clips shows the truth
// and a fresh generate shows 0. Closing the panel / reselecting
// just detaches the slider (no revert; the clip keeps its edit).
function setArmSpaceTarget(animName, entity) {
armSpaceAnim = animName
armSpaceEntity = entity
var cur = AnimationControlController.currentArmSpace(animName, entity)
armSpaceSlider.value = cur
armSpaceSlider.lastApplied = cur
}
function detachArmSpace() {
armSpaceAnim = ""
armSpaceEntity = ""
armSpaceSlider.value = 0
armSpaceSlider.lastApplied = 0
}
// `#854`: point the arm-space slider at a clip. The adjustment
// STICKS to that clip (so it exports widened) and is independent
// per clip. Generation always produces a CLEAN clip (slider value
// never bakes into it), and the slider is seeded with the target
// clip's ACTUAL current angle — so switching clips shows the truth
// and a fresh generate shows 0. Closing the panel / reselecting
// just detaches the slider (no revert; the clip keeps its edit).
function setArmSpaceTarget(animName, entity) {
armSpaceAnim = animName
armSpaceEntity = entity
var cur = AnimationControlController.currentArmSpace(animName, entity)
armSpaceSlider.lastApplied = cur
armSpaceSlider.value = cur
}
function detachArmSpace() {
armSpaceAnim = ""
armSpaceEntity = ""
armSpaceSlider.value = 0
armSpaceSlider.lastApplied = 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 `@qml/PropertiesPanel.qml` around lines 7967 - 7986, Update setArmSpaceTarget
so it assigns armSpaceSlider.lastApplied to cur before assigning
armSpaceSlider.value, preventing the synchronous onValueChanged handler from
treating retargeting as a user adjustment. Preserve the existing target
assignment and currentArmSpace lookup behavior.

fernandotonon and others added 12 commits July 12, 2026 00:11
The fixture rewrite fixed the SIGSEGV; the remaining failures were
over-specified geometry expectations (a T-posed arm swung about the
forward axis reduces |X| rather than increasing it). Rewrote the mirror
check to assert sagittal symmetry + per-side X sign, and added a
temporary stderr trace to the idempotence test to capture the real
vectors from CI (a bare Skeleton can't run locally — no GL).
The deterministic unit tests exposed a real off-by-one bug: adjustArmSpace
rewrote keyframe rotations via TransformKeyFrame::setRotation, which does
NOT invalidate NodeAnimationTrack's interpolation caches. The next apply()
replayed the pre-edit rotations, so each adjust appeared to lag one call
behind — 20° then 10° landed at 20°, and →0 left the clip at 30°. In the
live GUI this was masked by the render loop's continuous re-apply and the
paused re-pose (_notifyDirty), but a single deterministic evaluation caught
it (CI trace: at10 at 20° instead of 10°, restored at 30° instead of base).

Fix: call track->_keyFrameDataChanged() after editing each track so the
next evaluation rebuilds from the new keyframes. Now absolute + idempotent
holds exactly: 20 then 10 == 10, and →0 restores the base pose.

Removed the debug traces and pinned the test assertions to the correct
geometry (a T-posed arm swung about the forward axis moves toward ±Y,
reducing |X|; both arms mirror across the sagittal plane).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The arm-space test failures were a MEASUREMENT artifact, not a production
bug: Ogre's Animation::apply accumulates onto the current pose (node->
rotate, not set), and the armWorldDir helper called apply without resetting
first — so each measurement compounded the previous apply and the pose
appeared to lag one call behind (20 then 10 read as 20°, →0 read as 30°).
Resetting to bind before apply makes every measurement independent; the
absolute/idempotent contract now verifies exactly (20 then 10 == 10, →0 ==
base, mirrored per-side swing).

The core adjustArmSpace was correct throughout — the earlier
_keyFrameDataChanged() call is kept as correct hygiene for direct keyframe
edits (documented in CLAUDE.md) but was not the cause. Removed the debug
traces.

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

Root cause of the persistent unit-test failure: g_armSpaceApplied was a
process-global static keyed by (skeletonName, animName). CI runs each gtest
suite in ONE process (--gtest_filter="AnimationMergerTest.*"), so state
leaked between tests — the idempotence test's first call already saw
stored=30 from the earlier WidensAndTucks test, and the per-call trace
confirmed every delta was computed against a polluted baseline (applied
angle = sum of all-but-last call). This is the same fragility CodeRabbit
flagged as "not instance-safe".

Fix: track the applied angle on the skeleton's bone[0] UserObjectBindings
under a per-animation key ("qtme.armspace.<anim>"). It now lives and dies
with the skeleton instance — isolated across entities AND tests, no global
state. getStoredArmSpace/setStoredArmSpace helpers; currentArmSpace,
adjustArmSpace, the regenerate-clear in applyMotionClip, and
migrateArmSpaceKey (now takes Ogre::Skeleton*) all go through it.

The core swing math was correct all along (the in-function pose trace
tracked keyframes exactly); the bug was purely the shared-state baseline.
Removed all debug traces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The macOS Pack step's create-dmg fails intermittently on CI (it mounts a
disk image and AppleScript-positions icons; the mount/detach races) — seen
blocking PRs #866 and #867 despite no code issue, passing on plain rerun.
Retry up to 3× (detaching stray mounts between tries) and, if it still
fails, fall back to a plain hdiutil UDZO DMG so a transient tooling flake
never blocks the build. The pretty layout is cosmetic; the fallback DMG
installs identically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The hardened Pack step failed with 'attempt: unbound variable' under
set -u: the echo used a unicode ellipsis directly after $attempt
("attempt $attempt…"), and bash tried to expand the multi-byte character
as part of the variable name. Brace the expansion (${attempt}) and use
ASCII punctuation in the retry/fallback echoes. Verified with bash -n and
a set -u loop check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The deterministic macOS Pack failure was github.ref_name being "867/merge"
on a PR: the slash made create-dmg treat "QtMeshEditor-867/merge-MacOS.dmg"
as a path, cd into a nonexistent "QtMeshEditor-867/" dir, and emit the DMG
under the wrong name — so the step failed regardless of retries. (This,
not a create-dmg mount flake, is why macOS failed on every PR run.)

Fix: tr '/' '-' out of ref_name for the DMG basename. Release tags (X.Y.Z)
are unaffected, so the artifact-upload/release steps still match. The
retry + hdiutil fallback added earlier stays as belt-and-suspenders.

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

Copy link
Copy Markdown

@fernandotonon
fernandotonon merged commit 34b68f6 into master Jul 12, 2026
21 checks passed
@fernandotonon
fernandotonon deleted the feat/arm-space-854 branch July 12, 2026 20:44
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.

Retarget post-processing controls: Mixamo-style arm-space slider

1 participant