From 24b1c4be3f288b78504482d1297fb2c61d94a988 Mon Sep 17 00:00:00 2001
From: Fernando
Date: Fri, 13 Mar 2026 16:39:29 -0400
Subject: [PATCH 1/4] Add scene save/load via glTF with multi-entity skeleton
support
Persist entire scenes (meshes, transforms, materials, skeletons, animations)
to a single glTF file. Handles Assimp's shared-skin merging by using
entity-name-prefixed bone filtering for correct round-trip of multiple
skeletal entities.
- Add sceneExporter()/sceneImporter() to MeshImporterExporter
- Add Open Scene (Ctrl+O) / Save Scene (Ctrl+S) to File menu
- Add save_scene/open_scene MCP tools
- Fix crash when opening scene with skeleton debug/bone weights active
- Fix TransformOperator null camera guard
- Fix Manager isForbiddenNodeName for empty names
- Add unit tests for scene save/load and MCP tools
- Update docs, README, and HTML landing page
Co-Authored-By: Claude Opus 4.6
---
CLAUDE.md | 2 +-
README.md | 1 +
docs/index.html | 29 +-
src/AnimationWidget.cpp | 30 +-
src/MCPServer.cpp | 114 +++-
src/MCPServer.h | 2 +
src/MCPServer_test.cpp | 84 +++
src/Manager.cpp | 3 +-
src/MeshImporterExporter.cpp | 863 ++++++++++++++++++++++++++++++
src/MeshImporterExporter.h | 3 +
src/MeshImporterExporter_test.cpp | 164 ++++++
src/TransformOperator.cpp | 3 +-
src/mainwindow.cpp | 52 +-
src/mainwindow.h | 2 +
ui_files/mainwindow.ui | 19 +
15 files changed, 1344 insertions(+), 27 deletions(-)
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..c843b6db3 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. 27 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
+
27 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/src/AnimationWidget.cpp b/src/AnimationWidget.cpp
index b3cd48b4b..981d034d8 100755
--- a/src/AnimationWidget.cpp
+++ b/src/AnimationWidget.cpp
@@ -33,24 +33,20 @@ AnimationWidget::AnimationWidget(QWidget *parent) :
connect(SelectionSet::getSingleton(),&SelectionSet::nodeSelectionChanged,this,updateTables);
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 SkeletonDebug and BoneWeightOverlay instances for entities attached to this node.
+ // Signal fires before entities are destroyed, so we can safely access them.
+ // Collect entities first — deleting overlays modifies the scene node's
+ // attached objects collection, which would invalidate the iterator.
+ 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..840ed7d3a 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,80 @@ 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);
+
+ MeshImporterExporter::sceneImporter(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);
+
+ auto it = node->getAttachedObjectIterator();
+ while (it.hasMoreElements()) {
+ auto* obj = it.getNext();
+ if (obj->getMovableType() == "Entity") {
+ 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 +2579,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, positions, 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/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp
index 40eb1d0f1..ce1e3f357 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,861 @@ 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 ────────────────
+void MeshImporterExporter::sceneImporter(const QString &_uri)
+{
+ if (_uri.isEmpty()) return;
+
+ QFileInfo file(_uri);
+ if (!file.exists()) return;
+
+ ensureResourceGroup(file.path());
+
+ // Clear existing scene
+ SelectionSet::getSingleton()->clearList();
+ auto* manager = Manager::getSingleton();
+ auto sceneNodesCopy = manager->getSceneNodes();
+ for (auto* sn : sceneNodesCopy)
+ manager->destroySceneNode(sn);
+
+ try {
+ Assimp::Importer assimpImporter;
+ assimpImporter.SetPropertyBool(AI_CONFIG_IMPORT_FBX_PRESERVE_PIVOTS, false);
+
+ // Same flags as AssimpToOgreImporter BUT without aiProcess_OptimizeGraph
+ // (it flattens the hierarchy we need to preserve)
+ 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;
+ }
+
+ // 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());
+ 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);
+
+ // Determine entity prefix for bone/animation filtering.
+ // During export with multiple entities, bones are prefixed
+ // with "entityName_". Detect this from the parent node name.
+ std::string entityPrefix;
+ if (meshNodes.size() > 1 && node->mParent
+ && node->mParent != scene->mRootNode)
+ {
+ entityPrefix = std::string(node->mParent->mName.C_Str()) + "_";
+ }
+
+ // 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);
+ }
+
+ } 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");
+ } catch (std::exception& ex) {
+ auto msg = QString("Scene import failed: %1").arg(ex.what());
+ Ogre::LogManager::getSingleton().logError(msg.toStdString());
+ SentryReporter::captureMessage(msg, "error");
+ }
+}
diff --git a/src/MeshImporterExporter.h b/src/MeshImporterExporter.h
index 394ae58f6..290c43402 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 void sceneImporter(const QString &_uri);
};
#endif // MESHIMPORTEREXPORTER_H
diff --git a/src/MeshImporterExporter_test.cpp b/src/MeshImporterExporter_test.cpp
index 652bd203a..f72ef473f 100644
--- a/src/MeshImporterExporter_test.cpp
+++ b/src/MeshImporterExporter_test.cpp
@@ -8,8 +8,10 @@
#include
#include
#include
+#include
#include "Manager.h"
#include "MeshImporterExporter.h"
+#include "SelectionSet.h"
#include "OgreXML/OgreXMLSkeletonSerializer.h"
#include
#include "TestHelpers.h"
@@ -1583,3 +1585,165 @@ 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 positions
+ 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, 1.5f, 1.5f));
+
+ 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);
+
+ // Export to temp file
+ 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));
+
+ // Clear scene and reimport
+ MeshImporterExporter::sceneImporter(sceneFile);
+
+ // Verify 2 scene nodes were restored
+ auto& nodes = manager->getSceneNodes();
+ ASSERT_EQ(nodes.size(), 2);
+
+ // Find nodes and verify transforms (order may differ)
+ 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);
+ }
+ 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);
+
+ // Reimport to verify both entities load correctly
+ 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);
+
+ // Export to temp .scene.gltf
+ 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));
+
+ // Clear scene and reimport
+ MeshImporterExporter::sceneImporter(sceneFile);
+
+ // Verify 1 scene node was restored
+ auto& nodes = manager->getSceneNodes();
+ ASSERT_EQ(nodes.size(), 1);
+
+ // Get the reimported entity and verify skeleton with animation
+ auto* reimportedNode = nodes.first();
+ auto* sceneMgr = manager->getSceneMgr();
+ ASSERT_TRUE(sceneMgr->hasEntity(reimportedNode->getName()));
+
+ auto* reimportedEntity = sceneMgr->getEntity(reimportedNode->getName());
+ ASSERT_TRUE(reimportedEntity->hasSkeleton());
+ EXPECT_GE(reimportedEntity->getMesh()->getSkeleton()->getNumAnimations(), 1);
+}
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/mainwindow.cpp b/src/mainwindow.cpp
index 6ae781253..7d97f248d 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();
}
@@ -393,10 +396,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 +600,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");
diff --git a/src/mainwindow.h b/src/mainwindow.h
index f8c71ba74..fe82356c6 100755
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -50,6 +50,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();
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
+
+
From 8a072d10aede43fee63ce496b42f248cb19b3366 Mon Sep 17 00:00:00 2001
From: Fernando
Date: Fri, 13 Mar 2026 16:56:56 -0400
Subject: [PATCH 2/4] Address code review feedback on scene save/load PR
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Parse file before clearing scene so invalid files don't destroy user's work
- Change sceneImporter() to return bool; MCP checks result before reporting success
- Guard entityPrefix detection for exact synthetic "_mesh" pattern only
- Route scene files from recent files menu to sceneImporter() instead of importer()
- Fix open_scene MCP tool description (remove "positions" claim)
- Fix tool count in docs (27 → 29)
- Strengthen tests: rotation + all scale axes, material dedup via glTF JSON,
exact animation count/name assertions
Co-Authored-By: Claude Opus 4.6
---
docs/index.html | 4 +-
src/MCPServer.cpp | 6 ++-
src/MeshImporterExporter.cpp | 90 ++++++++++++++++++-------------
src/MeshImporterExporter.h | 2 +-
src/MeshImporterExporter_test.cpp | 47 +++++++++++-----
src/mainwindow.cpp | 5 +-
6 files changed, 98 insertions(+), 56 deletions(-)
diff --git a/docs/index.html b/docs/index.html
index c843b6db3..81dc6d30c 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -581,7 +581,7 @@ Scene Save & Load
🔌
MCP Server (AI Agents)
-
Built-in Model Context Protocol server lets AI agents like Claude or Cursor control the editor. 27 tools for materials, meshes, scenes, 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.
@@ -802,7 +802,7 @@
Configure Your AI Agent
-
27 Tools Available
+
29 Tools Available
# Save and load entire scenes (meshes, transforms, animations)
diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp
index 840ed7d3a..4a4cde107 100644
--- a/src/MCPServer.cpp
+++ b/src/MCPServer.cpp
@@ -2050,7 +2050,9 @@ QJsonObject MCPServer::toolOpenScene(const QJsonObject &args)
if (!QFile::exists(filePath))
return makeErrorResult("Error: File not found: " + filePath);
- MeshImporterExporter::sceneImporter(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();
@@ -2608,7 +2610,7 @@ QJsonArray MCPServer::buildToolsList()
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, positions, and animation counts.",
+ "Reports what was loaded including entity names and animation counts.",
inputSchema
));
}
diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp
index ce1e3f357..58b8826b8 100755
--- a/src/MeshImporterExporter.cpp
+++ b/src/MeshImporterExporter.cpp
@@ -1538,16 +1538,43 @@ int MeshImporterExporter::sceneExporter(const QString &_uri)
}
// ─── Scene-level import: glTF → multiple scene nodes ────────────────
-void MeshImporterExporter::sceneImporter(const QString &_uri)
+bool MeshImporterExporter::sceneImporter(const QString &_uri)
{
- if (_uri.isEmpty()) return;
+ if (_uri.isEmpty()) return false;
QFileInfo file(_uri);
- if (!file.exists()) return;
+ if (!file.exists()) return false;
ensureResourceGroup(file.path());
- // Clear existing scene
+ // 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();
auto sceneNodesCopy = manager->getSceneNodes();
@@ -1555,32 +1582,6 @@ void MeshImporterExporter::sceneImporter(const QString &_uri)
manager->destroySceneNode(sn);
try {
- Assimp::Importer assimpImporter;
- assimpImporter.SetPropertyBool(AI_CONFIG_IMPORT_FBX_PRESERVE_PIVOTS, false);
-
- // Same flags as AssimpToOgreImporter BUT without aiProcess_OptimizeGraph
- // (it flattens the hierarchy we need to preserve)
- 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;
- }
// Process materials
MaterialProcessor materialProcessor;
@@ -1637,6 +1638,21 @@ void MeshImporterExporter::sceneImporter(const QString &_uri)
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());
@@ -1650,14 +1666,13 @@ void MeshImporterExporter::sceneImporter(const QString &_uri)
if (auto old = Ogre::MeshManager::getSingleton().getByName(meshName))
Ogre::MeshManager::getSingleton().remove(old);
- // Determine entity prefix for bone/animation filtering.
- // During export with multiple entities, bones are prefixed
- // with "entityName_". Detect this from the parent node name.
+ // 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 (meshNodes.size() > 1 && node->mParent
- && node->mParent != scene->mRootNode)
+ if (isSyntheticMeshNode && meshNodes.size() > 1)
{
- entityPrefix = std::string(node->mParent->mName.C_Str()) + "_";
+ entityPrefix = nodeName.toStdString() + "_";
}
// Check if any of this node's meshes have bones
@@ -1896,13 +1911,16 @@ void MeshImporterExporter::sceneImporter(const QString &_uri)
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 290c43402..5e858d398 100755
--- a/src/MeshImporterExporter.h
+++ b/src/MeshImporterExporter.h
@@ -52,7 +52,7 @@ class MeshImporterExporter
static QString exportFileDialogFilter();
static int sceneExporter(const QString &_uri);
- static void sceneImporter(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 f72ef473f..412bdf73a 100644
--- a/src/MeshImporterExporter_test.cpp
+++ b/src/MeshImporterExporter_test.cpp
@@ -9,6 +9,9 @@
#include
#include
#include
+#include
+#include
+#include
#include "Manager.h"
#include "MeshImporterExporter.h"
#include "SelectionSet.h"
@@ -1617,12 +1620,15 @@ class SceneSaveLoadTest : public ::testing::Test {
TEST_F(SceneSaveLoadTest, RoundTrip_TwoEntities_PreservesTransforms) {
auto* manager = Manager::getSingleton();
- // Create two entities with different positions
+ // 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, 1.5f, 1.5f));
+ 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");
@@ -1631,7 +1637,6 @@ TEST_F(SceneSaveLoadTest, RoundTrip_TwoEntities_PreservesTransforms) {
ASSERT_EQ(manager->getSceneNodes().size(), 2);
- // Export to temp file
QTemporaryDir tmpDir;
ASSERT_TRUE(tmpDir.isValid());
QString sceneFile = tmpDir.path() + "/test_scene.scene.gltf";
@@ -1640,14 +1645,11 @@ TEST_F(SceneSaveLoadTest, RoundTrip_TwoEntities_PreservesTransforms) {
ASSERT_EQ(exportResult, 0);
ASSERT_TRUE(QFileInfo::exists(sceneFile));
- // Clear scene and reimport
- MeshImporterExporter::sceneImporter(sceneFile);
+ ASSERT_TRUE(MeshImporterExporter::sceneImporter(sceneFile));
- // Verify 2 scene nodes were restored
auto& nodes = manager->getSceneNodes();
ASSERT_EQ(nodes.size(), 2);
- // Find nodes and verify transforms (order may differ)
bool foundNode1 = false, foundNode2 = false;
for (auto* sn : nodes)
{
@@ -1657,6 +1659,14 @@ TEST_F(SceneSaveLoadTest, RoundTrip_TwoEntities_PreservesTransforms) {
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)
{
@@ -1695,8 +1705,17 @@ TEST_F(SceneSaveLoadTest, MaterialDedup_SharedMaterial_ExportedOnce) {
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
- MeshImporterExporter::sceneImporter(sceneFile);
+ ASSERT_TRUE(MeshImporterExporter::sceneImporter(sceneFile));
EXPECT_EQ(manager->getSceneNodes().size(), 2);
}
@@ -1721,8 +1740,8 @@ TEST_F(SceneSaveLoadTest, RoundTrip_SkeletonEntity_PreservesAnimations) {
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");
- // Export to temp .scene.gltf
QTemporaryDir tmpDir;
ASSERT_TRUE(tmpDir.isValid());
QString sceneFile = tmpDir.path() + "/test_anim_roundtrip.scene.gltf";
@@ -1731,19 +1750,19 @@ TEST_F(SceneSaveLoadTest, RoundTrip_SkeletonEntity_PreservesAnimations) {
ASSERT_EQ(exportResult, 0);
ASSERT_TRUE(QFileInfo::exists(sceneFile));
- // Clear scene and reimport
- MeshImporterExporter::sceneImporter(sceneFile);
+ ASSERT_TRUE(MeshImporterExporter::sceneImporter(sceneFile));
- // Verify 1 scene node was restored
auto& nodes = manager->getSceneNodes();
ASSERT_EQ(nodes.size(), 1);
- // Get the reimported entity and verify skeleton with animation
auto* reimportedNode = nodes.first();
auto* sceneMgr = manager->getSceneMgr();
ASSERT_TRUE(sceneMgr->hasEntity(reimportedNode->getName()));
auto* reimportedEntity = sceneMgr->getEntity(reimportedNode->getName());
ASSERT_TRUE(reimportedEntity->hasSkeleton());
- EXPECT_GE(reimportedEntity->getMesh()->getSkeleton()->getNumAnimations(), 1);
+ 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/mainwindow.cpp b/src/mainwindow.cpp
index 7d97f248d..d0829819b 100755
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -1397,7 +1397,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));
From 5539f577f019606793cfba551daa37f4b063dd69 Mon Sep 17 00:00:00 2001
From: Fernando
Date: Fri, 13 Mar 2026 19:31:34 -0400
Subject: [PATCH 3/4] Fix crash on scene load with skeleton debug, ViewCube
z-order, and CI test
- Add Manager::sceneClearing signal emitted before scene teardown loop
- AnimationWidget connects to sceneClearing to disableAllSkeletonDebug()
before any entities are destroyed, preventing SkeletonDebug timer from
accessing dangling entity pointers
- Remove Qt.WindowStaysOnTopHint from ViewCube so it doesn't render on
top of material editor modals and dock widgets
- Fix ManagerHeadlessTest.IsForbiddenNodeName to expect empty string as
forbidden (matches the isForbiddenNodeName change from prior commit)
- Replace deprecated getAttachedObjectIterator() in MCPServer
Co-Authored-By: Claude Opus 4.6
---
qml/ViewCubeWindow.qml | 2 +-
src/AnimationWidget.cpp | 13 +++++++++----
src/MCPServer.cpp | 29 +++++++++++++----------------
src/Manager.h | 1 +
src/Manager_test.cpp | 4 +++-
src/MeshImporterExporter.cpp | 1 +
6 files changed, 28 insertions(+), 22 deletions(-)
diff --git a/qml/ViewCubeWindow.qml b/qml/ViewCubeWindow.qml
index 11009ce15..4cb8d6f0d 100644
--- a/qml/ViewCubeWindow.qml
+++ b/qml/ViewCubeWindow.qml
@@ -10,7 +10,7 @@ Window {
maximumWidth: 64
minimumHeight: 64
maximumHeight: 64
- flags: Qt.Tool | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint
+ flags: Qt.Tool | Qt.FramelessWindowHint
color: "transparent"
visible: ViewCubeController.visible
x: ViewCubeController.windowX
diff --git a/src/AnimationWidget.cpp b/src/AnimationWidget.cpp
index 981d034d8..b031dad0c 100755
--- a/src/AnimationWidget.cpp
+++ b/src/AnimationWidget.cpp
@@ -32,11 +32,16 @@ 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.
- // Collect entities first — deleting overlays modifies the scene node's
- // attached objects collection, which would invalidate the iterator.
+ // 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())
{
diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp
index 4a4cde107..68a18fae5 100644
--- a/src/MCPServer.cpp
+++ b/src/MCPServer.cpp
@@ -2067,24 +2067,21 @@ QJsonObject MCPServer::toolOpenScene(const QJsonObject &args)
QString nodeName = QString::fromStdString(node->getName());
result += QString(" - %1").arg(nodeName);
- auto it = node->getAttachedObjectIterator();
- while (it.hasMoreElements()) {
- auto* obj = it.getNext();
- if (obj->getMovableType() == "Entity") {
- 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);
- }
+ 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 += ")";
}
result += "\n";
}
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 58b8826b8..ea53f5f01 100755
--- a/src/MeshImporterExporter.cpp
+++ b/src/MeshImporterExporter.cpp
@@ -1577,6 +1577,7 @@ bool MeshImporterExporter::sceneImporter(const QString &_uri)
// 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);
From eb5d821eaf8252e4043d808db74af74f3eb49509 Mon Sep 17 00:00:00 2001
From: Fernando
Date: Fri, 13 Mar 2026 21:13:58 -0400
Subject: [PATCH 4/4] Fix ViewCube z-order: use QQuickWidget with lowered
NSWindow level on macOS
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replace the top-level QML Window with a QQuickWidget managed by
ViewCubeController. On macOS, Qt::Tool windows float above all app
windows (NSFloatingWindowLevel), causing the ViewCube to render on top
of the material editor and dialogs. Fix by lowering the NSWindow level
to NSNormalWindowLevel via the Objective-C runtime, so other windows
can appear above the cube when focused.
Key changes:
- ViewCubeController now owns a QQuickWidget (initWidget) instead of
MainWindow creating a QQmlApplicationEngine
- ViewCubeWindow.qml root changed from Window to Rectangle (embeddable)
- Removed windowX/Y/positionChanged — positioning is internal via
mapToGlobal in reposition()
- Cube is raised on viewport mouse click/wheel and main window activate
to stay visible over the viewport at NSNormalWindowLevel
- visibilityChanged only emits from setVisible (user toggle), not from
viewport lifecycle events — closing a viewport no longer unchecks the
menu toggle
- No widget reparenting — avoids crash when switching viewport layouts
Co-Authored-By: Claude Opus 4.6
---
qml/ViewCubeWindow.qml | 11 +-
src/ViewCube/ViewCubeController.cpp | 132 ++++++++++++++++++-----
src/ViewCube/ViewCubeController.h | 11 +-
src/ViewCube/ViewCubeController_test.cpp | 26 +----
src/mainwindow.cpp | 22 +---
src/mainwindow.h | 2 -
6 files changed, 116 insertions(+), 88 deletions(-)
diff --git a/qml/ViewCubeWindow.qml b/qml/ViewCubeWindow.qml
index 4cb8d6f0d..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
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/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 d0829819b..afd8ae405 100755
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -147,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;
@@ -367,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);
diff --git a/src/mainwindow.h b/src/mainwindow.h
index fe82356c6..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;
@@ -131,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;