From c47c240ba21e7e02aab08405460e2e35892da40a Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 11 Mar 2026 13:05:03 -0400 Subject: [PATCH 1/4] Add mesh info overlay to viewport (closes #179) Displays mesh statistics (vertices, triangles, submeshes, materials, bones, animations) on the active viewport. Shows stats for selected entities when a selection exists, otherwise shows aggregated scene stats. Toggled via Options > Show Mesh Info menu or MCP toggle_mesh_info tool. Implemented as a top-level Qt::Tool window to avoid ghost-text artifacts from Ogre's direct-to-native rendering (WA_PaintOnScreen). The overlay tracks the active viewport via OgreWidget::focusOnWidget and repositions via event filters on both MainWindow and the active OgreWidget. Bumps version to 2.12.0. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 1 + CMakeLists.txt | 2 +- src/CMakeLists.txt | 2 + src/MCPServer.cpp | 36 ++++++ src/MCPServer.h | 1 + src/MCPServer_test.cpp | 43 ++++++- src/MeshInfoOverlay.cpp | 209 +++++++++++++++++++++++++++++++++++ src/MeshInfoOverlay.h | 44 ++++++++ src/MeshInfoOverlay_test.cpp | 177 +++++++++++++++++++++++++++++ src/mainwindow.cpp | 16 ++- src/mainwindow.h | 2 + ui_files/mainwindow.ui | 12 ++ 12 files changed, 540 insertions(+), 5 deletions(-) create mode 100644 src/MeshInfoOverlay.cpp create mode 100644 src/MeshInfoOverlay.h create mode 100644 src/MeshInfoOverlay_test.cpp diff --git a/CLAUDE.md b/CLAUDE.md index e7a011a18..137ff7321 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,6 +84,7 @@ Three singletons manage core state. All run on the main thread. Access via `Clas ### Debug Overlays - **NormalVisualizer** (`src/NormalVisualizer.h/cpp`): Draws vertex normals as colored lines (|X|=Red, |Y|=Green, |Z|=Blue). Toggled globally via Options → Show Normals menu or MCP `toggle_normals` tool. Supports real-time animation: requests software-skinned normals via `addSoftwareAnimationRequest(true)` and updates each frame for skeletal entities. Overlays attach to dedicated child scene nodes to avoid unsafe `static_cast` crashes in `ObjectItemModel` and `Manager::getEntities()`. +- **MeshInfoOverlay** (`src/MeshInfoOverlay.h/cpp`): Floating overlay showing mesh statistics (vertices, triangles, submeshes, materials, bones, animations) on the active viewport. Shows stats for selected entities or aggregated scene stats. Toggled via Options → Show Mesh Info menu or MCP `toggle_mesh_info` tool. Implemented as a top-level `Qt::Tool` window to avoid ghost-text artifacts from Ogre's direct-to-native rendering (`WA_PaintOnScreen`). - **BoneWeightOverlay** (`src/BoneWeightOverlay.h/cpp`): Per-entity bone weight heat-map overlay. ### MCP Server diff --git a/CMakeLists.txt b/CMakeLists.txt index cb33c9b85..e202f96d1 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -13,7 +13,7 @@ cmake_minimum_required(VERSION 3.24.0) cmake_policy(SET CMP0005 NEW) cmake_policy(SET CMP0048 NEW) # manages project version -project(QtMeshEditor VERSION 2.11.3 LANGUAGES CXX) +project(QtMeshEditor VERSION 2.12.0 LANGUAGES CXX) message(STATUS "Building QtMeshEditor version ${PROJECT_VERSION}") set(QTMESHEDITOR_VERSION_STRING "\"${PROJECT_VERSION}\"") diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 06b5692f5..aa76c4a9e 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -45,6 +45,7 @@ BoneWeightOverlay.cpp NormalVisualizer.cpp AnimationMerger.cpp CLIPipeline.cpp +MeshInfoOverlay.cpp ) set(HEADER_FILES @@ -93,6 +94,7 @@ BoneWeightOverlay.h NormalVisualizer.h AnimationMerger.h CLIPipeline.h +MeshInfoOverlay.h ) set(TEST_SOURCES "") diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index 6ad4ba516..e2cbebad9 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -9,6 +9,7 @@ #include "OgreWidget.h" #include "AnimationWidget.h" #include "NormalVisualizer.h" +#include "MeshInfoOverlay.h" #include #include #include @@ -443,6 +444,8 @@ QJsonObject MCPServer::callTool(const QString &name, const QJsonObject &args) toolResult = toolToggleBoneWeights(args); } else if (name == "toggle_normals") { toolResult = toolToggleNormals(args); + } else if (name == "toggle_mesh_info") { + toolResult = toolToggleMeshInfo(args); } else if (name == "merge_animations") { toolResult = toolMergeAnimations(args); } else { @@ -1901,6 +1904,21 @@ QJsonObject MCPServer::toolToggleNormals(const QJsonObject &args) return makeSuccessResult(QString("Normals %1").arg(show ? "shown" : "hidden")); } +QJsonObject MCPServer::toolToggleMeshInfo(const QJsonObject &args) +{ + if (!m_mainWindow) + return makeErrorResult("Error: MainWindow not available. Run with --with-mcp flag."); + + MeshInfoOverlay* overlay = m_mainWindow->findChild(); + if (!overlay) + return makeErrorResult("Error: MeshInfoOverlay not found"); + + bool show = args.contains("show") ? args["show"].toBool() : !overlay->isVisible(); + overlay->setVisible(show); + + return makeSuccessResult(QString("Mesh info overlay %1").arg(show ? "shown" : "hidden")); +} + QJsonObject MCPServer::toolMergeAnimations(const QJsonObject &args) { try { @@ -2447,6 +2465,24 @@ QJsonArray MCPServer::buildToolsList() )); } + // toggle_mesh_info + { + QJsonObject inputSchema; + inputSchema["type"] = "object"; + QJsonObject props; + props["show"] = QJsonObject{{"type", "boolean"}, {"description", "True to show, false to hide. If omitted, toggles the current state."}}; + inputSchema["properties"] = props; + + tools.append(buildToolDefinition( + "toggle_mesh_info", + "Show or hide the mesh info overlay on the active viewport. " + "Displays statistics including vertex/triangle counts, submeshes, " + "materials, bones, and animations. Shows stats for selected entities " + "when a selection exists, otherwise shows aggregated scene stats.", + inputSchema + )); + } + // merge_animations { QJsonObject inputSchema; diff --git a/src/MCPServer.h b/src/MCPServer.h index 8c5c87115..6e38017ee 100644 --- a/src/MCPServer.h +++ b/src/MCPServer.h @@ -142,6 +142,7 @@ private slots: QJsonObject toolToggleSkeletonDebug(const QJsonObject &args); QJsonObject toolToggleBoneWeights(const QJsonObject &args); QJsonObject toolToggleNormals(const QJsonObject &args); + QJsonObject toolToggleMeshInfo(const QJsonObject &args); QJsonObject toolMergeAnimations(const QJsonObject &args); // Animation diff --git a/src/MCPServer_test.cpp b/src/MCPServer_test.cpp index 11660d607..6ec4bca61 100644 --- a/src/MCPServer_test.cpp +++ b/src/MCPServer_test.cpp @@ -2511,6 +2511,26 @@ TEST_F(MCPServerTest, ToggleNormalsIsRecognizedTool) EXPECT_FALSE(getResultText(result).contains("Unknown tool")); } +// ========================================================================== +// NEW TESTS: toggle_mesh_info +// ========================================================================== + +TEST_F(MCPServerTest, ToggleMeshInfoNoMainWindow) +{ + QJsonObject args; + args["show"] = true; + QJsonObject result = server->callTool("toggle_mesh_info", args); + EXPECT_TRUE(isError(result)); + EXPECT_TRUE(getResultText(result).contains("MainWindow") || + getResultText(result).contains("MeshInfoOverlay")); +} + +TEST_F(MCPServerTest, ToggleMeshInfoIsRecognizedTool) +{ + QJsonObject result = server->callTool("toggle_mesh_info", QJsonObject()); + EXPECT_FALSE(getResultText(result).contains("Unknown tool")); +} + // ========================================================================== // NEW TESTS: Protocol edge cases // ========================================================================== @@ -2539,9 +2559,9 @@ TEST_F(MCPServerTest, AllToolNamesAreRecognized) "list_skeletal_animations", "get_animation_info", "set_animation_length", "set_animation_time", "add_keyframe", "remove_keyframe", "play_animation", "toggle_skeleton_debug", "toggle_bone_weights", - "toggle_normals", "merge_animations" + "toggle_normals", "toggle_mesh_info", "merge_animations" }; - EXPECT_EQ(allTools.size(), 26); + EXPECT_EQ(allTools.size(), 27); for (const QString &tool : allTools) { QJsonObject result = server->callTool(tool, QJsonObject()); @@ -2683,6 +2703,25 @@ TEST_F(MCPServerTest, ToggleNormals_ToggleOnOff) getResultText(resultOff).contains("NormalVisualizer")); } +TEST_F(MCPServerTest, ToggleMeshInfo_ToggleOnOff) +{ + // Server has no MainWindow set -- toggle_mesh_info requires MainWindow + QJsonObject argsOn; + argsOn["show"] = true; + QJsonObject resultOn = server->callTool("toggle_mesh_info", argsOn); + EXPECT_TRUE(isError(resultOn)); + + QJsonObject argsOff; + argsOff["show"] = false; + QJsonObject resultOff = server->callTool("toggle_mesh_info", argsOff); + EXPECT_TRUE(isError(resultOff)); + + EXPECT_TRUE(getResultText(resultOn).contains("MainWindow") || + getResultText(resultOn).contains("MeshInfoOverlay")); + EXPECT_TRUE(getResultText(resultOff).contains("MainWindow") || + getResultText(resultOff).contains("MeshInfoOverlay")); +} + TEST_F(MCPServerTest, PlayAnimation_StartAndStop) { if (!canLoadMeshFiles()) { GTEST_SKIP() << "Skipping: entity creation not supported without render window"; } diff --git a/src/MeshInfoOverlay.cpp b/src/MeshInfoOverlay.cpp new file mode 100644 index 000000000..2a228c43b --- /dev/null +++ b/src/MeshInfoOverlay.cpp @@ -0,0 +1,209 @@ +#include "MeshInfoOverlay.h" +#include "Manager.h" +#include "SelectionSet.h" +#include "EditorViewport.h" +#include "OgreWidget.h" +#include "CLIPipeline.h" +#include "mainwindow.h" + +#include +#include +#include + +MeshInfoOverlay::MeshInfoOverlay(MainWindow* parent) + : QObject(parent) + , mMainWindow(parent) +{ + connect(SelectionSet::getSingleton(), &SelectionSet::selectionChanged, + this, &MeshInfoOverlay::refresh); + connect(Manager::getSingleton(), &Manager::entityCreated, + this, &MeshInfoOverlay::refresh); + connect(Manager::getSingleton(), &Manager::sceneNodeDestroyed, + this, &MeshInfoOverlay::refresh); + + // Track main window moves/resizes to reposition the floating overlay + mMainWindow->installEventFilter(this); +} + +MeshInfoOverlay::~MeshInfoOverlay() +{ + delete mLabel; +} + +bool MeshInfoOverlay::eventFilter(QObject* obj, QEvent* event) +{ + auto type = event->type(); + if (type == QEvent::Move || type == QEvent::Resize) { + if (mVisible && mLabel && mActiveWidget) + repositionLabel(); + } + return QObject::eventFilter(obj, event); +} + +void MeshInfoOverlay::setActiveWidget(OgreWidget* widget) +{ + if (mActiveWidget == widget) + return; + + // Stop watching old widget for move/resize + if (mActiveWidget) + mActiveWidget->removeEventFilter(this); + + mActiveWidget = widget; + + // Watch new widget for move/resize (dock splitter drags, etc.) + if (mActiveWidget) + mActiveWidget->installEventFilter(this); + + refresh(); +} + +QList MeshInfoOverlay::collectEntities(bool& isSelection) const +{ + QList entities; + isSelection = false; + + auto* sel = SelectionSet::getSingleton(); + if (!sel->isEmpty()) { + entities = sel->getResolvedEntities(); + isSelection = true; + } else { + // Cannot use Manager::getEntities() — it static_casts all attached + // objects to Entity* without type checking (crashes on Lights, etc.). + for (Ogre::SceneNode* node : Manager::getSingleton()->getSceneNodes()) { + if (!node) continue; + for (int i = 0; i < static_cast(node->numAttachedObjects()); ++i) { + Ogre::MovableObject* obj = node->getAttachedObject(i); + if (obj && obj->getMovableType() == "Entity") + entities.append(static_cast(obj)); + } + } + } + return entities; +} + +QString MeshInfoOverlay::formatStats(const QList& entities, bool isSelection) +{ + if (entities.isEmpty()) + return QStringLiteral("No meshes"); + + QLocale locale; + unsigned int totalVerts = 0; + unsigned int totalTris = 0; + unsigned int totalSubmeshes = 0; + unsigned short totalBones = 0; + int totalAnims = 0; + QSet materialSet; + + for (Ogre::Entity* entity : entities) { + if (!entity) continue; + MeshInfo info = CLIPipeline::extractMeshInfo(entity, QString()); + totalVerts += info.vertices; + totalTris += info.triangles; + totalSubmeshes += info.submeshes; + totalBones += info.boneCount; + totalAnims += info.animations.size(); + for (const QString& mat : info.materials) + materialSet.insert(mat); + } + + QString header; + if (entities.size() == 1 && entities.first()) { + const Ogre::MeshPtr& mesh = entities.first()->getMesh(); + if (mesh) + header = QString::fromStdString(mesh->getName()); + else + header = QStringLiteral("Unknown mesh"); + } else if (entities.size() == 1) { + header = QStringLiteral("Unknown mesh"); + } else if (isSelection) { + header = QString("Selected (%1 meshes)").arg(entities.size()); + } else { + header = QString("Scene (%1 meshes)").arg(entities.size()); + } + + QString text = header + "\n\n"; + text += QString("Verts: %1 Tris: %2\n") + .arg(locale.toString(totalVerts)) + .arg(locale.toString(totalTris)); + text += QString("Submeshes: %1 Materials: %2") + .arg(totalSubmeshes) + .arg(materialSet.size()); + + if (totalBones > 0 || totalAnims > 0) { + text += QString("\nBones: %1 Anims: %2") + .arg(totalBones) + .arg(totalAnims); + } + + return text; +} + +void MeshInfoOverlay::ensureLabel() +{ + if (mLabel) + return; + + // Create as a top-level frameless transparent window. This gives the + // label its own compositor surface, completely independent of Ogre's + // direct-to-native rendering in OgreWidget (WA_PaintOnScreen with + // null paintEngine). Child widgets of OgreWidget leave ghost text + // because Qt cannot clear old pixels on a surface it doesn't own. + mLabel = new QLabel(mMainWindow); + mLabel->setWindowFlags(Qt::Tool | Qt::FramelessWindowHint + | Qt::WindowTransparentForInput); + mLabel->setAttribute(Qt::WA_TranslucentBackground); + mLabel->setAttribute(Qt::WA_ShowWithoutActivating); + mLabel->setStyleSheet( + "background-color: rgba(0, 0, 0, 100);" + "color: white;" + "font-family: monospace;" + "font-size: 11px;" + "padding: 6px 10px;" + "border-radius: 4px;" + ); + mLabel->hide(); +} + +void MeshInfoOverlay::repositionLabel() +{ + if (!mLabel || !mActiveWidget) + return; + QPoint globalPos = mActiveWidget->mapToGlobal(QPoint(8, 8)); + mLabel->move(globalPos); +} + +void MeshInfoOverlay::setVisible(bool visible) +{ + mVisible = visible; + if (visible) { + // If no active widget yet, use the first viewport + if (!mActiveWidget) { + auto viewports = mMainWindow->findChildren(); + if (!viewports.isEmpty()) + setActiveWidget(viewports.first()->getOgreWidget()); + } + refresh(); + } else if (mLabel) { + mLabel->hide(); + } +} + +void MeshInfoOverlay::refresh() +{ + if (!mVisible || !mActiveWidget) { + if (mLabel) mLabel->hide(); + return; + } + + ensureLabel(); + + bool isSelection = false; + auto entities = collectEntities(isSelection); + QString text = formatStats(entities, isSelection); + + mLabel->setText(text); + mLabel->adjustSize(); + repositionLabel(); + mLabel->show(); +} diff --git a/src/MeshInfoOverlay.h b/src/MeshInfoOverlay.h new file mode 100644 index 000000000..78b966397 --- /dev/null +++ b/src/MeshInfoOverlay.h @@ -0,0 +1,44 @@ +#ifndef MESHINFOOVERLAY_H +#define MESHINFOOVERLAY_H + +#include +#include +#include +#include +#include + +class MainWindow; +class OgreWidget; + +class MeshInfoOverlay : public QObject +{ + Q_OBJECT +public: + explicit MeshInfoOverlay(MainWindow* parent); + ~MeshInfoOverlay() override; + + bool isVisible() const { return mVisible; } + + /// Build overlay text from a list of entities (pure data, testable without GUI). + static QString formatStats(const QList& entities, bool isSelection); + +public slots: + void setVisible(bool visible); + void refresh(); + void setActiveWidget(OgreWidget* widget); + +protected: + bool eventFilter(QObject* obj, QEvent* event) override; + +private: + void ensureLabel(); + void repositionLabel(); + QList collectEntities(bool& isSelection) const; + + MainWindow* mMainWindow; + bool mVisible = false; + QPointer mLabel; // top-level transparent overlay window + QPointer mActiveWidget; // currently focused viewport +}; + +#endif // MESHINFOOVERLAY_H diff --git a/src/MeshInfoOverlay_test.cpp b/src/MeshInfoOverlay_test.cpp new file mode 100644 index 000000000..eb7d6cc40 --- /dev/null +++ b/src/MeshInfoOverlay_test.cpp @@ -0,0 +1,177 @@ +#include +#include +#include +#include +#include "MeshInfoOverlay.h" +#include "Manager.h" +#include "SelectionSet.h" +#include "TestHelpers.h" + +// =========================================================================== +// Pure formatting tests (no Ogre needed) +// =========================================================================== + +TEST(MeshInfoOverlayFormat, EmptyList) +{ + QList empty; + QString result = MeshInfoOverlay::formatStats(empty, false); + EXPECT_EQ(result, "No meshes"); +} + +TEST(MeshInfoOverlayFormat, EmptyListSelection) +{ + QList empty; + QString result = MeshInfoOverlay::formatStats(empty, true); + EXPECT_EQ(result, "No meshes"); +} + +TEST(MeshInfoOverlayFormat, NullEntityInList) +{ + QList withNull; + withNull << nullptr; + + QString result = MeshInfoOverlay::formatStats(withNull, false); + // Size is 1, so a header is generated, but null entity is skipped + EXPECT_FALSE(result.isEmpty()); + EXPECT_TRUE(result.contains("Verts:")); +} + +// =========================================================================== +// Integration tests (require Ogre for entity creation) +// =========================================================================== + +class MeshInfoOverlayIntegrationTest : 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"; + } + createStandardOgreMaterials(); + } + + void TearDown() override { + if (!Manager::getSingletonPtr()) + return; + SelectionSet::getSingleton()->clear(); + Manager::kill(); + if (app) app->processEvents(); + QThread::msleep(50); + } +}; + +TEST_F(MeshInfoOverlayIntegrationTest, FormatStatsSingleEntity) +{ + if (!canLoadMeshFiles()) + GTEST_SKIP() << "Skipping: mesh loading not supported in headless mode"; + + auto meshPtr = createInMemoryTriangleMesh("MeshInfoSingleMesh"); + ASSERT_TRUE(meshPtr); + + Ogre::SceneNode* node = Manager::getSingleton()->getSceneMgr() + ->getRootSceneNode()->createChildSceneNode("MeshInfoSingleNode"); + Ogre::Entity* entity = Manager::getSingleton()->getSceneMgr()->createEntity( + "MeshInfoSingleEntity", meshPtr); + node->attachObject(entity); + + QList entities; + entities << entity; + + QString result = MeshInfoOverlay::formatStats(entities, false); + + // Should contain the mesh name + EXPECT_TRUE(result.contains("MeshInfoSingleMesh")) + << "Result: " << result.toStdString(); + + // Should contain vertex/triangle info + EXPECT_TRUE(result.contains("Verts:")) << "Result: " << result.toStdString(); + EXPECT_TRUE(result.contains("Tris:")) << "Result: " << result.toStdString(); + + // 3 vertices, 1 triangle + EXPECT_TRUE(result.contains("3")) << "Result: " << result.toStdString(); + EXPECT_TRUE(result.contains("1")) << "Result: " << result.toStdString(); + + // Clean up + node->detachObject(entity); + Manager::getSingleton()->getSceneMgr()->destroyEntity(entity); + Manager::getSingleton()->getSceneMgr()->destroySceneNode(node); +} + +TEST_F(MeshInfoOverlayIntegrationTest, FormatStatsMultipleEntitiesScene) +{ + if (!canLoadMeshFiles()) + GTEST_SKIP() << "Skipping: mesh loading not supported in headless mode"; + + std::vector entities; + std::vector nodes; + + for (int i = 0; i < 3; ++i) { + std::string suffix = std::to_string(i); + auto meshPtr = createInMemoryTriangleMesh("MeshInfoMultiMesh" + suffix); + ASSERT_TRUE(meshPtr); + + Ogre::SceneNode* node = Manager::getSingleton()->getSceneMgr() + ->getRootSceneNode()->createChildSceneNode("MeshInfoMultiNode" + suffix); + Ogre::Entity* entity = Manager::getSingleton()->getSceneMgr()->createEntity( + "MeshInfoMultiEntity" + suffix, meshPtr); + node->attachObject(entity); + + entities.push_back(entity); + nodes.push_back(node); + } + + QList entityList; + for (auto* e : entities) entityList << e; + + // Not a selection -- should say "Scene" + QString result = MeshInfoOverlay::formatStats(entityList, false); + EXPECT_TRUE(result.contains("Scene (3 meshes)")) + << "Result: " << result.toStdString(); + + // As selection -- should say "Selected" + QString selResult = MeshInfoOverlay::formatStats(entityList, true); + EXPECT_TRUE(selResult.contains("Selected (3 meshes)")) + << "Result: " << selResult.toStdString(); + + // Aggregated: 3 entities x 3 verts = 9 verts, 3 tris + EXPECT_TRUE(result.contains("9")) << "Expected 9 vertices. Result: " << result.toStdString(); + + // Clean up + for (size_t i = 0; i < entities.size(); ++i) { + nodes[i]->detachObject(entities[i]); + Manager::getSingleton()->getSceneMgr()->destroyEntity(entities[i]); + Manager::getSingleton()->getSceneMgr()->destroySceneNode(nodes[i]); + } +} + +TEST_F(MeshInfoOverlayIntegrationTest, FormatStatsWithSkeleton) +{ + if (!canLoadMeshFiles()) + GTEST_SKIP() << "Skipping: mesh loading not supported in headless mode"; + + Ogre::Entity* entity = createAnimatedTestEntity("MeshInfoAnimEntity"); + if (!entity) + GTEST_SKIP() << "Skipping: could not create animated test entity"; + + ASSERT_TRUE(entity->hasSkeleton()); + + QList entities; + entities << entity; + + QString result = MeshInfoOverlay::formatStats(entities, false); + + // Should show bone and animation info + EXPECT_TRUE(result.contains("Bones:")) << "Result: " << result.toStdString(); + EXPECT_TRUE(result.contains("Anims:")) << "Result: " << result.toStdString(); + + // Clean up + Ogre::SceneNode* node = entity->getParentSceneNode(); + node->detachObject(entity); + Manager::getSingleton()->getSceneMgr()->destroyEntity(entity); + Manager::getSingleton()->getSceneMgr()->destroySceneNode(node); +} diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 51dd2b9f3..09fe2a01b 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -42,6 +42,7 @@ #include "MCPSettingsDialog.h" #include "MCPServer.h" #include "NormalVisualizer.h" +#include "MeshInfoOverlay.h" #include "LLMManager.h" #include "QMLMaterialHighlighter.h" #include "ModelDownloader.h" @@ -140,8 +141,10 @@ MainWindow::MainWindow(QWidget *parent) : /// /////////////////////// TODO improve the ui (toolbar, menubar,....) and add translation (obviously Portuguese but french, english, may be japaneese !) MainWindow::~MainWindow() { - // Destroy NormalVisualizer early — it connects to Manager signals and - // accesses Ogre resources, so it must be deleted while Manager is alive. + // Destroy overlays early — they connect to Manager signals and + // access Ogre resources, so they must be deleted while Manager is alive. + delete m_meshInfoOverlay; + m_meshInfoOverlay = nullptr; delete m_normalVisualizer; m_normalVisualizer = nullptr; @@ -346,6 +349,13 @@ void MainWindow::initToolBar() m_normalVisualizer = new NormalVisualizer(Manager::getSingleton()->getSceneMgr(), this); connect(ui->actionShow_Normals, &QAction::toggled, m_normalVisualizer, &NormalVisualizer::setVisible); + // show mesh info overlay + m_meshInfoOverlay = new MeshInfoOverlay(this); + connect(ui->actionShow_Mesh_Info, &QAction::toggled, m_meshInfoOverlay, &MeshInfoOverlay::setVisible); + // Connect viewports created before the overlay existed + for (EditorViewport* vp : mDockWidgetList) + connect(vp->getOgreWidget(), &OgreWidget::focusOnWidget, m_meshInfoOverlay, &MeshInfoOverlay::setActiveWidget); + // AI Settings menu QMenu* aiMenu = menuBar()->addMenu(tr("&AI")); QAction* aiSettingsAction = aiMenu->addAction(QIcon(":/icones/ai.png"), tr("AI Model Settings...")); @@ -831,6 +841,8 @@ void MainWindow::createEditorViewport(/*TODO add the type of view (perspective, connect(pOgreViewport, SIGNAL(widgetAboutToClose(EditorViewport* const&)), this, SLOT(onWidgetClosing(EditorViewport* const&))); connect(pOgreViewport->getOgreWidget(), SIGNAL(focusOnWidget(OgreWidget*)), TransformOperator::getSingleton(), SLOT(setActiveWidget(OgreWidget*))); + if (m_meshInfoOverlay) + connect(pOgreViewport->getOgreWidget(), &OgreWidget::focusOnWidget, m_meshInfoOverlay, &MeshInfoOverlay::setActiveWidget); if(!mDockWidgetList.isEmpty()) { diff --git a/src/mainwindow.h b/src/mainwindow.h index 65a5bc6e0..c02802ffe 100755 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -14,6 +14,7 @@ class LLMSettingsWidget; class MCPServer; class NormalVisualizer; +class MeshInfoOverlay; namespace Ui { class MainWindow; @@ -123,6 +124,7 @@ public slots: const QPalette& darkPalette(); NormalVisualizer* m_normalVisualizer = nullptr; + MeshInfoOverlay* m_meshInfoOverlay = nullptr; MCPServer* m_mcpServer = nullptr; QMenu* m_recentFilesMenu = nullptr; diff --git a/ui_files/mainwindow.ui b/ui_files/mainwindow.ui index 227a41bdd..d67177f49 100755 --- a/ui_files/mainwindow.ui +++ b/ui_files/mainwindow.ui @@ -80,6 +80,7 @@ + @@ -225,6 +226,17 @@ Show Normals + + + true + + + false + + + Show Mesh Info + + Import Mesh From bc823e38e0be1cfd7d6d51fdc049c228eea1beeb Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 11 Mar 2026 13:26:37 -0400 Subject: [PATCH 2/4] Address code review feedback and fix CI link error - Fix CI: add MeshInfoOverlay.cpp/h to tests/CMakeLists.txt so MaterialEditorQML test targets can link MCPServer's toggle_mesh_info - Sync QAction with MCP toggles: add visibilityChanged signal to MeshInfoOverlay and connect it back to actionShow_Mesh_Info so programmatic toggles keep the menu checkmark in sync - Hide overlay on viewport destroy: handle Hide/Close/Destroy events in eventFilter to prevent orphaned floating label when active viewport is removed - Fix null entity counting: filter nulls before computing header and stats so null-only lists correctly report "No meshes" Co-Authored-By: Claude Opus 4.6 --- src/MeshInfoOverlay.cpp | 33 ++++++++++++++++++++++++--------- src/MeshInfoOverlay.h | 3 +++ src/MeshInfoOverlay_test.cpp | 5 ++--- src/mainwindow.cpp | 2 ++ tests/CMakeLists.txt | 2 ++ 5 files changed, 33 insertions(+), 12 deletions(-) diff --git a/src/MeshInfoOverlay.cpp b/src/MeshInfoOverlay.cpp index 2a228c43b..6d7fbac22 100644 --- a/src/MeshInfoOverlay.cpp +++ b/src/MeshInfoOverlay.cpp @@ -33,6 +33,17 @@ MeshInfoOverlay::~MeshInfoOverlay() bool MeshInfoOverlay::eventFilter(QObject* obj, QEvent* event) { auto type = event->type(); + + // Hide the overlay when the active viewport is hidden or destroyed + if (obj == mActiveWidget && + (type == QEvent::Hide || type == QEvent::Close || type == QEvent::Destroy)) { + if (mLabel) + mLabel->hide(); + if (type != QEvent::Hide) + mActiveWidget = nullptr; + return QObject::eventFilter(obj, event); + } + if (type == QEvent::Move || type == QEvent::Resize) { if (mVisible && mLabel && mActiveWidget) repositionLabel(); @@ -84,7 +95,13 @@ QList MeshInfoOverlay::collectEntities(bool& isSelection) const QString MeshInfoOverlay::formatStats(const QList& entities, bool isSelection) { - if (entities.isEmpty()) + // Filter out null entries so counts and headers are accurate + QList valid; + for (Ogre::Entity* e : entities) { + if (e) valid.append(e); + } + + if (valid.isEmpty()) return QStringLiteral("No meshes"); QLocale locale; @@ -95,8 +112,7 @@ QString MeshInfoOverlay::formatStats(const QList& entities, bool int totalAnims = 0; QSet materialSet; - for (Ogre::Entity* entity : entities) { - if (!entity) continue; + for (Ogre::Entity* entity : valid) { MeshInfo info = CLIPipeline::extractMeshInfo(entity, QString()); totalVerts += info.vertices; totalTris += info.triangles; @@ -108,18 +124,16 @@ QString MeshInfoOverlay::formatStats(const QList& entities, bool } QString header; - if (entities.size() == 1 && entities.first()) { - const Ogre::MeshPtr& mesh = entities.first()->getMesh(); + if (valid.size() == 1) { + const Ogre::MeshPtr& mesh = valid.first()->getMesh(); if (mesh) header = QString::fromStdString(mesh->getName()); else header = QStringLiteral("Unknown mesh"); - } else if (entities.size() == 1) { - header = QStringLiteral("Unknown mesh"); } else if (isSelection) { - header = QString("Selected (%1 meshes)").arg(entities.size()); + header = QString("Selected (%1 meshes)").arg(valid.size()); } else { - header = QString("Scene (%1 meshes)").arg(entities.size()); + header = QString("Scene (%1 meshes)").arg(valid.size()); } QString text = header + "\n\n"; @@ -176,6 +190,7 @@ void MeshInfoOverlay::repositionLabel() void MeshInfoOverlay::setVisible(bool visible) { mVisible = visible; + emit visibilityChanged(visible); if (visible) { // If no active widget yet, use the first viewport if (!mActiveWidget) { diff --git a/src/MeshInfoOverlay.h b/src/MeshInfoOverlay.h index 78b966397..d67c0a258 100644 --- a/src/MeshInfoOverlay.h +++ b/src/MeshInfoOverlay.h @@ -22,6 +22,9 @@ class MeshInfoOverlay : public QObject /// Build overlay text from a list of entities (pure data, testable without GUI). static QString formatStats(const QList& entities, bool isSelection); +signals: + void visibilityChanged(bool visible); + public slots: void setVisible(bool visible); void refresh(); diff --git a/src/MeshInfoOverlay_test.cpp b/src/MeshInfoOverlay_test.cpp index eb7d6cc40..44b8fe411 100644 --- a/src/MeshInfoOverlay_test.cpp +++ b/src/MeshInfoOverlay_test.cpp @@ -31,9 +31,8 @@ TEST(MeshInfoOverlayFormat, NullEntityInList) withNull << nullptr; QString result = MeshInfoOverlay::formatStats(withNull, false); - // Size is 1, so a header is generated, but null entity is skipped - EXPECT_FALSE(result.isEmpty()); - EXPECT_TRUE(result.contains("Verts:")); + // Null entries are filtered out, so a null-only list reports "No meshes" + EXPECT_EQ(result, "No meshes"); } // =========================================================================== diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 09fe2a01b..a0c7ca7ff 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -352,6 +352,8 @@ void MainWindow::initToolBar() // show mesh info overlay m_meshInfoOverlay = new MeshInfoOverlay(this); connect(ui->actionShow_Mesh_Info, &QAction::toggled, m_meshInfoOverlay, &MeshInfoOverlay::setVisible); + // Sync menu checkmark when MCP or other code toggles the overlay directly + connect(m_meshInfoOverlay, &MeshInfoOverlay::visibilityChanged, ui->actionShow_Mesh_Info, &QAction::setChecked); // Connect viewports created before the overlay existed for (EditorViewport* vp : mDockWidgetList) connect(vp->getOgreWidget(), &OgreWidget::focusOnWidget, m_meshInfoOverlay, &MeshInfoOverlay::setActiveWidget); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index bf7149647..ff86ae13a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -55,6 +55,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/AnimationMerger.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MCPServer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MCPSettingsDialog.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/MeshInfoOverlay.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/QMLMaterialHighlighter.cpp ) @@ -103,6 +104,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/AnimationMerger.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/MCPServer.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/MCPSettingsDialog.h + ${CMAKE_CURRENT_SOURCE_DIR}/../src/MeshInfoOverlay.h ) # Add Ogre-Procedural sources (matching src/CMakeLists.txt) From 40acd5f2ee38dc2cdf046277c5e927e123487db5 Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 11 Mar 2026 13:45:51 -0400 Subject: [PATCH 3/4] Fix CI: add CLIPipeline.cpp to tests/CMakeLists.txt MeshInfoOverlay calls CLIPipeline::extractMeshInfo(), so the MaterialEditorQML test targets need CLIPipeline.cpp linked in. Co-Authored-By: Claude Opus 4.6 --- tests/CMakeLists.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ff86ae13a..cf47e19b8 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -56,6 +56,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/MCPServer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MCPSettingsDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MeshInfoOverlay.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/CLIPipeline.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/QMLMaterialHighlighter.cpp ) @@ -105,6 +106,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/MCPServer.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/MCPSettingsDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/MeshInfoOverlay.h + ${CMAKE_CURRENT_SOURCE_DIR}/../src/CLIPipeline.h ) # Add Ogre-Procedural sources (matching src/CMakeLists.txt) From c5c58a6564d117231a8cc299b99b99c7d1c9a936 Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 11 Mar 2026 14:21:32 -0400 Subject: [PATCH 4/4] Increase MeshInfoOverlay and MCPServer test coverage Add 24 new MeshInfoOverlay tests covering lifecycle, event handling, visibility toggling, widget management, and entity collection. Add MCPServer toggle_mesh_info success-path test with fake MainWindow. Co-Authored-By: Claude Opus 4.6 --- src/MCPServer_test.cpp | 38 +++++ src/MeshInfoOverlay_test.cpp | 321 +++++++++++++++++++++++++++++++++++ 2 files changed, 359 insertions(+) diff --git a/src/MCPServer_test.cpp b/src/MCPServer_test.cpp index 6ec4bca61..7964047a9 100644 --- a/src/MCPServer_test.cpp +++ b/src/MCPServer_test.cpp @@ -9,8 +9,10 @@ #include #include #include +#include #include "MCPServer.h" #include "Manager.h" +#include "MeshInfoOverlay.h" #include "PrimitiveObject.h" #include "SelectionSet.h" #include @@ -2722,6 +2724,42 @@ TEST_F(MCPServerTest, ToggleMeshInfo_ToggleOnOff) getResultText(resultOff).contains("MeshInfoOverlay")); } +TEST_F(MCPServerTest, ToggleMeshInfo_SuccessPath) +{ + // Create a fake MainWindow with a MeshInfoOverlay child so findChild works + QMainWindow fakeWindow; + auto* overlay = new MeshInfoOverlay(reinterpret_cast(&fakeWindow)); + server->setMainWindow(reinterpret_cast(&fakeWindow)); + + EXPECT_FALSE(overlay->isVisible()); + + // Toggle on + QJsonObject argsOn; + argsOn["show"] = true; + QJsonObject resultOn = server->callTool("toggle_mesh_info", argsOn); + EXPECT_FALSE(isError(resultOn)) << getResultText(resultOn).toStdString(); + EXPECT_TRUE(getResultText(resultOn).contains("shown")); + EXPECT_TRUE(overlay->isVisible()); + + // Toggle off + QJsonObject argsOff; + argsOff["show"] = false; + QJsonObject resultOff = server->callTool("toggle_mesh_info", argsOff); + EXPECT_FALSE(isError(resultOff)) << getResultText(resultOff).toStdString(); + EXPECT_TRUE(getResultText(resultOff).contains("hidden")); + EXPECT_FALSE(overlay->isVisible()); + + // Toggle without show arg — should flip to true + QJsonObject resultToggle = server->callTool("toggle_mesh_info", QJsonObject()); + EXPECT_FALSE(isError(resultToggle)) << getResultText(resultToggle).toStdString(); + EXPECT_TRUE(getResultText(resultToggle).contains("shown")); + EXPECT_TRUE(overlay->isVisible()); + + // Clean up + server->setMainWindow(nullptr); + delete overlay; +} + TEST_F(MCPServerTest, PlayAnimation_StartAndStop) { if (!canLoadMeshFiles()) { GTEST_SKIP() << "Skipping: entity creation not supported without render window"; } diff --git a/src/MeshInfoOverlay_test.cpp b/src/MeshInfoOverlay_test.cpp index 44b8fe411..d71e0bd87 100644 --- a/src/MeshInfoOverlay_test.cpp +++ b/src/MeshInfoOverlay_test.cpp @@ -1,8 +1,12 @@ #include #include #include +#include +#include #include +#include #include "MeshInfoOverlay.h" +#include "OgreWidget.h" #include "Manager.h" #include "SelectionSet.h" #include "TestHelpers.h" @@ -35,6 +39,287 @@ TEST(MeshInfoOverlayFormat, NullEntityInList) EXPECT_EQ(result, "No meshes"); } +TEST(MeshInfoOverlayFormat, MixedNullAndNullOnlyList) +{ + QList nulls; + nulls << nullptr << nullptr << nullptr; + EXPECT_EQ(MeshInfoOverlay::formatStats(nulls, false), "No meshes"); + EXPECT_EQ(MeshInfoOverlay::formatStats(nulls, true), "No meshes"); +} + +// =========================================================================== +// Lifecycle tests (require Ogre for Manager/SelectionSet singletons) +// =========================================================================== + +// MeshInfoOverlay takes MainWindow* but only uses QObject/QWidget methods +// on it (installEventFilter, findChildren, QLabel parent). We use a plain +// QMainWindow to avoid the heavyweight real MainWindow construction. +static MainWindow* fakeMainWindow(QMainWindow* w) +{ + return reinterpret_cast(w); +} + +class MeshInfoOverlayLifecycleTest : public ::testing::Test { +protected: + QApplication* app = nullptr; + QMainWindow* window = nullptr; + MeshInfoOverlay* overlay = nullptr; + + void SetUp() override { + Manager::kill(); + QThread::msleep(50); + app = qobject_cast(QCoreApplication::instance()); + ASSERT_NE(app, nullptr); + if (!tryInitOgre()) { + GTEST_SKIP() << "Ogre initialization failed"; + } + createStandardOgreMaterials(); + + window = new QMainWindow(); + overlay = new MeshInfoOverlay(fakeMainWindow(window)); + } + + void TearDown() override { + delete overlay; + overlay = nullptr; + delete window; + window = nullptr; + if (Manager::getSingletonPtr()) { + SelectionSet::getSingleton()->clear(); + Manager::kill(); + } + if (app) app->processEvents(); + QThread::msleep(50); + } +}; + +TEST_F(MeshInfoOverlayLifecycleTest, DefaultStateIsHidden) +{ + EXPECT_FALSE(overlay->isVisible()); +} + +TEST_F(MeshInfoOverlayLifecycleTest, SetVisibleEmitsSignal) +{ + QSignalSpy spy(overlay, &MeshInfoOverlay::visibilityChanged); + ASSERT_TRUE(spy.isValid()); + + overlay->setVisible(true); + ASSERT_EQ(spy.count(), 1); + EXPECT_TRUE(spy.first().first().toBool()); + EXPECT_TRUE(overlay->isVisible()); + + overlay->setVisible(false); + ASSERT_EQ(spy.count(), 2); + EXPECT_FALSE(spy.last().first().toBool()); + EXPECT_FALSE(overlay->isVisible()); +} + +TEST_F(MeshInfoOverlayLifecycleTest, SetVisibleWithoutActiveWidget) +{ + // No viewports in fake QMainWindow, so mActiveWidget stays null. + // setVisible(true) should not crash and overlay stays visible (state-wise). + overlay->setVisible(true); + EXPECT_TRUE(overlay->isVisible()); + + overlay->setVisible(false); + EXPECT_FALSE(overlay->isVisible()); +} + +TEST_F(MeshInfoOverlayLifecycleTest, SetActiveWidgetTriggersRefresh) +{ + OgreWidget viewport(window); + overlay->setVisible(true); + + // Setting an active widget triggers refresh which creates the label + overlay->setActiveWidget(&viewport); + EXPECT_TRUE(overlay->isVisible()); +} + +TEST_F(MeshInfoOverlayLifecycleTest, SetActiveWidgetSameWidgetNoOp) +{ + OgreWidget viewport(window); + overlay->setActiveWidget(&viewport); + + // Setting the same widget again should be a no-op (early return) + overlay->setActiveWidget(&viewport); + EXPECT_TRUE(true); // no crash +} + +TEST_F(MeshInfoOverlayLifecycleTest, SetActiveWidgetSwitchesWidget) +{ + OgreWidget viewport1(window); + OgreWidget viewport2(window); + + overlay->setVisible(true); + overlay->setActiveWidget(&viewport1); + overlay->setActiveWidget(&viewport2); + // Switched without crash; event filter moved to viewport2 + EXPECT_TRUE(overlay->isVisible()); +} + +TEST_F(MeshInfoOverlayLifecycleTest, SetActiveWidgetNull) +{ + OgreWidget viewport(window); + overlay->setActiveWidget(&viewport); + overlay->setActiveWidget(nullptr); + // No crash, active widget cleared + EXPECT_TRUE(true); +} + +TEST_F(MeshInfoOverlayLifecycleTest, EventFilterMoveRepositionsLabel) +{ + OgreWidget viewport(window); + overlay->setActiveWidget(&viewport); + overlay->setVisible(true); + + // Send a Move event to the viewport — should reposition label + QMoveEvent moveEvent(QPoint(100, 100), QPoint(0, 0)); + QCoreApplication::sendEvent(&viewport, &moveEvent); + // No crash = success +} + +TEST_F(MeshInfoOverlayLifecycleTest, EventFilterResizeRepositionsLabel) +{ + OgreWidget viewport(window); + overlay->setActiveWidget(&viewport); + overlay->setVisible(true); + + // Send a Resize event + QResizeEvent resizeEvent(QSize(800, 600), QSize(640, 480)); + QCoreApplication::sendEvent(&viewport, &resizeEvent); + // No crash = success +} + +TEST_F(MeshInfoOverlayLifecycleTest, EventFilterHideActiveWidget) +{ + OgreWidget viewport(window); + overlay->setActiveWidget(&viewport); + overlay->setVisible(true); + + // Hiding the viewport should hide the label but not clear mActiveWidget + QHideEvent hideEvent; + QCoreApplication::sendEvent(&viewport, &hideEvent); + // No crash, label hidden +} + +TEST_F(MeshInfoOverlayLifecycleTest, EventFilterDestroyActiveWidget) +{ + auto* viewport = new OgreWidget(window); + overlay->setActiveWidget(viewport); + overlay->setVisible(true); + + // Destroying the viewport should hide the label and clear mActiveWidget + delete viewport; + // QPointer nulls mActiveWidget; overlay handles this gracefully + overlay->refresh(); // should not crash with null active widget +} + +TEST_F(MeshInfoOverlayLifecycleTest, EventFilterMainWindowMove) +{ + OgreWidget viewport(window); + overlay->setActiveWidget(&viewport); + overlay->setVisible(true); + + // Moving the main window should also reposition the label + QMoveEvent moveEvent(QPoint(200, 200), QPoint(0, 0)); + QCoreApplication::sendEvent(window, &moveEvent); + // No crash = success +} + +TEST_F(MeshInfoOverlayLifecycleTest, RefreshWhenHiddenHidesLabel) +{ + OgreWidget viewport(window); + overlay->setActiveWidget(&viewport); + overlay->setVisible(true); + + // Now hide and refresh — label should be hidden + overlay->setVisible(false); + overlay->refresh(); + EXPECT_FALSE(overlay->isVisible()); +} + +TEST_F(MeshInfoOverlayLifecycleTest, RefreshWithNoActiveWidgetHidesLabel) +{ + overlay->setVisible(true); + // No active widget set, refresh should not crash + overlay->refresh(); +} + +TEST_F(MeshInfoOverlayLifecycleTest, RefreshWithEmptyScene) +{ + OgreWidget viewport(window); + overlay->setActiveWidget(&viewport); + overlay->setVisible(true); + + // Empty scene, overlay should show "No meshes" + overlay->refresh(); + EXPECT_TRUE(overlay->isVisible()); +} + +TEST_F(MeshInfoOverlayLifecycleTest, RefreshWithEntityInScene) +{ + if (!canLoadMeshFiles()) + GTEST_SKIP() << "mesh loading not supported"; + + OgreWidget viewport(window); + overlay->setActiveWidget(&viewport); + + auto meshPtr = createInMemoryTriangleMesh("OverlayLifecycleMesh"); + ASSERT_TRUE(meshPtr); + + // Use Manager::addSceneNode so collectEntities finds it via getSceneNodes() + Ogre::SceneNode* node = Manager::getSingleton()->addSceneNode("OverlayLifecycleNode"); + auto* entity = Manager::getSingleton()->getSceneMgr()->createEntity( + "OverlayLifecycleEntity", meshPtr); + node->attachObject(entity); + + overlay->setVisible(true); + // Overlay should show stats for the entity in the scene + EXPECT_TRUE(overlay->isVisible()); + + node->detachObject(entity); + Manager::getSingleton()->getSceneMgr()->destroyEntity(entity); +} + +TEST_F(MeshInfoOverlayLifecycleTest, RefreshWithSelection) +{ + if (!canLoadMeshFiles()) + GTEST_SKIP() << "mesh loading not supported"; + + OgreWidget viewport(window); + overlay->setActiveWidget(&viewport); + + auto meshPtr = createInMemoryTriangleMesh("OverlaySelMesh"); + ASSERT_TRUE(meshPtr); + + Ogre::SceneNode* node = Manager::getSingleton()->addSceneNode("OverlaySelNode"); + auto* entity = Manager::getSingleton()->getSceneMgr()->createEntity( + "OverlaySelEntity", meshPtr); + node->attachObject(entity); + + // Add to selection — collectEntities should use selected entities + SelectionSet::getSingleton()->selectOne(node); + + overlay->setVisible(true); + EXPECT_TRUE(overlay->isVisible()); + + // Clean up + SelectionSet::getSingleton()->clear(); + node->detachObject(entity); + Manager::getSingleton()->getSceneMgr()->destroyEntity(entity); +} + +TEST_F(MeshInfoOverlayLifecycleTest, DestructorCleansUp) +{ + OgreWidget viewport(window); + overlay->setActiveWidget(&viewport); + overlay->setVisible(true); + + // Explicit delete to exercise destructor with active label + delete overlay; + overlay = nullptr; // prevent double-delete in TearDown +} + // =========================================================================== // Integration tests (require Ogre for entity creation) // =========================================================================== @@ -95,6 +380,9 @@ TEST_F(MeshInfoOverlayIntegrationTest, FormatStatsSingleEntity) EXPECT_TRUE(result.contains("3")) << "Result: " << result.toStdString(); EXPECT_TRUE(result.contains("1")) << "Result: " << result.toStdString(); + // Should NOT contain bones/anims line (no skeleton) + EXPECT_FALSE(result.contains("Bones:")) << "Result: " << result.toStdString(); + // Clean up node->detachObject(entity); Manager::getSingleton()->getSceneMgr()->destroyEntity(entity); @@ -140,6 +428,10 @@ TEST_F(MeshInfoOverlayIntegrationTest, FormatStatsMultipleEntitiesScene) // Aggregated: 3 entities x 3 verts = 9 verts, 3 tris EXPECT_TRUE(result.contains("9")) << "Expected 9 vertices. Result: " << result.toStdString(); + // Should show Submeshes and Materials + EXPECT_TRUE(result.contains("Submeshes:")) << "Result: " << result.toStdString(); + EXPECT_TRUE(result.contains("Materials:")) << "Result: " << result.toStdString(); + // Clean up for (size_t i = 0; i < entities.size(); ++i) { nodes[i]->detachObject(entities[i]); @@ -174,3 +466,32 @@ TEST_F(MeshInfoOverlayIntegrationTest, FormatStatsWithSkeleton) Manager::getSingleton()->getSceneMgr()->destroyEntity(entity); Manager::getSingleton()->getSceneMgr()->destroySceneNode(node); } + +TEST_F(MeshInfoOverlayIntegrationTest, FormatStatsMixedNullAndValid) +{ + if (!canLoadMeshFiles()) + GTEST_SKIP() << "mesh loading not supported"; + + auto meshPtr = createInMemoryTriangleMesh("MeshInfoMixedMesh"); + ASSERT_TRUE(meshPtr); + + auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); + auto* node = sceneMgr->getRootSceneNode()->createChildSceneNode("MeshInfoMixedNode"); + auto* entity = sceneMgr->createEntity("MeshInfoMixedEntity", meshPtr); + node->attachObject(entity); + + // Mix of null and valid: nulls should be filtered, only valid counted + QList entities; + entities << nullptr << entity << nullptr; + + QString result = MeshInfoOverlay::formatStats(entities, false); + // Single valid entity — header should be mesh name, not "Scene (3 meshes)" + EXPECT_TRUE(result.contains("MeshInfoMixedMesh")) + << "Result: " << result.toStdString(); + EXPECT_FALSE(result.contains("Scene")) + << "Should not say Scene for 1 valid entity. Result: " << result.toStdString(); + + node->detachObject(entity); + sceneMgr->destroyEntity(entity); + sceneMgr->destroySceneNode(node); +}