📖 우선 커스텀 에디터 제작기가 아닌 조사인 이유는 원래 만드려던 툴이 이미 엔진에 있다는 걸 뒤늦게 알아버렸기 때문...
📖 코드를 뒤져본 시간이 아까워서 나중에 진짜 필요한 툴을 만들 때 어떻게든 쓰이지 않을까 + 누군가 궁금해할까 싶어 글로 남기는 것

뷰포트의 상단에 보면 확인할 수 있듯이 기본 상태는 Selection Mode로 에디터상의 액터들을 선택하고 배치할 수 있는 상태이다.
이미 제작된 여러 모드가 있는데 그 외에 나만의 에디터 모드를 만드는 방법을 소개하고자한다.
Edit 탭 -> Plugins 창 -> Add -> Editor Mode 플러그인 제작

플러그인을 사용하지 않고 직접 추가하는 방식도 가능은 하지만 권장되지 않는다고 함 (GPT 의견)
📖 Slate Widget이나 언리얼 엔진 자체에 대한 이해도가 높으면 상관없겠지만 모듈을 등록하고 해제하는 것도 그렇고 ITF에 대한 예제를 확인할 수 없다는 점에서도 프리셋이 되어있는 Editor Mode 기반의 플러그인을 사용해서 작업하는 것이 수월할 것 같다.
처음 플러그인을 추가하면 플러그인 이름 기반으로 만들어진 소스 파일들과 기본 예제 툴 2개가 생성된다.
[플러그인 이름]EditorMode, [플러그인 이름]EditorModeCommands, [플러그인 이름]EditorModeToolkit, [플러그인 이름]Module, Tools

뭐 어쩌라는 건가 싶다..
📖 여기서부터는 여기저기 헤매면서 코드를 뒤져본 내용 위주기때문에 부정확한 정보가 섞여있을 수 있으니 알고있는 정보와 다르다면 그게 맞을 확률이 높습니다..😢
UE 4버전을 기준으로 작성된 문서이긴하지만 프레임워크에 대한 상세한 설명이 되어있는 링크를 남깁니다. 필요하신 분은 읽어보시면 좋을듯(저는 이해 못했지만...)
https://www.gradientspace.com/tutorials/2021/01/19/the-interactive-tools-framework-in-ue426
모드에서 필요한 기능을 실질적으로 수행해줄 클래스.
UInteractiveTool의 자식 클래스.
엔진을 실행시켜보면 Tool 등록을 위해 크게 2가지의 과정이 필요하고 이를 위한 최소한의 흐름을 설명하고자 한다.
- RegisterCommands()
[플러그인 이름]EditorModeCommands : 모드에서 사용될 모든 Command를 등록한다.
TSharedPtr<FUICommandInfo> SelectTool;
TSharedPtr<FUICommandInfo> AddInstanceTool;
이런식으로 사용할 Tool의 Command를 모두 멤버로 선언해둔다.
[플러그인 이름]EditorModeCommands.cpp
void FISMAddInstanceEditorModeCommands::RegisterCommands()
{
TArray <TSharedPtr<FUICommandInfo>>& ToolCommands = Commands.FindOrAdd(NAME_Default);
UI_COMMAND(SelectTool, "Select Actor", "Select Actor To Add Instance, Actor should have Instanced Static Mesh Component", EUserInterfaceActionType::Button, FInputChord());
ToolCommands.Add(SelectTool);
UI_COMMAND(AddInstanceTool, "Add Instance", "Add Instances matches to Selected Actors Transform, Check 'Replace' to Delete Selected Actor After Add Instances", EUserInterfaceActionType::Button, FInputChord());
ToolCommands.Add(AddInstanceTool);
}
UI_COMMAND 매크로를 사용해 버튼과 텍스트 등을 바인딩해주고 ToolCommands 배열에도 추가한다.
- Enter()
[플러그인 이름]EditorMode : UEdMode를 상속 받은 클래스
static FString SelectToolName;
static FString AddInstanceToolName;
[플러그인 이름]EditorMode.cpp
// ID 지정해두고
FString UISMAddInstanceEditorMode::SelectToolName = TEXT("ISMAddInstance_SelectTool");
FString UISMAddInstanceEditorMode::AddInstanceToolName = TEXT("ISMAddInstance_AddInstanceTool");
// Enter()에서 Tool 등록
void UISMAddInstanceEditorMode::Enter()
{
UEdMode::Enter();
const FISMAddInstanceEditorModeCommands& ToolCommands = FISMAddInstanceEditorModeCommands::Get();
RegisterTool(ToolCommands.SelectTool, SelectToolName, NewObject<UISMAddInstanceSimpleToolBuilder>(this));
RegisterTool(ToolCommands.AddInstanceTool, AddInstanceToolName, NewObject<UISMAddInstanceInteractiveToolBuilder>(this));
// active tool type is not relevant here, we just set to default
GetToolManager()->SelectActiveToolType(EToolSide::Left, SelectToolName);
}
이 때 RegisterTool()에서 Builder를 넘겨주는 부분에 유의!
Tool 자체를 넘기는 것이 아니고 우리가 필요한 Tool의 Builder 클래스를 만들어서 넘긴다.
📖 여기까지가 기본 셋업 과정으로 이렇게하면 원하는 툴만 추가해서 기본 형태의 모드 위젯을 구성할 수 있다.
(툴은 그냥 클래스만 추가해서는 안 되고 빌더가 같이 있어야한다, 엔진에서도 툴은 직접 인스턴싱하지말고 항상 빌더를 통하라고 설명되어있다.)
예제로 주어지는 Tool 클래스를 보면 이미 3가지의 클래스가 선언되어있다.
UInteractiveToolBuilder자식 클래스
RegisterTool()에 넘겨줄 빌더.
UInteractiveToolPropertySet자식 클래스
UPROPERTY(EditAnywhere, Category = Options)
FVector SomeProperty;
Tool에서 표현할 속성값들을 넣고
Tool 클래스의 Setup()에서 등록한다.
// Create the property set and register it with the Tool
Properties = NewObject<UISMAddInstanceInteractiveToolProperties>(this, "Measurement");
AddToolPropertySource(Properties);
UInteractiveTool자식 클래스
Properties를 멤버로 들고있다.
생성 및 할당은 Setup()에서.
UPROPERTY()
TObjectPtr<UISMAddInstanceInteractiveToolProperties> Properties;
다양한 모디파이어 인터페이스 구현을 통해서 클릭이나 드래그 등의 행동에 바인딩이 가능하다.
ex) USingleClickTool.h
UCLASS(MinimalAPI)
class USingleClickTool : public UInteractiveTool, public IClickBehaviorTarget
{
GENERATED_BODY()
public:
/**
* Register default primary-button-click InputBehaviors
*/
INTERACTIVETOOLSFRAMEWORK_API virtual void Setup() override;
/**
* Test if the Target is hit at this 2D position / 3D ray
*/
INTERACTIVETOOLSFRAMEWORK_API virtual FInputRayHit IsHitByClick(const FInputDeviceRay& ClickPos);
/**
* Click the Target at this 2D position / 3D ray. Default behavior is to print debug string.
*/
INTERACTIVETOOLSFRAMEWORK_API virtual void OnClicked(const FInputDeviceRay& ClickPos);
};
InteractiveTool이 툴로써 기능에대한 클래스라면 Toolkit 클래스는 UI 부분을 정의하고 있다.
GetToolkitFName() , GetToolPaletteNames() 등등 에디터에서 보여지는 UI 버튼 등의 이름에 대해 지정되어있고 오버라이드를 통해 커스터마이징이 가능하다.
크게 팔레트와 툴 버튼이 들어갈 TSharedPtr<SBorder> ModeToolHeader와
툴 속성을 포함한 Details View가 들어갈 TSharedPtr<SBorder> InlineContentHolder로 나누어져 있다.
CreatePrimaryModePanel()뼈대가 되는 SlateWidget 구성
SharedRef<SDockTab> FModeToolkit::CreatePrimaryModePanel(const FSpawnTabArgs& Args)
{
TSharedPtr<SWidget> TabContent;
if (!HasToolkitBuilder())
{
TabContent = SNew(SBorder)
.BorderImage(FAppStyle::GetBrush("ToolPanel.GroupBorder"))
.Padding(0.0f)
[
SNew(SVerticalBox)
+ SVerticalBox::Slot()
.AutoHeight()
.HAlign(HAlign_Left)
[
SAssignNew(ModeToolBarContainer, SBorder)
.BorderImage(FAppStyle::GetBrush("ToolPanel.GroupBorder"))
.Padding(FMargin(4, 0, 0, 0))
]
+ SVerticalBox::Slot()
.FillHeight(1.0f)
[
SNew(SVerticalBox)
+ SVerticalBox::Slot()
.Padding(0.0, 8.0, 0.0, 0.0)
.AutoHeight()
[
SAssignNew(ModeToolHeader, SBorder)
.BorderImage(FAppStyle::GetBrush("ToolPanel.GroupBorder"))
]
+ SVerticalBox::Slot()
.FillHeight(1)
[
SAssignNew(InlineContentHolder, SBorder)
.BorderImage(FAppStyle::GetBrush("ToolPanel.GroupBorder"))
.Visibility(this, &FModeToolkit::GetInlineContentHolderVisibility)
]
]
];
}
// else if ToolkitBuilder is defined, make the Toolkit
else
{
TabContent = SAssignNew(InlineContentHolder, SBorder)
.BorderImage(FAppStyle::GetBrush("ToolPanel.GroupBorder"))
.Padding(0.0f)
[
SNew(SVerticalBox)
+ SVerticalBox::Slot()
.AutoHeight()
.HAlign(HAlign_Left)
[
SAssignNew(ModeToolBarContainer, SBorder)
.Padding(FMargin(4, 0, 0, 0))
.BorderImage(FAppStyle::GetBrush("ToolPanel.GroupBorder"))
]
];
}
const TSharedPtr<SDockTab> CreatedTab = SNew(SDockTab);
if (TabContent)
{
CreatedTab->SetContent(TabContent.ToSharedRef());
}
PrimaryTab = CreatedTab;
UpdatePrimaryModePanel();
return CreatedTab.ToSharedRef();
}
UpdatePrimaryModePanel(): UI 패널 업데이트
void FModeToolkit::UpdatePrimaryModePanel()
{
if (GetEditorMode() || GetScriptableEditorMode().IsValid())
{
const FText TabName = GetEditorModeDisplayName();
const FSlateBrush* TabIcon = GetEditorModeIcon().GetSmallIcon();
if (PrimaryTab.IsValid())
{
TSharedPtr<SDockTab> TabPtr = PrimaryTab.Pin();
TabPtr->SetTabIcon(TabIcon);
TabPtr->SetLabel(TabName);
if (HasToolkitBuilder())
{
TabPtr->SetParentDockTabStackTabWellHidden(HasToolkitBuilder());
const TSharedPtr<SWidget> Content = GetInlineContent() ;
if ( Content && InlineContentHolder.IsValid() )
{
InlineContentHolder->SetContent( Content.ToSharedRef() );
}
return;
}
}
if (HasIntegratedToolPalettes())
{
TSharedRef<SSegmentedControl<FName>> PaletteTabBox = SNew(SSegmentedControl<FName>)
.UniformPadding(FMargin(8.f, 3.f))
.Value_Lambda([this]() { return GetCurrentPalette(); } )
.OnValueChanged_Lambda([this](const FName& Palette) { SetCurrentPalette(Palette); } );
// Only show if there is more than one child in the switcher
PaletteTabBox->SetVisibility(TAttribute<EVisibility>::Create(TAttribute<EVisibility>::FGetter::CreateLambda([PaletteTabBox]() -> EVisibility
{
return PaletteTabBox->NumSlots() > 1 ? EVisibility::Visible : EVisibility::Collapsed;
})));
// Also build the toolkit here
TArray<FName> PaletteNames;
GetToolPaletteNames(PaletteNames);
TSharedPtr<FUICommandList> CommandList;
CommandList = GetToolkitCommands();
TSharedRef< SWidgetSwitcher > PaletteSwitcher = SNew(SWidgetSwitcher)
.WidgetIndex_Lambda(
[this, PaletteNames]() -> int32
{
int32 FoundIndex;
if (PaletteNames.Find(GetCurrentPalette(), FoundIndex))
{
return FoundIndex;
}
return 0;
});
for (auto Palette : PaletteNames)
{
FName ToolbarCustomizationName = GetEditorMode() ? GetEditorMode()->GetModeInfo().ToolbarCustomizationName : GetScriptableEditorMode()->GetModeInfo().ToolbarCustomizationName;
TSharedRef<SWidget> PaletteWidget = CreatePaletteWidget(CommandList, ToolbarCustomizationName, Palette);
const bool bRebuildChildren = false;
PaletteTabBox->AddSlot(Palette, false)
.Text(GetToolPaletteDisplayName(Palette));
PaletteSwitcher->AddSlot()
[
PaletteWidget
];
}
PaletteTabBox->RebuildChildren();
if (ModeToolHeader)
{
ModeToolHeader->SetContent(
SNew(SVerticalBox)
+ SVerticalBox::Slot()
.Padding(0.f, 0.f, 0.f, 8.f)
.HAlign(HAlign_Center)
.AutoHeight()
[
PaletteTabBox
]
+ SVerticalBox::Slot()
.AutoHeight()
[
PaletteSwitcher
]
);
}
}
else
{
if (ModeToolHeader)
{
ModeToolHeader->SetContent(SNullWidget::NullWidget);
}
if (ModeToolBarContainer)
{
ModeToolBarContainer->SetContent(SNullWidget::NullWidget);
}
}
if (InlineContentHolder.IsValid())
{
if (TSharedPtr<SWidget> InlineContent = GetInlineContent())
{
InlineContentHolder->SetContent(
///////////////스크롤박스로 만드는 것에 주의!!!//////////////////
SNew(SScrollBox)
+ SScrollBox::Slot()
[
InlineContent.ToSharedRef()
]);
}
}
}
}
GetInlineContent(): 여기서 넘겨주는 위젯으로InlineContent패널을 업데이트
오버라이딩을 통해서 자유롭게 구성이 가능하다.

Inline Content Holder 는 업데이트 될 때마다 스크롤 박스로 만들어지기때문에 그 안에 하단 고정 위젯을 만들어봤자 위로 딸려올라가버린다.
이를 해결하기위해 ModeToolKit에서 InvokeUI()를 오버라이드 후 다시 그리는 방법이 있다.
(언리얼 엔진의 모델링 툴 에디터에서 사용 중인 방법)
InvokeUI()에서 UpdatePrimaryModePanel()를 호출하고UpdatePrimaryModePanel()에서 Inline Content Holder를 그리고 있기 때문.void FISMAddInstanceEditorModeToolkit::InvokeUI()
{
FModeToolkit::InvokeUI();
InlineContentHolder->SetContent(GetInlineContent().ToSharedRef());
}

스크롤박스를 쓰지 않으면 이런식으로 하단 정렬된 박스를 사용할 수 있다.
엔진의 FModelingToolsEditorModeToolkit 소스코드와
WidgetReflector를 통해 디버깅을 하면 필요한 기능이나 SlateWidget 구성 정보를 확인할 수 있다.
(확인은 되는데 구현이 내 마음같이 안 되서 문제긴 했지만...)
Tools -> Debug -> Widget Reflector로 창을 열고
마우스 모양으로 위젯을 피킹해서 선택된 위젯의 정보를 확인할 수 있다.


어떤 위젯이 선택된 상태인지, 상하위 계층구조, 어디서 생성됐는지 등 다양한 정보를 확인할 수 있다.
https://lxjk.github.io/2019/10/01/How-to-Make-Tools-in-U-E.html
자세하게 설명이 되어있기는한데 샘플 프로젝트가 내 엔진(5.5)에서는 제대로 동작을 안 해서 큰 도움은 받지 못했다.
에디터 모드에 한정되지 않고 탭에 추가하는 것 등등 더 포괄적인 설명이 되어있다.