diff --git a/install/bitmaps/selection_csgintersect.png b/install/bitmaps/selection_csgintersect.png
new file mode 100644
index 0000000000..76e0407b78
Binary files /dev/null and b/install/bitmaps/selection_csgintersect.png differ
diff --git a/install/menu.xml b/install/menu.xml
index 5daf7139f4..dca301d00b 100644
--- a/install/menu.xml
+++ b/install/menu.xml
@@ -217,6 +217,7 @@
+
diff --git a/install/user.xml b/install/user.xml
index 2eba382d34..02ff0f5354 100644
--- a/install/user.xml
+++ b/install/user.xml
@@ -267,6 +267,7 @@
+
diff --git a/radiantcore/brush/csg/CSG.cpp b/radiantcore/brush/csg/CSG.cpp
index 56af18f3a3..eafa63c0b5 100644
--- a/radiantcore/brush/csg/CSG.cpp
+++ b/radiantcore/brush/csg/CSG.cpp
@@ -166,6 +166,40 @@ bool Brush_subtract(const BrushNodePtr& brush, const Brush& other, BrushPtrVecto
return false;
}
+// Clips the given brush to be inside the clipper brush
+// Returns true if intersection exists and result is valid
+bool Brush_intersect(BrushNodePtr& brush, const Brush& clipper)
+{
+ if (!brush->getBrush().localAABB().intersects(clipper.localAABB()))
+ {
+ return false; // AABBs don't overlap - no intersection possible
+ }
+
+ for (Brush::const_iterator i(clipper.begin()); i != clipper.end(); ++i)
+ {
+ const Face& face = *(*i);
+
+ if (!face.contributes()) continue;
+
+ BrushSplitType split = brush->getBrush().classifyPlane(face.plane3());
+
+ if (split.counts[ePlaneFront] != 0 && split.counts[ePlaneBack] != 0)
+ {
+ // Brush spans this plane - clip to keep only inside (back) part
+ brush->getBrush().addFace(face);
+ }
+ else if (split.counts[ePlaneBack] == 0)
+ {
+ // All vertices in front/on plane = brush entirely outside clipper
+ return false;
+ }
+ // else: all vertices behind = already inside this half-space, continue
+ }
+
+ brush->getBrush().removeEmptyFaces();
+ return !brush->getBrush().empty();
+}
+
class SubtractBrushesFromUnselected :
public scene::NodeVisitor
{
@@ -492,6 +526,117 @@ void mergeSelectedBrushes(const cmd::ArgumentList& args)
SceneChangeNotify();
}
+void intersectSelectedBrushes(const cmd::ArgumentList& args)
+{
+ BrushPtrVector brushes = selection::algorithm::getSelectedBrushes();
+
+ if (brushes.empty())
+ {
+ throw cmd::ExecutionNotPossible(_("CSG Intersect: No brushes selected."));
+ }
+
+ if (brushes.size() < 2)
+ {
+ throw cmd::ExecutionNotPossible(_("CSG Intersect: At least 2 brushes must be selected."));
+ }
+
+ // Group the brushes by their parents
+ std::map brushesByEntity;
+
+ for (const auto& brushNode : brushes)
+ {
+ auto parent = brushNode->getParent();
+
+ if (brushesByEntity.find(parent) == brushesByEntity.end())
+ {
+ brushesByEntity[parent] = BrushPtrVector();
+ }
+
+ brushesByEntity[parent].emplace_back(brushNode);
+ }
+
+ bool selectionIsSuitable = false;
+ // At least one group should have more than two members
+ for (const auto& pair : brushesByEntity)
+ {
+ if (pair.second.size() >= 2)
+ {
+ selectionIsSuitable = true;
+ break;
+ }
+ }
+
+ if (!selectionIsSuitable)
+ {
+ throw cmd::ExecutionNotPossible(_("CSG Intersect: At least two brushes of the same entity have to be selected."));
+ }
+
+ UndoableCommand undo("brushIntersect");
+
+ bool anyIntersected = false;
+
+ for (const auto& pair : brushesByEntity)
+ {
+ if (pair.second.size() < 2)
+ {
+ continue;
+ }
+
+ const auto& group = pair.second;
+
+ // Take the last selected node as reference for layers and parent
+ auto lastBrush = group.back();
+ auto parent = lastBrush->getParent();
+
+ // Start with clone of first brush
+ BrushNodePtr result = std::dynamic_pointer_cast(group[0]->clone());
+
+ // Intersect with each subsequent brush
+ bool valid = true;
+ for (std::size_t i = 1; i < group.size() && valid; ++i)
+ {
+ valid = Brush_intersect(result, group[i]->getBrush());
+ }
+
+ if (!valid || result->getBrush().empty())
+ {
+ continue;
+ }
+
+ anyIntersected = true;
+
+ // Create new brush with result geometry
+ scene::INodePtr newBrush = GlobalBrushCreator().createBrush();
+
+ parent->addChildNode(newBrush);
+
+ // Move the new brush to the same layers as the source
+ newBrush->assignToLayers(lastBrush->getLayers());
+
+ result->getBrush().removeEmptyFaces();
+ ASSERT_MESSAGE(!result->getBrush().empty(), "brush left with no faces after intersect");
+
+ Node_getBrush(newBrush)->copy(result->getBrush());
+
+ // Remove the original brushes
+ for (const auto& brush : group)
+ {
+ scene::removeNodeFromParent(brush);
+ }
+
+ // Select the new brush
+ Node_setSelected(newBrush, true);
+ }
+
+ if (!anyIntersected)
+ {
+ throw cmd::ExecutionFailure(_("CSG Intersect: Failed - no valid intersection found."));
+ }
+
+ rMessage() << "CSG Intersect: Succeeded." << std::endl;
+ SceneChangeNotify();
+}
+
void registerCommands()
{
using selection::pred::haveBrush;
@@ -499,6 +644,7 @@ void registerCommands()
GlobalCommandSystem().addWithCheck("CSGSubtract", subtractBrushesFromUnselected,
haveBrush);
GlobalCommandSystem().addWithCheck("CSGMerge", mergeSelectedBrushes, haveBrush);
+ GlobalCommandSystem().addWithCheck("CSGIntersect", intersectSelectedBrushes, haveBrush);
GlobalCommandSystem().addWithCheck("CSGHollow", hollowSelectedBrushes, haveBrush);
GlobalCommandSystem().addWithCheck("CSGRoom", makeRoomForSelectedBrushes, haveBrush);
}
diff --git a/radiantcore/brush/csg/CSG.h b/radiantcore/brush/csg/CSG.h
index 14676c2895..eeff188918 100644
--- a/radiantcore/brush/csg/CSG.h
+++ b/radiantcore/brush/csg/CSG.h
@@ -44,6 +44,11 @@ void subtractBrushesFromUnselected(const cmd::ArgumentList& args);
*/
void mergeSelectedBrushes(const cmd::ArgumentList& args);
+/**
+ * Intersects selected brushes, keeping only the overlapping volume.
+ */
+void intersectSelectedBrushes(const cmd::ArgumentList& args);
+
/**
* Connect the various events to the functions in this namespace
*/
diff --git a/test/CSG.cpp b/test/CSG.cpp
index 88d0b4c3be..023848e708 100644
--- a/test/CSG.cpp
+++ b/test/CSG.cpp
@@ -224,4 +224,121 @@ TEST_F(CsgTest, CSGMergeWithFuncStatic)
ASSERT_TRUE(walker.getEntityNode()->hasChildNodes());
}
+TEST_F(CsgTest, CSGIntersectTwoOverlappingBrushes)
+{
+ loadMap("csg_intersect.map");
+
+ auto worldspawn = GlobalMapModule().getWorldspawn();
+
+ // Find the two overlapping brushes with materials "1" and "2"
+ auto firstBrush = algorithm::findFirstBrushWithMaterial(worldspawn, "1");
+ auto secondBrush = algorithm::findFirstBrushWithMaterial(worldspawn, "2");
+
+ ASSERT_TRUE(firstBrush != nullptr);
+ ASSERT_TRUE(secondBrush != nullptr);
+ ASSERT_TRUE(Node_getIBrush(firstBrush)->getNumFaces() == 6);
+ ASSERT_TRUE(Node_getIBrush(secondBrush)->getNumFaces() == 6);
+
+ // Select the brushes and intersect them
+ GlobalSelectionSystem().setSelectedAll(false);
+ Node_setSelected(firstBrush, true);
+ Node_setSelected(secondBrush, true);
+
+ // CSG intersect
+ GlobalCommandSystem().executeCommand("CSGIntersect");
+
+ // The two brushes should be gone, replaced by a new one
+ ASSERT_TRUE(firstBrush->getParent() == nullptr);
+ ASSERT_TRUE(secondBrush->getParent() == nullptr);
+
+ // The intersection should have created a new brush
+ // It should have materials from the first brush (since we started with that)
+ auto resultBrush = algorithm::findFirstBrushWithMaterial(worldspawn, "1");
+ ASSERT_TRUE(resultBrush != nullptr);
+
+ // The result should be a valid 6-sided brush (the intersection of two cubes is a cube)
+ ASSERT_TRUE(Node_getIBrush(resultBrush)->getNumFaces() == 6);
+}
+
+TEST_F(CsgTest, CSGIntersectNonOverlappingBrushes)
+{
+ loadMap("csg_intersect.map");
+
+ auto worldspawn = GlobalMapModule().getWorldspawn();
+
+ // Find brush "1" and the non-overlapping brush "3"
+ auto firstBrush = algorithm::findFirstBrushWithMaterial(worldspawn, "1");
+ auto nonOverlappingBrush = algorithm::findFirstBrushWithMaterial(worldspawn, "3");
+
+ ASSERT_TRUE(firstBrush != nullptr);
+ ASSERT_TRUE(nonOverlappingBrush != nullptr);
+
+ // Select the brushes
+ GlobalSelectionSystem().setSelectedAll(false);
+ Node_setSelected(firstBrush, true);
+ Node_setSelected(nonOverlappingBrush, true);
+
+ // CSG intersect - should fail silently because brushes don't overlap
+ GlobalCommandSystem().executeCommand("CSGIntersect");
+
+ // The original brushes should still exist (operation failed, no changes)
+ ASSERT_TRUE(firstBrush->getParent() != nullptr);
+ ASSERT_TRUE(nonOverlappingBrush->getParent() != nullptr);
+}
+
+TEST_F(CsgTest, CSGIntersectContainedBrush)
+{
+ loadMap("csg_intersect.map");
+
+ auto worldspawn = GlobalMapModule().getWorldspawn();
+
+ // Find brush "1" (large) and brush "4" (small, contained within "1")
+ auto largeBrush = algorithm::findFirstBrushWithMaterial(worldspawn, "1");
+ auto smallBrush = algorithm::findFirstBrushWithMaterial(worldspawn, "4");
+
+ ASSERT_TRUE(largeBrush != nullptr);
+ ASSERT_TRUE(smallBrush != nullptr);
+ ASSERT_TRUE(Node_getIBrush(largeBrush)->getNumFaces() == 6);
+ ASSERT_TRUE(Node_getIBrush(smallBrush)->getNumFaces() == 6);
+
+ // Select the brushes
+ GlobalSelectionSystem().setSelectedAll(false);
+ Node_setSelected(largeBrush, true);
+ Node_setSelected(smallBrush, true);
+
+ // CSG intersect
+ GlobalCommandSystem().executeCommand("CSGIntersect");
+
+ // The two brushes should be gone
+ ASSERT_TRUE(largeBrush->getParent() == nullptr);
+ ASSERT_TRUE(smallBrush->getParent() == nullptr);
+
+ // The result should be a brush equal in size to the small brush
+ // The result will have material "4" since those faces define the intersection volume
+ auto resultBrush = algorithm::findFirstBrushWithMaterial(worldspawn, "4");
+ ASSERT_TRUE(resultBrush != nullptr);
+ ASSERT_TRUE(Node_getIBrush(resultBrush)->getNumFaces() == 6);
+}
+
+TEST_F(CsgTest, CSGIntersectRequiresTwoBrushes)
+{
+ loadMap("csg_intersect.map");
+
+ auto worldspawn = GlobalMapModule().getWorldspawn();
+
+ // Find just one brush
+ auto brush = algorithm::findFirstBrushWithMaterial(worldspawn, "1");
+ ASSERT_TRUE(brush != nullptr);
+
+ // Select only one brush
+ GlobalSelectionSystem().setSelectedAll(false);
+ Node_setSelected(brush, true);
+
+ // CSG intersect - should fail silently because we need at least 2 brushes
+ GlobalCommandSystem().executeCommand("CSGIntersect");
+
+ // The original brush should still exist (operation failed, no changes)
+ ASSERT_TRUE(brush->getParent() != nullptr);
+}
+
}
diff --git a/test/resources/tdm/maps/csg_intersect.map b/test/resources/tdm/maps/csg_intersect.map
new file mode 100644
index 0000000000..c191fdcb4d
--- /dev/null
+++ b/test/resources/tdm/maps/csg_intersect.map
@@ -0,0 +1,54 @@
+Version 2
+// entity 0
+{
+"classname" "worldspawn"
+"description" "Test map for CSG Intersect"
+// primitive 0 - cube from 0,0,0 to 128,128,128 with material "1"
+{
+brushDef3
+{
+( 1 0 0 -128 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) "1" 0 0 0
+( -1 0 0 0 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) "1" 0 0 0
+( 0 1 0 -128 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) "1" 0 0 0
+( 0 -1 0 0 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) "1" 0 0 0
+( 0 0 1 -128 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) "1" 0 0 0
+( 0 0 -1 0 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) "1" 0 0 0
+}
+}
+// primitive 1 - cube from 64,64,64 to 192,192,192 with material "2"
+{
+brushDef3
+{
+( 1 0 0 -192 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) "2" 0 0 0
+( -1 0 0 64 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) "2" 0 0 0
+( 0 1 0 -192 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) "2" 0 0 0
+( 0 -1 0 64 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) "2" 0 0 0
+( 0 0 1 -192 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) "2" 0 0 0
+( 0 0 -1 64 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) "2" 0 0 0
+}
+}
+// primitive 2 - non-overlapping cube at 256,0,0 with material "3" for testing no-intersection case
+{
+brushDef3
+{
+( 1 0 0 -320 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) "3" 0 0 0
+( -1 0 0 256 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) "3" 0 0 0
+( 0 1 0 -64 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) "3" 0 0 0
+( 0 -1 0 0 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) "3" 0 0 0
+( 0 0 1 -64 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) "3" 0 0 0
+( 0 0 -1 0 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) "3" 0 0 0
+}
+}
+// primitive 3 - small cube fully inside brush 0 (material "4") - from 32,32,32 to 96,96,96
+{
+brushDef3
+{
+( 1 0 0 -96 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) "4" 0 0 0
+( -1 0 0 32 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) "4" 0 0 0
+( 0 1 0 -96 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) "4" 0 0 0
+( 0 -1 0 32 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) "4" 0 0 0
+( 0 0 1 -96 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) "4" 0 0 0
+( 0 0 -1 32 ) ( ( 0.03125 0 0 ) ( 0 0.03125 0 ) ) "4" 0 0 0
+}
+}
+}