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 +} +} +}