diff --git a/CMakeLists.txt b/CMakeLists.txt index 1f7d0b04..8a182ca9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1326,6 +1326,7 @@ add_executable(wowee_editor tools/editor/cli_migrate.cpp tools/editor/cli_convert_single.cpp tools/editor/cli_validate_interop.cpp + tools/editor/cli_glb_inspect.cpp tools/editor/editor_app.cpp tools/editor/editor_camera.cpp tools/editor/editor_viewport.cpp diff --git a/tools/editor/cli_glb_inspect.cpp b/tools/editor/cli_glb_inspect.cpp new file mode 100644 index 00000000..f5e18626 --- /dev/null +++ b/tools/editor/cli_glb_inspect.cpp @@ -0,0 +1,712 @@ +#include "cli_glb_inspect.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace editor { +namespace cli { + +namespace { + +int handleValidateOrInfoGlb(int& i, int argc, char** argv) { + // Shared handler: --validate-glb errors out on broken structure; + // --info-glb prints the same metadata but exits 0 unless the + // file is unreadable. Same parser, different verdict policy. + bool isValidate = (std::strcmp(argv[i], "--validate-glb") == 0); + std::string path = argv[++i]; + bool jsonOut = (i + 1 < argc && + std::strcmp(argv[i + 1], "--json") == 0); + if (jsonOut) i++; + std::ifstream in(path, std::ios::binary); + if (!in) { + std::fprintf(stderr, + "%s: cannot open %s\n", + isValidate ? "validate-glb" : "info-glb", path.c_str()); + return 1; + } + std::vector bytes((std::istreambuf_iterator(in)), + std::istreambuf_iterator()); + std::vector errors; + // 12-byte header: 'glTF' magic, version=2, total length. + uint32_t magic = 0, version = 0, totalLen = 0; + if (bytes.size() < 12) { + errors.push_back("file too short for glTF header (need 12 bytes)"); + } else { + std::memcpy(&magic, &bytes[0], 4); + std::memcpy(&version, &bytes[4], 4); + std::memcpy(&totalLen, &bytes[8], 4); + if (magic != 0x46546C67) { + errors.push_back("magic is not 'glTF' (0x46546C67)"); + } + if (version != 2) { + errors.push_back("version " + std::to_string(version) + + " not supported (only glTF 2.0)"); + } + if (totalLen != bytes.size()) { + errors.push_back("totalLength=" + std::to_string(totalLen) + + " != file size " + std::to_string(bytes.size())); + } + } + // JSON chunk follows: 4-byte length, 4-byte type ('JSON'), + // then payload. Then BIN chunk same shape. + uint32_t jsonLen = 0, jsonType = 0; + uint32_t binLen = 0, binType = 0; + std::string jsonStr; + std::vector binData; + if (errors.empty()) { + if (bytes.size() < 20) { + errors.push_back("missing JSON chunk header"); + } else { + std::memcpy(&jsonLen, &bytes[12], 4); + std::memcpy(&jsonType, &bytes[16], 4); + if (jsonType != 0x4E4F534A) { + errors.push_back("first chunk type is not 'JSON' (0x4E4F534A)"); + } + if (20 + jsonLen > bytes.size()) { + errors.push_back("JSON chunk extends past file end"); + } else { + jsonStr.assign(bytes.begin() + 20, + bytes.begin() + 20 + jsonLen); + } + } + size_t binOff = 20 + jsonLen; + if (binOff + 8 <= bytes.size()) { + std::memcpy(&binLen, &bytes[binOff], 4); + std::memcpy(&binType, &bytes[binOff + 4], 4); + if (binType != 0x004E4942) { + errors.push_back("second chunk type is not 'BIN\\0' (0x004E4942)"); + } + if (binOff + 8 + binLen > bytes.size()) { + errors.push_back("BIN chunk extends past file end"); + } else { + binData.assign(bytes.begin() + binOff + 8, + bytes.begin() + binOff + 8 + binLen); + } + } + // BIN chunk is optional in spec; only flag missing if + // accessors below reference a buffer. + } + // Parse JSON and validate structure. + nlohmann::json gj; + int meshCount = 0, primitiveCount = 0, accessorCount = 0, + bufferViewCount = 0, bufferCount = 0; + std::string assetVersion; + if (errors.empty() && !jsonStr.empty()) { + try { + gj = nlohmann::json::parse(jsonStr); + assetVersion = gj.value("/asset/version"_json_pointer, std::string{}); + if (assetVersion != "2.0") { + errors.push_back("asset.version is '" + assetVersion + + "', not '2.0'"); + } + if (gj.contains("meshes") && gj["meshes"].is_array()) { + meshCount = static_cast(gj["meshes"].size()); + for (const auto& m : gj["meshes"]) { + if (m.contains("primitives") && m["primitives"].is_array()) { + primitiveCount += static_cast(m["primitives"].size()); + } + } + } + if (gj.contains("accessors") && gj["accessors"].is_array()) { + accessorCount = static_cast(gj["accessors"].size()); + // Verify each accessor's bufferView exists. + for (size_t a = 0; a < gj["accessors"].size(); ++a) { + const auto& acc = gj["accessors"][a]; + if (acc.contains("bufferView")) { + int bv = acc["bufferView"]; + if (!gj.contains("bufferViews") || + bv >= static_cast(gj["bufferViews"].size())) { + errors.push_back("accessor " + std::to_string(a) + + " bufferView=" + std::to_string(bv) + + " out of range"); + } + } + } + } + if (gj.contains("bufferViews") && gj["bufferViews"].is_array()) { + bufferViewCount = static_cast(gj["bufferViews"].size()); + for (size_t b = 0; b < gj["bufferViews"].size(); ++b) { + const auto& bv = gj["bufferViews"][b]; + uint32_t bo = bv.value("byteOffset", 0u); + uint32_t bl = bv.value("byteLength", 0u); + uint64_t end = uint64_t(bo) + bl; + if (end > binLen) { + errors.push_back("bufferView " + std::to_string(b) + + " range [" + std::to_string(bo) + + ", " + std::to_string(end) + + ") past BIN chunk length " + + std::to_string(binLen)); + } + } + } + if (gj.contains("buffers") && gj["buffers"].is_array()) { + bufferCount = static_cast(gj["buffers"].size()); + } + } catch (const std::exception& e) { + errors.push_back(std::string("JSON parse error: ") + e.what()); + } + } + int errorCount = static_cast(errors.size()); + if (jsonOut) { + nlohmann::json j; + j["glb"] = path; + j["fileSize"] = bytes.size(); + j["version"] = version; + j["assetVersion"] = assetVersion; + j["totalLength"] = totalLen; + j["jsonLength"] = jsonLen; + j["binLength"] = binLen; + j["meshes"] = meshCount; + j["primitives"] = primitiveCount; + j["accessors"] = accessorCount; + j["bufferViews"] = bufferViewCount; + j["buffers"] = bufferCount; + j["errorCount"] = errorCount; + j["errors"] = errors; + j["passed"] = errors.empty(); + std::printf("%s\n", j.dump(2).c_str()); + return (isValidate && errorCount > 0) ? 1 : 0; + } + std::printf("GLB: %s\n", path.c_str()); + std::printf(" file bytes : %zu\n", bytes.size()); + std::printf(" glTF version: %u (asset.version=%s)\n", + version, assetVersion.empty() ? "?" : assetVersion.c_str()); + std::printf(" totalLength : %u\n", totalLen); + std::printf(" JSON chunk : %u bytes\n", jsonLen); + std::printf(" BIN chunk : %u bytes\n", binLen); + std::printf(" meshes : %d (%d primitives)\n", + meshCount, primitiveCount); + std::printf(" accessors : %d bufferViews: %d buffers: %d\n", + accessorCount, bufferViewCount, bufferCount); + if (errors.empty()) { + std::printf(" PASSED\n"); + return 0; + } + std::printf(" FAILED — %d error(s):\n", errorCount); + for (const auto& e : errors) std::printf(" - %s\n", e.c_str()); + return isValidate ? 1 : 0; +} + +int handleInfoGlbTree(int& i, int /*argc*/, char** argv) { + // Pretty `tree`-style view of glTF structure. --info-glb gives + // counts; this shows the actual scene→node→mesh→primitive + // hierarchy with names. Useful when debugging 'why is this + // imported model showing up empty in three.js?' (often + // because the scene's nodes[] array references the wrong node). + std::string path = argv[++i]; + std::ifstream in(path, std::ios::binary); + if (!in) { + std::fprintf(stderr, + "info-glb-tree: cannot open %s\n", path.c_str()); + return 1; + } + std::vector bytes((std::istreambuf_iterator(in)), + std::istreambuf_iterator()); + if (bytes.size() < 28) { + std::fprintf(stderr, "info-glb-tree: file too short\n"); + return 1; + } + uint32_t magic, version; + std::memcpy(&magic, &bytes[0], 4); + std::memcpy(&version, &bytes[4], 4); + if (magic != 0x46546C67 || version != 2) { + std::fprintf(stderr, "info-glb-tree: not glTF 2.0\n"); + return 1; + } + uint32_t jsonLen; + std::memcpy(&jsonLen, &bytes[12], 4); + std::string jsonStr(bytes.begin() + 20, bytes.begin() + 20 + jsonLen); + nlohmann::json gj; + try { gj = nlohmann::json::parse(jsonStr); } + catch (const std::exception& e) { + std::fprintf(stderr, "info-glb-tree: JSON parse failed: %s\n", e.what()); + return 1; + } + // Tree drawing + auto branch = [](bool last) { return last ? "└─ " : "├─ "; }; + auto cont = [](bool last) { return last ? " " : "│ "; }; + std::printf("%s\n", path.c_str()); + // Asset section + std::string genName = gj.value("/asset/version"_json_pointer, std::string{}); + std::string gen = gj.value("/asset/generator"_json_pointer, std::string{}); + std::printf("├─ asset (v%s, %s)\n", + genName.c_str(), + gen.empty() ? "no generator" : gen.c_str()); + // Buffers + int nBuf = (gj.contains("buffers") && gj["buffers"].is_array()) + ? static_cast(gj["buffers"].size()) : 0; + std::printf("├─ buffers (%d)\n", nBuf); + if (nBuf > 0) { + for (int b = 0; b < nBuf; ++b) { + bool last = (b == nBuf - 1); + uint64_t bl = gj["buffers"][b].value("byteLength", 0u); + std::printf("│ %s[%d] %llu bytes\n", branch(last), b, + static_cast(bl)); + } + } + // BufferViews + int nBV = (gj.contains("bufferViews") && gj["bufferViews"].is_array()) + ? static_cast(gj["bufferViews"].size()) : 0; + std::printf("├─ bufferViews (%d)\n", nBV); + for (int v = 0; v < nBV; ++v) { + bool last = (v == nBV - 1); + const auto& bv = gj["bufferViews"][v]; + uint32_t bo = bv.value("byteOffset", 0u); + uint32_t bl = bv.value("byteLength", 0u); + int target = bv.value("target", 0); + std::printf("│ %s[%d] off=%u len=%u%s\n", + branch(last), v, bo, bl, + target == 34962 ? " (vertex)" + : target == 34963 ? " (index)" + : ""); + } + // Accessors + int nAcc = (gj.contains("accessors") && gj["accessors"].is_array()) + ? static_cast(gj["accessors"].size()) : 0; + std::printf("├─ accessors (%d)\n", nAcc); + for (int a = 0; a < nAcc; ++a) { + bool last = (a == nAcc - 1); + const auto& acc = gj["accessors"][a]; + int ct = acc.value("componentType", 0); + std::string type = acc.value("type", std::string{}); + uint32_t count = acc.value("count", 0u); + int bv = acc.value("bufferView", -1); + const char* ctName = + ct == 5120 ? "i8" : + ct == 5121 ? "u8" : + ct == 5122 ? "i16" : + ct == 5123 ? "u16" : + ct == 5125 ? "u32" : + ct == 5126 ? "f32" : "?"; + std::printf("│ %s[%d] %s %s ×%u (bv=%d)\n", + branch(last), a, ctName, type.c_str(), count, bv); + } + // Meshes (with primitives nested) + int nMesh = (gj.contains("meshes") && gj["meshes"].is_array()) + ? static_cast(gj["meshes"].size()) : 0; + std::printf("├─ meshes (%d)\n", nMesh); + for (int m = 0; m < nMesh; ++m) { + bool lastM = (m == nMesh - 1); + const auto& mesh = gj["meshes"][m]; + std::string name = mesh.value("name", std::string{}); + int nPrim = (mesh.contains("primitives") && mesh["primitives"].is_array()) + ? static_cast(mesh["primitives"].size()) : 0; + std::printf("│ %s[%d]%s%s (%d primitives)\n", + branch(lastM), m, + name.empty() ? "" : " ", + name.c_str(), nPrim); + for (int p = 0; p < nPrim; ++p) { + bool lastP = (p == nPrim - 1); + const auto& prim = mesh["primitives"][p]; + int idxAcc = prim.value("indices", -1); + int mode = prim.value("mode", 4); + const char* modeName = + mode == 0 ? "POINTS" : + mode == 1 ? "LINES" : + mode == 4 ? "TRIANGLES" : "?"; + std::printf("│ %s%s[%d] %s indices=acc#%d\n", + cont(lastM), branch(lastP), p, modeName, idxAcc); + } + } + // Nodes (flat list — could be tree but glTF nodes are a graph) + int nNode = (gj.contains("nodes") && gj["nodes"].is_array()) + ? static_cast(gj["nodes"].size()) : 0; + std::printf("├─ nodes (%d)\n", nNode); + for (int n = 0; n < nNode; ++n) { + bool last = (n == nNode - 1); + const auto& node = gj["nodes"][n]; + std::string name = node.value("name", std::string{}); + int meshIdx = node.value("mesh", -1); + std::printf("│ %s[%d]%s%s%s\n", + branch(last), n, + name.empty() ? "" : " ", + name.c_str(), + meshIdx >= 0 ? (" -> mesh#" + std::to_string(meshIdx)).c_str() : ""); + } + // Scenes (last branch) + int nScene = (gj.contains("scenes") && gj["scenes"].is_array()) + ? static_cast(gj["scenes"].size()) : 0; + std::printf("└─ scenes (%d, default=%d)\n", + nScene, gj.value("scene", 0)); + for (int s = 0; s < nScene; ++s) { + bool lastS = (s == nScene - 1); + const auto& scene = gj["scenes"][s]; + int nodeRefs = (scene.contains("nodes") && scene["nodes"].is_array()) + ? static_cast(scene["nodes"].size()) : 0; + std::printf(" %s[%d] nodes=[", branch(lastS), s); + if (scene.contains("nodes") && scene["nodes"].is_array()) { + for (size_t k = 0; k < scene["nodes"].size(); ++k) { + std::printf("%s%d", k ? "," : "", scene["nodes"][k].get()); + } + } + std::printf("] (%d nodes)\n", nodeRefs); + } + return 0; +} + +int handleInfoGlbBytes(int& i, int argc, char** argv) { + // Per-section + per-bufferView byte breakdown of a .glb. Useful + // for understanding what's bloating a baked .glb (vertex attrs + // vs indices, position vs uv vs normal data, mesh-level + // payloads). Pairs with --info-glb (counts) and --info-glb-tree + // (structure). + std::string path = argv[++i]; + bool jsonOut = (i + 1 < argc && + std::strcmp(argv[i + 1], "--json") == 0); + if (jsonOut) i++; + std::ifstream in(path, std::ios::binary); + if (!in) { + std::fprintf(stderr, + "info-glb-bytes: cannot open %s\n", path.c_str()); + return 1; + } + std::vector bytes((std::istreambuf_iterator(in)), + std::istreambuf_iterator()); + if (bytes.size() < 28) { + std::fprintf(stderr, "info-glb-bytes: file too short\n"); + return 1; + } + uint32_t magic, version; + std::memcpy(&magic, &bytes[0], 4); + std::memcpy(&version, &bytes[4], 4); + if (magic != 0x46546C67 || version != 2) { + std::fprintf(stderr, "info-glb-bytes: not glTF 2.0\n"); + return 1; + } + uint32_t jsonLen, binLen = 0; + std::memcpy(&jsonLen, &bytes[12], 4); + std::string jsonStr(bytes.begin() + 20, + bytes.begin() + 20 + jsonLen); + size_t binOff = 20 + jsonLen; + if (binOff + 8 <= bytes.size()) { + std::memcpy(&binLen, &bytes[binOff], 4); + } + uint32_t headerBytes = 12; // magic+version+totalLength + uint32_t jsonHdrBytes = 8; // jsonLen + jsonType + uint32_t binHdrBytes = (binLen > 0) ? 8 : 0; + nlohmann::json gj; + try { gj = nlohmann::json::parse(jsonStr); } + catch (const std::exception& e) { + std::fprintf(stderr, + "info-glb-bytes: JSON parse failed: %s\n", e.what()); + return 1; + } + // Per-bufferView size table. + struct BV { int idx; uint32_t off, len; std::string label; }; + std::vector bufferViews; + if (gj.contains("bufferViews") && gj["bufferViews"].is_array()) { + for (size_t k = 0; k < gj["bufferViews"].size(); ++k) { + const auto& bv = gj["bufferViews"][k]; + BV b; + b.idx = static_cast(k); + b.off = bv.value("byteOffset", 0u); + b.len = bv.value("byteLength", 0u); + int target = bv.value("target", 0); + b.label = (target == 34962) ? "vertex" : + (target == 34963) ? "index" : "other"; + bufferViews.push_back(b); + } + } + // Bucket bufferViews by purpose using accessor types. + // Walk accessors: each references a bufferView, with type + // (VEC3/VEC2/SCALAR) hinting at content (position/uv/etc.) + std::map bytesByPurpose; + if (gj.contains("accessors") && gj["accessors"].is_array() && + gj.contains("meshes") && gj["meshes"].is_array()) { + std::set seenAccessors; + for (const auto& m : gj["meshes"]) { + if (!m.contains("primitives") || !m["primitives"].is_array()) continue; + for (const auto& p : m["primitives"]) { + if (!p.contains("attributes")) continue; + for (auto it = p["attributes"].begin(); + it != p["attributes"].end(); ++it) { + int ai = it.value().get(); + if (seenAccessors.count(ai)) continue; + seenAccessors.insert(ai); + if (ai < 0 || ai >= static_cast(gj["accessors"].size())) continue; + const auto& acc = gj["accessors"][ai]; + int bv = acc.value("bufferView", -1); + if (bv < 0 || bv >= static_cast(bufferViews.size())) continue; + std::string typeStr = acc.value("type", std::string{}); + int comp = acc.value("componentType", 0); + uint32_t cnt = acc.value("count", 0u); + uint32_t byteStride = + typeStr == "VEC3" ? 12 : + typeStr == "VEC2" ? 8 : + typeStr == "VEC4" ? 16 : + typeStr == "SCALAR" ? + (comp == 5126 ? 4 : comp == 5125 ? 4 : + comp == 5123 ? 2 : comp == 5121 ? 1 : 4) : 4; + uint64_t b = uint64_t(cnt) * byteStride; + bytesByPurpose[it.key()] += b; + } + // Indices accessor. + if (p.contains("indices")) { + int ai = p["indices"].get(); + if (seenAccessors.count(ai)) continue; + seenAccessors.insert(ai); + if (ai < 0 || ai >= static_cast(gj["accessors"].size())) continue; + const auto& acc = gj["accessors"][ai]; + uint32_t cnt = acc.value("count", 0u); + int comp = acc.value("componentType", 0); + uint32_t s = (comp == 5125 ? 4 : comp == 5123 ? 2 : 4); + bytesByPurpose["INDICES"] += uint64_t(cnt) * s; + } + } + } + } + uint64_t totalBytes = bytes.size(); + if (jsonOut) { + nlohmann::json j; + j["glb"] = path; + j["totalBytes"] = totalBytes; + j["sections"] = { + {"header", headerBytes}, + {"jsonHeader", jsonHdrBytes}, + {"json", jsonLen}, + {"binHeader", binHdrBytes}, + {"bin", binLen} + }; + nlohmann::json bvArr = nlohmann::json::array(); + for (const auto& bv : bufferViews) { + bvArr.push_back({{"index", bv.idx}, + {"target", bv.label}, + {"bytes", bv.len}}); + } + j["bufferViews"] = bvArr; + nlohmann::json byPurp = nlohmann::json::object(); + for (const auto& [p, b] : bytesByPurpose) byPurp[p] = b; + j["byPurpose"] = byPurp; + std::printf("%s\n", j.dump(2).c_str()); + return 0; + } + std::printf("GLB bytes: %s\n", path.c_str()); + std::printf(" total: %llu bytes (%.2f MB)\n", + static_cast(totalBytes), + totalBytes / (1024.0 * 1024.0)); + std::printf("\n Sections:\n"); + auto pct = [&](uint64_t v) { + return totalBytes ? 100.0 * v / totalBytes : 0.0; + }; + std::printf(" header : %5u bytes %5.2f%%\n", headerBytes, pct(headerBytes)); + std::printf(" JSON hdr : %5u bytes %5.2f%%\n", jsonHdrBytes, pct(jsonHdrBytes)); + std::printf(" JSON : %5u bytes %5.2f%%\n", jsonLen, pct(jsonLen)); + std::printf(" BIN hdr : %5u bytes %5.2f%%\n", binHdrBytes, pct(binHdrBytes)); + std::printf(" BIN : %5u bytes %5.2f%%\n", binLen, pct(binLen)); + if (!bufferViews.empty()) { + std::printf("\n BufferViews:\n"); + std::printf(" idx target bytes MB share-of-bin\n"); + for (const auto& bv : bufferViews) { + double bvPct = binLen ? 100.0 * bv.len / binLen : 0.0; + std::printf(" %3d %-7s %8u %6.2f %5.2f%%\n", + bv.idx, bv.label.c_str(), bv.len, + bv.len / (1024.0 * 1024.0), bvPct); + } + } + if (!bytesByPurpose.empty()) { + std::printf("\n By attribute:\n"); + for (const auto& [p, b] : bytesByPurpose) { + double bPct = binLen ? 100.0 * b / binLen : 0.0; + std::printf(" %-12s %8llu bytes (%.2f%% of BIN)\n", + p.c_str(), + static_cast(b), bPct); + } + } + return 0; +} + +int handleCheckGlbBounds(int& i, int argc, char** argv) { + // Cross-checks every position accessor's claimed min/max + // against the actual data in the BIN chunk. glTF viewers use + // these for camera framing and frustum culling — stale + // values (e.g. from a tool that edited geometry without + // recomputing) cause models to vanish at certain angles or + // get framed wrong on load. + std::string path = argv[++i]; + bool jsonOut = (i + 1 < argc && + std::strcmp(argv[i + 1], "--json") == 0); + if (jsonOut) i++; + std::ifstream in(path, std::ios::binary); + if (!in) { + std::fprintf(stderr, + "check-glb-bounds: cannot open %s\n", path.c_str()); + return 1; + } + std::vector bytes((std::istreambuf_iterator(in)), + std::istreambuf_iterator()); + // Parse glb structure (re-implements --validate-glb's parser + // since we need access to the BIN chunk bytes here). + if (bytes.size() < 28) { + std::fprintf(stderr, + "check-glb-bounds: file too short to be a .glb\n"); + return 1; + } + uint32_t magic, version; + std::memcpy(&magic, &bytes[0], 4); + std::memcpy(&version, &bytes[4], 4); + if (magic != 0x46546C67 || version != 2) { + std::fprintf(stderr, + "check-glb-bounds: not a valid glTF 2.0 binary\n"); + return 1; + } + uint32_t jsonLen, jsonType; + std::memcpy(&jsonLen, &bytes[12], 4); + std::memcpy(&jsonType, &bytes[16], 4); + std::string jsonStr(bytes.begin() + 20, bytes.begin() + 20 + jsonLen); + size_t binOff = 20 + jsonLen; + std::memcpy(&magic, &bytes[binOff + 4], 4); // chunkType + const uint8_t* binData = &bytes[binOff + 8]; + uint32_t binLen; + std::memcpy(&binLen, &bytes[binOff], 4); + (void)binLen; // not range-checked here; --validate-glb does that + nlohmann::json gj; + try { gj = nlohmann::json::parse(jsonStr); } + catch (const std::exception& e) { + std::fprintf(stderr, + "check-glb-bounds: JSON parse failed: %s\n", e.what()); + return 1; + } + std::vector errors; + int posAccessors = 0, mismatched = 0; + // Walk all primitives, collect their POSITION accessor index, + // dedupe (multiple primitives can share an accessor — only + // recompute once per unique). + std::set posAccIndices; + if (gj.contains("meshes") && gj["meshes"].is_array()) { + for (const auto& m : gj["meshes"]) { + if (!m.contains("primitives") || !m["primitives"].is_array()) continue; + for (const auto& p : m["primitives"]) { + if (p.contains("attributes") && + p["attributes"].contains("POSITION")) { + posAccIndices.insert(p["attributes"]["POSITION"].get()); + } + } + } + } + const auto& accessors = gj["accessors"]; + const auto& bufferViews = gj["bufferViews"]; + for (int ai : posAccIndices) { + if (ai < 0 || ai >= static_cast(accessors.size())) { + errors.push_back("position accessor " + std::to_string(ai) + + " out of range"); + continue; + } + const auto& acc = accessors[ai]; + if (acc.value("type", std::string{}) != "VEC3" || + acc.value("componentType", 0) != 5126) { + errors.push_back("accessor " + std::to_string(ai) + + " is not VEC3 FLOAT"); + continue; + } + posAccessors++; + int bvIdx = acc.value("bufferView", -1); + if (bvIdx < 0 || bvIdx >= static_cast(bufferViews.size())) { + errors.push_back("accessor " + std::to_string(ai) + + " bufferView " + std::to_string(bvIdx) + + " out of range"); + continue; + } + const auto& bv = bufferViews[bvIdx]; + uint32_t bvOff = bv.value("byteOffset", 0u); + uint32_t accOff = acc.value("byteOffset", 0u); + uint32_t count = acc.value("count", 0u); + const uint8_t* p = binData + bvOff + accOff; + glm::vec3 actualMin{1e30f}, actualMax{-1e30f}; + for (uint32_t v = 0; v < count; ++v) { + glm::vec3 pos; + std::memcpy(&pos.x, p + v * 12 + 0, 4); + std::memcpy(&pos.y, p + v * 12 + 4, 4); + std::memcpy(&pos.z, p + v * 12 + 8, 4); + actualMin = glm::min(actualMin, pos); + actualMax = glm::max(actualMax, pos); + } + // Compare against claimed min/max (within float epsilon). + glm::vec3 claimedMin{0}, claimedMax{0}; + bool hasClaimed = (acc.contains("min") && acc.contains("max")); + if (hasClaimed) { + claimedMin.x = acc["min"][0]; claimedMin.y = acc["min"][1]; claimedMin.z = acc["min"][2]; + claimedMax.x = acc["max"][0]; claimedMax.y = acc["max"][1]; claimedMax.z = acc["max"][2]; + auto close = [](float a, float b) { + return std::abs(a - b) < 1e-3f; + }; + bool ok = close(claimedMin.x, actualMin.x) && + close(claimedMin.y, actualMin.y) && + close(claimedMin.z, actualMin.z) && + close(claimedMax.x, actualMax.x) && + close(claimedMax.y, actualMax.y) && + close(claimedMax.z, actualMax.z); + if (!ok) { + mismatched++; + char buf[256]; + std::snprintf(buf, sizeof(buf), + "accessor %d bounds mismatch: claimed [%g,%g,%g]-[%g,%g,%g] vs actual [%g,%g,%g]-[%g,%g,%g]", + ai, + claimedMin.x, claimedMin.y, claimedMin.z, + claimedMax.x, claimedMax.y, claimedMax.z, + actualMin.x, actualMin.y, actualMin.z, + actualMax.x, actualMax.y, actualMax.z); + errors.push_back(buf); + } + } else { + // glTF spec requires position accessors to declare min/max. + errors.push_back("accessor " + std::to_string(ai) + + " missing required min/max for POSITION attribute"); + mismatched++; + } + } + if (jsonOut) { + nlohmann::json j; + j["glb"] = path; + j["positionAccessors"] = posAccessors; + j["mismatched"] = mismatched; + j["errors"] = errors; + j["passed"] = errors.empty(); + std::printf("%s\n", j.dump(2).c_str()); + return errors.empty() ? 0 : 1; + } + std::printf("GLB bounds: %s\n", path.c_str()); + std::printf(" position accessors checked : %d\n", posAccessors); + std::printf(" mismatched : %d\n", mismatched); + if (errors.empty()) { + std::printf(" PASSED\n"); + return 0; + } + std::printf(" FAILED — %zu error(s):\n", errors.size()); + for (const auto& e : errors) std::printf(" - %s\n", e.c_str()); + return 1; +} + +} // namespace + +bool handleGlbInspect(int& i, int argc, char** argv, int& outRc) { + if ((std::strcmp(argv[i], "--validate-glb") == 0 || + std::strcmp(argv[i], "--info-glb") == 0) && i + 1 < argc) { + outRc = handleValidateOrInfoGlb(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--info-glb-tree") == 0 && i + 1 < argc) { + outRc = handleInfoGlbTree(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--info-glb-bytes") == 0 && i + 1 < argc) { + outRc = handleInfoGlbBytes(i, argc, argv); return true; + } + if (std::strcmp(argv[i], "--check-glb-bounds") == 0 && i + 1 < argc) { + outRc = handleCheckGlbBounds(i, argc, argv); return true; + } + return false; +} + +} // namespace cli +} // namespace editor +} // namespace wowee diff --git a/tools/editor/cli_glb_inspect.hpp b/tools/editor/cli_glb_inspect.hpp new file mode 100644 index 00000000..9b47732f --- /dev/null +++ b/tools/editor/cli_glb_inspect.hpp @@ -0,0 +1,24 @@ +#pragma once + +namespace wowee { +namespace editor { +namespace cli { + +// Dispatch the GLB introspection / validation handlers. GLB is +// our open replacement for proprietary M2/WMO bake outputs; +// these handlers help debug what the baker produced and verify +// the result is glTF-2.0 compliant. +// --validate-glb / --info-glb shared parser, different verdict +// --info-glb-tree tree-style scene/node/mesh dump +// --info-glb-bytes per-section + per-bufferView size table +// --check-glb-bounds cross-check accessor min/max vs BIN data +// +// All four support an optional trailing `--json` flag for +// machine-readable output. +// +// Returns true if matched; outRc holds the exit code. +bool handleGlbInspect(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 5eb8fd8f..bc981034 100644 --- a/tools/editor/main.cpp +++ b/tools/editor/main.cpp @@ -27,6 +27,7 @@ #include "cli_migrate.hpp" #include "cli_convert_single.hpp" #include "cli_validate_interop.hpp" +#include "cli_glb_inspect.hpp" #include "content_pack.hpp" #include "npc_spawner.hpp" #include "object_placer.hpp" @@ -437,6 +438,9 @@ int main(int argc, char* argv[]) { if (wowee::editor::cli::handleValidateInterop(i, argc, argv, outRc)) { return outRc; } + if (wowee::editor::cli::handleGlbInspect(i, argc, argv, outRc)) { + return outRc; + } } if (std::strcmp(argv[i], "--data") == 0 && i + 1 < argc) { dataPath = argv[++i]; @@ -2485,667 +2489,6 @@ int main(int argc, char* argv[]) { t.loadMs, t.tiles, t.chunks, mspt); } return 0; - } else if ((std::strcmp(argv[i], "--validate-glb") == 0 || - std::strcmp(argv[i], "--info-glb") == 0) && i + 1 < argc) { - // Shared handler: --validate-glb errors out on broken structure; - // --info-glb prints the same metadata but exits 0 unless the - // file is unreadable. Same parser, different verdict policy. - bool isValidate = (std::strcmp(argv[i], "--validate-glb") == 0); - std::string path = argv[++i]; - bool jsonOut = (i + 1 < argc && - std::strcmp(argv[i + 1], "--json") == 0); - if (jsonOut) i++; - std::ifstream in(path, std::ios::binary); - if (!in) { - std::fprintf(stderr, - "%s: cannot open %s\n", - isValidate ? "validate-glb" : "info-glb", path.c_str()); - return 1; - } - std::vector bytes((std::istreambuf_iterator(in)), - std::istreambuf_iterator()); - std::vector errors; - // 12-byte header: 'glTF' magic, version=2, total length. - uint32_t magic = 0, version = 0, totalLen = 0; - if (bytes.size() < 12) { - errors.push_back("file too short for glTF header (need 12 bytes)"); - } else { - std::memcpy(&magic, &bytes[0], 4); - std::memcpy(&version, &bytes[4], 4); - std::memcpy(&totalLen, &bytes[8], 4); - if (magic != 0x46546C67) { - errors.push_back("magic is not 'glTF' (0x46546C67)"); - } - if (version != 2) { - errors.push_back("version " + std::to_string(version) + - " not supported (only glTF 2.0)"); - } - if (totalLen != bytes.size()) { - errors.push_back("totalLength=" + std::to_string(totalLen) + - " != file size " + std::to_string(bytes.size())); - } - } - // JSON chunk follows: 4-byte length, 4-byte type ('JSON'), - // then payload. Then BIN chunk same shape. - uint32_t jsonLen = 0, jsonType = 0; - uint32_t binLen = 0, binType = 0; - std::string jsonStr; - std::vector binData; - if (errors.empty()) { - if (bytes.size() < 20) { - errors.push_back("missing JSON chunk header"); - } else { - std::memcpy(&jsonLen, &bytes[12], 4); - std::memcpy(&jsonType, &bytes[16], 4); - if (jsonType != 0x4E4F534A) { - errors.push_back("first chunk type is not 'JSON' (0x4E4F534A)"); - } - if (20 + jsonLen > bytes.size()) { - errors.push_back("JSON chunk extends past file end"); - } else { - jsonStr.assign(bytes.begin() + 20, - bytes.begin() + 20 + jsonLen); - } - } - size_t binOff = 20 + jsonLen; - if (binOff + 8 <= bytes.size()) { - std::memcpy(&binLen, &bytes[binOff], 4); - std::memcpy(&binType, &bytes[binOff + 4], 4); - if (binType != 0x004E4942) { - errors.push_back("second chunk type is not 'BIN\\0' (0x004E4942)"); - } - if (binOff + 8 + binLen > bytes.size()) { - errors.push_back("BIN chunk extends past file end"); - } else { - binData.assign(bytes.begin() + binOff + 8, - bytes.begin() + binOff + 8 + binLen); - } - } - // BIN chunk is optional in spec; only flag missing if - // accessors below reference a buffer. - } - // Parse JSON and validate structure. - nlohmann::json gj; - int meshCount = 0, primitiveCount = 0, accessorCount = 0, - bufferViewCount = 0, bufferCount = 0; - std::string assetVersion; - if (errors.empty() && !jsonStr.empty()) { - try { - gj = nlohmann::json::parse(jsonStr); - assetVersion = gj.value("/asset/version"_json_pointer, std::string{}); - if (assetVersion != "2.0") { - errors.push_back("asset.version is '" + assetVersion + - "', not '2.0'"); - } - if (gj.contains("meshes") && gj["meshes"].is_array()) { - meshCount = static_cast(gj["meshes"].size()); - for (const auto& m : gj["meshes"]) { - if (m.contains("primitives") && m["primitives"].is_array()) { - primitiveCount += static_cast(m["primitives"].size()); - } - } - } - if (gj.contains("accessors") && gj["accessors"].is_array()) { - accessorCount = static_cast(gj["accessors"].size()); - // Verify each accessor's bufferView exists. - for (size_t a = 0; a < gj["accessors"].size(); ++a) { - const auto& acc = gj["accessors"][a]; - if (acc.contains("bufferView")) { - int bv = acc["bufferView"]; - if (!gj.contains("bufferViews") || - bv >= static_cast(gj["bufferViews"].size())) { - errors.push_back("accessor " + std::to_string(a) + - " bufferView=" + std::to_string(bv) + - " out of range"); - } - } - } - } - if (gj.contains("bufferViews") && gj["bufferViews"].is_array()) { - bufferViewCount = static_cast(gj["bufferViews"].size()); - for (size_t b = 0; b < gj["bufferViews"].size(); ++b) { - const auto& bv = gj["bufferViews"][b]; - uint32_t bo = bv.value("byteOffset", 0u); - uint32_t bl = bv.value("byteLength", 0u); - uint64_t end = uint64_t(bo) + bl; - if (end > binLen) { - errors.push_back("bufferView " + std::to_string(b) + - " range [" + std::to_string(bo) + - ", " + std::to_string(end) + - ") past BIN chunk length " + - std::to_string(binLen)); - } - } - } - if (gj.contains("buffers") && gj["buffers"].is_array()) { - bufferCount = static_cast(gj["buffers"].size()); - } - } catch (const std::exception& e) { - errors.push_back(std::string("JSON parse error: ") + e.what()); - } - } - int errorCount = static_cast(errors.size()); - if (jsonOut) { - nlohmann::json j; - j["glb"] = path; - j["fileSize"] = bytes.size(); - j["version"] = version; - j["assetVersion"] = assetVersion; - j["totalLength"] = totalLen; - j["jsonLength"] = jsonLen; - j["binLength"] = binLen; - j["meshes"] = meshCount; - j["primitives"] = primitiveCount; - j["accessors"] = accessorCount; - j["bufferViews"] = bufferViewCount; - j["buffers"] = bufferCount; - j["errorCount"] = errorCount; - j["errors"] = errors; - j["passed"] = errors.empty(); - std::printf("%s\n", j.dump(2).c_str()); - return (isValidate && errorCount > 0) ? 1 : 0; - } - std::printf("GLB: %s\n", path.c_str()); - std::printf(" file bytes : %zu\n", bytes.size()); - std::printf(" glTF version: %u (asset.version=%s)\n", - version, assetVersion.empty() ? "?" : assetVersion.c_str()); - std::printf(" totalLength : %u\n", totalLen); - std::printf(" JSON chunk : %u bytes\n", jsonLen); - std::printf(" BIN chunk : %u bytes\n", binLen); - std::printf(" meshes : %d (%d primitives)\n", - meshCount, primitiveCount); - std::printf(" accessors : %d bufferViews: %d buffers: %d\n", - accessorCount, bufferViewCount, bufferCount); - if (errors.empty()) { - std::printf(" PASSED\n"); - return 0; - } - std::printf(" FAILED — %d error(s):\n", errorCount); - for (const auto& e : errors) std::printf(" - %s\n", e.c_str()); - return isValidate ? 1 : 0; - } else if (std::strcmp(argv[i], "--info-glb-tree") == 0 && i + 1 < argc) { - // Pretty `tree`-style view of glTF structure. --info-glb gives - // counts; this shows the actual scene→node→mesh→primitive - // hierarchy with names. Useful when debugging 'why is this - // imported model showing up empty in three.js?' (often - // because the scene's nodes[] array references the wrong node). - std::string path = argv[++i]; - std::ifstream in(path, std::ios::binary); - if (!in) { - std::fprintf(stderr, - "info-glb-tree: cannot open %s\n", path.c_str()); - return 1; - } - std::vector bytes((std::istreambuf_iterator(in)), - std::istreambuf_iterator()); - if (bytes.size() < 28) { - std::fprintf(stderr, "info-glb-tree: file too short\n"); - return 1; - } - uint32_t magic, version; - std::memcpy(&magic, &bytes[0], 4); - std::memcpy(&version, &bytes[4], 4); - if (magic != 0x46546C67 || version != 2) { - std::fprintf(stderr, "info-glb-tree: not glTF 2.0\n"); - return 1; - } - uint32_t jsonLen; - std::memcpy(&jsonLen, &bytes[12], 4); - std::string jsonStr(bytes.begin() + 20, bytes.begin() + 20 + jsonLen); - nlohmann::json gj; - try { gj = nlohmann::json::parse(jsonStr); } - catch (const std::exception& e) { - std::fprintf(stderr, "info-glb-tree: JSON parse failed: %s\n", e.what()); - return 1; - } - // Tree drawing - auto branch = [](bool last) { return last ? "└─ " : "├─ "; }; - auto cont = [](bool last) { return last ? " " : "│ "; }; - std::printf("%s\n", path.c_str()); - // Asset section - std::string genName = gj.value("/asset/version"_json_pointer, std::string{}); - std::string gen = gj.value("/asset/generator"_json_pointer, std::string{}); - std::printf("├─ asset (v%s, %s)\n", - genName.c_str(), - gen.empty() ? "no generator" : gen.c_str()); - // Buffers - int nBuf = (gj.contains("buffers") && gj["buffers"].is_array()) - ? static_cast(gj["buffers"].size()) : 0; - std::printf("├─ buffers (%d)\n", nBuf); - if (nBuf > 0) { - for (int b = 0; b < nBuf; ++b) { - bool last = (b == nBuf - 1); - uint64_t bl = gj["buffers"][b].value("byteLength", 0u); - std::printf("│ %s[%d] %llu bytes\n", branch(last), b, - static_cast(bl)); - } - } - // BufferViews - int nBV = (gj.contains("bufferViews") && gj["bufferViews"].is_array()) - ? static_cast(gj["bufferViews"].size()) : 0; - std::printf("├─ bufferViews (%d)\n", nBV); - for (int v = 0; v < nBV; ++v) { - bool last = (v == nBV - 1); - const auto& bv = gj["bufferViews"][v]; - uint32_t bo = bv.value("byteOffset", 0u); - uint32_t bl = bv.value("byteLength", 0u); - int target = bv.value("target", 0); - std::printf("│ %s[%d] off=%u len=%u%s\n", - branch(last), v, bo, bl, - target == 34962 ? " (vertex)" - : target == 34963 ? " (index)" - : ""); - } - // Accessors - int nAcc = (gj.contains("accessors") && gj["accessors"].is_array()) - ? static_cast(gj["accessors"].size()) : 0; - std::printf("├─ accessors (%d)\n", nAcc); - for (int a = 0; a < nAcc; ++a) { - bool last = (a == nAcc - 1); - const auto& acc = gj["accessors"][a]; - int ct = acc.value("componentType", 0); - std::string type = acc.value("type", std::string{}); - uint32_t count = acc.value("count", 0u); - int bv = acc.value("bufferView", -1); - const char* ctName = - ct == 5120 ? "i8" : - ct == 5121 ? "u8" : - ct == 5122 ? "i16" : - ct == 5123 ? "u16" : - ct == 5125 ? "u32" : - ct == 5126 ? "f32" : "?"; - std::printf("│ %s[%d] %s %s ×%u (bv=%d)\n", - branch(last), a, ctName, type.c_str(), count, bv); - } - // Meshes (with primitives nested) - int nMesh = (gj.contains("meshes") && gj["meshes"].is_array()) - ? static_cast(gj["meshes"].size()) : 0; - std::printf("├─ meshes (%d)\n", nMesh); - for (int m = 0; m < nMesh; ++m) { - bool lastM = (m == nMesh - 1); - const auto& mesh = gj["meshes"][m]; - std::string name = mesh.value("name", std::string{}); - int nPrim = (mesh.contains("primitives") && mesh["primitives"].is_array()) - ? static_cast(mesh["primitives"].size()) : 0; - std::printf("│ %s[%d]%s%s (%d primitives)\n", - branch(lastM), m, - name.empty() ? "" : " ", - name.c_str(), nPrim); - for (int p = 0; p < nPrim; ++p) { - bool lastP = (p == nPrim - 1); - const auto& prim = mesh["primitives"][p]; - int idxAcc = prim.value("indices", -1); - int mode = prim.value("mode", 4); - const char* modeName = - mode == 0 ? "POINTS" : - mode == 1 ? "LINES" : - mode == 4 ? "TRIANGLES" : "?"; - std::printf("│ %s%s[%d] %s indices=acc#%d\n", - cont(lastM), branch(lastP), p, modeName, idxAcc); - } - } - // Nodes (flat list — could be tree but glTF nodes are a graph) - int nNode = (gj.contains("nodes") && gj["nodes"].is_array()) - ? static_cast(gj["nodes"].size()) : 0; - std::printf("├─ nodes (%d)\n", nNode); - for (int n = 0; n < nNode; ++n) { - bool last = (n == nNode - 1); - const auto& node = gj["nodes"][n]; - std::string name = node.value("name", std::string{}); - int meshIdx = node.value("mesh", -1); - std::printf("│ %s[%d]%s%s%s\n", - branch(last), n, - name.empty() ? "" : " ", - name.c_str(), - meshIdx >= 0 ? (" -> mesh#" + std::to_string(meshIdx)).c_str() : ""); - } - // Scenes (last branch) - int nScene = (gj.contains("scenes") && gj["scenes"].is_array()) - ? static_cast(gj["scenes"].size()) : 0; - std::printf("└─ scenes (%d, default=%d)\n", - nScene, gj.value("scene", 0)); - for (int s = 0; s < nScene; ++s) { - bool lastS = (s == nScene - 1); - const auto& scene = gj["scenes"][s]; - int nodeRefs = (scene.contains("nodes") && scene["nodes"].is_array()) - ? static_cast(scene["nodes"].size()) : 0; - std::printf(" %s[%d] nodes=[", branch(lastS), s); - if (scene.contains("nodes") && scene["nodes"].is_array()) { - for (size_t k = 0; k < scene["nodes"].size(); ++k) { - std::printf("%s%d", k ? "," : "", scene["nodes"][k].get()); - } - } - std::printf("] (%d nodes)\n", nodeRefs); - } - return 0; - } else if (std::strcmp(argv[i], "--info-glb-bytes") == 0 && i + 1 < argc) { - // Per-section + per-bufferView byte breakdown of a .glb. Useful - // for understanding what's bloating a baked .glb (vertex attrs - // vs indices, position vs uv vs normal data, mesh-level - // payloads). Pairs with --info-glb (counts) and --info-glb-tree - // (structure). - std::string path = argv[++i]; - bool jsonOut = (i + 1 < argc && - std::strcmp(argv[i + 1], "--json") == 0); - if (jsonOut) i++; - std::ifstream in(path, std::ios::binary); - if (!in) { - std::fprintf(stderr, - "info-glb-bytes: cannot open %s\n", path.c_str()); - return 1; - } - std::vector bytes((std::istreambuf_iterator(in)), - std::istreambuf_iterator()); - if (bytes.size() < 28) { - std::fprintf(stderr, "info-glb-bytes: file too short\n"); - return 1; - } - uint32_t magic, version; - std::memcpy(&magic, &bytes[0], 4); - std::memcpy(&version, &bytes[4], 4); - if (magic != 0x46546C67 || version != 2) { - std::fprintf(stderr, "info-glb-bytes: not glTF 2.0\n"); - return 1; - } - uint32_t jsonLen, binLen = 0; - std::memcpy(&jsonLen, &bytes[12], 4); - std::string jsonStr(bytes.begin() + 20, - bytes.begin() + 20 + jsonLen); - size_t binOff = 20 + jsonLen; - if (binOff + 8 <= bytes.size()) { - std::memcpy(&binLen, &bytes[binOff], 4); - } - uint32_t headerBytes = 12; // magic+version+totalLength - uint32_t jsonHdrBytes = 8; // jsonLen + jsonType - uint32_t binHdrBytes = (binLen > 0) ? 8 : 0; - nlohmann::json gj; - try { gj = nlohmann::json::parse(jsonStr); } - catch (const std::exception& e) { - std::fprintf(stderr, - "info-glb-bytes: JSON parse failed: %s\n", e.what()); - return 1; - } - // Per-bufferView size table. - struct BV { int idx; uint32_t off, len; std::string label; }; - std::vector bufferViews; - if (gj.contains("bufferViews") && gj["bufferViews"].is_array()) { - for (size_t k = 0; k < gj["bufferViews"].size(); ++k) { - const auto& bv = gj["bufferViews"][k]; - BV b; - b.idx = static_cast(k); - b.off = bv.value("byteOffset", 0u); - b.len = bv.value("byteLength", 0u); - int target = bv.value("target", 0); - b.label = (target == 34962) ? "vertex" : - (target == 34963) ? "index" : "other"; - bufferViews.push_back(b); - } - } - // Bucket bufferViews by purpose using accessor types. - // Walk accessors: each references a bufferView, with type - // (VEC3/VEC2/SCALAR) hinting at content (position/uv/etc.) - std::map bytesByPurpose; - if (gj.contains("accessors") && gj["accessors"].is_array() && - gj.contains("meshes") && gj["meshes"].is_array()) { - std::set seenAccessors; - for (const auto& m : gj["meshes"]) { - if (!m.contains("primitives") || !m["primitives"].is_array()) continue; - for (const auto& p : m["primitives"]) { - if (!p.contains("attributes")) continue; - for (auto it = p["attributes"].begin(); - it != p["attributes"].end(); ++it) { - int ai = it.value().get(); - if (seenAccessors.count(ai)) continue; - seenAccessors.insert(ai); - if (ai < 0 || ai >= static_cast(gj["accessors"].size())) continue; - const auto& acc = gj["accessors"][ai]; - int bv = acc.value("bufferView", -1); - if (bv < 0 || bv >= static_cast(bufferViews.size())) continue; - std::string typeStr = acc.value("type", std::string{}); - int comp = acc.value("componentType", 0); - uint32_t cnt = acc.value("count", 0u); - uint32_t byteStride = - typeStr == "VEC3" ? 12 : - typeStr == "VEC2" ? 8 : - typeStr == "VEC4" ? 16 : - typeStr == "SCALAR" ? - (comp == 5126 ? 4 : comp == 5125 ? 4 : - comp == 5123 ? 2 : comp == 5121 ? 1 : 4) : 4; - uint64_t b = uint64_t(cnt) * byteStride; - bytesByPurpose[it.key()] += b; - } - // Indices accessor. - if (p.contains("indices")) { - int ai = p["indices"].get(); - if (seenAccessors.count(ai)) continue; - seenAccessors.insert(ai); - if (ai < 0 || ai >= static_cast(gj["accessors"].size())) continue; - const auto& acc = gj["accessors"][ai]; - uint32_t cnt = acc.value("count", 0u); - int comp = acc.value("componentType", 0); - uint32_t s = (comp == 5125 ? 4 : comp == 5123 ? 2 : 4); - bytesByPurpose["INDICES"] += uint64_t(cnt) * s; - } - } - } - } - uint64_t totalBytes = bytes.size(); - if (jsonOut) { - nlohmann::json j; - j["glb"] = path; - j["totalBytes"] = totalBytes; - j["sections"] = { - {"header", headerBytes}, - {"jsonHeader", jsonHdrBytes}, - {"json", jsonLen}, - {"binHeader", binHdrBytes}, - {"bin", binLen} - }; - nlohmann::json bvArr = nlohmann::json::array(); - for (const auto& bv : bufferViews) { - bvArr.push_back({{"index", bv.idx}, - {"target", bv.label}, - {"bytes", bv.len}}); - } - j["bufferViews"] = bvArr; - nlohmann::json byPurp = nlohmann::json::object(); - for (const auto& [p, b] : bytesByPurpose) byPurp[p] = b; - j["byPurpose"] = byPurp; - std::printf("%s\n", j.dump(2).c_str()); - return 0; - } - std::printf("GLB bytes: %s\n", path.c_str()); - std::printf(" total: %llu bytes (%.2f MB)\n", - static_cast(totalBytes), - totalBytes / (1024.0 * 1024.0)); - std::printf("\n Sections:\n"); - auto pct = [&](uint64_t v) { - return totalBytes ? 100.0 * v / totalBytes : 0.0; - }; - std::printf(" header : %5u bytes %5.2f%%\n", headerBytes, pct(headerBytes)); - std::printf(" JSON hdr : %5u bytes %5.2f%%\n", jsonHdrBytes, pct(jsonHdrBytes)); - std::printf(" JSON : %5u bytes %5.2f%%\n", jsonLen, pct(jsonLen)); - std::printf(" BIN hdr : %5u bytes %5.2f%%\n", binHdrBytes, pct(binHdrBytes)); - std::printf(" BIN : %5u bytes %5.2f%%\n", binLen, pct(binLen)); - if (!bufferViews.empty()) { - std::printf("\n BufferViews:\n"); - std::printf(" idx target bytes MB share-of-bin\n"); - for (const auto& bv : bufferViews) { - double bvPct = binLen ? 100.0 * bv.len / binLen : 0.0; - std::printf(" %3d %-7s %8u %6.2f %5.2f%%\n", - bv.idx, bv.label.c_str(), bv.len, - bv.len / (1024.0 * 1024.0), bvPct); - } - } - if (!bytesByPurpose.empty()) { - std::printf("\n By attribute:\n"); - for (const auto& [p, b] : bytesByPurpose) { - double bPct = binLen ? 100.0 * b / binLen : 0.0; - std::printf(" %-12s %8llu bytes (%.2f%% of BIN)\n", - p.c_str(), - static_cast(b), bPct); - } - } - return 0; - } else if (std::strcmp(argv[i], "--check-glb-bounds") == 0 && i + 1 < argc) { - // Cross-checks every position accessor's claimed min/max - // against the actual data in the BIN chunk. glTF viewers use - // these for camera framing and frustum culling — stale - // values (e.g. from a tool that edited geometry without - // recomputing) cause models to vanish at certain angles or - // get framed wrong on load. - std::string path = argv[++i]; - bool jsonOut = (i + 1 < argc && - std::strcmp(argv[i + 1], "--json") == 0); - if (jsonOut) i++; - std::ifstream in(path, std::ios::binary); - if (!in) { - std::fprintf(stderr, - "check-glb-bounds: cannot open %s\n", path.c_str()); - return 1; - } - std::vector bytes((std::istreambuf_iterator(in)), - std::istreambuf_iterator()); - // Parse glb structure (re-implements --validate-glb's parser - // since we need access to the BIN chunk bytes here). - if (bytes.size() < 28) { - std::fprintf(stderr, - "check-glb-bounds: file too short to be a .glb\n"); - return 1; - } - uint32_t magic, version; - std::memcpy(&magic, &bytes[0], 4); - std::memcpy(&version, &bytes[4], 4); - if (magic != 0x46546C67 || version != 2) { - std::fprintf(stderr, - "check-glb-bounds: not a valid glTF 2.0 binary\n"); - return 1; - } - uint32_t jsonLen, jsonType; - std::memcpy(&jsonLen, &bytes[12], 4); - std::memcpy(&jsonType, &bytes[16], 4); - std::string jsonStr(bytes.begin() + 20, bytes.begin() + 20 + jsonLen); - size_t binOff = 20 + jsonLen; - std::memcpy(&magic, &bytes[binOff + 4], 4); // chunkType - const uint8_t* binData = &bytes[binOff + 8]; - uint32_t binLen; - std::memcpy(&binLen, &bytes[binOff], 4); - (void)binLen; // not range-checked here; --validate-glb does that - nlohmann::json gj; - try { gj = nlohmann::json::parse(jsonStr); } - catch (const std::exception& e) { - std::fprintf(stderr, - "check-glb-bounds: JSON parse failed: %s\n", e.what()); - return 1; - } - std::vector errors; - int posAccessors = 0, mismatched = 0; - // Walk all primitives, collect their POSITION accessor index, - // dedupe (multiple primitives can share an accessor — only - // recompute once per unique). - std::set posAccIndices; - if (gj.contains("meshes") && gj["meshes"].is_array()) { - for (const auto& m : gj["meshes"]) { - if (!m.contains("primitives") || !m["primitives"].is_array()) continue; - for (const auto& p : m["primitives"]) { - if (p.contains("attributes") && - p["attributes"].contains("POSITION")) { - posAccIndices.insert(p["attributes"]["POSITION"].get()); - } - } - } - } - const auto& accessors = gj["accessors"]; - const auto& bufferViews = gj["bufferViews"]; - for (int ai : posAccIndices) { - if (ai < 0 || ai >= static_cast(accessors.size())) { - errors.push_back("position accessor " + std::to_string(ai) + - " out of range"); - continue; - } - const auto& acc = accessors[ai]; - if (acc.value("type", std::string{}) != "VEC3" || - acc.value("componentType", 0) != 5126) { - errors.push_back("accessor " + std::to_string(ai) + - " is not VEC3 FLOAT"); - continue; - } - posAccessors++; - int bvIdx = acc.value("bufferView", -1); - if (bvIdx < 0 || bvIdx >= static_cast(bufferViews.size())) { - errors.push_back("accessor " + std::to_string(ai) + - " bufferView " + std::to_string(bvIdx) + - " out of range"); - continue; - } - const auto& bv = bufferViews[bvIdx]; - uint32_t bvOff = bv.value("byteOffset", 0u); - uint32_t accOff = acc.value("byteOffset", 0u); - uint32_t count = acc.value("count", 0u); - const uint8_t* p = binData + bvOff + accOff; - glm::vec3 actualMin{1e30f}, actualMax{-1e30f}; - for (uint32_t v = 0; v < count; ++v) { - glm::vec3 pos; - std::memcpy(&pos.x, p + v * 12 + 0, 4); - std::memcpy(&pos.y, p + v * 12 + 4, 4); - std::memcpy(&pos.z, p + v * 12 + 8, 4); - actualMin = glm::min(actualMin, pos); - actualMax = glm::max(actualMax, pos); - } - // Compare against claimed min/max (within float epsilon). - glm::vec3 claimedMin{0}, claimedMax{0}; - bool hasClaimed = (acc.contains("min") && acc.contains("max")); - if (hasClaimed) { - claimedMin.x = acc["min"][0]; claimedMin.y = acc["min"][1]; claimedMin.z = acc["min"][2]; - claimedMax.x = acc["max"][0]; claimedMax.y = acc["max"][1]; claimedMax.z = acc["max"][2]; - auto close = [](float a, float b) { - return std::abs(a - b) < 1e-3f; - }; - bool ok = close(claimedMin.x, actualMin.x) && - close(claimedMin.y, actualMin.y) && - close(claimedMin.z, actualMin.z) && - close(claimedMax.x, actualMax.x) && - close(claimedMax.y, actualMax.y) && - close(claimedMax.z, actualMax.z); - if (!ok) { - mismatched++; - char buf[256]; - std::snprintf(buf, sizeof(buf), - "accessor %d bounds mismatch: claimed [%g,%g,%g]-[%g,%g,%g] vs actual [%g,%g,%g]-[%g,%g,%g]", - ai, - claimedMin.x, claimedMin.y, claimedMin.z, - claimedMax.x, claimedMax.y, claimedMax.z, - actualMin.x, actualMin.y, actualMin.z, - actualMax.x, actualMax.y, actualMax.z); - errors.push_back(buf); - } - } else { - // glTF spec requires position accessors to declare min/max. - errors.push_back("accessor " + std::to_string(ai) + - " missing required min/max for POSITION attribute"); - mismatched++; - } - } - if (jsonOut) { - nlohmann::json j; - j["glb"] = path; - j["positionAccessors"] = posAccessors; - j["mismatched"] = mismatched; - j["errors"] = errors; - j["passed"] = errors.empty(); - std::printf("%s\n", j.dump(2).c_str()); - return errors.empty() ? 0 : 1; - } - std::printf("GLB bounds: %s\n", path.c_str()); - std::printf(" position accessors checked : %d\n", posAccessors); - std::printf(" mismatched : %d\n", mismatched); - if (errors.empty()) { - std::printf(" PASSED\n"); - return 0; - } - std::printf(" FAILED — %zu error(s):\n", errors.size()); - for (const auto& e : errors) 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