diff --git a/dCommon/Lxfml.cpp b/dCommon/Lxfml.cpp index 6248189cf..1aff4fb5a 100644 --- a/dCommon/Lxfml.cpp +++ b/dCommon/Lxfml.cpp @@ -12,6 +12,7 @@ #include #include #include +#include namespace { // The base LXFML xml file to use when creating new models. @@ -107,17 +108,56 @@ namespace { return text + FormatNumber(transformation.x) + ',' + FormatNumber(transformation.y) + ',' + FormatNumber(transformation.z); } - // Every element named `name` under `parent`'s children named `childName` (e.g. every Bone of every Part) - template - void ForEachGrandchild(tinyxml2::XMLElement* parent, const char* childName, const char* name, Visit&& visit) { - if (!parent) return; - for (auto* child = parent->FirstChildElement(childName); child; child = child->NextSiblingElement(childName)) { - for (auto* element = child->FirstChildElement(name); element; element = element->NextSiblingElement(name)) visit(element); + // A box in world space, grown point by point + struct Box { + bool any = false; + double minX{}, minY{}, minZ{}, maxX{}, maxY{}, maxZ{}; + + void Add(const double x, const double y, const double z) { + minX = any ? std::min(minX, x) : x; maxX = any ? std::max(maxX, x) : x; + minY = any ? std::min(minY, y) : y; maxY = any ? std::max(maxY, y) : y; + minZ = any ? std::min(minZ, z) : z; maxZ = any ? std::max(maxZ, z) : z; + any = true; } + + // A brick's own box, placed in the world by its bone: all 8 corners, rotated then moved. A rotation that can't + // be read counts as the bone's origin. + void Add(const BrickBounds::Bounds& bounds, const Transformation& transformation) { + std::array rotation{}; + for (size_t i = 0; i < rotation.size(); i++) { + const auto value = GeneralUtils::TryParse(transformation.rotation[i]); + if (!value) { + Add(transformation.x, transformation.y, transformation.z); + return; + } + rotation[i] = *value; + } + for (uint32_t corner = 0; corner < 8; corner++) { + const double x = (corner & 1) ? bounds.max.x : bounds.min.x; + const double y = (corner & 2) ? bounds.max.y : bounds.min.y; + const double z = (corner & 4) ? bounds.max.z : bounds.min.z; + Add(x * rotation[0] + y * rotation[3] + z * rotation[6] + transformation.x, + x * rotation[1] + y * rotation[4] + z * rotation[7] + transformation.y, + x * rotation[2] + y * rotation[5] + z * rotation[8] + transformation.z); + } + } + }; + + // The box of a part's design: the part's designID, or its brick's + const BrickBounds::Bounds* FindBounds(const BrickBounds::DesignBounds& bounds, const tinyxml2::XMLElement* part, const tinyxml2::XMLElement* brick) { + if (bounds.empty()) return nullptr; + for (const auto* element : { part, brick }) { + const char* design = element->Attribute("designID"); + const auto id = design ? GeneralUtils::TryParse(design) : std::nullopt; + if (!id) continue; + const auto it = bounds.find(*id); + if (it != bounds.end()) return &it->second; + } + return nullptr; } } -Lxfml::Result Lxfml::NormalizePosition(const std::string_view data, const NiPoint3& curPosition) { +Lxfml::Result Lxfml::NormalizePosition(const std::string_view data, const NiPoint3& curPosition, const BrickBounds::DesignBounds& bounds) { Result toReturn; if (data.empty()) return toReturn; @@ -129,37 +169,43 @@ Lxfml::Result Lxfml::NormalizePosition(const std::string_view data, const NiPoin // 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. + // The box around the model: each part's own box placed by its first bone, as the brick is in the world; a part + // whose design has no box counts the origins of its bones instead. std::vector bones; + Box box; 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); }); + for (auto* part = brick->FirstChildElement("Part"); part; part = part->NextSiblingElement("Part")) { + const auto* design = FindBounds(bounds, part, brick); + bool placed = false; + for (auto* bone = part->FirstChildElement("Bone"); bone; bone = bone->NextSiblingElement("Bone")) { + bones.push_back(bone); + const auto transformation = ParseTransformation(bone->Attribute("transformation")); + if (!transformation || placed) continue; + if (design) { + box.Add(*design, *transformation); + placed = true; + } else { + box.Add(transformation->x, transformation->y, transformation->z); + } + } + } } } - // 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 + // The new origin: the middle of the box, on its floor, as the live server placed models. 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 // model's pivot does. double rootX = curPosition.x, rootY = curPosition.y, rootZ = curPosition.z; if (curPosition == NiPoint3Constant::ZERO) { - bool any = false; - double minX{}, minY{}, minZ{}, maxX{}, maxY{}, maxZ{}; - for (const auto* bone : bones) { - const auto transformation = ParseTransformation(bone->Attribute("transformation")); - if (!transformation) continue; - const auto& [rotation, x, y, z] = *transformation; - minX = any ? std::min(minX, x) : x; maxX = any ? std::max(maxX, x) : x; - minY = any ? std::min(minY, y) : y; maxY = any ? std::max(maxY, y) : y; - minZ = any ? std::min(minZ, z) : z; maxZ = any ? std::max(maxZ, z) : z; - any = true; - } // Nothing to place it by: keep the model as it is, at the origin - if (!any) { + if (!box.any) { toReturn.lxfml = std::string(data); return toReturn; } - rootX = (minX + maxX) / 2.0; - rootY = minY; - rootZ = (minZ + maxZ) / 2.0; + rootX = (box.minX + box.maxX) / 2.0; + rootY = box.minY; + rootZ = (box.minZ + box.maxZ) / 2.0; } rootX = GeneralUtils::RountToNearestEven(rootX, 0.8); rootZ = GeneralUtils::RountToNearestEven(rootZ, 0.8); @@ -210,7 +256,7 @@ static tinyxml2::XMLElement* CloneElementDeep(const tinyxml2::XMLElement* src, t return dst; } -std::vector Lxfml::Split(const std::string_view data, const NiPoint3& curPosition) { +std::vector Lxfml::Split(const std::string_view data, const NiPoint3& curPosition, const BrickBounds::DesignBounds& bounds) { std::vector results; // Handle empty or invalid input @@ -330,7 +376,7 @@ std::vector Lxfml::Split(const std::string_view data, const NiPoi // Print to string, then normalize position and compute center (the input is at most 10 MB, so each part is too) tinyxml2::XMLPrinter printer; outDoc.Print(&printer); - return NormalizePosition(printer.CStr(), curPosition); + return NormalizePosition(printer.CStr(), curPosition, bounds); }; // 1) Process groups (each top-level Group becomes one output; nested groups are included) diff --git a/dCommon/Lxfml.h b/dCommon/Lxfml.h index 9503bcf24..1d07be0a8 100644 --- a/dCommon/Lxfml.h +++ b/dCommon/Lxfml.h @@ -8,6 +8,7 @@ #include #include +#include "BrickBounds.h" #include "NiPoint3.h" namespace Lxfml { @@ -18,8 +19,10 @@ 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 new center is the bottom middle of the box around the model's bricks, each brick's box taken from `bounds` by + // its design ID (a brick with no box there counts as its bones' origins). + [[nodiscard]] Result NormalizePosition(const std::string_view data, const NiPoint3& curPosition = NiPoint3Constant::ZERO, const BrickBounds::DesignBounds& bounds = {}); + [[nodiscard]] std::vector Split(const std::string_view data, const NiPoint3& curPosition = NiPoint3Constant::ZERO, const BrickBounds::DesignBounds& bounds = {}); // What a model is made of: every brick's design id (one per Brick, in file order) and the box around the bricks' // origins (their bones' positions; zero with no bricks). Brick shapes aren't known here, so the box is the diff --git a/dGame/dUtilities/BrickByBrick.cpp b/dGame/dUtilities/BrickByBrick.cpp index 821ee670d..534249265 100644 --- a/dGame/dUtilities/BrickByBrick.cpp +++ b/dGame/dUtilities/BrickByBrick.cpp @@ -21,6 +21,8 @@ #include "Item.h" #include "Logger.h" #include "Lxfml.h" +#include "BrickBounds.h" +#include "AssetManager.h" #include "ObjectIDManager.h" #include "PropertyManagementComponent.h" #include "PropertyMessages.h" @@ -117,7 +119,8 @@ namespace { // the owner keeps editing isn't made for every save (docs/UgcServer.md) const auto debounce = Game::config ? GeneralUtils::TryParse(Game::config->GetValue("ugc_debounce_seconds")).value_or(120) : 120; const auto processAfter = UgcDebounce::ProcessAfter(std::time(nullptr), debounce); - for (const auto& part : Lxfml::Split(lxfml)) { + // Each model is placed by its bricks' boxes, from the client's brickdb (read on the first save, then kept) + for (const auto& part : Lxfml::Split(lxfml, NiPoint3Constant::ZERO, BrickBounds::Get(Game::assetManager))) { const auto [modelId, blueprintId] = ObjectIDManager::GetNewModelIDs(); Sd0 model = sd0; model.FromData(reinterpret_cast(part.lxfml.data()), part.lxfml.size()); diff --git a/tests/dCommonTests/LxfmlTests.cpp b/tests/dCommonTests/LxfmlTests.cpp index 42503c42b..c420c4ee6 100644 --- a/tests/dCommonTests/LxfmlTests.cpp +++ b/tests/dCommonTests/LxfmlTests.cpp @@ -516,6 +516,106 @@ TEST(LxfmlTests, NormalizeAtAPositionKeepsTheBricksInPlace) { EXPECT_EQ(Transformations(result.lxfml, "Rigid").at("0"), "1,0,0,0,1,0,0,0,1,0,0,0"); } +namespace { + // A 2x2 brick's box around its origin, as the brickdb has it + const BrickBounds::DesignBounds BOUNDS = { { 3003, { NiPoint3(-0.4f, 0.0f, -1.2f), NiPoint3(1.2f, 1.138f, 0.4f) } } }; + + std::string Brick(const std::string& ref, const std::string& design, const std::string& transformation) { + return R"()"; + } + + std::string RigidSystem(const std::string& ref, const std::string& transformation, const std::string& boneRefs) { + return R"()"; + } + + std::string Model(const std::string& bricks, const std::string& rigidSystems) { + return PROBE_HEAD + bricks + "" + rigidSystems + ""; + } + + const std::string UNROTATED = "1,0,0,0,1,0,0,0,1,"; +} + +TEST(LxfmlTests, NormalizeOneBrickByItsBox) { + // The bottom middle of the 2x2's box is 0.4 along x and -0.4 along z from its origin + const auto lxfml = Model(Brick("0", "3003", UNROTATED + "10,3.2,-4.4"), RigidSystem("0", UNROTATED + "10,3.2,-4.4", "0")); + const auto result = Lxfml::NormalizePosition(lxfml, NiPoint3Constant::ZERO, BOUNDS); + EXPECT_EQ(result.center, NiPoint3(10.4f, 3.2f, -4.8f)); + EXPECT_EQ(Transformations(result.lxfml, "Bone").at("0"), UNROTATED + "-0.4,0,0.4"); +} + +TEST(LxfmlTests, NormalizeAStackOfBricksByTheirBox) { + const auto lxfml = Model( + Brick("0", "3003", UNROTATED + "10,3.2,-4.4") + Brick("1", "3003", UNROTATED + "10,4.16,-4.4") + Brick("2", "3003", UNROTATED + "10,5.12,-4.4"), + RigidSystem("0", UNROTATED + "10,3.2,-4.4", "0,1,2")); + const auto result = Lxfml::NormalizePosition(lxfml, NiPoint3Constant::ZERO, BOUNDS); + EXPECT_EQ(result.center, NiPoint3(10.4f, 3.2f, -4.8f)); + const auto bones = Transformations(result.lxfml, "Bone"); + EXPECT_EQ(bones.at("0"), UNROTATED + "-0.4,0,0.4"); + EXPECT_EQ(bones.at("1"), UNROTATED + "-0.4,0.96,0.4"); + EXPECT_EQ(bones.at("2"), UNROTATED + "-0.4,1.92,0.4"); +} + +TEST(LxfmlTests, NormalizeARotatedBrickByItsRotatedBox) { + // A quarter turn about y: the brick's x runs along the world's -z and its z along the world's x + const std::string quarterTurn = "0,0,-1,0,1,0,1,0,0,"; + auto result = Lxfml::NormalizePosition(Model(Brick("0", "3003", quarterTurn + "10,3.2,-4.4"), ""), NiPoint3Constant::ZERO, BOUNDS); + EXPECT_EQ(result.center, NiPoint3(9.6f, 3.2f, -4.8f)); + EXPECT_EQ(Transformations(result.lxfml, "Bone").at("0"), quarterTurn + "0.4,0,0.4"); + + // Upside down: the box hangs below the origin, so the floor is 1.138 under it + const std::string upsideDown = "1,0,0,0,-1,0,0,0,-1,"; + result = Lxfml::NormalizePosition(Model(Brick("0", "3003", upsideDown + "10,3.2,-4.4"), ""), NiPoint3Constant::ZERO, BOUNDS); + EXPECT_NEAR(result.center.x, 10.4f, 1e-4f); + EXPECT_NEAR(result.center.y, 2.062f, 1e-4f); + EXPECT_NEAR(result.center.z, -4.0f, 1e-4f); + EXPECT_EQ(Transformations(result.lxfml, "Bone").at("0"), upsideDown + "-0.4,1.138,-0.4"); +} + +TEST(LxfmlTests, NormalizeADesignWithoutABoxByItsOrigin) { + // 99999 has no box: its bone's origin counts as a point of the model's box + const auto lxfml = Model(Brick("0", "3003", UNROTATED + "10,3.2,-4.4") + Brick("1", "99999", UNROTATED + "12.8,2.24,-4.4"), + RigidSystem("0", UNROTATED + "10,3.2,-4.4", "0,1")); + const auto result = Lxfml::NormalizePosition(lxfml, NiPoint3Constant::ZERO, BOUNDS); + EXPECT_EQ(result.center, NiPoint3(11.2f, 2.24f, -4.8f)); + const auto bones = Transformations(result.lxfml, "Bone"); + EXPECT_EQ(bones.at("0"), UNROTATED + "-1.2,0.96,0.4"); + EXPECT_EQ(bones.at("1"), UNROTATED + "1.6,0,0.4"); +} + +TEST(LxfmlTests, NormalizeLeavesTheRigidsAndEverythingElseAsSent) { + const auto lxfml = Model(Brick("0", "3003", UNROTATED + "10,3.2,-4.4"), RigidSystem("0", "0,0,-1,0,1,0,1,0,0,10,3.2,-4.4", "0")); + const auto result = Lxfml::NormalizePosition(lxfml, NiPoint3Constant::ZERO, BOUNDS); + EXPECT_EQ(Transformations(result.lxfml, "Rigid").at("0"), "0,0,-1,0,1,0,1,0,0,10,3.2,-4.4"); + tinyxml2::XMLDocument doc; + ASSERT_EQ(doc.Parse(result.lxfml.c_str()), tinyxml2::XML_SUCCESS); + const auto* part = doc.FirstChildElement("LXFML")->FirstChildElement("Bricks")->FirstChildElement("Brick")->FirstChildElement("Part"); + EXPECT_STREQ(part->Attribute("designID"), "3003"); + EXPECT_STREQ(part->Attribute("materials"), "21"); +} + +TEST(LxfmlTests, SplitPlacesEachRigidSystemByItsOwnBox) { + const auto lxfml = Model( + Brick("0", "3003", UNROTATED + "10,3.2,-4.4") + Brick("1", "3003", UNROTATED + "10,4.16,-4.4") + + Brick("2", "3003", "0,0,-1,0,1,0,1,0,0,30,0.8,7.6"), + RigidSystem("0", UNROTATED + "10,3.2,-4.4", "0,1") + RigidSystem("1", "0,0,-1,0,1,0,1,0,0,30,0.8,7.6", "2")); + const auto results = Lxfml::Split(lxfml, NiPoint3Constant::ZERO, BOUNDS); + ASSERT_EQ(results.size(), 2); + + EXPECT_EQ(results[0].center, NiPoint3(10.4f, 3.2f, -4.8f)); + const auto first = Transformations(results[0].lxfml, "Bone"); + EXPECT_EQ(first.size(), 2); + EXPECT_EQ(first.at("0"), UNROTATED + "-0.4,0,0.4"); + EXPECT_EQ(first.at("1"), UNROTATED + "-0.4,0.96,0.4"); + EXPECT_EQ(Transformations(results[0].lxfml, "Rigid").at("0"), UNROTATED + "10,3.2,-4.4"); + + EXPECT_EQ(results[1].center, NiPoint3(29.6f, 0.8f, 7.2f)); + const auto second = Transformations(results[1].lxfml, "Bone"); + EXPECT_EQ(second.size(), 1); + EXPECT_EQ(second.at("2"), "0,0,-1,0,1,0,1,0,0,0.4,0,0.4"); + EXPECT_EQ(Transformations(results[1].lxfml, "Rigid").at("1"), "0,0,-1,0,1,0,1,0,0,30,0.8,7.6"); +} + TEST(LxfmlTests, ReadContentsListsBricksAndTheirBox) { const std::string lxfml = R"(