diff --git a/libs/scene/merge/SelectionGroupMergerBase.h b/libs/scene/merge/SelectionGroupMergerBase.h index 2e89553768..b37b427468 100644 --- a/libs/scene/merge/SelectionGroupMergerBase.h +++ b/libs/scene/merge/SelectionGroupMergerBase.h @@ -5,6 +5,7 @@ #include "iselectiongroup.h" #include "imap.h" #include "NodeUtils.h" +#include "math/Hash.h" namespace scene { @@ -38,6 +39,26 @@ class SelectionGroupMergerBase return members; } + // A group fingerprint only consists of the member fingerprints, its ID and the member ordering is irrelevant + std::string getGroupFingerprint(selection::ISelectionGroup& group) + { + std::set memberFingerprints; + + group.foreachNode([&](const INodePtr& member) + { + memberFingerprints.emplace(NodeUtils::GetGroupMemberFingerprint(member)); + }); + + math::Hash hash; + + for (const auto& fingerprint : memberFingerprints) + { + hash.addString(fingerprint); + } + + return hash; + } + using NodeFingerprints = std::map; NodeFingerprints collectNodeFingerprints(const IMapRootNodePtr& root) diff --git a/libs/scene/merge/ThreeWaySelectionGroupMerger.h b/libs/scene/merge/ThreeWaySelectionGroupMerger.h index 566523d1f2..2dcae89bd3 100644 --- a/libs/scene/merge/ThreeWaySelectionGroupMerger.h +++ b/libs/scene/merge/ThreeWaySelectionGroupMerger.h @@ -11,6 +11,23 @@ namespace merge class ThreeWaySelectionGroupMerger : public SelectionGroupMergerBase { +public: + struct Change + { + enum class Type + { + NodeAddedToGroup, + NodeRemovedFromGroup, + TargetGroupAdded, + TargetGroupRemoved, + NodeGroupsReordered, + }; + + std::size_t groupId; + INodePtr member; + Type type; + }; + private: IMapRootNodePtr _baseRoot; IMapRootNodePtr _sourceRoot; @@ -24,6 +41,20 @@ class ThreeWaySelectionGroupMerger : NodeFingerprints _sourceNodes; NodeFingerprints _targetNodes; + std::map _sourceGroupFingerprints; + std::set _targetGroupFingerprints; + + std::set _addedSourceGroupIds; // groups that have been added to source + std::set _addedTargetGroupIds; // groups that have been added to target + + std::set _removedSourceGroupIds; // base groups that have been removed in source + std::set _removedTargetGroupIds; // base groups that have been removed in target + + std::set _modifiedSourceGroupIds; // groups that have been modified in source + std::set _modifiedTargetGroupIds; // groups that have been modified in target + + std::vector _changes; + public: ThreeWaySelectionGroupMerger(const IMapRootNodePtr& baseRoot, const IMapRootNodePtr& sourceRoot, const IMapRootNodePtr& targetRoot) : _baseRoot(baseRoot), @@ -48,6 +79,11 @@ class ThreeWaySelectionGroupMerger : { return _baseRoot; } + + const std::vector& getChangeLog() const + { + return _changes; + } void adjustTargetGroups() { @@ -60,6 +96,171 @@ class ThreeWaySelectionGroupMerger : _baseNodes = collectNodeFingerprints(_baseRoot); _log << "Got " << _baseNodes.size() << " in the base map" << std::endl; + + _baseManager.foreachSelectionGroup( + std::bind(&ThreeWaySelectionGroupMerger::processBaseGroup, this, std::placeholders::_1)); + + // We need to know which groups have been added to source and target, respectively + _sourceManager.foreachSelectionGroup( + std::bind(&ThreeWaySelectionGroupMerger::processSourceGroup, this, std::placeholders::_1)); + _targetManager.foreachSelectionGroup( + std::bind(&ThreeWaySelectionGroupMerger::processTargetGroup, this, std::placeholders::_1)); + + // Create all source groups and assign new group IDs to not conflict with anything in the target + addMissingGroupsToTarget(); + + // Apply all source group removals if the group has not been modified in target + removeGroupsFromTarget(); + + // Try to replicate all membership changes to groups in the source map + adjustGroupMemberships(); + } + +private: + void adjustGroupMemberships() + { + for (auto id : _modifiedSourceGroupIds) + { + auto targetGroup = _targetManager.getSelectionGroup(id); + + if (!targetGroup) + { + _log << "The target group with ID " << id << " is no longer present, cannot apply changes." << std::endl; + continue; + } + + auto sourceGroup = _sourceManager.getSelectionGroup(id); + + sourceGroup->foreachNode([&](const INodePtr& member) + { + auto existingTargetNode = _targetNodes.find(NodeUtils::GetGroupMemberFingerprint(member)); + + if (existingTargetNode != _targetNodes.end()) + { + _log << "Adding target node to newly created group" << std::endl; + targetGroup->addNode(existingTargetNode->second); + } + }); + } + } + + void removeGroupsFromTarget() + { + for (auto id : _removedSourceGroupIds) + { + // This group can be removed if it has not been modified in target + if (_modifiedTargetGroupIds.count(id) > 0) + { + _log << "The removed source group ID " << id << " has been modified in the target map, won't remove." << std::endl; + continue; + } + + _log << "Removing group with ID " << id << " from the target map, as it has been removed in the source" << std::endl; + _targetManager.deleteSelectionGroup(id); + } + } + + void addMissingGroupsToTarget() + { + for (auto id : _addedSourceGroupIds) + { + auto sourceFingerprint = _sourceGroupFingerprints[id]; + + // Check if there is an equivalent group in the target map + if (_targetGroupFingerprints.count(sourceFingerprint)) + { + _log << "Missing source group has an equivalent group in the target map" << std::endl; + continue; + } + + auto targetGroup = _targetManager.createSelectionGroup(); + + _log << "Adding missing source group to the target map: ID=" << targetGroup->getId() << std::endl; + + auto sourceGroup = _sourceManager.getSelectionGroup(id); + + sourceGroup->foreachNode([&](const INodePtr& member) + { + auto existingTargetNode = _targetNodes.find(NodeUtils::GetGroupMemberFingerprint(member)); + + if (existingTargetNode != _targetNodes.end()) + { + _log << "Adding target node to newly created group" << std::endl; + targetGroup->addNode(existingTargetNode->second); + } + }); + } + } + + void processBaseGroup(selection::ISelectionGroup& group) + { + _log << "Processing base group with ID: " << group.getId() << ", size: " << group.size() << std::endl; + + // Check if this group exists in source + auto sourceGroup = _sourceManager.getSelectionGroup(group.getId()); + + if (!sourceGroup) + { + _log << "Base group is not present in source: " << group.getId() << std::endl; + _removedSourceGroupIds.insert(group.getId()); + } + + // Check if this group exists in target + auto targetGroup = _targetManager.getSelectionGroup(group.getId()); + + if (!targetGroup) + { + _log << "Base group is not present in target: " << group.getId() << std::endl; + _removedTargetGroupIds.insert(group.getId()); + } + } + + void processSourceGroup(selection::ISelectionGroup& group) + { + _log << "Processing source group with ID: " << group.getId() << ", size: " << group.size() << std::endl; + + auto sourceFingerprint = getGroupFingerprint(group); + _sourceGroupFingerprints.emplace(group.getId(), sourceFingerprint); + + // Check if this group exists in base + auto baseGroup = _baseManager.getSelectionGroup(group.getId()); + + if (!baseGroup) + { + _log << "Source group is not present in base: " << group.getId() << std::endl; + _addedSourceGroupIds.insert(group.getId()); + return; + } + + // The base group exists, check if it has the same members + if (sourceFingerprint != getGroupFingerprint(*baseGroup)) + { + _modifiedSourceGroupIds.insert(group.getId()); + } + } + + void processTargetGroup(selection::ISelectionGroup& group) + { + _log << "Processing target group with ID: " << group.getId() << ", size: " << group.size() << std::endl; + + auto targetFingerprint = getGroupFingerprint(group); + _targetGroupFingerprints.emplace(targetFingerprint); + + // Check if this group exists in base + auto baseGroup = _baseManager.getSelectionGroup(group.getId()); + + if (!baseGroup) + { + _log << "Target group is not present in base: " << group.getId() << std::endl; + _addedTargetGroupIds.insert(group.getId()); + return; + } + + // The base group exists, check if it has the same members + if (targetFingerprint == getGroupFingerprint(*baseGroup)) + { + _modifiedTargetGroupIds.insert(group.getId()); + } } }; diff --git a/test/MapMerging.cpp b/test/MapMerging.cpp index ff22eb72ef..7abdabc337 100644 --- a/test/MapMerging.cpp +++ b/test/MapMerging.cpp @@ -13,6 +13,7 @@ #include "scene/merge/MergeOperation.h" #include "scene/merge/ThreeWayMergeOperation.h" #include "scene/merge/SelectionGroupMerger.h" +#include "scene/merge/ThreeWaySelectionGroupMerger.h" #include "scene/merge/LayerMerger.h" namespace test @@ -22,6 +23,7 @@ using MapMergeTest = RadiantTest; using SelectionGroupMergeTest = RadiantTest; using LayerMergeTest = RadiantTest; using ThreeWayMergeTest = RadiantTest; +using ThreeWaySelectionGroupMergeTest = RadiantTest; TEST_F(MapMergeTest, BrushFingerprint) { @@ -2378,4 +2380,193 @@ TEST_F(ThreeWayMergeTest, RemovalOfModifiedKeyValue) EXPECT_EQ(Node_getEntity(entity)->getKeyValue("extra2"), ""); } +inline std::unique_ptr setupThreeWayGroupMerger(const std::string& baseMap, + const std::string& sourceMap, const std::string& targetMap) +{ + auto baseResource = GlobalMapResourceManager().createFromPath(baseMap); + EXPECT_TRUE(baseResource->load()) << "Test map not found: " << baseMap; + + auto changedResource = GlobalMapResourceManager().createFromPath(sourceMap); + EXPECT_TRUE(changedResource->load()) << "Test map not found: " << sourceMap; + + auto targetResource = GlobalMapResourceManager().createFromPath(targetMap); + EXPECT_TRUE(targetResource->load()) << "Test map not found: " << targetMap; + + auto operation = ThreeWayMergeOperation::Create(baseResource->getRootNode(), changedResource->getRootNode(), targetResource->getRootNode()); + operation->setMergeSelectionGroups(false); // we do this manually + operation->applyActions(); + + return std::make_unique(baseResource->getRootNode(), changedResource->getRootNode(), targetResource->getRootNode()); +} + +inline std::size_t changeCountByType(const std::vector& log, + ThreeWaySelectionGroupMerger::Change::Type type) +{ + return std::count_if(log.begin(), log.end(), [=](const ThreeWaySelectionGroupMerger::Change& change) + { + return change.type == type; + }); +} + +inline bool groupContainsNodes(selection::ISelectionGroup& group, const std::vector& nodes) +{ + std::set groupMembers; + group.foreachNode([&](const scene::INodePtr& node) { groupMembers.insert(node); }); + + for (const auto& requiredNode : nodes) + { + if (groupMembers.count(requiredNode) != 1) + { + return false; + } + } + + return !nodes.empty(); +} + +// Three-Way Selection Group Merge Scenarios +// +// Base Map Groups: +// N1-N2-N3 +// [N1-N2-N3]-L1 +// 11-12-13-14 +// FS1-FS2 +// FS3-FS4 +// 1-2 +// [1-2]-3 +// 5-6 +// +// Target Map Changes: +// L2 deleted +// FS1-FS2 group dissolved +// Added Brushes 15 and 16 +// [5-6]-15 group added +// +// Source Map Changes: +// [[N1-N2-N3]-L1]-L2 group added +// Brush 14 removed by degeneration 11-12-13 remains +// [5-6]-4 group added +// FS3-FS4 group dissolved + +void verifyTargetScene(const scene::IMapRootNodePtr& targetRoot) +{ + // FS1 and FS2 no longer form a group + auto func_static_1 = algorithm::getEntityByName(targetRoot, "func_static_1"); + auto func_static_2 = algorithm::getEntityByName(targetRoot, "func_static_2"); + + EXPECT_EQ(std::dynamic_pointer_cast(func_static_1)->getGroupIds().size(), 0); + EXPECT_EQ(std::dynamic_pointer_cast(func_static_2)->getGroupIds().size(), 0); + + // FS3 and FS4 still form a group + auto func_static_3 = algorithm::getEntityByName(targetRoot, "func_static_3"); + auto func_static_4 = algorithm::getEntityByName(targetRoot, "func_static_4"); + + EXPECT_EQ(std::dynamic_pointer_cast(func_static_3)->getGroupIds().size(), 1); + EXPECT_EQ(std::dynamic_pointer_cast(func_static_4)->getGroupIds().size(), 1); + + // [5-6]-15 group added + auto brush15 = algorithm::findFirstBrushWithMaterial(algorithm::findWorldspawn(targetRoot), "textures/numbers/15"); + auto brush5 = algorithm::findFirstBrushWithMaterial(algorithm::findWorldspawn(targetRoot), "textures/numbers/5"); + auto brush6 = algorithm::findFirstBrushWithMaterial(algorithm::findWorldspawn(targetRoot), "textures/numbers/6"); + + bool foundGroup = false; + + for (auto groupId : std::dynamic_pointer_cast(brush15)->getGroupIds()) + { + auto group = targetRoot->getSelectionGroupManager().getSelectionGroup(groupId); + + if (groupContainsNodes(*group, { brush15, brush5, brush6 })) + { + foundGroup = true; + break; + } + } + + EXPECT_TRUE(foundGroup) << "Brush 15 does not form a group with brush 5 and 6"; + + auto brush11 = algorithm::findFirstBrushWithMaterial(algorithm::findWorldspawn(targetRoot), "textures/numbers/11"); + auto brush12 = algorithm::findFirstBrushWithMaterial(algorithm::findWorldspawn(targetRoot), "textures/numbers/12"); + auto brush13 = algorithm::findFirstBrushWithMaterial(algorithm::findWorldspawn(targetRoot), "textures/numbers/13"); + + EXPECT_EQ(std::dynamic_pointer_cast(brush11)->getGroupIds().size(), 1); + EXPECT_EQ(std::dynamic_pointer_cast(brush12)->getGroupIds().size(), 1); + EXPECT_EQ(std::dynamic_pointer_cast(brush13)->getGroupIds().size(), 1); + + auto brush11Group = targetRoot->getSelectionGroupManager().getSelectionGroup( + std::dynamic_pointer_cast(brush11)->getGroupIds().front()); + + EXPECT_TRUE(groupContainsNodes(*brush11Group, { brush11, brush12, brush13 })); +} + +TEST_F(ThreeWaySelectionGroupMergeTest, GroupRemoval) +{ + auto merger = setupThreeWayGroupMerger("maps/threeway_merge_groups_base.mapx", "maps/threeway_merge_groups_source_1.mapx", "maps/threeway_merge_groups_target_1.mapx"); + + verifyTargetScene(merger->getTargetRoot()); + + merger->adjustTargetGroups(); + + // Target scene tries to add a group with L2 in it, but this node is no longer present + EXPECT_FALSE(algorithm::getEntityByName(merger->getTargetRoot(), "light_2")); + + // FS3 and FS4 no longer form a group + auto func_static_3 = algorithm::getEntityByName(merger->getTargetRoot(), "func_static_3"); + auto func_static_4 = algorithm::getEntityByName(merger->getTargetRoot(), "func_static_4"); + + EXPECT_EQ(std::dynamic_pointer_cast(func_static_3)->getGroupIds().size(), 0); + EXPECT_EQ(std::dynamic_pointer_cast(func_static_4)->getGroupIds().size(), 0); + + EXPECT_EQ(changeCountByType(merger->getChangeLog(), ThreeWaySelectionGroupMerger::Change::Type::TargetGroupRemoved), 1); + + // The rest of the target changes should still be intact + verifyTargetScene(merger->getTargetRoot()); + + // Run another merger, it shouldn't find any actions to take + merger = std::make_unique(merger->getBaseRoot(), merger->getSourceRoot(), merger->getTargetRoot()); + merger->adjustTargetGroups(); + EXPECT_TRUE(merger->getChangeLog().empty()); + + verifyTargetScene(merger->getTargetRoot()); +} + +TEST_F(ThreeWaySelectionGroupMergeTest, GroupAddition) +{ + auto merger = setupThreeWayGroupMerger("maps/threeway_merge_groups_base.mapx", "maps/threeway_merge_groups_source_1.mapx", "maps/threeway_merge_groups_target_1.mapx"); + + verifyTargetScene(merger->getTargetRoot()); + + // Brush 4 is not part of any group yet + auto brush4 = algorithm::findFirstBrushWithMaterial(algorithm::findWorldspawn(merger->getTargetRoot()), "textures/numbers/4"); + EXPECT_EQ(std::dynamic_pointer_cast(brush4)->getGroupIds().size(), 0); + + merger->adjustTargetGroups(); + + // Target scene tries to add a group with L2 in it, but this node is no longer present + EXPECT_FALSE(algorithm::getEntityByName(merger->getTargetRoot(), "light_2")); + + // Brushes 5+6 already formed a group, Brush 4 should have been added + brush4 = algorithm::findFirstBrushWithMaterial(algorithm::findWorldspawn(merger->getTargetRoot()), "textures/numbers/4"); + auto brush5 = algorithm::findFirstBrushWithMaterial(algorithm::findWorldspawn(merger->getTargetRoot()), "textures/numbers/5"); + auto brush6 = algorithm::findFirstBrushWithMaterial(algorithm::findWorldspawn(merger->getTargetRoot()), "textures/numbers/6"); + + EXPECT_EQ(std::dynamic_pointer_cast(brush4)->getGroupIds().size(), 1); + + auto group = merger->getTargetRoot()->getSelectionGroupManager().getSelectionGroup(std::dynamic_pointer_cast(brush4)->getGroupIds().front()); + + EXPECT_TRUE(groupContainsNodes(*group, { brush4, brush5, brush6 })); + + // This change should have been recorded + EXPECT_EQ(changeCountByType(merger->getChangeLog(), ThreeWaySelectionGroupMerger::Change::Type::TargetGroupAdded), 1); + + // The rest of the target changes should still be intact + verifyTargetScene(merger->getTargetRoot()); + + // Run another merger, it shouldn't find any actions to take + merger = std::make_unique(merger->getBaseRoot(), merger->getSourceRoot(), merger->getTargetRoot()); + merger->adjustTargetGroups(); + EXPECT_TRUE(merger->getChangeLog().empty()); + + verifyTargetScene(merger->getTargetRoot()); +} + } diff --git a/test/resources/tdm/maps/threeway_merge_groups_base.mapx b/test/resources/tdm/maps/threeway_merge_groups_base.mapx new file mode 100644 index 0000000000..40c032e0fd --- /dev/null +++ b/test/resources/tdm/maps/threeway_merge_groups_base.mapx @@ -0,0 +1,1483 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/resources/tdm/maps/threeway_merge_groups_source_1.mapx b/test/resources/tdm/maps/threeway_merge_groups_source_1.mapx new file mode 100644 index 0000000000..aa7ef0e56c --- /dev/null +++ b/test/resources/tdm/maps/threeway_merge_groups_source_1.mapx @@ -0,0 +1,1443 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/resources/tdm/maps/threeway_merge_groups_target_1.mapx b/test/resources/tdm/maps/threeway_merge_groups_target_1.mapx new file mode 100644 index 0000000000..e173447d07 --- /dev/null +++ b/test/resources/tdm/maps/threeway_merge_groups_target_1.mapx @@ -0,0 +1,1558 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +