diff --git a/install/menu.xml b/install/menu.xml
index 340c62c7b1..1d4ca6d45f 100644
--- a/install/menu.xml
+++ b/install/menu.xml
@@ -233,6 +233,7 @@
+
diff --git a/radiant/CMakeLists.txt b/radiant/CMakeLists.txt
index ec361e59b9..78f116196f 100644
--- a/radiant/CMakeLists.txt
+++ b/radiant/CMakeLists.txt
@@ -30,6 +30,7 @@ add_executable(darkradiant
ui/aas/RenderableAasFile.cpp
ui/about/AboutDialog.cpp
ui/array/ArrayDialog.cpp
+ ui/brush/CreateTrimDialog.cpp
ui/brush/FindBrush.cpp
ui/brush/QuerySidesDialog.cpp
ui/commandlist/CommandList.cpp
diff --git a/radiant/ui/UserInterfaceModule.cpp b/radiant/ui/UserInterfaceModule.cpp
index b2c02119f0..19f8e37749 100644
--- a/radiant/ui/UserInterfaceModule.cpp
+++ b/radiant/ui/UserInterfaceModule.cpp
@@ -65,6 +65,7 @@
#include "ui/terrain/TerrainGeneratorDialog.h"
#include "ui/selectionset/SelectionSetToolmenu.h"
#include "ui/brush/QuerySidesDialog.h"
+#include "ui/brush/CreateTrimDialog.h"
#include "ui/brush/FindBrush.h"
#include "ui/array/ArrayDialog.h"
#include "ui/mousetool/RegistrationHelper.h"
@@ -541,6 +542,7 @@ void UserInterfaceModule::registerUICommands()
GlobalCommandSystem().addCommand("ExportCollisionModelDialog", ExportCollisionModelDialog::Show);
GlobalCommandSystem().addWithCheck("QueryBrushPrefabSidesDialog", QuerySidesDialog::Show,
selection::pred::haveBrush, {cmd::ARGTYPE_INT});
+ GlobalCommandSystem().addCommand("CreateTrimDialog", CreateTrimDialog::CreateTrimCmd);
// Set up the CloneSelection command to react on key up events only
GlobalEventManager().addCommand("CloneSelection", "CloneSelection", true); // react on keyUp
diff --git a/radiant/ui/brush/CreateTrimDialog.cpp b/radiant/ui/brush/CreateTrimDialog.cpp
new file mode 100644
index 0000000000..adc740a8c7
--- /dev/null
+++ b/radiant/ui/brush/CreateTrimDialog.cpp
@@ -0,0 +1,102 @@
+#include "CreateTrimDialog.h"
+
+#include "i18n.h"
+#include "icommandsystem.h"
+#include "iselection.h"
+#include "string/convert.h"
+#include "selectionlib.h"
+#include "wxutil/dialog/MessageBox.h"
+
+namespace
+{
+ const char* WINDOW_TITLE = N_("Create Trim");
+ const char* LABEL_HEIGHT = N_("Trim Height:");
+ const char* LABEL_DEPTH = N_("Trim Depth:");
+ const char* LABEL_FIT_TO = N_("Fit To:");
+ const char* LABEL_MITERED = N_("45-degree mitered ends");
+
+ const double DEFAULT_HEIGHT = 16;
+ const double DEFAULT_DEPTH = 1;
+
+ const char* FIT_BOTTOM = N_("Bottom");
+ const char* FIT_TOP = N_("Top");
+ const char* FIT_LEFT = N_("Left");
+ const char* FIT_RIGHT = N_("Right");
+}
+
+namespace ui {
+
+CreateTrimDialog::CreateTrimDialog() :
+ wxutil::Dialog(_(WINDOW_TITLE))
+{
+ _heightHandle = addSpinButton(_(LABEL_HEIGHT), 1, 4096, 1, 0);
+ _depthHandle = addSpinButton(_(LABEL_DEPTH), 1, 4096, 1, 0);
+
+ ui::IDialog::ComboBoxOptions fitOptions;
+ fitOptions.push_back(_(FIT_BOTTOM));
+ fitOptions.push_back(_(FIT_TOP));
+ fitOptions.push_back(_(FIT_LEFT));
+ fitOptions.push_back(_(FIT_RIGHT));
+ _fitToHandle = addComboBox(_(LABEL_FIT_TO), fitOptions);
+
+ _miteredHandle = addCheckbox(_(LABEL_MITERED));
+
+ setElementValue(_heightHandle, string::to_string(DEFAULT_HEIGHT));
+ setElementValue(_depthHandle, string::to_string(DEFAULT_DEPTH));
+ setElementValue(_fitToHandle, _(FIT_BOTTOM));
+ setElementValue(_miteredHandle, "0");
+}
+
+bool CreateTrimDialog::QueryTrimParams(TrimParams& params)
+{
+ auto* dialog = new CreateTrimDialog;
+
+ IDialog::Result result = dialog->run();
+
+ if (result == IDialog::RESULT_OK)
+ {
+ params.height = string::convert(dialog->getElementValue(dialog->_heightHandle));
+ params.depth = string::convert(dialog->getElementValue(dialog->_depthHandle));
+
+ std::string fitToStr = dialog->getElementValue(dialog->_fitToHandle);
+
+ if (fitToStr == _(FIT_TOP))
+ params.fitTo = FitTo::Top;
+ else if (fitToStr == _(FIT_LEFT))
+ params.fitTo = FitTo::Left;
+ else if (fitToStr == _(FIT_RIGHT))
+ params.fitTo = FitTo::Right;
+ else
+ params.fitTo = FitTo::Bottom;
+
+ params.mitered = (dialog->getElementValue(dialog->_miteredHandle) == "1");
+
+ return true;
+ }
+
+ return false;
+}
+
+void CreateTrimDialog::CreateTrimCmd(const cmd::ArgumentList& args)
+{
+ if (GlobalSelectionSystem().getSelectionInfo().componentCount == 0)
+ {
+ wxutil::Messagebox::ShowError(_("Cannot create trim. No faces selected."));
+ return;
+ }
+
+ TrimParams params;
+
+ if (QueryTrimParams(params))
+ {
+ cmd::ArgumentList trimArgs;
+ trimArgs.push_back(params.height);
+ trimArgs.push_back(params.depth);
+ trimArgs.push_back(static_cast(params.fitTo));
+ trimArgs.push_back(params.mitered ? 1 : 0);
+
+ GlobalCommandSystem().executeCommand("CreateTrimForFaces", trimArgs);
+ }
+}
+
+} // namespace
diff --git a/radiant/ui/brush/CreateTrimDialog.h b/radiant/ui/brush/CreateTrimDialog.h
new file mode 100644
index 0000000000..e57e203b82
--- /dev/null
+++ b/radiant/ui/brush/CreateTrimDialog.h
@@ -0,0 +1,37 @@
+#pragma once
+
+#include "icommandsystem.h"
+#include "wxutil/dialog/Dialog.h"
+
+namespace ui
+{
+
+class CreateTrimDialog :
+ public wxutil::Dialog
+{
+public:
+ enum class FitTo { Bottom, Top, Left, Right };
+
+ struct TrimParams
+ {
+ double height;
+ double depth;
+ FitTo fitTo;
+ bool mitered;
+ };
+
+private:
+ Handle _heightHandle;
+ Handle _depthHandle;
+ Handle _fitToHandle;
+ Handle _miteredHandle;
+
+public:
+ CreateTrimDialog();
+
+ static bool QueryTrimParams(TrimParams& params);
+
+ static void CreateTrimCmd(const cmd::ArgumentList& args);
+};
+
+} // namespace ui
diff --git a/radiantcore/selection/algorithm/General.cpp b/radiantcore/selection/algorithm/General.cpp
index ba5d9421a0..3d9079178c 100644
--- a/radiantcore/selection/algorithm/General.cpp
+++ b/radiantcore/selection/algorithm/General.cpp
@@ -1015,6 +1015,12 @@ void registerCommands()
[] { return !FaceInstance::Selection().empty(); }
);
+ GlobalCommandSystem().addWithCheck(
+ "CreateTrimForFaces", createTrimForSelectedFaces,
+ [] { return !FaceInstance::Selection().empty(); },
+ { cmd::ARGTYPE_DOUBLE, cmd::ARGTYPE_DOUBLE, cmd::ARGTYPE_INT, cmd::ARGTYPE_INT }
+ );
+
GlobalCommandSystem().addCommand("Copy", clipboard::copy);
GlobalCommandSystem().addCommand("Cut", clipboard::cut);
GlobalCommandSystem().addCommand("Paste", clipboard::paste);
diff --git a/radiantcore/selection/algorithm/Primitives.cpp b/radiantcore/selection/algorithm/Primitives.cpp
index c3a02856ab..7b5e46b528 100644
--- a/radiantcore/selection/algorithm/Primitives.cpp
+++ b/radiantcore/selection/algorithm/Primitives.cpp
@@ -1,6 +1,7 @@
#include "Primitives.h"
#include
+#include
#include "i18n.h"
#include "igroupnode.h"
@@ -683,6 +684,253 @@ void brushSetDetailFlag(const cmd::ArgumentList& args)
}
}
+void createTrimForSelectedFaces(const cmd::ArgumentList& args)
+{
+ if (args.size() < 4)
+ {
+ rError() << "Usage: CreateTrimForFaces " << std::endl;
+ return;
+ }
+
+ double height = args[0].getDouble();
+ double depth = args[1].getDouble();
+ int fitToInt = args[2].getInt();
+ bool mitered = args[3].getInt() != 0;
+
+ if (FaceInstance::Selection().empty())
+ {
+ throw cmd::ExecutionNotPossible(_("No faces selected."));
+ return;
+ }
+
+ if (height <= 0 || depth <= 0)
+ {
+ throw cmd::ExecutionFailure(_("Height and depth must be positive."));
+ return;
+ }
+
+ UndoableCommand cmd("createTrimForSelectedFaces");
+
+ // Collect face instances first (iterating while modifying selection is unsafe)
+ std::vector faceInstances;
+ for (FaceInstance* fi : FaceInstance::Selection())
+ {
+ if (fi->getFace().contributes() && fi->getFace().getWinding().size() == 4)
+ {
+ faceInstances.push_back(fi);
+ }
+ }
+
+ int unsuitableCount = static_cast(FaceInstance::Selection().size()) - static_cast(faceInstances.size());
+
+ float naturalScale = registry::getValue("user/ui/textures/defaultTextureScale");
+ ShiftScaleRotation ssr;
+ ssr.scale[0] = naturalScale;
+ ssr.scale[1] = naturalScale;
+
+ for (FaceInstance* fi : faceInstances)
+ {
+ Face& face = fi->getFace();
+ const Winding& winding = face.getWinding();
+ const Plane3& plane = face.getPlane3();
+ Vector3 N = plane.normal();
+
+ // Determine the "up" direction on the face plane
+ Vector3 worldUp(0, 0, 1);
+ Vector3 faceUp = worldUp - N * N.dot(worldUp);
+
+ if (faceUp.getLengthSquared() < 0.001)
+ {
+ // Face is nearly horizontal, use world Y as up reference
+ worldUp = Vector3(0, 1, 0);
+ faceUp = worldUp - N * N.dot(worldUp);
+ }
+ faceUp.normalise();
+
+ Vector3 faceRight = N.cross(faceUp).getNormalised();
+
+ // Project winding vertices onto face-local axes
+ double uMin = std::numeric_limits::max();
+ double uMax = -std::numeric_limits::max();
+ double rMin = std::numeric_limits::max();
+ double rMax = -std::numeric_limits::max();
+
+ for (std::size_t i = 0; i < winding.size(); i++)
+ {
+ double u = faceUp.dot(winding[i].vertex);
+ double r = faceRight.dot(winding[i].vertex);
+ uMin = std::min(uMin, u);
+ uMax = std::max(uMax, u);
+ rMin = std::min(rMin, r);
+ rMax = std::max(rMax, r);
+ }
+
+ // Compute trim bounds in face-local coordinates
+ double trimUMin, trimUMax, trimRMin, trimRMax;
+
+ switch (fitToInt)
+ {
+ case 0: // Bottom
+ trimUMin = uMin; trimUMax = uMin + height;
+ trimRMin = rMin; trimRMax = rMax;
+ break;
+ case 1: // Top
+ trimUMin = uMax - height; trimUMax = uMax;
+ trimRMin = rMin; trimRMax = rMax;
+ break;
+ case 2: // Left
+ trimUMin = uMin; trimUMax = uMax;
+ trimRMin = rMin; trimRMax = rMin + height;
+ break;
+ case 3: // Right
+ trimUMin = uMin; trimUMax = uMax;
+ trimRMin = rMax - height; trimRMax = rMax;
+ break;
+ default:
+ trimUMin = uMin; trimUMax = uMin + height;
+ trimRMin = rMin; trimRMax = rMax;
+ break;
+ }
+
+ // nBase: the face plane distance along the normal
+ double nBase = N.dot(winding[0].vertex);
+
+ // Helper to convert face-local coords to world coords
+ auto corner = [&](double n, double u, double r) -> Vector3
+ {
+ return N * n + faceUp * u + faceRight * r;
+ };
+
+ double n0 = nBase;
+ double n1 = nBase + depth;
+
+ // Determine which direction the trim extends along and which ends get mitered
+ // For top/bottom: trim runs along R, miters at R ends
+ // For left/right: trim runs along U, miters at U ends
+ bool miterREnds = (fitToInt == 0 || fitToInt == 1); // bottom/top
+ bool miterUEnds = (fitToInt == 2 || fitToInt == 3); // left/right
+
+ // Create the brush node
+ scene::INodePtr brushNode = GlobalBrushCreator().createBrush();
+ Brush* brush = Node_getBrush(brushNode);
+
+ std::string shader = face.getShader();
+ TextureProjection projection;
+
+ brush->clear();
+ brush->reserve(6);
+
+ // Front face (+N direction, outer face at depth from wall)
+ // addPlane points chosen so (p1-p2)x(p0-p2) = +N
+ brush->addPlane(
+ corner(n1, trimUMax, trimRMin),
+ corner(n1, trimUMin, trimRMin),
+ corner(n1, trimUMin, trimRMax),
+ shader, projection
+ );
+
+ // Back face (-N direction, against the wall)
+ brush->addPlane(
+ corner(n0, trimUMax, trimRMax),
+ corner(n0, trimUMin, trimRMax),
+ corner(n0, trimUMin, trimRMin),
+ shader, projection
+ );
+
+ // Top face (+U direction)
+ brush->addPlane(
+ corner(n1, trimUMax, trimRMax),
+ corner(n0, trimUMax, trimRMax),
+ corner(n0, trimUMax, trimRMin),
+ shader, projection
+ );
+
+ // Bottom face (-U direction)
+ brush->addPlane(
+ corner(n1, trimUMin, trimRMin),
+ corner(n0, trimUMin, trimRMin),
+ corner(n0, trimUMin, trimRMax),
+ shader, projection
+ );
+
+ if (mitered && miterREnds)
+ {
+ // Left miter: back face (wall side) at full extent,
+ // front face (outer) shortened by depth
+ brush->addPlane(
+ corner(n1, trimUMin, trimRMin + depth),
+ corner(n0, trimUMax, trimRMin),
+ corner(n0, trimUMin, trimRMin),
+ shader, projection
+ );
+
+ // Right miter: same principle on the right end
+ brush->addPlane(
+ corner(n0, trimUMin, trimRMax),
+ corner(n0, trimUMax, trimRMax),
+ corner(n1, trimUMin, trimRMax - depth),
+ shader, projection
+ );
+ }
+ else if (mitered && miterUEnds)
+ {
+ // Bottom miter: back at full extent, front shortened
+ brush->addPlane(
+ corner(n0, trimUMin, trimRMin),
+ corner(n0, trimUMin, trimRMax),
+ corner(n1, trimUMin + depth, trimRMin),
+ shader, projection
+ );
+
+ // Top miter: same principle on the top end
+ brush->addPlane(
+ corner(n0, trimUMax, trimRMax),
+ corner(n0, trimUMax, trimRMin),
+ corner(n1, trimUMax - depth, trimRMin),
+ shader, projection
+ );
+ }
+ else
+ {
+ // Standard flat end faces
+
+ // Right face (+R direction)
+ brush->addPlane(
+ corner(n1, trimUMax, trimRMax),
+ corner(n1, trimUMin, trimRMax),
+ corner(n0, trimUMin, trimRMax),
+ shader, projection
+ );
+
+ // Left face (-R direction)
+ brush->addPlane(
+ corner(n0, trimUMax, trimRMin),
+ corner(n0, trimUMin, trimRMin),
+ corner(n1, trimUMin, trimRMin),
+ shader, projection
+ );
+ }
+
+ scene::INodePtr worldSpawnNode = GlobalMapModule().findOrInsertWorldspawn();
+ worldSpawnNode->addChildNode(brushNode);
+
+ // Apply natural texturing
+ brush->forEachFace([&](Face& f) { f.setShiftScaleRotation(ssr); });
+
+ fi->setSelected(selection::ComponentSelectionMode::Face, false);
+ Node_setSelected(brushNode, true);
+ }
+
+ SceneChangeNotify();
+
+ if (unsuitableCount > 0)
+ {
+ radiant::NotificationMessage::SendInformation(
+ fmt::format(_("{0:d} faces were not suitable (must have exactly 4 vertices)."), unsuitableCount)
+ );
+ }
+}
+
} // namespace algorithm
} // namespace selection
diff --git a/radiantcore/selection/algorithm/Primitives.h b/radiantcore/selection/algorithm/Primitives.h
index a681fead8f..f638d9c6c9 100644
--- a/radiantcore/selection/algorithm/Primitives.h
+++ b/radiantcore/selection/algorithm/Primitives.h
@@ -83,6 +83,12 @@ namespace selection {
*/
void createDecalsForSelectedFaces();
+ /**
+ * Creates a trim brush for each selected face instance.
+ * Args: height, depth, fitTo (0=bottom,1=top,2=left,3=right), mitered (0/1)
+ */
+ void createTrimForSelectedFaces(const cmd::ArgumentList& args);
+
/**
* greebo: Applies the visportal/nodraw texture combo to the selected brushes.
*/
diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt
index 5c95aed43a..09dffc1b78 100644
--- a/test/CMakeLists.txt
+++ b/test/CMakeLists.txt
@@ -59,6 +59,7 @@ add_executable(drtest
TestOrthoViewManager.cpp
TextureTool.cpp
Transformation.cpp
+ TrimTool.cpp
UndoRedo.cpp
VFS.cpp
WorldspawnColour.cpp
diff --git a/test/TrimTool.cpp b/test/TrimTool.cpp
new file mode 100644
index 0000000000..321054a86c
--- /dev/null
+++ b/test/TrimTool.cpp
@@ -0,0 +1,310 @@
+#include "RadiantTest.h"
+
+#include "imap.h"
+#include "ibrush.h"
+#include "icommandsystem.h"
+#include "iselection.h"
+
+#include "render/View.h"
+#include "selection/SelectionVolume.h"
+
+#include "algorithm/Primitives.h"
+#include "algorithm/Scene.h"
+#include "algorithm/View.h"
+#include "testutil/CommandFailureHelper.h"
+
+namespace test
+{
+
+using TrimToolTest = RadiantTest;
+
+namespace
+{
+
+// Selects the top face of the given brush by constructing a camera view looking down
+void selectTopFace(const scene::INodePtr& brushNode)
+{
+ render::View view(true);
+ algorithm::constructCameraView(view, brushNode->worldAABB(), Vector3(0, 0, -1), Vector3(-90, 0, 0));
+
+ auto rectangle = selection::Rectangle::ConstructFromPoint(
+ Vector2(0, 0), Vector2(8.0 / algorithm::DeviceWidth, 8.0 / algorithm::DeviceHeight));
+ ConstructSelectionTest(view, rectangle);
+
+ SelectionVolume test(view);
+ GlobalSelectionSystem().selectPoint(test, selection::SelectionSystem::eToggle, true);
+}
+
+// Selects a vertical face of the given brush
+void selectSideFace(const scene::INodePtr& brushNode)
+{
+ render::View view(true);
+ algorithm::constructCameraView(view, brushNode->worldAABB(), Vector3(-1, 0, 0), Vector3(0, 180, 0));
+
+ auto rectangle = selection::Rectangle::ConstructFromPoint(
+ Vector2(0, 0), Vector2(8.0 / algorithm::DeviceWidth, 8.0 / algorithm::DeviceHeight));
+ ConstructSelectionTest(view, rectangle);
+
+ SelectionVolume test(view);
+ GlobalSelectionSystem().selectPoint(test, selection::SelectionSystem::eToggle, true);
+}
+
+// Counts the brush children of the given parent node
+std::size_t countBrushes(const scene::INodePtr& parent)
+{
+ return algorithm::getChildCount(parent, [](const scene::INodePtr& node)
+ {
+ return Node_isBrush(node);
+ });
+}
+
+}
+
+TEST_F(TrimToolTest, CreateTrimRequiresArguments)
+{
+ auto worldspawn = GlobalMapModule().findOrInsertWorldspawn();
+ auto brush = algorithm::createCubicBrush(worldspawn, { 0, 0, 0 });
+ selectTopFace(brush);
+ ASSERT_EQ(GlobalSelectionSystem().getSelectedFaceCount(), 1);
+
+ auto brushCountBefore = countBrushes(worldspawn);
+
+ GlobalCommandSystem().executeCommand("CreateTrimForFaces", cmd::ArgumentList{});
+
+ EXPECT_EQ(countBrushes(worldspawn), brushCountBefore) << "No trim brush should be created with missing arguments";
+}
+
+TEST_F(TrimToolTest, CreateTrimRequiresFaceSelection)
+{
+ auto worldspawn = GlobalMapModule().findOrInsertWorldspawn();
+ algorithm::createCubicBrush(worldspawn, { 0, 0, 0 });
+
+ CommandFailureHelper helper;
+
+ GlobalCommandSystem().executeCommand("CreateTrimForFaces", { 8.0, 4.0, 0, 0 });
+
+ EXPECT_TRUE(helper.executionHasNotBeenPossible()) << "Should fail when no faces are selected";
+}
+
+TEST_F(TrimToolTest, CreateTrimRejectsNonPositiveHeight)
+{
+ auto worldspawn = GlobalMapModule().findOrInsertWorldspawn();
+ auto brush = algorithm::createCubicBrush(worldspawn, { 0, 0, 0 });
+ selectTopFace(brush);
+ ASSERT_EQ(GlobalSelectionSystem().getSelectedFaceCount(), 1);
+
+ CommandFailureHelper helper;
+
+ GlobalCommandSystem().executeCommand("CreateTrimForFaces", { 0.0, 4.0, 0, 0 });
+
+ EXPECT_TRUE(helper.messageReceived()) << "Should fail with non-positive height";
+}
+
+TEST_F(TrimToolTest, CreateTrimRejectsNonPositiveDepth)
+{
+ auto worldspawn = GlobalMapModule().findOrInsertWorldspawn();
+ auto brush = algorithm::createCubicBrush(worldspawn, { 0, 0, 0 });
+ selectTopFace(brush);
+ ASSERT_EQ(GlobalSelectionSystem().getSelectedFaceCount(), 1);
+
+ CommandFailureHelper helper;
+
+ GlobalCommandSystem().executeCommand("CreateTrimForFaces", { 8.0, -1.0, 0, 0 });
+
+ EXPECT_TRUE(helper.messageReceived()) << "Should fail with non-positive depth";
+}
+
+TEST_F(TrimToolTest, CreateTrimBottomFit)
+{
+ auto worldspawn = GlobalMapModule().findOrInsertWorldspawn();
+ auto brush = algorithm::createCubicBrush(worldspawn, { 0, 0, 0 });
+
+ auto brushCountBefore = countBrushes(worldspawn);
+
+ selectTopFace(brush);
+ ASSERT_EQ(GlobalSelectionSystem().getSelectedFaceCount(), 1);
+
+ // fitTo=0 (bottom), mitered=0
+ GlobalCommandSystem().executeCommand("CreateTrimForFaces", { 16.0, 4.0, 0, 0 });
+
+ EXPECT_EQ(countBrushes(worldspawn), brushCountBefore + 1) << "One trim brush should have been created";
+
+ // The face should have been deselected
+ EXPECT_EQ(GlobalSelectionSystem().getSelectedFaceCount(), 0);
+
+ // The new trim brush should be selected
+ EXPECT_EQ(GlobalSelectionSystem().countSelected(), 1);
+
+ // Verify the trim brush has 6 faces (a valid cuboid)
+ auto selectedNode = GlobalSelectionSystem().ultimateSelected();
+ auto* trimBrush = Node_getIBrush(selectedNode);
+ ASSERT_NE(trimBrush, nullptr);
+ EXPECT_EQ(trimBrush->getNumFaces(), 6);
+}
+
+TEST_F(TrimToolTest, CreateTrimTopFit)
+{
+ auto worldspawn = GlobalMapModule().findOrInsertWorldspawn();
+ auto brush = algorithm::createCubicBrush(worldspawn, { 0, 0, 0 });
+
+ auto brushCountBefore = countBrushes(worldspawn);
+
+ selectTopFace(brush);
+ ASSERT_EQ(GlobalSelectionSystem().getSelectedFaceCount(), 1);
+
+ // fitTo=1 (top), mitered=0
+ GlobalCommandSystem().executeCommand("CreateTrimForFaces", { 16.0, 4.0, 1, 0 });
+
+ EXPECT_EQ(countBrushes(worldspawn), brushCountBefore + 1);
+
+ auto selectedNode = GlobalSelectionSystem().ultimateSelected();
+ auto* trimBrush = Node_getIBrush(selectedNode);
+ ASSERT_NE(trimBrush, nullptr);
+ EXPECT_EQ(trimBrush->getNumFaces(), 6);
+}
+
+TEST_F(TrimToolTest, CreateTrimLeftFit)
+{
+ auto worldspawn = GlobalMapModule().findOrInsertWorldspawn();
+ auto brush = algorithm::createCubicBrush(worldspawn, { 0, 0, 0 });
+
+ auto brushCountBefore = countBrushes(worldspawn);
+
+ selectTopFace(brush);
+ ASSERT_EQ(GlobalSelectionSystem().getSelectedFaceCount(), 1);
+
+ // fitTo=2 (left), mitered=0
+ GlobalCommandSystem().executeCommand("CreateTrimForFaces", { 16.0, 4.0, 2, 0 });
+
+ EXPECT_EQ(countBrushes(worldspawn), brushCountBefore + 1);
+
+ auto selectedNode = GlobalSelectionSystem().ultimateSelected();
+ auto* trimBrush = Node_getIBrush(selectedNode);
+ ASSERT_NE(trimBrush, nullptr);
+ EXPECT_EQ(trimBrush->getNumFaces(), 6);
+}
+
+TEST_F(TrimToolTest, CreateTrimRightFit)
+{
+ auto worldspawn = GlobalMapModule().findOrInsertWorldspawn();
+ auto brush = algorithm::createCubicBrush(worldspawn, { 0, 0, 0 });
+
+ auto brushCountBefore = countBrushes(worldspawn);
+
+ selectTopFace(brush);
+ ASSERT_EQ(GlobalSelectionSystem().getSelectedFaceCount(), 1);
+
+ // fitTo=3 (right), mitered=0
+ GlobalCommandSystem().executeCommand("CreateTrimForFaces", { 16.0, 4.0, 3, 0 });
+
+ EXPECT_EQ(countBrushes(worldspawn), brushCountBefore + 1);
+
+ auto selectedNode = GlobalSelectionSystem().ultimateSelected();
+ auto* trimBrush = Node_getIBrush(selectedNode);
+ ASSERT_NE(trimBrush, nullptr);
+ EXPECT_EQ(trimBrush->getNumFaces(), 6);
+}
+
+TEST_F(TrimToolTest, CreateTrimMiteredBottom)
+{
+ auto worldspawn = GlobalMapModule().findOrInsertWorldspawn();
+ auto brush = algorithm::createCubicBrush(worldspawn, { 0, 0, 0 });
+
+ auto brushCountBefore = countBrushes(worldspawn);
+
+ selectTopFace(brush);
+ ASSERT_EQ(GlobalSelectionSystem().getSelectedFaceCount(), 1);
+
+ // fitTo=0 (bottom), mitered=1
+ GlobalCommandSystem().executeCommand("CreateTrimForFaces", { 16.0, 4.0, 0, 1 });
+
+ EXPECT_EQ(countBrushes(worldspawn), brushCountBefore + 1);
+
+ auto selectedNode = GlobalSelectionSystem().ultimateSelected();
+ auto* trimBrush = Node_getIBrush(selectedNode);
+ ASSERT_NE(trimBrush, nullptr);
+ EXPECT_EQ(trimBrush->getNumFaces(), 6);
+}
+
+TEST_F(TrimToolTest, CreateTrimMiteredLeft)
+{
+ auto worldspawn = GlobalMapModule().findOrInsertWorldspawn();
+ auto brush = algorithm::createCubicBrush(worldspawn, { 0, 0, 0 });
+
+ auto brushCountBefore = countBrushes(worldspawn);
+
+ selectTopFace(brush);
+ ASSERT_EQ(GlobalSelectionSystem().getSelectedFaceCount(), 1);
+
+ // fitTo=2 (left), mitered=1
+ GlobalCommandSystem().executeCommand("CreateTrimForFaces", { 16.0, 4.0, 2, 1 });
+
+ EXPECT_EQ(countBrushes(worldspawn), brushCountBefore + 1);
+
+ auto selectedNode = GlobalSelectionSystem().ultimateSelected();
+ auto* trimBrush = Node_getIBrush(selectedNode);
+ ASSERT_NE(trimBrush, nullptr);
+ EXPECT_EQ(trimBrush->getNumFaces(), 6);
+}
+
+TEST_F(TrimToolTest, CreateTrimOnVerticalFace)
+{
+ auto worldspawn = GlobalMapModule().findOrInsertWorldspawn();
+ auto brush = algorithm::createCubicBrush(worldspawn, { 0, 0, 0 });
+
+ auto brushCountBefore = countBrushes(worldspawn);
+
+ selectSideFace(brush);
+ ASSERT_EQ(GlobalSelectionSystem().getSelectedFaceCount(), 1);
+
+ // fitTo=0 (bottom), mitered=0
+ GlobalCommandSystem().executeCommand("CreateTrimForFaces", { 16.0, 4.0, 0, 0 });
+
+ EXPECT_EQ(countBrushes(worldspawn), brushCountBefore + 1);
+
+ auto selectedNode = GlobalSelectionSystem().ultimateSelected();
+ auto* trimBrush = Node_getIBrush(selectedNode);
+ ASSERT_NE(trimBrush, nullptr);
+ EXPECT_EQ(trimBrush->getNumFaces(), 6);
+}
+
+TEST_F(TrimToolTest, CreateTrimIsAddedToWorldspawn)
+{
+ auto worldspawn = GlobalMapModule().findOrInsertWorldspawn();
+ auto brush = algorithm::createCubicBrush(worldspawn, { 0, 0, 0 });
+
+ selectTopFace(brush);
+ ASSERT_EQ(GlobalSelectionSystem().getSelectedFaceCount(), 1);
+
+ GlobalCommandSystem().executeCommand("CreateTrimForFaces", { 16.0, 4.0, 0, 0 });
+
+ // The newly created trim brush should be a child of worldspawn
+ auto selectedNode = GlobalSelectionSystem().ultimateSelected();
+ ASSERT_NE(selectedNode, nullptr);
+ EXPECT_EQ(selectedNode->getParent(), worldspawn);
+}
+
+TEST_F(TrimToolTest, CreateTrimInheritsShaderFromFace)
+{
+ auto worldspawn = GlobalMapModule().findOrInsertWorldspawn();
+ std::string material = "textures/common/caulk";
+ auto brush = algorithm::createCubicBrush(worldspawn, { 0, 0, 0 }, material);
+
+ selectTopFace(brush);
+ ASSERT_EQ(GlobalSelectionSystem().getSelectedFaceCount(), 1);
+
+ GlobalCommandSystem().executeCommand("CreateTrimForFaces", { 16.0, 4.0, 0, 0 });
+
+ auto selectedNode = GlobalSelectionSystem().ultimateSelected();
+ auto* trimBrush = Node_getIBrush(selectedNode);
+ ASSERT_NE(trimBrush, nullptr);
+
+ // All faces of the trim brush should use the same shader as the source face
+ for (int i = 0; i < trimBrush->getNumFaces(); ++i)
+ {
+ EXPECT_EQ(trimBrush->getFace(i).getShader(), material)
+ << "Trim face " << i << " should inherit the source face shader";
+ }
+}
+
+}
diff --git a/tools/msvc/DarkRadiant.vcxproj b/tools/msvc/DarkRadiant.vcxproj
index aa1e41c04d..90e8077af7 100644
--- a/tools/msvc/DarkRadiant.vcxproj
+++ b/tools/msvc/DarkRadiant.vcxproj
@@ -369,6 +369,7 @@
+
@@ -613,6 +614,7 @@
+
diff --git a/tools/msvc/DarkRadiant.vcxproj.filters b/tools/msvc/DarkRadiant.vcxproj.filters
index 9101166194..799354ff28 100644
--- a/tools/msvc/DarkRadiant.vcxproj.filters
+++ b/tools/msvc/DarkRadiant.vcxproj.filters
@@ -361,6 +361,9 @@
src\ui\brush
+
+ src\ui\brush
+
src\xyview
@@ -915,6 +918,9 @@
src\ui\brush
+
+ src\ui\brush
+
src\xyview