Files
EVEngine/test/editor_building_target.cpp
T
西风逍遥游andCursor 24502babef Nest satellite packages under their host modules.
Keep CMake module names and trim switches, but stop flattening editing/editor and runtime bridges as top-level src/modules directories. Parent scans skip nested facets so runtime-3d can drop authoring by directory leaf.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 17:56:12 +08:00

757 lines
33 KiB
C++

#include "building/editing/BuildingTarget.h"
#include "building/BuildingDef.h"
#include "building/PlacementSystem.h"
#include "building/PlacementWorld.h"
#include "editing/EditingAuthority.h"
#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
using namespace eve::building_editing;
namespace {
void registerHouse() {
eve::building::BuildingDefinition definition;
definition.id = "house";
definition.displayName = "House";
definition.footprintW = 2;
definition.footprintH = 2;
definition.rotationMode = "cardinal";
definition.tags = {"building", "home"};
eve::building::BuildingRegistry::registerBuilding(definition);
}
void registerEdges() {
eve::building::BuildingDefinition fence;
fence.id = "editor-fence";
fence.displayName = "Fence";
fence.placementKind = "edge";
fence.channel = "boundary";
fence.tags = {"building", "fence"};
eve::building::BuildingRegistry::registerBuilding(fence);
eve::building::BuildingDefinition gate = fence;
gate.id = "editor-gate";
gate.displayName = "Gate";
gate.tags = {"building", "gate"};
eve::building::BuildingRegistry::registerBuilding(gate);
}
void registerAreaTile() {
eve::building::BuildingDefinition tile;
tile.id = "editor-area-tile";
tile.displayName = "Area Tile";
tile.footprintW = 1;
tile.footprintH = 1;
tile.channel = "floor";
tile.tags = {"building", "floor"};
eve::building::BuildingRegistry::registerBuilding(tile);
}
void registerCorners() {
eve::building::BuildingDefinition post;
post.id = "editor-post";
post.placementKind = "corner";
post.channel = "posts";
post.tags = {"building", "post"};
eve::building::BuildingRegistry::registerBuilding(post);
eve::building::BuildingDefinition reinforced = post;
reinforced.id = "editor-reinforced-post";
reinforced.tags = {"building", "post", "reinforced"};
eve::building::BuildingRegistry::registerBuilding(reinforced);
}
void registerFreeProps() {
eve::building::BuildingDefinition prop;
prop.id = "editor-free-prop";
prop.placementKind = "free";
prop.snapMode = "free";
prop.rotationMode = "free";
prop.channel = "decor";
prop.freeRadiusCells = 0.2f;
prop.freeFootprintWidthCells = 0.8f;
prop.freeFootprintHeightCells = 0.4f;
prop.freeFootprintVertices = {-0.4f, -0.2f, 0.4f, -0.2f,
0.4f, 0.2f, -0.4f, 0.2f};
eve::building::BuildingRegistry::registerBuilding(prop);
}
DomainOperation inverse(const DomainOperation& source) {
DomainOperation result = source;
result.type = source.inverseType;
result.payload = source.inverse;
return result;
}
BuildingInstanceSnapshot house(int id, int x, int y) {
BuildingInstanceSnapshot result;
result.instanceId = id;
result.buildingId = "house";
result.cellX = x;
result.cellY = y;
result.worldX = x * 10.0;
result.worldY = y * 10.0;
result.tags = {"building", "home"};
return result;
}
} // namespace
TEST_CASE("editor.building.preview_reports_snap_footprint_and_occupancy") {
registerHouse();
eve::building::PlacementWorld world(8, 8, 10.f);
BuildingPlacementTarget target("build-world", &world);
const auto preview = target.preview("house", 21.0, 31.0, 0.0, 91.0);
CHECK_EQ(static_cast<int>(preview.status), static_cast<int>(EditorStatus::Applied));
CHECK_EQ(preview.snappedCellX, 2);
CHECK_EQ(preview.snappedCellY, 3);
CHECK_EQ(preview.normalizedRotation, 90.0);
CHECK_EQ(preview.cells.size(), size_t{4});
for (const BuildingFootprintCell& cell : preview.cells) {
CHECK(cell.inBounds);
CHECK_EQ(cell.occupant, 0);
}
auto place = target.makePlace(house(target.nextAvailableInstanceId(), 2, 3));
REQUIRE(place.ok());
REQUIRE(target.applyDomainOperation(place.value()).ok());
const auto occupied = target.preview("house", 21.0, 31.0, 0.0, 0.0);
CHECK_EQ(static_cast<int>(occupied.status), static_cast<int>(EditorStatus::Rejected));
REQUIRE(!occupied.diagnostics.empty());
CHECK(occupied.cells[0].occupant > 0);
}
TEST_CASE("editor.building.place_move_remove_are_exactly_reversible") {
registerHouse();
eve::building::PlacementWorld world(12, 12, 10.f);
BuildingPlacementTarget target("reversible-world", &world);
BuildingInstanceSnapshot placed = house(77, 1, 1);
placed.level = 2;
placed.properties["skin"] = "blue";
placed.garrison = {{"unit-1", "worker", {"builder"}}};
placed.garrisonRevision = 4;
auto place = target.makePlace(placed);
REQUIRE(place.ok());
REQUIRE(target.applyDomainOperation(place.value()).ok());
REQUIRE(world.hasBuilding(77));
CHECK_EQ(world.getBuildingProp(77, "skin"), "blue");
auto move = target.makeMove(77, 6, 5, 179.0);
REQUIRE(move.ok());
REQUIRE(target.applyDomainOperation(move.value()).ok());
CHECK_EQ(world.getBuildingCellX(77), 6);
CHECK_EQ(world.getBuildingCellY(77), 5);
CHECK_EQ(world.getBuildingRotation(77), 180.f);
REQUIRE(target.applyDomainOperation(inverse(move.value())).ok());
CHECK_EQ(world.getBuildingCellX(77), 1);
CHECK_EQ(world.getBuildingCellY(77), 1);
auto remove = target.makeRemove(77);
REQUIRE(remove.ok());
REQUIRE(target.applyDomainOperation(remove.value()).ok());
CHECK(!world.hasBuilding(77));
REQUIRE(target.applyDomainOperation(inverse(remove.value())).ok());
REQUIRE(world.hasBuilding(77));
const auto restored = target.instance(77);
REQUIRE(restored.ok());
CHECK_EQ(restored.value().properties.at("skin"), "blue");
CHECK_EQ(restored.value().garrison.size(), size_t{1});
CHECK_EQ(restored.value().garrison[0].id, "unit-1");
CHECK_EQ(restored.value().garrisonRevision, uint64_t{4});
CHECK_EQ(restored.value().level, 2);
CHECK_EQ(world.getOccupantAtLevel("", 1, 1, 2), 77);
}
TEST_CASE("editor.building.undo_restore_conflicts_instead_of_overwriting_occupancy") {
registerHouse();
eve::building::PlacementWorld world(8, 8, 10.f);
BuildingPlacementTarget target("conflict-world", &world);
auto first = target.makePlace(house(10, 1, 1));
REQUIRE(first.ok());
REQUIRE(target.applyDomainOperation(first.value()).ok());
auto remove = target.makeRemove(10);
REQUIRE(remove.ok());
REQUIRE(target.applyDomainOperation(remove.value()).ok());
auto replacement = target.makePlace(house(11, 1, 1));
REQUIRE(replacement.ok());
REQUIRE(target.applyDomainOperation(replacement.value()).ok());
const auto conflict = target.applyDomainOperation(inverse(remove.value()));
CHECK_EQ(static_cast<int>(conflict.code()), static_cast<int>(EditorStatus::Rejected));
CHECK(!world.hasBuilding(10));
CHECK(world.hasBuilding(11));
}
TEST_CASE("editor.building.snapshot_is_deterministic_and_complete") {
registerHouse();
eve::building::PlacementWorld world(8, 8, 10.f);
world.setId("settlement");
BuildingPlacementTarget target("snapshot-world", &world);
auto first = target.makePlace(house(3, 0, 0));
auto second = target.makePlace(house(9, 4, 4));
REQUIRE(first.ok());
REQUIRE(second.ok());
REQUIRE(target.applyDomainOperation(first.value()).ok());
REQUIRE(target.applyDomainOperation(second.value()).ok());
const EditorValue snapshot = target.snapshotValue();
const auto* root = snapshot.getIf<EditorValue::Object>();
REQUIRE(root);
CHECK_EQ(*root->at("schemaVersion").getIf<int64_t>(), int64_t{8});
CHECK_EQ(*root->at("worldId").getIf<std::string>(), "settlement");
const auto* instances = root->at("instances").getIf<EditorValue::Array>();
REQUIRE(instances);
CHECK_EQ(instances->size(), size_t{2});
const auto* curveGroups = root->at("edgeCurveGroups").getIf<EditorValue::Array>();
REQUIRE(curveGroups);
CHECK(curveGroups->empty());
CHECK_EQ(*instances->at(0).getIf<EditorValue::Object>()->at("instanceId").getIf<int64_t>(), int64_t{3});
CHECK_EQ(*instances->at(1).getIf<EditorValue::Object>()->at("instanceId").getIf<int64_t>(), int64_t{9});
}
TEST_CASE("editor.building.corner_place_move_replace_and_undo_preserve_vertex_domain") {
registerCorners();
eve::building::PlacementWorld world(8, 8, 10.f);
BuildingPlacementTarget target("corner-world", &world);
BuildingInstanceSnapshot post;
post.instanceId = 81;
post.buildingId = "editor-post";
post.placementKind = "corner";
post.cornerX = 2;
post.cornerY = 3;
post.cellX = 2;
post.cellY = 3;
post.worldX = 20.0;
post.worldY = 30.0;
post.channel = "posts";
post.tags = {"building", "post"};
auto place = target.makePlace(post);
REQUIRE(place.ok());
REQUIRE(target.applyDomainOperation(place.value()).ok());
CHECK_EQ(world.getCornerOccupant("posts", 2, 3), 81);
auto move = target.makeMove(81, 5, 6, 0.0);
REQUIRE(move.ok());
REQUIRE(target.applyDomainOperation(move.value()).ok());
CHECK_EQ(world.getCornerOccupant("posts", 5, 6), 81);
CHECK_EQ(world.getCornerOccupant("posts", 2, 3), 0);
REQUIRE(target.applyDomainOperation(inverse(move.value())).ok());
CHECK_EQ(world.getCornerOccupant("posts", 2, 3), 81);
auto replace = target.makeReplace(81, "editor-reinforced-post");
REQUIRE(replace.ok());
REQUIRE(target.applyDomainOperation(replace.value()).ok());
CHECK_EQ(world.getBuildingId(81), "editor-reinforced-post");
REQUIRE(target.applyDomainOperation(inverse(replace.value())).ok());
CHECK_EQ(world.getBuildingId(81), "editor-post");
const auto restored = target.instance(81);
REQUIRE(restored.ok());
CHECK_EQ(restored.value().placementKind, "corner");
CHECK_EQ(restored.value().cornerX, 2);
CHECK_EQ(restored.value().cornerY, 3);
}
TEST_CASE("editor.building.free_snapshot_move_and_undo_preserve_exact_anchor") {
registerFreeProps();
eve::building::PlacementWorld world(8, 8, 10.f);
BuildingPlacementTarget target("free-world", &world);
BuildingInstanceSnapshot prop;
prop.instanceId = 91;
prop.buildingId = "editor-free-prop";
prop.placementKind = "free";
prop.worldX = 12.25;
prop.worldY = 17.75;
prop.elevation = 0.5;
prop.rotationDegrees = 23.0;
prop.channel = "decor";
prop.freeRadius = 2.0;
prop.freeHalfWidth = 4.0;
prop.freeHalfHeight = 2.0;
prop.freeFootprintVertices = {-4.0, -2.0, 4.0, -2.0, 4.0, 2.0, -4.0, 2.0};
auto place = target.makePlace(prop);
REQUIRE(place.ok());
REQUIRE(target.applyDomainOperation(place.value()).ok());
CHECK_EQ(world.getBuildingWorldX(91), 12.25f);
auto move = target.makeMoveFree(91, 43.125, 51.875, 2.5, 71.0);
REQUIRE(move.ok());
REQUIRE(target.applyDomainOperation(move.value()).ok());
CHECK_EQ(world.getBuildingWorldX(91), 43.125f);
CHECK_EQ(world.getBuildingElevation(91), 2.5f);
CHECK_EQ(world.getBuildingRotation(91), 71.f);
REQUIRE(target.applyDomainOperation(inverse(move.value())).ok());
const auto restored = target.instance(91);
REQUIRE(restored.ok());
CHECK_EQ(restored.value().placementKind, "free");
CHECK_EQ(restored.value().worldX, 12.25);
CHECK_EQ(restored.value().worldY, 17.75);
CHECK_EQ(restored.value().freeRadius, 2.0);
CHECK_EQ(restored.value().freeHalfWidth, 4.0);
CHECK_EQ(restored.value().freeHalfHeight, 2.0);
CHECK_EQ(restored.value().freeFootprintVertices.size(), size_t{8});
CHECK_EQ(restored.value().freeFootprintVertices[0], -4.0);
}
TEST_CASE("editor.building.replace_commits_undoes_and_redoes_through_authority") {
registerHouse();
eve::building::BuildingDefinition upgraded;
upgraded.id = "upgraded-house";
upgraded.displayName = "Upgraded House";
upgraded.footprintW = 2;
upgraded.footprintH = 2;
upgraded.rotationMode = "cardinal";
upgraded.tags = {"building", "upgraded"};
eve::building::BuildingRegistry::registerBuilding(upgraded);
eve::building::PlacementWorld world(10, 10, 10.f);
BuildingPlacementTarget target("replace-world", &world);
auto place = target.makePlace(house(42, 2, 2));
REQUIRE(place.ok());
REQUIRE(target.applyDomainOperation(place.value()).ok());
world.setBuildingProp(42, "owner", "player");
auto replacement = target.makeReplace(42, upgraded.id);
REQUIRE(replacement.ok());
eve::editing::LocalWorldAuthority authority(&target);
eve::editing::TransactionSpec transaction;
transaction.id = eve::editing::TransactionId("building.replace.1");
transaction.label = "Replace building";
transaction.target = target.targetId();
transaction.baseRevision = target.revision();
const std::vector<DomainOperation> operations{replacement.value()};
auto plan = authority.preflight(transaction, operations);
REQUIRE(plan.ok());
eve::building::PlacementSystem::clearEvents();
auto committed = authority.commit(plan.value());
REQUIRE(committed.ok());
CHECK_EQ(world.getBuildingId(42), upgraded.id);
CHECK_EQ(world.getBuildingProp(42, "owner"), "player");
const size_t committedEventCount = eve::building::PlacementSystem::events().size();
auto undone = authority.compensate(committed.value());
REQUIRE(undone.ok());
CHECK_EQ(world.getBuildingId(42), "house");
CHECK_EQ(world.getBuildingProp(42, "owner"), "player");
CHECK_EQ(eve::building::PlacementSystem::events().size(), committedEventCount);
transaction.id = eve::editing::TransactionId("building.replace.2");
transaction.baseRevision = target.revision();
auto redoPlan = authority.preflight(transaction, operations);
REQUIRE(redoPlan.ok());
auto redone = authority.commit(redoPlan.value());
REQUIRE(redone.ok());
CHECK_EQ(world.getBuildingId(42), upgraded.id);
}
TEST_CASE("editor.building.edge_replace_and_undo_preserve_canonical_address") {
registerEdges();
eve::building::PlacementWorld world(8, 8, 10.f);
BuildingPlacementTarget target("edge-replace-world", &world);
BuildingInstanceSnapshot edge;
edge.instanceId = 51;
edge.buildingId = "editor-fence";
edge.placementKind = "edge";
edge.edgeX = 3;
edge.edgeY = 4;
edge.edgeAxis = "vertical";
edge.cellX = 3;
edge.cellY = 4;
edge.worldX = 30.0;
edge.worldY = 45.0;
edge.rotationDegrees = 90.0;
edge.channel = "boundary";
edge.properties["owner"] = "player";
auto place = target.makePlace(edge);
REQUIRE(place.ok());
REQUIRE(target.applyDomainOperation(place.value()).ok());
auto replacement = target.makeReplace(51, "editor-gate");
REQUIRE(replacement.ok());
eve::editing::LocalWorldAuthority authority(&target);
eve::editing::TransactionSpec transaction;
transaction.id = eve::editing::TransactionId("building.edge.replace.1");
transaction.label = "Replace edge building";
transaction.target = target.targetId();
transaction.baseRevision = target.revision();
const std::vector<DomainOperation> operations{replacement.value()};
auto plan = authority.preflight(transaction, operations);
REQUIRE(plan.ok());
auto committed = authority.commit(plan.value());
REQUIRE(committed.ok());
CHECK_EQ(world.getBuildingId(51), "editor-gate");
auto replaced = target.instance(51);
REQUIRE(replaced.ok());
CHECK_EQ(replaced.value().edgeX, 3);
CHECK_EQ(replaced.value().edgeY, 4);
CHECK_EQ(replaced.value().edgeAxis, "vertical");
CHECK_EQ(replaced.value().properties.at("owner"), "player");
REQUIRE(authority.compensate(committed.value()).ok());
CHECK_EQ(world.getBuildingId(51), "editor-fence");
auto restored = target.instance(51);
REQUIRE(restored.ok());
CHECK_EQ(restored.value().edgeAxis, "vertical");
CHECK_EQ(restored.value().properties.at("owner"), "player");
}
TEST_CASE("editor.building.surface_attachment_survives_replace_and_undo") {
registerHouse();
eve::building::BuildingDefinition upgraded = *eve::building::BuildingRegistry::find("house");
upgraded.id = "surface-upgraded-house";
eve::building::BuildingRegistry::registerBuilding(upgraded);
eve::building::PlacementWorld world(8, 8, 10.f);
BuildingPlacementTarget target("surface-replace-world", &world);
BuildingInstanceSnapshot placed = house(61, 2, 2);
placed.surfaceId = "terrain/main";
placed.surfaceRevision = 27;
placed.surfaceNormalX = 0.25;
placed.surfaceNormalY = 0.95;
placed.surfaceNormalZ = 0.1;
placed.surfaceTangentX = 0.9;
placed.surfaceTangentY = -0.2;
placed.surfaceTangentZ = 0.3;
placed.surfaceSampleCount = 4;
placed.surfaceMaxSlopeDegrees = 14.0;
placed.surfaceHeightDelta = 0.6;
auto place = target.makePlace(placed);
REQUIRE(place.ok());
REQUIRE(target.applyDomainOperation(place.value()).ok());
auto replacement = target.makeReplace(61, upgraded.id);
REQUIRE(replacement.ok());
REQUIRE(target.applyDomainOperation(replacement.value()).ok());
REQUIRE(target.applyDomainOperation(inverse(replacement.value())).ok());
const auto restored = target.instance(61);
REQUIRE(restored.ok());
CHECK_EQ(restored.value().surfaceId, "terrain/main");
CHECK_EQ(restored.value().surfaceRevision, uint64_t{27});
CHECK_EQ(restored.value().surfaceSampleCount, 4);
CHECK_EQ(restored.value().surfaceMaxSlopeDegrees, 14.0);
CHECK_EQ(restored.value().surfaceHeightDelta, 0.6);
}
TEST_CASE("editor.building.set_preflights_final_definition_and_pose_atomically") {
registerHouse();
eve::building::BuildingDefinition upgraded = *eve::building::BuildingRegistry::find("house");
upgraded.id = "atomic-upgraded-house";
eve::building::BuildingRegistry::registerBuilding(upgraded);
eve::building::PlacementWorld world(8, 8, 10.f);
BuildingPlacementTarget target("atomic-set-world", &world);
auto source = target.makePlace(house(71, 0, 0));
auto blocker = target.makePlace(house(72, 4, 4));
REQUIRE(source.ok());
REQUIRE(blocker.ok());
REQUIRE(target.applyDomainOperation(source.value()).ok());
REQUIRE(target.applyDomainOperation(blocker.value()).ok());
auto replacement = target.makeReplace(71, upgraded.id);
REQUIRE(replacement.ok());
DomainOperation conflicting = replacement.value();
auto* payload = conflicting.payload.getIf<EditorValue::Object>();
REQUIRE(payload);
(*payload)["cellX"] = int64_t{4};
(*payload)["cellY"] = int64_t{4};
const auto rejected = target.applyDomainOperation(conflicting);
CHECK_EQ(static_cast<int>(rejected.code()), static_cast<int>(EditorStatus::Rejected));
CHECK_EQ(world.getBuildingId(71), "house");
CHECK_EQ(world.getBuildingCellX(71), 0);
CHECK_EQ(world.getBuildingCellY(71), 0);
}
TEST_CASE("editor.building.rectangle_is_one_atomic_undoable_gesture") {
registerAreaTile();
eve::building::PlacementWorld world(8, 8, 10.f);
BuildingPlacementTarget target("area-rectangle-world", &world);
auto rectangle = target.makeRectangle("editor-area-tile", 1, 2, 3, 3, 0.0);
REQUIRE(rectangle.ok());
CHECK_EQ(rectangle.value().affectedObjects.size(), size_t{6});
eve::editing::LocalWorldAuthority authority(&target);
eve::editing::TransactionSpec transaction;
transaction.id = eve::editing::TransactionId("building.area.rectangle.1");
transaction.label = "Paint rectangle";
transaction.target = target.targetId();
transaction.baseRevision = target.revision();
const std::vector<DomainOperation> operations{rectangle.value()};
auto plan = authority.preflight(transaction, operations);
REQUIRE(plan.ok());
const auto revisionBefore = target.revision();
auto committed = authority.commit(plan.value());
REQUIRE(committed.ok());
CHECK_EQ(world.getBuildingCount(), 6);
CHECK_EQ(target.revision(), revisionBefore + 1);
REQUIRE(authority.compensate(committed.value()).ok());
CHECK_EQ(world.getBuildingCount(), 0);
CHECK_EQ(target.revision(), revisionBefore + 2);
}
TEST_CASE("editor.building.edge_path_is_one_atomic_undoable_gesture") {
registerEdges();
eve::building::PlacementWorld world(8, 8, 10.f);
BuildingPlacementTarget target("edge-path-world", &world);
auto path = target.makeEdgePath("editor-fence", {{1, 1}, {4, 1}, {4, 3}});
REQUIRE(path.ok());
CHECK_EQ(path.value().affectedObjects.size(), size_t{5});
eve::editing::LocalWorldAuthority authority(&target);
eve::editing::TransactionSpec transaction;
transaction.id = eve::editing::TransactionId("building.edge-path.1");
transaction.label = "Place edge path";
transaction.target = target.targetId();
transaction.baseRevision = target.revision();
const std::vector<DomainOperation> operations{path.value()};
auto plan = authority.preflight(transaction, operations);
REQUIRE(plan.ok());
const auto revisionBefore = target.revision();
auto committed = authority.commit(plan.value());
REQUIRE(committed.ok());
CHECK_EQ(world.getBuildingCount(), 5);
CHECK_EQ(target.revision(), revisionBefore + 1);
CHECK(world.getEdgeOccupant("boundary", 3, 1, "north") > 0);
CHECK(world.getEdgeOccupant("boundary", 4, 2, "west") > 0);
REQUIRE(authority.compensate(committed.value()).ok());
CHECK_EQ(world.getBuildingCount(), 0);
CHECK_EQ(target.revision(), revisionBefore + 2);
}
TEST_CASE("editor.building.edge_curve_is_one_atomic_undoable_gesture") {
registerEdges();
eve::building::PlacementWorld world(10, 10, 10.f);
BuildingPlacementTarget target("edge-curve-world", &world);
auto curve = target.makeEdgeCubicBezier(
"editor-fence", {{1.0, 1.0}, {1.0, 6.0}, {7.0, 6.0}, {7.0, 1.0}}, 24);
REQUIRE(curve.ok());
CHECK(curve.value().affectedObjects.size() > size_t{7});
eve::editing::LocalWorldAuthority authority(&target);
eve::editing::TransactionSpec transaction;
transaction.id = eve::editing::TransactionId("building.edge-curve.1");
transaction.label = "Place cubic edge curve";
transaction.target = target.targetId();
transaction.baseRevision = target.revision();
const std::vector<DomainOperation> operations{curve.value()};
auto plan = authority.preflight(transaction, operations);
REQUIRE(plan.ok());
const auto revisionBefore = target.revision();
auto committed = authority.commit(plan.value());
REQUIRE(committed.ok());
CHECK_EQ(world.getBuildingCount(),
static_cast<int>(curve.value().affectedObjects.size()));
CHECK_EQ(world.getEdgeCurveGroupCount(), 1);
const int firstMember = world.getBuildingInstanceAt(0);
auto group = world.edgeCurveGroupForInstance(firstMember);
REQUIRE(group.ok());
CHECK_EQ(group.value().subdivisions, 24);
CHECK_EQ(group.value().controlPoints.size(), size_t{4});
const auto memberBeforeUndo = world.buildings().at(firstMember);
CHECK_EQ(target.revision(), revisionBefore + 1);
REQUIRE(authority.compensate(committed.value()).ok());
CHECK_EQ(world.getBuildingCount(), 0);
CHECK_EQ(world.getEdgeCurveGroupCount(), 0);
CHECK_EQ(target.revision(), revisionBefore + 2);
transaction.baseRevision = target.revision();
const int blocker = world.placeEdge(
"editor-fence", memberBeforeUndo.edge.x, memberBeforeUndo.edge.y,
memberBeforeUndo.edge.axis == eve::building::EdgeAxis::Horizontal ? "north" : "west");
REQUIRE(blocker > 0);
auto rejectedRedo = authority.preflight(transaction, operations);
CHECK(!rejectedRedo.ok());
CHECK_EQ(world.getBuildingCount(), 1);
CHECK_EQ(world.getEdgeCurveGroupCount(), 0);
REQUIRE(world.removeBuilding(blocker));
auto redoPlan = authority.preflight(transaction, operations);
REQUIRE(redoPlan.ok());
REQUIRE(authority.commit(redoPlan.value()).ok());
CHECK_EQ(world.getEdgeCurveGroupCount(), 1);
auto restoredGroup = world.edgeCurveGroupForInstance(firstMember);
REQUIRE(restoredGroup.ok());
CHECK_EQ(restoredGroup.value(), group.value());
const EditorValue snapshot = target.snapshotValue();
const auto* root = snapshot.getIf<EditorValue::Object>();
REQUIRE(root);
const auto* curveGroups = root->at("edgeCurveGroups").getIf<EditorValue::Array>();
REQUIRE(curveGroups);
CHECK_EQ(curveGroups->size(), size_t{1});
}
TEST_CASE("editor.building.edge_curve_handles_preview_and_update_group_atomically") {
registerEdges();
eve::building::PlacementWorld world(12, 12, 10.f);
BuildingPlacementTarget target("edge-curve-handle-world", &world);
const std::vector<BuildingEdgeCurvePoint> original{
{1.0, 1.0}, {1.0, 6.0}, {7.0, 6.0}, {7.0, 1.0}};
auto draft = target.previewEdgeCubicBezier("editor-fence", original, 24);
CHECK_EQ(static_cast<int>(draft.status), static_cast<int>(EditorStatus::Applied));
CHECK_EQ(draft.controlPoints.size(), size_t{4});
CHECK(draft.edges.size() > size_t{7});
auto gizmo = target.edgeCubicBezierGizmo("editor-fence", original, 24);
CHECK_EQ(static_cast<int>(gizmo.status), static_cast<int>(EditorStatus::Applied));
CHECK(gizmo.primitives.size() > draft.sampledVertices.size());
CHECK_EQ(gizmo.primitives.front().id, "curve.control.0");
CHECK_EQ(gizmo.primitives.front().kind, "sphere");
auto initial = target.makeEdgeCubicBezier("editor-fence", original, 24);
REQUIRE(initial.ok());
REQUIRE(target.applyDomainOperation(initial.value()).ok());
const int member = world.getBuildingInstanceAt(0);
auto before = world.edgeCurveGroupForInstance(member);
REQUIRE(before.ok());
const std::vector<BuildingEdgeCurvePoint> dragged{
{1.0, 1.0}, {2.0, 7.0}, {8.0, 5.0}, {8.0, 1.0}};
auto liveDraft =
target.previewEdgeCubicBezier("editor-fence", dragged, 32, member);
CHECK_EQ(static_cast<int>(liveDraft.status),
static_cast<int>(EditorStatus::Applied));
auto update = target.makeUpdateEdgeCubicBezier(member, dragged, 32);
REQUIRE(update.ok());
eve::editing::LocalWorldAuthority authority(&target);
eve::editing::TransactionSpec transaction;
transaction.id = eve::editing::TransactionId("building.edge-curve.drag.1");
transaction.label = "Drag cubic edge curve handle";
transaction.target = target.targetId();
transaction.baseRevision = target.revision();
const std::vector<DomainOperation> operations{update.value()};
auto plan = authority.preflight(transaction, operations);
REQUIRE(plan.ok());
auto committed = authority.commit(plan.value());
REQUIRE(committed.ok());
CHECK_EQ(world.getEdgeCurveGroupCount(), 1);
auto after = world.edgeCurveGroup(before.value().id);
REQUIRE(after.ok());
CHECK_EQ(after.value().subdivisions, 32);
CHECK(after.value().controlPoints != before.value().controlPoints);
REQUIRE(authority.compensate(committed.value()).ok());
auto undone = world.edgeCurveGroup(before.value().id);
REQUIRE(undone.ok());
CHECK_EQ(undone.value(), before.value());
}
TEST_CASE("editor.building.surface_curve_create_preview_and_gizmo_share_frames") {
registerEdges();
eve::building::PlacementWorld world(12, 12, 10.f);
world.setGridPlane("xz");
eve::building::PlacementSystem::registerSurfaceProvider(
"editor.curve-terrain", [](const eve::building::PlacementWorld&, float x, float y) {
eve::building::PlacementSystem::PlacementHit hit;
hit.worldX = x;
hit.worldY = x * 0.1f;
hit.worldZ = y;
hit.normalX = -0.1f;
hit.normalY = 1.f;
hit.surfaceId = "terrain.editor";
hit.surfaceRevision = 21;
return eve::Result<eve::building::PlacementSystem::PlacementHit>::success(
std::move(hit));
});
BuildingPlacementTarget target("surface-curve-editor-world", &world);
const std::vector<BuildingEdgeCurvePoint> controls{
{1.0, 1.0}, {1.0, 6.0}, {7.0, 6.0}, {7.0, 1.0}};
auto preview = target.previewEdgeCubicBezier(
"editor-fence", controls, 16, 0, "editor.curve-terrain");
REQUIRE(static_cast<int>(preview.status) ==
static_cast<int>(EditorStatus::Applied));
CHECK_EQ(preview.surfaceProviderName, "editor.curve-terrain");
CHECK_EQ(preview.surfaceId, "terrain.editor");
CHECK_EQ(preview.surfaceRevision, uint64_t{21});
CHECK_EQ(preview.surfaceSamples.size(), size_t{17});
auto gizmo = target.edgeCubicBezierGizmo(
"editor-fence", controls, 16, 0, 0, "editor.curve-terrain");
REQUIRE(static_cast<int>(gizmo.status) ==
static_cast<int>(EditorStatus::Applied));
bool foundElevatedPath = false;
for (const auto& primitive : gizmo.primitives)
if (primitive.id.rfind("curve.path.", 0) == 0 && primitive.position[1] > 1.0)
foundElevatedPath = true;
CHECK(foundElevatedPath);
auto operation = target.makeEdgeCubicBezierOnSurface(
"editor-fence", controls, 16, "editor.curve-terrain");
REQUIRE(operation.ok());
REQUIRE(target.applyDomainOperation(operation.value()).ok());
REQUIRE(world.getEdgeCurveGroupCount() == 1);
auto committed = world.edgeCurveGroupForInstance(world.getBuildingInstanceAt(0));
REQUIRE(committed.ok());
CHECK_EQ(committed.value().surfaceProviderName, "editor.curve-terrain");
CHECK_EQ(committed.value().surfaceId, "terrain.editor");
CHECK_EQ(committed.value().surfaceRevision, uint64_t{21});
CHECK_EQ(committed.value().surfaceSamples.size(), size_t{17});
BuildingEdgeCurveDragSession drag(&target, "editor-fence",
committed.value().instanceIds.front(), controls, 16,
"editor.curve-terrain");
auto missed = drag.beginDrag(100.0, 100.0, 100.0, 0.0, 0.0, -1.0);
CHECK(!missed.ok());
CHECK(!drag.isDragging());
auto begun = drag.beginDrag(10.0, 1.0, 30.0, 0.0, 0.0, -1.0);
REQUIRE(begun.ok());
CHECK(drag.isDragging());
CHECK_EQ(drag.activeControlPointIndex(), 0);
auto moved = drag.updateDrag(20.0, 2.0, 30.0, 0.0, 0.0, -1.0);
REQUIRE(moved.ok());
CHECK_EQ(static_cast<int>(moved.value().status),
static_cast<int>(EditorStatus::Applied));
CHECK(std::abs(moved.value().curve.controlPoints[0].x - 2.0) < 0.0001);
auto draggedOperation = drag.finishDrag();
REQUIRE(draggedOperation.ok());
CHECK(!drag.isDragging());
REQUIRE(target.applyDomainOperation(draggedOperation.value()).ok());
auto afterDrag = world.edgeCurveGroup(committed.value().id);
REQUIRE(afterDrag.ok());
CHECK(std::abs(afterDrag.value().controlPoints[0].x - 2.f) < 0.0001f);
CHECK_EQ(afterDrag.value().surfaceId, "terrain.editor");
std::vector<BuildingEdgeCurvePoint> afterDragControls;
for (const auto& point : afterDrag.value().controlPoints)
afterDragControls.push_back({point.x, point.y});
BuildingEdgeCurveDragSession staleDrag(
&target, "editor-fence", afterDrag.value().instanceIds.front(), afterDragControls, 16,
"editor.curve-terrain");
REQUIRE(staleDrag.beginDrag(20.0, 2.0, 30.0, 0.0, 0.0, -1.0).ok());
REQUIRE(target.commitDomainState(target.cloneDomainState()).ok());
auto staleFinish = staleDrag.finishDrag();
CHECK(!staleFinish.ok());
CHECK_EQ(static_cast<int>(staleFinish.code()),
static_cast<int>(EditorStatus::Conflict));
CHECK(!staleDrag.isDragging());
auto rejected = target.previewEdgeCubicBezier(
"editor-fence", controls, 16, 0, "editor.missing-terrain");
CHECK_EQ(static_cast<int>(rejected.status),
static_cast<int>(EditorStatus::Rejected));
CHECK(!rejected.diagnostics.empty());
auto rejectedGizmo = target.edgeCubicBezierGizmo(
"editor-fence", controls, 16, 0, 0, "editor.missing-terrain");
CHECK_EQ(static_cast<int>(rejectedGizmo.status),
static_cast<int>(EditorStatus::Rejected));
bool foundRejectedPath = false;
for (const auto& primitive : rejectedGizmo.primitives)
if (primitive.id.rfind("curve.path.", 0) == 0 && primitive.color[0] == 0.95)
foundRejectedPath = true;
CHECK(foundRejectedPath);
eve::building::PlacementSystem::unregisterSurface("editor.curve-terrain");
eve::building::BuildingRegistry::clear();
}
TEST_CASE("editor.building.brush_conflict_discards_complete_staging_candidate") {
registerAreaTile();
eve::building::PlacementWorld world(9, 9, 10.f);
BuildingPlacementTarget target("area-conflict-world", &world);
auto brush = target.makeBrush("editor-area-tile", 4, 4, 1, 0.0);
REQUIRE(brush.ok());
CHECK_EQ(brush.value().affectedObjects.size(), size_t{5});
BuildingInstanceSnapshot blockerSnapshot;
blockerSnapshot.instanceId = 99;
blockerSnapshot.buildingId = "editor-area-tile";
blockerSnapshot.cellX = 4;
blockerSnapshot.cellY = 5;
blockerSnapshot.worldX = 40.0;
blockerSnapshot.worldY = 50.0;
blockerSnapshot.channel = "floor";
auto blockerPlacement = target.makePlace(blockerSnapshot);
REQUIRE(blockerPlacement.ok());
REQUIRE(target.applyDomainOperation(blockerPlacement.value()).ok());
const int countBefore = world.getBuildingCount();
const auto revisionBefore = target.revision();
eve::building::PlacementSystem::clearEvents();
const auto rejected = target.applyDomainOperation(brush.value());
CHECK_EQ(static_cast<int>(rejected.code()), static_cast<int>(EditorStatus::Rejected));
CHECK_EQ(world.getBuildingCount(), countBefore);
CHECK(world.hasBuilding(99));
CHECK_EQ(target.revision(), revisionBefore);
CHECK(eve::building::PlacementSystem::events().empty());
}