Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 25 additions & 4 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -572,10 +572,16 @@ <h3 class="feature-title">Visual Material Editor</h3>
<p class="feature-desc">Real-time material preview with a visual editor. Change colors, textures, lighting, blending, and fog settings. Full undo/redo with 50-step history.</p>
</div>

<div class="feature-card">
<div class="feature-icon">💾</div>
<h3 class="feature-title">Scene Save &amp; Load</h3>
<p class="feature-desc">Save your entire scene &mdash; multiple meshes with positions, rotations, scales, materials, skeletons, and animations &mdash; to a single glTF file. Reopen later to pick up where you left off.</p>
</div>

<div class="feature-card">
<div class="feature-icon">🔌</div>
<h3 class="feature-title">MCP Server (AI Agents)</h3>
<p class="feature-desc">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.</p>
<p class="feature-desc">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.</p>
</div>
</div>
</section>
Expand Down Expand Up @@ -618,6 +624,17 @@ <h3 class="feature-title">Asset Inspection</h3>
</p>
</div>

<div class="feature-card">
<h3 class="feature-title">Scene Persistence</h3>
<p class="feature-desc">
1. Import multiple meshes into the scene<br>
2. Position, rotate, and scale each object<br>
3. File &rarr; Save Scene as .scene.glb<br>
4. Reopen anytime to restore everything<br>
5. Skeletons, animations, and materials preserved
</p>
</div>

<div class="feature-card">
<h3 class="feature-title">CLI & Automation</h3>
<p class="feature-desc">
Expand Down Expand Up @@ -785,16 +802,20 @@ <h3>Configure Your AI Agent</h3>
</div>

<div class="install-step">
<h3>25 Tools Available</h3>
<h3>29 Tools Available</h3>
<div class="code-wrapper">
<div class="code-block">
# 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</div>
Expand Down
11 changes: 1 addition & 10 deletions qml/ViewCubeWindow.qml
Original file line number Diff line number Diff line change
@@ -1,20 +1,11 @@
import QtQuick
import QtQuick.Window
import ViewCubeModule 1.0

Window {
Rectangle {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the required Window flags and software backend setup still exist somewhere in the PR branch.
rg -n -C2 'WindowStaysOnTopHint|FramelessWindowHint|Qt::Tool|setSceneGraphBackend|QSG_RHI_BACKEND|software'

Repository: fernandotonon/QtMeshEditor

Length of output: 49671


🏁 Script executed:

cat -n qml/ViewCubeWindow.qml

Repository: fernandotonon/QtMeshEditor

Length of output: 20759


🏁 Script executed:

cat -n src/ViewCube/ViewCubeController.cpp | head -100

Repository: fernandotonon/QtMeshEditor

Length of output: 3852


Restore Window root and add the missing Qt::WindowStaysOnTopHint flag.

Line 4 changes the root to Rectangle, which cannot carry window flags and makes this component non-compliant with the ViewCube rendering contract. Even though ViewCubeController.cpp applies flags in C++, it is missing Qt::WindowStaysOnTopHint (only sets FramelessWindowHint and Tool). The guideline explicitly requires all three flags along with software rendering backend to avoid GL conflicts with Ogre.

Suggested patch (QML side)
 import QtQuick
+import QtQuick.Window
 import ViewCubeModule 1.0
 
-Rectangle {
+Window {
     id: root
     width: 64
     height: 64
+    flags: Qt.FramelessWindowHint | Qt.Tool | Qt.WindowStaysOnTopHint
     color: "transparent"

Additionally, ensure ViewCubeController::initWidget() enforces software rendering for this widget (e.g., QQuickWindow::setGraphicsApi(QSGRendererInterface::Software) on the QQuickWidget's window).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Rectangle {
import QtQuick
import QtQuick.Window
import ViewCubeModule 1.0
Window {
id: root
width: 64
height: 64
flags: Qt.FramelessWindowHint | Qt.Tool | Qt.WindowStaysOnTopHint
color: "transparent"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@qml/ViewCubeWindow.qml` at line 4, Restore the QML root to Window (replace
the Rectangle root in ViewCubeWindow.qml) so the component can carry window
flags, and update ViewCubeController::initWidget() to set the missing
Qt::WindowStaysOnTopHint in addition to Qt::FramelessWindowHint and Qt::Tool
when creating/applying window flags; also enforce software rendering for this
widget by calling QQuickWindow::setGraphicsApi(QSGRendererInterface::Software)
on the QQuickWidget's window (or equivalent) during initWidget() to avoid GL
conflicts with Ogre.

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 ""
Expand Down
35 changes: 18 additions & 17 deletions src/AnimationWidget.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -32,25 +32,26 @@
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<Ogre::Entity*> entities;
for(auto* obj : node->getAttachedObjects())
{
if(obj->getMovableType() != "Entity")
continue;
auto* entity = static_cast<Ogre::Entity*>(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<Ogre::Entity*>(obj));
}
for(auto* entity : entities)
{
delete mWeightOverlays.take(entity);
delete mShowSkeleton.take(entity);

Check failure on line 54 in src/AnimationWidget.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rewrite the code so that you no longer need this "delete".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZzo_u3nGsLvhjI1ZtRt&open=AZzo_u3nGsLvhjI1ZtRt&pullRequest=194
}
});

Expand Down
113 changes: 112 additions & 1 deletion src/MCPServer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,7 @@
// 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)) {
Expand Down Expand Up @@ -448,6 +448,10 @@
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));
Expand Down Expand Up @@ -2017,6 +2021,79 @@
}
}

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)

Check warning on line 2032 in src/MCPServer.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the init-statement to declare "result" inside the if statement.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZzo_vC0GsLvhjI1ZtTn&open=AZzo_vC0GsLvhjI1ZtTn&pullRequest=194
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) {

Check warning on line 2038 in src/MCPServer.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Catch a more specific exception instead of a generic one.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZzo_vC0GsLvhjI1ZtTp&open=AZzo_vC0GsLvhjI1ZtTp&pullRequest=194
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)

Check warning on line 2054 in src/MCPServer.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the init-statement to declare "ok" inside the if statement.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZzpDp3eXVi-AwETCuQ5&open=AZzpDp3eXVi-AwETCuQ5&pullRequest=194
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<Ogre::Entity*>(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) {

Check failure on line 2077 in src/MCPServer.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not nest more than 3 if|for|do|while|switch statements.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZzpnO1Da2oXHuy73m9S&open=AZzpnO1Da2oXHuy73m9S&pullRequest=194
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()
Expand Down Expand Up @@ -2501,6 +2578,40 @@
));
}

// 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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
));
}

return tools;
}

Expand Down
2 changes: 2 additions & 0 deletions src/MCPServer.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
84 changes: 84 additions & 0 deletions src/MCPServer_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#include <QSignalSpy>
#include <QElapsedTimer>
#include <QDir>
#include <QTemporaryDir>
#include <memory>
#include <QMainWindow>
#include "MCPServer.h"
Expand Down Expand Up @@ -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)"));
}
3 changes: 2 additions & 1 deletion src/Manager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,8 @@ QList<Ogre::Entity *> &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
Expand Down
Loading
Loading