From d03c2dad1fb514356a461995864af4335855a1d6 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 9 May 2026 05:57:25 -0700 Subject: [PATCH] refactor(editor): extract zone/project mesh-bake handlers into cli_bake.cpp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the 3D-export bake handlers out of main.cpp: --bake-zone-glb --bake-zone-stl --bake-zone-obj --bake-project-obj --bake-project-stl --bake-project-glb The STL + GLB project bakes share a combined dispatcher (one function with internal STL-vs-GLB branching) since they walk the same per-zone asset list and only differ in the output emission code. main.cpp drops 12,119 → 11,261 lines (-858). The combined-OR opener spanning multiple lines created a parse-error fragment in the extraction; caught + manually fixed before commit (same pattern as the WOM info attachments/particles/sequences extraction). --- CMakeLists.txt | 1 + tools/editor/cli_bake.cpp | 929 ++++++++++++++++++++++++++++++++++++++ tools/editor/cli_bake.hpp | 18 + tools/editor/main.cpp | 867 +---------------------------------- 4 files changed, 952 insertions(+), 863 deletions(-) create mode 100644 tools/editor/cli_bake.cpp create mode 100644 tools/editor/cli_bake.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 74ae535f..60a3851c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1322,6 +1322,7 @@ add_executable(wowee_editor tools/editor/cli_items.cpp tools/editor/cli_extract_info.cpp tools/editor/cli_export.cpp + tools/editor/cli_bake.cpp tools/editor/editor_app.cpp tools/editor/editor_camera.cpp tools/editor/editor_viewport.cpp diff --git a/tools/editor/cli_bake.cpp b/tools/editor/cli_bake.cpp new file mode 100644 index 00000000..e244c544 --- /dev/null +++ b/tools/editor/cli_bake.cpp @@ -0,0 +1,929 @@ +#include "cli_bake.hpp" + +#include "pipeline/wowee_model.hpp" +#include "pipeline/wowee_building.hpp" +#include "pipeline/wowee_terrain_loader.hpp" +#include "object_placer.hpp" +#include "zone_manifest.hpp" +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace editor { +namespace cli { + +namespace { + +int handleBakeZoneGlb(int& i, int argc, char** argv) { + // Bake every WHM tile in a zone into ONE .glb so the whole + // multi-tile zone opens in three.js / model-viewer with one + // file. Each tile becomes its own mesh+node so they can be + // toggled independently. v1: terrain only — object/WOB + // instances are a follow-up that needs careful per-mesh + // bufferView slicing. + std::string zoneDir = argv[++i]; + std::string outPath; + if (i + 1 < argc && argv[i + 1][0] != '-') outPath = argv[++i]; + namespace fs = std::filesystem; + std::string manifestPath = zoneDir + "/zone.json"; + if (!fs::exists(manifestPath)) { + std::fprintf(stderr, + "bake-zone-glb: %s has no zone.json\n", zoneDir.c_str()); + return 1; + } + wowee::editor::ZoneManifest zm; + if (!zm.load(manifestPath)) { + std::fprintf(stderr, + "bake-zone-glb: failed to parse zone.json\n"); + return 1; + } + if (outPath.empty()) outPath = zoneDir + "/" + zm.mapName + ".glb"; + if (zm.tiles.empty()) { + std::fprintf(stderr, "bake-zone-glb: zone has no tiles\n"); + return 1; + } + constexpr float kTileSize = 533.33333f; + constexpr float kChunkSize = kTileSize / 16.0f; + constexpr float kVertSpacing = kChunkSize / 8.0f; + // Per-tile mesh metadata so we can create one node per tile + // and slice its index range from the shared bufferView. + struct TileMesh { + int tx, ty; + uint32_t vertOff, vertCount; + uint32_t idxOff, idxCount; + }; + std::vector tileMeshes; + std::vector positions; + std::vector indices; + int loadedTiles = 0; + glm::vec3 bMin{1e30f}, bMax{-1e30f}; + 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)) { + std::fprintf(stderr, + "bake-zone-glb: tile (%d,%d) WHM/WOT missing — skipping\n", + tx, ty); + continue; + } + wowee::pipeline::ADTTerrain terrain; + wowee::pipeline::WoweeTerrainLoader::load(tileBase, terrain); + TileMesh tm{tx, ty, 0, 0, 0, 0}; + tm.vertOff = static_cast(positions.size()); + tm.idxOff = static_cast(indices.size()); + // Same per-chunk outer-grid layout as --export-whm-glb, + // but accumulated across all tiles so they share one + // global vertex+index pool. + 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 chunkVertOff = + static_cast(positions.size()); + 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); + } + } + 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 chunkVertOff + r * 9 + c; + }; + 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)); + } + } + } + } + tm.vertCount = static_cast(positions.size()) - tm.vertOff; + tm.idxCount = static_cast(indices.size()) - tm.idxOff; + if (tm.vertCount > 0 && tm.idxCount > 0) { + tileMeshes.push_back(tm); + loadedTiles++; + } + } + if (loadedTiles == 0) { + std::fprintf(stderr, "bake-zone-glb: no tiles loaded\n"); + return 1; + } + // Pack BIN chunk same way as --export-whm-glb (positions + + // synthetic +Z normals + indices). Per-tile accessors slice + // their index region via byteOffset. + 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. One mesh + one node per tile so they can + // be toggled in viewers. + nlohmann::json gj; + gj["asset"] = {{"version", "2.0"}, + {"generator", "wowee_editor --bake-zone-glb"}}; + gj["scene"] = 0; + gj["buffers"] = nlohmann::json::array({nlohmann::json{ + {"byteLength", binSize} + }}); + // Three shared bufferViews — pos, nrm, idx — sliced into + // per-tile primitives via byteOffset on the index accessor. + 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; + // Shared position+normal accessors (covering the full pool; + // primitives reference them, the index accessor does the + // per-tile slicing). + 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-tile mesh + node + indices accessor. + nlohmann::json meshes = nlohmann::json::array(); + nlohmann::json nodes = nlohmann::json::array(); + nlohmann::json sceneNodes = nlohmann::json::array(); + for (const auto& tm : tileMeshes) { + uint32_t accIdx = static_cast(accessors.size()); + accessors.push_back({ + {"bufferView", 2}, + {"byteOffset", tm.idxOff * 4}, + {"componentType", 5125}, + {"count", tm.idxCount}, + {"type", "SCALAR"} + }); + uint32_t meshIdx = static_cast(meshes.size()); + meshes.push_back({ + {"primitives", nlohmann::json::array({nlohmann::json{ + {"attributes", {{"POSITION", 0}, {"NORMAL", 1}}}, + {"indices", accIdx}, {"mode", 4} + }})} + }); + std::string nodeName = "tile_" + std::to_string(tm.tx) + + "_" + std::to_string(tm.ty); + uint32_t nodeIdx = static_cast(nodes.size()); + nodes.push_back({{"name", nodeName}, {"mesh", meshIdx}}); + sceneNodes.push_back(nodeIdx); + } + gj["accessors"] = accessors; + gj["meshes"] = meshes; + gj["nodes"] = nodes; + gj["scenes"] = nlohmann::json::array({nlohmann::json{ + {"nodes", sceneNodes} + }}); + 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("Baked %s -> %s\n", zoneDir.c_str(), outPath.c_str()); + std::printf(" %d tile(s), %u verts, %u tris, %zu meshes, %u-byte BIN\n", + loadedTiles, totalV, totalI / 3, + meshes.size(), binLen); + return 0; +} + +int handleBakeZoneStl(int& i, int argc, char** argv) { + // STL counterpart to --bake-zone-glb. Designers can 3D-print a + // miniature of an entire multi-tile zone in one slicer load — + // useful for tabletop RPG props or a physical reference of a + // playtest area. + std::string zoneDir = argv[++i]; + std::string outPath; + if (i + 1 < argc && argv[i + 1][0] != '-') outPath = argv[++i]; + namespace fs = std::filesystem; + std::string manifestPath = zoneDir + "/zone.json"; + if (!fs::exists(manifestPath)) { + std::fprintf(stderr, + "bake-zone-stl: %s has no zone.json\n", zoneDir.c_str()); + return 1; + } + wowee::editor::ZoneManifest zm; + if (!zm.load(manifestPath)) { + std::fprintf(stderr, + "bake-zone-stl: failed to parse zone.json\n"); + return 1; + } + if (outPath.empty()) outPath = zoneDir + "/" + zm.mapName + ".stl"; + if (zm.tiles.empty()) { + std::fprintf(stderr, "bake-zone-stl: zone has no tiles\n"); + return 1; + } + std::ofstream out(outPath); + if (!out) { + std::fprintf(stderr, "bake-zone-stl: 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; + // Solid name sanitized to alphanum + underscore. + std::string solidName = zm.mapName; + for (auto& c : solidName) { + if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '_')) c = '_'; + } + if (solidName.empty()) solidName = "wowee_zone"; + out << "solid " << solidName << "\n"; + int loadedTiles = 0, holesSkipped = 0; + uint64_t triCount = 0; + // For each tile, generate the same 9x9 outer-grid mesh and + // emit per-triangle facets directly (STL has no shared + // vertex pool — each triangle stands alone). Compute face + // normal from cross product (slicers use it for orientation). + 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)) { + std::fprintf(stderr, + "bake-zone-stl: tile (%d, %d) WHM/WOT missing — skipping\n", + tx, ty); + continue; + } + wowee::pipeline::ADTTerrain terrain; + wowee::pipeline::WoweeTerrainLoader::load(tileBase, terrain); + loadedTiles++; + 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; + // Pre-compute the 9x9 vertex grid for this chunk. + glm::vec3 V[9][9]; + for (int row = 0; row < 9; ++row) { + for (int col = 0; col < 9; ++col) { + V[row][col] = { + chunkBaseX - row * kVertSpacing, + chunkBaseY - col * kVertSpacing, + chunk.position[2] + + chunk.heightMap.heights[row * 17 + col] + }; + } + } + bool isHoleChunk = (chunk.holes != 0); + auto emitTri = [&](const glm::vec3& a, + const glm::vec3& b, + const glm::vec3& c) { + glm::vec3 e1 = b - a, e2 = c - a; + glm::vec3 n = glm::cross(e1, e2); + float len = glm::length(n); + if (len > 1e-12f) n /= len; + else n = {0, 0, 1}; + out << " facet normal " << n.x << " " << n.y << " " << n.z << "\n" + << " outer loop\n" + << " vertex " << a.x << " " << a.y << " " << a.z << "\n" + << " vertex " << b.x << " " << b.y << " " << b.z << "\n" + << " vertex " << c.x << " " << c.y << " " << c.z << "\n" + << " endloop\n" + << " endfacet\n"; + triCount++; + }; + 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))) { + holesSkipped++; + continue; + } + } + emitTri(V[row][col], V[row][col + 1], V[row + 1][col + 1]); + emitTri(V[row][col], V[row + 1][col + 1], V[row + 1][col]); + } + } + } + } + } + out << "endsolid " << solidName << "\n"; + out.close(); + if (loadedTiles == 0) { + std::fprintf(stderr, "bake-zone-stl: no tiles loaded\n"); + std::filesystem::remove(outPath); + return 1; + } + std::printf("Baked %s -> %s\n", zoneDir.c_str(), outPath.c_str()); + std::printf(" %d tile(s), %llu facets, %d hole quads skipped\n", + loadedTiles, static_cast(triCount), + holesSkipped); + return 0; +} + +int handleBakeZoneObj(int& i, int argc, char** argv) { + // OBJ companion to --bake-zone-glb / --bake-zone-stl. Same + // multi-tile WHM aggregation, but as Wavefront OBJ — opens + // directly in Blender / MeshLab / 3DS Max for hand-editing. + // Each tile becomes its own 'g' block so designers can hide + // tiles independently. + std::string zoneDir = argv[++i]; + std::string outPath; + if (i + 1 < argc && argv[i + 1][0] != '-') outPath = argv[++i]; + namespace fs = std::filesystem; + std::string manifestPath = zoneDir + "/zone.json"; + if (!fs::exists(manifestPath)) { + std::fprintf(stderr, + "bake-zone-obj: %s has no zone.json\n", zoneDir.c_str()); + return 1; + } + wowee::editor::ZoneManifest zm; + if (!zm.load(manifestPath)) { + std::fprintf(stderr, "bake-zone-obj: parse failed\n"); + return 1; + } + if (outPath.empty()) outPath = zoneDir + "/" + zm.mapName + ".obj"; + if (zm.tiles.empty()) { + std::fprintf(stderr, "bake-zone-obj: zone has no tiles\n"); + return 1; + } + std::ofstream out(outPath); + if (!out) { + std::fprintf(stderr, "bake-zone-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-zone-obj\n"; + out << "# Zone: " << zm.mapName << " (" << zm.tiles.size() + << " tiles)\n"; + out << "o " << zm.mapName << "\n"; + // OBJ uses a single global vertex pool with per-tile g-blocks + // and per-tile face index offsetting. We accumulate per-tile + // vertex blocks first (so face indices know their offsets), + // then per-tile face blocks at the end. + // Layout: emit ALL verts first (organized by tile, in order), + // then emit ALL face blocks. OBJ requires verts before faces + // that reference them. + int loadedTiles = 0; + int totalVerts = 0; + // Per-tile bookkeeping: vertex base index (1-based for OBJ) + // and which faces reference it. + struct TileMeta { + int tx, ty; + uint32_t vertBase; // 1-based OBJ index of first vert + uint32_t vertCount; + std::vector faceI0, faceI1, faceI2; // local indices + }; + std::vector tiles; + 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)) { + std::fprintf(stderr, + "bake-zone-obj: tile (%d, %d) WHM/WOT missing — skipping\n", + tx, ty); + continue; + } + wowee::pipeline::ADTTerrain terrain; + wowee::pipeline::WoweeTerrainLoader::load(tileBase, terrain); + TileMeta tm{tx, ty, static_cast(totalVerts + 1), 0, {}, {}, {}}; + // Walk chunks; emit verts to file as we go (so we don't + // hold a giant vector in memory). Track local indices for + // face emission afterwards. + uint32_t tileLocalIdx = 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; + float chunkBaseX = (32.0f - terrain.coord.y) * kTileSize - cy * kChunkSize; + float chunkBaseY = (32.0f - terrain.coord.x) * kTileSize - cx * kChunkSize; + uint32_t chunkBaseLocal = tileLocalIdx; + 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"; + tileLocalIdx++; + } + } + 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; + }; + tm.faceI0.push_back(idx(row, col)); + tm.faceI1.push_back(idx(row, col + 1)); + tm.faceI2.push_back(idx(row + 1, col + 1)); + tm.faceI0.push_back(idx(row, col)); + tm.faceI1.push_back(idx(row + 1, col + 1)); + tm.faceI2.push_back(idx(row + 1, col)); + } + } + } + } + tm.vertCount = tileLocalIdx; + totalVerts += tm.vertCount; + if (tm.vertCount > 0) { + tiles.push_back(std::move(tm)); + loadedTiles++; + } + } + // Now emit per-tile face groups (after all verts are written). + uint64_t totalFaces = 0; + for (const auto& tm : tiles) { + out << "g tile_" << tm.tx << "_" << tm.ty << "\n"; + for (size_t k = 0; k < tm.faceI0.size(); ++k) { + uint32_t a = tm.faceI0[k] + tm.vertBase; + uint32_t b = tm.faceI1[k] + tm.vertBase; + uint32_t c = tm.faceI2[k] + tm.vertBase; + out << "f " << a << " " << b << " " << c << "\n"; + totalFaces++; + } + } + out.close(); + if (loadedTiles == 0) { + std::fprintf(stderr, "bake-zone-obj: no tiles loaded\n"); + std::filesystem::remove(outPath); + return 1; + } + std::printf("Baked %s -> %s\n", zoneDir.c_str(), outPath.c_str()); + std::printf(" %d tile(s), %d verts, %llu tris\n", + loadedTiles, totalVerts, + static_cast(totalFaces)); + return 0; +} + +int handleBakeProjectObj(int& i, int argc, char** argv) { + // Project-level OBJ bake: every zone in 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 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 faceI0, faceI1, faceI2; + }; + std::vector 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(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(totalFaces)); + return 0; +} + +int handleBakeProjectStlOrGlb(int& i, int argc, char** argv) { + // STL + glTF project bakes share the per-zone walking logic + // with --bake-project-obj. Only the output emission differs: + // STL → per-triangle 'facet normal'+'outer loop'+vertex×3 + // GLB → packed BIN chunk + JSON describing per-zone meshes + // Coords match across all three exporters so an .obj/.stl/ + // .glb of the same source line up spatially when overlaid. + bool isStl = (std::strcmp(argv[i], "--bake-project-stl") == 0); + const char* cmdName = isStl ? "bake-project-stl" : "bake-project-glb"; + 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, + "%s: %s is not a directory\n", cmdName, projectDir.c_str()); + return 1; + } + if (outPath.empty()) { + outPath = projectDir + "/project." + (isStl ? "stl" : "glb"); + } + std::vector 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, "%s: no zones found\n", cmdName); + return 1; + } + constexpr float kTileSize = 533.33333f; + constexpr float kChunkSize = kTileSize / 16.0f; + constexpr float kVertSpacing = kChunkSize / 8.0f; + // Common pass: collect per-zone vertex+index pools. STL emits + // per-triangle facets directly; GLB packs everything into BIN. + struct ZonePool { + std::string name; + std::vector verts; + std::vector indices; + }; + std::vector zones; + int totalZones = 0, totalTiles = 0; + glm::vec3 bMin{1e30f}, bMax{-1e30f}; + for (const auto& zoneDir : zoneDirs) { + wowee::editor::ZoneManifest zm; + if (!zm.load(zoneDir + "/zone.json")) continue; + ZonePool zp; + zp.name = zm.mapName; + int zoneTiles = 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 chunkBase = static_cast(zp.verts.size()); + 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] + }; + zp.verts.push_back(p); + bMin = glm::min(bMin, p); + bMax = glm::max(bMax, p); + } + } + 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 chunkBase + r * 9 + c; + }; + zp.indices.push_back(idx(row, col)); + zp.indices.push_back(idx(row, col + 1)); + zp.indices.push_back(idx(row + 1, col + 1)); + zp.indices.push_back(idx(row, col)); + zp.indices.push_back(idx(row + 1, col + 1)); + zp.indices.push_back(idx(row + 1, col)); + } + } + } + } + } + if (zp.verts.empty()) continue; + totalTiles += zoneTiles; + totalZones++; + zones.push_back(std::move(zp)); + } + if (zones.empty()) { + std::fprintf(stderr, "%s: no loadable terrain found\n", cmdName); + return 1; + } + if (isStl) { + std::ofstream out(outPath); + if (!out) { + std::fprintf(stderr, "%s: cannot write %s\n", cmdName, outPath.c_str()); + return 1; + } + out << "solid wowee_project\n"; + uint64_t triCount = 0; + for (const auto& zp : zones) { + for (size_t k = 0; k + 2 < zp.indices.size(); k += 3) { + const auto& v0 = zp.verts[zp.indices[k]]; + const auto& v1 = zp.verts[zp.indices[k + 1]]; + const auto& v2 = zp.verts[zp.indices[k + 2]]; + glm::vec3 n = glm::cross(v1 - v0, v2 - v0); + float len = glm::length(n); + if (len > 1e-12f) n /= len; else n = {0, 0, 1}; + out << " facet normal " << n.x << " " << n.y << " " << n.z << "\n" + << " outer loop\n" + << " vertex " << v0.x << " " << v0.y << " " << v0.z << "\n" + << " vertex " << v1.x << " " << v1.y << " " << v1.z << "\n" + << " vertex " << v2.x << " " << v2.y << " " << v2.z << "\n" + << " endloop\n" + << " endfacet\n"; + triCount++; + } + } + out << "endsolid wowee_project\n"; + out.close(); + std::printf("Baked %s -> %s\n", projectDir.c_str(), outPath.c_str()); + std::printf(" %d zone(s), %d tiles, %llu facets\n", + totalZones, totalTiles, + static_cast(triCount)); + return 0; + } + // GLB path: pack positions+normals+indices into one BIN chunk, + // one mesh+node per zone with sliced index accessor. + uint32_t totalV = 0, totalI = 0; + for (const auto& zp : zones) { + totalV += static_cast(zp.verts.size()); + totalI += static_cast(zp.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); + uint32_t vCursor = 0, iCursor = 0; + // Per-zone bookkeeping for accessor slicing. + struct ZoneSlice { std::string name; uint32_t vOff, vCnt, iOff, iCnt; }; + std::vector slices; + for (const auto& zp : zones) { + ZoneSlice s{zp.name, vCursor, static_cast(zp.verts.size()), + iCursor, static_cast(zp.indices.size())}; + for (const auto& v : zp.verts) { + std::memcpy(&bin[posOff + vCursor * 12 + 0], &v.x, 4); + std::memcpy(&bin[posOff + vCursor * 12 + 4], &v.y, 4); + std::memcpy(&bin[posOff + vCursor * 12 + 8], &v.z, 4); + float nx = 0, ny = 0, nz = 1; + std::memcpy(&bin[nrmOff + vCursor * 12 + 0], &nx, 4); + std::memcpy(&bin[nrmOff + vCursor * 12 + 4], &ny, 4); + std::memcpy(&bin[nrmOff + vCursor * 12 + 8], &nz, 4); + vCursor++; + } + // Offset zone indices by the global vertBase so they + // resolve into the merged pool. + for (uint32_t idx : zp.indices) { + uint32_t global = idx + s.vOff; + std::memcpy(&bin[idxOff + iCursor * 4], &global, 4); + iCursor++; + } + slices.push_back(s); + } + nlohmann::json gj; + gj["asset"] = {{"version", "2.0"}, + {"generator", "wowee_editor --bake-project-glb"}}; + gj["scene"] = 0; + gj["buffers"] = nlohmann::json::array({{{"byteLength", binSize}}}); + nlohmann::json bvs = nlohmann::json::array(); + bvs.push_back({{"buffer", 0}, {"byteOffset", posOff}, + {"byteLength", totalV * 12}, {"target", 34962}}); + bvs.push_back({{"buffer", 0}, {"byteOffset", nrmOff}, + {"byteLength", totalV * 12}, {"target", 34962}}); + bvs.push_back({{"buffer", 0}, {"byteOffset", idxOff}, + {"byteLength", totalI * 4}, {"target", 34963}}); + gj["bufferViews"] = bvs; + 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"}}); + nlohmann::json meshes = nlohmann::json::array(); + nlohmann::json nodes = nlohmann::json::array(); + nlohmann::json sceneNodes = nlohmann::json::array(); + for (const auto& s : slices) { + uint32_t accIdx = static_cast(accessors.size()); + accessors.push_back({{"bufferView", 2}, + {"byteOffset", s.iOff * 4}, + {"componentType", 5125}, + {"count", s.iCnt}, {"type", "SCALAR"}}); + uint32_t meshIdx = static_cast(meshes.size()); + meshes.push_back({{"primitives", nlohmann::json::array({nlohmann::json{ + {"attributes", {{"POSITION", 0}, {"NORMAL", 1}}}, + {"indices", accIdx}, {"mode", 4}}})}}); + uint32_t nodeIdx = static_cast(nodes.size()); + nodes.push_back({{"name", "zone_" + s.name}, {"mesh", meshIdx}}); + sceneNodes.push_back(nodeIdx); + } + gj["accessors"] = accessors; + gj["meshes"] = meshes; + gj["nodes"] = nodes; + gj["scenes"] = nlohmann::json::array({{{"nodes", sceneNodes}}}); + 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, "%s: cannot write %s\n", cmdName, 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 jt = 0x4E4F534A; + out.write(reinterpret_cast(&jsonLen), 4); + out.write(reinterpret_cast(&jt), 4); + out.write(jsonStr.data(), jsonLen); + uint32_t bt = 0x004E4942; + out.write(reinterpret_cast(&binLen), 4); + out.write(reinterpret_cast(&bt), 4); + out.write(reinterpret_cast(bin.data()), binLen); + out.close(); + std::printf("Baked %s -> %s\n", projectDir.c_str(), outPath.c_str()); + std::printf(" %d zone(s), %d tiles, %u verts, %u tris, %u-byte BIN\n", + totalZones, totalTiles, totalV, totalI / 3, binLen); + return 0; +} + + +} // namespace + +bool handleBake(int& i, int argc, char** argv, int& outRc) { + if (std::strcmp(argv[i], "--bake-zone-glb") == 0 && i + 1 < argc) { + outRc = handleBakeZoneGlb(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--bake-zone-stl") == 0 && i + 1 < argc) { + outRc = handleBakeZoneStl(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--bake-zone-obj") == 0 && i + 1 < argc) { + outRc = handleBakeZoneObj(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--bake-project-obj") == 0 && i + 1 < argc) { + outRc = handleBakeProjectObj(i, argc, argv); return true; + } + if ((std::strcmp(argv[i], "--bake-project-stl") == 0 || + std::strcmp(argv[i], "--bake-project-glb") == 0) && + i + 1 < argc) { + outRc = handleBakeProjectStlOrGlb(i, argc, argv); return true; + } + return false; +} + +} // namespace cli +} // namespace editor +} // namespace wowee diff --git a/tools/editor/cli_bake.hpp b/tools/editor/cli_bake.hpp new file mode 100644 index 00000000..77d27803 --- /dev/null +++ b/tools/editor/cli_bake.hpp @@ -0,0 +1,18 @@ +#pragma once + +namespace wowee { +namespace editor { +namespace cli { + +// Dispatch the zone & project mesh-bake handlers — these stitch +// every WHM heightfield + WOM/WOB asset in a zone into a single +// 3D file (.obj / .stl / .glb) for external DCC import. +// --bake-zone-glb --bake-zone-stl --bake-zone-obj +// --bake-project-obj --bake-project-stl --bake-project-glb +// +// Returns true if matched; outRc holds the exit code. +bool handleBake(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 8f342fe7..0965f187 100644 --- a/tools/editor/main.cpp +++ b/tools/editor/main.cpp @@ -23,6 +23,7 @@ #include "cli_items.hpp" #include "cli_extract_info.hpp" #include "cli_export.hpp" +#include "cli_bake.hpp" #include "content_pack.hpp" #include "npc_spawner.hpp" #include "object_placer.hpp" @@ -419,6 +420,9 @@ int main(int argc, char* argv[]) { if (wowee::editor::cli::handleExport(i, argc, argv, outRc)) { return outRc; } + if (wowee::editor::cli::handleBake(i, argc, argv, outRc)) { + return outRc; + } } if (std::strcmp(argv[i], "--data") == 0 && i + 1 < argc) { dataPath = argv[++i]; @@ -4469,869 +4473,6 @@ int main(int argc, char* argv[]) { 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], "--bake-zone-glb") == 0 && i + 1 < argc) { - // Bake every WHM tile in a zone into ONE .glb so the whole - // multi-tile zone opens in three.js / model-viewer with one - // file. Each tile becomes its own mesh+node so they can be - // toggled independently. v1: terrain only — object/WOB - // instances are a follow-up that needs careful per-mesh - // bufferView slicing. - std::string zoneDir = argv[++i]; - std::string outPath; - if (i + 1 < argc && argv[i + 1][0] != '-') outPath = argv[++i]; - namespace fs = std::filesystem; - std::string manifestPath = zoneDir + "/zone.json"; - if (!fs::exists(manifestPath)) { - std::fprintf(stderr, - "bake-zone-glb: %s has no zone.json\n", zoneDir.c_str()); - return 1; - } - wowee::editor::ZoneManifest zm; - if (!zm.load(manifestPath)) { - std::fprintf(stderr, - "bake-zone-glb: failed to parse zone.json\n"); - return 1; - } - if (outPath.empty()) outPath = zoneDir + "/" + zm.mapName + ".glb"; - if (zm.tiles.empty()) { - std::fprintf(stderr, "bake-zone-glb: zone has no tiles\n"); - return 1; - } - constexpr float kTileSize = 533.33333f; - constexpr float kChunkSize = kTileSize / 16.0f; - constexpr float kVertSpacing = kChunkSize / 8.0f; - // Per-tile mesh metadata so we can create one node per tile - // and slice its index range from the shared bufferView. - struct TileMesh { - int tx, ty; - uint32_t vertOff, vertCount; - uint32_t idxOff, idxCount; - }; - std::vector tileMeshes; - std::vector positions; - std::vector indices; - int loadedTiles = 0; - glm::vec3 bMin{1e30f}, bMax{-1e30f}; - 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)) { - std::fprintf(stderr, - "bake-zone-glb: tile (%d,%d) WHM/WOT missing — skipping\n", - tx, ty); - continue; - } - wowee::pipeline::ADTTerrain terrain; - wowee::pipeline::WoweeTerrainLoader::load(tileBase, terrain); - TileMesh tm{tx, ty, 0, 0, 0, 0}; - tm.vertOff = static_cast(positions.size()); - tm.idxOff = static_cast(indices.size()); - // Same per-chunk outer-grid layout as --export-whm-glb, - // but accumulated across all tiles so they share one - // global vertex+index pool. - 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 chunkVertOff = - static_cast(positions.size()); - 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); - } - } - 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 chunkVertOff + r * 9 + c; - }; - 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)); - } - } - } - } - tm.vertCount = static_cast(positions.size()) - tm.vertOff; - tm.idxCount = static_cast(indices.size()) - tm.idxOff; - if (tm.vertCount > 0 && tm.idxCount > 0) { - tileMeshes.push_back(tm); - loadedTiles++; - } - } - if (loadedTiles == 0) { - std::fprintf(stderr, "bake-zone-glb: no tiles loaded\n"); - return 1; - } - // Pack BIN chunk same way as --export-whm-glb (positions + - // synthetic +Z normals + indices). Per-tile accessors slice - // their index region via byteOffset. - 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. One mesh + one node per tile so they can - // be toggled in viewers. - nlohmann::json gj; - gj["asset"] = {{"version", "2.0"}, - {"generator", "wowee_editor --bake-zone-glb"}}; - gj["scene"] = 0; - gj["buffers"] = nlohmann::json::array({nlohmann::json{ - {"byteLength", binSize} - }}); - // Three shared bufferViews — pos, nrm, idx — sliced into - // per-tile primitives via byteOffset on the index accessor. - 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; - // Shared position+normal accessors (covering the full pool; - // primitives reference them, the index accessor does the - // per-tile slicing). - 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-tile mesh + node + indices accessor. - nlohmann::json meshes = nlohmann::json::array(); - nlohmann::json nodes = nlohmann::json::array(); - nlohmann::json sceneNodes = nlohmann::json::array(); - for (const auto& tm : tileMeshes) { - uint32_t accIdx = static_cast(accessors.size()); - accessors.push_back({ - {"bufferView", 2}, - {"byteOffset", tm.idxOff * 4}, - {"componentType", 5125}, - {"count", tm.idxCount}, - {"type", "SCALAR"} - }); - uint32_t meshIdx = static_cast(meshes.size()); - meshes.push_back({ - {"primitives", nlohmann::json::array({nlohmann::json{ - {"attributes", {{"POSITION", 0}, {"NORMAL", 1}}}, - {"indices", accIdx}, {"mode", 4} - }})} - }); - std::string nodeName = "tile_" + std::to_string(tm.tx) + - "_" + std::to_string(tm.ty); - uint32_t nodeIdx = static_cast(nodes.size()); - nodes.push_back({{"name", nodeName}, {"mesh", meshIdx}}); - sceneNodes.push_back(nodeIdx); - } - gj["accessors"] = accessors; - gj["meshes"] = meshes; - gj["nodes"] = nodes; - gj["scenes"] = nlohmann::json::array({nlohmann::json{ - {"nodes", sceneNodes} - }}); - 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("Baked %s -> %s\n", zoneDir.c_str(), outPath.c_str()); - std::printf(" %d tile(s), %u verts, %u tris, %zu meshes, %u-byte BIN\n", - loadedTiles, totalV, totalI / 3, - meshes.size(), binLen); - return 0; - } else if (std::strcmp(argv[i], "--bake-zone-stl") == 0 && i + 1 < argc) { - // STL counterpart to --bake-zone-glb. Designers can 3D-print a - // miniature of an entire multi-tile zone in one slicer load — - // useful for tabletop RPG props or a physical reference of a - // playtest area. - std::string zoneDir = argv[++i]; - std::string outPath; - if (i + 1 < argc && argv[i + 1][0] != '-') outPath = argv[++i]; - namespace fs = std::filesystem; - std::string manifestPath = zoneDir + "/zone.json"; - if (!fs::exists(manifestPath)) { - std::fprintf(stderr, - "bake-zone-stl: %s has no zone.json\n", zoneDir.c_str()); - return 1; - } - wowee::editor::ZoneManifest zm; - if (!zm.load(manifestPath)) { - std::fprintf(stderr, - "bake-zone-stl: failed to parse zone.json\n"); - return 1; - } - if (outPath.empty()) outPath = zoneDir + "/" + zm.mapName + ".stl"; - if (zm.tiles.empty()) { - std::fprintf(stderr, "bake-zone-stl: zone has no tiles\n"); - return 1; - } - std::ofstream out(outPath); - if (!out) { - std::fprintf(stderr, "bake-zone-stl: 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; - // Solid name sanitized to alphanum + underscore. - std::string solidName = zm.mapName; - for (auto& c : solidName) { - if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || - (c >= '0' && c <= '9') || c == '_')) c = '_'; - } - if (solidName.empty()) solidName = "wowee_zone"; - out << "solid " << solidName << "\n"; - int loadedTiles = 0, holesSkipped = 0; - uint64_t triCount = 0; - // For each tile, generate the same 9x9 outer-grid mesh and - // emit per-triangle facets directly (STL has no shared - // vertex pool — each triangle stands alone). Compute face - // normal from cross product (slicers use it for orientation). - 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)) { - std::fprintf(stderr, - "bake-zone-stl: tile (%d, %d) WHM/WOT missing — skipping\n", - tx, ty); - continue; - } - wowee::pipeline::ADTTerrain terrain; - wowee::pipeline::WoweeTerrainLoader::load(tileBase, terrain); - loadedTiles++; - 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; - // Pre-compute the 9x9 vertex grid for this chunk. - glm::vec3 V[9][9]; - for (int row = 0; row < 9; ++row) { - for (int col = 0; col < 9; ++col) { - V[row][col] = { - chunkBaseX - row * kVertSpacing, - chunkBaseY - col * kVertSpacing, - chunk.position[2] + - chunk.heightMap.heights[row * 17 + col] - }; - } - } - bool isHoleChunk = (chunk.holes != 0); - auto emitTri = [&](const glm::vec3& a, - const glm::vec3& b, - const glm::vec3& c) { - glm::vec3 e1 = b - a, e2 = c - a; - glm::vec3 n = glm::cross(e1, e2); - float len = glm::length(n); - if (len > 1e-12f) n /= len; - else n = {0, 0, 1}; - out << " facet normal " << n.x << " " << n.y << " " << n.z << "\n" - << " outer loop\n" - << " vertex " << a.x << " " << a.y << " " << a.z << "\n" - << " vertex " << b.x << " " << b.y << " " << b.z << "\n" - << " vertex " << c.x << " " << c.y << " " << c.z << "\n" - << " endloop\n" - << " endfacet\n"; - triCount++; - }; - 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))) { - holesSkipped++; - continue; - } - } - emitTri(V[row][col], V[row][col + 1], V[row + 1][col + 1]); - emitTri(V[row][col], V[row + 1][col + 1], V[row + 1][col]); - } - } - } - } - } - out << "endsolid " << solidName << "\n"; - out.close(); - if (loadedTiles == 0) { - std::fprintf(stderr, "bake-zone-stl: no tiles loaded\n"); - std::filesystem::remove(outPath); - return 1; - } - std::printf("Baked %s -> %s\n", zoneDir.c_str(), outPath.c_str()); - std::printf(" %d tile(s), %llu facets, %d hole quads skipped\n", - loadedTiles, static_cast(triCount), - holesSkipped); - return 0; - } else if (std::strcmp(argv[i], "--bake-zone-obj") == 0 && i + 1 < argc) { - // OBJ companion to --bake-zone-glb / --bake-zone-stl. Same - // multi-tile WHM aggregation, but as Wavefront OBJ — opens - // directly in Blender / MeshLab / 3DS Max for hand-editing. - // Each tile becomes its own 'g' block so designers can hide - // tiles independently. - std::string zoneDir = argv[++i]; - std::string outPath; - if (i + 1 < argc && argv[i + 1][0] != '-') outPath = argv[++i]; - namespace fs = std::filesystem; - std::string manifestPath = zoneDir + "/zone.json"; - if (!fs::exists(manifestPath)) { - std::fprintf(stderr, - "bake-zone-obj: %s has no zone.json\n", zoneDir.c_str()); - return 1; - } - wowee::editor::ZoneManifest zm; - if (!zm.load(manifestPath)) { - std::fprintf(stderr, "bake-zone-obj: parse failed\n"); - return 1; - } - if (outPath.empty()) outPath = zoneDir + "/" + zm.mapName + ".obj"; - if (zm.tiles.empty()) { - std::fprintf(stderr, "bake-zone-obj: zone has no tiles\n"); - return 1; - } - std::ofstream out(outPath); - if (!out) { - std::fprintf(stderr, "bake-zone-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-zone-obj\n"; - out << "# Zone: " << zm.mapName << " (" << zm.tiles.size() - << " tiles)\n"; - out << "o " << zm.mapName << "\n"; - // OBJ uses a single global vertex pool with per-tile g-blocks - // and per-tile face index offsetting. We accumulate per-tile - // vertex blocks first (so face indices know their offsets), - // then per-tile face blocks at the end. - // Layout: emit ALL verts first (organized by tile, in order), - // then emit ALL face blocks. OBJ requires verts before faces - // that reference them. - int loadedTiles = 0; - int totalVerts = 0; - // Per-tile bookkeeping: vertex base index (1-based for OBJ) - // and which faces reference it. - struct TileMeta { - int tx, ty; - uint32_t vertBase; // 1-based OBJ index of first vert - uint32_t vertCount; - std::vector faceI0, faceI1, faceI2; // local indices - }; - std::vector tiles; - 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)) { - std::fprintf(stderr, - "bake-zone-obj: tile (%d, %d) WHM/WOT missing — skipping\n", - tx, ty); - continue; - } - wowee::pipeline::ADTTerrain terrain; - wowee::pipeline::WoweeTerrainLoader::load(tileBase, terrain); - TileMeta tm{tx, ty, static_cast(totalVerts + 1), 0, {}, {}, {}}; - // Walk chunks; emit verts to file as we go (so we don't - // hold a giant vector in memory). Track local indices for - // face emission afterwards. - uint32_t tileLocalIdx = 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; - float chunkBaseX = (32.0f - terrain.coord.y) * kTileSize - cy * kChunkSize; - float chunkBaseY = (32.0f - terrain.coord.x) * kTileSize - cx * kChunkSize; - uint32_t chunkBaseLocal = tileLocalIdx; - 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"; - tileLocalIdx++; - } - } - 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; - }; - tm.faceI0.push_back(idx(row, col)); - tm.faceI1.push_back(idx(row, col + 1)); - tm.faceI2.push_back(idx(row + 1, col + 1)); - tm.faceI0.push_back(idx(row, col)); - tm.faceI1.push_back(idx(row + 1, col + 1)); - tm.faceI2.push_back(idx(row + 1, col)); - } - } - } - } - tm.vertCount = tileLocalIdx; - totalVerts += tm.vertCount; - if (tm.vertCount > 0) { - tiles.push_back(std::move(tm)); - loadedTiles++; - } - } - // Now emit per-tile face groups (after all verts are written). - uint64_t totalFaces = 0; - for (const auto& tm : tiles) { - out << "g tile_" << tm.tx << "_" << tm.ty << "\n"; - for (size_t k = 0; k < tm.faceI0.size(); ++k) { - uint32_t a = tm.faceI0[k] + tm.vertBase; - uint32_t b = tm.faceI1[k] + tm.vertBase; - uint32_t c = tm.faceI2[k] + tm.vertBase; - out << "f " << a << " " << b << " " << c << "\n"; - totalFaces++; - } - } - out.close(); - if (loadedTiles == 0) { - std::fprintf(stderr, "bake-zone-obj: no tiles loaded\n"); - std::filesystem::remove(outPath); - return 1; - } - std::printf("Baked %s -> %s\n", zoneDir.c_str(), outPath.c_str()); - std::printf(" %d tile(s), %d verts, %llu tris\n", - loadedTiles, totalVerts, - static_cast(totalFaces)); - return 0; - } else if (std::strcmp(argv[i], "--bake-project-obj") == 0 && i + 1 < argc) { - // Project-level OBJ bake: every zone in 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 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 faceI0, faceI1, faceI2; - }; - std::vector 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(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(totalFaces)); - return 0; - } else if ((std::strcmp(argv[i], "--bake-project-stl") == 0 || - std::strcmp(argv[i], "--bake-project-glb") == 0) && - i + 1 < argc) { - // STL + glTF project bakes share the per-zone walking logic - // with --bake-project-obj. Only the output emission differs: - // STL → per-triangle 'facet normal'+'outer loop'+vertex×3 - // GLB → packed BIN chunk + JSON describing per-zone meshes - // Coords match across all three exporters so an .obj/.stl/ - // .glb of the same source line up spatially when overlaid. - bool isStl = (std::strcmp(argv[i], "--bake-project-stl") == 0); - const char* cmdName = isStl ? "bake-project-stl" : "bake-project-glb"; - 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, - "%s: %s is not a directory\n", cmdName, projectDir.c_str()); - return 1; - } - if (outPath.empty()) { - outPath = projectDir + "/project." + (isStl ? "stl" : "glb"); - } - std::vector 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, "%s: no zones found\n", cmdName); - return 1; - } - constexpr float kTileSize = 533.33333f; - constexpr float kChunkSize = kTileSize / 16.0f; - constexpr float kVertSpacing = kChunkSize / 8.0f; - // Common pass: collect per-zone vertex+index pools. STL emits - // per-triangle facets directly; GLB packs everything into BIN. - struct ZonePool { - std::string name; - std::vector verts; - std::vector indices; - }; - std::vector zones; - int totalZones = 0, totalTiles = 0; - glm::vec3 bMin{1e30f}, bMax{-1e30f}; - for (const auto& zoneDir : zoneDirs) { - wowee::editor::ZoneManifest zm; - if (!zm.load(zoneDir + "/zone.json")) continue; - ZonePool zp; - zp.name = zm.mapName; - int zoneTiles = 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 chunkBase = static_cast(zp.verts.size()); - 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] - }; - zp.verts.push_back(p); - bMin = glm::min(bMin, p); - bMax = glm::max(bMax, p); - } - } - 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 chunkBase + r * 9 + c; - }; - zp.indices.push_back(idx(row, col)); - zp.indices.push_back(idx(row, col + 1)); - zp.indices.push_back(idx(row + 1, col + 1)); - zp.indices.push_back(idx(row, col)); - zp.indices.push_back(idx(row + 1, col + 1)); - zp.indices.push_back(idx(row + 1, col)); - } - } - } - } - } - if (zp.verts.empty()) continue; - totalTiles += zoneTiles; - totalZones++; - zones.push_back(std::move(zp)); - } - if (zones.empty()) { - std::fprintf(stderr, "%s: no loadable terrain found\n", cmdName); - return 1; - } - if (isStl) { - std::ofstream out(outPath); - if (!out) { - std::fprintf(stderr, "%s: cannot write %s\n", cmdName, outPath.c_str()); - return 1; - } - out << "solid wowee_project\n"; - uint64_t triCount = 0; - for (const auto& zp : zones) { - for (size_t k = 0; k + 2 < zp.indices.size(); k += 3) { - const auto& v0 = zp.verts[zp.indices[k]]; - const auto& v1 = zp.verts[zp.indices[k + 1]]; - const auto& v2 = zp.verts[zp.indices[k + 2]]; - glm::vec3 n = glm::cross(v1 - v0, v2 - v0); - float len = glm::length(n); - if (len > 1e-12f) n /= len; else n = {0, 0, 1}; - out << " facet normal " << n.x << " " << n.y << " " << n.z << "\n" - << " outer loop\n" - << " vertex " << v0.x << " " << v0.y << " " << v0.z << "\n" - << " vertex " << v1.x << " " << v1.y << " " << v1.z << "\n" - << " vertex " << v2.x << " " << v2.y << " " << v2.z << "\n" - << " endloop\n" - << " endfacet\n"; - triCount++; - } - } - out << "endsolid wowee_project\n"; - out.close(); - std::printf("Baked %s -> %s\n", projectDir.c_str(), outPath.c_str()); - std::printf(" %d zone(s), %d tiles, %llu facets\n", - totalZones, totalTiles, - static_cast(triCount)); - return 0; - } - // GLB path: pack positions+normals+indices into one BIN chunk, - // one mesh+node per zone with sliced index accessor. - uint32_t totalV = 0, totalI = 0; - for (const auto& zp : zones) { - totalV += static_cast(zp.verts.size()); - totalI += static_cast(zp.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); - uint32_t vCursor = 0, iCursor = 0; - // Per-zone bookkeeping for accessor slicing. - struct ZoneSlice { std::string name; uint32_t vOff, vCnt, iOff, iCnt; }; - std::vector slices; - for (const auto& zp : zones) { - ZoneSlice s{zp.name, vCursor, static_cast(zp.verts.size()), - iCursor, static_cast(zp.indices.size())}; - for (const auto& v : zp.verts) { - std::memcpy(&bin[posOff + vCursor * 12 + 0], &v.x, 4); - std::memcpy(&bin[posOff + vCursor * 12 + 4], &v.y, 4); - std::memcpy(&bin[posOff + vCursor * 12 + 8], &v.z, 4); - float nx = 0, ny = 0, nz = 1; - std::memcpy(&bin[nrmOff + vCursor * 12 + 0], &nx, 4); - std::memcpy(&bin[nrmOff + vCursor * 12 + 4], &ny, 4); - std::memcpy(&bin[nrmOff + vCursor * 12 + 8], &nz, 4); - vCursor++; - } - // Offset zone indices by the global vertBase so they - // resolve into the merged pool. - for (uint32_t idx : zp.indices) { - uint32_t global = idx + s.vOff; - std::memcpy(&bin[idxOff + iCursor * 4], &global, 4); - iCursor++; - } - slices.push_back(s); - } - nlohmann::json gj; - gj["asset"] = {{"version", "2.0"}, - {"generator", "wowee_editor --bake-project-glb"}}; - gj["scene"] = 0; - gj["buffers"] = nlohmann::json::array({{{"byteLength", binSize}}}); - nlohmann::json bvs = nlohmann::json::array(); - bvs.push_back({{"buffer", 0}, {"byteOffset", posOff}, - {"byteLength", totalV * 12}, {"target", 34962}}); - bvs.push_back({{"buffer", 0}, {"byteOffset", nrmOff}, - {"byteLength", totalV * 12}, {"target", 34962}}); - bvs.push_back({{"buffer", 0}, {"byteOffset", idxOff}, - {"byteLength", totalI * 4}, {"target", 34963}}); - gj["bufferViews"] = bvs; - 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"}}); - nlohmann::json meshes = nlohmann::json::array(); - nlohmann::json nodes = nlohmann::json::array(); - nlohmann::json sceneNodes = nlohmann::json::array(); - for (const auto& s : slices) { - uint32_t accIdx = static_cast(accessors.size()); - accessors.push_back({{"bufferView", 2}, - {"byteOffset", s.iOff * 4}, - {"componentType", 5125}, - {"count", s.iCnt}, {"type", "SCALAR"}}); - uint32_t meshIdx = static_cast(meshes.size()); - meshes.push_back({{"primitives", nlohmann::json::array({nlohmann::json{ - {"attributes", {{"POSITION", 0}, {"NORMAL", 1}}}, - {"indices", accIdx}, {"mode", 4}}})}}); - uint32_t nodeIdx = static_cast(nodes.size()); - nodes.push_back({{"name", "zone_" + s.name}, {"mesh", meshIdx}}); - sceneNodes.push_back(nodeIdx); - } - gj["accessors"] = accessors; - gj["meshes"] = meshes; - gj["nodes"] = nodes; - gj["scenes"] = nlohmann::json::array({{{"nodes", sceneNodes}}}); - 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, "%s: cannot write %s\n", cmdName, 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 jt = 0x4E4F534A; - out.write(reinterpret_cast(&jsonLen), 4); - out.write(reinterpret_cast(&jt), 4); - out.write(jsonStr.data(), jsonLen); - uint32_t bt = 0x004E4942; - out.write(reinterpret_cast(&binLen), 4); - out.write(reinterpret_cast(&bt), 4); - out.write(reinterpret_cast(bin.data()), binLen); - out.close(); - std::printf("Baked %s -> %s\n", projectDir.c_str(), outPath.c_str()); - std::printf(" %d zone(s), %d tiles, %u verts, %u tris, %u-byte BIN\n", - totalZones, totalTiles, totalV, totalI / 3, 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