From 92ea41f1aef2c5bce17c870451392308f6debefd Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 6 May 2026 12:27:46 -0700 Subject: [PATCH] feat(editor): add --export-whm-obj for terrain heightmap visualization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tools/editor/main.cpp | 113 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 112 insertions(+), 1 deletion(-) diff --git a/tools/editor/main.cpp b/tools/editor/main.cpp index c67b47f1..24c7bb42 100644 --- a/tools/editor/main.cpp +++ b/tools/editor/main.cpp @@ -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 [out.obj]\n"); std::printf(" Convert a WOC collision mesh to OBJ for visualization (per-flag color groups)\n"); + std::printf(" --export-whm-obj [out.obj]\n"); + std::printf(" Convert a WHM heightmap to OBJ terrain mesh (9x9 outer grid per chunk)\n"); std::printf(" --validate [--json]\n"); std::printf(" Score zone open-format completeness and exit\n"); std::printf(" --validate-wom [--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,