feat(editor): add --export-whm-obj for terrain heightmap visualization

Completes the open-format -> universal-text bridge for the last
binary geometry format. WHM was the missing one; designers now have
OBJ exports for all four (WOM models, WOB buildings, WOC collision,
WHM terrain).

  wowee_editor --export-whm-obj custom_zones/MyZone/MyZone_30_30

Mesh layout:
- 9x9 outer vertex grid per chunk (skips the 8x8 inner verts the
  engine uses for 4-tri fans). That's 81 verts and 128 tris per
  chunk; full ADT = 20736 verts + 32768 tris.
- One OBJ 'g chunk_X_Y' per MapChunk so designers can hide chunks
  individually in Blender (e.g. to inspect a single problem area).
- Hole bits respected — cave-entrance quads correctly disappear.
- Coords match WoweeCollisionBuilder's outer-grid layout exactly,
  so an --export-whm-obj and --export-woc-obj of the same source
  align spatially when overlaid in Blender. (Verified: first vertex
  of both is (1066.67, 1066.67, ~98.5) for a tile (30, 30) export.)
- UVs are simply row/8, col/8 in [0,1] per chunk so a checker
  texture renders at the canonical scale for size reference.

Verified: scaffolded zone -> WHM/WOT auto-built -> --export-whm-obj
produces 256 chunks loaded, 20736 verts, 32768 faces, 256 'g'
blocks. Counts exactly match the chunk × outer-grid math.
This commit is contained in:
Kelsi 2026-05-06 12:27:46 -07:00
parent 0f05759027
commit 92ea41f1ae

View file

@ -441,6 +441,8 @@ static void printUsage(const char* argv0) {
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(" --export-whm-obj <wot-base> [out.obj]\n");
std::printf(" Convert a WHM heightmap to OBJ terrain mesh (9x9 outer grid per chunk)\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");
@ -523,7 +525,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",
"--export-woc-obj", "--export-whm-obj",
"--convert-m2", "--convert-wmo",
};
for (int i = 1; i < argc; i++) {
@ -2693,6 +2695,115 @@ int main(int argc, char* argv[]) {
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], "--export-whm-obj") == 0 && i + 1 < argc) {
// Convert a WHM/WOT terrain pair to OBJ for visualization in
// Blender / MeshLab. Emits the 9x9 outer vertex grid per
// chunk (skipping the 8x8 inner verts the engine uses for
// 4-tri fans) — that's the canonical 'heightmap as mesh'
// view, 256 chunks × 81 verts = 20736 verts, 32768 tris.
// Geometry mirrors WoweeCollisionBuilder's outer-grid layout
// exactly so the OBJ aligns with the corresponding WOC.
std::string base = argv[++i];
std::string outPath;
if (i + 1 < argc && argv[i + 1][0] != '-') {
outPath = argv[++i];
}
for (const char* ext : {".wot", ".whm"}) {
if (base.size() >= 4 && base.substr(base.size() - 4) == ext) {
base = base.substr(0, base.size() - 4);
break;
}
}
if (!wowee::pipeline::WoweeTerrainLoader::exists(base)) {
std::fprintf(stderr, "WHM/WOT not found: %s.{whm,wot}\n", base.c_str());
return 1;
}
if (outPath.empty()) outPath = base + ".obj";
wowee::pipeline::ADTTerrain terrain;
wowee::pipeline::WoweeTerrainLoader::load(base, terrain);
std::ofstream obj(outPath);
if (!obj) {
std::fprintf(stderr, "Failed to open output file: %s\n", outPath.c_str());
return 1;
}
// Tile + chunk constants — must match WoweeCollisionBuilder so
// exports of the same source align in space when overlaid.
constexpr float kTileSize = 533.33333f;
constexpr float kChunkSize = kTileSize / 16.0f;
constexpr float kVertSpacing = kChunkSize / 8.0f;
obj << "# Wavefront OBJ generated by wowee_editor --export-whm-obj\n";
obj << "# Source: " << base << ".whm\n";
obj << "# Tile coord: (" << terrain.coord.x << ", " << terrain.coord.y << ")\n";
obj << "# Layout: 9x9 outer vertex grid per chunk, 8x8 quads -> 2 tris each\n\n";
obj << "o WoweeTerrain_" << terrain.coord.x << "_" << terrain.coord.y << "\n";
int loadedChunks = 0;
uint32_t vertOffset = 0;
for (int cx = 0; cx < 16; ++cx) {
for (int cy = 0; cy < 16; ++cy) {
const auto& chunk = terrain.getChunk(cx, cy);
if (!chunk.heightMap.isLoaded()) continue;
loadedChunks++;
// Same XY origin formula as collision builder so
// overlaid OBJ exports line up exactly.
float chunkBaseX = (32.0f - terrain.coord.y) * kTileSize - cy * kChunkSize;
float chunkBaseY = (32.0f - terrain.coord.x) * kTileSize - cx * kChunkSize;
// Emit 9x9 outer verts. Layout: heights[row*17 + col]
// for col in [0,8] (the inner 8 verts at col 9..16
// are skipped — they're the quad-center verts).
for (int row = 0; row < 9; ++row) {
for (int col = 0; col < 9; ++col) {
float x = chunkBaseX - row * kVertSpacing;
float y = chunkBaseY - col * kVertSpacing;
float z = chunk.position[2] +
chunk.heightMap.heights[row * 17 + col];
obj << "v " << x << " " << y << " " << z << "\n";
}
}
// Per-vertex UV: just the row/col in 0..1 — Blender
// can use this to slap a checker texture for scale.
for (int row = 0; row < 9; ++row) {
for (int col = 0; col < 9; ++col) {
obj << "vt " << (col / 8.0f) << " "
<< (row / 8.0f) << "\n";
}
}
// 8x8 quads — two tris each, respecting hole bits so
// cave-entrance quads correctly disappear from the mesh.
bool isHoleChunk = (chunk.holes != 0);
obj << "g chunk_" << cx << "_" << cy << "\n";
auto idx = [&](int r, int c) {
return vertOffset + r * 9 + c + 1; // 1-based
};
for (int row = 0; row < 8; ++row) {
for (int col = 0; col < 8; ++col) {
if (isHoleChunk) {
int hx = col / 2, hy = row / 2;
if (chunk.holes & (1 << (hy * 4 + hx))) continue;
}
uint32_t i00 = idx(row, col);
uint32_t i10 = idx(row, col + 1);
uint32_t i01 = idx(row + 1, col);
uint32_t i11 = idx(row + 1, col + 1);
obj << "f " << i00 << "/" << i00 << " "
<< i10 << "/" << i10 << " "
<< i11 << "/" << i11 << "\n";
obj << "f " << i00 << "/" << i00 << " "
<< i11 << "/" << i11 << " "
<< i01 << "/" << i01 << "\n";
}
}
vertOffset += 81; // 9x9 verts per chunk
}
}
obj.close();
// Estimated tri count: chunks × 128 (8x8 quads × 2 tris).
// Holes reduce this but counting exactly would mean walking
// the bitmask again — the rough estimate is the user-visible
// useful number anyway.
std::printf("Exported %s.whm -> %s\n", base.c_str(), outPath.c_str());
std::printf(" %d chunks loaded, ~%d verts, ~%d tris\n",
loadedChunks, loadedChunks * 81, loadedChunks * 128);
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,