Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added install/bitmaps/selection_csgintersect.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions install/menu.xml
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@
<menuItem name="makeRoom" caption="Make &amp;Room" command="CSGRoom" icon="selection_makeroom.png" />
<menuItem name="csgSubtract" caption="CSG &amp;Subtract" command="CSGSubtract" icon="selection_csgsubtract.png" />
<menuItem name="csgMerge" caption="CSG &amp;Merge" command="CSGMerge" icon="selection_csgmerge.png" />
<menuItem name="csgIntersect" caption="CSG &amp;Intersect" command="CSGIntersect" icon="selection_csgintersect.png" />
</subMenu>

<menuSeparator />
Expand Down
1 change: 1 addition & 0 deletions install/user.xml
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@
<separator/>
<toolbutton name="csgsubstract" action="CSGSubtract" tooltip="CSG Subtract" icon="selection_csgsubtract.png"/>
<toolbutton name="csgmerge" action="CSGMerge" tooltip="CSG Merge" icon="selection_csgmerge.png"/>
<toolbutton name="csgintersect" action="CSGIntersect" tooltip="CSG Intersect" icon="selection_csgintersect.png"/>
<toolbutton name="csghollow" action="CSGHollow" tooltip="Hollow" icon="selection_makehollow.png"/>
<toolbutton name="csgroom" action="CSGRoom" tooltip="Make Room" icon="selection_makeroom.png"/>
<separator/>
Expand Down
146 changes: 146 additions & 0 deletions radiantcore/brush/csg/CSG.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -492,13 +526,125 @@ 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<scene::INodePtr, BrushPtrVector> 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<BrushNode>(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;

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);
}
Expand Down
5 changes: 5 additions & 0 deletions radiantcore/brush/csg/CSG.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
117 changes: 117 additions & 0 deletions test/CSG.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -224,4 +224,121 @@ TEST_F(CsgTest, CSGMergeWithFuncStatic)
ASSERT_TRUE(walker.getEntityNode()->hasChildNodes());
}

TEST_F(CsgTest, CSGIntersectTwoOverlappingBrushes)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice, love to see tests being added, especially for new features👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will add more!

{
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);
}

}
54 changes: 54 additions & 0 deletions test/resources/tdm/maps/csg_intersect.map
Original file line number Diff line number Diff line change
@@ -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
}
}
}