diff --git a/CMakeLists.txt b/CMakeLists.txt index a5ec2d60..98224b9d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1308,6 +1308,7 @@ add_executable(wowee_editor tools/editor/cli_gen_texture.cpp tools/editor/cli_gen_mesh.cpp tools/editor/cli_mesh_io.cpp + tools/editor/cli_mesh_edit.cpp tools/editor/editor_app.cpp tools/editor/editor_camera.cpp tools/editor/editor_viewport.cpp diff --git a/tools/editor/cli_mesh_edit.cpp b/tools/editor/cli_mesh_edit.cpp new file mode 100644 index 00000000..9a60938c --- /dev/null +++ b/tools/editor/cli_mesh_edit.cpp @@ -0,0 +1,824 @@ +#include "cli_mesh_edit.hpp" + +#include "pipeline/wowee_model.hpp" +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace editor { +namespace cli { + +namespace { + +int handleAddTextureToMesh(int& i, int argc, char** argv) { + // Manual companion to --gen-mesh-textured. Binds an + // existing PNG to a WOM by appending it to texturePaths + // (or reusing the slot if already present) and pointing + // the chosen batch at it. + // + // The PNG path stored in the WOM is just the leaf — the + // runtime resolves textures relative to the model's own + // directory, so the user is responsible for placing the + // PNG next to the WOM. + std::string womBase = argv[++i]; + std::string pngPath = argv[++i]; + int batchIdx = 0; + if (i + 1 < argc && argv[i + 1][0] != '-') { + try { batchIdx = std::stoi(argv[++i]); } + catch (...) { + std::fprintf(stderr, + "add-texture-to-mesh: batchIdx must be an integer\n"); + return 1; + } + } + // Strip .wom if user passed a full filename. + if (womBase.size() >= 4 && + womBase.substr(womBase.size() - 4) == ".wom") { + womBase = womBase.substr(0, womBase.size() - 4); + } + namespace fs = std::filesystem; + if (!wowee::pipeline::WoweeModelLoader::exists(womBase)) { + std::fprintf(stderr, + "add-texture-to-mesh: %s.wom does not exist\n", + womBase.c_str()); + return 1; + } + if (!fs::exists(pngPath)) { + std::fprintf(stderr, + "add-texture-to-mesh: png '%s' does not exist\n", + pngPath.c_str()); + return 1; + } + auto wom = wowee::pipeline::WoweeModelLoader::load(womBase); + if (!wom.isValid()) { + std::fprintf(stderr, + "add-texture-to-mesh: failed to load %s.wom\n", + womBase.c_str()); + return 1; + } + if (wom.batches.empty()) { + std::fprintf(stderr, + "add-texture-to-mesh: %s.wom has no batches " + "(run --migrate-wom to upgrade WOM1/WOM2 first)\n", + womBase.c_str()); + return 1; + } + if (batchIdx < 0 || + static_cast(batchIdx) >= wom.batches.size()) { + std::fprintf(stderr, + "add-texture-to-mesh: batchIdx %d out of range " + "(have %zu batches)\n", + batchIdx, wom.batches.size()); + return 1; + } + std::string pngLeaf = fs::path(pngPath).filename().string(); + // Reuse texture slot if the leaf is already in the table; + // otherwise append a new slot at the end. + uint32_t texIdx = static_cast(wom.texturePaths.size()); + for (size_t k = 0; k < wom.texturePaths.size(); ++k) { + if (wom.texturePaths[k] == pngLeaf) { + texIdx = static_cast(k); + break; + } + } + if (texIdx == wom.texturePaths.size()) { + wom.texturePaths.push_back(pngLeaf); + } + wom.batches[batchIdx].textureIndex = texIdx; + if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { + std::fprintf(stderr, + "add-texture-to-mesh: failed to re-save %s.wom\n", + womBase.c_str()); + return 1; + } + std::printf("Bound %s -> %s.wom batch %d (texture slot %u)\n", + pngLeaf.c_str(), womBase.c_str(), + batchIdx, texIdx); + std::printf(" total texture slots : %zu\n", wom.texturePaths.size()); + // Warn if the PNG isn't sitting next to the WOM — the + // runtime resolves leaf paths relative to the WOM dir. + std::string womDir = fs::path(womBase).parent_path().string(); + if (womDir.empty()) womDir = "."; + std::string expected = womDir + "/" + pngLeaf; + if (!fs::exists(expected)) { + std::printf(" NOTE: %s does not exist next to the WOM\n", + expected.c_str()); + std::printf(" copy or move %s -> %s before shipping\n", + pngPath.c_str(), expected.c_str()); + } + return 0; +} + +int handleScaleMesh(int& i, int argc, char** argv) { + // Uniformly scale a WOM in place. Multiplies every + // vertex position, every bone pivot, and the bounds by + // . Normals are unchanged (uniform scale + // preserves direction). Useful for "I imported this OBJ + // but it's the wrong size" cleanup. + std::string womBase = argv[++i]; + float factor = 1.0f; + try { factor = std::stof(argv[++i]); } + catch (...) { + std::fprintf(stderr, + "scale-mesh: must be a number\n"); + return 1; + } + if (factor <= 0.0f || !std::isfinite(factor)) { + std::fprintf(stderr, + "scale-mesh: factor must be positive and finite (got %g)\n", + factor); + return 1; + } + if (womBase.size() >= 4 && + womBase.substr(womBase.size() - 4) == ".wom") { + womBase = womBase.substr(0, womBase.size() - 4); + } + if (!wowee::pipeline::WoweeModelLoader::exists(womBase)) { + std::fprintf(stderr, + "scale-mesh: %s.wom does not exist\n", womBase.c_str()); + return 1; + } + auto wom = wowee::pipeline::WoweeModelLoader::load(womBase); + if (!wom.isValid()) { + std::fprintf(stderr, + "scale-mesh: failed to load %s.wom\n", womBase.c_str()); + return 1; + } + for (auto& v : wom.vertices) v.position *= factor; + for (auto& b : wom.bones) b.pivot *= factor; + // Animation translations also scale; rotation/scale + // tracks are dimensionless. + for (auto& a : wom.animations) { + for (auto& bone : a.boneKeyframes) { + for (auto& kf : bone) kf.translation *= factor; + } + } + wom.boundMin *= factor; + wom.boundMax *= factor; + wom.boundRadius *= factor; + if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { + std::fprintf(stderr, + "scale-mesh: failed to save %s.wom\n", womBase.c_str()); + return 1; + } + std::printf("Scaled %s.wom by %g\n", womBase.c_str(), factor); + std::printf(" new bounds : (%.3f, %.3f, %.3f) - (%.3f, %.3f, %.3f)\n", + wom.boundMin.x, wom.boundMin.y, wom.boundMin.z, + wom.boundMax.x, wom.boundMax.y, wom.boundMax.z); + std::printf(" new radius : %.3f\n", wom.boundRadius); + return 0; +} + +int handleTranslateMesh(int& i, int argc, char** argv) { + // Offset every vertex (and bones / anim translations / + // bounds) by (dx, dy, dz). Useful for re-centering a + // mesh whose origin was wrong on import, or for shifting + // a procedural primitive that isn't centered the way + // you want. + std::string womBase = argv[++i]; + float dx = 0, dy = 0, dz = 0; + try { + dx = std::stof(argv[++i]); + dy = std::stof(argv[++i]); + dz = std::stof(argv[++i]); + } catch (...) { + std::fprintf(stderr, + "translate-mesh: dx/dy/dz must be numbers\n"); + return 1; + } + if (!std::isfinite(dx) || !std::isfinite(dy) || !std::isfinite(dz)) { + std::fprintf(stderr, + "translate-mesh: offsets must be finite\n"); + return 1; + } + if (womBase.size() >= 4 && + womBase.substr(womBase.size() - 4) == ".wom") { + womBase = womBase.substr(0, womBase.size() - 4); + } + if (!wowee::pipeline::WoweeModelLoader::exists(womBase)) { + std::fprintf(stderr, + "translate-mesh: %s.wom does not exist\n", womBase.c_str()); + return 1; + } + auto wom = wowee::pipeline::WoweeModelLoader::load(womBase); + if (!wom.isValid()) { + std::fprintf(stderr, + "translate-mesh: failed to load %s.wom\n", womBase.c_str()); + return 1; + } + glm::vec3 d(dx, dy, dz); + for (auto& v : wom.vertices) v.position += d; + for (auto& b : wom.bones) b.pivot += d; + // Bone-relative animation translations don't shift with + // the model — only the bone pivots do, since translations + // are in bone-local space. Leave anim keyframes alone. + wom.boundMin += d; + wom.boundMax += d; + // Radius is unchanged (translation is rigid, doesn't + // change extent). + if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { + std::fprintf(stderr, + "translate-mesh: failed to save %s.wom\n", womBase.c_str()); + return 1; + } + std::printf("Translated %s.wom by (%g, %g, %g)\n", + womBase.c_str(), dx, dy, dz); + std::printf(" new bounds : (%.3f, %.3f, %.3f) - (%.3f, %.3f, %.3f)\n", + wom.boundMin.x, wom.boundMin.y, wom.boundMin.z, + wom.boundMax.x, wom.boundMax.y, wom.boundMax.z); + return 0; +} + +int handleStripMesh(int& i, int argc, char** argv) { + // Drop bones and/or animations from a WOM in place. Use + // case: a model imported with full skeleton + anims that + // will only ever be placed as static decoration — there's + // no point shipping the bone data, and stripping it can + // shrink the file substantially. + // + // Default (no flags) is a no-op so the user explicitly + // opts in to destruction. --bones drops bones (and + // therefore animations, since they reference bones). + // --anims drops only animations. --all is shorthand for + // both. + std::string womBase = argv[++i]; + bool dropBones = false, dropAnims = false; + while (i + 1 < argc && argv[i + 1][0] == '-') { + std::string flag = argv[++i]; + if (flag == "--bones") { dropBones = true; } + else if (flag == "--anims") { dropAnims = true; } + else if (flag == "--all") { dropBones = true; dropAnims = true; } + else { + std::fprintf(stderr, + "strip-mesh: unknown flag '%s'\n", flag.c_str()); + return 1; + } + } + if (!dropBones && !dropAnims) { + std::fprintf(stderr, + "strip-mesh: no --bones / --anims / --all specified — nothing to do\n"); + return 1; + } + if (womBase.size() >= 4 && + womBase.substr(womBase.size() - 4) == ".wom") { + womBase = womBase.substr(0, womBase.size() - 4); + } + namespace fs = std::filesystem; + std::string fullPath = womBase + ".wom"; + if (!wowee::pipeline::WoweeModelLoader::exists(womBase)) { + std::fprintf(stderr, + "strip-mesh: %s.wom does not exist\n", womBase.c_str()); + return 1; + } + uint64_t bytesBefore = fs::file_size(fullPath); + auto wom = wowee::pipeline::WoweeModelLoader::load(womBase); + if (!wom.isValid()) { + std::fprintf(stderr, + "strip-mesh: failed to load %s.wom\n", womBase.c_str()); + return 1; + } + size_t bonesBefore = wom.bones.size(); + size_t animsBefore = wom.animations.size(); + if (dropBones) { + wom.bones.clear(); + // Bones implies anims (anims reference bones). + wom.animations.clear(); + // Reset per-vertex skinning to identity-on-bone-0 so + // a renderer that expects the field doesn't read + // stale indices. + for (auto& v : wom.vertices) { + v.boneWeights[0] = 255; + v.boneWeights[1] = 0; + v.boneWeights[2] = 0; + v.boneWeights[3] = 0; + v.boneIndices[0] = 0; + v.boneIndices[1] = 0; + v.boneIndices[2] = 0; + v.boneIndices[3] = 0; + } + } else if (dropAnims) { + wom.animations.clear(); + } + if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { + std::fprintf(stderr, + "strip-mesh: failed to save %s.wom\n", womBase.c_str()); + return 1; + } + uint64_t bytesAfter = fs::file_size(fullPath); + std::printf("Stripped %s.wom\n", womBase.c_str()); + std::printf(" bones : %zu -> %zu\n", bonesBefore, wom.bones.size()); + std::printf(" animations : %zu -> %zu\n", animsBefore, wom.animations.size()); + std::printf(" bytes : %llu -> %llu (%+lld)\n", + static_cast(bytesBefore), + static_cast(bytesAfter), + static_cast(bytesAfter) - + static_cast(bytesBefore)); + return 0; +} + +int handleRotateMesh(int& i, int argc, char** argv) { + // Rotate every vertex position and normal around the + // chosen axis (x, y, or z) by . Bone pivots + // also rotate so the skeleton stays in sync. Bounds are + // recomputed from rotated positions (axis-aligned bbox + // grows during rotation). + std::string womBase = argv[++i]; + std::string axisStr = argv[++i]; + float degrees = 0.0f; + try { degrees = std::stof(argv[++i]); } + catch (...) { + std::fprintf(stderr, + "rotate-mesh: must be a number\n"); + return 1; + } + if (!std::isfinite(degrees)) { + std::fprintf(stderr, + "rotate-mesh: degrees must be finite\n"); + return 1; + } + std::transform(axisStr.begin(), axisStr.end(), axisStr.begin(), + [](unsigned char c) { return std::tolower(c); }); + int axis = -1; + if (axisStr == "x") axis = 0; + else if (axisStr == "y") axis = 1; + else if (axisStr == "z") axis = 2; + else { + std::fprintf(stderr, + "rotate-mesh: axis must be x, y, or z (got '%s')\n", + axisStr.c_str()); + return 1; + } + if (womBase.size() >= 4 && + womBase.substr(womBase.size() - 4) == ".wom") { + womBase = womBase.substr(0, womBase.size() - 4); + } + if (!wowee::pipeline::WoweeModelLoader::exists(womBase)) { + std::fprintf(stderr, + "rotate-mesh: %s.wom does not exist\n", womBase.c_str()); + return 1; + } + auto wom = wowee::pipeline::WoweeModelLoader::load(womBase); + if (!wom.isValid()) { + std::fprintf(stderr, + "rotate-mesh: failed to load %s.wom\n", womBase.c_str()); + return 1; + } + float rad = degrees * 3.14159265358979f / 180.0f; + float cs = std::cos(rad), sn = std::sin(rad); + // Rotation around each axis: standard right-hand rule. + auto rot = [axis, cs, sn](glm::vec3 v) -> glm::vec3 { + if (axis == 0) { + return glm::vec3(v.x, + cs * v.y - sn * v.z, + sn * v.y + cs * v.z); + } + if (axis == 1) { + return glm::vec3( cs * v.x + sn * v.z, + v.y, + -sn * v.x + cs * v.z); + } + return glm::vec3(cs * v.x - sn * v.y, + sn * v.x + cs * v.y, + v.z); + }; + for (auto& v : wom.vertices) { + v.position = rot(v.position); + v.normal = rot(v.normal); + } + for (auto& b : wom.bones) { + b.pivot = rot(b.pivot); + } + // Recompute bounds from rotated vertices (axis-aligned + // bbox can only grow under rotation, so reuse the loop). + wom.boundMin = glm::vec3(1e30f); + wom.boundMax = glm::vec3(-1e30f); + for (const auto& v : wom.vertices) { + wom.boundMin = glm::min(wom.boundMin, v.position); + wom.boundMax = glm::max(wom.boundMax, v.position); + } + wom.boundRadius = glm::length(wom.boundMax - wom.boundMin) * 0.5f; + if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { + std::fprintf(stderr, + "rotate-mesh: failed to save %s.wom\n", womBase.c_str()); + return 1; + } + std::printf("Rotated %s.wom by %g° around %s\n", + womBase.c_str(), degrees, axisStr.c_str()); + std::printf(" new bounds : (%.3f, %.3f, %.3f) - (%.3f, %.3f, %.3f)\n", + wom.boundMin.x, wom.boundMin.y, wom.boundMin.z, + wom.boundMax.x, wom.boundMax.y, wom.boundMax.z); + return 0; +} + +int handleCenterMesh(int& i, int argc, char** argv) { + // Translate the mesh so the bounds center lands at the + // origin. Convenience for "this mesh's pivot is in some + // weird corner — make it center-pivoted." Doesn't change + // shape, just shifts. + std::string womBase = argv[++i]; + if (womBase.size() >= 4 && + womBase.substr(womBase.size() - 4) == ".wom") { + womBase = womBase.substr(0, womBase.size() - 4); + } + if (!wowee::pipeline::WoweeModelLoader::exists(womBase)) { + std::fprintf(stderr, + "center-mesh: %s.wom does not exist\n", womBase.c_str()); + return 1; + } + auto wom = wowee::pipeline::WoweeModelLoader::load(womBase); + if (!wom.isValid()) { + std::fprintf(stderr, + "center-mesh: failed to load %s.wom\n", womBase.c_str()); + return 1; + } + glm::vec3 center = (wom.boundMin + wom.boundMax) * 0.5f; + for (auto& v : wom.vertices) v.position -= center; + for (auto& b : wom.bones) b.pivot -= center; + wom.boundMin -= center; + wom.boundMax -= center; + // Radius is preserved (pure translation). + if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { + std::fprintf(stderr, + "center-mesh: failed to save %s.wom\n", womBase.c_str()); + return 1; + } + std::printf("Centered %s.wom (shifted by %g, %g, %g)\n", + womBase.c_str(), -center.x, -center.y, -center.z); + std::printf(" new bounds : (%.3f, %.3f, %.3f) - (%.3f, %.3f, %.3f)\n", + wom.boundMin.x, wom.boundMin.y, wom.boundMin.z, + wom.boundMax.x, wom.boundMax.y, wom.boundMax.z); + return 0; +} + +int handleFlipMeshNormals(int& i, int argc, char** argv) { + // Invert every vertex normal. Use case: an OBJ imported + // with flipped winding renders inside-out — flipping the + // normals makes shading correct without re-winding the + // index buffer (which would also need batch-aware care). + // Also useful for skybox-like meshes where the "outside" + // texture should appear when looking from inside. + std::string womBase = argv[++i]; + if (womBase.size() >= 4 && + womBase.substr(womBase.size() - 4) == ".wom") { + womBase = womBase.substr(0, womBase.size() - 4); + } + if (!wowee::pipeline::WoweeModelLoader::exists(womBase)) { + std::fprintf(stderr, + "flip-mesh-normals: %s.wom does not exist\n", + womBase.c_str()); + return 1; + } + auto wom = wowee::pipeline::WoweeModelLoader::load(womBase); + if (!wom.isValid()) { + std::fprintf(stderr, + "flip-mesh-normals: failed to load %s.wom\n", + womBase.c_str()); + return 1; + } + for (auto& v : wom.vertices) v.normal = -v.normal; + if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { + std::fprintf(stderr, + "flip-mesh-normals: failed to save %s.wom\n", + womBase.c_str()); + return 1; + } + std::printf("Flipped normals on %s.wom (%zu vertices)\n", + womBase.c_str(), wom.vertices.size()); + return 0; +} + +int handleMirrorMesh(int& i, int argc, char** argv) { + // Mirror every vertex + normal across the chosen axis. + // Negating just one position component reverses face + // winding (the triangle's signed area flips), so we + // also swap the second and third index of every triangle + // to keep front-faces facing forward and lighting + // correct. Bone pivots mirror too. + // + // Useful for "I have a left arm, mirror it for the right + // arm" content reuse. The output is byte-stable + // independent of execution order. + std::string womBase = argv[++i]; + std::string axisStr = argv[++i]; + std::transform(axisStr.begin(), axisStr.end(), axisStr.begin(), + [](unsigned char c) { return std::tolower(c); }); + int axis = -1; + if (axisStr == "x") axis = 0; + else if (axisStr == "y") axis = 1; + else if (axisStr == "z") axis = 2; + else { + std::fprintf(stderr, + "mirror-mesh: axis must be x, y, or z (got '%s')\n", + axisStr.c_str()); + return 1; + } + if (womBase.size() >= 4 && + womBase.substr(womBase.size() - 4) == ".wom") { + womBase = womBase.substr(0, womBase.size() - 4); + } + if (!wowee::pipeline::WoweeModelLoader::exists(womBase)) { + std::fprintf(stderr, + "mirror-mesh: %s.wom does not exist\n", womBase.c_str()); + return 1; + } + auto wom = wowee::pipeline::WoweeModelLoader::load(womBase); + if (!wom.isValid()) { + std::fprintf(stderr, + "mirror-mesh: failed to load %s.wom\n", womBase.c_str()); + return 1; + } + for (auto& v : wom.vertices) { + v.position[axis] = -v.position[axis]; + v.normal[axis] = -v.normal[axis]; + } + for (auto& b : wom.bones) { + b.pivot[axis] = -b.pivot[axis]; + } + // Flip winding: swap idx[1] and idx[2] of every triangle. + // Indices are stored as a flat list of triangle triples. + for (size_t k = 0; k + 2 < wom.indices.size(); k += 3) { + std::swap(wom.indices[k + 1], wom.indices[k + 2]); + } + // Bounds: the mirrored extent on this axis is just the + // negation of the previous extent — recompute from + // vertices to be safe. + wom.boundMin = glm::vec3(1e30f); + wom.boundMax = glm::vec3(-1e30f); + for (const auto& v : wom.vertices) { + wom.boundMin = glm::min(wom.boundMin, v.position); + wom.boundMax = glm::max(wom.boundMax, v.position); + } + wom.boundRadius = glm::length(wom.boundMax - wom.boundMin) * 0.5f; + if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { + std::fprintf(stderr, + "mirror-mesh: failed to save %s.wom\n", womBase.c_str()); + return 1; + } + std::printf("Mirrored %s.wom across %s axis\n", + womBase.c_str(), axisStr.c_str()); + std::printf(" vertices touched : %zu\n", wom.vertices.size()); + std::printf(" triangles flipped: %zu\n", wom.indices.size() / 3); + std::printf(" new bounds : (%.3f, %.3f, %.3f) - (%.3f, %.3f, %.3f)\n", + wom.boundMin.x, wom.boundMin.y, wom.boundMin.z, + wom.boundMax.x, wom.boundMax.y, wom.boundMax.z); + return 0; +} + +int handleSmoothMeshNormals(int& i, int argc, char** argv) { + // Recompute per-vertex normals as the area-weighted + // average of incident face normals. Useful when: + // - Imported geometry has no normals (--import-obj + // leaves them zero or face-flat). + // - Custom transforms have desynced normals from the + // positions (e.g., user post-processed the WOM + // externally). + // - Faceted-by-construction meshes (cube, stairs) need + // a smooth re-shade for stylistic reasons. + // + // The cross-product magnitude is twice the triangle area, + // which weights large faces more — bigger triangles + // contribute more to the local surface direction. + std::string womBase = argv[++i]; + if (womBase.size() >= 4 && + womBase.substr(womBase.size() - 4) == ".wom") { + womBase = womBase.substr(0, womBase.size() - 4); + } + if (!wowee::pipeline::WoweeModelLoader::exists(womBase)) { + std::fprintf(stderr, + "smooth-mesh-normals: %s.wom does not exist\n", + womBase.c_str()); + return 1; + } + auto wom = wowee::pipeline::WoweeModelLoader::load(womBase); + if (!wom.isValid()) { + std::fprintf(stderr, + "smooth-mesh-normals: failed to load %s.wom\n", + womBase.c_str()); + return 1; + } + // Reset vertex normals to zero so the accumulator sums + // cleanly. + for (auto& v : wom.vertices) v.normal = glm::vec3(0); + for (size_t k = 0; k + 2 < wom.indices.size(); k += 3) { + uint32_t i0 = wom.indices[k]; + uint32_t i1 = wom.indices[k + 1]; + uint32_t i2 = wom.indices[k + 2]; + if (i0 >= wom.vertices.size() || + i1 >= wom.vertices.size() || + i2 >= wom.vertices.size()) continue; + glm::vec3 p0 = wom.vertices[i0].position; + glm::vec3 p1 = wom.vertices[i1].position; + glm::vec3 p2 = wom.vertices[i2].position; + // Cross product magnitude == 2 * triangle area, used + // as the weight. + glm::vec3 faceN = glm::cross(p1 - p0, p2 - p0); + wom.vertices[i0].normal += faceN; + wom.vertices[i1].normal += faceN; + wom.vertices[i2].normal += faceN; + } + int normalized = 0, degenerate = 0; + for (auto& v : wom.vertices) { + float len = glm::length(v.normal); + if (len > 1e-6f) { + v.normal /= len; + normalized++; + } else { + // Vertex unreferenced or sum cancelled — fall + // back to "up" rather than leaving zero so the + // shader doesn't get a dark NaN spot. + v.normal = glm::vec3(0, 1, 0); + degenerate++; + } + } + if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { + std::fprintf(stderr, + "smooth-mesh-normals: failed to save %s.wom\n", + womBase.c_str()); + return 1; + } + std::printf("Smoothed normals on %s.wom\n", womBase.c_str()); + std::printf(" vertices touched : %zu\n", wom.vertices.size()); + std::printf(" triangles read : %zu\n", wom.indices.size() / 3); + std::printf(" normalized : %d\n", normalized); + if (degenerate > 0) { + std::printf(" degenerate : %d (set to (0,1,0))\n", + degenerate); + } + return 0; +} + +int handleMergeMeshes(int& i, int argc, char** argv) { + // Combine two WOMs into one. The second mesh's indices + // are offset by the first mesh's vertex count, and its + // batches are appended with their indexStart shifted by + // the first mesh's index count and their textureIndex + // shifted by the first mesh's texture-slot count. + // + // Bones/animations are NOT merged — that requires + // skeleton retargeting which is beyond a simple + // concatenation. If either input has bones, the merged + // output is treated as static (bones cleared, weights + // reset to identity-on-bone-0) so renderers don't read + // mismatched indices. + std::string aBase = argv[++i]; + std::string bBase = argv[++i]; + std::string outBase = argv[++i]; + auto stripExt = [](std::string p) { + if (p.size() >= 4 && p.substr(p.size() - 4) == ".wom") { + return p.substr(0, p.size() - 4); + } + return p; + }; + aBase = stripExt(aBase); + bBase = stripExt(bBase); + outBase = stripExt(outBase); + if (!wowee::pipeline::WoweeModelLoader::exists(aBase)) { + std::fprintf(stderr, + "merge-meshes: %s.wom does not exist\n", aBase.c_str()); + return 1; + } + if (!wowee::pipeline::WoweeModelLoader::exists(bBase)) { + std::fprintf(stderr, + "merge-meshes: %s.wom does not exist\n", bBase.c_str()); + return 1; + } + auto a = wowee::pipeline::WoweeModelLoader::load(aBase); + auto b = wowee::pipeline::WoweeModelLoader::load(bBase); + if (!a.isValid() || !b.isValid()) { + std::fprintf(stderr, + "merge-meshes: failed to load one of the inputs\n"); + return 1; + } + wowee::pipeline::WoweeModel out; + out.name = std::filesystem::path(outBase).stem().string(); + out.version = 3; + out.vertices = a.vertices; + out.vertices.insert(out.vertices.end(), + b.vertices.begin(), b.vertices.end()); + out.indices = a.indices; + uint32_t indexOffset = static_cast(a.vertices.size()); + for (uint32_t idx : b.indices) { + out.indices.push_back(idx + indexOffset); + } + out.texturePaths = a.texturePaths; + uint32_t textureOffset = static_cast(a.texturePaths.size()); + for (const auto& t : b.texturePaths) { + out.texturePaths.push_back(t); + } + // Promote single-batch / no-batch inputs into proper + // batches so the merged output is well-formed v3. + auto ensureBatch = [](const wowee::pipeline::WoweeModel& m) { + std::vector bs = m.batches; + if (bs.empty()) { + wowee::pipeline::WoweeModel::Batch only; + only.indexStart = 0; + only.indexCount = static_cast(m.indices.size()); + only.textureIndex = 0; + only.blendMode = 0; + only.flags = 0; + bs.push_back(only); + } + return bs; + }; + auto aBatches = ensureBatch(a); + auto bBatches = ensureBatch(b); + for (const auto& bt : aBatches) out.batches.push_back(bt); + uint32_t indexStartOffset = static_cast(a.indices.size()); + for (auto bt : bBatches) { + bt.indexStart += indexStartOffset; + bt.textureIndex += textureOffset; + out.batches.push_back(bt); + } + // Static-only output (see header comment). + for (auto& v : out.vertices) { + v.boneWeights[0] = 255; + v.boneWeights[1] = 0; + v.boneWeights[2] = 0; + v.boneWeights[3] = 0; + v.boneIndices[0] = 0; + v.boneIndices[1] = 0; + v.boneIndices[2] = 0; + v.boneIndices[3] = 0; + } + // Bounds: union of inputs. + out.boundMin = glm::min(a.boundMin, b.boundMin); + out.boundMax = glm::max(a.boundMax, b.boundMax); + out.boundRadius = glm::length(out.boundMax - out.boundMin) * 0.5f; + std::filesystem::path outPath(outBase); + std::filesystem::create_directories(outPath.parent_path()); + if (!wowee::pipeline::WoweeModelLoader::save(out, outBase)) { + std::fprintf(stderr, + "merge-meshes: failed to save %s.wom\n", outBase.c_str()); + return 1; + } + std::printf("Merged %s.wom + %s.wom -> %s.wom\n", + aBase.c_str(), bBase.c_str(), outBase.c_str()); + std::printf(" vertices : %zu = %zu + %zu\n", + out.vertices.size(), + a.vertices.size(), b.vertices.size()); + std::printf(" indices : %zu = %zu + %zu\n", + out.indices.size(), + a.indices.size(), b.indices.size()); + std::printf(" batches : %zu = %zu + %zu\n", + out.batches.size(), + aBatches.size(), bBatches.size()); + std::printf(" textures : %zu = %zu + %zu\n", + out.texturePaths.size(), + a.texturePaths.size(), b.texturePaths.size()); + std::printf(" bounds : (%.3f, %.3f, %.3f) - (%.3f, %.3f, %.3f)\n", + out.boundMin.x, out.boundMin.y, out.boundMin.z, + out.boundMax.x, out.boundMax.y, out.boundMax.z); + return 0; +} + + +} // namespace + +bool handleMeshEdit(int& i, int argc, char** argv, int& outRc) { + if (std::strcmp(argv[i], "--add-texture-to-mesh") == 0 && i + 2 < argc) { + outRc = handleAddTextureToMesh(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--scale-mesh") == 0 && i + 2 < argc) { + outRc = handleScaleMesh(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--translate-mesh") == 0 && i + 4 < argc) { + outRc = handleTranslateMesh(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--strip-mesh") == 0 && i + 1 < argc) { + outRc = handleStripMesh(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--rotate-mesh") == 0 && i + 2 < argc) { + outRc = handleRotateMesh(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--center-mesh") == 0 && i + 1 < argc) { + outRc = handleCenterMesh(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--flip-mesh-normals") == 0 && i + 1 < argc) { + outRc = handleFlipMeshNormals(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--mirror-mesh") == 0 && i + 2 < argc) { + outRc = handleMirrorMesh(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--smooth-mesh-normals") == 0 && i + 1 < argc) { + outRc = handleSmoothMeshNormals(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--merge-meshes") == 0 && i + 3 < argc) { + outRc = handleMergeMeshes(i, argc, argv); return true; + } + return false; +} + +} // namespace cli +} // namespace editor +} // namespace wowee diff --git a/tools/editor/cli_mesh_edit.hpp b/tools/editor/cli_mesh_edit.hpp new file mode 100644 index 00000000..cff9e663 --- /dev/null +++ b/tools/editor/cli_mesh_edit.hpp @@ -0,0 +1,19 @@ +#pragma once + +namespace wowee { +namespace editor { +namespace cli { + +// Dispatch the WOM mesh editing/transform commands: +// --add-texture-to-mesh --scale-mesh +// --translate-mesh --strip-mesh +// --rotate-mesh --center-mesh +// --flip-mesh-normals --mirror-mesh +// --smooth-mesh-normals --merge-meshes +// +// Returns true if matched; outRc holds the exit code. +bool handleMeshEdit(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 fd441c39..2f7987e5 100644 --- a/tools/editor/main.cpp +++ b/tools/editor/main.cpp @@ -9,6 +9,7 @@ #include "cli_gen_texture.hpp" #include "cli_gen_mesh.hpp" #include "cli_mesh_io.hpp" +#include "cli_mesh_edit.hpp" #include "content_pack.hpp" #include "npc_spawner.hpp" #include "object_placer.hpp" @@ -810,6 +811,9 @@ int main(int argc, char* argv[]) { if (wowee::editor::cli::handleMeshIO(i, argc, argv, outRc)) { return outRc; } + if (wowee::editor::cli::handleMeshEdit(i, argc, argv, outRc)) { + return outRc; + } } if (std::strcmp(argv[i], "--data") == 0 && i + 1 < argc) { dataPath = argv[++i]; @@ -16624,747 +16628,6 @@ int main(int argc, char* argv[]) { std::printf(" size : %dx%d\n", W, H); std::printf(" spec : %s\n", spec.c_str()); return 0; - } else if (std::strcmp(argv[i], "--add-texture-to-mesh") == 0 && i + 2 < argc) { - // Manual companion to --gen-mesh-textured. Binds an - // existing PNG to a WOM by appending it to texturePaths - // (or reusing the slot if already present) and pointing - // the chosen batch at it. - // - // The PNG path stored in the WOM is just the leaf — the - // runtime resolves textures relative to the model's own - // directory, so the user is responsible for placing the - // PNG next to the WOM. - std::string womBase = argv[++i]; - std::string pngPath = argv[++i]; - int batchIdx = 0; - if (i + 1 < argc && argv[i + 1][0] != '-') { - try { batchIdx = std::stoi(argv[++i]); } - catch (...) { - std::fprintf(stderr, - "add-texture-to-mesh: batchIdx must be an integer\n"); - return 1; - } - } - // Strip .wom if user passed a full filename. - if (womBase.size() >= 4 && - womBase.substr(womBase.size() - 4) == ".wom") { - womBase = womBase.substr(0, womBase.size() - 4); - } - namespace fs = std::filesystem; - if (!wowee::pipeline::WoweeModelLoader::exists(womBase)) { - std::fprintf(stderr, - "add-texture-to-mesh: %s.wom does not exist\n", - womBase.c_str()); - return 1; - } - if (!fs::exists(pngPath)) { - std::fprintf(stderr, - "add-texture-to-mesh: png '%s' does not exist\n", - pngPath.c_str()); - return 1; - } - auto wom = wowee::pipeline::WoweeModelLoader::load(womBase); - if (!wom.isValid()) { - std::fprintf(stderr, - "add-texture-to-mesh: failed to load %s.wom\n", - womBase.c_str()); - return 1; - } - if (wom.batches.empty()) { - std::fprintf(stderr, - "add-texture-to-mesh: %s.wom has no batches " - "(run --migrate-wom to upgrade WOM1/WOM2 first)\n", - womBase.c_str()); - return 1; - } - if (batchIdx < 0 || - static_cast(batchIdx) >= wom.batches.size()) { - std::fprintf(stderr, - "add-texture-to-mesh: batchIdx %d out of range " - "(have %zu batches)\n", - batchIdx, wom.batches.size()); - return 1; - } - std::string pngLeaf = fs::path(pngPath).filename().string(); - // Reuse texture slot if the leaf is already in the table; - // otherwise append a new slot at the end. - uint32_t texIdx = static_cast(wom.texturePaths.size()); - for (size_t k = 0; k < wom.texturePaths.size(); ++k) { - if (wom.texturePaths[k] == pngLeaf) { - texIdx = static_cast(k); - break; - } - } - if (texIdx == wom.texturePaths.size()) { - wom.texturePaths.push_back(pngLeaf); - } - wom.batches[batchIdx].textureIndex = texIdx; - if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { - std::fprintf(stderr, - "add-texture-to-mesh: failed to re-save %s.wom\n", - womBase.c_str()); - return 1; - } - std::printf("Bound %s -> %s.wom batch %d (texture slot %u)\n", - pngLeaf.c_str(), womBase.c_str(), - batchIdx, texIdx); - std::printf(" total texture slots : %zu\n", wom.texturePaths.size()); - // Warn if the PNG isn't sitting next to the WOM — the - // runtime resolves leaf paths relative to the WOM dir. - std::string womDir = fs::path(womBase).parent_path().string(); - if (womDir.empty()) womDir = "."; - std::string expected = womDir + "/" + pngLeaf; - if (!fs::exists(expected)) { - std::printf(" NOTE: %s does not exist next to the WOM\n", - expected.c_str()); - std::printf(" copy or move %s -> %s before shipping\n", - pngPath.c_str(), expected.c_str()); - } - return 0; - } else if (std::strcmp(argv[i], "--scale-mesh") == 0 && i + 2 < argc) { - // Uniformly scale a WOM in place. Multiplies every - // vertex position, every bone pivot, and the bounds by - // . Normals are unchanged (uniform scale - // preserves direction). Useful for "I imported this OBJ - // but it's the wrong size" cleanup. - std::string womBase = argv[++i]; - float factor = 1.0f; - try { factor = std::stof(argv[++i]); } - catch (...) { - std::fprintf(stderr, - "scale-mesh: must be a number\n"); - return 1; - } - if (factor <= 0.0f || !std::isfinite(factor)) { - std::fprintf(stderr, - "scale-mesh: factor must be positive and finite (got %g)\n", - factor); - return 1; - } - if (womBase.size() >= 4 && - womBase.substr(womBase.size() - 4) == ".wom") { - womBase = womBase.substr(0, womBase.size() - 4); - } - if (!wowee::pipeline::WoweeModelLoader::exists(womBase)) { - std::fprintf(stderr, - "scale-mesh: %s.wom does not exist\n", womBase.c_str()); - return 1; - } - auto wom = wowee::pipeline::WoweeModelLoader::load(womBase); - if (!wom.isValid()) { - std::fprintf(stderr, - "scale-mesh: failed to load %s.wom\n", womBase.c_str()); - return 1; - } - for (auto& v : wom.vertices) v.position *= factor; - for (auto& b : wom.bones) b.pivot *= factor; - // Animation translations also scale; rotation/scale - // tracks are dimensionless. - for (auto& a : wom.animations) { - for (auto& bone : a.boneKeyframes) { - for (auto& kf : bone) kf.translation *= factor; - } - } - wom.boundMin *= factor; - wom.boundMax *= factor; - wom.boundRadius *= factor; - if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { - std::fprintf(stderr, - "scale-mesh: failed to save %s.wom\n", womBase.c_str()); - return 1; - } - std::printf("Scaled %s.wom by %g\n", womBase.c_str(), factor); - std::printf(" new bounds : (%.3f, %.3f, %.3f) - (%.3f, %.3f, %.3f)\n", - wom.boundMin.x, wom.boundMin.y, wom.boundMin.z, - wom.boundMax.x, wom.boundMax.y, wom.boundMax.z); - std::printf(" new radius : %.3f\n", wom.boundRadius); - return 0; - } else if (std::strcmp(argv[i], "--translate-mesh") == 0 && i + 4 < argc) { - // Offset every vertex (and bones / anim translations / - // bounds) by (dx, dy, dz). Useful for re-centering a - // mesh whose origin was wrong on import, or for shifting - // a procedural primitive that isn't centered the way - // you want. - std::string womBase = argv[++i]; - float dx = 0, dy = 0, dz = 0; - try { - dx = std::stof(argv[++i]); - dy = std::stof(argv[++i]); - dz = std::stof(argv[++i]); - } catch (...) { - std::fprintf(stderr, - "translate-mesh: dx/dy/dz must be numbers\n"); - return 1; - } - if (!std::isfinite(dx) || !std::isfinite(dy) || !std::isfinite(dz)) { - std::fprintf(stderr, - "translate-mesh: offsets must be finite\n"); - return 1; - } - if (womBase.size() >= 4 && - womBase.substr(womBase.size() - 4) == ".wom") { - womBase = womBase.substr(0, womBase.size() - 4); - } - if (!wowee::pipeline::WoweeModelLoader::exists(womBase)) { - std::fprintf(stderr, - "translate-mesh: %s.wom does not exist\n", womBase.c_str()); - return 1; - } - auto wom = wowee::pipeline::WoweeModelLoader::load(womBase); - if (!wom.isValid()) { - std::fprintf(stderr, - "translate-mesh: failed to load %s.wom\n", womBase.c_str()); - return 1; - } - glm::vec3 d(dx, dy, dz); - for (auto& v : wom.vertices) v.position += d; - for (auto& b : wom.bones) b.pivot += d; - // Bone-relative animation translations don't shift with - // the model — only the bone pivots do, since translations - // are in bone-local space. Leave anim keyframes alone. - wom.boundMin += d; - wom.boundMax += d; - // Radius is unchanged (translation is rigid, doesn't - // change extent). - if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { - std::fprintf(stderr, - "translate-mesh: failed to save %s.wom\n", womBase.c_str()); - return 1; - } - std::printf("Translated %s.wom by (%g, %g, %g)\n", - womBase.c_str(), dx, dy, dz); - std::printf(" new bounds : (%.3f, %.3f, %.3f) - (%.3f, %.3f, %.3f)\n", - wom.boundMin.x, wom.boundMin.y, wom.boundMin.z, - wom.boundMax.x, wom.boundMax.y, wom.boundMax.z); - return 0; - } else if (std::strcmp(argv[i], "--strip-mesh") == 0 && i + 1 < argc) { - // Drop bones and/or animations from a WOM in place. Use - // case: a model imported with full skeleton + anims that - // will only ever be placed as static decoration — there's - // no point shipping the bone data, and stripping it can - // shrink the file substantially. - // - // Default (no flags) is a no-op so the user explicitly - // opts in to destruction. --bones drops bones (and - // therefore animations, since they reference bones). - // --anims drops only animations. --all is shorthand for - // both. - std::string womBase = argv[++i]; - bool dropBones = false, dropAnims = false; - while (i + 1 < argc && argv[i + 1][0] == '-') { - std::string flag = argv[++i]; - if (flag == "--bones") { dropBones = true; } - else if (flag == "--anims") { dropAnims = true; } - else if (flag == "--all") { dropBones = true; dropAnims = true; } - else { - std::fprintf(stderr, - "strip-mesh: unknown flag '%s'\n", flag.c_str()); - return 1; - } - } - if (!dropBones && !dropAnims) { - std::fprintf(stderr, - "strip-mesh: no --bones / --anims / --all specified — nothing to do\n"); - return 1; - } - if (womBase.size() >= 4 && - womBase.substr(womBase.size() - 4) == ".wom") { - womBase = womBase.substr(0, womBase.size() - 4); - } - namespace fs = std::filesystem; - std::string fullPath = womBase + ".wom"; - if (!wowee::pipeline::WoweeModelLoader::exists(womBase)) { - std::fprintf(stderr, - "strip-mesh: %s.wom does not exist\n", womBase.c_str()); - return 1; - } - uint64_t bytesBefore = fs::file_size(fullPath); - auto wom = wowee::pipeline::WoweeModelLoader::load(womBase); - if (!wom.isValid()) { - std::fprintf(stderr, - "strip-mesh: failed to load %s.wom\n", womBase.c_str()); - return 1; - } - size_t bonesBefore = wom.bones.size(); - size_t animsBefore = wom.animations.size(); - if (dropBones) { - wom.bones.clear(); - // Bones implies anims (anims reference bones). - wom.animations.clear(); - // Reset per-vertex skinning to identity-on-bone-0 so - // a renderer that expects the field doesn't read - // stale indices. - for (auto& v : wom.vertices) { - v.boneWeights[0] = 255; - v.boneWeights[1] = 0; - v.boneWeights[2] = 0; - v.boneWeights[3] = 0; - v.boneIndices[0] = 0; - v.boneIndices[1] = 0; - v.boneIndices[2] = 0; - v.boneIndices[3] = 0; - } - } else if (dropAnims) { - wom.animations.clear(); - } - if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { - std::fprintf(stderr, - "strip-mesh: failed to save %s.wom\n", womBase.c_str()); - return 1; - } - uint64_t bytesAfter = fs::file_size(fullPath); - std::printf("Stripped %s.wom\n", womBase.c_str()); - std::printf(" bones : %zu -> %zu\n", bonesBefore, wom.bones.size()); - std::printf(" animations : %zu -> %zu\n", animsBefore, wom.animations.size()); - std::printf(" bytes : %llu -> %llu (%+lld)\n", - static_cast(bytesBefore), - static_cast(bytesAfter), - static_cast(bytesAfter) - - static_cast(bytesBefore)); - return 0; - } else if (std::strcmp(argv[i], "--rotate-mesh") == 0 && i + 3 < argc) { - // Rotate every vertex position and normal around the - // chosen axis (x, y, or z) by . Bone pivots - // also rotate so the skeleton stays in sync. Bounds are - // recomputed from rotated positions (axis-aligned bbox - // grows during rotation). - std::string womBase = argv[++i]; - std::string axisStr = argv[++i]; - float degrees = 0.0f; - try { degrees = std::stof(argv[++i]); } - catch (...) { - std::fprintf(stderr, - "rotate-mesh: must be a number\n"); - return 1; - } - if (!std::isfinite(degrees)) { - std::fprintf(stderr, - "rotate-mesh: degrees must be finite\n"); - return 1; - } - std::transform(axisStr.begin(), axisStr.end(), axisStr.begin(), - [](unsigned char c) { return std::tolower(c); }); - int axis = -1; - if (axisStr == "x") axis = 0; - else if (axisStr == "y") axis = 1; - else if (axisStr == "z") axis = 2; - else { - std::fprintf(stderr, - "rotate-mesh: axis must be x, y, or z (got '%s')\n", - axisStr.c_str()); - return 1; - } - if (womBase.size() >= 4 && - womBase.substr(womBase.size() - 4) == ".wom") { - womBase = womBase.substr(0, womBase.size() - 4); - } - if (!wowee::pipeline::WoweeModelLoader::exists(womBase)) { - std::fprintf(stderr, - "rotate-mesh: %s.wom does not exist\n", womBase.c_str()); - return 1; - } - auto wom = wowee::pipeline::WoweeModelLoader::load(womBase); - if (!wom.isValid()) { - std::fprintf(stderr, - "rotate-mesh: failed to load %s.wom\n", womBase.c_str()); - return 1; - } - float rad = degrees * 3.14159265358979f / 180.0f; - float cs = std::cos(rad), sn = std::sin(rad); - // Rotation around each axis: standard right-hand rule. - auto rot = [axis, cs, sn](glm::vec3 v) -> glm::vec3 { - if (axis == 0) { - return glm::vec3(v.x, - cs * v.y - sn * v.z, - sn * v.y + cs * v.z); - } - if (axis == 1) { - return glm::vec3( cs * v.x + sn * v.z, - v.y, - -sn * v.x + cs * v.z); - } - return glm::vec3(cs * v.x - sn * v.y, - sn * v.x + cs * v.y, - v.z); - }; - for (auto& v : wom.vertices) { - v.position = rot(v.position); - v.normal = rot(v.normal); - } - for (auto& b : wom.bones) { - b.pivot = rot(b.pivot); - } - // Recompute bounds from rotated vertices (axis-aligned - // bbox can only grow under rotation, so reuse the loop). - wom.boundMin = glm::vec3(1e30f); - wom.boundMax = glm::vec3(-1e30f); - for (const auto& v : wom.vertices) { - wom.boundMin = glm::min(wom.boundMin, v.position); - wom.boundMax = glm::max(wom.boundMax, v.position); - } - wom.boundRadius = glm::length(wom.boundMax - wom.boundMin) * 0.5f; - if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { - std::fprintf(stderr, - "rotate-mesh: failed to save %s.wom\n", womBase.c_str()); - return 1; - } - std::printf("Rotated %s.wom by %g° around %s\n", - womBase.c_str(), degrees, axisStr.c_str()); - std::printf(" new bounds : (%.3f, %.3f, %.3f) - (%.3f, %.3f, %.3f)\n", - wom.boundMin.x, wom.boundMin.y, wom.boundMin.z, - wom.boundMax.x, wom.boundMax.y, wom.boundMax.z); - return 0; - } else if (std::strcmp(argv[i], "--center-mesh") == 0 && i + 1 < argc) { - // Translate the mesh so the bounds center lands at the - // origin. Convenience for "this mesh's pivot is in some - // weird corner — make it center-pivoted." Doesn't change - // shape, just shifts. - std::string womBase = argv[++i]; - if (womBase.size() >= 4 && - womBase.substr(womBase.size() - 4) == ".wom") { - womBase = womBase.substr(0, womBase.size() - 4); - } - if (!wowee::pipeline::WoweeModelLoader::exists(womBase)) { - std::fprintf(stderr, - "center-mesh: %s.wom does not exist\n", womBase.c_str()); - return 1; - } - auto wom = wowee::pipeline::WoweeModelLoader::load(womBase); - if (!wom.isValid()) { - std::fprintf(stderr, - "center-mesh: failed to load %s.wom\n", womBase.c_str()); - return 1; - } - glm::vec3 center = (wom.boundMin + wom.boundMax) * 0.5f; - for (auto& v : wom.vertices) v.position -= center; - for (auto& b : wom.bones) b.pivot -= center; - wom.boundMin -= center; - wom.boundMax -= center; - // Radius is preserved (pure translation). - if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { - std::fprintf(stderr, - "center-mesh: failed to save %s.wom\n", womBase.c_str()); - return 1; - } - std::printf("Centered %s.wom (shifted by %g, %g, %g)\n", - womBase.c_str(), -center.x, -center.y, -center.z); - std::printf(" new bounds : (%.3f, %.3f, %.3f) - (%.3f, %.3f, %.3f)\n", - wom.boundMin.x, wom.boundMin.y, wom.boundMin.z, - wom.boundMax.x, wom.boundMax.y, wom.boundMax.z); - return 0; - } else if (std::strcmp(argv[i], "--flip-mesh-normals") == 0 && i + 1 < argc) { - // Invert every vertex normal. Use case: an OBJ imported - // with flipped winding renders inside-out — flipping the - // normals makes shading correct without re-winding the - // index buffer (which would also need batch-aware care). - // Also useful for skybox-like meshes where the "outside" - // texture should appear when looking from inside. - std::string womBase = argv[++i]; - if (womBase.size() >= 4 && - womBase.substr(womBase.size() - 4) == ".wom") { - womBase = womBase.substr(0, womBase.size() - 4); - } - if (!wowee::pipeline::WoweeModelLoader::exists(womBase)) { - std::fprintf(stderr, - "flip-mesh-normals: %s.wom does not exist\n", - womBase.c_str()); - return 1; - } - auto wom = wowee::pipeline::WoweeModelLoader::load(womBase); - if (!wom.isValid()) { - std::fprintf(stderr, - "flip-mesh-normals: failed to load %s.wom\n", - womBase.c_str()); - return 1; - } - for (auto& v : wom.vertices) v.normal = -v.normal; - if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { - std::fprintf(stderr, - "flip-mesh-normals: failed to save %s.wom\n", - womBase.c_str()); - return 1; - } - std::printf("Flipped normals on %s.wom (%zu vertices)\n", - womBase.c_str(), wom.vertices.size()); - return 0; - } else if (std::strcmp(argv[i], "--mirror-mesh") == 0 && i + 2 < argc) { - // Mirror every vertex + normal across the chosen axis. - // Negating just one position component reverses face - // winding (the triangle's signed area flips), so we - // also swap the second and third index of every triangle - // to keep front-faces facing forward and lighting - // correct. Bone pivots mirror too. - // - // Useful for "I have a left arm, mirror it for the right - // arm" content reuse. The output is byte-stable - // independent of execution order. - std::string womBase = argv[++i]; - std::string axisStr = argv[++i]; - std::transform(axisStr.begin(), axisStr.end(), axisStr.begin(), - [](unsigned char c) { return std::tolower(c); }); - int axis = -1; - if (axisStr == "x") axis = 0; - else if (axisStr == "y") axis = 1; - else if (axisStr == "z") axis = 2; - else { - std::fprintf(stderr, - "mirror-mesh: axis must be x, y, or z (got '%s')\n", - axisStr.c_str()); - return 1; - } - if (womBase.size() >= 4 && - womBase.substr(womBase.size() - 4) == ".wom") { - womBase = womBase.substr(0, womBase.size() - 4); - } - if (!wowee::pipeline::WoweeModelLoader::exists(womBase)) { - std::fprintf(stderr, - "mirror-mesh: %s.wom does not exist\n", womBase.c_str()); - return 1; - } - auto wom = wowee::pipeline::WoweeModelLoader::load(womBase); - if (!wom.isValid()) { - std::fprintf(stderr, - "mirror-mesh: failed to load %s.wom\n", womBase.c_str()); - return 1; - } - for (auto& v : wom.vertices) { - v.position[axis] = -v.position[axis]; - v.normal[axis] = -v.normal[axis]; - } - for (auto& b : wom.bones) { - b.pivot[axis] = -b.pivot[axis]; - } - // Flip winding: swap idx[1] and idx[2] of every triangle. - // Indices are stored as a flat list of triangle triples. - for (size_t k = 0; k + 2 < wom.indices.size(); k += 3) { - std::swap(wom.indices[k + 1], wom.indices[k + 2]); - } - // Bounds: the mirrored extent on this axis is just the - // negation of the previous extent — recompute from - // vertices to be safe. - wom.boundMin = glm::vec3(1e30f); - wom.boundMax = glm::vec3(-1e30f); - for (const auto& v : wom.vertices) { - wom.boundMin = glm::min(wom.boundMin, v.position); - wom.boundMax = glm::max(wom.boundMax, v.position); - } - wom.boundRadius = glm::length(wom.boundMax - wom.boundMin) * 0.5f; - if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { - std::fprintf(stderr, - "mirror-mesh: failed to save %s.wom\n", womBase.c_str()); - return 1; - } - std::printf("Mirrored %s.wom across %s axis\n", - womBase.c_str(), axisStr.c_str()); - std::printf(" vertices touched : %zu\n", wom.vertices.size()); - std::printf(" triangles flipped: %zu\n", wom.indices.size() / 3); - std::printf(" new bounds : (%.3f, %.3f, %.3f) - (%.3f, %.3f, %.3f)\n", - wom.boundMin.x, wom.boundMin.y, wom.boundMin.z, - wom.boundMax.x, wom.boundMax.y, wom.boundMax.z); - return 0; - } else if (std::strcmp(argv[i], "--smooth-mesh-normals") == 0 && i + 1 < argc) { - // Recompute per-vertex normals as the area-weighted - // average of incident face normals. Useful when: - // - Imported geometry has no normals (--import-obj - // leaves them zero or face-flat). - // - Custom transforms have desynced normals from the - // positions (e.g., user post-processed the WOM - // externally). - // - Faceted-by-construction meshes (cube, stairs) need - // a smooth re-shade for stylistic reasons. - // - // The cross-product magnitude is twice the triangle area, - // which weights large faces more — bigger triangles - // contribute more to the local surface direction. - std::string womBase = argv[++i]; - if (womBase.size() >= 4 && - womBase.substr(womBase.size() - 4) == ".wom") { - womBase = womBase.substr(0, womBase.size() - 4); - } - if (!wowee::pipeline::WoweeModelLoader::exists(womBase)) { - std::fprintf(stderr, - "smooth-mesh-normals: %s.wom does not exist\n", - womBase.c_str()); - return 1; - } - auto wom = wowee::pipeline::WoweeModelLoader::load(womBase); - if (!wom.isValid()) { - std::fprintf(stderr, - "smooth-mesh-normals: failed to load %s.wom\n", - womBase.c_str()); - return 1; - } - // Reset vertex normals to zero so the accumulator sums - // cleanly. - for (auto& v : wom.vertices) v.normal = glm::vec3(0); - for (size_t k = 0; k + 2 < wom.indices.size(); k += 3) { - uint32_t i0 = wom.indices[k]; - uint32_t i1 = wom.indices[k + 1]; - uint32_t i2 = wom.indices[k + 2]; - if (i0 >= wom.vertices.size() || - i1 >= wom.vertices.size() || - i2 >= wom.vertices.size()) continue; - glm::vec3 p0 = wom.vertices[i0].position; - glm::vec3 p1 = wom.vertices[i1].position; - glm::vec3 p2 = wom.vertices[i2].position; - // Cross product magnitude == 2 * triangle area, used - // as the weight. - glm::vec3 faceN = glm::cross(p1 - p0, p2 - p0); - wom.vertices[i0].normal += faceN; - wom.vertices[i1].normal += faceN; - wom.vertices[i2].normal += faceN; - } - int normalized = 0, degenerate = 0; - for (auto& v : wom.vertices) { - float len = glm::length(v.normal); - if (len > 1e-6f) { - v.normal /= len; - normalized++; - } else { - // Vertex unreferenced or sum cancelled — fall - // back to "up" rather than leaving zero so the - // shader doesn't get a dark NaN spot. - v.normal = glm::vec3(0, 1, 0); - degenerate++; - } - } - if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) { - std::fprintf(stderr, - "smooth-mesh-normals: failed to save %s.wom\n", - womBase.c_str()); - return 1; - } - std::printf("Smoothed normals on %s.wom\n", womBase.c_str()); - std::printf(" vertices touched : %zu\n", wom.vertices.size()); - std::printf(" triangles read : %zu\n", wom.indices.size() / 3); - std::printf(" normalized : %d\n", normalized); - if (degenerate > 0) { - std::printf(" degenerate : %d (set to (0,1,0))\n", - degenerate); - } - return 0; - } else if (std::strcmp(argv[i], "--merge-meshes") == 0 && i + 3 < argc) { - // Combine two WOMs into one. The second mesh's indices - // are offset by the first mesh's vertex count, and its - // batches are appended with their indexStart shifted by - // the first mesh's index count and their textureIndex - // shifted by the first mesh's texture-slot count. - // - // Bones/animations are NOT merged — that requires - // skeleton retargeting which is beyond a simple - // concatenation. If either input has bones, the merged - // output is treated as static (bones cleared, weights - // reset to identity-on-bone-0) so renderers don't read - // mismatched indices. - std::string aBase = argv[++i]; - std::string bBase = argv[++i]; - std::string outBase = argv[++i]; - auto stripExt = [](std::string p) { - if (p.size() >= 4 && p.substr(p.size() - 4) == ".wom") { - return p.substr(0, p.size() - 4); - } - return p; - }; - aBase = stripExt(aBase); - bBase = stripExt(bBase); - outBase = stripExt(outBase); - if (!wowee::pipeline::WoweeModelLoader::exists(aBase)) { - std::fprintf(stderr, - "merge-meshes: %s.wom does not exist\n", aBase.c_str()); - return 1; - } - if (!wowee::pipeline::WoweeModelLoader::exists(bBase)) { - std::fprintf(stderr, - "merge-meshes: %s.wom does not exist\n", bBase.c_str()); - return 1; - } - auto a = wowee::pipeline::WoweeModelLoader::load(aBase); - auto b = wowee::pipeline::WoweeModelLoader::load(bBase); - if (!a.isValid() || !b.isValid()) { - std::fprintf(stderr, - "merge-meshes: failed to load one of the inputs\n"); - return 1; - } - wowee::pipeline::WoweeModel out; - out.name = std::filesystem::path(outBase).stem().string(); - out.version = 3; - out.vertices = a.vertices; - out.vertices.insert(out.vertices.end(), - b.vertices.begin(), b.vertices.end()); - out.indices = a.indices; - uint32_t indexOffset = static_cast(a.vertices.size()); - for (uint32_t idx : b.indices) { - out.indices.push_back(idx + indexOffset); - } - out.texturePaths = a.texturePaths; - uint32_t textureOffset = static_cast(a.texturePaths.size()); - for (const auto& t : b.texturePaths) { - out.texturePaths.push_back(t); - } - // Promote single-batch / no-batch inputs into proper - // batches so the merged output is well-formed v3. - auto ensureBatch = [](const wowee::pipeline::WoweeModel& m) { - std::vector bs = m.batches; - if (bs.empty()) { - wowee::pipeline::WoweeModel::Batch only; - only.indexStart = 0; - only.indexCount = static_cast(m.indices.size()); - only.textureIndex = 0; - only.blendMode = 0; - only.flags = 0; - bs.push_back(only); - } - return bs; - }; - auto aBatches = ensureBatch(a); - auto bBatches = ensureBatch(b); - for (const auto& bt : aBatches) out.batches.push_back(bt); - uint32_t indexStartOffset = static_cast(a.indices.size()); - for (auto bt : bBatches) { - bt.indexStart += indexStartOffset; - bt.textureIndex += textureOffset; - out.batches.push_back(bt); - } - // Static-only output (see header comment). - for (auto& v : out.vertices) { - v.boneWeights[0] = 255; - v.boneWeights[1] = 0; - v.boneWeights[2] = 0; - v.boneWeights[3] = 0; - v.boneIndices[0] = 0; - v.boneIndices[1] = 0; - v.boneIndices[2] = 0; - v.boneIndices[3] = 0; - } - // Bounds: union of inputs. - out.boundMin = glm::min(a.boundMin, b.boundMin); - out.boundMax = glm::max(a.boundMax, b.boundMax); - out.boundRadius = glm::length(out.boundMax - out.boundMin) * 0.5f; - std::filesystem::path outPath(outBase); - std::filesystem::create_directories(outPath.parent_path()); - if (!wowee::pipeline::WoweeModelLoader::save(out, outBase)) { - std::fprintf(stderr, - "merge-meshes: failed to save %s.wom\n", outBase.c_str()); - return 1; - } - std::printf("Merged %s.wom + %s.wom -> %s.wom\n", - aBase.c_str(), bBase.c_str(), outBase.c_str()); - std::printf(" vertices : %zu = %zu + %zu\n", - out.vertices.size(), - a.vertices.size(), b.vertices.size()); - std::printf(" indices : %zu = %zu + %zu\n", - out.indices.size(), - a.indices.size(), b.indices.size()); - std::printf(" batches : %zu = %zu + %zu\n", - out.batches.size(), - aBatches.size(), bBatches.size()); - std::printf(" textures : %zu = %zu + %zu\n", - out.texturePaths.size(), - a.texturePaths.size(), b.texturePaths.size()); - std::printf(" bounds : (%.3f, %.3f, %.3f) - (%.3f, %.3f, %.3f)\n", - out.boundMin.x, out.boundMin.y, out.boundMin.z, - out.boundMax.x, out.boundMax.y, out.boundMax.z); - return 0; } else if (std::strcmp(argv[i], "--add-texture-to-zone") == 0 && i + 2 < argc) { // Import an existing PNG into a zone directory. Useful // for the "I have an artist-painted texture, get it into