Skip to content

fix(anim): Blender-rig FBX animation space — isometric --animation empty frames + turntable --animation (#936) - #943

Merged
fernandotonon merged 3 commits into
masterfrom
fix/anim-scale-space-936
Aug 10, 2026
Merged

fix(anim): Blender-rig FBX animation space — isometric --animation empty frames + turntable --animation (#936)#943
fernandotonon merged 3 commits into
masterfrom
fix/anim-scale-space-936

Conversation

@fernandotonon

@fernandotonon fernandotonon commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes #936 on both fronts.

The bug — animated renders empty while static works

Blender-exported rigs (Quaternius KnightCharacter.fbx, Trex.fbx) carry a static ×100 scale on the armature node, and the exporter re-expresses that scale in every animation curve. Two import bugs compounded so the animated pose blew up ~100× and left the camera frame (static bind renders were self-consistent and fine):

  1. AnimationProcessor stored scale keys raw. Ogre applies keyframe scale multiplicatively on the bind pose, so a key equal to the node's bind scale double-applies it (bind ×100 × key ×100). Scale keys are now stored relative to the channel node's bind scale — the space the keys actually live in — mirroring the existing position/rotation delta conversion. Regression unit test included.
  2. BoneProcessor mixed bind spaces. The root bone's bind used the armature node's raw local mTransformation, while every child bind derives from the offset matrices (which are mesh-node-relative). The root bind is now meshNodeWorld⁻¹ · armatureNodeWorld, making the whole chain agree with the node-local animation keys. For Mixamo-style rigs this reduces to the old value (verified bit-identical output).

The feature — turntable --animation (the issue's original ask)

  • --animation <name>: frame i sampled at length·i/(frames−1), camera fixed at the front; --orbit combines rotation + animation.
  • --at <seconds>: normal orbit of a single posed frame (pose thumbnail).
  • Clear error (exit 1) for an unknown animation name.

Verification

check before after
Knight HumanArmature|Walking isometric (4×4) 0 non-bg px 290,386 px, walk cycle visible in all directions
Trex Armature|TRex_Attack isometric (4×4) 228 stray px 771,756 px (169,709 at final)
Rumba Dancing (Mixamo) animated isometric 458,438 px 458,438 px — bit-identical
Knight posed bounds (qtmesh pose) 6.6 units, −20 Z off-frame matches bind bounds
Quaternius woman + Hip Hop Dancing renders non-empty, sane
Pure-data unit tests (incl. new scale-space test) 62/62 green

White silhouettes in headless sheets are the separate shaded-rendering issue (#933).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added animation sampling for turntable renders, including selecting an animation, rendering at a specific timestamp, and sampling across its duration.
    • Added an option to combine animation playback with camera orbiting or keep the camera fixed.
    • Added usage examples for animated and timestamp-based turntable rendering.
  • Bug Fixes
    • Improved skeletal animation transforms for models with scaled nodes.
    • Corrected root bone positioning relative to mesh placement.
  • Tests
    • Added regression coverage for animation scale handling.

#936)

isometric --animation (and pose/GUI playback) rendered EMPTY frames on
Blender-exported rigs (Quaternius KnightCharacter/Trex): the armature node
carries a static x100 scale that the exporter re-expresses in every
animation curve, and two import bugs blew the animated pose ~100x out of
the camera frame while the static bind rendered fine:

- AnimationProcessor: scale keys were stored RAW, but Ogre applies keyframe
  scale multiplicatively on the bind pose — a key equal to the node's bind
  scale double-applied it. Scale keys are now stored relative to the channel
  NODE's bind scale (the space the keys live in), mirroring the existing
  position/rotation delta conversion. Regression test added.
- BoneProcessor: the root bone's bind used the armature node's raw local
  mTransformation while every child bind derives from the offset matrices
  (mesh-node-relative), so the two disagreed by every ancestor transform.
  The root bind is now meshNodeWorld^-1 * armatureNodeWorld — identical to
  the old value for Mixamo-style rigs (verified bit-identical renders).

Also ships the issue's second ask: qtmesh turntable --animation <name>
(sample the clip across its duration, fixed front camera; --orbit combines
rotation+animation) and --at <seconds> (normal orbit of a single posed
frame).

Verified: KnightCharacter Walking + Trex Attack isometric sheets now render
in all directions (0 px -> 290k/170k px); Rumba Dancing renders stay
bit-identical; posed bounds match bind bounds; 62 pure-data tests +
turntable modes + bad-name error paths green.

Fixes #936

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

coderabbitai Bot commented Aug 10, 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 Plus

Run ID: 4221d0d4-79f5-4e7d-b687-0ac2057f6304

📥 Commits

Reviewing files that changed from the base of the PR and between d8bdcb0 and 49f87e4.

📒 Files selected for processing (3)
  • src/Assimp/AnimationProcessor.cpp
  • src/CLIPipeline.cpp
  • src/ModelTurntableRenderer.cpp
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/ModelTurntableRenderer.cpp
  • src/CLIPipeline.cpp

📝 Walkthrough

Walkthrough

The change adds qtmesh turntable support for named animation sampling, optional camera orbiting, and fixed-time poses. Skeletal import now converts animation and root bone transforms relative to node and mesh bind transforms. Regression coverage validates scaled animation keys.

Changes

Animated turntable rendering

Layer / File(s) Summary
Skeletal bind-space transforms
src/Assimp/AnimationProcessor.cpp, src/Assimp/AnimationProcessor_test.cpp, src/Assimp/BoneProcessor.*
Animation keys use node bind-scale mappings. Root bone transforms use mesh-node-relative world transforms. The test verifies relative scale keys.
Turntable animation options
src/ModelTurntableRenderer.h, src/CLIPipeline.cpp, CLAUDE.md
TurntableOptions and the CLI support --animation, --orbit, and non-negative --at values. Usage examples describe the new modes.
Animation pose sampling
src/ModelTurntableRenderer.cpp
The renderer validates animation states, applies fixed or duration-based samples, controls front-facing or orbiting cameras, and disables the sampled state after rendering.

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

Sequence Diagram(s)

sequenceDiagram
  participant CLI
  participant ModelTurntableRenderer
  participant OgreAnimationState
  participant Camera
  CLI->>ModelTurntableRenderer: pass animationName, orbitWithAnimation, atSeconds
  ModelTurntableRenderer->>OgreAnimationState: enable and set sampled time
  ModelTurntableRenderer->>Camera: keep front view or apply orbit
  ModelTurntableRenderer->>OgreAnimationState: disable sampled state after rendering
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the animation-space fix and the new turntable animation support.
Description check ✅ Passed The description includes a clear summary, technical details, verification results, and coverage of the requested animation features.
Linked Issues check ✅ Passed The implementation addresses all coding requirements in #936, including animation sampling, fixed or orbiting cameras, posed frames, and unknown-name errors.
Out of Scope Changes check ✅ Passed The changes remain within scope by fixing animation-space imports and adding the requested turntable animation controls.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/anim-scale-space-936

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: 83904ec7e7

ℹ️ 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 on lines +38 to 40
const Ogre::Matrix4 rootBoneGlobalTransformation =
meshWorldInv * nodeWorldTransform(bone->mNode->mParent);
applyTransformation(bone->mNode->mParent->mName.C_Str(), rootBoneGlobalTransformation);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Convert root animation keys into the re-rooted bind space

When the mesh-bearing node has a transform relative to the armature, this changes the root bind from the armature's local transform to meshWorldInv * armatureWorld, but AnimationProcessor::processAnimationChannel still subtracts node-local position keys from bone->getPosition() and performs the analogous rotation conversion. Consequently, even a key equal to the source bind transform produces a non-identity delta—for example, a mesh at X=5 and an armature at X=10 yields a root bind at X=5 but a +5 animation translation—so such rigs shift or rotate incorrectly during every animation. The root channel keys need to be converted into the same mesh-relative space, or their local bind reference must be retained separately.

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.

Fixed in the follow-up commit — position/rotation keys are now mapped through the constant space-change prefix C = OgreBindLocal · NodeBindLocal⁻¹ before the delta conversion, so a key equal to the source bind produces an identity delta even when the root was re-rooted (your mesh-at-X=5 example included). C is exactly identity when the two binds agree, and Rumba (Mixamo) renders stay bit-identical. Knight posed bounds now match bind bounds exactly.

Comment on lines +552 to +555
// #936: --at poses once, before framing bounds matter for the orbit.
if (animState && !wantsAnimation && options.atSeconds >= 0.0f)
applyTurntableAnimationFrame(animatedEntity, animState,
std::min(options.atSeconds, animLength));

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 Frame the posed animation rather than the bind pose

For --at, the animation is applied only after bounds was computed and the entities were recentered from the bind pose at lines 529-538; sampled --animation frames likewise reuse those stale bounds. On clips with root motion or poses extending beyond the bind AABB, placeCameraOnAxis therefore aims and zooms around the bind pose, causing the requested pose or later frames to be off-center or clipped. Compute posed bounds before framing --at, and use bounds encompassing the sampled clip for animated sheets.

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.

Acknowledged but keeping rest-bounds framing deliberately: Ogre entity AABBs are derived from the mesh's bind bound (skinned excursions aren't reflected), so truly posed bounds would need a software-skinned vertex readback per frame — the same territory as the #933 shaded-rendering work, where it's better addressed once. Rest-bounds framing with the 1.25 auto-fit padding is also the isometric renderer's established behavior (stable, non-jittering orbit), and our imported clips keep the root in place. Tracking the posed-bounds idea for #933.

Codex P1: with the mesh-node-relative root bind, a key equal to the node's
bind transform must still produce an identity delta. Position/rotation keys
now pass through C = OgreBindLocal * NodeBindLocal^-1 before the delta
conversion — exactly identity for every bone whose two binds agree (all
non-re-rooted bones, Mixamo rigs; Rumba renders stay bit-identical).
Knight posed bounds now match bind exactly.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/Assimp/AnimationProcessor_test.cpp`:
- Around line 33-35: Update AnimationProcessorTest to use the shared Ogre test
fixture instead of constructing Ogre::Root directly. In the fixture SetUp(),
call ASSERT_TRUE(tryInitOgre()) and ASSERT_TRUE(canLoadMeshFiles()) so missing
Ogre prerequisites fail clearly in CI, then remove the test-local Root
initialization.

In `@src/Assimp/AnimationProcessor.cpp`:
- Around line 230-249: Validate and normalize zero components of nodeBindScale
immediately after decomposing the channel node, before constructing or inverting
nodeBind. When the bind scale is non-invertible, skip the space-change mapping
or apply the existing defined fallback, ensuring nodeBind.inverse() is never
called with a zero scale.

In `@src/Assimp/BoneProcessor.cpp`:
- Around line 28-40: Add a single SentryReporter::addBreadcrumb call before the
outer mesh loop describing the mesh-relative root-bind conversion performed by
this BoneProcessor flow. Keep it at loop scope rather than adding breadcrumbs
inside the per-bone processing path.

In `@src/CLIPipeline.cpp`:
- Around line 4570-4584: Update the --animation and --at handling in the CLI
argument parser to return usage error 2 when no following argument exists or
when the next token is another option. Preserve the existing animation
assignment and --at numeric/non-negative validation for valid values, and ensure
neither option falls through to static rendering.

In `@src/ModelTurntableRenderer.cpp`:
- Around line 552-555: Move the `applyTurntableAnimationFrame` call for the
`--at` pose to before `refreshEntityBounds()` in the turntable rendering flow.
Ensure `refreshEntityBounds()` and the subsequent `placeCameraOnAxis()` use
bounds calculated from the applied pose, while preserving the existing animation
and `options.atSeconds` conditions.
- Around line 519-523: Update the turntable rendering flow around the
animation-state setup to snapshot every state’s enabled, looping, and
time-position values before disabling them, then restore those values—including
the sampled clip—on success, error returns, and every catch path. Before
sampling the final frame, make a looping selected clip non-looping so its time
clamps at animLength rather than wrapping to zero.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: dc5e7d76-9498-486a-a9fe-d29bdc18745c

📥 Commits

Reviewing files that changed from the base of the PR and between 33945c7 and d8bdcb0.

📒 Files selected for processing (8)
  • CLAUDE.md
  • src/Assimp/AnimationProcessor.cpp
  • src/Assimp/AnimationProcessor_test.cpp
  • src/Assimp/BoneProcessor.cpp
  • src/Assimp/BoneProcessor.h
  • src/CLIPipeline.cpp
  • src/ModelTurntableRenderer.cpp
  • src/ModelTurntableRenderer.h

Comment thread src/Assimp/AnimationProcessor_test.cpp
Comment thread src/Assimp/AnimationProcessor.cpp
Comment thread src/Assimp/BoneProcessor.cpp
Comment thread src/CLIPipeline.cpp Outdated
Comment on lines +519 to +523
// Only the sampled clip drives the pose — disable everything else.
if (Ogre::AnimationStateSet *states = animatedEntity->getAllAnimationStates())
for (const auto &entry : states->getAnimationStates())
if (entry.second)
entry.second->setEnabled(false);

@coderabbitai coderabbitai Bot Aug 10, 2026

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 | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n -C 3 'setLoop\(|getLoop\(|setTimePosition\(|getTimePosition\(' src
fd -a -i 'CMakeLists.txt' . -x rg -n 'OGRE|Ogre' {}

Repository: fernandotonon/QtMeshEditor

Length of output: 48123


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- ModelTurntableRenderer outline ---\n'
ast-grep outline src/ModelTurntableRenderer.cpp --match 'renderTurntable' --view expanded || true

printf '\n--- ModelTurntableRenderer relevant lines 400-590 ---\n'
sed -n '400,590p' src/ModelTurntableRenderer.cpp | nl -ba -v400

printf '\n--- Search for turntable renderer usages/API ---\n'
rg -n -C 4 'turntable|Turntable|ModelTurntableRenderer|createTurntable|turntable' src

Repository: fernandotonon/QtMeshEditor

Length of output: 255


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- ModelTurntableRenderer outline ---'
ast-grep outline src/ModelTurntableRenderer.cpp --match 'renderTurntable' --view expanded || true

echo
echo '--- ModelTurntableRenderer relevant lines 400-590 ---'
sed -n '400,590p' src/ModelTurntableRenderer.cpp | nl -ba -v400

echo
echo '--- Search for turntable renderer usages/API ---'
rg -n -C 4 'turntable|Turntable|ModelTurntableRenderer|createTurntable|turntable' src

Repository: fernandotonon/QtMeshEditor

Length of output: 343


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- ModelTurntableRenderer relevant lines 400-590 ---'
sed -n '400,590p' src/ModelTurntableRenderer.cpp

echo
echo '--- Turntable renderer usages/API ---'
rg -n -C 4 'turntable|Turntable|ModelTurntableRenderer|createTurntable|turntable' src

echo
echo '--- Ogre version hints from CMake/requirements ---'
rg -n -C 2 'OGRE|Ogre|ogre|FindOGRE|find_package' CMakeLists.txt src/src/CMakeLists.txt src/CMakeLists.txt 2>/dev/null || true

Repository: fernandotonon/QtMeshEditor

Length of output: 50383


🌐 Web query:

Ogre 13 AnimationState setTimePosition setLoop behavior at end wrap loop setLoop false

💡 Result:

In Ogre 13, the behavior of AnimationState::setTimePosition is directly determined by the state of the looping flag, which is controlled via setLoop [1][2]. When you call setTimePosition(timePos), the internal logic behaves as follows [3]: 1. If setLoop(true) is enabled: The animation time wraps using a modulo operation (fmod), ensuring the time position always stays within the range [0, length) [3]. 2. If setLoop(false) is disabled: The animation time clamps to the valid range [0, length] using Math::Clamp [3]. When looping is disabled (setLoop(false)), once the time position reaches the animation length, the animation is considered to have ended [3][4]. You can verify this state programmatically by calling the hasEnded method, which returns true if the animation time has reached or exceeded its length while looping is disabled [3][4]. Essentially, setting the loop to false forces the animation to stop and stay at the final frame (the end of the animation length) rather than wrapping back to the beginning [3].

Citations:


Preserve animation-state changes across all exit paths.

The turntable renderer disables every other animation state and clears animState before returning, but the catch handlers exit early without restoring those states or the sampled clip. Capture each state’s enabled/loop/time position before Line 519, then restore it on all success, exception, and error paths. If the selected clip is looping, set it non-looping before sampling the last frame so it clamps at animLength instead of wrapping to zero.

🤖 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/ModelTurntableRenderer.cpp` around lines 519 - 523, Update the turntable
rendering flow around the animation-state setup to snapshot every state’s
enabled, looping, and time-position values before disabling them, then restore
those values—including the sampled clip—on success, error returns, and every
catch path. Before sampling the final frame, make a looping selected clip
non-looping so its time clamps at animLength rather than wrapping to zero.

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.

Fixed in 49f87e4 — the sampled animation state is disabled on both exception paths as well. Note the pre-existing enabled-state snapshot/restore is deliberately not deeper: this renderer is a CLI one-shot (process exits via _exit after the command), same as the isometric renderer.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- referenced commit ---'
git show --no-ext-diff --format=fuller --stat 49f87e4e
git show --no-ext-diff --format= -- src/ModelTurntableRenderer.cpp 49f87e4e

echo
echo '--- current focused renderer flow ---'
sed -n '430,610p' src/ModelTurntableRenderer.cpp | nl -ba -v430

echo
echo '--- CLI exit and isometric animation cleanup references ---'
rg -n -C 5 '_exit\s*\(|AnimationState|setEnabled\(false\)|setLoop\(' src -g '*.{cpp,h,hpp}'

Length of output: 2805


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- ModelTurntableRenderer.cpp: animation setup and cleanup ---'
sed -n '470,610p' src/ModelTurntableRenderer.cpp

echo
echo '--- process termination and CLI command completion ---'
rg -n -C 6 '_exit\s*\(' src || true

echo
echo '--- isometric rendering animation-state handling ---'
rg -n -C 8 'setEnabled\s*\(|setLoop\s*\(|setTimePosition\s*\(' src \
  -g '*Isometric*.cpp' -g '*Isometric*.h' -g '*isometric*.cpp' -g '*isometric*.h' || true

echo
echo '--- relevant source files that reference isometric rendering ---'
rg -n -C 3 'isometric' src -g '*.{cpp,h,hpp}' || true

Length of output: 55372


@fernandotonon, verified. CLIPipeline::run() calls _exit(rc) after the subcommand returns. The isometric renderer uses the same one-shot state-cleanup model. Full animation-state restoration is not required here.

Commit 49f87e4e also disables the sampled state in both catch paths. That part is fixed.

One part remains: the final animated frame samples t == animLength, but the selected Ogre::AnimationState remains looping. In Ogre, a looping state wraps that position to time zero. The final frame can duplicate the first frame instead of showing the clip endpoint. Set animState->setLoop(false) before the sampling loop, or specifically before sampling the endpoint.


✏️ Learnings added
Learnt from: fernandotonon
Repo: fernandotonon/QtMeshEditor PR: 943
File: src/ModelTurntableRenderer.cpp:519-523
Timestamp: 2026-08-10T05:45:00.308Z
Learning: `CLIPipeline::run()` terminates CLI subcommands with `_exit(rc)`. CLI renderers such as `src/ModelTurntableRenderer.cpp` and `src/ModelIsometricRenderer.cpp` use one-shot animation-state cleanup and do not need to restore the prior animation-state snapshot before return.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

Comment thread src/ModelTurntableRenderer.cpp
… validation, state restore

- skip the space-change mapping when a channel node's bind scale has a zero
  component (singular transform; keys then apply in node space as before)
- turntable --animation/--at without a value is a usage error (2) instead of
  silently rendering the static pose with exit 0
- the sampled animation state is disabled on the exception paths too

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

Copy link
Copy Markdown

@fernandotonon
fernandotonon merged commit aaf75fe into master Aug 10, 2026
21 checks passed
@fernandotonon
fernandotonon deleted the fix/anim-scale-space-936 branch August 10, 2026 06:49
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.

isometric --animation renders empty frames (turntable --animation also missing)

1 participant