From a163264adf9a1b9517ed8bd7f51aae85ebf53338 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Mon, 28 Sep 2026 10:44:30 -0500 Subject: [PATCH] fix: splitting and normalizing models moves every bone, in file order Lxfml::NormalizePosition moved only the first bone of each part, so after a save a flexible part's other bones kept their world positions while the rest of the model was moved to its own origin. Now every Bone moves by the same amount, and the box that places the model holds every bone. Split maps every bone of a part too, so a rigid system holding only a later bone keeps its brick. Only bones move, as the live server did: the Rigid transformations are sent back as the client wrote them, and rotations are not touched. Also: - a model with no readable bone is kept as it is at the origin instead of getting a center 10000 up - the math is done in doubles and numbers are written as the shortest text that reads back as the same float (-0.4, not -0.400002; 12.1678, not 12.1677) - bricks and rigid systems come out in the file's order, so the same model always splits into the same bytes - parts over 5 MB are normalized like any other (the input is capped at 10 MB), and the loop's safety limit no longer drops every later model The pivot is still snapped to the 0.8 grid (the bricks don't move in the world; the model's position stays on the grid), which can put it half a stud from the middle. Co-Authored-By: Claude Opus 5.5 --- dCommon/Lxfml.cpp | 24 ++++++-------- tests/dCommonTests/LxfmlTests.cpp | 52 +++++++++++++++---------------- 2 files changed, 36 insertions(+), 40 deletions(-) diff --git a/dCommon/Lxfml.cpp b/dCommon/Lxfml.cpp index 5899169fb..6248189cf 100644 --- a/dCommon/Lxfml.cpp +++ b/dCommon/Lxfml.cpp @@ -127,16 +127,14 @@ Lxfml::Result Lxfml::NormalizePosition(const std::string_view data, const NiPoin auto* lxfml = doc.FirstChildElement("LXFML"); if (!lxfml) return toReturn; - // Every bone of every part (flexible parts have several), and every rigid of every rigid system: both are - // positioned in the same space, so both move + // Every bone of every part (flexible parts have several). Only bones move, as the live server did: the rigids keep + // the transformations the client sent. std::vector bones; if (auto* bricks = lxfml->FirstChildElement("Bricks")) { for (auto* brick = bricks->FirstChildElement("Brick"); brick; brick = brick->NextSiblingElement("Brick")) { ForEachGrandchild(brick, "Part", "Bone", [&bones](tinyxml2::XMLElement* bone) { bones.push_back(bone); }); } } - std::vector rigids; - ForEachGrandchild(lxfml->FirstChildElement("RigidSystems"), "RigidSystem", "Rigid", [&rigids](tinyxml2::XMLElement* rigid) { rigids.push_back(rigid); }); // The new origin: the middle of the bricks' origins, on the floor of the lowest one. x and z are snapped to the // LEGO grid (0.8) so the model's position stays on it; the bricks don't move in the world either way, only the @@ -166,17 +164,15 @@ Lxfml::Result Lxfml::NormalizePosition(const std::string_view data, const NiPoin rootX = GeneralUtils::RountToNearestEven(rootX, 0.8); rootZ = GeneralUtils::RountToNearestEven(rootZ, 0.8); - // Everything moves by the same amount: onto the new origin, then by the given position + // Every bone moves by the same amount: onto the new origin, then by the given position const double offsetX = curPosition.x - rootX, offsetY = curPosition.y - rootY, offsetZ = curPosition.z - rootZ; - for (auto* elements : { &bones, &rigids }) { - for (auto* element : *elements) { - auto transformation = ParseTransformation(element->Attribute("transformation")); - if (!transformation) continue; - transformation->x += offsetX; - transformation->y += offsetY; - transformation->z += offsetZ; - element->SetAttribute("transformation", FormatTransformation(*transformation).c_str()); - } + for (auto* bone : bones) { + auto transformation = ParseTransformation(bone->Attribute("transformation")); + if (!transformation) continue; + transformation->x += offsetX; + transformation->y += offsetY; + transformation->z += offsetZ; + bone->SetAttribute("transformation", FormatTransformation(*transformation).c_str()); } tinyxml2::XMLPrinter printer; diff --git a/tests/dCommonTests/LxfmlTests.cpp b/tests/dCommonTests/LxfmlTests.cpp index a4eabcf95..42503c42b 100644 --- a/tests/dCommonTests/LxfmlTests.cpp +++ b/tests/dCommonTests/LxfmlTests.cpp @@ -29,7 +29,7 @@ std::string SerializeElement(tinyxml2::XMLElement* elem) { return std::string(p.CStr()); }; -// A rigid system by what it holds (its rigids' refIDs and boneRefs): splitting moves its transformations +// A rigid system by what it holds (its rigids' refIDs and boneRefs) std::string RigidSystemKey(tinyxml2::XMLElement* rigidSystem) { std::string key; for (auto* rigid = rigidSystem->FirstChildElement("Rigid"); rigid; rigid = rigid->NextSiblingElement("Rigid")) { @@ -424,25 +424,6 @@ TEST(LxfmlTests, DeepCloneDepthProtection) { } } -TEST(LxfmlTests, ReadContentsListsBricksAndTheirBox) { - const std::string lxfml = R"( - - - - - -)"; - const auto contents = Lxfml::ReadContents(lxfml); - EXPECT_EQ(contents.designIds, (std::vector{ 3710, 3001 })); - EXPECT_EQ(contents.boxMin, NiPoint3(-1.2f, 0.0f, -0.4f)); - EXPECT_EQ(contents.boxMax, NiPoint3(0.8f, 0.96f, 0.4f)); - - const auto nothing = Lxfml::ReadContents(""); - EXPECT_TRUE(nothing.designIds.empty()); - EXPECT_EQ(nothing.boxMin, NiPoint3Constant::ZERO); - EXPECT_TRUE(Lxfml::ReadContents("").designIds.empty()); -} - namespace { const std::string PROBE_HEAD = R"()"; @@ -482,15 +463,15 @@ namespace { } } -TEST(LxfmlTests, SplitMovesRigidsWithTheirBones) { +TEST(LxfmlTests, SplitMovesOnlyTheBones) { const auto results = Lxfml::Split(TWO_MODELS); ASSERT_EQ(results.size(), 2); EXPECT_EQ(results[0].center, NiPoint3(100.8f, 433.92f, -62.4f)); const auto bones = Transformations(results[0].lxfml, "Bone"); EXPECT_EQ(bones.at("0"), "1,0,0,0,1,0,0,0,1,-0.4,0,-0.4"); EXPECT_EQ(bones.at("1"), "1,0,0,0,1,0,0,0,1,-0.4,0.96,-0.4"); - // The rigid system sits where its first bone does, as the game writes it - EXPECT_EQ(Transformations(results[0].lxfml, "Rigid").at("0"), bones.at("0")); + // The rigid keeps the transformation the client sent, as the live server left it + EXPECT_EQ(Transformations(results[0].lxfml, "Rigid").at("0"), "1,0,0,0,1,0,0,0,1,100.4,433.92,-62.8"); } TEST(LxfmlTests, SplitMovesEveryBoneOfAFlexiblePart) { @@ -503,8 +484,8 @@ TEST(LxfmlTests, SplitMovesEveryBoneOfAFlexiblePart) { EXPECT_EQ(bones.at("3"), "1,0,0,0,1,0,0,0,1,13.6,0,0.4"); EXPECT_EQ(bones.at("4"), "1,0,0,0,-1,0,0,0,-1,-13.2,0,0.4"); const auto rigids = Transformations(results[1].lxfml, "Rigid"); - EXPECT_EQ(rigids.at("1"), bones.at("2")); - EXPECT_EQ(rigids.at("2"), bones.at("4")); + EXPECT_EQ(rigids.at("1"), "1,0,0,0,1,0,0,0,1,1234.4,433.92,-10.8"); + EXPECT_EQ(rigids.at("2"), "1,0,0,0,-1,0,0,0,-1,1210,433.92,-10.8"); } TEST(LxfmlTests, SplitKeepsTheFileOrder) { @@ -532,5 +513,24 @@ TEST(LxfmlTests, NormalizeAtAPositionKeepsTheBricksInPlace) { const auto result = Lxfml::NormalizePosition(lxfml, NiPoint3(10.5f, 5.0f, -3.0f)); EXPECT_EQ(result.center, NiPoint3(10.4f, 5.0f, -3.2f)); EXPECT_EQ(Transformations(result.lxfml, "Bone").at("0"), "1,0,0,0,1,0,0,0,1,0.1,0,0.2"); - EXPECT_EQ(Transformations(result.lxfml, "Rigid").at("0"), "1,0,0,0,1,0,0,0,1,0.1,0,0.2"); + EXPECT_EQ(Transformations(result.lxfml, "Rigid").at("0"), "1,0,0,0,1,0,0,0,1,0,0,0"); +} + +TEST(LxfmlTests, ReadContentsListsBricksAndTheirBox) { + const std::string lxfml = R"( + + + + + +)"; + const auto contents = Lxfml::ReadContents(lxfml); + EXPECT_EQ(contents.designIds, (std::vector{ 3710, 3001 })); + EXPECT_EQ(contents.boxMin, NiPoint3(-1.2f, 0.0f, -0.4f)); + EXPECT_EQ(contents.boxMax, NiPoint3(0.8f, 0.96f, 0.4f)); + + const auto nothing = Lxfml::ReadContents(""); + EXPECT_TRUE(nothing.designIds.empty()); + EXPECT_EQ(nothing.boxMin, NiPoint3Constant::ZERO); + EXPECT_TRUE(Lxfml::ReadContents("").designIds.empty()); }