From fd74b76a2a6a851a8b29f57d2805a57938f23c96 Mon Sep 17 00:00:00 2001 From: Klaus Silveira Date: Thu, 9 Apr 2026 05:28:38 -0400 Subject: [PATCH] Added group management panel and recursive ungroup command. --- include/ui/iusercontrol.h | 1 + libs/scene/Group.h | 35 ++ radiant/CMakeLists.txt | 1 + radiant/ui/UserInterfaceModule.cpp | 11 + .../ui/selectiongroup/SelectionGroupControl.h | 35 ++ .../ui/selectiongroup/SelectionGroupPanel.cpp | 388 ++++++++++++++++++ .../ui/selectiongroup/SelectionGroupPanel.h | 97 +++++ radiantcore/selection/algorithm/Group.cpp | 5 + radiantcore/selection/algorithm/Group.h | 1 + .../selection/group/SelectionGroupModule.cpp | 1 + test/CMakeLists.txt | 1 + test/SelectionGroup.cpp | 269 ++++++++++++ tools/msvc/DarkRadiant.vcxproj | 3 + tools/msvc/DarkRadiant.vcxproj.filters | 12 + tools/msvc/Tests/Tests.vcxproj | 1 + tools/msvc/Tests/Tests.vcxproj.filters | 1 + 16 files changed, 862 insertions(+) create mode 100644 radiant/ui/selectiongroup/SelectionGroupControl.h create mode 100644 radiant/ui/selectiongroup/SelectionGroupPanel.cpp create mode 100644 radiant/ui/selectiongroup/SelectionGroupPanel.h create mode 100644 test/SelectionGroup.cpp diff --git a/include/ui/iusercontrol.h b/include/ui/iusercontrol.h index 03c1ad2e34..1139864614 100644 --- a/include/ui/iusercontrol.h +++ b/include/ui/iusercontrol.h @@ -83,6 +83,7 @@ struct UserControl constexpr static const char* AasVisualisationPanel = "AasVisualisationPanel"; constexpr static const char* OrthoBackgroundPanel = "OrthoBackgroundPanel"; constexpr static const char* DecalShooter = "DecalShooter"; + constexpr static const char* SelectionGroupPanel = "SelectionGroupPanel"; }; } diff --git a/libs/scene/Group.h b/libs/scene/Group.h index 2b4f5028bd..1c4c23a8dd 100644 --- a/libs/scene/Group.h +++ b/libs/scene/Group.h @@ -179,4 +179,39 @@ inline void ungroupSelected() SceneChangeNotify(); } +/** + * Dissolve all group memberships of the currently selected nodes, + * removing every nested group level at once. + * Will throw cmd::ExecutionNotPossible if it cannot execute. + */ +inline void ungroupSelectedRecursively() +{ + checkUngroupSelectedAvailable(); + + UndoableCommand cmd("UngroupSelectedRecursively"); + + std::set ids; + + GlobalSelectionSystem().foreachSelected([&](const scene::INodePtr& node) + { + std::shared_ptr selectable = std::dynamic_pointer_cast(node); + + if (!selectable) return; + + for (std::size_t id : selectable->getGroupIds()) + { + ids.insert(id); + } + }); + + auto& selGroupMgr = detail::getMapSelectionGroupManager(); + + std::for_each(ids.begin(), ids.end(), [&](std::size_t id) + { + selGroupMgr.deleteSelectionGroup(id); + }); + + SceneChangeNotify(); +} + } diff --git a/radiant/CMakeLists.txt b/radiant/CMakeLists.txt index bd72f9179b..6d41f7e0ed 100644 --- a/radiant/CMakeLists.txt +++ b/radiant/CMakeLists.txt @@ -161,6 +161,7 @@ add_executable(darkradiant ui/terrain/TerrainGeneratorDialog.cpp ui/script/ScriptMenu.cpp ui/script/ScriptWindow.cpp + ui/selectiongroup/SelectionGroupPanel.cpp ui/selectionset/SelectionSetToolmenu.cpp ui/skin/SkinEditor.cpp ui/skin/SkinEditorTreeView.cpp diff --git a/radiant/ui/UserInterfaceModule.cpp b/radiant/ui/UserInterfaceModule.cpp index dc0c76226d..1c62478895 100644 --- a/radiant/ui/UserInterfaceModule.cpp +++ b/radiant/ui/UserInterfaceModule.cpp @@ -83,6 +83,7 @@ #include "lightinspector/LightInspectorControl.h" #include "decalshooter/DecalShooterControl.h" #include "overlay/OrthoBackgroundControl.h" +#include "selectiongroup/SelectionGroupControl.h" #include "patch/PatchInspectorControl.h" #include "surfaceinspector/SurfaceInspectorControl.h" #include "textool/TextureToolControl.h" @@ -206,6 +207,12 @@ void UserInterfaceModule::initialiseModule(const IApplicationContext& ctx) []() { return cmd::ExecutionNotPossible::ToBool(selection::checkUngroupSelectedAvailable); }), IOrthoContextMenu::SECTION_SELECTION_GROUPS); + GlobalOrthoContextMenu().addItem(std::make_shared( + new wxutil::IconTextMenuItem(_("Ungroup Selection Recursively"), "ungroup_selection.png"), + []() { selection::ungroupSelectedRecursively(); }, + []() { return cmd::ExecutionNotPossible::ToBool(selection::checkUngroupSelectedAvailable); }), + IOrthoContextMenu::SECTION_SELECTION_GROUPS); + _longOperationHandler.reset(new LongRunningOperationHandler); _mapFileProgressHandler.reset(new MapFileProgressHandler); _autoSaveRequestHandler.reset(new AutoSaveRequestHandler); @@ -270,6 +277,7 @@ void UserInterfaceModule::initialiseModule(const IApplicationContext& ctx) registerControl(std::make_shared()); registerControl(std::make_shared()); registerControl(std::make_shared()); + registerControl(std::make_shared()); GlobalMainFrame().signal_MainFrameConstructed().connect([&]() { @@ -311,6 +319,9 @@ void UserInterfaceModule::initialiseModule(const IApplicationContext& ctx) GlobalMainFrame().addControl( UserControl::DecalShooter, ControlSettings::floating(300, 200) ); + GlobalMainFrame().addControl( + UserControl::SelectionGroupPanel, ControlSettings::floating(300, 400) + ); _viewMenu = std::make_unique(); }); diff --git a/radiant/ui/selectiongroup/SelectionGroupControl.h b/radiant/ui/selectiongroup/SelectionGroupControl.h new file mode 100644 index 0000000000..c233040086 --- /dev/null +++ b/radiant/ui/selectiongroup/SelectionGroupControl.h @@ -0,0 +1,35 @@ +#pragma once + +#include "i18n.h" +#include "ui/iusercontrol.h" +#include "SelectionGroupPanel.h" + +namespace ui +{ + +class SelectionGroupControl : + public IUserControlCreator +{ +public: + std::string getControlName() override + { + return UserControl::SelectionGroupPanel; + } + + std::string getDisplayName() override + { + return _("Selection Groups"); + } + + std::string getIcon() override + { + return "group_selection.png"; + } + + wxWindow* createWidget(wxWindow* parent) override + { + return new SelectionGroupPanel(parent); + } +}; + +} diff --git a/radiant/ui/selectiongroup/SelectionGroupPanel.cpp b/radiant/ui/selectiongroup/SelectionGroupPanel.cpp new file mode 100644 index 0000000000..6795d86eac --- /dev/null +++ b/radiant/ui/selectiongroup/SelectionGroupPanel.cpp @@ -0,0 +1,388 @@ +#include "SelectionGroupPanel.h" + +#include "i18n.h" +#include "imap.h" +#include "iundo.h" +#include "iselectable.h" +#include "iselectiongroup.h" +#include "scene/Node.h" + +#include "wxutil/dataview/TreeView.h" +#include "util/ScopedBoolLock.h" + +#include +#include +#include + +#include + +namespace ui +{ + +SelectionGroupPanel::SelectionGroupPanel(wxWindow* parent) : + DockablePanel(parent), + _treeView(nullptr), + _removeFromGroupButton(nullptr), + _deleteButton(nullptr), + _refreshOnIdle(false), + _callbackActive(false) +{ + populateWindow(); +} + +SelectionGroupPanel::~SelectionGroupPanel() +{ + if (_treeView) + { + _treeView->Unbind(wxEVT_DATAVIEW_ITEM_ACTIVATED, &SelectionGroupPanel::onItemActivated, this); + _treeView->Unbind(wxEVT_DATAVIEW_SELECTION_CHANGED, &SelectionGroupPanel::onItemSelected, this); + } + + if (panelIsActive()) + { + disconnectListeners(); + } +} + +void SelectionGroupPanel::onPanelActivated() +{ + connectListeners(); + queueRefresh(); +} + +void SelectionGroupPanel::onPanelDeactivated() +{ + disconnectListeners(); + cancelCallbacks(); +} + +void SelectionGroupPanel::connectListeners() +{ + _mapEventSignal = GlobalMapModule().signal_mapEvent().connect( + sigc::mem_fun(this, &SelectionGroupPanel::onMapEvent)); + + connectToMapRoot(); +} + +void SelectionGroupPanel::disconnectListeners() +{ + _mapEventSignal.disconnect(); + disconnectFromMapRoot(); + _refreshOnIdle = false; +} + +void SelectionGroupPanel::connectToMapRoot() +{ + disconnectFromMapRoot(); + + if (GlobalMapModule().getRoot()) + { + _undoEventSignal = GlobalUndoSystem().signal_undoEvent().connect( + [this](IUndoSystem::EventType, const std::string&) { queueRefresh(); }); + } +} + +void SelectionGroupPanel::disconnectFromMapRoot() +{ + _undoEventSignal.disconnect(); +} + +void SelectionGroupPanel::populateWindow() +{ + SetSizer(new wxBoxSizer(wxVERTICAL)); + + auto* vbox = new wxBoxSizer(wxVERTICAL); + GetSizer()->Add(vbox, 1, wxEXPAND | wxALL, 12); + + _treeStore = new wxutil::TreeModel(_columns); + + _treeView = wxutil::TreeView::CreateWithModel(this, _treeStore.get(), wxDV_MULTIPLE); + + _treeView->AppendTextColumn(_("Group"), _columns.name.getColumnIndex(), + wxDATAVIEW_CELL_INERT, wxCOL_WIDTH_AUTOSIZE, wxALIGN_NOT, wxDATAVIEW_COL_SORTABLE); + + _treeView->AppendTextColumn(_("Members"), _columns.memberCount.getColumnIndex(), + wxDATAVIEW_CELL_INERT, wxCOL_WIDTH_AUTOSIZE); + + _treeView->Bind(wxEVT_DATAVIEW_ITEM_ACTIVATED, &SelectionGroupPanel::onItemActivated, this); + _treeView->Bind(wxEVT_DATAVIEW_SELECTION_CHANGED, &SelectionGroupPanel::onItemSelected, this); + + vbox->Add(_treeView, 1, wxEXPAND | wxBOTTOM, 6); + + createButtons(); + createPopupMenu(); +} + +void SelectionGroupPanel::createButtons() +{ + auto* buttonBox = new wxBoxSizer(wxHORIZONTAL); + + _removeFromGroupButton = new wxButton(this, wxID_ANY, _("Remove from Group")); + _removeFromGroupButton->Bind(wxEVT_BUTTON, [this](auto&) { removeSelectedFromGroup(); }); + _removeFromGroupButton->SetToolTip(_("Remove the selected members from their group")); + _removeFromGroupButton->Enable(false); + + _deleteButton = new wxButton(this, wxID_ANY, _("Delete Group")); + _deleteButton->Bind(wxEVT_BUTTON, [this](auto&) { deleteSelectedGroup(); }); + _deleteButton->SetToolTip(_("Delete the entire selected group")); + _deleteButton->Enable(false); + + buttonBox->Add(_removeFromGroupButton, 1, wxEXPAND | wxRIGHT, 6); + buttonBox->Add(_deleteButton, 1, wxEXPAND, 6); + + GetSizer()->Add(buttonBox, 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, 12); +} + +void SelectionGroupPanel::createPopupMenu() +{ + _popupMenu = std::make_shared(); + + _popupMenu->addItem( + new wxMenuItem(nullptr, wxID_ANY, _("Select group members")), + [this]() { + auto id = getSelectedGroupId(); + if (id == 0 || !hasMapRoot()) return; + auto& mgr = GlobalMapModule().getRoot()->getSelectionGroupManager(); + auto group = mgr.getSelectionGroup(id); + if (group) group->setSelected(true); + }, + [this]() { return getSelectedGroupId() != 0; } + ); + + _popupMenu->addItem( + new wxMenuItem(nullptr, wxID_ANY, _("Deselect group members")), + [this]() { + auto id = getSelectedGroupId(); + if (id == 0 || !hasMapRoot()) return; + auto& mgr = GlobalMapModule().getRoot()->getSelectionGroupManager(); + auto group = mgr.getSelectionGroup(id); + if (group) group->setSelected(false); + }, + [this]() { return getSelectedGroupId() != 0; } + ); + + _popupMenu->addSeparator(); + + _popupMenu->addItem( + new wxMenuItem(nullptr, wxID_ANY, _("Remove from group")), + [this]() { removeSelectedFromGroup(); }, + [this]() { return hasSelectedMembers(); } + ); + + _popupMenu->addItem( + new wxMenuItem(nullptr, wxID_ANY, _("Delete group")), + [this]() { deleteSelectedGroup(); }, + [this]() { return getSelectedGroupId() != 0; } + ); + + _treeView->Bind(wxEVT_DATAVIEW_ITEM_CONTEXT_MENU, + [this](auto&) { _popupMenu->show(this); }); +} + +void SelectionGroupPanel::queueRefresh() +{ + _refreshOnIdle = true; + requestIdleCallback(); +} + +void SelectionGroupPanel::onIdle() +{ + if (_refreshOnIdle) + { + _refreshOnIdle = false; + refresh(); + } +} + +void SelectionGroupPanel::refresh() +{ + if (!hasMapRoot()) + { + _treeStore->Clear(); + updateButtonSensitivity(); + return; + } + + util::ScopedBoolLock lock(_callbackActive); + wxWindowUpdateLocker freezer(_treeView); + + _treeStore->Clear(); + + auto& mgr = GlobalMapModule().getRoot()->getSelectionGroupManager(); + + mgr.foreachSelectionGroup([&](selection::ISelectionGroup& group) + { + auto groupRow = _treeStore->AddItem(); + + std::string displayName = group.getName().empty() + ? fmt::format(_("Group {0}"), group.getId()) + : group.getName(); + + groupRow[_columns.groupId] = static_cast(group.getId()); + groupRow[_columns.name] = displayName; + groupRow[_columns.memberCount] = std::to_string(group.size()); + groupRow[_columns.isGroup] = true; + groupRow[_columns.node] = wxVariant(static_cast(nullptr)); + + groupRow.SendItemAdded(); + + group.foreachNode([&](const scene::INodePtr& node) + { + auto childRow = _treeStore->AddItemUnderParent(groupRow.getItem()); + + childRow[_columns.groupId] = static_cast(group.getId()); + childRow[_columns.name] = node->name(); + childRow[_columns.memberCount] = std::string(); + childRow[_columns.isGroup] = false; + childRow[_columns.node] = wxVariant(node.get()); + + childRow.SendItemAdded(); + }); + }); + + updateButtonSensitivity(); +} + +void SelectionGroupPanel::onMapEvent(IMap::MapEvent ev) +{ + if (ev == IMap::MapLoaded) + { + connectToMapRoot(); + queueRefresh(); + } + else if (ev == IMap::MapUnloading) + { + disconnectFromMapRoot(); + queueRefresh(); + } +} + +void SelectionGroupPanel::onItemActivated(wxDataViewEvent& ev) +{ + if (_callbackActive) return; + + auto item = ev.GetItem(); + if (!item.IsOk() || !hasMapRoot()) return; + + wxutil::TreeModel::Row row(item, *_treeStore); + + if (row[_columns.isGroup].getBool()) + { + auto id = static_cast(row[_columns.groupId].getInteger()); + auto& mgr = GlobalMapModule().getRoot()->getSelectionGroupManager(); + auto group = mgr.getSelectionGroup(id); + if (group) group->setSelected(true); + } + else + { + auto* nodePtr = static_cast(row[_columns.node].getPointer()); + if (!nodePtr) return; + + auto* selectable = dynamic_cast(nodePtr); + if (selectable) selectable->setSelected(true); + } +} + +void SelectionGroupPanel::onItemSelected(wxDataViewEvent& ev) +{ + updateButtonSensitivity(); +} + +void SelectionGroupPanel::updateButtonSensitivity() +{ + _removeFromGroupButton->Enable(hasSelectedMembers()); + _deleteButton->Enable(getSelectedGroupId() != 0); +} + +void SelectionGroupPanel::removeSelectedFromGroup() +{ + if (!hasMapRoot()) return; + + wxDataViewItemArray selection; + _treeView->GetSelections(selection); + if (selection.empty()) return; + + auto& mgr = GlobalMapModule().getRoot()->getSelectionGroupManager(); + + for (const auto& item : selection) + { + if (!item.IsOk()) continue; + + wxutil::TreeModel::Row row(item, *_treeStore); + + if (row[_columns.isGroup].getBool()) continue; + + auto groupId = static_cast(row[_columns.groupId].getInteger()); + auto* nodePtr = static_cast(row[_columns.node].getPointer()); + if (!nodePtr) continue; + + auto group = mgr.getSelectionGroup(groupId); + if (!group) continue; + + auto* sceneNode = dynamic_cast(nodePtr); + if (sceneNode) + { + group->removeNode(sceneNode->shared_from_this()); + } + } + + queueRefresh(); +} + +void SelectionGroupPanel::deleteSelectedGroup() +{ + auto id = getSelectedGroupId(); + if (id == 0 || !hasMapRoot()) return; + + auto& mgr = GlobalMapModule().getRoot()->getSelectionGroupManager(); + mgr.deleteSelectionGroup(id); + + queueRefresh(); +} + +std::size_t SelectionGroupPanel::getSelectedGroupId() +{ + wxDataViewItemArray selection; + _treeView->GetSelections(selection); + + for (const auto& item : selection) + { + if (!item.IsOk()) continue; + + wxutil::TreeModel::Row row(item, *_treeStore); + + if (row[_columns.isGroup].getBool()) + { + return static_cast(row[_columns.groupId].getInteger()); + } + } + + return 0; +} + +bool SelectionGroupPanel::hasSelectedMembers() +{ + wxDataViewItemArray selection; + _treeView->GetSelections(selection); + + for (const auto& item : selection) + { + if (!item.IsOk()) continue; + + wxutil::TreeModel::Row row(item, *_treeStore); + + if (!row[_columns.isGroup].getBool()) + { + return true; + } + } + + return false; +} + +bool SelectionGroupPanel::hasMapRoot() +{ + return GlobalMapModule().getRoot() != nullptr; +} + +} diff --git a/radiant/ui/selectiongroup/SelectionGroupPanel.h b/radiant/ui/selectiongroup/SelectionGroupPanel.h new file mode 100644 index 0000000000..3eab346a4d --- /dev/null +++ b/radiant/ui/selectiongroup/SelectionGroupPanel.h @@ -0,0 +1,97 @@ +#pragma once + +#include "imap.h" + +#include + +#include "wxutil/DockablePanel.h" +#include "wxutil/dataview/TreeModel.h" +#include "wxutil/event/SingleIdleCallback.h" +#include "wxutil/menu/PopupMenu.h" + +namespace wxutil +{ + class TreeView; +} + +class wxButton; + +namespace ui +{ + +class SelectionGroupPanel : + public wxutil::DockablePanel, + public wxutil::SingleIdleCallback +{ +private: + struct TreeColumns : + public wxutil::TreeModel::ColumnRecord + { + TreeColumns() : + groupId(add(wxutil::TreeModel::Column::Integer)), + name(add(wxutil::TreeModel::Column::String)), + memberCount(add(wxutil::TreeModel::Column::String)), + isGroup(add(wxutil::TreeModel::Column::Boolean)), + node(add(wxutil::TreeModel::Column::Pointer)) + {} + + wxutil::TreeModel::Column groupId; + wxutil::TreeModel::Column name; + wxutil::TreeModel::Column memberCount; + wxutil::TreeModel::Column isGroup; + wxutil::TreeModel::Column node; + }; + + wxutil::TreeView* _treeView; + TreeColumns _columns; + wxutil::TreeModel::Ptr _treeStore; + + wxButton* _removeFromGroupButton; + wxButton* _deleteButton; + + bool _refreshOnIdle; + bool _callbackActive; + + sigc::connection _mapEventSignal; + sigc::connection _undoEventSignal; + + wxutil::PopupMenuPtr _popupMenu; + +public: + SelectionGroupPanel(wxWindow* parent); + ~SelectionGroupPanel() override; + +protected: + void onIdle() override; + + void onPanelActivated() override; + void onPanelDeactivated() override; + +private: + void connectListeners(); + void disconnectListeners(); + void connectToMapRoot(); + void disconnectFromMapRoot(); + + void queueRefresh(); + void refresh(); + + void populateWindow(); + void createButtons(); + void createPopupMenu(); + + void onMapEvent(IMap::MapEvent ev); + void onItemActivated(wxDataViewEvent& ev); + void onItemSelected(wxDataViewEvent& ev); + + void updateButtonSensitivity(); + + void removeSelectedFromGroup(); + void deleteSelectedGroup(); + + std::size_t getSelectedGroupId(); + bool hasSelectedMembers(); + bool hasMapRoot(); +}; + +} diff --git a/radiantcore/selection/algorithm/Group.cpp b/radiantcore/selection/algorithm/Group.cpp index ea6998ee93..3951c637f6 100644 --- a/radiantcore/selection/algorithm/Group.cpp +++ b/radiantcore/selection/algorithm/Group.cpp @@ -397,6 +397,11 @@ void ungroupSelectedCmd(const cmd::ArgumentList& args) ungroupSelected(); } +void ungroupSelectedRecursivelyCmd(const cmd::ArgumentList& args) +{ + ungroupSelectedRecursively(); +} + } // namespace algorithm } // namespace selection diff --git a/radiantcore/selection/algorithm/Group.h b/radiantcore/selection/algorithm/Group.h index 6947957812..3e30a45de5 100644 --- a/radiantcore/selection/algorithm/Group.h +++ b/radiantcore/selection/algorithm/Group.h @@ -128,6 +128,7 @@ namespace algorithm void deleteAllSelectionGroupsCmd(const cmd::ArgumentList& args); void groupSelectedCmd(const cmd::ArgumentList& args); void ungroupSelectedCmd(const cmd::ArgumentList& args); + void ungroupSelectedRecursivelyCmd(const cmd::ArgumentList& args); } // namespace algorithm } // namespace selection diff --git a/radiantcore/selection/group/SelectionGroupModule.cpp b/radiantcore/selection/group/SelectionGroupModule.cpp index d7f9352e72..40f834a6b1 100644 --- a/radiantcore/selection/group/SelectionGroupModule.cpp +++ b/radiantcore/selection/group/SelectionGroupModule.cpp @@ -39,6 +39,7 @@ class SelectionGroupModule: public ISelectionGroupModule { GlobalCommandSystem().addCommand("GroupSelected", algorithm::groupSelectedCmd); GlobalCommandSystem().addCommand("UngroupSelected", algorithm::ungroupSelectedCmd); + GlobalCommandSystem().addCommand("UngroupSelectedRecursively", algorithm::ungroupSelectedRecursivelyCmd); GlobalCommandSystem().addCommand("DeleteAllSelectionGroups", algorithm::deleteAllSelectionGroupsCmd); GlobalMapModule().signal_mapEvent().connect( diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 5c95aed43a..48cbdbd3ad 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -52,6 +52,7 @@ add_executable(drtest SceneStatistics.cpp SelectionAlgorithm.cpp Selection.cpp + SelectionGroup.cpp Settings.cpp SoundManager.cpp TerrainGenerator.cpp diff --git a/test/SelectionGroup.cpp b/test/SelectionGroup.cpp new file mode 100644 index 0000000000..979bbe1204 --- /dev/null +++ b/test/SelectionGroup.cpp @@ -0,0 +1,269 @@ +#include "RadiantTest.h" + +#include "iselection.h" +#include "iselectiongroup.h" +#include "imap.h" +#include "selectionlib.h" +#include "algorithm/Scene.h" +#include "scene/Group.h" +#include "command/ExecutionNotPossible.h" + +namespace test +{ + +using SelectionGroupTest = RadiantTest; + +TEST_F(SelectionGroupTest, GroupSelectedNodes) +{ + loadMap("selection_test2.map"); + + auto worldspawn = GlobalMapModule().findOrInsertWorldspawn(); + auto brush1 = algorithm::findFirstBrushWithMaterial(worldspawn, "textures/numbers/1"); + auto brush2 = algorithm::findFirstBrushWithMaterial(worldspawn, "textures/numbers/2"); + + Node_setSelected(brush1, true); + Node_setSelected(brush2, true); + + selection::groupSelected(); + + auto selectable1 = std::dynamic_pointer_cast(brush1); + auto selectable2 = std::dynamic_pointer_cast(brush2); + + EXPECT_TRUE(selectable1->isGroupMember()); + EXPECT_TRUE(selectable2->isGroupMember()); + EXPECT_EQ(selectable1->getMostRecentGroupId(), selectable2->getMostRecentGroupId()); +} + +TEST_F(SelectionGroupTest, UngroupSelectedNodes) +{ + loadMap("selection_test2.map"); + + auto worldspawn = GlobalMapModule().findOrInsertWorldspawn(); + auto brush1 = algorithm::findFirstBrushWithMaterial(worldspawn, "textures/numbers/1"); + auto brush2 = algorithm::findFirstBrushWithMaterial(worldspawn, "textures/numbers/2"); + + Node_setSelected(brush1, true); + Node_setSelected(brush2, true); + selection::groupSelected(); + + GlobalSelectionSystem().setSelectedAll(false); + + Node_setSelected(brush1, true); + Node_setSelected(brush2, true); + selection::ungroupSelected(); + + auto selectable1 = std::dynamic_pointer_cast(brush1); + auto selectable2 = std::dynamic_pointer_cast(brush2); + + EXPECT_FALSE(selectable1->isGroupMember()); + EXPECT_FALSE(selectable2->isGroupMember()); +} + +TEST_F(SelectionGroupTest, UngroupOnlyRemovesMostRecentGroup) +{ + loadMap("selection_test2.map"); + + auto worldspawn = GlobalMapModule().findOrInsertWorldspawn(); + auto brush1 = algorithm::findFirstBrushWithMaterial(worldspawn, "textures/numbers/1"); + auto brush2 = algorithm::findFirstBrushWithMaterial(worldspawn, "textures/numbers/2"); + auto brush3 = algorithm::findFirstBrushWithMaterial(worldspawn, "textures/numbers/3"); + + // Create inner group + Node_setSelected(brush1, true); + Node_setSelected(brush2, true); + selection::groupSelected(); + GlobalSelectionSystem().setSelectedAll(false); + + // Create outer group + Node_setSelected(brush1, true); + Node_setSelected(brush2, true); + Node_setSelected(brush3, true); + selection::groupSelected(); + GlobalSelectionSystem().setSelectedAll(false); + + auto selectable1 = std::dynamic_pointer_cast(brush1); + EXPECT_EQ(selectable1->getGroupIds().size(), 2); + + // Ungroup should only remove the outer group + Node_setSelected(brush1, true); + Node_setSelected(brush2, true); + Node_setSelected(brush3, true); + selection::ungroupSelected(); + + EXPECT_TRUE(selectable1->isGroupMember()) << "Inner group should still exist"; + EXPECT_EQ(selectable1->getGroupIds().size(), 1); +} + +TEST_F(SelectionGroupTest, UngroupRecursivelyRemovesAllGroups) +{ + loadMap("selection_test2.map"); + + auto worldspawn = GlobalMapModule().findOrInsertWorldspawn(); + auto brush1 = algorithm::findFirstBrushWithMaterial(worldspawn, "textures/numbers/1"); + auto brush2 = algorithm::findFirstBrushWithMaterial(worldspawn, "textures/numbers/2"); + auto brush3 = algorithm::findFirstBrushWithMaterial(worldspawn, "textures/numbers/3"); + + // Create inner group + Node_setSelected(brush1, true); + Node_setSelected(brush2, true); + selection::groupSelected(); + GlobalSelectionSystem().setSelectedAll(false); + + // Create outer group + Node_setSelected(brush1, true); + Node_setSelected(brush2, true); + Node_setSelected(brush3, true); + selection::groupSelected(); + GlobalSelectionSystem().setSelectedAll(false); + + auto selectable1 = std::dynamic_pointer_cast(brush1); + auto selectable2 = std::dynamic_pointer_cast(brush2); + auto selectable3 = std::dynamic_pointer_cast(brush3); + + EXPECT_EQ(selectable1->getGroupIds().size(), 2); + EXPECT_EQ(selectable2->getGroupIds().size(), 2); + EXPECT_EQ(selectable3->getGroupIds().size(), 1); + + // Recursive ungroup should remove all groups + Node_setSelected(brush1, true); + Node_setSelected(brush2, true); + Node_setSelected(brush3, true); + selection::ungroupSelectedRecursively(); + + EXPECT_FALSE(selectable1->isGroupMember()); + EXPECT_FALSE(selectable2->isGroupMember()); + EXPECT_FALSE(selectable3->isGroupMember()); + EXPECT_EQ(selectable1->getGroupIds().size(), 0); + EXPECT_EQ(selectable2->getGroupIds().size(), 0); + EXPECT_EQ(selectable3->getGroupIds().size(), 0); +} + +TEST_F(SelectionGroupTest, UngroupRecursivelyWithThreeLevels) +{ + loadMap("selection_test2.map"); + + auto worldspawn = GlobalMapModule().findOrInsertWorldspawn(); + auto brush1 = algorithm::findFirstBrushWithMaterial(worldspawn, "textures/numbers/1"); + auto brush2 = algorithm::findFirstBrushWithMaterial(worldspawn, "textures/numbers/2"); + auto brush3 = algorithm::findFirstBrushWithMaterial(worldspawn, "textures/numbers/3"); + + // Level 1 + Node_setSelected(brush1, true); + Node_setSelected(brush2, true); + selection::groupSelected(); + GlobalSelectionSystem().setSelectedAll(false); + + // Level 2 + Node_setSelected(brush2, true); + Node_setSelected(brush3, true); + selection::groupSelected(); + GlobalSelectionSystem().setSelectedAll(false); + + // Level 3 + Node_setSelected(brush1, true); + Node_setSelected(brush2, true); + Node_setSelected(brush3, true); + selection::groupSelected(); + GlobalSelectionSystem().setSelectedAll(false); + + auto selectable1 = std::dynamic_pointer_cast(brush1); + auto selectable2 = std::dynamic_pointer_cast(brush2); + auto selectable3 = std::dynamic_pointer_cast(brush3); + EXPECT_EQ(selectable1->getGroupIds().size(), 2); + EXPECT_EQ(selectable2->getGroupIds().size(), 3); + + // Recursive ungroup on all three + Node_setSelected(brush1, true); + Node_setSelected(brush2, true); + Node_setSelected(brush3, true); + selection::ungroupSelectedRecursively(); + + EXPECT_FALSE(selectable1->isGroupMember()); + EXPECT_FALSE(selectable2->isGroupMember()); + EXPECT_FALSE(selectable3->isGroupMember()); +} + +TEST_F(SelectionGroupTest, UngroupRecursivelyOnSubsetAffectsSharedGroups) +{ + loadMap("selection_test2.map"); + + auto worldspawn = GlobalMapModule().findOrInsertWorldspawn(); + auto brush1 = algorithm::findFirstBrushWithMaterial(worldspawn, "textures/numbers/1"); + auto brush2 = algorithm::findFirstBrushWithMaterial(worldspawn, "textures/numbers/2"); + auto brush3 = algorithm::findFirstBrushWithMaterial(worldspawn, "textures/numbers/3"); + + // Create group + Node_setSelected(brush1, true); + Node_setSelected(brush2, true); + Node_setSelected(brush3, true); + selection::groupSelected(); + GlobalSelectionSystem().setSelectedAll(false); + + // Recursive ungroup with only brush1 selected + // This should delete the group, which also removes brush2 and brush3 from it + Node_setSelected(brush1, true); + selection::ungroupSelectedRecursively(); + + auto selectable1 = std::dynamic_pointer_cast(brush1); + auto selectable2 = std::dynamic_pointer_cast(brush2); + auto selectable3 = std::dynamic_pointer_cast(brush3); + + EXPECT_FALSE(selectable1->isGroupMember()); + EXPECT_FALSE(selectable2->isGroupMember()); + EXPECT_FALSE(selectable3->isGroupMember()); +} + +TEST_F(SelectionGroupTest, UngroupRecursivelyThrowsWithNothingSelected) +{ + GlobalSelectionSystem().setSelectedAll(false); + + EXPECT_THROW(selection::ungroupSelectedRecursively(), cmd::ExecutionNotPossible); +} + +TEST_F(SelectionGroupTest, UngroupRecursivelyThrowsWithNoGroups) +{ + loadMap("selection_test2.map"); + + auto worldspawn = GlobalMapModule().findOrInsertWorldspawn(); + auto brush1 = algorithm::findFirstBrushWithMaterial(worldspawn, "textures/numbers/1"); + + Node_setSelected(brush1, true); + + EXPECT_THROW(selection::ungroupSelectedRecursively(), cmd::ExecutionNotPossible); +} + +TEST_F(SelectionGroupTest, RemoveNodeFromGroup) +{ + loadMap("selection_test2.map"); + + auto worldspawn = GlobalMapModule().findOrInsertWorldspawn(); + auto brush1 = algorithm::findFirstBrushWithMaterial(worldspawn, "textures/numbers/1"); + auto brush2 = algorithm::findFirstBrushWithMaterial(worldspawn, "textures/numbers/2"); + auto brush3 = algorithm::findFirstBrushWithMaterial(worldspawn, "textures/numbers/3"); + + Node_setSelected(brush1, true); + Node_setSelected(brush2, true); + Node_setSelected(brush3, true); + selection::groupSelected(); + GlobalSelectionSystem().setSelectedAll(false); + + auto selectable1 = std::dynamic_pointer_cast(brush1); + auto selectable2 = std::dynamic_pointer_cast(brush2); + auto selectable3 = std::dynamic_pointer_cast(brush3); + + auto groupId = selectable1->getMostRecentGroupId(); + auto& mgr = GlobalMapModule().getRoot()->getSelectionGroupManager(); + auto group = mgr.getSelectionGroup(groupId); + + EXPECT_EQ(group->size(), 3); + + // Remove one node from the group + group->removeNode(brush2); + + EXPECT_TRUE(selectable1->isGroupMember()); + EXPECT_FALSE(selectable2->isGroupMember()); + EXPECT_TRUE(selectable3->isGroupMember()); + EXPECT_EQ(group->size(), 2); +} + +} diff --git a/tools/msvc/DarkRadiant.vcxproj b/tools/msvc/DarkRadiant.vcxproj index b95c637691..1dafc8c3fb 100644 --- a/tools/msvc/DarkRadiant.vcxproj +++ b/tools/msvc/DarkRadiant.vcxproj @@ -351,6 +351,7 @@ + @@ -593,6 +594,8 @@ + + diff --git a/tools/msvc/DarkRadiant.vcxproj.filters b/tools/msvc/DarkRadiant.vcxproj.filters index 2a20f74165..f09a2aca2b 100644 --- a/tools/msvc/DarkRadiant.vcxproj.filters +++ b/tools/msvc/DarkRadiant.vcxproj.filters @@ -137,6 +137,9 @@ {44f6abf2-2b6d-46ce-8c41-291f862855f6} + + {8b3a1c4d-5e2f-4a7b-9c6d-3e8f1a2b4c5d} + {8d1f0555-13d0-4735-ad5e-26bdf8578bc6} @@ -499,6 +502,9 @@ src\ui\selectionset + + src\ui\selectiongroup + src\ui\modelexport @@ -1134,6 +1140,12 @@ src\ui\selectionset + + src\ui\selectiongroup + + + src\ui\selectiongroup + src\ui\modelexport diff --git a/tools/msvc/Tests/Tests.vcxproj b/tools/msvc/Tests/Tests.vcxproj index 698c92c0ad..e67692c57a 100644 --- a/tools/msvc/Tests/Tests.vcxproj +++ b/tools/msvc/Tests/Tests.vcxproj @@ -142,6 +142,7 @@ + diff --git a/tools/msvc/Tests/Tests.vcxproj.filters b/tools/msvc/Tests/Tests.vcxproj.filters index c81301cd44..ff2ab42afd 100644 --- a/tools/msvc/Tests/Tests.vcxproj.filters +++ b/tools/msvc/Tests/Tests.vcxproj.filters @@ -5,6 +5,7 @@ +