Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ cmake_minimum_required(VERSION 3.24.0)
cmake_policy(SET CMP0005 NEW)
cmake_policy(SET CMP0048 NEW) # manages project version

project(QtMeshEditor VERSION 2.18.1 LANGUAGES C CXX)
project(QtMeshEditor VERSION 2.19.0 LANGUAGES C CXX)
message(STATUS "Building QtMeshEditor version ${PROJECT_VERSION}")

set(QTMESHEDITOR_VERSION_STRING "\"${PROJECT_VERSION}\"")
Expand Down
21 changes: 14 additions & 7 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>QtMeshEditor - Free 3D Asset Tool for Indie Game Developers</title>
<meta name="description" content="Free 3D asset tool for indie game developers. Merge Mixamo animations, convert between 40+ formats, edit materials with AI. GUI, CLI, and REST API.">
<meta name="description" content="Free 3D asset tool for indie game developers. Merge Mixamo and Unreal Engine animations, convert between 40+ formats, edit materials with AI. GUI, CLI, and REST API.">
<style>
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=Source+Code+Pro:wght@400;600&display=swap');

Expand Down Expand Up @@ -530,8 +530,8 @@ <h1 class="logo">QtMeshEditor</h1>

<!-- Hero: Animation Merging Demo -->
<section class="section">
<h2 class="section-title">Merge Mixamo Animations in Seconds</h2>
<p class="section-subtitle">Download individual animations from <strong style="color: var(--neon-cyan);">Mixamo</strong>, drop them into QtMeshEditor, and merge them into a single mesh &mdash; export as glTF, Collada, OBJ, or Ogre Mesh.</p>
<h2 class="section-title">Merge Animations from Mixamo &amp; Unreal Engine</h2>
<p class="section-subtitle">Download individual animations from <strong style="color: var(--neon-cyan);">Mixamo</strong> or export retargeted animations from <strong style="color: var(--neon-cyan);">Unreal Engine</strong>, drop them into QtMeshEditor, and merge them into a single mesh &mdash; export as glTF, Collada, OBJ, or Ogre Mesh.</p>
<div class="hero-demo">
<img src="https://github.com/user-attachments/assets/16e81f29-f64c-402d-aac4-dbb1bc2f3aef" alt="Merge animations demo - combining multiple FBX files into one mesh">
<p class="hero-demo-caption">Load animation files &rarr; Select all &rarr; Click Merge &rarr; Export to your engine</p>
Expand All @@ -545,7 +545,7 @@ <h2 class="section-title">Why QtMeshEditor?</h2>
<div class="feature-card">
<div class="feature-icon">🔀</div>
<h3 class="feature-title">Animation Merging</h3>
<p class="feature-desc">Combine skeletal animations from multiple files into one mesh. Works with any source where each animation is a separate file. GUI, CLI, and API support.</p>
<p class="feature-desc">Combine skeletal animations from multiple files into one mesh. Works with Mixamo, Unreal Engine retargets, and any source where each animation is a separate FBX file. GUI, CLI, and API support.</p>
</div>

<div class="feature-card">
Expand Down Expand Up @@ -594,11 +594,17 @@ <h2 class="section-title">Game Dev Workflows</h2>
<div class="feature-card">
<h3 class="feature-title">Animation Pipeline</h3>
<p class="feature-desc">
1. Download animations as FBX (e.g. from Mixamo)<br>
<strong style="color: var(--neon-cyan);">Mixamo workflow:</strong><br>
1. Download animations as FBX from Mixamo<br>
2. Drag all files into QtMeshEditor<br>
3. Select all entities and click Merge<br>
4. Export as glTF, Collada, or OBJ<br>
5. Import into your game engine
4. Export as glTF, Collada, or OBJ<br><br>
<strong style="color: var(--neon-cyan);">Unreal Engine workflow:</strong><br>
1. Export your UE skeletal mesh as FBX<br>
2. Export retargeted animations as FBX<br>
3. Load the mesh, then import each animation<br>
&nbsp;&nbsp;&nbsp;(auto-detected as animation-only &rarr; instant merge)<br>
4. Export to glTF or any supported format
</p>
</div>

Expand Down Expand Up @@ -701,6 +707,7 @@ <h2 class="section-title">Animation Merging</h2>
<div class="install-step">
<h3>GUI &mdash; Drag, Select, Merge</h3>
<p>Load all your animation files, select all entities in the scene, then click the <strong style="color: var(--neon-cyan);">Merge Animations</strong> button in the toolbar. All animations are combined into the first entity.</p>
<p style="margin-top: 0.8em;">For <strong style="color: var(--neon-cyan);">Unreal Engine retarget FBX</strong> files (mesh-less, animation-only exports): load your UE skeletal mesh first, then import the retarget FBX. QtMeshEditor auto-detects it as animation-only and offers an instant merge dialog &mdash; no extra steps needed. Z-up / Y-up coordinate conversion is handled automatically.</p>
</div>

<div class="install-step">
Expand Down
115 changes: 97 additions & 18 deletions src/AnimationMerger.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,32 @@
#include <OgreSkeletonInstance.h>
#include <OgreAnimation.h>
#include <QSet>
#include <QMap>
#include <QRegularExpression>
#include <unordered_map>

// Remove common Mixamo noise from animation names before slugifying.
// Registry: skeleton name → up-axis (1=Y-up, 2=Z-up).
// Populated by AnimationMerger::registerSkeletonUpAxis() at import time.
static QMap<QString, int> s_skeletonUpAxis;

Check failure on line 12 in src/AnimationMerger.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Global variables should be const.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ1OhbsGrXmPZjkvoJxW&open=AZ1OhbsGrXmPZjkvoJxW&pullRequest=239

void AnimationMerger::registerSkeletonUpAxis(const std::string& name, int upAxis) {
s_skeletonUpAxis[QString::fromStdString(name)] = upAxis;
}

static int lookupUpAxis(const std::string& name) {
return s_skeletonUpAxis.value(QString::fromStdString(name), 1); // default Y-up
}

// Remove common noise tokens from animation names before slugifying.
// e.g. "Armature|mixamo.com|Layer0" → "Armature|Layer0"
static QString cleanMixamoNoise(const QString& name)
// e.g. "Armature|unreal_take|Layer0" → "Armature|Layer0"
// e.g. "Unreal Take" (UE FBX take name, space variant) → ""
static QString cleanAnimNoise(const QString& name)
{
QString s = name;
s.replace(QRegularExpression("\\|?mixamo\\.com\\|?"), "|");
// Match both "unreal_take" (underscore) and "Unreal Take" (space) — UE FBX exports
s.replace(QRegularExpression("\\|?unreal[_ ]take\\|?", QRegularExpression::CaseInsensitiveOption), "|");
s.replace(QRegularExpression("\\|\\|+"), "|");
if (s.startsWith('|')) s.remove(0, 1);
if (s.endsWith('|')) s.chop(1);
Expand All @@ -36,7 +53,7 @@
static QString buildAnimName(const QString& prefix, const QString& animName)
{
QString slugPrefix = slugify(prefix);
QString cleanAnim = cleanMixamoNoise(animName);
QString cleanAnim = cleanAnimNoise(animName);
QString slugAnim = slugify(cleanAnim);

// Both empty — shouldn't happen, but guard against it
Expand All @@ -53,21 +70,24 @@
}

// Deduplicate a name against an existing set, appending _2, _3, etc. if needed.
// Strips any existing trailing _N suffix first so repeated merges produce
// test_jump, test_jump_2, test_jump_3 (not test_jump_2_2, test_jump_2_2_2).
// Intentional numeric suffixes (e.g. "mm_attack_03") are preserved when the
// name is unique. _N stripping only kicks in when the name already collides,
// so repeated merges produce jump, jump_2, jump_3 (not jump_2_2, jump_2_2_2).
static QString deduplicateName(const QString& desired, QSet<QString>& existingNames)
{
// Strip existing _N suffix to find the base name
// If the desired name is free, use it as-is (preserve intentional _N suffixes).
if (!existingNames.contains(desired)) {
existingNames.insert(desired);
return desired;
}

// Collision — strip any trailing _N to find the canonical base, then re-suffix.
QString baseName = desired;
QRegularExpression trailingSuffix("_(\\d+)$");
auto match = trailingSuffix.match(baseName);
if (match.hasMatch())
baseName = baseName.left(match.capturedStart());

if (!existingNames.contains(baseName)) {
existingNames.insert(baseName);
return baseName;
}
int suffix = 2;
QString candidate;
do {
Expand All @@ -81,8 +101,27 @@
// This bypasses Ogre's _mergeSkeletonAnimations hierarchy check, which rejects skeletons
// that are structurally compatible (same bone names) but differ in their root chain
// (e.g. a mesh skeleton wrapping 'root' under a mesh-name bone that the animation lacks).
static void mergeAnimationsByName(Ogre::Skeleton* baseSkel, const Ogre::Skeleton* srcSkel)
//
// srcUpAxis / baseUpAxis: coordinate-system up-axis (1=Y-up, 2=Z-up).
// When the source was exported from a Z-up tool (e.g. Unreal Engine) and the base
// skeleton is Y-up (e.g. Mixamo), needsZupToYup converts the keyframe data.
//
// Additionally, AnimationProcessor stores translations in bone-local space (pre-multiplied
// by the inverse binding-pose orientation). If the source and target skeletons have
// different binding-pose orientations for the same bone — e.g. an animation-only FBX has
// identity root while a mesh FBX has a baked R_x(+90°) root — the stored delta must be
// re-expressed in the target bone's local space.
// We apply: corrected = (q_src⁻¹ * q_dst) * stored
static void mergeAnimationsByName(Ogre::Skeleton* baseSkel, const Ogre::Skeleton* srcSkel,
int srcUpAxis = 1, int baseUpAxis = 1)
{
const bool needsZupToYup = (srcUpAxis == 2 && baseUpAxis == 1);

// Z-up (Assimp/FBX after ConvertToLeftHanded) → Y-up (Ogre) for translations:
// R_x(-90°): (x,y,z) → (x, z, -y)
// Rotations are stored in bone-local space and pass through unchanged —
// the baked dest bone orientation provides the correct world-space result.

// Build name→handle map for the base skeleton
std::unordered_map<std::string, unsigned short> baseHandleByName;
for (unsigned short i = 0; i < baseSkel->getNumBones(); ++i)
Expand Down Expand Up @@ -110,12 +149,44 @@
dstTrack->setAssociatedNode(baseSkel->getBone(baseHandle));
dstTrack->setUseShortestRotationPath(srcTrack->getUseShortestRotationPath());

// Per-bone binding-pose orientation correction.
// AnimationProcessor stores translate/rotate keyframes in the source bone's
// local space (divided by the source bone's binding-pose orientation).
// If source and target have different binding poses for this bone, re-express
// the stored values in the target bone's local space:
// corrected = (q_dst⁻¹ * q_src) * stored
const Ogre::Quaternion q_src = srcSkel->getBone(srcHandle)->getOrientation();
const Ogre::Quaternion q_dst = baseSkel->getBone(baseHandle)->getOrientation();
const Ogre::Quaternion boneCorrection = q_src.Inverse() * q_dst;

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 Use destination-to-source quaternion order for bone correction

The translation-space remap in mergeAnimationsByName computes boneCorrection as q_src.Inverse() * q_dst, but converting a vector from source bone-local space into destination bone-local space requires q_dst.Inverse() * q_src (which also matches the nearby comment). With the current order, merges where bind orientations differ rotate translation deltas in the wrong direction, producing incorrect root/track motion for orientation-mismatched skeleton pairs.

Useful? React with 👍 / 👎.

const bool needsBoneCorrection = !boneCorrection.equals(Ogre::Quaternion::IDENTITY, Ogre::Radian(1e-4f));

for (unsigned short k = 0; k < srcTrack->getNumKeyFrames(); ++k)
{
const auto* kf = srcTrack->getNodeKeyFrame(k);
auto* dstKf = dstTrack->createNodeKeyFrame(kf->getTime());
dstKf->setTranslate(kf->getTranslate());
dstKf->setRotation(kf->getRotation());

Ogre::Vector3 t = kf->getTranslate();
Ogre::Quaternion r = kf->getRotation();

// Step 1: correct translation for binding-pose orientation mismatch.
// Translations are stored in bone-local space (AnimationProcessor divides by
// the source bone's binding-pose orientation). If the target bone has a
// different binding-pose orientation, re-express the vector in that space.
// Rotations do NOT need this correction — the target binding pose already
// provides the equivalent compensation via the scene-node/skeleton setup.
if (needsBoneCorrection) {

Check failure on line 177 in src/AnimationMerger.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not nest more than 3 if|for|do|while|switch statements.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ1OhbsGrXmPZjkvoJxX&open=AZ1OhbsGrXmPZjkvoJxX&pullRequest=239
t = boneCorrection * t;
}
// Step 2: convert coordinate system (Z-up source → Y-up base).
// R_x(-90°) maps (x,y,z) → (x, z, -y): Z-up axis becomes Y-up.
// Rotations pass through unchanged — bone-local storage makes them
// self-consistent once the dest binding pose is baked to Y-up.
if (needsZupToYup) {

Check failure on line 184 in src/AnimationMerger.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not nest more than 3 if|for|do|while|switch statements.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ1OhbsGrXmPZjkvoJxY&open=AZ1OhbsGrXmPZjkvoJxY&pullRequest=239
t = Ogre::Vector3(t.x, t.z, -t.y);
}

dstKf->setTranslate(t);
dstKf->setRotation(r);
Comment on lines +152 to +189

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for any existing tests or usages that might validate this formula
rg -n "boneCorrection|q_src|q_dst" --type cpp -C3

Repository: fernandotonon/QtMeshEditor

Length of output: 3230


🏁 Script executed:

#!/bin/bash
# Search for test files related to AnimationMerger
fd -e cpp -e h -path "*test*" | head -20

Repository: fernandotonon/QtMeshEditor

Length of output: 238


🏁 Script executed:

#!/bin/bash
# Search for all AnimationMerger-related files and their sizes
fd -i animationmerger | xargs wc -l

Repository: fernandotonon/QtMeshEditor

Length of output: 174


🏁 Script executed:

#!/bin/bash
# Look at the full context around line 114 to understand the function-level comment
sed -n '100,130p' src/AnimationMerger.cpp

Repository: fernandotonon/QtMeshEditor

Length of output: 1926


🏁 Script executed:

#!/bin/bash
# Check if there's any validation or actual usage of the boneCorrection in the merge
sed -n '140,200p' src/AnimationMerger.cpp

Repository: fernandotonon/QtMeshEditor

Length of output: 3340


🏁 Script executed:

#!/bin/bash
# Examine the test file for bone correction validation
head -100 src/AnimationMerger_test.cpp

Repository: fernandotonon/QtMeshEditor

Length of output: 3751


🏁 Script executed:

#!/bin/bash
# Search for test cases related to bone correction or quaternion validation
rg -n "boneCorrection|mergeAnimationsByName|TEST|test" src/AnimationMerger_test.cpp | head -40

Repository: fernandotonon/QtMeshEditor

Length of output: 1019


🏁 Script executed:

#!/bin/bash
# Read the MergeAnimationsBasic test and related tests to understand validation
sed -n '136,220p' src/AnimationMerger_test.cpp

Repository: fernandotonon/QtMeshEditor

Length of output: 3640


🏁 Script executed:

#!/bin/bash
# Also check the MergeAnimationsMixamoCleanup test which mentions "Mixamo" (likely Y-up with binding pose differences)
sed -n '219,270p' src/AnimationMerger_test.cpp

Repository: fernandotonon/QtMeshEditor

Length of output: 2431


🏁 Script executed:

#!/bin/bash
# Check if there are any comments in the test file explaining bone correction validation
rg -n "binding.pose|correction|q_src|q_dst|orientation" src/AnimationMerger_test.cpp

Repository: fernandotonon/QtMeshEditor

Length of output: 52


🏁 Script executed:

#!/bin/bash
# Check if createTestSkeleton ever sets non-identity bone orientations
sed -n '70,100p' src/AnimationMerger_test.cpp

Repository: fernandotonon/QtMeshEditor

Length of output: 1278


🏁 Script executed:

#!/bin/bash
# Search for any test that might set quaternion or orientation values
rg -n "setOrientation|Quaternion|R_x" src/AnimationMerger_test.cpp

Repository: fernandotonon/QtMeshEditor

Length of output: 52


🏁 Script executed:

#!/bin/bash
# Check if there are any integration tests or test FBX files mentioned
rg -n "\.fbx|test.*FBX|integration" src/AnimationMerger_test.cpp

Repository: fernandotonon/QtMeshEditor

Length of output: 52


🏁 Script executed:

#!/bin/bash
# Double-check by reading the full function to see if there's any other context
sed -n '107,165p' src/AnimationMerger.cpp

Repository: fernandotonon/QtMeshEditor

Length of output: 3481


Fix the quaternion order in the bone correction formula.

Line 157's comment states the correct formula: corrected = (q_dst⁻¹ * q_src) * stored. Line 114's docstring also correctly states: correction = q_dst.Inverse() * q_src. However, line 160 implements the inverse: boneCorrection = q_src.Inverse() * q_dst.

The stored values are in the source bone's local space (pre-multiplied by q_src⁻¹). Converting to the destination bone's local space requires: v_dest_local = q_dst⁻¹ * (q_src * v_stored) = (q_dst⁻¹ * q_src) * v_stored.

Change line 160 to:

const Ogre::Quaternion boneCorrection = q_dst.Inverse() * q_src;

The current test suite does not catch this because all test skeletons use identity binding poses. This bug manifests only when merging animations between skeletons with different binding-pose orientations (e.g., Unreal Engine FBX with R_x(-90°) root merged into a Mixamo character). Recommend adding a test case with non-identity binding poses.

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

In `@src/AnimationMerger.cpp` around lines 152 - 189, The bone correction
quaternion is computed with the operands reversed; update the computation of
boneCorrection (currently using q_src.Inverse() * q_dst) to use the correct
order q_dst.Inverse() * q_src so that stored source-local vectors are
re-expressed into the destination bone-local space; change the assignment where
boneCorrection is created (refer to q_src, q_dst, boneCorrection, and
needsBoneCorrection) accordingly and add/adjust a unit test that uses
non-identity binding-pose orientations to catch regressions.

dstKf->setScale(kf->getScale());
}
}
Expand Down Expand Up @@ -268,7 +339,9 @@
for (const auto& [tempName, finalName] : srcTempToFinal)
renameAnimation(srcSkel.get(), tempName, finalName);

mergeAnimationsByName(baseSkel.get(), srcSkel.get());
mergeAnimationsByName(baseSkel.get(), srcSkel.get(),
lookupUpAxis(srcSkel->getName()),
lookupUpAxis(baseSkel->getName()));
mergedCount += numAnims;
}

Expand Down Expand Up @@ -314,7 +387,9 @@
for (const auto& [tempName, finalName] : srcTempToFinal)
renameAnimation(srcSkel.get(), tempName, finalName);

mergeAnimationsByName(baseSkel.get(), srcSkel.get());
mergeAnimationsByName(baseSkel.get(), srcSkel.get(),
lookupUpAxis(srcSkel->getName()),
lookupUpAxis(baseSkel->getName()));
mergedCount += numAnims;
}

Expand Down Expand Up @@ -349,12 +424,16 @@
// This was a base animation — prefix it (unless already prefixed)
QString origSlug = slugify(origQName);
if (origSlug.startsWith(baseSlug + "_") || origSlug == baseSlug)
desired = slugify(cleanMixamoNoise(origQName)); // already prefixed
desired = slugify(cleanAnimNoise(origQName)); // already prefixed
else
desired = buildAnimName(baseRawName, origQName); // needs prefix
} else {
// This was merged from a source — already has prefix, just clean
desired = slugify(cleanMixamoNoise(origQName));
// This was merged from a source — already renamed in Step 1b, just clean.
desired = slugify(cleanAnimNoise(origQName));
// If cleaning removes the entire name (e.g. "unreal_take" → ""),
// keep the already-processed name from Step 1b as-is.
if (desired.isEmpty())
desired = slugify(origQName);
}

QString finalName = deduplicateName(desired, finalNames);
Expand Down
6 changes: 6 additions & 0 deletions src/AnimationMerger.h
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ class AnimationMerger {
/// Check if two skeletons have compatible bone hierarchies (matched by name).
static bool areSkeletonsCompatible(const Ogre::SkeletonPtr& a, const Ogre::SkeletonPtr& b);

/// Register the coordinate-system up-axis for a named skeleton.
/// 1 = Y-up (Mixamo/default), 2 = Z-up (Unreal Engine).
/// Must be called by the importer immediately after loading so that
/// mergeAnimations() can apply the correct coordinate transform.
static void registerSkeletonUpAxis(const std::string& skeletonName, int upAxis);

/// Rename an animation on a skeleton by cloning with a new name and removing the old.
/// Ogre::Animation has no setName(), so this clone-and-remove pattern is the only way.
static void renameAnimation(Ogre::Skeleton* skel,
Expand Down
69 changes: 69 additions & 0 deletions src/AnimationMerger_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,75 @@ TEST_F(AnimationMergerTest, MergeAnimationsDeduplication)
Ogre::SkeletonManager::getSingleton().remove(skelC);
}

TEST_F(AnimationMergerTest, MergeAnimationsUnrealTakeCleanup)
{
// Animation-only FBX from Unreal Engine retarget: animation is named "Unreal Take"
// (with a space). After cleanup it should fall back to the skeleton/file name.
auto skelBase = createTestSkeleton("ue_base_skel", {"root", "spine"}, {"idle"});
auto skelAnim = createTestSkeleton("mm_attack_03.skeleton", {"root", "spine"}, {"Unreal Take"});

auto meshBase = createInMemoryMesh("ue_base_mesh", skelBase);

auto* sceneMgr = Manager::getSingleton()->getSceneMgr();
auto* nodeBase = sceneMgr->getRootSceneNode()->createChildSceneNode("ueBaseNode");
auto* entityBase = sceneMgr->createEntity("ueBaseEntity", meshBase);
nodeBase->attachObject(entityBase);

QString err;
auto* merged = AnimationMerger::mergeAnimations(entityBase, {}, {skelAnim}, err);

ASSERT_NE(merged, nullptr) << err.toStdString();
auto* skel = merged->getMesh()->getSkeleton().get();

// "Unreal Take" cleaned to "" → falls back to skeleton name "mm_attack_03"
EXPECT_TRUE(skel->hasAnimation("mm_attack_03"))
<< "Expected animation named after source file, not 'Unreal Take' or empty";
EXPECT_FALSE(skel->hasAnimation("unreal_take"))
<< "Noise token should have been removed";
EXPECT_FALSE(skel->hasAnimation(""))
<< "Animation name must not be empty";

nodeBase->detachAllObjects();
sceneMgr->destroyEntity(entityBase);
sceneMgr->destroySceneNode(nodeBase);
Ogre::MeshManager::getSingleton().remove(meshBase);
Ogre::SkeletonManager::getSingleton().remove(skelBase);
Ogre::SkeletonManager::getSingleton().remove(skelAnim);
}

TEST_F(AnimationMergerTest, MergeAnimationsNumericSuffixPreserved)
{
// Intentional numeric suffix in file name must not be stripped by deduplication.
// "mm_attack_03.skeleton" → animation should be "mm_attack_03", not "mm_attack".
auto skelBase = createTestSkeleton("numsfx_base_skel", {"root"}, {"idle"});
auto skelAnim = createTestSkeleton("mm_attack_03.skeleton", {"root"}, {"Unreal Take"});

auto meshBase = createInMemoryMesh("numsfx_base_mesh", skelBase);

auto* sceneMgr = Manager::getSingleton()->getSceneMgr();
auto* nodeBase = sceneMgr->getRootSceneNode()->createChildSceneNode("numsfxBaseNode");
auto* entityBase = sceneMgr->createEntity("numsfxBaseEntity", meshBase);
nodeBase->attachObject(entityBase);

QString err;
auto* merged = AnimationMerger::mergeAnimations(entityBase, {}, {skelAnim}, err);

ASSERT_NE(merged, nullptr) << err.toStdString();
auto* skel = merged->getMesh()->getSkeleton().get();

EXPECT_TRUE(skel->hasAnimation("mm_attack_03"))
<< "Numeric suffix _03 should be preserved (not stripped to mm_attack)";
EXPECT_FALSE(skel->hasAnimation("mm_attack"))
<< "Deduplication must not preemptively strip intentional _N suffixes";

nodeBase->detachAllObjects();
sceneMgr->destroyEntity(entityBase);
sceneMgr->destroySceneNode(nodeBase);
Ogre::MeshManager::getSingleton().remove(meshBase);
Ogre::SkeletonManager::getSingleton().remove(skelBase);
Ogre::SkeletonManager::getSingleton().remove(skelAnim);
}

// Standalone test that doesn't need Ogre initialization
TEST(AnimationMergerStandaloneTest, MergeNoSkeletonError)
{
Expand Down
Loading
Loading