From 3cf3b35885fbbd6f6707c4bc611cadf665580a16 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 9 May 2026 11:15:31 -0700 Subject: [PATCH] refactor(editor): extract edge classification into cli_weld MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "loop over triangles, key edges by canonical-vertex pair, count uses, classify boundary/manifold/non-manifold" pass was duplicated across cli_mesh_info, cli_world_info, and the new cli_audits watertight check. Hoist it into cli_weld as classifyEdges(indices, canon) returning an EdgeStats struct with boundary / manifold / nonManifold counters and a watertight() convenience method. All three callers verified byte-identical: • --info-mesh-stats firepit: 180 edges, watertight YES • --info-wob-stats cube: 18 manifold, watertight YES • --audit-watertight /tmp/...: 61 meshes, 12 failures, rc=12 About 60 more lines of duplication removed; classifyEdges + buildWeldMap together form the complete reusable surface for new weld/topology audit commands. --- tools/editor/cli_audits.cpp | 37 ++++----------------- tools/editor/cli_mesh_info.cpp | 58 ++++++++++----------------------- tools/editor/cli_weld.cpp | 33 +++++++++++++++++++ tools/editor/cli_weld.hpp | 31 +++++++++++++++--- tools/editor/cli_world_info.cpp | 22 ++++--------- 5 files changed, 90 insertions(+), 91 deletions(-) diff --git a/tools/editor/cli_audits.cpp b/tools/editor/cli_audits.cpp index 7866c89d..74108499 100644 --- a/tools/editor/cli_audits.cpp +++ b/tools/editor/cli_audits.cpp @@ -322,10 +322,9 @@ int handleValidateZonePack(int& i, int argc, char** argv) { } // Watertight check on a single WOM after the standard weld pass. -// Returns true if the welded mesh has zero boundary edges and zero -// non-manifold edges. Stats are written through outBoundary / -// outNonManifold for the per-zone audit's summary line. eps is the -// caller-supplied weld tolerance. +// Returns true if every welded edge is shared by exactly 2 triangles. +// Per-mesh stats are written through outBoundary / outNonManifold +// for the audit's summary line. bool isWomWatertightAfterWeld( const std::string& womBase, float eps, std::size_t& outTris, std::size_t& outBoundary, @@ -341,32 +340,10 @@ bool isWomWatertightAfterWeld( for (const auto& v : wom.vertices) positions.push_back(v.position); std::size_t uniq = 0; auto canon = buildWeldMap(positions, eps, uniq); - 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); - }; - std::unordered_map edgeUses; - edgeUses.reserve(outTris * 3); - for (std::size_t t = 0; t < outTris; ++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()) { - return false; - } - 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)]; - } - outBoundary = 0; - outNonManifold = 0; - for (const auto& [_k, count] : edgeUses) { - if (count == 1) ++outBoundary; - else if (count >= 3) ++outNonManifold; - } - return outBoundary == 0 && outNonManifold == 0; + EdgeStats edges = classifyEdges(wom.indices, canon); + outBoundary = edges.boundary; + outNonManifold = edges.nonManifold; + return edges.watertight(); } int handleAuditWatertight(int& i, int argc, char** argv) { diff --git a/tools/editor/cli_mesh_info.cpp b/tools/editor/cli_mesh_info.cpp index 464e62b9..f6ea614c 100644 --- a/tools/editor/cli_mesh_info.cpp +++ b/tools/editor/cli_mesh_info.cpp @@ -395,17 +395,7 @@ int handleInfoMeshStats(int& i, int argc, char** argv) { } uniquePositions = wom.vertices.size(); } - // Edge-use counter: key is (lo<<32 | hi) of the two canonical - // 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); - }; + // First pass: triangle areas + range checks (no edge work). 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]; @@ -420,21 +410,10 @@ int handleInfoMeshStats(int& i, int argc, char** argv) { 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)); + double area = 0.5 * glm::length(glm::cross(b - a, c - a)); if (area < 1e-12) ++degenerate; areas.push_back(area); totalArea += area; - if (runEdgeAnalysis) { - uint32_t c0 = canon[i0], c1 = canon[i1], c2 = canon[i2]; - // Skip degenerate edges where the two endpoints map to - // the same canonical vertex — they aren't real edges - // after welding. - if (c0 != c1) ++edgeUses[edgeKey(c0, c1)]; - if (c1 != c2) ++edgeUses[edgeKey(c1, c2)]; - if (c2 != c0) ++edgeUses[edgeKey(c2, c0)]; - } } double minArea = areas.empty() ? 0.0 : *std::min_element(areas.begin(), areas.end()); @@ -449,16 +428,15 @@ int handleInfoMeshStats(int& i, int argc, char** argv) { 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; + // Edge analysis via shared cli_weld utility. Skipped for huge + // meshes (>2M tris) since the underlying unordered_map would + // balloon. + const bool runEdgeAnalysis = (triCount <= 2'000'000); + EdgeStats edges; + if (runEdgeAnalysis) { + edges = classifyEdges(wom.indices, canon); } - bool watertight = runEdgeAnalysis && boundaryEdges == 0 && - nonManifoldEdges == 0; + bool watertight = runEdgeAnalysis && edges.watertight(); glm::vec3 dim = wom.boundMax - wom.boundMin; double bboxVol = double(dim.x) * dim.y * dim.z; if (jsonOut) { @@ -477,10 +455,10 @@ int handleInfoMeshStats(int& i, int argc, char** argv) { j["totalVertices"] = wom.vertices.size(); } if (runEdgeAnalysis) { - j["edges"] = {{"total", edgeUses.size()}, - {"boundary", boundaryEdges}, - {"manifold", manifoldEdges}, - {"nonManifold", nonManifoldEdges}}; + j["edges"] = {{"total", edges.total}, + {"boundary", edges.boundary}, + {"manifold", edges.manifold}, + {"nonManifold", edges.nonManifold}}; j["watertight"] = watertight; } std::printf("%s\n", j.dump(2).c_str()); @@ -500,11 +478,11 @@ int handleInfoMeshStats(int& i, int argc, char** argv) { uniquePositions, wom.vertices.size(), weldEps); } 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(" edges : %zu total\n", edges.total); + std::printf(" boundary : %zu (open seams)\n", edges.boundary); + std::printf(" manifold : %zu (shared by 2 tris)\n", edges.manifold); std::printf(" non-manifold : %zu (shared by 3+ tris)\n", - nonManifoldEdges); + edges.nonManifold); std::printf(" watertight : %s%s\n", watertight ? "YES" : "NO", useWeld ? " (after weld)" : ""); } else { diff --git a/tools/editor/cli_weld.cpp b/tools/editor/cli_weld.cpp index aea6911e..6a934ce9 100644 --- a/tools/editor/cli_weld.cpp +++ b/tools/editor/cli_weld.cpp @@ -4,6 +4,7 @@ #include #include #include +#include namespace wowee { namespace editor { @@ -34,6 +35,38 @@ std::vector buildWeldMap( return canon; } +EdgeStats classifyEdges(const std::vector& indices, + const std::vector& canon) { + EdgeStats stats; + if (indices.size() % 3 != 0) return stats; + 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); + }; + std::unordered_map edgeUses; + edgeUses.reserve(indices.size()); + for (std::size_t t = 0; t + 2 < indices.size(); t += 3) { + uint32_t i0 = indices[t + 0]; + uint32_t i1 = indices[t + 1]; + uint32_t i2 = indices[t + 2]; + if (i0 >= canon.size() || i1 >= canon.size() || + i2 >= canon.size()) { + continue; + } + 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)]; + } + stats.total = edgeUses.size(); + for (const auto& [_k, count] : edgeUses) { + if (count == 1) ++stats.boundary; + else if (count == 2) ++stats.manifold; + else ++stats.nonManifold; + } + return stats; +} + } // namespace cli } // namespace editor } // namespace wowee diff --git a/tools/editor/cli_weld.hpp b/tools/editor/cli_weld.hpp index a53c1ed4..075492a1 100644 --- a/tools/editor/cli_weld.hpp +++ b/tools/editor/cli_weld.hpp @@ -10,11 +10,12 @@ namespace editor { namespace cli { // Vertex weld pass shared by --info-mesh-stats / --info-wob-stats / -// --bake-wom-collision. Positions are quantized onto a 1/eps grid; -// every vertex sharing a cell with a previously-seen vertex is -// remapped to that vertex's index. Returns canon[v] giving the -// canonical (lowest-index) representative of v's equivalence class -// and writes the count of distinct cells to `uniqueOut`. +// --bake-wom-collision / --audit-watertight. Positions are quantized +// onto a 1/eps grid; every vertex sharing a cell with a previously- +// seen vertex is remapped to that vertex's index. Returns canon[v] +// giving the canonical (lowest-index) representative of v's +// equivalence class and writes the count of distinct cells to +// `uniqueOut`. // // Implementation uses std::map, uint32_t> // for exact equality on the quantized key — a hash-based key would @@ -25,6 +26,26 @@ std::vector buildWeldMap( float eps, std::size_t& uniqueOut); +// Edge classification result from walking a triangle list with a +// canon[] map (typically built by buildWeldMap above, but the +// identity mapping also works for "as-authored" edge counts). +struct EdgeStats { + std::size_t total = 0; // distinct edges seen + std::size_t boundary = 0; // shared by exactly 1 triangle (open seam) + std::size_t manifold = 0; // shared by exactly 2 (closed surface) + std::size_t nonManifold = 0; // shared by 3+ (branching surface) + bool watertight() const { + return boundary == 0 && nonManifold == 0; + } +}; + +// Walk every triangle in `indices` (must be a multiple of 3), +// remap each corner through canon[], and count edge uses. An +// edge whose two canonical endpoints are equal is dropped (it +// became a self-loop after welding and isn't a real edge). +EdgeStats classifyEdges(const std::vector& indices, + const std::vector& canon); + } // namespace cli } // namespace editor } // namespace wowee diff --git a/tools/editor/cli_world_info.cpp b/tools/editor/cli_world_info.cpp index 182ffcff..ef097f9f 100644 --- a/tools/editor/cli_world_info.cpp +++ b/tools/editor/cli_world_info.cpp @@ -95,10 +95,6 @@ int handleInfoWobStats(int& i, int argc, char** argv) { 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; @@ -140,8 +136,7 @@ int handleInfoWobStats(int& i, int argc, char** argv) { } gs.uniquePositions = g.vertices.size(); } - std::unordered_map edgeUses; - edgeUses.reserve(gs.tris * 3); + // Triangle area pass (also catches out-of-range indices). 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]; @@ -160,17 +155,12 @@ int handleInfoWobStats(int& i, int argc, char** argv) { 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); + EdgeStats edges = classifyEdges(g.indices, canon); + gs.boundary = edges.boundary; + gs.manifold = edges.manifold; + gs.nonManifold = edges.nonManifold; + gs.watertight = edges.watertight(); aggBoundary += gs.boundary; aggManifold += gs.manifold; aggNonManifold += gs.nonManifold;