From d870207a9f10302b94cc4201c18c75ee2723272f Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Wed, 4 Nov 2026 21:19:23 -0600 Subject: [PATCH] fix: place brick by brick models by the bottom middle of their bricks' box When a brick by brick save is split into models, each model was centred on the middle of its bones' origins, on the lowest origin. A brick's origin is at a corner of it, not its middle, so a single 2x2 came back offset by half a brick, and the model's floor was taken from the origins rather than from the bricks. Now each model is placed as the live server placed it: at the bottom middle of the box around its bricks. Each brick's own box comes from the client's brickdb by its design ID and is turned and moved by its bone (all 8 corners), and the model's box holds every brick's. Its x and z middle and its lowest y are the new origin, still snapped to the 0.8 grid on x and z, and every bone moves by that; rigids and rotations are untouched. A design the brickdb has no box for counts its bones' origins, as before, so no brick is left out. Lxfml::NormalizePosition and Split take the boxes by design ID, and the brick by brick save passes the ones read from the client (read on the first save, then kept). Co-Authored-By: Claude Opus 5.5 --- dCommon/Lxfml.cpp | 100 ++++++++++++++++++++++-------- dCommon/Lxfml.h | 7 ++- dGame/dUtilities/BrickByBrick.cpp | 5 +- tests/dCommonTests/LxfmlTests.cpp | 100 ++++++++++++++++++++++++++++++ 4 files changed, 182 insertions(+), 30 deletions(-) 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"(