feat(editor): --info-wob-stats + fix weld hash collision bug

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 <eps> 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).
This commit is contained in:
Kelsi 2026-05-09 10:57:22 -07:00
parent a7989cc7ab
commit 4112b6d257
4 changed files with 208 additions and 12 deletions

View file

@ -11,8 +11,10 @@
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <map>
#include <string>
#include <system_error>
#include <tuple>
#include <unordered_map>
#include <vector>
@ -383,20 +385,21 @@ int handleInfoMeshStats(int& i, int argc, char** argv) {
std::vector<uint32_t> 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<uint64_t, uint32_t> bucket;
bucket.reserve(wom.vertices.size());
auto posKey = [&](const glm::vec3& p) -> uint64_t {
int64_t qx = static_cast<int64_t>(std::lround(p.x * invEps));
int64_t qy = static_cast<int64_t>(std::lround(p.y * invEps));
int64_t qz = static_cast<int64_t>(std::lround(p.z * invEps));
uint64_t h = static_cast<uint64_t>(qx) * 0x9E3779B185EBCA87ULL;
h ^= static_cast<uint64_t>(qy) * 0xC2B2AE3D27D4EB4FULL;
h ^= static_cast<uint64_t>(qz) * 0x165667B19E3779F9ULL;
return h;
using QKey = std::tuple<int64_t, int64_t, int64_t>;
std::map<QKey, uint32_t> bucket;
auto qkey = [&](const glm::vec3& p) -> QKey {
return {static_cast<int64_t>(std::lround(p.x * invEps)),
static_cast<int64_t>(std::lround(p.y * invEps)),
static_cast<int64_t>(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<uint32_t>(v));