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
24 changes: 24 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,22 @@ cmake --build build_local --target UnitTests -j4
./build_local/bin/QtMeshEditor.app/Contents/MacOS/QtMeshEditor --with-mcp --http-port 8080 # with HTTP API
```

**CLI pipeline (`qtmesh`):**
```bash
# A 'qtmesh' symlink is created automatically during build
qtmesh info model.fbx # show mesh info (text)
qtmesh info model.fbx --json # show mesh info (JSON)
qtmesh convert model.fbx -o model.gltf2 # convert between formats
qtmesh fix model.fbx -o fixed.fbx # re-import/export with standard optimizations
qtmesh fix model.fbx --all # apply all extra fixes (remove degenerates, merge materials)
qtmesh anim model.fbx --list # list animations
qtmesh anim model.fbx --list --json # list animations (JSON)
qtmesh anim model.fbx --rename "Take 001" "Idle" -o out.fbx # rename an animation
qtmesh anim base.fbx --merge walk.fbx run.fbx -o merged.fbx
```

CLI mode is activated by: (1) invoking via the `qtmesh` symlink, (2) passing `--cli`, or (3) using a recognized subcommand (`info`, `fix`, `convert`, `anim`) as the first argument. Use `--verbose` to see Ogre/engine debug output.

If Xcode SDK is updated, clear CMake cache (`rm build_local/CMakeCache.txt`) and reconfigure.

## Dependencies
Expand Down Expand Up @@ -78,6 +94,14 @@ Three singletons manage core state. All run on the main thread. Access via `Clas
- stdout is redirected to stderr to isolate MCP JSON-RPC from Ogre/Qt debug output; original stdout fd saved for MCP responses.
- HTTP API uses QTcpServer with deferred tool execution (QTimer::singleShot) to avoid re-entrant crashes from Ogre event processing.

### CLI Pipeline

- **CLIPipeline** (`src/CLIPipeline.h/cpp`): Headless command-line interface for mesh operations. All static methods — entry point is `CLIPipeline::run(argc, argv)`.
- Subcommands: `info`, `fix`, `convert`, `anim` (list/rename/merge).
- Activated via `qtmesh` symlink (created at build time), `--cli` flag, or recognized subcommand as first arg.
- Redirects stdout to stderr (Ogre/Qt noise) and writes CLI output to the original stdout fd. Uses `_exit()` to avoid Ogre static destructor crashes on macOS.
- **AnimationMerger** (`src/AnimationMerger.h/cpp`): Public `renameAnimation()` static method used by both CLI and GUI for animation renaming.

### Mesh Import/Export

- **MeshImporterExporter** (`src/MeshImporterExporter.h/cpp`): Static methods. Supports .mesh, .obj, .dae, .gltf, .fbx via custom Assimp processors in `src/Assimp/`.
Expand Down
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.10.0 LANGUAGES CXX)
project(QtMeshEditor VERSION 2.11.0 LANGUAGES CXX)
message(STATUS "Building QtMeshEditor version ${PROJECT_VERSION}")

set(QTMESHEDITOR_VERSION_STRING "\"${PROJECT_VERSION}\"")
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,35 @@ QtMeshEditor helps you prepare 3D assets for your game or project:
- **AI agent integration** — Let AI tools like Claude, Cursor, or custom scripts control the editor via MCP protocol
- **Batch process via CLI** — Automate your asset pipeline from the command line

### :wrench: CLI Pipeline (`qtmesh`)

A `qtmesh` command is created alongside the GUI binary during build. Use it to automate your 3D asset pipeline without launching the editor.
On Windows, use the bundled `qtmesh.cmd` wrapper (or add the install directory to `PATH`):

```bash
# Inspect a mesh
qtmesh info model.fbx # human-readable summary
qtmesh info model.fbx --json # JSON output for scripting

# Convert between formats
qtmesh convert model.fbx -o model.gltf2
qtmesh convert model.dae -o model.mesh

# Fix / optimize a mesh
qtmesh fix model.fbx -o fixed.fbx # standard optimizations
qtmesh fix model.fbx -o fixed.fbx --all # all fixes (remove degenerates, merge materials)

# Animation tools
qtmesh anim model.fbx --list # list animations
qtmesh anim model.fbx --list --json # list as JSON
qtmesh anim model.fbx --rename "Take 001" "Idle" -o renamed.fbx

# Merge animations from multiple files
qtmesh anim base.fbx --merge walk.fbx run.fbx -o merged.fbx
```

Use `--verbose` for engine debug output, `--help` for full usage.

### :package: Format Support

| Format | Extension | Import | Export | Skeleton/Animation |
Expand Down
81 changes: 74 additions & 7 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -621,11 +621,11 @@ <h3 class="feature-title">Asset Inspection</h3>
<div class="feature-card">
<h3 class="feature-title">CLI & Automation</h3>
<p class="feature-desc">
&bull; Merge animations from command line<br>
&bull; <code style="color: var(--neon-cyan);">qtmesh</code> CLI for headless mesh ops<br>
&bull; Inspect, convert, fix, merge &mdash; all from terminal<br>
&bull; HTTP REST API for scripting<br>
&bull; MCP protocol for AI agents<br>
&bull; Scriptable &mdash; automate your asset pipeline<br>
&bull; Headless mode for servers
&bull; Scriptable &mdash; automate your asset pipeline
</p>
</div>
</div>
Expand All @@ -647,10 +647,9 @@ <h3>CLI &mdash; One Command</h3>
<p>Merge animations from the command line, perfect for build scripts and automation:</p>
<div class="code-wrapper">
<div class="code-block">
./QtMeshEditor merge-animations \
--base character.fbx \
--animations walk.fbx run.fbx jump.fbx idle.fbx \
--output character_merged.mesh</div>
qtmesh anim character.fbx \
--merge walk.fbx run.fbx jump.fbx idle.fbx \
-o character_merged.mesh</div>
</div>
</div>

Expand All @@ -673,6 +672,74 @@ <h3>HTTP API &mdash; Script It</h3>
</div>
</section>

<!-- CLI Pipeline Section -->
<section class="section">
<h2 class="section-title">CLI Pipeline</h2>
<p class="section-subtitle">
A <code style="color: var(--neon-cyan);">qtmesh</code> command is built alongside the GUI. Use it to automate your 3D asset pipeline without launching the editor.
On Windows, use the bundled <code style="color: var(--primary-green);">qtmesh.cmd</code> wrapper, or add its directory to <code style="color: var(--primary-green);">PATH</code>.
</p>

<div class="install-steps">
<div class="install-step">
<h3>Inspect a Mesh</h3>
<div class="code-wrapper">
<div class="code-block">
# Human-readable summary
qtmesh info model.fbx

# JSON output (for scripting)
qtmesh info model.fbx --json</div>
</div>
</div>

<div class="install-step">
<h3>Convert Between Formats</h3>
<div class="code-wrapper">
<div class="code-block">
qtmesh convert model.fbx -o model.gltf2
qtmesh convert model.dae -o model.mesh</div>
</div>
</div>

<div class="install-step">
<h3>Fix / Optimize a Mesh</h3>
<div class="code-wrapper">
<div class="code-block">
# Standard optimizations (join vertices, smooth normals, optimize)
qtmesh fix model.fbx -o fixed.fbx

# All fixes (remove degenerates, merge materials)
qtmesh fix model.fbx -o fixed.fbx --all</div>
</div>
</div>

<div class="install-step">
<h3>Animation Tools</h3>
<div class="code-wrapper">
<div class="code-block">
# List all animations
qtmesh anim model.fbx --list
qtmesh anim model.fbx --list --json

# Rename an animation
qtmesh anim model.fbx --rename "Take 001" "Idle" -o renamed.fbx

# Merge animations from multiple files
qtmesh anim base.fbx \
--merge walk.fbx run.fbx jump.fbx \
-o merged.fbx</div>
</div>
</div>
</div>

<p style="text-align: center; color: #aaa; font-family: 'Source Code Pro', monospace; margin-top: 30px;">
Multiline examples use POSIX <code style="color: var(--primary-green);">\</code> continuations; on Windows, join into one line or adapt for PowerShell.<br>
Use <code style="color: var(--primary-green);">--verbose</code> for engine debug output &bull;
<code style="color: var(--primary-green);">--help</code> for full usage
</p>
</section>

<!-- MCP Server Section -->
<section class="section">
<h2 class="section-title">AI Agent Integration</h2>
Expand Down
6 changes: 2 additions & 4 deletions src/AnimationMerger.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,7 @@ static QString slugify(const QString& name)
return s;
}

// Rename an animation on a skeleton by cloning it with a new name and removing the old one.
// Ogre::Animation has no setName(), so this is the only way.
static void renameAnimationOnSkeleton(Ogre::Skeleton* skel,
void AnimationMerger::renameAnimation(Ogre::Skeleton* skel,
const std::string& oldName,
const std::string& newName)
{
Expand Down Expand Up @@ -164,7 +162,7 @@ Ogre::Entity* AnimationMerger::mergeAnimations(

// Apply renames on the source skeleton
for (const auto& [oldName, newName] : renameList)
renameAnimationOnSkeleton(srcSkel.get(), oldName, newName);
renameAnimation(srcSkel.get(), oldName, newName);

// Merge all animations (empty vector = all). Ogre handles track/keyframe
// copying, bone handle remapping, and binding pose differences.
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);

/// 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,
const std::string& oldName,
const std::string& newName);

/// Merge animations from sourceEntities into baseEntity's skeleton.
/// Base entity's own animations are kept as-is. Source animations are
/// named after their scene node (single anim) or nodeName_animName (multiple).
Expand Down
57 changes: 43 additions & 14 deletions src/Assimp/BoneProcessor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

// First, create a map of bone names to aiBones for easier look-up
for(auto i = 0u; i < scene->mNumMeshes; i++) {
aiMesh* mesh = scene->mMeshes[i];

Check warning on line 8 in src/Assimp/BoneProcessor.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make the type of this variable a pointer-to-const. The current type of "mesh" is "struct aiMesh *".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZzQo3ObLSFP-gpYl6yA&open=AZzQo3ObLSFP-gpYl6yA&pullRequest=178
for(auto j = 0u; j < mesh->mNumBones; j++) {
aiBone* bone = mesh->mBones[j];
aiBonesMap[bone->mName.C_Str()] = bone;
Expand All @@ -14,12 +14,12 @@

// Create the root bones
for(auto i = 0u; i < scene->mNumMeshes; i++) {
aiMesh* mesh = scene->mMeshes[i];

Check warning on line 17 in src/Assimp/BoneProcessor.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make the type of this variable a pointer-to-const. The current type of "mesh" is "struct aiMesh *".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZzQo3ObLSFP-gpYl6yB&open=AZzQo3ObLSFP-gpYl6yB&pullRequest=178
for(auto j = 0u; j < mesh->mNumBones; j++) {
aiBone* bone = mesh->mBones[j];
if(bone->mNode && bone->mNode->mParent && !skeleton->hasBone(bone->mNode->mParent->mName.C_Str())) {
createBone(bone->mNode->mParent->mName.C_Str());
Ogre::Matrix4 rootBoneGlobalTransformation = convertToOgreMatrix4(bone->mNode->mParent->mTransformation).inverse();
Ogre::Matrix4 rootBoneGlobalTransformation = convertToOgreMatrix4(bone->mNode->mParent->mTransformation);
applyTransformation(bone->mNode->mParent->mName.C_Str(), rootBoneGlobalTransformation);
}
}
Expand All @@ -29,25 +29,31 @@
processBoneHierarchy(scene->mRootNode);
}

void BoneProcessor::processBoneHierarchy(aiNode* node) {

Check warning on line 32 in src/Assimp/BoneProcessor.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make the type of this parameter a pointer-to-const. The current type of "node" is "struct aiNode *".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZzQo3ObLSFP-gpYl6yD&open=AZzQo3ObLSFP-gpYl6yD&pullRequest=178
// Check if this node corresponds to a bone
if(aiBonesMap.find(node->mName.C_Str()) != aiBonesMap.end()) {
bool isSkinned = (aiBonesMap.find(node->mName.C_Str()) != aiBonesMap.end());
bool isExistingBone = skeleton->hasBone(node->mName.C_Str());

if (isSkinned) {
aiBone* bone = aiBonesMap[node->mName.C_Str()];
createBone(bone->mName.C_Str());
processBoneNode(bone);

// Recursively process children bones
for(auto i = 0u; i < node->mNumChildren; i++) {
aiNode* childNode = node->mChildren[i];
processBoneHierarchy(childNode);
}
}
else {
// If this node isn't a bone, still process its children
for(auto i = 0u; i < node->mNumChildren; i++) {
aiNode* childNode = node->mChildren[i];
processBoneHierarchy(childNode);

// Recursively process children
for (auto i = 0u; i < node->mNumChildren; i++) {
aiNode* child = node->mChildren[i];

// If this node is a bone (skinned or already created as root),
// also create non-skinned children as bones (e.g. leaf/tip bones
// that have no vertex weights but are part of the skeleton).
if ((isSkinned || isExistingBone) && child->mNumMeshes == 0 &&
aiBonesMap.find(child->mName.C_Str()) == aiBonesMap.end() &&
!skeleton->hasBone(child->mName.C_Str()))
{
processNonSkinnedBone(child);
}

processBoneHierarchy(child);
}
}

Expand All @@ -68,7 +74,7 @@
);
}

void BoneProcessor::applyTransformation(const std::string &boneName, const Ogre::Matrix4 &transform)

Check warning on line 77 in src/Assimp/BoneProcessor.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This function should be declared "const".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZzQo3ObLSFP-gpYl6yF&open=AZzQo3ObLSFP-gpYl6yF&pullRequest=178
{
// Convert the Ogre::Matrix4 to an Ogre::Affine3
Ogre::Affine3 affine(transform);
Expand All @@ -87,7 +93,30 @@
ogreBone->setScale(scale);
}

void BoneProcessor::processNonSkinnedBone(aiNode* node) {

Check warning on line 96 in src/Assimp/BoneProcessor.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make the type of this parameter a pointer-to-const. The current type of "node" is "struct aiNode *".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZzQo3ObLSFP-gpYl6yH&open=AZzQo3ObLSFP-gpYl6yH&pullRequest=178
std::string boneName = node->mName.C_Str();
createBone(boneName);

// Use the node's local transform directly.
Ogre::Matrix4 transform = convertToOgreMatrix4(node->mTransformation);
applyTransformation(boneName, transform);

// Set parent-child relationship
if (node->mParent && node->mParent->mName.length &&
skeleton->hasBone(node->mParent->mName.C_Str()))
{
Ogre::Bone* parentBone = skeleton->getBone(node->mParent->mName.C_Str());
Ogre::Bone* ogreBone = skeleton->getBone(boneName);
if (!std::any_of(parentBone->getChildren().begin(), parentBone->getChildren().end(),
[&ogreBone](const auto& childNode) {
return childNode->getName() == ogreBone->getName();
})) {
parentBone->addChild(ogreBone);
}
}
}

void BoneProcessor::processBoneNode(aiBone* bone) {

Check warning on line 119 in src/Assimp/BoneProcessor.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make the type of this parameter a pointer-to-const. The current type of "bone" is "struct aiBone *".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZzQo3ObLSFP-gpYl6yI&open=AZzQo3ObLSFP-gpYl6yI&pullRequest=178
// Convert the aiBone's offset matrix to an Ogre::Matrix4
Ogre::Matrix4 offsetMatrix = convertToOgreMatrix4(bone->mOffsetMatrix);

Expand All @@ -96,7 +125,7 @@

// If the bone has a parent, multiply the global transformation of the bone with the inverse of the global transformation of the parent to get the local transformation
if(bone->mNode->mParent && bone->mNode->mParent->mName.length) {
if(skeleton->hasBone(bone->mNode->mParent->mName.C_Str())){

Check warning on line 128 in src/Assimp/BoneProcessor.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Merge this "if" statement with the enclosing one.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZzQo3ObLSFP-gpYl6yJ&open=AZzQo3ObLSFP-gpYl6yJ&pullRequest=178
Ogre::Bone* parentBone = skeleton->getBone(bone->mNode->mParent->mName.C_Str());
Ogre::Matrix4 parentGlobalTransform = parentBone->_getFullTransform().inverse();
globalTransform = parentGlobalTransform * globalTransform;
Expand All @@ -107,7 +136,7 @@

// Add the bone to the parent bone, if it exists
if(bone->mNode->mParent && bone->mNode->mParent->mName.length) {
if(skeleton->hasBone(bone->mNode->mParent->mName.C_Str())){

Check warning on line 139 in src/Assimp/BoneProcessor.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Merge this "if" statement with the enclosing one.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZzQo3ObLSFP-gpYl6yK&open=AZzQo3ObLSFP-gpYl6yK&pullRequest=178
Ogre::Bone* parentBone = skeleton->getBone(bone->mNode->mParent->mName.C_Str());
Ogre::Bone* ogreBone = skeleton->getBone(bone->mName.C_Str());
// Check if ogreBone is already a child of parentBone
Expand Down
3 changes: 2 additions & 1 deletion src/Assimp/BoneProcessor.h
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ class BoneProcessor {
void createBone(const std::string& boneName);
void processBoneHierarchy(aiNode* node);
void processBoneNode(aiBone *bone);
void processNonSkinnedBone(aiNode* node);
Ogre::Matrix4 convertToOgreMatrix4(const aiMatrix4x4& aiMat);
void applyTransformation(const std::string& boneName, const Ogre::Matrix4 &transform);

Ogre::SkeletonPtr skeleton;
Ogre::SkeletonPtr skeleton;
std::map<std::string, aiBone*> aiBonesMap;
};
3 changes: 2 additions & 1 deletion src/Assimp/Importer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ THE SOFTWARE.

#include <algorithm>

Ogre::MeshPtr AssimpToOgreImporter::loadModel(const std::string& path, bool convertToLeftHanded) {
Ogre::MeshPtr AssimpToOgreImporter::loadModel(const std::string& path, bool convertToLeftHanded, unsigned int additionalFlags) {
importer.SetPropertyBool(AI_CONFIG_IMPORT_FBX_PRESERVE_PIVOTS, false);

unsigned int flags = aiProcess_CalcTangentSpace |
Expand All @@ -54,6 +54,7 @@ Ogre::MeshPtr AssimpToOgreImporter::loadModel(const std::string& path, bool conv
aiProcess_GlobalScale;
if (convertToLeftHanded)
flags |= aiProcess_ConvertToLeftHanded;
flags |= additionalFlags;

const aiScene* scene = importer.ReadFile(path, flags);

Expand Down
2 changes: 1 addition & 1 deletion src/Assimp/Importer.h
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@

class AssimpToOgreImporter {
public:
AssimpToOgreImporter() : importer() {}

Check warning on line 38 in src/Assimp/Importer.h

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this use of the constructor's initializer list for data member "importer". It is redundant with default initialization behavior.

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

Ogre::MeshPtr loadModel(const std::string& path, bool convertToLeftHanded = true);
Ogre::MeshPtr loadModel(const std::string& path, bool convertToLeftHanded = true, unsigned int additionalFlags = 0);

private:
Assimp::Importer importer;
Expand Down
Loading
Loading