From 4112b6d2579a492cb1f407a189a4bf487765adcb Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 9 May 2026 10:57:22 -0700 Subject: [PATCH] feat(editor): --info-wob-stats + fix weld hash collision bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add --info-wob-stats reporting per-group + aggregate triangle counts, surface area, edge analysis, and watertight check for WOB buildings. Same flag surface as --info-mesh-stats including --weld for true topological closure check on per-face-vertex meshes. Also fixes a correctness bug in the weld implementation of --info-mesh-stats: the previous code used a 64-bit hash of the quantized position as the equality key, which gave false-positive collisions that incorrectly merged distinct vertices. A unit cube's 8 corners collapsed to 2 positions under the buggy hash. Replace with std::map keyed on the actual quantized (qx, qy, qz) tuple so equality is exact. Re-verified: cube 8→8 watertight YES; firepit 240→80 watertight YES (was wrongly reporting 56 unique with 48 non-manifold edges); tent_solid 18→6 watertight YES; tent_fixed 21→9 with 5 boundary edges at the door perimeter (correct — door is intentionally open). --- tools/editor/cli_arg_required.cpp | 2 +- tools/editor/cli_help.cpp | 2 + tools/editor/cli_mesh_info.cpp | 25 ++-- tools/editor/cli_world_info.cpp | 191 ++++++++++++++++++++++++++++++ 4 files changed, 208 insertions(+), 12 deletions(-) diff --git a/tools/editor/cli_arg_required.cpp b/tools/editor/cli_arg_required.cpp index c30ed641..51b9e70b 100644 --- a/tools/editor/cli_arg_required.cpp +++ b/tools/editor/cli_arg_required.cpp @@ -14,7 +14,7 @@ const char* const kArgRequired[] = { "--info-zone-models-total", "--info-project-models-total", "--list-zone-meshes-detail", "--list-project-meshes-detail", "--info-mesh", "--info-mesh-storage-budget", "--info-mesh-stats", - "--info-wob", "--info-woc", "--info-wot", + "--info-wob", "--info-wob-stats", "--info-woc", "--info-wot", "--info-creatures", "--info-objects", "--info-quests", "--info-extract", "--info-extract-tree", "--info-extract-budget", "--list-missing-sidecars", diff --git a/tools/editor/cli_help.cpp b/tools/editor/cli_help.cpp index 040f483d..eb687174 100644 --- a/tools/editor/cli_help.cpp +++ b/tools/editor/cli_help.cpp @@ -627,6 +627,8 @@ void printUsage(const char* argv0) { std::printf(" Aggregate WOM/WOB stats across an entire project (per-zone breakdown + totals)\n"); std::printf(" --info-wob [--json]\n"); std::printf(" Print WOB building metadata (groups, portals, doodads) and exit\n"); + std::printf(" --info-wob-stats [--weld ] [--json]\n"); + std::printf(" Per-group + aggregate geometric stats (surface area, edges, watertight) for a WOB building\n"); std::printf(" --info-woc [--json]\n"); std::printf(" Print WOC collision metadata (triangle counts, bounds) and exit\n"); std::printf(" --info-wot [--json]\n"); diff --git a/tools/editor/cli_mesh_info.cpp b/tools/editor/cli_mesh_info.cpp index 0a6c8931..5d9656b3 100644 --- a/tools/editor/cli_mesh_info.cpp +++ b/tools/editor/cli_mesh_info.cpp @@ -11,8 +11,10 @@ #include #include #include +#include #include #include +#include #include #include @@ -383,20 +385,21 @@ int handleInfoMeshStats(int& i, int argc, char** argv) { std::vector canon(wom.vertices.size()); std::size_t uniquePositions = 0; if (useWeld) { + // Use the quantized (qx, qy, qz) tuple as the equality key — + // a hash key would risk false-positive collisions that + // incorrectly merge distinct corners (e.g. a cube's 8 corners + // collapsing to 2). std::map gives exact-match equality at + // O(log n) per op which is fast enough for any real mesh. const float invEps = 1.0f / std::max(weldEps, 1e-9f); - std::unordered_map bucket; - bucket.reserve(wom.vertices.size()); - auto posKey = [&](const glm::vec3& p) -> uint64_t { - int64_t qx = static_cast(std::lround(p.x * invEps)); - int64_t qy = static_cast(std::lround(p.y * invEps)); - int64_t qz = static_cast(std::lround(p.z * invEps)); - uint64_t h = static_cast(qx) * 0x9E3779B185EBCA87ULL; - h ^= static_cast(qy) * 0xC2B2AE3D27D4EB4FULL; - h ^= static_cast(qz) * 0x165667B19E3779F9ULL; - return h; + using QKey = std::tuple; + std::map bucket; + auto qkey = [&](const glm::vec3& p) -> QKey { + return {static_cast(std::lround(p.x * invEps)), + static_cast(std::lround(p.y * invEps)), + static_cast(std::lround(p.z * invEps))}; }; for (std::size_t v = 0; v < wom.vertices.size(); ++v) { - uint64_t k = posKey(wom.vertices[v].position); + QKey k = qkey(wom.vertices[v].position); auto it = bucket.find(k); if (it == bucket.end()) { bucket.emplace(k, static_cast(v)); diff --git a/tools/editor/cli_world_info.cpp b/tools/editor/cli_world_info.cpp index d8ba921b..bf4e9203 100644 --- a/tools/editor/cli_world_info.cpp +++ b/tools/editor/cli_world_info.cpp @@ -4,12 +4,19 @@ #include "pipeline/wowee_collision.hpp" #include "pipeline/wowee_terrain_loader.hpp" #include "pipeline/adt_loader.hpp" +#include #include +#include +#include #include #include #include +#include #include +#include +#include +#include namespace wowee { namespace editor { @@ -61,6 +68,187 @@ int handleInfoWob(int& i, int argc, char** argv) { return 0; } +int handleInfoWobStats(int& i, int argc, char** argv) { + // Geometric stats on a WOB building, per-group and aggregated + // across all groups: triangle count, surface area, watertight + // check via the same edge analysis as --info-mesh-stats. Pass + // --weld to merge per-face vertex duplicates before edge + // analysis (true topological closure check). + std::string base = argv[++i]; + bool jsonOut = false; + bool useWeld = false; + float weldEps = 1e-5f; + while (i + 1 < argc && argv[i + 1][0] == '-') { + if (std::strcmp(argv[i + 1], "--json") == 0) { + jsonOut = true; ++i; + } else if (std::strcmp(argv[i + 1], "--weld") == 0 && i + 2 < argc) { + useWeld = true; + try { weldEps = std::stof(argv[i + 2]); } catch (...) {} + i += 2; + } else { + break; + } + } + if (base.size() >= 4 && base.substr(base.size() - 4) == ".wob") + base = base.substr(0, base.size() - 4); + if (!wowee::pipeline::WoweeBuildingLoader::exists(base)) { + std::fprintf(stderr, "WOB not found: %s.wob\n", base.c_str()); + return 1; + } + auto bld = wowee::pipeline::WoweeBuildingLoader::load(base); + 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); + }; + struct GroupStats { + std::string name; + std::size_t tris = 0; + std::size_t degenerate = 0; + std::size_t uniquePositions = 0; + std::size_t totalVerts = 0; + std::size_t boundary = 0, manifold = 0, nonManifold = 0; + bool watertight = false; + double surfaceArea = 0.0; + }; + std::vector perGroup; + perGroup.reserve(bld.groups.size()); + std::size_t aggBoundary = 0, aggManifold = 0, aggNonManifold = 0; + std::size_t aggTris = 0, aggDegenerate = 0; + double aggArea = 0.0; + for (const auto& g : bld.groups) { + GroupStats gs; + gs.name = g.name; + gs.totalVerts = g.vertices.size(); + if (g.indices.size() % 3 != 0) { + std::fprintf(stderr, + "info-wob-stats: group '%s' has indices %% 3 != 0\n", + g.name.c_str()); + return 1; + } + gs.tris = g.indices.size() / 3; + // Build canon[] for this group, optionally welding. + std::vector canon(g.vertices.size()); + if (useWeld) { + // Tuple key (qx,qy,qz) gives exact equality matching; + // a hash key would risk false-positive collisions + // collapsing distinct corners. See cli_mesh_info.cpp + // for the same pattern. + const float invEps = 1.0f / std::max(weldEps, 1e-9f); + using QKey = std::tuple; + std::map bucket; + auto qkey = [&](const glm::vec3& p) -> QKey { + return {static_cast(std::lround(p.x * invEps)), + static_cast(std::lround(p.y * invEps)), + static_cast(std::lround(p.z * invEps))}; + }; + for (std::size_t v = 0; v < g.vertices.size(); ++v) { + QKey k = qkey(g.vertices[v].position); + auto it = bucket.find(k); + if (it == bucket.end()) { + bucket.emplace(k, static_cast(v)); + canon[v] = static_cast(v); + } else { + canon[v] = it->second; + } + } + gs.uniquePositions = bucket.size(); + } else { + for (std::size_t v = 0; v < g.vertices.size(); ++v) { + canon[v] = static_cast(v); + } + gs.uniquePositions = g.vertices.size(); + } + std::unordered_map edgeUses; + edgeUses.reserve(gs.tris * 3); + for (std::size_t t = 0; t < gs.tris; ++t) { + uint32_t i0 = g.indices[t * 3 + 0]; + uint32_t i1 = g.indices[t * 3 + 1]; + uint32_t i2 = g.indices[t * 3 + 2]; + if (i0 >= g.vertices.size() || + i1 >= g.vertices.size() || + i2 >= g.vertices.size()) { + std::fprintf(stderr, + "info-wob-stats: group '%s' has out-of-range index\n", + g.name.c_str()); + return 1; + } + glm::vec3 a = g.vertices[i0].position; + glm::vec3 b = g.vertices[i1].position; + glm::vec3 c = g.vertices[i2].position; + double area = 0.5 * glm::length(glm::cross(b - a, c - a)); + if (area < 1e-12) ++gs.degenerate; + gs.surfaceArea += area; + uint32_t c0 = canon[i0], c1 = canon[i1], c2 = canon[i2]; + if (c0 != c1) ++edgeUses[edgeKey(c0, c1)]; + if (c1 != c2) ++edgeUses[edgeKey(c1, c2)]; + if (c2 != c0) ++edgeUses[edgeKey(c2, c0)]; + } + for (const auto& [_k, count] : edgeUses) { + if (count == 1) ++gs.boundary; + else if (count == 2) ++gs.manifold; + else ++gs.nonManifold; + } + gs.watertight = (gs.boundary == 0 && gs.nonManifold == 0); + aggBoundary += gs.boundary; + aggManifold += gs.manifold; + aggNonManifold += gs.nonManifold; + aggTris += gs.tris; + aggDegenerate += gs.degenerate; + aggArea += gs.surfaceArea; + perGroup.push_back(std::move(gs)); + } + if (jsonOut) { + nlohmann::json j; + j["wob"] = base + ".wob"; + j["welded"] = useWeld; + if (useWeld) j["weldEps"] = weldEps; + j["aggregate"] = {{"groups", perGroup.size()}, + {"triangles", aggTris}, + {"degenerateTriangles", aggDegenerate}, + {"surfaceArea", aggArea}, + {"boundary", aggBoundary}, + {"manifold", aggManifold}, + {"nonManifold", aggNonManifold}}; + nlohmann::json gs = nlohmann::json::array(); + for (const auto& g : perGroup) { + gs.push_back({{"name", g.name}, + {"triangles", g.tris}, + {"degenerate", g.degenerate}, + {"surfaceArea", g.surfaceArea}, + {"uniquePositions", g.uniquePositions}, + {"totalVerts", g.totalVerts}, + {"boundary", g.boundary}, + {"manifold", g.manifold}, + {"nonManifold", g.nonManifold}, + {"watertight", g.watertight}}); + } + j["groups"] = gs; + std::printf("%s\n", j.dump(2).c_str()); + return 0; + } + std::printf("WOB stats: %s.wob\n", base.c_str()); + std::printf(" groups : %zu\n", perGroup.size()); + std::printf(" total tris : %zu (%zu degenerate)\n", + aggTris, aggDegenerate); + std::printf(" total area : %.4f\n", aggArea); + std::printf(" aggregate edges : %zu boundary, %zu manifold, %zu non-manifold\n", + aggBoundary, aggManifold, aggNonManifold); + if (useWeld) { + std::printf(" weld eps : %.6f\n", weldEps); + } + std::printf("\n Per group:\n"); + std::printf(" idx tris area verts→uniq boundary manifold non-m closed\n"); + for (std::size_t k = 0; k < perGroup.size(); ++k) { + const auto& g = perGroup[k]; + std::printf(" %3zu %5zu %8.3f %5zu→%-5zu %8zu %8zu %5zu %s\n", + k, g.tris, g.surfaceArea, + g.totalVerts, g.uniquePositions, + g.boundary, g.manifold, g.nonManifold, + g.watertight ? "YES" : "no"); + } + return 0; +} + int handleInfoWot(int& i, int argc, char** argv) { std::string base = argv[++i]; bool jsonOut = (i + 1 < argc && @@ -173,6 +361,9 @@ bool handleWorldInfo(int& i, int argc, char** argv, int& outRc) { if (std::strcmp(argv[i], "--info-wob") == 0 && i + 1 < argc) { outRc = handleInfoWob(i, argc, argv); return true; } + if (std::strcmp(argv[i], "--info-wob-stats") == 0 && i + 1 < argc) { + outRc = handleInfoWobStats(i, argc, argv); return true; + } if (std::strcmp(argv[i], "--info-wot") == 0 && i + 1 < argc) { outRc = handleInfoWot(i, argc, argv); return true; }