From 191a821ec8bd0a9c64ed50b8ddd1a9ad9dcb94ee Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 9 May 2026 07:50:02 -0700 Subject: [PATCH] refactor(editor): move --import-obj into cli_wom_io.cpp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds --import-obj as the 5th handler in cli_wom_io.{hpp,cpp}, joining its export-side sibling. The handler is the WOM-side counterpart to --import-wob-obj (which lives in cli_world_io because it produces WOB) — together they close the OBJ round-trip story for both single meshes and multi-group buildings. main.cpp shrinks by 183 lines (6,786 to 6,603). --- tools/editor/cli_wom_io.cpp | 188 ++++++++++++++++++++++++++++++++++++ tools/editor/cli_wom_io.hpp | 1 + tools/editor/main.cpp | 183 ----------------------------------- 3 files changed, 189 insertions(+), 183 deletions(-) diff --git a/tools/editor/cli_wom_io.cpp b/tools/editor/cli_wom_io.cpp index ee980233..2cf86c14 100644 --- a/tools/editor/cli_wom_io.cpp +++ b/tools/editor/cli_wom_io.cpp @@ -501,6 +501,191 @@ int handleImportStl(int& i, int argc, char** argv) { return 0; } +int handleImportObj(int& i, int argc, char** argv) { + // Convert a Wavefront OBJ back into WOM. Round-trips with + // --export-obj for the geometry/UV/normal data; bones, + // animations, and material flags are not in OBJ and stay + // empty (the resulting WOM is WOM1, static-only). The intent + // is "edit a static prop in Blender, ship it". + std::string objPath = argv[++i]; + std::string womBase; + if (i + 1 < argc && argv[i + 1][0] != '-') { + womBase = argv[++i]; + } + if (!std::filesystem::exists(objPath)) { + std::fprintf(stderr, "OBJ not found: %s\n", objPath.c_str()); + return 1; + } + if (womBase.empty()) { + womBase = objPath; + if (womBase.size() >= 4 && + womBase.substr(womBase.size() - 4) == ".obj") { + womBase = womBase.substr(0, womBase.size() - 4); + } + } + std::ifstream in(objPath); + if (!in) { + std::fprintf(stderr, "Failed to open OBJ: %s\n", objPath.c_str()); + return 1; + } + // Pools — OBJ stores positions/UVs/normals in independent + // arrays and references them by index in face lines, so we + // collect each pool first then expand into WOM vertices on + // the fly (one WOM vertex per (vIdx, vtIdx, vnIdx) triple + // since WOM has interleaved vertex data, not pooled). + std::vector positions; + std::vector texcoords; + std::vector normals; + wowee::pipeline::WoweeModel wom; + wom.version = 1; + std::unordered_map dedupe; + int badFaces = 0; + int triangulatedNgons = 0; + std::string objectName; + std::string line; + // Convert a single OBJ vertex token like "3/4/5" or "3//5" or + // "3/4" or "3" into a WOM vertex index, deduping identical + // (pos, uv, normal) triples to keep the buffer compact. + 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); + } + } + } + // Translate negative (relative) indices to absolute. + auto absIdx = [](int idx, size_t poolSize) -> int { + if (idx < 0) return static_cast(poolSize) + idx; + return idx - 1; // OBJ is 1-based + }; + 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; + std::string key = std::to_string(vi) + "/" + + std::to_string(ti) + "/" + + std::to_string(ni); + auto it = dedupe.find(key); + if (it != dedupe.end()) return static_cast(it->second); + wowee::pipeline::WoweeModel::Vertex vert; + vert.position = positions[vi]; + if (ti >= 0 && ti < static_cast(texcoords.size())) { + vert.texCoord = texcoords[ti]; + // Reverse the V-flip from --export-obj so a round-trip + // returns the original UVs unchanged. + 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}; + } + uint32_t newIdx = static_cast(wom.vertices.size()); + wom.vertices.push_back(vert); + dedupe[key] = newIdx; + return static_cast(newIdx); + }; + while (std::getline(in, line)) { + // Strip CR for CRLF files. + while (!line.empty() && (line.back() == '\r' || line.back() == ' ')) + line.pop_back(); + if (line.empty() || line[0] == '#') 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 == "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; } + // Fan-triangulate (works for triangles, quads, and + // n-gons; assumes the polygon is convex which is the + // common case from DCC exporters). + if (resolved.size() > 3) triangulatedNgons++; + for (size_t k = 1; k + 1 < resolved.size(); ++k) { + wom.indices.push_back(static_cast(resolved[0])); + wom.indices.push_back(static_cast(resolved[k])); + wom.indices.push_back(static_cast(resolved[k + 1])); + } + } + // mtllib/usemtl/g/s lines are silently skipped — material + // info doesn't survive the round-trip but groups would + // (left as future work; current import keeps it simple). + } + if (wom.vertices.empty() || wom.indices.empty()) { + std::fprintf(stderr, "import-obj: no geometry found in %s\n", + objPath.c_str()); + return 1; + } + wom.name = objectName.empty() + ? std::filesystem::path(objPath).stem().string() + : objectName; + // Compute bounds from positions — the renderer culls by these + // so wrong values cause the model to disappear at distance. + wom.boundMin = wom.vertices[0].position; + wom.boundMax = wom.boundMin; + for (const auto& v : wom.vertices) { + wom.boundMin = glm::min(wom.boundMin, v.position); + wom.boundMax = glm::max(wom.boundMax, v.position); + } + glm::vec3 center = (wom.boundMin + wom.boundMax) * 0.5f; + float r2 = 0; + for (const auto& v : wom.vertices) { + glm::vec3 d = v.position - center; + r2 = std::max(r2, glm::dot(d, d)); + } + wom.boundRadius = std::sqrt(r2); + if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { + std::fprintf(stderr, "import-obj: failed to write %s.wom\n", + womBase.c_str()); + return 1; + } + std::printf("Imported %s -> %s.wom\n", objPath.c_str(), womBase.c_str()); + std::printf(" %zu verts, %zu tris, bounds [%.2f, %.2f, %.2f] - [%.2f, %.2f, %.2f]\n", + wom.vertices.size(), wom.indices.size() / 3, + wom.boundMin.x, wom.boundMin.y, wom.boundMin.z, + wom.boundMax.x, wom.boundMax.y, wom.boundMax.z); + 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; +} + } // namespace bool handleWomIo(int& i, int argc, char** argv, int& outRc) { @@ -516,6 +701,9 @@ bool handleWomIo(int& i, int argc, char** argv, int& outRc) { if (std::strcmp(argv[i], "--import-stl") == 0 && i + 1 < argc) { outRc = handleImportStl(i, argc, argv); return true; } + if (std::strcmp(argv[i], "--import-obj") == 0 && i + 1 < argc) { + outRc = handleImportObj(i, argc, argv); return true; + } return false; } diff --git a/tools/editor/cli_wom_io.hpp b/tools/editor/cli_wom_io.hpp index d5424583..d7522f5a 100644 --- a/tools/editor/cli_wom_io.hpp +++ b/tools/editor/cli_wom_io.hpp @@ -11,6 +11,7 @@ namespace cli { // --export-glb WOM -> glTF 2.0 binary (browsers, Three.js) // --export-stl WOM -> ASCII STL (slicers / 3D printers) // --import-stl ASCII STL -> WOM (round-trip from CAD tools) +// --import-obj Wavefront OBJ -> WOM (round-trip from Blender etc.) // // Returns true if matched; outRc holds the exit code. bool handleWomIo(int& i, int argc, char** argv, int& outRc); diff --git a/tools/editor/main.cpp b/tools/editor/main.cpp index 5f71af57..ddf8eb27 100644 --- a/tools/editor/main.cpp +++ b/tools/editor/main.cpp @@ -1276,189 +1276,6 @@ int main(int argc, char* argv[]) { t.loadMs, t.tiles, t.chunks, mspt); } 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, - // animations, and material flags are not in OBJ and stay - // empty (the resulting WOM is WOM1, static-only). The intent - // is "edit a static prop in Blender, ship it". - std::string objPath = argv[++i]; - std::string womBase; - if (i + 1 < argc && argv[i + 1][0] != '-') { - womBase = argv[++i]; - } - if (!std::filesystem::exists(objPath)) { - std::fprintf(stderr, "OBJ not found: %s\n", objPath.c_str()); - return 1; - } - if (womBase.empty()) { - womBase = objPath; - if (womBase.size() >= 4 && - womBase.substr(womBase.size() - 4) == ".obj") { - womBase = womBase.substr(0, womBase.size() - 4); - } - } - std::ifstream in(objPath); - if (!in) { - std::fprintf(stderr, "Failed to open OBJ: %s\n", objPath.c_str()); - return 1; - } - // Pools — OBJ stores positions/UVs/normals in independent - // arrays and references them by index in face lines, so we - // collect each pool first then expand into WOM vertices on - // the fly (one WOM vertex per (vIdx, vtIdx, vnIdx) triple - // since WOM has interleaved vertex data, not pooled). - std::vector positions; - std::vector texcoords; - std::vector normals; - wowee::pipeline::WoweeModel wom; - wom.version = 1; - std::unordered_map dedupe; - int badFaces = 0; - int triangulatedNgons = 0; - std::string objectName; - std::string line; - // Convert a single OBJ vertex token like "3/4/5" or "3//5" or - // "3/4" or "3" into a WOM vertex index, deduping identical - // (pos, uv, normal) triples to keep the buffer compact. - 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); - } - } - } - // Translate negative (relative) indices to absolute. - auto absIdx = [](int idx, size_t poolSize) -> int { - if (idx < 0) return static_cast(poolSize) + idx; - return idx - 1; // OBJ is 1-based - }; - 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; - std::string key = std::to_string(vi) + "/" + - std::to_string(ti) + "/" + - std::to_string(ni); - auto it = dedupe.find(key); - if (it != dedupe.end()) return static_cast(it->second); - wowee::pipeline::WoweeModel::Vertex vert; - vert.position = positions[vi]; - if (ti >= 0 && ti < static_cast(texcoords.size())) { - vert.texCoord = texcoords[ti]; - // Reverse the V-flip from --export-obj so a round-trip - // returns the original UVs unchanged. - 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}; - } - uint32_t newIdx = static_cast(wom.vertices.size()); - wom.vertices.push_back(vert); - dedupe[key] = newIdx; - return static_cast(newIdx); - }; - while (std::getline(in, line)) { - // Strip CR for CRLF files. - while (!line.empty() && (line.back() == '\r' || line.back() == ' ')) - line.pop_back(); - if (line.empty() || line[0] == '#') 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 == "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; } - // Fan-triangulate (works for triangles, quads, and - // n-gons; assumes the polygon is convex which is the - // common case from DCC exporters). - if (resolved.size() > 3) triangulatedNgons++; - for (size_t k = 1; k + 1 < resolved.size(); ++k) { - wom.indices.push_back(static_cast(resolved[0])); - wom.indices.push_back(static_cast(resolved[k])); - wom.indices.push_back(static_cast(resolved[k + 1])); - } - } - // mtllib/usemtl/g/s lines are silently skipped — material - // info doesn't survive the round-trip but groups would - // (left as future work; current import keeps it simple). - } - if (wom.vertices.empty() || wom.indices.empty()) { - std::fprintf(stderr, "import-obj: no geometry found in %s\n", - objPath.c_str()); - return 1; - } - wom.name = objectName.empty() - ? std::filesystem::path(objPath).stem().string() - : objectName; - // Compute bounds from positions — the renderer culls by these - // so wrong values cause the model to disappear at distance. - wom.boundMin = wom.vertices[0].position; - wom.boundMax = wom.boundMin; - for (const auto& v : wom.vertices) { - wom.boundMin = glm::min(wom.boundMin, v.position); - wom.boundMax = glm::max(wom.boundMax, v.position); - } - glm::vec3 center = (wom.boundMin + wom.boundMax) * 0.5f; - float r2 = 0; - for (const auto& v : wom.vertices) { - glm::vec3 d = v.position - center; - r2 = std::max(r2, glm::dot(d, d)); - } - wom.boundRadius = std::sqrt(r2); - if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { - std::fprintf(stderr, "import-obj: failed to write %s.wom\n", - womBase.c_str()); - return 1; - } - std::printf("Imported %s -> %s.wom\n", objPath.c_str(), womBase.c_str()); - std::printf(" %zu verts, %zu tris, bounds [%.2f, %.2f, %.2f] - [%.2f, %.2f, %.2f]\n", - wom.vertices.size(), wom.indices.size() / 3, - wom.boundMin.x, wom.boundMin.y, wom.boundMin.z, - wom.boundMax.x, wom.boundMax.y, wom.boundMax.z); - 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-png") == 0 && i + 1 < argc) { // Render heightmap, normal-map, and zone-map PNG previews for a // terrain. Useful for portfolio screenshots, ground-truth map