feat(editor): add --export-woc-obj for collision-mesh visualization

WOC is the open collision format used for movement queries. When the
player gets stuck or walks somewhere they shouldn't, you need to SEE
the mesh — eyeballing flag bits in --info-woc only goes so far.

  wowee_editor --export-woc-obj custom_zones/Z/Z_30_30.woc

Each flag class gets its own OBJ 'g' block so designers can hide
categories independently in Blender to debug:
  - walkable / steep / water / indoor / nonwalkable
  - and combinations like 'walkable_water' for shallow swimable areas

Implementation notes:
- Triangle-soup topology preserved (no vertex dedupe). Adjacent
  triangles often have different flags; deduping would merge
  categories and lose the boundary.
- Bucket by exact flag value (uint8) so combination flags become
  their own group rather than counting twice.
- Index math: triangle t vertex k -> OBJ index (t*3 + k + 1).
- Header comment preserves source path, triangle counts, walkable/
  steep counts, and tile (x, y) for provenance.

Verified on a freshly-scaffolded zone's auto-built WOC (32768
triangles, all walkable on flat terrain): export reports
'1 flag class(es)', single 'g walkable' block in the OBJ, last
face index correctly = 32768*3 = 98304.
This commit is contained in:
Kelsi 2026-05-06 12:22:31 -07:00
parent a9789b0154
commit a20e795ddb

View file

@ -437,6 +437,8 @@ static void printUsage(const char* argv0) {
std::printf(" Convert a WOB building to Wavefront OBJ (one group per WOB group)\n");
std::printf(" --import-wob-obj <obj-path> [wob-base]\n");
std::printf(" Convert a Wavefront OBJ back into WOB (round-trips with --export-wob-obj)\n");
std::printf(" --export-woc-obj <woc-path> [out.obj]\n");
std::printf(" Convert a WOC collision mesh to OBJ for visualization (per-flag color groups)\n");
std::printf(" --validate <zoneDir> [--json]\n");
std::printf(" Score zone open-format completeness and exit\n");
std::printf(" --validate-wom <wom-base> [--json]\n");
@ -519,6 +521,7 @@ int main(int argc, char* argv[]) {
"--build-woc", "--regen-collision", "--fix-zone",
"--export-png", "--export-obj", "--import-obj",
"--export-wob-obj", "--import-wob-obj",
"--export-woc-obj",
"--convert-m2", "--convert-wmo",
};
for (int i = 1; i < argc; i++) {
@ -2601,6 +2604,88 @@ int main(int argc, char* argv[]) {
std::printf(" warning: skipped %d malformed face(s)\n", badFaces);
}
return 0;
} else if (std::strcmp(argv[i], "--export-woc-obj") == 0 && i + 1 < argc) {
// Visualize a WOC collision mesh in any 3D tool. Each
// walkability class becomes its own OBJ group (walkable /
// steep / water / indoor) so designers can hide categories
// independently in Blender to debug 'why can the player
// walk here?' or 'why can't they walk there?'.
std::string path = argv[++i];
std::string outPath;
if (i + 1 < argc && argv[i + 1][0] != '-') {
outPath = argv[++i];
}
if (!std::filesystem::exists(path)) {
std::fprintf(stderr, "WOC not found: %s\n", path.c_str());
return 1;
}
if (outPath.empty()) {
outPath = path;
if (outPath.size() >= 4 &&
outPath.substr(outPath.size() - 4) == ".woc") {
outPath = outPath.substr(0, outPath.size() - 4);
}
outPath += ".obj";
}
auto woc = wowee::pipeline::WoweeCollisionBuilder::load(path);
if (!woc.isValid()) {
std::fprintf(stderr, "WOC has no triangles: %s\n", path.c_str());
return 1;
}
std::ofstream obj(outPath);
if (!obj) {
std::fprintf(stderr, "Failed to open output file: %s\n", outPath.c_str());
return 1;
}
// Bucket triangles by flag combination so the OBJ can split
// them into named groups. Flag bits: walkable=0x01, water=0x02,
// steep=0x04, indoor=0x08 (per WoweeCollision::Triangle).
// Triangles can have multiple flags set so a per-flag group
// would over-count; instead we bucket by exact flag value.
std::unordered_map<uint8_t, std::vector<size_t>> byFlag;
for (size_t t = 0; t < woc.triangles.size(); ++t) {
byFlag[woc.triangles[t].flags].push_back(t);
}
obj << "# Wavefront OBJ generated by wowee_editor --export-woc-obj\n";
obj << "# Source: " << path << "\n";
obj << "# Triangles: " << woc.triangles.size()
<< " (walkable=" << woc.walkableCount()
<< " steep=" << woc.steepCount() << ")\n";
obj << "# Tile: (" << woc.tileX << ", " << woc.tileY << ")\n\n";
obj << "o WoweeCollision\n";
// Emit ALL vertices first (3 per triangle, no dedupe — the
// collision mesh has triangle-soup topology where shared
// verts often have different flags, so deduping would
// actually merge categories).
for (const auto& tri : woc.triangles) {
obj << "v " << tri.v0.x << " " << tri.v0.y << " " << tri.v0.z << "\n";
obj << "v " << tri.v1.x << " " << tri.v1.y << " " << tri.v1.z << "\n";
obj << "v " << tri.v2.x << " " << tri.v2.y << " " << tri.v2.z << "\n";
}
// Emit faces grouped by flag class. OBJ index of triangle t
// vertex k is (t * 3 + k + 1) — 1-based, three verts per tri.
auto flagName = [](uint8_t f) {
if (f == 0) return std::string("nonwalkable");
std::string s;
if (f & 0x01) s += "walkable";
if (f & 0x02) { if (!s.empty()) s += "_"; s += "water"; }
if (f & 0x04) { if (!s.empty()) s += "_"; s += "steep"; }
if (f & 0x08) { if (!s.empty()) s += "_"; s += "indoor"; }
if (s.empty()) s = "flag" + std::to_string(int(f));
return s;
};
for (const auto& [flag, tris] : byFlag) {
obj << "g " << flagName(flag) << "\n";
for (size_t t : tris) {
uint32_t base = static_cast<uint32_t>(t * 3 + 1);
obj << "f " << base << " " << (base + 1) << " " << (base + 2) << "\n";
}
}
obj.close();
std::printf("Exported %s -> %s\n", path.c_str(), outPath.c_str());
std::printf(" %zu triangles in %zu flag class(es), tile (%u, %u)\n",
woc.triangles.size(), byFlag.size(), woc.tileX, woc.tileY);
return 0;
} else if (std::strcmp(argv[i], "--import-obj") == 0 && i + 1 < argc) {
// Convert a Wavefront OBJ back into WOM. Round-trips with
// --export-obj for the geometry/UV/normal data; bones,