diff --git a/CLAUDE.md b/CLAUDE.md index 4fc1039a5..52e50cff5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,7 +112,7 @@ Three singletons manage core state. All run on the main thread. Access via `Clas ### Mesh Import/Export -- **MeshImporterExporter** (`src/MeshImporterExporter.h/cpp`): Static methods. Supports .mesh, .obj, .dae, .gltf, .fbx via custom Assimp processors in `src/Assimp/`. +- **MeshImporterExporter** (`src/MeshImporterExporter.h/cpp`): Static methods. Supports .mesh, .obj, .dae, .gltf, .fbx via custom Assimp processors in `src/Assimp/`. Also provides `sceneExporter()`/`sceneImporter()` for saving/loading entire scenes (multiple entities with transforms, materials, skeletons, and animations) as glTF files. Multi-entity scenes use entity-name-prefixed bones to avoid cross-entity skeleton contamination when Assimp merges skins. - **FBXExporter** (`src/FBX/FBXExporter.h/cpp`): Custom FBX Binary v7300 exporter that writes directly from Ogre data. Handles geometry, skeleton, skin deformers, animations, and materials. Replaces Assimp's broken FBX exporter. ### Local LLM diff --git a/README.md b/README.md index adb812ae0..8a729780f 100755 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Split View|Skeleton Animation Controls QtMeshEditor helps you prepare 3D assets for your game or project: - **Merge animations** — Combine multiple animation files (e.g. from Mixamo) into one mesh with all animations +- **Save & load scenes** — Persist your entire scene (meshes, transforms, materials, skeletons, animations) to a single glTF file - **Convert between 40+ formats** — Import FBX, glTF, OBJ, Collada, STL, and more; export to what your engine needs - **Edit materials visually** — Real-time material preview with AI-assisted generation - **Inspect skeletons & animations** — Visualize bones, bone weights, preview animations, rename them diff --git a/docs/index.html b/docs/index.html index 3be239c35..81dc6d30c 100644 --- a/docs/index.html +++ b/docs/index.html @@ -572,10 +572,16 @@

Visual Material Editor

Real-time material preview with a visual editor. Change colors, textures, lighting, blending, and fog settings. Full undo/redo with 50-step history.

+
+
💾
+

Scene Save & Load

+

Save your entire scene — multiple meshes with positions, rotations, scales, materials, skeletons, and animations — to a single glTF file. Reopen later to pick up where you left off.

+
+
🔌

MCP Server (AI Agents)

-

Built-in Model Context Protocol server lets AI agents like Claude or Cursor control the editor. 25 tools for materials, meshes, transforms, and animations via HTTP API.

+

Built-in Model Context Protocol server lets AI agents like Claude or Cursor control the editor. 29 tools for materials, meshes, scenes, transforms, and animations via HTTP API.

@@ -618,6 +624,17 @@

Asset Inspection

+
+

Scene Persistence

+

+ 1. Import multiple meshes into the scene
+ 2. Position, rotate, and scale each object
+ 3. File → Save Scene as .scene.glb
+ 4. Reopen anytime to restore everything
+ 5. Skeletons, animations, and materials preserved +

+
+

CLI & Automation

@@ -785,16 +802,20 @@

Configure Your AI Agent

-

25 Tools Available

+

29 Tools Available

+# Save and load entire scenes (meshes, transforms, animations) +curl -X POST localhost:8080/api/tools/save_scene \ + -d '{"file_path":"/tmp/my_scene.scene.glb"}' +curl -X POST localhost:8080/api/tools/open_scene \ + -d '{"file_path":"/tmp/my_scene.scene.glb"}' + # Create a sphere and apply a red material curl -X POST localhost:8080/api/tools/create_primitive \ -d '{"type":"sphere","name":"MySphere"}' curl -X POST localhost:8080/api/tools/create_material \ -d '{"name":"RedMat","colors":{"diffuse":[1,0,0]}}' -curl -X POST localhost:8080/api/tools/apply_material \ - -d '{"material":"RedMat","mesh":"MySphere"}' # List all available tools curl localhost:8080/api/tools
diff --git a/qml/ViewCubeWindow.qml b/qml/ViewCubeWindow.qml index 11009ce15..999612223 100644 --- a/qml/ViewCubeWindow.qml +++ b/qml/ViewCubeWindow.qml @@ -1,20 +1,11 @@ import QtQuick -import QtQuick.Window import ViewCubeModule 1.0 -Window { +Rectangle { id: root width: 64 height: 64 - minimumWidth: 64 - maximumWidth: 64 - minimumHeight: 64 - maximumHeight: 64 - flags: Qt.Tool | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint color: "transparent" - visible: ViewCubeController.visible - x: ViewCubeController.windowX - y: ViewCubeController.windowY // Internal state property string hoveredZone: "" // face name, corner name, or "" diff --git a/src/AnimationWidget.cpp b/src/AnimationWidget.cpp index b3cd48b4b..b031dad0c 100755 --- a/src/AnimationWidget.cpp +++ b/src/AnimationWidget.cpp @@ -32,25 +32,26 @@ AnimationWidget::AnimationWidget(QWidget *parent) : connect(SelectionSet::getSingleton(),&SelectionSet::entitySelectionChanged,this,updateTables); connect(SelectionSet::getSingleton(),&SelectionSet::nodeSelectionChanged,this,updateTables); + // Clean up ALL skeleton debug/weight overlays before scene is cleared. + // This must happen before any nodes are destroyed so that SkeletonDebug + // timers don't fire with dangling entity pointers during teardown. + connect(Manager::getSingleton(), &Manager::sceneClearing, this, [this]() { + disableAllSkeletonDebug(); + }); + connect(Manager::getSingleton(), &Manager::sceneNodeDestroyed, this, [this](Ogre::SceneNode* const& node) { - // Clean up any SkeletonDebug and BoneWeightOverlay instances for entities attached to this node - // Signal fires before entities are destroyed, so we can safely access them - const auto& attachedObjects = node->getAttachedObjects(); - for(auto* obj : attachedObjects) + // Clean up any remaining SkeletonDebug and BoneWeightOverlay instances + // for entities attached to this node (e.g. single-node deletion). + QList entities; + for(auto* obj : node->getAttachedObjects()) { - if(obj->getMovableType() != "Entity") - continue; - auto* entity = static_cast(obj); - if(mWeightOverlays.contains(entity)) - { - mWeightOverlays.value(entity)->deleteLater(); - mWeightOverlays.remove(entity); - } - if(mShowSkeleton.contains(entity)) - { - mShowSkeleton.value(entity)->deleteLater(); - mShowSkeleton.remove(entity); - } + if(obj->getMovableType() == "Entity") + entities.append(static_cast(obj)); + } + for(auto* entity : entities) + { + delete mWeightOverlays.take(entity); + delete mShowSkeleton.take(entity); } }); diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index e2cbebad9..68a18fae5 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -378,7 +378,7 @@ QJsonObject MCPServer::callTool(const QString &name, const QJsonObject &args) // Start a performance transaction for heavy tools static const QStringList heavyTools = { "load_mesh", "export_mesh", "take_screenshot", "create_primitive", "create_material", - "merge_animations" + "merge_animations", "save_scene", "open_scene" }; uintptr_t txn = 0; if (heavyTools.contains(name)) { @@ -448,6 +448,10 @@ QJsonObject MCPServer::callTool(const QString &name, const QJsonObject &args) toolResult = toolToggleMeshInfo(args); } else if (name == "merge_animations") { toolResult = toolMergeAnimations(args); + } else if (name == "save_scene") { + toolResult = toolSaveScene(args); + } else if (name == "open_scene") { + toolResult = toolOpenScene(args); } else { if (txn) SentryReporter::finishTransaction(txn); return makeErrorResult(QString("Unknown tool: %1").arg(name)); @@ -2017,6 +2021,79 @@ QJsonObject MCPServer::toolMergeAnimations(const QJsonObject &args) } } +QJsonObject MCPServer::toolSaveScene(const QJsonObject &args) +{ + try { + QString filePath = args["file_path"].toString(); + if (filePath.isEmpty()) + return makeErrorResult("Error: 'file_path' is required (e.g. /tmp/scene.scene.glb)"); + + int result = MeshImporterExporter::sceneExporter(filePath); + if (result != 0) + return makeErrorResult("Error: Failed to save scene to " + filePath); + + return makeSuccessResult("Scene saved to " + filePath); + } catch (Ogre::Exception& e) { + return makeErrorResult(QString("Error: Ogre exception — %1").arg(e.getFullDescription().c_str())); + } catch (std::exception& e) { + return makeErrorResult(QString("Error: %1").arg(e.what())); + } +} + +QJsonObject MCPServer::toolOpenScene(const QJsonObject &args) +{ + try { + QString filePath = args["file_path"].toString(); + if (filePath.isEmpty()) + return makeErrorResult("Error: 'file_path' is required"); + + if (!QFile::exists(filePath)) + return makeErrorResult("Error: File not found: " + filePath); + + bool ok = MeshImporterExporter::sceneImporter(filePath); + if (!ok) + return makeErrorResult("Error: Failed to import scene from " + filePath); + + // Report what was loaded + Manager* mgr = Manager::getSingletonPtr(); + if (!mgr) + return makeSuccessResult("Scene loaded from " + filePath); + + auto sceneNodes = mgr->getSceneNodes(); + QString result = QString("Scene loaded from %1. %2 scene node(s):\n") + .arg(filePath).arg(sceneNodes.size()); + + for (auto* node : sceneNodes) { + QString nodeName = QString::fromStdString(node->getName()); + result += QString(" - %1").arg(nodeName); + + for (auto* obj : node->getAttachedObjects()) { + if (obj->getMovableType() != "Entity") continue; + auto* entity = static_cast(obj); + result += QString(" (entity: %1").arg(QString::fromStdString(entity->getName())); + if (entity->hasSkeleton()) { + auto* skel = entity->getMesh()->getSkeleton().get(); + result += QString(", %1 animation(s)").arg(skel->getNumAnimations()); + for (unsigned short ai = 0; ai < skel->getNumAnimations(); ++ai) { + result += QString("\n anim[%1]: '%2' (%3s)") + .arg(ai) + .arg(QString::fromStdString(skel->getAnimation(ai)->getName())) + .arg(skel->getAnimation(ai)->getLength(), 0, 'f', 2); + } + } + result += ")"; + } + result += "\n"; + } + + return makeSuccessResult(result); + } catch (Ogre::Exception& e) { + return makeErrorResult(QString("Error: Ogre exception — %1").arg(e.getFullDescription().c_str())); + } catch (std::exception& e) { + return makeErrorResult(QString("Error: %1").arg(e.what())); + } +} + // Helper methods QJsonArray MCPServer::buildToolsList() @@ -2501,6 +2578,40 @@ QJsonArray MCPServer::buildToolsList() )); } + // save_scene + { + QJsonObject inputSchema; + inputSchema["type"] = "object"; + QJsonObject props; + props["file_path"] = QJsonObject{{"type", "string"}, {"description", "Absolute path to save the scene file (e.g. /tmp/scene.scene.glb). Use .scene.glb for binary glTF or .scene.gltf for text."}}; + inputSchema["properties"] = props; + inputSchema["required"] = QJsonArray{"file_path"}; + + tools.append(buildToolDefinition( + "save_scene", + "Save the entire scene (all loaded meshes with positions, rotations, scales, materials, skeletons, and animations) to a glTF file. " + "Use .scene.glb for binary glTF (recommended, embeds textures) or .scene.gltf for text format.", + inputSchema + )); + } + + // open_scene + { + QJsonObject inputSchema; + inputSchema["type"] = "object"; + QJsonObject props; + props["file_path"] = QJsonObject{{"type", "string"}, {"description", "Absolute path to a scene file to open (*.scene.glb, *.scene.gltf, *.glb, *.gltf)"}}; + inputSchema["properties"] = props; + inputSchema["required"] = QJsonArray{"file_path"}; + + tools.append(buildToolDefinition( + "open_scene", + "Open a scene file, replacing the current scene. Loads all meshes with their transforms, materials, skeletons, and animations. " + "Reports what was loaded including entity names and animation counts.", + inputSchema + )); + } + return tools; } diff --git a/src/MCPServer.h b/src/MCPServer.h index 6e38017ee..b658c57e3 100644 --- a/src/MCPServer.h +++ b/src/MCPServer.h @@ -144,6 +144,8 @@ private slots: QJsonObject toolToggleNormals(const QJsonObject &args); QJsonObject toolToggleMeshInfo(const QJsonObject &args); QJsonObject toolMergeAnimations(const QJsonObject &args); + QJsonObject toolSaveScene(const QJsonObject &args); + QJsonObject toolOpenScene(const QJsonObject &args); // Animation struct NodeAnimation { diff --git a/src/MCPServer_test.cpp b/src/MCPServer_test.cpp index 7964047a9..8ce96652d 100644 --- a/src/MCPServer_test.cpp +++ b/src/MCPServer_test.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include "MCPServer.h" @@ -3464,3 +3465,86 @@ TEST_F(MCPServerTest, AnimSuccPath_NavigatePrevAtStart) // When at first keyframe, "prev" should stay at first keyframe (t=0.0) EXPECT_TRUE(getResultText(result).contains("Navigated to keyframe")); } + +// --- Scene save/load tools --- + +TEST_F(MCPServerTest, SaveScene_EmptyPath_ReturnsError) +{ + if (!canLoadMeshFiles()) { GTEST_SKIP() << "Skipping: entity creation not supported without render window"; } + + QJsonObject args; + args["file_path"] = ""; + QJsonObject result = server->callTool("save_scene", args); + EXPECT_TRUE(isError(result)); + EXPECT_TRUE(getResultText(result).contains("file_path")); +} + +TEST_F(MCPServerTest, OpenScene_MissingFile_ReturnsError) +{ + if (!canLoadMeshFiles()) { GTEST_SKIP() << "Skipping: entity creation not supported without render window"; } + + QJsonObject args; + args["file_path"] = "/tmp/nonexistent_scene_file_12345.scene.glb"; + QJsonObject result = server->callTool("open_scene", args); + EXPECT_TRUE(isError(result)); + EXPECT_TRUE(getResultText(result).contains("not found") || getResultText(result).contains("Error")); +} + +TEST_F(MCPServerTest, SaveScene_ValidScene_Succeeds) +{ + if (!canLoadMeshFiles()) { GTEST_SKIP() << "Skipping: entity creation not supported without render window"; } + + auto mesh1 = createInMemoryTriangleMesh("SaveSceneMesh1"); + auto mesh2 = createInMemoryTriangleMesh("SaveSceneMesh2"); + + auto* node1 = Manager::getSingleton()->addSceneNode("SaveSceneNode1"); + Manager::getSingleton()->createEntity(node1, mesh1); + + auto* node2 = Manager::getSingleton()->addSceneNode("SaveSceneNode2"); + Manager::getSingleton()->createEntity(node2, mesh2); + + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + QString filePath = tmpDir.path() + "/test_save.scene.glb"; + + QJsonObject args; + args["file_path"] = filePath; + QJsonObject result = server->callTool("save_scene", args); + EXPECT_FALSE(isError(result)); + EXPECT_TRUE(getResultText(result).contains("Scene saved")); + EXPECT_TRUE(QFile::exists(filePath)); +} + +TEST_F(MCPServerTest, OpenScene_ValidFile_LoadsEntities) +{ + if (!canLoadMeshFiles()) { GTEST_SKIP() << "Skipping: entity creation not supported without render window"; } + + // Create entities and save the scene + auto mesh1 = createInMemoryTriangleMesh("OpenSceneMesh1"); + auto mesh2 = createInMemoryTriangleMesh("OpenSceneMesh2"); + + auto* node1 = Manager::getSingleton()->addSceneNode("OpenSceneNode1"); + Manager::getSingleton()->createEntity(node1, mesh1); + + auto* node2 = Manager::getSingleton()->addSceneNode("OpenSceneNode2"); + Manager::getSingleton()->createEntity(node2, mesh2); + + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + QString filePath = tmpDir.path() + "/test_open.scene.glb"; + + QJsonObject saveArgs; + saveArgs["file_path"] = filePath; + QJsonObject saveResult = server->callTool("save_scene", saveArgs); + ASSERT_FALSE(isError(saveResult)); + ASSERT_TRUE(QFile::exists(filePath)); + + // Open the saved scene + QJsonObject openArgs; + openArgs["file_path"] = filePath; + QJsonObject openResult = server->callTool("open_scene", openArgs); + EXPECT_FALSE(isError(openResult)); + QString resultText = getResultText(openResult); + EXPECT_TRUE(resultText.contains("Scene loaded")); + EXPECT_TRUE(resultText.contains("scene node(s)")); +} diff --git a/src/Manager.cpp b/src/Manager.cpp index dc85e736c..bfd7811c4 100755 --- a/src/Manager.cpp +++ b/src/Manager.cpp @@ -438,7 +438,8 @@ QList &Manager::getEntities() bool Manager::isForbiddenNodeName(const QString &_name) { - return (_name=="TPCameraChildSceneNode" //TODO add a define for TPCameraChildSceneNode + return (_name.isEmpty() //Ogre 14 creates unnamed nodes with empty string (e.g. SpaceCamera nodes) + ||_name=="TPCameraChildSceneNode" //TODO add a define for TPCameraChildSceneNode ||_name=="GridLine_node" //TODO add a define for GridLine_node ||_name==SELECTIONBOX_OBJECT_NAME ||_name==TRANSFORM_OBJECT_NAME diff --git a/src/Manager.h b/src/Manager.h index f2b7f3f80..87a87e9a7 100755 --- a/src/Manager.h +++ b/src/Manager.h @@ -97,6 +97,7 @@ class Manager : public QObject void sceneNodeCreated(Ogre::SceneNode* const& newNode); void sceneNodeDestroyed(Ogre::SceneNode* const& node); void entityCreated(Ogre::Entity* const& newEntity); + void sceneClearing(); private: explicit Manager(MainWindow *parent); diff --git a/src/Manager_test.cpp b/src/Manager_test.cpp index d1a7d28e3..ccd2221ea 100644 --- a/src/Manager_test.cpp +++ b/src/Manager_test.cpp @@ -227,12 +227,14 @@ TEST_F(ManagerHeadlessTest, IsForbiddenNodeName) EXPECT_TRUE(mgr->isForbiddenNodeName("Unnamed_0")); EXPECT_TRUE(mgr->isForbiddenNodeName("Unnamed_camera")); + // Empty/unnamed are forbidden + EXPECT_TRUE(mgr->isForbiddenNodeName("")); + // Non-forbidden names EXPECT_FALSE(mgr->isForbiddenNodeName("Cube")); EXPECT_FALSE(mgr->isForbiddenNodeName("Sphere")); EXPECT_FALSE(mgr->isForbiddenNodeName("MyObject")); EXPECT_FALSE(mgr->isForbiddenNodeName("TPCameraChildSceneNode_0")); - EXPECT_FALSE(mgr->isForbiddenNodeName("")); } TEST_F(ManagerHeadlessTest, IsValidFileExtention) diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index 40eb1d0f1..ea53f5f01 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -41,8 +41,13 @@ THE SOFTWARE. #include "OgreXML/pugixml.hpp" #include "Manager.h" +#include "SelectionSet.h" #include "SentryReporter.h" #include "Assimp/Importer.h" +#include "Assimp/MaterialProcessor.h" +#include "Assimp/MeshProcessor.h" +#include "Assimp/BoneProcessor.h" +#include "Assimp/AnimationProcessor.h" #ifndef WIN32 #include @@ -1043,3 +1048,880 @@ int MeshImporterExporter::exporter(const Ogre::SceneNode *_sn, const QString &_u return 0; } + +// ─── Scene-level export: all scene nodes → single glTF ────────────── +static aiScene* buildSceneAiScene() +{ + auto* manager = Manager::getSingleton(); + const auto& sceneNodes = manager->getSceneNodes(); + + auto* scene = new aiScene(); + scene->mRootNode = new aiNode("Scene"); + + if (sceneNodes.isEmpty()) + { + // Valid empty scene + scene->mNumMeshes = 0; + scene->mNumMaterials = 1; + scene->mMaterials = new aiMaterial*[1]; + scene->mMaterials[0] = new aiMaterial(); + return scene; + } + + // --- Collect all entities from scene nodes --- + struct NodeEntityPair { + Ogre::SceneNode* sceneNode; + Ogre::Entity* entity; + }; + std::vector nodeEntities; + for (auto* sn : sceneNodes) + { + if (!manager->getSceneMgr()->hasEntity(sn->getName())) + continue; + auto* entity = manager->getSceneMgr()->getEntity(sn->getName()); + if (entity) + nodeEntities.push_back({sn, entity}); + } + + if (nodeEntities.empty()) + { + scene->mNumMeshes = 0; + scene->mNumMaterials = 1; + scene->mMaterials = new aiMaterial*[1]; + scene->mMaterials[0] = new aiMaterial(); + return scene; + } + + // --- Deduplicate materials across all entities --- + std::vector materials; + std::map> matIndexMap; + for (const auto& [sn, entity] : nodeEntities) + { + for (const auto* sub : entity->getSubEntities()) + { + auto mat = sub->getMaterial(); + if (matIndexMap.find(mat->getName()) == matIndexMap.end()) + { + matIndexMap[mat->getName()] = static_cast(materials.size()); + materials.push_back(mat); + } + } + } + + scene->mNumMaterials = static_cast(materials.size()); + scene->mMaterials = new aiMaterial*[scene->mNumMaterials]; + for (unsigned int i = 0; i < scene->mNumMaterials; ++i) + { + auto* aiMat = new aiMaterial(); + aiString matName(materials[i]->getName()); + aiMat->AddProperty(&matName, AI_MATKEY_NAME); + + auto* tech = materials[i]->getTechnique(0); + if (tech && tech->getNumPasses() > 0) + { + auto* pass = tech->getPass(0); + auto d = pass->getDiffuse(); + aiColor4D diffuse(d.r, d.g, d.b, d.a); + aiMat->AddProperty(&diffuse, 1, AI_MATKEY_COLOR_DIFFUSE); + + auto s = pass->getSpecular(); + aiColor4D specular(s.r, s.g, s.b, s.a); + aiMat->AddProperty(&specular, 1, AI_MATKEY_COLOR_SPECULAR); + + auto a = pass->getAmbient(); + aiColor4D ambient(a.r, a.g, a.b, a.a); + aiMat->AddProperty(&ambient, 1, AI_MATKEY_COLOR_AMBIENT); + + auto e = pass->getSelfIllumination(); + aiColor4D emissive(e.r, e.g, e.b, e.a); + aiMat->AddProperty(&emissive, 1, AI_MATKEY_COLOR_EMISSIVE); + + float shininess = pass->getShininess(); + aiMat->AddProperty(&shininess, 1, AI_MATKEY_SHININESS); + + unsigned short diffuseIdx = 0; + unsigned short normalIdx = 0; + for (unsigned short ti = 0; ti < pass->getNumTextureUnitStates(); ++ti) + { + auto* tus = pass->getTextureUnitState(ti); + if (tus->getContentType() == Ogre::TextureUnitState::CONTENT_NAMED) + { + aiString texPath(tus->getTextureName()); + const auto& tusName = tus->getName(); + if (tusName == "normal_map" || tusName == "NormalMap") + { + aiMat->AddProperty(&texPath, AI_MATKEY_TEXTURE(aiTextureType_NORMALS, normalIdx)); + ++normalIdx; + } + else + { + aiMat->AddProperty(&texPath, AI_MATKEY_TEXTURE(aiTextureType_DIFFUSE, diffuseIdx)); + ++diffuseIdx; + } + } + } + } + scene->mMaterials[i] = aiMat; + } + + // --- Count total meshes and build child nodes --- + unsigned int totalMeshes = 0; + for (const auto& [sn, entity] : nodeEntities) + totalMeshes += entity->getMesh()->getNumSubMeshes(); + + scene->mNumMeshes = totalMeshes; + scene->mMeshes = new aiMesh*[totalMeshes]; + + // Root node children: one per scene node + scene->mRootNode->mNumChildren = static_cast(nodeEntities.size()); + scene->mRootNode->mChildren = new aiNode*[scene->mRootNode->mNumChildren]; + + unsigned int globalMeshIdx = 0; + std::set processedSkeletons; + std::vector allAnimations; + + for (unsigned int ni = 0; ni < nodeEntities.size(); ++ni) + { + auto* sn = nodeEntities[ni].sceneNode; + auto* entity = nodeEntities[ni].entity; + const Ogre::MeshPtr mesh = entity->getMesh(); + const unsigned int numSub = mesh->getNumSubMeshes(); + const bool hasSkeleton = entity->hasSkeleton(); + Ogre::Skeleton* skeleton = hasSkeleton ? mesh->getSkeleton().get() : nullptr; + + // Build bone handle→name map for this entity + // Prefix bone names with entity name to ensure uniqueness across entities + std::map boneHandleToName; + std::string bonePrefix = (nodeEntities.size() > 1 && hasSkeleton) + ? std::string(sn->getName()) + "_" : ""; + + // Create the scene node's aiNode + aiNode* entityNode; + if (hasSkeleton) + { + // For skeletal meshes: create entity node with bone hierarchy + mesh child + entityNode = new aiNode(std::string(sn->getName())); + entityNode->mParent = scene->mRootNode; + + // Prefixed bone node builder + std::function buildPrefixedBoneNode; + buildPrefixedBoneNode = [&](Ogre::Bone* bone, aiNode* parent) -> aiNode* { + auto* node = new aiNode(bonePrefix + std::string(bone->getName())); + node->mParent = parent; + Ogre::Matrix4 localTransform; + localTransform.makeTransform(bone->getPosition(), bone->getScale(), bone->getOrientation()); + node->mTransformation = toAiMatrix(localTransform); + const auto& children = bone->getChildren(); + if (!children.empty()) { + node->mNumChildren = static_cast(children.size()); + node->mChildren = new aiNode*[node->mNumChildren]; + unsigned int ci = 0; + for (auto* child : children) { + auto* childBone = dynamic_cast(child); + if (childBone) + node->mChildren[ci++] = buildPrefixedBoneNode(childBone, node); + } + node->mNumChildren = ci; + } + return node; + }; + + auto numBones = skeleton->getNumBones(); + std::vector rootBoneNodes; + for (unsigned short bi = 0; bi < numBones; ++bi) + { + auto* bone = skeleton->getBone(bi); + boneHandleToName[bone->getHandle()] = bonePrefix + std::string(bone->getName()); + if (!bone->getParent()) + rootBoneNodes.push_back(buildPrefixedBoneNode(bone, entityNode)); + } + + auto* meshNode = new aiNode(std::string(sn->getName()) + "_mesh"); + meshNode->mParent = entityNode; + meshNode->mNumMeshes = numSub; + meshNode->mMeshes = new unsigned int[numSub]; + for (unsigned int si = 0; si < numSub; ++si) + meshNode->mMeshes[si] = globalMeshIdx + si; + + entityNode->mNumChildren = static_cast(rootBoneNodes.size()) + 1; + entityNode->mChildren = new aiNode*[entityNode->mNumChildren]; + for (unsigned int i = 0; i < rootBoneNodes.size(); ++i) + entityNode->mChildren[i] = rootBoneNodes[i]; + entityNode->mChildren[rootBoneNodes.size()] = meshNode; + } + else + { + // Non-skeletal: meshes directly on node + entityNode = new aiNode(std::string(sn->getName())); + entityNode->mParent = scene->mRootNode; + entityNode->mNumMeshes = numSub; + entityNode->mMeshes = new unsigned int[numSub]; + for (unsigned int si = 0; si < numSub; ++si) + entityNode->mMeshes[si] = globalMeshIdx + si; + } + + // Set transform from scene node position/orientation/scale + Ogre::Matrix4 nodeTransform; + nodeTransform.makeTransform(sn->getPosition(), sn->getScale(), sn->getOrientation()); + entityNode->mTransformation = toAiMatrix(nodeTransform); + + scene->mRootNode->mChildren[ni] = entityNode; + + // --- Build meshes for this entity --- + for (unsigned int si = 0; si < numSub; ++si) + { + const Ogre::SubMesh* subMesh = mesh->getSubMesh(si); + const Ogre::VertexData* vData = subMesh->useSharedVertices + ? mesh->sharedVertexData : subMesh->vertexData; + if (!vData) { scene->mMeshes[globalMeshIdx + si] = new aiMesh(); continue; } + + auto* aiM = new aiMesh(); + scene->mMeshes[globalMeshIdx + si] = aiM; + aiM->mPrimitiveTypes = aiPrimitiveType_TRIANGLE; + aiM->mNumVertices = static_cast(vData->vertexCount); + aiM->mVertices = new aiVector3D[aiM->mNumVertices]; + + // Material index + const auto* subEnt = entity->getSubEntity(si); + auto matIt = matIndexMap.find(subEnt->getMaterial()->getName()); + aiM->mMaterialIndex = (matIt != matIndexMap.end()) ? matIt->second : 0; + + // Read positions + const auto* posElem = vData->vertexDeclaration->findElementBySemantic(Ogre::VES_POSITION); + if (posElem) + { + auto vbuf = vData->vertexBufferBinding->getBuffer(posElem->getSource()); + auto* base = static_cast(vbuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + for (unsigned int j = 0; j < aiM->mNumVertices; ++j) + { + const Ogre::Real* p; + posElem->baseVertexPointerToElement(const_cast(base + j * vbuf->getVertexSize()), &p); + aiM->mVertices[j] = aiVector3D(p[0], p[1], p[2]); + } + vbuf->unlock(); + } + + // Read normals + const auto* normElem = vData->vertexDeclaration->findElementBySemantic(Ogre::VES_NORMAL); + if (normElem) + { + aiM->mNormals = new aiVector3D[aiM->mNumVertices]; + auto vbuf = vData->vertexBufferBinding->getBuffer(normElem->getSource()); + auto* base = static_cast(vbuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + for (unsigned int j = 0; j < aiM->mNumVertices; ++j) + { + const Ogre::Real* p; + normElem->baseVertexPointerToElement(const_cast(base + j * vbuf->getVertexSize()), &p); + aiM->mNormals[j] = aiVector3D(p[0], p[1], p[2]); + } + vbuf->unlock(); + } + + // Read texture coordinates + const auto* tcElem = vData->vertexDeclaration->findElementBySemantic(Ogre::VES_TEXTURE_COORDINATES); + if (tcElem) + { + aiM->mTextureCoords[0] = new aiVector3D[aiM->mNumVertices]; + aiM->mNumUVComponents[0] = 2; + auto vbuf = vData->vertexBufferBinding->getBuffer(tcElem->getSource()); + auto* base = static_cast(vbuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + for (unsigned int j = 0; j < aiM->mNumVertices; ++j) + { + const Ogre::Real* p; + tcElem->baseVertexPointerToElement(const_cast(base + j * vbuf->getVertexSize()), &p); + aiM->mTextureCoords[0][j] = aiVector3D(p[0], p[1], 0.0f); + } + vbuf->unlock(); + } + + // Read indices + const Ogre::IndexData* iData = subMesh->indexData; + if (iData && iData->indexCount > 0) + { + aiM->mNumFaces = static_cast(iData->indexCount / 3); + aiM->mFaces = new aiFace[aiM->mNumFaces]; + auto ibuf = iData->indexBuffer; + auto* ibase = static_cast(ibuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + bool use32 = ibuf->getType() == Ogre::HardwareIndexBuffer::IT_32BIT; + + for (unsigned int f = 0; f < aiM->mNumFaces; ++f) + { + aiM->mFaces[f].mNumIndices = 3; + aiM->mFaces[f].mIndices = new unsigned int[3]; + for (unsigned int v = 0; v < 3; ++v) + { + unsigned int idx = use32 + ? reinterpret_cast(ibase)[f * 3 + v] + : reinterpret_cast(ibase)[f * 3 + v]; + aiM->mFaces[f].mIndices[v] = idx; + } + } + ibuf->unlock(); + } + + // Bone weights + if (hasSkeleton) + { + const auto& boneAssignments = subMesh->useSharedVertices + ? mesh->getBoneAssignments() : subMesh->getBoneAssignments(); + std::map> boneWeightsMap; + for (const auto& [vertIdx, vba] : boneAssignments) + { + aiVertexWeight w; + w.mVertexId = vba.vertexIndex; + w.mWeight = vba.weight; + boneWeightsMap[vba.boneIndex].push_back(w); + } + + if (!boneWeightsMap.empty()) + { + aiM->mNumBones = static_cast(boneWeightsMap.size()); + aiM->mBones = new aiBone*[aiM->mNumBones]; + unsigned int bi = 0; + for (const auto& [handle, weights] : boneWeightsMap) + { + auto* aiBoneObj = new aiBone(); + auto nameIt = boneHandleToName.find(handle); + if (nameIt != boneHandleToName.end()) + aiBoneObj->mName = aiString(nameIt->second); + + auto* bone = skeleton->getBone(handle); + Ogre::Matrix4 globalTransform = bone->_getFullTransform(); + aiBoneObj->mOffsetMatrix = toAiMatrix(globalTransform.inverse()); + + aiBoneObj->mNumWeights = static_cast(weights.size()); + aiBoneObj->mWeights = new aiVertexWeight[aiBoneObj->mNumWeights]; + for (unsigned int wi = 0; wi < aiBoneObj->mNumWeights; ++wi) + aiBoneObj->mWeights[wi] = weights[wi]; + + aiM->mBones[bi++] = aiBoneObj; + } + } + } + } + + // --- Animations --- + // When bone names are prefixed, each entity needs its own animations + // Only dedup when there's no prefix (single entity) + if (hasSkeleton && skeleton->getNumAnimations() > 0 + && (bonePrefix.empty() ? processedSkeletons.insert(skeleton).second : true)) + { + for (unsigned short ai = 0; ai < skeleton->getNumAnimations(); ++ai) + { + auto* ogreAnim = skeleton->getAnimation(ai); + auto* anim = new aiAnimation(); + anim->mName = aiString(bonePrefix + ogreAnim->getName()); + anim->mTicksPerSecond = 1.0; + anim->mDuration = ogreAnim->getLength(); + + std::vector channels; + for (const auto& [handle, track] : ogreAnim->_getNodeTrackList()) + { + auto* bone = dynamic_cast(track->getAssociatedNode()); + if (!bone) continue; + + auto* nodeAnim = new aiNodeAnim(); + nodeAnim->mNodeName = aiString(bonePrefix + std::string(bone->getName())); + + auto numKeyFrames = track->getNumKeyFrames(); + nodeAnim->mNumPositionKeys = numKeyFrames; + nodeAnim->mNumRotationKeys = numKeyFrames; + nodeAnim->mNumScalingKeys = numKeyFrames; + nodeAnim->mPositionKeys = new aiVectorKey[numKeyFrames]; + nodeAnim->mRotationKeys = new aiQuatKey[numKeyFrames]; + nodeAnim->mScalingKeys = new aiVectorKey[numKeyFrames]; + + Ogre::Vector3 bindPos = bone->getPosition(); + Ogre::Quaternion bindRot = bone->getOrientation(); + + for (unsigned short ki = 0; ki < numKeyFrames; ++ki) + { + auto* kf = track->getNodeKeyFrame(ki); + double time = kf->getTime(); + + Ogre::Vector3 pos = bindPos + kf->getTranslate(); + nodeAnim->mPositionKeys[ki].mTime = time; + nodeAnim->mPositionKeys[ki].mValue = aiVector3D(pos.x, pos.y, pos.z); + + Ogre::Quaternion rot = bindRot * kf->getRotation(); + rot.normalise(); + nodeAnim->mRotationKeys[ki].mTime = time; + nodeAnim->mRotationKeys[ki].mValue = aiQuaternion(rot.w, rot.x, rot.y, rot.z); + + Ogre::Vector3 scl = kf->getScale(); + nodeAnim->mScalingKeys[ki].mTime = time; + nodeAnim->mScalingKeys[ki].mValue = aiVector3D(scl.x, scl.y, scl.z); + } + channels.push_back(nodeAnim); + } + anim->mNumChannels = static_cast(channels.size()); + anim->mChannels = new aiNodeAnim*[anim->mNumChannels]; + for (unsigned int ci = 0; ci < anim->mNumChannels; ++ci) + anim->mChannels[ci] = channels[ci]; + allAnimations.push_back(anim); + } + } + + globalMeshIdx += numSub; + } + + // Assign animations to scene + if (!allAnimations.empty()) + { + scene->mNumAnimations = static_cast(allAnimations.size()); + scene->mAnimations = new aiAnimation*[scene->mNumAnimations]; + for (unsigned int i = 0; i < scene->mNumAnimations; ++i) + scene->mAnimations[i] = allAnimations[i]; + } + + return scene; +} + +int MeshImporterExporter::sceneExporter(const QString &_uri) +{ + if (_uri.isEmpty()) return -1; + + QFileInfo file(_uri); + + try { + // Export textures for all entities + auto* manager = Manager::getSingleton(); + for (auto* sn : manager->getSceneNodes()) + { + if (!manager->getSceneMgr()->hasEntity(sn->getName())) + continue; + auto* entity = manager->getSceneMgr()->getEntity(sn->getName()); + if (entity) + exportMaterial(entity, file); + } + + aiScene* scene = buildSceneAiScene(); + if (!scene) + { + Ogre::LogManager::getSingleton().logError("Failed to build scene aiScene"); + return -1; + } + + // Determine format from extension + // Strip .scene prefix if present (e.g., "model.scene.glb" → use "glb2") + QString suffix = file.suffix().toLower(); + QString formatId = (suffix == "glb") ? "glb2" : "gltf2"; + + Assimp::Exporter exporter; + + // Both Ogre and glTF are right-handed — no ConvertToLeftHanded + aiReturn result = exporter.Export(scene, formatId.toStdString().c_str(), + file.filePath().toStdString().c_str(), 0); + if (result != AI_SUCCESS) + { + auto msg = QString("Scene export failed (code %1): %2") + .arg(result).arg(exporter.GetErrorString()); + qWarning() << msg; + Ogre::LogManager::getSingleton().logError(msg.toStdString()); + SentryReporter::captureMessage(msg, "error"); + delete scene; + return -1; + } + + delete scene; + } catch (std::exception& ex) { + auto msg = QString("Scene export failed: %1").arg(ex.what()); + Ogre::LogManager::getSingleton().logError(msg.toStdString()); + SentryReporter::captureMessage(msg, "error"); + return -1; + } catch (...) { + Ogre::LogManager::getSingleton().logError("Scene export failed with unknown exception"); + return -1; + } + + return 0; +} + +// ─── Scene-level import: glTF → multiple scene nodes ──────────────── +bool MeshImporterExporter::sceneImporter(const QString &_uri) +{ + if (_uri.isEmpty()) return false; + + QFileInfo file(_uri); + if (!file.exists()) return false; + + ensureResourceGroup(file.path()); + + // Parse the file BEFORE clearing the scene so we don't destroy + // the user's work if the file is invalid. + Assimp::Importer assimpImporter; + assimpImporter.SetPropertyBool(AI_CONFIG_IMPORT_FBX_PRESERVE_PIVOTS, false); + + unsigned int flags = aiProcess_CalcTangentSpace | + aiProcess_JoinIdenticalVertices | + aiProcess_Triangulate | + aiProcess_RemoveComponent | + aiProcess_GenSmoothNormals | + aiProcess_ValidateDataStructure | + aiProcess_LimitBoneWeights | + aiProcess_SortByPType | + aiProcess_ImproveCacheLocality | + aiProcess_FixInfacingNormals | + aiProcess_PopulateArmatureData | + aiProcess_OptimizeMeshes | + aiProcess_GlobalScale; + + const aiScene* scene = assimpImporter.ReadFile(file.filePath().toStdString(), flags); + if (!scene || !scene->mRootNode) + { + Ogre::LogManager::getSingleton().logError( + "Scene import failed: " + std::string(assimpImporter.GetErrorString())); + return false; + } + + // File is valid — now clear existing scene + SelectionSet::getSingleton()->clearList(); + auto* manager = Manager::getSingleton(); + emit manager->sceneClearing(); // let listeners clean up before nodes are destroyed + auto sceneNodesCopy = manager->getSceneNodes(); + for (auto* sn : sceneNodesCopy) + manager->destroySceneNode(sn); + + try { + + // Process materials + MaterialProcessor materialProcessor; + materialProcessor.loadScene(scene); + + // Build set of bone names to distinguish bones from scene nodes + std::set boneNames; + for (unsigned int mi = 0; mi < scene->mNumMeshes; ++mi) + { + const aiMesh* mesh = scene->mMeshes[mi]; + for (unsigned int bi = 0; bi < mesh->mNumBones; ++bi) + boneNames.insert(mesh->mBones[bi]->mName.C_Str()); + } + + // Helper: decompose aiMatrix4x4 into position, orientation, scale + auto decomposeTransform = [](const aiMatrix4x4& m, + Ogre::Vector3& pos, Ogre::Quaternion& orient, Ogre::Vector3& scale) + { + aiVector3D aiPos, aiScale; + aiQuaternion aiRot; + m.Decompose(aiScale, aiRot, aiPos); + pos = Ogre::Vector3(aiPos.x, aiPos.y, aiPos.z); + orient = Ogre::Quaternion(aiRot.w, aiRot.x, aiRot.y, aiRot.z); + scale = Ogre::Vector3(aiScale.x, aiScale.y, aiScale.z); + }; + + // Collect all mesh-bearing nodes with their world transforms + struct NodeEntry { + const aiNode* node; + aiMatrix4x4 worldTransform; + }; + std::vector meshNodes; + + std::function collectNodes; + collectNodes = [&](const aiNode* node, const aiMatrix4x4& parentTransform) + { + aiMatrix4x4 worldTransform = parentTransform * node->mTransformation; + bool isBone = boneNames.count(node->mName.C_Str()) > 0 && node->mNumMeshes == 0; + + if (node->mNumMeshes > 0 && !isBone) + meshNodes.push_back({node, worldTransform}); + + if (!isBone) + { + for (unsigned int ci = 0; ci < node->mNumChildren; ++ci) + collectNodes(node->mChildren[ci], worldTransform); + } + }; + collectNodes(scene->mRootNode, aiMatrix4x4()); + + // Create one Ogre entity per mesh-bearing node + for (const auto& entry : meshNodes) + { + const aiNode* node = entry.node; + + QString nodeName = QString::fromUtf8(node->mName.C_Str()); + + // Detect the synthetic "/_mesh" pattern produced + // by our scene exporter for skeletal entities. Only strip the suffix + // and enable prefix-based bone filtering when this exact pattern is present. + bool isSyntheticMeshNode = false; + if (node->mParent && node->mParent != scene->mRootNode) + { + QString parentName = QString::fromUtf8(node->mParent->mName.C_Str()); + if (!parentName.isEmpty() && nodeName == parentName + "_mesh") + { + isSyntheticMeshNode = true; + nodeName = parentName; + } + } + + if (nodeName.isEmpty()) + nodeName = QString("SceneNode_%1").arg(manager->getSceneNodes().size()); + + // Make unique name + QString baseName = nodeName; + int counter = 1; + while (manager->hasSceneNode(nodeName) || manager->isForbiddenNodeName(nodeName)) + nodeName = QString("%1_%2").arg(baseName).arg(counter++); + + std::string meshName = (nodeName + "_mesh").toStdString(); + if (auto old = Ogre::MeshManager::getSingleton().getByName(meshName)) + Ogre::MeshManager::getSingleton().remove(old); + + // Entity prefix for bone/animation filtering. + // Only applied when we detected our synthetic export pattern AND + // there are multiple entities (shared skin scenario). + std::string entityPrefix; + if (isSyntheticMeshNode && meshNodes.size() > 1) + { + entityPrefix = nodeName.toStdString() + "_"; + } + + // Check if any of this node's meshes have bones + bool hasBones = false; + for (unsigned int mi = 0; mi < node->mNumMeshes && !hasBones; ++mi) + { + if (scene->mMeshes[node->mMeshes[mi]]->mNumBones > 0) + hasBones = true; + } + + // Create per-entity skeleton scoped to only this node's meshes + Ogre::SkeletonPtr skeleton; + if (hasBones) + { + std::string skelName = meshName + ".skeleton"; + if (auto old = Ogre::SkeletonManager::getSingleton().getByName(skelName)) + Ogre::SkeletonManager::getSingleton().remove(old); + + skeleton = Ogre::SkeletonManager::getSingleton().create( + skelName, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME, true); + + std::vector nodeMeshPtrs; + for (unsigned int mi = 0; mi < node->mNumMeshes; ++mi) + nodeMeshPtrs.push_back(scene->mMeshes[node->mMeshes[mi]]); + + // Build skeleton directly from mesh bone data. + // When a shared glTF skin merges bones from multiple entities, + // use the entity prefix to include only this entity's bones. + + // Step 1: Create all bones and compute global transforms + std::map boneGlobalTransforms; + for (auto* m : nodeMeshPtrs) + { + for (unsigned int bi = 0; bi < m->mNumBones; ++bi) + { + aiBone* bone = m->mBones[bi]; + std::string name = bone->mName.C_Str(); + + // Filter: only include bones matching this entity's prefix + if (!entityPrefix.empty() + && name.rfind(entityPrefix, 0) != 0) + continue; + + if (skeleton->hasBone(name)) + continue; + + skeleton->createBone(name); + + // Global transform = inverse of offset matrix + aiMatrix4x4 off = bone->mOffsetMatrix; + aiMatrix4x4 global = off; + global.Inverse(); + Ogre::Matrix4 ogreGlobal( + global.a1, global.a2, global.a3, global.a4, + global.b1, global.b2, global.b3, global.b4, + global.c1, global.c2, global.c3, global.c4, + global.d1, global.d2, global.d3, global.d4); + boneGlobalTransforms[name] = ogreGlobal; + } + } + + // Step 2: Set parent-child relationships using mNode hierarchy + // (only between bones that both exist in this skeleton) + for (auto* m : nodeMeshPtrs) + { + for (unsigned int bi = 0; bi < m->mNumBones; ++bi) + { + aiBone* bone = m->mBones[bi]; + if (!bone->mNode || !bone->mNode->mParent) + continue; + + // Walk up the node tree to find a parent that's in this skeleton + aiNode* parentNode = bone->mNode->mParent; + while (parentNode) + { + std::string parentName = parentNode->mName.C_Str(); + if (skeleton->hasBone(parentName)) + { + Ogre::Bone* parentBone = skeleton->getBone(parentName); + Ogre::Bone* childBone = skeleton->getBone(bone->mName.C_Str()); + if (!childBone->getParent()) + parentBone->addChild(childBone); + break; + } + parentNode = parentNode->mParent; + } + } + } + + // Step 3: Apply transforms (convert global to local) + for (auto& [name, globalTf] : boneGlobalTransforms) + { + Ogre::Bone* bone = skeleton->getBone(name); + Ogre::Matrix4 localTf = globalTf; + if (bone->getParent()) + { + auto parentIt = boneGlobalTransforms.find(bone->getParent()->getName()); + if (parentIt != boneGlobalTransforms.end()) + localTf = parentIt->second.inverse() * globalTf; + } + Ogre::Affine3 affine(localTf); + Ogre::Vector3 pos, scale; + Ogre::Quaternion orient; + affine.decomposition(pos, scale, orient); + bone->setPosition(pos); + bone->setOrientation(orient); + bone->setScale(scale); + } + + skeleton->setBindingPose(); + + // Filter animations: use entity prefix if available, + // otherwise fall back to bone name matching + if (scene->HasAnimations()) + { + std::vector relevantAnims; + for (unsigned int ai = 0; ai < scene->mNumAnimations; ++ai) + { + aiAnimation* anim = scene->mAnimations[ai]; + if (!entityPrefix.empty()) + { + // Match by animation name prefix (e.g., "Hip Hop Dancing_mixamo.com") + std::string animName = anim->mName.C_Str(); + if (animName.rfind(entityPrefix, 0) == 0) + relevantAnims.push_back(anim); + } + else + { + // No prefix: match by bone names in channels + for (unsigned int ci = 0; ci < anim->mNumChannels; ++ci) + { + if (skeleton->hasBone(anim->mChannels[ci]->mNodeName.C_Str())) + { + relevantAnims.push_back(anim); + break; + } + } + } + } + + if (!relevantAnims.empty()) + { + // Non-owning temp scene for AnimationProcessor + aiScene tempScene; + tempScene.mAnimations = relevantAnims.data(); + tempScene.mNumAnimations = static_cast(relevantAnims.size()); + AnimationProcessor animationProcessor(skeleton); + animationProcessor.processAnimations(&tempScene); + tempScene.mAnimations = nullptr; + tempScene.mNumAnimations = 0; + } + + // Remove empty animations (channels for other entities' bones + // got skipped, leaving zero tracks) + std::vector emptyAnims; + for (unsigned short ai = 0; ai < skeleton->getNumAnimations(); ++ai) + { + auto* anim = skeleton->getAnimation(ai); + if (anim->getNumNodeTracks() == 0) + emptyAnims.push_back(anim->getName()); + } + for (const auto& name : emptyAnims) + skeleton->removeAnimation(name); + } + } + + // Use MeshProcessor: temporarily suppress children to process + // only this node's meshes (not descendants). + unsigned int savedNumChildren = node->mNumChildren; + aiNode** savedChildren = node->mChildren; + const_cast(node)->mNumChildren = 0; + const_cast(node)->mChildren = nullptr; + + // When entity prefix filtering is active, strip foreign bones + // from meshes so MeshProcessor doesn't try to look them up + // in this entity's skeleton (they would cause getBone() to throw). + struct SavedBones { + aiMesh* mesh; + aiBone** origBones; + unsigned int origNumBones; + std::vector filteredBones; + }; + std::vector savedBones; + + if (!entityPrefix.empty() && skeleton) + { + for (unsigned int mi = 0; mi < node->mNumMeshes; ++mi) + { + aiMesh* mesh = scene->mMeshes[node->mMeshes[mi]]; + if (mesh->mNumBones == 0) continue; + + SavedBones sb; + sb.mesh = mesh; + sb.origBones = mesh->mBones; + sb.origNumBones = mesh->mNumBones; + for (unsigned int bi = 0; bi < mesh->mNumBones; ++bi) + { + std::string bname = mesh->mBones[bi]->mName.C_Str(); + if (bname.rfind(entityPrefix, 0) == 0) + sb.filteredBones.push_back(mesh->mBones[bi]); + } + mesh->mBones = sb.filteredBones.data(); + mesh->mNumBones = static_cast(sb.filteredBones.size()); + savedBones.push_back(std::move(sb)); + } + } + + MeshProcessor meshProcessor(skeleton); + meshProcessor.processNode(const_cast(node), const_cast(scene)); + + // Restore original bone arrays + for (auto& sb : savedBones) + { + sb.mesh->mBones = sb.origBones; + sb.mesh->mNumBones = sb.origNumBones; + } + + const_cast(node)->mNumChildren = savedNumChildren; + const_cast(node)->mChildren = savedChildren; + + Ogre::MeshPtr ogreMesh = meshProcessor.createMesh( + meshName, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME, + materialProcessor); + + // Create scene node with decomposed world transform + Ogre::SceneNode* sn = manager->addSceneNode(nodeName); + + Ogre::Vector3 pos; + Ogre::Quaternion orient; + Ogre::Vector3 scale; + decomposeTransform(entry.worldTransform, pos, orient, scale); + sn->setPosition(pos); + sn->setOrientation(orient); + sn->setScale(scale); + + manager->createEntity(sn, ogreMesh); + } + + return true; + } catch (Ogre::Exception& e) { + Ogre::LogManager::getSingleton().logError("Scene import failed: " + e.getFullDescription()); + SentryReporter::captureMessage( + QString("Scene import failed: %1").arg(e.getFullDescription().c_str()), "error"); + return false; + } catch (std::exception& ex) { + auto msg = QString("Scene import failed: %1").arg(ex.what()); + Ogre::LogManager::getSingleton().logError(msg.toStdString()); + SentryReporter::captureMessage(msg, "error"); + return false; + } +} diff --git a/src/MeshImporterExporter.h b/src/MeshImporterExporter.h index 394ae58f6..5e858d398 100755 --- a/src/MeshImporterExporter.h +++ b/src/MeshImporterExporter.h @@ -50,6 +50,9 @@ class MeshImporterExporter static int exporter(const Ogre::SceneNode *_sn, const QString &_uri, const QString &_format); static QString formatFileURI(const QString &_uri, const QString &_format); static QString exportFileDialogFilter(); + + static int sceneExporter(const QString &_uri); + static bool sceneImporter(const QString &_uri); }; #endif // MESHIMPORTEREXPORTER_H diff --git a/src/MeshImporterExporter_test.cpp b/src/MeshImporterExporter_test.cpp index 652bd203a..412bdf73a 100644 --- a/src/MeshImporterExporter_test.cpp +++ b/src/MeshImporterExporter_test.cpp @@ -8,8 +8,13 @@ #include #include #include +#include +#include +#include +#include #include "Manager.h" #include "MeshImporterExporter.h" +#include "SelectionSet.h" #include "OgreXML/OgreXMLSkeletonSerializer.h" #include #include "TestHelpers.h" @@ -1583,3 +1588,181 @@ TEST_F(MeshImporterExporterTest, ImportOgreXML_PositionsOnly_NoNormalsNoUVs) { QFile::remove(xmlPath); } +// ─── Scene Save/Load Tests ────────────────────────────────────────── + +class SceneSaveLoadTest : public ::testing::Test { +protected: + QApplication* app = nullptr; + + void SetUp() override { + Manager::kill(); + QThread::msleep(50); + + app = qobject_cast(QCoreApplication::instance()); + ASSERT_NE(app, nullptr); + + if (!tryInitOgre()) { + GTEST_SKIP() << "Skipping: Ogre initialization failed"; + } + if (!canLoadMeshFiles()) { + GTEST_SKIP() << "Skipping: Cannot load mesh files (no GL context)"; + } + createStandardOgreMaterials(); + } + + void TearDown() override { + Manager::kill(); + if (app) app->processEvents(); + QThread::msleep(50); + } +}; + +TEST_F(SceneSaveLoadTest, RoundTrip_TwoEntities_PreservesTransforms) { + auto* manager = Manager::getSingleton(); + + // Create two entities with different transforms (position, rotation, scale) + auto mesh1 = createInMemoryTriangleMesh("scene_rt_mesh1"); + auto* sn1 = manager->addSceneNode("SceneNode1"); + manager->createEntity(sn1, mesh1); + sn1->setPosition(Ogre::Vector3(1.0f, 2.0f, 3.0f)); + sn1->setScale(Ogre::Vector3(1.5f, 2.0f, 0.5f)); + // 45-degree rotation around Y + Ogre::Quaternion rot1(Ogre::Degree(45), Ogre::Vector3::UNIT_Y); + sn1->setOrientation(rot1); + + auto mesh2 = createInMemoryTriangleMesh("scene_rt_mesh2"); + auto* sn2 = manager->addSceneNode("SceneNode2"); + manager->createEntity(sn2, mesh2); + sn2->setPosition(Ogre::Vector3(-1.0f, 0.0f, 5.0f)); + + ASSERT_EQ(manager->getSceneNodes().size(), 2); + + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + QString sceneFile = tmpDir.path() + "/test_scene.scene.gltf"; + + int exportResult = MeshImporterExporter::sceneExporter(sceneFile); + ASSERT_EQ(exportResult, 0); + ASSERT_TRUE(QFileInfo::exists(sceneFile)); + + ASSERT_TRUE(MeshImporterExporter::sceneImporter(sceneFile)); + + auto& nodes = manager->getSceneNodes(); + ASSERT_EQ(nodes.size(), 2); + + bool foundNode1 = false, foundNode2 = false; + for (auto* sn : nodes) + { + auto pos = sn->getPosition(); + if (std::abs(pos.x - 1.0f) < 0.1f && std::abs(pos.y - 2.0f) < 0.1f) + { + foundNode1 = true; + EXPECT_NEAR(pos.z, 3.0f, 0.1f); + EXPECT_NEAR(sn->getScale().x, 1.5f, 0.1f); + EXPECT_NEAR(sn->getScale().y, 2.0f, 0.1f); + EXPECT_NEAR(sn->getScale().z, 0.5f, 0.1f); + // Verify rotation preserved (45 degrees around Y) + auto orient = sn->getOrientation(); + EXPECT_NEAR(orient.w, rot1.w, 0.05f); + EXPECT_NEAR(orient.x, rot1.x, 0.05f); + EXPECT_NEAR(orient.y, rot1.y, 0.05f); + EXPECT_NEAR(orient.z, rot1.z, 0.05f); + } + else if (std::abs(pos.x - (-1.0f)) < 0.1f) + { + foundNode2 = true; + EXPECT_NEAR(pos.y, 0.0f, 0.1f); + EXPECT_NEAR(pos.z, 5.0f, 0.1f); + } + } + EXPECT_TRUE(foundNode1) << "First node with position (1,2,3) not found"; + EXPECT_TRUE(foundNode2) << "Second node with position (-1,0,5) not found"; +} + +TEST_F(SceneSaveLoadTest, MaterialDedup_SharedMaterial_ExportedOnce) { + auto* manager = Manager::getSingleton(); + + // Create a shared material + auto sharedMat = Ogre::MaterialManager::getSingleton().create( + "SharedTestMat", Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + sharedMat->getTechnique(0)->getPass(0)->setDiffuse(1, 0, 0, 1); + + // Create two entities sharing the same material + auto mesh1 = createInMemoryTriangleMesh("dedup_mesh1"); + auto* sn1 = manager->addSceneNode("DedupNode1"); + auto* e1 = manager->createEntity(sn1, mesh1); + e1->setMaterialName("SharedTestMat"); + + auto mesh2 = createInMemoryTriangleMesh("dedup_mesh2"); + auto* sn2 = manager->addSceneNode("DedupNode2"); + auto* e2 = manager->createEntity(sn2, mesh2); + e2->setMaterialName("SharedTestMat"); + + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + QString sceneFile = tmpDir.path() + "/test_dedup.scene.gltf"; + + int result = MeshImporterExporter::sceneExporter(sceneFile); + EXPECT_EQ(result, 0); + + // Read the exported glTF text and verify only one material entry + QFile gltfFile(sceneFile); + ASSERT_TRUE(gltfFile.open(QIODevice::ReadOnly)); + QByteArray gltfData = gltfFile.readAll(); + QJsonDocument doc = QJsonDocument::fromJson(gltfData); + ASSERT_TRUE(doc.isObject()); + QJsonArray materials = doc.object()["materials"].toArray(); + EXPECT_EQ(materials.size(), 1) << "Shared material should be deduplicated to 1 entry"; + + // Reimport to verify both entities load correctly + ASSERT_TRUE(MeshImporterExporter::sceneImporter(sceneFile)); + EXPECT_EQ(manager->getSceneNodes().size(), 2); +} + +TEST_F(SceneSaveLoadTest, EmptyScene_ExportsValidFile) { + auto* manager = Manager::getSingleton(); + ASSERT_EQ(manager->getSceneNodes().size(), 0); + + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + QString sceneFile = tmpDir.path() + "/empty_scene.scene.gltf"; + + int result = MeshImporterExporter::sceneExporter(sceneFile); + EXPECT_EQ(result, 0); + EXPECT_TRUE(QFileInfo::exists(sceneFile)); +} + +TEST_F(SceneSaveLoadTest, RoundTrip_SkeletonEntity_PreservesAnimations) { + auto* manager = Manager::getSingleton(); + + // Create an animated entity with skeleton and "TestAnim" animation + auto* entity = createAnimatedTestEntity("SceneAnimRT"); + ASSERT_NE(entity, nullptr); + ASSERT_TRUE(entity->hasSkeleton()); + ASSERT_EQ(entity->getMesh()->getSkeleton()->getNumAnimations(), 1); + EXPECT_EQ(entity->getMesh()->getSkeleton()->getAnimation(static_cast(0))->getName(), "TestAnim"); + + QTemporaryDir tmpDir; + ASSERT_TRUE(tmpDir.isValid()); + QString sceneFile = tmpDir.path() + "/test_anim_roundtrip.scene.gltf"; + + int exportResult = MeshImporterExporter::sceneExporter(sceneFile); + ASSERT_EQ(exportResult, 0); + ASSERT_TRUE(QFileInfo::exists(sceneFile)); + + ASSERT_TRUE(MeshImporterExporter::sceneImporter(sceneFile)); + + auto& nodes = manager->getSceneNodes(); + ASSERT_EQ(nodes.size(), 1); + + auto* reimportedNode = nodes.first(); + auto* sceneMgr = manager->getSceneMgr(); + ASSERT_TRUE(sceneMgr->hasEntity(reimportedNode->getName())); + + auto* reimportedEntity = sceneMgr->getEntity(reimportedNode->getName()); + ASSERT_TRUE(reimportedEntity->hasSkeleton()); + auto* skel = reimportedEntity->getMesh()->getSkeleton().get(); + EXPECT_EQ(skel->getNumAnimations(), 1) << "Expected exactly 1 animation after round-trip"; + if (skel->getNumAnimations() > 0) + EXPECT_EQ(skel->getAnimation(static_cast(0))->getName(), "TestAnim"); +} diff --git a/src/TransformOperator.cpp b/src/TransformOperator.cpp index c9dd598ff..fb20cf736 100755 --- a/src/TransformOperator.cpp +++ b/src/TransformOperator.cpp @@ -314,7 +314,8 @@ void TransformOperator::setActiveWidget(OgreWidget* ogreWidget) Ogre::Ray TransformOperator::rayFromScreenPoint(const QPoint& pos) { - if(m_pActiveWidget) + if(m_pActiveWidget && m_pActiveWidget->getViewport() + && m_pActiveWidget->getViewport()->getCamera()) { int width = m_pActiveWidget->getViewport()->getActualWidth() / mWindowSizeModifier; int height = m_pActiveWidget->getViewport()->getActualHeight() / mWindowSizeModifier; diff --git a/src/ViewCube/ViewCubeController.cpp b/src/ViewCube/ViewCubeController.cpp index aa7e2e4e3..4da01901c 100644 --- a/src/ViewCube/ViewCubeController.cpp +++ b/src/ViewCube/ViewCubeController.cpp @@ -2,10 +2,19 @@ #include "OgreWidget.h" #include "SpaceCamera.h" +#include #include +#include +#include +#include #include #include +#ifdef Q_OS_MACOS +#include +#include +#endif + ViewCubeController* ViewCubeController::s_instance = nullptr; ViewCubeController::ViewCubeController(QWidget* mainWindow, QObject* parent) @@ -38,6 +47,51 @@ ViewCubeController* ViewCubeController::qmlInstance(QQmlEngine* engine, QJSEngin return s_instance; } +void ViewCubeController::initWidget() +{ + m_cubeWidget = new QQuickWidget(); + m_cubeWidget->setWindowFlags(Qt::FramelessWindowHint | Qt::Tool); + m_cubeWidget->setAttribute(Qt::WA_TranslucentBackground); + m_cubeWidget->setClearColor(Qt::transparent); + m_cubeWidget->setResizeMode(QQuickWidget::SizeRootObjectToView); + m_cubeWidget->setFixedSize(64, 64); + + qmlRegisterSingletonType("ViewCubeModule", 1, 0, "ViewCubeController", + [](QQmlEngine* engine, QJSEngine*) -> QObject* { + auto* inst = ViewCubeController::instance(); + engine->setObjectOwnership(inst, QQmlEngine::CppOwnership); + return inst; + }); + + m_cubeWidget->engine()->addImportPath(QCoreApplication::applicationDirPath() + "/qml"); + m_cubeWidget->engine()->addImportPath(QLibraryInfo::path(QLibraryInfo::QmlImportsPath)); + + m_cubeWidget->setSource(QUrl("qrc:/ViewCube/ViewCubeWindow.qml")); + + // Show briefly to create the native window, then hide + m_cubeWidget->show(); + +#ifdef Q_OS_MACOS + // Qt::Tool creates an NSPanel at NSFloatingWindowLevel on macOS, causing + // it to float above ALL app windows (including material editor, dialogs). + // Lower to NSNormalWindowLevel (0) so other windows can appear above the + // cube when focused, while still keeping Qt::Tool benefits (no Dock icon, + // groups with parent app, auto-hides when app loses focus). + { + using GetWindowFn = id (*)(id, SEL); + using SetLevelFn = void (*)(id, SEL, long); + + auto nsView = reinterpret_cast(m_cubeWidget->winId()); + auto nsWindow = reinterpret_cast(objc_msgSend)( + nsView, sel_registerName("window")); + reinterpret_cast(objc_msgSend)( + nsWindow, sel_registerName("setLevel:"), 0L); + } +#endif + + m_cubeWidget->hide(); +} + bool ViewCubeController::isVisible() const { return m_visible && m_activeWidget && m_activeWidget->isVisible(); @@ -114,9 +168,6 @@ void ViewCubeController::rotateByDelta(qreal dx, qreal dy) Ogre::Radian pitch(scaledDy * 0.05f); // Apply arcball rotation (same logic as SpaceCamera::arcBall) - // We access the orientation through animateToOrientation with 0 duration - // to avoid needing to expose the target node directly. - // Instead, compute the new orientation ourselves. Ogre::Quaternion current = cam->getOrientation(); Ogre::Quaternion yawQ(yaw, Ogre::Vector3::UNIT_Y); Ogre::Quaternion pitchQ(pitch, Ogre::Vector3::UNIT_X); @@ -128,8 +179,12 @@ void ViewCubeController::rotateByDelta(qreal dx, qreal dy) void ViewCubeController::setActiveWidget(OgreWidget* widget) { - if (m_activeWidget == widget) + if (m_activeWidget == widget) { + // Same widget got focus again — raise the cube above the main window + if (m_cubeWidget && isVisible()) + m_cubeWidget->raise(); return; + } if (m_activeWidget) { m_activeWidget->removeEventFilter(this); @@ -142,12 +197,11 @@ void ViewCubeController::setActiveWidget(OgreWidget* widget) m_activeWidget->installEventFilter(this); connect(m_activeWidget, &QObject::destroyed, this, [this]() { m_activeWidget = nullptr; - emit visibilityChanged(isVisible()); + updateWidgetVisibility(); }); } - emit visibilityChanged(isVisible()); - reposition(); + updateWidgetVisibility(); updateOrientation(); } @@ -176,47 +230,73 @@ void ViewCubeController::setVisible(bool visible) if (m_visible == visible) return; m_visible = visible; + updateWidgetVisibility(); emit visibilityChanged(visible); - if (visible) - reposition(); } bool ViewCubeController::eventFilter(QObject* obj, QEvent* event) { auto type = event->type(); - if (obj == m_activeWidget && - (type == QEvent::Show || type == QEvent::Hide || - type == QEvent::Close || type == QEvent::Destroy)) { - if (type == QEvent::Close || type == QEvent::Destroy) + if (obj == m_activeWidget) { + if (type == QEvent::Close || type == QEvent::Destroy) { m_activeWidget = nullptr; - emit visibilityChanged(isVisible()); - return QObject::eventFilter(obj, event); + updateWidgetVisibility(); + return QObject::eventFilter(obj, event); + } + if (type == QEvent::Show) { + updateWidgetVisibility(); + return QObject::eventFilter(obj, event); + } + + // Reposition when active widget moves/resizes + if (type == QEvent::Move || type == QEvent::Resize) { + if (m_visible) + reposition(); + } + + // Any mouse interaction with the viewport brings the main window to + // front at NSNormalWindowLevel, pushing the cube behind it. Re-raise + // the cube so it stays visible over the viewport. + if (type == QEvent::MouseButtonPress || type == QEvent::Wheel) { + if (m_cubeWidget && isVisible()) + m_cubeWidget->raise(); + } } - // Reposition when active widget or main window moves/resizes - if (type == QEvent::Move || type == QEvent::Resize) { - if (m_visible && m_activeWidget && - (obj == m_activeWidget || obj == m_mainWindow)) - reposition(); + // Raise cube when main window regains focus (e.g. switching back from material editor) + if (obj == m_mainWindow && type == QEvent::WindowActivate) { + if (m_cubeWidget && isVisible()) + m_cubeWidget->raise(); } + return QObject::eventFilter(obj, event); } void ViewCubeController::reposition() { - if (!m_activeWidget) + if (!m_activeWidget || !m_cubeWidget) return; - // Position in top-right corner with a margin + // Position in top-right corner of the active viewport const int cubeSize = 64; const int margin = 4; QPoint topRight = m_activeWidget->mapToGlobal( QPoint(m_activeWidget->width() - cubeSize - margin, margin)); - if (m_windowX != topRight.x() || m_windowY != topRight.y()) { - m_windowX = topRight.x(); - m_windowY = topRight.y(); - emit positionChanged(); + m_cubeWidget->move(topRight); +} + +void ViewCubeController::updateWidgetVisibility() +{ + if (!m_cubeWidget) + return; + + if (isVisible()) { + reposition(); + m_cubeWidget->show(); + m_cubeWidget->raise(); + } else { + m_cubeWidget->hide(); } } diff --git a/src/ViewCube/ViewCubeController.h b/src/ViewCube/ViewCubeController.h index 97dff1827..acb80836d 100644 --- a/src/ViewCube/ViewCubeController.h +++ b/src/ViewCube/ViewCubeController.h @@ -7,6 +7,7 @@ class OgreWidget; class SpaceCamera; +class QQuickWidget; class QWidget; class ViewCubeController : public QObject @@ -19,8 +20,6 @@ class ViewCubeController : public QObject Q_PROPERTY(qreal qx READ qx NOTIFY orientationChanged) Q_PROPERTY(qreal qy READ qy NOTIFY orientationChanged) Q_PROPERTY(qreal qz READ qz NOTIFY orientationChanged) - Q_PROPERTY(int windowX READ windowX NOTIFY positionChanged) - Q_PROPERTY(int windowY READ windowY NOTIFY positionChanged) Q_PROPERTY(bool visible READ isVisible NOTIFY visibilityChanged) public: @@ -34,8 +33,6 @@ class ViewCubeController : public QObject qreal qx() const { return m_qx; } qreal qy() const { return m_qy; } qreal qz() const { return m_qz; } - int windowX() const { return m_windowX; } - int windowY() const { return m_windowY; } bool isVisible() const; Q_INVOKABLE void snapToView(const QString& face); @@ -45,10 +42,10 @@ class ViewCubeController : public QObject void setActiveWidget(OgreWidget* widget); void updateOrientation(); void setVisible(bool visible); + void initWidget(); signals: void orientationChanged(); - void positionChanged(); void visibilityChanged(bool visible); protected: @@ -56,6 +53,7 @@ class ViewCubeController : public QObject private: void reposition(); + void updateWidgetVisibility(); SpaceCamera* activeCamera() const; static ViewCubeController* s_instance; @@ -65,10 +63,9 @@ class ViewCubeController : public QObject qreal m_qx = 0.0; qreal m_qy = 0.0; qreal m_qz = 0.0; - int m_windowX = 0; - int m_windowY = 0; bool m_visible = false; QPointer m_activeWidget; + QQuickWidget* m_cubeWidget = nullptr; }; #endif // VIEWCUBECONTROLLER_H diff --git a/src/ViewCube/ViewCubeController_test.cpp b/src/ViewCube/ViewCubeController_test.cpp index 27f0856c8..a7d9cc567 100644 --- a/src/ViewCube/ViewCubeController_test.cpp +++ b/src/ViewCube/ViewCubeController_test.cpp @@ -37,8 +37,6 @@ TEST_F(ViewCubeControllerTest, DefaultState) EXPECT_DOUBLE_EQ(controller->qx(), 0.0); EXPECT_DOUBLE_EQ(controller->qy(), 0.0); EXPECT_DOUBLE_EQ(controller->qz(), 0.0); - EXPECT_EQ(controller->windowX(), 0); - EXPECT_EQ(controller->windowY(), 0); } // --------------------------------------------------------------------------- @@ -243,10 +241,6 @@ TEST(ViewCubeControllerMainWindow, ConstructorInstallsEventFilter) QResizeEvent resizeEvt(QSize(400, 300), QSize(300, 200)); QCoreApplication::sendEvent(&mainWindow, &resizeEvt); - // Position unchanged (no active widget) - EXPECT_EQ(ctrl->windowX(), 0); - EXPECT_EQ(ctrl->windowY(), 0); - delete ctrl; } @@ -262,9 +256,6 @@ TEST(ViewCubeControllerMainWindow, EventFilterPassesThroughNonMoveResizeEvents) QEvent focusEvt(QEvent::FocusIn); QCoreApplication::sendEvent(&mainWindow, &focusEvt); - EXPECT_EQ(ctrl->windowX(), 0); - EXPECT_EQ(ctrl->windowY(), 0); - delete ctrl; } @@ -274,12 +265,10 @@ TEST(ViewCubeControllerMainWindow, EventFilterMoveWithVisibleButNoActiveWidget) auto* ctrl = new ViewCubeController(&mainWindow); ctrl->setVisible(true); - // Move event: visible=true but no activeWidget → inner check short-circuits + // Move event: visible=true but no activeWidget → reposition short-circuits QMoveEvent moveEvt(QPoint(100, 100), QPoint(0, 0)); QCoreApplication::sendEvent(&mainWindow, &moveEvt); - EXPECT_EQ(ctrl->windowX(), 0); - delete ctrl; } @@ -345,16 +334,3 @@ TEST_F(ViewCubeControllerTest, UpdateOrientationDoesNotEmitWhenOrientationUnchan EXPECT_EQ(spy.count(), 0); } - -TEST_F(ViewCubeControllerTest, WindowPositionDefaultsToZero) -{ - QSignalSpy spy(controller, &ViewCubeController::positionChanged); - - // Without active widget, reposition is a no-op - controller->setVisible(true); - controller->updateOrientation(); // calls reposition internally - - EXPECT_EQ(controller->windowX(), 0); - EXPECT_EQ(controller->windowY(), 0); - EXPECT_EQ(spy.count(), 0); -} diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 6ae781253..afd8ae405 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -114,14 +114,17 @@ MainWindow::MainWindow(QWidget *parent) : try { m_pRoot->renderOneFrame(); } catch (Ogre::Exception& e) { + fprintf(stderr, "RENDER ERROR (Ogre): %s\n", e.getFullDescription().c_str()); SentryReporter::captureMessage( QString("Render error (Ogre): %1").arg(e.getFullDescription().c_str()), "error"); if(m_pTimer) m_pTimer->stop(); } catch (std::exception& e) { + fprintf(stderr, "RENDER ERROR (std): %s\n", e.what()); SentryReporter::captureMessage( QString("Render error (std): %1").arg(e.what()), "error"); if(m_pTimer) m_pTimer->stop(); } catch (...) { + fprintf(stderr, "RENDER ERROR (unknown)\n"); SentryReporter::captureMessage("Render error (unknown)", "error"); if(m_pTimer) m_pTimer->stop(); } @@ -144,8 +147,6 @@ MainWindow::~MainWindow() { // Destroy overlays early — they connect to Manager signals and // access Ogre resources, so they must be deleted while Manager is alive. - delete m_viewCubeEngine; - m_viewCubeEngine = nullptr; // ViewCubeController is parented to this, no manual delete needed m_viewCubeController = nullptr; @@ -364,25 +365,13 @@ void MainWindow::initToolBar() for (EditorViewport* vp : mDockWidgetList) connect(vp->getOgreWidget(), &OgreWidget::focusOnWidget, m_meshInfoOverlay, &MeshInfoOverlay::setActiveWidget); - // ViewCube (3D navigation gizmo) - m_viewCubeController = new ViewCubeController(this, this); - // Force software rendering for the ViewCube QML window (avoid GL conflicts with Ogre) + // ViewCube (3D navigation gizmo) — top-level window positioned over the active viewport + m_viewCubeController = new ViewCubeController(this); + // Force software rendering for the ViewCube QML widget (avoid GL conflicts with Ogre) qputenv("QSG_RHI_BACKEND", "software"); qputenv("QT_QUICK_BACKEND", "software"); QQuickWindow::setGraphicsApi(QSGRendererInterface::Software); - - m_viewCubeEngine = new QQmlApplicationEngine(this); - m_viewCubeEngine->addImportPath(QCoreApplication::applicationDirPath() + "/qml"); - m_viewCubeEngine->addImportPath(QLibraryInfo::path(QLibraryInfo::QmlImportsPath)); - - qmlRegisterSingletonType("ViewCubeModule", 1, 0, "ViewCubeController", - [](QQmlEngine* engine, QJSEngine*) -> QObject* { - auto* inst = ViewCubeController::instance(); - engine->setObjectOwnership(inst, QQmlEngine::CppOwnership); - return inst; - }); - - m_viewCubeEngine->load(QUrl("qrc:/ViewCube/ViewCubeWindow.qml")); + m_viewCubeController->initWidget(); connect(ui->actionShow_View_Cube, &QAction::toggled, m_viewCubeController, &ViewCubeController::setVisible); connect(m_viewCubeController, &ViewCubeController::visibilityChanged, ui->actionShow_View_Cube, &QAction::setChecked); @@ -393,10 +382,12 @@ void MainWindow::initToolBar() m_viewCubeController->setActiveWidget(w); }); - // Default to visible and activate the first viewport - m_viewCubeController->setVisible(true); + // Activate the first viewport, then set visible + // (setActiveWidget emits visibilityChanged which checks isVisible(), + // so m_visible must be true AND the widget must be visible) if (!mDockWidgetList.isEmpty()) m_viewCubeController->setActiveWidget(mDockWidgetList.first()->getOgreWidget()); + m_viewCubeController->setVisible(true); // AI Settings menu QMenu* aiMenu = menuBar()->addMenu(tr("&AI")); @@ -595,6 +586,49 @@ void MainWindow::importMeshs(const QStringList &_uriList) SentryReporter::finishTransaction(txn); } +void MainWindow::on_actionOpen_Scene_triggered() +{ + SentryReporter::addBreadcrumb("ui.action", "Open scene file"); + + QString fileName = QFileDialog::getOpenFileName(this, tr("Open Scene"), + "", + tr("Scene Files (*.scene.glb *.scene.gltf);;glTF Files (*.gltf *.glb);;All Files (*)"), + nullptr, QFileDialog::DontUseNativeDialog); + if (fileName.isEmpty()) return; + + auto txn = SentryReporter::startTransaction("ui.import", "scene.import"); + try { + MeshImporterExporter::sceneImporter(fileName); + } catch (...) { + SentryReporter::finishTransaction(txn); + throw; + } + SentryReporter::finishTransaction(txn); + addToRecentFiles(fileName); +} + +void MainWindow::on_actionSave_Scene_triggered() +{ + SentryReporter::addBreadcrumb("ui.action", "Save scene file"); + + QString fileName = QFileDialog::getSaveFileName(this, tr("Save Scene"), + "scene.scene.glb", + tr("Scene glTF Binary (*.scene.glb);;Scene glTF (*.scene.gltf)"), + nullptr, QFileDialog::DontUseNativeDialog); + if (fileName.isEmpty()) return; + + auto txn = SentryReporter::startTransaction("ui.export", "scene.export"); + try { + int result = MeshImporterExporter::sceneExporter(fileName); + if (result != 0) + QMessageBox::warning(this, tr("Save Scene"), tr("Failed to save scene.")); + } catch (...) { + SentryReporter::finishTransaction(txn); + throw; + } + SentryReporter::finishTransaction(txn); +} + void MainWindow::on_actionExport_Selected_triggered() { SentryReporter::addBreadcrumb("ui.action", "Export selected mesh"); @@ -1349,7 +1383,10 @@ void MainWindow::openRecentFile() QString filePath = action->data().toString(); if (QFileInfo::exists(filePath)) { addToRecentFiles(filePath); - mUriList.append(filePath); + if (filePath.endsWith(".scene.glb") || filePath.endsWith(".scene.gltf")) + MeshImporterExporter::sceneImporter(filePath); + else + mUriList.append(filePath); } else { QMessageBox::warning(this, tr("File Not Found"), tr("The file \"%1\" no longer exists.").arg(filePath)); diff --git a/src/mainwindow.h b/src/mainwindow.h index f8c71ba74..a68ce94a4 100755 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -16,7 +16,6 @@ class MCPServer; class NormalVisualizer; class MeshInfoOverlay; class ViewCubeController; -class QQmlApplicationEngine; namespace Ui { class MainWindow; @@ -50,6 +49,8 @@ class MainWindow : public QMainWindow, public Ogre::FrameListener private slots: void on_actionImport_triggered(); + void on_actionOpen_Scene_triggered(); + void on_actionSave_Scene_triggered(); void on_actionMaterial_Editor_triggered(); void on_actionAbout_triggered(); void on_actionMerge_Animations_triggered(); @@ -129,7 +130,6 @@ public slots: NormalVisualizer* m_normalVisualizer = nullptr; MeshInfoOverlay* m_meshInfoOverlay = nullptr; ViewCubeController* m_viewCubeController = nullptr; - QQmlApplicationEngine* m_viewCubeEngine = nullptr; MCPServer* m_mcpServer = nullptr; QMenu* m_recentFilesMenu = nullptr; diff --git a/ui_files/mainwindow.ui b/ui_files/mainwindow.ui index 669bb00ac..1a4653e97 100755 --- a/ui_files/mainwindow.ui +++ b/ui_files/mainwindow.ui @@ -94,6 +94,9 @@ File + + + @@ -475,6 +478,22 @@ Show View Cube + + + Open Scene + + + Ctrl+O + + + + + Save Scene + + + Ctrl+S + +