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