From 2e8660e9d6f15e262ec1dff47cb320d0166deb1b Mon Sep 17 00:00:00 2001 From: Fernando Date: Mon, 11 May 2026 22:54:47 -0400 Subject: [PATCH 1/5] feat(perf): GPU memory & VRAM reporting (Phase 6 slice A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a pure-data MemoryEstimator that walks Ogre's mesh vertex/index buffers and the live texture pool to produce a SceneMemoryReport (per-mesh bytes, per-texture bytes, totals, optional budget warning). Surfaced through: - MeshInfoOverlay — appends "GPU: " to the floating mesh-info panel so the value is visible in the editor itself. - MCP tool `get_memory_usage` — returns the formatted summary plus a compact JSON payload for LLM clients. - CLI subcommand `qtmesh memory [--json] [--budget ]` with non-zero exit when the budget is exceeded, for CI pipelines. Closes the first acceptance criterion of issue #261 (memory usage panel with per-asset breakdown and configurable budgets). Tests cover the byte math, budget parsing, JSON/text serialisation, and the new overlay line. Ogre-backed paths are guarded by tryInitOgre() per existing project conventions. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/CLIPipeline.cpp | 66 ++++++++ src/CLIPipeline.h | 3 + src/CMakeLists.txt | 2 + src/MCPServer.cpp | 55 +++++++ src/MCPServer.h | 1 + src/MemoryEstimator.cpp | 285 +++++++++++++++++++++++++++++++++++ src/MemoryEstimator.h | 91 +++++++++++ src/MemoryEstimator_test.cpp | 183 ++++++++++++++++++++++ src/MeshInfoOverlay.cpp | 15 ++ src/MeshInfoOverlay_test.cpp | 24 +++ src/main.cpp | 2 +- 11 files changed, 726 insertions(+), 1 deletion(-) create mode 100644 src/MemoryEstimator.cpp create mode 100644 src/MemoryEstimator.h create mode 100644 src/MemoryEstimator_test.cpp diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index 9fbc141b3..26f363f47 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -12,6 +12,7 @@ #include "MaterialPresetLibrary.h" #include "TextureChannelPacker.h" #include "NormalMapGenerator.h" +#include "MemoryEstimator.h" #include "QtMeshCloudClient.h" #include #include @@ -601,6 +602,10 @@ void CLIPipeline::printUsage() " --all Apply all extra fixes\n" " (no flags) Standard import/export (joins vertices, smooths normals, optimizes)\n" "\n" + " memory [--json] [--budget ]\n" + " Report per-mesh GPU bytes and per-texture VRAM bytes\n" + " --budget accepts e.g. 50MB, 1GB; exit 1 if exceeded\n" + "\n" "Global options:\n" " --help, -h Show this help\n" " --version, -v Show version\n" @@ -962,6 +967,7 @@ int CLIPipeline::run(int argc, char* argv[]) else if (cmd == "material") rc = cmdMaterial(argc, argv); else if (cmd == "pack-textures") rc = cmdPackTextures(argc, argv); else if (cmd == "normal-from-height") rc = cmdNormalFromHeight(argc, argv); + else if (cmd == "memory") rc = cmdMemory(argc, argv); if (rc < 0) { err() << "Error: Unknown command '" << cmd << "'" << Qt::endl; @@ -3272,3 +3278,63 @@ int CLIPipeline::cmdScan(int argc, char* argv[]) return 1; return scanExit; } + +int CLIPipeline::cmdMemory(int argc, char* argv[]) +{ + // Parse: memory [--json] [--budget ] + QString filePath; + bool jsonOutput = false; + quint64 budgetBytes = 0; + + for (int i = 1; i < argc; ++i) { + QString arg(argv[i]); + if (arg == "memory" || arg == "--cli") continue; + if (arg == "--json") { jsonOutput = true; continue; } + if (arg == "--budget" && i + 1 < argc) { + budgetBytes = MemoryEstimator::parseBudget(argv[++i]); + if (budgetBytes == 0) { + err() << "Error: Invalid --budget value (use e.g. 50MB, 1GB)" << Qt::endl; + return 2; + } + continue; + } + if (!arg.startsWith("-") && filePath.isEmpty()) { filePath = arg; continue; } + } + + if (filePath.isEmpty()) { + err() << "Error: No input file specified." << Qt::endl; + err() << "Usage: qtmesh memory [--json] [--budget ]" << Qt::endl; + return 2; + } + + QFileInfo fi(filePath); + if (!fi.exists()) { + err() << "Error: File not found: " << filePath << Qt::endl; + return 1; + } + + if (!initOgreHeadless()) return 1; + + SentryReporter::addBreadcrumb("cli.memory", + QString("Memory .%1%2").arg(fi.suffix(), + budgetBytes > 0 ? QString(" budget=%1B").arg(budgetBytes) : QString())); + + MeshImporterExporter::importer({fi.absoluteFilePath()}, 0); + auto& entities = Manager::getSingleton()->getEntities(); + if (entities.isEmpty()) { + err() << "Error: Failed to load file: " << filePath << Qt::endl; + return 1; + } + + SceneMemoryReport report = MemoryEstimator::estimateScene(budgetBytes); + + if (jsonOutput) { + QJsonObject obj = MemoryEstimator::toJson(report); + obj["file"] = fi.fileName(); + cliWrite(QString::fromUtf8(QJsonDocument(obj).toJson(QJsonDocument::Indented))); + } else { + cliWrite(MemoryEstimator::toText(report)); + } + + return report.overBudget() ? 1 : 0; +} diff --git a/src/CLIPipeline.h b/src/CLIPipeline.h index 7b5f64aac..66f13e96f 100644 --- a/src/CLIPipeline.h +++ b/src/CLIPipeline.h @@ -85,6 +85,9 @@ class CLIPipeline { /// source via Sobel filter. Headless equivalent of the GUI /// "Generate Normal Map…" dialog. static int cmdNormalFromHeight(int argc, char* argv[]); + /// Phase 6 slice A: estimate GPU memory & VRAM for a mesh file + /// (per-submesh + per-texture, optional --json, optional --budget). + static int cmdMemory(int argc, char* argv[]); /// Map file extension to MeshImporterExporter format string. static QString formatForExtension(const QString& path); diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 09e1b2ac3..2646400bd 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -79,6 +79,7 @@ MaterialPresetLibrary.cpp MeshLodController.cpp TextureChannelPacker.cpp NormalMapGenerator.cpp +MemoryEstimator.cpp MeshValidator.cpp AIChatManager.cpp WelcomeScreenController.cpp @@ -165,6 +166,7 @@ MaterialPresetLibrary.h MeshLodController.h TextureChannelPacker.h NormalMapGenerator.h +MemoryEstimator.h MeshValidator.h AIChatManager.h WelcomeScreenController.h diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index 2957b9b0b..5f603c7f5 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -16,6 +16,7 @@ #include "MeshInfoOverlay.h" #include "MeshValidator.h" #include "MeshLodController.h" +#include "MemoryEstimator.h" #include #include #include @@ -430,6 +431,7 @@ const QMap& MCPServer::toolHandlers() {QStringLiteral("generate_auto_lods"), &MCPServer::toolGenerateAutoLods}, {QStringLiteral("remove_lods"), &MCPServer::toolRemoveLods}, {QStringLiteral("get_lod_info"), &MCPServer::toolGetLodInfo}, + {QStringLiteral("get_memory_usage"), &MCPServer::toolGetMemoryUsage}, {QStringLiteral("list_files"), &MCPServer::toolListFiles}, {QStringLiteral("search_files"), &MCPServer::toolSearchFiles}, {QStringLiteral("read_file"), &MCPServer::toolReadFile}, @@ -2732,6 +2734,45 @@ QJsonObject MCPServer::toolGetLodInfo(const QJsonObject &args) return makeSuccessResult(lines.join("\n")); } +QJsonObject MCPServer::toolGetMemoryUsage(const QJsonObject &args) +{ + try { + Manager* mgr = Manager::getSingletonPtr(); + if (!mgr) + return makeErrorResult("Error: Manager not available"); + + quint64 budget = 0; + if (args.contains("budget")) { + QString spec = args.value("budget").toString(); + if (!spec.isEmpty()) { + budget = MemoryEstimator::parseBudget(spec); + if (budget == 0) + return makeErrorResult( + QString("Invalid budget '%1' — use e.g. '50MB', '1GB'").arg(spec)); + } + } + + SceneMemoryReport report = MemoryEstimator::estimateScene(budget); + + // Return human-readable text in the standard success envelope so the + // MCP client sees a useful summary; JSON consumers should call the + // CLI (`qtmesh memory --json`) or inspect the structured fields below. + QString summary = MemoryEstimator::toText(report); + + // Append a one-line numeric summary so LLM clients can extract totals + // without parsing the table. + summary += QString("\nTotals JSON: %1") + .arg(QString::fromUtf8( + QJsonDocument(MemoryEstimator::toJson(report)) + .toJson(QJsonDocument::Compact))); + + return makeSuccessResult(summary); + } catch (Ogre::Exception& e) { + return makeErrorResult( + QString("Ogre error: %1").arg(QString::fromStdString(e.getFullDescription()))); + } +} + // Helper methods QJsonObject MCPServer::toolListFiles(const QJsonObject &args) @@ -4068,6 +4109,20 @@ QJsonArray MCPServer::buildToolsList() ); } + // get_memory_usage + { + QJsonObject props; + props["budget"] = QJsonObject{{"type", "string"}, + {"description", "Optional memory budget (e.g. '50MB', '1GB'). When the report exceeds the budget the response flags 'overBudget'."}}; + appendTool( + "get_memory_usage", + "Report estimated GPU memory for every loaded mesh (vertex + index buffers) " + "and VRAM for every resident texture. Includes per-asset breakdown and scene totals. " + "Use to spot heavy meshes/textures before exporting to a memory-constrained target.", + props + ); + } + // delete_entity { QJsonObject props; diff --git a/src/MCPServer.h b/src/MCPServer.h index f2bc3617a..3d9defd4f 100644 --- a/src/MCPServer.h +++ b/src/MCPServer.h @@ -165,6 +165,7 @@ private slots: QJsonObject toolGenerateAutoLods(const QJsonObject &args); QJsonObject toolRemoveLods(const QJsonObject &args); QJsonObject toolGetLodInfo(const QJsonObject &args); + QJsonObject toolGetMemoryUsage(const QJsonObject &args); QJsonObject toolListFiles(const QJsonObject &args); QJsonObject toolSearchFiles(const QJsonObject &args); QJsonObject toolReadFile(const QJsonObject &args); diff --git a/src/MemoryEstimator.cpp b/src/MemoryEstimator.cpp new file mode 100644 index 000000000..aab015cfd --- /dev/null +++ b/src/MemoryEstimator.cpp @@ -0,0 +1,285 @@ +#include "MemoryEstimator.h" +#include "Manager.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +// ----- Primitive math helpers ------------------------------------------------ + +quint64 MemoryEstimator::meshBytes(unsigned int vertexCount, unsigned int vertexStride, + unsigned int indexCount, unsigned int indexSize) +{ + return static_cast(vertexCount) * vertexStride + + static_cast(indexCount) * indexSize; +} + +quint64 MemoryEstimator::textureBytes(unsigned int width, unsigned int height, + unsigned int bytesPerPixel, bool hasMips) +{ + quint64 base = static_cast(width) * height * bytesPerPixel; + if (hasMips) { + // Full mip chain converges to base * 4/3 ≈ +33%. + base = (base * 4) / 3; + } + return base; +} + +QString MemoryEstimator::formatBytes(quint64 bytes) +{ + constexpr double KB = 1024.0; + constexpr double MB = 1024.0 * 1024.0; + constexpr double GB = 1024.0 * 1024.0 * 1024.0; + + if (bytes >= static_cast(GB)) + return QString::number(bytes / GB, 'f', 2) + " GB"; + if (bytes >= static_cast(MB)) + return QString::number(bytes / MB, 'f', 2) + " MB"; + if (bytes >= static_cast(KB)) + return QString::number(bytes / KB, 'f', 1) + " KB"; + return QString::number(bytes) + " B"; +} + +quint64 MemoryEstimator::parseBudget(const QString& spec) +{ + static const QRegularExpression re( + QStringLiteral("^\\s*(\\d+(?:\\.\\d+)?)\\s*([KMG]?B?)\\s*$"), + QRegularExpression::CaseInsensitiveOption); + auto m = re.match(spec); + if (!m.hasMatch()) return 0; + + bool ok = false; + double value = m.captured(1).toDouble(&ok); + if (!ok || value < 0) return 0; + + QString unit = m.captured(2).toUpper(); + double multiplier = 1.0; + if (unit.startsWith('K')) multiplier = 1024.0; + else if (unit.startsWith('M')) multiplier = 1024.0 * 1024.0; + else if (unit.startsWith('G')) multiplier = 1024.0 * 1024.0 * 1024.0; + + return static_cast(value * multiplier); +} + +// ----- Ogre-backed estimators ------------------------------------------------ + +// LCOV_EXCL_START — exercised only when Ogre is initialised (skipped in unit tests). +MeshMemoryEstimate MemoryEstimator::estimateEntity(const Ogre::Entity* entity) +{ + MeshMemoryEstimate est; + if (!entity) return est; + + const Ogre::MeshPtr& mesh = entity->getMesh(); + if (!mesh) return est; + + est.name = QString::fromStdString(mesh->getName()); + + auto accumulateVertexData = [&](Ogre::VertexData* vd) { + if (!vd) return; + est.vertexCount += vd->vertexCount; + // Sum the stride from every bound vertex buffer in the declaration. + // Most meshes use a single source, but tangents/UV2 etc. may use extras. + QSet seen; + for (const auto& elem : vd->vertexDeclaration->getElements()) { + unsigned short source = elem.getSource(); + if (seen.contains(source)) continue; + seen.insert(source); + unsigned int stride = vd->vertexDeclaration->getVertexSize(source); + est.vertexBytes += static_cast(vd->vertexCount) * stride; + } + }; + + accumulateVertexData(mesh->sharedVertexData); + + for (unsigned int i = 0; i < mesh->getNumSubMeshes(); ++i) { + Ogre::SubMesh* sub = mesh->getSubMesh(i); + if (!sub) continue; + + if (!sub->useSharedVertices) + accumulateVertexData(sub->vertexData); + + if (sub->indexData) { + est.indexCount += sub->indexData->indexCount; + unsigned int indexSize = + (sub->indexData->indexBuffer + && sub->indexData->indexBuffer->getType() + == Ogre::HardwareIndexBuffer::IT_32BIT) ? 4 : 2; + est.indexBytes += static_cast(sub->indexData->indexCount) * indexSize; + } + } + + return est; +} + +QList MemoryEstimator::estimateAllTextures() +{ + QList out; + + auto& mgr = Ogre::TextureManager::getSingleton(); + auto it = mgr.getResourceIterator(); + QSet seen; + + while (it.hasMoreElements()) { + Ogre::ResourcePtr res = it.getNext(); + Ogre::TexturePtr tex = std::dynamic_pointer_cast(res); + if (!tex) continue; + + // Skip unloaded textures (no resident pixels yet). + unsigned int w = tex->getWidth(); + unsigned int h = tex->getHeight(); + if (w == 0 || h == 0) continue; + + QString name = QString::fromStdString(tex->getName()); + if (seen.contains(name)) continue; + seen.insert(name); + + TextureMemoryEstimate est; + est.name = name; + est.width = w; + est.height = h; + size_t bpp = Ogre::PixelUtil::getNumElemBytes(tex->getFormat()); + est.bytesPerPixel = static_cast(bpp); + est.hasMips = tex->getNumMipmaps() > 0; + est.bytes = textureBytes(w, h, est.bytesPerPixel, est.hasMips); + out.append(est); + } + + return out; +} + +SceneMemoryReport MemoryEstimator::estimateScene(quint64 budgetBytes) +{ + SceneMemoryReport report; + report.budgetBytes = budgetBytes; + + Manager* mgr = Manager::getSingletonPtr(); + if (!mgr) return report; + + QSet seenMeshes; + for (Ogre::SceneNode* node : mgr->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") continue; + + auto* entity = static_cast(obj); + MeshMemoryEstimate est = estimateEntity(entity); + // De-duplicate identical mesh instances so totals reflect unique + // GPU residents, not draw-call counts. + if (est.name.isEmpty() || seenMeshes.contains(est.name)) continue; + seenMeshes.insert(est.name); + report.meshes.append(est); + report.meshTotalBytes += est.totalBytes(); + } + } + + report.textures = estimateAllTextures(); + for (const auto& t : report.textures) + report.textureTotalBytes += t.bytes; + + return report; +} +// LCOV_EXCL_STOP + +// ----- Serialisation -------------------------------------------------------- + +QJsonObject MemoryEstimator::toJson(const SceneMemoryReport& report) +{ + QJsonObject obj; + + QJsonArray meshArr; + for (const auto& m : report.meshes) { + QJsonObject mo; + mo["name"] = m.name; + mo["vertexCount"] = static_cast(m.vertexCount); + mo["indexCount"] = static_cast(m.indexCount); + mo["vertexBytes"] = static_cast(m.vertexBytes); + mo["indexBytes"] = static_cast(m.indexBytes); + mo["totalBytes"] = static_cast(m.totalBytes()); + meshArr.append(mo); + } + obj["meshes"] = meshArr; + + QJsonArray texArr; + for (const auto& t : report.textures) { + QJsonObject to; + to["name"] = t.name; + to["width"] = static_cast(t.width); + to["height"] = static_cast(t.height); + to["bytesPerPixel"] = static_cast(t.bytesPerPixel); + to["hasMips"] = t.hasMips; + to["bytes"] = static_cast(t.bytes); + texArr.append(to); + } + obj["textures"] = texArr; + + QJsonObject totals; + totals["meshBytes"] = static_cast(report.meshTotalBytes); + totals["textureBytes"] = static_cast(report.textureTotalBytes); + totals["totalBytes"] = static_cast(report.totalBytes()); + obj["totals"] = totals; + + if (report.budgetBytes > 0) { + QJsonObject budget; + budget["bytes"] = static_cast(report.budgetBytes); + budget["overBudget"] = report.overBudget(); + obj["budget"] = budget; + } + + return obj; +} + +QString MemoryEstimator::toText(const SceneMemoryReport& report) +{ + QString out; + QTextStream s(&out); + + s << "Memory Report\n"; + s << "=============\n\n"; + + s << "Meshes (" << report.meshes.size() << "):\n"; + if (report.meshes.isEmpty()) { + s << " (none)\n"; + } else { + for (const auto& m : report.meshes) { + s << " " << m.name + << " v=" << m.vertexCount + << " i=" << m.indexCount + << " " << formatBytes(m.totalBytes()) << "\n"; + } + } + s << " TOTAL: " << formatBytes(report.meshTotalBytes) << "\n\n"; + + s << "Textures (" << report.textures.size() << "):\n"; + if (report.textures.isEmpty()) { + s << " (none)\n"; + } else { + for (const auto& t : report.textures) { + s << " " << t.name + << " " << t.width << "x" << t.height + << " " << t.bytesPerPixel << "Bpp" + << (t.hasMips ? " +mips" : "") + << " " << formatBytes(t.bytes) << "\n"; + } + } + s << " TOTAL: " << formatBytes(report.textureTotalBytes) << "\n\n"; + + s << "Scene total: " << formatBytes(report.totalBytes()) << "\n"; + + if (report.budgetBytes > 0) { + s << "Budget: " << formatBytes(report.budgetBytes); + if (report.overBudget()) + s << " *** OVER BUDGET ***"; + s << "\n"; + } + + return out; +} diff --git a/src/MemoryEstimator.h b/src/MemoryEstimator.h new file mode 100644 index 000000000..ed80f58ce --- /dev/null +++ b/src/MemoryEstimator.h @@ -0,0 +1,91 @@ +#ifndef MEMORYESTIMATOR_H +#define MEMORYESTIMATOR_H + +#include +#include +#include +#include + +namespace Ogre { + class Entity; + class Mesh; + class Texture; +} + +// Per-mesh GPU memory breakdown, derived from vertex/index buffer sizes +// declared on the loaded Ogre mesh. Geometry only — texture VRAM is reported +// separately so callers can warn on each axis independently. +struct MeshMemoryEstimate { + QString name; + quint64 vertexBytes = 0; + quint64 indexBytes = 0; + unsigned int vertexCount = 0; + unsigned int indexCount = 0; + + quint64 totalBytes() const { return vertexBytes + indexBytes; } +}; + +// Per-texture VRAM estimate: width * height * bytesPerPixel, plus a mip +// overhead factor (1.33x for full mip chain, 1.0x for base level only). +struct TextureMemoryEstimate { + QString name; + unsigned int width = 0; + unsigned int height = 0; + unsigned int bytesPerPixel = 0; + bool hasMips = false; + quint64 bytes = 0; +}; + +struct SceneMemoryReport { + QList meshes; + QList textures; + quint64 meshTotalBytes = 0; + quint64 textureTotalBytes = 0; + quint64 budgetBytes = 0; // 0 = unlimited + + quint64 totalBytes() const { return meshTotalBytes + textureTotalBytes; } + bool overBudget() const { return budgetBytes > 0 && totalBytes() > budgetBytes; } +}; + +// Pure-data memory estimator. All methods are static and side-effect free. +// Designed so unit tests can exercise the byte math without an Ogre context. +class MemoryEstimator { +public: + // ---- Primitive estimators (testable without Ogre) ---- + + // Geometry bytes for a single submesh: vertexCount * vertexStride + indexCount * indexSize. + static quint64 meshBytes(unsigned int vertexCount, unsigned int vertexStride, + unsigned int indexCount, unsigned int indexSize); + + // Texture VRAM bytes: width * height * bpp, optionally with mip overhead (4/3x). + static quint64 textureBytes(unsigned int width, unsigned int height, + unsigned int bytesPerPixel, bool hasMips); + + // Format byte count as human-readable: "1.23 MB", "456 KB", "789 B". + static QString formatBytes(quint64 bytes); + + // Parse "50MB", "1.5 GB", "2048KB" → bytes. Returns 0 on parse error. + static quint64 parseBudget(const QString& spec); + + // ---- Ogre-backed estimators ---- + + // Walk every submesh on the entity and accumulate vertex/index bytes. + static MeshMemoryEstimate estimateEntity(const Ogre::Entity* entity); + + // Walk Ogre::TextureManager and return one estimate per named texture. + // Filters: textures not yet loaded (no width/height) are skipped so the + // total reflects what is actually resident on the GPU right now. + static QList estimateAllTextures(); + + // Build a scene-wide report by iterating Manager's scene nodes. + // budgetBytes=0 disables the over-budget flag. + static SceneMemoryReport estimateScene(quint64 budgetBytes = 0); + + // Serialize a SceneMemoryReport as JSON (used by CLI/MCP). + static QJsonObject toJson(const SceneMemoryReport& report); + + // Serialize as human-readable text (CLI default output). + static QString toText(const SceneMemoryReport& report); +}; + +#endif // MEMORYESTIMATOR_H diff --git a/src/MemoryEstimator_test.cpp b/src/MemoryEstimator_test.cpp new file mode 100644 index 000000000..124493c3c --- /dev/null +++ b/src/MemoryEstimator_test.cpp @@ -0,0 +1,183 @@ +#include + +#include "MemoryEstimator.h" + +#include +#include + +// All tests here exercise the pure-data primitives — no Ogre required, so +// they run on every CI build (including Linux Xvfb where Ogre is gated). + +TEST(MemoryEstimatorTest, MeshBytesEmpty) +{ + EXPECT_EQ(0u, MemoryEstimator::meshBytes(0, 32, 0, 2)); +} + +TEST(MemoryEstimatorTest, MeshBytesVertices16BitIndices) +{ + // 1024 verts × 32 bytes/vert + 3072 indices × 2 bytes = 32768 + 6144 = 38912 + EXPECT_EQ(38912u, MemoryEstimator::meshBytes(1024, 32, 3072, 2)); +} + +TEST(MemoryEstimatorTest, MeshBytes32BitIndices) +{ + // 1024 verts × 32 + 3072 × 4 = 32768 + 12288 = 45056 + EXPECT_EQ(45056u, MemoryEstimator::meshBytes(1024, 32, 3072, 4)); +} + +TEST(MemoryEstimatorTest, TextureBytesNoMips) +{ + // 1024 × 1024 × 4 = 4 MB base + EXPECT_EQ(4ull * 1024 * 1024, + MemoryEstimator::textureBytes(1024, 1024, 4, false)); +} + +TEST(MemoryEstimatorTest, TextureBytesWithMips) +{ + // Base 4 MB × 4/3 ≈ 5.33 MB + quint64 base = 4ull * 1024 * 1024; + EXPECT_EQ((base * 4) / 3, + MemoryEstimator::textureBytes(1024, 1024, 4, true)); +} + +TEST(MemoryEstimatorTest, TextureBytesNonSquare) +{ + // 2048 × 512 × 1 byte (alpha-only) = 1 MB + EXPECT_EQ(1ull * 1024 * 1024, + MemoryEstimator::textureBytes(2048, 512, 1, false)); +} + +TEST(MemoryEstimatorTest, FormatBytesSmall) +{ + EXPECT_EQ(QString("512 B"), MemoryEstimator::formatBytes(512)); +} + +TEST(MemoryEstimatorTest, FormatBytesKB) +{ + EXPECT_EQ(QString("2.0 KB"), MemoryEstimator::formatBytes(2048)); +} + +TEST(MemoryEstimatorTest, FormatBytesMB) +{ + EXPECT_EQ(QString("4.00 MB"), + MemoryEstimator::formatBytes(4ull * 1024 * 1024)); +} + +TEST(MemoryEstimatorTest, FormatBytesGB) +{ + EXPECT_EQ(QString("1.50 GB"), + MemoryEstimator::formatBytes(3ull * 512 * 1024 * 1024)); +} + +TEST(MemoryEstimatorTest, ParseBudgetBytes) +{ + EXPECT_EQ(2048u, MemoryEstimator::parseBudget("2048")); + EXPECT_EQ(2048u, MemoryEstimator::parseBudget("2048B")); + EXPECT_EQ(2048u, MemoryEstimator::parseBudget("2048 B")); +} + +TEST(MemoryEstimatorTest, ParseBudgetKB) +{ + EXPECT_EQ(1024u * 5, MemoryEstimator::parseBudget("5KB")); + EXPECT_EQ(1024u * 5, MemoryEstimator::parseBudget("5 kb")); +} + +TEST(MemoryEstimatorTest, ParseBudgetMB) +{ + EXPECT_EQ(50ull * 1024 * 1024, MemoryEstimator::parseBudget("50MB")); + EXPECT_EQ(50ull * 1024 * 1024, MemoryEstimator::parseBudget("50 mb")); +} + +TEST(MemoryEstimatorTest, ParseBudgetFractionalGB) +{ + EXPECT_EQ(static_cast(1.5 * 1024 * 1024 * 1024), + MemoryEstimator::parseBudget("1.5 GB")); +} + +TEST(MemoryEstimatorTest, ParseBudgetGarbage) +{ + EXPECT_EQ(0u, MemoryEstimator::parseBudget("")); + EXPECT_EQ(0u, MemoryEstimator::parseBudget("not a budget")); + EXPECT_EQ(0u, MemoryEstimator::parseBudget("--50MB")); +} + +TEST(MemoryEstimatorTest, JsonRoundTripEmpty) +{ + SceneMemoryReport empty; + QJsonObject obj = MemoryEstimator::toJson(empty); + EXPECT_TRUE(obj.contains("meshes")); + EXPECT_TRUE(obj.contains("textures")); + EXPECT_TRUE(obj.contains("totals")); + EXPECT_EQ(0, obj["meshes"].toArray().size()); + EXPECT_EQ(0, obj["textures"].toArray().size()); + EXPECT_FALSE(obj.contains("budget")); // omitted when budgetBytes == 0 +} + +TEST(MemoryEstimatorTest, JsonWithMeshAndTexture) +{ + SceneMemoryReport report; + MeshMemoryEstimate m; + m.name = "Cube.mesh"; + m.vertexCount = 24; + m.vertexBytes = 24 * 32; + m.indexCount = 36; + m.indexBytes = 36 * 2; + report.meshes.append(m); + report.meshTotalBytes = m.totalBytes(); + + TextureMemoryEstimate t; + t.name = "diffuse.png"; + t.width = 512; + t.height = 512; + t.bytesPerPixel = 4; + t.bytes = 512 * 512 * 4; + report.textures.append(t); + report.textureTotalBytes = t.bytes; + + QJsonObject obj = MemoryEstimator::toJson(report); + EXPECT_EQ(1, obj["meshes"].toArray().size()); + EXPECT_EQ(1, obj["textures"].toArray().size()); + EXPECT_EQ(QString("Cube.mesh"), obj["meshes"].toArray()[0].toObject()["name"].toString()); + EXPECT_EQ(static_cast(m.totalBytes()), + obj["totals"].toObject()["meshBytes"].toVariant().toLongLong()); +} + +TEST(MemoryEstimatorTest, JsonIncludesBudgetWhenSet) +{ + SceneMemoryReport report; + report.budgetBytes = 50 * 1024 * 1024; + report.meshTotalBytes = 60 * 1024 * 1024; + QJsonObject obj = MemoryEstimator::toJson(report); + ASSERT_TRUE(obj.contains("budget")); + QJsonObject budget = obj["budget"].toObject(); + EXPECT_EQ(static_cast(report.budgetBytes), + budget["bytes"].toVariant().toLongLong()); + EXPECT_TRUE(budget["overBudget"].toBool()); +} + +TEST(MemoryEstimatorTest, TextHasHeader) +{ + SceneMemoryReport empty; + QString out = MemoryEstimator::toText(empty); + EXPECT_TRUE(out.contains("Memory Report")); + EXPECT_TRUE(out.contains("Meshes (0)")); + EXPECT_TRUE(out.contains("Textures (0)")); +} + +TEST(MemoryEstimatorTest, TextFlagsOverBudget) +{ + SceneMemoryReport report; + report.budgetBytes = 10 * 1024 * 1024; + report.meshTotalBytes = 15 * 1024 * 1024; + QString out = MemoryEstimator::toText(report); + EXPECT_TRUE(out.contains("OVER BUDGET")); +} + +TEST(MemoryEstimatorTest, TextOmitsBudgetWhenZero) +{ + SceneMemoryReport report; + report.meshTotalBytes = 1024; + QString out = MemoryEstimator::toText(report); + EXPECT_FALSE(out.contains("Budget")); + EXPECT_FALSE(out.contains("OVER BUDGET")); +} diff --git a/src/MeshInfoOverlay.cpp b/src/MeshInfoOverlay.cpp index 3eeccc16b..5d762fe61 100644 --- a/src/MeshInfoOverlay.cpp +++ b/src/MeshInfoOverlay.cpp @@ -4,6 +4,7 @@ #include "EditorViewport.h" #include "OgreWidget.h" #include "CLIPipeline.h" +#include "MemoryEstimator.h" #include "mainwindow.h" #include @@ -110,6 +111,8 @@ QString MeshInfoOverlay::formatStats(const QList& entities, bool unsigned int totalSubmeshes = 0; unsigned short totalBones = 0; int totalAnims = 0; + quint64 totalGpuBytes = 0; + QSet seenMeshes; QSet materialSet; for (Ogre::Entity* entity : valid) { @@ -121,6 +124,14 @@ QString MeshInfoOverlay::formatStats(const QList& entities, bool totalAnims += info.animations.size(); for (const QString& mat : info.materials) materialSet.insert(mat); + + // De-duplicate GPU bytes by mesh name so we count each unique mesh + // resident once, not per-instance. + MeshMemoryEstimate mem = MemoryEstimator::estimateEntity(entity); + if (!mem.name.isEmpty() && !seenMeshes.contains(mem.name)) { + seenMeshes.insert(mem.name); + totalGpuBytes += mem.totalBytes(); + } } QString header; @@ -150,6 +161,10 @@ QString MeshInfoOverlay::formatStats(const QList& entities, bool .arg(totalAnims); } + if (totalGpuBytes > 0) { + text += QString("\nGPU: %1").arg(MemoryEstimator::formatBytes(totalGpuBytes)); + } + return text; } diff --git a/src/MeshInfoOverlay_test.cpp b/src/MeshInfoOverlay_test.cpp index 538f2307a..3aefd664b 100644 --- a/src/MeshInfoOverlay_test.cpp +++ b/src/MeshInfoOverlay_test.cpp @@ -256,6 +256,30 @@ TEST_F(MeshInfoOverlayIntegrationTest, FormatStatsWithSkeleton) Manager::getSingleton()->getSceneMgr()->destroySceneNode(node); } +TEST_F(MeshInfoOverlayIntegrationTest, FormatStatsIncludesGpuBytes) +{ + ASSERT_TRUE(canLoadMeshFiles()) << "mesh loading requires GL (Xvfb in CI)"; + auto meshPtr = createInMemoryTriangleMesh("MeshInfoGpuMesh"); + ASSERT_TRUE(meshPtr); + + auto* sceneMgr = Manager::getSingleton()->getSceneMgr(); + auto* node = sceneMgr->getRootSceneNode()->createChildSceneNode("MeshInfoGpuNode"); + auto* entity = sceneMgr->createEntity("MeshInfoGpuEntity", meshPtr); + node->attachObject(entity); + + QList entities; + entities << entity; + + QString result = MeshInfoOverlay::formatStats(entities, false); + // Phase 6 slice A: GPU memory line is appended when a mesh contributes bytes. + EXPECT_TRUE(result.contains("GPU:")) + << "Result: " << result.toStdString(); + + node->detachObject(entity); + sceneMgr->destroyEntity(entity); + sceneMgr->destroySceneNode(node); +} + TEST_F(MeshInfoOverlayIntegrationTest, FormatStatsMixedNullAndValid) { ASSERT_TRUE(canLoadMeshFiles()); diff --git a/src/main.cpp b/src/main.cpp index 9adf5207e..ce4d4f51b 100755 --- a/src/main.cpp +++ b/src/main.cpp @@ -88,7 +88,7 @@ int main(int argc, char *argv[]) if (arg == "info" || arg == "fix" || arg == "convert" || arg == "anim" || arg == "validate" || arg == "lod" || arg == "pose" || arg == "scan" || arg == "material" || arg == "pack-textures" - || arg == "normal-from-height") + || arg == "normal-from-height" || arg == "memory") cliMode = true; break; // first non-flag arg determines mode } From 439a081c85ffcdb46683fd6c238f8e091b861ea0 Mon Sep 17 00:00:00 2001 From: Fernando Date: Mon, 11 May 2026 23:52:59 -0400 Subject: [PATCH 2/5] fix(perf): wire MemoryEstimator into MaterialEditorQML test targets + CR feedback CI failure on #494: MaterialEditorQML_perf_test / _qml_test linked against MCPServer / CLIPipeline / MeshInfoOverlay sources but the new MemoryEstimator.cpp was missing from tests/CMakeLists.txt's source list, producing undefined references on the GCC link stage. Added it alongside TextureChannelPacker.cpp / NormalMapGenerator.cpp. CodeRabbit feedback addressed in the same commit (keep the diff small): - MCP get_memory_usage now returns the SceneMemoryReport as a structured `memory` field in the result envelope rather than appending a JSON string to the human summary. Machine consumers no longer have to text-parse the response. - MemoryEstimator::estimateScene now iterates node->numAttachedObjects() with an unsigned counter, matching the Ogre API and dropping the static_cast noise. - qtmesh memory --budget error message clarifies that omitting the flag means unlimited (was ambiguous on `--budget 0`). Also adds cloud-rules integration the user asked for: when --budget is not passed and a QtMesh Cloud token is available (--token / QTMESH_TOKEN / QTMESH_CLOUD_TOKEN), `qtmesh memory` GETs /v1/ingest/rules and reads rules.memory_budget_mb from the project config. --no-cloud opts out. The selected source is recorded in Sentry breadcrumbs and in --json output (budgetSource: "cli" | "cloud:"). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/CLIPipeline.cpp | 63 +++++++++++++++++++++++++++++++++++++---- src/MCPServer.cpp | 19 ++++--------- src/MemoryEstimator.cpp | 2 +- tests/CMakeLists.txt | 1 + 4 files changed, 65 insertions(+), 20 deletions(-) diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index 26f363f47..59a5c79f4 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -602,9 +602,12 @@ void CLIPipeline::printUsage() " --all Apply all extra fixes\n" " (no flags) Standard import/export (joins vertices, smooths normals, optimizes)\n" "\n" - " memory [--json] [--budget ]\n" + " memory [--json] [--budget ] [--token ] [--no-cloud]\n" " Report per-mesh GPU bytes and per-texture VRAM bytes\n" " --budget accepts e.g. 50MB, 1GB; exit 1 if exceeded\n" + " If --budget omitted and a token is set, the project's\n" + " memory_budget_mb is fetched from QtMesh Cloud rules.\n" + " --no-cloud opts out.\n" "\n" "Global options:\n" " --help, -h Show this help\n" @@ -3281,21 +3284,32 @@ int CLIPipeline::cmdScan(int argc, char* argv[]) int CLIPipeline::cmdMemory(int argc, char* argv[]) { - // Parse: memory [--json] [--budget ] + // Parse: memory [--json] [--budget ] [--token ] [--no-cloud] QString filePath; + QString tokenArg; bool jsonOutput = false; + bool noCloud = false; quint64 budgetBytes = 0; + bool budgetExplicit = false; for (int i = 1; i < argc; ++i) { QString arg(argv[i]); if (arg == "memory" || arg == "--cli") continue; if (arg == "--json") { jsonOutput = true; continue; } + if (arg == "--no-cloud") { noCloud = true; continue; } + if (arg == "--token" && i + 1 < argc) { + tokenArg = QString::fromLocal8Bit(argv[++i]); + continue; + } if (arg == "--budget" && i + 1 < argc) { budgetBytes = MemoryEstimator::parseBudget(argv[++i]); if (budgetBytes == 0) { - err() << "Error: Invalid --budget value (use e.g. 50MB, 1GB)" << Qt::endl; + err() << "Error: Invalid --budget value. Use a positive size " + "(e.g. 50MB, 1GB) or omit --budget for unlimited." + << Qt::endl; return 2; } + budgetExplicit = true; continue; } if (!arg.startsWith("-") && filePath.isEmpty()) { filePath = arg; continue; } @@ -3303,7 +3317,8 @@ int CLIPipeline::cmdMemory(int argc, char* argv[]) if (filePath.isEmpty()) { err() << "Error: No input file specified." << Qt::endl; - err() << "Usage: qtmesh memory [--json] [--budget ]" << Qt::endl; + err() << "Usage: qtmesh memory [--json] [--budget ] [--token ] [--no-cloud]" + << Qt::endl; return 2; } @@ -3313,11 +3328,45 @@ int CLIPipeline::cmdMemory(int argc, char* argv[]) return 1; } + // No explicit --budget: if a cloud token is available, try to fetch + // memory_budget_mb from QtMesh Cloud's project rules. Mirrors the + // `qtmesh scan` remote-rules path. --no-cloud opts out. + QString budgetSource = budgetExplicit ? QStringLiteral("cli") : QString(); + if (!budgetExplicit && !noCloud) { + const QString ingest = resolveIngestToken(tokenArg); + if (!ingest.isEmpty()) { + SentryReporter::addBreadcrumb(QStringLiteral("cli.memory"), + QStringLiteral("QtMesh Cloud fetchRules: requested")); + const auto rules = QtMeshCloudClient::fetchRules(ingest); + if (rules.ok) { + const QJsonObject rulesObj = rules.config.value("rules").toObject(); + const double mb = rulesObj.value("memory_budget_mb").toDouble(0.0); + if (mb > 0.0) { + budgetBytes = static_cast(mb * 1024.0 * 1024.0); + budgetSource = QStringLiteral("cloud:%1").arg(rules.source); + err() << "Note: Using QtMesh Cloud memory_budget_mb=" + << mb << " (source: " << rules.source << ")." << Qt::endl; + } + SentryReporter::addBreadcrumb(QStringLiteral("cli.memory"), + QStringLiteral("QtMesh Cloud fetchRules: ok source=%1 budget_mb=%2") + .arg(rules.source).arg(mb)); + } else { + err() << "Warning: Could not load QtMesh Cloud rules (" + << rules.errorString << "). Continuing without remote budget." << Qt::endl; + SentryReporter::addBreadcrumb(QStringLiteral("cli.memory"), + QStringLiteral("QtMesh Cloud fetchRules: failed %1").arg(rules.errorString), + QStringLiteral("warning")); + } + } + } + if (!initOgreHeadless()) return 1; SentryReporter::addBreadcrumb("cli.memory", - QString("Memory .%1%2").arg(fi.suffix(), - budgetBytes > 0 ? QString(" budget=%1B").arg(budgetBytes) : QString())); + QString("Memory .%1%2 source=%3").arg( + fi.suffix(), + budgetBytes > 0 ? QString(" budget=%1B").arg(budgetBytes) : QString(), + budgetSource.isEmpty() ? QStringLiteral("none") : budgetSource)); MeshImporterExporter::importer({fi.absoluteFilePath()}, 0); auto& entities = Manager::getSingleton()->getEntities(); @@ -3331,6 +3380,8 @@ int CLIPipeline::cmdMemory(int argc, char* argv[]) if (jsonOutput) { QJsonObject obj = MemoryEstimator::toJson(report); obj["file"] = fi.fileName(); + if (!budgetSource.isEmpty()) + obj["budgetSource"] = budgetSource; cliWrite(QString::fromUtf8(QJsonDocument(obj).toJson(QJsonDocument::Indented))); } else { cliWrite(MemoryEstimator::toText(report)); diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index 5f603c7f5..f16466a39 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -2754,19 +2754,12 @@ QJsonObject MCPServer::toolGetMemoryUsage(const QJsonObject &args) SceneMemoryReport report = MemoryEstimator::estimateScene(budget); - // Return human-readable text in the standard success envelope so the - // MCP client sees a useful summary; JSON consumers should call the - // CLI (`qtmesh memory --json`) or inspect the structured fields below. - QString summary = MemoryEstimator::toText(report); - - // Append a one-line numeric summary so LLM clients can extract totals - // without parsing the table. - summary += QString("\nTotals JSON: %1") - .arg(QString::fromUtf8( - QJsonDocument(MemoryEstimator::toJson(report)) - .toJson(QJsonDocument::Compact))); - - return makeSuccessResult(summary); + // Human-readable text goes in the standard `content` field; machine + // consumers (LLM tool wrappers, CI scripts) read the structured + // `memory` payload alongside it. + QJsonObject result = makeSuccessResult(MemoryEstimator::toText(report)); + result["memory"] = MemoryEstimator::toJson(report); + return result; } catch (Ogre::Exception& e) { return makeErrorResult( QString("Ogre error: %1").arg(QString::fromStdString(e.getFullDescription()))); diff --git a/src/MemoryEstimator.cpp b/src/MemoryEstimator.cpp index aab015cfd..f40dc463b 100644 --- a/src/MemoryEstimator.cpp +++ b/src/MemoryEstimator.cpp @@ -166,7 +166,7 @@ SceneMemoryReport MemoryEstimator::estimateScene(quint64 budgetBytes) QSet seenMeshes; for (Ogre::SceneNode* node : mgr->getSceneNodes()) { if (!node) continue; - for (int i = 0; i < static_cast(node->numAttachedObjects()); ++i) { + for (unsigned int i = 0; i < node->numAttachedObjects(); ++i) { Ogre::MovableObject* obj = node->getAttachedObject(i); if (!obj || obj->getMovableType() != "Entity") continue; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e1e697abc..e75f6fd7c 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -88,6 +88,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/MaterialPresetLibrary.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/TextureChannelPacker.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/NormalMapGenerator.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/MemoryEstimator.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MeshLodController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MeshValidator.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/AIChatManager.cpp From 17e2f02d6b01987cb1ba628ee62de558d4bc6518 Mon Sep 17 00:00:00 2001 From: Fernando Date: Mon, 11 May 2026 23:54:08 -0400 Subject: [PATCH 3/5] docs(mcp): describe structured memory field in get_memory_usage schema CodeRabbit review hint: the tool description should advertise the structured payload now that the response carries both text and JSON. Clarifies that machine consumers should read result["memory"] for per-mesh, per-texture, totals, and budget fields rather than parsing the human-readable summary in `content`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/MCPServer.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index f16466a39..7f324f520 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -4106,11 +4106,13 @@ QJsonArray MCPServer::buildToolsList() { QJsonObject props; props["budget"] = QJsonObject{{"type", "string"}, - {"description", "Optional memory budget (e.g. '50MB', '1GB'). When the report exceeds the budget the response flags 'overBudget'."}}; + {"description", "Optional memory budget (e.g. '50MB', '1GB'). When the report exceeds the budget the response flags 'overBudget' under the structured 'memory' field."}}; appendTool( "get_memory_usage", "Report estimated GPU memory for every loaded mesh (vertex + index buffers) " - "and VRAM for every resident texture. Includes per-asset breakdown and scene totals. " + "and VRAM for every resident texture. The response includes a human-readable summary " + "in the standard content field and a structured 'memory' object with per-mesh, " + "per-texture, totals, and optional budget fields for machine consumers. " "Use to spot heavy meshes/textures before exporting to a memory-constrained target.", props ); From 4f9696c2bdda6174c69f47726ff176ad0de5f86f Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 12 May 2026 00:22:34 -0400 Subject: [PATCH 4/5] refactor(perf): address SonarCloud findings on slice A (#494) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sonar Quality Gate was already passing; this clears the 16 issues flagged on the new code: CLIPipeline.cpp / cmdMemory - S3776 / S134: cognitive complexity 44 → ~15, max nesting depth back below the threshold. Extracted parseMemoryArgs, applyCloudBudget, and emitMemoryReport into an anonymous-namespace block. cmdMemory is now a thin orchestrator that delegates parsing, cloud lookup, and serialisation. - S886 / S5350 / S6004: tightened `for (int i = …)` parsing loop with const-correct locals, moved `entities` into an if-init-statement and dropped the redundant reference binding. MCPServer.cpp / toolGetMemoryUsage - S5350 / S6004: Manager* → const Manager* via if-init-statement; `spec` declared const. Left the method non-const with an inline note: ToolHandler is a non-const member-fn pointer (every other tool method in this class follows the same convention, so flipping just this one would break the registry signature). MemoryEstimator.cpp - S5276: formatBytes now stores 1024/1024² constants as quint64 and casts to double at the division site, so the comparison stays in integer space and the precision-loss warnings go away. - S995 / S5350: estimateEntity's accumulateVertexData takes a pointer-to-const VertexData; estimateScene iterates with const Ogre::SceneNode* / const Ogre::Entity*. - S5276: vertex stride stored as size_t to match Ogre's API return type instead of narrowing to unsigned int at the call site. No behaviour changes; manual smoke test reruns of `qtmesh memory robot.mesh --budget 10KB` still produce the expected text report, JSON payload, and exit-1 over-budget signal. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/CLIPipeline.cpp | 158 +++++++++++++++++++++++----------------- src/MCPServer.cpp | 7 +- src/MemoryEstimator.cpp | 30 ++++---- 3 files changed, 111 insertions(+), 84 deletions(-) diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index 59a5c79f4..37c9dcb91 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -3282,110 +3282,136 @@ int CLIPipeline::cmdScan(int argc, char* argv[]) return scanExit; } -int CLIPipeline::cmdMemory(int argc, char* argv[]) -{ - // Parse: memory [--json] [--budget ] [--token ] [--no-cloud] +namespace { + +struct MemoryCmdArgs { QString filePath; QString tokenArg; bool jsonOutput = false; bool noCloud = false; quint64 budgetBytes = 0; bool budgetExplicit = false; +}; +// Parse argv into MemoryCmdArgs. Returns: +// 0 = parsed ok, run command +// 1 = print usage + exit 2 (missing file / bad value) +// 2 = print "invalid budget" + exit 2 +int parseMemoryArgs(int argc, char* argv[], MemoryCmdArgs& out) +{ for (int i = 1; i < argc; ++i) { - QString arg(argv[i]); + const QString arg(argv[i]); if (arg == "memory" || arg == "--cli") continue; - if (arg == "--json") { jsonOutput = true; continue; } - if (arg == "--no-cloud") { noCloud = true; continue; } + if (arg == "--json") { out.jsonOutput = true; continue; } + if (arg == "--no-cloud") { out.noCloud = true; continue; } if (arg == "--token" && i + 1 < argc) { - tokenArg = QString::fromLocal8Bit(argv[++i]); + out.tokenArg = QString::fromLocal8Bit(argv[++i]); continue; } if (arg == "--budget" && i + 1 < argc) { - budgetBytes = MemoryEstimator::parseBudget(argv[++i]); - if (budgetBytes == 0) { - err() << "Error: Invalid --budget value. Use a positive size " - "(e.g. 50MB, 1GB) or omit --budget for unlimited." - << Qt::endl; - return 2; - } - budgetExplicit = true; + out.budgetBytes = MemoryEstimator::parseBudget(argv[++i]); + if (out.budgetBytes == 0) return 2; + out.budgetExplicit = true; continue; } - if (!arg.startsWith("-") && filePath.isEmpty()) { filePath = arg; continue; } + if (!arg.startsWith("-") && out.filePath.isEmpty()) { + out.filePath = arg; + } } + return out.filePath.isEmpty() ? 1 : 0; +} - if (filePath.isEmpty()) { +// Apply QtMesh Cloud's rules.memory_budget_mb when no explicit --budget was +// given. Mutates budgetBytes and budgetSource; never fails the command. +void applyCloudBudget(const QString& tokenArg, quint64& budgetBytes, QString& budgetSource) +{ + const QString ingest = resolveIngestToken(tokenArg); + if (ingest.isEmpty()) return; + + SentryReporter::addBreadcrumb(QStringLiteral("cli.memory"), + QStringLiteral("QtMesh Cloud fetchRules: requested")); + const auto rules = QtMeshCloudClient::fetchRules(ingest); + if (!rules.ok) { + err() << "Warning: Could not load QtMesh Cloud rules (" + << rules.errorString << "). Continuing without remote budget." << Qt::endl; + SentryReporter::addBreadcrumb(QStringLiteral("cli.memory"), + QStringLiteral("QtMesh Cloud fetchRules: failed %1").arg(rules.errorString), + QStringLiteral("warning")); + return; + } + + const QJsonObject rulesObj = rules.config.value("rules").toObject(); + const double mb = rulesObj.value("memory_budget_mb").toDouble(0.0); + if (mb > 0.0) { + budgetBytes = static_cast(mb * 1024.0 * 1024.0); + budgetSource = QStringLiteral("cloud:%1").arg(rules.source); + err() << "Note: Using QtMesh Cloud memory_budget_mb=" + << mb << " (source: " << rules.source << ")." << Qt::endl; + } + SentryReporter::addBreadcrumb(QStringLiteral("cli.memory"), + QStringLiteral("QtMesh Cloud fetchRules: ok source=%1 budget_mb=%2") + .arg(rules.source).arg(mb)); +} + +void emitMemoryReport(const SceneMemoryReport& report, const QFileInfo& fi, + const QString& budgetSource, bool jsonOutput) +{ + if (jsonOutput) { + QJsonObject obj = MemoryEstimator::toJson(report); + obj["file"] = fi.fileName(); + if (!budgetSource.isEmpty()) + obj["budgetSource"] = budgetSource; + cliWrite(QString::fromUtf8(QJsonDocument(obj).toJson(QJsonDocument::Indented))); + } else { + cliWrite(MemoryEstimator::toText(report)); + } +} + +} // namespace + +int CLIPipeline::cmdMemory(int argc, char* argv[]) +{ + // Parse: memory [--json] [--budget ] [--token ] [--no-cloud] + MemoryCmdArgs cmdArgs; + const int parseRc = parseMemoryArgs(argc, argv, cmdArgs); + if (parseRc == 2) { + err() << "Error: Invalid --budget value. Use a positive size " + "(e.g. 50MB, 1GB) or omit --budget for unlimited." << Qt::endl; + return 2; + } + if (parseRc == 1) { err() << "Error: No input file specified." << Qt::endl; err() << "Usage: qtmesh memory [--json] [--budget ] [--token ] [--no-cloud]" << Qt::endl; return 2; } - QFileInfo fi(filePath); + const QFileInfo fi(cmdArgs.filePath); if (!fi.exists()) { - err() << "Error: File not found: " << filePath << Qt::endl; + err() << "Error: File not found: " << cmdArgs.filePath << Qt::endl; return 1; } - // No explicit --budget: if a cloud token is available, try to fetch - // memory_budget_mb from QtMesh Cloud's project rules. Mirrors the - // `qtmesh scan` remote-rules path. --no-cloud opts out. - QString budgetSource = budgetExplicit ? QStringLiteral("cli") : QString(); - if (!budgetExplicit && !noCloud) { - const QString ingest = resolveIngestToken(tokenArg); - if (!ingest.isEmpty()) { - SentryReporter::addBreadcrumb(QStringLiteral("cli.memory"), - QStringLiteral("QtMesh Cloud fetchRules: requested")); - const auto rules = QtMeshCloudClient::fetchRules(ingest); - if (rules.ok) { - const QJsonObject rulesObj = rules.config.value("rules").toObject(); - const double mb = rulesObj.value("memory_budget_mb").toDouble(0.0); - if (mb > 0.0) { - budgetBytes = static_cast(mb * 1024.0 * 1024.0); - budgetSource = QStringLiteral("cloud:%1").arg(rules.source); - err() << "Note: Using QtMesh Cloud memory_budget_mb=" - << mb << " (source: " << rules.source << ")." << Qt::endl; - } - SentryReporter::addBreadcrumb(QStringLiteral("cli.memory"), - QStringLiteral("QtMesh Cloud fetchRules: ok source=%1 budget_mb=%2") - .arg(rules.source).arg(mb)); - } else { - err() << "Warning: Could not load QtMesh Cloud rules (" - << rules.errorString << "). Continuing without remote budget." << Qt::endl; - SentryReporter::addBreadcrumb(QStringLiteral("cli.memory"), - QStringLiteral("QtMesh Cloud fetchRules: failed %1").arg(rules.errorString), - QStringLiteral("warning")); - } - } - } + // No explicit --budget: try QtMesh Cloud's memory_budget_mb (token gated). + QString budgetSource = cmdArgs.budgetExplicit ? QStringLiteral("cli") : QString(); + if (!cmdArgs.budgetExplicit && !cmdArgs.noCloud) + applyCloudBudget(cmdArgs.tokenArg, cmdArgs.budgetBytes, budgetSource); if (!initOgreHeadless()) return 1; SentryReporter::addBreadcrumb("cli.memory", QString("Memory .%1%2 source=%3").arg( fi.suffix(), - budgetBytes > 0 ? QString(" budget=%1B").arg(budgetBytes) : QString(), + cmdArgs.budgetBytes > 0 ? QString(" budget=%1B").arg(cmdArgs.budgetBytes) : QString(), budgetSource.isEmpty() ? QStringLiteral("none") : budgetSource)); MeshImporterExporter::importer({fi.absoluteFilePath()}, 0); - auto& entities = Manager::getSingleton()->getEntities(); - if (entities.isEmpty()) { - err() << "Error: Failed to load file: " << filePath << Qt::endl; + if (const auto& entities = Manager::getSingleton()->getEntities(); entities.isEmpty()) { + err() << "Error: Failed to load file: " << cmdArgs.filePath << Qt::endl; return 1; } - SceneMemoryReport report = MemoryEstimator::estimateScene(budgetBytes); - - if (jsonOutput) { - QJsonObject obj = MemoryEstimator::toJson(report); - obj["file"] = fi.fileName(); - if (!budgetSource.isEmpty()) - obj["budgetSource"] = budgetSource; - cliWrite(QString::fromUtf8(QJsonDocument(obj).toJson(QJsonDocument::Indented))); - } else { - cliWrite(MemoryEstimator::toText(report)); - } - + const SceneMemoryReport report = MemoryEstimator::estimateScene(cmdArgs.budgetBytes); + emitMemoryReport(report, fi, budgetSource, cmdArgs.jsonOutput); return report.overBudget() ? 1 : 0; } diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index 7f324f520..450c2e4a0 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -2736,14 +2736,15 @@ QJsonObject MCPServer::toolGetLodInfo(const QJsonObject &args) QJsonObject MCPServer::toolGetMemoryUsage(const QJsonObject &args) { + // NOTE: cannot be `const` — ToolHandler is a non-const member-fn pointer + // (matching every other tool in this class). try { - Manager* mgr = Manager::getSingletonPtr(); - if (!mgr) + if (const Manager* mgr = Manager::getSingletonPtr(); !mgr) return makeErrorResult("Error: Manager not available"); quint64 budget = 0; if (args.contains("budget")) { - QString spec = args.value("budget").toString(); + const QString spec = args.value("budget").toString(); if (!spec.isEmpty()) { budget = MemoryEstimator::parseBudget(spec); if (budget == 0) diff --git a/src/MemoryEstimator.cpp b/src/MemoryEstimator.cpp index f40dc463b..f7e07201c 100644 --- a/src/MemoryEstimator.cpp +++ b/src/MemoryEstimator.cpp @@ -35,16 +35,16 @@ quint64 MemoryEstimator::textureBytes(unsigned int width, unsigned int height, QString MemoryEstimator::formatBytes(quint64 bytes) { - constexpr double KB = 1024.0; - constexpr double MB = 1024.0 * 1024.0; - constexpr double GB = 1024.0 * 1024.0 * 1024.0; - - if (bytes >= static_cast(GB)) - return QString::number(bytes / GB, 'f', 2) + " GB"; - if (bytes >= static_cast(MB)) - return QString::number(bytes / MB, 'f', 2) + " MB"; - if (bytes >= static_cast(KB)) - return QString::number(bytes / KB, 'f', 1) + " KB"; + constexpr quint64 KB = 1024ULL; + constexpr quint64 MB = 1024ULL * 1024ULL; + constexpr quint64 GB = 1024ULL * 1024ULL * 1024ULL; + + if (bytes >= GB) + return QString::number(static_cast(bytes) / GB, 'f', 2) + " GB"; + if (bytes >= MB) + return QString::number(static_cast(bytes) / MB, 'f', 2) + " MB"; + if (bytes >= KB) + return QString::number(static_cast(bytes) / KB, 'f', 1) + " KB"; return QString::number(bytes) + " B"; } @@ -82,7 +82,7 @@ MeshMemoryEstimate MemoryEstimator::estimateEntity(const Ogre::Entity* entity) est.name = QString::fromStdString(mesh->getName()); - auto accumulateVertexData = [&](Ogre::VertexData* vd) { + auto accumulateVertexData = [&](const Ogre::VertexData* vd) { if (!vd) return; est.vertexCount += vd->vertexCount; // Sum the stride from every bound vertex buffer in the declaration. @@ -92,7 +92,7 @@ MeshMemoryEstimate MemoryEstimator::estimateEntity(const Ogre::Entity* entity) unsigned short source = elem.getSource(); if (seen.contains(source)) continue; seen.insert(source); - unsigned int stride = vd->vertexDeclaration->getVertexSize(source); + const size_t stride = vd->vertexDeclaration->getVertexSize(source); est.vertexBytes += static_cast(vd->vertexCount) * stride; } }; @@ -164,13 +164,13 @@ SceneMemoryReport MemoryEstimator::estimateScene(quint64 budgetBytes) if (!mgr) return report; QSet seenMeshes; - for (Ogre::SceneNode* node : mgr->getSceneNodes()) { + for (const Ogre::SceneNode* node : mgr->getSceneNodes()) { if (!node) continue; for (unsigned int i = 0; i < node->numAttachedObjects(); ++i) { - Ogre::MovableObject* obj = node->getAttachedObject(i); + const Ogre::MovableObject* obj = node->getAttachedObject(i); if (!obj || obj->getMovableType() != "Entity") continue; - auto* entity = static_cast(obj); + const auto* entity = static_cast(obj); MeshMemoryEstimate est = estimateEntity(entity); // De-duplicate identical mesh instances so totals reflect unique // GPU residents, not draw-call counts. From 6238d8e4637d0c4ce1ddcb4c66a93bd78bec7b02 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 12 May 2026 00:38:10 -0400 Subject: [PATCH 5/5] refactor(perf): clear remaining SonarCloud minor findings on slice A MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the first refactor pass dropped issues 16 → 4, this clears the final three actionable items: - MemoryEstimator.cpp:42 (S6004): hoist KB/MB/GB constexpr quint64 constants to anonymous namespace scope so each if's first condition can read them directly without a per-branch init-statement. - MemoryEstimator.cpp:103 (S5350): SubMesh* → const SubMesh* in estimateEntity's submesh loop. - CLIPipeline.cpp:3302 (S886): rewrite parseMemoryArgs's for loop as an index-driven while, so the in-body ++i (used to consume the argument of --token / --budget) no longer mutates the loop variable Sonar tracks. The remaining MCPServer.cpp:2737 "should be const" finding is intentional and now flagged with a NOSONAR(cpp:S5817) comment: the class's ToolHandler is a non-const member-function pointer, so flipping just one tool method would break the registry signature. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/CLIPipeline.cpp | 16 +++++++++------- src/MCPServer.cpp | 5 +++-- src/MemoryEstimator.cpp | 24 +++++++++++++----------- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index 37c9dcb91..64e7d7c78 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -3299,17 +3299,19 @@ struct MemoryCmdArgs { // 2 = print "invalid budget" + exit 2 int parseMemoryArgs(int argc, char* argv[], MemoryCmdArgs& out) { - for (int i = 1; i < argc; ++i) { + int i = 1; + while (i < argc) { const QString arg(argv[i]); + ++i; if (arg == "memory" || arg == "--cli") continue; - if (arg == "--json") { out.jsonOutput = true; continue; } - if (arg == "--no-cloud") { out.noCloud = true; continue; } - if (arg == "--token" && i + 1 < argc) { - out.tokenArg = QString::fromLocal8Bit(argv[++i]); + if (arg == "--json") { out.jsonOutput = true; continue; } + if (arg == "--no-cloud") { out.noCloud = true; continue; } + if (arg == "--token" && i < argc) { + out.tokenArg = QString::fromLocal8Bit(argv[i++]); continue; } - if (arg == "--budget" && i + 1 < argc) { - out.budgetBytes = MemoryEstimator::parseBudget(argv[++i]); + if (arg == "--budget" && i < argc) { + out.budgetBytes = MemoryEstimator::parseBudget(argv[i++]); if (out.budgetBytes == 0) return 2; out.budgetExplicit = true; continue; diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index 450c2e4a0..c148cba0b 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -2734,10 +2734,11 @@ QJsonObject MCPServer::toolGetLodInfo(const QJsonObject &args) return makeSuccessResult(lines.join("\n")); } +// NOSONAR(cpp:S5817) — ToolHandler is a non-const member-fn pointer (matching +// every other tool method in this class); marking just this one const would +// break the registry signature in MCPServer.h. QJsonObject MCPServer::toolGetMemoryUsage(const QJsonObject &args) { - // NOTE: cannot be `const` — ToolHandler is a non-const member-fn pointer - // (matching every other tool in this class). try { if (const Manager* mgr = Manager::getSingletonPtr(); !mgr) return makeErrorResult("Error: Manager not available"); diff --git a/src/MemoryEstimator.cpp b/src/MemoryEstimator.cpp index f7e07201c..528480f30 100644 --- a/src/MemoryEstimator.cpp +++ b/src/MemoryEstimator.cpp @@ -33,18 +33,20 @@ quint64 MemoryEstimator::textureBytes(unsigned int width, unsigned int height, return base; } +namespace { +constexpr quint64 kKB = 1024ULL; +constexpr quint64 kMB = 1024ULL * 1024ULL; +constexpr quint64 kGB = 1024ULL * 1024ULL * 1024ULL; +} // namespace + QString MemoryEstimator::formatBytes(quint64 bytes) { - constexpr quint64 KB = 1024ULL; - constexpr quint64 MB = 1024ULL * 1024ULL; - constexpr quint64 GB = 1024ULL * 1024ULL * 1024ULL; - - if (bytes >= GB) - return QString::number(static_cast(bytes) / GB, 'f', 2) + " GB"; - if (bytes >= MB) - return QString::number(static_cast(bytes) / MB, 'f', 2) + " MB"; - if (bytes >= KB) - return QString::number(static_cast(bytes) / KB, 'f', 1) + " KB"; + if (bytes >= kGB) + return QString::number(static_cast(bytes) / kGB, 'f', 2) + " GB"; + if (bytes >= kMB) + return QString::number(static_cast(bytes) / kMB, 'f', 2) + " MB"; + if (bytes >= kKB) + return QString::number(static_cast(bytes) / kKB, 'f', 1) + " KB"; return QString::number(bytes) + " B"; } @@ -100,7 +102,7 @@ MeshMemoryEstimate MemoryEstimator::estimateEntity(const Ogre::Entity* entity) accumulateVertexData(mesh->sharedVertexData); for (unsigned int i = 0; i < mesh->getNumSubMeshes(); ++i) { - Ogre::SubMesh* sub = mesh->getSubMesh(i); + const Ogre::SubMesh* sub = mesh->getSubMesh(i); if (!sub) continue; if (!sub->useSharedVertices)