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 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-11-04 21:19:23 -06:00
parent ba41bf0311
commit d870207a9f
4 changed files with 182 additions and 30 deletions

View File

@@ -12,6 +12,7 @@
#include <sstream>
#include <charconv>
#include <optional>
#include <array>
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<typename Visit>
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<double, 9> rotation{};
for (size_t i = 0; i < rotation.size(); i++) {
const auto value = GeneralUtils::TryParse<double>(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<uint32_t>(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<tinyxml2::XMLElement*> 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::Result> Lxfml::Split(const std::string_view data, const NiPoint3& curPosition) {
std::vector<Lxfml::Result> Lxfml::Split(const std::string_view data, const NiPoint3& curPosition, const BrickBounds::DesignBounds& bounds) {
std::vector<Result> results;
// Handle empty or invalid input
@@ -330,7 +376,7 @@ std::vector<Lxfml::Result> 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)

View File

@@ -8,6 +8,7 @@
#include <string_view>
#include <vector>
#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<Result> 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<Result> 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

View File

@@ -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<int64_t>(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<const uint8_t*>(part.lxfml.data()), part.lxfml.size());

View File

@@ -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"(<Brick refID=")" + ref + R"(" designID=")" + design + R"("><Part refID=")" + ref + R"(" designID=")" + design +
R"(" materials="21"><Bone refID=")" + ref + R"(" transformation=")" + transformation + R"("/></Part></Brick>)";
}
std::string RigidSystem(const std::string& ref, const std::string& transformation, const std::string& boneRefs) {
return R"(<RigidSystem><Rigid refID=")" + ref + R"(" transformation=")" + transformation + R"(" boneRefs=")" + boneRefs + R"("/></RigidSystem>)";
}
std::string Model(const std::string& bricks, const std::string& rigidSystems) {
return PROBE_HEAD + bricks + "</Bricks><RigidSystems>" + rigidSystems + "</RigidSystems><GroupSystems><GroupSystem></GroupSystem></GroupSystems></LXFML>";
}
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"(<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
<LXFML versionMajor="5" versionMinor="0">