feat(editor): add --bake-project-obj for whole-project terrain export

Project-level OBJ bake — combines every zone's terrain into one
giant OBJ with one 'g zone_NAME' block per zone. Useful for
previewing an entire multi-zone project's terrain in MeshLab/
Blender at once, or for printing the full map:

  wowee_editor --bake-project-obj custom_zones

  Baked custom_zones -> custom_zones/project.obj
    2 zone(s), 3 tiles, 62208 verts, 98304 tris

Layout: single global vertex pool (so OBJ indexing stays valid),
per-zone face groups so designers can hide individual zones in
their viewer for area-by-area inspection. Hole bits respected.
Coords match WoweeCollisionBuilder's outer-grid layout exactly so
zones spatially line up at WoW grid boundaries — adjacent tiles
across zones connect seamlessly.

Pairs with the existing --bake-zone-* family (single zone) and
--export-project-html (web index of per-zone viewers). Three
levels of granularity now available:
  --export-glb / --export-obj / --export-stl     single model/file
  --bake-zone-glb / -obj / -stl                  single zone
  --bake-project-obj                             entire project  <- new

Verified: 2-zone project (Forest 2 tiles + Desert 1 tile) baked
to project.obj with 62208 verts (3 × 20736), 98304 tris (3 ×
32768), 2 'g' blocks correctly named (zone_Desert, zone_Forest).
This commit is contained in:
Kelsi 2026-05-06 15:59:51 -07:00
parent b628535a91
commit 54c309a779

View file

@ -592,6 +592,8 @@ static void printUsage(const char* argv0) {
std::printf(" Bake every WHM tile in a zone into one STL for 3D-printing the terrain\n");
std::printf(" --bake-zone-obj <zoneDir> [out.obj]\n");
std::printf(" Bake every WHM tile in a zone into one Wavefront OBJ (one g-block per tile)\n");
std::printf(" --bake-project-obj <projectDir> [out.obj]\n");
std::printf(" Bake every zone in a project into one Wavefront OBJ (one g-block per zone)\n");
std::printf(" --import-obj <obj-path> [wom-base]\n");
std::printf(" Convert a Wavefront OBJ back into WOM (round-trips with --export-obj)\n");
std::printf(" --export-wob-obj <wob-base> [out.obj]\n");
@ -797,6 +799,7 @@ int main(int argc, char* argv[]) {
"--export-glb", "--export-wob-glb", "--export-whm-glb",
"--export-stl", "--import-stl",
"--bake-zone-glb", "--bake-zone-stl", "--bake-zone-obj",
"--bake-project-obj",
"--convert-m2", "--convert-wmo",
"--convert-dbc-json", "--convert-json-dbc", "--convert-blp-png",
"--migrate-wom", "--migrate-zone", "--migrate-jsondbc",
@ -7636,6 +7639,133 @@ int main(int argc, char* argv[]) {
loadedTiles, totalVerts,
static_cast<unsigned long long>(totalFaces));
return 0;
} else if (std::strcmp(argv[i], "--bake-project-obj") == 0 && i + 1 < argc) {
// Project-level OBJ bake: every zone in <projectDir> gets
// emitted into one giant OBJ with one 'g zone_NAME' block
// per zone. Useful for previewing an entire project's terrain
// in MeshLab/Blender at once, or for printing the whole map.
std::string projectDir = argv[++i];
std::string outPath;
if (i + 1 < argc && argv[i + 1][0] != '-') outPath = argv[++i];
namespace fs = std::filesystem;
if (!fs::exists(projectDir) || !fs::is_directory(projectDir)) {
std::fprintf(stderr,
"bake-project-obj: %s is not a directory\n",
projectDir.c_str());
return 1;
}
if (outPath.empty()) outPath = projectDir + "/project.obj";
std::vector<std::string> zoneDirs;
for (const auto& entry : fs::directory_iterator(projectDir)) {
if (!entry.is_directory()) continue;
if (!fs::exists(entry.path() / "zone.json")) continue;
zoneDirs.push_back(entry.path().string());
}
std::sort(zoneDirs.begin(), zoneDirs.end());
if (zoneDirs.empty()) {
std::fprintf(stderr,
"bake-project-obj: no zones found in %s\n",
projectDir.c_str());
return 1;
}
std::ofstream out(outPath);
if (!out) {
std::fprintf(stderr,
"bake-project-obj: cannot write %s\n", outPath.c_str());
return 1;
}
constexpr float kTileSize = 533.33333f;
constexpr float kChunkSize = kTileSize / 16.0f;
constexpr float kVertSpacing = kChunkSize / 8.0f;
out << "# Wavefront OBJ generated by wowee_editor --bake-project-obj\n";
out << "# Project: " << projectDir << " (" << zoneDirs.size() << " zones)\n";
// Single global vertex pool. Per-zone we accumulate verts then
// emit faces; same shape as --bake-zone-obj.
int totalZones = 0, totalTiles = 0;
int totalVerts = 0;
uint64_t totalFaces = 0;
struct Pending {
std::string zoneName;
uint32_t vertBase; // 1-based OBJ index
std::vector<uint32_t> faceI0, faceI1, faceI2;
};
std::vector<Pending> queues;
for (const auto& zoneDir : zoneDirs) {
wowee::editor::ZoneManifest zm;
if (!zm.load(zoneDir + "/zone.json")) continue;
Pending pq;
pq.zoneName = zm.mapName;
pq.vertBase = static_cast<uint32_t>(totalVerts + 1);
int zoneTiles = 0;
uint32_t zoneLocalIdx = 0;
for (const auto& [tx, ty] : zm.tiles) {
std::string tileBase = zoneDir + "/" + zm.mapName + "_" +
std::to_string(tx) + "_" +
std::to_string(ty);
if (!wowee::pipeline::WoweeTerrainLoader::exists(tileBase)) continue;
wowee::pipeline::ADTTerrain terrain;
wowee::pipeline::WoweeTerrainLoader::load(tileBase, terrain);
zoneTiles++;
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;
float chunkBaseX = (32.0f - terrain.coord.y) * kTileSize - cy * kChunkSize;
float chunkBaseY = (32.0f - terrain.coord.x) * kTileSize - cx * kChunkSize;
uint32_t chunkBaseLocal = zoneLocalIdx;
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];
out << "v " << x << " " << y << " " << z << "\n";
zoneLocalIdx++;
}
}
bool isHoleChunk = (chunk.holes != 0);
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;
}
auto idx = [&](int r, int c) {
return chunkBaseLocal + r * 9 + c;
};
pq.faceI0.push_back(idx(row, col));
pq.faceI1.push_back(idx(row, col + 1));
pq.faceI2.push_back(idx(row + 1, col + 1));
pq.faceI0.push_back(idx(row, col));
pq.faceI1.push_back(idx(row + 1, col + 1));
pq.faceI2.push_back(idx(row + 1, col));
}
}
}
}
}
if (zoneLocalIdx == 0) continue;
totalVerts += zoneLocalIdx;
totalTiles += zoneTiles;
totalZones++;
queues.push_back(std::move(pq));
}
// After all verts written, emit faces grouped by zone.
for (const auto& pq : queues) {
out << "g zone_" << pq.zoneName << "\n";
for (size_t k = 0; k < pq.faceI0.size(); ++k) {
out << "f " << (pq.faceI0[k] + pq.vertBase) << " "
<< (pq.faceI1[k] + pq.vertBase) << " "
<< (pq.faceI2[k] + pq.vertBase) << "\n";
totalFaces++;
}
}
out.close();
std::printf("Baked %s -> %s\n", projectDir.c_str(), outPath.c_str());
std::printf(" %d zone(s), %d tiles, %d verts, %llu tris\n",
totalZones, totalTiles, totalVerts,
static_cast<unsigned long long>(totalFaces));
return 0;
} else if (std::strcmp(argv[i], "--export-wob-obj") == 0 && i + 1 < argc) {
// WOB is the WMO replacement; like --export-obj for WOM, this
// bridges WOB into the universal-3D-tool ecosystem. Each WOB