diff --git a/CLAUDE.md b/CLAUDE.md
index 44580cf..49125e2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
@@ -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/`.
diff --git a/CMakeLists.txt b/CMakeLists.txt
index b17e5f9..8073ea9 100755
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -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}\"")
diff --git a/README.md b/README.md
index e4d7a42..4d2a3e1 100755
--- a/README.md
+++ b/README.md
@@ -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 |
diff --git a/docs/index.html b/docs/index.html
index 94c6d47..ffa7851 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -621,11 +621,11 @@
Asset Inspection
CLI & Automation
- • Merge animations from command line
+ • qtmesh CLI for headless mesh ops
+ • Inspect, convert, fix, merge — all from terminal
• HTTP REST API for scripting
• MCP protocol for AI agents
- • Scriptable — automate your asset pipeline
- • Headless mode for servers
+ • Scriptable — automate your asset pipeline
@@ -647,10 +647,9 @@ CLI — One Command
Merge animations from the command line, perfect for build scripts and automation:
-./QtMeshEditor merge-animations \
- --base character.fbx \
- --animations walk.fbx run.fbx jump.fbx idle.fbx \
- --output character_merged.mesh
+qtmesh anim character.fbx \
+ --merge walk.fbx run.fbx jump.fbx idle.fbx \
+ -o character_merged.mesh
@@ -673,6 +672,74 @@ HTTP API — Script It
+
+
+ CLI Pipeline
+
+ A qtmesh command is built alongside the GUI. Use it to automate your 3D asset pipeline without launching the editor.
+ On Windows, use the bundled qtmesh.cmd wrapper, or add its directory to PATH.
+
+
+
+
+
Inspect a Mesh
+
+
+# Human-readable summary
+qtmesh info model.fbx
+
+# JSON output (for scripting)
+qtmesh info model.fbx --json
+
+
+
+
+
Convert Between Formats
+
+
+qtmesh convert model.fbx -o model.gltf2
+qtmesh convert model.dae -o model.mesh
+
+
+
+
+
Fix / Optimize a Mesh
+
+
+# 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
+
+
+
+
+
Animation Tools
+
+
+# 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
+
+
+
+
+
+ Multiline examples use POSIX \ continuations; on Windows, join into one line or adapt for PowerShell.
+ Use --verbose for engine debug output •
+ --help for full usage
+
+
+
AI Agent Integration
diff --git a/src/AnimationMerger.cpp b/src/AnimationMerger.cpp
index b4f0385..0327c84 100644
--- a/src/AnimationMerger.cpp
+++ b/src/AnimationMerger.cpp
@@ -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)
{
@@ -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.
diff --git a/src/AnimationMerger.h b/src/AnimationMerger.h
index 22f0706..19c9711 100644
--- a/src/AnimationMerger.h
+++ b/src/AnimationMerger.h
@@ -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).
diff --git a/src/Assimp/BoneProcessor.cpp b/src/Assimp/BoneProcessor.cpp
index a0a4dda..13b1346 100644
--- a/src/Assimp/BoneProcessor.cpp
+++ b/src/Assimp/BoneProcessor.cpp
@@ -19,7 +19,7 @@ void BoneProcessor::processBones(Ogre::SkeletonPtr skeleton, const aiScene *scen
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);
}
}
@@ -30,24 +30,30 @@ void BoneProcessor::processBones(Ogre::SkeletonPtr skeleton, const aiScene *scen
}
void BoneProcessor::processBoneHierarchy(aiNode* node) {
- // 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);
}
}
@@ -87,6 +93,29 @@ void BoneProcessor::applyTransformation(const std::string &boneName, const Ogre:
ogreBone->setScale(scale);
}
+void BoneProcessor::processNonSkinnedBone(aiNode* node) {
+ 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) {
// Convert the aiBone's offset matrix to an Ogre::Matrix4
Ogre::Matrix4 offsetMatrix = convertToOgreMatrix4(bone->mOffsetMatrix);
diff --git a/src/Assimp/BoneProcessor.h b/src/Assimp/BoneProcessor.h
index addc440..c033f48 100644
--- a/src/Assimp/BoneProcessor.h
+++ b/src/Assimp/BoneProcessor.h
@@ -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 aiBonesMap;
};
diff --git a/src/Assimp/Importer.cpp b/src/Assimp/Importer.cpp
index b3fda01..2fca3a5 100644
--- a/src/Assimp/Importer.cpp
+++ b/src/Assimp/Importer.cpp
@@ -35,7 +35,7 @@ THE SOFTWARE.
#include
-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 |
@@ -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);
diff --git a/src/Assimp/Importer.h b/src/Assimp/Importer.h
index 84e4f3b..8c34890 100644
--- a/src/Assimp/Importer.h
+++ b/src/Assimp/Importer.h
@@ -37,7 +37,7 @@ class AssimpToOgreImporter {
public:
AssimpToOgreImporter() : importer() {}
- 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;
diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp
new file mode 100644
index 0000000..567b71b
--- /dev/null
+++ b/src/CLIPipeline.cpp
@@ -0,0 +1,841 @@
+#include "CLIPipeline.h"
+#include "Manager.h"
+#include "MeshImporterExporter.h"
+#include "AnimationMerger.h"
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+#include
+#include
+
+#ifndef Q_OS_WIN
+#include
+#endif
+
+// Saved original stdout fd — Ogre's stdout gets redirected to stderr
+// so that Ogre debug output doesn't contaminate CLI pipeline output.
+static int s_savedStdoutFd = -1;
+
+/// Write a string to the CLI output stream (original stdout before redirect).
+static void cliWrite(const QString& text)
+{
+ QByteArray utf8 = text.toUtf8();
+#ifndef Q_OS_WIN
+ if (s_savedStdoutFd >= 0) {
+ ::write(s_savedStdoutFd, utf8.constData(), utf8.size());
+ } else
+#endif
+ {
+ fwrite(utf8.constData(), 1, utf8.size(), stdout);
+ fflush(stdout);
+ }
+}
+
+static QTextStream& err()
+{
+ static QTextStream s(stderr);
+ return s;
+}
+
+static bool s_verbose = false;
+
+/// Suppress qDebug/qInfo/qWarning in non-verbose mode.
+/// qCritical and qFatal always pass through.
+static void cliMessageHandler(QtMsgType type, const QMessageLogContext& ctx, const QString& msg)
+{
+ Q_UNUSED(ctx);
+ if (s_verbose || type == QtCriticalMsg || type == QtFatalMsg) {
+ fprintf(stderr, "%s\n", qPrintable(msg));
+ }
+}
+
+/// Redirect stdout to stderr so Ogre/Qt debug output doesn't pollute
+/// CLI output. Actual CLI output uses the saved original stdout fd.
+static void redirectStdout()
+{
+#ifndef Q_OS_WIN
+ s_savedStdoutFd = dup(STDOUT_FILENO);
+ dup2(STDERR_FILENO, STDOUT_FILENO);
+#endif
+ qInstallMessageHandler(cliMessageHandler);
+}
+
+void CLIPipeline::printVersion()
+{
+ cliWrite(QString("qtmesh %1\n").arg(QTMESHEDITOR_VERSION));
+}
+
+void CLIPipeline::printUsage()
+{
+ cliWrite(
+ "Usage: qtmesh [options]\n"
+ "\n"
+ "Commands:\n"
+ " info [--json] Show mesh information\n"
+ " fix [-o