diff --git a/tools/editor/main.cpp b/tools/editor/main.cpp index b5c44889..3902238e 100644 --- a/tools/editor/main.cpp +++ b/tools/editor/main.cpp @@ -8,6 +8,7 @@ #include "terrain_editor.hpp" #include "terrain_biomes.hpp" #include +#include #include "pipeline/wowee_model.hpp" #include "pipeline/wowee_building.hpp" #include "pipeline/wowee_collision.hpp" @@ -421,6 +422,8 @@ static void printUsage(const char* argv0) { std::printf(" --regen-collision Rebuild every WOC under a zone dir and exit\n"); std::printf(" --fix-zone Re-parse + re-save zone JSONs to apply latest scrubs/caps and exit\n"); std::printf(" --export-png Render heightmap, normal-map, and zone-map PNG previews\n"); + std::printf(" --export-obj [out.obj]\n"); + std::printf(" Convert a WOM model to Wavefront OBJ for use in Blender/MeshLab\n"); std::printf(" --validate [--json]\n"); std::printf(" Score zone open-format completeness and exit\n"); std::printf(" --validate-wom [--json]\n"); @@ -493,7 +496,7 @@ int main(int argc, char* argv[]) { "--remove-creature", "--remove-object", "--remove-quest", "--copy-zone", "--build-woc", "--regen-collision", "--fix-zone", - "--export-png", + "--export-png", "--export-obj", "--convert-m2", "--convert-wmo", }; for (int i = 1; i < argc; i++) { @@ -1859,6 +1862,103 @@ int main(int argc, char* argv[]) { for (const auto& e : errs) std::printf(" - %s\n", e.c_str()); } return 1; + } else if (std::strcmp(argv[i], "--export-obj") == 0 && i + 1 < argc) { + // Convert WOM (our open M2 replacement) to Wavefront OBJ — a + // universally supported text format that opens directly in + // Blender, MeshLab, ZBrush, Maya, and basically every other 3D + // tool ever made. Makes the open-format ecosystem actually + // useful for content authors who don't want to write a custom + // WOM importer for their DCC of choice. + 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) == ".wom") + base = base.substr(0, base.size() - 4); + if (!wowee::pipeline::WoweeModelLoader::exists(base)) { + std::fprintf(stderr, "WOM not found: %s.wom\n", base.c_str()); + return 1; + } + if (outPath.empty()) outPath = base + ".obj"; + auto wom = wowee::pipeline::WoweeModelLoader::load(base); + if (!wom.isValid()) { + std::fprintf(stderr, "WOM has no geometry to export: %s.wom\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; + } + // Header — preserves provenance so a designer reopening the OBJ + // weeks later knows where it came from. The MTL line is a + // courtesy: we don't currently emit a .mtl, but downstream + // tools won't error without one either. + obj << "# Wavefront OBJ generated by wowee_editor --export-obj\n"; + obj << "# Source: " << base << ".wom (v" << wom.version << ")\n"; + obj << "# Verts: " << wom.vertices.size() + << " Tris: " << wom.indices.size() / 3 + << " Textures: " << wom.texturePaths.size() << "\n\n"; + obj << "o " << (wom.name.empty() ? "WoweeModel" : wom.name) << "\n"; + // Positions (v), texcoords (vt), normals (vn) — OBJ flips V so + // that the same UVs that look right in our Vulkan renderer + // also look right in Blender's bottom-left UV convention. + for (const auto& v : wom.vertices) { + obj << "v " << v.position.x << " " << v.position.y + << " " << v.position.z << "\n"; + } + for (const auto& v : wom.vertices) { + obj << "vt " << v.texCoord.x << " " << (1.0f - v.texCoord.y) << "\n"; + } + for (const auto& v : wom.vertices) { + obj << "vn " << v.normal.x << " " << v.normal.y + << " " << v.normal.z << "\n"; + } + // Faces — split per-batch so each material/texture range becomes + // its own group. Falls back to a single group when the WOM + // wasn't authored with batches (WOM1/WOM2). OBJ indices are + // 1-based, hence the +1. + auto emitFaces = [&](const char* groupName, + uint32_t start, uint32_t count) { + obj << "g " << groupName << "\n"; + for (uint32_t k = 0; k < count; k += 3) { + uint32_t i0 = wom.indices[start + k] + 1; + uint32_t i1 = wom.indices[start + k + 1] + 1; + uint32_t i2 = wom.indices[start + k + 2] + 1; + obj << "f " + << i0 << "/" << i0 << "/" << i0 << " " + << i1 << "/" << i1 << "/" << i1 << " " + << i2 << "/" << i2 << "/" << i2 << "\n"; + } + }; + if (wom.batches.empty()) { + emitFaces("mesh", 0, + static_cast(wom.indices.size())); + } else { + for (size_t b = 0; b < wom.batches.size(); ++b) { + const auto& batch = wom.batches[b]; + std::string groupName = "batch_" + std::to_string(b); + if (batch.textureIndex < wom.texturePaths.size()) { + // Strip directory + extension for a readable group + // name; full path is preserved in the file header + // comment so nothing is lost. + std::string tex = wom.texturePaths[batch.textureIndex]; + auto slash = tex.find_last_of("/\\"); + if (slash != std::string::npos) tex = tex.substr(slash + 1); + auto dot = tex.find_last_of('.'); + if (dot != std::string::npos) tex = tex.substr(0, dot); + if (!tex.empty()) groupName += "_" + tex; + } + emitFaces(groupName.c_str(), batch.indexStart, batch.indexCount); + } + } + obj.close(); + std::printf("Exported %s.wom -> %s\n", base.c_str(), outPath.c_str()); + std::printf(" %zu verts, %zu tris, %zu groups\n", + wom.vertices.size(), wom.indices.size() / 3, + wom.batches.empty() ? size_t(1) : wom.batches.size()); + return 0; } else if (std::strcmp(argv[i], "--export-png") == 0 && i + 1 < argc) { // Render heightmap, normal-map, and zone-map PNG previews for a // terrain. Useful for portfolio screenshots, ground-truth map