From e128d91d6658deb5eac2bbd74199cd761239e77e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 9 May 2026 07:03:14 -0700 Subject: [PATCH] refactor(editor): extract WOB/WHM/WOC IO into cli_world_io.cpp Moves all six world-asset interchange handlers (--export-wob-glb, --export-wob-obj, --import-wob-obj, --export-whm-glb, --export-whm-obj, --export-woc-obj) out of main.cpp into a new cli_world_io.{hpp,cpp} module. WOB / WHM / WOC are our open replacements for proprietary WMO / ADT-heightmap / ADT-collision data; these are the bridge that lets the open formats round-trip through Blender, MeshLab, Three.js, and the rest of the standard 3D toolchain. main.cpp shrinks by 858 lines (8,997 to 8,140). The single-mesh --import-obj handler stays inline for now -- it shadow-mirrors cli_wom_io's --import-stl semantics and will move there next. --- CMakeLists.txt | 1 + tools/editor/cli_world_io.cpp | 929 ++++++++++++++++++++++++++++++++++ tools/editor/cli_world_io.hpp | 24 + tools/editor/main.cpp | 866 +------------------------------ 4 files changed, 958 insertions(+), 862 deletions(-) create mode 100644 tools/editor/cli_world_io.cpp create mode 100644 tools/editor/cli_world_io.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 855ef14e..44c65d6c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1328,6 +1328,7 @@ add_executable(wowee_editor tools/editor/cli_validate_interop.cpp tools/editor/cli_glb_inspect.cpp tools/editor/cli_wom_io.cpp + tools/editor/cli_world_io.cpp tools/editor/editor_app.cpp tools/editor/editor_camera.cpp tools/editor/editor_viewport.cpp diff --git a/tools/editor/cli_world_io.cpp b/tools/editor/cli_world_io.cpp new file mode 100644 index 00000000..07960b28 --- /dev/null +++ b/tools/editor/cli_world_io.cpp @@ -0,0 +1,929 @@ +#include "cli_world_io.hpp" + +#include "pipeline/wowee_building.hpp" +#include "pipeline/wowee_collision.hpp" +#include "pipeline/wowee_terrain_loader.hpp" +#include "pipeline/adt_loader.hpp" +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace editor { +namespace cli { + +namespace { + +int handleExportWobGlb(int& i, int argc, char** argv) { + // glTF 2.0 binary export for WOB. Same purpose as --export-glb + // for WOM but adapted for buildings: each WOB group becomes + // one primitive in a single mesh, sharing one big vertex + // pool concatenated from per-group vertex arrays. + std::string base = argv[++i]; + std::string outPath; + if (i + 1 < argc && argv[i + 1][0] != '-') { + outPath = argv[++i]; + } + if (base.size() >= 4 && base.substr(base.size() - 4) == ".wob") + base = base.substr(0, base.size() - 4); + if (!wowee::pipeline::WoweeBuildingLoader::exists(base)) { + std::fprintf(stderr, "WOB not found: %s.wob\n", base.c_str()); + return 1; + } + if (outPath.empty()) outPath = base + ".glb"; + auto bld = wowee::pipeline::WoweeBuildingLoader::load(base); + if (!bld.isValid()) { + std::fprintf(stderr, "WOB has no groups: %s.wob\n", base.c_str()); + return 1; + } + // Total counts + per-group offsets needed before allocating + // the BIN buffer. Index buffer is uint32 so groups can each + // index into the global pool by offset. + uint32_t totalV = 0, totalI = 0; + std::vector groupVertOff(bld.groups.size(), 0); + std::vector groupIdxOff(bld.groups.size(), 0); + for (size_t g = 0; g < bld.groups.size(); ++g) { + groupVertOff[g] = totalV; + groupIdxOff[g] = totalI; + totalV += static_cast(bld.groups[g].vertices.size()); + totalI += static_cast(bld.groups[g].indices.size()); + } + if (totalV == 0 || totalI == 0) { + std::fprintf(stderr, "WOB has no vertex data\n"); + return 1; + } + const uint32_t posOff = 0; + const uint32_t nrmOff = posOff + totalV * 12; + const uint32_t uvOff = nrmOff + totalV * 12; + const uint32_t idxOff = uvOff + totalV * 8; + const uint32_t binSize = idxOff + totalI * 4; + std::vector bin(binSize); + // Pack per-group geometry into the global pool. Indices get + // offset by the group's starting vertex index so they + // continue to reference the right vertices in the merged pool. + uint32_t vCursor = 0, iCursor = 0; + glm::vec3 bMin{1e30f}, bMax{-1e30f}; + for (size_t g = 0; g < bld.groups.size(); ++g) { + const auto& grp = bld.groups[g]; + for (const auto& v : grp.vertices) { + std::memcpy(&bin[posOff + vCursor * 12 + 0], &v.position.x, 4); + std::memcpy(&bin[posOff + vCursor * 12 + 4], &v.position.y, 4); + std::memcpy(&bin[posOff + vCursor * 12 + 8], &v.position.z, 4); + std::memcpy(&bin[nrmOff + vCursor * 12 + 0], &v.normal.x, 4); + std::memcpy(&bin[nrmOff + vCursor * 12 + 4], &v.normal.y, 4); + std::memcpy(&bin[nrmOff + vCursor * 12 + 8], &v.normal.z, 4); + std::memcpy(&bin[uvOff + vCursor * 8 + 0], &v.texCoord.x, 4); + std::memcpy(&bin[uvOff + vCursor * 8 + 4], &v.texCoord.y, 4); + bMin = glm::min(bMin, v.position); + bMax = glm::max(bMax, v.position); + vCursor++; + } + // Offset indices by group's vertex base so merged pool + // indexing still works. uint32 indices, written LE. + for (uint32_t idx : grp.indices) { + uint32_t off = idx + groupVertOff[g]; + std::memcpy(&bin[idxOff + iCursor * 4], &off, 4); + iCursor++; + } + } + // Build glTF JSON. + nlohmann::json gj; + gj["asset"] = {{"version", "2.0"}, + {"generator", "wowee_editor --export-wob-glb"}}; + gj["scene"] = 0; + gj["scenes"] = nlohmann::json::array({nlohmann::json{{"nodes", {0}}}}); + gj["nodes"] = nlohmann::json::array({nlohmann::json{ + {"name", bld.name.empty() ? "WoweeBuilding" : bld.name}, + {"mesh", 0} + }}); + gj["buffers"] = nlohmann::json::array({nlohmann::json{ + {"byteLength", binSize} + }}); + nlohmann::json bufferViews = nlohmann::json::array(); + bufferViews.push_back({{"buffer", 0}, {"byteOffset", posOff}, + {"byteLength", totalV * 12}, {"target", 34962}}); + bufferViews.push_back({{"buffer", 0}, {"byteOffset", nrmOff}, + {"byteLength", totalV * 12}, {"target", 34962}}); + bufferViews.push_back({{"buffer", 0}, {"byteOffset", uvOff}, + {"byteLength", totalV * 8}, {"target", 34962}}); + bufferViews.push_back({{"buffer", 0}, {"byteOffset", idxOff}, + {"byteLength", totalI * 4}, {"target", 34963}}); + gj["bufferViews"] = bufferViews; + nlohmann::json accessors = nlohmann::json::array(); + accessors.push_back({ + {"bufferView", 0}, {"componentType", 5126}, + {"count", totalV}, {"type", "VEC3"}, + {"min", {bMin.x, bMin.y, bMin.z}}, + {"max", {bMax.x, bMax.y, bMax.z}} + }); + accessors.push_back({{"bufferView", 1}, {"componentType", 5126}, + {"count", totalV}, {"type", "VEC3"}}); + accessors.push_back({{"bufferView", 2}, {"componentType", 5126}, + {"count", totalV}, {"type", "VEC2"}}); + // Per-group primitives — each gets its own indices accessor + // sliced from the shared index bufferView via byteOffset. + nlohmann::json primitives = nlohmann::json::array(); + for (size_t g = 0; g < bld.groups.size(); ++g) { + uint32_t accIdx = static_cast(accessors.size()); + accessors.push_back({ + {"bufferView", 3}, + {"byteOffset", groupIdxOff[g] * 4}, + {"componentType", 5125}, + {"count", bld.groups[g].indices.size()}, + {"type", "SCALAR"} + }); + primitives.push_back({ + {"attributes", {{"POSITION", 0}, {"NORMAL", 1}, {"TEXCOORD_0", 2}}}, + {"indices", accIdx}, + {"mode", 4} + }); + } + gj["accessors"] = accessors; + gj["meshes"] = nlohmann::json::array({nlohmann::json{ + {"primitives", primitives} + }}); + std::string jsonStr = gj.dump(); + while (jsonStr.size() % 4 != 0) jsonStr += ' '; + uint32_t jsonLen = static_cast(jsonStr.size()); + uint32_t binLen = binSize; + uint32_t totalLen = 12 + 8 + jsonLen + 8 + binLen; + std::ofstream out(outPath, std::ios::binary); + if (!out) { + std::fprintf(stderr, "Failed to open output: %s\n", outPath.c_str()); + return 1; + } + uint32_t magic = 0x46546C67; + uint32_t version = 2; + out.write(reinterpret_cast(&magic), 4); + out.write(reinterpret_cast(&version), 4); + out.write(reinterpret_cast(&totalLen), 4); + uint32_t jsonChunkType = 0x4E4F534A; + out.write(reinterpret_cast(&jsonLen), 4); + out.write(reinterpret_cast(&jsonChunkType), 4); + out.write(jsonStr.data(), jsonLen); + uint32_t binChunkType = 0x004E4942; + out.write(reinterpret_cast(&binLen), 4); + out.write(reinterpret_cast(&binChunkType), 4); + out.write(reinterpret_cast(bin.data()), binLen); + out.close(); + std::printf("Exported %s.wob -> %s\n", base.c_str(), outPath.c_str()); + std::printf(" %zu groups -> %zu primitives, %u verts, %u tris, %u-byte BIN\n", + bld.groups.size(), primitives.size(), + totalV, totalI / 3, binLen); + return 0; +} + +int handleExportWhmGlb(int& i, int argc, char** argv) { + // glTF 2.0 binary export for WHM/WOT terrain. Mirrors + // --export-whm-obj's mesh layout (9x9 outer grid per chunk + // → 8x8 quads → 2 tris each), but ships as a single .glb + // viewable in any modern web 3D tool. Per-chunk primitives + // so designers can hide individual chunks in three.js. + 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 + ".glb"; + wowee::pipeline::ADTTerrain terrain; + wowee::pipeline::WoweeTerrainLoader::load(base, terrain); + // Same coord constants as --export-whm-obj so the .glb and + // .obj of the same source align spatially. + constexpr float kTileSize = 533.33333f; + constexpr float kChunkSize = kTileSize / 16.0f; + constexpr float kVertSpacing = kChunkSize / 8.0f; + // Walk the 16x16 chunk grid, build per-chunk vertex + index + // arrays. Hole bits respected (cave-entrance quads dropped). + struct ChunkMesh { uint32_t vertOff, vertCount, idxOff, idxCount; }; + std::vector chunkMeshes; + std::vector positions; // packed sequentially + std::vector indices; + int loadedChunks = 0; + glm::vec3 bMin{1e30f}, bMax{-1e30f}; + 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++; + ChunkMesh cm{}; + cm.vertOff = static_cast(positions.size()); + cm.idxOff = static_cast(indices.size()); + float chunkBaseX = (32.0f - terrain.coord.y) * kTileSize - cy * kChunkSize; + float chunkBaseY = (32.0f - terrain.coord.x) * kTileSize - cx * kChunkSize; + // 9x9 outer verts (skip 8x8 inner fan-center verts). + for (int row = 0; row < 9; ++row) { + for (int col = 0; col < 9; ++col) { + glm::vec3 p{ + chunkBaseX - row * kVertSpacing, + chunkBaseY - col * kVertSpacing, + chunk.position[2] + chunk.heightMap.heights[row * 17 + col] + }; + positions.push_back(p); + bMin = glm::min(bMin, p); + bMax = glm::max(bMax, p); + } + } + cm.vertCount = 81; + bool isHoleChunk = (chunk.holes != 0); + auto idx = [&](int r, int c) { return cm.vertOff + r * 9 + c; }; + 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; + } + indices.push_back(idx(row, col)); + indices.push_back(idx(row, col + 1)); + indices.push_back(idx(row + 1, col + 1)); + indices.push_back(idx(row, col)); + indices.push_back(idx(row + 1, col + 1)); + indices.push_back(idx(row + 1, col)); + } + } + cm.idxCount = static_cast(indices.size()) - cm.idxOff; + chunkMeshes.push_back(cm); + } + } + if (loadedChunks == 0) { + std::fprintf(stderr, "WHM has no loaded chunks\n"); + return 1; + } + // Synthesize normals as +Z (terrain is Z-up). Real per-vertex + // normals would need a smoothing pass across chunk boundaries + // — skip for v1, viewers can compute their own from positions. + const uint32_t totalV = static_cast(positions.size()); + const uint32_t totalI = static_cast(indices.size()); + const uint32_t posOff = 0; + const uint32_t nrmOff = posOff + totalV * 12; + const uint32_t idxOff = nrmOff + totalV * 12; + const uint32_t binSize = idxOff + totalI * 4; + std::vector bin(binSize); + for (uint32_t v = 0; v < totalV; ++v) { + std::memcpy(&bin[posOff + v * 12 + 0], &positions[v].x, 4); + std::memcpy(&bin[posOff + v * 12 + 4], &positions[v].y, 4); + std::memcpy(&bin[posOff + v * 12 + 8], &positions[v].z, 4); + float nx = 0, ny = 0, nz = 1; + std::memcpy(&bin[nrmOff + v * 12 + 0], &nx, 4); + std::memcpy(&bin[nrmOff + v * 12 + 4], &ny, 4); + std::memcpy(&bin[nrmOff + v * 12 + 8], &nz, 4); + } + std::memcpy(&bin[idxOff], indices.data(), totalI * 4); + // Build glTF JSON. + nlohmann::json gj; + gj["asset"] = {{"version", "2.0"}, + {"generator", "wowee_editor --export-whm-glb"}}; + gj["scene"] = 0; + gj["scenes"] = nlohmann::json::array({nlohmann::json{{"nodes", {0}}}}); + std::string nodeName = "WoweeTerrain_" + std::to_string(terrain.coord.x) + + "_" + std::to_string(terrain.coord.y); + gj["nodes"] = nlohmann::json::array({nlohmann::json{ + {"name", nodeName}, {"mesh", 0} + }}); + gj["buffers"] = nlohmann::json::array({nlohmann::json{ + {"byteLength", binSize} + }}); + nlohmann::json bufferViews = nlohmann::json::array(); + bufferViews.push_back({{"buffer", 0}, {"byteOffset", posOff}, + {"byteLength", totalV * 12}, {"target", 34962}}); + bufferViews.push_back({{"buffer", 0}, {"byteOffset", nrmOff}, + {"byteLength", totalV * 12}, {"target", 34962}}); + bufferViews.push_back({{"buffer", 0}, {"byteOffset", idxOff}, + {"byteLength", totalI * 4}, {"target", 34963}}); + gj["bufferViews"] = bufferViews; + nlohmann::json accessors = nlohmann::json::array(); + accessors.push_back({ + {"bufferView", 0}, {"componentType", 5126}, + {"count", totalV}, {"type", "VEC3"}, + {"min", {bMin.x, bMin.y, bMin.z}}, + {"max", {bMax.x, bMax.y, bMax.z}} + }); + accessors.push_back({{"bufferView", 1}, {"componentType", 5126}, + {"count", totalV}, {"type", "VEC3"}}); + // Per-chunk primitive — sliced from shared index bufferView. + nlohmann::json primitives = nlohmann::json::array(); + for (const auto& cm : chunkMeshes) { + if (cm.idxCount == 0) continue; // all-hole chunk + uint32_t accIdx = static_cast(accessors.size()); + accessors.push_back({ + {"bufferView", 2}, + {"byteOffset", cm.idxOff * 4}, + {"componentType", 5125}, + {"count", cm.idxCount}, + {"type", "SCALAR"} + }); + primitives.push_back({ + {"attributes", {{"POSITION", 0}, {"NORMAL", 1}}}, + {"indices", accIdx}, + {"mode", 4} + }); + } + gj["accessors"] = accessors; + gj["meshes"] = nlohmann::json::array({nlohmann::json{ + {"primitives", primitives} + }}); + std::string jsonStr = gj.dump(); + while (jsonStr.size() % 4 != 0) jsonStr += ' '; + uint32_t jsonLen = static_cast(jsonStr.size()); + uint32_t binLen = binSize; + uint32_t totalLen = 12 + 8 + jsonLen + 8 + binLen; + std::ofstream out(outPath, std::ios::binary); + if (!out) { + std::fprintf(stderr, "Failed to open output: %s\n", outPath.c_str()); + return 1; + } + uint32_t magic = 0x46546C67, version = 2; + out.write(reinterpret_cast(&magic), 4); + out.write(reinterpret_cast(&version), 4); + out.write(reinterpret_cast(&totalLen), 4); + uint32_t jsonChunkType = 0x4E4F534A; + out.write(reinterpret_cast(&jsonLen), 4); + out.write(reinterpret_cast(&jsonChunkType), 4); + out.write(jsonStr.data(), jsonLen); + uint32_t binChunkType = 0x004E4942; + out.write(reinterpret_cast(&binLen), 4); + out.write(reinterpret_cast(&binChunkType), 4); + out.write(reinterpret_cast(bin.data()), binLen); + out.close(); + std::printf("Exported %s.whm -> %s\n", base.c_str(), outPath.c_str()); + std::printf(" %d chunks loaded, %u verts, %u tris, %zu primitives, %u-byte BIN\n", + loadedChunks, totalV, totalI / 3, primitives.size(), binLen); + return 0; +} + +int handleExportWobObj(int& i, int argc, char** argv) { + // WOB is the WMO replacement; like --export-obj for WOM, this + // bridges WOB into the universal-3D-tool ecosystem. Each WOB + // group becomes one OBJ 'g' block, preserving the room/floor + // structure for downstream selection in Blender/MeshLab. + std::string base = argv[++i]; + std::string outPath; + if (i + 1 < argc && argv[i + 1][0] != '-') { + outPath = argv[++i]; + } + if (base.size() >= 4 && base.substr(base.size() - 4) == ".wob") + base = base.substr(0, base.size() - 4); + if (!wowee::pipeline::WoweeBuildingLoader::exists(base)) { + std::fprintf(stderr, "WOB not found: %s.wob\n", base.c_str()); + return 1; + } + if (outPath.empty()) outPath = base + ".obj"; + auto bld = wowee::pipeline::WoweeBuildingLoader::load(base); + if (!bld.isValid()) { + std::fprintf(stderr, "WOB has no groups to export: %s.wob\n", base.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; + } + // Total verts/tris across all groups for the header. + size_t totalV = 0, totalI = 0; + for (const auto& g : bld.groups) { + totalV += g.vertices.size(); + totalI += g.indices.size(); + } + obj << "# Wavefront OBJ generated by wowee_editor --export-wob-obj\n"; + obj << "# Source: " << base << ".wob\n"; + obj << "# Groups: " << bld.groups.size() + << " Verts: " << totalV + << " Tris: " << totalI / 3 + << " Portals: " << bld.portals.size() + << " Doodads: " << bld.doodads.size() << "\n\n"; + obj << "o " << (bld.name.empty() ? "WoweeBuilding" : bld.name) << "\n"; + // OBJ uses a single global vertex pool, so we offset each group's + // local indices by the running total of verts written so far. + uint32_t vertOffset = 0; + for (size_t g = 0; g < bld.groups.size(); ++g) { + const auto& grp = bld.groups[g]; + if (grp.vertices.empty()) continue; + for (const auto& v : grp.vertices) { + obj << "v " << v.position.x << " " + << v.position.y << " " + << v.position.z << "\n"; + } + for (const auto& v : grp.vertices) { + obj << "vt " << v.texCoord.x << " " + << (1.0f - v.texCoord.y) << "\n"; + } + for (const auto& v : grp.vertices) { + obj << "vn " << v.normal.x << " " + << v.normal.y << " " + << v.normal.z << "\n"; + } + std::string groupName = grp.name.empty() + ? "group_" + std::to_string(g) + : grp.name; + if (grp.isOutdoor) groupName += "_outdoor"; + obj << "g " << groupName << "\n"; + for (size_t k = 0; k + 2 < grp.indices.size(); k += 3) { + uint32_t i0 = grp.indices[k] + 1 + vertOffset; + uint32_t i1 = grp.indices[k + 1] + 1 + vertOffset; + uint32_t i2 = grp.indices[k + 2] + 1 + vertOffset; + obj << "f " + << i0 << "/" << i0 << "/" << i0 << " " + << i1 << "/" << i1 << "/" << i1 << " " + << i2 << "/" << i2 << "/" << i2 << "\n"; + } + vertOffset += static_cast(grp.vertices.size()); + } + // Doodad placements as a separate informational block — emit + // each as a comment line so OBJ stays valid but the data is + // recoverable for tools that want to re-create the placements. + if (!bld.doodads.empty()) { + obj << "\n# Doodad placements (model, position, rotation, scale):\n"; + for (const auto& d : bld.doodads) { + obj << "# doodad " << d.modelPath + << " pos " << d.position.x << "," << d.position.y << "," << d.position.z + << " rot " << d.rotation.x << "," << d.rotation.y << "," << d.rotation.z + << " scale " << d.scale << "\n"; + } + } + obj.close(); + std::printf("Exported %s.wob -> %s\n", base.c_str(), outPath.c_str()); + std::printf(" %zu groups, %zu verts, %zu tris, %zu doodad placements\n", + bld.groups.size(), totalV, totalI / 3, + bld.doodads.size()); + return 0; +} + +int handleImportWobObj(int& i, int argc, char** argv) { + // Round-trip companion to --export-wob-obj. Each OBJ 'g' block + // becomes one WoweeBuilding::Group; geometry under that group + // is deduped into the group's local vertex array. Faces + // before any 'g' directive land in a default 'imported' group. + // Doodad placements written as # comment lines by --export-wob-obj + // ARE recognized and re-instanced into bld.doodads. + std::string objPath = argv[++i]; + std::string wobBase; + if (i + 1 < argc && argv[i + 1][0] != '-') { + wobBase = argv[++i]; + } + if (!std::filesystem::exists(objPath)) { + std::fprintf(stderr, "OBJ not found: %s\n", objPath.c_str()); + return 1; + } + if (wobBase.empty()) { + wobBase = objPath; + if (wobBase.size() >= 4 && + wobBase.substr(wobBase.size() - 4) == ".obj") { + wobBase = wobBase.substr(0, wobBase.size() - 4); + } + } + std::ifstream in(objPath); + if (!in) { + std::fprintf(stderr, "Failed to open OBJ: %s\n", objPath.c_str()); + return 1; + } + // Global pools (OBJ vertex/uv/normal indices reference these + // across all groups). + std::vector positions; + std::vector texcoords; + std::vector normals; + wowee::pipeline::WoweeBuilding bld; + // Active group bookkeeping: dedupe table is per-group since + // each WOB group has its own local vertex buffer. + std::string activeGroup = "imported"; + std::unordered_map groupDedupe; + int activeGroupIdx = -1; + int badFaces = 0; + int triangulatedNgons = 0; + std::string objectName; + auto ensureActiveGroup = [&]() { + if (activeGroupIdx >= 0) return; + wowee::pipeline::WoweeBuilding::Group g; + g.name = activeGroup; + if (g.name.size() >= 8 && + g.name.substr(g.name.size() - 8) == "_outdoor") { + g.name = g.name.substr(0, g.name.size() - 8); + g.isOutdoor = true; + } + bld.groups.push_back(g); + activeGroupIdx = static_cast(bld.groups.size()) - 1; + groupDedupe.clear(); + }; + auto resolveCorner = [&](const std::string& token) -> int { + int v = 0, t = 0, n = 0; + { + const char* p = token.c_str(); + char* endp = nullptr; + v = std::strtol(p, &endp, 10); + if (*endp == '/') { + ++endp; + if (*endp != '/') t = std::strtol(endp, &endp, 10); + if (*endp == '/') { + ++endp; + n = std::strtol(endp, &endp, 10); + } + } + } + auto absIdx = [](int idx, size_t pool) { + if (idx < 0) return static_cast(pool) + idx; + return idx - 1; + }; + int vi = absIdx(v, positions.size()); + int ti = (t == 0) ? -1 : absIdx(t, texcoords.size()); + int ni = (n == 0) ? -1 : absIdx(n, normals.size()); + if (vi < 0 || vi >= static_cast(positions.size())) return -1; + ensureActiveGroup(); + std::string key = std::to_string(vi) + "/" + + std::to_string(ti) + "/" + + std::to_string(ni); + auto it = groupDedupe.find(key); + if (it != groupDedupe.end()) return static_cast(it->second); + wowee::pipeline::WoweeBuilding::Vertex vert; + vert.position = positions[vi]; + if (ti >= 0 && ti < static_cast(texcoords.size())) { + vert.texCoord = texcoords[ti]; + // Reverse the V-flip from --export-wob-obj. + vert.texCoord.y = 1.0f - vert.texCoord.y; + } else { + vert.texCoord = {0, 0}; + } + if (ni >= 0 && ni < static_cast(normals.size())) { + vert.normal = normals[ni]; + } else { + vert.normal = {0, 0, 1}; + } + vert.color = {1, 1, 1, 1}; + auto& grp = bld.groups[activeGroupIdx]; + uint32_t newIdx = static_cast(grp.vertices.size()); + grp.vertices.push_back(vert); + groupDedupe[key] = newIdx; + return static_cast(newIdx); + }; + std::string line; + while (std::getline(in, line)) { + while (!line.empty() && (line.back() == '\r' || line.back() == ' ')) + line.pop_back(); + if (line.empty()) continue; + // Recognize doodad placement comment lines emitted by + // --export-wob-obj so the round-trip preserves them. + if (line[0] == '#') { + if (line.find("# doodad ") == 0) { + std::istringstream ss(line); + std::string hash, doodadKw, modelPath, posKw, posStr, + rotKw, rotStr, scaleKw; + float scale = 1.0f; + ss >> hash >> doodadKw >> modelPath + >> posKw >> posStr + >> rotKw >> rotStr + >> scaleKw >> scale; + auto parse3 = [](const std::string& s, glm::vec3& out) { + int got = std::sscanf(s.c_str(), "%f,%f,%f", + &out.x, &out.y, &out.z); + return got == 3; + }; + wowee::pipeline::WoweeBuilding::DoodadPlacement d; + d.modelPath = modelPath; + if (parse3(posStr, d.position) && + parse3(rotStr, d.rotation) && + std::isfinite(scale) && scale > 0.0f) { + d.scale = scale; + bld.doodads.push_back(d); + } + } + continue; + } + std::istringstream ss(line); + std::string tag; + ss >> tag; + if (tag == "v") { + glm::vec3 p; ss >> p.x >> p.y >> p.z; + positions.push_back(p); + } else if (tag == "vt") { + glm::vec2 t; ss >> t.x >> t.y; + texcoords.push_back(t); + } else if (tag == "vn") { + glm::vec3 n; ss >> n.x >> n.y >> n.z; + normals.push_back(n); + } else if (tag == "o") { + if (objectName.empty()) ss >> objectName; + } else if (tag == "g") { + // New group — flush dedupe table so the next batch of + // verts is local to this group. + std::string name; + ss >> name; + activeGroup = name.empty() ? "group" : name; + activeGroupIdx = -1; + groupDedupe.clear(); + } else if (tag == "f") { + std::vector corners; + std::string c; + while (ss >> c) corners.push_back(c); + if (corners.size() < 3) { badFaces++; continue; } + std::vector resolved; + resolved.reserve(corners.size()); + bool ok = true; + for (const auto& cc : corners) { + int idx = resolveCorner(cc); + if (idx < 0) { ok = false; break; } + resolved.push_back(idx); + } + if (!ok) { badFaces++; continue; } + if (resolved.size() > 3) triangulatedNgons++; + auto& grp = bld.groups[activeGroupIdx]; + for (size_t k = 1; k + 1 < resolved.size(); ++k) { + grp.indices.push_back(static_cast(resolved[0])); + grp.indices.push_back(static_cast(resolved[k])); + grp.indices.push_back(static_cast(resolved[k + 1])); + } + } + // mtllib/usemtl/s lines silently skipped. + } + // Compute per-group bounds + global building bound. + if (bld.groups.empty()) { + std::fprintf(stderr, "import-wob-obj: no geometry found in %s\n", + objPath.c_str()); + return 1; + } + glm::vec3 bMin{1e30f}, bMax{-1e30f}; + for (auto& grp : bld.groups) { + if (grp.vertices.empty()) continue; + grp.boundMin = grp.vertices[0].position; + grp.boundMax = grp.boundMin; + for (const auto& v : grp.vertices) { + grp.boundMin = glm::min(grp.boundMin, v.position); + grp.boundMax = glm::max(grp.boundMax, v.position); + } + bMin = glm::min(bMin, grp.boundMin); + bMax = glm::max(bMax, grp.boundMax); + } + glm::vec3 center = (bMin + bMax) * 0.5f; + float r2 = 0; + for (const auto& grp : bld.groups) { + for (const auto& v : grp.vertices) { + glm::vec3 d = v.position - center; + r2 = std::max(r2, glm::dot(d, d)); + } + } + bld.boundRadius = std::sqrt(r2); + bld.name = objectName.empty() + ? std::filesystem::path(objPath).stem().string() + : objectName; + if (!wowee::pipeline::WoweeBuildingLoader::save(bld, wobBase)) { + std::fprintf(stderr, "import-wob-obj: failed to write %s.wob\n", + wobBase.c_str()); + return 1; + } + size_t totalV = 0, totalI = 0; + for (const auto& g : bld.groups) { + totalV += g.vertices.size(); + totalI += g.indices.size(); + } + std::printf("Imported %s -> %s.wob\n", objPath.c_str(), wobBase.c_str()); + std::printf(" %zu groups, %zu verts, %zu tris, %zu doodad placements\n", + bld.groups.size(), totalV, totalI / 3, bld.doodads.size()); + if (triangulatedNgons > 0) { + std::printf(" fan-triangulated %d n-gon(s)\n", triangulatedNgons); + } + if (badFaces > 0) { + std::printf(" warning: skipped %d malformed face(s)\n", badFaces); + } + return 0; +} + +int handleExportWocObj(int& i, int argc, char** argv) { + // 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> 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(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; +} + +int handleExportWhmObj(int& i, int argc, char** argv) { + // 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; +} + + +} // namespace + +bool handleWorldIo(int& i, int argc, char** argv, int& outRc) { + if (std::strcmp(argv[i], "--export-wob-glb") == 0 && i + 1 < argc) { + outRc = handleExportWobGlb(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--export-whm-glb") == 0 && i + 1 < argc) { + outRc = handleExportWhmGlb(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--export-wob-obj") == 0 && i + 1 < argc) { + outRc = handleExportWobObj(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--import-wob-obj") == 0 && i + 1 < argc) { + outRc = handleImportWobObj(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--export-woc-obj") == 0 && i + 1 < argc) { + outRc = handleExportWocObj(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--export-whm-obj") == 0 && i + 1 < argc) { + outRc = handleExportWhmObj(i, argc, argv); return true; + } + return false; +} + +} // namespace cli +} // namespace editor +} // namespace wowee diff --git a/tools/editor/cli_world_io.hpp b/tools/editor/cli_world_io.hpp new file mode 100644 index 00000000..9a2f4037 --- /dev/null +++ b/tools/editor/cli_world_io.hpp @@ -0,0 +1,24 @@ +#pragma once + +namespace wowee { +namespace editor { +namespace cli { + +// Dispatch the world-asset interchange handlers. WOB / WHM / WOC +// are our open replacements for proprietary WMO / ADT-heightmap / +// ADT-collision data; these handlers bridge them to the formats +// every other 3D tool understands so the open-format ecosystem +// is actually usable. +// --export-wob-glb WOB -> glTF 2.0 binary +// --export-wob-obj WOB -> Wavefront OBJ (per-group) +// --import-wob-obj Wavefront OBJ -> WOB (preserves doodads) +// --export-whm-glb WHM/WOT terrain -> glTF 2.0 (per-chunk) +// --export-whm-obj WHM/WOT terrain -> Wavefront OBJ +// --export-woc-obj WOC collision -> OBJ (grouped by walkability) +// +// Returns true if matched; outRc holds the exit code. +bool handleWorldIo(int& i, int argc, char** argv, int& outRc); + +} // namespace cli +} // namespace editor +} // namespace wowee diff --git a/tools/editor/main.cpp b/tools/editor/main.cpp index 9a1cc0e1..fd7ccb6e 100644 --- a/tools/editor/main.cpp +++ b/tools/editor/main.cpp @@ -29,6 +29,7 @@ #include "cli_validate_interop.hpp" #include "cli_glb_inspect.hpp" #include "cli_wom_io.hpp" +#include "cli_world_io.hpp" #include "content_pack.hpp" #include "npc_spawner.hpp" #include "object_placer.hpp" @@ -446,6 +447,9 @@ int main(int argc, char* argv[]) { if (wowee::editor::cli::handleWomIo(i, argc, argv, outRc)) { return outRc; } + if (wowee::editor::cli::handleWorldIo(i, argc, argv, outRc)) { + return outRc; + } } if (std::strcmp(argv[i], "--data") == 0 && i + 1 < argc) { dataPath = argv[++i]; @@ -2494,868 +2498,6 @@ int main(int argc, char* argv[]) { t.loadMs, t.tiles, t.chunks, mspt); } return 0; - } else if (std::strcmp(argv[i], "--export-wob-glb") == 0 && i + 1 < argc) { - // glTF 2.0 binary export for WOB. Same purpose as --export-glb - // for WOM but adapted for buildings: each WOB group becomes - // one primitive in a single mesh, sharing one big vertex - // pool concatenated from per-group vertex arrays. - std::string base = argv[++i]; - std::string outPath; - if (i + 1 < argc && argv[i + 1][0] != '-') { - outPath = argv[++i]; - } - if (base.size() >= 4 && base.substr(base.size() - 4) == ".wob") - base = base.substr(0, base.size() - 4); - if (!wowee::pipeline::WoweeBuildingLoader::exists(base)) { - std::fprintf(stderr, "WOB not found: %s.wob\n", base.c_str()); - return 1; - } - if (outPath.empty()) outPath = base + ".glb"; - auto bld = wowee::pipeline::WoweeBuildingLoader::load(base); - if (!bld.isValid()) { - std::fprintf(stderr, "WOB has no groups: %s.wob\n", base.c_str()); - return 1; - } - // Total counts + per-group offsets needed before allocating - // the BIN buffer. Index buffer is uint32 so groups can each - // index into the global pool by offset. - uint32_t totalV = 0, totalI = 0; - std::vector groupVertOff(bld.groups.size(), 0); - std::vector groupIdxOff(bld.groups.size(), 0); - for (size_t g = 0; g < bld.groups.size(); ++g) { - groupVertOff[g] = totalV; - groupIdxOff[g] = totalI; - totalV += static_cast(bld.groups[g].vertices.size()); - totalI += static_cast(bld.groups[g].indices.size()); - } - if (totalV == 0 || totalI == 0) { - std::fprintf(stderr, "WOB has no vertex data\n"); - return 1; - } - const uint32_t posOff = 0; - const uint32_t nrmOff = posOff + totalV * 12; - const uint32_t uvOff = nrmOff + totalV * 12; - const uint32_t idxOff = uvOff + totalV * 8; - const uint32_t binSize = idxOff + totalI * 4; - std::vector bin(binSize); - // Pack per-group geometry into the global pool. Indices get - // offset by the group's starting vertex index so they - // continue to reference the right vertices in the merged pool. - uint32_t vCursor = 0, iCursor = 0; - glm::vec3 bMin{1e30f}, bMax{-1e30f}; - for (size_t g = 0; g < bld.groups.size(); ++g) { - const auto& grp = bld.groups[g]; - for (const auto& v : grp.vertices) { - std::memcpy(&bin[posOff + vCursor * 12 + 0], &v.position.x, 4); - std::memcpy(&bin[posOff + vCursor * 12 + 4], &v.position.y, 4); - std::memcpy(&bin[posOff + vCursor * 12 + 8], &v.position.z, 4); - std::memcpy(&bin[nrmOff + vCursor * 12 + 0], &v.normal.x, 4); - std::memcpy(&bin[nrmOff + vCursor * 12 + 4], &v.normal.y, 4); - std::memcpy(&bin[nrmOff + vCursor * 12 + 8], &v.normal.z, 4); - std::memcpy(&bin[uvOff + vCursor * 8 + 0], &v.texCoord.x, 4); - std::memcpy(&bin[uvOff + vCursor * 8 + 4], &v.texCoord.y, 4); - bMin = glm::min(bMin, v.position); - bMax = glm::max(bMax, v.position); - vCursor++; - } - // Offset indices by group's vertex base so merged pool - // indexing still works. uint32 indices, written LE. - for (uint32_t idx : grp.indices) { - uint32_t off = idx + groupVertOff[g]; - std::memcpy(&bin[idxOff + iCursor * 4], &off, 4); - iCursor++; - } - } - // Build glTF JSON. - nlohmann::json gj; - gj["asset"] = {{"version", "2.0"}, - {"generator", "wowee_editor --export-wob-glb"}}; - gj["scene"] = 0; - gj["scenes"] = nlohmann::json::array({nlohmann::json{{"nodes", {0}}}}); - gj["nodes"] = nlohmann::json::array({nlohmann::json{ - {"name", bld.name.empty() ? "WoweeBuilding" : bld.name}, - {"mesh", 0} - }}); - gj["buffers"] = nlohmann::json::array({nlohmann::json{ - {"byteLength", binSize} - }}); - nlohmann::json bufferViews = nlohmann::json::array(); - bufferViews.push_back({{"buffer", 0}, {"byteOffset", posOff}, - {"byteLength", totalV * 12}, {"target", 34962}}); - bufferViews.push_back({{"buffer", 0}, {"byteOffset", nrmOff}, - {"byteLength", totalV * 12}, {"target", 34962}}); - bufferViews.push_back({{"buffer", 0}, {"byteOffset", uvOff}, - {"byteLength", totalV * 8}, {"target", 34962}}); - bufferViews.push_back({{"buffer", 0}, {"byteOffset", idxOff}, - {"byteLength", totalI * 4}, {"target", 34963}}); - gj["bufferViews"] = bufferViews; - nlohmann::json accessors = nlohmann::json::array(); - accessors.push_back({ - {"bufferView", 0}, {"componentType", 5126}, - {"count", totalV}, {"type", "VEC3"}, - {"min", {bMin.x, bMin.y, bMin.z}}, - {"max", {bMax.x, bMax.y, bMax.z}} - }); - accessors.push_back({{"bufferView", 1}, {"componentType", 5126}, - {"count", totalV}, {"type", "VEC3"}}); - accessors.push_back({{"bufferView", 2}, {"componentType", 5126}, - {"count", totalV}, {"type", "VEC2"}}); - // Per-group primitives — each gets its own indices accessor - // sliced from the shared index bufferView via byteOffset. - nlohmann::json primitives = nlohmann::json::array(); - for (size_t g = 0; g < bld.groups.size(); ++g) { - uint32_t accIdx = static_cast(accessors.size()); - accessors.push_back({ - {"bufferView", 3}, - {"byteOffset", groupIdxOff[g] * 4}, - {"componentType", 5125}, - {"count", bld.groups[g].indices.size()}, - {"type", "SCALAR"} - }); - primitives.push_back({ - {"attributes", {{"POSITION", 0}, {"NORMAL", 1}, {"TEXCOORD_0", 2}}}, - {"indices", accIdx}, - {"mode", 4} - }); - } - gj["accessors"] = accessors; - gj["meshes"] = nlohmann::json::array({nlohmann::json{ - {"primitives", primitives} - }}); - std::string jsonStr = gj.dump(); - while (jsonStr.size() % 4 != 0) jsonStr += ' '; - uint32_t jsonLen = static_cast(jsonStr.size()); - uint32_t binLen = binSize; - uint32_t totalLen = 12 + 8 + jsonLen + 8 + binLen; - std::ofstream out(outPath, std::ios::binary); - if (!out) { - std::fprintf(stderr, "Failed to open output: %s\n", outPath.c_str()); - return 1; - } - uint32_t magic = 0x46546C67; - uint32_t version = 2; - out.write(reinterpret_cast(&magic), 4); - out.write(reinterpret_cast(&version), 4); - out.write(reinterpret_cast(&totalLen), 4); - uint32_t jsonChunkType = 0x4E4F534A; - out.write(reinterpret_cast(&jsonLen), 4); - out.write(reinterpret_cast(&jsonChunkType), 4); - out.write(jsonStr.data(), jsonLen); - uint32_t binChunkType = 0x004E4942; - out.write(reinterpret_cast(&binLen), 4); - out.write(reinterpret_cast(&binChunkType), 4); - out.write(reinterpret_cast(bin.data()), binLen); - out.close(); - std::printf("Exported %s.wob -> %s\n", base.c_str(), outPath.c_str()); - std::printf(" %zu groups -> %zu primitives, %u verts, %u tris, %u-byte BIN\n", - bld.groups.size(), primitives.size(), - totalV, totalI / 3, binLen); - return 0; - } else if (std::strcmp(argv[i], "--export-whm-glb") == 0 && i + 1 < argc) { - // glTF 2.0 binary export for WHM/WOT terrain. Mirrors - // --export-whm-obj's mesh layout (9x9 outer grid per chunk - // → 8x8 quads → 2 tris each), but ships as a single .glb - // viewable in any modern web 3D tool. Per-chunk primitives - // so designers can hide individual chunks in three.js. - 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 + ".glb"; - wowee::pipeline::ADTTerrain terrain; - wowee::pipeline::WoweeTerrainLoader::load(base, terrain); - // Same coord constants as --export-whm-obj so the .glb and - // .obj of the same source align spatially. - constexpr float kTileSize = 533.33333f; - constexpr float kChunkSize = kTileSize / 16.0f; - constexpr float kVertSpacing = kChunkSize / 8.0f; - // Walk the 16x16 chunk grid, build per-chunk vertex + index - // arrays. Hole bits respected (cave-entrance quads dropped). - struct ChunkMesh { uint32_t vertOff, vertCount, idxOff, idxCount; }; - std::vector chunkMeshes; - std::vector positions; // packed sequentially - std::vector indices; - int loadedChunks = 0; - glm::vec3 bMin{1e30f}, bMax{-1e30f}; - 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++; - ChunkMesh cm{}; - cm.vertOff = static_cast(positions.size()); - cm.idxOff = static_cast(indices.size()); - float chunkBaseX = (32.0f - terrain.coord.y) * kTileSize - cy * kChunkSize; - float chunkBaseY = (32.0f - terrain.coord.x) * kTileSize - cx * kChunkSize; - // 9x9 outer verts (skip 8x8 inner fan-center verts). - for (int row = 0; row < 9; ++row) { - for (int col = 0; col < 9; ++col) { - glm::vec3 p{ - chunkBaseX - row * kVertSpacing, - chunkBaseY - col * kVertSpacing, - chunk.position[2] + chunk.heightMap.heights[row * 17 + col] - }; - positions.push_back(p); - bMin = glm::min(bMin, p); - bMax = glm::max(bMax, p); - } - } - cm.vertCount = 81; - bool isHoleChunk = (chunk.holes != 0); - auto idx = [&](int r, int c) { return cm.vertOff + r * 9 + c; }; - 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; - } - indices.push_back(idx(row, col)); - indices.push_back(idx(row, col + 1)); - indices.push_back(idx(row + 1, col + 1)); - indices.push_back(idx(row, col)); - indices.push_back(idx(row + 1, col + 1)); - indices.push_back(idx(row + 1, col)); - } - } - cm.idxCount = static_cast(indices.size()) - cm.idxOff; - chunkMeshes.push_back(cm); - } - } - if (loadedChunks == 0) { - std::fprintf(stderr, "WHM has no loaded chunks\n"); - return 1; - } - // Synthesize normals as +Z (terrain is Z-up). Real per-vertex - // normals would need a smoothing pass across chunk boundaries - // — skip for v1, viewers can compute their own from positions. - const uint32_t totalV = static_cast(positions.size()); - const uint32_t totalI = static_cast(indices.size()); - const uint32_t posOff = 0; - const uint32_t nrmOff = posOff + totalV * 12; - const uint32_t idxOff = nrmOff + totalV * 12; - const uint32_t binSize = idxOff + totalI * 4; - std::vector bin(binSize); - for (uint32_t v = 0; v < totalV; ++v) { - std::memcpy(&bin[posOff + v * 12 + 0], &positions[v].x, 4); - std::memcpy(&bin[posOff + v * 12 + 4], &positions[v].y, 4); - std::memcpy(&bin[posOff + v * 12 + 8], &positions[v].z, 4); - float nx = 0, ny = 0, nz = 1; - std::memcpy(&bin[nrmOff + v * 12 + 0], &nx, 4); - std::memcpy(&bin[nrmOff + v * 12 + 4], &ny, 4); - std::memcpy(&bin[nrmOff + v * 12 + 8], &nz, 4); - } - std::memcpy(&bin[idxOff], indices.data(), totalI * 4); - // Build glTF JSON. - nlohmann::json gj; - gj["asset"] = {{"version", "2.0"}, - {"generator", "wowee_editor --export-whm-glb"}}; - gj["scene"] = 0; - gj["scenes"] = nlohmann::json::array({nlohmann::json{{"nodes", {0}}}}); - std::string nodeName = "WoweeTerrain_" + std::to_string(terrain.coord.x) + - "_" + std::to_string(terrain.coord.y); - gj["nodes"] = nlohmann::json::array({nlohmann::json{ - {"name", nodeName}, {"mesh", 0} - }}); - gj["buffers"] = nlohmann::json::array({nlohmann::json{ - {"byteLength", binSize} - }}); - nlohmann::json bufferViews = nlohmann::json::array(); - bufferViews.push_back({{"buffer", 0}, {"byteOffset", posOff}, - {"byteLength", totalV * 12}, {"target", 34962}}); - bufferViews.push_back({{"buffer", 0}, {"byteOffset", nrmOff}, - {"byteLength", totalV * 12}, {"target", 34962}}); - bufferViews.push_back({{"buffer", 0}, {"byteOffset", idxOff}, - {"byteLength", totalI * 4}, {"target", 34963}}); - gj["bufferViews"] = bufferViews; - nlohmann::json accessors = nlohmann::json::array(); - accessors.push_back({ - {"bufferView", 0}, {"componentType", 5126}, - {"count", totalV}, {"type", "VEC3"}, - {"min", {bMin.x, bMin.y, bMin.z}}, - {"max", {bMax.x, bMax.y, bMax.z}} - }); - accessors.push_back({{"bufferView", 1}, {"componentType", 5126}, - {"count", totalV}, {"type", "VEC3"}}); - // Per-chunk primitive — sliced from shared index bufferView. - nlohmann::json primitives = nlohmann::json::array(); - for (const auto& cm : chunkMeshes) { - if (cm.idxCount == 0) continue; // all-hole chunk - uint32_t accIdx = static_cast(accessors.size()); - accessors.push_back({ - {"bufferView", 2}, - {"byteOffset", cm.idxOff * 4}, - {"componentType", 5125}, - {"count", cm.idxCount}, - {"type", "SCALAR"} - }); - primitives.push_back({ - {"attributes", {{"POSITION", 0}, {"NORMAL", 1}}}, - {"indices", accIdx}, - {"mode", 4} - }); - } - gj["accessors"] = accessors; - gj["meshes"] = nlohmann::json::array({nlohmann::json{ - {"primitives", primitives} - }}); - std::string jsonStr = gj.dump(); - while (jsonStr.size() % 4 != 0) jsonStr += ' '; - uint32_t jsonLen = static_cast(jsonStr.size()); - uint32_t binLen = binSize; - uint32_t totalLen = 12 + 8 + jsonLen + 8 + binLen; - std::ofstream out(outPath, std::ios::binary); - if (!out) { - std::fprintf(stderr, "Failed to open output: %s\n", outPath.c_str()); - return 1; - } - uint32_t magic = 0x46546C67, version = 2; - out.write(reinterpret_cast(&magic), 4); - out.write(reinterpret_cast(&version), 4); - out.write(reinterpret_cast(&totalLen), 4); - uint32_t jsonChunkType = 0x4E4F534A; - out.write(reinterpret_cast(&jsonLen), 4); - out.write(reinterpret_cast(&jsonChunkType), 4); - out.write(jsonStr.data(), jsonLen); - uint32_t binChunkType = 0x004E4942; - out.write(reinterpret_cast(&binLen), 4); - out.write(reinterpret_cast(&binChunkType), 4); - out.write(reinterpret_cast(bin.data()), binLen); - out.close(); - std::printf("Exported %s.whm -> %s\n", base.c_str(), outPath.c_str()); - std::printf(" %d chunks loaded, %u verts, %u tris, %zu primitives, %u-byte BIN\n", - loadedChunks, totalV, totalI / 3, primitives.size(), binLen); - 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 - // group becomes one OBJ 'g' block, preserving the room/floor - // structure for downstream selection in Blender/MeshLab. - std::string base = argv[++i]; - std::string outPath; - if (i + 1 < argc && argv[i + 1][0] != '-') { - outPath = argv[++i]; - } - if (base.size() >= 4 && base.substr(base.size() - 4) == ".wob") - base = base.substr(0, base.size() - 4); - if (!wowee::pipeline::WoweeBuildingLoader::exists(base)) { - std::fprintf(stderr, "WOB not found: %s.wob\n", base.c_str()); - return 1; - } - if (outPath.empty()) outPath = base + ".obj"; - auto bld = wowee::pipeline::WoweeBuildingLoader::load(base); - if (!bld.isValid()) { - std::fprintf(stderr, "WOB has no groups to export: %s.wob\n", base.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; - } - // Total verts/tris across all groups for the header. - size_t totalV = 0, totalI = 0; - for (const auto& g : bld.groups) { - totalV += g.vertices.size(); - totalI += g.indices.size(); - } - obj << "# Wavefront OBJ generated by wowee_editor --export-wob-obj\n"; - obj << "# Source: " << base << ".wob\n"; - obj << "# Groups: " << bld.groups.size() - << " Verts: " << totalV - << " Tris: " << totalI / 3 - << " Portals: " << bld.portals.size() - << " Doodads: " << bld.doodads.size() << "\n\n"; - obj << "o " << (bld.name.empty() ? "WoweeBuilding" : bld.name) << "\n"; - // OBJ uses a single global vertex pool, so we offset each group's - // local indices by the running total of verts written so far. - uint32_t vertOffset = 0; - for (size_t g = 0; g < bld.groups.size(); ++g) { - const auto& grp = bld.groups[g]; - if (grp.vertices.empty()) continue; - for (const auto& v : grp.vertices) { - obj << "v " << v.position.x << " " - << v.position.y << " " - << v.position.z << "\n"; - } - for (const auto& v : grp.vertices) { - obj << "vt " << v.texCoord.x << " " - << (1.0f - v.texCoord.y) << "\n"; - } - for (const auto& v : grp.vertices) { - obj << "vn " << v.normal.x << " " - << v.normal.y << " " - << v.normal.z << "\n"; - } - std::string groupName = grp.name.empty() - ? "group_" + std::to_string(g) - : grp.name; - if (grp.isOutdoor) groupName += "_outdoor"; - obj << "g " << groupName << "\n"; - for (size_t k = 0; k + 2 < grp.indices.size(); k += 3) { - uint32_t i0 = grp.indices[k] + 1 + vertOffset; - uint32_t i1 = grp.indices[k + 1] + 1 + vertOffset; - uint32_t i2 = grp.indices[k + 2] + 1 + vertOffset; - obj << "f " - << i0 << "/" << i0 << "/" << i0 << " " - << i1 << "/" << i1 << "/" << i1 << " " - << i2 << "/" << i2 << "/" << i2 << "\n"; - } - vertOffset += static_cast(grp.vertices.size()); - } - // Doodad placements as a separate informational block — emit - // each as a comment line so OBJ stays valid but the data is - // recoverable for tools that want to re-create the placements. - if (!bld.doodads.empty()) { - obj << "\n# Doodad placements (model, position, rotation, scale):\n"; - for (const auto& d : bld.doodads) { - obj << "# doodad " << d.modelPath - << " pos " << d.position.x << "," << d.position.y << "," << d.position.z - << " rot " << d.rotation.x << "," << d.rotation.y << "," << d.rotation.z - << " scale " << d.scale << "\n"; - } - } - obj.close(); - std::printf("Exported %s.wob -> %s\n", base.c_str(), outPath.c_str()); - std::printf(" %zu groups, %zu verts, %zu tris, %zu doodad placements\n", - bld.groups.size(), totalV, totalI / 3, - bld.doodads.size()); - return 0; - } else if (std::strcmp(argv[i], "--import-wob-obj") == 0 && i + 1 < argc) { - // Round-trip companion to --export-wob-obj. Each OBJ 'g' block - // becomes one WoweeBuilding::Group; geometry under that group - // is deduped into the group's local vertex array. Faces - // before any 'g' directive land in a default 'imported' group. - // Doodad placements written as # comment lines by --export-wob-obj - // ARE recognized and re-instanced into bld.doodads. - std::string objPath = argv[++i]; - std::string wobBase; - if (i + 1 < argc && argv[i + 1][0] != '-') { - wobBase = argv[++i]; - } - if (!std::filesystem::exists(objPath)) { - std::fprintf(stderr, "OBJ not found: %s\n", objPath.c_str()); - return 1; - } - if (wobBase.empty()) { - wobBase = objPath; - if (wobBase.size() >= 4 && - wobBase.substr(wobBase.size() - 4) == ".obj") { - wobBase = wobBase.substr(0, wobBase.size() - 4); - } - } - std::ifstream in(objPath); - if (!in) { - std::fprintf(stderr, "Failed to open OBJ: %s\n", objPath.c_str()); - return 1; - } - // Global pools (OBJ vertex/uv/normal indices reference these - // across all groups). - std::vector positions; - std::vector texcoords; - std::vector normals; - wowee::pipeline::WoweeBuilding bld; - // Active group bookkeeping: dedupe table is per-group since - // each WOB group has its own local vertex buffer. - std::string activeGroup = "imported"; - std::unordered_map groupDedupe; - int activeGroupIdx = -1; - int badFaces = 0; - int triangulatedNgons = 0; - std::string objectName; - auto ensureActiveGroup = [&]() { - if (activeGroupIdx >= 0) return; - wowee::pipeline::WoweeBuilding::Group g; - g.name = activeGroup; - if (g.name.size() >= 8 && - g.name.substr(g.name.size() - 8) == "_outdoor") { - g.name = g.name.substr(0, g.name.size() - 8); - g.isOutdoor = true; - } - bld.groups.push_back(g); - activeGroupIdx = static_cast(bld.groups.size()) - 1; - groupDedupe.clear(); - }; - auto resolveCorner = [&](const std::string& token) -> int { - int v = 0, t = 0, n = 0; - { - const char* p = token.c_str(); - char* endp = nullptr; - v = std::strtol(p, &endp, 10); - if (*endp == '/') { - ++endp; - if (*endp != '/') t = std::strtol(endp, &endp, 10); - if (*endp == '/') { - ++endp; - n = std::strtol(endp, &endp, 10); - } - } - } - auto absIdx = [](int idx, size_t pool) { - if (idx < 0) return static_cast(pool) + idx; - return idx - 1; - }; - int vi = absIdx(v, positions.size()); - int ti = (t == 0) ? -1 : absIdx(t, texcoords.size()); - int ni = (n == 0) ? -1 : absIdx(n, normals.size()); - if (vi < 0 || vi >= static_cast(positions.size())) return -1; - ensureActiveGroup(); - std::string key = std::to_string(vi) + "/" + - std::to_string(ti) + "/" + - std::to_string(ni); - auto it = groupDedupe.find(key); - if (it != groupDedupe.end()) return static_cast(it->second); - wowee::pipeline::WoweeBuilding::Vertex vert; - vert.position = positions[vi]; - if (ti >= 0 && ti < static_cast(texcoords.size())) { - vert.texCoord = texcoords[ti]; - // Reverse the V-flip from --export-wob-obj. - vert.texCoord.y = 1.0f - vert.texCoord.y; - } else { - vert.texCoord = {0, 0}; - } - if (ni >= 0 && ni < static_cast(normals.size())) { - vert.normal = normals[ni]; - } else { - vert.normal = {0, 0, 1}; - } - vert.color = {1, 1, 1, 1}; - auto& grp = bld.groups[activeGroupIdx]; - uint32_t newIdx = static_cast(grp.vertices.size()); - grp.vertices.push_back(vert); - groupDedupe[key] = newIdx; - return static_cast(newIdx); - }; - std::string line; - while (std::getline(in, line)) { - while (!line.empty() && (line.back() == '\r' || line.back() == ' ')) - line.pop_back(); - if (line.empty()) continue; - // Recognize doodad placement comment lines emitted by - // --export-wob-obj so the round-trip preserves them. - if (line[0] == '#') { - if (line.find("# doodad ") == 0) { - std::istringstream ss(line); - std::string hash, doodadKw, modelPath, posKw, posStr, - rotKw, rotStr, scaleKw; - float scale = 1.0f; - ss >> hash >> doodadKw >> modelPath - >> posKw >> posStr - >> rotKw >> rotStr - >> scaleKw >> scale; - auto parse3 = [](const std::string& s, glm::vec3& out) { - int got = std::sscanf(s.c_str(), "%f,%f,%f", - &out.x, &out.y, &out.z); - return got == 3; - }; - wowee::pipeline::WoweeBuilding::DoodadPlacement d; - d.modelPath = modelPath; - if (parse3(posStr, d.position) && - parse3(rotStr, d.rotation) && - std::isfinite(scale) && scale > 0.0f) { - d.scale = scale; - bld.doodads.push_back(d); - } - } - continue; - } - std::istringstream ss(line); - std::string tag; - ss >> tag; - if (tag == "v") { - glm::vec3 p; ss >> p.x >> p.y >> p.z; - positions.push_back(p); - } else if (tag == "vt") { - glm::vec2 t; ss >> t.x >> t.y; - texcoords.push_back(t); - } else if (tag == "vn") { - glm::vec3 n; ss >> n.x >> n.y >> n.z; - normals.push_back(n); - } else if (tag == "o") { - if (objectName.empty()) ss >> objectName; - } else if (tag == "g") { - // New group — flush dedupe table so the next batch of - // verts is local to this group. - std::string name; - ss >> name; - activeGroup = name.empty() ? "group" : name; - activeGroupIdx = -1; - groupDedupe.clear(); - } else if (tag == "f") { - std::vector corners; - std::string c; - while (ss >> c) corners.push_back(c); - if (corners.size() < 3) { badFaces++; continue; } - std::vector resolved; - resolved.reserve(corners.size()); - bool ok = true; - for (const auto& cc : corners) { - int idx = resolveCorner(cc); - if (idx < 0) { ok = false; break; } - resolved.push_back(idx); - } - if (!ok) { badFaces++; continue; } - if (resolved.size() > 3) triangulatedNgons++; - auto& grp = bld.groups[activeGroupIdx]; - for (size_t k = 1; k + 1 < resolved.size(); ++k) { - grp.indices.push_back(static_cast(resolved[0])); - grp.indices.push_back(static_cast(resolved[k])); - grp.indices.push_back(static_cast(resolved[k + 1])); - } - } - // mtllib/usemtl/s lines silently skipped. - } - // Compute per-group bounds + global building bound. - if (bld.groups.empty()) { - std::fprintf(stderr, "import-wob-obj: no geometry found in %s\n", - objPath.c_str()); - return 1; - } - glm::vec3 bMin{1e30f}, bMax{-1e30f}; - for (auto& grp : bld.groups) { - if (grp.vertices.empty()) continue; - grp.boundMin = grp.vertices[0].position; - grp.boundMax = grp.boundMin; - for (const auto& v : grp.vertices) { - grp.boundMin = glm::min(grp.boundMin, v.position); - grp.boundMax = glm::max(grp.boundMax, v.position); - } - bMin = glm::min(bMin, grp.boundMin); - bMax = glm::max(bMax, grp.boundMax); - } - glm::vec3 center = (bMin + bMax) * 0.5f; - float r2 = 0; - for (const auto& grp : bld.groups) { - for (const auto& v : grp.vertices) { - glm::vec3 d = v.position - center; - r2 = std::max(r2, glm::dot(d, d)); - } - } - bld.boundRadius = std::sqrt(r2); - bld.name = objectName.empty() - ? std::filesystem::path(objPath).stem().string() - : objectName; - if (!wowee::pipeline::WoweeBuildingLoader::save(bld, wobBase)) { - std::fprintf(stderr, "import-wob-obj: failed to write %s.wob\n", - wobBase.c_str()); - return 1; - } - size_t totalV = 0, totalI = 0; - for (const auto& g : bld.groups) { - totalV += g.vertices.size(); - totalI += g.indices.size(); - } - std::printf("Imported %s -> %s.wob\n", objPath.c_str(), wobBase.c_str()); - std::printf(" %zu groups, %zu verts, %zu tris, %zu doodad placements\n", - bld.groups.size(), totalV, totalI / 3, bld.doodads.size()); - if (triangulatedNgons > 0) { - std::printf(" fan-triangulated %d n-gon(s)\n", triangulatedNgons); - } - if (badFaces > 0) { - 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> 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(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], "--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,