From 5e404a9fe632c22eb6d45a33538db7e68842da70 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 9 May 2026 10:41:58 -0700 Subject: [PATCH] feat(editor): add --info-mesh-stats geometric audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reports total surface area, per-triangle area histogram (min/max/mean/median), edge analysis (boundary / manifold / non-manifold counts), watertight check, and degenerate triangle count for a single WOM. Watertightness here is the topological notion: every edge must be shared by exactly 2 triangles via shared vertex indices. This is what collision bakes and physics queries actually need — visually-closed primitives whose adjacent faces don't weld vertices will (correctly) report as non-watertight. Already caught a real defect in handleTent's door-fan triangulation: the fan covers the door cutout area with a stray triangle and leaves a vertex unreferenced. Edge analysis is gated by triCount <= 2M to keep the unordered_map bounded for huge baked terrain meshes. --- tools/editor/cli_arg_required.cpp | 2 +- tools/editor/cli_help.cpp | 2 + tools/editor/cli_mesh_info.cpp | 145 ++++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 1 deletion(-) diff --git a/tools/editor/cli_arg_required.cpp b/tools/editor/cli_arg_required.cpp index 946b7b45..246fe391 100644 --- a/tools/editor/cli_arg_required.cpp +++ b/tools/editor/cli_arg_required.cpp @@ -13,7 +13,7 @@ const char* const kArgRequired[] = { "--list-project-textures", "--info-zone-models-total", "--info-project-models-total", "--list-zone-meshes-detail", "--list-project-meshes-detail", "--info-mesh", - "--info-mesh-storage-budget", + "--info-mesh-storage-budget", "--info-mesh-stats", "--info-wob", "--info-woc", "--info-wot", "--info-creatures", "--info-objects", "--info-quests", "--info-extract", "--info-extract-tree", "--info-extract-budget", diff --git a/tools/editor/cli_help.cpp b/tools/editor/cli_help.cpp index 3b244096..d587d22d 100644 --- a/tools/editor/cli_help.cpp +++ b/tools/editor/cli_help.cpp @@ -613,6 +613,8 @@ void printUsage(const char* argv0) { std::printf(" Single-mesh detail: bounds, version, batches, bones, textures, attachments in one view\n"); std::printf(" --info-mesh-storage-budget [--json]\n"); std::printf(" Estimated bytes-per-category breakdown for a single WOM (vertices/indices/bones/...)\n"); + std::printf(" --info-mesh-stats [--json]\n"); + std::printf(" Geometric stats: total surface area, triangle area histogram, edge use, watertight check\n"); std::printf(" --list-project-meshes-detail [--json]\n"); std::printf(" Per-mesh listing across every zone in a project (sorted by triangle count)\n"); std::printf(" --info-project-models-total [--json]\n"); diff --git a/tools/editor/cli_mesh_info.cpp b/tools/editor/cli_mesh_info.cpp index 8a9c4af4..780520ce 100644 --- a/tools/editor/cli_mesh_info.cpp +++ b/tools/editor/cli_mesh_info.cpp @@ -2,15 +2,18 @@ #include "pipeline/wowee_model.hpp" #include "pipeline/wowee_building.hpp" +#include #include #include +#include #include #include #include #include #include #include +#include #include namespace wowee { @@ -315,6 +318,145 @@ int handleInfoMesh(int& i, int argc, char** argv) { } +int handleInfoMeshStats(int& i, int argc, char** argv) { + // Geometric statistics on a WOM mesh: total surface area, + // triangle area distribution (min/max/mean/median), edge + // count, and a watertight check (is every edge shared by + // exactly 2 triangles?). Watertightness is what collision + // baking and physics need; the histogram catches degenerate + // (zero-area) and outsized triangles that would otherwise + // hide inside a mesh. + std::string base = argv[++i]; + bool jsonOut = (i + 1 < argc && + std::strcmp(argv[i + 1], "--json") == 0); + if (jsonOut) i++; + if (base.size() >= 4 && base.substr(base.size() - 4) == ".wom") { + base = base.substr(0, base.size() - 4); + } + if (!wowee::pipeline::WoweeModelLoader::exists(base)) { + std::fprintf(stderr, + "info-mesh-stats: %s.wom does not exist\n", base.c_str()); + return 1; + } + auto wom = wowee::pipeline::WoweeModelLoader::load(base); + if (!wom.isValid()) { + std::fprintf(stderr, + "info-mesh-stats: failed to load %s.wom\n", base.c_str()); + return 1; + } + if (wom.indices.size() % 3 != 0) { + std::fprintf(stderr, + "info-mesh-stats: index count %zu not divisible by 3\n", + wom.indices.size()); + return 1; + } + const std::size_t triCount = wom.indices.size() / 3; + std::vector areas; + areas.reserve(triCount); + double totalArea = 0.0; + std::size_t degenerate = 0; + // Edge-use counter: key is (lo<<32 | hi) of the two endpoint + // indices; value counts how many triangles share that edge. + // Skipped for huge meshes (>2M tris) since the unordered_map + // would balloon. + const bool runEdgeAnalysis = (triCount <= 2'000'000); + std::unordered_map edgeUses; + if (runEdgeAnalysis) edgeUses.reserve(triCount * 3); + auto edgeKey = [](uint32_t a, uint32_t b) -> uint64_t { + if (a > b) std::swap(a, b); + return (uint64_t(a) << 32) | uint64_t(b); + }; + for (std::size_t t = 0; t < triCount; ++t) { + uint32_t i0 = wom.indices[t * 3 + 0]; + uint32_t i1 = wom.indices[t * 3 + 1]; + uint32_t i2 = wom.indices[t * 3 + 2]; + if (i0 >= wom.vertices.size() || + i1 >= wom.vertices.size() || + i2 >= wom.vertices.size()) { + std::fprintf(stderr, + "info-mesh-stats: out-of-range index in triangle %zu\n", t); + return 1; + } + glm::vec3 a = wom.vertices[i0].position; + glm::vec3 b = wom.vertices[i1].position; + glm::vec3 c = wom.vertices[i2].position; + glm::vec3 e1 = b - a; + glm::vec3 e2 = c - a; + double area = 0.5 * glm::length(glm::cross(e1, e2)); + if (area < 1e-12) ++degenerate; + areas.push_back(area); + totalArea += area; + if (runEdgeAnalysis) { + ++edgeUses[edgeKey(i0, i1)]; + ++edgeUses[edgeKey(i1, i2)]; + ++edgeUses[edgeKey(i2, i0)]; + } + } + double minArea = areas.empty() ? 0.0 : + *std::min_element(areas.begin(), areas.end()); + double maxArea = areas.empty() ? 0.0 : + *std::max_element(areas.begin(), areas.end()); + double meanArea = areas.empty() ? 0.0 : totalArea / areas.size(); + double medianArea = 0.0; + if (!areas.empty()) { + std::vector sortedAreas(areas); + std::nth_element(sortedAreas.begin(), + sortedAreas.begin() + sortedAreas.size() / 2, + sortedAreas.end()); + medianArea = sortedAreas[sortedAreas.size() / 2]; + } + std::size_t boundaryEdges = 0; // shared by 1 triangle + std::size_t manifoldEdges = 0; // shared by 2 + std::size_t nonManifoldEdges = 0; // shared by 3+ + for (const auto& [_k, count] : edgeUses) { + if (count == 1) ++boundaryEdges; + else if (count == 2) ++manifoldEdges; + else ++nonManifoldEdges; + } + bool watertight = runEdgeAnalysis && boundaryEdges == 0 && + nonManifoldEdges == 0; + glm::vec3 dim = wom.boundMax - wom.boundMin; + double bboxVol = double(dim.x) * dim.y * dim.z; + if (jsonOut) { + nlohmann::json j; + j["base"] = base; + j["triangles"] = triCount; + j["surfaceArea"] = totalArea; + j["bboxVolume"] = bboxVol; + j["areas"] = {{"min", minArea}, {"max", maxArea}, + {"mean", meanArea}, {"median", medianArea}}; + j["degenerateTriangles"] = degenerate; + if (runEdgeAnalysis) { + j["edges"] = {{"total", edgeUses.size()}, + {"boundary", boundaryEdges}, + {"manifold", manifoldEdges}, + {"nonManifold", nonManifoldEdges}}; + j["watertight"] = watertight; + } + std::printf("%s\n", j.dump(2).c_str()); + return 0; + } + std::printf("Mesh stats: %s.wom\n", base.c_str()); + std::printf(" triangles : %zu (%zu degenerate)\n", + triCount, degenerate); + std::printf(" surface area : %.4f\n", totalArea); + std::printf(" bbox volume : %.4f (%.3f x %.3f x %.3f)\n", + bboxVol, dim.x, dim.y, dim.z); + std::printf(" triangle area : min %.6f / max %.6f / mean %.6f / median %.6f\n", + minArea, maxArea, meanArea, medianArea); + if (runEdgeAnalysis) { + std::printf(" edges : %zu total\n", edgeUses.size()); + std::printf(" boundary : %zu (open seams)\n", boundaryEdges); + std::printf(" manifold : %zu (shared by 2 tris)\n", manifoldEdges); + std::printf(" non-manifold : %zu (shared by 3+ tris)\n", + nonManifoldEdges); + std::printf(" watertight : %s\n", watertight ? "YES" : "NO"); + } else { + std::printf(" edges : (skipped, too many triangles)\n"); + } + return 0; +} + int handleInfoMeshStorageBudget(int& i, int argc, char** argv) { // Estimated bytes-per-category breakdown for a WOM. // Numbers are based on the in-memory struct sizes, not @@ -586,6 +728,9 @@ bool handleMeshInfo(int& i, int argc, char** argv, int& outRc) { if (std::strcmp(argv[i], "--info-mesh-storage-budget") == 0 && i + 1 < argc) { outRc = handleInfoMeshStorageBudget(i, argc, argv); return true; } + if (std::strcmp(argv[i], "--info-mesh-stats") == 0 && i + 1 < argc) { + outRc = handleInfoMeshStats(i, argc, argv); return true; + } if (std::strcmp(argv[i], "--info-project-models-total") == 0 && i + 1 < argc) { outRc = handleInfoProjectModelsTotal(i, argc, argv); return true; }