UnrealC++で自作アセットのインポートオプションを作る

#はじめに
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">Slateでインポートオプションのダイアログ作れた！<br>IDetailsView使うと入力部分は全部勝手にやってくれるから便利だった！<a href="https://twitter.com/hashtag/UE4?src=hash&amp;ref_src=twsrc%5Etfw">#UE4</a> <a href="https://twitter.com/hashtag/UE4Study?src=hash&amp;ref_src=twsrc%5Etfw">#UE4Study</a> <a href="https://t.co/yiE0CmqfOF">pic.twitter.com/yiE0CmqfOF</a></p>&mdash; Naotsun (@Naotsun_UE) <a href="https://twitter.com/Naotsun_UE/status/1219475696403144707?ref_src=twsrc%5Etfw">January 21, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

PDFをアセットとして読み込むことができるプラグインで、PDFをインポートする際にページの範囲指定や解像度を指定できるように上のツイートのようなインポートオプションを作成したので、その作り方についてご紹介したいと思います。

自作アセットの作り方についてはヒストリアさんが、詳しく解説されていますのでまずは以下の記事を読むことをお勧めします。
[[UE4] 独自のアセットを実装する方法(1)　アセットクラスの実装](http://historia.co.jp/archives/1401/)
[[UE4] 独自のアセットを実装する方法(2)　インポートの実装](http://historia.co.jp/archives/1423)
[[UE4] 独自のアセットを実装する方法(3)　再インポートの実装](http://historia.co.jp/archives/1425/)
[[UE4] 独自のアセットを実装する方法(4)　アセットにアクションを追加する](http://historia.co.jp/archives/1531/)
[[UE4] 独自のアセットを実装する方法(5)　アセットエディタを実装する](http://historia.co.jp/archives/1428)
[[UE4] 独自のアセットを実装する方法(6)　独自のシリアライズを実装する](http://historia.co.jp/archives/1457)

#つくるもの
![1.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325493/cddce090-acd0-3c7d-f07b-1930769ce8d1.png)
今回はPDFアセットの例を紹介するので上の画像のようなインポートオプションを作成します。
スタティックメッシュアセットなどと同様にインポート時と再インポートに表示されるようにします。

#つくってみる
まずは、インポートオプションとして表示するUIのクラスをSlateを用いて作成します。
以下がヘッダーファイルと実装ファイルです。記事が長くなるのでGithubのURLになります。
[PDFImportOptions.h](https://github.com/Naotsun19B/PDFImporter/blob/master/Plugins/PDFImporter/Source/PDFImporterEd/Public/PDFImportOptions.h)
[PDFImportOptions.cpp](https://github.com/Naotsun19B/PDFImporter/blob/master/Plugins/PDFImporter/Source/PDFImporterEd/Private/PDFImportOptions.cpp)

まずはヘッダーファイルから見ていきましょう。

```PDFImportOptions.h
UCLASS()
class PDFIMPORTERED_API UPDFImportOptions : public UObject
{
	GENERATED_BODY()
	
public:
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PageRange")
	bool SpecifyPageRange;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PageRange", meta = (ClampMin = 1, UIMin = 1, EditCondition = "SpecifyPageRange"))
	int FirstPage;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PageRange", meta = (ClampMin = 1, UIMin = 1, EditCondition = "SpecifyPageRange"))
	int LastPage;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Dpi")
	int Dpi;

public:
	UPDFImportOptions() : SpecifyPageRange(false), FirstPage(1), LastPage(1), Dpi(150) {}
};
```
まず、インポートオプションで入力するパラメータをメンバ変数に持つUObjectを作成します。UPROPERTYを付けたメンバ変数とコンストラクタだけ定義します。

```PDFImportOptions.h
class SPDFImportOptions : public SCompoundWidget
{
private:
	UPDFImportOptions* ImportOptions;
	bool bShouldImport;
	TWeakPtr<class SWindow> WidgetWindow;
	TSharedPtr<class IDetailsView> DetailsView;

public:
	SLATE_BEGIN_ARGS(SPDFImportOptions)
		: _WidgetWindow()
		, _ImportOptions(nullptr)
		{}

		SLATE_ARGUMENT(TSharedPtr<SWindow>, WidgetWindow)
		SLATE_ARGUMENT(UPDFImportOptions*, ImportOptions)
		SLATE_ARGUMENT(FText, Filename)
	SLATE_END_ARGS()

public:
	SPDFImportOptions();

	void Construct(const FArguments& InArgs);

	// Button reaction
	FReply OnImport();
	FReply OnCancel();
	// End of Button reaction

	// Import was done
	bool ShouldImport() const { return bShouldImport; }
};
```
次に`SCompoundWidget`を継承したクラスを作成します。UnrealC++ではSlateに関するクラスの接頭子はSになります。

まず、プライベートなメンバ変数として
・先程作成したパラメータのUObjectクラスのポインタ
・インポートされたか(キャンセルボタンを押した場合false)
・親のウィンドウのポインタ
・この記事の主役のポインタ(詳しくは後で説明します)
の4つを定義します。

次にSlateクラス特有の構文を見ていきましょう。`SLATE_BEGIN_ARGS`と`SLATE_END_ARGS`の二つのマクロに囲まれた部分がSlateクラスに外部から値を渡すために必要な構文です。
**ここで`SLATE_ARGUMENT`で変数型と変数名を登録すると下のConstruct関数の引数の`InArgs`のメンバ変数となり、関数内で値を使用することができます。**
また、パラメータのUObjectクラスが引数にある理由は外部で作成したインスタンスの値をこのSlateクラスで変えて、結果を返却する関数が不要になるためです。

次にコンストラクタと先程のConstruct関数、インポートボタンとキャンセルボタンが押された時に呼び出される関数、インポートされたかを取得する関数を定義します。

---
次は実装ファイルを見ていきましょう。
コンストラクタは特に何もしていないので飛ばします。

```PDFImportOptions.cpp
void SPDFImportOptions::Construct(const FArguments& InArgs)
{
	ImportOptions = InArgs._ImportOptions;
	WidgetWindow = InArgs._WidgetWindow;

	check(ImportOptions)

	FPropertyEditorModule& PropertyEditorModule = FModuleManager::GetModuleChecked<FPropertyEditorModule>("PropertyEditor");
	FDetailsViewArgs DetailsViewArgs;
	DetailsViewArgs.bAllowSearch = false;
	DetailsViewArgs.NameAreaSettings = FDetailsViewArgs::HideNameArea;
	DetailsView = PropertyEditorModule.CreateDetailView(DetailsViewArgs);
	DetailsView->SetObject(ImportOptions);

	this->ChildSlot
	[
		SNew(SVerticalBox)
		// ファイルパス
		+ SVerticalBox::Slot()
			.AutoHeight()
			.Padding(2)
			[
				SNew(SBorder)
				.Padding(FMargin(3))
				.BorderImage(FEditorStyle::GetBrush("ToolPanel.GroupBorder"))
				[
					SNew(SHorizontalBox)
					+ SHorizontalBox::Slot()
					.AutoWidth()
					[
						SNew(STextBlock)
						.Font(FEditorStyle::GetFontStyle("CurveEd.LabelFont"))
						.Text(LOCTEXT("Import_CurrentFileTitle", "Current File: "))
					]
					+ SHorizontalBox::Slot()
					.Padding(5, 0, 0, 0)
					.AutoWidth()
					.VAlign(VAlign_Center)
					[
						SNew(STextBlock)
						.Font(FEditorStyle::GetFontStyle("CurveEd.InfoFont"))
						.Text(InArgs._Filename)
					]
				]
			]
		// 入力欄
		+ SVerticalBox::Slot()
			.Padding(2)
			.MaxHeight(500.0f)
			[
				DetailsView->AsShared()
			]
		// インポートとキャンセルのボタン
		+ SVerticalBox::Slot()
			.AutoHeight()
			.HAlign(HAlign_Right)
			.Padding(2)
			[
				SNew(SUniformGridPanel)
				.SlotPadding(2)
				+ SUniformGridPanel::Slot(0, 0)
					[
						SNew(SButton)
						.HAlign(HAlign_Center)
						.Text(LOCTEXT("PDFImportOptions_Import", "Import"))
						.OnClicked(this, &SPDFImportOptions::OnImport)
					]

				+ SUniformGridPanel::Slot(1, 0)
					[
						SNew(SButton)
						.HAlign(HAlign_Center)
						.Text(LOCTEXT("PDFImportOptions_Cancel", "Cancel"))
						.ToolTipText(LOCTEXT("PDFImportOptions_Cancel_ToolTip", "Cancels importing this PDF file"))
						.OnClicked(this, &SPDFImportOptions::OnCancel)
					]
			]
	];
}
```
Construct関数内では主に初期化とSlateを用いたUIの構成を記述します。

最初に、先程説明したようにInArgsにメンバ変数で親ウィンドウのポインタとパラメータのUObjectクラスを初期化します。

次に先程後で説明すると言った主役、`IDetailsView`クラスの登場です。このクラスは渡したUObjectクラスが持つUPROPERTY付のメンバ変数をBPの編集画面などでよく見るリスト形式にして表示してくれます。
**自分でSlateを用いてリストを構築する必要がなく、パラメータの追加や削除はUObjectクラスのメンバ変数を変更するだけで良いのでとても便利なクラスです。**

初期化の手順は以下の通りです。
1. `FDetailsViewArgs`型の変数を作成し、パラメータを設定する。
2. `PropertyEditor`モジュールをロード
3. `IDetailsView`型のメンバ変数を`FPropertyEditorModule::CreateDetailView`関数で初期化
4. `IDetailsView::SetObject`関数でパラメータのUObjectクラスを設定する

[**`FDetailsViewArgs`のパラメータでは検索欄の表示やスクロールバーの表示など様々なことを設定することができます。**](https://docs.unrealengine.com/en-US/API/Editor/PropertyEditor/FDetailsViewArgs/index.html)

次にSlateでUIを構築する部分ですが、Slateについては話が長くなるので詳しく説明しません。
ボタンなどに表示される文字を変えたい場合は`.Text(LOCTEXT("~~","~~"))`の2番目の文字列を編集してください。

ファイルパスとボタンの部分はSlateで色々とめんどうなことをやっていますが、入力欄は`IDetailsView::AsShared`関数だけなので`IDetailsView`クラスがいかに便利かがわかるかと思います。

```PDFImportOptions.cpp
FReply SPDFImportOptions::OnImport()
{
	bShouldImport = true;

	if (!ImportOptions->SpecifyPageRange)
	{
		ImportOptions->FirstPage = 0;
		ImportOptions->LastPage = 0;
	}

	if (WidgetWindow.IsValid())
	{
		WidgetWindow.Pin()->RequestDestroyWindow();
	}
	return FReply::Handled();
}

FReply SPDFImportOptions::OnCancel()
{
	bShouldImport = false;
	if (WidgetWindow.IsValid())
	{
		WidgetWindow.Pin()->RequestDestroyWindow();
	}
	return FReply::Handled();
}
```
次にボタンが押された時に呼び出される関数ですが、ここでは主に`bShouldImport`の値を結果に応じて変えます。
`WidgetWindow.Pin()->RequestDestroyWindow`関数で表示しているウィンドウを閉じることができます。

少し長くなりましたがこれでインポートオプションを入力するUIのクラスが完成しました。

---
次にこのUIを表示する部分を見ていきましょう。この部分はインポートや再インポートに関する`UFactory`を継承したクラスに記述します。
以下がヘッダーファイルと実装ファイルです。こちらも記事が長くなるのでGithubのURLになります。
[PDFFactory.h](https://github.com/Naotsun19B/PDFImporter/blob/master/Plugins/PDFImporter/Source/PDFImporterEd/Public/PDFFactory.h)
[PDFFactory.cpp](https://github.com/Naotsun19B/PDFImporter/blob/master/Plugins/PDFImporter/Source/PDFImporterEd/Private/PDFFactory.cpp)

```PDFFactory.h
private:
	// Display a dialog to enter import options
	void ShowImportOptionWindow(TSharedPtr<class SPDFImportOptions>& Options, const FString& Filename, class UPDFImportOptions* &Result);
```
```PDFFactory.cpp
void UPDFFactory::ShowImportOptionWindow(TSharedPtr<SPDFImportOptions>& Options, const FString& Filename, UPDFImportOptions* &Result)
{
	TSharedRef<SWindow> Window = SNew(SWindow)
		.Title(LOCTEXT("WindowTitle", "PDF Import Options"))
		.SizingRule(ESizingRule::Autosized);

	Window->SetContent(
		SAssignNew(Options, SPDFImportOptions)
		.WidgetWindow(Window)
		.ImportOptions(Result)
		.Filename(FText::FromString(Filename))
	);

	TSharedPtr<SWindow> ParentWindow;

	if (FModuleManager::Get().IsModuleLoaded("MainFrame"))
	{
		IMainFrameModule& MainFrame = FModuleManager::LoadModuleChecked<IMainFrameModule>("MainFrame");
		ParentWindow = MainFrame.GetParentWindow();
	}

	FSlateApplication::Get().AddModalWindow(Window, ParentWindow, false);
}
```
UIの表示を行う`ShowImportOptionWindow`関数を定義します。
まずUIを表示するウィンドウのタイトルなどを設定します。
次に`SWindow::SetContent`内で`SPDFImportOptions`に渡す値を設定します。
次に[「UnrealC++でゲームとエディタで使えるファイルピッカーを作る」](https://qiita.com/Naotsun/items/2f8a94e92df5a41d95f3)でもやったようにエディタ画面のウィンドウを取得します。
最後に、`FSlateApplication::Get().AddModalWindow`関数でウィンドウの表示を行います。

ウィンドウのタイトルを変えたい場合は`.Title(LOCTEXT("~~","~~"))`の2番目の文字列を編集してください。

```PDFFactory.cpp
UObject* UPDFFactory::FactoryCreateFile(
	UClass* InClass,
	UObject* InParent,
	FName InName,
	EObjectFlags Flags,
	const FString& Filename,
	const TCHAR* Parms,
	FFeedbackContext* Warn,
	bool& bOutOperationCanceled
)
{
	TSharedPtr<SPDFImportOptions> Options;
	UPDFImportOptions* Result = NewObject<UPDFImportOptions>();
	ShowImportOptionWindow(Options, Filename, Result);

	if (Options->ShouldImport())
	{
		UPDF* NewPDF = CastChecked<UPDF>(StaticConstructObject_Internal(InClass, InParent, InName, Flags));
		UPDF* LoadedPDF = GhostscriptCore->ConvertPdfToPdfAsset(Filename, Result->Dpi, Result->FirstPage, Result->LastPage, true);

		if (LoadedPDF != nullptr)
		{
			NewPDF->PageRange = LoadedPDF->PageRange;
			NewPDF->Dpi = LoadedPDF->Dpi;
			NewPDF->Pages = LoadedPDF->Pages;

			NewPDF->Filename = Filename;
			NewPDF->TimeStamp = IFileManager::Get().GetTimeStamp(*Filename);
			NewPDF->AssetImportData = NewObject<UAssetImportData>();
			NewPDF->AssetImportData->SourceData.Insert({ NewPDF->Filename, NewPDF->TimeStamp });
		}

		return NewPDF;
	}
	else
	{
		bOutOperationCanceled = true;
		return nullptr;
	}
}
```
最後に`FactoryCreateFile`関数内で`ShowImportOptionWindow`関数を呼び出して値を取得してアセットに適用します。
**キャンセルボタンが押された時に、引数の`bOutOperationCanceled`をtrueにするのを忘れるとキャンセルした時にエラーが出るので気を付けましょう。**

#おわりに
自作アセットの作り方については色んな方が解説されていますが、インポートオプションの表示については情報がなかったため記事を書いてみました。説明が長い上にSlateにはノータッチなので分かりにくい記事になってしまったかもしれませんが、分からないことがあればコメントか[Twitter](https://twitter.com/Naotsun_UE)までご連絡頂ければわかる範囲で対応することができます。
この記事が、自作アセットにインポートオプションを付けようとしている方の助けになれば幸いです。