From 25848349a33a2ea3a97b1e07a67bfc8675114aae Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Sat, 13 Sep 2025 20:49:29 -0500 Subject: [PATCH] LXFML SPLITTING Included test file --- dCommon/Lxfml.cpp | 271 ++++++++++++++++++ dCommon/Lxfml.h | 20 ++ tests/dCommonTests/CMakeLists.txt | 1 + tests/dCommonTests/lxfml_split_tests.cpp | 145 ++++++++++ tests/dCommonTests/test.lxfml | 336 +++++++++++++++++++++++ 5 files changed, 773 insertions(+) create mode 100644 tests/dCommonTests/lxfml_split_tests.cpp create mode 100644 tests/dCommonTests/test.lxfml diff --git a/dCommon/Lxfml.cpp b/dCommon/Lxfml.cpp index e71b2d8e..d6305a72 100644 --- a/dCommon/Lxfml.cpp +++ b/dCommon/Lxfml.cpp @@ -5,6 +5,10 @@ #include "TinyXmlUtils.h" #include +#include +#include +#include +#include Lxfml::Result Lxfml::NormalizePosition(const std::string_view data, const NiPoint3& curPosition) { Result toReturn; @@ -128,3 +132,270 @@ Lxfml::Result Lxfml::NormalizePosition(const std::string_view data, const NiPoin toReturn.center = newRootPos; return toReturn; } + +// Deep-clone an XMLElement (attributes, text, and child elements) into a target document +static tinyxml2::XMLElement* CloneElementDeep(const tinyxml2::XMLElement* src, tinyxml2::XMLDocument& dstDoc) { + if (!src) return nullptr; + auto* dst = dstDoc.NewElement(src->Name()); + + // copy attributes + for (const tinyxml2::XMLAttribute* attr = src->FirstAttribute(); attr; attr = attr->Next()) { + dst->SetAttribute(attr->Name(), attr->Value()); + } + + // copy children (elements and text) + for (const tinyxml2::XMLNode* child = src->FirstChild(); child; child = child->NextSibling()) { + if (const tinyxml2::XMLElement* childElem = child->ToElement()) { + dst->InsertEndChild(CloneElementDeep(childElem, dstDoc)); + } else if (const tinyxml2::XMLText* txt = child->ToText()) { + auto* n = dstDoc.NewText(txt->Value()); + dst->InsertEndChild(n); + } else if (const tinyxml2::XMLComment* c = child->ToComment()) { + auto* n = dstDoc.NewComment(c->Value()); + dst->InsertEndChild(n); + } + } + + return dst; +} + +std::vector Lxfml::Split(const std::string_view data, const NiPoint3& curPosition) { + std::vector results; + tinyxml2::XMLDocument doc; + const auto err = doc.Parse(data.data()); + if (err != tinyxml2::XML_SUCCESS) { + LOG("Failed to parse xml %s.", StringifiedEnum::ToString(err).data()); + return results; + } + + auto* lxfml = doc.FirstChildElement("LXFML"); + if (!lxfml) { + LOG("Failed to find LXFML element."); + return results; + } + + // Build maps: partRef -> Part element, partRef -> Brick element, boneRef -> partRef, brickRef -> Brick element + std::unordered_map partRefToPart; + std::unordered_map partRefToBrick; + std::unordered_map boneRefToPartRef; + std::unordered_map brickByRef; + + auto* bricksParent = lxfml->FirstChildElement("Bricks"); + if (bricksParent) { + for (auto* brick = bricksParent->FirstChildElement("Brick"); brick; brick = brick->NextSiblingElement("Brick")) { + const char* brickRef = brick->Attribute("refID"); + if (brickRef) brickByRef.emplace(std::string(brickRef), brick); + for (auto* part = brick->FirstChildElement("Part"); part; part = part->NextSiblingElement("Part")) { + const char* partRef = part->Attribute("refID"); + if (partRef) { + partRefToPart.emplace(std::string(partRef), part); + partRefToBrick.emplace(std::string(partRef), brick); + } + auto* bone = part->FirstChildElement("Bone"); + if (bone) { + const char* boneRef = bone->Attribute("refID"); + if (boneRef) boneRefToPartRef.emplace(std::string(boneRef), partRef ? std::string(partRef) : std::string()); + } + } + } + } + + // Collect RigidSystem elements + std::vector rigidSystems; + auto* rigidSystemsParent = lxfml->FirstChildElement("RigidSystems"); + if (rigidSystemsParent) { + for (auto* rs = rigidSystemsParent->FirstChildElement("RigidSystem"); rs; rs = rs->NextSiblingElement("RigidSystem")) { + rigidSystems.push_back(rs); + } + } + + // Collect top-level groups (immediate children of GroupSystem) + std::vector groupRoots; + auto* groupSystemsParent = lxfml->FirstChildElement("GroupSystems"); + if (groupSystemsParent) { + for (auto* gs = groupSystemsParent->FirstChildElement("GroupSystem"); gs; gs = gs->NextSiblingElement("GroupSystem")) { + for (auto* group = gs->FirstChildElement("Group"); group; group = group->NextSiblingElement("Group")) { + groupRoots.push_back(group); + } + } + } + + // Track used bricks and rigidsystems + std::unordered_set usedBrickRefs; + std::unordered_set usedRigidSystems; + + auto splitListAttr = [](const char* attr) { + std::vector out; + if (!attr) return out; + std::stringstream ss(attr); + std::string token; + while (std::getline(ss, token, ',')) { + if (!token.empty()) out.push_back(token); + } + return out; + }; + + // Helper to create output document from sets of brick refs and rigidsystem pointers + auto makeOutput = [&](const std::unordered_set& bricksToInclude, const std::vector& rigidSystemsToInclude, const std::vector& groupsToInclude = {}) { + tinyxml2::XMLDocument outDoc; + outDoc.Parse(Lxfml::base.c_str()); + auto* outRoot = outDoc.FirstChildElement("LXFML"); + auto* outBricks = outRoot->FirstChildElement("Bricks"); + auto* outRigidSystems = outRoot->FirstChildElement("RigidSystems"); + auto* outGroupSystems = outRoot->FirstChildElement("GroupSystems"); + + // clone and insert bricks + for (const auto& bref : bricksToInclude) { + auto it = brickByRef.find(bref); + if (it == brickByRef.end()) continue; + tinyxml2::XMLElement* cloned = CloneElementDeep(it->second, outDoc); + if (cloned) outBricks->InsertEndChild(cloned); + } + + // clone and insert rigidsystems + for (auto* rsPtr : rigidSystemsToInclude) { + tinyxml2::XMLElement* cloned = CloneElementDeep(rsPtr, outDoc); + if (cloned) outRigidSystems->InsertEndChild(cloned); + } + + // clone and insert group(s) if requested + if (outGroupSystems && !groupsToInclude.empty()) { + // clear default children + while (outGroupSystems->FirstChild()) outGroupSystems->DeleteChild(outGroupSystems->FirstChild()); + // create a GroupSystem element and append requested groups + auto* newGS = outDoc.NewElement("GroupSystem"); + for (auto* gptr : groupsToInclude) { + tinyxml2::XMLElement* clonedG = CloneElementDeep(gptr, outDoc); + if (clonedG) newGS->InsertEndChild(clonedG); + } + outGroupSystems->InsertEndChild(newGS); + } + + // Print to string + tinyxml2::XMLPrinter printer; + outDoc.Print(&printer); + // Normalize position and compute center using existing helper + auto normalized = NormalizePosition(printer.CStr(), curPosition); + return normalized; + }; + + // 1) Process groups (each top-level Group becomes one output; nested groups are included) + for (auto* groupRoot : groupRoots) { + // collect all partRefs in this group's subtree + std::unordered_set partRefs; + std::function collectParts = [&](const tinyxml2::XMLElement* g) { + if (!g) return; + const char* partAttr = g->Attribute("partRefs"); + if (partAttr) { + for (auto& tok : splitListAttr(partAttr)) partRefs.insert(tok); + } + for (auto* child = g->FirstChildElement("Group"); child; child = child->NextSiblingElement("Group")) collectParts(child); + }; + collectParts(groupRoot); + + // Build initial sets of bricks and boneRefs + std::unordered_set bricksIncluded; + std::unordered_set boneRefsIncluded; + for (const auto& pref : partRefs) { + auto pit = partRefToBrick.find(pref); + if (pit != partRefToBrick.end()) { + const char* bref = pit->second->Attribute("refID"); + if (bref) bricksIncluded.insert(std::string(bref)); + } + auto partIt = partRefToPart.find(pref); + if (partIt != partRefToPart.end()) { + auto* bone = partIt->second->FirstChildElement("Bone"); + if (bone) { + const char* bref = bone->Attribute("refID"); + if (bref) boneRefsIncluded.insert(std::string(bref)); + } + } + } + + // Iteratively include any RigidSystems that reference any boneRefsIncluded + bool changed = true; + std::vector rigidSystemsToInclude; + while (changed) { + changed = false; + for (auto* rs : rigidSystems) { + if (usedRigidSystems.find(rs) != usedRigidSystems.end()) continue; + // parse boneRefs of this rigid system (from its children) + bool intersects = false; + std::vector rsBoneRefs; + for (auto* rigid = rs->FirstChildElement("Rigid"); rigid; rigid = rigid->NextSiblingElement("Rigid")) { + const char* battr = rigid->Attribute("boneRefs"); + if (!battr) continue; + for (auto& tok : splitListAttr(battr)) { + rsBoneRefs.push_back(tok); + if (boneRefsIncluded.find(tok) != boneRefsIncluded.end()) intersects = true; + } + } + if (!intersects) continue; + // include this rigid system and all boneRefs it references + usedRigidSystems.insert(rs); + rigidSystemsToInclude.push_back(rs); + for (const auto& br : rsBoneRefs) { + boneRefsIncluded.insert(br); + auto bpIt = boneRefToPartRef.find(br); + if (bpIt != boneRefToPartRef.end()) { + auto partRef = bpIt->second; + auto pbIt = partRefToBrick.find(partRef); + if (pbIt != partRefToBrick.end()) { + const char* bref = pbIt->second->Attribute("refID"); + if (bref && bricksIncluded.insert(std::string(bref)).second) changed = true; + } + } + } + // also include bricks for any newly discovered parts + } + } + + // include bricks from bricksIncluded into used set + for (const auto& b : bricksIncluded) usedBrickRefs.insert(b); + + // make output doc and push result (include this group's XML) + std::vector groupsVec{ groupRoot }; + auto normalized = makeOutput(bricksIncluded, rigidSystemsToInclude, groupsVec); + results.push_back(normalized); + } + + // 2) Process remaining RigidSystems (each becomes its own file) + for (auto* rs : rigidSystems) { + if (usedRigidSystems.find(rs) != usedRigidSystems.end()) continue; + std::unordered_set bricksIncluded; + // collect boneRefs referenced by this rigid system + for (auto* rigid = rs->FirstChildElement("Rigid"); rigid; rigid = rigid->NextSiblingElement("Rigid")) { + const char* battr = rigid->Attribute("boneRefs"); + if (!battr) continue; + for (auto& tok : splitListAttr(battr)) { + auto bpIt = boneRefToPartRef.find(tok); + if (bpIt != boneRefToPartRef.end()) { + auto partRef = bpIt->second; + auto pbIt = partRefToBrick.find(partRef); + if (pbIt != partRefToBrick.end()) { + const char* bref = pbIt->second->Attribute("refID"); + if (bref) bricksIncluded.insert(std::string(bref)); + } + } + } + } + // mark used + for (const auto& b : bricksIncluded) usedBrickRefs.insert(b); + usedRigidSystems.insert(rs); + + std::vector rsVec{ rs }; + auto normalized = makeOutput(bricksIncluded, rsVec); + results.push_back(normalized); + } + + // 3) Any remaining bricks not included become their own files + for (const auto& [bref, brickPtr] : brickByRef) { + if (usedBrickRefs.find(bref) != usedBrickRefs.end()) continue; + std::unordered_set bricksIncluded{ bref }; + auto normalized = makeOutput(bricksIncluded, {}); + results.push_back(normalized); + usedBrickRefs.insert(bref); + } + + return results; +} diff --git a/dCommon/Lxfml.h b/dCommon/Lxfml.h index 80710713..f0486c8d 100644 --- a/dCommon/Lxfml.h +++ b/dCommon/Lxfml.h @@ -6,6 +6,7 @@ #include #include +#include #include "NiPoint3.h" @@ -18,6 +19,25 @@ namespace Lxfml { // Normalizes a LXFML model to be positioned relative to its local 0, 0, 0 rather than a game worlds 0, 0, 0. // Returns a struct of its new center and the updated LXFML containing these edits. [[nodiscard]] Result NormalizePosition(const std::string_view data, const NiPoint3& curPosition = NiPoint3Constant::ZERO); + [[nodiscard]] std::vector Split(const std::string_view data, const NiPoint3& curPosition = NiPoint3Constant::ZERO); + + // The base LXFML xml file to use when creating new models. + static inline std::string base = R"( + + + + + + + + + + + + + + +)"; // these are only for the migrations due to a bug in one of the implementations. [[nodiscard]] Result NormalizePositionOnlyFirstPart(const std::string_view data); diff --git a/tests/dCommonTests/CMakeLists.txt b/tests/dCommonTests/CMakeLists.txt index 17b31ced..888930a3 100644 --- a/tests/dCommonTests/CMakeLists.txt +++ b/tests/dCommonTests/CMakeLists.txt @@ -10,6 +10,7 @@ set(DCOMMONTEST_SOURCES "TestLUString.cpp" "TestLUWString.cpp" "dCommonDependencies.cpp" + "lxfml_split_tests.cpp" ) add_subdirectory(dEnumsTests) diff --git a/tests/dCommonTests/lxfml_split_tests.cpp b/tests/dCommonTests/lxfml_split_tests.cpp new file mode 100644 index 00000000..86216464 --- /dev/null +++ b/tests/dCommonTests/lxfml_split_tests.cpp @@ -0,0 +1,145 @@ +#include "gtest/gtest.h" + +#include "Lxfml.h" +#include "TinyXmlUtils.h" + +#include +#include +#include +#include + +using namespace TinyXmlUtils; + +static std::string ReadFile(const std::string& path) { + std::ifstream in(path, std::ios::in | std::ios::binary); + std::ostringstream ss; + ss << in.rdbuf(); + return ss.str(); +} + +TEST(LxfmlSplitTests, SplitUsesAllBricksAndNoDuplicates) { + // Read the sample test.lxfml included in tests. Resolve path relative to this source file. + std::filesystem::path srcDir = std::filesystem::path(__FILE__).parent_path(); + std::filesystem::path filePath = srcDir / "test.lxfml"; + std::string data = ReadFile(filePath.string()); + ASSERT_FALSE(data.empty()) << "Failed to read " << filePath.string(); + + + auto results = Lxfml::Split(data); + ASSERT_GT(results.size(), 0); + + // Write split outputs to disk for manual inspection + std::filesystem::path outDir = srcDir / "lxfml_splits"; + std::error_code ec; + std::filesystem::create_directories(outDir, ec); + for (size_t i = 0; i < results.size(); ++i) { + auto outPath = outDir / ("split_" + std::to_string(i) + ".lxfml"); + std::ofstream ofs(outPath, std::ios::out | std::ios::binary); + ASSERT_TRUE(ofs) << "Failed to open output file: " << outPath.string(); + ofs << results[i].lxfml; + ofs.close(); + } + + // parse original to count bricks + tinyxml2::XMLDocument doc; + ASSERT_EQ(doc.Parse(data.c_str()), tinyxml2::XML_SUCCESS); + DocumentReader reader(doc); + auto lxfml = reader["LXFML"]; + ASSERT_TRUE(lxfml); + + // Collect original RigidSystems and Groups (serialize each element string) + auto serializeElement = [](tinyxml2::XMLElement* elem) { + tinyxml2::XMLPrinter p; + elem->Accept(&p); + return std::string(p.CStr()); + }; + + std::unordered_set originalRigidSet; + if (auto* rsParent = doc.FirstChildElement("LXFML")->FirstChildElement("RigidSystems")) { + for (auto* rs = rsParent->FirstChildElement("RigidSystem"); rs; rs = rs->NextSiblingElement("RigidSystem")) { + originalRigidSet.insert(serializeElement(rs)); + } + } + + std::unordered_set originalGroupSet; + if (auto* gsParent = doc.FirstChildElement("LXFML")->FirstChildElement("GroupSystems")) { + for (auto* gs = gsParent->FirstChildElement("GroupSystem"); gs; gs = gs->NextSiblingElement("GroupSystem")) { + for (auto* g = gs->FirstChildElement("Group"); g; g = g->NextSiblingElement("Group")) { + // collect this group and nested groups + std::function collectGroups = [&](tinyxml2::XMLElement* grp) { + originalGroupSet.insert(serializeElement(grp)); + for (auto* child = grp->FirstChildElement("Group"); child; child = child->NextSiblingElement("Group")) collectGroups(child); + }; + collectGroups(g); + } + } + } + + std::unordered_set originalBricks; + for (const auto& brick : lxfml["Bricks"]) { + const auto* ref = brick.Attribute("refID"); + if (ref) originalBricks.insert(ref); + } + ASSERT_GT(originalBricks.size(), 0); + + // Collect bricks across all results and ensure no duplicates and all used + std::unordered_set usedBricks; + // Track used rigid systems and groups (serialized strings) + std::unordered_set usedRigidSet; + std::unordered_set usedGroupSet; + for (const auto& res : results) { + tinyxml2::XMLDocument outDoc; + ASSERT_EQ(outDoc.Parse(res.lxfml.c_str()), tinyxml2::XML_SUCCESS); + DocumentReader outReader(outDoc); + auto outLxfml = outReader["LXFML"]; + ASSERT_TRUE(outLxfml); + // collect rigid systems in this output + if (auto* rsParent = outDoc.FirstChildElement("LXFML")->FirstChildElement("RigidSystems")) { + for (auto* rs = rsParent->FirstChildElement("RigidSystem"); rs; rs = rs->NextSiblingElement("RigidSystem")) { + auto s = serializeElement(rs); + // no duplicate allowed across outputs + ASSERT_EQ(usedRigidSet.find(s), usedRigidSet.end()) << "Duplicate RigidSystem across splits"; + usedRigidSet.insert(s); + } + } + // collect groups in this output + if (auto* gsParent = outDoc.FirstChildElement("LXFML")->FirstChildElement("GroupSystems")) { + for (auto* gs = gsParent->FirstChildElement("GroupSystem"); gs; gs = gs->NextSiblingElement("GroupSystem")) { + for (auto* g = gs->FirstChildElement("Group"); g; g = g->NextSiblingElement("Group")) { + std::function collectGroupsOut = [&](tinyxml2::XMLElement* grp) { + auto s = serializeElement(grp); + ASSERT_EQ(usedGroupSet.find(s), usedGroupSet.end()) << "Duplicate Group across splits"; + usedGroupSet.insert(s); + for (auto* child = grp->FirstChildElement("Group"); child; child = child->NextSiblingElement("Group")) collectGroupsOut(child); + }; + collectGroupsOut(g); + } + } + } + for (const auto& brick : outLxfml["Bricks"]) { + const auto* ref = brick.Attribute("refID"); + if (ref) { + // no duplicate allowed + ASSERT_EQ(usedBricks.find(ref), usedBricks.end()) << "Duplicate brick ref across splits: " << ref; + usedBricks.insert(ref); + } + } + } + + // Every original brick must be used in one of the outputs + for (const auto& bref : originalBricks) { + ASSERT_NE(usedBricks.find(bref), usedBricks.end()) << "Brick not used in splits: " << bref; + } + + // And usedBricks should not contain anything outside original + for (const auto& ub : usedBricks) { + ASSERT_NE(originalBricks.find(ub), originalBricks.end()) << "Split produced unknown brick: " << ub; + } + + // Ensure all original rigid systems and groups were used exactly once + ASSERT_EQ(originalRigidSet.size(), usedRigidSet.size()) << "RigidSystem count mismatch"; + for (const auto& s : originalRigidSet) ASSERT_NE(usedRigidSet.find(s), usedRigidSet.end()) << "RigidSystem missing in splits"; + + ASSERT_EQ(originalGroupSet.size(), usedGroupSet.size()) << "Group count mismatch"; + for (const auto& s : originalGroupSet) ASSERT_NE(usedGroupSet.find(s), usedGroupSet.end()) << "Group missing in splits"; +} diff --git a/tests/dCommonTests/test.lxfml b/tests/dCommonTests/test.lxfml new file mode 100644 index 00000000..f9ce0d35 --- /dev/null +++ b/tests/dCommonTests/test.lxfml @@ -0,0 +1,336 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +