From 26f1947c84765f88fcfbfa7ea61ccd4a2e080879 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 9 May 2026 11:33:10 -0700 Subject: [PATCH] feat(editor): add --audit-watertight-wob building QA tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sibling of --audit-watertight that walks every .wob under and runs the welded watertight check on every group. A WOB passes only if every group is closed — interior rooms in a real building should each be a closed solid even though the building as a whole has intentional portal openings between them. Per-failure detail lists which groups failed and why (boundary edge count + non-manifold edge count). Exit code is the number of failed buildings (capped at 255) — same CI-friendly contract as the WOM audit. Smoke tested against /tmp/migtest cube.wob: PASS, 12 tris, exit code 0. --- tools/editor/cli_arg_required.cpp | 2 +- tools/editor/cli_audits.cpp | 135 ++++++++++++++++++++++++++++++ tools/editor/cli_help.cpp | 2 + 3 files changed, 138 insertions(+), 1 deletion(-) diff --git a/tools/editor/cli_arg_required.cpp b/tools/editor/cli_arg_required.cpp index 0b359ff0..65324153 100644 --- a/tools/editor/cli_arg_required.cpp +++ b/tools/editor/cli_arg_required.cpp @@ -142,7 +142,7 @@ const char* const kArgRequired[] = { "--bake-zone-glb", "--bake-zone-stl", "--bake-zone-obj", "--bake-project-obj", "--bake-project-stl", "--bake-project-glb", "--bake-wom-collision", "--bake-wob-collision", - "--audit-watertight", + "--audit-watertight", "--audit-watertight-wob", "--convert-m2", "--convert-m2-batch", "--convert-wmo", "--convert-wmo-batch", "--convert-dbc-json", "--convert-dbc-batch", "--convert-json-dbc", diff --git a/tools/editor/cli_audits.cpp b/tools/editor/cli_audits.cpp index 74108499..f020ad16 100644 --- a/tools/editor/cli_audits.cpp +++ b/tools/editor/cli_audits.cpp @@ -2,6 +2,7 @@ #include "cli_weld.hpp" #include "pipeline/wowee_model.hpp" +#include "pipeline/wowee_building.hpp" #include #include @@ -433,6 +434,136 @@ int handleAuditWatertight(int& i, int argc, char** argv) { return std::min(failCount, 255); } +// Welded watertight check on a single WOB. Each group is welded +// independently (rooms separated by portals must stay distinct +// collision surfaces). A WOB passes if EVERY group is closed — +// per-group failure breakdowns are returned via the out vectors. +bool isWobWatertightAfterWeld( + const std::string& wobBase, float eps, + std::vector& outFailedGroups, + std::size_t& outTotalTris, + std::size_t& outTotalBoundary, + std::size_t& outTotalNonManifold) { + auto bld = wowee::pipeline::WoweeBuildingLoader::load(wobBase); + outTotalTris = outTotalBoundary = outTotalNonManifold = 0; + if (!bld.isValid()) return false; + bool allOk = true; + for (const auto& g : bld.groups) { + if (g.indices.size() % 3 != 0) { + outFailedGroups.push_back(g.name + " (indices%3 != 0)"); + allOk = false; + continue; + } + outTotalTris += g.indices.size() / 3; + std::vector positions; + positions.reserve(g.vertices.size()); + for (const auto& v : g.vertices) positions.push_back(v.position); + std::size_t uniq = 0; + auto canon = buildWeldMap(positions, eps, uniq); + EdgeStats edges = classifyEdges(g.indices, canon); + outTotalBoundary += edges.boundary; + outTotalNonManifold += edges.nonManifold; + if (!edges.watertight()) { + outFailedGroups.push_back( + g.name + " (" + std::to_string(edges.boundary) + + " boundary, " + std::to_string(edges.nonManifold) + + " non-manifold)"); + allOk = false; + } + } + return allOk; +} + +int handleAuditWatertightWob(int& i, int argc, char** argv) { + // Walk every .wob under and run the welded-watertight + // check on every group. PASS only if all groups in all WOBs + // are closed — a real building's interior groups should each + // be a closed surface even though the building as a whole has + // intentional portal openings between them. + std::string root = argv[++i]; + bool jsonOut = false; + float weldEps = 1e-4f; + 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) { + try { weldEps = std::stof(argv[i + 2]); } catch (...) {} + i += 2; + } else { + break; + } + } + namespace fs = std::filesystem; + if (!fs::exists(root) || !fs::is_directory(root)) { + std::fprintf(stderr, + "audit-watertight-wob: %s is not a directory\n", root.c_str()); + return 1; + } + struct Result { + std::string rel; + std::size_t tris; + std::size_t boundary; + std::size_t nonManifold; + std::vector failedGroups; + bool ok; + }; + std::vector rows; + std::error_code ec; + for (const auto& e : fs::recursive_directory_iterator(root, ec)) { + if (!e.is_regular_file()) continue; + if (e.path().extension() != ".wob") continue; + std::string base = e.path().string(); + base = base.substr(0, base.size() - 4); + Result r; + r.rel = fs::relative(e.path(), root).string(); + r.ok = isWobWatertightAfterWeld(base, weldEps, r.failedGroups, + r.tris, r.boundary, r.nonManifold); + rows.push_back(std::move(r)); + } + std::sort(rows.begin(), rows.end(), [](const Result& a, const Result& b) { + return a.rel < b.rel; + }); + int failCount = 0; + for (const auto& r : rows) if (!r.ok) ++failCount; + if (jsonOut) { + nlohmann::json j; + j["root"] = root; + j["weldEps"] = weldEps; + j["totalBuildings"] = rows.size(); + j["failures"] = failCount; + nlohmann::json items = nlohmann::json::array(); + for (const auto& r : rows) { + items.push_back({{"path", r.rel}, + {"triangles", r.tris}, + {"boundary", r.boundary}, + {"nonManifold", r.nonManifold}, + {"failedGroups", r.failedGroups}, + {"watertight", r.ok}}); + } + j["buildings"] = items; + std::printf("%s\n", j.dump(2).c_str()); + return std::min(failCount, 255); + } + std::printf("Watertight WOB audit: %s (weld eps %.6f)\n", + root.c_str(), weldEps); + if (rows.empty()) { + std::printf(" No .wob files found.\n"); + return 0; + } + for (const auto& r : rows) { + std::printf(" %s %s (%zu tris)\n", + r.ok ? "PASS" : "FAIL", r.rel.c_str(), r.tris); + if (!r.ok) { + for (const auto& fg : r.failedGroups) { + std::printf(" group %s\n", fg.c_str()); + } + } + } + std::printf("\n TOTAL: %zu buildings, %d failure(s)\n", + rows.size(), failCount); + return std::min(failCount, 255); +} + } // namespace bool handleAudits(int& i, int argc, char** argv, int& outRc) { @@ -463,6 +594,10 @@ bool handleAudits(int& i, int argc, char** argv, int& outRc) { outRc = handleAuditWatertight(i, argc, argv); return true; } + if (std::strcmp(argv[i], "--audit-watertight-wob") == 0 && i + 1 < argc) { + outRc = handleAuditWatertightWob(i, argc, argv); + return true; + } return false; } diff --git a/tools/editor/cli_help.cpp b/tools/editor/cli_help.cpp index 0b153e82..176d56c0 100644 --- a/tools/editor/cli_help.cpp +++ b/tools/editor/cli_help.cpp @@ -511,6 +511,8 @@ void printUsage(const char* argv0) { std::printf(" Convert a multi-group WOB building into a single WOC collision file (weld is per-group)\n"); std::printf(" --audit-watertight [--weld ] [--json]\n"); std::printf(" Walk every .wom under root, run welded watertight check; exit code = failure count (CI-friendly)\n"); + std::printf(" --audit-watertight-wob [--weld ] [--json]\n"); + std::printf(" Walk every .wob, check that EVERY group is closed (per-group weld) — interior rooms must be solid\n"); std::printf(" --import-obj [wom-base]\n"); std::printf(" Convert a Wavefront OBJ back into WOM (round-trips with --export-obj)\n"); std::printf(" --export-wob-obj [out.obj]\n");