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
+
+