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 ] [flags] Fix/optimize a mesh (overwrites input if no -o)\n" + " convert -o Convert between 3D formats\n" + " anim --list [--json] List animations\n" + " anim --rename [-o ]\n" + " Rename an animation (overwrites input if no -o)\n" + " anim --merge [f2...] [-o ]\n" + " Merge animations from other files into base\n" + " (overwrites input if no -o)\n" + "\n" + "Fix flags:\n" + " --remove-degenerates Remove degenerate triangles\n" + " --merge-materials Remove redundant materials\n" + " --all Apply all extra fixes\n" + " (no flags) Standard import/export (joins vertices, smooths normals, optimizes)\n" + "\n" + "Global options:\n" + " --help, -h Show this help\n" + " --version, -v Show version\n" + " --verbose Show Ogre/engine debug output\n" + ); +} + +QString CLIPipeline::formatForExtension(const QString& path) +{ + if (path.endsWith(".fbx", Qt::CaseInsensitive)) return "FBX Binary (*.fbx)"; + if (path.endsWith(".glb2", Qt::CaseInsensitive)) return "glTF 2.0 Binary (*.glb2)"; + if (path.endsWith(".gltf2", Qt::CaseInsensitive)) return "glTF 2.0 (*.gltf2)"; + if (path.endsWith(".dae", Qt::CaseInsensitive)) return "Collada (*.dae)"; + if (path.endsWith(".obj", Qt::CaseInsensitive)) return "OBJ (*.obj)"; + if (path.endsWith(".stl", Qt::CaseInsensitive)) return "STL (*.stl)"; + if (path.endsWith(".ply", Qt::CaseInsensitive)) return "PLY (*.ply)"; + if (path.endsWith(".3ds", Qt::CaseInsensitive)) return "3DS (*.3ds)"; + if (path.endsWith(".x", Qt::CaseInsensitive)) return "X (*.x)"; + if (path.endsWith(".mesh.xml", Qt::CaseInsensitive)) return "Ogre XML (*.mesh.xml)"; + if (path.endsWith(".mesh", Qt::CaseInsensitive)) return "Ogre Mesh (*.mesh)"; + if (path.endsWith(".assbin", Qt::CaseInsensitive)) return "Assimp Binary (*.assbin)"; + return "Ogre Mesh (*.mesh)"; +} + +bool CLIPipeline::initOgreHeadless() +{ + // Suppress Ogre log output unless --verbose was given. + // Creating our own LogManager before Root prevents Root from + // creating a default one that writes to ogre.log and stdout. + if (!s_verbose) { + if (!Ogre::LogManager::getSingletonPtr()) { + auto* logMgr = new Ogre::LogManager(); + logMgr->createLog("ogre.log", true, false, true); // default, debugOut=false, suppressFile=true + } else { + Ogre::LogManager::getSingleton().getDefaultLog()->setDebugOutputEnabled(false); + } + } + + try { + Manager::getSingleton(); + } catch (...) { + err() << "Error: Failed to initialize Ogre." << Qt::endl; + return false; + } + + static QWidget* hiddenWidget = nullptr; + if (!hiddenWidget) { + hiddenWidget = new QWidget(); + hiddenWidget->setAttribute(Qt::WA_DontShowOnScreen); + hiddenWidget->resize(1, 1); + hiddenWidget->show(); + } + + try { + Ogre::NameValuePairList params; + params["externalWindowHandle"] = Ogre::StringConverter::toString( + static_cast(hiddenWidget->winId())); +#ifdef Q_OS_MACOS + params["macAPI"] = "cocoa"; + params["macAPICocoaUseNSView"] = "true"; +#endif + Manager::getSingleton()->getRoot()->createRenderWindow( + "CLIHidden", 1, 1, false, ¶ms); + return true; + } catch (...) { + err() << "Error: Failed to create render window." << Qt::endl; + return false; + } +} + +MeshInfo CLIPipeline::extractMeshInfo(const Ogre::Entity* entity, const QString& fileName) +{ + MeshInfo info; + info.file = fileName; + + if (!entity) return info; + + const Ogre::MeshPtr& mesh = entity->getMesh(); + if (!mesh) return info; + + info.submeshes = mesh->getNumSubMeshes(); + + // Count vertices + if (mesh->sharedVertexData) + info.vertices += mesh->sharedVertexData->vertexCount; + for (unsigned int i = 0; i < info.submeshes; ++i) { + Ogre::SubMesh* sub = mesh->getSubMesh(i); + if (sub->vertexData) + info.vertices += sub->vertexData->vertexCount; + if (sub->indexData) + info.triangles += sub->indexData->indexCount / 3; + } + + // Materials + std::set> seenMats; + for (unsigned int i = 0; i < entity->getNumSubEntities(); ++i) { + Ogre::SubEntity* subEnt = entity->getSubEntity(i); + if (subEnt && subEnt->getMaterial()) { + auto name = subEnt->getMaterial()->getName(); + if (seenMats.insert(name).second) + info.materials << QString::fromStdString(name); + } + } + + // Textures + std::set> seenTex; + for (unsigned int i = 0; i < entity->getNumSubEntities(); ++i) { + auto mat = entity->getSubEntity(i)->getMaterial(); + if (!mat) continue; + for (auto* tech : mat->getTechniques()) { + for (auto* pass : tech->getPasses()) { + for (auto* tus : pass->getTextureUnitStates()) { + if (tus->getContentType() == Ogre::TextureUnitState::CONTENT_NAMED) { + auto name = tus->getTextureName(); + if (seenTex.insert(name).second) + info.textures << QString::fromStdString(name); + } + } + } + } + } + + // Skeleton & animations + if (entity->hasSkeleton()) { + Ogre::SkeletonPtr skel = mesh->getSkeleton(); + if (skel) { + info.skeletonName = QString::fromStdString(skel->getName()); + info.boneCount = skel->getNumBones(); + for (unsigned short a = 0; a < skel->getNumAnimations(); ++a) { + auto* anim = skel->getAnimation(a); + info.animations.append({ + QString::fromStdString(anim->getName()), + anim->getLength() + }); + } + } + } + + // Bounding box + auto bb = mesh->getBounds(); + info.bbMin = bb.getMinimum(); + info.bbMax = bb.getMaximum(); + + return info; +} + +QString CLIPipeline::formatMeshInfoText(const MeshInfo& info) +{ + QString result; + QTextStream s(&result); + + s << "File: " << info.file << "\n"; + s << "Vertices: " << info.vertices << "\n"; + s << "Triangles: " << info.triangles << "\n"; + s << "Submeshes: " << info.submeshes << "\n"; + s << "Materials: " << (info.materials.isEmpty() ? "(none)" : info.materials.join(", ")) << "\n"; + + if (!info.textures.isEmpty()) + s << "Textures: " << info.textures.join(", ") << "\n"; + + if (!info.skeletonName.isEmpty()) { + s << "Skeleton: " << info.skeletonName + << " (" << info.boneCount << " bones)\n"; + + if (!info.animations.isEmpty()) { + s << "Animations:\n"; + for (const auto& anim : info.animations) + s << " " << anim.name + << QString(" %1s").arg(anim.duration, 0, 'f', 3) << "\n"; + } + } + + s << "Bounding Box: (" + << QString::number(info.bbMin.x, 'f', 2) << ", " + << QString::number(info.bbMin.y, 'f', 2) << ", " + << QString::number(info.bbMin.z, 'f', 2) << ") to (" + << QString::number(info.bbMax.x, 'f', 2) << ", " + << QString::number(info.bbMax.y, 'f', 2) << ", " + << QString::number(info.bbMax.z, 'f', 2) << ")\n"; + + return result; +} + +QString CLIPipeline::formatMeshInfoJson(const MeshInfo& info) +{ + QJsonObject obj; + obj["file"] = info.file; + obj["vertices"] = static_cast(info.vertices); + obj["triangles"] = static_cast(info.triangles); + obj["submeshes"] = static_cast(info.submeshes); + + QJsonArray mats; + for (const auto& m : info.materials) mats.append(m); + obj["materials"] = mats; + + if (!info.textures.isEmpty()) { + QJsonArray texs; + for (const auto& t : info.textures) texs.append(t); + obj["textures"] = texs; + } + + if (!info.skeletonName.isEmpty()) { + QJsonObject skel; + skel["name"] = info.skeletonName; + skel["bones"] = info.boneCount; + obj["skeleton"] = skel; + + QJsonArray anims; + for (const auto& a : info.animations) { + QJsonObject ao; + ao["name"] = a.name; + ao["duration"] = static_cast(a.duration); + anims.append(ao); + } + obj["animations"] = anims; + } + + QJsonObject bb; + bb["min"] = QJsonArray{info.bbMin.x, info.bbMin.y, info.bbMin.z}; + bb["max"] = QJsonArray{info.bbMax.x, info.bbMax.y, info.bbMax.z}; + obj["boundingBox"] = bb; + + return QString::fromUtf8(QJsonDocument(obj).toJson(QJsonDocument::Indented)); +} + +int CLIPipeline::run(int argc, char* argv[]) +{ + // Pre-scan for --verbose before anything else + for (int i = 1; i < argc; ++i) { + if (QString(argv[i]) == "--verbose") { + s_verbose = true; + break; + } + } + + // Find the subcommand (skip executable name and --cli flag) + int cmdIndex = 1; + for (int i = 1; i < argc; ++i) { + QString arg(argv[i]); + if (arg == "--cli" || arg == "--verbose") { + cmdIndex = i + 1; + continue; + } + if (arg == "--help" || arg == "-h") { + printUsage(); + return 0; + } + if (arg == "--version" || arg == "-v") { + printVersion(); + return 0; + } + // First non-flag argument is the subcommand + if (!arg.startsWith("-")) { + cmdIndex = i; + break; + } + } + + if (cmdIndex >= argc) { + printUsage(); + return 2; + } + + QString cmd(argv[cmdIndex]); + + // Create QApplication once here so subcommands don't need to. + // This also lets us call _exit() after the subcommand returns, + // skipping QApplication/Ogre static destructor teardown that + // causes SIGSEGV on macOS (GL context cleanup race). + QApplication a(argc, argv); + QCoreApplication::setOrganizationName("QtMeshEditor"); + QCoreApplication::setApplicationName("QtMeshEditor"); + QCoreApplication::setApplicationVersion(QTMESHEDITOR_VERSION); + + // Redirect stdout to stderr so Ogre/Qt debug output doesn't + // pollute the CLI pipeline output (JSON, info text, etc.) + redirectStdout(); + + int rc = -1; + if (cmd == "info") rc = cmdInfo(argc, argv); + else if (cmd == "fix") rc = cmdFix(argc, argv); + else if (cmd == "convert") rc = cmdConvert(argc, argv); + else if (cmd == "anim") rc = cmdAnim(argc, argv); + + if (rc >= 0) _exit(rc); + + err() << "Error: Unknown command '" << cmd << "'" << Qt::endl; + printUsage(); + return 2; +} + +int CLIPipeline::cmdInfo(int argc, char* argv[]) +{ + // Parse: info [--json] + QString filePath; + bool jsonOutput = false; + + for (int i = 1; i < argc; ++i) { + QString arg(argv[i]); + if (arg == "info" || arg == "--cli") continue; + if (arg == "--json") { jsonOutput = true; continue; } + if (!arg.startsWith("-") && filePath.isEmpty()) { filePath = arg; continue; } + } + + if (filePath.isEmpty()) { + err() << "Error: No input file specified." << Qt::endl; + err() << "Usage: qtmesh info [--json]" << Qt::endl; + return 2; + } + + QFileInfo fi(filePath); + if (!fi.exists()) { + err() << "Error: File not found: " << filePath << Qt::endl; + return 1; + } + + if (!initOgreHeadless()) return 1; + + // Load the file + MeshImporterExporter::importer({fi.absoluteFilePath()}); + + auto& entities = Manager::getSingleton()->getEntities(); + if (entities.isEmpty()) { + err() << "Error: Failed to load file: " << filePath << Qt::endl; + return 1; + } + + // If multiple entities loaded, show info for all + if (jsonOutput) { + QJsonArray arr; + for (Ogre::Entity* entity : entities) { + MeshInfo info = extractMeshInfo(entity, fi.fileName()); + QJsonDocument doc = QJsonDocument::fromJson(formatMeshInfoJson(info).toUtf8()); + arr.append(doc.object()); + } + // Single entity: emit object directly; multiple: emit array + if (arr.size() == 1) + cliWrite(QString::fromUtf8(QJsonDocument(arr[0].toObject()).toJson(QJsonDocument::Indented))); + else + cliWrite(QString::fromUtf8(QJsonDocument(arr).toJson(QJsonDocument::Indented))); + } else { + for (Ogre::Entity* entity : entities) { + MeshInfo info = extractMeshInfo(entity, fi.fileName()); + cliWrite(formatMeshInfoText(info)); + } + } + + return 0; +} + +int CLIPipeline::cmdConvert(int argc, char* argv[]) +{ + // Parse: convert -o [--format fmt] + QString inputPath, outputPath, format; + + for (int i = 1; i < argc; ++i) { + QString arg(argv[i]); + if (arg == "convert" || arg == "--cli") continue; + if ((arg == "-o" || arg == "--output") && i + 1 < argc) { + outputPath = QString(argv[++i]); + continue; + } + if (arg == "--format" && i + 1 < argc) { + format = QString(argv[++i]); + continue; + } + if (!arg.startsWith("-") && inputPath.isEmpty()) { + inputPath = arg; + continue; + } + } + + if (inputPath.isEmpty() || outputPath.isEmpty()) { + err() << "Error: Missing required arguments." << Qt::endl; + err() << "Usage: qtmesh convert -o [--format fmt]" << Qt::endl; + return 2; + } + + QFileInfo fi(inputPath); + if (!fi.exists()) { + err() << "Error: File not found: " << inputPath << Qt::endl; + return 1; + } + + if (!initOgreHeadless()) return 1; + + MeshImporterExporter::importer({fi.absoluteFilePath()}); + + auto& entities = Manager::getSingleton()->getEntities(); + if (entities.isEmpty()) { + err() << "Error: Failed to load file: " << inputPath << Qt::endl; + return 1; + } + + Ogre::Entity* entity = entities.first(); + auto* node = entity->getParentSceneNode(); + + QString fmt = format.isEmpty() ? formatForExtension(outputPath) : format; + + QFileInfo outFi(outputPath); + QString absOutput = outFi.absoluteFilePath(); + + int result = MeshImporterExporter::exporter(node, absOutput, fmt); + if (result != 0) { + err() << "Error: Export failed." << Qt::endl; + return 1; + } + + cliWrite(QString("Converted: %1 -> %2\n").arg(fi.fileName(), outFi.fileName())); + + return 0; +} + +int CLIPipeline::cmdFix(int argc, char* argv[]) +{ + // Parse: fix -o [flags] + QString inputPath, outputPath; + FixOptions opts; + bool allFlag = false; + + for (int i = 1; i < argc; ++i) { + QString arg(argv[i]); + if (arg == "fix" || arg == "--cli") continue; + if ((arg == "-o" || arg == "--output") && i + 1 < argc) { + outputPath = QString(argv[++i]); + continue; + } + if (arg == "--remove-degenerates") { opts.removeDegenerates = true; continue; } + if (arg == "--merge-materials") { opts.mergeMaterials = true; continue; } + if (arg == "--all") { allFlag = true; continue; } + if (!arg.startsWith("-") && inputPath.isEmpty()) { + inputPath = arg; + continue; + } + } + + if (inputPath.isEmpty()) { + err() << "Error: Missing required arguments." << Qt::endl; + err() << "Usage: qtmesh fix [-o ] [--remove-degenerates] [--merge-materials] [--all]" << Qt::endl; + return 2; + } + + if (outputPath.isEmpty()) { + outputPath = inputPath; // overwrite in place + } + + QFileInfo fi(inputPath); + if (!fi.exists()) { + err() << "Error: File not found: " << inputPath << Qt::endl; + return 1; + } + + if (allFlag) { + opts.removeDegenerates = true; + opts.mergeMaterials = true; + } + + QFileInfo outFi(outputPath); + + // Get "before" counts using the same Assimp flags as the standard import + // pipeline, so numbers match what `info` reports for the file on disk. + unsigned int vertsBefore = 0, trisBefore = 0; + { + Assimp::Importer rawImporter; + rawImporter.SetPropertyBool(AI_CONFIG_IMPORT_FBX_PRESERVE_PIVOTS, false); + unsigned int stdFlags = aiProcess_CalcTangentSpace | + aiProcess_JoinIdenticalVertices | + aiProcess_Triangulate | + aiProcess_RemoveComponent | + aiProcess_GenSmoothNormals | + aiProcess_ValidateDataStructure | + aiProcess_OptimizeGraph | + aiProcess_LimitBoneWeights | + aiProcess_SortByPType | + aiProcess_ImproveCacheLocality | + aiProcess_FixInfacingNormals | + aiProcess_PopulateArmatureData | + aiProcess_OptimizeMeshes | + aiProcess_GlobalScale; + const aiScene* rawScene = rawImporter.ReadFile( + fi.absoluteFilePath().toStdString(), stdFlags); + if (rawScene) { + for (unsigned int m = 0; m < rawScene->mNumMeshes; ++m) { + vertsBefore += rawScene->mMeshes[m]->mNumVertices; + trisBefore += rawScene->mMeshes[m]->mNumFaces; + } + } + } + + // Route through the Ogre pipeline (MeshImporterExporter). + // The standard Assimp import already applies key fixes: + // JoinIdenticalVertices, GenSmoothNormals, ValidateDataStructure, + // OptimizeMeshes, OptimizeGraph, ImproveCacheLocality, CalcTangentSpace. + // MeshImporterExporter::exporter uses the custom FBXExporter for FBX + // (Assimp's FBX exporter is broken and produces files that freeze viewers). + if (!initOgreHeadless()) return 1; + + MeshImporterExporter::importer({fi.absoluteFilePath()}, opts.toAssimpFlags()); + auto& entities = Manager::getSingleton()->getEntities(); + if (entities.isEmpty()) { + err() << "Error: Failed to load file: " << inputPath << Qt::endl; + return 1; + } + + // Gather "after" counts from Ogre entities + unsigned int vertsAfter = 0, trisAfter = 0; + for (Ogre::Entity* entity : entities) { + MeshInfo info = extractMeshInfo(entity, fi.fileName()); + vertsAfter += info.vertices; + trisAfter += info.triangles; + } + + auto* node = entities.first()->getParentSceneNode(); + QString fmt = formatForExtension(outputPath); + + int result = MeshImporterExporter::exporter(node, outFi.absoluteFilePath(), fmt); + if (result != 0) { + err() << "Error: Export failed." << Qt::endl; + return 1; + } + + // Report + QString report; + report += QString("Fixed: %1 -> %2\n").arg(fi.fileName(), outFi.fileName()); + report += " Standard: join-vertices, recalc-normals, optimize, validate\n"; + if (opts.anySet()) { + QStringList extras; + if (opts.removeDegenerates) extras << "remove-degenerates"; + if (opts.mergeMaterials) extras << "merge-materials"; + report += QString(" Extra: %1\n").arg(extras.join(", ")); + } + + if (vertsBefore > 0) { + double vertChange = ((double)vertsAfter - (double)vertsBefore) / (double)vertsBefore * 100.0; + report += QString(" Vertices: %1 -> %2").arg(vertsBefore).arg(vertsAfter); + if (vertsBefore == vertsAfter) + report += " (unchanged)"; + else + report += QString(" (%1%2%)").arg(vertChange >= 0 ? "+" : "").arg(vertChange, 0, 'f', 1); + report += "\n"; + + double triChange = trisBefore > 0 + ? ((double)trisAfter - (double)trisBefore) / (double)trisBefore * 100.0 : 0.0; + report += QString(" Triangles: %1 -> %2").arg(trisBefore).arg(trisAfter); + if (trisBefore == trisAfter) + report += " (unchanged)"; + else + report += QString(" (%1%2%)").arg(triChange >= 0 ? "+" : "").arg(triChange, 0, 'f', 1); + report += "\n"; + } + + cliWrite(report); + return 0; +} + +int CLIPipeline::cmdAnim(int argc, char* argv[]) +{ + // Parse: anim --list [--json] + // or: anim --rename [-o ] + // or: anim --merge [f2...] [-o ] + QString filePath, oldName, newName, outputPath; + bool listMode = false; + bool renameMode = false; + bool mergeMode = false; + bool jsonOutput = false; + QStringList mergeFiles; + + // Collect positional args (excluding flags) + QStringList positional; + for (int i = 1; i < argc; ++i) { + QString arg(argv[i]); + if (arg == "anim" || arg == "--cli") continue; + if (arg == "--list") { listMode = true; continue; } + if (arg == "--json") { jsonOutput = true; continue; } + if (arg == "--rename" && i + 2 < argc) { + renameMode = true; + oldName = QString(argv[++i]); + newName = QString(argv[++i]); + continue; + } + if (arg == "--merge") { + mergeMode = true; + // Collect files until next --flag or end + while (i + 1 < argc && QString(argv[i + 1]).left(2) != "--" + && QString(argv[i + 1]) != "-o") { + mergeFiles.append(QString(argv[++i])); + } + continue; + } + if ((arg == "-o" || arg == "--output") && i + 1 < argc) { + outputPath = QString(argv[++i]); + continue; + } + if (!arg.startsWith("-")) + positional << arg; + } + + if (positional.isEmpty()) { + err() << "Error: No input file specified." << Qt::endl; + return 2; + } + + filePath = positional[0]; + + if (!listMode && !renameMode && !mergeMode) { + err() << "Error: Specify --list, --rename , or --merge ." << Qt::endl; + err() << "Usage: qtmesh anim --list [--json]" << Qt::endl; + err() << " qtmesh anim --rename [-o ]" << Qt::endl; + err() << " qtmesh anim --merge [f2...] [-o ]" << Qt::endl; + return 2; + } + + if ((renameMode || mergeMode) && outputPath.isEmpty()) { + outputPath = filePath; // overwrite in place + } + + QFileInfo fi(filePath); + if (!fi.exists()) { + err() << "Error: File not found: " << filePath << Qt::endl; + return 1; + } + + if (!initOgreHeadless()) return 1; + + MeshImporterExporter::importer({fi.absoluteFilePath()}); + + auto& entities = Manager::getSingleton()->getEntities(); + if (entities.isEmpty()) { + err() << "Error: Failed to load file: " << filePath << Qt::endl; + return 1; + } + + Ogre::Entity* entity = entities.first(); + if (!entity->hasSkeleton()) { + err() << "Error: File has no skeleton/animations." << Qt::endl; + return 1; + } + + Ogre::SkeletonPtr skel = entity->getMesh()->getSkeleton(); + if (!skel) { + err() << "Error: No skeleton found." << Qt::endl; + return 1; + } + + if (listMode) { + if (skel->getNumAnimations() == 0) { + cliWrite(jsonOutput ? "[]\n" : "No animations found.\n"); + return 0; + } + + if (jsonOutput) { + QJsonArray arr; + for (unsigned short i = 0; i < skel->getNumAnimations(); ++i) { + auto* anim = skel->getAnimation(i); + QJsonObject obj; + obj["name"] = QString::fromStdString(anim->getName()); + obj["duration"] = static_cast(anim->getLength()); + arr.append(obj); + } + cliWrite(QString::fromUtf8(QJsonDocument(arr).toJson(QJsonDocument::Indented)) + "\n"); + } else { + QString listing = "Animations:\n"; + for (unsigned short i = 0; i < skel->getNumAnimations(); ++i) { + auto* anim = skel->getAnimation(i); + listing += QString(" %1 %2s\n") + .arg(QString::fromStdString(anim->getName())) + .arg(anim->getLength(), 0, 'f', 3); + } + cliWrite(listing); + } + + return 0; + } + + // Merge mode + if (mergeMode) { + // Load animation files, verifying each import succeeds + for (const auto& f : mergeFiles) { + int countBefore = Manager::getSingleton()->getEntities().size(); + MeshImporterExporter::importer({f}); + int countAfter = Manager::getSingleton()->getEntities().size(); + if (countAfter <= countBefore) { + err() << "Error: Failed to load animation file: " << f << Qt::endl; + return 1; + } + } + + auto& allEntities = Manager::getSingleton()->getEntities(); + if (allEntities.size() < 2) { + err() << "Error: Need at least 2 loaded entities to merge (got " << allEntities.size() << ")" << Qt::endl; + return 1; + } + + QString mergeErr; + Ogre::Entity* merged = AnimationMerger::mergeAnimations(allEntities.first(), allEntities, mergeErr); + if (!merged) { + err() << "Error: Merge failed: " << mergeErr << Qt::endl; + return 1; + } + + auto* mergeNode = merged->getParentSceneNode(); + QFileInfo outFi(outputPath); + int result = MeshImporterExporter::exporter(mergeNode, outFi.absoluteFilePath(), formatForExtension(outputPath)); + if (result != 0) { + err() << "Error: Export failed." << Qt::endl; + return 1; + } + + cliWrite(QString("Merged %1 files -> %2\n").arg(allEntities.size()).arg(outFi.fileName())); + return 0; + } + + // Rename mode + if (!skel->hasAnimation(oldName.toStdString())) { + err() << "Error: Animation '" << oldName << "' not found." << Qt::endl; + err() << "Available animations:" << Qt::endl; + for (unsigned short i = 0; i < skel->getNumAnimations(); ++i) + err() << " " << QString::fromStdString(skel->getAnimation(i)->getName()) << Qt::endl; + return 1; + } + + if (oldName != newName && skel->hasAnimation(newName.toStdString())) { + err() << "Error: Animation '" << newName << "' already exists." << Qt::endl; + return 1; + } + + AnimationMerger::renameAnimation(skel.get(), oldName.toStdString(), newName.toStdString()); + entity->refreshAvailableAnimationState(); + + auto* node = entity->getParentSceneNode(); + QFileInfo outFi(outputPath); + QString fmt = formatForExtension(outputPath); + + int result = MeshImporterExporter::exporter(node, outFi.absoluteFilePath(), fmt); + if (result != 0) { + err() << "Error: Export failed." << Qt::endl; + return 1; + } + + cliWrite(QString("Renamed animation '%1' -> '%2'\nOutput: %3\n").arg(oldName, newName, outFi.fileName())); + + return 0; +} + diff --git a/src/CLIPipeline.h b/src/CLIPipeline.h new file mode 100644 index 0000000..972055f --- /dev/null +++ b/src/CLIPipeline.h @@ -0,0 +1,79 @@ +#ifndef CLIPIPELINE_H +#define CLIPIPELINE_H + +#include +#include +#include +#include +#include +#include + +struct MeshInfo { + QString file; + unsigned int vertices = 0; + unsigned int triangles = 0; + unsigned int submeshes = 0; + QStringList materials; + QStringList textures; + QString skeletonName; + unsigned short boneCount = 0; + struct AnimInfo { + QString name; + float duration = 0.0f; + }; + QList animations; + Ogre::Vector3 bbMin = Ogre::Vector3::ZERO; + Ogre::Vector3 bbMax = Ogre::Vector3::ZERO; +}; + +struct FixOptions { + bool removeDegenerates = false; + bool mergeMaterials = false; + + bool anySet() const { + return removeDegenerates || mergeMaterials; + } + + unsigned int toAssimpFlags() const { + unsigned int flags = 0; + if (removeDegenerates) flags |= aiProcess_FindDegenerates; + if (mergeMaterials) flags |= aiProcess_RemoveRedundantMaterials; + return flags; + } +}; + +class CLIPipeline { +public: + CLIPipeline() = delete; + + /// Entry point: parse argv and dispatch to subcommand. + /// Returns process exit code (0=success, 1=runtime error, 2=usage error). + static int run(int argc, char* argv[]); + + /// Extract mesh info from a loaded Ogre Entity (pure data, no I/O). + static MeshInfo extractMeshInfo(const Ogre::Entity* entity, const QString& fileName); + + /// Format MeshInfo as human-readable text. + static QString formatMeshInfoText(const MeshInfo& info); + + /// Format MeshInfo as JSON string. + static QString formatMeshInfoJson(const MeshInfo& info); + +private: + static void printUsage(); + static void printVersion(); + + /// Initialize Ogre in headless mode (hidden render window). + /// Returns true on success. + static bool initOgreHeadless(); + + static int cmdInfo(int argc, char* argv[]); + static int cmdFix(int argc, char* argv[]); + static int cmdConvert(int argc, char* argv[]); + static int cmdAnim(int argc, char* argv[]); + + /// Map file extension to MeshImporterExporter format string. + static QString formatForExtension(const QString& path); +}; + +#endif // CLIPIPELINE_H diff --git a/src/CLIPipeline_test.cpp b/src/CLIPipeline_test.cpp new file mode 100644 index 0000000..39198fb --- /dev/null +++ b/src/CLIPipeline_test.cpp @@ -0,0 +1,845 @@ +#include +#include +#include +#include +#include +#include +#include +#include "CLIPipeline.h" +#include "TestHelpers.h" + +// --- Formatting tests (no Ogre needed) --- + +class CLIPipelineFormatTest : public ::testing::Test {}; + +TEST_F(CLIPipelineFormatTest, FormatMeshInfoText_BasicFields) +{ + MeshInfo info; + info.file = "test.mesh"; + info.vertices = 100; + info.triangles = 50; + info.submeshes = 2; + info.materials << "mat1" << "mat2"; + info.bbMin = Ogre::Vector3(-1, -2, -3); + info.bbMax = Ogre::Vector3(1, 2, 3); + + QString text = CLIPipeline::formatMeshInfoText(info); + + EXPECT_TRUE(text.contains("File: test.mesh")); + EXPECT_TRUE(text.contains("Vertices: 100")); + EXPECT_TRUE(text.contains("Triangles: 50")); + EXPECT_TRUE(text.contains("Submeshes: 2")); + EXPECT_TRUE(text.contains("mat1, mat2")); + EXPECT_TRUE(text.contains("Bounding Box:")); +} + +TEST_F(CLIPipelineFormatTest, FormatMeshInfoText_WithSkeleton) +{ + MeshInfo info; + info.file = "animated.fbx"; + info.vertices = 200; + info.triangles = 100; + info.submeshes = 1; + info.skeletonName = "test.skeleton"; + info.boneCount = 10; + info.animations.append({"walk", 1.2f}); + info.animations.append({"run", 0.8f}); + + QString text = CLIPipeline::formatMeshInfoText(info); + + EXPECT_TRUE(text.contains("Skeleton: test.skeleton (10 bones)")); + EXPECT_TRUE(text.contains("Animations:")); + EXPECT_TRUE(text.contains("walk")); + EXPECT_TRUE(text.contains("run")); +} + +TEST_F(CLIPipelineFormatTest, FormatMeshInfoText_NoMaterials) +{ + MeshInfo info; + info.file = "empty.mesh"; + QString text = CLIPipeline::formatMeshInfoText(info); + EXPECT_TRUE(text.contains("(none)")); +} + +TEST_F(CLIPipelineFormatTest, FormatMeshInfoJson_Structure) +{ + MeshInfo info; + info.file = "test.mesh"; + info.vertices = 300; + info.triangles = 150; + info.submeshes = 3; + info.materials << "matA"; + info.bbMin = Ogre::Vector3(0, 0, 0); + info.bbMax = Ogre::Vector3(1, 1, 1); + + QString json = CLIPipeline::formatMeshInfoJson(info); + QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8()); + ASSERT_TRUE(doc.isObject()); + + QJsonObject obj = doc.object(); + EXPECT_EQ(obj["file"].toString(), "test.mesh"); + EXPECT_EQ(obj["vertices"].toInt(), 300); + EXPECT_EQ(obj["triangles"].toInt(), 150); + EXPECT_EQ(obj["submeshes"].toInt(), 3); + EXPECT_TRUE(obj["materials"].isArray()); + EXPECT_EQ(obj["materials"].toArray().size(), 1); + EXPECT_TRUE(obj["boundingBox"].isObject()); +} + +TEST_F(CLIPipelineFormatTest, FormatMeshInfoJson_WithAnimations) +{ + MeshInfo info; + info.file = "anim.fbx"; + info.skeletonName = "skel.skeleton"; + info.boneCount = 5; + info.animations.append({"idle", 3.5f}); + + QString json = CLIPipeline::formatMeshInfoJson(info); + QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8()); + QJsonObject obj = doc.object(); + + EXPECT_TRUE(obj.contains("skeleton")); + EXPECT_EQ(obj["skeleton"].toObject()["bones"].toInt(), 5); + EXPECT_TRUE(obj.contains("animations")); + EXPECT_EQ(obj["animations"].toArray().size(), 1); + EXPECT_EQ(obj["animations"].toArray()[0].toObject()["name"].toString(), "idle"); +} + +TEST_F(CLIPipelineFormatTest, FormatMeshInfoJson_NoSkeleton) +{ + MeshInfo info; + info.file = "noskel.obj"; + + QString json = CLIPipeline::formatMeshInfoJson(info); + QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8()); + QJsonObject obj = doc.object(); + + EXPECT_FALSE(obj.contains("skeleton")); + EXPECT_FALSE(obj.contains("animations")); +} + +// --- MeshInfo extraction tests (need Ogre) --- + +class CLIPipelineOgreTest : public ::testing::Test { +protected: + void SetUp() override { + if (!tryInitOgre() || !canLoadMeshFiles()) + GTEST_SKIP() << "Ogre not available"; + createStandardOgreMaterials(); + } +}; + +TEST_F(CLIPipelineOgreTest, ExtractMeshInfo_TriangleMesh) +{ + auto mesh = createInMemoryTriangleMesh("cli_test_tri"); + auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); + auto* node = Manager::getSingleton()->addSceneNode("cli_test_tri"); + auto* entity = sceneMgr->createEntity("cli_test_tri", mesh); + node->attachObject(entity); + + MeshInfo info = CLIPipeline::extractMeshInfo(entity, "triangle.mesh"); + + EXPECT_EQ(info.file, "triangle.mesh"); + EXPECT_EQ(info.vertices, 3u); + EXPECT_EQ(info.triangles, 1u); + EXPECT_EQ(info.submeshes, 1u); + EXPECT_TRUE(info.skeletonName.isEmpty()); + EXPECT_EQ(info.boneCount, 0); + EXPECT_TRUE(info.animations.isEmpty()); +} + +TEST_F(CLIPipelineOgreTest, ExtractMeshInfo_AnimatedEntity) +{ + auto* entity = createAnimatedTestEntity("cli_test_anim"); + ASSERT_NE(entity, nullptr); + + MeshInfo info = CLIPipeline::extractMeshInfo(entity, "animated.fbx"); + + EXPECT_EQ(info.file, "animated.fbx"); + EXPECT_GT(info.vertices, 0u); + EXPECT_TRUE(entity->hasSkeleton()); + EXPECT_FALSE(info.skeletonName.isEmpty()); + EXPECT_EQ(info.boneCount, 2); + EXPECT_EQ(info.animations.size(), 1); + EXPECT_EQ(info.animations[0].name, "TestAnim"); + EXPECT_FLOAT_EQ(info.animations[0].duration, 1.0f); +} + +TEST_F(CLIPipelineOgreTest, ExtractMeshInfo_NullEntity) +{ + MeshInfo info = CLIPipeline::extractMeshInfo(nullptr, "null.mesh"); + EXPECT_EQ(info.vertices, 0u); + EXPECT_EQ(info.triangles, 0u); +} + +// --- FixOptions tests --- + +TEST(FixOptionsTest, AnySet_DefaultIsFalse) +{ + FixOptions opts; + EXPECT_FALSE(opts.anySet()); +} + +TEST(FixOptionsTest, AnySet_RemoveDegenerates) +{ + FixOptions opts; + opts.removeDegenerates = true; + EXPECT_TRUE(opts.anySet()); +} + +TEST(FixOptionsTest, AnySet_MergeMaterials) +{ + FixOptions opts; + opts.mergeMaterials = true; + EXPECT_TRUE(opts.anySet()); +} + +TEST(FixOptionsTest, AnySet_AllFlags) +{ + FixOptions opts; + opts.removeDegenerates = true; + opts.mergeMaterials = true; + EXPECT_TRUE(opts.anySet()); +} + +TEST(FixOptionsTest, ToAssimpFlags_Default) +{ + FixOptions opts; + EXPECT_EQ(opts.toAssimpFlags(), 0u); +} + +TEST(FixOptionsTest, ToAssimpFlags_RemoveDegenerates) +{ + FixOptions opts; + opts.removeDegenerates = true; + EXPECT_EQ(opts.toAssimpFlags(), static_cast(aiProcess_FindDegenerates)); +} + +TEST(FixOptionsTest, ToAssimpFlags_MergeMaterials) +{ + FixOptions opts; + opts.mergeMaterials = true; + EXPECT_EQ(opts.toAssimpFlags(), static_cast(aiProcess_RemoveRedundantMaterials)); +} + +TEST(FixOptionsTest, ToAssimpFlags_All) +{ + FixOptions opts; + opts.removeDegenerates = true; + opts.mergeMaterials = true; + unsigned int expected = aiProcess_FindDegenerates | aiProcess_RemoveRedundantMaterials; + EXPECT_EQ(opts.toAssimpFlags(), expected); +} + +// --- Formatting edge cases --- + +TEST_F(CLIPipelineFormatTest, FormatMeshInfoText_WithTextures) +{ + MeshInfo info; + info.file = "tex.mesh"; + info.textures << "diffuse.png" << "normal.png"; + + QString text = CLIPipeline::formatMeshInfoText(info); + EXPECT_TRUE(text.contains("Textures: diffuse.png, normal.png")); +} + +TEST_F(CLIPipelineFormatTest, FormatMeshInfoJson_WithTextures) +{ + MeshInfo info; + info.file = "tex.mesh"; + info.textures << "color.png"; + info.bbMin = Ogre::Vector3::ZERO; + info.bbMax = Ogre::Vector3::UNIT_SCALE; + + QString json = CLIPipeline::formatMeshInfoJson(info); + QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8()); + QJsonObject obj = doc.object(); + + EXPECT_TRUE(obj.contains("textures")); + EXPECT_EQ(obj["textures"].toArray().size(), 1); + EXPECT_EQ(obj["textures"].toArray()[0].toString(), "color.png"); +} + +// --- Process-based CLI tests --- + +namespace { + +QString findAppBinary() +{ + QString testBinDir = QCoreApplication::applicationDirPath(); + +#ifdef Q_OS_MACOS + QString macPath = testBinDir + "/QtMeshEditor.app/Contents/MacOS/QtMeshEditor"; + if (QFile::exists(macPath)) + return macPath; +#endif + + QString directPath = testBinDir + "/QtMeshEditor"; +#ifdef Q_OS_WIN + directPath += ".exe"; +#endif + if (QFile::exists(directPath)) + return directPath; + + return {}; +} + +QString testDataDir() +{ + QString binDir = QCoreApplication::applicationDirPath(); + QDir dir(binDir); + dir.cdUp(); // bin -> build_local + dir.cdUp(); // build_local -> project root + return dir.absoluteFilePath("media/models"); +} + +QString tempPath(const QString& filename) +{ + return QDir::tempPath() + "/" + filename; +} + +} // anonymous namespace + +// --- Global options --- + +TEST(CLIPipelineCLI, HelpFlag) +{ + QString binary = findAppBinary(); + if (binary.isEmpty()) GTEST_SKIP() << "Binary not found"; + + QProcess proc; + proc.start(binary, {"--cli", "--help"}); + ASSERT_TRUE(proc.waitForFinished(30000)); + EXPECT_EQ(proc.exitCode(), 0); + + QString out = QString::fromUtf8(proc.readAllStandardOutput()); + EXPECT_TRUE(out.contains("Usage:") || out.contains("Commands:")) + << "stdout: " << out.toStdString(); +} + +TEST(CLIPipelineCLI, HelpFlagShort) +{ + QString binary = findAppBinary(); + if (binary.isEmpty()) GTEST_SKIP() << "Binary not found"; + + QProcess proc; + proc.start(binary, {"--cli", "-h"}); + ASSERT_TRUE(proc.waitForFinished(30000)); + EXPECT_EQ(proc.exitCode(), 0); + + QString out = QString::fromUtf8(proc.readAllStandardOutput()); + EXPECT_TRUE(out.contains("Usage:") || out.contains("Commands:")); +} + +TEST(CLIPipelineCLI, VersionFlag) +{ + QString binary = findAppBinary(); + if (binary.isEmpty()) GTEST_SKIP() << "Binary not found"; + + QProcess proc; + proc.start(binary, {"--cli", "--version"}); + ASSERT_TRUE(proc.waitForFinished(30000)); + EXPECT_EQ(proc.exitCode(), 0); + + QString out = QString::fromUtf8(proc.readAllStandardOutput()); + EXPECT_TRUE(out.contains("qtmesh")) << "stdout: " << out.toStdString(); +} + +TEST(CLIPipelineCLI, VersionFlagShort) +{ + QString binary = findAppBinary(); + if (binary.isEmpty()) GTEST_SKIP() << "Binary not found"; + + QProcess proc; + proc.start(binary, {"--cli", "-v"}); + ASSERT_TRUE(proc.waitForFinished(30000)); + EXPECT_EQ(proc.exitCode(), 0); + + QString out = QString::fromUtf8(proc.readAllStandardOutput()); + EXPECT_TRUE(out.contains("qtmesh")); +} + +TEST(CLIPipelineCLI, NoCommand) +{ + QString binary = findAppBinary(); + if (binary.isEmpty()) GTEST_SKIP() << "Binary not found"; + + QProcess proc; + proc.start(binary, {"--cli"}); + ASSERT_TRUE(proc.waitForFinished(30000)); + EXPECT_EQ(proc.exitCode(), 2); +} + +TEST(CLIPipelineCLI, UnknownCommand) +{ + QString binary = findAppBinary(); + if (binary.isEmpty()) GTEST_SKIP() << "Binary not found"; + + QProcess proc; + proc.start(binary, {"--cli", "bogus"}); + ASSERT_TRUE(proc.waitForFinished(30000)); + EXPECT_EQ(proc.exitCode(), 2); + + QString errOut = QString::fromUtf8(proc.readAllStandardError()); + EXPECT_TRUE(errOut.contains("Unknown command")) << "stderr: " << errOut.toStdString(); +} + +// --- info subcommand --- + +TEST(CLIPipelineCLI, InfoNoFile) +{ + QString binary = findAppBinary(); + if (binary.isEmpty()) GTEST_SKIP() << "Binary not found"; + + QProcess proc; + proc.start(binary, {"info"}); + ASSERT_TRUE(proc.waitForFinished(30000)); + EXPECT_EQ(proc.exitCode(), 2); +} + +TEST(CLIPipelineCLI, InfoNonexistentFile) +{ + QString binary = findAppBinary(); + if (binary.isEmpty()) GTEST_SKIP() << "Binary not found"; + + QProcess proc; + proc.start(binary, {"info", tempPath("nonexistent_file_12345.fbx")}); + ASSERT_TRUE(proc.waitForFinished(30000)); + EXPECT_EQ(proc.exitCode(), 1); + + QString errOut = QString::fromUtf8(proc.readAllStandardError()); + EXPECT_TRUE(errOut.contains("File not found")) << "stderr: " << errOut.toStdString(); +} + +TEST(CLIPipelineCLI, InfoValidFile) +{ + QString binary = findAppBinary(); + if (binary.isEmpty()) GTEST_SKIP() << "Binary not found"; + + QString file = testDataDir() + "/Twist Dance.fbx"; + if (!QFile::exists(file)) + GTEST_SKIP() << "Test data not found"; + + QProcess proc; + proc.start(binary, {"info", file}); + ASSERT_TRUE(proc.waitForFinished(60000)); + EXPECT_EQ(proc.exitCode(), 0); + + QString out = QString::fromUtf8(proc.readAllStandardOutput()); + EXPECT_TRUE(out.contains("Vertices:")) << "stdout: " << out.toStdString(); + EXPECT_TRUE(out.contains("Triangles:")); +} + +TEST(CLIPipelineCLI, InfoJsonOutput) +{ + QString binary = findAppBinary(); + if (binary.isEmpty()) GTEST_SKIP() << "Binary not found"; + + QString file = testDataDir() + "/Twist Dance.fbx"; + if (!QFile::exists(file)) + GTEST_SKIP() << "Test data not found"; + + QProcess proc; + proc.start(binary, {"info", file, "--json"}); + ASSERT_TRUE(proc.waitForFinished(60000)); + EXPECT_EQ(proc.exitCode(), 0); + + QString out = QString::fromUtf8(proc.readAllStandardOutput()); + QJsonDocument doc = QJsonDocument::fromJson(out.toUtf8()); + EXPECT_TRUE(doc.isObject()) << "stdout not valid JSON: " << out.toStdString(); + EXPECT_TRUE(doc.object().contains("vertices")); +} + +// --- convert subcommand --- + +TEST(CLIPipelineCLI, ConvertMissingArgs) +{ + QString binary = findAppBinary(); + if (binary.isEmpty()) GTEST_SKIP() << "Binary not found"; + + QProcess proc; + proc.start(binary, {"convert"}); + ASSERT_TRUE(proc.waitForFinished(30000)); + EXPECT_EQ(proc.exitCode(), 2); +} + +TEST(CLIPipelineCLI, ConvertMissingOutput) +{ + QString binary = findAppBinary(); + if (binary.isEmpty()) GTEST_SKIP() << "Binary not found"; + + QProcess proc; + proc.start(binary, {"convert", "somefile.fbx"}); + ASSERT_TRUE(proc.waitForFinished(30000)); + EXPECT_EQ(proc.exitCode(), 2); +} + +TEST(CLIPipelineCLI, ConvertNonexistentFile) +{ + QString binary = findAppBinary(); + if (binary.isEmpty()) GTEST_SKIP() << "Binary not found"; + + QProcess proc; + proc.start(binary, {"convert", tempPath("nonexistent_12345.fbx"), "-o", tempPath("out.mesh")}); + ASSERT_TRUE(proc.waitForFinished(30000)); + EXPECT_EQ(proc.exitCode(), 1); +} + +TEST(CLIPipelineCLI, ConvertValidFile) +{ + QString binary = findAppBinary(); + if (binary.isEmpty()) GTEST_SKIP() << "Binary not found"; + + QString file = testDataDir() + "/Twist Dance.fbx"; + if (!QFile::exists(file)) + GTEST_SKIP() << "Test data not found"; + + QString outFile = tempPath("cli_convert_test.mesh"); + QString outMaterial = tempPath("cli_convert_test.material"); + QFile::remove(outFile); + QFile::remove(outMaterial); + + QProcess proc; + proc.start(binary, {"convert", file, "-o", outFile}); + ASSERT_TRUE(proc.waitForFinished(60000)); + EXPECT_EQ(proc.exitCode(), 0); + + QString out = QString::fromUtf8(proc.readAllStandardOutput()); + EXPECT_TRUE(out.contains("Converted:")) << "stdout: " << out.toStdString(); + EXPECT_TRUE(QFile::exists(outFile)); + + QFile::remove(outFile); + QFile::remove(outMaterial); +} + +// --- fix subcommand --- + +TEST(CLIPipelineCLI, FixNoFile) +{ + QString binary = findAppBinary(); + if (binary.isEmpty()) GTEST_SKIP() << "Binary not found"; + + QProcess proc; + proc.start(binary, {"fix"}); + ASSERT_TRUE(proc.waitForFinished(30000)); + EXPECT_EQ(proc.exitCode(), 2); +} + +TEST(CLIPipelineCLI, FixNonexistentFile) +{ + QString binary = findAppBinary(); + if (binary.isEmpty()) GTEST_SKIP() << "Binary not found"; + + QProcess proc; + proc.start(binary, {"fix", tempPath("nonexistent_12345.fbx")}); + ASSERT_TRUE(proc.waitForFinished(30000)); + EXPECT_EQ(proc.exitCode(), 1); +} + +TEST(CLIPipelineCLI, FixValidFile) +{ + QString binary = findAppBinary(); + if (binary.isEmpty()) GTEST_SKIP() << "Binary not found"; + + QString file = testDataDir() + "/Twist Dance.fbx"; + if (!QFile::exists(file)) + GTEST_SKIP() << "Test data not found"; + + QString outFile = tempPath("cli_fix_test.mesh"); + QString outMaterial = tempPath("cli_fix_test.material"); + QFile::remove(outFile); + QFile::remove(outMaterial); + + QProcess proc; + proc.start(binary, {"fix", file, "-o", outFile}); + ASSERT_TRUE(proc.waitForFinished(60000)); + EXPECT_EQ(proc.exitCode(), 0); + + QString out = QString::fromUtf8(proc.readAllStandardOutput()); + EXPECT_TRUE(out.contains("Fixed:")) << "stdout: " << out.toStdString(); + EXPECT_TRUE(out.contains("Vertices:")); + + QFile::remove(outFile); + QFile::remove(outMaterial); +} + +TEST(CLIPipelineCLI, FixWithAllFlag) +{ + QString binary = findAppBinary(); + if (binary.isEmpty()) GTEST_SKIP() << "Binary not found"; + + QString file = testDataDir() + "/Twist Dance.fbx"; + if (!QFile::exists(file)) + GTEST_SKIP() << "Test data not found"; + + QString outFile = tempPath("cli_fix_all_test.mesh"); + QString outMaterial = tempPath("cli_fix_all_test.material"); + QFile::remove(outFile); + QFile::remove(outMaterial); + + QProcess proc; + proc.start(binary, {"fix", file, "-o", outFile, "--all"}); + ASSERT_TRUE(proc.waitForFinished(60000)); + EXPECT_EQ(proc.exitCode(), 0); + + QString out = QString::fromUtf8(proc.readAllStandardOutput()); + EXPECT_TRUE(out.contains("Extra:")) << "stdout: " << out.toStdString(); + + QFile::remove(outFile); + QFile::remove(outMaterial); +} + +TEST(CLIPipelineCLI, FixWithIndividualFlags) +{ + QString binary = findAppBinary(); + if (binary.isEmpty()) GTEST_SKIP() << "Binary not found"; + + QString file = testDataDir() + "/Twist Dance.fbx"; + if (!QFile::exists(file)) + GTEST_SKIP() << "Test data not found"; + + QString outFile = tempPath("cli_fix_flags_test.mesh"); + QString outMaterial = tempPath("cli_fix_flags_test.material"); + QFile::remove(outFile); + QFile::remove(outMaterial); + + QProcess proc; + proc.start(binary, {"fix", file, "-o", outFile, "--remove-degenerates", "--merge-materials"}); + ASSERT_TRUE(proc.waitForFinished(60000)); + EXPECT_EQ(proc.exitCode(), 0); + + QString out = QString::fromUtf8(proc.readAllStandardOutput()); + EXPECT_TRUE(out.contains("remove-degenerates")) << "stdout: " << out.toStdString(); + EXPECT_TRUE(out.contains("merge-materials")); + + QFile::remove(outFile); + QFile::remove(outMaterial); +} + +// --- anim subcommand --- + +TEST(CLIPipelineCLI, AnimNoFile) +{ + QString binary = findAppBinary(); + if (binary.isEmpty()) GTEST_SKIP() << "Binary not found"; + + QProcess proc; + proc.start(binary, {"anim"}); + ASSERT_TRUE(proc.waitForFinished(30000)); + EXPECT_NE(proc.exitCode(), 0); +} + +TEST(CLIPipelineCLI, AnimNoAction) +{ + QString binary = findAppBinary(); + if (binary.isEmpty()) GTEST_SKIP() << "Binary not found"; + + QProcess proc; + proc.start(binary, {"anim", "somefile.fbx"}); + ASSERT_TRUE(proc.waitForFinished(30000)); + EXPECT_EQ(proc.exitCode(), 2); + + QString errOut = QString::fromUtf8(proc.readAllStandardError()); + EXPECT_TRUE(errOut.contains("--list") && errOut.contains("--rename") && errOut.contains("--merge")) + << "stderr: " << errOut.toStdString(); +} + +TEST(CLIPipelineCLI, AnimListNonexistentFile) +{ + QString binary = findAppBinary(); + if (binary.isEmpty()) GTEST_SKIP() << "Binary not found"; + + QProcess proc; + proc.start(binary, {"anim", tempPath("nonexistent_12345.fbx"), "--list"}); + ASSERT_TRUE(proc.waitForFinished(30000)); + EXPECT_EQ(proc.exitCode(), 1); +} + +TEST(CLIPipelineCLI, AnimListValid) +{ + QString binary = findAppBinary(); + if (binary.isEmpty()) GTEST_SKIP() << "Binary not found"; + + QString file = testDataDir() + "/Twist Dance.fbx"; + if (!QFile::exists(file)) + GTEST_SKIP() << "Test data not found"; + + QProcess proc; + proc.start(binary, {"anim", file, "--list"}); + ASSERT_TRUE(proc.waitForFinished(60000)); + EXPECT_EQ(proc.exitCode(), 0); + + QString out = QString::fromUtf8(proc.readAllStandardOutput()); + EXPECT_TRUE(out.contains("Animations:") || out.contains("No animations")) + << "stdout: " << out.toStdString(); +} + +TEST(CLIPipelineCLI, AnimListJson) +{ + QString binary = findAppBinary(); + if (binary.isEmpty()) GTEST_SKIP() << "Binary not found"; + + QString file = testDataDir() + "/Twist Dance.fbx"; + if (!QFile::exists(file)) + GTEST_SKIP() << "Test data not found"; + + QProcess proc; + proc.start(binary, {"anim", file, "--list", "--json"}); + ASSERT_TRUE(proc.waitForFinished(60000)); + EXPECT_EQ(proc.exitCode(), 0); + + QString out = QString::fromUtf8(proc.readAllStandardOutput()); + QJsonDocument doc = QJsonDocument::fromJson(out.toUtf8()); + EXPECT_TRUE(doc.isArray()) << "Expected JSON array, got: " << out.toStdString(); +} + +TEST(CLIPipelineCLI, AnimRenameNonexistentAnimation) +{ + QString binary = findAppBinary(); + if (binary.isEmpty()) GTEST_SKIP() << "Binary not found"; + + QString file = testDataDir() + "/Twist Dance.fbx"; + if (!QFile::exists(file)) + GTEST_SKIP() << "Test data not found"; + + QProcess proc; + proc.start(binary, {"anim", file, "--rename", "NonExistentAnim", "NewName"}); + ASSERT_TRUE(proc.waitForFinished(60000)); + EXPECT_EQ(proc.exitCode(), 1); + + QString errOut = QString::fromUtf8(proc.readAllStandardError()); + EXPECT_TRUE(errOut.contains("not found")) << "stderr: " << errOut.toStdString(); +} + +TEST(CLIPipelineCLI, AnimRenameDuplicateTarget) +{ + QString binary = findAppBinary(); + if (binary.isEmpty()) GTEST_SKIP() << "Binary not found"; + + QString file = testDataDir() + "/Twist Dance.fbx"; + if (!QFile::exists(file)) + GTEST_SKIP() << "Test data not found"; + + // Get existing animation name + QProcess listProc; + listProc.start(binary, {"anim", file, "--list", "--json"}); + ASSERT_TRUE(listProc.waitForFinished(60000)); + if (listProc.exitCode() != 0) GTEST_SKIP() << "Could not list animations"; + + QString listOut = QString::fromUtf8(listProc.readAllStandardOutput()); + QJsonDocument doc = QJsonDocument::fromJson(listOut.toUtf8()); + if (!doc.isArray() || doc.array().size() < 2) GTEST_SKIP() << "Need at least 2 animations"; + + // Attempt to rename first animation to the name of the second + QString firstName = doc.array()[0].toObject()["name"].toString(); + QString secondName = doc.array()[1].toObject()["name"].toString(); + + QProcess proc; + proc.start(binary, {"anim", file, "--rename", firstName, secondName}); + ASSERT_TRUE(proc.waitForFinished(60000)); + EXPECT_EQ(proc.exitCode(), 1); + + QString errOut = QString::fromUtf8(proc.readAllStandardError()); + EXPECT_TRUE(errOut.contains("already exists")) << "stderr: " << errOut.toStdString(); +} + +TEST(CLIPipelineCLI, AnimRenameValid) +{ + QString binary = findAppBinary(); + if (binary.isEmpty()) GTEST_SKIP() << "Binary not found"; + + QString file = testDataDir() + "/Twist Dance.fbx"; + if (!QFile::exists(file)) + GTEST_SKIP() << "Test data not found"; + + // First get the animation name + QProcess listProc; + listProc.start(binary, {"anim", file, "--list", "--json"}); + ASSERT_TRUE(listProc.waitForFinished(60000)); + if (listProc.exitCode() != 0) GTEST_SKIP() << "Could not list animations"; + + QString listOut = QString::fromUtf8(listProc.readAllStandardOutput()); + QJsonDocument doc = QJsonDocument::fromJson(listOut.toUtf8()); + if (!doc.isArray() || doc.array().isEmpty()) GTEST_SKIP() << "No animations in test file"; + + QString animName = doc.array()[0].toObject()["name"].toString(); + if (animName.isEmpty()) GTEST_SKIP() << "Could not get animation name"; + + QString outFile = tempPath("cli_rename_test.mesh"); + QString outMaterial = tempPath("cli_rename_test.material"); + QFile::remove(outFile); + QFile::remove(outMaterial); + + QProcess proc; + proc.start(binary, {"anim", file, "--rename", animName, "RenamedAnim", "-o", outFile}); + ASSERT_TRUE(proc.waitForFinished(60000)); + EXPECT_EQ(proc.exitCode(), 0); + + QString out = QString::fromUtf8(proc.readAllStandardOutput()); + EXPECT_TRUE(out.contains("Renamed animation")) << "stdout: " << out.toStdString(); + EXPECT_TRUE(QFile::exists(outFile)); + + QFile::remove(outFile); + QFile::remove(outMaterial); +} + +TEST(CLIPipelineCLI, AnimMergeNonexistentAnimFile) +{ + QString binary = findAppBinary(); + if (binary.isEmpty()) GTEST_SKIP() << "Binary not found"; + + QString file = testDataDir() + "/Twist Dance.fbx"; + if (!QFile::exists(file)) + GTEST_SKIP() << "Test data not found"; + + QProcess proc; + proc.start(binary, {"anim", file, "--merge", tempPath("nonexistent_file_12345.fbx"), + "-o", tempPath("merge_fail.mesh")}); + ASSERT_TRUE(proc.waitForFinished(60000)); + EXPECT_NE(proc.exitCode(), 0); + + QString errOut = QString::fromUtf8(proc.readAllStandardError()); + EXPECT_TRUE(errOut.contains("Failed to load animation file") || errOut.contains("Error:")) + << "stderr: " << errOut.toStdString(); +} + +TEST(CLIPipelineCLI, AnimMergeValidFiles) +{ + QString binary = findAppBinary(); + if (binary.isEmpty()) GTEST_SKIP() << "Binary not found"; + + QString baseFile = testDataDir() + "/Twist Dance.fbx"; + QString animFile = testDataDir() + "/Hip Hop Dancing.fbx"; + if (!QFile::exists(baseFile) || !QFile::exists(animFile)) + GTEST_SKIP() << "Test data not found"; + + QString outFile = tempPath("cli_anim_merge_test.mesh"); + QString outMaterial = tempPath("cli_anim_merge_test.material"); + QFile::remove(outFile); + QFile::remove(outMaterial); + + QProcess proc; + proc.start(binary, {"anim", baseFile, "--merge", animFile, "-o", outFile}); + ASSERT_TRUE(proc.waitForFinished(120000)); + EXPECT_EQ(proc.exitCode(), 0); + + QString out = QString::fromUtf8(proc.readAllStandardOutput()); + EXPECT_TRUE(out.contains("Merged")) << "stdout: " << out.toStdString(); + EXPECT_TRUE(QFile::exists(outFile)); + + QFile::remove(outFile); + QFile::remove(outMaterial); +} + +// --- Verbose flag --- + +TEST(CLIPipelineCLI, VerboseWithHelp) +{ + QString binary = findAppBinary(); + if (binary.isEmpty()) GTEST_SKIP() << "Binary not found"; + + QProcess proc; + proc.start(binary, {"--cli", "--verbose", "--help"}); + ASSERT_TRUE(proc.waitForFinished(30000)); + EXPECT_EQ(proc.exitCode(), 0); +} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index fc7c9c5..06b5692 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -44,6 +44,7 @@ SentryReporter.cpp BoneWeightOverlay.cpp NormalVisualizer.cpp AnimationMerger.cpp +CLIPipeline.cpp ) set(HEADER_FILES @@ -91,6 +92,7 @@ SentryReporter.h BoneWeightOverlay.h NormalVisualizer.h AnimationMerger.h +CLIPipeline.h ) set(TEST_SOURCES "") @@ -274,6 +276,28 @@ else() ) endif() +############################################################## +# Create 'qtmesh' symlink/wrapper for CLI pipeline mode +############################################################## +if(APPLE) + add_custom_command(TARGET ${CMAKE_PROJECT_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E create_symlink + "$/Contents/MacOS/${CMAKE_PROJECT_NAME}" + "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/qtmesh" + COMMENT "Creating qtmesh symlink -> app bundle binary" + ) +elseif(WIN32) + file(WRITE "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/qtmesh.cmd" + "@echo off\n\"%~dp0${CMAKE_PROJECT_NAME}.exe\" --cli %*\n") +else() + add_custom_command(TARGET ${CMAKE_PROJECT_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E create_symlink + "${CMAKE_PROJECT_NAME}" + "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/qtmesh" + COMMENT "Creating qtmesh symlink -> ${CMAKE_PROJECT_NAME}" + ) +endif() + ############################################################## # Linking the executable ############################################################## diff --git a/src/FBX/FBXExporter.cpp b/src/FBX/FBXExporter.cpp index e20f227..ae4cfc0 100644 --- a/src/FBX/FBXExporter.cpp +++ b/src/FBX/FBXExporter.cpp @@ -458,32 +458,6 @@ class FBXDocumentBuilder if (m_skeleton) m_skeleton->reset(); - // Collect which bones have vertex assignments (deforming bones). - // Bones WITHOUT assignments (e.g. "Armature") are root-container bones - // that BoneProcessor recreates from parentNode->mTransformation.inverse(). - if (m_hasSkeleton) - { - for (unsigned int si = 0; si < m_mesh->getNumSubMeshes(); ++si) - { - const auto* subMesh = m_mesh->getSubMesh(si); - const auto& assignments = subMesh->useSharedVertices - ? m_mesh->getBoneAssignments() : subMesh->getBoneAssignments(); - for (const auto& [_, vba] : assignments) - m_bonesWithAssignments.insert(vba.boneIndex); - } - } - - // Build material index map (sorted by name, matching std::map iteration order - // which is the same order materials will be connected to the mesh model) - { - std::set matNames; - for (const auto* sub : m_entity->getSubEntities()) - matNames.insert(sub->getMaterial()->getName()); - int idx = 0; - for (const auto& name : matNames) - m_materialIndexMap[name] = idx++; - } - m_w.writeHeader(); writeHeaderExtension(); @@ -575,7 +549,7 @@ class FBXDocumentBuilder // so the root-node scale becomes 100*0.01 = 1.0 (no additional scaling). writeP70double("UnitScaleFactor", 100.0); writeP70double("OriginalUnitScaleFactor", 100.0); - writeP70int("TimeMode", 6); // 30 fps + writeP70enum("TimeMode", 6); // 30 fps writeP70enum("TimeProtocol", 2); writeP70enum("SnapOnFrameMode", 0); writeP70KTime("TimeSpanStart", 0); @@ -631,7 +605,7 @@ class FBXDocumentBuilder { // Count object types int defCount = 1; // GlobalSettings always - int modelCount = 1; // root mesh model + int modelCount = m_mesh->getNumSubMeshes(); // one mesh model per submesh int geomCount = m_mesh->getNumSubMeshes(); int matCount = 0; int deformerCount = 0; @@ -770,7 +744,7 @@ class FBXDocumentBuilder m_w.endProperties(); writeGeometryObjects(); - writeMeshModel(); + writeMeshModels(); writeMaterialObjects(); writeTextureObjects(); if (m_hasSkeleton) @@ -1016,11 +990,8 @@ class FBXDocumentBuilder // ── LayerElementMaterial ── { + // Each Model has exactly one material connected, so index is always 0 int matIndex = 0; - auto* subEnt = m_entity->getSubEntity(si); - auto matIt = m_materialIndexMap.find(subEnt->getMaterial()->getName()); - if (matIt != m_materialIndexMap.end()) - matIndex = matIt->second; m_w.beginNode("LayerElementMaterial"); m_w.writePropertyI(0); @@ -1075,31 +1046,38 @@ class FBXDocumentBuilder } } - // ── Mesh Model ─────────────────────────────────────────────── - void writeMeshModel() + // ── Mesh Models (one per submesh) ───────────────────────────── + void writeMeshModels() { - m_meshModelId = nextId(); - std::string modelName = std::string(m_entity->getName()); + for (size_t gi = 0; gi < m_geomIds.size(); ++gi) + { + unsigned int si = m_geomSubmeshIndices[gi]; + int64_t modelId = nextId(); + m_meshModelIds.push_back(modelId); - m_w.beginNode("Model"); - m_w.writePropertyL(m_meshModelId); - m_w.writePropertyS(modelName + std::string("\x00\x01", 2) + "Model"); - m_w.writePropertyS("Mesh"); - m_w.endProperties(); + std::string modelName = std::string(m_entity->getName()) + + "_submesh" + std::to_string(si); - m_w.beginNode("Version"); m_w.writePropertyI(232); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.beginNode("Model"); + m_w.writePropertyL(modelId); + m_w.writePropertyS(modelName + std::string("\x00\x01", 2) + "Model"); + m_w.writePropertyS("Mesh"); + m_w.endProperties(); - m_w.beginNode("Properties70"); - m_w.endProperties(); - writeP70LclTranslation(0.0, 0.0, 0.0); - writeP70LclRotation(0.0, 0.0, 0.0); - writeP70LclScaling(1.0, 1.0, 1.0); - m_w.endNode(); // Properties70 + m_w.beginNode("Version"); m_w.writePropertyI(232); m_w.endProperties(); m_w.endNodeLeaf(); - m_w.beginNode("Shading"); m_w.writePropertyBool(true); m_w.endProperties(); m_w.endNodeLeaf(); - m_w.beginNode("Culling"); m_w.writePropertyS("CullingOff"); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.beginNode("Properties70"); + m_w.endProperties(); + writeP70LclTranslation(0.0, 0.0, 0.0); + writeP70LclRotation(0.0, 0.0, 0.0); + writeP70LclScaling(1.0, 1.0, 1.0); + m_w.endNode(); // Properties70 + + m_w.beginNode("Shading"); m_w.writePropertyBool(true); m_w.endProperties(); m_w.endNodeLeaf(); + m_w.beginNode("Culling"); m_w.writePropertyS("CullingOff"); m_w.endProperties(); m_w.endNodeLeaf(); - m_w.endNode(); // Model + m_w.endNode(); // Model + } } // ── Material objects ───────────────────────────────────────── @@ -1169,33 +1147,13 @@ class FBXDocumentBuilder m_w.endNode(); // NodeAttribute // Model (LimbNode) — Z-mirrored, using initial (bind pose) values. - // Non-deforming bones (no vertex weights, e.g. "Armature") need their - // transform INVERTED before writing. BoneProcessor on reimport creates - // these from parentNode->mTransformation.inverse(), so writing the - // inverse here ensures the double-inversion recovers the original. - bool isNonDeforming = m_bonesWithAssignments.find(bone->getHandle()) - == m_bonesWithAssignments.end(); - - Ogre::Vector3 pos; - Ogre::Quaternion ori; - Ogre::Vector3 scl; - - if (isNonDeforming) - { - Ogre::Matrix4 localMat = buildLocalMatrix( - bone->getInitialPosition(), - bone->getInitialScale(), - bone->getInitialOrientation()); - Ogre::Matrix4 invMat = localMat.inverse(); - Ogre::Affine3 aff(invMat); - aff.decomposition(pos, scl, ori); - } - else - { - pos = bone->getInitialPosition(); - ori = bone->getInitialOrientation(); - scl = bone->getInitialScale(); - } + // All bones use raw transforms directly. BoneProcessor on reimport + // also reads transforms directly (no inversion), so the round-trip + // preserves the original values. External tools like Blender also + // read these transforms directly and get correct bone positions. + Ogre::Vector3 pos = bone->getInitialPosition(); + Ogre::Quaternion ori = bone->getInitialOrientation(); + Ogre::Vector3 scl = bone->getInitialScale(); Ogre::Quaternion mirroredRot = mirrorZ(ori); double rx, ry, rz; @@ -1297,19 +1255,32 @@ class FBXDocumentBuilder m_w.endProperties(); m_w.endNodeLeaf(); - // Transform = mesh bind pose transform (identity — mesh is at origin) - double identityArr[16] = {1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1}; - m_w.beginNode("Transform"); - m_w.writePropertyArrayD(std::vector(identityArr, identityArr + 16)); - m_w.endProperties(); - m_w.endNodeLeaf(); - // TransformLink = bone's global bind pose, Z-mirrored // Use computeGlobalBindPose (from initial transforms) instead of // _getFullTransform() which may reflect the current animation state Ogre::Matrix4 boneGlobal = computeGlobalBindPose(bone); double transformLinkArr[16]; matrix4ToDoublesMirrorZ(boneGlobal, transformLinkArr); + + // Transform = bone_global^{-1} @ mesh_global (in bone space, per FBX convention) + // Blender's FBX importer computes mesh_global = TransformLink @ Transform, + // so storing bone^{-1} ensures all clusters produce the same mesh_global. + // Since our mesh is at origin, mesh_global = identity → Transform = bone^{-1}. + Ogre::Matrix4 boneGlobalMirrored; + for (int r = 0; r < 4; ++r) + for (int c = 0; c < 4; ++c) + boneGlobalMirrored[r][c] = transformLinkArr[c * 4 + r]; + Ogre::Matrix4 transformMat = boneGlobalMirrored.inverse(); + double transformArr[16]; + for (int r = 0; r < 4; ++r) + for (int c = 0; c < 4; ++c) + transformArr[c * 4 + r] = transformMat[r][c]; + + m_w.beginNode("Transform"); + m_w.writePropertyArrayD(std::vector(transformArr, transformArr + 16)); + m_w.endProperties(); + m_w.endNodeLeaf(); + m_w.beginNode("TransformLink"); m_w.writePropertyArrayD(std::vector(transformLinkArr, transformLinkArr + 16)); m_w.endProperties(); @@ -1548,7 +1519,8 @@ class FBXDocumentBuilder void writeBindPose() { int64_t poseId = nextId(); - int poseNodeCount = 1 + m_skeleton->getNumBones(); // mesh + all bones + int poseNodeCount = static_cast(m_meshModelIds.size()) + + m_skeleton->getNumBones(); // mesh models + all bones m_w.beginNode("Pose"); m_w.writePropertyL(poseId); @@ -1571,10 +1543,11 @@ class FBXDocumentBuilder m_w.endProperties(); m_w.endNodeLeaf(); - // Mesh model PoseNode (identity — mesh has no transform) + // Mesh model PoseNodes (identity — meshes have no transform) { double identity[16] = {1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1}; - writePoseNode(m_meshModelId, identity); + for (auto modelId : m_meshModelIds) + writePoseNode(modelId, identity); } // Bone PoseNodes (global bind pose, Z-mirrored) @@ -1672,16 +1645,20 @@ class FBXDocumentBuilder m_w.beginNode("Connections"); m_w.endProperties(); - // Mesh model → root (id 0) - writeConnection("OO", m_meshModelId, 0); - - // Geometry → mesh model - for (auto geomId : m_geomIds) - writeConnection("OO", geomId, m_meshModelId); + // Each mesh model → root (id 0), geometry → its model, material → its model + for (size_t gi = 0; gi < m_meshModelIds.size(); ++gi) + { + int64_t modelId = m_meshModelIds[gi]; + writeConnection("OO", modelId, 0); + writeConnection("OO", m_geomIds[gi], modelId); - // Materials → mesh model - for (const auto& [name, matId] : m_materialIds) - writeConnection("OO", matId, m_meshModelId); + // Connect the submesh's material to this model + unsigned int si = m_geomSubmeshIndices[gi]; + auto* subEnt = m_entity->getSubEntity(si); + auto matIt = m_materialIds.find(subEnt->getMaterial()->getName()); + if (matIt != m_materialIds.end()) + writeConnection("OO", matIt->second, modelId); + } // Texture → Material (OP with "DiffuseColor") — connect to ALL materials that use each texture { @@ -1930,17 +1907,16 @@ class FBXDocumentBuilder int64_t m_nextId = 1000000; int64_t m_documentId = 100000; - int64_t m_meshModelId = 0; + std::vector m_meshModelIds; std::vector m_geomIds; std::vector m_geomSubmeshIndices; // submesh index for each entry in m_geomIds std::map m_materialIds; - std::map m_materialIndexMap; // matName → index (matching connection order) std::map m_textureIds; std::map m_videoIds; std::map m_boneModelIds; std::map m_boneAttrIds; - std::set m_bonesWithAssignments; + std::vector m_skinIds; std::vector m_animStackIds; diff --git a/src/MergeAnimationsCLI_test.cpp b/src/MergeAnimationsCLI_test.cpp index ae1999f..5f9ee3a 100644 --- a/src/MergeAnimationsCLI_test.cpp +++ b/src/MergeAnimationsCLI_test.cpp @@ -56,43 +56,45 @@ TEST(MergeAnimationsCLI, MissingArgs) if (binary.isEmpty()) GTEST_SKIP() << "QtMeshEditor binary not found"; + // anim with no file or flags should fail with usage error QProcess proc; - proc.start(binary, {"merge-animations"}); + proc.start(binary, {"anim"}); ASSERT_TRUE(proc.waitForFinished(30000)); EXPECT_NE(proc.exitCode(), 0); QString stderrOutput = QString::fromUtf8(proc.readAllStandardError()); - EXPECT_TRUE(stderrOutput.contains("Usage:")) << "stderr: " << stderrOutput.toStdString(); + EXPECT_TRUE(stderrOutput.contains("Error:")) << "stderr: " << stderrOutput.toStdString(); } -TEST(MergeAnimationsCLI, MissingOutput) +TEST(MergeAnimationsCLI, MissingMergeFiles) { QString binary = findAppBinary(); if (binary.isEmpty()) GTEST_SKIP() << "QtMeshEditor binary not found"; + // anim with file but --merge with no files should still proceed to load + // but fail because no merge files means only 1 entity loaded QProcess proc; - proc.start(binary, {"merge-animations", "--base", "somefile.fbx"}); + proc.start(binary, {"anim", "somefile.fbx", "--merge"}); ASSERT_TRUE(proc.waitForFinished(30000)); EXPECT_NE(proc.exitCode(), 0); - QString stderrOutput = QString::fromUtf8(proc.readAllStandardError()); - EXPECT_TRUE(stderrOutput.contains("Usage:")) << "stderr: " << stderrOutput.toStdString(); } -TEST(MergeAnimationsCLI, MissingBase) +TEST(MergeAnimationsCLI, MissingAction) { QString binary = findAppBinary(); if (binary.isEmpty()) GTEST_SKIP() << "QtMeshEditor binary not found"; + // anim with file but no --list/--rename/--merge should fail with usage QProcess proc; - proc.start(binary, {"merge-animations", "--output", tempPath("out.mesh")}); + proc.start(binary, {"anim", "somefile.fbx"}); ASSERT_TRUE(proc.waitForFinished(30000)); EXPECT_NE(proc.exitCode(), 0); QString stderrOutput = QString::fromUtf8(proc.readAllStandardError()); - EXPECT_TRUE(stderrOutput.contains("Usage:")) << "stderr: " << stderrOutput.toStdString(); + EXPECT_TRUE(stderrOutput.contains("--merge")) << "stderr: " << stderrOutput.toStdString(); } TEST(MergeAnimationsCLI, NonExistentBaseFile) @@ -102,9 +104,9 @@ TEST(MergeAnimationsCLI, NonExistentBaseFile) GTEST_SKIP() << "QtMeshEditor binary not found"; QProcess proc; - proc.start(binary, {"merge-animations", - "--base", tempPath("nonexistent_file_12345.fbx"), - "--output", tempPath("merge_test_out.mesh")}); + proc.start(binary, {"anim", tempPath("nonexistent_file_12345.fbx"), + "--merge", "other.fbx", + "-o", tempPath("merge_test_out.mesh")}); ASSERT_TRUE(proc.waitForFinished(30000)); EXPECT_NE(proc.exitCode(), 0); @@ -126,9 +128,8 @@ TEST(MergeAnimationsCLI, SingleFileNoAnimations) QString outputFile = tempPath("merge_test_single.mesh"); QProcess proc; - proc.start(binary, {"merge-animations", - "--base", baseFile, - "--output", outputFile}); + proc.start(binary, {"anim", baseFile, "--merge", + "-o", outputFile}); ASSERT_TRUE(proc.waitForFinished(60000)); EXPECT_EQ(proc.exitCode(), 1); @@ -162,10 +163,8 @@ TEST(MergeAnimationsCLI, SuccessfulMerge) QFile::remove(materialFile); QProcess proc; - proc.start(binary, {"merge-animations", - "--base", baseFile, - "--animations", animFile, - "--output", outputFile}); + proc.start(binary, {"anim", baseFile, "--merge", animFile, + "-o", outputFile}); ASSERT_TRUE(proc.waitForFinished(120000)) << "Process timed out"; QString stderrOutput = QString::fromUtf8(proc.readAllStandardError()); diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index f8cc165..5fcda08 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -776,7 +776,7 @@ static void ensureResourceGroup(const QString &path) } } -void MeshImporterExporter::importer(const QStringList &_uriList) +void MeshImporterExporter::importer(const QStringList &_uriList, unsigned int additionalFlags) { try{ foreach(const QString &fileName,_uriList) @@ -810,7 +810,7 @@ void MeshImporterExporter::importer(const QStringList &_uriList) // DirectX .x is natively left-handed — skip ConvertToLeftHanded // to avoid double-flipping geometry and UVs. bool convertLH = (file.suffix().compare("x", Qt::CaseInsensitive) != 0); - Ogre::MeshPtr mesh = importer.loadModel(file.filePath().toStdString(), convertLH); + Ogre::MeshPtr mesh = importer.loadModel(file.filePath().toStdString(), convertLH, additionalFlags); if (!mesh) return; auto meshName = file.baseName(); diff --git a/src/MeshImporterExporter.h b/src/MeshImporterExporter.h index 0fa517e..394ae58 100755 --- a/src/MeshImporterExporter.h +++ b/src/MeshImporterExporter.h @@ -45,7 +45,7 @@ class MeshImporterExporter static const QMap exportFormats; public: - static void importer(const QStringList &_uriList); + static void importer(const QStringList &_uriList, unsigned int additionalFlags = 0); static QString exporter(const Ogre::SceneNode *_sn); static int exporter(const Ogre::SceneNode *_sn, const QString &_uri, const QString &_format); static QString formatFileURI(const QString &_uri, const QString &_format); diff --git a/src/SkeletonDebug.cpp b/src/SkeletonDebug.cpp index 7bc854d..d26479f 100755 --- a/src/SkeletonDebug.cpp +++ b/src/SkeletonDebug.cpp @@ -76,7 +76,7 @@ void SkeletonDebug::createChildBoneRepresentations(const Ogre::Bone* pBone, Ogre { for(unsigned short i = 0; i < pBone->numChildren(); ++i) { - float length = pBone->getChild(i)->getPosition().length(); + float length = pBone->getChild(i)->getInitialPosition().length(); if(length < 0.00001f) continue; @@ -109,7 +109,7 @@ std::map> SkeletonDebug::createBoneVisua auto* tp = mEntity->attachObjectToBone(pBone->getName(), (Ogre::MovableObject*)ent); mBoneEntities.push_back(ent); - float length = pBone->getPosition().length(); + float length = pBone->getInitialPosition().length(); if(length >= 0.00001f) tp->setScale(length, length, length); } diff --git a/src/TestHelpers.h b/src/TestHelpers.h index 9e57979..405392e 100644 --- a/src/TestHelpers.h +++ b/src/TestHelpers.h @@ -88,7 +88,7 @@ static inline void createStandardOgreMaterials() * GL context that Ogre needs for hardware buffer operations (creating * entities, loading meshes, etc.) without requiring a visible window. * - * Follows the same pattern used in main.cpp for merge-animations CLI. + * Follows the same pattern used in CLIPipeline for headless CLI mode. * * Returns true if a render window already exists or was created * successfully, false on failure. diff --git a/src/main.cpp b/src/main.cpp index 8e55fb0..1d0c5f2 100755 --- a/src/main.cpp +++ b/src/main.cpp @@ -19,11 +19,7 @@ #include "ModelDownloader.h" #include "MCPServer.h" #include "SentryReporter.h" -#include "AnimationMerger.h" -#include "MeshImporterExporter.h" -#include "Manager.h" -#include "SelectionSet.h" -#include +#include "CLIPipeline.h" #ifndef Q_OS_WIN #include @@ -55,13 +51,35 @@ int main(int argc, char *argv[]) forceX11PlatformIfNeeded(); #endif - // Check for MCP server mode and merge-animations CLI before creating QApplication + // CLI pipeline mode detection — check before creating QApplication + { + bool cliMode = false; + QString execName = QFileInfo(QString(argv[0])).fileName().toLower(); + if (execName.startsWith("qtmesh") && !execName.contains("editor")) { + cliMode = true; + } + for (int i = 1; i < argc; ++i) { + QString arg(argv[i]); + if (arg == "--cli") { cliMode = true; break; } + } + if (!cliMode) { + for (int i = 1; i < argc; ++i) { + QString arg(argv[i]); + if (arg.startsWith("-")) + continue; // skip flags like --verbose + if (arg == "info" || arg == "fix" || arg == "convert" || arg == "anim") + cliMode = true; + break; // first non-flag arg determines mode + } + } + if (cliMode) + return CLIPipeline::run(argc, argv); + } + + // Check for MCP server mode before creating QApplication bool mcpOnlyMode = false; bool mcpWithGuiMode = false; - bool mergeMode = false; int httpPort = 8080; - QString mergeBase, mergeOutput; - QStringList mergeAnimFiles; for (int i = 1; i < argc; ++i) { QString arg = QString(argv[i]); if (arg == "--mcp" || arg == "-mcp") { @@ -70,16 +88,6 @@ int main(int argc, char *argv[]) mcpWithGuiMode = true; } else if (arg == "--http-port" && i + 1 < argc) { httpPort = QString(argv[++i]).toInt(); - } else if (arg == "merge-animations") { - mergeMode = true; - } else if (mergeMode && arg == "--base" && i + 1 < argc) { - mergeBase = QString(argv[++i]); - } else if (mergeMode && arg == "--output" && i + 1 < argc) { - mergeOutput = QString(argv[++i]); - } else if (mergeMode && arg == "--animations") { - while (i + 1 < argc && QString(argv[i + 1]).left(2) != "--") { - mergeAnimFiles.append(QString(argv[++i])); - } } } @@ -94,92 +102,6 @@ int main(int argc, char *argv[]) #endif } - if (mergeMode) { - // CLI merge-animations mode — needs QApplication + a hidden render window - // because Ogre requires a GL context to create entity hardware buffers. - QApplication a(argc, argv); - QCoreApplication::setOrganizationName("QtMeshEditor"); - QCoreApplication::setOrganizationDomain("none"); - QCoreApplication::setApplicationName("QtMeshEditor"); - QCoreApplication::setApplicationVersion(QTMESHEDITOR_VERSION); - - if (mergeBase.isEmpty() || mergeOutput.isEmpty()) { - qCritical() << "Usage: qtmesheditor merge-animations --base --animations [file2 ...] --output "; - return 1; - } - - // Init Ogre and create a hidden render window for the GL context - Manager::getSingleton(); - QWidget hiddenWidget; - hiddenWidget.setAttribute(Qt::WA_DontShowOnScreen); - hiddenWidget.resize(1, 1); - hiddenWidget.show(); - - Ogre::NameValuePairList params; - params["externalWindowHandle"] = Ogre::StringConverter::toString(hiddenWidget.winId()); -#ifdef Q_OS_MACOS - params["macAPI"] = "cocoa"; - params["macAPICocoaUseNSView"] = "true"; -#endif - Manager::getSingleton()->getRoot()->createRenderWindow( - "MergeHidden", 1, 1, false, ¶ms); - - // Load base file and verify it loaded - MeshImporterExporter::importer({mergeBase}); - { - auto& baseEntities = Manager::getSingleton()->getEntities(); - if (baseEntities.isEmpty()) { - qCritical().noquote() << "Failed to load base file:" << mergeBase; - Manager::kill(); - return 1; - } - } - - // Load animation files - for (const auto& f : mergeAnimFiles) - MeshImporterExporter::importer({f}); - - // Get all loaded entities (getEntities() rebuilds the list each call) - auto& entities = Manager::getSingleton()->getEntities(); - if (entities.size() < 2) { - qCritical() << "Need at least 2 loaded entities to merge (got" << entities.size() << ")"; - Manager::kill(); - return 1; - } - - QString err; - Ogre::Entity* merged = AnimationMerger::mergeAnimations(entities.first(), entities, err); - if (!merged) { - qCritical().noquote() << "Merge failed:" << err; - Manager::kill(); - return 1; - } - - // Determine export format from file extension - auto formatForExtension = [](const QString& path) -> QString { - if (path.endsWith(".glb2")) return "glTF 2.0 Binary (*.glb2)"; - if (path.endsWith(".gltf2")) return "glTF 2.0 (*.gltf2)"; - if (path.endsWith(".dae")) return "Collada (*.dae)"; - if (path.endsWith(".obj")) return "OBJ (*.obj)"; - if (path.endsWith(".stl")) return "STL (*.stl)"; - if (path.endsWith(".mesh.xml")) return "Ogre XML (*.mesh.xml)"; - if (path.endsWith(".mesh")) return "Ogre Mesh (*.mesh)"; - return "Ogre Mesh (*.mesh)"; - }; - - auto* node = merged->getParentSceneNode(); - int result = MeshImporterExporter::exporter(node, mergeOutput, formatForExtension(mergeOutput)); - if (result != 0) { - qCritical() << "Export failed"; - Manager::kill(); - return 1; - } - - qDebug().noquote() << "Merged" << entities.size() << "files ->" << mergeOutput; - Manager::kill(); - return 0; - } - if (mcpOnlyMode) { // MCP Server mode - runs as console application without GUI QCoreApplication a(argc, argv); diff --git a/src/test_main.cpp b/src/test_main.cpp index 130b707..d4a1226 100644 --- a/src/test_main.cpp +++ b/src/test_main.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include "Manager.h" #ifndef Q_OS_WIN @@ -37,10 +38,29 @@ static void crashHandler(int sig) #endif } +// Suppress qDebug/qInfo/qWarning noise from Ogre and Qt internals. +// qCritical and qFatal always pass through so real errors are visible. +static void testMessageHandler(QtMsgType type, const QMessageLogContext& ctx, const QString& msg) +{ + Q_UNUSED(ctx); + if (type == QtCriticalMsg || type == QtFatalMsg) + fprintf(stderr, "%s\n", qPrintable(msg)); +} + int main(int argc, char **argv) { QApplication app(argc, argv); + // Suppress Ogre log output (debug spam from Root, RenderSystem, plugins). + // Must be done before any Manager::getSingleton() call creates Root. + if (!Ogre::LogManager::getSingletonPtr()) { + auto* logMgr = new Ogre::LogManager(); + logMgr->createLog("ogre.log", true, false, true); // suppressDebugOut, suppressFileOutput + } + + // Suppress Qt debug messages + qInstallMessageHandler(testMessageHandler); + // Install signal handlers AFTER QApplication to avoid Qt overwriting them. signal(SIGSEGV, crashHandler); signal(SIGABRT, crashHandler);