Update directive utilities plugin

This commit is contained in:
2026-08-15 21:06:43 +02:00
parent 2fe7f83a38
commit a06fed1cba
103 changed files with 18211 additions and 1071 deletions

View File

@@ -6,8 +6,5 @@
; /README.txt
; /Extras/...
; /Binaries/ThirdParty/*.dll
/README.md
/LICENSE
/CHANGELOG.md
/Documentation/...
/Config/DefaultDirectiveUtilities.ini

View File

@@ -1,19 +1,25 @@
{
"FileVersion": 3,
"Version": 3,
"VersionName": "2.0",
"Version": 7,
"VersionName": "2.2.0",
"FriendlyName": "Directive Utilities",
"Description": "An open-source Unreal Engine plugin that provides runtime and editor utility nodes for developers.",
"Category": "Unreal Directive",
"CreatedBy": "Unreal Directive",
"CreatedByURL": "https://unrealdirective.com",
"DocsURL": "https://udcore.unrealdirective.com/",
"MarketplaceURL": "https://unrealdirective.com",
"DocsURL": "https://github.com/UnrealDirective/DirectiveUtilities/tree/main/Documentation",
"MarketplaceURL": "",
"SupportURL": "https://github.com/UnrealDirective/DirectiveUtilities/issues",
"EngineVersion": "5.8.0",
"CanContainContent": false,
"IsBetaVersion": false,
"IsExperimentalVersion": false,
"Installed": false,
"Installed": true,
"SupportedTargetPlatforms": [
"Win64",
"Mac",
"Linux"
],
"Modules": [
{
"Name": "DirectiveUtilitiesRuntime",
@@ -25,10 +31,25 @@
"Linux"
]
},
{
"Name": "DirectiveUtilitiesBlueprintNodes",
"Type": "UncookedOnly",
"LoadingPhase": "Default",
"PlatformAllowList": [
"Win64",
"Mac",
"Linux"
]
},
{
"Name": "DirectiveUtilitiesEditor",
"Type": "Editor",
"LoadingPhase": "Default",
"PlatformAllowList": [
"Win64",
"Mac",
"Linux"
],
"TargetAllowList": [
"Editor"
]
@@ -50,7 +71,10 @@
"Plugins": [
{
"Name": "EditorScriptingUtilities",
"Enabled": true
"Enabled": true,
"TargetAllowList": [
"Editor"
]
},
{
"Name": "EnhancedInput",

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
using UnrealBuildTool;
public class DirectiveUtilitiesBlueprintNodes : ModuleRules
{
public DirectiveUtilitiesBlueprintNodes(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PublicDependencyModuleNames.AddRange(
new string[]
{
"BlueprintGraph",
"Core",
"CoreUObject",
"Engine",
"DirectiveUtilitiesRuntime",
}
);
PrivateDependencyModuleNames.Add("UnrealEd");
}
}

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "DirectiveUtilitiesBlueprintNodes.h"
#include "Engine/Blueprint.h"
#include "Nodes/DirectiveUtilMapNodeMigration.h"
#include "UObject/UObjectGlobals.h"
#include "UObject/UObjectIterator.h"
void FDirectiveUtilitiesBlueprintNodesModule::StartupModule()
{
AssetLoadedHandle = FCoreUObjectDelegates::OnAssetLoaded.AddRaw(this, &FDirectiveUtilitiesBlueprintNodesModule::HandleAssetLoaded);
for (TObjectIterator<UBlueprint> Blueprint; Blueprint; ++Blueprint)
{
HandleAssetLoaded(*Blueprint);
}
}
void FDirectiveUtilitiesBlueprintNodesModule::ShutdownModule()
{
FCoreUObjectDelegates::OnAssetLoaded.Remove(AssetLoadedHandle);
}
void FDirectiveUtilitiesBlueprintNodesModule::HandleAssetLoaded(UObject* Asset)
{
UBlueprint* Blueprint = Cast<UBlueprint>(Asset);
if (Blueprint && !Blueprint->HasAnyFlags(RF_Transient))
{
DirectiveUtilMapNodeMigration::UpgradeLegacyAppendNodes(*Blueprint);
}
}
IMPLEMENT_MODULE(FDirectiveUtilitiesBlueprintNodesModule, DirectiveUtilitiesBlueprintNodes)

View File

@@ -0,0 +1,127 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Nodes/DirectiveUtilMapNodeMigration.h"
#include "EdGraph/EdGraph.h"
#include "EdGraph/EdGraphPin.h"
#include "EdGraphSchema_K2.h"
#include "Engine/Blueprint.h"
#include "K2Node_CallFunction.h"
#include "Kismet2/BlueprintEditorUtils.h"
#include "Libraries/DirectiveUtilMapFunctionLibrary.h"
#include "Nodes/K2Node_DirectiveUtilMapAppend.h"
namespace
{
bool IsLegacyAppendNode(const UK2Node_CallFunction& Node)
{
return !Node.IsA<UK2Node_DirectiveUtilMapAppend>()
&& Node.FunctionReference.GetMemberParentClass() == UDirectiveUtilMapFunctionLibrary::StaticClass()
&& Node.FunctionReference.GetMemberName() == GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMapFunctionLibrary, Map_Append);
}
void CopyMapPinTypes(const UK2Node_CallFunction& LegacyNode, UK2Node_DirectiveUtilMapAppend& NewNode)
{
const FEdGraphPinType* MapType = nullptr;
for (const FName PinName : {FName(TEXT("TargetMap")), FName(TEXT("SourceMap"))})
{
const UEdGraphPin* LegacyPin = LegacyNode.FindPin(PinName);
if (!LegacyPin)
{
continue;
}
if (!LegacyPin->LinkedTo.IsEmpty() && LegacyPin->LinkedTo[0]->PinType.IsMap())
{
MapType = &LegacyPin->LinkedTo[0]->PinType;
break;
}
if (LegacyPin->PinType.IsMap()
&& LegacyPin->PinType.PinCategory != UEdGraphSchema_K2::PC_Wildcard
&& LegacyPin->PinType.PinValueType.TerminalCategory != UEdGraphSchema_K2::PC_Wildcard)
{
MapType = &LegacyPin->PinType;
break;
}
}
if (!MapType)
{
return;
}
for (const FName PinName : {FName(TEXT("TargetMap")), FName(TEXT("SourceMap"))})
{
if (UEdGraphPin* NewPin = NewNode.FindPin(PinName))
{
NewPin->PinType.PinCategory = MapType->PinCategory;
NewPin->PinType.PinSubCategory = MapType->PinSubCategory;
NewPin->PinType.PinSubCategoryObject = MapType->PinSubCategoryObject;
NewPin->PinType.PinSubCategoryMemberReference = MapType->PinSubCategoryMemberReference;
NewPin->PinType.PinValueType = MapType->PinValueType;
NewPin->PinType.bIsWeakPointer = MapType->bIsWeakPointer;
NewPin->PinType.bIsUObjectWrapper = MapType->bIsUObjectWrapper;
NewPin->PinType.bSerializeAsSinglePrecisionFloat = MapType->bSerializeAsSinglePrecisionFloat;
}
}
}
bool ReplaceLegacyAppendNode(UK2Node_CallFunction& LegacyNode)
{
UEdGraph* Graph = LegacyNode.GetGraph();
const UEdGraphSchema_K2* Schema = Graph ? Cast<UEdGraphSchema_K2>(Graph->GetSchema()) : nullptr;
if (!Graph || !Schema)
{
return false;
}
UK2Node_DirectiveUtilMapAppend* NewNode = NewObject<UK2Node_DirectiveUtilMapAppend>(Graph, NAME_None, RF_Transactional);
Graph->AddNode(NewNode, false, false);
NewNode->CreateNewGuid();
NewNode->PostPlacedNewNode();
NewNode->AllocateDefaultPins();
NewNode->AdvancedPinDisplay = LegacyNode.AdvancedPinDisplay;
NewNode->SetEnabledState(LegacyNode.GetDesiredEnabledState(), LegacyNode.HasUserSetTheEnabledState());
CopyMapPinTypes(LegacyNode, *NewNode);
if (Schema->ReplaceOldNodeWithNew(&LegacyNode, NewNode, {}))
{
return true;
}
NewNode->DestroyNode();
return false;
}
}
bool DirectiveUtilMapNodeMigration::UpgradeLegacyAppendNodes(UBlueprint& Blueprint)
{
TArray<UEdGraph*> Graphs;
Blueprint.GetAllGraphs(Graphs);
bool bModified = false;
for (UEdGraph* Graph : Graphs)
{
if (!Graph)
{
continue;
}
TArray<UK2Node_CallFunction*> FunctionNodes;
Graph->GetNodesOfClass(FunctionNodes);
for (UK2Node_CallFunction* FunctionNode : FunctionNodes)
{
if (FunctionNode && IsLegacyAppendNode(*FunctionNode))
{
bModified |= ReplaceLegacyAppendNode(*FunctionNode);
}
}
}
if (bModified)
{
FBlueprintEditorUtils::MarkBlueprintAsModified(&Blueprint);
}
return bModified;
}

View File

@@ -0,0 +1,207 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Nodes/K2Node_DirectiveUtilMapAppend.h"
#include "BlueprintActionDatabaseRegistrar.h"
#include "BlueprintNodeSpawner.h"
#include "EdGraph/EdGraph.h"
#include "EdGraph/EdGraphPin.h"
#include "EdGraphSchema_K2.h"
#include "Kismet2/CompilerResultsLog.h"
#include "Libraries/DirectiveUtilMapFunctionLibrary.h"
#define LOCTEXT_NAMESPACE "K2Node_DirectiveUtilMapAppend"
namespace DirectiveUtilMapAppendPins
{
const FName TargetMap(TEXT("TargetMap"));
const FName SourceMap(TEXT("SourceMap"));
}
UK2Node_DirectiveUtilMapAppend::UK2Node_DirectiveUtilMapAppend()
{
FunctionReference.SetExternalMember(
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMapFunctionLibrary, Map_Append),
UDirectiveUtilMapFunctionLibrary::StaticClass());
}
void UK2Node_DirectiveUtilMapAppend::AllocateDefaultPins()
{
Super::AllocateDefaultPins();
ConformMapPins();
}
void UK2Node_DirectiveUtilMapAppend::PostReconstructNode()
{
Super::PostReconstructNode();
ConformMapPins();
}
void UK2Node_DirectiveUtilMapAppend::NotifyPinConnectionListChanged(UEdGraphPin* Pin)
{
Super::NotifyPinConnectionListChanged(Pin);
if (IsAppendMapPin(Pin) && ConformMapPins())
{
GetGraph()->NotifyGraphChanged();
}
}
void UK2Node_DirectiveUtilMapAppend::ValidateNodeDuringCompilation(FCompilerResultsLog& MessageLog) const
{
Super::ValidateNodeDuringCompilation(MessageLog);
const UEdGraphPin* TargetMap = FindPin(DirectiveUtilMapAppendPins::TargetMap);
const UEdGraphPin* SourceMap = FindPin(DirectiveUtilMapAppendPins::SourceMap);
if (!TargetMap || !SourceMap || TargetMap->LinkedTo.IsEmpty() || SourceMap->LinkedTo.IsEmpty())
{
return;
}
if (!HaveMatchingMapTypes(TargetMap->LinkedTo[0]->PinType, SourceMap->LinkedTo[0]->PinType))
{
MessageLog.Error(*LOCTEXT("MismatchedMapTypes", "Append requires matching map types on @@.").ToString(), this);
}
}
void UK2Node_DirectiveUtilMapAppend::GetMenuActions(FBlueprintActionDatabaseRegistrar& ActionRegistrar) const
{
UClass* ActionKey = GetClass();
if (ActionRegistrar.IsOpenForRegistration(ActionKey))
{
UBlueprintNodeSpawner* NodeSpawner = UBlueprintNodeSpawner::Create(GetClass());
check(NodeSpawner);
ActionRegistrar.AddBlueprintAction(ActionKey, NodeSpawner);
}
}
bool UK2Node_DirectiveUtilMapAppend::IsConnectionDisallowed(
const UEdGraphPin* MyPin,
const UEdGraphPin* OtherPin,
FString& OutReason) const
{
if (Super::IsConnectionDisallowed(MyPin, OtherPin, OutReason))
{
return true;
}
if (!IsAppendMapPin(MyPin) || !OtherPin || !IsConcreteMap(OtherPin->PinType))
{
return false;
}
const FName SiblingName = MyPin->PinName == DirectiveUtilMapAppendPins::TargetMap
? DirectiveUtilMapAppendPins::SourceMap
: DirectiveUtilMapAppendPins::TargetMap;
const UEdGraphPin* SiblingPin = FindPin(SiblingName);
if (!SiblingPin)
{
return false;
}
const FEdGraphPinType* SiblingType = &SiblingPin->PinType;
if (!SiblingPin->LinkedTo.IsEmpty())
{
SiblingType = &SiblingPin->LinkedTo[0]->PinType;
}
if (IsConcreteMap(*SiblingType) && !HaveMatchingMapTypes(*SiblingType, OtherPin->PinType))
{
OutReason = LOCTEXT("MapTypeMismatch", "Both maps must have the same key and value types.").ToString();
return true;
}
return false;
}
bool UK2Node_DirectiveUtilMapAppend::ConformMapPins()
{
UEdGraphPin* TargetMap = FindPin(DirectiveUtilMapAppendPins::TargetMap);
UEdGraphPin* SourceMap = FindPin(DirectiveUtilMapAppendPins::SourceMap);
if (!TargetMap || !SourceMap)
{
return false;
}
const FEdGraphPinType* ResolvedType = nullptr;
for (const UEdGraphPin* MapPin : {TargetMap, SourceMap})
{
if (!MapPin->LinkedTo.IsEmpty() && IsConcreteMap(MapPin->LinkedTo[0]->PinType))
{
ResolvedType = &MapPin->LinkedTo[0]->PinType;
break;
}
}
const FEdGraphPinType PreviousTargetType = TargetMap->PinType;
const FEdGraphPinType PreviousSourceType = SourceMap->PinType;
if (ResolvedType)
{
ApplyMapType(*TargetMap, *ResolvedType);
ApplyMapType(*SourceMap, *ResolvedType);
}
else
{
ResetMapType(*TargetMap);
ResetMapType(*SourceMap);
}
return PreviousTargetType != TargetMap->PinType || PreviousSourceType != SourceMap->PinType;
}
bool UK2Node_DirectiveUtilMapAppend::IsAppendMapPin(const UEdGraphPin* Pin)
{
return Pin && (Pin->PinName == DirectiveUtilMapAppendPins::TargetMap || Pin->PinName == DirectiveUtilMapAppendPins::SourceMap);
}
bool UK2Node_DirectiveUtilMapAppend::IsConcreteMap(const FEdGraphPinType& PinType)
{
return PinType.IsMap()
&& PinType.PinCategory != UEdGraphSchema_K2::PC_Wildcard
&& PinType.PinValueType.TerminalCategory != UEdGraphSchema_K2::PC_Wildcard;
}
bool UK2Node_DirectiveUtilMapAppend::HaveMatchingMapTypes(
const FEdGraphPinType& First,
const FEdGraphPinType& Second)
{
return First.IsMap()
&& Second.IsMap()
&& First.PinCategory == Second.PinCategory
&& First.PinSubCategory == Second.PinSubCategory
&& First.PinSubCategoryObject == Second.PinSubCategoryObject
&& First.bIsWeakPointer == Second.bIsWeakPointer
&& First.bIsUObjectWrapper == Second.bIsUObjectWrapper
&& First.PinValueType.TerminalCategory == Second.PinValueType.TerminalCategory
&& First.PinValueType.TerminalSubCategory == Second.PinValueType.TerminalSubCategory
&& First.PinValueType.TerminalSubCategoryObject == Second.PinValueType.TerminalSubCategoryObject
&& First.PinValueType.bTerminalIsWeakPointer == Second.PinValueType.bTerminalIsWeakPointer
&& First.PinValueType.bTerminalIsUObjectWrapper == Second.PinValueType.bTerminalIsUObjectWrapper;
}
void UK2Node_DirectiveUtilMapAppend::ApplyMapType(UEdGraphPin& Pin, const FEdGraphPinType& MapType)
{
Pin.PinType.PinCategory = MapType.PinCategory;
Pin.PinType.PinSubCategory = MapType.PinSubCategory;
Pin.PinType.PinSubCategoryObject = MapType.PinSubCategoryObject;
Pin.PinType.PinSubCategoryMemberReference = MapType.PinSubCategoryMemberReference;
Pin.PinType.PinValueType = MapType.PinValueType;
Pin.PinType.bIsWeakPointer = MapType.bIsWeakPointer;
Pin.PinType.bIsUObjectWrapper = MapType.bIsUObjectWrapper;
Pin.PinType.bSerializeAsSinglePrecisionFloat = MapType.bSerializeAsSinglePrecisionFloat;
}
void UK2Node_DirectiveUtilMapAppend::ResetMapType(UEdGraphPin& Pin)
{
Pin.PinType.PinCategory = UEdGraphSchema_K2::PC_Wildcard;
Pin.PinType.PinSubCategory = NAME_None;
Pin.PinType.PinSubCategoryObject = nullptr;
Pin.PinType.PinSubCategoryMemberReference = FSimpleMemberReference();
Pin.PinType.PinValueType = FEdGraphTerminalType();
Pin.PinType.PinValueType.TerminalCategory = UEdGraphSchema_K2::PC_Wildcard;
Pin.PinType.bIsWeakPointer = false;
Pin.PinType.bIsUObjectWrapper = false;
Pin.PinType.bSerializeAsSinglePrecisionFloat = false;
}
#undef LOCTEXT_NAMESPACE

View File

@@ -0,0 +1,20 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#pragma once
#include "CoreMinimal.h"
#include "Modules/ModuleManager.h"
class UObject;
class FDirectiveUtilitiesBlueprintNodesModule : public IModuleInterface
{
public:
virtual void StartupModule() override;
virtual void ShutdownModule() override;
private:
void HandleAssetLoaded(UObject* Asset);
FDelegateHandle AssetLoadedHandle;
};

View File

@@ -0,0 +1,12 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#pragma once
#include "CoreTypes.h"
class UBlueprint;
namespace DirectiveUtilMapNodeMigration
{
DIRECTIVEUTILITIESBLUEPRINTNODES_API bool UpgradeLegacyAppendNodes(UBlueprint& Blueprint);
}

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#pragma once
#include "K2Node_CallFunction.h"
#include "K2Node_DirectiveUtilMapAppend.generated.h"
class FBlueprintActionDatabaseRegistrar;
class FCompilerResultsLog;
class UEdGraphPin;
UCLASS()
class DIRECTIVEUTILITIESBLUEPRINTNODES_API UK2Node_DirectiveUtilMapAppend : public UK2Node_CallFunction
{
GENERATED_BODY()
public:
UK2Node_DirectiveUtilMapAppend();
virtual void AllocateDefaultPins() override;
virtual void PostReconstructNode() override;
virtual void NotifyPinConnectionListChanged(UEdGraphPin* Pin) override;
virtual void ValidateNodeDuringCompilation(FCompilerResultsLog& MessageLog) const override;
virtual void GetMenuActions(FBlueprintActionDatabaseRegistrar& ActionRegistrar) const override;
virtual bool IsConnectionDisallowed(const UEdGraphPin* MyPin, const UEdGraphPin* OtherPin, FString& OutReason) const override;
private:
bool ConformMapPins();
static bool IsAppendMapPin(const UEdGraphPin* Pin);
static bool IsConcreteMap(const FEdGraphPinType& PinType);
static bool HaveMatchingMapTypes(const FEdGraphPinType& First, const FEdGraphPinType& Second);
static void ApplyMapType(UEdGraphPin& Pin, const FEdGraphPinType& MapType);
static void ResetMapType(UEdGraphPin& Pin);
};

View File

@@ -16,6 +16,7 @@ public class DirectiveUtilitiesEditor : ModuleRules
"Engine",
"EditorSubsystem",
"EditorScriptingUtilities",
"AssetRegistry",
"DirectiveUtilitiesRuntime",
}
);
@@ -26,7 +27,6 @@ public class DirectiveUtilitiesEditor : ModuleRules
"Slate",
"SlateCore",
"UnrealEd",
"AssetRegistry",
"AssetTools",
}
);

View File

@@ -0,0 +1,21 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#pragma once
#include "AssetRegistry/IAssetRegistry.h"
namespace DirectiveUtilitiesEditor
{
inline void EnsureAssetRegistryScan(IAssetRegistry& AssetRegistry)
{
if (!AssetRegistry.IsSearchAllAssets())
{
AssetRegistry.SearchAllAssets(true);
}
if (AssetRegistry.IsLoadingAssets())
{
AssetRegistry.WaitForCompletion();
}
}
}

View File

@@ -0,0 +1,123 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "AssetRegistry/DirectiveUtilDependencyCycleFinder.h"
namespace DirectiveUtilitiesEditor
{
FDependencyCycleFinder::FDependencyCycleFinder(const TMap<FName, TArray<FName>>& InGraph)
: Graph(InGraph)
{
}
TArray<FDirectiveUtilAssetDependencyCycle> FDependencyCycleFinder::Find() const
{
const TArray<FName> FinishOrder = BuildFinishOrder();
const TMap<FName, TArray<FName>> ReverseGraph = BuildReverseGraph();
TSet<FName> Visited;
TArray<FDirectiveUtilAssetDependencyCycle> Cycles;
for (int32 Index = FinishOrder.Num() - 1; Index >= 0; --Index)
{
const FName Root = FinishOrder[Index];
if (Visited.Contains(Root))
{
continue;
}
FDirectiveUtilAssetDependencyCycle Cycle;
TArray<FName> Pending = {Root};
Visited.Add(Root);
while (!Pending.IsEmpty())
{
const FName Package = Pending.Pop(EAllowShrinking::No);
Cycle.Packages.Add(Package);
const TArray<FName>* Referencers = ReverseGraph.Find(Package);
if (!Referencers)
{
continue;
}
for (const FName Referencer : *Referencers)
{
if (!Visited.Contains(Referencer))
{
Visited.Add(Referencer);
Pending.Add(Referencer);
}
}
}
const TArray<FName>* RootDependencies = Cycle.Packages.Num() == 1
? Graph.Find(Cycle.Packages[0])
: nullptr;
const bool bSelfCycle = RootDependencies && RootDependencies->Contains(Cycle.Packages[0]);
if (Cycle.Packages.Num() > 1 || bSelfCycle)
{
Cycle.Packages.Sort(FNameLexicalLess());
Cycles.Add(MoveTemp(Cycle));
}
}
Cycles.Sort([](const FDirectiveUtilAssetDependencyCycle& Left, const FDirectiveUtilAssetDependencyCycle& Right) {
return Left.Packages[0].LexicalLess(Right.Packages[0]);
});
return Cycles;
}
TArray<FName> FDependencyCycleFinder::BuildFinishOrder() const
{
TArray<FName> Packages;
Graph.GetKeys(Packages);
Packages.Sort(FNameLexicalLess());
TArray<FName> FinishOrder;
FinishOrder.Reserve(Packages.Num());
TSet<FName> Visited;
for (const FName Root : Packages)
{
if (Visited.Contains(Root))
{
continue;
}
TArray<FTraversalFrame> Pending = {{Root, 0}};
Visited.Add(Root);
while (!Pending.IsEmpty())
{
FTraversalFrame& Frame = Pending.Last();
const TArray<FName>* Dependencies = Graph.Find(Frame.Package);
if (Dependencies && Frame.NextDependencyIndex < Dependencies->Num())
{
const FName Dependency = (*Dependencies)[Frame.NextDependencyIndex++];
if (!Visited.Contains(Dependency))
{
Visited.Add(Dependency);
Pending.Add({Dependency, 0});
}
continue;
}
FinishOrder.Add(Frame.Package);
Pending.Pop(EAllowShrinking::No);
}
}
return FinishOrder;
}
TMap<FName, TArray<FName>> FDependencyCycleFinder::BuildReverseGraph() const
{
TMap<FName, TArray<FName>> ReverseGraph;
for (const TPair<FName, TArray<FName>>& Node : Graph)
{
ReverseGraph.FindOrAdd(Node.Key);
for (const FName Dependency : Node.Value)
{
ReverseGraph.FindOrAdd(Dependency).Add(Node.Key);
}
}
for (TPair<FName, TArray<FName>>& Node : ReverseGraph)
{
Node.Value.Sort(FNameLexicalLess());
}
return ReverseGraph;
}
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#pragma once
#include "CoreMinimal.h"
#include "Types/DirectiveUtilEditorAuditTypes.h"
namespace DirectiveUtilitiesEditor
{
class DIRECTIVEUTILITIESEDITOR_API FDependencyCycleFinder
{
public:
explicit FDependencyCycleFinder(const TMap<FName, TArray<FName>>& InGraph);
TArray<FDirectiveUtilAssetDependencyCycle> Find() const;
private:
struct FTraversalFrame
{
FName Package;
int32 NextDependencyIndex = 0;
};
TArray<FName> BuildFinishOrder() const;
TMap<FName, TArray<FName>> BuildReverseGraph() const;
const TMap<FName, TArray<FName>>& Graph;
};
}

View File

@@ -1,6 +1,7 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "DirectiveUtilitiesEditor.h"
#include "Tasks/DirectiveUtilEditorSlowTask.h"
void FDirectiveUtilitiesEditorModule::StartupModule()
{
@@ -8,6 +9,7 @@ void FDirectiveUtilitiesEditorModule::StartupModule()
void FDirectiveUtilitiesEditorModule::ShutdownModule()
{
UDirectiveUtilEditorSlowTask::FinishActiveTask();
}
IMPLEMENT_MODULE(FDirectiveUtilitiesEditorModule, DirectiveUtilitiesEditor)

View File

@@ -0,0 +1,433 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilEditorAssetAuditLibrary.h"
#include "AssetRegistry/DirectiveUtilAssetRegistry.h"
#include "AssetRegistry/DirectiveUtilDependencyCycleFinder.h"
#include "AssetRegistry/ARFilter.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "Engine/AssetManager.h"
#include "HAL/FileManager.h"
#include "Misc/PackageName.h"
#include "UObject/ObjectRedirector.h"
namespace
{
using namespace UE::AssetRegistry;
bool IsPackageInsideAuditPath(const FName PackageName, const FName Path)
{
const FString Package = PackageName.ToString();
FString Parent = Path.ToString();
Parent.RemoveFromEnd(TEXT("/"));
return Package == Parent || Package.StartsWith(Parent + TEXT("/"));
}
bool ContainsPathSegment(const FName PackagePath, const FString& Segment)
{
const FString Path = TEXT("/") + PackagePath.ToString().TrimChar(TEXT('/')) + TEXT("/");
return Path.Contains(TEXT("/") + Segment + TEXT("/"));
}
bool IsExcluded(const FAssetData& Asset, const FDirectiveUtilAssetAuditOptions& Options)
{
if (Asset.AssetClassPath == UObjectRedirector::StaticClass()->GetClassPathName())
{
return true;
}
if (Options.bUseDefaultPathExclusions
&& (ContainsPathSegment(Asset.PackagePath, TEXT("Developers"))
|| ContainsPathSegment(Asset.PackagePath, TEXT("__ExternalActors__"))
|| ContainsPathSegment(Asset.PackagePath, TEXT("__ExternalObjects__"))
|| ContainsPathSegment(Asset.PackagePath, TEXT("Test"))
|| ContainsPathSegment(Asset.PackagePath, TEXT("Tests"))))
{
return true;
}
for (const FName Path : Options.ExcludedPackagePaths)
{
if (IsPackageInsideAuditPath(Asset.PackageName, Path))
{
return true;
}
}
return false;
}
TArray<FAssetData> GetScannedAssets(IAssetRegistry& AssetRegistry, const FDirectiveUtilAssetAuditOptions& Options)
{
DirectiveUtilitiesEditor::EnsureAssetRegistryScan(AssetRegistry);
FARFilter Filter;
Filter.bRecursivePaths = true;
if (Options.PackagePaths.IsEmpty())
{
Filter.PackagePaths.Add(TEXT("/Game"));
}
else
{
Filter.PackagePaths.Append(Options.PackagePaths);
}
TArray<FAssetData> Assets;
AssetRegistry.GetAssets(Filter, Assets);
Assets.RemoveAll([&Options](const FAssetData& Asset) {
return !Asset.IsValid() || IsExcluded(Asset, Options);
});
Assets.Sort([](const FAssetData& Left, const FAssetData& Right) {
return Left.GetSoftObjectPath().LexicalLess(Right.GetSoftObjectPath());
});
return Assets;
}
bool IsPrimaryAsset(const FAssetData& Asset)
{
const UAssetManager* AssetManager = UAssetManager::GetIfInitialized();
return AssetManager && AssetManager->GetPrimaryAssetIdForData(Asset).IsValid();
}
EDirectiveUtilAssetDependencyType GetDependencyType(const FAssetDependency& Dependency)
{
if (EnumHasAnyFlags(Dependency.Category, EDependencyCategory::SearchableName))
{
return EDirectiveUtilAssetDependencyType::SearchableName;
}
if (EnumHasAnyFlags(Dependency.Category, EDependencyCategory::Manage))
{
return EnumHasAnyFlags(Dependency.Properties, EDependencyProperty::Direct)
? EDirectiveUtilAssetDependencyType::DirectManagement
: EDirectiveUtilAssetDependencyType::IndirectManagement;
}
return EnumHasAnyFlags(Dependency.Properties, EDependencyProperty::Hard)
? EDirectiveUtilAssetDependencyType::HardPackage
: EDirectiveUtilAssetDependencyType::SoftPackage;
}
bool PackageExists(IAssetRegistry& AssetRegistry, const FName PackageName)
{
if (PackageName.IsNone())
{
return true;
}
const FString Package = PackageName.ToString();
if (Package.StartsWith(TEXT("/Script/")) || Package.StartsWith(TEXT("/Memory/")) || Package.StartsWith(TEXT("/Temp/")))
{
return true;
}
if (FindPackage(nullptr, *Package))
{
return true;
}
TArray<FAssetData> PackageAssets;
return AssetRegistry.GetAssetsByPackageName(PackageName, PackageAssets, true) && !PackageAssets.IsEmpty();
}
class FAssetAuditScan
{
public:
FAssetAuditScan(IAssetRegistry& InAssetRegistry, const FDirectiveUtilAssetAuditOptions& InOptions)
: AssetRegistry(InAssetRegistry)
, bIncludePrimaryAssets(InOptions.bIncludePrimaryAssets)
, Assets(GetScannedAssets(InAssetRegistry, InOptions))
{
}
const TArray<FAssetData>& GetAssets() const
{
return Assets;
}
const TArray<FAssetDependency>& GetDependencies(const FName PackageName)
{
if (const TArray<FAssetDependency>* Existing = Dependencies.Find(PackageName))
{
return *Existing;
}
TArray<FAssetDependency>& Result = Dependencies.Add(PackageName);
AssetRegistry.GetDependencies(FAssetIdentifier(PackageName), Result, EDependencyCategory::All);
return Result;
}
const TArray<FName>& GetReferencers(const FName PackageName)
{
if (const TArray<FName>* Existing = Referencers.Find(PackageName))
{
return *Existing;
}
TArray<FName>& Result = Referencers.Add(PackageName);
AssetRegistry.GetReferencers(PackageName, Result, EDependencyCategory::All);
Result.Remove(PackageName);
Result.Sort(FNameLexicalLess());
return Result;
}
bool IncludesPrimaryAssets() const
{
return bIncludePrimaryAssets;
}
IAssetRegistry& GetAssetRegistry() const
{
return AssetRegistry;
}
private:
IAssetRegistry& AssetRegistry;
bool bIncludePrimaryAssets;
TArray<FAssetData> Assets;
TMap<FName, TArray<FAssetDependency>> Dependencies;
TMap<FName, TArray<FName>> Referencers;
};
FString EscapeCsv(const FString& Value)
{
FString Escaped = Value.Replace(TEXT("\""), TEXT("\"\""));
return FString::Printf(TEXT("\"%s\""), *Escaped);
}
FString DependencyTypeToString(const EDirectiveUtilAssetDependencyType Type)
{
switch (Type)
{
case EDirectiveUtilAssetDependencyType::HardPackage:
return TEXT("Hard Package");
case EDirectiveUtilAssetDependencyType::SoftPackage:
return TEXT("Soft Package");
case EDirectiveUtilAssetDependencyType::SearchableName:
return TEXT("Searchable Name");
case EDirectiveUtilAssetDependencyType::DirectManagement:
return TEXT("Direct Management");
case EDirectiveUtilAssetDependencyType::IndirectManagement:
return TEXT("Indirect Management");
}
return TEXT("Unknown");
}
TArray<FAssetData> FindUnreferencedCandidates(FAssetAuditScan& Scan)
{
TArray<FAssetData> Candidates;
for (const FAssetData& Asset : Scan.GetAssets())
{
if ((!IsPrimaryAsset(Asset) || Scan.IncludesPrimaryAssets()) && Scan.GetReferencers(Asset.PackageName).IsEmpty())
{
Candidates.Add(Asset);
}
}
return Candidates;
}
TArray<FDirectiveUtilMissingAssetReference> FindMissingReferences(FAssetAuditScan& Scan)
{
TArray<FDirectiveUtilMissingAssetReference> MissingReferences;
for (const FAssetData& Asset : Scan.GetAssets())
{
for (const FAssetDependency& Dependency : Scan.GetDependencies(Asset.PackageName))
{
if (!PackageExists(Scan.GetAssetRegistry(), Dependency.AssetId.PackageName))
{
FDirectiveUtilMissingAssetReference& MissingReference = MissingReferences.AddDefaulted_GetRef();
MissingReference.ReferencingAsset = Asset;
MissingReference.MissingPackage = Dependency.AssetId.PackageName;
MissingReference.DependencyType = GetDependencyType(Dependency);
}
}
}
MissingReferences.Sort([](const FDirectiveUtilMissingAssetReference& Left, const FDirectiveUtilMissingAssetReference& Right) {
if (Left.ReferencingAsset.PackageName != Right.ReferencingAsset.PackageName)
{
return Left.ReferencingAsset.PackageName.LexicalLess(Right.ReferencingAsset.PackageName);
}
return Left.MissingPackage.LexicalLess(Right.MissingPackage);
});
return MissingReferences;
}
TArray<FDirectiveUtilAssetDependencyCycle> FindDependencyCycles(FAssetAuditScan& Scan)
{
TSet<FName> ScannedPackages;
for (const FAssetData& Asset : Scan.GetAssets())
{
ScannedPackages.Add(Asset.PackageName);
}
TMap<FName, TArray<FName>> Graph;
for (const FName Package : ScannedPackages)
{
TArray<FName>& Edges = Graph.Add(Package);
for (const FAssetDependency& Dependency : Scan.GetDependencies(Package))
{
if (ScannedPackages.Contains(Dependency.AssetId.PackageName))
{
Edges.AddUnique(Dependency.AssetId.PackageName);
}
}
Edges.Sort(FNameLexicalLess());
}
return DirectiveUtilitiesEditor::FDependencyCycleFinder(Graph).Find();
}
}
TArray<FAssetData> UDirectiveUtilEditorAssetAuditLibrary::FindUnreferencedAssetCandidates(
const FDirectiveUtilAssetAuditOptions& Options)
{
IAssetRegistry* AssetRegistry = IAssetRegistry::Get();
if (!AssetRegistry)
{
return {};
}
FAssetAuditScan Scan(*AssetRegistry, Options);
return FindUnreferencedCandidates(Scan);
}
TArray<FDirectiveUtilMissingAssetReference> UDirectiveUtilEditorAssetAuditLibrary::FindMissingAssetReferences(
const FDirectiveUtilAssetAuditOptions& Options)
{
IAssetRegistry* AssetRegistry = IAssetRegistry::Get();
if (!AssetRegistry)
{
return {};
}
FAssetAuditScan Scan(*AssetRegistry, Options);
return FindMissingReferences(Scan);
}
TArray<FDirectiveUtilAssetDependencyCycle> UDirectiveUtilEditorAssetAuditLibrary::FindAssetDependencyCycles(
const FDirectiveUtilAssetAuditOptions& Options)
{
IAssetRegistry* AssetRegistry = IAssetRegistry::Get();
if (!AssetRegistry)
{
return {};
}
FAssetAuditScan Scan(*AssetRegistry, Options);
return FindDependencyCycles(Scan);
}
FDirectiveUtilAssetAuditReport UDirectiveUtilEditorAssetAuditLibrary::BuildAssetAuditReport(
const FDirectiveUtilAssetAuditOptions& Options)
{
FDirectiveUtilAssetAuditReport Report;
IAssetRegistry* AssetRegistry = IAssetRegistry::Get();
if (!AssetRegistry)
{
return Report;
}
FAssetAuditScan Scan(*AssetRegistry, Options);
const TArray<FAssetData> Candidates = FindUnreferencedCandidates(Scan);
Report.MissingReferences = FindMissingReferences(Scan);
Report.DependencyCycles = FindDependencyCycles(Scan);
TSet<FSoftObjectPath> CandidatePaths;
for (const FAssetData& Candidate : Candidates)
{
CandidatePaths.Add(Candidate.GetSoftObjectPath());
}
TMap<FName, int32> MissingCounts;
for (const FDirectiveUtilMissingAssetReference& MissingReference : Report.MissingReferences)
{
++MissingCounts.FindOrAdd(MissingReference.ReferencingAsset.PackageName);
}
TSet<FName> CyclicPackages;
for (const FDirectiveUtilAssetDependencyCycle& Cycle : Report.DependencyCycles)
{
CyclicPackages.Append(Cycle.Packages);
}
for (const FAssetData& Asset : Scan.GetAssets())
{
FDirectiveUtilAssetAuditEntry& Entry = Report.Assets.AddDefaulted_GetRef();
Entry.Asset = Asset;
Entry.PackageName = Asset.PackageName;
Entry.PackagePath = Asset.PackagePath;
Entry.AssetClass = Asset.AssetClassPath.ToString();
Entry.bPrimaryAsset = IsPrimaryAsset(Asset);
Entry.DependencyCount = Scan.GetDependencies(Asset.PackageName).Num();
Entry.ReferencerCount = Scan.GetReferencers(Asset.PackageName).Num();
FString PackageFilename;
if (FPackageName::DoesPackageExist(Asset.PackageName.ToString(), &PackageFilename))
{
Entry.DiskSize = FMath::Max<int64>(IFileManager::Get().FileSize(*PackageFilename), 0);
}
if (CandidatePaths.Contains(Asset.GetSoftObjectPath()))
{
Entry.Findings.Add(TEXT("Unreferenced candidate"));
}
if (const int32* MissingCount = MissingCounts.Find(Asset.PackageName))
{
Entry.Findings.Add(FString::Printf(TEXT("%d missing reference%s"), *MissingCount, *MissingCount == 1 ? TEXT("") : TEXT("s")));
}
if (CyclicPackages.Contains(Asset.PackageName))
{
Entry.Findings.Add(TEXT("Dependency cycle"));
}
}
return Report;
}
FString UDirectiveUtilEditorAssetAuditLibrary::AssetAuditReportToCsv(const FDirectiveUtilAssetAuditReport& Report)
{
TArray<FString> Rows;
Rows.Add(TEXT("Asset,Package,Path,Class,Disk Size,Dependencies,Referencers,Primary Asset,Findings"));
for (const FDirectiveUtilAssetAuditEntry& Entry : Report.Assets)
{
const TArray<FString> Columns = {
EscapeCsv(Entry.Asset.GetSoftObjectPath().ToString()),
EscapeCsv(Entry.PackageName.ToString()),
EscapeCsv(Entry.PackagePath.ToString()),
EscapeCsv(Entry.AssetClass),
LexToString(Entry.DiskSize),
LexToString(Entry.DependencyCount),
LexToString(Entry.ReferencerCount),
Entry.bPrimaryAsset ? TEXT("true") : TEXT("false"),
EscapeCsv(FString::Join(Entry.Findings, TEXT("; "))),
};
Rows.Add(FString::Join(Columns, TEXT(",")));
}
Rows.Add(TEXT(""));
Rows.Add(TEXT("Missing Reference,Referencing Asset,Dependency Type"));
for (const FDirectiveUtilMissingAssetReference& MissingReference : Report.MissingReferences)
{
const TArray<FString> Columns = {
EscapeCsv(MissingReference.MissingPackage.ToString()),
EscapeCsv(MissingReference.ReferencingAsset.GetSoftObjectPath().ToString()),
EscapeCsv(DependencyTypeToString(MissingReference.DependencyType)),
};
Rows.Add(FString::Join(Columns, TEXT(",")));
}
Rows.Add(TEXT(""));
Rows.Add(TEXT("Dependency Cycle"));
for (const FDirectiveUtilAssetDependencyCycle& Cycle : Report.DependencyCycles)
{
TArray<FString> Packages;
for (const FName Package : Cycle.Packages)
{
Packages.Add(Package.ToString());
}
Rows.Add(EscapeCsv(FString::Join(Packages, TEXT(" -> "))));
}
return FString::Join(Rows, TEXT("\n"));
}

View File

@@ -2,14 +2,19 @@
#include "Libraries/DirectiveUtilEditorAssetLibrary.h"
#include "AssetRegistry/DirectiveUtilAssetRegistry.h"
#include "Editor.h"
#include "Subsystems/EditorAssetSubsystem.h"
#include "Algo/Transform.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "AssetRegistry/ARFilter.h"
#include "AssetToolsModule.h"
#include "Editor.h"
#include "IAssetTools.h"
#include "Misc/App.h"
#include "Misc/AssetRegistryInterface.h"
#include "Modules/ModuleManager.h"
#include "Runtime/Launch/Resources/Version.h"
#include "UObject/ObjectRedirector.h"
namespace
@@ -18,10 +23,7 @@ namespace
{
if (IAssetRegistry* AssetRegistry = IAssetRegistry::Get())
{
if (AssetRegistry->IsLoadingAssets())
{
AssetRegistry->WaitForCompletion();
}
DirectiveUtilitiesEditor::EnsureAssetRegistryScan(*AssetRegistry);
}
}
}
@@ -116,10 +118,7 @@ EDirectiveUtilSuccessStatus UDirectiveUtilEditorAssetLibrary::FixUpRedirectorsIn
return EDirectiveUtilSuccessStatus::Failure;
}
if (AssetRegistry->IsLoadingAssets())
{
AssetRegistry->WaitForCompletion();
}
DirectiveUtilitiesEditor::EnsureAssetRegistryScan(*AssetRegistry);
FAssetToolsModule& AssetToolsModule = FModuleManager::LoadModuleChecked<FAssetToolsModule>("AssetTools");
IAssetTools& AssetTools = AssetToolsModule.Get();
@@ -160,6 +159,11 @@ EDirectiveUtilSuccessStatus UDirectiveUtilEditorAssetLibrary::FixUpRedirectorsIn
{
return EDirectiveUtilSuccessStatus::Success;
}
if (FApp::IsUnattended())
{
UE_LOG(LogTemp, Warning, TEXT("Redirector fix-up requires an interactive editor session."));
return EDirectiveUtilSuccessStatus::Failure;
}
AssetTools.FixupReferencers(Redirectors, false, ERedirectFixupMode::DeleteFixedUpRedirectors);
OutRedirectorsProcessed = Redirectors.Num();

View File

@@ -0,0 +1,268 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilEditorBlueprintLibrary.h"
#include "AssetRegistry/DirectiveUtilAssetRegistry.h"
#include "AssetRegistry/ARFilter.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "Blueprint/BlueprintSupport.h"
#include "Components/ActorComponent.h"
#include "Engine/Blueprint.h"
#include "Engine/BlueprintGeneratedClass.h"
#include "Engine/SCS_Node.h"
#include "Engine/SimpleConstructionScript.h"
#include "Kismet2/BlueprintEditorUtils.h"
#include "Misc/PackageName.h"
#include "UObject/Package.h"
namespace
{
bool IsPackageInsideBlueprintSearchPath(const FName PackageName, const FName Path)
{
const FString Package = PackageName.ToString();
FString Parent = Path.ToString();
Parent.RemoveFromEnd(TEXT("/"));
return Package == Parent || Package.StartsWith(Parent + TEXT("/"));
}
TArray<FAssetData> GetBlueprintAssets(const FDirectiveUtilBlueprintSearchOptions& Options)
{
IAssetRegistry* AssetRegistry = IAssetRegistry::Get();
if (!AssetRegistry)
{
return {};
}
DirectiveUtilitiesEditor::EnsureAssetRegistryScan(*AssetRegistry);
FARFilter Filter;
Filter.ClassPaths.Add(UBlueprint::StaticClass()->GetClassPathName());
Filter.bRecursiveClasses = true;
Filter.bRecursivePaths = true;
if (Options.PackagePaths.IsEmpty())
{
Filter.PackagePaths.Add(TEXT("/Game"));
}
else
{
Filter.PackagePaths.Append(Options.PackagePaths);
}
TArray<FAssetData> Assets;
AssetRegistry->GetAssets(Filter, Assets);
Assets.RemoveAll([&Options](const FAssetData& Asset) {
for (const FName Path : Options.ExcludedPackagePaths)
{
if (IsPackageInsideBlueprintSearchPath(Asset.PackageName, Path))
{
return true;
}
}
return false;
});
Assets.Sort([](const FAssetData& Left, const FAssetData& Right) {
return Left.GetSoftObjectPath().LexicalLess(Right.GetSoftObjectPath());
});
return Assets;
}
UBlueprint* LoadBlueprint(const FAssetData& Asset)
{
return Cast<UBlueprint>(Asset.GetAsset());
}
FString GetClassObjectPath(const UClass* Class)
{
return Class ? Class->GetPathName() : FString();
}
bool IsDirectParent(const FAssetData& Asset, const UClass* ParentClass)
{
const FString ParentTag = Asset.GetTagValueRef<FString>(FBlueprintTags::ParentClassPath);
return FPackageName::ExportTextPathToObjectPath(ParentTag) == GetClassObjectPath(ParentClass);
}
bool HasComponentClass(const UBlueprint* Blueprint, const UClass* ComponentClass, const bool bIncludeDerivedComponents)
{
TSet<const UBlueprint*> Visited;
for (const UBlueprint* Current = Blueprint; Current && !Visited.Contains(Current);)
{
Visited.Add(Current);
if (Current->SimpleConstructionScript)
{
for (const USCS_Node* Node : Current->SimpleConstructionScript->GetAllNodes())
{
if (!Node || !Node->ComponentClass)
{
continue;
}
const bool bMatches = bIncludeDerivedComponents
? Node->ComponentClass->IsChildOf(ComponentClass)
: Node->ComponentClass == ComponentClass;
if (bMatches)
{
return true;
}
}
}
Current = Current->ParentClass ? UBlueprint::GetBlueprintFromClass(Current->ParentClass) : nullptr;
}
return false;
}
}
EDirectiveUtilBlueprintCompileStatus UDirectiveUtilEditorBlueprintLibrary::GetBlueprintCompileStatus(
const UBlueprint* Blueprint)
{
if (!IsValid(Blueprint))
{
return EDirectiveUtilBlueprintCompileStatus::Unknown;
}
switch (Blueprint->Status)
{
case BS_Dirty:
return EDirectiveUtilBlueprintCompileStatus::Dirty;
case BS_Error:
return EDirectiveUtilBlueprintCompileStatus::Error;
case BS_UpToDate:
return EDirectiveUtilBlueprintCompileStatus::UpToDate;
case BS_BeingCreated:
return EDirectiveUtilBlueprintCompileStatus::BeingCreated;
case BS_UpToDateWithWarnings:
return EDirectiveUtilBlueprintCompileStatus::UpToDateWithWarnings;
case BS_Unknown:
default:
return EDirectiveUtilBlueprintCompileStatus::Unknown;
}
}
TArray<FAssetData> UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsByCompileStatus(
const EDirectiveUtilBlueprintCompileStatus CompileStatus,
const FDirectiveUtilBlueprintSearchOptions& Options)
{
TArray<FAssetData> Matches;
for (const FAssetData& Asset : GetBlueprintAssets(Options))
{
if (const UBlueprint* Blueprint = LoadBlueprint(Asset))
{
if (GetBlueprintCompileStatus(Blueprint) == CompileStatus)
{
Matches.Add(Asset);
}
}
}
return Matches;
}
TArray<FAssetData> UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsByParentClass(
UClass* ParentClass,
const FDirectiveUtilBlueprintSearchOptions& Options,
const bool bIncludeDescendants)
{
TArray<FAssetData> Matches;
if (!IsValid(ParentClass))
{
return Matches;
}
for (const FAssetData& Asset : GetBlueprintAssets(Options))
{
if (!bIncludeDescendants)
{
if (IsDirectParent(Asset, ParentClass))
{
Matches.Add(Asset);
}
continue;
}
const UBlueprint* Blueprint = LoadBlueprint(Asset);
if (Blueprint && Blueprint->ParentClass && Blueprint->ParentClass->IsChildOf(ParentClass))
{
Matches.Add(Asset);
}
}
return Matches;
}
TArray<FAssetData> UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsImplementingInterface(
UClass* InterfaceClass,
const FDirectiveUtilBlueprintSearchOptions& Options)
{
TArray<FAssetData> Matches;
if (!IsValid(InterfaceClass) || !InterfaceClass->HasAnyClassFlags(CLASS_Interface))
{
return Matches;
}
const FString InterfacePath = GetClassObjectPath(InterfaceClass);
for (const FAssetData& Asset : GetBlueprintAssets(Options))
{
const FString Interfaces = Asset.GetTagValueRef<FString>(FBlueprintTags::ImplementedInterfaces);
const FString ParentTag = Asset.GetTagValueRef<FString>(FBlueprintTags::ParentClassPath);
UClass* TaggedParent = FindObject<UClass>(nullptr, *FPackageName::ExportTextPathToObjectPath(ParentTag));
if (!Interfaces.Contains(InterfacePath) && TaggedParent && !TaggedParent->ImplementsInterface(InterfaceClass))
{
continue;
}
const UBlueprint* Blueprint = LoadBlueprint(Asset);
if (Blueprint && Blueprint->GeneratedClass && Blueprint->GeneratedClass->ImplementsInterface(InterfaceClass))
{
Matches.Add(Asset);
}
}
return Matches;
}
TArray<FAssetData> UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsContainingComponentClass(
UClass* ComponentClass,
const FDirectiveUtilBlueprintSearchOptions& Options,
const bool bIncludeDerivedComponents)
{
TArray<FAssetData> Matches;
if (!IsValid(ComponentClass) || !ComponentClass->IsChildOf(UActorComponent::StaticClass()))
{
return Matches;
}
for (const FAssetData& Asset : GetBlueprintAssets(Options))
{
if (const UBlueprint* Blueprint = LoadBlueprint(Asset))
{
if (HasComponentClass(Blueprint, ComponentClass, bIncludeDerivedComponents))
{
Matches.Add(Asset);
}
}
}
return Matches;
}
TArray<FName> UDirectiveUtilEditorBlueprintLibrary::GetUnusedBlueprintVariables(
UBlueprint* Blueprint,
const bool bIncludeExternallyAccessibleVariables)
{
TArray<FName> Names;
if (!IsValid(Blueprint))
{
return Names;
}
TArray<FProperty*> UsedVariables;
TArray<FProperty*> UnusedVariables;
FBlueprintEditorUtils::GetUsedAndUnusedVariables(Blueprint, UsedVariables, UnusedVariables);
Names.Reserve(UnusedVariables.Num());
for (const FProperty* Variable : UnusedVariables)
{
if (Variable && (bIncludeExternallyAccessibleVariables || FBlueprintEditorUtils::IsPropertyPrivate(Variable)))
{
Names.Add(Variable->GetFName());
}
}
Names.Sort(FNameLexicalLess());
return Names;
}

View File

@@ -0,0 +1,65 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilEditorTaskLibrary.h"
#include "Framework/Application/SlateApplication.h"
#include "Framework/Notifications/NotificationManager.h"
#include "Styling/AppStyle.h"
#include "Tasks/DirectiveUtilEditorSlowTask.h"
#include "Widgets/Notifications/SNotificationList.h"
UDirectiveUtilEditorSlowTask* UDirectiveUtilEditorTaskLibrary::StartEditorSlowTask(
const float TotalWork,
const FText& Description,
const bool bCanCancel)
{
if (!FMath::IsFinite(TotalWork) || TotalWork <= 0.0f)
{
return nullptr;
}
UDirectiveUtilEditorSlowTask* Task = NewObject<UDirectiveUtilEditorSlowTask>();
Task->Initialize(TotalWork, Description, bCanCancel, true);
return Task->IsActive() ? Task : nullptr;
}
bool UDirectiveUtilEditorTaskLibrary::ShowEditorNotification(
const FText& Message,
const EDirectiveUtilEditorNotificationState State,
const float ExpireDuration)
{
if (!FSlateApplication::IsInitialized())
{
return false;
}
FNotificationInfo NotificationInfo(Message);
NotificationInfo.bFireAndForget = true;
NotificationInfo.ExpireDuration = FMath::IsFinite(ExpireDuration)
? FMath::Max(0.0f, ExpireDuration)
: 0.0f;
if (State == EDirectiveUtilEditorNotificationState::Warning)
{
NotificationInfo.Image = FAppStyle::GetBrush("Icons.WarningWithColor");
}
const TSharedPtr<SNotificationItem> Notification = FSlateNotificationManager::Get().AddNotification(NotificationInfo);
if (!Notification.IsValid())
{
return false;
}
switch (State)
{
case EDirectiveUtilEditorNotificationState::Success:
Notification->SetCompletionState(SNotificationItem::CS_Success);
break;
case EDirectiveUtilEditorNotificationState::Failure:
Notification->SetCompletionState(SNotificationItem::CS_Fail);
break;
default:
Notification->SetCompletionState(SNotificationItem::CS_None);
break;
}
return true;
}

View File

@@ -3,17 +3,47 @@
#include "Subsystems/DirectiveUtilEditorActorSubsystem.h"
#include "DirectiveUtilLogChannels.h"
#include "Components/StaticMeshComponent.h"
#include "Editor.h"
#include "Engine/StaticMeshActor.h"
#include "Engine/StaticMesh.h"
#include "Engine/Texture.h"
#include "Engine/Texture2D.h"
#include "Materials/Material.h"
#include "Materials/MaterialInterface.h"
#include "Materials/MaterialExpressionTextureObject.h"
#include "EditorViewportClient.h"
#include "LevelEditorViewport.h"
#include "Components/BoxComponent.h"
#include "Components/CapsuleComponent.h"
#include "Components/SphereComponent.h"
#include "Materials/MaterialExpressionTextureSample.h"
#include "Runtime/Launch/Resources/Version.h"
#include "ScopedTransaction.h"
#include "Engine/World.h"
namespace
{
bool IsFiniteVector(const FVector& Value)
{
return FMath::IsFinite(Value.X) && FMath::IsFinite(Value.Y) && FMath::IsFinite(Value.Z);
}
bool IsFiniteQuat(const FQuat& Value)
{
return FMath::IsFinite(Value.X)
&& FMath::IsFinite(Value.Y)
&& FMath::IsFinite(Value.Z)
&& FMath::IsFinite(Value.W);
}
bool MatchesTextureReference(const UTexture* Texture, const TSoftObjectPtr<UTexture2D>& TextureReference)
{
return TextureReference.IsNull()
? Texture == nullptr
: Texture && FSoftObjectPath(Texture) == TextureReference.ToSoftObjectPath();
}
// Adds an actor to OutActors when whether it matches the predicate (evaluated once across all of the
// actor's components) equals whether matches are being included. Centralizes the include/exclude
// aggregation so a multi-component actor is judged per-actor rather than per-component.
@@ -39,7 +69,7 @@ namespace
for (TSourceActor* Actor : *Source)
{
if (!Actor || Seen.Contains(Actor)) { continue; }
if (!IsValid(Actor) || Seen.Contains(Actor)) { continue; }
if (ActorMatches(Actor) == bIncludeMatches)
{
Seen.Add(Actor);
@@ -47,27 +77,163 @@ namespace
}
}
}
struct FActorLayoutData
{
AActor* Actor = nullptr;
FBox Bounds = FBox(ForceInit);
FVector Location = FVector::ZeroVector;
int32 AttachmentDepth = 0;
};
float GetAxisValue(const FVector& Vector, const EDirectiveUtilActorLayoutAxis Axis)
{
switch (Axis)
{
case EDirectiveUtilActorLayoutAxis::X:
return Vector.X;
case EDirectiveUtilActorLayoutAxis::Y:
return Vector.Y;
case EDirectiveUtilActorLayoutAxis::Z:
return Vector.Z;
}
return 0.0f;
}
void SetAxisValue(FVector& Vector, const EDirectiveUtilActorLayoutAxis Axis, const float Value)
{
switch (Axis)
{
case EDirectiveUtilActorLayoutAxis::X:
Vector.X = Value;
break;
case EDirectiveUtilActorLayoutAxis::Y:
Vector.Y = Value;
break;
case EDirectiveUtilActorLayoutAxis::Z:
Vector.Z = Value;
break;
}
}
int32 GetAttachmentDepth(const AActor* Actor)
{
int32 Depth = 0;
TSet<const AActor*> Visited;
for (const AActor* Parent = Actor ? Actor->GetAttachParentActor() : nullptr;
Parent && !Visited.Contains(Parent);
Parent = Parent->GetAttachParentActor())
{
Visited.Add(Parent);
++Depth;
}
return Depth;
}
bool IsLayoutActorValid(AActor* Actor)
{
return IsValid(Actor)
&& Actor->GetWorld()
&& !Actor->HasAnyFlags(RF_Transient)
&& !Actor->IsActorBeingDestroyed();
}
TArray<FActorLayoutData> GetLayoutActors(
const TArray<AActor*>& Actors,
FDirectiveUtilActorOperationResult& Result)
{
TArray<FActorLayoutData> LayoutActors;
TSet<AActor*> Seen;
for (AActor* Actor : Actors)
{
if (!Actor || Seen.Contains(Actor))
{
continue;
}
Seen.Add(Actor);
if (!IsLayoutActorValid(Actor))
{
Result.SkippedActors.Add(Actor);
continue;
}
FActorLayoutData Data;
Data.Actor = Actor;
Data.Location = Actor->GetActorLocation();
if (!IsFiniteVector(Data.Location)
|| !IsFiniteVector(Actor->GetActorScale3D())
|| !IsFiniteQuat(Actor->GetActorQuat()))
{
Result.SkippedActors.Add(Actor);
continue;
}
Data.Bounds = Actor->GetComponentsBoundingBox(true, true);
if (!Data.Bounds.IsValid)
{
Data.Bounds = FBox(Data.Location, Data.Location);
}
if (!IsFiniteVector(Data.Bounds.Min) || !IsFiniteVector(Data.Bounds.Max))
{
Result.SkippedActors.Add(Actor);
continue;
}
Data.AttachmentDepth = GetAttachmentDepth(Actor);
LayoutActors.Add(MoveTemp(Data));
}
return LayoutActors;
}
void ApplyLocations(
TArray<FActorLayoutData>& LayoutActors,
const TMap<AActor*, FVector>& Locations,
FDirectiveUtilActorOperationResult& Result)
{
LayoutActors.Sort([](const FActorLayoutData& Left, const FActorLayoutData& Right) {
return Left.AttachmentDepth < Right.AttachmentDepth;
});
for (const FActorLayoutData& Data : LayoutActors)
{
const FVector* Location = Locations.Find(Data.Actor);
if (!Location || !IsFiniteVector(*Location) || Data.Actor->GetActorLocation().Equals(*Location))
{
continue;
}
Data.Actor->Modify();
if (Data.Actor->SetActorLocation(*Location, false, nullptr, ETeleportType::TeleportPhysics))
{
Result.ChangedActors.Add(Data.Actor);
}
else
{
Result.SkippedActors.Add(Data.Actor);
}
}
}
}
void UDirectiveUtilEditorActorSubsystem::FocusActorsInViewport(const TArray<AActor*> Actors, const bool bInstant)
{
if (Actors.Num() == 0) { return; }
if (!GEditor) { return; }
FViewport* ActiveViewport = GEditor->GetActiveViewport();
if (!ActiveViewport) { return; }
FEditorViewportClient* ViewportClient = static_cast<FEditorViewportClient*>(ActiveViewport->GetClient());
if (!ViewportClient) { return; }
if (!GCurrentLevelEditingViewportClient) { return; }
FBox BoundingBox = FBox(ForceInit);
for (const auto Actor : Actors)
for (const AActor* Actor : Actors)
{
if (!Actor) { continue; }
BoundingBox += Actor->GetComponentsBoundingBox(true, true);
if (!IsValid(Actor)) { continue; }
const FBox ActorBounds = Actor->GetComponentsBoundingBox(true, true);
if (ActorBounds.IsValid)
{
BoundingBox += ActorBounds;
}
}
ViewportClient->FocusViewportOnBox(BoundingBox, bInstant);
if (BoundingBox.IsValid)
{
GCurrentLevelEditingViewportClient->FocusViewportOnBox(BoundingBox, bInstant);
}
}
TArray<UClass*> UDirectiveUtilEditorActorSubsystem::GetAllLevelClasses()
@@ -84,6 +250,236 @@ TArray<UClass*> UDirectiveUtilEditorActorSubsystem::GetAllLevelClasses()
return ActorClasses;
}
FDirectiveUtilActorOperationResult UDirectiveUtilEditorActorSubsystem::AlignActors(
const TArray<AActor*>& Actors,
const EDirectiveUtilActorLayoutAxis Axis,
const EDirectiveUtilActorAlignment Alignment)
{
FDirectiveUtilActorOperationResult Result;
TArray<FActorLayoutData> LayoutActors = GetLayoutActors(Actors, Result);
if (LayoutActors.Num() < 2)
{
return Result;
}
FBox CombinedBounds(ForceInit);
for (const FActorLayoutData& Data : LayoutActors)
{
CombinedBounds += Data.Bounds;
}
float Target = 0.0f;
switch (Alignment)
{
case EDirectiveUtilActorAlignment::Minimum:
Target = GetAxisValue(CombinedBounds.Min, Axis);
break;
case EDirectiveUtilActorAlignment::Center:
Target = GetAxisValue(CombinedBounds.GetCenter(), Axis);
break;
case EDirectiveUtilActorAlignment::Maximum:
Target = GetAxisValue(CombinedBounds.Max, Axis);
break;
}
TMap<AActor*, FVector> Locations;
for (const FActorLayoutData& Data : LayoutActors)
{
const float Current = Alignment == EDirectiveUtilActorAlignment::Minimum
? GetAxisValue(Data.Bounds.Min, Axis)
: Alignment == EDirectiveUtilActorAlignment::Maximum
? GetAxisValue(Data.Bounds.Max, Axis)
: GetAxisValue(Data.Bounds.GetCenter(), Axis);
FVector Location = Data.Location;
SetAxisValue(Location, Axis, GetAxisValue(Location, Axis) + Target - Current);
Locations.Add(Data.Actor, Location);
}
const FScopedTransaction Transaction(NSLOCTEXT("DirectiveUtilities", "AlignActors", "Align Actors"));
ApplyLocations(LayoutActors, Locations, Result);
return Result;
}
FDirectiveUtilActorOperationResult UDirectiveUtilEditorActorSubsystem::DistributeActors(
const TArray<AActor*>& Actors,
const EDirectiveUtilActorLayoutAxis Axis,
const EDirectiveUtilActorDistribution Distribution)
{
FDirectiveUtilActorOperationResult Result;
TArray<FActorLayoutData> LayoutActors = GetLayoutActors(Actors, Result);
if (LayoutActors.Num() < 3)
{
return Result;
}
LayoutActors.Sort([Axis](const FActorLayoutData& Left, const FActorLayoutData& Right) {
return GetAxisValue(Left.Bounds.GetCenter(), Axis) < GetAxisValue(Right.Bounds.GetCenter(), Axis);
});
TMap<AActor*, FVector> Locations;
if (Distribution == EDirectiveUtilActorDistribution::Centers)
{
const float Start = GetAxisValue(LayoutActors[0].Bounds.GetCenter(), Axis);
const float End = GetAxisValue(LayoutActors.Last().Bounds.GetCenter(), Axis);
const float Spacing = (End - Start) / (LayoutActors.Num() - 1);
for (int32 Index = 1; Index < LayoutActors.Num() - 1; ++Index)
{
const FActorLayoutData& Data = LayoutActors[Index];
FVector Location = Data.Location;
const float Delta = Start + Spacing * Index - GetAxisValue(Data.Bounds.GetCenter(), Axis);
SetAxisValue(Location, Axis, GetAxisValue(Location, Axis) + Delta);
Locations.Add(Data.Actor, Location);
}
}
else
{
float TotalSize = 0.0f;
for (const FActorLayoutData& Data : LayoutActors)
{
TotalSize += GetAxisValue(Data.Bounds.GetSize(), Axis);
}
const float Start = GetAxisValue(LayoutActors[0].Bounds.Min, Axis);
const float End = GetAxisValue(LayoutActors.Last().Bounds.Max, Axis);
const float Gap = (End - Start - TotalSize) / (LayoutActors.Num() - 1);
float NextMinimum = GetAxisValue(LayoutActors[0].Bounds.Max, Axis) + Gap;
for (int32 Index = 1; Index < LayoutActors.Num() - 1; ++Index)
{
const FActorLayoutData& Data = LayoutActors[Index];
FVector Location = Data.Location;
const float Delta = NextMinimum - GetAxisValue(Data.Bounds.Min, Axis);
SetAxisValue(Location, Axis, GetAxisValue(Location, Axis) + Delta);
Locations.Add(Data.Actor, Location);
NextMinimum += GetAxisValue(Data.Bounds.GetSize(), Axis) + Gap;
}
}
const FScopedTransaction Transaction(NSLOCTEXT("DirectiveUtilities", "DistributeActors", "Distribute Actors"));
ApplyLocations(LayoutActors, Locations, Result);
return Result;
}
FDirectiveUtilActorOperationResult UDirectiveUtilEditorActorSubsystem::SnapActorsToSurface(
const TArray<AActor*>& Actors,
const FVector TraceDirection,
const float MaximumDistance,
const TEnumAsByte<ECollisionChannel> TraceChannel,
const EDirectiveUtilSurfacePlacement Placement,
const bool bAlignToNormal)
{
FDirectiveUtilActorOperationResult Result;
TArray<FActorLayoutData> LayoutActors = GetLayoutActors(Actors, Result);
if (LayoutActors.IsEmpty()
|| !FMath::IsFinite(MaximumDistance)
|| MaximumDistance <= 0.0f
|| !IsFiniteVector(TraceDirection)
|| TraceDirection.IsNearlyZero()
|| TraceChannel.GetValue() >= ECC_MAX)
{
for (const FActorLayoutData& Data : LayoutActors)
{
Result.SkippedActors.Add(Data.Actor);
}
return Result;
}
const FVector Direction = TraceDirection.GetSafeNormal();
FCollisionQueryParams QueryParams(SCENE_QUERY_STAT(DirectiveUtilitiesSnapActors), true);
for (const FActorLayoutData& Data : LayoutActors)
{
QueryParams.AddIgnoredActor(Data.Actor);
}
struct FSnapTarget
{
FVector Location;
FQuat Rotation;
};
TMap<AActor*, FSnapTarget> Targets;
for (const FActorLayoutData& Data : LayoutActors)
{
FHitResult Hit;
const FVector Start = Data.Location;
if (!Data.Actor->GetWorld()->LineTraceSingleByChannel(
Hit,
Start,
Start + Direction * MaximumDistance,
TraceChannel,
QueryParams))
{
Result.SkippedActors.Add(Data.Actor);
continue;
}
if (!IsFiniteVector(Hit.Location)
|| (bAlignToNormal && (!IsFiniteVector(Hit.ImpactNormal) || Hit.ImpactNormal.IsNearlyZero())))
{
Result.SkippedActors.Add(Data.Actor);
continue;
}
FVector Location = Hit.Location;
if (Placement == EDirectiveUtilSurfacePlacement::Bounds)
{
const FVector Extent = Data.Bounds.GetExtent();
const float Support = FMath::Abs(Direction.X) * Extent.X
+ FMath::Abs(Direction.Y) * Extent.Y
+ FMath::Abs(Direction.Z) * Extent.Z;
Location = Hit.Location - Direction * Support - (Data.Bounds.GetCenter() - Data.Location);
}
FQuat Rotation = Data.Actor->GetActorQuat();
if (bAlignToNormal)
{
Rotation = FQuat::FindBetweenNormals(Rotation.GetUpVector(), Hit.ImpactNormal) * Rotation;
Rotation.Normalize();
}
if (IsFiniteVector(Location) && IsFiniteQuat(Rotation))
{
Targets.Add(Data.Actor, {Location, Rotation});
}
else
{
Result.SkippedActors.Add(Data.Actor);
}
}
LayoutActors.Sort([](const FActorLayoutData& Left, const FActorLayoutData& Right) {
return Left.AttachmentDepth < Right.AttachmentDepth;
});
const FScopedTransaction Transaction(NSLOCTEXT("DirectiveUtilities", "SnapActorsToSurface", "Snap Actors To Surface"));
for (const FActorLayoutData& Data : LayoutActors)
{
const FSnapTarget* Target = Targets.Find(Data.Actor);
if (!Target)
{
continue;
}
const bool bLocationChanged = !Data.Actor->GetActorLocation().Equals(Target->Location);
const bool bRotationChanged = !Data.Actor->GetActorQuat().Equals(Target->Rotation);
if (!bLocationChanged && !bRotationChanged)
{
continue;
}
Data.Actor->Modify();
const bool bMoved = Data.Actor->SetActorLocationAndRotation(
Target->Location,
Target->Rotation,
false,
nullptr,
ETeleportType::TeleportPhysics);
if (bMoved)
{
Result.ChangedActors.Add(Data.Actor);
}
else
{
Result.SkippedActors.Add(Data.Actor);
}
}
return Result;
}
void UDirectiveUtilEditorActorSubsystem::FilterStaticMeshActors(
TArray<AStaticMeshActor*>& OutStaticMeshActors,
TArray<AActor*> ActorsToFilter) const
@@ -735,11 +1131,11 @@ void UDirectiveUtilEditorActorSubsystem::FilterActorsByTexture(
{
if (const UMaterialExpressionTextureSample* TextureSample = Cast<UMaterialExpressionTextureSample>(Expression))
{
if (TextureSample->Texture == TextureReference) { return true; }
if (MatchesTextureReference(TextureSample->Texture, TextureReference)) { return true; }
}
if (const UMaterialExpressionTextureObject* TextureObject = Cast<UMaterialExpressionTextureObject>(Expression))
{
if (TextureObject->Texture == TextureReference) { return true; }
if (MatchesTextureReference(TextureObject->Texture, TextureReference)) { return true; }
}
}
}
@@ -757,11 +1153,11 @@ void UDirectiveUtilEditorActorSubsystem::FilterActorsByTexture(
{
if (const UMaterialExpressionTextureSample* TextureSample = Cast<UMaterialExpressionTextureSample>(Expression))
{
if (TextureSample->Texture == TextureReference) { return true; }
if (MatchesTextureReference(TextureSample->Texture, TextureReference)) { return true; }
}
if (const UMaterialExpressionTextureObject* TextureObject = Cast<UMaterialExpressionTextureObject>(Expression))
{
if (TextureObject->Texture == TextureReference) { return true; }
if (MatchesTextureReference(TextureObject->Texture, TextureReference)) { return true; }
}
}
}
@@ -901,36 +1297,7 @@ void UDirectiveUtilEditorActorSubsystem::GetActorsByMaterialSoftReference(
const EDirectiveUtilInclusivity Inclusivity)
{
const TArray<AActor*> SourceActors = SelectionMethod == Selection ? GetSelectedLevelActors() : GetAllLevelActors();
TArray<AStaticMeshActor*> StaticMeshActors;
FilterStaticMeshActors(StaticMeshActors, SourceActors);
FilterActorsByPredicate(StaticMeshActors, FoundActors, Inclusivity, [&](AStaticMeshActor* StaticMeshActor) -> bool
{
const UStaticMeshComponent* StaticMeshComp = StaticMeshActor->GetStaticMeshComponent();
if (!StaticMeshComp) { return false; }
const UStaticMesh* Mesh = StaticMeshComp->GetStaticMesh();
if (!Mesh) { return false; }
if (MaterialSource == BaseAndOverride || MaterialSource == OverrideOnly)
{
for (int32 i = 0; i < StaticMeshComp->GetNumMaterials(); i++)
{
if (StaticMeshComp->GetMaterial(i) == Material) { return true; }
}
}
if (MaterialSource == BaseAndOverride || MaterialSource == BaseOnly)
{
for (int32 i = 0; i < Mesh->GetStaticMaterials().Num(); i++)
{
if (Mesh->GetMaterial(i) == Material) { return true; }
}
}
return false;
});
UE_LOG(LogDirectiveUtilEditor, Display, TEXT("%i actors with the material reference were found."),
FoundActors.Num());
FilterActorsByMaterial(SourceActors, FoundActors, Material, MaterialSource, Inclusivity);
}
void UDirectiveUtilEditorActorSubsystem::GetActorsByMaterialName(
@@ -941,38 +1308,7 @@ void UDirectiveUtilEditorActorSubsystem::GetActorsByMaterialName(
const EDirectiveUtilInclusivity Inclusivity)
{
const TArray<AActor*> SourceActors = SelectionMethod == Selection ? GetSelectedLevelActors() : GetAllLevelActors();
TArray<AStaticMeshActor*> StaticMeshActors;
FilterStaticMeshActors(StaticMeshActors, SourceActors);
FilterActorsByPredicate(StaticMeshActors, FoundActors, Inclusivity, [&](AStaticMeshActor* StaticMeshActor) -> bool
{
const UStaticMeshComponent* StaticMeshComp = StaticMeshActor->GetStaticMeshComponent();
if (!StaticMeshComp) { return false; }
const UStaticMesh* Mesh = StaticMeshComp->GetStaticMesh();
if (!Mesh) { return false; }
if (MaterialSource == BaseAndOverride || MaterialSource == OverrideOnly)
{
for (int32 i = 0; i < StaticMeshComp->GetNumMaterials(); i++)
{
const UMaterialInterface* Mat = StaticMeshComp->GetMaterial(i);
if (Mat && Mat->GetName().Contains(MaterialName)) { return true; }
}
}
if (MaterialSource == BaseAndOverride || MaterialSource == BaseOnly)
{
for (int32 i = 0; i < Mesh->GetStaticMaterials().Num(); i++)
{
const UMaterialInterface* Mat = Mesh->GetMaterial(i);
if (Mat && Mat->GetName().Contains(MaterialName)) { return true; }
}
}
return false;
});
UE_LOG(LogDirectiveUtilEditor, Display, TEXT("%i actors with material %s were found."), FoundActors.Num(),
*MaterialName);
FilterActorsByMaterialName(SourceActors, FoundActors, MaterialName, MaterialSource, Inclusivity);
}
void UDirectiveUtilEditorActorSubsystem::GetActorsByVertexCount(
@@ -1288,11 +1624,11 @@ void UDirectiveUtilEditorActorSubsystem::GetActorsByTextureSoftReference(
{
if (const UMaterialExpressionTextureSample* TextureSample = Cast<UMaterialExpressionTextureSample>(Expression))
{
if (TextureSample->Texture == Texture) { return true; }
if (MatchesTextureReference(TextureSample->Texture, Texture)) { return true; }
}
if (const UMaterialExpressionTextureObject* TextureObject = Cast<UMaterialExpressionTextureObject>(Expression))
{
if (TextureObject->Texture == Texture) { return true; }
if (MatchesTextureReference(TextureObject->Texture, Texture)) { return true; }
}
}
}
@@ -1341,8 +1677,7 @@ void UDirectiveUtilEditorActorSubsystem::GetActorsByTextureName(
void UDirectiveUtilEditorActorSubsystem::GetInvalidActors(TArray<AActor*>& FoundActors)
{
for (AActor* Actor : GetAllLevelActors()) { if (!IsValid(Actor)) { FoundActors.AddUnique(Actor); } }
UE_LOG(LogDirectiveUtilEditor, Display, TEXT("%i invalid actors were found."), FoundActors.Num());
FoundActors.Reset();
}
void UDirectiveUtilEditorActorSubsystem::PushOverrideMaterialsToSource(UStaticMeshComponent* StaticMeshComponent)
@@ -1361,11 +1696,14 @@ void UDirectiveUtilEditorActorSubsystem::PushOverrideMaterialsToSource(UStaticMe
}
const FScopedTransaction Transaction(NSLOCTEXT("DirectiveUtilities", "PushOverrideMaterialsToSource", "Push Override Materials To Source"));
for (int32 i = 0; i < StaticMeshComponent->GetNumMaterials(); i++)
const TArray<TObjectPtr<UMaterialInterface>>& OverrideMaterials = StaticMeshComponent->OverrideMaterials;
const int32 MaterialSlotCount = StaticMesh->GetStaticMaterials().Num();
for (int32 MaterialIndex = 0; MaterialIndex < FMath::Min(OverrideMaterials.Num(), MaterialSlotCount); ++MaterialIndex)
{
if (UMaterialInterface* Material = StaticMeshComponent->GetMaterial(i))
UMaterialInterface* OverrideMaterial = OverrideMaterials[MaterialIndex];
if (OverrideMaterial && StaticMesh->GetMaterial(MaterialIndex) != OverrideMaterial)
{
StaticMesh->SetMaterial(i, Material);
StaticMesh->SetMaterial(MaterialIndex, OverrideMaterial);
}
}
UE_LOG(LogDirectiveUtilEditor, Display, TEXT("Materials were pushed to source for %s."), *StaticMeshComponent->GetName());

View File

@@ -0,0 +1,85 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Tasks/DirectiveUtilEditorSlowTask.h"
#include "Misc/ScopedSlowTask.h"
namespace
{
UDirectiveUtilEditorSlowTask* ActiveSlowTask = nullptr;
}
void UDirectiveUtilEditorSlowTask::Initialize(
const float TotalWork,
const FText& Description,
const bool bCanCancel,
const bool bShowDialog)
{
Finish();
if (!FMath::IsFinite(TotalWork) || TotalWork <= 0.0f)
{
return;
}
if (ActiveSlowTask && ActiveSlowTask != this)
{
return;
}
SlowTask = new FScopedSlowTask(TotalWork, Description);
ActiveSlowTask = this;
AddToRoot();
if (bShowDialog)
{
SlowTask->MakeDialog(bCanCancel, true);
}
}
bool UDirectiveUtilEditorSlowTask::Advance(const float Work, const FText& Message)
{
if (!SlowTask || !FMath::IsFinite(Work) || Work < 0.0f)
{
return false;
}
const float CompletedWork = SlowTask->CompletedWork + SlowTask->CurrentFrameScope;
const float RemainingWork = FMath::Max(0.0f, SlowTask->TotalAmountOfWork - CompletedWork);
SlowTask->EnterProgressFrame(FMath::Min(Work, RemainingWork), Message);
return true;
}
bool UDirectiveUtilEditorSlowTask::IsCancelRequested() const
{
return SlowTask && SlowTask->ShouldCancel();
}
bool UDirectiveUtilEditorSlowTask::IsActive() const
{
return SlowTask != nullptr;
}
void UDirectiveUtilEditorSlowTask::Finish()
{
if (!SlowTask)
{
return;
}
delete SlowTask;
SlowTask = nullptr;
if (ActiveSlowTask == this)
{
ActiveSlowTask = nullptr;
}
if (IsRooted())
{
RemoveFromRoot();
}
}
void UDirectiveUtilEditorSlowTask::FinishActiveTask()
{
if (ActiveSlowTask)
{
ActiveSlowTask->Finish();
}
}

View File

@@ -0,0 +1,36 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#pragma once
#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "Types/DirectiveUtilEditorAuditTypes.h"
#include "DirectiveUtilEditorAssetAuditLibrary.generated.h"
UCLASS()
class DIRECTIVEUTILITIESEDITOR_API UDirectiveUtilEditorAssetAuditLibrary : public UBlueprintFunctionLibrary
{
GENERATED_BODY()
public:
/** Returns assets with no known Asset Registry referencers. Results are candidates and may still be loaded indirectly. */
UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Editor|Asset Audit")
static TArray<FAssetData> FindUnreferencedAssetCandidates(const FDirectiveUtilAssetAuditOptions& Options);
/** Returns Asset Registry dependencies whose packages cannot be found. */
UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Editor|Asset Audit")
static TArray<FDirectiveUtilMissingAssetReference> FindMissingAssetReferences(const FDirectiveUtilAssetAuditOptions& Options);
/** Returns dependency cycles between assets in the scanned paths. */
UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Editor|Asset Audit")
static TArray<FDirectiveUtilAssetDependencyCycle> FindAssetDependencyCycles(const FDirectiveUtilAssetAuditOptions& Options);
/** Builds a read-only report for assets in the scanned paths. */
UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Editor|Asset Audit")
static FDirectiveUtilAssetAuditReport BuildAssetAuditReport(const FDirectiveUtilAssetAuditOptions& Options);
/** Converts an asset audit report to CSV text. */
UFUNCTION(BlueprintPure, Category = "Directive Utilities|Editor|Asset Audit")
static FString AssetAuditReportToCsv(const FDirectiveUtilAssetAuditReport& Report);
};

View File

@@ -2,6 +2,7 @@
#pragma once
#include "AssetRegistry/AssetData.h"
#include "CoreMinimal.h"
#include "EditorAssetLibrary.h"
#include "Types/DirectiveUtilEditorAssetTypes.h"
@@ -44,7 +45,8 @@ public:
/**
* Fixes up (and deletes) object redirectors found under the given directories, without loading every asset.
* Equivalent to the Content Browser's "Fix Up Redirectors in Folder", but scriptable and headless-friendly.
* Equivalent to the Content Browser's "Fix Up Redirectors in Folder". Unreal shows a completion dialog,
* so this returns Failure in unattended sessions instead of blocking the process.
* @param DirectoryPaths Directories to scan for redirectors. If empty, the entire registry is scanned.
* @param OutRedirectorsProcessed [out] The number of redirectors submitted for fix-up (the engine does not report per-redirector success).
* @return Success if the operation ran (even if nothing needed fixing), Failure otherwise (e.g. a fixup is already in progress).

View File

@@ -0,0 +1,49 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#pragma once
#include "CoreMinimal.h"
#include "AssetRegistry/AssetData.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "Types/DirectiveUtilEditorBlueprintTypes.h"
#include "DirectiveUtilEditorBlueprintLibrary.generated.h"
class UBlueprint;
UCLASS()
class DIRECTIVEUTILITIESEDITOR_API UDirectiveUtilEditorBlueprintLibrary : public UBlueprintFunctionLibrary
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintPure, Category = "Directive Utilities|Editor|Blueprint Inspection")
static EDirectiveUtilBlueprintCompileStatus GetBlueprintCompileStatus(const UBlueprint* Blueprint);
UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Editor|Blueprint Inspection")
static TArray<FAssetData> FindBlueprintsByCompileStatus(
EDirectiveUtilBlueprintCompileStatus CompileStatus,
const FDirectiveUtilBlueprintSearchOptions& Options);
UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Editor|Blueprint Inspection")
static TArray<FAssetData> FindBlueprintsByParentClass(
UClass* ParentClass,
const FDirectiveUtilBlueprintSearchOptions& Options,
bool bIncludeDescendants = true);
UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Editor|Blueprint Inspection")
static TArray<FAssetData> FindBlueprintsImplementingInterface(
UClass* InterfaceClass,
const FDirectiveUtilBlueprintSearchOptions& Options);
UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Editor|Blueprint Inspection")
static TArray<FAssetData> FindBlueprintsContainingComponentClass(
UClass* ComponentClass,
const FDirectiveUtilBlueprintSearchOptions& Options,
bool bIncludeDerivedComponents = true);
UFUNCTION(BlueprintPure, Category = "Directive Utilities|Editor|Blueprint Inspection")
static TArray<FName> GetUnusedBlueprintVariables(
UBlueprint* Blueprint,
bool bIncludeExternallyAccessibleVariables = false);
};

View File

@@ -0,0 +1,25 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#pragma once
#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "Types/DirectiveUtilEditorTaskTypes.h"
#include "DirectiveUtilEditorTaskLibrary.generated.h"
class UDirectiveUtilEditorSlowTask;
UCLASS()
class DIRECTIVEUTILITIESEDITOR_API UDirectiveUtilEditorTaskLibrary : public UBlueprintFunctionLibrary
{
GENERATED_BODY()
public:
/** Starts a modal editor progress task. TotalWork must be greater than zero, and only one task can be active. */
UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Editor|Task")
static UDirectiveUtilEditorSlowTask* StartEditorSlowTask(float TotalWork, const FText& Description, bool bCanCancel = false);
/** Displays a fire-and-forget editor notification. */
UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Editor|Notification")
static bool ShowEditorNotification(const FText& Message, EDirectiveUtilEditorNotificationState State = EDirectiveUtilEditorNotificationState::Neutral, float ExpireDuration = 3.0f);
};

View File

@@ -11,7 +11,7 @@
class UCapsuleComponent;
class UBoxComponent;
class USphereComponent;
class UStaticMeshActor;
class AStaticMeshActor;
/**
* DirectiveUtilEditorActorSubsystem
@@ -44,6 +44,30 @@ public:
UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Editor")
TArray<UClass*> GetAllLevelClasses();
/** Aligns actor bounds to the combined minimum, center, or maximum on one axis. */
UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Editor|Actor Layout")
static FDirectiveUtilActorOperationResult AlignActors(
const TArray<AActor*>& Actors,
EDirectiveUtilActorLayoutAxis Axis,
EDirectiveUtilActorAlignment Alignment);
/** Distributes actors between the outer actors using equal center spacing or equal bounds gaps. */
UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Editor|Actor Layout")
static FDirectiveUtilActorOperationResult DistributeActors(
const TArray<AActor*>& Actors,
EDirectiveUtilActorLayoutAxis Axis,
EDirectiveUtilActorDistribution Distribution);
/** Traces from each actor and places its pivot or directional bounds on the hit surface. */
UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Editor|Actor Layout", meta = (AdvancedDisplay = "bAlignToNormal"))
static FDirectiveUtilActorOperationResult SnapActorsToSurface(
const TArray<AActor*>& Actors,
FVector TraceDirection,
float MaximumDistance,
TEnumAsByte<ECollisionChannel> TraceChannel = ECC_Visibility,
EDirectiveUtilSurfacePlacement Placement = EDirectiveUtilSurfacePlacement::Bounds,
bool bAlignToNormal = false);
//-----------------------------
// Filters
//-----------------------------
@@ -679,7 +703,7 @@ public:
* Returns a list of invalid actors.
* @param FoundActors The list of actors that were found.
*/
UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Select", meta=(AdvancedDisplay=1))
UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Select", meta=(DeprecatedFunction, DeprecationMessage="Actor enumeration excludes invalid actors. This node always returns an empty array."))
void GetInvalidActors(TArray<AActor*>& FoundActors);
//-----------------------------

View File

@@ -0,0 +1,33 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#pragma once
#include "CoreMinimal.h"
#include "DirectiveUtilEditorSlowTask.generated.h"
struct FScopedSlowTask;
UCLASS(BlueprintType)
class DIRECTIVEUTILITIESEDITOR_API UDirectiveUtilEditorSlowTask : public UObject
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Editor|Task")
bool Advance(float Work, const FText& Message);
UFUNCTION(BlueprintPure, Category = "Directive Utilities|Editor|Task")
bool IsCancelRequested() const;
UFUNCTION(BlueprintPure, Category = "Directive Utilities|Editor|Task")
bool IsActive() const;
UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Editor|Task")
void Finish();
void Initialize(float TotalWork, const FText& Description, bool bCanCancel, bool bShowDialog);
static void FinishActiveTask();
private:
FScopedSlowTask* SlowTask = nullptr;
};

View File

@@ -0,0 +1,112 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#pragma once
#include "CoreMinimal.h"
#include "AssetRegistry/AssetData.h"
#include "DirectiveUtilEditorAuditTypes.generated.h"
UENUM(BlueprintType)
enum class EDirectiveUtilAssetDependencyType : uint8
{
HardPackage UMETA(DisplayName = "Hard Package"),
SoftPackage UMETA(DisplayName = "Soft Package"),
SearchableName UMETA(DisplayName = "Searchable Name"),
DirectManagement UMETA(DisplayName = "Direct Management"),
IndirectManagement UMETA(DisplayName = "Indirect Management"),
};
USTRUCT(BlueprintType)
struct FDirectiveUtilAssetAuditOptions
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Directive Utilities|Asset Audit")
TArray<FName> PackagePaths;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Directive Utilities|Asset Audit")
TArray<FName> ExcludedPackagePaths;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Directive Utilities|Asset Audit")
bool bUseDefaultPathExclusions = true;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Directive Utilities|Asset Audit")
bool bIncludePrimaryAssets = false;
FDirectiveUtilAssetAuditOptions()
{
PackagePaths.Add(TEXT("/Game"));
}
};
USTRUCT(BlueprintType)
struct FDirectiveUtilMissingAssetReference
{
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly, Category = "Directive Utilities|Asset Audit")
FAssetData ReferencingAsset;
UPROPERTY(BlueprintReadOnly, Category = "Directive Utilities|Asset Audit")
FName MissingPackage;
UPROPERTY(BlueprintReadOnly, Category = "Directive Utilities|Asset Audit")
EDirectiveUtilAssetDependencyType DependencyType = EDirectiveUtilAssetDependencyType::SoftPackage;
};
USTRUCT(BlueprintType)
struct FDirectiveUtilAssetDependencyCycle
{
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly, Category = "Directive Utilities|Asset Audit")
TArray<FName> Packages;
};
USTRUCT(BlueprintType)
struct FDirectiveUtilAssetAuditEntry
{
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly, Category = "Directive Utilities|Asset Audit")
FAssetData Asset;
UPROPERTY(BlueprintReadOnly, Category = "Directive Utilities|Asset Audit")
FName PackageName;
UPROPERTY(BlueprintReadOnly, Category = "Directive Utilities|Asset Audit")
FName PackagePath;
UPROPERTY(BlueprintReadOnly, Category = "Directive Utilities|Asset Audit")
FString AssetClass;
UPROPERTY(BlueprintReadOnly, Category = "Directive Utilities|Asset Audit")
int64 DiskSize = 0;
UPROPERTY(BlueprintReadOnly, Category = "Directive Utilities|Asset Audit")
int32 DependencyCount = 0;
UPROPERTY(BlueprintReadOnly, Category = "Directive Utilities|Asset Audit")
int32 ReferencerCount = 0;
UPROPERTY(BlueprintReadOnly, Category = "Directive Utilities|Asset Audit")
bool bPrimaryAsset = false;
UPROPERTY(BlueprintReadOnly, Category = "Directive Utilities|Asset Audit")
TArray<FString> Findings;
};
USTRUCT(BlueprintType)
struct FDirectiveUtilAssetAuditReport
{
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly, Category = "Directive Utilities|Asset Audit")
TArray<FDirectiveUtilAssetAuditEntry> Assets;
UPROPERTY(BlueprintReadOnly, Category = "Directive Utilities|Asset Audit")
TArray<FDirectiveUtilMissingAssetReference> MissingReferences;
UPROPERTY(BlueprintReadOnly, Category = "Directive Utilities|Asset Audit")
TArray<FDirectiveUtilAssetDependencyCycle> DependencyCycles;
};

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#pragma once
#include "CoreMinimal.h"
#include "DirectiveUtilEditorBlueprintTypes.generated.h"
UENUM(BlueprintType)
enum class EDirectiveUtilBlueprintCompileStatus : uint8
{
Unknown,
Dirty,
Error,
UpToDate UMETA(DisplayName = "Up To Date"),
BeingCreated UMETA(DisplayName = "Being Created"),
UpToDateWithWarnings UMETA(DisplayName = "Up To Date With Warnings"),
};
USTRUCT(BlueprintType)
struct FDirectiveUtilBlueprintSearchOptions
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Directive Utilities|Blueprint Inspection")
TArray<FName> PackagePaths;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Directive Utilities|Blueprint Inspection")
TArray<FName> ExcludedPackagePaths;
FDirectiveUtilBlueprintSearchOptions()
{
PackagePaths.Add(TEXT("/Game"));
}
};

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#pragma once
#include "DirectiveUtilEditorTaskTypes.generated.h"
UENUM(BlueprintType)
enum class EDirectiveUtilEditorNotificationState : uint8
{
Neutral,
Success,
Warning,
Failure
};

View File

@@ -3,6 +3,51 @@
#pragma once
#include "CoreMinimal.h"
#include "DirectiveUtilEditorTypes.generated.h"
class AActor;
UENUM(BlueprintType)
enum class EDirectiveUtilActorLayoutAxis : uint8
{
X,
Y,
Z,
};
UENUM(BlueprintType)
enum class EDirectiveUtilActorAlignment : uint8
{
Minimum,
Center,
Maximum,
};
UENUM(BlueprintType)
enum class EDirectiveUtilActorDistribution : uint8
{
Centers,
BoundsGaps UMETA(DisplayName = "Bounds Gaps"),
};
UENUM(BlueprintType)
enum class EDirectiveUtilSurfacePlacement : uint8
{
Pivot,
Bounds,
};
USTRUCT(BlueprintType)
struct FDirectiveUtilActorOperationResult
{
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly, Category = "Directive Utilities|Actor Layout")
TArray<TObjectPtr<AActor>> ChangedActors;
UPROPERTY(BlueprintReadOnly, Category = "Directive Utilities|Actor Layout")
TArray<TObjectPtr<AActor>> SkippedActors;
};
/**
* EDirectiveUtilSelectionMethod

View File

@@ -13,6 +13,8 @@ public class DirectiveUtilitiesRuntime : ModuleRules
new string[]
{
"Core",
"CoreUObject",
"Engine",
"GameplayTags",
"NetCore",
}
@@ -22,11 +24,10 @@ public class DirectiveUtilitiesRuntime : ModuleRules
PrivateDependencyModuleNames.AddRange(
new string[]
{
"CoreUObject",
"Engine",
"Slate",
"SlateCore",
"AIModule",
"NavigationSystem",
"EnhancedInput",
"ApplicationCore",
}

View File

@@ -1,4 +1,6 @@
#include "DirectiveUtilLogChannels.h"
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "DirectiveUtilLogChannels.h"
DEFINE_LOG_CATEGORY(LogDirectiveUtil);
DEFINE_LOG_CATEGORY(LogDirectiveUtilEditor);

View File

@@ -2,9 +2,92 @@
#include "Libraries/DirectiveUtilFunctionLibrary.h"
#include "Engine/World.h"
#include "HAL/CriticalSection.h"
#include "HAL/PlatformApplicationMisc.h"
#include "HAL/PlatformTime.h"
#include "Misc/App.h"
#include "Misc/CommandLine.h"
#include "Misc/ConfigCacheIni.h"
#include "Misc/ScopeLock.h"
namespace
{
struct FDirectiveUtilStopwatchState
{
FCriticalSection Lock;
TMap<FName, double> StartTimesByKey;
};
FDirectiveUtilStopwatchState StopwatchState;
EDirectiveUtilWorldType ToDirectiveWorldType(const EWorldType::Type WorldType)
{
switch (WorldType)
{
case EWorldType::None:
return EDirectiveUtilWorldType::None;
case EWorldType::Game:
return EDirectiveUtilWorldType::Game;
case EWorldType::Editor:
return EDirectiveUtilWorldType::Editor;
case EWorldType::PIE:
return EDirectiveUtilWorldType::PlayInEditor;
case EWorldType::EditorPreview:
return EDirectiveUtilWorldType::EditorPreview;
case EWorldType::GamePreview:
return EDirectiveUtilWorldType::GamePreview;
case EWorldType::GameRPC:
return EDirectiveUtilWorldType::GameRPC;
case EWorldType::Inactive:
return EDirectiveUtilWorldType::Inactive;
}
return EDirectiveUtilWorldType::Unknown;
}
EDirectiveUtilBuildConfiguration ToDirectiveBuildConfiguration(const EBuildConfiguration BuildConfiguration)
{
switch (BuildConfiguration)
{
case EBuildConfiguration::Unknown:
return EDirectiveUtilBuildConfiguration::Unknown;
case EBuildConfiguration::Debug:
return EDirectiveUtilBuildConfiguration::Debug;
case EBuildConfiguration::DebugGame:
return EDirectiveUtilBuildConfiguration::DebugGame;
case EBuildConfiguration::Development:
return EDirectiveUtilBuildConfiguration::Development;
case EBuildConfiguration::Shipping:
return EDirectiveUtilBuildConfiguration::Shipping;
case EBuildConfiguration::Test:
return EDirectiveUtilBuildConfiguration::Test;
}
return EDirectiveUtilBuildConfiguration::Unknown;
}
EDirectiveUtilBuildTargetType ToDirectiveBuildTargetType(const EBuildTargetType BuildTargetType)
{
switch (BuildTargetType)
{
case EBuildTargetType::Unknown:
return EDirectiveUtilBuildTargetType::Unknown;
case EBuildTargetType::Game:
return EDirectiveUtilBuildTargetType::Game;
case EBuildTargetType::Server:
return EDirectiveUtilBuildTargetType::Server;
case EBuildTargetType::Client:
return EDirectiveUtilBuildTargetType::Client;
case EBuildTargetType::Editor:
return EDirectiveUtilBuildTargetType::Editor;
case EBuildTargetType::Program:
return EDirectiveUtilBuildTargetType::Program;
}
return EDirectiveUtilBuildTargetType::Unknown;
}
}
void UDirectiveUtilFunctionLibrary::GetChildClasses(const UClass* BaseClass, const bool bRecursive, TArray<UClass*>& DerivedClasses)
{
@@ -57,6 +140,22 @@ bool UDirectiveUtilFunctionLibrary::IsRunningInEditor()
return GIsEditor;
}
EDirectiveUtilWorldType UDirectiveUtilFunctionLibrary::GetWorldType(const UObject* WorldContextObject)
{
const UWorld* World = WorldContextObject ? WorldContextObject->GetWorld() : nullptr;
return World ? ToDirectiveWorldType(World->WorldType) : EDirectiveUtilWorldType::Unknown;
}
EDirectiveUtilBuildConfiguration UDirectiveUtilFunctionLibrary::GetBuildConfigurationType()
{
return ToDirectiveBuildConfiguration(FApp::GetBuildConfiguration());
}
EDirectiveUtilBuildTargetType UDirectiveUtilFunctionLibrary::GetBuildTargetType()
{
return ToDirectiveBuildTargetType(FApp::GetBuildTargetType());
}
bool UDirectiveUtilFunctionLibrary::HasCommandLineSwitch(const FString& Switch)
{
return HasCommandLineSwitch(FCommandLine::Get(), Switch);
@@ -67,6 +166,44 @@ bool UDirectiveUtilFunctionLibrary::GetCommandLineOption(const FString& Key, FSt
return GetCommandLineOption(FCommandLine::Get(), Key, OutValue);
}
bool UDirectiveUtilFunctionLibrary::StartStopwatch(const FName Key, const bool bRestartIfRunning)
{
if (Key.IsNone())
{
return false;
}
FScopeLock Lock(&StopwatchState.Lock);
if (!bRestartIfRunning && StopwatchState.StartTimesByKey.Contains(Key))
{
return false;
}
StopwatchState.StartTimesByKey.Add(Key, FPlatformTime::Seconds());
return true;
}
bool UDirectiveUtilFunctionLibrary::StopStopwatch(const FName Key, double& ElapsedMilliseconds)
{
ElapsedMilliseconds = 0.0;
if (Key.IsNone())
{
return false;
}
double StartTime = 0.0;
{
FScopeLock Lock(&StopwatchState.Lock);
if (!StopwatchState.StartTimesByKey.RemoveAndCopyValue(Key, StartTime))
{
return false;
}
}
ElapsedMilliseconds = FMath::Max((FPlatformTime::Seconds() - StartTime) * 1000.0, 0.0);
return true;
}
bool UDirectiveUtilFunctionLibrary::HasCommandLineSwitch(const TCHAR* CommandLine, const FString& Switch)
{
if (Switch.IsEmpty())

View File

@@ -27,13 +27,26 @@ FGameplayTagContainer UDirectiveUtilGameplayTagFunctionLibrary::GetTagParents(co
int32 UDirectiveUtilGameplayTagFunctionLibrary::GetTagDepth(const FGameplayTag& Tag)
{
return GetTagSegments(Tag).Num();
int32 Depth = 0;
for (FGameplayTag Current = Tag; Current.IsValid(); Current = Current.RequestDirectParent())
{
++Depth;
}
return Depth;
}
FString UDirectiveUtilGameplayTagFunctionLibrary::GetTagLeafName(const FGameplayTag& Tag)
{
const TArray<FString> Segments = GetTagSegments(Tag);
return Segments.Num() > 0 ? Segments.Last() : FString();
if (!Tag.IsValid())
{
return FString();
}
const FString TagString = Tag.GetTagName().ToString();
int32 SeparatorIndex = INDEX_NONE;
return TagString.FindLastChar(TEXT('.'), SeparatorIndex)
? TagString.Mid(SeparatorIndex + 1)
: TagString;
}
TArray<FString> UDirectiveUtilGameplayTagFunctionLibrary::GetTagSegments(const FGameplayTag& Tag)
@@ -64,10 +77,9 @@ FGameplayTagContainer UDirectiveUtilGameplayTagFunctionLibrary::GetTagDirectChil
return DirectChildren;
}
const int32 DirectChildDepth = GetTagDepth(Tag) + 1;
for (const FGameplayTag& Child : GetTagChildren(Tag))
{
if (GetTagDepth(Child) == DirectChildDepth)
if (Child.RequestDirectParent() == Tag)
{
DirectChildren.AddTag(Child);
}
@@ -77,29 +89,31 @@ FGameplayTagContainer UDirectiveUtilGameplayTagFunctionLibrary::GetTagDirectChil
FGameplayTag UDirectiveUtilGameplayTagFunctionLibrary::GetTagCommonAncestor(const FGameplayTag& TagA, const FGameplayTag& TagB)
{
const TArray<FString> SegmentsA = GetTagSegments(TagA);
const TArray<FString> SegmentsB = GetTagSegments(TagB);
FString Prefix;
for (int32 Index = 0; Index < SegmentsA.Num() && Index < SegmentsB.Num(); ++Index)
{
if (!SegmentsA[Index].Equals(SegmentsB[Index]))
{
break;
}
if (!Prefix.IsEmpty())
{
Prefix += TEXT(".");
}
Prefix += SegmentsA[Index];
}
if (Prefix.IsEmpty())
if (!TagA.IsValid() || !TagB.IsValid())
{
return FGameplayTag();
}
// A common prefix of two registered tags is itself registered (parents auto-register).
return FGameplayTag::RequestGameplayTag(FName(*Prefix), false);
FGameplayTag AncestorA = TagA;
FGameplayTag AncestorB = TagB;
int32 DepthA = GetTagDepth(AncestorA);
int32 DepthB = GetTagDepth(AncestorB);
while (DepthA > DepthB)
{
AncestorA = AncestorA.RequestDirectParent();
--DepthA;
}
while (DepthB > DepthA)
{
AncestorB = AncestorB.RequestDirectParent();
--DepthB;
}
while (AncestorA.IsValid() && AncestorB.IsValid() && AncestorA != AncestorB)
{
AncestorA = AncestorA.RequestDirectParent();
AncestorB = AncestorB.RequestDirectParent();
}
return AncestorA == AncestorB ? AncestorA : FGameplayTag();
}
FGameplayTag UDirectiveUtilGameplayTagFunctionLibrary::GetTagAtDepth(const FGameplayTag& Tag, const int32 Depth)
@@ -109,20 +123,18 @@ FGameplayTag UDirectiveUtilGameplayTagFunctionLibrary::GetTagAtDepth(const FGame
return FGameplayTag();
}
const TArray<FString> Segments = GetTagSegments(Tag);
if (Depth >= Segments.Num())
const int32 TagDepth = GetTagDepth(Tag);
if (Depth >= TagDepth)
{
return Tag;
}
FString Prefix = Segments[0];
for (int32 Index = 1; Index < Depth; ++Index)
FGameplayTag Ancestor = Tag;
for (int32 CurrentDepth = TagDepth; CurrentDepth > Depth; --CurrentDepth)
{
Prefix += TEXT(".");
Prefix += Segments[Index];
Ancestor = Ancestor.RequestDirectParent();
}
// An ancestor of a registered tag is always registered itself (parents auto-register).
return FGameplayTag::RequestGameplayTag(FName(*Prefix), false);
return Ancestor;
}
FGameplayTagContainer UDirectiveUtilGameplayTagFunctionLibrary::GetTagSiblings(const FGameplayTag& Tag)

View File

@@ -77,8 +77,11 @@ EDirectiveUtilSuccessStatus UDirectiveUtilInputFunctionLibrary::AddInputMappingC
if (FailedIndices.Num() > 0)
{
FString FailedIndicesStr = FString::JoinBy(FailedIndices, TEXT(", "), [](const int32 Index) { return FString::Printf(TEXT("%d"), Index); });
UE_LOGFMT(LogDirectiveUtil, Warning, "{FailedIndicies} Input Mapping Contexts failed to load and were not added! The failed indexes are [{FailedIndicieIndexes}]", FailedIndices.Num(), FailedIndicesStr);
const FString FailedIndicesString = FString::JoinBy(
FailedIndices, TEXT(", "), [](const int32 Index) { return FString::FromInt(Index); });
UE_LOGFMT(LogDirectiveUtil, Warning,
"{FailedContextCount} input mapping contexts could not be loaded. Indexes: [{FailedContextIndexes}]",
FailedIndices.Num(), FailedIndicesString);
}
if (LoadedContexts.IsEmpty())
@@ -104,7 +107,7 @@ EDirectiveUtilSuccessStatus UDirectiveUtilInputFunctionLibrary::RemoveInputMappi
AController* PlayerController,
const TArray<TSoftObjectPtr<UInputMappingContext>>& Contexts)
{
if (Contexts.IsEmpty()) { return EDirectiveUtilSuccessStatus::Failure;; }
if (Contexts.IsEmpty()) { return EDirectiveUtilSuccessStatus::Failure; }
UEnhancedInputLocalPlayerSubsystem* EnhancedInput;
const bool bEnhancedInputRetrievedFromController = TryGetEnhancedInputSubsystemFromController(PlayerController, EnhancedInput);
@@ -118,10 +121,11 @@ EDirectiveUtilSuccessStatus UDirectiveUtilInputFunctionLibrary::RemoveInputMappi
for (int32 Index = 0; Index < Contexts.Num(); ++Index)
{
const TSoftObjectPtr<UInputMappingContext>& Context = Contexts[Index];
if (const UInputMappingContext* MappingContext = Context.LoadSynchronous())
if (const UInputMappingContext* MappingContext = Context.Get())
{
EnhancedInput->RemoveMappingContext(MappingContext);
} else
}
else
{
FailedIndices.Add(Index);
}
@@ -129,8 +133,11 @@ EDirectiveUtilSuccessStatus UDirectiveUtilInputFunctionLibrary::RemoveInputMappi
if (FailedIndices.Num() > 0)
{
FString FailedIndicesStr = FString::JoinBy(FailedIndices, TEXT(", "), [](const int32 Index) { return FString::Printf(TEXT("%d"), Index); });
UE_LOGFMT(LogDirectiveUtil, Warning, "{FailedIndicies} Input Mapping Contexts failed to load and were not removed! The failed indexes are [{FailedIndicieIndexes}]", FailedIndices.Num(), FailedIndicesStr);
const FString FailedIndicesString = FString::JoinBy(
FailedIndices, TEXT(", "), [](const int32 Index) { return FString::FromInt(Index); });
UE_LOGFMT(LogDirectiveUtil, Warning,
"{FailedContextCount} input mapping contexts were not loaded and could not be removed. Indexes: [{FailedContextIndexes}]",
FailedIndices.Num(), FailedIndicesString);
}
if (FailedIndices.Num() == Contexts.Num())
@@ -143,28 +150,27 @@ EDirectiveUtilSuccessStatus UDirectiveUtilInputFunctionLibrary::RemoveInputMappi
}
EDirectiveUtilSuccessStatus UDirectiveUtilInputFunctionLibrary::SwapInputMappingContexts(
AController* PlayerController,
const TSoftObjectPtr<UInputMappingContext> PreviousContext,
const TSoftObjectPtr<UInputMappingContext> NewContext,
const int32 Priority,
const bool bUsePreviousPriority)
AController* PlayerController,
const TSoftObjectPtr<UInputMappingContext> PreviousContext,
const TSoftObjectPtr<UInputMappingContext> NewContext,
const int32 Priority,
const bool bUsePreviousPriority)
{
const UInputMappingContext* LoadedPreviousMappingContext = PreviousContext.LoadSynchronous();
const UInputMappingContext* LoadedNewMappingContext = NewContext.LoadSynchronous();
if (!LoadedPreviousMappingContext || !LoadedNewMappingContext)
{
UE_LOGFMT(LogDirectiveUtil, Warning, "Both the previous and new input mapping contexts must be valid.");
return EDirectiveUtilSuccessStatus::Failure;
}
UEnhancedInputLocalPlayerSubsystem* EnhancedInput;
if (!TryGetEnhancedInputSubsystemFromController(PlayerController, EnhancedInput))
{
return EDirectiveUtilSuccessStatus::Failure;
}
if (int32 PreviousPriority; EnhancedInput->HasMappingContext(LoadedPreviousMappingContext, PreviousPriority))
const UInputMappingContext* LoadedNewMappingContext = NewContext.LoadSynchronous();
if (!LoadedNewMappingContext)
{
UE_LOGFMT(LogDirectiveUtil, Warning, "The new input mapping context could not be loaded.");
return EDirectiveUtilSuccessStatus::Failure;
}
const UInputMappingContext* LoadedPreviousMappingContext = PreviousContext.Get();
if (int32 PreviousPriority; LoadedPreviousMappingContext && EnhancedInput->HasMappingContext(LoadedPreviousMappingContext, PreviousPriority))
{
const int32 TargetPriority = bUsePreviousPriority ? PreviousPriority : Priority;
EnhancedInput->RemoveMappingContext(LoadedPreviousMappingContext);
@@ -174,7 +180,7 @@ EDirectiveUtilSuccessStatus UDirectiveUtilInputFunctionLibrary::SwapInputMapping
else
{
EnhancedInput->AddMappingContext(LoadedNewMappingContext, Priority);
UE_LOGFMT(LogDirectiveUtil, Warning, "Previous input mapping context {PreviousContext} not found. New context {NewContext} added at priority {BackupPriority}.", LoadedPreviousMappingContext->GetName(), LoadedNewMappingContext->GetName(), Priority);
UE_LOGFMT(LogDirectiveUtil, Verbose, "Previous input mapping context was not active. New context {NewContext} added at priority {BackupPriority}.", LoadedNewMappingContext->GetName(), Priority);
}
return EDirectiveUtilSuccessStatus::Success;
@@ -189,7 +195,7 @@ UEnhancedInputLocalPlayerSubsystem* UDirectiveUtilInputFunctionLibrary::GetEnhan
bool UDirectiveUtilInputFunctionLibrary::IsInputMappingContextActive(AController* PlayerController, TSoftObjectPtr<UInputMappingContext> Context)
{
const UInputMappingContext* MappingContext = Context.LoadSynchronous();
const UInputMappingContext* MappingContext = Context.Get();
if (!MappingContext)
{
return false;

View File

@@ -179,7 +179,6 @@ void UDirectiveUtilMapFunctionLibrary::GenericMap_Append(
return;
}
// Appending a map onto itself is a no-op; bail before AddPair can reallocate under the source pointers.
if (TargetMap == SourceMap)
{
return;

View File

@@ -0,0 +1,214 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilMathFunctionLibrary.h"
namespace
{
double EaseBackIn(double t)
{
if (t <= 0.0) { return 0.0; }
if (t >= 1.0) { return 1.0; }
const double s = 1.70158;
return t * t * ((s + 1.0) * t - s);
}
double EaseBackOut(double t)
{
if (t <= 0.0) { return 0.0; }
if (t >= 1.0) { return 1.0; }
const double s = 1.70158;
t -= 1.0;
return t * t * ((s + 1.0) * t + s) + 1.0;
}
double EaseBackInOut(double t)
{
if (t <= 0.0) { return 0.0; }
if (t >= 1.0) { return 1.0; }
const double s = 1.70158 * 1.525;
t *= 2.0;
if (t < 1.0)
{
return 0.5 * (t * t * ((s + 1.0) * t - s));
}
t -= 2.0;
return 0.5 * (t * t * ((s + 1.0) * t + s) + 2.0);
}
double EaseElasticIn(double t)
{
if (t <= 0.0) { return 0.0; }
if (t >= 1.0) { return 1.0; }
const double p = 0.3;
const double s = p / 4.0;
t -= 1.0;
return -(FMath::Pow(2.0, 10.0 * t) * FMath::Sin((t - s) * (2.0 * PI) / p));
}
double EaseElasticOut(double t)
{
if (t <= 0.0) { return 0.0; }
if (t >= 1.0) { return 1.0; }
const double p = 0.3;
const double s = p / 4.0;
return FMath::Pow(2.0, -10.0 * t) * FMath::Sin((t - s) * (2.0 * PI) / p) + 1.0;
}
double EaseElasticInOut(double t)
{
if (t <= 0.0) { return 0.0; }
if (t >= 1.0) { return 1.0; }
const double p = 0.3 * 1.5;
const double s = p / 4.0;
t *= 2.0;
if (t < 1.0)
{
t -= 1.0;
return -0.5 * (FMath::Pow(2.0, 10.0 * t) * FMath::Sin((t - s) * (2.0 * PI) / p));
}
t -= 1.0;
return FMath::Pow(2.0, -10.0 * t) * FMath::Sin((t - s) * (2.0 * PI) / p) * 0.5 + 1.0;
}
double EaseBounceOut(double t)
{
const double n1 = 7.5625;
const double d1 = 2.75;
if (t < 1.0 / d1)
{
return n1 * t * t;
}
if (t < 2.0 / d1)
{
t -= 1.5 / d1;
return n1 * t * t + 0.75;
}
if (t < 2.5 / d1)
{
t -= 2.25 / d1;
return n1 * t * t + 0.9375;
}
t -= 2.625 / d1;
return n1 * t * t + 0.984375;
}
double EaseBounceIn(double t)
{
return 1.0 - EaseBounceOut(1.0 - t);
}
double EaseBounceInOut(double t)
{
return t < 0.5
? (1.0 - EaseBounceOut(1.0 - 2.0 * t)) * 0.5
: (1.0 + EaseBounceOut(2.0 * t - 1.0)) * 0.5;
}
FTransform BlendEasedTransforms(const FTransform& A, const FTransform& B, const double Alpha)
{
FQuat Rotation = FQuat::Slerp(A.GetRotation(), B.GetRotation(), Alpha);
Rotation.Normalize();
return FTransform(Rotation,
FMath::Lerp(A.GetLocation(), B.GetLocation(), Alpha),
FMath::Lerp(A.GetScale3D(), B.GetScale3D(), Alpha));
}
template <typename ValueType, typename BlendType>
TArray<ValueType> EaseArrays(const TArray<ValueType>& From, const TArray<ValueType>& To, const float Alpha,
const EDirectiveUtilEaseType EaseType, const TArray<float>& PerElementAlphas, BlendType Blend)
{
const bool bPerElement = !PerElementAlphas.IsEmpty();
if (From.Num() != To.Num() || !FMath::IsFinite(Alpha)
|| (bPerElement && PerElementAlphas.Num() != From.Num()))
{
return {};
}
const float SharedEasedAlpha = UDirectiveUtilMathFunctionLibrary::EaseAlpha(Alpha, EaseType);
TArray<ValueType> Result;
Result.SetNumUninitialized(From.Num());
for (int32 Index = 0; Index < From.Num(); ++Index)
{
float EasedAlpha = SharedEasedAlpha;
if (bPerElement)
{
if (!FMath::IsFinite(PerElementAlphas[Index]))
{
return {};
}
EasedAlpha = UDirectiveUtilMathFunctionLibrary::EaseAlpha(PerElementAlphas[Index], EaseType);
}
if (From[Index].ContainsNaN() || To[Index].ContainsNaN())
{
return {};
}
Result[Index] = Blend(From[Index], To[Index], static_cast<double>(EasedAlpha));
}
return Result;
}
}
float UDirectiveUtilMathFunctionLibrary::EaseAlpha(const float Alpha, const EDirectiveUtilEaseType EaseType)
{
const double t = static_cast<double>(FMath::Clamp(Alpha, 0.0f, 1.0f));
switch (EaseType)
{
case EDirectiveUtilEaseType::BackIn: return static_cast<float>(EaseBackIn(t));
case EDirectiveUtilEaseType::BackOut: return static_cast<float>(EaseBackOut(t));
case EDirectiveUtilEaseType::BackInOut: return static_cast<float>(EaseBackInOut(t));
case EDirectiveUtilEaseType::ElasticIn: return static_cast<float>(EaseElasticIn(t));
case EDirectiveUtilEaseType::ElasticOut: return static_cast<float>(EaseElasticOut(t));
case EDirectiveUtilEaseType::ElasticInOut: return static_cast<float>(EaseElasticInOut(t));
case EDirectiveUtilEaseType::BounceIn: return static_cast<float>(EaseBounceIn(t));
case EDirectiveUtilEaseType::BounceOut: return static_cast<float>(EaseBounceOut(t));
case EDirectiveUtilEaseType::BounceInOut: return static_cast<float>(EaseBounceInOut(t));
case EDirectiveUtilEaseType::Linear:
default: return static_cast<float>(t);
}
}
float UDirectiveUtilMathFunctionLibrary::EaseFloat(const float A, const float B, const float Alpha, const EDirectiveUtilEaseType EaseType)
{
return FMath::Lerp(A, B, EaseAlpha(Alpha, EaseType));
}
FVector UDirectiveUtilMathFunctionLibrary::EaseVector(const FVector& A, const FVector& B, const float Alpha, const EDirectiveUtilEaseType EaseType)
{
return FMath::Lerp(A, B, static_cast<double>(EaseAlpha(Alpha, EaseType)));
}
FRotator UDirectiveUtilMathFunctionLibrary::EaseRotator(const FRotator& A, const FRotator& B, const float Alpha, const EDirectiveUtilEaseType EaseType)
{
return FQuat::Slerp(A.Quaternion(), B.Quaternion(), EaseAlpha(Alpha, EaseType)).Rotator();
}
FLinearColor UDirectiveUtilMathFunctionLibrary::EaseColor(const FLinearColor& A, const FLinearColor& B, const float Alpha, const EDirectiveUtilEaseType EaseType)
{
return FMath::Lerp(A, B, EaseAlpha(Alpha, EaseType));
}
FTransform UDirectiveUtilMathFunctionLibrary::EaseTransform(const FTransform& A, const FTransform& B,
const float Alpha, const EDirectiveUtilEaseType EaseType)
{
return BlendEasedTransforms(A, B, static_cast<double>(EaseAlpha(Alpha, EaseType)));
}
TArray<FVector> UDirectiveUtilMathFunctionLibrary::EaseLocationArrays(const TArray<FVector>& From,
const TArray<FVector>& To, const float Alpha, const EDirectiveUtilEaseType EaseType,
const TArray<float>& PerElementAlphas)
{
return EaseArrays(From, To, Alpha, EaseType, PerElementAlphas,
[](const FVector& A, const FVector& B, const double EasedAlpha)
{
return FMath::Lerp(A, B, EasedAlpha);
});
}
TArray<FTransform> UDirectiveUtilMathFunctionLibrary::EaseTransformArrays(const TArray<FTransform>& From,
const TArray<FTransform>& To, const float Alpha, const EDirectiveUtilEaseType EaseType,
const TArray<float>& PerElementAlphas)
{
return EaseArrays(From, To, Alpha, EaseType, PerElementAlphas, &BlendEasedTransforms);
}

View File

@@ -0,0 +1,219 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilMathFunctionLibrary.h"
namespace
{
float GetUsableRandomWeight(const float Weight)
{
return FMath::IsFinite(Weight) && Weight > 0.0f ? Weight : 0.0f;
}
FVector2D MakeRandomPointInAnnulus(const double InnerRadius, const double OuterRadius,
const double AngleSample, const double RadiusSample)
{
const double Angle = AngleSample * UE_TWO_PI;
const double Radius = FMath::Sqrt(FMath::Lerp(
InnerRadius * InnerRadius,
OuterRadius * OuterRadius,
RadiusSample));
return FVector2D(FMath::Cos(Angle) * Radius, FMath::Sin(Angle) * Radius);
}
template <typename RandomFractionFunction>
FVector MakeRandomPointInSphere(const double Radius, RandomFractionFunction&& RandomFraction)
{
FVector Point;
double SizeSquared;
do
{
const double X = static_cast<double>(RandomFraction()) * 2.0 - 1.0;
const double Y = static_cast<double>(RandomFraction()) * 2.0 - 1.0;
const double Z = static_cast<double>(RandomFraction()) * 2.0 - 1.0;
Point = FVector(X, Y, Z);
SizeSquared = Point.SizeSquared();
}
while (SizeSquared > 1.0);
return Point * Radius;
}
}
int32 UDirectiveUtilMathFunctionLibrary::GetRandomIndexFromWeights(const TArray<float>& Weights)
{
double Total = 0.0;
for (const float Weight : Weights)
{
Total += GetUsableRandomWeight(Weight);
}
if (Total <= 0.0)
{
return INDEX_NONE;
}
const double Roll = static_cast<double>(FMath::FRand()) * Total;
double Accumulated = 0.0;
int32 LastPositiveIndex = INDEX_NONE;
for (int32 Index = 0; Index < Weights.Num(); ++Index)
{
const float Weight = GetUsableRandomWeight(Weights[Index]);
if (Weight <= 0.0f)
{
continue;
}
LastPositiveIndex = Index;
Accumulated += Weight;
if (Roll < Accumulated)
{
return Index;
}
}
return LastPositiveIndex;
}
int32 UDirectiveUtilMathFunctionLibrary::GetRandomIndexFromWeightsFromStream(FRandomStream& Stream, const TArray<float>& Weights)
{
double Total = 0.0;
for (const float Weight : Weights)
{
Total += GetUsableRandomWeight(Weight);
}
if (Total <= 0.0)
{
return INDEX_NONE;
}
const double Roll = static_cast<double>(Stream.FRand()) * Total;
double Accumulated = 0.0;
int32 LastPositiveIndex = INDEX_NONE;
for (int32 Index = 0; Index < Weights.Num(); ++Index)
{
const float Weight = GetUsableRandomWeight(Weights[Index]);
if (Weight <= 0.0f)
{
continue;
}
LastPositiveIndex = Index;
Accumulated += Weight;
if (Roll < Accumulated)
{
return Index;
}
}
return LastPositiveIndex;
}
FVector2D UDirectiveUtilMathFunctionLibrary::RandomPointInCircle(const float Radius)
{
if (!FMath::IsFinite(Radius))
{
return FVector2D::ZeroVector;
}
const double AbsoluteRadius = FMath::Abs(static_cast<double>(Radius));
if (AbsoluteRadius == 0.0)
{
return FVector2D::ZeroVector;
}
const double AngleSample = FMath::FRand();
const double RadiusSample = FMath::FRand();
return MakeRandomPointInAnnulus(0.0, AbsoluteRadius, AngleSample, RadiusSample);
}
FVector2D UDirectiveUtilMathFunctionLibrary::RandomPointInCircleFromStream(FRandomStream& Stream, const float Radius)
{
if (!FMath::IsFinite(Radius))
{
return FVector2D::ZeroVector;
}
const double AbsoluteRadius = FMath::Abs(static_cast<double>(Radius));
if (AbsoluteRadius == 0.0)
{
return FVector2D::ZeroVector;
}
const double AngleSample = Stream.FRand();
const double RadiusSample = Stream.FRand();
return MakeRandomPointInAnnulus(0.0, AbsoluteRadius, AngleSample, RadiusSample);
}
FVector2D UDirectiveUtilMathFunctionLibrary::RandomPointInAnnulus(const float InnerRadius, const float OuterRadius)
{
if (!FMath::IsFinite(InnerRadius) || !FMath::IsFinite(OuterRadius))
{
return FVector2D::ZeroVector;
}
const double FirstRadius = FMath::Abs(static_cast<double>(InnerRadius));
const double SecondRadius = FMath::Abs(static_cast<double>(OuterRadius));
const double Inner = FMath::Min(FirstRadius, SecondRadius);
const double Outer = FMath::Max(FirstRadius, SecondRadius);
if (Outer == 0.0)
{
return FVector2D::ZeroVector;
}
const double AngleSample = FMath::FRand();
const double RadiusSample = FMath::FRand();
return MakeRandomPointInAnnulus(Inner, Outer, AngleSample, RadiusSample);
}
FVector2D UDirectiveUtilMathFunctionLibrary::RandomPointInAnnulusFromStream(FRandomStream& Stream,
const float InnerRadius, const float OuterRadius)
{
if (!FMath::IsFinite(InnerRadius) || !FMath::IsFinite(OuterRadius))
{
return FVector2D::ZeroVector;
}
const double FirstRadius = FMath::Abs(static_cast<double>(InnerRadius));
const double SecondRadius = FMath::Abs(static_cast<double>(OuterRadius));
const double Inner = FMath::Min(FirstRadius, SecondRadius);
const double Outer = FMath::Max(FirstRadius, SecondRadius);
if (Outer == 0.0)
{
return FVector2D::ZeroVector;
}
const double AngleSample = Stream.FRand();
const double RadiusSample = Stream.FRand();
return MakeRandomPointInAnnulus(Inner, Outer, AngleSample, RadiusSample);
}
FVector UDirectiveUtilMathFunctionLibrary::RandomPointInSphere(const float Radius)
{
if (!FMath::IsFinite(Radius))
{
return FVector::ZeroVector;
}
const double AbsoluteRadius = FMath::Abs(static_cast<double>(Radius));
if (AbsoluteRadius == 0.0)
{
return FVector::ZeroVector;
}
return MakeRandomPointInSphere(AbsoluteRadius, []
{
return FMath::FRand();
});
}
FVector UDirectiveUtilMathFunctionLibrary::RandomPointInSphereFromStream(FRandomStream& Stream, const float Radius)
{
if (!FMath::IsFinite(Radius))
{
return FVector::ZeroVector;
}
const double AbsoluteRadius = FMath::Abs(static_cast<double>(Radius));
if (AbsoluteRadius == 0.0)
{
return FVector::ZeroVector;
}
return MakeRandomPointInSphere(AbsoluteRadius, [&Stream]
{
return Stream.FRand();
});
}

View File

@@ -0,0 +1,576 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilMathFunctionLibrary.h"
#include <limits>
namespace
{
float GetUsableStatisticsWeight(const float Weight)
{
return FMath::IsFinite(Weight) && Weight > 0.0f ? Weight : 0.0f;
}
template <typename ValueType>
ValueType SelectStatisticsNth(TArray<ValueType>& Values, const int32 NthIndex)
{
int32 Left = 0;
int32 Right = Values.Num() - 1;
int32 RemainingDepth = FMath::FloorLog2(static_cast<uint32>(Values.Num())) * 2;
while (Left < Right)
{
if (RemainingDepth-- <= 0)
{
Values.Sort();
return Values[NthIndex];
}
const int32 Middle = Left + (Right - Left) / 2;
if (Values[Middle] < Values[Left])
{
Values.Swap(Middle, Left);
}
if (Values[Right] < Values[Left])
{
Values.Swap(Right, Left);
}
if (Values[Right] < Values[Middle])
{
Values.Swap(Right, Middle);
}
const ValueType Pivot = Values[Middle];
int32 LessEnd = Left;
int32 Current = Left;
int32 GreaterStart = Right;
while (Current <= GreaterStart)
{
if (Values[Current] < Pivot)
{
Values.Swap(LessEnd++, Current++);
}
else if (Pivot < Values[Current])
{
Values.Swap(Current, GreaterStart--);
}
else
{
++Current;
}
}
if (NthIndex < LessEnd)
{
Right = LessEnd - 1;
}
else if (NthIndex > GreaterStart)
{
Left = GreaterStart + 1;
}
else
{
return Values[NthIndex];
}
}
return Values[Left];
}
template <typename ValueType>
double CalculateStatisticsMedian(TArray<ValueType>& Values)
{
const int32 Middle = Values.Num() / 2;
const ValueType UpperMiddle = SelectStatisticsNth(Values, Middle);
if (Values.Num() % 2 != 0)
{
return static_cast<double>(UpperMiddle);
}
ValueType LowerMiddle = Values[0];
for (int32 Index = 1; Index < Middle; ++Index)
{
LowerMiddle = FMath::Max(LowerMiddle, Values[Index]);
}
return (static_cast<double>(LowerMiddle) + static_cast<double>(UpperMiddle)) * 0.5;
}
}
float UDirectiveUtilMathFunctionLibrary::RoundToDecimals(const float Value, int32 Decimals)
{
Decimals = FMath::Clamp(Decimals, 0, 10);
if (Decimals == 0)
{
return FMath::RoundHalfFromZero(Value);
}
const double Factor = FMath::Pow(10.0, static_cast<double>(Decimals));
return static_cast<float>(FMath::RoundHalfFromZero(static_cast<double>(Value) * Factor) / Factor);
}
FText UDirectiveUtilMathFunctionLibrary::RoundToDecimalsAsText(const float Value, int32 Decimals)
{
Decimals = FMath::Clamp(Decimals, 0, 10);
FNumberFormattingOptions Options;
Options.MinimumFractionalDigits = 0;
Options.MaximumFractionalDigits = Decimals;
Options.RoundingMode = ERoundingMode::HalfFromZero;
return FText::AsNumber(Value, &Options);
}
FText UDirectiveUtilMathFunctionLibrary::FormatBytes(const int64 Bytes, int32 Decimals)
{
Decimals = FMath::Clamp(Decimals, 0, 3);
static const TCHAR* Suffixes[] = { TEXT("B"), TEXT("KB"), TEXT("MB"), TEXT("GB"), TEXT("TB"), TEXT("PB") };
const bool bNegative = Bytes < 0;
double Value = FMath::Abs(static_cast<double>(Bytes));
int32 SuffixIndex = 0;
while (Value >= 1024.0 && SuffixIndex < UE_ARRAY_COUNT(Suffixes) - 1)
{
Value /= 1024.0;
++SuffixIndex;
}
return FText::FromString(FString::Printf(TEXT("%s%.*f %s"),
bNegative ? TEXT("-") : TEXT(""), SuffixIndex == 0 ? 0 : Decimals, Value, Suffixes[SuffixIndex]));
}
FText UDirectiveUtilMathFunctionLibrary::FormatDuration(const float Seconds, const bool bIncludeSeconds)
{
if (!FMath::IsFinite(Seconds))
{
return FText::FromString(TEXT("0s"));
}
const double AbsoluteSeconds = FMath::Abs(static_cast<double>(Seconds));
const int64 TotalSeconds = AbsoluteSeconds >= static_cast<double>(TNumericLimits<int64>::Max())
? TNumericLimits<int64>::Max()
: static_cast<int64>(AbsoluteSeconds);
const int64 VisibleSeconds = bIncludeSeconds ? TotalSeconds : (TotalSeconds / 60) * 60;
const bool bNegative = Seconds < 0.0f && VisibleSeconds > 0;
const int64 UnitValues[] = { TotalSeconds / 86400, (TotalSeconds / 3600) % 24, (TotalSeconds / 60) % 60, TotalSeconds % 60 };
static const TCHAR* UnitSuffixes[] = { TEXT("d"), TEXT("h"), TEXT("m"), TEXT("s") };
const int32 NumUnits = bIncludeSeconds ? 4 : 3;
int32 FirstUnit = NumUnits - 1;
for (int32 Index = 0; Index < NumUnits; ++Index)
{
if (UnitValues[Index] != 0)
{
FirstUnit = Index;
break;
}
}
int32 LastUnit = FirstUnit;
for (int32 Index = NumUnits - 1; Index >= FirstUnit; --Index)
{
if (UnitValues[Index] != 0)
{
LastUnit = Index;
break;
}
}
FString Result = bNegative ? TEXT("-") : TEXT("");
for (int32 Index = FirstUnit; Index <= LastUnit; ++Index)
{
if (Index == FirstUnit)
{
Result += FString::Printf(TEXT("%lld%s"), UnitValues[Index], UnitSuffixes[Index]);
}
else
{
Result += FString::Printf(TEXT(" %02lld%s"), UnitValues[Index], UnitSuffixes[Index]);
}
}
return FText::FromString(Result);
}
FText UDirectiveUtilMathFunctionLibrary::FormatRelativeTime(const FDateTime& Timestamp)
{
const FTimespan Delta = FDateTime::Now() - Timestamp;
const bool bFuture = Delta.GetTicks() < 0;
// Round first so timestamps near the current second stay in the expected bucket.
const int64 SecondsAbs = static_cast<int64>(FMath::RoundToDouble(FMath::Abs(Delta.GetTotalSeconds())));
if (SecondsAbs < 60)
{
return FText::FromString(TEXT("just now"));
}
int64 Count;
const TCHAR* Unit;
if (SecondsAbs < 3600)
{
Count = SecondsAbs / 60;
Unit = TEXT("minute");
}
else if (SecondsAbs < 86400)
{
Count = SecondsAbs / 3600;
Unit = TEXT("hour");
}
else
{
Count = SecondsAbs / 86400;
Unit = TEXT("day");
}
const FString Quantity = FString::Printf(TEXT("%lld %s%s"), Count, Unit, Count == 1 ? TEXT("") : TEXT("s"));
return FText::FromString(bFuture
? FString::Printf(TEXT("in %s"), *Quantity)
: FString::Printf(TEXT("%s ago"), *Quantity));
}
int64 UDirectiveUtilMathFunctionLibrary::GetIntArraySum(const TArray<int32>& Values)
{
int64 Sum = 0;
for (const int32 Value : Values)
{
Sum += Value;
}
return Sum;
}
float UDirectiveUtilMathFunctionLibrary::GetIntArrayAverage(const TArray<int32>& Values)
{
if (Values.IsEmpty())
{
return 0.0f;
}
return static_cast<float>(static_cast<double>(GetIntArraySum(Values)) / Values.Num());
}
float UDirectiveUtilMathFunctionLibrary::GetIntArrayMedian(const TArray<int32>& Values)
{
if (Values.IsEmpty())
{
return 0.0f;
}
TArray<int32> WorkingValues = Values;
return static_cast<float>(CalculateStatisticsMedian(WorkingValues));
}
float UDirectiveUtilMathFunctionLibrary::GetIntArrayStandardDeviation(const TArray<int32>& Values)
{
if (Values.IsEmpty())
{
return 0.0f;
}
const double Mean = static_cast<double>(GetIntArraySum(Values)) / Values.Num();
double SquaredDeltaSum = 0.0;
for (const int32 Value : Values)
{
const double Delta = static_cast<double>(Value) - Mean;
SquaredDeltaSum += Delta * Delta;
}
return static_cast<float>(FMath::Sqrt(SquaredDeltaSum / Values.Num()));
}
float UDirectiveUtilMathFunctionLibrary::GetFloatArraySum(const TArray<float>& Values)
{
double Sum = 0.0;
for (const float Value : Values)
{
Sum += static_cast<double>(Value);
}
return static_cast<float>(Sum);
}
float UDirectiveUtilMathFunctionLibrary::GetFloatArrayAverage(const TArray<float>& Values)
{
if (Values.IsEmpty())
{
return 0.0f;
}
double Sum = 0.0;
for (const float Value : Values)
{
Sum += static_cast<double>(Value);
}
return static_cast<float>(Sum / Values.Num());
}
float UDirectiveUtilMathFunctionLibrary::GetFloatArrayMedian(const TArray<float>& Values)
{
if (Values.IsEmpty())
{
return 0.0f;
}
TArray<float> WorkingValues = Values;
if (WorkingValues.ContainsByPredicate([](const float Value) { return FMath::IsNaN(Value); }))
{
return std::numeric_limits<float>::quiet_NaN();
}
return static_cast<float>(CalculateStatisticsMedian(WorkingValues));
}
float UDirectiveUtilMathFunctionLibrary::GetFloatArrayStandardDeviation(const TArray<float>& Values)
{
if (Values.IsEmpty())
{
return 0.0f;
}
double Sum = 0.0;
for (const float Value : Values)
{
Sum += static_cast<double>(Value);
}
const double Mean = Sum / Values.Num();
double SquaredDeltaSum = 0.0;
for (const float Value : Values)
{
const double Delta = static_cast<double>(Value) - Mean;
SquaredDeltaSum += Delta * Delta;
}
return static_cast<float>(FMath::Sqrt(SquaredDeltaSum / Values.Num()));
}
bool UDirectiveUtilMathFunctionLibrary::GetAngleArrayAverage(const TArray<float>& Angles,
float& AverageAngle, float& ResultantStrength)
{
AverageAngle = 0.0f;
ResultantStrength = 0.0f;
if (Angles.IsEmpty())
{
return false;
}
double SineSum = 0.0;
double CosineSum = 0.0;
for (const float Angle : Angles)
{
if (!FMath::IsFinite(Angle))
{
return false;
}
const double Radians = FMath::DegreesToRadians(FMath::Fmod(static_cast<double>(Angle), 360.0));
SineSum += FMath::Sin(Radians);
CosineSum += FMath::Cos(Radians);
}
const double Magnitude = FMath::Sqrt(SineSum * SineSum + CosineSum * CosineSum);
ResultantStrength = static_cast<float>(FMath::Clamp(Magnitude / Angles.Num(), 0.0, 1.0));
if (ResultantStrength <= UE_DOUBLE_SMALL_NUMBER)
{
ResultantStrength = 0.0f;
return false;
}
AverageAngle = static_cast<float>(FMath::RadiansToDegrees(FMath::Atan2(SineSum, CosineSum)));
return true;
}
bool UDirectiveUtilMathFunctionLibrary::GetWeightedFloatArrayAverage(const TArray<float>& Values,
const TArray<float>& Weights, float& Average)
{
Average = 0.0f;
if (Values.IsEmpty() || Values.Num() != Weights.Num())
{
return false;
}
double WeightedSum = 0.0;
double WeightSum = 0.0;
for (int32 Index = 0; Index < Values.Num(); ++Index)
{
if (!FMath::IsFinite(Values[Index]))
{
return false;
}
const double Weight = GetUsableStatisticsWeight(Weights[Index]);
WeightedSum += static_cast<double>(Values[Index]) * Weight;
WeightSum += Weight;
}
if (WeightSum <= 0.0)
{
return false;
}
Average = static_cast<float>(WeightedSum / WeightSum);
return FMath::IsFinite(Average);
}
bool UDirectiveUtilMathFunctionLibrary::GetWeightedVectorArrayAverage(const TArray<FVector>& Values,
const TArray<float>& Weights, FVector& Average)
{
Average = FVector::ZeroVector;
if (Values.IsEmpty() || Values.Num() != Weights.Num())
{
return false;
}
FVector RunningAverage = FVector::ZeroVector;
double WeightSum = 0.0;
for (int32 Index = 0; Index < Values.Num(); ++Index)
{
if (Values[Index].ContainsNaN())
{
return false;
}
const double Weight = GetUsableStatisticsWeight(Weights[Index]);
if (Weight > 0.0)
{
const double NewWeightSum = WeightSum + Weight;
RunningAverage = FMath::LerpStable(RunningAverage, Values[Index], Weight / NewWeightSum);
WeightSum = NewWeightSum;
}
}
if (WeightSum <= 0.0 || RunningAverage.ContainsNaN())
{
return false;
}
Average = RunningAverage;
return true;
}
bool UDirectiveUtilMathFunctionLibrary::NormalizeFloatArrayToRange(const TArray<float>& Values,
const float OutputMinimum, const float OutputMaximum, TArray<float>& NormalizedValues)
{
TArray<float> ValuesCopy;
const TArray<float>* SourceValues = &Values;
if (&Values == &NormalizedValues)
{
ValuesCopy = Values;
SourceValues = &ValuesCopy;
}
NormalizedValues.Reset();
if (SourceValues->IsEmpty() || !FMath::IsFinite(OutputMinimum) || !FMath::IsFinite(OutputMaximum))
{
return false;
}
float InputMinimum = (*SourceValues)[0];
float InputMaximum = (*SourceValues)[0];
for (const float Value : *SourceValues)
{
if (!FMath::IsFinite(Value))
{
return false;
}
InputMinimum = FMath::Min(InputMinimum, Value);
InputMaximum = FMath::Max(InputMaximum, Value);
}
NormalizedValues.SetNumUninitialized(SourceValues->Num());
if (InputMinimum == InputMaximum)
{
NormalizedValues.Init(OutputMinimum, SourceValues->Num());
return true;
}
const double Scale = (static_cast<double>(OutputMaximum) - OutputMinimum)
/ (static_cast<double>(InputMaximum) - InputMinimum);
for (int32 Index = 0; Index < SourceValues->Num(); ++Index)
{
NormalizedValues[Index] = static_cast<float>(OutputMinimum
+ (static_cast<double>((*SourceValues)[Index]) - InputMinimum) * Scale);
}
return true;
}
bool UDirectiveUtilMathFunctionLibrary::NormalizeWeights(const TArray<float>& Weights,
TArray<float>& NormalizedWeights)
{
TArray<float> WeightsCopy;
const TArray<float>* SourceWeights = &Weights;
if (&Weights == &NormalizedWeights)
{
WeightsCopy = Weights;
SourceWeights = &WeightsCopy;
}
NormalizedWeights.Reset();
if (SourceWeights->IsEmpty())
{
return false;
}
double WeightSum = 0.0;
for (const float Weight : *SourceWeights)
{
WeightSum += GetUsableStatisticsWeight(Weight);
}
if (WeightSum <= 0.0)
{
return false;
}
NormalizedWeights.SetNumUninitialized(SourceWeights->Num());
for (int32 Index = 0; Index < SourceWeights->Num(); ++Index)
{
NormalizedWeights[Index] = static_cast<float>(GetUsableStatisticsWeight((*SourceWeights)[Index]) / WeightSum);
}
return true;
}
bool UDirectiveUtilMathFunctionLibrary::GetFloatArrayPercentile(const TArray<float>& Values,
const float Percentile, float& Value)
{
Value = 0.0f;
if (Values.IsEmpty() || !FMath::IsFinite(Percentile))
{
return false;
}
for (const float Candidate : Values)
{
if (!FMath::IsFinite(Candidate))
{
return false;
}
}
TArray<float> WorkingValues = Values;
const double Position = FMath::Clamp(static_cast<double>(Percentile), 0.0, 100.0)
* 0.01 * (WorkingValues.Num() - 1);
const int32 LowerIndex = FMath::FloorToInt(Position);
const int32 UpperIndex = FMath::CeilToInt(Position);
const float LowerValue = SelectStatisticsNth(WorkingValues, LowerIndex);
if (LowerIndex == UpperIndex)
{
Value = LowerValue;
return true;
}
const float UpperValue = SelectStatisticsNth(WorkingValues, UpperIndex);
Value = static_cast<float>(FMath::Lerp(
static_cast<double>(LowerValue),
static_cast<double>(UpperValue),
Position - LowerIndex));
return true;
}
bool UDirectiveUtilMathFunctionLibrary::GetFloatArrayRootMeanSquare(const TArray<float>& Values,
float& RootMeanSquare)
{
RootMeanSquare = 0.0f;
if (Values.IsEmpty())
{
return false;
}
double SquaredSum = 0.0;
for (const float Value : Values)
{
if (!FMath::IsFinite(Value))
{
return false;
}
SquaredSum += static_cast<double>(Value) * Value;
}
RootMeanSquare = static_cast<float>(FMath::Sqrt(SquaredSum / Values.Num()));
return FMath::IsFinite(RootMeanSquare);
}

View File

@@ -7,6 +7,10 @@
#include "GameFramework/SaveGame.h"
#include "HAL/FileManager.h"
#include "Misc/Paths.h"
#include "PlatformFeatures.h"
#include "SaveGameSystem.h"
#include <ctime>
namespace
{
@@ -24,11 +28,51 @@ namespace
{
return UDirectiveUtilStringFunctionLibrary::IsValidFileName(SlotName);
}
ISaveGameSystem* GetSaveGameSystem()
{
return IPlatformFeaturesModule::Get().GetSaveGameSystem();
}
FDateTime ConvertUtcFileTimeToLocal(const FDateTime& UtcTimestamp)
{
const int64 UnixSeconds = UtcTimestamp.ToUnixTimestamp();
const int32 Milliseconds = UtcTimestamp.GetMillisecond();
const time_t Time = static_cast<time_t>(UnixSeconds);
tm LocalTm;
#if PLATFORM_WINDOWS
if (localtime_s(&LocalTm, &Time) != 0)
{
return UtcTimestamp;
}
#else
if (localtime_r(&Time, &LocalTm) == nullptr)
{
return UtcTimestamp;
}
#endif
return FDateTime(
LocalTm.tm_year + 1900,
LocalTm.tm_mon + 1,
LocalTm.tm_mday,
LocalTm.tm_hour,
LocalTm.tm_min,
LocalTm.tm_sec,
Milliseconds);
}
}
TArray<FString> UDirectiveUtilSaveGameFunctionLibrary::GetAllSaveSlotNames()
{
TArray<FString> SlotNames;
if (ISaveGameSystem* SaveSystem = GetSaveGameSystem())
{
if (SaveSystem->GetSaveGameNames(SlotNames, 0))
{
return SlotNames;
}
}
TArray<FString> Files;
IFileManager::Get().FindFiles(Files, *(GetSaveGamesDirectory() / TEXT("*.sav")), true, false);
@@ -49,13 +93,21 @@ bool UDirectiveUtilSaveGameFunctionLibrary::GetSaveSlotTimestamp(const FString&
return false;
}
if (ISaveGameSystem* SaveSystem = GetSaveGameSystem())
{
if (!SaveSystem->DoesSaveGameExist(*SlotName, 0))
{
return false;
}
}
const FDateTime Timestamp = IFileManager::Get().GetTimeStamp(*GetSaveSlotFilePath(SlotName));
if (Timestamp == FDateTime::MinValue())
{
return false;
}
OutTimestamp = Timestamp + (FDateTime::Now() - FDateTime::UtcNow());
OutTimestamp = ConvertUtcFileTimeToLocal(Timestamp);
return true;
}
@@ -98,10 +150,30 @@ bool UDirectiveUtilSaveGameFunctionLibrary::DeleteSaveSlot(const FString& SlotNa
bool UDirectiveUtilSaveGameFunctionLibrary::RenameSaveSlot(const FString& OldSlotName, const FString& NewSlotName, const int32 UserIndex)
{
if (!IsValidSaveSlotName(OldSlotName) || !IsValidSaveSlotName(NewSlotName) || OldSlotName == NewSlotName)
if (!IsValidSaveSlotName(OldSlotName) || !IsValidSaveSlotName(NewSlotName)
|| OldSlotName.Equals(NewSlotName, ESearchCase::CaseSensitive))
{
return false;
}
const bool bCaseOnlyRename = OldSlotName.Equals(NewSlotName, ESearchCase::IgnoreCase);
if (bCaseOnlyRename)
{
TArray<uint8> SaveData;
if (!UGameplayStatics::LoadDataFromSlot(SaveData, OldSlotName, UserIndex) || SaveData.Num() == 0)
{
return false;
}
if (!UGameplayStatics::SaveDataToSlot(SaveData, NewSlotName, UserIndex))
{
return false;
}
// Slot-name case sensitivity belongs to the active platform backend. Deleting
// the old spelling here can delete the newly written slot on a case-insensitive
// filesystem, so treat both spellings as the same logical slot and rewrite it.
return true;
}
if (!UGameplayStatics::DoesSaveGameExist(OldSlotName, UserIndex) || UGameplayStatics::DoesSaveGameExist(NewSlotName, UserIndex))
{
return false;

View File

@@ -14,6 +14,80 @@ namespace
const FTCHARToUTF8 Converter(*String, String.Len());
return TArray<uint8>(reinterpret_cast<const uint8*>(Converter.Get()), Converter.Length());
}
int32 CalculateLevenshteinDistance(
FStringView Left,
FStringView Right,
TArray<int32>& PreviousRow,
TArray<int32>& CurrentRow)
{
int32 Start = 0;
while (Start < Left.Len() && Start < Right.Len() && Left[Start] == Right[Start])
{
++Start;
}
int32 LeftEnd = Left.Len();
int32 RightEnd = Right.Len();
while (LeftEnd > Start && RightEnd > Start && Left[LeftEnd - 1] == Right[RightEnd - 1])
{
--LeftEnd;
--RightEnd;
}
int32 LeftLength = LeftEnd - Start;
int32 RightLength = RightEnd - Start;
int32 LeftStart = Start;
int32 RightStart = Start;
if (RightLength > LeftLength)
{
Swap(Left, Right);
Swap(LeftLength, RightLength);
Swap(LeftStart, RightStart);
}
if (RightLength == 0)
{
return LeftLength;
}
PreviousRow.SetNumUninitialized(RightLength + 1);
CurrentRow.SetNumUninitialized(RightLength + 1);
for (int32 ColumnIndex = 0; ColumnIndex <= RightLength; ++ColumnIndex)
{
PreviousRow[ColumnIndex] = ColumnIndex;
}
for (int32 RowIndex = 1; RowIndex <= LeftLength; ++RowIndex)
{
CurrentRow[0] = RowIndex;
for (int32 ColumnIndex = 1; ColumnIndex <= RightLength; ++ColumnIndex)
{
const int32 SubstitutionCost = Left[LeftStart + RowIndex - 1] == Right[RightStart + ColumnIndex - 1] ? 0 : 1;
CurrentRow[ColumnIndex] = FMath::Min3(
PreviousRow[ColumnIndex] + 1,
CurrentRow[ColumnIndex - 1] + 1,
PreviousRow[ColumnIndex - 1] + SubstitutionCost);
}
Swap(PreviousRow, CurrentRow);
}
return PreviousRow[RightLength];
}
float CalculateStringSimilarity(
const FStringView Left,
const FStringView Right,
const int32 MaxLength,
TArray<int32>& PreviousRow,
TArray<int32>& CurrentRow)
{
if (MaxLength == 0)
{
return 1.0f;
}
const int32 Distance = CalculateLevenshteinDistance(Left, Right, PreviousRow, CurrentRow);
return 1.0f - static_cast<float>(Distance) / static_cast<float>(MaxLength);
}
}
bool UDirectiveUtilStringFunctionLibrary::ContainsLetters(const FString& String)
@@ -219,50 +293,27 @@ TArray<FString> UDirectiveUtilStringFunctionLibrary::GetSortedStringArray(const
int32 UDirectiveUtilStringFunctionLibrary::GetLevenshteinDistance(const FString& A, const FString& B, const bool bCaseSensitive)
{
const FString StringA = bCaseSensitive ? A : A.ToLower();
const FString StringB = bCaseSensitive ? B : B.ToLower();
const int32 LenA = StringA.Len();
const int32 LenB = StringB.Len();
if (LenA == 0) { return LenB; }
if (LenB == 0) { return LenA; }
const FString NormalizedA = bCaseSensitive ? FString() : A.ToLower();
const FString NormalizedB = bCaseSensitive ? FString() : B.ToLower();
const FStringView ViewA = bCaseSensitive ? FStringView(A) : FStringView(NormalizedA);
const FStringView ViewB = bCaseSensitive ? FStringView(B) : FStringView(NormalizedB);
TArray<int32> PreviousRow;
TArray<int32> CurrentRow;
PreviousRow.SetNumUninitialized(LenB + 1);
CurrentRow.SetNumUninitialized(LenB + 1);
for (int32 ColumnIndex = 0; ColumnIndex <= LenB; ++ColumnIndex)
{
PreviousRow[ColumnIndex] = ColumnIndex;
}
for (int32 RowIndex = 1; RowIndex <= LenA; ++RowIndex)
{
CurrentRow[0] = RowIndex;
for (int32 ColumnIndex = 1; ColumnIndex <= LenB; ++ColumnIndex)
{
const int32 SubstitutionCost = (StringA[RowIndex - 1] == StringB[ColumnIndex - 1]) ? 0 : 1;
CurrentRow[ColumnIndex] = FMath::Min3(
PreviousRow[ColumnIndex] + 1,
CurrentRow[ColumnIndex - 1] + 1,
PreviousRow[ColumnIndex - 1] + SubstitutionCost);
}
Exchange(PreviousRow, CurrentRow);
}
return PreviousRow[LenB];
return CalculateLevenshteinDistance(ViewA, ViewB, PreviousRow, CurrentRow);
}
float UDirectiveUtilStringFunctionLibrary::GetStringSimilarity(const FString& A, const FString& B, const bool bCaseSensitive)
{
const int32 MaxLength = FMath::Max(A.Len(), B.Len());
if (MaxLength == 0)
{
return 1.0f;
}
const int32 Distance = GetLevenshteinDistance(A, B, bCaseSensitive);
return 1.0f - (static_cast<float>(Distance) / static_cast<float>(MaxLength));
const FString NormalizedA = bCaseSensitive ? FString() : A.ToLower();
const FString NormalizedB = bCaseSensitive ? FString() : B.ToLower();
TArray<int32> PreviousRow;
TArray<int32> CurrentRow;
return CalculateStringSimilarity(
bCaseSensitive ? FStringView(A) : FStringView(NormalizedA),
bCaseSensitive ? FStringView(B) : FStringView(NormalizedB),
FMath::Max(A.Len(), B.Len()),
PreviousRow,
CurrentRow);
}
bool UDirectiveUtilStringFunctionLibrary::ContainsAny(const FString& Source, const TArray<FString>& SearchTerms, const bool bCaseSensitive)
@@ -394,28 +445,96 @@ int32 UDirectiveUtilStringFunctionLibrary::Crc32Bytes(const TArray<uint8>& Bytes
return static_cast<int32>(FCrc::MemCrc32(Bytes.GetData(), Bytes.Num()));
}
namespace
{
bool IsReservedDeviceFileName(const FString& FileName)
{
FString Stem = FileName;
int32 DotIndex = INDEX_NONE;
if (FileName.FindChar(TEXT('.'), DotIndex))
{
Stem = FileName.Left(DotIndex);
}
while (Stem.EndsWith(TEXT(".")) || Stem.EndsWith(TEXT(" ")))
{
Stem.LeftChopInline(1);
}
if (Stem.IsEmpty())
{
return false;
}
static const TCHAR* ReservedNames[] = {
TEXT("CON"), TEXT("PRN"), TEXT("AUX"), TEXT("CLOCK$"), TEXT("NUL"),
TEXT("COM1"), TEXT("COM2"), TEXT("COM3"), TEXT("COM4"), TEXT("COM5"),
TEXT("COM6"), TEXT("COM7"), TEXT("COM8"), TEXT("COM9"),
TEXT("LPT1"), TEXT("LPT2"), TEXT("LPT3"), TEXT("LPT4"), TEXT("LPT5"),
TEXT("LPT6"), TEXT("LPT7"), TEXT("LPT8"), TEXT("LPT9")
};
for (const TCHAR* ReservedName : ReservedNames)
{
if (Stem.Equals(ReservedName, ESearchCase::IgnoreCase))
{
return true;
}
}
return false;
}
}
bool UDirectiveUtilStringFunctionLibrary::IsValidFileName(const FString& String)
{
return !String.IsEmpty() && FPaths::GetCleanFilename(String) == String && SanitizeFileName(String) == String;
return !String.IsEmpty()
&& String != TEXT(".")
&& String != TEXT("..")
&& FPaths::GetCleanFilename(String) == String
&& SanitizeFileName(String) == String;
}
FString UDirectiveUtilStringFunctionLibrary::SanitizeFileName(const FString& String, const FString& Replacement)
{
return FPaths::MakeValidFileName(String, Replacement.IsEmpty() ? TEXT('\0') : Replacement[0]);
FString Result = FPaths::MakeValidFileName(String, Replacement.IsEmpty() ? TEXT('\0') : Replacement[0]);
while (Result.EndsWith(TEXT(".")) || Result.EndsWith(TEXT(" ")))
{
Result.LeftChopInline(1);
}
if (IsReservedDeviceFileName(Result))
{
Result = FString(TEXT("_")) + Result;
}
return Result;
}
int32 UDirectiveUtilStringFunctionLibrary::FindBestStringMatch(const FString& Input, const TArray<FString>& Candidates, float& OutSimilarity, const bool bCaseSensitive)
{
OutSimilarity = 0.0f;
int32 BestIndex = INDEX_NONE;
const FString NormalizedInput = bCaseSensitive ? FString() : Input.ToLower();
const FStringView InputView = bCaseSensitive ? FStringView(Input) : FStringView(NormalizedInput);
TArray<int32> PreviousRow;
TArray<int32> CurrentRow;
for (int32 Index = 0; Index < Candidates.Num(); ++Index)
{
const float Similarity = GetStringSimilarity(Input, Candidates[Index], bCaseSensitive);
const FString NormalizedCandidate = bCaseSensitive ? FString() : Candidates[Index].ToLower();
const FStringView CandidateView = bCaseSensitive
? FStringView(Candidates[Index])
: FStringView(NormalizedCandidate);
const float Similarity = CalculateStringSimilarity(
InputView,
CandidateView,
FMath::Max(Input.Len(), Candidates[Index].Len()),
PreviousRow,
CurrentRow);
if (BestIndex == INDEX_NONE || Similarity > OutSimilarity)
{
BestIndex = Index;
OutSimilarity = Similarity;
if (Similarity == 1.0f)
{
break;
}
}
}
return BestIndex;

View File

@@ -0,0 +1,102 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Tasks/DirectiveUtilAsyncActionBase.h"
#include "Engine/Engine.h"
#include "Engine/World.h"
void UDirectiveUtilAsyncActionBase::RegisterWithGameInstance(const UObject* WorldContextObject)
{
UnbindWorldCleanup();
Super::RegisterWithGameInstance(WorldContextObject);
RegisteredWorld = WorldContextObject && GEngine
? GEngine->GetWorldFromContextObject(WorldContextObject, EGetWorldErrorMode::ReturnNull)
: nullptr;
if (RegisteredWorld.IsValid())
{
WorldCleanupHandle = FWorldDelegates::OnWorldCleanup.AddUObject(
this,
&UDirectiveUtilAsyncActionBase::HandleWorldCleanup);
}
}
void UDirectiveUtilAsyncActionBase::SetReadyToDestroy()
{
UnbindWorldCleanup();
Super::SetReadyToDestroy();
}
void UDirectiveUtilAsyncActionBase::BeginDestroy()
{
UnbindWorldCleanup();
Super::BeginDestroy();
}
void UDirectiveUtilAsyncActionBase::HandleWorldCleanup(
UWorld* World,
const bool,
const bool)
{
if (World == RegisteredWorld.Get())
{
SetReadyToDestroy();
}
}
void UDirectiveUtilAsyncActionBase::UnbindWorldCleanup()
{
if (WorldCleanupHandle.IsValid())
{
FWorldDelegates::OnWorldCleanup.Remove(WorldCleanupHandle);
WorldCleanupHandle.Reset();
}
RegisteredWorld.Reset();
}
void UDirectiveUtilCancellableAsyncAction::RegisterWithGameInstance(const UObject* WorldContextObject)
{
UnbindWorldCleanup();
Super::RegisterWithGameInstance(WorldContextObject);
RegisteredWorld = WorldContextObject && GEngine
? GEngine->GetWorldFromContextObject(WorldContextObject, EGetWorldErrorMode::ReturnNull)
: nullptr;
if (RegisteredWorld.IsValid())
{
WorldCleanupHandle = FWorldDelegates::OnWorldCleanup.AddUObject(
this,
&UDirectiveUtilCancellableAsyncAction::HandleWorldCleanup);
}
}
void UDirectiveUtilCancellableAsyncAction::SetReadyToDestroy()
{
UnbindWorldCleanup();
Super::SetReadyToDestroy();
}
void UDirectiveUtilCancellableAsyncAction::BeginDestroy()
{
UnbindWorldCleanup();
Super::BeginDestroy();
}
void UDirectiveUtilCancellableAsyncAction::HandleWorldCleanup(
UWorld* World,
const bool,
const bool)
{
if (World == RegisteredWorld.Get())
{
Cancel();
}
}
void UDirectiveUtilCancellableAsyncAction::UnbindWorldCleanup()
{
if (WorldCleanupHandle.IsValid())
{
FWorldDelegates::OnWorldCleanup.Remove(WorldCleanupHandle);
WorldCleanupHandle.Reset();
}
RegisteredWorld.Reset();
}

View File

@@ -52,7 +52,7 @@ void UDirectiveUtilTask_AsyncLoadAsset::Activate()
void UDirectiveUtilTask_AsyncLoadAsset::OnLoaded()
{
UObject* LoadedAsset = StreamableHandle.IsValid() ? StreamableHandle->GetLoadedAsset() : nullptr;
UObject* LoadedAsset = SoftAsset.Get();
if (LoadedAsset)
{
Completed.Broadcast(LoadedAsset);
@@ -120,7 +120,7 @@ void UDirectiveUtilTask_AsyncLoadClass::Activate()
void UDirectiveUtilTask_AsyncLoadClass::OnLoaded()
{
UClass* LoadedClass = StreamableHandle.IsValid() ? Cast<UClass>(StreamableHandle->GetLoadedAsset()) : nullptr;
UClass* LoadedClass = SoftClass.Get();
if (LoadedClass)
{
Completed.Broadcast(LoadedClass);
@@ -157,8 +157,6 @@ UDirectiveUtilTask_AsyncLoadAssets* UDirectiveUtilTask_AsyncLoadAssets::AsyncLoa
void UDirectiveUtilTask_AsyncLoadAssets::Activate()
{
// Unset references are filtered out of the request but keep their null slots in the output;
// duplicates are requested once and resolved per slot.
TArray<FSoftObjectPath> PathsToLoad;
for (const TSoftObjectPtr<UObject>& SoftAsset : SoftAssets)
{
@@ -170,7 +168,6 @@ void UDirectiveUtilTask_AsyncLoadAssets::Activate()
if (PathsToLoad.Num() == 0)
{
// Nothing to request; an empty request list is an error path in the streamable manager.
OnLoaded();
return;
}
@@ -194,8 +191,6 @@ void UDirectiveUtilTask_AsyncLoadAssets::Activate()
return;
}
// Binding fails when the load already finished (all assets were in memory); complete directly,
// with the guard keeping the broadcast exactly once.
if (!StreamableHandle->BindUpdateDelegate(FStreamableUpdateDelegate::CreateUObject(this, &UDirectiveUtilTask_AsyncLoadAssets::OnUpdate)))
{
OnLoaded();

View File

@@ -25,11 +25,28 @@ UDirectiveUtilTask_Delay* UDirectiveUtilTask_Delay::CancellableDelay(UObject* Wo
void UDirectiveUtilTask_Delay::EndTask()
{
if (UWorld* World = GEngine ? GEngine->GetWorldFromContextObject(WorldContextObject, EGetWorldErrorMode::ReturnNull) : nullptr)
Cancel();
}
void UDirectiveUtilTask_Delay::Cancel()
{
bFinished = true;
if (FTimerManager* TimerManager = GetTimerManager())
{
World->GetTimerManager().ClearTimer(TimerHandle);
TimerManager->ClearTimer(TimerHandle);
}
SetReadyToDestroy();
Completed.Clear();
Super::Cancel();
}
bool UDirectiveUtilTask_Delay::IsActive() const
{
return !bFinished && Super::IsActive();
}
bool UDirectiveUtilTask_Delay::ShouldBroadcastDelegates() const
{
return !bFinished && Super::ShouldBroadcastDelegates();
}
void UDirectiveUtilTask_Delay::Activate()
@@ -40,11 +57,14 @@ void UDirectiveUtilTask_Delay::Activate()
if (!World)
{
UE_LOG(LogDirectiveUtil, Warning, TEXT("Cancellable Delay failed to activate. World is null."));
bFinished = true;
SetReadyToDestroy();
return;
}
const float ClampedDuration = FMath::Max(Duration, KINDA_SMALL_NUMBER);
const float ClampedDuration = FMath::IsFinite(Duration)
? FMath::Max(Duration, KINDA_SMALL_NUMBER)
: KINDA_SMALL_NUMBER;
World->GetTimerManager().SetTimer(TimerHandle, this, &UDirectiveUtilTask_Delay::OnDelayComplete, ClampedDuration, false);
UE_LOG(LogDirectiveUtil, Verbose, TEXT("Cancellable Delay started for %f seconds."), ClampedDuration);
}
@@ -52,6 +72,10 @@ void UDirectiveUtilTask_Delay::Activate()
void UDirectiveUtilTask_Delay::OnDelayComplete()
{
UE_LOG(LogDirectiveUtil, Verbose, TEXT("Cancellable Delay completed."));
Completed.Broadcast();
if (ShouldBroadcastDelegates())
{
Completed.Broadcast();
}
bFinished = true;
SetReadyToDestroy();
}

View File

@@ -0,0 +1,286 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Tasks/DirectiveUtilTask_Flow.h"
#include "DirectiveUtilLogChannels.h"
#include "Engine/Engine.h"
#include "Engine/World.h"
#include "TimerManager.h"
UDirectiveUtilTask_UpdateForDuration* UDirectiveUtilTask_UpdateForDuration::UpdateForDuration(
UObject* WorldContextObject,
const float Duration,
const float UpdateInterval)
{
UDirectiveUtilTask_UpdateForDuration* Action = NewObject<UDirectiveUtilTask_UpdateForDuration>();
Action->WorldContextObject = WorldContextObject;
Action->Duration = Duration;
Action->UpdateInterval = UpdateInterval;
if (WorldContextObject)
{
Action->RegisterWithGameInstance(WorldContextObject);
}
return Action;
}
void UDirectiveUtilTask_UpdateForDuration::Activate()
{
UWorld* World = WorldContextObject && GEngine
? GEngine->GetWorldFromContextObject(WorldContextObject, EGetWorldErrorMode::LogAndReturnNull)
: nullptr;
if (!World)
{
UE_LOG(LogDirectiveUtil, Warning, TEXT("Update for Duration failed to activate. World is null."));
bFinished = true;
SetReadyToDestroy();
return;
}
FTimerManager& TimerManager = World->GetTimerManager();
if (!FMath::IsFinite(Duration) || Duration <= 0.0f)
{
CompletionTimerHandle = TimerManager.SetTimerForNextTick(this, &UDirectiveUtilTask_UpdateForDuration::OnComplete);
return;
}
TimerManager.SetTimer(
CompletionTimerHandle,
this,
&UDirectiveUtilTask_UpdateForDuration::OnComplete,
Duration,
false);
if (!FMath::IsFinite(UpdateInterval) || UpdateInterval <= 0.0f)
{
UpdateTimerHandle = TimerManager.SetTimerForNextTick(this, &UDirectiveUtilTask_UpdateForDuration::OnUpdate);
return;
}
FTimerManagerTimerParameters UpdateParameters;
UpdateParameters.bLoop = true;
UpdateParameters.bMaxOncePerFrame = true;
UpdateParameters.FirstDelay = UpdateInterval;
TimerManager.SetTimer(
UpdateTimerHandle,
this,
&UDirectiveUtilTask_UpdateForDuration::OnUpdate,
UpdateInterval,
UpdateParameters);
}
void UDirectiveUtilTask_UpdateForDuration::Cancel()
{
bFinished = true;
ClearTimers();
Updated.Clear();
Completed.Clear();
Super::Cancel();
}
bool UDirectiveUtilTask_UpdateForDuration::IsActive() const
{
return !bFinished && Super::IsActive();
}
bool UDirectiveUtilTask_UpdateForDuration::ShouldBroadcastDelegates() const
{
return !bFinished && Super::ShouldBroadcastDelegates();
}
void UDirectiveUtilTask_UpdateForDuration::OnUpdate()
{
if (!ShouldBroadcastDelegates())
{
ClearTimers();
return;
}
FTimerManager* TimerManager = GetTimerManager();
if (!TimerManager)
{
Cancel();
return;
}
const float RemainingTime = TimerManager->GetTimerRemaining(CompletionTimerHandle);
const float ElapsedTime = RemainingTime >= 0.0f
? FMath::Clamp(Duration - RemainingTime, 0.0f, Duration)
: Duration;
BroadcastUpdate(ElapsedTime, ElapsedTime / Duration);
if (ShouldBroadcastDelegates() && (!FMath::IsFinite(UpdateInterval) || UpdateInterval <= 0.0f))
{
UpdateTimerHandle = TimerManager->SetTimerForNextTick(this, &UDirectiveUtilTask_UpdateForDuration::OnUpdate);
}
}
void UDirectiveUtilTask_UpdateForDuration::OnComplete()
{
if (!ShouldBroadcastDelegates())
{
ClearTimers();
return;
}
if (!bHasUpdated || LastElapsedTime < Duration)
{
const float FinalElapsedTime = FMath::IsFinite(Duration) && Duration > 0.0f ? Duration : 0.0f;
BroadcastUpdate(FinalElapsedTime, 1.0f);
}
if (!ShouldBroadcastDelegates())
{
return;
}
ClearTimers();
Completed.Broadcast();
bFinished = true;
SetReadyToDestroy();
}
void UDirectiveUtilTask_UpdateForDuration::BroadcastUpdate(const float ElapsedTime, const float Alpha)
{
const float DeltaTime = bHasUpdated ? FMath::Max(ElapsedTime - LastElapsedTime, 0.0f) : ElapsedTime;
LastElapsedTime = ElapsedTime;
bHasUpdated = true;
Updated.Broadcast(ElapsedTime, DeltaTime, Alpha);
}
void UDirectiveUtilTask_UpdateForDuration::ClearTimers()
{
if (FTimerManager* TimerManager = GetTimerManager())
{
TimerManager->ClearTimer(UpdateTimerHandle);
TimerManager->ClearTimer(CompletionTimerHandle);
}
}
UDirectiveUtilTask_RepeatWithInterval* UDirectiveUtilTask_RepeatWithInterval::RepeatWithInterval(
UObject* WorldContextObject,
const int32 Count,
const float Interval,
const float InitialDelay)
{
UDirectiveUtilTask_RepeatWithInterval* Action = NewObject<UDirectiveUtilTask_RepeatWithInterval>();
Action->WorldContextObject = WorldContextObject;
Action->Count = Count;
Action->Interval = Interval;
Action->InitialDelay = InitialDelay;
if (WorldContextObject)
{
Action->RegisterWithGameInstance(WorldContextObject);
}
return Action;
}
void UDirectiveUtilTask_RepeatWithInterval::Activate()
{
UWorld* World = WorldContextObject && GEngine
? GEngine->GetWorldFromContextObject(WorldContextObject, EGetWorldErrorMode::LogAndReturnNull)
: nullptr;
if (!World)
{
UE_LOG(LogDirectiveUtil, Warning, TEXT("Repeat with Interval failed to activate. World is null."));
bFinished = true;
SetReadyToDestroy();
return;
}
if (Count == 0 || Count < -1)
{
TimerHandle = World->GetTimerManager().SetTimerForNextTick(this, &UDirectiveUtilTask_RepeatWithInterval::Complete);
return;
}
const float SafeInitialDelay = FMath::IsFinite(InitialDelay) && InitialDelay > 0.0f
? InitialDelay
: 0.0f;
Schedule(SafeInitialDelay);
}
void UDirectiveUtilTask_RepeatWithInterval::Cancel()
{
bFinished = true;
ClearTimer();
Iteration.Clear();
Completed.Clear();
Super::Cancel();
}
bool UDirectiveUtilTask_RepeatWithInterval::IsActive() const
{
return !bFinished && Super::IsActive();
}
bool UDirectiveUtilTask_RepeatWithInterval::ShouldBroadcastDelegates() const
{
return !bFinished && Super::ShouldBroadcastDelegates();
}
void UDirectiveUtilTask_RepeatWithInterval::Schedule(const float Delay)
{
FTimerManager* TimerManager = GetTimerManager();
if (!TimerManager)
{
Cancel();
return;
}
if (Delay > 0.0f)
{
TimerManager->SetTimer(TimerHandle, this, &UDirectiveUtilTask_RepeatWithInterval::OnIteration, Delay, false);
}
else
{
TimerHandle = TimerManager->SetTimerForNextTick(this, &UDirectiveUtilTask_RepeatWithInterval::OnIteration);
}
}
void UDirectiveUtilTask_RepeatWithInterval::OnIteration()
{
if (!ShouldBroadcastDelegates())
{
ClearTimer();
return;
}
const int32 CurrentIndex = NextIndex;
NextIndex = NextIndex == MAX_int32 ? 0 : NextIndex + 1;
const int32 Remaining = Count == -1 ? -1 : Count - NextIndex;
Iteration.Broadcast(CurrentIndex, Remaining);
if (!ShouldBroadcastDelegates())
{
return;
}
if (Count != -1 && NextIndex >= Count)
{
Complete();
return;
}
const float SafeInterval = FMath::IsFinite(Interval) && Interval > 0.0f
? Interval
: 0.0f;
Schedule(SafeInterval);
}
void UDirectiveUtilTask_RepeatWithInterval::Complete()
{
if (!ShouldBroadcastDelegates())
{
ClearTimer();
return;
}
ClearTimer();
Completed.Broadcast();
bFinished = true;
SetReadyToDestroy();
}
void UDirectiveUtilTask_RepeatWithInterval::ClearTimer()
{
if (FTimerManager* TimerManager = GetTimerManager())
{
TimerManager->ClearTimer(TimerHandle);
}
}

View File

@@ -6,9 +6,22 @@
#include "Blueprint/AIBlueprintHelperLibrary.h"
#include "Engine/World.h"
#include "DrawDebugHelpers.h"
#include "NavigationSystem.h"
#include "Navigation/PathFollowingComponent.h"
#include "TimerManager.h"
namespace
{
float GetNonNegativeFiniteValue(const float Value)
{
return FMath::IsFinite(Value) ? FMath::Max(Value, 0.0f) : 0.0f;
}
bool IsWithinAcceptanceRadius(const FVector& CurrentLocation, const FVector& TargetLocation, const float AcceptanceRadius)
{
return FVector::DistSquared2D(CurrentLocation, TargetLocation) <= FMath::Square(AcceptanceRadius);
}
}
UDirectiveUtilTask_MoveToLocation* UDirectiveUtilTask_MoveToLocation::MoveToLocation(
UObject* WorldContextObject,
@@ -22,9 +35,9 @@ UDirectiveUtilTask_MoveToLocation* UDirectiveUtilTask_MoveToLocation::MoveToLoca
UDirectiveUtilTask_MoveToLocation* Action = NewObject<UDirectiveUtilTask_MoveToLocation>();
Action->Controller = Controller;
Action->Destination = Destination;
Action->AcceptanceRadius = AcceptanceRadius;
Action->AcceptanceRadius = GetNonNegativeFiniteValue(AcceptanceRadius);
Action->bDebugLineTrace = bDebugLineTrace;
Action->StuckThreshold = StuckThreshold;
Action->StuckThreshold = GetNonNegativeFiniteValue(StuckThreshold);
Action->bCheckStuckMovement = bCheckStuckMovement;
if (WorldContextObject)
@@ -38,27 +51,45 @@ UDirectiveUtilTask_MoveToLocation* UDirectiveUtilTask_MoveToLocation::MoveToLoca
void UDirectiveUtilTask_MoveToLocation::EndTask()
{
if (IsValid(Controller))
{
Controller->StopMovement();
}
ExecuteCompleted(false);
}
void UDirectiveUtilTask_MoveToLocation::Activate()
{
if(!Controller || !Controller->GetPawn())
if (bHasCompleted)
{
ExecuteCompleted(false);
UE_LOG(LogDirectiveUtil, Warning, TEXT("Controller or pawn has been destroyed while moving to location. Aborting."));
return;
}
StartLocation = Controller->GetPawn()->GetActorLocation();
APawn* Pawn = IsValid(Controller) ? Controller->GetPawn() : nullptr;
UWorld* World = IsValid(Pawn) ? Controller->GetWorld() : nullptr;
if (!World)
{
ExecuteCompleted(false);
UE_LOG(LogDirectiveUtil, Warning, TEXT("Controller, pawn, or world is unavailable while moving to location. Aborting."));
return;
}
if (!FNavigationSystem::GetCurrent<UNavigationSystemV1>(World))
{
ExecuteCompleted(false);
UE_LOG(LogDirectiveUtil, Verbose, TEXT("Navigation is unavailable while moving to location."));
return;
}
TimerWorld = World;
StartLocation = Pawn->GetActorLocation();
LastCheckedLocation = StartLocation;
CurrentLocation = StartLocation;
Controller->GetWorld()->GetTimerManager().SetTimer(TimerHandle, this, &UDirectiveUtilTask_MoveToLocation::CheckMoveToLocation, 0.1f, true);
World->GetTimerManager().SetTimer(TimerHandle, this, &UDirectiveUtilTask_MoveToLocation::CheckMoveToLocation, 0.1f, true);
if (bCheckStuckMovement)
{
Controller->GetWorld()->GetTimerManager().SetTimer(StuckTimerHandle, this, &UDirectiveUtilTask_MoveToLocation::CheckStuckMovement, 3.f, true);
World->GetTimerManager().SetTimer(StuckTimerHandle, this, &UDirectiveUtilTask_MoveToLocation::CheckStuckMovement, 3.f, true);
}
UAIBlueprintHelperLibrary::SimpleMoveToLocation(Controller, Destination);
@@ -67,7 +98,7 @@ void UDirectiveUtilTask_MoveToLocation::Activate()
if (bDebugLineTrace)
{
DrawDebugLine(
Controller->GetWorld(),
World,
Destination + FVector(0, 0, 100),
Destination,
FColor::Green,
@@ -81,18 +112,18 @@ void UDirectiveUtilTask_MoveToLocation::Activate()
void UDirectiveUtilTask_MoveToLocation::CheckMoveToLocation()
{
if(!Controller || !Controller->GetPawn())
APawn* Pawn = IsValid(Controller) ? Controller->GetPawn() : nullptr;
if (!IsValid(Pawn) || !TimerWorld.IsValid())
{
ExecuteCompleted(false);
UE_LOG(LogDirectiveUtil, Warning, TEXT("Controller or pawn has been destroyed while moving to location. Aborting."));
UE_LOG(LogDirectiveUtil, Warning, TEXT("Controller, pawn, or world is unavailable while moving to location. Aborting."));
return;
}
CurrentLocation = Controller->GetPawn()->GetActorLocation();
CurrentLocation = Pawn->GetActorLocation();
UE_LOG(LogDirectiveUtil, Verbose, TEXT("Controller is moving to location (%s). Current distance: %f."), *Destination.ToString(), FVector::Dist(CurrentLocation, Destination));
if (FVector::Dist(CurrentLocation, Destination) < AcceptanceRadius)
if (IsWithinAcceptanceRadius(CurrentLocation, Destination, AcceptanceRadius))
{
UE_LOG(LogDirectiveUtil, Verbose, TEXT("Controller has moved to location."));
ExecuteCompleted(true);
@@ -103,16 +134,17 @@ void UDirectiveUtilTask_MoveToLocation::CheckMoveToLocation()
if (!PathFollowing || PathFollowing->GetStatus() == EPathFollowingStatus::Idle)
{
UE_LOG(LogDirectiveUtil, Verbose, TEXT("Path following has stopped. Completing move to location."));
ExecuteCompleted(FVector::Dist(CurrentLocation, Destination) < AcceptanceRadius);
ExecuteCompleted(IsWithinAcceptanceRadius(CurrentLocation, Destination, AcceptanceRadius));
}
}
void UDirectiveUtilTask_MoveToLocation::CheckStuckMovement()
{
if(!Controller || !Controller->GetPawn())
const APawn* Pawn = IsValid(Controller) ? Controller->GetPawn() : nullptr;
if (!IsValid(Pawn) || !TimerWorld.IsValid())
{
ExecuteCompleted(false);
UE_LOG(LogDirectiveUtil, Warning, TEXT("Controller or pawn has been destroyed while moving to location. Aborting."));
UE_LOG(LogDirectiveUtil, Warning, TEXT("Controller, pawn, or world is unavailable while moving to location. Aborting."));
return;
}
@@ -133,13 +165,14 @@ void UDirectiveUtilTask_MoveToLocation::ExecuteCompleted(const bool bSuccess)
}
bHasCompleted = true;
UE_LOG(LogDirectiveUtil, Log, TEXT("Movement to location completed. Success: %s."), bSuccess ? TEXT("true") : TEXT("false"));
UE_LOG(LogDirectiveUtil, Verbose, TEXT("Movement to location completed. Success: %s."), bSuccess ? TEXT("true") : TEXT("false"));
if (Controller)
if (UWorld* World = TimerWorld.Get())
{
Controller->GetWorld()->GetTimerManager().ClearTimer(TimerHandle);
Controller->GetWorld()->GetTimerManager().ClearTimer(StuckTimerHandle);
World->GetTimerManager().ClearTimer(TimerHandle);
World->GetTimerManager().ClearTimer(StuckTimerHandle);
}
TimerWorld.Reset();
Completed.Broadcast(bSuccess);
@@ -160,8 +193,8 @@ UDirectiveUtilTask_MoveToActor* UDirectiveUtilTask_MoveToActor::MoveToActor(
UDirectiveUtilTask_MoveToActor* Action = NewObject<UDirectiveUtilTask_MoveToActor>();
Action->Controller = Controller;
Action->Goal = Goal;
Action->AcceptanceRadius = AcceptanceRadius;
Action->StuckThreshold = StuckThreshold;
Action->AcceptanceRadius = GetNonNegativeFiniteValue(AcceptanceRadius);
Action->StuckThreshold = GetNonNegativeFiniteValue(StuckThreshold);
Action->bCheckStuckMovement = bCheckStuckMovement;
if (WorldContextObject)
@@ -174,27 +207,45 @@ UDirectiveUtilTask_MoveToActor* UDirectiveUtilTask_MoveToActor::MoveToActor(
void UDirectiveUtilTask_MoveToActor::EndTask()
{
if (IsValid(Controller))
{
Controller->StopMovement();
}
ExecuteCompleted(false);
}
void UDirectiveUtilTask_MoveToActor::Activate()
{
if(!Controller || !Controller->GetPawn() || !IsValid(Goal))
if (bHasCompleted)
{
ExecuteCompleted(false);
UE_LOG(LogDirectiveUtil, Warning, TEXT("Controller, pawn, or goal has been destroyed while moving to actor. Aborting."));
return;
}
StartLocation = Controller->GetPawn()->GetActorLocation();
APawn* Pawn = IsValid(Controller) ? Controller->GetPawn() : nullptr;
UWorld* World = IsValid(Pawn) ? Controller->GetWorld() : nullptr;
if (!World || !IsValid(Goal))
{
ExecuteCompleted(false);
UE_LOG(LogDirectiveUtil, Warning, TEXT("Controller, pawn, goal, or world is unavailable while moving to actor. Aborting."));
return;
}
if (!FNavigationSystem::GetCurrent<UNavigationSystemV1>(World))
{
ExecuteCompleted(false);
UE_LOG(LogDirectiveUtil, Verbose, TEXT("Navigation is unavailable while moving to actor."));
return;
}
TimerWorld = World;
StartLocation = Pawn->GetActorLocation();
LastCheckedLocation = StartLocation;
CurrentLocation = StartLocation;
Controller->GetWorld()->GetTimerManager().SetTimer(TimerHandle, this, &UDirectiveUtilTask_MoveToActor::CheckMoveToActor, 0.1f, true);
World->GetTimerManager().SetTimer(TimerHandle, this, &UDirectiveUtilTask_MoveToActor::CheckMoveToActor, 0.1f, true);
if (bCheckStuckMovement)
{
Controller->GetWorld()->GetTimerManager().SetTimer(StuckTimerHandle, this, &UDirectiveUtilTask_MoveToActor::CheckStuckMovement, 3.f, true);
World->GetTimerManager().SetTimer(StuckTimerHandle, this, &UDirectiveUtilTask_MoveToActor::CheckStuckMovement, 3.f, true);
}
UAIBlueprintHelperLibrary::SimpleMoveToActor(Controller, Goal);
@@ -203,10 +254,11 @@ void UDirectiveUtilTask_MoveToActor::Activate()
void UDirectiveUtilTask_MoveToActor::CheckMoveToActor()
{
if(!Controller || !Controller->GetPawn())
APawn* Pawn = IsValid(Controller) ? Controller->GetPawn() : nullptr;
if (!IsValid(Pawn) || !TimerWorld.IsValid())
{
ExecuteCompleted(false);
UE_LOG(LogDirectiveUtil, Warning, TEXT("Controller or pawn has been destroyed while moving to actor. Aborting."));
UE_LOG(LogDirectiveUtil, Warning, TEXT("Controller, pawn, or world is unavailable while moving to actor. Aborting."));
return;
}
@@ -219,10 +271,10 @@ void UDirectiveUtilTask_MoveToActor::CheckMoveToActor()
// The goal can move, so its location is re-read every poll.
const FVector GoalLocation = Goal->GetActorLocation();
CurrentLocation = Controller->GetPawn()->GetActorLocation();
CurrentLocation = Pawn->GetActorLocation();
UE_LOG(LogDirectiveUtil, Verbose, TEXT("Controller is moving to actor (%s). Current distance: %f."), *GetNameSafe(Goal), FVector::Dist(CurrentLocation, GoalLocation));
if (FVector::Dist(CurrentLocation, GoalLocation) < AcceptanceRadius)
if (IsWithinAcceptanceRadius(CurrentLocation, GoalLocation, AcceptanceRadius))
{
UE_LOG(LogDirectiveUtil, Verbose, TEXT("Controller has moved to actor."));
ExecuteCompleted(true);
@@ -233,16 +285,17 @@ void UDirectiveUtilTask_MoveToActor::CheckMoveToActor()
if (!PathFollowing || PathFollowing->GetStatus() == EPathFollowingStatus::Idle)
{
UE_LOG(LogDirectiveUtil, Verbose, TEXT("Path following has stopped. Completing move to actor."));
ExecuteCompleted(FVector::Dist(CurrentLocation, GoalLocation) < AcceptanceRadius);
ExecuteCompleted(IsWithinAcceptanceRadius(CurrentLocation, GoalLocation, AcceptanceRadius));
}
}
void UDirectiveUtilTask_MoveToActor::CheckStuckMovement()
{
if(!Controller || !Controller->GetPawn())
const APawn* Pawn = IsValid(Controller) ? Controller->GetPawn() : nullptr;
if (!IsValid(Pawn) || !TimerWorld.IsValid())
{
ExecuteCompleted(false);
UE_LOG(LogDirectiveUtil, Warning, TEXT("Controller or pawn has been destroyed while moving to actor. Aborting."));
UE_LOG(LogDirectiveUtil, Warning, TEXT("Controller, pawn, or world is unavailable while moving to actor. Aborting."));
return;
}
@@ -263,13 +316,14 @@ void UDirectiveUtilTask_MoveToActor::ExecuteCompleted(const bool bSuccess)
}
bHasCompleted = true;
UE_LOG(LogDirectiveUtil, Log, TEXT("Movement to actor completed. Success: %s."), bSuccess ? TEXT("true") : TEXT("false"));
UE_LOG(LogDirectiveUtil, Verbose, TEXT("Movement to actor completed. Success: %s."), bSuccess ? TEXT("true") : TEXT("false"));
if (Controller)
if (UWorld* World = TimerWorld.Get())
{
Controller->GetWorld()->GetTimerManager().ClearTimer(TimerHandle);
Controller->GetWorld()->GetTimerManager().ClearTimer(StuckTimerHandle);
World->GetTimerManager().ClearTimer(TimerHandle);
World->GetTimerManager().ClearTimer(StuckTimerHandle);
}
TimerWorld.Reset();
Completed.Broadcast(bSuccess);

View File

@@ -51,7 +51,25 @@ public:
* @param TargetArray - The array to remove duplicates from.
*/
UFUNCTION(BlueprintCallable, CustomThunk, meta=(DisplayName = "Remove Duplicates", CompactNodeTitle = "REMOVE DUPLICATES", ArrayParm = "TargetArray"), Category="Directive Utilities|Array")
static void Array_RemoveDuplicates(const TArray<int32>& TargetArray);
static void Array_RemoveDuplicates(UPARAM(ref) TArray<int32>& TargetArray);
/**
* Appends every source element to the target array.
* @param TargetArray - The array to append to.
* @param SourceArray - The array to append.
*/
UFUNCTION(BlueprintCallable, CustomThunk, meta=(DisplayName = "Append Array Optimized", CompactNodeTitle = "APPEND", Keywords = "append merge concatenate bulk", ArrayParm = "TargetArray,SourceArray", ArrayTypeDependentParams = "SourceArray"), Category="Directive Utilities|Array")
static void Array_AppendOptimized(UPARAM(ref) TArray<int32>& TargetArray, const TArray<int32>& SourceArray);
/**
* Inserts every source element into the target array at the given index.
* @param TargetArray - The array to insert into.
* @param SourceArray - The array to insert.
* @param Index - The index at which to insert the source array.
* @returns True if one or more elements were inserted.
*/
UFUNCTION(BlueprintCallable, CustomThunk, meta=(DisplayName = "Insert Array Optimized", CompactNodeTitle = "INSERT ARRAY", Keywords = "insert splice merge bulk", ArrayParm = "TargetArray,SourceArray", ArrayTypeDependentParams = "SourceArray"), Category="Directive Utilities|Array")
static bool Array_InsertOptimized(UPARAM(ref) TArray<int32>& TargetArray, const TArray<int32>& SourceArray, const int32 Index);
/**
* Returns a copy of the first element of the array.
@@ -106,7 +124,7 @@ public:
* @returns True if an element was removed.
*/
UFUNCTION(BlueprintCallable, CustomThunk, meta=(DisplayName = "Pop", ArrayParm = "TargetArray", ArrayTypeDependentParams = "OutItem"), Category="Directive Utilities|Array")
static bool Array_Pop(const TArray<int32>& TargetArray, int32& OutItem);
static bool Array_Pop(UPARAM(ref) TArray<int32>& TargetArray, int32& OutItem);
/**
* Removes the first element of the array and returns a copy of it.
@@ -115,7 +133,7 @@ public:
* @returns True if an element was removed.
*/
UFUNCTION(BlueprintCallable, CustomThunk, meta=(DisplayName = "Pop First", ArrayParm = "TargetArray", ArrayTypeDependentParams = "OutItem"), Category="Directive Utilities|Array")
static bool Array_PopFirst(const TArray<int32>& TargetArray, int32& OutItem);
static bool Array_PopFirst(UPARAM(ref) TArray<int32>& TargetArray, int32& OutItem);
/**
* Removes the element at the given index by swapping it with the last element (does not preserve order).
@@ -125,7 +143,26 @@ public:
* @returns True if the index was valid and an element was removed.
*/
UFUNCTION(BlueprintCallable, CustomThunk, meta=(DisplayName = "Remove At Swap", ArrayParm = "TargetArray"), Category="Directive Utilities|Array")
static bool Array_RemoveAtSwap(const TArray<int32>& TargetArray, const int32 Index);
static bool Array_RemoveAtSwap(UPARAM(ref) TArray<int32>& TargetArray, const int32 Index);
/**
* Removes the elements at the given indices while preserving the order of the remaining elements.
* Duplicate and invalid indices are ignored.
* @param TargetArray - The array to remove from.
* @param Indices - The indices to remove.
* @returns The number of elements removed.
*/
UFUNCTION(BlueprintCallable, CustomThunk, meta=(DisplayName = "Remove At Indices", CompactNodeTitle = "REMOVE INDICES", Keywords = "remove delete batch multiple", ArrayParm = "TargetArray"), Category="Directive Utilities|Array")
static int32 Array_RemoveAtIndices(UPARAM(ref) TArray<int32>& TargetArray, const TArray<int32>& Indices);
/**
* Removes every matching item while preserving the order of the remaining elements.
* @param TargetArray - The array to remove matching items from.
* @param Item - The item to remove.
* @returns True if one or more items were removed.
*/
UFUNCTION(BlueprintCallable, CustomThunk, meta=(DisplayName = "Remove All Occurrences", CompactNodeTitle = "REMOVE ALL", Keywords = "remove item delete matching", ArrayParm = "TargetArray", ArrayTypeDependentParams = "Item", AutoCreateRefTerm = "Item"), Category="Directive Utilities|Array")
static bool Array_RemoveAllOccurrences(UPARAM(ref) TArray<int32>& TargetArray, const int32& Item);
/**
* Returns a copy of a contiguous range of the array. The range is clamped to the array bounds.
@@ -143,7 +180,7 @@ public:
* @param Shift - The number of positions to rotate. Positive rotates toward the end; negative toward the start.
*/
UFUNCTION(BlueprintCallable, CustomThunk, meta=(DisplayName = "Rotate", ArrayParm = "TargetArray"), Category="Directive Utilities|Array")
static void Array_Rotate(const TArray<int32>& TargetArray, const int32 Shift);
static void Array_Rotate(UPARAM(ref) TArray<int32>& TargetArray, const int32 Shift);
/**
* Returns a copy of the array with duplicates removed, keeping the first occurrence and preserving order.
@@ -173,6 +210,72 @@ public:
UFUNCTION(BlueprintPure, CustomThunk, meta=(DisplayName = "Get Most Common", ArrayParm = "TargetArray", ArrayTypeDependentParams = "OutItem", BlueprintThreadSafe), Category="Directive Utilities|Array")
static bool Array_GetMostCommon(const TArray<int32>& TargetArray, int32& OutItem, int32& OutCount);
/**
* Returns randomly selected elements from the array.
* @param TargetArray The array to sample.
* @param Count The requested number of elements, up to 1,000,000.
* @param bWithReplacement Whether the same source element can be selected more than once.
* @param OutArray The sampled elements.
*/
UFUNCTION(BlueprintCallable, CustomThunk, meta=(DisplayName = "Sample Array", ArrayParm = "TargetArray,OutArray", ArrayTypeDependentParams = "OutArray"), Category="Directive Utilities|Array")
static void Array_Sample(const TArray<int32>& TargetArray, int32 Count, bool bWithReplacement, TArray<int32>& OutArray);
/**
* Returns randomly selected elements using a random stream.
* @param TargetArray The array to sample.
* @param Count The requested number of elements, up to 1,000,000.
* @param bWithReplacement Whether the same source element can be selected more than once.
* @param RandomStream The stream used to select elements.
* @param OutArray The sampled elements.
*/
UFUNCTION(BlueprintCallable, CustomThunk, meta=(DisplayName = "Sample Array from Stream", ArrayParm = "TargetArray,OutArray", ArrayTypeDependentParams = "OutArray"), Category="Directive Utilities|Array")
static void Array_SampleFromStream(const TArray<int32>& TargetArray, int32 Count, bool bWithReplacement, UPARAM(ref) FRandomStream& RandomStream, TArray<int32>& OutArray);
/**
* Returns randomly selected elements using per-element weights.
* @param TargetArray The array to sample.
* @param Weights The selection weight for each source element.
* @param Count The requested number of elements, up to 1,000,000.
* @param bWithReplacement Whether the same source element can be selected more than once.
* @param OutArray The sampled elements.
* @returns True when the inputs were valid and the sample was produced.
*/
UFUNCTION(BlueprintCallable, CustomThunk, meta=(DisplayName = "Sample Weighted Array", ArrayParm = "TargetArray,OutArray", ArrayTypeDependentParams = "OutArray"), Category="Directive Utilities|Array")
static bool Array_SampleWeighted(const TArray<int32>& TargetArray, const TArray<float>& Weights, int32 Count, bool bWithReplacement, TArray<int32>& OutArray);
/**
* Returns randomly selected elements using per-element weights and a random stream.
* @param TargetArray The array to sample.
* @param Weights The selection weight for each source element.
* @param Count The requested number of elements, up to 1,000,000.
* @param bWithReplacement Whether the same source element can be selected more than once.
* @param RandomStream The stream used to select elements.
* @param OutArray The sampled elements.
* @returns True when the inputs were valid and the sample was produced.
*/
UFUNCTION(BlueprintCallable, CustomThunk, meta=(DisplayName = "Sample Weighted Array from Stream", ArrayParm = "TargetArray,OutArray", ArrayTypeDependentParams = "OutArray"), Category="Directive Utilities|Array")
static bool Array_SampleWeightedFromStream(const TArray<int32>& TargetArray, const TArray<float>& Weights, int32 Count, bool bWithReplacement, UPARAM(ref) FRandomStream& RandomStream, TArray<int32>& OutArray);
/**
* Returns one zero-based page from the array.
* @param TargetArray The array to read.
* @param PageIndex The zero-based page index.
* @param PageSize The maximum number of elements per page.
* @param OutArray The requested page.
* @param OutPageCount The total number of pages.
* @returns True when the page index and size are valid.
*/
UFUNCTION(BlueprintPure, CustomThunk, meta=(DisplayName = "Get Array Page", ArrayParm = "TargetArray,OutArray", ArrayTypeDependentParams = "OutArray", BlueprintThreadSafe), Category="Directive Utilities|Array")
static bool Array_GetPage(const TArray<int32>& TargetArray, int32 PageIndex, int32 PageSize, TArray<int32>& OutArray, int32& OutPageCount);
/** Sorts strings in natural order so embedded numbers are compared numerically. */
UFUNCTION(BlueprintCallable, Category="Directive Utilities|Array")
static void NaturalSortStringArray(UPARAM(ref) TArray<FString>& TargetArray, bool bDescending = false);
/** Sorts names in natural order so embedded numbers are compared numerically. */
UFUNCTION(BlueprintCallable, Category="Directive Utilities|Array")
static void NaturalSortNameArray(UPARAM(ref) TArray<FName>& TargetArray, bool bDescending = false);
/*~
* Native functions that will be called by the below custom thunk layers, which read off the property address and call the appropriate native handler.
@@ -182,6 +285,8 @@ public:
static int32 GenericArray_NextIndex(const void* TargetArray, const FArrayProperty* ArrayProperty, int32 Index, bool bLoop);
static int32 GenericArray_PreviousIndex(const void* TargetArray, const FArrayProperty* ArrayProperty, int32 Index, bool bLoop);
static void GenericArray_RemoveDuplicates(void* TargetArray, const FArrayProperty* ArrayProperty);
static void GenericArray_AppendOptimized(void* TargetArray, const FArrayProperty* TargetArrayProperty, const void* SourceArray, const FArrayProperty* SourceArrayProperty);
static bool GenericArray_InsertOptimized(void* TargetArray, const FArrayProperty* TargetArrayProperty, const void* SourceArray, const FArrayProperty* SourceArrayProperty, int32 Index);
static bool GenericArray_GetItemAtIndex(const void* TargetArray, const FArrayProperty* ArrayProperty, int32 Index, void* OutItemPtr);
static bool GenericArray_GetFirstItem(const void* TargetArray, const FArrayProperty* ArrayProperty, void* OutItemPtr);
static bool GenericArray_GetLastItem(const void* TargetArray, const FArrayProperty* ArrayProperty, void* OutItemPtr);
@@ -189,11 +294,16 @@ public:
static bool GenericArray_Pop(void* TargetArray, const FArrayProperty* ArrayProperty, void* OutItemPtr);
static bool GenericArray_PopFirst(void* TargetArray, const FArrayProperty* ArrayProperty, void* OutItemPtr);
static bool GenericArray_RemoveAtSwap(void* TargetArray, const FArrayProperty* ArrayProperty, int32 Index);
static int32 GenericArray_RemoveAtIndices(void* TargetArray, const FArrayProperty* ArrayProperty, const TArray<int32>& Indices);
static bool GenericArray_RemoveAllOccurrences(void* TargetArray, const FArrayProperty* ArrayProperty, const void* Item);
static void GenericArray_Slice(const void* TargetArray, const FArrayProperty* TargetArrayProperty, int32 StartIndex, int32 Count, void* OutArray, const FArrayProperty* OutArrayProperty);
static void GenericArray_Rotate(void* TargetArray, const FArrayProperty* ArrayProperty, int32 Shift);
static void GenericArray_GetDistinct(const void* TargetArray, const FArrayProperty* TargetArrayProperty, void* OutArray, const FArrayProperty* OutArrayProperty);
static int32 GenericArray_CountOccurrences(const void* TargetArray, const FArrayProperty* ArrayProperty, const void* ItemToCount);
static bool GenericArray_GetMostCommon(const void* TargetArray, const FArrayProperty* ArrayProperty, void* OutItemPtr, int32* OutCount);
static void GenericArray_Sample(const void* TargetArray, const FArrayProperty* TargetArrayProperty, int32 Count, bool bWithReplacement, FRandomStream* RandomStream, void* OutArray, const FArrayProperty* OutArrayProperty);
static bool GenericArray_SampleWeighted(const void* TargetArray, const FArrayProperty* TargetArrayProperty, const TArray<float>& Weights, int32 Count, bool bWithReplacement, FRandomStream* RandomStream, void* OutArray, const FArrayProperty* OutArrayProperty);
static bool GenericArray_GetPage(const void* TargetArray, const FArrayProperty* TargetArrayProperty, int32 PageIndex, int32 PageSize, void* OutArray, const FArrayProperty* OutArrayProperty, int32* OutPageCount);
/*~
* Custom thunk layers that read off the property address and call the appropriate native handler.
@@ -258,6 +368,78 @@ public:
P_NATIVE_END;
}
DECLARE_FUNCTION(execArray_AppendOptimized)
{
Stack.MostRecentProperty = nullptr;
Stack.StepCompiledIn<FArrayProperty>(nullptr);
void* TargetArrayAddr = Stack.MostRecentPropertyAddress;
FArrayProperty* TargetArrayProperty = CastField<FArrayProperty>(Stack.MostRecentProperty);
if (!TargetArrayProperty)
{
Stack.bArrayContextFailed = true;
return;
}
Stack.MostRecentProperty = nullptr;
Stack.StepCompiledIn<FArrayProperty>(nullptr);
const void* SourceArrayAddr = Stack.MostRecentPropertyAddress;
const FArrayProperty* SourceArrayProperty = CastField<FArrayProperty>(Stack.MostRecentProperty);
if (!SourceArrayProperty)
{
Stack.bArrayContextFailed = true;
return;
}
P_FINISH;
P_NATIVE_BEGIN;
MARK_PROPERTY_DIRTY(Stack.Object, TargetArrayProperty);
GenericArray_AppendOptimized(
TargetArrayAddr,
TargetArrayProperty,
SourceArrayAddr,
SourceArrayProperty);
P_NATIVE_END;
}
DECLARE_FUNCTION(execArray_InsertOptimized)
{
Stack.MostRecentProperty = nullptr;
Stack.StepCompiledIn<FArrayProperty>(nullptr);
void* TargetArrayAddr = Stack.MostRecentPropertyAddress;
FArrayProperty* TargetArrayProperty = CastField<FArrayProperty>(Stack.MostRecentProperty);
if (!TargetArrayProperty)
{
Stack.bArrayContextFailed = true;
return;
}
Stack.MostRecentProperty = nullptr;
Stack.StepCompiledIn<FArrayProperty>(nullptr);
const void* SourceArrayAddr = Stack.MostRecentPropertyAddress;
const FArrayProperty* SourceArrayProperty = CastField<FArrayProperty>(Stack.MostRecentProperty);
if (!SourceArrayProperty)
{
Stack.bArrayContextFailed = true;
return;
}
P_GET_PROPERTY(FIntProperty, Index);
P_FINISH;
P_NATIVE_BEGIN;
const bool bInserted = GenericArray_InsertOptimized(
TargetArrayAddr,
TargetArrayProperty,
SourceArrayAddr,
SourceArrayProperty,
Index);
if (bInserted)
{
MARK_PROPERTY_DIRTY(Stack.Object, TargetArrayProperty);
}
*static_cast<bool*>(RESULT_PARAM) = bInserted;
P_NATIVE_END;
}
DECLARE_FUNCTION(execArray_GetValidFirstItemCopy)
{
Stack.MostRecentProperty = nullptr;
@@ -565,6 +747,70 @@ public:
P_NATIVE_END;
}
DECLARE_FUNCTION(execArray_RemoveAtIndices)
{
Stack.MostRecentProperty = nullptr;
Stack.StepCompiledIn<FArrayProperty>(nullptr);
void* ArrayAddr = Stack.MostRecentPropertyAddress;
FArrayProperty* ArrayProperty = CastField<FArrayProperty>(Stack.MostRecentProperty);
if (!ArrayProperty)
{
Stack.bArrayContextFailed = true;
return;
}
P_GET_TARRAY_REF(int32, Indices);
P_FINISH;
P_NATIVE_BEGIN;
const int32 RemovedCount = GenericArray_RemoveAtIndices(ArrayAddr, ArrayProperty, Indices);
if (RemovedCount > 0)
{
MARK_PROPERTY_DIRTY(Stack.Object, ArrayProperty);
}
*static_cast<int32*>(RESULT_PARAM) = RemovedCount;
P_NATIVE_END;
}
DECLARE_FUNCTION(execArray_RemoveAllOccurrences)
{
Stack.MostRecentProperty = nullptr;
Stack.StepCompiledIn<FArrayProperty>(nullptr);
void* ArrayAddr = Stack.MostRecentPropertyAddress;
FArrayProperty* ArrayProperty = CastField<FArrayProperty>(Stack.MostRecentProperty);
if (!ArrayProperty)
{
Stack.bArrayContextFailed = true;
return;
}
const FProperty* InnerProp = ArrayProperty->Inner;
const int32 PropertySize = InnerProp->GetElementSize() * InnerProp->ArrayDim;
void* StorageSpace = FMemory_Alloca(PropertySize);
InnerProp->InitializeValue(StorageSpace);
Stack.MostRecentPropertyAddress = nullptr;
Stack.MostRecentPropertyContainer = nullptr;
Stack.StepCompiledIn<FProperty>(StorageSpace);
P_FINISH;
if (const FBoolProperty* BoolProperty = CastField<const FBoolProperty>(InnerProp))
{
ensure(PropertySize == sizeof(uint8));
BoolProperty->SetPropertyValue(StorageSpace, *static_cast<uint8*>(StorageSpace) != 0);
}
P_NATIVE_BEGIN;
const bool bRemoved = GenericArray_RemoveAllOccurrences(ArrayAddr, ArrayProperty, StorageSpace);
if (bRemoved)
{
MARK_PROPERTY_DIRTY(Stack.Object, ArrayProperty);
}
*static_cast<bool*>(RESULT_PARAM) = bRemoved;
P_NATIVE_END;
InnerProp->DestroyValue(StorageSpace);
}
DECLARE_FUNCTION(execArray_Slice)
{
Stack.MostRecentProperty = nullptr;
@@ -713,4 +959,179 @@ public:
P_NATIVE_END;
InnerProp->DestroyValue(StorageSpace);
}
DECLARE_FUNCTION(execArray_Sample)
{
Stack.MostRecentProperty = nullptr;
Stack.StepCompiledIn<FArrayProperty>(nullptr);
const void* SourceArrayAddr = Stack.MostRecentPropertyAddress;
const FArrayProperty* SourceArrayProperty = CastField<FArrayProperty>(Stack.MostRecentProperty);
if (!SourceArrayProperty)
{
Stack.bArrayContextFailed = true;
return;
}
P_GET_PROPERTY(FIntProperty, Count);
P_GET_UBOOL(bWithReplacement);
Stack.MostRecentProperty = nullptr;
Stack.StepCompiledIn<FArrayProperty>(nullptr);
void* OutArrayAddr = Stack.MostRecentPropertyAddress;
const FArrayProperty* OutArrayProperty = CastField<FArrayProperty>(Stack.MostRecentProperty);
if (!OutArrayProperty)
{
Stack.bArrayContextFailed = true;
return;
}
P_FINISH;
P_NATIVE_BEGIN;
GenericArray_Sample(SourceArrayAddr, SourceArrayProperty, Count, bWithReplacement, nullptr, OutArrayAddr, OutArrayProperty);
P_NATIVE_END;
}
DECLARE_FUNCTION(execArray_SampleFromStream)
{
Stack.MostRecentProperty = nullptr;
Stack.StepCompiledIn<FArrayProperty>(nullptr);
const void* SourceArrayAddr = Stack.MostRecentPropertyAddress;
const FArrayProperty* SourceArrayProperty = CastField<FArrayProperty>(Stack.MostRecentProperty);
if (!SourceArrayProperty)
{
Stack.bArrayContextFailed = true;
return;
}
P_GET_PROPERTY(FIntProperty, Count);
P_GET_UBOOL(bWithReplacement);
P_GET_STRUCT_REF(FRandomStream, RandomStream);
Stack.MostRecentProperty = nullptr;
Stack.StepCompiledIn<FArrayProperty>(nullptr);
void* OutArrayAddr = Stack.MostRecentPropertyAddress;
const FArrayProperty* OutArrayProperty = CastField<FArrayProperty>(Stack.MostRecentProperty);
if (!OutArrayProperty)
{
Stack.bArrayContextFailed = true;
return;
}
P_FINISH;
P_NATIVE_BEGIN;
GenericArray_Sample(SourceArrayAddr, SourceArrayProperty, Count, bWithReplacement, &RandomStream, OutArrayAddr, OutArrayProperty);
P_NATIVE_END;
}
DECLARE_FUNCTION(execArray_SampleWeighted)
{
Stack.MostRecentProperty = nullptr;
Stack.StepCompiledIn<FArrayProperty>(nullptr);
const void* SourceArrayAddr = Stack.MostRecentPropertyAddress;
const FArrayProperty* SourceArrayProperty = CastField<FArrayProperty>(Stack.MostRecentProperty);
if (!SourceArrayProperty)
{
Stack.bArrayContextFailed = true;
return;
}
P_GET_TARRAY_REF(float, Weights);
P_GET_PROPERTY(FIntProperty, Count);
P_GET_UBOOL(bWithReplacement);
Stack.MostRecentProperty = nullptr;
Stack.StepCompiledIn<FArrayProperty>(nullptr);
void* OutArrayAddr = Stack.MostRecentPropertyAddress;
const FArrayProperty* OutArrayProperty = CastField<FArrayProperty>(Stack.MostRecentProperty);
if (!OutArrayProperty)
{
Stack.bArrayContextFailed = true;
return;
}
P_FINISH;
P_NATIVE_BEGIN;
*static_cast<bool*>(RESULT_PARAM) = GenericArray_SampleWeighted(
SourceArrayAddr,
SourceArrayProperty,
Weights,
Count,
bWithReplacement,
nullptr,
OutArrayAddr,
OutArrayProperty);
P_NATIVE_END;
}
DECLARE_FUNCTION(execArray_SampleWeightedFromStream)
{
Stack.MostRecentProperty = nullptr;
Stack.StepCompiledIn<FArrayProperty>(nullptr);
const void* SourceArrayAddr = Stack.MostRecentPropertyAddress;
const FArrayProperty* SourceArrayProperty = CastField<FArrayProperty>(Stack.MostRecentProperty);
if (!SourceArrayProperty)
{
Stack.bArrayContextFailed = true;
return;
}
P_GET_TARRAY_REF(float, Weights);
P_GET_PROPERTY(FIntProperty, Count);
P_GET_UBOOL(bWithReplacement);
P_GET_STRUCT_REF(FRandomStream, RandomStream);
Stack.MostRecentProperty = nullptr;
Stack.StepCompiledIn<FArrayProperty>(nullptr);
void* OutArrayAddr = Stack.MostRecentPropertyAddress;
const FArrayProperty* OutArrayProperty = CastField<FArrayProperty>(Stack.MostRecentProperty);
if (!OutArrayProperty)
{
Stack.bArrayContextFailed = true;
return;
}
P_FINISH;
P_NATIVE_BEGIN;
*static_cast<bool*>(RESULT_PARAM) = GenericArray_SampleWeighted(
SourceArrayAddr,
SourceArrayProperty,
Weights,
Count,
bWithReplacement,
&RandomStream,
OutArrayAddr,
OutArrayProperty);
P_NATIVE_END;
}
DECLARE_FUNCTION(execArray_GetPage)
{
Stack.MostRecentProperty = nullptr;
Stack.StepCompiledIn<FArrayProperty>(nullptr);
const void* SourceArrayAddr = Stack.MostRecentPropertyAddress;
const FArrayProperty* SourceArrayProperty = CastField<FArrayProperty>(Stack.MostRecentProperty);
if (!SourceArrayProperty)
{
Stack.bArrayContextFailed = true;
return;
}
P_GET_PROPERTY(FIntProperty, PageIndex);
P_GET_PROPERTY(FIntProperty, PageSize);
Stack.MostRecentProperty = nullptr;
Stack.StepCompiledIn<FArrayProperty>(nullptr);
void* OutArrayAddr = Stack.MostRecentPropertyAddress;
const FArrayProperty* OutArrayProperty = CastField<FArrayProperty>(Stack.MostRecentProperty);
if (!OutArrayProperty)
{
Stack.bArrayContextFailed = true;
return;
}
Stack.MostRecentProperty = nullptr;
Stack.MostRecentPropertyAddress = nullptr;
Stack.StepCompiledIn<FProperty>(nullptr);
int32* OutPageCount = reinterpret_cast<int32*>(Stack.MostRecentPropertyAddress);
P_FINISH;
P_NATIVE_BEGIN;
*static_cast<bool*>(RESULT_PARAM) = GenericArray_GetPage(SourceArrayAddr, SourceArrayProperty, PageIndex, PageSize, OutArrayAddr, OutArrayProperty, OutPageCount);
P_NATIVE_END;
}
};

View File

@@ -4,6 +4,7 @@
#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "Types/DirectiveUtilTypes.h"
#include "DirectiveUtilFunctionLibrary.generated.h"
/**
@@ -46,14 +47,14 @@ public:
* Get the content from the clipboard as FText.
* @returns The text from the clipboard.
*/
UFUNCTION(BlueprintPure, Category = "Directive Utilities|Clipboard" )
UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Clipboard" )
static FText GetTextFromClipboard();
/**
* Get the content from the clipboard as an FString.
* @returns The content from the clipboard as a string.
*/
UFUNCTION(BlueprintPure, Category = "Directive Utilities|Clipboard" )
UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Clipboard" )
static FString GetStringFromClipboard();
/**
@@ -77,6 +78,28 @@ public:
UFUNCTION(BlueprintPure, Category = "Directive Utilities|Utility")
static bool IsRunningInEditor();
/**
* Gets the type of world associated with the supplied context.
* @param WorldContextObject Object used to resolve the current world.
* @returns The resolved world type, or Unknown when the context has no world.
*/
UFUNCTION(BlueprintPure, Category = "Directive Utilities|Utility", meta = (WorldContext = "WorldContextObject"))
static EDirectiveUtilWorldType GetWorldType(const UObject* WorldContextObject);
/**
* Gets the build configuration of the running application.
* @returns The active build configuration.
*/
UFUNCTION(BlueprintPure, Category = "Directive Utilities|Utility", meta = (BlueprintThreadSafe))
static EDirectiveUtilBuildConfiguration GetBuildConfigurationType();
/**
* Gets the build target type of the running application.
* @returns The active build target type.
*/
UFUNCTION(BlueprintPure, Category = "Directive Utilities|Utility", meta = (BlueprintThreadSafe))
static EDirectiveUtilBuildTargetType GetBuildTargetType();
/**
* Checks whether a switch (e.g. "MySwitch" matching "-MySwitch") was passed on the
* process command line. Matching is case-insensitive.
@@ -96,6 +119,24 @@ public:
UFUNCTION(BlueprintPure, Category = "Directive Utilities|Utility")
static bool GetCommandLineOption(const FString& Key, FString& OutValue);
/**
* Starts a keyed stopwatch using monotonic real time.
* @param Key The name used to stop this stopwatch.
* @param bRestartIfRunning Whether to replace an active stopwatch with the same key.
* @returns True when the stopwatch was started.
*/
UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Utility|Profiling", meta = (DisplayName = "Start Stopwatch", Keywords = "timer profiling benchmark elapsed milliseconds"))
static bool StartStopwatch(FName Key, bool bRestartIfRunning = false);
/**
* Stops a keyed stopwatch and returns its elapsed real time.
* @param Key The name passed to Start Stopwatch.
* @param ElapsedMilliseconds The elapsed time in milliseconds, or zero when the key is not active.
* @returns True when an active stopwatch was found.
*/
UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Utility|Profiling", meta = (DisplayName = "Stop Stopwatch", Keywords = "timer profiling benchmark elapsed milliseconds"))
static bool StopStopwatch(FName Key, double& ElapsedMilliseconds);
/** Core of Has Command Line Switch that checks an explicit command line. */
static bool HasCommandLineSwitch(const TCHAR* CommandLine, const FString& Switch);

View File

@@ -32,7 +32,7 @@ public:
* @param Tag - The tag to read.
* @returns A container of the tag's ancestors.
*/
UFUNCTION(BlueprintPure, Category = "Directive Utilities|GameplayTags", meta = (BlueprintThreadSafe))
UFUNCTION(BlueprintPure, Category = "Directive Utilities|GameplayTags")
static FGameplayTagContainer GetTagParents(const FGameplayTag& Tag);
/**
@@ -65,7 +65,7 @@ public:
* @param Tag - The tag to read.
* @returns A container of the tag's descendants, or an empty container for an invalid tag.
*/
UFUNCTION(BlueprintPure, Category = "Directive Utilities|GameplayTags", meta = (BlueprintThreadSafe))
UFUNCTION(BlueprintPure, Category = "Directive Utilities|GameplayTags")
static FGameplayTagContainer GetTagChildren(const FGameplayTag& Tag);
/**
@@ -73,7 +73,7 @@ public:
* @param Tag - The tag to read.
* @returns A container of the tag's direct children, or an empty container for an invalid tag.
*/
UFUNCTION(BlueprintPure, Category = "Directive Utilities|GameplayTags", meta = (BlueprintThreadSafe))
UFUNCTION(BlueprintPure, Category = "Directive Utilities|GameplayTags")
static FGameplayTagContainer GetTagDirectChildren(const FGameplayTag& Tag);
/**
@@ -110,7 +110,7 @@ public:
* @param Tag - The tag to test.
* @returns True if the tag is valid and has no registered descendants; false for an invalid tag.
*/
UFUNCTION(BlueprintPure, Category = "Directive Utilities|GameplayTags", meta = (BlueprintThreadSafe))
UFUNCTION(BlueprintPure, Category = "Directive Utilities|GameplayTags")
static bool IsLeafTag(const FGameplayTag& Tag);
/**

View File

@@ -36,7 +36,7 @@ public:
/**
* Remove multiple Input Mapping Contexts.
* @param PlayerController The player controller to remove the contexts from. Will attempt to get the LocalPlayer from the controller.
* @param Contexts The contexts to remove.
* @param Contexts Loaded contexts to remove. This function does not load missing assets.
* @returns Returns Success if the contexts were successfully removed.
*/
UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Input", meta=(ExpandEnumAsExecs="ReturnValue", DefaultToSelf="PlayerController"))
@@ -45,17 +45,17 @@ public:
const TArray<TSoftObjectPtr<UInputMappingContext>>& Contexts);
/**
* Swap a designated Input Mapping Context with a new one.
* If the previous context is found, it will be removed and the new context will be added.
* If the previous context is not found, the new context will be added at the specified priority.
* @param PlayerController The player controller to swap the contexts on. Will attempt to get the LocalPlayer from the controller.
* @param PreviousContext The context to swap out.
* @param NewContext The context to swap in.
* @param Priority The priority to set the new context to.
* @param bUsePreviousPriority Whether to use the previous context's priority when adding the new context.
* @returns Returns Success if the contexts were successfully swapped.
*/
UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Input", meta=(ExpandEnumAsExecs="ReturnValue", DefaultToSelf="PlayerController"))
* Swap a designated Input Mapping Context with a new one.
* If the previous context is found, it will be removed and the new context will be added.
* If the previous context is not found, the new context will be added at the specified priority.
* @param PlayerController The player controller to swap the contexts on. Will attempt to get the LocalPlayer from the controller.
* @param PreviousContext The context to swap out.
* @param NewContext The context to swap in. This asset is loaded synchronously when needed.
* @param Priority The priority to set the new context to.
* @param bUsePreviousPriority Whether to use the previous context's priority when adding the new context.
* @returns Returns Success if the contexts were successfully swapped.
*/
UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Input", meta=(ExpandEnumAsExecs="ReturnValue", DefaultToSelf="PlayerController"))
static EDirectiveUtilSuccessStatus SwapInputMappingContexts(
AController* PlayerController,
TSoftObjectPtr<UInputMappingContext> PreviousContext,
@@ -74,7 +74,7 @@ public:
/**
* Returns whether the given input mapping context is currently active on the controller.
* @param PlayerController - The player controller to query.
* @param Context - The input mapping context to check.
* @param Context - The loaded input mapping context to check. This function does not load missing assets.
* @returns True if the context is currently applied.
*/
UFUNCTION(BlueprintPure, Category = "Directive Utilities|Input", meta=(DefaultToSelf="PlayerController"))

View File

@@ -67,7 +67,7 @@ public:
* @param SourceMap - The map to copy from.
* @param bOverwriteExisting - If true, keys already present in TargetMap are overwritten with SourceMap's values.
*/
UFUNCTION(BlueprintCallable, CustomThunk, meta=(DisplayName = "Append", CompactNodeTitle = "APPEND", MapParam = "TargetMap|SourceMap"), Category="Directive Utilities|Map")
UFUNCTION(BlueprintCallable, CustomThunk, meta=(BlueprintInternalUseOnly = "true", DisplayName = "Append", CompactNodeTitle = "APPEND", MapParam = "TargetMap|SourceMap"), Category="Directive Utilities|Map")
static void Map_Append(const TMap<int32, int32>& TargetMap, const TMap<int32, int32>& SourceMap, bool bOverwriteExisting = true);
/*~

View File

@@ -3,6 +3,7 @@
#pragma once
#include "CoreMinimal.h"
#include "Components/SplineComponent.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "Types/DirectiveUtilMathTypes.h"
#include "DirectiveUtilMathFunctionLibrary.generated.h"
@@ -18,6 +19,7 @@ class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilMathFunctionLibrary : public U
GENERATED_BODY()
public:
static constexpr int32 MaximumGeneratedElementCount = 1000000;
/**
* Returns a perlin noise value between -1 and 1 at the given position.
@@ -41,18 +43,507 @@ public:
* Returns the angle in degrees between two vectors.
* @param A - The first vector.
* @param B - The second vector.
* @returns The angle between the two vectors in degrees.
* @returns The angle between the two vectors in degrees, or 0 if either
* vector is zero or non-finite.
*/
UFUNCTION(BlueprintPure, meta = (BlueprintThreadSafe), Category = "Directive Utilities|Math|Vector")
static float AngleBetweenVectors(const FVector& A, const FVector& B);
/**
* Returns the signed angle in degrees from one vector to another around an axis.
* The vectors are projected onto the plane perpendicular to the axis before measuring.
* @param From - The starting direction.
* @param To - The target direction.
* @param Axis - The axis that defines the rotation plane and positive direction.
* @returns The signed angle in the [-180, 180] range, or 0 if an input cannot define a direction.
*/
UFUNCTION(BlueprintPure, meta = (DisplayName = "Signed Angle Between Vectors", BlueprintThreadSafe), Category = "Directive Utilities|Math|Vector")
static float SignedAngleBetweenVectors(const FVector& From, const FVector& To, const FVector& Axis);
/**
* Returns the shortest signed difference in degrees from one angle to another.
* Exactly opposite angles always return +180, regardless of how the inputs are spelled.
* @param From - The starting angle in degrees.
* @param To - The target angle in degrees.
* @returns The signed difference in the (-180, 180] range, or 0 for non-finite input.
*/
UFUNCTION(BlueprintPure, meta = (DisplayName = "Delta Angle (Degrees)", BlueprintThreadSafe), Category = "Directive Utilities|Math|Float")
static float DeltaAngle(float From, float To);
/**
* Interpolates between two angles along the shortest path.
* Alpha 0 returns A. Values outside [0, 1] extrapolate along that same
* shortest-path direction without wrapping, so a timeline past the end
* does not jump the seam.
* @param A - The starting angle in degrees.
* @param B - The target angle in degrees.
* @param Alpha - The interpolation alpha. Values outside [0, 1] extrapolate.
* @returns A plus the shortest signed delta to B, scaled by Alpha, or 0 for non-finite input.
*/
UFUNCTION(BlueprintPure, meta = (DisplayName = "Lerp Angle (Degrees)", BlueprintThreadSafe), Category = "Directive Utilities|Math|Float")
static float LerpAngle(float A, float B, float Alpha);
/**
* Repeats a value between two bounds, reversing direction at each bound.
* @param Value - The value to repeat.
* @param Minimum - One range bound.
* @param Maximum - The other range bound.
* @returns The ping-ponged value, the shared bound for a zero-sized range, or 0 for non-finite input.
*/
UFUNCTION(BlueprintPure, meta = (DisplayName = "Ping Pong (Float)", BlueprintThreadSafe), Category = "Directive Utilities|Math|Float")
static float PingPong(float Value, float Minimum = 0.0f, float Maximum = 1.0f);
/**
* Applies cubic smoothing to a value between two bounds.
* @returns A value in the [0, 1] range, or 0 for non-finite input.
*/
UFUNCTION(BlueprintPure, meta = (DisplayName = "Smooth Step", BlueprintThreadSafe), Category = "Directive Utilities|Math|Float")
static float SmoothStep(float Value, float Minimum = 0.0f, float Maximum = 1.0f);
/**
* Applies quintic smoothing to a value between two bounds.
* @returns A value in the [0, 1] range, or 0 for non-finite input.
*/
UFUNCTION(BlueprintPure, meta = (DisplayName = "Smoother Step", BlueprintThreadSafe), Category = "Directive Utilities|Math|Float")
static float SmootherStep(float Value, float Minimum = 0.0f, float Maximum = 1.0f);
/**
* Returns a normalized falloff between an inner and outer radius.
* @returns 1 at or inside the inner radius, 0 beyond the outer radius, or 0 for non-finite input.
* Equal radii are a step: 1 at or inside the shared radius, 0 outside.
*/
UFUNCTION(BlueprintPure, meta = (DisplayName = "Range Falloff", BlueprintThreadSafe), Category = "Directive Utilities|Math|Float")
static float RangeFalloff(float Distance, float InnerRadius, float OuterRadius, float FalloffExponent = 1.0f);
/**
* Tests whether a direction lies within a cone centered on another direction.
* @param Direction - The direction to test.
* @param ConeDirection - The center direction of the cone.
* @param ConeHalfAngleDegrees - The angle from the cone center to its edge. Clamped to [0, 180].
* @returns True when the direction lies inside or on the cone, or false for an invalid direction or angle.
*/
UFUNCTION(BlueprintPure, meta = (DisplayName = "Is Direction Within Cone", BlueprintThreadSafe), Category = "Directive Utilities|Math|Vector")
static bool IsDirectionWithinCone(const FVector& Direction, const FVector& ConeDirection, float ConeHalfAngleDegrees);
/**
* Calculates the normalized direction and distance from one point to another.
* @returns False when the points are equal or an input is non-finite.
*/
UFUNCTION(BlueprintPure, meta = (DisplayName = "Get Direction And Distance", BlueprintThreadSafe), Category = "Directive Utilities|Math|Vector")
static bool GetDirectionAndDistance(const FVector& From, const FVector& To, FVector& Direction, double& Distance);
/**
* Rotates a 2D point around a pivot in degrees.
* @returns The rotated point, or zero for non-finite input.
*/
UFUNCTION(BlueprintPure, meta = (DisplayName = "Rotate Point Around Pivot 2D", BlueprintThreadSafe), Category = "Directive Utilities|Math|Vector")
static FVector2D RotatePointAroundPivot2D(const FVector2D& Point, const FVector2D& Pivot, float AngleDegrees);
/**
* Calculates the signed distance from a point to a plane.
* @returns The signed distance, or 0 when the plane normal is zero or an input is non-finite.
*/
UFUNCTION(BlueprintPure, meta = (DisplayName = "Signed Distance To Plane", BlueprintThreadSafe), Category = "Directive Utilities|Math|Vector")
static double SignedDistanceToPlane(const FVector& Point, const FVector& PlanePoint, const FVector& PlaneNormal);
/**
* Tests whether a point lies within a cone and optional maximum distance.
* @returns True when the point lies inside or on the cone and within the distance limit.
*/
UFUNCTION(BlueprintPure, meta = (DisplayName = "Is Point Within Cone", BlueprintThreadSafe), Category = "Directive Utilities|Math|Vector")
static bool IsPointWithinCone(const FVector& Point, const FVector& ConeOrigin, const FVector& ConeDirection,
float ConeHalfAngleDegrees, double MaximumDistance = 0.0);
/**
* Samples a location along the polyline through an array, with Alpha 0 at the first point and 1 at the last.
* Progress is distance-weighted, so equal alpha steps cover equal distance.
* A closed loop adds the segment from the last point back to the first and wraps Alpha instead of clamping it.
* @returns The sampled location, or the zero vector for an empty array or non-finite input.
*/
UFUNCTION(BlueprintPure, meta = (DisplayName = "Sample Location Array", BlueprintThreadSafe), Category = "Directive Utilities|Math|Vector")
static FVector SampleLocationArray(const TArray<FVector>& Locations, float Alpha, bool bClosedLoop = false);
/** Creates one transform per location using a shared rotation and scale. */
UFUNCTION(BlueprintPure, meta = (DisplayName = "Locations To Transforms", BlueprintThreadSafe), Category = "Directive Utilities|Math|Transform")
static TArray<FTransform> LocationsToTransforms(const TArray<FVector>& Locations,
FRotator Rotation = FRotator(0.0, 0.0, 0.0), FVector Scale = FVector(1.0, 1.0, 1.0));
/**
* Creates one transform per location with its local X axis facing toward or away from a target.
* A location equal to Target uses Rotation Offset without a facing rotation.
*/
UFUNCTION(BlueprintPure, meta = (DisplayName = "Locations To Facing Transforms", BlueprintThreadSafe, AdvancedDisplay = "UpDirection,RotationOffset,Scale,bFaceAway"), Category = "Directive Utilities|Math|Transform")
static TArray<FTransform> LocationsToFacingTransforms(const TArray<FVector>& Locations,
FVector Target, FVector UpDirection = FVector(0.0, 0.0, 1.0),
FRotator RotationOffset = FRotator(0.0, 0.0, 0.0), FVector Scale = FVector(1.0, 1.0, 1.0),
bool bFaceAway = false);
/**
* Creates transforms from location, rotation, and scale arrays.
* Rotation and scale arrays may be empty, contain one value to broadcast, or match the location count.
* @returns True when the attribute-array lengths and values are valid.
*/
UFUNCTION(BlueprintPure, meta = (DisplayName = "Make Transforms From Arrays", AutoCreateRefTerm = "Rotations,Scales", BlueprintThreadSafe), Category = "Directive Utilities|Math|Transform")
static bool MakeTransformsFromArrays(const TArray<FVector>& Locations, const TArray<FRotator>& Rotations,
const TArray<FVector>& Scales, TArray<FTransform>& Transforms);
/**
* Samples a transform along the path through an array, with Alpha 0 at the first transform and 1 at the last.
* Progress is distance-weighted by location. Rotation takes the shortest path and scale interpolates linearly.
* A closed loop adds the segment from the last transform back to the first and wraps Alpha instead of clamping it.
* @returns The sampled transform, or the identity for an empty array or non-finite input.
*/
UFUNCTION(BlueprintPure, meta = (DisplayName = "Sample Transform Array", BlueprintThreadSafe), Category = "Directive Utilities|Math|Transform")
static FTransform SampleTransformArray(const TArray<FTransform>& Transforms, float Alpha, bool bClosedLoop = false);
/**
* Generates a rectangular grid on the local XY plane.
* @param Origin - The first point, or the grid center when Centered is true.
* @param Rotation - The grid plane rotation.
* @param Dimensions - The number of points along the local X and Y axes.
* @param Spacing - The signed center-to-center spacing along the local X and Y axes.
* @param bCentered - Whether to center the grid on Origin.
* @returns Points ordered by X, then Y, or an empty array for invalid input or an unsupported point count.
*/
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Generate Grid Points 2D"), Category = "Directive Utilities|Math|Point Generation")
static TArray<FVector> GenerateGridPoints2D(const FVector& Origin, const FRotator& Rotation,
FIntPoint Dimensions, const FVector2D& Spacing, bool bCentered = true);
/**
* Generates a rectangular 3D grid.
* @param Origin - The first point, or the grid center when Centered is true.
* @param Rotation - The grid rotation.
* @param Dimensions - The number of points along the local X, Y, and Z axes.
* @param Spacing - The signed center-to-center spacing along the local X, Y, and Z axes.
* @param bCentered - Whether to center the grid on Origin.
* @returns Points ordered by X, then Y, then Z, or an empty array for invalid input or an unsupported point count.
*/
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Generate Grid Points 3D"), Category = "Directive Utilities|Math|Point Generation")
static TArray<FVector> GenerateGridPoints3D(const FVector& Origin, const FRotator& Rotation,
FIntVector Dimensions, const FVector& Spacing, bool bCentered = true);
/**
* Generates transforms on a rectangular grid on the local XY plane.
* @param Origin - The first location, or the grid center when Centered is true.
* @param Rotation - The grid plane rotation.
* @param Dimensions - The number of points along the local X and Y axes.
* @param Spacing - The signed center-to-center spacing along the local X and Y axes.
* @param bCentered - Whether to center the grid on Origin.
* @param InstanceRotation - Shared rotation applied to every transform.
* @param Scale - Shared scale applied to every transform.
* @returns Transforms ordered by X, then Y, or an empty array for invalid input or an unsupported count.
*/
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Generate Grid Transforms 2D", AdvancedDisplay = "InstanceRotation,Scale"), Category = "Directive Utilities|Math|Point Generation")
static TArray<FTransform> GenerateGridTransforms2D(const FVector& Origin, const FRotator& Rotation,
FIntPoint Dimensions, const FVector2D& Spacing, bool bCentered = true,
FRotator InstanceRotation = FRotator(0.0, 0.0, 0.0), FVector Scale = FVector(1.0, 1.0, 1.0));
/**
* Generates transforms on a rectangular 3D grid.
* @param Origin - The first location, or the grid center when Centered is true.
* @param Rotation - The grid rotation.
* @param Dimensions - The number of points along the local X, Y, and Z axes.
* @param Spacing - The signed center-to-center spacing along the local X, Y, and Z axes.
* @param bCentered - Whether to center the grid on Origin.
* @param InstanceRotation - Shared rotation applied to every transform.
* @param Scale - Shared scale applied to every transform.
* @returns Transforms ordered by X, then Y, then Z, or an empty array for invalid input or an unsupported count.
*/
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Generate Grid Transforms 3D", AdvancedDisplay = "InstanceRotation,Scale"), Category = "Directive Utilities|Math|Point Generation")
static TArray<FTransform> GenerateGridTransforms3D(const FVector& Origin, const FRotator& Rotation,
FIntVector Dimensions, const FVector& Spacing, bool bCentered = true,
FRotator InstanceRotation = FRotator(0.0, 0.0, 0.0), FVector Scale = FVector(1.0, 1.0, 1.0));
/**
* Generates a rectangular hex grid on the rotated local XY plane.
* @param Origin - The first cell center, or the grid bounds center when Centered is true.
* @param Rotation - The grid plane rotation.
* @param Dimensions - The number of columns and rows.
* @param CellRadius - The distance from a cell center to a corner. Must be positive.
* @param Orientation - Whether the hex cells have pointy or flat tops.
* @param Gap - The signed edge-to-edge gap between adjacent cells. Negative values overlap cells.
* @param bCentered - Whether to center the grid bounds on Origin.
* @returns Points ordered by row, then column, or an empty array for invalid input or an unsupported point count.
*/
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Generate Rectangular Hex Grid"), Category = "Directive Utilities|Math|Hex Grid")
static TArray<FVector> GenerateRectangularHexGrid(const FVector& Origin, const FRotator& Rotation,
FIntPoint Dimensions, double CellRadius,
EDirectiveUtilHexOrientation Orientation = EDirectiveUtilHexOrientation::PointyTop,
double Gap = 0.0, bool bCentered = true);
/**
* Generates transforms for a rectangular hex grid with a shared instance rotation and scale.
* Cell order matches Generate Rectangular Hex Grid.
*/
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Generate Rectangular Hex Grid Transforms", AdvancedDisplay = "InstanceRotation,Scale"), Category = "Directive Utilities|Math|Hex Grid")
static TArray<FTransform> GenerateRectangularHexGridTransforms(const FVector& Origin, const FRotator& Rotation,
FIntPoint Dimensions, double CellRadius,
EDirectiveUtilHexOrientation Orientation = EDirectiveUtilHexOrientation::PointyTop,
double Gap = 0.0, bool bCentered = true,
FRotator InstanceRotation = FRotator(0.0, 0.0, 0.0), FVector Scale = FVector(1.0, 1.0, 1.0));
/**
* Returns the axial coordinate of every cell of a rectangular hex grid, in the same cell order as
* Generate Rectangular Hex Grid.
* @returns The coordinates, or an empty array for invalid input or an unsupported count.
*/
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Get Rectangular Hex Grid Coordinates"), Category = "Directive Utilities|Math|Hex Grid")
static TArray<FIntPoint> GetRectangularHexGridCoordinates(FIntPoint Dimensions,
EDirectiveUtilHexOrientation Orientation = EDirectiveUtilHexOrientation::PointyTop);
/**
* Generates a hexagon-shaped grid on the rotated local XY plane.
* @param Origin - The center cell location.
* @param Rotation - The grid plane rotation.
* @param GridRadius - The number of cell rings around the center cell.
* @param CellRadius - The distance from a cell center to a corner. Must be positive.
* @param Orientation - Whether the hex cells have pointy or flat tops.
* @param Gap - The signed edge-to-edge gap between adjacent cells. Negative values overlap cells.
* @returns Points ordered by axial R, then Q, or an empty array for invalid input or an unsupported point count.
*/
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Generate Hexagonal Hex Grid"), Category = "Directive Utilities|Math|Hex Grid")
static TArray<FVector> GenerateHexagonalHexGrid(const FVector& Origin, const FRotator& Rotation,
int32 GridRadius, double CellRadius,
EDirectiveUtilHexOrientation Orientation = EDirectiveUtilHexOrientation::PointyTop,
double Gap = 0.0);
/**
* Generates transforms for a hexagon-shaped grid with a shared instance rotation and scale.
* Cell order matches Generate Hexagonal Hex Grid.
*/
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Generate Hexagonal Hex Grid Transforms", AdvancedDisplay = "InstanceRotation,Scale"), Category = "Directive Utilities|Math|Hex Grid")
static TArray<FTransform> GenerateHexagonalHexGridTransforms(const FVector& Origin, const FRotator& Rotation,
int32 GridRadius, double CellRadius,
EDirectiveUtilHexOrientation Orientation = EDirectiveUtilHexOrientation::PointyTop,
double Gap = 0.0,
FRotator InstanceRotation = FRotator(0.0, 0.0, 0.0), FVector Scale = FVector(1.0, 1.0, 1.0));
/**
* Converts an axial hex coordinate to a location on the rotated local XY plane.
* @returns The cell center, or the zero vector for invalid layout input or coordinate overflow.
*/
UFUNCTION(BlueprintPure, meta = (DisplayName = "Hex Coordinate To Location", BlueprintThreadSafe), Category = "Directive Utilities|Math|Hex Grid")
static FVector HexCoordinateToLocation(FIntPoint Coordinate, const FVector& Origin, const FRotator& Rotation,
double CellRadius, EDirectiveUtilHexOrientation Orientation = EDirectiveUtilHexOrientation::PointyTop,
double Gap = 0.0);
/**
* Finds the axial coordinate of the nearest hex after projecting a location onto the rotated local XY plane.
* @returns The nearest axial coordinate, or (0, 0) for invalid layout input or an unrepresentable coordinate.
*/
UFUNCTION(BlueprintPure, meta = (DisplayName = "Location To Hex Coordinate", BlueprintThreadSafe), Category = "Directive Utilities|Math|Hex Grid")
static FIntPoint LocationToHexCoordinate(const FVector& Location, const FVector& Origin,
const FRotator& Rotation, double CellRadius,
EDirectiveUtilHexOrientation Orientation = EDirectiveUtilHexOrientation::PointyTop,
double Gap = 0.0);
/**
* Returns the six adjacent axial coordinates in a stable direction order.
* @returns Six neighbors, or an empty array when a neighbor would exceed the FIntPoint range.
*/
UFUNCTION(BlueprintPure, meta = (DisplayName = "Get Hex Neighbors", BlueprintThreadSafe), Category = "Directive Utilities|Math|Hex Grid")
static TArray<FIntPoint> GetHexNeighbors(FIntPoint Coordinate);
/** Returns the number of hex-grid steps between two axial coordinates. */
UFUNCTION(BlueprintPure, meta = (DisplayName = "Get Hex Distance", BlueprintThreadSafe), Category = "Directive Utilities|Math|Hex Grid")
static int64 GetHexDistance(FIntPoint A, FIntPoint B);
/**
* Returns every axial coordinate within a number of steps of a center cell, ordered by axial R, then Q.
* With a zero center the order matches the cells of Generate Hexagonal Hex Grid.
* @returns The coordinates, or an empty array for a negative range, coordinate overflow, or an unsupported count.
*/
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Get Hexes In Range"), Category = "Directive Utilities|Math|Hex Grid")
static TArray<FIntPoint> GetHexesInRange(FIntPoint Center, int32 Range);
/**
* Returns the axial coordinates exactly Radius steps from a center cell.
* Consecutive entries are adjacent and trace the ring once. A radius of zero returns the center.
* @returns The ring coordinates, or an empty array for a negative radius or coordinate overflow.
*/
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Get Hex Ring"), Category = "Directive Utilities|Math|Hex Grid")
static TArray<FIntPoint> GetHexRing(FIntPoint Center, int32 Radius);
/**
* Returns the axial coordinates along the straight line between two cells, including both endpoints.
* @returns The line coordinates, or an empty array for an unsupported length.
*/
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Get Hex Line"), Category = "Directive Utilities|Math|Hex Grid")
static TArray<FIntPoint> GetHexLine(FIntPoint Start, FIntPoint End);
/**
* Returns the six corner locations of a hex cell on the rotated local XY plane, ordered counter-clockwise.
* Corners lie at Cell Radius from the cell center; Gap only moves the center.
* @returns The corner locations, or an empty array for invalid layout input or coordinate overflow.
*/
UFUNCTION(BlueprintPure, meta = (DisplayName = "Get Hex Cell Corners", BlueprintThreadSafe), Category = "Directive Utilities|Math|Hex Grid")
static TArray<FVector> GetHexCellCorners(FIntPoint Coordinate, const FVector& Origin, const FRotator& Rotation,
double CellRadius, EDirectiveUtilHexOrientation Orientation = EDirectiveUtilHexOrientation::PointyTop,
double Gap = 0.0);
/**
* Generates points at a fixed spacing along a direction.
* @param Origin - The first point, or the formation center when Centered is true.
* @param Direction - The direction of travel. Its magnitude is ignored.
* @param Count - The number of points to generate.
* @param Spacing - The signed center-to-center distance between points.
* @param bCentered - Whether to center the formation on Origin.
* @returns The generated points, or an empty array for invalid input.
*/
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Generate Points Along Direction"), Category = "Directive Utilities|Math|Point Generation")
static TArray<FVector> GeneratePointsAlongDirection(const FVector& Origin, const FVector& Direction,
int32 Count, double Spacing, bool bCentered = false);
/**
* Generates evenly spaced points between two locations.
* @param Start - The start of the segment.
* @param End - The end of the segment.
* @param Count - The number of points to generate.
* @param bIncludeEndpoints - Whether the generated points include Start and End.
* @returns The generated points, or an empty array for invalid input.
*/
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Generate Points Between Locations"), Category = "Directive Utilities|Math|Point Generation")
static TArray<FVector> GeneratePointsBetweenLocations(const FVector& Start, const FVector& End,
int32 Count, bool bIncludeEndpoints = true);
/**
* Generates points at fixed distances along a spline.
* @param Spline - The spline to sample.
* @param Spacing - The distance between regular samples. Must be positive.
* @param bIncludeEndpoint - Whether to append the exact end of an open sampling range.
* @param SpacingMode - Fixed samples every Spacing units. Even shrinks the spacing so the samples divide the range evenly.
* @param CoordinateSpace - The space of the returned points.
* @param StartDistance - The distance where sampling starts. Clamped to the spline length.
* @param EndDistance - The distance where sampling ends. Negative means the end of the spline.
* @returns The generated points, or an empty array for invalid input or an unsupported point count.
*/
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Generate Points Along Spline", AdvancedDisplay = "SpacingMode,CoordinateSpace,StartDistance,EndDistance"), Category = "Directive Utilities|Math|Point Generation")
static TArray<FVector> GeneratePointsAlongSpline(const USplineComponent* Spline, double Spacing,
bool bIncludeEndpoint = true,
EDirectiveUtilSplineSpacingMode SpacingMode = EDirectiveUtilSplineSpacingMode::Fixed,
ESplineCoordinateSpace::Type CoordinateSpace = ESplineCoordinateSpace::World,
double StartDistance = 0.0, double EndDistance = -1.0);
/**
* Generates a fixed number of evenly spaced points along a spline.
* A closed loop spreads the points around the loop; a count of one on an open range returns its midpoint.
* @param Count - The number of points to generate.
* @param bIncludeEndpoints - Whether the points include both ends of an open sampling range.
* @param StartDistance - The distance where sampling starts. Clamped to the spline length.
* @param EndDistance - The distance where sampling ends. Negative means the end of the spline.
* @returns The generated points, or an empty array for invalid input.
*/
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Generate Points Along Spline by Count", AdvancedDisplay = "StartDistance,EndDistance"), Category = "Directive Utilities|Math|Point Generation")
static TArray<FVector> GeneratePointsAlongSplineByCount(const USplineComponent* Spline, int32 Count,
bool bIncludeEndpoints = true,
ESplineCoordinateSpace::Type CoordinateSpace = ESplineCoordinateSpace::World,
double StartDistance = 0.0, double EndDistance = -1.0);
/**
* Generates transforms at fixed distances along a spline.
* Rotation follows the spline tangent and roll. Scale can include the spline scale before applying Scale Multiplier.
*/
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Generate Transforms Along Spline", AdvancedDisplay = "SpacingMode,CoordinateSpace,bUseSplineScale,RotationOffset,ScaleMultiplier,StartDistance,EndDistance"), Category = "Directive Utilities|Math|Point Generation")
static TArray<FTransform> GenerateTransformsAlongSpline(const USplineComponent* Spline, double Spacing,
bool bIncludeEndpoint = true,
EDirectiveUtilSplineSpacingMode SpacingMode = EDirectiveUtilSplineSpacingMode::Fixed,
ESplineCoordinateSpace::Type CoordinateSpace = ESplineCoordinateSpace::World,
bool bUseSplineScale = true,
FRotator RotationOffset = FRotator(0.0, 0.0, 0.0), FVector ScaleMultiplier = FVector(1.0, 1.0, 1.0),
double StartDistance = 0.0, double EndDistance = -1.0);
/**
* Generates a fixed number of evenly spaced transforms along a spline.
* Rotation follows the spline tangent and roll. Scale can include the spline scale before applying Scale Multiplier.
*/
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Generate Transforms Along Spline by Count", AdvancedDisplay = "bUseSplineScale,RotationOffset,ScaleMultiplier,StartDistance,EndDistance"), Category = "Directive Utilities|Math|Point Generation")
static TArray<FTransform> GenerateTransformsAlongSplineByCount(const USplineComponent* Spline, int32 Count,
bool bIncludeEndpoints = true,
ESplineCoordinateSpace::Type CoordinateSpace = ESplineCoordinateSpace::World,
bool bUseSplineScale = true,
FRotator RotationOffset = FRotator(0.0, 0.0, 0.0), FVector ScaleMultiplier = FVector(1.0, 1.0, 1.0),
double StartDistance = 0.0, double EndDistance = -1.0);
/**
* Generates evenly spaced points around a circle on the rotated local XY plane.
* @returns The generated points without repeating the first point, or an empty array for invalid input.
*/
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Generate Points On Circle"), Category = "Directive Utilities|Math|Point Generation")
static TArray<FVector> GeneratePointsOnCircle(const FVector& Center, const FRotator& Rotation,
double Radius, int32 Count, double StartAngleDegrees = 0.0);
/** Generates transforms around a circle with fixed, radial, or path-relative orientation. */
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Generate Transforms On Circle", AdvancedDisplay = "RotationOffset,Scale"), Category = "Directive Utilities|Math|Point Generation")
static TArray<FTransform> GenerateTransformsOnCircle(const FVector& Center, const FRotator& Rotation,
double Radius, int32 Count, double StartAngleDegrees = 0.0,
EDirectiveUtilRadialOrientation Orientation = EDirectiveUtilRadialOrientation::FaceCenter,
FRotator RotationOffset = FRotator(0.0, 0.0, 0.0), FVector Scale = FVector(1.0, 1.0, 1.0));
/**
* Generates evenly spaced points along an arc on the rotated local XY plane.
* @param bIncludeEndpoint - Whether the final point lies at Start Angle plus Arc Angle.
* @returns The generated points, or an empty array for invalid input.
*/
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Generate Points On Arc"), Category = "Directive Utilities|Math|Point Generation")
static TArray<FVector> GeneratePointsOnArc(const FVector& Center, const FRotator& Rotation,
double Radius, int32 Count, double StartAngleDegrees = 0.0, double ArcAngleDegrees = 90.0,
bool bIncludeEndpoint = true);
/** Generates transforms along an arc with fixed, radial, or path-relative orientation. */
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Generate Transforms On Arc", AdvancedDisplay = "RotationOffset,Scale"), Category = "Directive Utilities|Math|Point Generation")
static TArray<FTransform> GenerateTransformsOnArc(const FVector& Center, const FRotator& Rotation,
double Radius, int32 Count, double StartAngleDegrees = 0.0, double ArcAngleDegrees = 90.0,
bool bIncludeEndpoint = true,
EDirectiveUtilRadialOrientation Orientation = EDirectiveUtilRadialOrientation::FaceCenter,
FRotator RotationOffset = FRotator(0.0, 0.0, 0.0), FVector Scale = FVector(1.0, 1.0, 1.0));
/**
* Generates a deterministic sunflower distribution across a disc on the rotated local XY plane.
* @returns Approximately even area coverage, or an empty array for invalid input.
*/
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Generate Points On Disc"), Category = "Directive Utilities|Math|Point Generation")
static TArray<FVector> GeneratePointsOnDisc(const FVector& Center, const FRotator& Rotation,
double Radius, int32 Count, double AngleOffsetDegrees = 0.0);
/**
* Generates a deterministic Fibonacci distribution across a sphere surface.
* @returns Approximately even surface coverage, or an empty array for invalid input.
*/
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Generate Points On Sphere"), Category = "Directive Utilities|Math|Point Generation")
static TArray<FVector> GeneratePointsOnSphere(const FVector& Center, const FRotator& Rotation,
double Radius, int32 Count, double AngleOffsetDegrees = 0.0);
/**
* Offsets each location along a direction by Perlin noise sampled at that location.
* The offset varies smoothly between -Amplitude and Amplitude across the noise field.
* @param Locations - The locations to offset.
* @param NoiseScale - The world-space size of the noise features. Must be positive.
* @param Amplitude - The maximum offset distance along the direction.
* @param Direction - The offset direction. Its magnitude is ignored.
* @param NoiseOffset - World-space shift of the noise field, for varying the pattern between layers.
* @returns The offset locations, or an empty array for invalid input.
*/
UFUNCTION(BlueprintPure, meta = (DisplayName = "Offset Locations By Noise", BlueprintThreadSafe, AdvancedDisplay = "Direction,NoiseOffset"), Category = "Directive Utilities|Math|Point Generation")
static TArray<FVector> OffsetLocationsByNoise(const TArray<FVector>& Locations, double NoiseScale,
double Amplitude, FVector Direction = FVector(0.0, 0.0, 1.0),
FVector NoiseOffset = FVector(0.0, 0.0, 0.0));
/**
* Offsets each transform location along a direction by Perlin noise sampled at that location.
* Rotation and scale are unchanged. Behaves like Offset Locations By Noise.
*/
UFUNCTION(BlueprintPure, meta = (DisplayName = "Offset Transforms By Noise", BlueprintThreadSafe, AdvancedDisplay = "Direction,NoiseOffset"), Category = "Directive Utilities|Math|Point Generation")
static TArray<FTransform> OffsetTransformsByNoise(const TArray<FTransform>& Transforms, double NoiseScale,
double Amplitude, FVector Direction = FVector(0.0, 0.0, 1.0),
FVector NoiseOffset = FVector(0.0, 0.0, 0.0));
/**
* Applies a Back/Elastic/Bounce easing curve to a normalized alpha.
* @note These are the Penner easing curves the engine's built-in "Ease" node (EEasingFunc) does not provide.
* For Sinusoidal/Exponential/Circular/power easings, use the engine's "Ease" node instead.
* @param Alpha - The input alpha. Clamped to the [0, 1] range.
* @param EaseType - The easing curve to apply.
* @returns The eased alpha. Note that Back and Elastic curves intentionally overshoot the [0, 1] range.
* @returns The eased alpha. Endpoints are exact. Back and Elastic curves intentionally overshoot the [0, 1] range between the endpoints.
*/
UFUNCTION(BlueprintPure, meta = (DisplayName = "Ease Alpha", BlueprintThreadSafe), Category = "Directive Utilities|Math|Easing")
static float EaseAlpha(float Alpha, EDirectiveUtilEaseType EaseType);
@@ -101,6 +592,39 @@ public:
UFUNCTION(BlueprintPure, meta = (DisplayName = "Ease (Color)", BlueprintThreadSafe), Category = "Directive Utilities|Math|Easing")
static FLinearColor EaseColor(const FLinearColor& A, const FLinearColor& B, float Alpha, EDirectiveUtilEaseType EaseType);
/**
* Eases a transform from A to B. Rotation takes the shortest path; location and scale interpolate linearly
* before the eased alpha is applied.
* @param A - The start transform (returned at Alpha 0).
* @param B - The target transform (returned at Alpha 1).
* @param Alpha - The input alpha. Clamped to the [0, 1] range.
* @param EaseType - The easing curve to apply.
* @returns The eased transform between A and B.
*/
UFUNCTION(BlueprintPure, meta = (DisplayName = "Ease (Transform)", BlueprintThreadSafe), Category = "Directive Utilities|Math|Easing")
static FTransform EaseTransform(const FTransform& A, const FTransform& B, float Alpha, EDirectiveUtilEaseType EaseType);
/**
* Eases each location in From toward the same index in To. Use with two generated layouts to blend formations.
* @param Alpha - The shared input alpha. Clamped to the [0, 1] range.
* @param PerElementAlphas - When non-empty, one alpha per element replaces Alpha for staggered blends.
* @returns The eased locations, or an empty array for mismatched lengths or non-finite input.
*/
UFUNCTION(BlueprintPure, meta = (DisplayName = "Ease Location Arrays", AutoCreateRefTerm = "PerElementAlphas", BlueprintThreadSafe, AdvancedDisplay = "PerElementAlphas"), Category = "Directive Utilities|Math|Easing")
static TArray<FVector> EaseLocationArrays(const TArray<FVector>& From, const TArray<FVector>& To,
float Alpha, EDirectiveUtilEaseType EaseType, const TArray<float>& PerElementAlphas);
/**
* Eases each transform in From toward the same index in To. Use with two generated layouts to blend formations.
* Rotation takes the shortest path; location and scale interpolate linearly before the eased alpha is applied.
* @param Alpha - The shared input alpha. Clamped to the [0, 1] range.
* @param PerElementAlphas - When non-empty, one alpha per element replaces Alpha for staggered blends.
* @returns The eased transforms, or an empty array for mismatched lengths or non-finite input.
*/
UFUNCTION(BlueprintPure, meta = (DisplayName = "Ease Transform Arrays", AutoCreateRefTerm = "PerElementAlphas", BlueprintThreadSafe, AdvancedDisplay = "PerElementAlphas"), Category = "Directive Utilities|Math|Easing")
static TArray<FTransform> EaseTransformArrays(const TArray<FTransform>& From, const TArray<FTransform>& To,
float Alpha, EDirectiveUtilEaseType EaseType, const TArray<float>& PerElementAlphas);
/**
* Rounds a float to a given number of decimal places. Rounds half away from zero,
* matching "Round To Decimals (Text)".
@@ -138,7 +662,7 @@ public:
* Formats a duration in seconds as d/h/m/s units from the largest nonzero unit down, with
* two-digit padding after the first ("1h 03m 05s", "2d 04h", "45s"). With bIncludeSeconds
* false the seconds unit is dropped and sub-minute durations return "0m". Negative input gets
* a leading minus sign; non-finite input returns "0s". Output is English-only.
* a leading minus sign when a nonzero unit remains; non-finite input returns "0s". Output is English-only.
* @param Seconds - The duration in seconds.
* @param bIncludeSeconds - Whether to include the seconds unit.
* @returns The formatted duration text.
@@ -224,9 +748,59 @@ public:
UFUNCTION(BlueprintPure, meta = (BlueprintThreadSafe), Category = "Directive Utilities|Math|Array")
static float GetFloatArrayStandardDeviation(const TArray<float>& Values);
/**
* Calculates the circular mean of an angle array in degrees.
* @returns False for an empty array, non-finite input, or an undefined or numerically indeterminate circular mean.
*/
UFUNCTION(BlueprintPure, meta = (DisplayName = "Get Angle Array Average", BlueprintThreadSafe), Category = "Directive Utilities|Math|Array")
static bool GetAngleArrayAverage(const TArray<float>& Angles, float& AverageAngle, float& ResultantStrength);
/**
* Calculates the weighted average of a float array.
* @returns False when the arrays differ in size, contain invalid values, or have no positive weight.
*/
UFUNCTION(BlueprintPure, meta = (DisplayName = "Get Weighted Float Array Average", BlueprintThreadSafe), Category = "Directive Utilities|Math|Array")
static bool GetWeightedFloatArrayAverage(const TArray<float>& Values, const TArray<float>& Weights, float& Average);
/**
* Calculates the weighted average of a vector array.
* @returns False when the arrays differ in size, contain invalid values, or have no positive weight.
*/
UFUNCTION(BlueprintPure, meta = (DisplayName = "Get Weighted Vector Array Average", BlueprintThreadSafe), Category = "Directive Utilities|Math|Array")
static bool GetWeightedVectorArrayAverage(const TArray<FVector>& Values, const TArray<float>& Weights, FVector& Average);
/**
* Normalizes a float array to an output range.
* @returns False for an empty array or non-finite input.
*/
UFUNCTION(BlueprintPure, meta = (DisplayName = "Normalize Float Array To Range", BlueprintThreadSafe), Category = "Directive Utilities|Math|Array")
static bool NormalizeFloatArrayToRange(const TArray<float>& Values, float OutputMinimum, float OutputMaximum,
TArray<float>& NormalizedValues);
/**
* Normalizes positive weights so their sum is one. Negative and non-finite weights are treated as zero.
* @returns False for an empty array or when no positive weight remains.
*/
UFUNCTION(BlueprintPure, meta = (DisplayName = "Normalize Weights", BlueprintThreadSafe), Category = "Directive Utilities|Math|Array")
static bool NormalizeWeights(const TArray<float>& Weights, TArray<float>& NormalizedWeights);
/**
* Calculates a percentile using the Type 7 linear method without modifying the input array.
* @returns False for an empty array or non-finite input.
*/
UFUNCTION(BlueprintPure, meta = (DisplayName = "Get Float Array Percentile", BlueprintThreadSafe), Category = "Directive Utilities|Math|Array")
static bool GetFloatArrayPercentile(const TArray<float>& Values, float Percentile, float& Value);
/**
* Calculates the root mean square of a float array.
* @returns False for an empty array or non-finite input.
*/
UFUNCTION(BlueprintPure, meta = (DisplayName = "Get Float Array Root Mean Square", BlueprintThreadSafe), Category = "Directive Utilities|Math|Array")
static bool GetFloatArrayRootMeanSquare(const TArray<float>& Values, float& RootMeanSquare);
/**
* Returns a random index into the Weights array, where each index's probability is proportional to its weight.
* Useful for loot tables and weighted spawning. Negative weights are treated as zero.
* Useful for loot tables and weighted spawning. Negative and non-finite weights are treated as zero.
* @param Weights - The per-index weights.
* @returns The selected index, or INDEX_NONE (-1) if the array is empty or all weights are zero.
*/
@@ -236,9 +810,33 @@ public:
/**
* Deterministic version of Get Random Index From Weights that draws from (and advances) the provided random stream.
* @param Stream - The random stream to draw from.
* @param Weights - The per-index weights. Negative weights are treated as zero.
* @param Weights - The per-index weights. Negative and non-finite weights are treated as zero.
* @returns The selected index, or INDEX_NONE (-1) if the array is empty or all weights are zero.
*/
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Get Random Index From Weights (Stream)"), Category = "Directive Utilities|Math|Random")
static int32 GetRandomIndexFromWeightsFromStream(UPARAM(ref) FRandomStream& Stream, const TArray<float>& Weights);
/** Returns a uniformly distributed random point inside a circle. */
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Random Point In Circle"), Category = "Directive Utilities|Math|Random")
static FVector2D RandomPointInCircle(float Radius);
/** Returns a deterministic uniformly distributed random point inside a circle. Invalid or zero radii do not advance the stream. */
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Random Point In Circle (Stream)"), Category = "Directive Utilities|Math|Random")
static FVector2D RandomPointInCircleFromStream(UPARAM(ref) FRandomStream& Stream, float Radius);
/** Returns a uniformly distributed random point inside a 2D annulus. */
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Random Point In Annulus"), Category = "Directive Utilities|Math|Random")
static FVector2D RandomPointInAnnulus(float InnerRadius, float OuterRadius);
/** Returns a deterministic uniformly distributed random point inside a 2D annulus. Invalid or zero radii do not advance the stream. */
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Random Point In Annulus (Stream)"), Category = "Directive Utilities|Math|Random")
static FVector2D RandomPointInAnnulusFromStream(UPARAM(ref) FRandomStream& Stream, float InnerRadius, float OuterRadius);
/** Returns a uniformly distributed random point inside a sphere. */
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Random Point In Sphere"), Category = "Directive Utilities|Math|Random")
static FVector RandomPointInSphere(float Radius);
/** Returns a deterministic uniformly distributed random point inside a sphere. Invalid or zero radii do not advance the stream. */
UFUNCTION(BlueprintCallable, meta = (DisplayName = "Random Point In Sphere (Stream)"), Category = "Directive Utilities|Math|Random")
static FVector RandomPointInSphereFromStream(UPARAM(ref) FRandomStream& Stream, float Radius);
};

View File

@@ -13,6 +13,7 @@ class USaveGame;
* Save-slot utilities that fill the gaps left by UGameplayStatics: enumerating slots, reading slot
* timestamps, and serializing a save object to/from an in-memory byte array. This is slot/IO QoL only,
* not a save framework: use the engine's SaveGameToSlot/LoadGameFromSlot for the actual slot I/O.
* Slot operations accept flat file names so they behave consistently across platform save backends.
*/
UCLASS()
class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilSaveGameFunctionLibrary : public UBlueprintFunctionLibrary
@@ -22,9 +23,10 @@ class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilSaveGameFunctionLibrary : publ
public:
/**
* Returns the names of all existing save slots in the project's default save directory.
* @note This enumerates the engine's default file-based save directory (Saved/SaveGames); it does not
* cover platform-specific save systems (e.g. console storage).
* Returns the names of all existing save slots known to the engine save game system.
* Uses ISaveGameSystem::GetSaveGameNames so the result matches DoesSaveSlotExist /
* DeleteSaveSlot / RenameSaveSlot. Falls back to the default Saved/SaveGames directory
* only when the active backend cannot enumerate slots.
* @returns The save slot names (without extension).
*/
UFUNCTION(BlueprintCallable, Category = "Directive Utilities|SaveGame")
@@ -34,7 +36,7 @@ public:
* Returns the last-modified timestamp of a save slot, if it exists.
* @param SlotName - The save slot name.
* @param OutTimestamp - [out] The slot's last-modified time (local), or a default time if it does not exist.
* Converted from the file system's UTC timestamp to local time.
* Converted from the file system's UTC timestamp using the timezone rules for that instant.
* @returns True if the slot exists.
*/
UFUNCTION(BlueprintCallable, Category = "Directive Utilities|SaveGame")
@@ -81,8 +83,9 @@ public:
/**
* Renames a save slot by copying its data to the new name and then deleting the original.
* Fails without mutating anything unless both names are valid, the names differ, the old slot
* exists, and the new slot does not. On failure the original slot is never lost. Goes through
* the engine's save game system, so unlike enumeration it also works on platform save backends.
* exists, and the new slot does not. Case-only renames (Slot -> slot) rewrite the existing
* slot instead of reporting a collision. On failure the original slot is never lost. Goes through
* the engine's save game system so it stays consistent with DoesSaveSlotExist and GetAllSaveSlotNames.
* @param OldSlotName - The existing save slot name.
* @param NewSlotName - The new save slot name.
* @param UserIndex - The platform user index the save belongs to.

View File

@@ -305,7 +305,8 @@ public:
/**
* Checks whether the string is safe to use as a bare file name: not empty, no path
* separators or relative segments, and no characters invalid in file names.
* separators or relative segments, no characters invalid in file names, no trailing
* dot or space, and not a reserved device name (CON, PRN, AUX, NUL, COM1-9, LPT1-9).
* @param String - The string to check.
* @returns True if the string is a valid bare file name.
*/
@@ -314,7 +315,8 @@ public:
/**
* Returns the string with path separators and characters invalid in file names removed
* (or replaced when a replacement character is provided). May return an empty string.
* (or replaced when a replacement character is provided). Trailing dots and spaces are
* stripped. Reserved device names are prefixed with an underscore. May return an empty string.
* @param String - The string to sanitize.
* @param Replacement - Optional single-character replacement for stripped characters.
* @returns The sanitized file name.

View File

@@ -21,6 +21,6 @@ public:
* Returns true if the provided text is not empty.
* @param Text - The text to check.
*/
UFUNCTION(BlueprintPure, Category = "Directive Utilities|Text" )
UFUNCTION(BlueprintPure, meta = (AutoCreateRefTerm = "Text"), Category = "Directive Utilities|Text")
static bool IsNotEmpty(const FText& Text);
};

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#pragma once
#include "CoreMinimal.h"
#include "Engine/CancellableAsyncAction.h"
#include "Kismet/BlueprintAsyncActionBase.h"
#include "DirectiveUtilAsyncActionBase.generated.h"
UCLASS(Abstract)
class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilAsyncActionBase : public UBlueprintAsyncActionBase
{
GENERATED_BODY()
public:
virtual void RegisterWithGameInstance(const UObject* WorldContextObject) override;
virtual void SetReadyToDestroy() override;
protected:
virtual void BeginDestroy() override;
private:
void HandleWorldCleanup(UWorld* World, bool bSessionEnded, bool bCleanupResources);
void UnbindWorldCleanup();
TWeakObjectPtr<UWorld> RegisteredWorld;
FDelegateHandle WorldCleanupHandle;
};
UCLASS(Abstract)
class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilCancellableAsyncAction : public UCancellableAsyncAction
{
GENERATED_BODY()
public:
virtual void RegisterWithGameInstance(const UObject* WorldContextObject) override;
virtual void SetReadyToDestroy() override;
protected:
virtual void BeginDestroy() override;
private:
void HandleWorldCleanup(UWorld* World, bool bSessionEnded, bool bCleanupResources);
void UnbindWorldCleanup();
TWeakObjectPtr<UWorld> RegisteredWorld;
FDelegateHandle WorldCleanupHandle;
};

View File

@@ -3,7 +3,7 @@
#pragma once
#include "CoreMinimal.h"
#include "Kismet/BlueprintAsyncActionBase.h"
#include "Tasks/DirectiveUtilAsyncActionBase.h"
#include "UObject/SoftObjectPtr.h"
#include "DirectiveUtilTask_AsyncLoadAsset.generated.h"
@@ -19,7 +19,7 @@ DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnAsyncLoadAssetsProgress, int32,
* Asynchronously loads a soft object reference and broadcasts the loaded asset, with a cancel option.
*/
UCLASS(BlueprintType, meta=(ExposedAsyncProxy = AsyncTask, DisplayName="Async Load Asset"))
class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilTask_AsyncLoadAsset : public UBlueprintAsyncActionBase
class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilTask_AsyncLoadAsset : public UDirectiveUtilAsyncActionBase
{
GENERATED_BODY()
@@ -71,7 +71,7 @@ protected:
* Asynchronously loads a soft class reference and broadcasts the loaded class, with a cancel option.
*/
UCLASS(BlueprintType, meta=(ExposedAsyncProxy = AsyncTask, DisplayName="Async Load Class"))
class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilTask_AsyncLoadClass : public UBlueprintAsyncActionBase
class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilTask_AsyncLoadClass : public UDirectiveUtilAsyncActionBase
{
GENERATED_BODY()
@@ -124,7 +124,7 @@ protected:
* loaded assets, with progress updates and a cancel option.
*/
UCLASS(BlueprintType, meta=(ExposedAsyncProxy = AsyncTask, DisplayName="Async Load Assets"))
class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilTask_AsyncLoadAssets : public UBlueprintAsyncActionBase
class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilTask_AsyncLoadAssets : public UDirectiveUtilAsyncActionBase
{
GENERATED_BODY()

View File

@@ -3,7 +3,7 @@
#pragma once
#include "CoreMinimal.h"
#include "Kismet/BlueprintAsyncActionBase.h"
#include "Tasks/DirectiveUtilAsyncActionBase.h"
#include "Engine/EngineTypes.h"
#include "Engine/HitResult.h"
#include "DirectiveUtilTask_AsyncTrace.generated.h"
@@ -28,7 +28,7 @@ enum class EDirectiveUtilTraceShape : uint8
* The trace cannot be cancelled.
*/
UCLASS(BlueprintType, meta=(ExposedAsyncProxy = AsyncTask, DisplayName="Async Trace"))
class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilTask_AsyncTrace : public UBlueprintAsyncActionBase
class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilTask_AsyncTrace : public UDirectiveUtilAsyncActionBase
{
GENERATED_BODY()

View File

@@ -3,7 +3,7 @@
#pragma once
#include "CoreMinimal.h"
#include "Kismet/BlueprintAsyncActionBase.h"
#include "Tasks/DirectiveUtilAsyncActionBase.h"
#include "Engine/TimerHandle.h"
#include "DirectiveUtilTask_Delay.generated.h"
@@ -11,10 +11,10 @@ DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnDelayCompleted);
/**
* DirectiveUtilTask_Delay
* A cancellable delay that can be ended early by calling EndTask.
* A cancellable delay.
*/
UCLASS(BlueprintType, meta=(ExposedAsyncProxy = AsyncTask, DisplayName="Cancellable Delay"))
class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilTask_Delay : public UBlueprintAsyncActionBase
class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilTask_Delay : public UDirectiveUtilCancellableAsyncAction
{
GENERATED_BODY()
@@ -23,10 +23,10 @@ public:
/**
* Starts a cancellable delay.
* When the delay has completed, the Completed delegate is called.
* Call EndTask to cancel the delay before it completes.
* Call Cancel to stop the delay before it completes.
*
* @param WorldContextObject The world context object.
* @param Duration The duration of the delay in seconds.
* @param Duration The duration of the delay in seconds. Non-finite and non-positive values complete on the next timer tick.
*/
UFUNCTION(
BlueprintCallable,
@@ -38,14 +38,13 @@ public:
))
static UDirectiveUtilTask_Delay* CancellableDelay(UObject* WorldContextObject, float Duration);
/**
* Ends the delay early.
*/
UFUNCTION(BlueprintCallable, Category = "Directive Utilities|FlowControl")
UFUNCTION(BlueprintCallable, meta=(DeprecatedFunction, DeprecationMessage="Use Cancel instead."), Category = "Directive Utilities|FlowControl")
void EndTask();
virtual void Activate() override;
virtual void Cancel() override;
virtual bool IsActive() const override;
virtual bool ShouldBroadcastDelegates() const override;
// The delegate called when the delay has completed.
UPROPERTY(BlueprintAssignable)
FOnDelayCompleted Completed;
@@ -55,6 +54,7 @@ protected:
TObjectPtr<UObject> WorldContextObject;
float Duration = 0.0f;
bool bFinished = false;
FTimerHandle TimerHandle;
void OnDelayComplete();

View File

@@ -0,0 +1,121 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#pragma once
#include "CoreMinimal.h"
#include "Tasks/DirectiveUtilAsyncActionBase.h"
#include "Engine/TimerHandle.h"
#include "DirectiveUtilTask_Flow.generated.h"
DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(FOnDurationUpdated, float, ElapsedTime, float, DeltaTime, float, Alpha);
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnDurationCompleted);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnRepeatIteration, int32, Index, int32, Remaining);
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnRepeatCompleted);
UCLASS(BlueprintType, meta=(ExposedAsyncProxy = AsyncTask, DisplayName="Update for Duration"))
class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilTask_UpdateForDuration : public UDirectiveUtilCancellableAsyncAction
{
GENERATED_BODY()
public:
UFUNCTION(
BlueprintCallable,
meta=(
BlueprintInternalUseOnly = "true",
Category = "Directive Utilities|FlowControl",
WorldContext = "WorldContextObject",
DisplayName = "Update for Duration",
AdvancedDisplay = "UpdateInterval"
))
static UDirectiveUtilTask_UpdateForDuration* UpdateForDuration(
UObject* WorldContextObject,
float Duration,
float UpdateInterval = 0.0f);
virtual void Activate() override;
virtual void Cancel() override;
virtual bool IsActive() const override;
virtual bool ShouldBroadcastDelegates() const override;
UPROPERTY(BlueprintAssignable)
FOnDurationUpdated Updated;
UPROPERTY(BlueprintAssignable)
FOnDurationCompleted Completed;
private:
UPROPERTY()
TObjectPtr<UObject> WorldContextObject;
float Duration = 0.0f;
float UpdateInterval = 0.0f;
float LastElapsedTime = 0.0f;
bool bHasUpdated = false;
bool bFinished = false;
FTimerHandle UpdateTimerHandle;
FTimerHandle CompletionTimerHandle;
void OnUpdate();
void OnComplete();
void BroadcastUpdate(float ElapsedTime, float Alpha);
void ClearTimers();
};
UCLASS(BlueprintType, meta=(ExposedAsyncProxy = AsyncTask, DisplayName="Repeat with Interval"))
class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilTask_RepeatWithInterval : public UDirectiveUtilCancellableAsyncAction
{
GENERATED_BODY()
public:
/**
* Runs a fixed number of iterations, or forever when Count is -1.
* @param Count - Iteration count. Use -1 to repeat until Cancel. Zero and other negative values complete on the next tick with no iterations.
* @param Interval - Delay between iterations. Non-positive or non-finite values run on consecutive ticks.
* @param InitialDelay - Delay before the first iteration. Non-positive or non-finite values start on the next tick.
*/
UFUNCTION(
BlueprintCallable,
meta=(
BlueprintInternalUseOnly = "true",
Category = "Directive Utilities|FlowControl",
WorldContext = "WorldContextObject",
DisplayName = "Repeat with Interval",
AdvancedDisplay = "InitialDelay"
))
static UDirectiveUtilTask_RepeatWithInterval* RepeatWithInterval(
UObject* WorldContextObject,
int32 Count,
float Interval,
float InitialDelay = 0.0f);
virtual void Activate() override;
virtual void Cancel() override;
virtual bool IsActive() const override;
virtual bool ShouldBroadcastDelegates() const override;
#if WITH_DEV_AUTOMATION_TESTS
void SetNextIndexForTesting(int32 Index) { NextIndex = Index; }
#endif
UPROPERTY(BlueprintAssignable)
FOnRepeatIteration Iteration;
UPROPERTY(BlueprintAssignable)
FOnRepeatCompleted Completed;
private:
UPROPERTY()
TObjectPtr<UObject> WorldContextObject;
int32 Count = 0;
int32 NextIndex = 0;
float Interval = 0.0f;
float InitialDelay = 0.0f;
bool bFinished = false;
FTimerHandle TimerHandle;
void Schedule(float Delay);
void OnIteration();
void Complete();
void ClearTimer();
};

View File

@@ -3,7 +3,7 @@
#pragma once
#include "CoreMinimal.h"
#include "Kismet/BlueprintAsyncActionBase.h"
#include "Tasks/DirectiveUtilAsyncActionBase.h"
#include "GameFramework/Controller.h"
#include "DirectiveUtilTask_MoveToLocation.generated.h"
@@ -15,7 +15,7 @@ DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnAsyncMoveToActor, bool, bSuccess)
* Asynchronously moves an actor to a location.
*/
UCLASS(BlueprintType, meta=(ExposedAsyncProxy = AsyncTask, DisplayName="Async Move To Location"))
class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilTask_MoveToLocation : public UBlueprintAsyncActionBase
class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilTask_MoveToLocation : public UDirectiveUtilAsyncActionBase
{
GENERATED_BODY()
@@ -27,15 +27,15 @@ public:
* bSuccess is true only when the pawn ends within AcceptanceRadius of Destination; the task also ends
* (with the same distance test) when path-following stops for any reason.
*
* If the controller or pawn is destroyed while moving, the task will automatically end.
* If the controller, pawn, or world becomes unavailable while moving, the task will automatically end.
* If bCheckStuckMovement is enabled and the controller gets stuck while moving, the task will automatically end.
*
* @param WorldContextObject The world context object.
* @param Controller The controller to move.
* @param Destination The vector location to move to.
* @param AcceptanceRadius The radius around the destination location that is considered acceptable. Be sure to set this to a reasonable value as the controller may never reach the exact destination.
* @param AcceptanceRadius The radius around the destination location that is considered acceptable. Negative and non-finite values are treated as zero.
* @param bCheckStuckMovement Check if the controller gets stuck while moving.
* @param StuckThreshold The distance threshold to consider the controller stuck.
* @param StuckThreshold The distance threshold to consider the controller stuck. Negative and non-finite values are treated as zero.
* @param bDebugLineTrace Display a line trace to the destination location for a short duration.
*/
UFUNCTION(
@@ -86,6 +86,8 @@ protected:
FTimerHandle StuckTimerHandle;
TWeakObjectPtr<UWorld> TimerWorld;
bool bHasCompleted = false;
void CheckMoveToLocation();
@@ -100,7 +102,7 @@ protected:
* Asynchronously moves an actor to another actor.
*/
UCLASS(BlueprintType, meta=(ExposedAsyncProxy = AsyncTask, DisplayName="Async Move To Actor"))
class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilTask_MoveToActor : public UBlueprintAsyncActionBase
class DIRECTIVEUTILITIESRUNTIME_API UDirectiveUtilTask_MoveToActor : public UDirectiveUtilAsyncActionBase
{
GENERATED_BODY()
@@ -113,15 +115,15 @@ public:
* (with the same distance test) when path-following stops for any reason. The goal's location is re-read
* every poll, so a moving goal is tracked.
*
* If the controller, pawn, or goal actor is destroyed while moving, the task will automatically end.
* If the controller, pawn, goal actor, or world becomes unavailable while moving, the task will automatically end.
* If bCheckStuckMovement is enabled and the controller gets stuck while moving, the task will automatically end.
*
* @param WorldContextObject The world context object.
* @param Controller The controller to move.
* @param Goal The actor to move to.
* @param AcceptanceRadius The radius around the goal actor that is considered acceptable. Be sure to set this to a reasonable value as the controller may never reach the goal's exact location.
* @param AcceptanceRadius The radius around the goal actor that is considered acceptable. Negative and non-finite values are treated as zero.
* @param bCheckStuckMovement Check if the controller gets stuck while moving.
* @param StuckThreshold The distance threshold to consider the controller stuck.
* @param StuckThreshold The distance threshold to consider the controller stuck. Negative and non-finite values are treated as zero.
*/
UFUNCTION(
BlueprintCallable,
@@ -171,6 +173,8 @@ protected:
FTimerHandle StuckTimerHandle;
TWeakObjectPtr<UWorld> TimerWorld;
bool bHasCompleted = false;
void CheckMoveToActor();

View File

@@ -1,4 +1,6 @@
#pragma once
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#pragma once
#include "CoreMinimal.h"
#include "DirectiveUtilInputTypes.generated.h"

View File

@@ -5,8 +5,37 @@
#include "CoreMinimal.h"
#include "DirectiveUtilMathTypes.generated.h"
/** Hex tile orientation on the local XY plane. */
UENUM(BlueprintType)
enum class EDirectiveUtilHexOrientation : uint8
{
PointyTop UMETA(DisplayName = "Pointy Top"),
FlatTop UMETA(DisplayName = "Flat Top"),
};
/** Rotation applied to transforms generated around a center point. */
UENUM(BlueprintType)
enum class EDirectiveUtilRadialOrientation : uint8
{
Fixed UMETA(DisplayName = "Fixed"),
FaceCenter UMETA(DisplayName = "Face Center"),
FaceAwayFromCenter UMETA(DisplayName = "Face Away From Center"),
FollowPath UMETA(DisplayName = "Follow Path"),
FaceAgainstPath UMETA(DisplayName = "Face Against Path"),
};
/** Spacing behavior for spline sample generation. */
UENUM(BlueprintType)
enum class EDirectiveUtilSplineSpacingMode : uint8
{
Fixed UMETA(DisplayName = "Fixed", Tooltip = "Samples every Spacing units. The final interval may be shorter."),
Even UMETA(DisplayName = "Even", Tooltip = "Shrinks Spacing so the samples divide the range evenly."),
};
/**
* Easing curves not provided by the engine's built-in Ease node (EEasingFunc): the classic Penner Back, Elastic and Bounce curves.
* Easing curves not provided by the engine's built-in Ease node (EEasingFunc): the classic Penner Back, Elastic and Bounce curves,
* plus Linear for un-eased interpolation.
* New values append at the end so existing Blueprint ordinals stay stable.
*/
UENUM(BlueprintType)
enum class EDirectiveUtilEaseType : uint8
@@ -20,4 +49,5 @@ enum class EDirectiveUtilEaseType : uint8
BounceIn UMETA(DisplayName = "Bounce In", Tooltip="Bounces with increasing energy before easing in."),
BounceOut UMETA(DisplayName = "Bounce Out", Tooltip="Bounces with decreasing energy after the end."),
BounceInOut UMETA(DisplayName = "Bounce In Out", Tooltip="Bounces at both the start and the end."),
Linear UMETA(DisplayName = "Linear", Tooltip="Interpolates at a constant rate without easing."),
};

View File

@@ -1,4 +1,6 @@
#pragma once
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#pragma once
#include "CoreMinimal.h"
#include "DirectiveUtilTypes.generated.h"
@@ -12,3 +14,39 @@ enum class EDirectiveUtilSuccessStatus : uint8
Success,
Failure,
};
UENUM(BlueprintType)
enum class EDirectiveUtilWorldType : uint8
{
Unknown,
None,
Game,
Editor,
PlayInEditor UMETA(DisplayName = "Play In Editor"),
EditorPreview UMETA(DisplayName = "Editor Preview"),
GamePreview UMETA(DisplayName = "Game Preview"),
GameRPC UMETA(DisplayName = "Game RPC"),
Inactive,
};
UENUM(BlueprintType)
enum class EDirectiveUtilBuildConfiguration : uint8
{
Unknown,
Debug,
DebugGame UMETA(DisplayName = "Debug Game"),
Development,
Shipping,
Test,
};
UENUM(BlueprintType)
enum class EDirectiveUtilBuildTargetType : uint8
{
Unknown,
Game,
Server,
Client,
Editor,
Program,
};

View File

@@ -23,13 +23,19 @@ public class DirectiveUtilitiesTests : ModuleRules
"DirectiveUtilitiesRuntime",
"AutomationTest",
"EnhancedInput",
"GameplayTags"
"GameplayTags",
"Projects"
}
);
if (Target.bBuildEditor)
{
PrivateIncludePaths.Add(System.IO.Path.Combine(ModuleDirectory, "../DirectiveUtilitiesEditor/Private"));
PrivateDependencyModuleNames.Add("AssetRegistry");
PrivateDependencyModuleNames.Add("BlueprintGraph");
PrivateDependencyModuleNames.Add("DirectiveUtilitiesBlueprintNodes");
PrivateDependencyModuleNames.Add("DirectiveUtilitiesEditor");
PrivateDependencyModuleNames.Add("UnrealEd");
}
}
}

View File

@@ -0,0 +1,320 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilArrayFunctionLibrary.h"
#include "Tests/DirectiveUtilTestObject.h"
#include "EdGraph/EdGraph.h"
#include "EdGraph/EdGraphPin.h"
#include "EdGraphSchema_K2.h"
#include "Engine/Blueprint.h"
#include "K2Node_CallArrayFunction.h"
#include "K2Node_Event.h"
#include "K2Node_VariableGet.h"
#include "K2Node_VariableSet.h"
#include "Kismet2/BlueprintEditorUtils.h"
#include "Kismet2/KismetEditorUtilities.h"
#include "Misc/AutomationTest.h"
#include "UObject/Package.h"
namespace DirectiveUtilArrayBlueprintVmTest
{
template <typename NodeType>
NodeType* AddNode(UEdGraph& Graph)
{
NodeType* Node = NewObject<NodeType>(&Graph);
Graph.AddNode(Node);
Node->CreateNewGuid();
Node->PostPlacedNewNode();
return Node;
}
UK2Node_VariableGet* AddVariableGet(UEdGraph& Graph, const FName PropertyName)
{
UK2Node_VariableGet* Node = AddNode<UK2Node_VariableGet>(Graph);
Node->VariableReference.SetSelfMember(PropertyName);
Node->AllocateDefaultPins();
return Node;
}
UK2Node_VariableSet* AddVariableSet(UEdGraph& Graph, const FName PropertyName)
{
UK2Node_VariableSet* Node = AddNode<UK2Node_VariableSet>(Graph);
Node->VariableReference.SetSelfMember(PropertyName);
Node->AllocateDefaultPins();
return Node;
}
UK2Node_CallArrayFunction* AddArrayCall(UEdGraph& Graph, const FName FunctionName)
{
UK2Node_CallArrayFunction* Node = AddNode<UK2Node_CallArrayFunction>(Graph);
Node->FunctionReference.SetExternalMember(FunctionName, UDirectiveUtilArrayFunctionLibrary::StaticClass());
Node->AllocateDefaultPins();
return Node;
}
bool Connect(UEdGraph& Graph, UEdGraphNode& FromNode, const FName FromPinName,
UEdGraphNode& ToNode, const FName ToPinName)
{
UEdGraphPin* FromPin = FromNode.FindPin(FromPinName);
UEdGraphPin* ToPin = ToNode.FindPin(ToPinName);
const UEdGraphSchema_K2* Schema = CastChecked<UEdGraphSchema_K2>(Graph.GetSchema());
return FromPin && ToPin && Schema->TryCreateConnection(FromPin, ToPin);
}
bool ConnectVariable(UEdGraph& Graph, const FName PropertyName,
UEdGraphNode& ToNode, const FName ToPinName)
{
UK2Node_VariableGet* VariableGet = AddVariableGet(Graph, PropertyName);
return Connect(Graph, *VariableGet, PropertyName, ToNode, ToPinName);
}
UBlueprint* BuildScenarioBlueprint(FAutomationTestBase& Test, const FName TargetProperty,
const FName SourceProperty, const FName ItemProperty)
{
const FName BlueprintName(*FString::Printf(
TEXT("BP_ArrayThunkVm_%s_%s"),
*TargetProperty.ToString(),
*FGuid::NewGuid().ToString(EGuidFormats::Digits)));
UPackage* Package = CreatePackage(
*FString::Printf(TEXT("/Temp/DirectiveUtilitiesTests/%s"), *BlueprintName.ToString()));
Package->SetFlags(RF_Transient);
UBlueprint* Blueprint = FKismetEditorUtilities::CreateBlueprint(
UDirectiveUtilTestObject::StaticClass(),
Package,
BlueprintName,
BPTYPE_Normal,
TEXT("DirectiveUtilities.ArrayBlueprintVmTests"));
if (!Test.TestNotNull(TEXT("The array VM test Blueprint should be created"), Blueprint))
{
return nullptr;
}
UEdGraph* Graph = FBlueprintEditorUtils::FindEventGraph(Blueprint);
if (!Test.TestNotNull(TEXT("The array VM test Blueprint should have an event graph"), Graph))
{
return nullptr;
}
UK2Node_Event* Event = AddNode<UK2Node_Event>(*Graph);
Event->EventReference.SetExternalMember(
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilTestObject, RunArrayThunkScenario),
UDirectiveUtilTestObject::StaticClass());
Event->bOverrideFunction = true;
Event->AllocateDefaultPins();
UK2Node_CallArrayFunction* Append = AddArrayCall(
*Graph,
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_AppendOptimized));
UK2Node_CallArrayFunction* Insert = AddArrayCall(
*Graph,
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_InsertOptimized));
UK2Node_VariableSet* SetInsertResult = AddVariableSet(*Graph, TEXT("TestInsertResult"));
UK2Node_CallArrayFunction* RemoveIndices = AddArrayCall(
*Graph,
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_RemoveAtIndices));
UK2Node_VariableSet* SetRemovedCount = AddVariableSet(*Graph, TEXT("TestRemovedCount"));
UK2Node_CallArrayFunction* RemoveAll = AddArrayCall(
*Graph,
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_RemoveAllOccurrences));
UK2Node_VariableSet* SetRemoveAllResult = AddVariableSet(*Graph, TEXT("TestRemoveAllResult"));
bool bConnected = true;
bConnected &= Connect(*Graph, *Event, UEdGraphSchema_K2::PN_Then, *Append, UEdGraphSchema_K2::PN_Execute);
bConnected &= Connect(*Graph, *Append, UEdGraphSchema_K2::PN_Then, *Insert, UEdGraphSchema_K2::PN_Execute);
bConnected &= Connect(*Graph, *Insert, UEdGraphSchema_K2::PN_Then, *SetInsertResult, UEdGraphSchema_K2::PN_Execute);
bConnected &= Connect(*Graph, *Insert, UEdGraphSchema_K2::PN_ReturnValue, *SetInsertResult, TEXT("TestInsertResult"));
bConnected &= Connect(*Graph, *SetInsertResult, UEdGraphSchema_K2::PN_Then, *RemoveIndices, UEdGraphSchema_K2::PN_Execute);
bConnected &= Connect(*Graph, *RemoveIndices, UEdGraphSchema_K2::PN_Then, *SetRemovedCount, UEdGraphSchema_K2::PN_Execute);
bConnected &= Connect(*Graph, *RemoveIndices, UEdGraphSchema_K2::PN_ReturnValue, *SetRemovedCount, TEXT("TestRemovedCount"));
bConnected &= Connect(*Graph, *SetRemovedCount, UEdGraphSchema_K2::PN_Then, *RemoveAll, UEdGraphSchema_K2::PN_Execute);
bConnected &= Connect(*Graph, *RemoveAll, UEdGraphSchema_K2::PN_Then, *SetRemoveAllResult, UEdGraphSchema_K2::PN_Execute);
bConnected &= Connect(*Graph, *RemoveAll, UEdGraphSchema_K2::PN_ReturnValue, *SetRemoveAllResult, TEXT("TestRemoveAllResult"));
for (UK2Node_CallArrayFunction* Call : {Append, Insert, RemoveIndices, RemoveAll})
{
bConnected &= ConnectVariable(*Graph, TargetProperty, *Call, TEXT("TargetArray"));
}
bConnected &= ConnectVariable(*Graph, SourceProperty, *Append, TEXT("SourceArray"));
bConnected &= ConnectVariable(*Graph, SourceProperty, *Insert, TEXT("SourceArray"));
bConnected &= ConnectVariable(*Graph, TEXT("TestIndices"), *RemoveIndices, TEXT("Indices"));
bConnected &= ConnectVariable(*Graph, ItemProperty, *RemoveAll, TEXT("Item"));
Insert->FindPinChecked(TEXT("Index"))->DefaultValue = TEXT("1");
if (!Test.TestTrue(TEXT("The array VM test graph should connect every pin"), bConnected))
{
return nullptr;
}
FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(Blueprint);
FKismetEditorUtilities::CompileBlueprint(Blueprint, EBlueprintCompileOptions::SkipGarbageCollection);
if (!Test.TestTrue(
TEXT("The array VM test Blueprint should compile"),
Blueprint->Status == BS_UpToDate || Blueprint->Status == BS_UpToDateWithWarnings))
{
return nullptr;
}
return Blueprint;
}
UDirectiveUtilTestObject* CreateScenarioInstance(FAutomationTestBase& Test, UBlueprint& Blueprint)
{
UDirectiveUtilTestObject* Instance = NewObject<UDirectiveUtilTestObject>(
GetTransientPackage(), Blueprint.GeneratedClass);
if (!Test.TestNotNull(TEXT("The compiled array VM test instance should be created"), Instance))
{
return nullptr;
}
Instance->TestIndices = {0, 4};
return Instance;
}
bool RunScenario(
FAutomationTestBase& Test,
UDirectiveUtilTestObject& Instance,
const bool bExpectedInsertResult = true,
const int32 ExpectedRemovedCount = 2,
const bool bExpectedRemoveAllResult = true)
{
UFunction* Function = Instance.FindFunction(
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilTestObject, RunArrayThunkScenario));
if (!Test.TestNotNull(TEXT("The compiled array VM event should exist"), Function))
{
return false;
}
Instance.ProcessEvent(Function, nullptr);
Test.TestEqual(
TEXT("Insert Array Optimized should marshal its Blueprint result"),
Instance.TestInsertResult,
bExpectedInsertResult);
Test.TestEqual(
TEXT("Remove At Indices should marshal its Blueprint result"),
Instance.TestRemovedCount,
ExpectedRemovedCount);
Test.TestEqual(
TEXT("Remove All Occurrences should marshal its Blueprint result"),
Instance.TestRemoveAllResult,
bExpectedRemoveAllResult);
return true;
}
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilArrayBlueprintVmTest,
"DirectiveUtilities.ArrayBlueprintVmTests",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilArrayBlueprintVmTest::RunTest(const FString& Parameters)
{
using namespace DirectiveUtilArrayBlueprintVmTest;
if (UBlueprint* Blueprint = BuildScenarioBlueprint(
*this, TEXT("TestBoolArray"), TEXT("TestBoolSourceArray"), TEXT("TestBoolItem")))
{
if (UDirectiveUtilTestObject* Instance = CreateScenarioInstance(*this, *Blueprint))
{
Instance->TestBoolArray = {true, false, true};
Instance->TestBoolSourceArray = {false, true};
Instance->TestBoolItem = false;
if (RunScenario(*this, *Instance))
{
TestEqual(TEXT("Boolean wildcard values should survive Blueprint VM execution"),
Instance->TestBoolArray, TArray<bool>({true, true}));
}
}
}
if (UBlueprint* Blueprint = BuildScenarioBlueprint(
*this, TEXT("TestBoolArray"), TEXT("TestBoolArray"), TEXT("TestBoolItem")))
{
if (UDirectiveUtilTestObject* Instance = CreateScenarioInstance(*this, *Blueprint))
{
Instance->TestBoolArray = {true, false};
Instance->TestBoolItem = true;
Instance->TestIndices = {-1, 99};
if (RunScenario(*this, *Instance, true, 0, true))
{
TestEqual(
TEXT("Self-aliasing wildcard arrays should survive Blueprint VM execution"),
Instance->TestBoolArray,
TArray<bool>({false, false, false, false}));
}
}
}
if (UBlueprint* Blueprint = BuildScenarioBlueprint(
*this, TEXT("TestBoolArray"), TEXT("TestBoolSourceArray"), TEXT("TestBoolItem")))
{
if (UDirectiveUtilTestObject* Instance = CreateScenarioInstance(*this, *Blueprint))
{
Instance->TestBoolArray = {true};
Instance->TestBoolSourceArray.Reset();
Instance->TestBoolItem = false;
Instance->TestIndices = {-1, 99};
if (RunScenario(*this, *Instance, false, 0, false))
{
TestEqual(
TEXT("No-op wildcard calls should preserve the target through Blueprint VM execution"),
Instance->TestBoolArray,
TArray<bool>({true}));
}
}
}
if (UBlueprint* Blueprint = BuildScenarioBlueprint(
*this, TEXT("TestStringArray"), TEXT("TestStringSourceArray"), TEXT("TestStringItem")))
{
if (UDirectiveUtilTestObject* Instance = CreateScenarioInstance(*this, *Blueprint))
{
Instance->TestStringArray = {TEXT("A"), TEXT("B"), TEXT("A")};
Instance->TestStringSourceArray = {TEXT("C"), TEXT("A")};
Instance->TestStringItem = TEXT("A");
if (RunScenario(*this, *Instance))
{
TestEqual(TEXT("String wildcard values should survive Blueprint VM execution"),
Instance->TestStringArray, TArray<FString>({TEXT("C"), TEXT("B"), TEXT("C")}));
}
}
}
if (UBlueprint* Blueprint = BuildScenarioBlueprint(
*this, TEXT("TestCollisionArray"), TEXT("TestCollisionSourceArray"), TEXT("TestCollisionItem")))
{
if (UDirectiveUtilTestObject* Instance = CreateScenarioInstance(*this, *Blueprint))
{
Instance->TestCollisionArray = {{1}, {2}, {1}};
Instance->TestCollisionSourceArray = {{3}, {1}};
Instance->TestCollisionItem = {1};
if (RunScenario(*this, *Instance))
{
TestEqual(TEXT("Struct wildcard values should survive Blueprint VM execution"),
Instance->TestCollisionArray, TArray<FDirectiveUtilCollisionValue>({{3}, {2}, {3}}));
}
}
}
if (UBlueprint* Blueprint = BuildScenarioBlueprint(
*this, TEXT("TestObjectArray"), TEXT("TestObjectSourceArray"), TEXT("TestObjectItem")))
{
if (UDirectiveUtilTestObject* Instance = CreateScenarioInstance(*this, *Blueprint))
{
UObject* A = NewObject<UDirectiveUtilTestObject>(Instance);
UObject* B = NewObject<UDirectiveUtilTestObject>(Instance);
UObject* C = NewObject<UDirectiveUtilTestObject>(Instance);
Instance->TestObjectArray = {A, B, A};
Instance->TestObjectSourceArray = {C, A};
Instance->TestObjectItem = A;
if (RunScenario(*this, *Instance))
{
TestEqual(TEXT("Object wildcard arrays should retain three values"), Instance->TestObjectArray.Num(), 3);
if (Instance->TestObjectArray.Num() == 3)
{
TestEqual(TEXT("Object wildcard arrays should retain the first source object"), Instance->TestObjectArray[0].Get(), C);
TestEqual(TEXT("Object wildcard arrays should retain the target object"), Instance->TestObjectArray[1].Get(), B);
TestEqual(TEXT("Object wildcard arrays should retain the second source object"), Instance->TestObjectArray[2].Get(), C);
}
}
}
}
return true;
}

View File

@@ -0,0 +1,207 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Misc/AutomationTest.h"
#include "EdGraph/EdGraph.h"
#include "EdGraph/EdGraphNode.h"
#include "EdGraph/EdGraphPin.h"
#include "EdGraphSchema_K2.h"
#include "Engine/Blueprint.h"
#include "K2Node_CallArrayFunction.h"
#include "Kismet2/BlueprintEditorUtils.h"
#include "Kismet2/CompilerResultsLog.h"
#include "Libraries/DirectiveUtilArrayFunctionLibrary.h"
#include "Tests/DirectiveUtilTestObject.h"
namespace DirectiveUtilArrayNodeTest
{
struct FDependentPin
{
FName Name;
EPinContainerType ContainerType;
};
struct FArrayNodeCase
{
FName FunctionName;
TArray<FDependentPin> DependentPins;
};
FEdGraphPinType MakeArrayType(
const FName Category,
const FName SubCategory = NAME_None,
UObject* SubCategoryObject = nullptr)
{
FEdGraphPinType PinType;
PinType.PinCategory = Category;
PinType.PinSubCategory = SubCategory;
PinType.PinSubCategoryObject = SubCategoryObject;
PinType.ContainerType = EPinContainerType::Array;
return PinType;
}
UEdGraph* MakeGraph()
{
UBlueprint* Blueprint = NewObject<UBlueprint>();
Blueprint->GeneratedClass = UObject::StaticClass();
Blueprint->SkeletonGeneratedClass = UObject::StaticClass();
UEdGraph* Graph = NewObject<UEdGraph>(Blueprint);
Graph->Schema = UEdGraphSchema_K2::StaticClass();
Blueprint->UbergraphPages.Add(Graph);
return Graph;
}
UEdGraphPin* AddArrayOutput(UEdGraph& Graph, const FName Name, const FEdGraphPinType& PinType)
{
UEdGraphNode* SourceNode = NewObject<UEdGraphNode>(&Graph);
Graph.AddNode(SourceNode);
return SourceNode->CreatePin(EGPD_Output, PinType, Name);
}
UK2Node_CallArrayFunction* AddFunctionNode(UEdGraph& Graph, const FName FunctionName)
{
UK2Node_CallArrayFunction* Node = NewObject<UK2Node_CallArrayFunction>(&Graph);
Graph.AddNode(Node);
const UFunction* Function = UDirectiveUtilArrayFunctionLibrary::StaticClass()->FindFunctionByName(FunctionName);
Node->FunctionReference.SetExternalMember(FunctionName, UDirectiveUtilArrayFunctionLibrary::StaticClass());
Node->bDefaultsToPureFunc = Function && Function->HasAnyFunctionFlags(FUNC_BlueprintPure);
Node->AllocateDefaultPins();
return Node;
}
void Connect(UEdGraphPin& OutputPin, UK2Node& Node, UEdGraphPin& InputPin)
{
OutputPin.MakeLinkTo(&InputPin);
Node.PinConnectionListChanged(&InputPin);
}
void Disconnect(UEdGraphPin& OutputPin, UK2Node& Node, UEdGraphPin& InputPin)
{
OutputPin.BreakLinkTo(&InputPin);
Node.PinConnectionListChanged(&InputPin);
}
bool HasElementType(
const UEdGraphPin& Pin,
const FEdGraphPinType& ArrayType,
const EPinContainerType ContainerType)
{
return Pin.PinType.ContainerType == ContainerType
&& Pin.PinType.PinCategory == ArrayType.PinCategory
&& Pin.PinType.PinSubCategory == ArrayType.PinSubCategory
&& Pin.PinType.PinSubCategoryObject == ArrayType.PinSubCategoryObject;
}
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilArrayNodeWildcardTest,
"DirectiveUtilities.ArrayNodeWildcardTests",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilArrayNodeWildcardTest::RunTest(const FString& Parameters)
{
using namespace DirectiveUtilArrayNodeTest;
const TArray<FArrayNodeCase> NodeCases = {
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_NextIndex), {}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_PreviousIndex), {}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_RemoveDuplicates), {}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_AppendOptimized), {{TEXT("SourceArray"), EPinContainerType::Array}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_InsertOptimized), {{TEXT("SourceArray"), EPinContainerType::Array}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_GetValidFirstItemCopy), {{TEXT("OutItem"), EPinContainerType::None}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_GetValidLastItemCopy), {{TEXT("OutItem"), EPinContainerType::None}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_GetValidItemFromIndexCopy), {{TEXT("OutItem"), EPinContainerType::None}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_GetRandomItem), {{TEXT("OutItem"), EPinContainerType::None}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_LastValue), {{TEXT("OutItem"), EPinContainerType::None}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_Pop), {{TEXT("OutItem"), EPinContainerType::None}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_PopFirst), {{TEXT("OutItem"), EPinContainerType::None}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_RemoveAtSwap), {}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_RemoveAtIndices), {}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_RemoveAllOccurrences), {{TEXT("Item"), EPinContainerType::None}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_Slice), {{TEXT("OutArray"), EPinContainerType::Array}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_Rotate), {}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_GetDistinct), {{TEXT("OutArray"), EPinContainerType::Array}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_CountOccurrences), {{TEXT("ItemToCount"), EPinContainerType::None}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_GetMostCommon), {{TEXT("OutItem"), EPinContainerType::None}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_Sample), {{TEXT("OutArray"), EPinContainerType::Array}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_SampleFromStream), {{TEXT("OutArray"), EPinContainerType::Array}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_SampleWeighted), {{TEXT("OutArray"), EPinContainerType::Array}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_SampleWeightedFromStream), {{TEXT("OutArray"), EPinContainerType::Array}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_GetPage), {{TEXT("OutArray"), EPinContainerType::Array}}}
};
const TArray<FEdGraphPinType> ArrayTypes = {
MakeArrayType(UEdGraphSchema_K2::PC_Boolean),
MakeArrayType(UEdGraphSchema_K2::PC_String),
MakeArrayType(UEdGraphSchema_K2::PC_Object, NAME_None, UDirectiveUtilTestObject::StaticClass()),
MakeArrayType(UEdGraphSchema_K2::PC_Struct, NAME_None, FDirectiveUtilCollisionValue::StaticStruct())
};
for (const FArrayNodeCase& NodeCase : NodeCases)
{
UEdGraph* Graph = MakeGraph();
UK2Node_CallArrayFunction* Node = AddFunctionNode(*Graph, NodeCase.FunctionName);
UEdGraphPin* TargetArray = Node->FindPin(TEXT("TargetArray"));
TestNotNull(*FString::Printf(TEXT("%s should have a TargetArray pin"), *NodeCase.FunctionName.ToString()), TargetArray);
if (!TargetArray)
{
continue;
}
TestTrue(
*FString::Printf(TEXT("%s should begin as a wildcard array"), *NodeCase.FunctionName.ToString()),
TargetArray->PinType.IsArray() && TargetArray->PinType.PinCategory == UEdGraphSchema_K2::PC_Wildcard);
if (NodeCase.FunctionName == GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_RemoveAtIndices))
{
const UEdGraphPin* IndicesPin = Node->FindPin(TEXT("Indices"));
TestNotNull(TEXT("RemoveAtIndices should have an Indices pin"), IndicesPin);
if (IndicesPin)
{
TestTrue(
TEXT("RemoveAtIndices should keep Indices as an integer array"),
IndicesPin->PinType.IsArray()
&& IndicesPin->PinType.PinCategory == UEdGraphSchema_K2::PC_Int);
}
}
FCompilerResultsLog ModuleValidationLog;
FBlueprintEditorUtils::ValidateEditorOnlyNodes(Node, ModuleValidationLog);
TestEqual(
*FString::Printf(TEXT("%s should be valid in a runtime Blueprint"), *NodeCase.FunctionName.ToString()),
ModuleValidationLog.NumWarnings,
0);
for (int32 TypeIndex = 0; TypeIndex < ArrayTypes.Num(); ++TypeIndex)
{
const FEdGraphPinType& ArrayType = ArrayTypes[TypeIndex];
UEdGraphPin* ArrayOutput = AddArrayOutput(
*Graph,
*FString::Printf(TEXT("ArrayOutput%d"), TypeIndex),
ArrayType);
Connect(*ArrayOutput, *Node, *TargetArray);
TestTrue(
*FString::Printf(TEXT("%s should resolve TargetArray type %d"), *NodeCase.FunctionName.ToString(), TypeIndex),
HasElementType(*TargetArray, ArrayType, EPinContainerType::Array));
for (const FDependentPin& ExpectedPin : NodeCase.DependentPins)
{
const UEdGraphPin* DependentPin = Node->FindPin(ExpectedPin.Name);
TestNotNull(
*FString::Printf(TEXT("%s should have a %s pin"), *NodeCase.FunctionName.ToString(), *ExpectedPin.Name.ToString()),
DependentPin);
if (DependentPin)
{
TestTrue(
*FString::Printf(TEXT("%s should resolve %s type %d"), *NodeCase.FunctionName.ToString(), *ExpectedPin.Name.ToString(), TypeIndex),
HasElementType(*DependentPin, ArrayType, ExpectedPin.ContainerType));
}
}
Disconnect(*ArrayOutput, *Node, *TargetArray);
TestTrue(
*FString::Printf(TEXT("%s should reset after type %d"), *NodeCase.FunctionName.ToString(), TypeIndex),
TargetArray->PinType.IsArray() && TargetArray->PinType.PinCategory == UEdGraphSchema_K2::PC_Wildcard);
}
}
return true;
}

View File

@@ -0,0 +1,801 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilArrayFunctionLibrary.h"
#include "Tests/DirectiveUtilTestObject.h"
#include "Algo/Reverse.h"
#include "Misc/AutomationTest.h"
namespace
{
FArrayProperty* GetIntegerArrayProperty()
{
return FindFProperty<FArrayProperty>(
UDirectiveUtilTestObject::StaticClass(),
GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestArray));
}
TArray<int32> MakeSequentialValues(const int32 Count)
{
TArray<int32> Values;
Values.SetNumUninitialized(Count);
for (int32 Index = 0; Index < Count; ++Index)
{
Values[Index] = Index;
}
return Values;
}
TArray<int32> MakeRepeatingValues(const int32 Count, const int32 DistinctCount)
{
TArray<int32> Values;
Values.Reserve(Count);
for (int32 Index = 0; Index < Count; ++Index)
{
Values.Add(Index % DistinctCount);
}
return Values;
}
TArray<int32> MakeDistinctReference(const TArray<int32>& Values)
{
TArray<int32> Result;
for (const int32 Value : Values)
{
Result.AddUnique(Value);
}
return Result;
}
bool FindMostCommonReference(const TArray<int32>& Values, int32& OutValue, int32& OutCount)
{
OutValue = 0;
OutCount = 0;
TMap<int32, int32> Counts;
for (const int32 Value : Values)
{
++Counts.FindOrAdd(Value);
}
for (const TPair<int32, int32>& Pair : Counts)
{
OutCount = FMath::Max(OutCount, Pair.Value);
}
for (const int32 Value : Values)
{
if (Counts.FindRef(Value) == OutCount)
{
OutValue = Value;
break;
}
}
return !Values.IsEmpty();
}
int32 CountReference(const TArray<int32>& Values, const int32 QueryValue)
{
int32 Count = 0;
for (const int32 Value : Values)
{
Count += Value == QueryValue ? 1 : 0;
}
return Count;
}
TArray<int32> RemoveAllReference(const TArray<int32>& Values, const int32 QueryValue)
{
TArray<int32> Result;
Result.Reserve(Values.Num());
for (const int32 Value : Values)
{
if (Value != QueryValue)
{
Result.Add(Value);
}
}
return Result;
}
TArray<int32> SliceReference(const TArray<int32>& Values, const int32 StartIndex, const int32 Count)
{
TArray<int32> Result;
Result.Reserve(Count);
for (int32 Index = 0; Index < Count; ++Index)
{
Result.Add(Values[StartIndex + Index]);
}
return Result;
}
TArray<int32> MakeSampleReference(const int32 SourceCount, const int32 RequestedCount, const int32 Seed)
{
TArray<int32> AvailableIndices = MakeSequentialValues(SourceCount);
TArray<int32> Result;
const int32 SampleCount = FMath::Clamp(RequestedCount, 0, SourceCount);
Result.Reserve(SampleCount);
FRandomStream RandomStream(Seed);
for (int32 SampleIndex = 0; SampleIndex < SampleCount; ++SampleIndex)
{
const int32 SelectedIndex = RandomStream.RandRange(0, AvailableIndices.Num() - 1);
Result.Add(AvailableIndices[SelectedIndex]);
AvailableIndices.RemoveAtSwap(SelectedIndex, 1, EAllowShrinking::No);
}
return Result;
}
template <typename ValueType>
TArray<ValueType> MakeRotationReference(const TArray<ValueType>& Values, const int32 Shift)
{
if (Values.IsEmpty())
{
return Values;
}
int32 NormalizedShift = Shift % Values.Num();
if (NormalizedShift < 0)
{
NormalizedShift += Values.Num();
}
TArray<ValueType> Result;
Result.Reserve(Values.Num());
for (int32 Index = 0; Index < Values.Num(); ++Index)
{
Result.Add(Values[(Index - NormalizedShift + Values.Num()) % Values.Num()]);
}
return Result;
}
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilArrayCardinalityTest,
"DirectiveUtilities.ArrayScenarios.Cardinality",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilArrayCardinalityTest::RunTest(const FString& Parameters)
{
FArrayProperty* ArrayProperty = GetIntegerArrayProperty();
if (!TestNotNull("Integer array property should be available", ArrayProperty))
{
return false;
}
UDirectiveUtilTestObject* TestObject = NewObject<UDirectiveUtilTestObject>();
struct FScenario
{
int32 ItemCount;
int32 DistinctCount;
};
const TArray<FScenario> Scenarios = {
{0, 1},
{1, 1},
{2, 1},
{3, 2},
{16, 16},
{31, 7},
{64, 4},
{257, 257},
{1024, 1},
{4096, 257},
{16384, 1024}
};
for (const FScenario& Scenario : Scenarios)
{
const TArray<int32> Source = MakeRepeatingValues(Scenario.ItemCount, Scenario.DistinctCount);
const TArray<int32> ExpectedDistinct = MakeDistinctReference(Source);
const FString Label = FString::Printf(
TEXT("items=%d distinct=%d"),
Scenario.ItemCount,
FMath::Min(Scenario.ItemCount, Scenario.DistinctCount));
TestObject->TestArray = Source;
UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveDuplicates(&TestObject->TestArray, ArrayProperty);
TestEqual(Label + TEXT(" RemoveDuplicates"), TestObject->TestArray, ExpectedDistinct);
TArray<int32> DistinctResult;
UDirectiveUtilArrayFunctionLibrary::GenericArray_GetDistinct(
&Source,
ArrayProperty,
&DistinctResult,
ArrayProperty);
TestEqual(Label + TEXT(" GetDistinct"), DistinctResult, ExpectedDistinct);
int32 MostCommonValue = INDEX_NONE;
int32 MostCommonCount = INDEX_NONE;
const bool bFoundMostCommon = UDirectiveUtilArrayFunctionLibrary::GenericArray_GetMostCommon(
&Source,
ArrayProperty,
&MostCommonValue,
&MostCommonCount);
if (Source.IsEmpty())
{
TestFalse(Label + TEXT(" GetMostCommon should fail"), bFoundMostCommon);
TestEqual(Label + TEXT(" GetMostCommon count"), MostCommonCount, 0);
}
else
{
TestTrue(Label + TEXT(" GetMostCommon should succeed"), bFoundMostCommon);
TestEqual(Label + TEXT(" GetMostCommon value"), MostCommonValue, 0);
TestEqual(
Label + TEXT(" GetMostCommon count"),
MostCommonCount,
FMath::DivideAndRoundUp(Scenario.ItemCount, Scenario.DistinctCount));
}
}
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilArraySamplingScenarioTest,
"DirectiveUtilities.ArrayScenarios.Sampling",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilArraySamplingScenarioTest::RunTest(const FString& Parameters)
{
FArrayProperty* ArrayProperty = GetIntegerArrayProperty();
if (!TestNotNull("Integer array property should be available", ArrayProperty))
{
return false;
}
constexpr int32 Seed = 7351;
for (const int32 SourceCount : {0, 1, 2, 3, 4, 17, 100, 1000})
{
const TArray<int32> Source = MakeSequentialValues(SourceCount);
const TArray<int32> RequestedCounts = {-1, 0, 1, SourceCount / 4, SourceCount, SourceCount + 5};
for (const int32 RequestedCount : RequestedCounts)
{
FRandomStream FirstStream(Seed);
FRandomStream SecondStream(Seed);
TArray<int32> FirstSample;
TArray<int32> SecondSample;
UDirectiveUtilArrayFunctionLibrary::GenericArray_Sample(
&Source,
ArrayProperty,
RequestedCount,
false,
&FirstStream,
&FirstSample,
ArrayProperty);
UDirectiveUtilArrayFunctionLibrary::GenericArray_Sample(
&Source,
ArrayProperty,
RequestedCount,
false,
&SecondStream,
&SecondSample,
ArrayProperty);
const FString Label = FString::Printf(TEXT("source=%d requested=%d"), SourceCount, RequestedCount);
TestEqual(Label + TEXT(" count"), FirstSample.Num(), FMath::Clamp(RequestedCount, 0, SourceCount));
TestEqual(Label + TEXT(" deterministic"), FirstSample, SecondSample);
TSet<int32> UniqueValues;
for (const int32 Value : FirstSample)
{
TestTrue(Label + TEXT(" source membership"), Source.Contains(Value));
UniqueValues.Add(Value);
}
TestEqual(Label + TEXT(" uniqueness"), UniqueValues.Num(), FirstSample.Num());
}
}
const TArray<int32> BoundarySource = MakeSequentialValues(100);
for (const int32 RequestedCount : {24, 25, 26, 75, 100})
{
FRandomStream RandomStream(Seed);
TArray<int32> Sample;
UDirectiveUtilArrayFunctionLibrary::GenericArray_Sample(
&BoundarySource,
ArrayProperty,
RequestedCount,
false,
&RandomStream,
&Sample,
ArrayProperty);
TestEqual(
FString::Printf(TEXT("threshold requested=%d"), RequestedCount),
Sample,
MakeSampleReference(BoundarySource.Num(), RequestedCount, Seed));
}
const TArray<int32> ReplacementSource = MakeSequentialValues(7);
for (const int32 RequestedCount : {0, 1, 7, 14, 100})
{
FRandomStream FirstStream(Seed);
FRandomStream SecondStream(Seed);
TArray<int32> FirstSample;
TArray<int32> SecondSample;
UDirectiveUtilArrayFunctionLibrary::GenericArray_Sample(
&ReplacementSource,
ArrayProperty,
RequestedCount,
true,
&FirstStream,
&FirstSample,
ArrayProperty);
UDirectiveUtilArrayFunctionLibrary::GenericArray_Sample(
&ReplacementSource,
ArrayProperty,
RequestedCount,
true,
&SecondStream,
&SecondSample,
ArrayProperty);
const FString Label = FString::Printf(TEXT("replacement requested=%d"), RequestedCount);
TestEqual(Label + TEXT(" count"), FirstSample.Num(), RequestedCount);
TestEqual(Label + TEXT(" deterministic"), FirstSample, SecondSample);
for (const int32 Value : FirstSample)
{
TestTrue(Label + TEXT(" source membership"), ReplacementSource.Contains(Value));
}
}
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilArrayWeightedSamplingScenarioTest,
"DirectiveUtilities.ArrayScenarios.WeightedSampling",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilArrayWeightedSamplingScenarioTest::RunTest(const FString& Parameters)
{
FArrayProperty* ArrayProperty = GetIntegerArrayProperty();
if (!TestNotNull("Integer array property should be available", ArrayProperty))
{
return false;
}
constexpr int32 Seed = 4815;
for (const int32 SourceCount : {0, 1, 2, 3, 17, 100, 1000, 4096})
{
const TArray<int32> Source = MakeSequentialValues(SourceCount);
TArray<float> Weights;
Weights.Reserve(SourceCount);
TSet<int32> SelectableValues;
for (int32 Index = 0; Index < SourceCount; ++Index)
{
const float Weight = Index % 3 == 0 ? 0.0f : static_cast<float>((Index % 7) + 1);
Weights.Add(Weight);
if (Weight > 0.0f)
{
SelectableValues.Add(Index);
}
}
const TArray<int32> RequestedCounts = {0, 1, SourceCount / 4, SourceCount, SourceCount + 5};
for (const int32 RequestedCount : RequestedCounts)
{
for (const bool bWithReplacement : {false, true})
{
FRandomStream FirstStream(Seed);
FRandomStream SecondStream(Seed);
TArray<int32> FirstSample;
TArray<int32> SecondSample;
const bool bFirstSucceeded = UDirectiveUtilArrayFunctionLibrary::GenericArray_SampleWeighted(
&Source,
ArrayProperty,
Weights,
RequestedCount,
bWithReplacement,
&FirstStream,
&FirstSample,
ArrayProperty);
const bool bSecondSucceeded = UDirectiveUtilArrayFunctionLibrary::GenericArray_SampleWeighted(
&Source,
ArrayProperty,
Weights,
RequestedCount,
bWithReplacement,
&SecondStream,
&SecondSample,
ArrayProperty);
const FString Label = FString::Printf(
TEXT("source=%d requested=%d replacement=%d"),
SourceCount,
RequestedCount,
bWithReplacement);
const bool bExpectedSuccess = RequestedCount == 0 || !SelectableValues.IsEmpty();
TestEqual(Label + TEXT(" first validity"), bFirstSucceeded, bExpectedSuccess);
TestEqual(Label + TEXT(" second validity"), bSecondSucceeded, bExpectedSuccess);
TestEqual(Label + TEXT(" deterministic"), FirstSample, SecondSample);
const int32 ExpectedCount = !bExpectedSuccess
? 0
: bWithReplacement
? RequestedCount
: FMath::Min(RequestedCount, SelectableValues.Num());
TestEqual(Label + TEXT(" count"), FirstSample.Num(), ExpectedCount);
TSet<int32> UniqueValues;
for (const int32 Value : FirstSample)
{
TestTrue(Label + TEXT(" selectable membership"), SelectableValues.Contains(Value));
UniqueValues.Add(Value);
}
if (!bWithReplacement)
{
TestEqual(Label + TEXT(" unique source indices"), UniqueValues.Num(), FirstSample.Num());
}
}
}
}
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilArrayRangeScenarioTest,
"DirectiveUtilities.ArrayScenarios.RangesAndPages",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilArrayRangeScenarioTest::RunTest(const FString& Parameters)
{
FArrayProperty* ArrayProperty = GetIntegerArrayProperty();
if (!TestNotNull("Integer array property should be available", ArrayProperty))
{
return false;
}
struct FPageScenario
{
int32 ItemCount;
int32 PageIndex;
int32 PageSize;
bool bExpectedValid;
int32 ExpectedPageCount;
};
const TArray<FPageScenario> PageScenarios = {
{0, 0, 1, false, 0},
{1, 0, 1, true, 1},
{2, 1, 1, true, 2},
{5, 0, 2, true, 3},
{5, 1, 2, true, 3},
{5, 2, 2, true, 3},
{5, 3, 2, false, 3},
{10, 3, 3, true, 4},
{10, 0, 10, true, 1},
{10, 0, 11, true, 1},
{10, -1, 3, false, 0},
{10, 0, 0, false, 0},
{10, 0, -1, false, 0},
{10, MAX_int32, 3, false, 4}
};
for (const FPageScenario& Scenario : PageScenarios)
{
const TArray<int32> Source = MakeSequentialValues(Scenario.ItemCount);
TArray<int32> Page;
int32 PageCount = INDEX_NONE;
const bool bValid = UDirectiveUtilArrayFunctionLibrary::GenericArray_GetPage(
&Source,
ArrayProperty,
Scenario.PageIndex,
Scenario.PageSize,
&Page,
ArrayProperty,
&PageCount);
const FString Label = FString::Printf(
TEXT("items=%d page=%d size=%d"),
Scenario.ItemCount,
Scenario.PageIndex,
Scenario.PageSize);
TestEqual(Label + TEXT(" validity"), bValid, Scenario.bExpectedValid);
TestEqual(Label + TEXT(" page count"), PageCount, Scenario.ExpectedPageCount);
TArray<int32> ExpectedPage;
if (Scenario.bExpectedValid)
{
const int32 StartIndex = Scenario.PageIndex * Scenario.PageSize;
for (int32 Index = StartIndex; Index < FMath::Min(StartIndex + Scenario.PageSize, Source.Num()); ++Index)
{
ExpectedPage.Add(Source[Index]);
}
}
TestEqual(Label + TEXT(" values"), Page, ExpectedPage);
}
const TArray<int32> SliceSource = MakeSequentialValues(10);
struct FSliceScenario
{
int32 StartIndex;
int32 Count;
};
const TArray<FSliceScenario> SliceScenarios = {
{-5, 3},
{0, -1},
{0, 0},
{0, 10},
{0, 20},
{5, 3},
{9, 5},
{10, 1},
{11, 1},
{MAX_int32, MAX_int32}
};
for (const FSliceScenario& Scenario : SliceScenarios)
{
const int32 StartIndex = FMath::Clamp(Scenario.StartIndex, 0, SliceSource.Num());
const int32 CopyCount = Scenario.Count > 0
? FMath::Min(Scenario.Count, SliceSource.Num() - StartIndex)
: 0;
TArray<int32> ExpectedSlice;
for (int32 Offset = 0; Offset < CopyCount; ++Offset)
{
ExpectedSlice.Add(SliceSource[StartIndex + Offset]);
}
TArray<int32> Slice;
UDirectiveUtilArrayFunctionLibrary::GenericArray_Slice(
&SliceSource,
ArrayProperty,
Scenario.StartIndex,
Scenario.Count,
&Slice,
ArrayProperty);
const FString Label = FString::Printf(TEXT("start=%d count=%d"), Scenario.StartIndex, Scenario.Count);
TestEqual(Label + TEXT(" separate output"), Slice, ExpectedSlice);
TArray<int32> AliasedSlice = SliceSource;
UDirectiveUtilArrayFunctionLibrary::GenericArray_Slice(
&AliasedSlice,
ArrayProperty,
Scenario.StartIndex,
Scenario.Count,
&AliasedSlice,
ArrayProperty);
TestEqual(Label + TEXT(" aliased output"), AliasedSlice, ExpectedSlice);
}
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilArrayRotationScenarioTest,
"DirectiveUtilities.ArrayScenarios.Rotation",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilArrayRotationScenarioTest::RunTest(const FString& Parameters)
{
FArrayProperty* IntegerArrayProperty = GetIntegerArrayProperty();
FArrayProperty* StringArrayProperty = FindFProperty<FArrayProperty>(
UDirectiveUtilTestObject::StaticClass(),
GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestStringArray));
if (!TestNotNull("Integer array property should be available", IntegerArrayProperty)
|| !TestNotNull("String array property should be available", StringArrayProperty))
{
return false;
}
for (const int32 ItemCount : {0, 1, 2, 3, 4, 5, 16, 257, 10000})
{
const TArray<int32> Source = MakeSequentialValues(ItemCount);
const TArray<int32> Shifts = {
0,
1,
-1,
ItemCount,
ItemCount + 1,
-ItemCount - 1,
MAX_int32,
MIN_int32
};
for (const int32 Shift : Shifts)
{
TArray<int32> Rotated = Source;
UDirectiveUtilArrayFunctionLibrary::GenericArray_Rotate(&Rotated, IntegerArrayProperty, Shift);
TestEqual(
FString::Printf(TEXT("POD items=%d shift=%d"), ItemCount, Shift),
Rotated,
MakeRotationReference(Source, Shift));
}
}
for (const int32 ItemCount : {0, 1, 2, 5, 32})
{
TArray<FString> Source;
Source.Reserve(ItemCount);
for (int32 Index = 0; Index < ItemCount; ++Index)
{
Source.Add(FString::Printf(TEXT("Value%d"), Index));
}
for (const int32 Shift : {0, 1, -1, 7, -11, MAX_int32, MIN_int32})
{
TArray<FString> Rotated = Source;
UDirectiveUtilArrayFunctionLibrary::GenericArray_Rotate(&Rotated, StringArrayProperty, Shift);
TestEqual(
FString::Printf(TEXT("managed items=%d shift=%d"), ItemCount, Shift),
Rotated,
MakeRotationReference(Source, Shift));
}
}
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilArrayOrderingScenarioTest,
"DirectiveUtilities.ArrayScenarios.NaturalOrdering",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilArrayOrderingScenarioTest::RunTest(const FString& Parameters)
{
for (const int32 ItemCount : {0, 1, 2, 10, 100, 1000})
{
TArray<FString> Strings;
TArray<FString> ExpectedStrings;
TArray<FName> Names;
TArray<FName> ExpectedNames;
Strings.Reserve(ItemCount);
ExpectedStrings.Reserve(ItemCount);
Names.Reserve(ItemCount);
ExpectedNames.Reserve(ItemCount);
for (int32 Index = 0; Index < ItemCount; ++Index)
{
ExpectedStrings.Add(FString::Printf(TEXT("Item%d"), Index));
ExpectedNames.Add(FName(*FString::Printf(TEXT("Actor%d"), Index)));
}
for (int32 Index = ItemCount - 1; Index >= 0; --Index)
{
Strings.Add(ExpectedStrings[Index]);
Names.Add(ExpectedNames[Index]);
}
UDirectiveUtilArrayFunctionLibrary::NaturalSortStringArray(Strings);
UDirectiveUtilArrayFunctionLibrary::NaturalSortNameArray(Names);
const FString Label = FString::Printf(TEXT("items=%d"), ItemCount);
TestEqual(Label + TEXT(" string ascending"), Strings, ExpectedStrings);
TestEqual(Label + TEXT(" name ascending"), Names, ExpectedNames);
UDirectiveUtilArrayFunctionLibrary::NaturalSortStringArray(Strings, true);
UDirectiveUtilArrayFunctionLibrary::NaturalSortNameArray(Names, true);
Algo::Reverse(ExpectedStrings);
Algo::Reverse(ExpectedNames);
TestEqual(Label + TEXT(" string descending"), Strings, ExpectedStrings);
TestEqual(Label + TEXT(" name descending"), Names, ExpectedNames);
}
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilArrayDeterministicFuzzTest,
"DirectiveUtilities.ArrayScenarios.DeterministicFuzz",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilArrayDeterministicFuzzTest::RunTest(const FString& Parameters)
{
FArrayProperty* ArrayProperty = GetIntegerArrayProperty();
if (!TestNotNull("Integer array property should be available", ArrayProperty))
{
return false;
}
UDirectiveUtilTestObject* TestObject = NewObject<UDirectiveUtilTestObject>();
const TArray<int32> ItemCounts = {0, 1, 2, 3, 7, 16, 31, 32, 127, 128, 1024, 4096};
for (const int32 Seed : {17, 271, 4099, 65537, 104729})
{
for (int32 CountIndex = 0; CountIndex < ItemCounts.Num(); ++CountIndex)
{
const int32 ItemCount = ItemCounts[CountIndex];
FRandomStream Stream(Seed + ItemCount * 31);
TArray<int32> Source;
Source.Reserve(ItemCount);
for (int32 Index = 0; Index < ItemCount; ++Index)
{
Source.Add(Stream.RandRange(-64, 64));
}
const FString Label = FString::Printf(TEXT("seed=%d items=%d"), Seed, ItemCount);
const TArray<int32> ExpectedDistinct = MakeDistinctReference(Source);
TestObject->TestArray = Source;
UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveDuplicates(&TestObject->TestArray, ArrayProperty);
TestEqual(Label + TEXT(" RemoveDuplicates"), TestObject->TestArray, ExpectedDistinct);
TArray<int32> DistinctResult;
UDirectiveUtilArrayFunctionLibrary::GenericArray_GetDistinct(
&Source,
ArrayProperty,
&DistinctResult,
ArrayProperty);
TestEqual(Label + TEXT(" GetDistinct"), DistinctResult, ExpectedDistinct);
int32 ExpectedMostCommon = 0;
int32 ExpectedMostCommonCount = 0;
const bool bExpectedMostCommon = FindMostCommonReference(
Source,
ExpectedMostCommon,
ExpectedMostCommonCount);
int32 MostCommon = 0;
int32 MostCommonCount = 0;
const bool bFoundMostCommon = UDirectiveUtilArrayFunctionLibrary::GenericArray_GetMostCommon(
&Source,
ArrayProperty,
&MostCommon,
&MostCommonCount);
TestEqual(Label + TEXT(" GetMostCommon result"), bFoundMostCommon, bExpectedMostCommon);
TestEqual(Label + TEXT(" GetMostCommon value"), MostCommon, ExpectedMostCommon);
TestEqual(Label + TEXT(" GetMostCommon count"), MostCommonCount, ExpectedMostCommonCount);
const int32 QueryValue = Stream.RandRange(-70, 70);
TestEqual(
Label + TEXT(" CountOccurrences"),
UDirectiveUtilArrayFunctionLibrary::GenericArray_CountOccurrences(&Source, ArrayProperty, &QueryValue),
CountReference(Source, QueryValue));
const TArray<int32> ExpectedRemoved = RemoveAllReference(Source, QueryValue);
TestObject->TestArray = Source;
const bool bRemoved = UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveAllOccurrences(
&TestObject->TestArray,
ArrayProperty,
&QueryValue);
TestEqual(Label + TEXT(" RemoveAllOccurrences result"), bRemoved, ExpectedRemoved.Num() != Source.Num());
TestEqual(Label + TEXT(" RemoveAllOccurrences values"), TestObject->TestArray, ExpectedRemoved);
TArray<int32> ExpectedAppended = Source;
ExpectedAppended.Append(Source);
TestObject->TestArray = Source;
UDirectiveUtilArrayFunctionLibrary::GenericArray_AppendOptimized(
&TestObject->TestArray,
ArrayProperty,
&TestObject->TestArray,
ArrayProperty);
TestEqual(Label + TEXT(" AppendOptimized self append"), TestObject->TestArray, ExpectedAppended);
for (const int32 Shift : {MIN_int32, -ItemCount - 1, -1, 0, 1, ItemCount + 1, MAX_int32})
{
TestObject->TestArray = Source;
UDirectiveUtilArrayFunctionLibrary::GenericArray_Rotate(&TestObject->TestArray, ArrayProperty, Shift);
TestEqual(
Label + FString::Printf(TEXT(" Rotate shift=%d"), Shift),
TestObject->TestArray,
MakeRotationReference(Source, Shift));
}
const int32 StartIndex = Stream.RandRange(-ItemCount - 2, ItemCount + 2);
const int32 SliceCount = Stream.RandRange(-2, ItemCount + 2);
const int32 ExpectedStart = FMath::Clamp(StartIndex, 0, ItemCount);
const int32 ExpectedSliceCount = FMath::Max(0, FMath::Min(SliceCount, ItemCount - ExpectedStart));
const TArray<int32> ExpectedSlice = SliceReference(Source, ExpectedStart, ExpectedSliceCount);
TArray<int32> SliceResult;
UDirectiveUtilArrayFunctionLibrary::GenericArray_Slice(
&Source,
ArrayProperty,
StartIndex,
SliceCount,
&SliceResult,
ArrayProperty);
TestEqual(Label + TEXT(" Slice"), SliceResult, ExpectedSlice);
const int32 PageSize = CountIndex % 4 == 0 ? 0 : 1 << (CountIndex % 7);
const int32 ExpectedPageCount = PageSize > 0 ? FMath::DivideAndRoundUp(ItemCount, PageSize) : 0;
for (const int32 PageIndex : {-1, 0, FMath::Max(0, ExpectedPageCount - 1), ExpectedPageCount})
{
TArray<int32> PageResult;
int32 PageCount = -1;
const bool bPageValid = UDirectiveUtilArrayFunctionLibrary::GenericArray_GetPage(
&Source,
ArrayProperty,
PageIndex,
PageSize,
&PageResult,
ArrayProperty,
&PageCount);
const bool bExpectedValid = PageSize > 0 && PageIndex >= 0 && PageIndex < ExpectedPageCount;
const TArray<int32> ExpectedPage = bExpectedValid
? SliceReference(Source, PageIndex * PageSize, FMath::Min(PageSize, ItemCount - PageIndex * PageSize))
: TArray<int32>();
const FString PageLabel = Label + FString::Printf(TEXT(" Page index=%d size=%d"), PageIndex, PageSize);
TestEqual(PageLabel + TEXT(" validity"), bPageValid, bExpectedValid);
TestEqual(
PageLabel + TEXT(" count"),
PageCount,
PageSize > 0 && PageIndex >= 0 ? ExpectedPageCount : 0);
TestEqual(PageLabel + TEXT(" values"), PageResult, ExpectedPage);
}
}
}
return true;
}

View File

@@ -12,13 +12,11 @@
#include "Components/StaticMeshComponent.h"
#include "GameFramework/DefaultPawn.h"
#include "GameFramework/PlayerController.h"
#include "TimerManager.h"
#include "Misc/AutomationTest.h"
#if WITH_EDITOR
namespace DirectiveUtilAsyncTraceTestHelpers
{
/** Creates a transient game world with a physics scene, initialized for play so traces resolve. */
UWorld* CreateTraceWorld()
{
UWorld* World = UWorld::CreateWorld(EWorldType::Game, false);
@@ -33,7 +31,6 @@ namespace DirectiveUtilAsyncTraceTestHelpers
return World;
}
/** Spawns a blocking cube actor at the origin so traces have something to hit. */
AStaticMeshActor* SpawnBlockingCube(UWorld* World, UStaticMesh* CubeMesh)
{
AStaticMeshActor* Cube = World->SpawnActor<AStaticMeshActor>(FVector::ZeroVector, FRotator::ZeroRotator);
@@ -42,15 +39,36 @@ namespace DirectiveUtilAsyncTraceTestHelpers
Component->SetStaticMesh(CubeMesh);
Component->SetCollisionProfileName(TEXT("BlockAll"));
Component->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
Component->UpdateCollisionProfile();
return Cube;
}
}
/**
* Latent command that ticks a trace world each frame until every listener has reported completion
* (or the frame budget runs out), asserts the expected outcome, and tears the world down.
*/
bool UDirectiveUtilTestMoveToLocationTask::HasRegisteredTimers() const
{
const UWorld* World = TimerWorld.Get();
return World && (World->GetTimerManager().TimerExists(TimerHandle) || World->GetTimerManager().TimerExists(StuckTimerHandle));
}
void UDirectiveUtilTestMoveToLocationTask::RegisterTimersForTest(UWorld* World)
{
TimerWorld = World;
World->GetTimerManager().SetTimer(TimerHandle, FTimerDelegate::CreateLambda([] {}), 60.0f, true);
World->GetTimerManager().SetTimer(StuckTimerHandle, FTimerDelegate::CreateLambda([] {}), 60.0f, true);
}
bool UDirectiveUtilTestMoveToActorTask::HasRegisteredTimers() const
{
const UWorld* World = TimerWorld.Get();
return World && (World->GetTimerManager().TimerExists(TimerHandle) || World->GetTimerManager().TimerExists(StuckTimerHandle));
}
void UDirectiveUtilTestMoveToActorTask::RegisterTimersForTest(UWorld* World)
{
TimerWorld = World;
World->GetTimerManager().SetTimer(TimerHandle, FTimerDelegate::CreateLambda([] {}), 60.0f, true);
World->GetTimerManager().SetTimer(StuckTimerHandle, FTimerDelegate::CreateLambda([] {}), 60.0f, true);
}
class FDirectiveUtilTickTraceWorld : public IAutomationLatentCommand
{
public:
@@ -111,18 +129,12 @@ private:
int32 FramesRemaining;
};
/**
* DirectiveUtilTask_AsyncTrace: verifies the null-world guard broadcasts an empty result, and that each trace
* shape (line, sphere, box, capsule) resolves against a blocking body and reports a hit.
*/
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilAsyncTraceTest, "DirectiveUtilities.AsyncTaskTraceTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilAsyncTraceTest, "DirectiveUtilities.AsyncTaskTraceTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilAsyncTraceTest::RunTest(const FString& Parameters)
{
// The null-world activation intentionally logs a warning.
AddExpectedMessagePlain(TEXT("Async Trace failed to activate. World is null."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
// Null world guard: activating with a null context broadcasts an empty result and does not crash.
{
UDirectiveUtilDelegateListener* Listener = NewObject<UDirectiveUtilDelegateListener>();
Listener->AddToRoot();
@@ -142,8 +154,8 @@ bool FDirectiveUtilAsyncTraceTest::RunTest(const FString& Parameters)
UStaticMesh* CubeMesh = LoadObject<UStaticMesh>(nullptr, TEXT("/Engine/BasicShapes/Cube.Cube"));
if (!CubeMesh)
{
AddInfo(TEXT("Engine cube mesh unavailable; skipping async trace hit scenarios."));
return true;
AddError(TEXT("Engine cube mesh unavailable for async trace hit scenarios."));
return false;
}
UWorld* World = DirectiveUtilAsyncTraceTestHelpers::CreateTraceWorld();
@@ -154,10 +166,9 @@ bool FDirectiveUtilAsyncTraceTest::RunTest(const FString& Parameters)
}
DirectiveUtilAsyncTraceTestHelpers::SpawnBlockingCube(World, CubeMesh);
// Trace straight down through the cube at the origin so every shape intersects it.
const FVector Start(0.0f, 0.0f, 500.0f);
const FVector End(0.0f, 0.0f, -500.0f);
const ETraceTypeQuery Channel = ETraceTypeQuery::TraceTypeQuery1; // Visibility, which BlockAll blocks.
const ETraceTypeQuery VisibilityChannel = ETraceTypeQuery::TraceTypeQuery1;
auto MakeListener = [](UDirectiveUtilTask_AsyncTrace* Task) -> UDirectiveUtilDelegateListener*
{
@@ -170,82 +181,23 @@ bool FDirectiveUtilAsyncTraceTest::RunTest(const FString& Parameters)
};
TArray<UDirectiveUtilDelegateListener*> Listeners;
Listeners.Add(MakeListener(UDirectiveUtilTask_AsyncTrace::AsyncLineTraceByChannel(World, Start, End, Channel, false)));
Listeners.Add(MakeListener(UDirectiveUtilTask_AsyncTrace::AsyncSphereTraceByChannel(World, Start, End, 25.0f, Channel, false)));
Listeners.Add(MakeListener(UDirectiveUtilTask_AsyncTrace::AsyncBoxTraceByChannel(World, Start, End, FVector(25.0f), FRotator::ZeroRotator, Channel, false)));
Listeners.Add(MakeListener(UDirectiveUtilTask_AsyncTrace::AsyncCapsuleTraceByChannel(World, Start, End, 25.0f, 50.0f, Channel, false)));
Listeners.Add(MakeListener(UDirectiveUtilTask_AsyncTrace::AsyncLineTraceByChannel(World, Start, End, VisibilityChannel, false)));
Listeners.Add(MakeListener(UDirectiveUtilTask_AsyncTrace::AsyncSphereTraceByChannel(World, Start, End, 25.0f, VisibilityChannel, false)));
Listeners.Add(MakeListener(UDirectiveUtilTask_AsyncTrace::AsyncBoxTraceByChannel(World, Start, End, FVector(25.0f), FRotator::ZeroRotator, VisibilityChannel, false)));
Listeners.Add(MakeListener(UDirectiveUtilTask_AsyncTrace::AsyncCapsuleTraceByChannel(World, Start, End, 25.0f, 50.0f, VisibilityChannel, false)));
ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickTraceWorld(this, World, Listeners, 120));
return true;
}
/**
* Latent command that ticks a move-to-location world each frame until the listener reports
* completion (or the frame budget runs out), asserts a single failed completion, and tears the
* world down.
*/
class FDirectiveUtilTickMoveToLocationWorld : public IAutomationLatentCommand
{
public:
FDirectiveUtilTickMoveToLocationWorld(FAutomationTestBase* InTest, UWorld* InWorld, UDirectiveUtilDelegateListener* InListener, int32 InFrames)
: Test(InTest)
, World(InWorld)
, Listener(InListener)
, FramesRemaining(InFrames)
{
}
virtual bool Update() override
{
if (UWorld* TickWorld = World.Get())
{
TickWorld->Tick(LEVELTICK_All, 0.05f);
}
if (Listener && !Listener->bCompleted && --FramesRemaining > 0)
{
return false;
}
if (Listener)
{
Test->TestTrue(TEXT("Move without navigation broadcasts Completed"), Listener->bCompleted);
Test->TestFalse(TEXT("Move without navigation reports failure"), Listener->bLastSuccess);
Test->TestEqual(TEXT("Move without navigation completes exactly once"), Listener->CompletedCount, 1);
Listener->Keepalive = nullptr;
Listener->RemoveFromRoot();
}
if (UWorld* TearDownWorld = World.Get())
{
GEngine->DestroyWorldContext(TearDownWorld);
TearDownWorld->DestroyWorld(false);
}
return true;
}
private:
FAutomationTestBase* Test;
TWeakObjectPtr<UWorld> World;
UDirectiveUtilDelegateListener* Listener;
int32 FramesRemaining;
};
/**
* DirectiveUtilTask_MoveToLocation: verifies the guard paths (null controller, controller without a pawn) and
* EndTask all broadcast Completed(false) without crashing, that a second EndTask does not broadcast
* again, and that a move with no navigation data terminates with failure. The successful navigation
* path requires a built navigation mesh and is exercised in a project-level test rather than here.
*/
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilMoveToLocationTest, "DirectiveUtilities.AsyncTaskMoveToLocationTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilMoveToLocationTest, "DirectiveUtilities.AsyncTaskMoveToLocationTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilMoveToLocationTest::RunTest(const FString& Parameters)
{
// The guard paths intentionally log a warning.
AddExpectedMessagePlain(TEXT("Controller, pawn, or world is unavailable while moving to location. Aborting."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
AddExpectedMessagePlain(TEXT("Controller or pawn has been destroyed while moving to location. Aborting."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
// Null controller guard: activating broadcasts Completed(false).
{
UDirectiveUtilDelegateListener* Listener = NewObject<UDirectiveUtilDelegateListener>();
Listener->AddToRoot();
@@ -262,16 +214,15 @@ bool FDirectiveUtilMoveToLocationTest::RunTest(const FString& Parameters)
Listener->RemoveFromRoot();
}
UWorld* World = UWorld::CreateWorld(EWorldType::Editor, false);
UWorld* World = UWorld::CreateWorld(EWorldType::Game, false);
if (!World)
{
AddError(TEXT("Failed to create a transient world for the move-to-location test."));
return false;
}
FWorldContext& WorldContext = GEngine->CreateNewWorldContext(EWorldType::Editor);
FWorldContext& WorldContext = GEngine->CreateNewWorldContext(EWorldType::Game);
WorldContext.SetCurrentWorld(World);
// Controller-without-pawn guard: activating broadcasts Completed(false).
{
APlayerController* Controller = World->SpawnActor<APlayerController>();
@@ -290,7 +241,6 @@ bool FDirectiveUtilMoveToLocationTest::RunTest(const FString& Parameters)
Listener->RemoveFromRoot();
}
// EndTask broadcasts Completed(false) and clears timers without crashing.
{
APlayerController* Controller = World->SpawnActor<APlayerController>();
@@ -309,7 +259,6 @@ bool FDirectiveUtilMoveToLocationTest::RunTest(const FString& Parameters)
Listener->RemoveFromRoot();
}
// Double completion guard: a second EndTask does not broadcast Completed again.
{
APlayerController* Controller = World->SpawnActor<APlayerController>();
@@ -328,31 +277,44 @@ bool FDirectiveUtilMoveToLocationTest::RunTest(const FString& Parameters)
Listener->RemoveFromRoot();
}
{
APlayerController* Controller = World->SpawnActor<APlayerController>();
ADefaultPawn* Pawn = World->SpawnActor<ADefaultPawn>();
if (Controller && Pawn)
{
Controller->SetPawn(Pawn);
UDirectiveUtilTestMoveToLocationTask* Task = NewObject<UDirectiveUtilTestMoveToLocationTask>();
Task->Configure(Controller, FVector(1000.0f, 0.0f, 0.0f), true);
Task->RegisterTimersForTest(World);
TestTrue("Move to location registers both lifecycle timers", Task->HasRegisteredTimers());
Task->ClearController();
Task->Complete();
TestFalse("Move to location clears timers without a controller", Task->HasRegisteredTimers());
Task->Configure(Controller, FVector(1000.0f, 0.0f, 0.0f), true);
Task->Activate();
TestFalse("A completed move to location should not restart", Task->HasRegisteredTimers());
}
}
GEngine->DestroyWorldContext(World);
World->DestroyWorld(false);
// No-navigation failure: without a navmesh the idle path-following check terminates the task
// with failure instead of polling forever.
{
// SimpleMoveToLocation may warn when the world has no navigation system.
AddExpectedMessagePlain(TEXT("SimpleMoveToActor called for NavSys:"), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
AddExpectedMessagePlain(TEXT("SimpleMove failed for"), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
UWorld* MoveWorld = DirectiveUtilAsyncTraceTestHelpers::CreateTraceWorld();
if (!MoveWorld)
{
AddInfo(TEXT("Failed to create a transient game world; skipping the no-navigation move scenario."));
return true;
AddError(TEXT("Failed to create a transient game world for the no-navigation move scenario."));
return false;
}
APlayerController* Controller = MoveWorld->SpawnActor<APlayerController>();
ADefaultPawn* Pawn = MoveWorld->SpawnActor<ADefaultPawn>(FVector::ZeroVector, FRotator::ZeroRotator);
if (!Controller || !Pawn)
{
AddInfo(TEXT("Failed to spawn a controller or pawn; skipping the no-navigation move scenario."));
AddError(TEXT("Failed to spawn a controller or pawn for the no-navigation move scenario."));
GEngine->DestroyWorldContext(MoveWorld);
MoveWorld->DestroyWorld(false);
return true;
return false;
}
Controller->SetPawn(Pawn);
@@ -364,26 +326,26 @@ bool FDirectiveUtilMoveToLocationTest::RunTest(const FString& Parameters)
Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnBoolCompleted);
Task->Activate();
ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickMoveToLocationWorld(this, MoveWorld, Listener, 120));
TestTrue("A location move without navigation broadcasts Completed", Listener->bCompleted);
TestFalse("A location move without navigation reports failure", Listener->bLastSuccess);
TestEqual("A location move without navigation completes exactly once", Listener->CompletedCount, 1);
Listener->Keepalive = nullptr;
Listener->RemoveFromRoot();
GEngine->DestroyWorldContext(MoveWorld);
MoveWorld->DestroyWorld(false);
}
return true;
}
/**
* DirectiveUtilTask_MoveToActor: verifies the guard paths (null controller, null goal) broadcast Completed(false)
* without crashing, that a second EndTask does not broadcast again, and that a move with no
* navigation data terminates with failure. The successful navigation path requires a built
* navigation mesh and is exercised in a project-level test rather than here.
*/
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilMoveToActorTest, "DirectiveUtilities.AsyncTaskMoveToActorTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilMoveToActorTest, "DirectiveUtilities.AsyncTaskMoveToActorTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilMoveToActorTest::RunTest(const FString& Parameters)
{
// The guard paths intentionally log a warning.
AddExpectedMessagePlain(TEXT("Controller, pawn, goal, or world is unavailable while moving to actor. Aborting."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
AddExpectedMessagePlain(TEXT("Controller, pawn, or world is unavailable while moving to actor. Aborting."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
AddExpectedMessagePlain(TEXT("Controller, pawn, or goal has been destroyed while moving to actor. Aborting."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
// Null controller guard: activating broadcasts Completed(false).
{
UDirectiveUtilDelegateListener* Listener = NewObject<UDirectiveUtilDelegateListener>();
Listener->AddToRoot();
@@ -400,16 +362,15 @@ bool FDirectiveUtilMoveToActorTest::RunTest(const FString& Parameters)
Listener->RemoveFromRoot();
}
UWorld* World = UWorld::CreateWorld(EWorldType::Editor, false);
UWorld* World = UWorld::CreateWorld(EWorldType::Game, false);
if (!World)
{
AddError(TEXT("Failed to create a transient world for the move-to-actor test."));
return false;
}
FWorldContext& WorldContext = GEngine->CreateNewWorldContext(EWorldType::Editor);
FWorldContext& WorldContext = GEngine->CreateNewWorldContext(EWorldType::Game);
WorldContext.SetCurrentWorld(World);
// Null goal guard: a controller with a pawn but no goal broadcasts Completed(false).
{
APlayerController* Controller = World->SpawnActor<APlayerController>();
ADefaultPawn* Pawn = World->SpawnActor<ADefaultPawn>(FVector::ZeroVector, FRotator::ZeroRotator);
@@ -433,11 +394,10 @@ bool FDirectiveUtilMoveToActorTest::RunTest(const FString& Parameters)
}
else
{
AddInfo(TEXT("Failed to spawn a controller or pawn; skipping the null-goal scenario."));
AddError(TEXT("Failed to spawn a controller or pawn for the null-goal scenario."));
}
}
// Double completion guard: a second EndTask does not broadcast Completed again.
{
APlayerController* Controller = World->SpawnActor<APlayerController>();
@@ -456,21 +416,35 @@ bool FDirectiveUtilMoveToActorTest::RunTest(const FString& Parameters)
Listener->RemoveFromRoot();
}
{
APlayerController* Controller = World->SpawnActor<APlayerController>();
ADefaultPawn* Pawn = World->SpawnActor<ADefaultPawn>();
AStaticMeshActor* Goal = World->SpawnActor<AStaticMeshActor>();
if (Controller && Pawn && Goal)
{
Controller->SetPawn(Pawn);
UDirectiveUtilTestMoveToActorTask* Task = NewObject<UDirectiveUtilTestMoveToActorTask>();
Task->Configure(Controller, Goal, true);
Task->RegisterTimersForTest(World);
TestTrue("Move to actor registers both lifecycle timers", Task->HasRegisteredTimers());
Task->ClearController();
Task->Complete();
TestFalse("Move to actor clears timers without a controller", Task->HasRegisteredTimers());
Task->Configure(Controller, Goal, true);
Task->Activate();
TestFalse("A completed move to actor should not restart", Task->HasRegisteredTimers());
}
}
GEngine->DestroyWorldContext(World);
World->DestroyWorld(false);
// No-navigation failure: without a navmesh the idle path-following check terminates the task
// with failure instead of polling forever.
{
// SimpleMoveToActor may warn when the world has no navigation system.
AddExpectedMessagePlain(TEXT("SimpleMoveToActor called for NavSys:"), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
AddExpectedMessagePlain(TEXT("SimpleMove failed for"), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
UWorld* MoveWorld = DirectiveUtilAsyncTraceTestHelpers::CreateTraceWorld();
if (!MoveWorld)
{
AddInfo(TEXT("Failed to create a transient game world; skipping the no-navigation move scenario."));
return true;
AddError(TEXT("Failed to create a transient game world for the no-navigation move scenario."));
return false;
}
APlayerController* Controller = MoveWorld->SpawnActor<APlayerController>();
@@ -478,10 +452,10 @@ bool FDirectiveUtilMoveToActorTest::RunTest(const FString& Parameters)
AStaticMeshActor* GoalActor = MoveWorld->SpawnActor<AStaticMeshActor>(FVector(10000.0f, 0.0f, 0.0f), FRotator::ZeroRotator);
if (!Controller || !Pawn || !GoalActor)
{
AddInfo(TEXT("Failed to spawn a controller, pawn, or goal; skipping the no-navigation move scenario."));
AddError(TEXT("Failed to spawn a controller, pawn, or goal for the no-navigation move scenario."));
GEngine->DestroyWorldContext(MoveWorld);
MoveWorld->DestroyWorld(false);
return true;
return false;
}
Controller->SetPawn(Pawn);
@@ -493,10 +467,14 @@ bool FDirectiveUtilMoveToActorTest::RunTest(const FString& Parameters)
Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnBoolCompleted);
Task->Activate();
ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickMoveToLocationWorld(this, MoveWorld, Listener, 120));
TestTrue("An actor move without navigation broadcasts Completed", Listener->bCompleted);
TestFalse("An actor move without navigation reports failure", Listener->bLastSuccess);
TestEqual("An actor move without navigation completes exactly once", Listener->CompletedCount, 1);
Listener->Keepalive = nullptr;
Listener->RemoveFromRoot();
GEngine->DestroyWorldContext(MoveWorld);
MoveWorld->DestroyWorld(false);
}
return true;
}
#endif // WITH_EDITOR

View File

@@ -1,6 +1,9 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Misc/AutomationTest.h"
#include "UObject/Class.h"
#include "UObject/Package.h"
#include "UObject/UnrealType.h"
#include "UObject/UObjectIterator.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilBlueprintCategoryTest, "DirectiveUtilities.BlueprintCategoryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
@@ -8,8 +11,9 @@ IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilBlueprintCategoryTest, "Directive
bool FDirectiveUtilBlueprintCategoryTest::RunTest(const FString& Parameters)
{
const FString ExpectedCategoryRoot = TEXT("Directive Utilities");
const FString RuntimeScriptPackage = TEXT("/Script/DirectiveUtilitiesRuntime");
const TSet<FString> PluginScriptPackages = {
TEXT("/Script/DirectiveUtilitiesRuntime"),
RuntimeScriptPackage,
TEXT("/Script/DirectiveUtilitiesEditor")
};
@@ -32,6 +36,15 @@ bool FDirectiveUtilBlueprintCategoryTest::RunTest(const FString& Parameters)
}
++TestedFunctionCount;
if (Class->GetOutermost()->GetName() == RuntimeScriptPackage)
{
TestFalse(
FString::Printf(TEXT("%s.%s is available at runtime"), *Class->GetName(), *Function->GetName()),
Class->GetOutermost()->HasAnyPackageFlags(PKG_EditorOnly | PKG_UncookedOnly | PKG_Developer)
|| Function->HasAnyFunctionFlags(FUNC_EditorOnly)
);
}
const FString Category = Function->GetMetaData(TEXT("Category"));
FString CategoryRoot = Category;
int32 CategoryDelimiterIndex = INDEX_NONE;

View File

@@ -0,0 +1,64 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#if WITH_EDITOR
#include "AssetRegistry/DirectiveUtilDependencyCycleFinder.h"
#include "Misc/AutomationTest.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilDependencyCycleFinderTest,
"DirectiveUtilities.EditorDependencyCycleFinderTests",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilDependencyCycleFinderTest::RunTest(const FString& Parameters)
{
TMap<FName, TArray<FName>> Graph;
Graph.Add(TEXT("/Game/A"), {TEXT("/Game/B")});
Graph.Add(TEXT("/Game/B"), {TEXT("/Game/A")});
Graph.Add(TEXT("/Game/C"), {TEXT("/Game/C")});
Graph.Add(TEXT("/Game/D"), {TEXT("/Game/E")});
Graph.Add(TEXT("/Game/E"));
const TArray<FDirectiveUtilAssetDependencyCycle> Cycles =
DirectiveUtilitiesEditor::FDependencyCycleFinder(Graph).Find();
TestEqual(TEXT("The graph contains two cycles"), Cycles.Num(), 2);
if (Cycles.Num() == 2)
{
TestEqual(TEXT("The first cycle contains two packages"), Cycles[0].Packages.Num(), 2);
TestTrue(TEXT("The first cycle contains A"), Cycles[0].Packages.Contains(TEXT("/Game/A")));
TestTrue(TEXT("The first cycle contains B"), Cycles[0].Packages.Contains(TEXT("/Game/B")));
TestEqual(TEXT("The self-cycle contains one package"), Cycles[1].Packages.Num(), 1);
TestTrue(TEXT("The self-cycle contains C"), Cycles[1].Packages.Contains(TEXT("/Game/C")));
}
constexpr int32 NodeCount = 20000;
constexpr int32 CycleStart = NodeCount / 2;
TArray<FName> Nodes;
Nodes.Reserve(NodeCount);
for (int32 Index = 0; Index < NodeCount; ++Index)
{
Nodes.Add(*FString::Printf(TEXT("/Game/Deep/%05d"), Index));
}
TMap<FName, TArray<FName>> DeepGraph;
DeepGraph.Reserve(NodeCount);
for (int32 Index = 0; Index < NodeCount - 1; ++Index)
{
DeepGraph.Add(Nodes[Index], {Nodes[Index + 1]});
}
DeepGraph.Add(Nodes.Last(), {Nodes[CycleStart]});
const TArray<FDirectiveUtilAssetDependencyCycle> DeepCycles =
DirectiveUtilitiesEditor::FDependencyCycleFinder(DeepGraph).Find();
TestEqual(TEXT("The deep graph contains one cycle"), DeepCycles.Num(), 1);
if (DeepCycles.Num() == 1)
{
TestEqual(TEXT("The deep cycle contains every connected member"), DeepCycles[0].Packages.Num(), NodeCount - CycleStart);
TestTrue(TEXT("The deep cycle starts at the expected package"), DeepCycles[0].Packages.Contains(Nodes[CycleStart]));
TestTrue(TEXT("The deep cycle ends at the expected package"), DeepCycles[0].Packages.Contains(Nodes.Last()));
}
return true;
}
#endif

View File

@@ -0,0 +1,167 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#if WITH_EDITOR
#include "Subsystems/DirectiveUtilEditorActorSubsystem.h"
#include "Components/BoxComponent.h"
#include "Engine/Engine.h"
#include "Engine/World.h"
#include "Misc/AutomationTest.h"
#include <limits>
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilEditorActorLayoutTest,
"DirectiveUtilities.EditorActorLayoutTests",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilEditorActorLayoutTest::RunTest(const FString& Parameters)
{
UWorld* World = UWorld::CreateWorld(EWorldType::Editor, false);
if (!World)
{
AddError(TEXT("Failed to create an editor world"));
return false;
}
FWorldContext& WorldContext = GEngine->CreateNewWorldContext(EWorldType::Editor);
WorldContext.SetCurrentWorld(World);
auto SpawnBox = [World](const FVector Location, const FVector Extent, const ECollisionChannel ObjectType) {
AActor* Actor = World->SpawnActor<AActor>();
Actor->ClearFlags(RF_Transient);
UBoxComponent* Box = NewObject<UBoxComponent>(Actor);
Actor->SetRootComponent(Box);
Box->SetBoxExtent(Extent);
Box->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
Box->SetCollisionObjectType(ObjectType);
Box->SetCollisionResponseToAllChannels(ECR_Block);
Box->RegisterComponent();
Actor->SetActorLocation(Location);
return Actor;
};
AActor* First = SpawnBox(FVector(0.0f, 0.0f, 100.0f), FVector(10.0f), ECC_WorldDynamic);
AActor* Middle = SpawnBox(FVector(30.0f, 20.0f, 100.0f), FVector(10.0f), ECC_WorldDynamic);
AActor* Last = SpawnBox(FVector(100.0f, 40.0f, 100.0f), FVector(10.0f), ECC_WorldDynamic);
FDirectiveUtilActorOperationResult AlignResult = UDirectiveUtilEditorActorSubsystem::AlignActors(
{First, Middle, Last},
EDirectiveUtilActorLayoutAxis::Y,
EDirectiveUtilActorAlignment::Minimum);
TestEqual(TEXT("Alignment changes two actors"), AlignResult.ChangedActors.Num(), 2);
TestTrue(TEXT("Alignment preserves X"), FMath::IsNearlyEqual(Middle->GetActorLocation().X, 30.0f));
TestTrue(TEXT("Alignment matches minimum bounds"), FMath::IsNearlyEqual(Middle->GetActorLocation().Y, 0.0f));
FDirectiveUtilActorOperationResult DistributeResult = UDirectiveUtilEditorActorSubsystem::DistributeActors(
{First, Middle, Last},
EDirectiveUtilActorLayoutAxis::X,
EDirectiveUtilActorDistribution::Centers);
TestEqual(TEXT("Distribution changes the middle actor"), DistributeResult.ChangedActors.Num(), 1);
TestTrue(TEXT("Center distribution uses equal spacing"), FMath::IsNearlyEqual(Middle->GetActorLocation().X, 50.0f));
TestTrue(TEXT("Distribution preserves Y"), FMath::IsNearlyEqual(Middle->GetActorLocation().Y, 0.0f));
AActor* Floor = SpawnBox(FVector::ZeroVector, FVector(200.0f, 200.0f, 10.0f), ECC_WorldStatic);
World->UpdateWorldComponents(true, false);
const FVector BeforeInvalidSnap = Middle->GetActorLocation();
const FDirectiveUtilActorOperationResult InvalidDistanceResult = UDirectiveUtilEditorActorSubsystem::SnapActorsToSurface(
{Middle},
FVector::DownVector,
std::numeric_limits<float>::quiet_NaN(),
ECC_Visibility,
EDirectiveUtilSurfacePlacement::Pivot,
false);
TestTrue(TEXT("Surface snapping skips a non-finite distance"), InvalidDistanceResult.SkippedActors.Contains(Middle));
TestTrue(TEXT("A non-finite distance preserves the actor transform"), Middle->GetActorLocation().Equals(BeforeInvalidSnap));
const FDirectiveUtilActorOperationResult InvalidDirectionResult = UDirectiveUtilEditorActorSubsystem::SnapActorsToSurface(
{Middle},
FVector(std::numeric_limits<float>::infinity(), 0.0f, -1.0f),
500.0f,
ECC_Visibility,
EDirectiveUtilSurfacePlacement::Pivot,
false);
TestTrue(TEXT("Surface snapping skips a non-finite direction"), InvalidDirectionResult.SkippedActors.Contains(Middle));
const FDirectiveUtilActorOperationResult InvalidChannelResult = UDirectiveUtilEditorActorSubsystem::SnapActorsToSurface(
{Middle},
FVector::DownVector,
500.0f,
static_cast<ECollisionChannel>(ECC_MAX),
EDirectiveUtilSurfacePlacement::Pivot,
false);
TestTrue(TEXT("Surface snapping skips an invalid collision channel"), InvalidChannelResult.SkippedActors.Contains(Middle));
FDirectiveUtilActorOperationResult SnapResult = UDirectiveUtilEditorActorSubsystem::SnapActorsToSurface(
{Middle},
FVector::DownVector,
500.0f,
ECC_Visibility,
EDirectiveUtilSurfacePlacement::Pivot,
false);
TestEqual(TEXT("Surface snapping changes the actor"), SnapResult.ChangedActors.Num(), 1);
TestTrue(TEXT("Pivot snapping reaches the floor surface"), FMath::IsNearlyEqual(Middle->GetActorLocation().Z, 10.0f, 0.1f));
AActor* BoundsActor = SpawnBox(FVector(150.0f, 150.0f, 100.0f), FVector(10.0f), ECC_WorldDynamic);
FDirectiveUtilActorOperationResult BoundsSnapResult = UDirectiveUtilEditorActorSubsystem::SnapActorsToSurface(
{BoundsActor},
FVector::DownVector,
500.0f,
ECC_Visibility,
EDirectiveUtilSurfacePlacement::Bounds,
false);
TestEqual(TEXT("Bounds snapping changes the actor"), BoundsSnapResult.ChangedActors.Num(), 1);
TestTrue(TEXT("Bounds snapping places the lower bound on the surface"), FMath::IsNearlyEqual(BoundsActor->GetActorLocation().Z, 20.0f, 0.1f));
AActor* NormalActor = SpawnBox(FVector(-150.0f, -150.0f, 100.0f), FVector(10.0f), ECC_WorldDynamic);
NormalActor->SetActorRotation(FRotator(45.0f, 0.0f, 0.0f));
FDirectiveUtilActorOperationResult NormalSnapResult = UDirectiveUtilEditorActorSubsystem::SnapActorsToSurface(
{NormalActor},
FVector::DownVector,
500.0f,
ECC_Visibility,
EDirectiveUtilSurfacePlacement::Pivot,
true);
TestEqual(TEXT("Normal-aligned snapping changes the actor"), NormalSnapResult.ChangedActors.Num(), 1);
TestTrue(TEXT("Normal-aligned snapping points the actor up from the surface"), NormalActor->GetActorUpVector().Equals(FVector::UpVector, 0.01f));
AActor* MissActor = SpawnBox(FVector(500.0f, 500.0f, 100.0f), FVector(10.0f), ECC_WorldDynamic);
FDirectiveUtilActorOperationResult MissResult = UDirectiveUtilEditorActorSubsystem::SnapActorsToSurface(
{MissActor},
FVector::DownVector,
500.0f,
ECC_Visibility,
EDirectiveUtilSurfacePlacement::Pivot,
false);
TestTrue(TEXT("Surface snapping reports collision misses as skipped"), MissResult.SkippedActors.Contains(MissActor));
Floor->Destroy();
AActor* Ceiling = SpawnBox(FVector(0.0f, 0.0f, 50.0f), FVector(20.0f, 20.0f, 10.0f), ECC_WorldStatic);
World->UpdateWorldComponents(true, false);
AActor* ActorWithoutRoot = World->SpawnActor<AActor>();
ActorWithoutRoot->ClearFlags(RF_Transient);
FDirectiveUtilActorOperationResult FailedMoveResult = UDirectiveUtilEditorActorSubsystem::SnapActorsToSurface(
{ActorWithoutRoot},
FVector::UpVector,
500.0f,
ECC_Visibility,
EDirectiveUtilSurfacePlacement::Pivot,
false);
TestTrue(TEXT("Surface snapping reports a failed transform as skipped"), FailedMoveResult.SkippedActors.Contains(ActorWithoutRoot));
TestFalse(TEXT("A failed transform is not reported as changed"), FailedMoveResult.ChangedActors.Contains(ActorWithoutRoot));
AActor* TransientActor = SpawnBox(FVector::ZeroVector, FVector(10.0f), ECC_WorldDynamic);
TransientActor->SetFlags(RF_Transient);
FDirectiveUtilActorOperationResult InvalidResult = UDirectiveUtilEditorActorSubsystem::AlignActors(
{First, TransientActor, nullptr},
EDirectiveUtilActorLayoutAxis::X,
EDirectiveUtilActorAlignment::Center);
TestTrue(TEXT("Transient actors are skipped"), InvalidResult.SkippedActors.Contains(TransientActor));
Ceiling->Destroy();
GEngine->DestroyWorldContext(World);
World->DestroyWorld(false);
return true;
}
#endif

View File

@@ -1,3 +1,5 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#if WITH_EDITOR
#include "Subsystems/DirectiveUtilEditorActorSubsystem.h"
@@ -5,17 +7,23 @@
#include "Misc/AutomationTest.h"
#include "Engine/StaticMesh.h"
#include "Engine/StaticMeshActor.h"
#include "Engine/Texture2D.h"
#include "Components/StaticMeshComponent.h"
#include "Components/BoxComponent.h"
#include "Components/CapsuleComponent.h"
#include "Materials/Material.h"
#include "Materials/MaterialExpressionTextureSample.h"
#include "Materials/MaterialInterface.h"
#include "Engine/World.h"
#include "Engine/Engine.h"
#include "UObject/Package.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilEditorActorSubsystemTest, "DirectiveUtilities.EditorActorSubsystemTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilEditorActorSubsystemTest::RunTest(const FString& Parameters)
{
UDirectiveUtilEditorActorSubsystem::FocusActorsInViewport({nullptr});
// IsActorWithinBoxBounds should not crash and should return false with null Actor
TestFalse("IsActorWithinBoxBounds should return false with null Actor",
UDirectiveUtilEditorActorSubsystem::IsActorWithinBoxBounds(nullptr, nullptr));
@@ -60,8 +68,8 @@ bool FDirectiveUtilEditorActorSubsystemFilterTest::RunTest(const FString& Parame
UStaticMesh* Sphere = LoadObject<UStaticMesh>(nullptr, TEXT("/Engine/BasicShapes/Sphere.Sphere"));
if (!Cube || !Sphere)
{
AddInfo(TEXT("Engine basic shapes unavailable; skipping include/exclude behaviour test."));
return true;
AddError(TEXT("Engine basic shapes unavailable for the include/exclude behaviour test."));
return false;
}
UWorld* World = UWorld::CreateWorld(EWorldType::Editor, false);
@@ -126,8 +134,8 @@ bool FDirectiveUtilEditorActorSubsystemFilterCoverageTest::RunTest(const FString
UMaterialInterface* MatB = LoadObject<UMaterialInterface>(nullptr, TEXT("/Engine/EngineMaterials/WorldGridMaterial.WorldGridMaterial"));
if (!Cube || !Sphere || !MatA || !MatB)
{
AddInfo(TEXT("Engine basic shapes/materials unavailable; skipping filter coverage test."));
return true;
AddError(TEXT("Engine basic shapes/materials unavailable for the filter coverage test."));
return false;
}
UWorld* World = UWorld::CreateWorld(EWorldType::Editor, false);
@@ -257,6 +265,31 @@ bool FDirectiveUtilEditorActorSubsystemFilterCoverageTest::RunTest(const FString
TestTrue("ByBounds around A's size => A", RunFilter([&](const TArray<AActor*>& S, TArray<AActor*>& O){ UDirectiveUtilEditorActorSubsystem::FilterActorsByBounds(S, O, Size - FVector(1.0f), Size + FVector(1.0f), Include); }).Contains(ActorA));
}
// Static-mesh bounds use the source mesh dimensions, independently of actor transform.
{
const FVector MeshSize = Cube->GetBounds().BoxExtent * 2.0f;
const TArray<AActor*> Match = RunFilter([&](const TArray<AActor*>& S, TArray<AActor*>& O)
{
UDirectiveUtilEditorActorSubsystem::FilterActorsByStaticMeshBounds(
S, O, MeshSize - FVector(1.0f), MeshSize + FVector(1.0f), Include);
});
TestTrue("ByStaticMeshBounds includes the cube actor", Match.Contains(ActorA));
TestFalse("ByStaticMeshBounds excludes the actor without a mesh", Match.Contains(ActorC));
}
// Override lightmap resolution is a distinct search lane from the mesh default.
{
CompA->bOverrideLightMapRes = true;
CompA->OverriddenLightMapRes = 128;
const TArray<AActor*> Match = RunFilter([](const TArray<AActor*>& S, TArray<AActor*>& O)
{
UDirectiveUtilEditorActorSubsystem::FilterActorsByLightmapResolution(
S, O, 128, 128, OverrideOnly, Include);
});
TestTrue("ByLightmapResolution finds the exact override", Match.Contains(ActorA));
TestFalse("ByLightmapResolution rejects a different override", Match.Contains(ActorB));
}
// World location: A at origin, B far away.
{
const TArray<AActor*> Near = RunFilter([](const TArray<AActor*>& S, TArray<AActor*>& O){ UDirectiveUtilEditorActorSubsystem::FilterActorsByWorldLocation(S, O, FVector::ZeroVector, 50.0f, Include); });
@@ -269,6 +302,67 @@ bool FDirectiveUtilEditorActorSubsystemFilterCoverageTest::RunTest(const FString
TestEqual("ByTextureName(absent) Exclude => all", RunFilter([](const TArray<AActor*>& S, TArray<AActor*>& O){ UDirectiveUtilEditorActorSubsystem::FilterActorsByTextureName(S, O, TEXT("__udcore_absent_texture__"), BaseAndOverride, Exclude); }).Num(), 3);
}
// Texture-reference filtering must follow the same include/exclude contract.
{
UTexture2D* AbsentTexture = NewObject<UTexture2D>(GetTransientPackage());
const TArray<AActor*> Inc = RunFilter([&](const TArray<AActor*>& S, TArray<AActor*>& O)
{
UDirectiveUtilEditorActorSubsystem::FilterActorsByTexture(S, O, AbsentTexture, BaseAndOverride, Include);
});
const TArray<AActor*> Exc = RunFilter([&](const TArray<AActor*>& S, TArray<AActor*>& O)
{
UDirectiveUtilEditorActorSubsystem::FilterActorsByTexture(S, O, AbsentTexture, BaseAndOverride, Exclude);
});
TestEqual("ByTexture(absent) Include returns none", Inc.Num(), 0);
TestEqual("ByTexture(absent) Exclude returns every valid source actor", Exc.Num(), Src.Num());
}
// Missing-resource convenience filters are semantic aliases, not merely spawn-tested nodes.
{
UStaticMesh* MissingMaterialMesh = DuplicateObject<UStaticMesh>(Cube, GetTransientPackage());
MissingMaterialMesh->SetMaterial(0, nullptr);
UStaticMeshComponent* MissingMaterialComp = SpawnMesh(MissingMaterialMesh, EComponentMobility::Static);
UStaticMeshComponent* MissingMeshComp = SpawnMesh(nullptr, EComponentMobility::Static);
const TArray<AActor*> MissingSource = { MissingMaterialComp->GetOwner(), MissingMeshComp->GetOwner(), ActorA };
TArray<AActor*> MissingMaterials;
UDirectiveUtilEditorActorSubsystem::FilterActorsByMissingMaterials(
MissingSource, MissingMaterials, BaseOnly, Include);
TestTrue("MissingMaterials finds the null source material", MissingMaterials.Contains(MissingMaterialComp->GetOwner()));
TestFalse("MissingMaterials rejects the valid cube material", MissingMaterials.Contains(ActorA));
TArray<AActor*> MissingMeshes;
UDirectiveUtilEditorActorSubsystem::FilterActorsByMissingStaticMeshes(MissingSource, MissingMeshes, Include);
TestTrue("MissingStaticMeshes finds a mesh component with no mesh", MissingMeshes.Contains(MissingMeshComp->GetOwner()));
TestFalse("MissingStaticMeshes rejects a valid mesh", MissingMeshes.Contains(ActorA));
}
{
UMaterial* MissingTextureMaterial = NewObject<UMaterial>(GetTransientPackage());
UMaterialExpressionTextureSample* MissingTextureExpression = NewObject<UMaterialExpressionTextureSample>(MissingTextureMaterial);
MissingTextureMaterial->GetExpressionCollection().AddExpression(MissingTextureExpression);
CompA->SetMaterial(0, MissingTextureMaterial);
const TArray<AActor*> MissingTextures = RunFilter([](const TArray<AActor*>& S, TArray<AActor*>& O)
{
UDirectiveUtilEditorActorSubsystem::FilterActorsByMissingTextures(S, O, OverrideOnly, Include);
});
TestTrue("Missing texture filter should include an actor with an unset texture expression", MissingTextures.Contains(ActorA));
TestFalse("Missing texture filter should exclude actors without unset texture expressions", MissingTextures.Contains(ActorB));
CompA->SetMaterial(0, MatA);
}
{
AActor* DestroyedActor = World->SpawnActor<AActor>();
World->DestroyActor(DestroyedActor);
TestFalse("Destroyed actor should be invalid", IsValid(DestroyedActor));
TArray<AActor*> InvalidActors = {DestroyedActor};
TArray<AActor*> FilteredInvalidActors;
UDirectiveUtilEditorActorSubsystem::FilterActorsByTag(
InvalidActors,
FilteredInvalidActors,
TEXT("Absent"),
Exclude);
TestEqual("Actor filters should skip invalid actors", FilteredInvalidActors.Num(), 0);
}
GEngine->DestroyWorldContext(World);
World->DestroyWorld(false);
@@ -327,10 +421,12 @@ IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilEditorActorSubsystemQueryAlignmen
bool FDirectiveUtilEditorActorSubsystemQueryAlignmentTest::RunTest(const FString& Parameters)
{
UStaticMesh* Cube = LoadObject<UStaticMesh>(nullptr, TEXT("/Engine/BasicShapes/Cube.Cube"));
if (!Cube)
UMaterialInterface* BasicMaterial = LoadObject<UMaterialInterface>(
nullptr, TEXT("/Engine/BasicShapes/BasicShapeMaterial.BasicShapeMaterial"));
if (!Cube || !BasicMaterial)
{
AddInfo(TEXT("Engine basic shapes unavailable; skipping query alignment test."));
return true;
AddError(TEXT("Engine basic shapes or materials unavailable for the query alignment test."));
return false;
}
// The GetActorsBy* queries read from the editor world rather than a passed array.
@@ -345,8 +441,8 @@ bool FDirectiveUtilEditorActorSubsystemQueryAlignmentTest::RunTest(const FString
}
if (!EditorWorld)
{
AddInfo(TEXT("No editor world available; skipping query alignment test."));
return true;
AddError(TEXT("No editor world available for the query alignment test."));
return false;
}
AActor* MeshActor = EditorWorld->SpawnActor<AActor>();
@@ -358,6 +454,7 @@ bool FDirectiveUtilEditorActorSubsystemQueryAlignmentTest::RunTest(const FString
MeshComponent->SetMobility(EComponentMobility::Movable);
MeshComponent->SetupAttachment(Root);
MeshComponent->SetStaticMesh(Cube);
MeshComponent->SetMaterial(0, BasicMaterial);
MeshComponent->RegisterComponent();
MeshActor->AddInstanceComponent(MeshComponent);
MeshActor->SetActorLocation(FVector::ZeroVector);
@@ -365,6 +462,10 @@ bool FDirectiveUtilEditorActorSubsystemQueryAlignmentTest::RunTest(const FString
// The query methods keep no instance state, so a transient instance is enough headless.
UDirectiveUtilEditorActorSubsystem* Subsystem = NewObject<UDirectiveUtilEditorActorSubsystem>();
TArray<AActor*> ByClass;
Subsystem->GetActorsByClass(ByClass, AActor::StaticClass(), World, Include);
TestTrue("Class: AActor query finds the fixture actor", ByClass.Contains(MeshActor));
// Bounding box: an enclosing box finds the actor, a disjoint one does not.
TArray<AActor*> InBox;
Subsystem->GetActorsByBoundingBox(InBox, FVector(-100000.0f), FVector(100000.0f), World, Include);
@@ -378,6 +479,81 @@ bool FDirectiveUtilEditorActorSubsystemQueryAlignmentTest::RunTest(const FString
Subsystem->GetActorsByStaticMeshName(ByName, TEXT("cub"), World, Include);
TestTrue("StaticMeshName: lowercase substring finds the actor", ByName.Contains(MeshActor));
TArray<AActor*> ByMaterial;
Subsystem->GetActorsByMaterial(ByMaterial, BasicMaterial, OverrideOnly, World, Include);
TestTrue("Material: generic actor with a static mesh component is included", ByMaterial.Contains(MeshActor));
TArray<AActor*> ByMaterialSoftReference;
Subsystem->GetActorsByMaterialSoftReference(
ByMaterialSoftReference, BasicMaterial, OverrideOnly, World, Include);
TestTrue("Material soft reference: generic actor with a static mesh component is included",
ByMaterialSoftReference.Contains(MeshActor));
TArray<AActor*> ByMaterialName;
Subsystem->GetActorsByMaterialName(
ByMaterialName, BasicMaterial->GetName(), OverrideOnly, World, Include);
TestTrue("Material name: generic actor with a static mesh component is included",
ByMaterialName.Contains(MeshActor));
TArray<AActor*> WithoutMaterial;
Subsystem->GetActorsByMaterial(WithoutMaterial, BasicMaterial, OverrideOnly, World, Exclude);
TestFalse("Material exclude: matching generic actor is excluded", WithoutMaterial.Contains(MeshActor));
const int32 VertexCount = Cube->GetNumVertices(0);
TArray<AActor*> ByVertexCount;
Subsystem->GetActorsByVertexCount(ByVertexCount, VertexCount, VertexCount, World, Include);
TestTrue("VertexCount: exact range finds the fixture actor", ByVertexCount.Contains(MeshActor));
const int32 TriangleCount = Cube->GetNumTriangles(0);
TArray<AActor*> ByTriangleCount;
Subsystem->GetActorsByTriCount(ByTriangleCount, TriangleCount, TriangleCount, World, Include);
TestTrue("TriangleCount: exact range finds the fixture actor", ByTriangleCount.Contains(MeshActor));
const float MeshSize = Cube->GetBoundingBox().GetSize().Size();
TArray<AActor*> ByMeshSize;
Subsystem->GetActorsByMeshSize(ByMeshSize, MeshSize - 1.0f, MeshSize + 1.0f, World, Include);
TestTrue("MeshSize: enclosing range finds the fixture actor", ByMeshSize.Contains(MeshActor));
TArray<AActor*> ByWorldLocation;
Subsystem->GetActorsByWorldLocation(ByWorldLocation, FVector::ZeroVector, 100.0f, World, Include);
TestTrue("WorldLocation: nearby query finds the fixture actor", ByWorldLocation.Contains(MeshActor));
const int32 LODCount = Cube->GetNumLODs();
TArray<AActor*> ByLODCount;
Subsystem->GetActorsByLODCount(ByLODCount, LODCount, LODCount, World, Include);
TestTrue("LODCount: exact range finds the fixture actor", ByLODCount.Contains(MeshActor));
TArray<AActor*> NaniteOff;
TArray<AActor*> NaniteOn;
Subsystem->GetActorsByNaniteEnabled(NaniteOff, false, World, Include);
Subsystem->GetActorsByNaniteEnabled(NaniteOn, true, World, Include);
TestTrue("Nanite: fixture belongs to exactly one state", NaniteOff.Contains(MeshActor) != NaniteOn.Contains(MeshActor));
const int32 SourceLightmapResolution = Cube->GetLightMapResolution();
TArray<AActor*> ByLightmapResolution;
Subsystem->GetActorsByLightmapResolution(
ByLightmapResolution, SourceLightmapResolution, SourceLightmapResolution, World, Include);
TestTrue("LightmapResolution: exact source resolution finds the fixture actor", ByLightmapResolution.Contains(MeshActor));
TArray<AActor*> ByMobility;
Subsystem->GetActorsByMobility(ByMobility, EComponentMobility::Movable, World, Include);
TestTrue("Mobility query finds the movable fixture actor", ByMobility.Contains(MeshActor));
TArray<AActor*> ByStaticMesh;
Subsystem->GetActorsByStaticMesh(ByStaticMesh, Cube, World, Include);
TestTrue("StaticMesh reference query finds the fixture actor", ByStaticMesh.Contains(MeshActor));
TArray<AActor*> ByStaticMeshSoft;
Subsystem->GetActorsByStaticMeshSoftReference(ByStaticMeshSoft, Cube, World, Include);
TestTrue("StaticMesh soft-reference query finds the fixture actor", ByStaticMeshSoft.Contains(MeshActor));
UTexture2D* AbsentTexture = NewObject<UTexture2D>(GetTransientPackage());
TArray<AActor*> ByTexture;
Subsystem->GetActorsByTexture(ByTexture, AbsentTexture, World, Include);
TestFalse("Texture reference query rejects an absent texture", ByTexture.Contains(MeshActor));
TArray<AActor*> ByTextureSoft;
Subsystem->GetActorsByTextureSoftReference(ByTextureSoft, AbsentTexture, World, Include);
TestFalse("Texture soft-reference query rejects an absent texture", ByTextureSoft.Contains(MeshActor));
TArray<AActor*> ByTextureName;
Subsystem->GetActorsByTextureName(ByTextureName, TEXT("__udcore_absent_texture__"), World, Include);
TestFalse("Texture-name query rejects an absent texture", ByTextureName.Contains(MeshActor));
TArray<AActor*> InvalidActors = { MeshActor };
Subsystem->GetInvalidActors(InvalidActors);
TestEqual("GetInvalidActors resets its deprecated output", InvalidActors.Num(), 0);
// Mobility: the root component's mobility is what counts.
const TArray<AActor*> Source = { MeshActor };
TArray<AActor*> MovableActors;
@@ -392,6 +568,47 @@ bool FDirectiveUtilEditorActorSubsystemQueryAlignmentTest::RunTest(const FString
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilPushOverrideMaterialsTest, "DirectiveUtilities.PushOverrideMaterialsTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilPushOverrideMaterialsTest::RunTest(const FString& Parameters)
{
UMaterialInterface* BaseMaterial = LoadObject<UMaterialInterface>(
nullptr, TEXT("/Engine/BasicShapes/BasicShapeMaterial.BasicShapeMaterial"));
UMaterialInterface* OverrideMaterial = LoadObject<UMaterialInterface>(
nullptr, TEXT("/Engine/EngineMaterials/WorldGridMaterial.WorldGridMaterial"));
UStaticMesh* Cube = LoadObject<UStaticMesh>(nullptr, TEXT("/Engine/BasicShapes/Cube.Cube"));
if (!BaseMaterial || !OverrideMaterial || !Cube)
{
AddError(TEXT("Engine materials unavailable for the override material test."));
return false;
}
UStaticMesh* StaticMesh = DuplicateObject<UStaticMesh>(Cube, GetTransientPackage());
StaticMesh->GetStaticMaterials()[0].MaterialInterface = BaseMaterial;
UStaticMeshComponent* StaticMeshComponent = NewObject<UStaticMeshComponent>(GetTransientPackage());
StaticMeshComponent->SetStaticMesh(StaticMesh);
StaticMeshComponent->SetMaterial(0, OverrideMaterial);
UDirectiveUtilEditorActorSubsystem::PushOverrideMaterialsToSource(StaticMeshComponent);
TestEqual("Override material is copied to its source slot", StaticMesh->GetMaterial(0), OverrideMaterial);
UStaticMesh* MeshWithoutOverride = DuplicateObject<UStaticMesh>(Cube, GetTransientPackage());
MeshWithoutOverride->GetStaticMaterials()[0].MaterialInterface = BaseMaterial;
UStaticMeshComponent* ComponentWithoutOverride = NewObject<UStaticMeshComponent>(GetTransientPackage());
ComponentWithoutOverride->SetStaticMesh(MeshWithoutOverride);
UDirectiveUtilEditorActorSubsystem::PushOverrideMaterialsToSource(ComponentWithoutOverride);
TestEqual("A source slot is unchanged when the component has no override",
MeshWithoutOverride->GetMaterial(0), BaseMaterial);
AddExpectedError(TEXT("Static Mesh Component is invalid."), EAutomationExpectedErrorFlags::Exact, 1);
UDirectiveUtilEditorActorSubsystem::PushOverrideMaterialsToSource(nullptr);
UStaticMeshComponent* ComponentWithoutMesh = NewObject<UStaticMeshComponent>(GetTransientPackage());
AddExpectedError(TEXT("Static Mesh Component has no valid static mesh."), EAutomationExpectedErrorFlags::Exact, 1);
UDirectiveUtilEditorActorSubsystem::PushOverrideMaterialsToSource(ComponentWithoutMesh);
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilEditorActorSubsystemBoundsTest, "DirectiveUtilities.EditorActorSubsystemBoundsTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilEditorActorSubsystemBoundsTest::RunTest(const FString& Parameters)

View File

@@ -0,0 +1,65 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#if WITH_EDITOR
#include "AssetRegistry/IAssetRegistry.h"
#include "Libraries/DirectiveUtilEditorAssetAuditLibrary.h"
#include "Misc/AutomationTest.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilEditorAssetAuditLibraryTest,
"DirectiveUtilities.EditorAssetAuditLibraryTests",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilEditorAssetAuditLibraryTest::RunTest(const FString& Parameters)
{
FDirectiveUtilAssetAuditOptions Options;
Options.PackagePaths = {TEXT("/Engine/BasicShapes")};
const FDirectiveUtilAssetAuditReport Report = UDirectiveUtilEditorAssetAuditLibrary::BuildAssetAuditReport(Options);
TestTrue(TEXT("The engine shape scan returns assets"), !Report.Assets.IsEmpty());
if (const IAssetRegistry* AssetRegistry = IAssetRegistry::Get())
{
TestTrue(TEXT("An asset audit starts the initial registry scan"), AssetRegistry->IsSearchAllAssets());
}
for (const FDirectiveUtilAssetAuditEntry& Entry : Report.Assets)
{
TestTrue(TEXT("Report assets have valid data"), Entry.Asset.IsValid());
TestTrue(TEXT("Report assets have a package"), !Entry.PackageName.IsNone());
TestTrue(TEXT("Report assets have a class"), !Entry.AssetClass.IsEmpty());
TestTrue(TEXT("Report counts are non-negative"), Entry.DependencyCount >= 0 && Entry.ReferencerCount >= 0);
}
const FString Csv = UDirectiveUtilEditorAssetAuditLibrary::AssetAuditReportToCsv(Report);
TestTrue(TEXT("CSV includes its header"), Csv.StartsWith(TEXT("Asset,Package,Path,Class")));
TestTrue(TEXT("CSV includes scanned assets"), Csv.Contains(TEXT("/Engine/BasicShapes")));
Options.ExcludedPackagePaths = {TEXT("/Engine/BasicShapes")};
TestTrue(
TEXT("Excluded paths return no candidates"),
UDirectiveUtilEditorAssetAuditLibrary::FindUnreferencedAssetCandidates(Options).IsEmpty());
TestTrue(
TEXT("Excluded paths return no missing references"),
UDirectiveUtilEditorAssetAuditLibrary::FindMissingAssetReferences(Options).IsEmpty());
TestTrue(
TEXT("Excluded paths return no cycles"),
UDirectiveUtilEditorAssetAuditLibrary::FindAssetDependencyCycles(Options).IsEmpty());
FDirectiveUtilAssetAuditOptions EngineOptions;
EngineOptions.PackagePaths = {TEXT("/Engine")};
const FDirectiveUtilAssetAuditReport EngineReport = UDirectiveUtilEditorAssetAuditLibrary::BuildAssetAuditReport(EngineOptions);
TestTrue(TEXT("A full engine audit returns more than the basic shapes scan"), EngineReport.Assets.Num() > Report.Assets.Num());
for (const FDirectiveUtilAssetDependencyCycle& Cycle : EngineReport.DependencyCycles)
{
TestTrue(TEXT("Dependency cycles contain at least one package"), !Cycle.Packages.IsEmpty());
for (int32 Index = 1; Index < Cycle.Packages.Num(); ++Index)
{
TestTrue(TEXT("Dependency cycle packages are sorted"), Cycle.Packages[Index - 1].LexicalLess(Cycle.Packages[Index]));
}
}
return true;
}
#endif

View File

@@ -1,8 +1,18 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#if WITH_EDITOR
#include "AssetRegistry/AssetData.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "Editor.h"
#include "Libraries/DirectiveUtilEditorAssetLibrary.h"
#include "Engine/World.h"
#include "Misc/AutomationTest.h"
#include "Misc/Guid.h"
#include "ObjectTools.h"
#include "Subsystems/EditorAssetSubsystem.h"
#include "Tests/DirectiveUtilTestObject.h"
#include "UObject/ObjectRedirector.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilEditorAssetLibraryTest, "DirectiveUtilities.EditorAssetLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
@@ -45,6 +55,78 @@ bool FDirectiveUtilEditorAssetLibraryTest::RunTest(const FString& Parameters)
TestEqual("FindDuplicateAssets should return each asset path once", UniquePaths.Num(), Pair.Value.DuplicateAssetPaths.Num());
}
int32 RedirectorsProcessed = INDEX_NONE;
TestEqual("FixUpRedirectorsInPaths succeeds when no redirectors match",
UDirectiveUtilEditorAssetLibrary::FixUpRedirectorsInPaths(
{TEXT("/Game/DirectiveUtilitiesTests/NoRedirectors")}, RedirectorsProcessed),
EDirectiveUtilSuccessStatus::Success);
TestEqual("No redirectors are reported for an empty path", RedirectorsProcessed, 0);
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilFixUpRedirectorsTest, "DirectiveUtilities.FixUpRedirectorsTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilFixUpRedirectorsTest::RunTest(const FString& Parameters)
{
UEditorAssetSubsystem* EditorAssetSubsystem = GEditor
? GEditor->GetEditorSubsystem<UEditorAssetSubsystem>()
: nullptr;
if (!EditorAssetSubsystem)
{
AddError(TEXT("Editor asset subsystem unavailable for the redirector test."));
return false;
}
const FString RootPath = FString::Printf(
TEXT("/Game/DirectiveUtilitiesTests/Redirectors_%s"),
*FGuid::NewGuid().ToString(EGuidFormats::Digits));
const FString OriginalName = TEXT("OriginalAsset");
const FString OriginalPackageName = RootPath / OriginalName;
UPackage* Package = CreatePackage(*OriginalPackageName);
UDirectiveUtilTestObject* Asset = NewObject<UDirectiveUtilTestObject>(
Package, *OriginalName, RF_Public | RF_Standalone);
FAssetRegistryModule::AssetCreated(Asset);
const FString RenamedAssetName = TEXT("RenamedAsset");
ObjectTools::FPackageGroupName PackageGroupName;
PackageGroupName.PackageName = RootPath / RenamedAssetName;
PackageGroupName.ObjectName = RenamedAssetName;
TSet<UPackage*> RefusedPackages;
FText RenameError;
if (!ObjectTools::RenameSingleObject(
Asset, PackageGroupName, RefusedPackages, RenameError, nullptr, true))
{
EditorAssetSubsystem->DeleteDirectory(RootPath);
AddError(FString::Printf(TEXT("Failed to rename the redirector test asset: %s"), *RenameError.ToString()));
return false;
}
const FString OriginalObjectPath = OriginalPackageName + TEXT(".") + OriginalName;
UObjectRedirector* Redirector = FindObject<UObjectRedirector>(nullptr, *OriginalObjectPath);
if (!Redirector)
{
EditorAssetSubsystem->DeleteDirectory(RootPath);
AddError(TEXT("Renaming the test asset did not create a redirector."));
return false;
}
TestNotNull("Renaming an asset creates a redirector", Redirector);
AddExpectedMessagePlain(
TEXT("Redirector fix-up requires an interactive editor session."),
ELogVerbosity::Warning,
EAutomationExpectedMessageFlags::Contains,
1);
int32 RedirectorsProcessed = INDEX_NONE;
const EDirectiveUtilSuccessStatus Status = UDirectiveUtilEditorAssetLibrary::FixUpRedirectorsInPaths(
{RootPath}, RedirectorsProcessed);
TestEqual("Redirector fix-up fails closed in unattended runs", Status, EDirectiveUtilSuccessStatus::Failure);
TestEqual("No redirector is reported as processed after a guarded run", RedirectorsProcessed, 0);
TestTrue("Guarded fix-up leaves the redirector intact", EditorAssetSubsystem->DoesAssetExist(OriginalObjectPath));
TestTrue("Renamed asset remains available", EditorAssetSubsystem->DoesAssetExist(
RootPath / RenamedAssetName + TEXT(".") + RenamedAssetName));
TestTrue("Redirector test assets are removed", EditorAssetSubsystem->DeleteDirectory(RootPath));
return true;
}

View File

@@ -0,0 +1,191 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#if WITH_EDITOR
#include "AssetRegistry/AssetRegistryModule.h"
#include "Components/StaticMeshComponent.h"
#include "Libraries/DirectiveUtilEditorBlueprintLibrary.h"
#include "Misc/AutomationTest.h"
#include "Tests/DirectiveUtilTestObject.h"
#include "EdGraphSchema_K2.h"
#include "Engine/Blueprint.h"
#include "Engine/SCS_Node.h"
#include "Engine/SimpleConstructionScript.h"
#include "GameFramework/Actor.h"
#include "Kismet2/BlueprintEditorUtils.h"
#include "Kismet2/KismetEditorUtilities.h"
#include "Misc/Guid.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilEditorBlueprintLibraryTest,
"DirectiveUtilities.EditorBlueprintLibraryTests",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilEditorBlueprintLibraryTest::RunTest(const FString& Parameters)
{
auto ContainsBlueprint = [](const TArray<FAssetData>& Assets, const UBlueprint* Expected) {
return Assets.ContainsByPredicate([Expected](const FAssetData& Asset) {
return Asset.GetAsset() == Expected;
});
};
TestEqual(
TEXT("A null Blueprint has unknown status"),
UDirectiveUtilEditorBlueprintLibrary::GetBlueprintCompileStatus(nullptr),
EDirectiveUtilBlueprintCompileStatus::Unknown);
UBlueprint* Blueprint = NewObject<UBlueprint>();
const TArray<TPair<EBlueprintStatus, EDirectiveUtilBlueprintCompileStatus>> Statuses = {
{BS_Unknown, EDirectiveUtilBlueprintCompileStatus::Unknown},
{BS_Dirty, EDirectiveUtilBlueprintCompileStatus::Dirty},
{BS_Error, EDirectiveUtilBlueprintCompileStatus::Error},
{BS_UpToDate, EDirectiveUtilBlueprintCompileStatus::UpToDate},
{BS_BeingCreated, EDirectiveUtilBlueprintCompileStatus::BeingCreated},
{BS_UpToDateWithWarnings, EDirectiveUtilBlueprintCompileStatus::UpToDateWithWarnings},
};
for (const TPair<EBlueprintStatus, EDirectiveUtilBlueprintCompileStatus>& Status : Statuses)
{
Blueprint->Status = Status.Key;
TestEqual(
TEXT("Blueprint status maps to the public enum"),
UDirectiveUtilEditorBlueprintLibrary::GetBlueprintCompileStatus(Blueprint),
Status.Value);
}
FDirectiveUtilBlueprintSearchOptions Options;
Options.PackagePaths = {TEXT("/Engine/BasicShapes")};
TestTrue(
TEXT("A folder without Blueprints has no compile-status matches"),
UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsByCompileStatus(
EDirectiveUtilBlueprintCompileStatus::UpToDate,
Options).IsEmpty());
TestTrue(
TEXT("A null parent class returns no matches"),
UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsByParentClass(nullptr, Options).IsEmpty());
TestTrue(
TEXT("A folder without Blueprints has no parent-class matches"),
UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsByParentClass(AActor::StaticClass(), Options).IsEmpty());
TestTrue(
TEXT("A non-interface class returns no interface matches"),
UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsImplementingInterface(UObject::StaticClass(), Options).IsEmpty());
TestTrue(
TEXT("A folder without Blueprints has no interface matches"),
UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsImplementingInterface(UInterface::StaticClass(), Options).IsEmpty());
TestTrue(
TEXT("A non-component class returns no component matches"),
UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsContainingComponentClass(UObject::StaticClass(), Options).IsEmpty());
TestTrue(
TEXT("A folder without Blueprints has no component matches"),
UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsContainingComponentClass(UActorComponent::StaticClass(), Options).IsEmpty());
TestTrue(
TEXT("A Blueprint without generated properties has no unused variables"),
UDirectiveUtilEditorBlueprintLibrary::GetUnusedBlueprintVariables(Blueprint).IsEmpty());
const FName FixtureName(*FString::Printf(
TEXT("BP_InspectionFixture_%s"),
*FGuid::NewGuid().ToString(EGuidFormats::Digits)));
const FString FixturePackageName = FString::Printf(
TEXT("/Game/DirectiveUtilitiesTests/%s"),
*FixtureName.ToString());
UPackage* TestPackage = CreatePackage(*FixturePackageName);
UBlueprint* InspectionBlueprint = FKismetEditorUtilities::CreateBlueprint(
AActor::StaticClass(),
TestPackage,
FixtureName,
BPTYPE_Normal,
TEXT("DirectiveUtilities.EditorBlueprintLibraryTests"));
if (!TestNotNull(TEXT("The inspection fixture Blueprint is created"), InspectionBlueprint))
{
return false;
}
TestTrue(
TEXT("The inspection fixture implements its test interface"),
FBlueprintEditorUtils::ImplementNewInterface(
InspectionBlueprint,
UDirectiveUtilTestInterface::StaticClass()->GetClassPathName()));
USCS_Node* ComponentNode = InspectionBlueprint->SimpleConstructionScript->CreateNode(
UStaticMeshComponent::StaticClass(),
TEXT("InspectionComponent"));
InspectionBlueprint->SimpleConstructionScript->AddNode(ComponentNode);
FEdGraphPinType VariableType;
VariableType.PinCategory = UEdGraphSchema_K2::PC_Int;
TestTrue(
TEXT("The inspection fixture adds an unused variable"),
FBlueprintEditorUtils::AddMemberVariable(InspectionBlueprint, TEXT("UnusedValue"), VariableType));
FBlueprintEditorUtils::SetBlueprintVariableMetaData(
InspectionBlueprint,
TEXT("UnusedValue"),
nullptr,
FBlueprintMetadata::MD_Private,
TEXT("true"));
FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(InspectionBlueprint);
FKismetEditorUtilities::CompileBlueprint(InspectionBlueprint);
FAssetRegistryModule::AssetCreated(InspectionBlueprint);
FDirectiveUtilBlueprintSearchOptions FixtureOptions;
FixtureOptions.PackagePaths = {TEXT("/Game/DirectiveUtilitiesTests")};
TestTrue(
TEXT("Compile-status search finds the fixture"),
ContainsBlueprint(
UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsByCompileStatus(
EDirectiveUtilBlueprintCompileStatus::UpToDate,
FixtureOptions),
InspectionBlueprint));
TestTrue(
TEXT("Direct-parent search finds the fixture"),
ContainsBlueprint(
UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsByParentClass(
AActor::StaticClass(),
FixtureOptions,
false),
InspectionBlueprint));
TestTrue(
TEXT("Descendant-parent search finds the fixture"),
ContainsBlueprint(
UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsByParentClass(
AActor::StaticClass(),
FixtureOptions,
true),
InspectionBlueprint));
TestTrue(
TEXT("Interface search finds the fixture"),
ContainsBlueprint(
UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsImplementingInterface(
UDirectiveUtilTestInterface::StaticClass(),
FixtureOptions),
InspectionBlueprint));
TestTrue(
TEXT("Exact component search finds the fixture"),
ContainsBlueprint(
UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsContainingComponentClass(
UStaticMeshComponent::StaticClass(),
FixtureOptions,
false),
InspectionBlueprint));
TestTrue(
TEXT("Derived component search finds the fixture"),
ContainsBlueprint(
UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsContainingComponentClass(
UActorComponent::StaticClass(),
FixtureOptions,
true),
InspectionBlueprint));
TestTrue(
TEXT("Unused-variable search finds the fixture variable"),
UDirectiveUtilEditorBlueprintLibrary::GetUnusedBlueprintVariables(InspectionBlueprint).Contains(TEXT("UnusedValue")));
FixtureOptions.ExcludedPackagePaths = {TEXT("/Game/DirectiveUtilitiesTests")};
TestTrue(
TEXT("Excluded paths remove the fixture from search"),
UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsByParentClass(
AActor::StaticClass(),
FixtureOptions).IsEmpty());
FAssetRegistryModule::AssetDeleted(InspectionBlueprint);
TestPackage->SetDirtyFlag(false);
return true;
}
#endif

View File

@@ -0,0 +1,98 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilEditorTaskLibrary.h"
#include "Misc/AutomationTest.h"
#include "Tasks/DirectiveUtilEditorSlowTask.h"
#include <limits>
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilEditorTaskLibraryTest,
"DirectiveUtilities.EditorTaskLibraryTests",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilEditorTaskLibraryTest::RunTest(const FString& Parameters)
{
TestNull("StartEditorSlowTask should reject zero work", UDirectiveUtilEditorTaskLibrary::StartEditorSlowTask(0.0f, FText::GetEmpty()));
TestNull(
"StartEditorSlowTask should reject NaN work",
UDirectiveUtilEditorTaskLibrary::StartEditorSlowTask(
std::numeric_limits<float>::quiet_NaN(),
FText::GetEmpty()));
TestNull(
"StartEditorSlowTask should reject infinite work",
UDirectiveUtilEditorTaskLibrary::StartEditorSlowTask(
std::numeric_limits<float>::infinity(),
FText::GetEmpty()));
UDirectiveUtilEditorSlowTask* PublicTask = UDirectiveUtilEditorTaskLibrary::StartEditorSlowTask(
1.0f,
FText::FromString(TEXT("Public task")));
TestNotNull("StartEditorSlowTask should create a task for positive work", PublicTask);
if (PublicTask)
{
TestNull(
"StartEditorSlowTask should reject a nested task",
UDirectiveUtilEditorTaskLibrary::StartEditorSlowTask(
1.0f,
FText::FromString(TEXT("Nested task"))));
PublicTask->Finish();
}
UDirectiveUtilEditorSlowTask* Task = NewObject<UDirectiveUtilEditorSlowTask>();
Task->Initialize(2.0f, FText::FromString(TEXT("Test task")), false, false);
TestTrue("Slow task should be active after initialization", Task->IsActive());
TestTrue("Advance should accept non-negative work", Task->Advance(1.0f, FText::FromString(TEXT("Step"))));
TestFalse("Advance should reject negative work", Task->Advance(-1.0f, FText::GetEmpty()));
TestFalse("Advance should reject NaN work", Task->Advance(std::numeric_limits<float>::quiet_NaN(), FText::GetEmpty()));
TestFalse("Advance should reject infinite work", Task->Advance(std::numeric_limits<float>::infinity(), FText::GetEmpty()));
TestFalse("A non-cancellable task should not report cancellation", Task->IsCancelRequested());
Task->Finish();
TestFalse("Slow task should be inactive after Finish", Task->IsActive());
TestFalse("Advance should fail after Finish", Task->Advance(1.0f, FText::GetEmpty()));
Task->Initialize(std::numeric_limits<float>::quiet_NaN(), FText::GetEmpty(), false, false);
TestFalse("Initialize should leave a task inactive for NaN work", Task->IsActive());
Task->Initialize(std::numeric_limits<float>::infinity(), FText::GetEmpty(), false, false);
TestFalse("Initialize should leave a task inactive for infinite work", Task->IsActive());
UDirectiveUtilEditorSlowTask* FirstTask = NewObject<UDirectiveUtilEditorSlowTask>();
UDirectiveUtilEditorSlowTask* SecondTask = NewObject<UDirectiveUtilEditorSlowTask>();
FirstTask->Initialize(1.0f, FText::FromString(TEXT("First task")), false, false);
SecondTask->Initialize(1.0f, FText::FromString(TEXT("Second task")), false, false);
TestTrue("The first slow task should remain active", FirstTask->IsActive());
TestFalse("A concurrent slow task should remain inactive", SecondTask->IsActive());
FirstTask->Finish();
SecondTask->Initialize(1.0f, FText::FromString(TEXT("Second task")), false, false);
TestTrue("A slow task should start after the active task finishes", SecondTask->IsActive());
SecondTask->Finish();
TWeakObjectPtr<UDirectiveUtilEditorSlowTask> AbandonedTask = NewObject<UDirectiveUtilEditorSlowTask>();
AbandonedTask->Initialize(1.0f, FText::FromString(TEXT("Abandoned task")), false, false);
TestTrue("An unfinished slow task should be active before collection", AbandonedTask->IsActive());
CollectGarbage(RF_NoFlags);
TestTrue("An unfinished slow task should stay alive until Finish", AbandonedTask.IsValid());
if (AbandonedTask.IsValid())
{
AbandonedTask->Finish();
}
CollectGarbage(RF_NoFlags);
TestFalse("A finished slow task should be collectable", AbandonedTask.IsValid());
UDirectiveUtilEditorSlowTask* TaskAfterCollection = NewObject<UDirectiveUtilEditorSlowTask>();
TaskAfterCollection->Initialize(1.0f, FText::FromString(TEXT("Task after collection")), false, false);
TestTrue("A slow task should start after the previous task is collected", TaskAfterCollection->IsActive());
TaskAfterCollection->Finish();
TestTrue(
"ShowEditorNotification should create a notification while Slate is active",
UDirectiveUtilEditorTaskLibrary::ShowEditorNotification(
FText::FromString(TEXT("Directive Utilities test")),
EDirectiveUtilEditorNotificationState::Success,
0.01f));
TestTrue(
"ShowEditorNotification should normalize a non-finite duration",
UDirectiveUtilEditorTaskLibrary::ShowEditorNotification(
FText::FromString(TEXT("Directive Utilities duration test")),
EDirectiveUtilEditorNotificationState::Neutral,
std::numeric_limits<float>::quiet_NaN()));
return true;
}

View File

@@ -1,41 +1,244 @@
#include "Libraries/DirectiveUtilFunctionLibrary.h"
#include "Tests/AutomationCommon.h"
#include "Misc/AutomationTest.h"
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilFunctionLibraryTest, "DirectiveUtilities.FunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
#include "Libraries/DirectiveUtilFunctionLibrary.h"
#include "Async/Async.h"
#include "Engine/World.h"
#include "HAL/PlatformProcess.h"
#include "Misc/AutomationTest.h"
#include "Misc/App.h"
#include "UObject/Package.h"
namespace DirectiveUtilFunctionLibraryTest
{
EDirectiveUtilBuildConfiguration GetExpectedBuildConfiguration(const EBuildConfiguration BuildConfiguration)
{
switch (BuildConfiguration)
{
case EBuildConfiguration::Unknown:
return EDirectiveUtilBuildConfiguration::Unknown;
case EBuildConfiguration::Debug:
return EDirectiveUtilBuildConfiguration::Debug;
case EBuildConfiguration::DebugGame:
return EDirectiveUtilBuildConfiguration::DebugGame;
case EBuildConfiguration::Development:
return EDirectiveUtilBuildConfiguration::Development;
case EBuildConfiguration::Shipping:
return EDirectiveUtilBuildConfiguration::Shipping;
case EBuildConfiguration::Test:
return EDirectiveUtilBuildConfiguration::Test;
}
return EDirectiveUtilBuildConfiguration::Unknown;
}
EDirectiveUtilBuildTargetType GetExpectedBuildTargetType(const EBuildTargetType BuildTargetType)
{
switch (BuildTargetType)
{
case EBuildTargetType::Unknown:
return EDirectiveUtilBuildTargetType::Unknown;
case EBuildTargetType::Game:
return EDirectiveUtilBuildTargetType::Game;
case EBuildTargetType::Server:
return EDirectiveUtilBuildTargetType::Server;
case EBuildTargetType::Client:
return EDirectiveUtilBuildTargetType::Client;
case EBuildTargetType::Editor:
return EDirectiveUtilBuildTargetType::Editor;
case EBuildTargetType::Program:
return EDirectiveUtilBuildTargetType::Program;
}
return EDirectiveUtilBuildTargetType::Unknown;
}
enum class EClipboardStage : uint8
{
CopyText,
CheckText,
CheckString,
CheckClear,
Complete
};
class FClipboardRoundTripCommand : public IAutomationLatentCommand
{
public:
FClipboardRoundTripCommand(FAutomationTestBase* InTest, FString InOriginalClipboard)
: Test(InTest)
, OriginalClipboard(MoveTemp(InOriginalClipboard))
{
}
virtual bool Update() override
{
switch (Stage)
{
case EClipboardStage::CopyText:
UDirectiveUtilFunctionLibrary::CopyTextToClipboard(FText::FromString(TextPayload));
BeginRetryWindow(EClipboardStage::CheckText);
return false;
case EClipboardStage::CheckText:
if (!HasExpectedValue(UDirectiveUtilFunctionLibrary::GetTextFromClipboard().ToString(), TextPayload, TEXT("GetTextFromClipboard should return the copied text")))
{
return false;
}
UDirectiveUtilFunctionLibrary::CopyStringToClipboard(StringPayload);
BeginRetryWindow(EClipboardStage::CheckString);
return false;
case EClipboardStage::CheckString:
if (!HasExpectedValue(UDirectiveUtilFunctionLibrary::GetStringFromClipboard(), StringPayload, TEXT("GetStringFromClipboard should return the copied string")))
{
return false;
}
UDirectiveUtilFunctionLibrary::ClearClipboard();
BeginRetryWindow(EClipboardStage::CheckClear);
return false;
case EClipboardStage::CheckClear:
if (!HasExpectedValue(UDirectiveUtilFunctionLibrary::GetStringFromClipboard(), FString(), TEXT("GetStringFromClipboard should return an empty string after clearing the clipboard")))
{
return false;
}
UDirectiveUtilFunctionLibrary::CopyStringToClipboard(OriginalClipboard);
Stage = EClipboardStage::Complete;
return true;
case EClipboardStage::Complete:
return true;
}
return true;
}
private:
void BeginRetryWindow(EClipboardStage NextStage)
{
Stage = NextStage;
FramesRemaining = 120;
}
bool HasExpectedValue(const FString& Actual, const FString& Expected, const TCHAR* FailureMessage)
{
if (Actual == Expected)
{
return true;
}
if (--FramesRemaining > 0)
{
return false;
}
Test->TestEqual(FailureMessage, Actual, Expected);
return true;
}
FAutomationTestBase* Test;
FString OriginalClipboard;
EClipboardStage Stage = EClipboardStage::CopyText;
int32 FramesRemaining = 0;
const FString TextPayload = TEXT("Directive Utilities Text Clipboard");
const FString StringPayload = TEXT("Directive Utilities String Clipboard");
};
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilFunctionLibraryTest, "DirectiveUtilities.FunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilFunctionLibraryTest::RunTest(const FString& Parameters)
{
// Preserve the user/CI clipboard so this test doesn't destroy it, and restore it before returning.
const FString OriginalClipboard = UDirectiveUtilFunctionLibrary::GetStringFromClipboard();
const FText TestText = FText::FromString(TEXT("Hello, Clipboard!"));
UDirectiveUtilFunctionLibrary::CopyTextToClipboard(TestText);
const FText ClipboardText = UDirectiveUtilFunctionLibrary::GetTextFromClipboard();
TestEqual("GetTextFromClipboard should return the copied text", ClipboardText.ToString(), TestText.ToString());
ADD_LATENT_AUTOMATION_COMMAND(FEngineWaitLatentCommand(1.0f));
const FString TestString = TEXT("Hello, Clipboard!");
UDirectiveUtilFunctionLibrary::CopyStringToClipboard(TestString);
const FString ClipboardString = UDirectiveUtilFunctionLibrary::GetStringFromClipboard();
TestEqual("GetStringFromClipboard should return the copied string", ClipboardString, TestString);
ADD_LATENT_AUTOMATION_COMMAND(FEngineWaitLatentCommand(1.0f));
UDirectiveUtilFunctionLibrary::ClearClipboard();
const FString ClearedClipboardString = UDirectiveUtilFunctionLibrary::GetStringFromClipboard();
TestEqual("GetStringFromClipboard should return an empty string after clearing the clipboard", ClearedClipboardString, TEXT(""));
ADD_LATENT_AUTOMATION_COMMAND(DirectiveUtilFunctionLibraryTest::FClipboardRoundTripCommand(this, OriginalClipboard));
const FString ProjectVersion = UDirectiveUtilFunctionLibrary::GetProjectVersion();
TestNotEqual("GetProjectVersion should return a non-empty string", ProjectVersion, FString(""));
TestTrue("IsRunningInEditor should return true in editor context",
UDirectiveUtilFunctionLibrary::IsRunningInEditor());
#if WITH_EDITOR
constexpr bool bExpectedEditorContext = true;
#else
constexpr bool bExpectedEditorContext = false;
#endif
TestEqual(
"IsRunningInEditor should match the target context",
UDirectiveUtilFunctionLibrary::IsRunningInEditor(),
bExpectedEditorContext);
// Pure pass-through to the engine's GetDerivedClasses (which appends to the output array).
struct FWorldTypeCase
{
EWorldType::Type WorldType;
EDirectiveUtilWorldType Expected;
};
const FWorldTypeCase WorldTypeCases[] = {
{ EWorldType::None, EDirectiveUtilWorldType::None },
{ EWorldType::Game, EDirectiveUtilWorldType::Game },
{ EWorldType::Editor, EDirectiveUtilWorldType::Editor },
{ EWorldType::PIE, EDirectiveUtilWorldType::PlayInEditor },
{ EWorldType::EditorPreview, EDirectiveUtilWorldType::EditorPreview },
{ EWorldType::GamePreview, EDirectiveUtilWorldType::GamePreview },
{ EWorldType::GameRPC, EDirectiveUtilWorldType::GameRPC },
{ EWorldType::Inactive, EDirectiveUtilWorldType::Inactive }
};
UWorld* TestWorld = NewObject<UWorld>();
TestNotNull("A transient world should be created for world type tests", TestWorld);
if (TestWorld)
{
for (const FWorldTypeCase& TestCase : WorldTypeCases)
{
TestWorld->WorldType = TestCase.WorldType;
TestEqual(
FString::Printf(TEXT("GetWorldType should map %s"), LexToString(TestCase.WorldType)),
UDirectiveUtilFunctionLibrary::GetWorldType(TestWorld),
TestCase.Expected);
}
TestWorld->WorldType = static_cast<EWorldType::Type>(MAX_uint8);
TestEqual(
"GetWorldType should reject unsupported world types",
UDirectiveUtilFunctionLibrary::GetWorldType(TestWorld),
EDirectiveUtilWorldType::Unknown);
}
TestEqual(
"GetWorldType should return Unknown for a null context",
UDirectiveUtilFunctionLibrary::GetWorldType(nullptr),
EDirectiveUtilWorldType::Unknown);
TestEqual(
"GetWorldType should return Unknown when the context has no world",
UDirectiveUtilFunctionLibrary::GetWorldType(GetTransientPackage()),
EDirectiveUtilWorldType::Unknown);
TestEqual(
"GetBuildConfigurationType should match the application build configuration",
UDirectiveUtilFunctionLibrary::GetBuildConfigurationType(),
DirectiveUtilFunctionLibraryTest::GetExpectedBuildConfiguration(FApp::GetBuildConfiguration()));
TestEqual(
"GetBuildTargetType should match the application build target",
UDirectiveUtilFunctionLibrary::GetBuildTargetType(),
DirectiveUtilFunctionLibraryTest::GetExpectedBuildTargetType(FApp::GetBuildTargetType()));
TestNotNull("The world type enum should be reflected", StaticEnum<EDirectiveUtilWorldType>());
TestNotNull("The build configuration enum should be reflected", StaticEnum<EDirectiveUtilBuildConfiguration>());
TestNotNull("The build target type enum should be reflected", StaticEnum<EDirectiveUtilBuildTargetType>());
const UFunction* GetWorldTypeFunction = UDirectiveUtilFunctionLibrary::StaticClass()->FindFunctionByName(
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilFunctionLibrary, GetWorldType));
TestNotNull("GetWorldType should be reflected", GetWorldTypeFunction);
if (GetWorldTypeFunction)
{
TestTrue("GetWorldType should be Blueprint pure", GetWorldTypeFunction->HasAnyFunctionFlags(FUNC_BlueprintPure));
TestFalse("GetWorldType should be available at runtime", GetWorldTypeFunction->HasAnyFunctionFlags(FUNC_EditorOnly));
#if WITH_EDITOR
TestEqual(
"GetWorldType should use its input as the Blueprint world context",
GetWorldTypeFunction->GetMetaData(TEXT("WorldContext")),
FString(TEXT("WorldContextObject")));
#endif
}
// Happy path (recursive): the running module guarantees a stable hierarchy to query.
TArray<UClass*> RecursiveDerived;
UDirectiveUtilFunctionLibrary::GetChildClasses(UBlueprintFunctionLibrary::StaticClass(), true, RecursiveDerived);
TestTrue("GetChildClasses should return a non-empty list for a base class with subclasses",
@@ -45,19 +248,121 @@ bool FDirectiveUtilFunctionLibraryTest::RunTest(const FString& Parameters)
TestFalse("GetChildClasses should not include the base class itself",
RecursiveDerived.Contains(UBlueprintFunctionLibrary::StaticClass()));
// Non-recursive results must not exceed the recursive results for the same base.
double ElapsedMilliseconds = 123.0;
TestFalse(
"StartStopwatch should reject NAME_None",
UDirectiveUtilFunctionLibrary::StartStopwatch(NAME_None));
TestFalse(
"StopStopwatch should reject NAME_None",
UDirectiveUtilFunctionLibrary::StopStopwatch(NAME_None, ElapsedMilliseconds));
TestEqual("StopStopwatch should reset elapsed time for NAME_None", ElapsedMilliseconds, 0.0);
const FName MissingStopwatchKey(TEXT("DirectiveUtilMissingStopwatch"));
ElapsedMilliseconds = 123.0;
TestFalse(
"StopStopwatch should reject a missing key",
UDirectiveUtilFunctionLibrary::StopStopwatch(MissingStopwatchKey, ElapsedMilliseconds));
TestEqual("StopStopwatch should reset elapsed time for a missing key", ElapsedMilliseconds, 0.0);
const FName BasicStopwatchKey(TEXT("DirectiveUtilBasicStopwatch"));
TestTrue(
"StartStopwatch should start an unused key",
UDirectiveUtilFunctionLibrary::StartStopwatch(BasicStopwatchKey));
TestFalse(
"StartStopwatch should preserve an active key by default",
UDirectiveUtilFunctionLibrary::StartStopwatch(BasicStopwatchKey));
TestTrue(
"StopStopwatch should stop an active key",
UDirectiveUtilFunctionLibrary::StopStopwatch(BasicStopwatchKey, ElapsedMilliseconds));
TestTrue("StopStopwatch should return a non-negative duration", ElapsedMilliseconds >= 0.0);
TestFalse(
"StopStopwatch should consume the active key",
UDirectiveUtilFunctionLibrary::StopStopwatch(BasicStopwatchKey, ElapsedMilliseconds));
const FName RestartStopwatchKey(TEXT("DirectiveUtilRestartStopwatch"));
TestTrue(
"StartStopwatch should start the restart test key",
UDirectiveUtilFunctionLibrary::StartStopwatch(RestartStopwatchKey));
TestTrue(
"StartStopwatch should replace an active key when requested",
UDirectiveUtilFunctionLibrary::StartStopwatch(RestartStopwatchKey, true));
TestTrue(
"StopStopwatch should stop a restarted key",
UDirectiveUtilFunctionLibrary::StopStopwatch(RestartStopwatchKey, ElapsedMilliseconds));
const FName FirstOverlapKey(TEXT("DirectiveUtilFirstOverlapStopwatch"));
const FName SecondOverlapKey(TEXT("DirectiveUtilSecondOverlapStopwatch"));
TestTrue(
"StartStopwatch should start the first overlapping key",
UDirectiveUtilFunctionLibrary::StartStopwatch(FirstOverlapKey));
TestTrue(
"StartStopwatch should start the second overlapping key",
UDirectiveUtilFunctionLibrary::StartStopwatch(SecondOverlapKey));
TestTrue(
"StopStopwatch should stop the first overlapping key out of order",
UDirectiveUtilFunctionLibrary::StopStopwatch(FirstOverlapKey, ElapsedMilliseconds));
TestTrue(
"StopStopwatch should stop the second overlapping key",
UDirectiveUtilFunctionLibrary::StopStopwatch(SecondOverlapKey, ElapsedMilliseconds));
const FName TimedStopwatchKey(TEXT("DirectiveUtilTimedStopwatch"));
TestTrue(
"StartStopwatch should start the elapsed-time test key",
UDirectiveUtilFunctionLibrary::StartStopwatch(TimedStopwatchKey));
FPlatformProcess::SleepNoStats(0.01f);
TestTrue(
"StopStopwatch should stop the elapsed-time test key",
UDirectiveUtilFunctionLibrary::StopStopwatch(TimedStopwatchKey, ElapsedMilliseconds));
TestTrue("StopStopwatch should measure elapsed real time in milliseconds", ElapsedMilliseconds >= 5.0);
const FName CrossThreadStopwatchKey(TEXT("DirectiveUtilCrossThreadStopwatch"));
TestTrue(
"StartStopwatch should start a key that will stop on another thread",
UDirectiveUtilFunctionLibrary::StartStopwatch(CrossThreadStopwatchKey));
double CrossThreadElapsedMilliseconds = 0.0;
TFuture<bool> CrossThreadStop = Async(EAsyncExecution::ThreadPool, [&CrossThreadElapsedMilliseconds, CrossThreadStopwatchKey]()
{
return UDirectiveUtilFunctionLibrary::StopStopwatch(
CrossThreadStopwatchKey,
CrossThreadElapsedMilliseconds);
});
TestTrue("StopStopwatch should find a key started on another thread", CrossThreadStop.Get());
TestTrue("Cross-thread stopwatch duration should be non-negative", CrossThreadElapsedMilliseconds >= 0.0);
const UFunction* StartStopwatchFunction = UDirectiveUtilFunctionLibrary::StaticClass()->FindFunctionByName(
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilFunctionLibrary, StartStopwatch));
const UFunction* StopStopwatchFunction = UDirectiveUtilFunctionLibrary::StaticClass()->FindFunctionByName(
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilFunctionLibrary, StopStopwatch));
TestNotNull("StartStopwatch should be reflected", StartStopwatchFunction);
TestNotNull("StopStopwatch should be reflected", StopStopwatchFunction);
for (const UFunction* StopwatchFunction : { StartStopwatchFunction, StopStopwatchFunction })
{
if (!StopwatchFunction)
{
continue;
}
TestTrue("Stopwatch functions should be Blueprint callable", StopwatchFunction->HasAnyFunctionFlags(FUNC_BlueprintCallable));
TestFalse("Stopwatch functions should not be Blueprint pure", StopwatchFunction->HasAnyFunctionFlags(FUNC_BlueprintPure));
TestFalse("Stopwatch functions should be available at runtime", StopwatchFunction->HasAnyFunctionFlags(FUNC_EditorOnly));
#if WITH_EDITOR
TestEqual(
"Stopwatch functions should use the profiling category",
StopwatchFunction->GetMetaData(TEXT("Category")),
FString(TEXT("Directive Utilities|Utility|Profiling")));
#endif
}
TArray<UClass*> NonRecursiveDerived;
UDirectiveUtilFunctionLibrary::GetChildClasses(UBlueprintFunctionLibrary::StaticClass(), false, NonRecursiveDerived);
TestTrue("GetChildClasses non-recursive count should not exceed recursive count",
NonRecursiveDerived.Num() <= RecursiveDerived.Num());
// Leaf class with no subclasses: an empty input array should remain empty.
TArray<UClass*> LeafDerived;
UDirectiveUtilFunctionLibrary::GetChildClasses(UDirectiveUtilFunctionLibrary::StaticClass(), true, LeafDerived);
TestEqual("GetChildClasses should return an empty list for a class with no subclasses",
LeafDerived.Num(), 0);
// Null base class: should not crash and should add nothing.
TArray<UClass*> NullBaseDerived;
UDirectiveUtilFunctionLibrary::GetChildClasses(nullptr, true, NullBaseDerived);
TestEqual("GetChildClasses should return an empty list for a null base class",
@@ -87,11 +392,9 @@ bool FDirectiveUtilFunctionLibraryTest::RunTest(const FString& Parameters)
TestFalse("GetCommandLineOption should return false for an empty key",
UDirectiveUtilFunctionLibrary::GetCommandLineOption(CommandLine, TEXT(""), Value));
// Smoke test through the process-command-line path.
TestFalse("HasCommandLineSwitch should not find a switch that was never passed",
UDirectiveUtilFunctionLibrary::HasCommandLineSwitch(TEXT("DirectiveUtilitiesDefinitelyNotPassed")));
}
UDirectiveUtilFunctionLibrary::CopyStringToClipboard(OriginalClipboard);
return true;
}

View File

@@ -1,10 +1,12 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilGameplayTagFunctionLibrary.h"
#include "GameplayTagsManager.h"
#include "Misc/AutomationTest.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilGameplayTagFunctionLibraryTest, "DirectiveUtilities.GameplayTagFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilGameplayTagFunctionLibraryTest, "DirectiveUtilities.GameplayTagFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilGameplayTagFunctionLibraryTest::RunTest(const FString& Parameters)
{
@@ -109,5 +111,23 @@ bool FDirectiveUtilGameplayTagFunctionLibraryTest::RunTest(const FString& Parame
TestEqual("FindRegisteredTags with no match should be empty", UDirectiveUtilGameplayTagFunctionLibrary::FindRegisteredTags(TEXT("DirectiveUtilitiesNoSuchTagXYZ")).Num(), 0);
TestEqual("FindRegisteredTags with an empty substring should be empty", UDirectiveUtilGameplayTagFunctionLibrary::FindRegisteredTags(TEXT("")).Num(), 0);
#if WITH_EDITOR
const TArray<FName> RegistryQueryFunctions = {
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilGameplayTagFunctionLibrary, GetTagParents),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilGameplayTagFunctionLibrary, GetTagChildren),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilGameplayTagFunctionLibrary, GetTagDirectChildren),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilGameplayTagFunctionLibrary, IsLeafTag)
};
for (const FName FunctionName : RegistryQueryFunctions)
{
const UFunction* Function = UDirectiveUtilGameplayTagFunctionLibrary::StaticClass()->FindFunctionByName(FunctionName);
TestNotNull(*FString::Printf(TEXT("%s should be reflected"), *FunctionName.ToString()), Function);
if (Function)
{
TestFalse(*FString::Printf(TEXT("%s should not advertise worker-thread safety"), *FunctionName.ToString()), Function->HasMetaData(TEXT("BlueprintThreadSafe")));
}
}
#endif
return true;
}

View File

@@ -4,6 +4,7 @@
#include "Types/DirectiveUtilInputTypes.h"
#include "Types/DirectiveUtilTypes.h"
#include "InputMappingContext.h"
#include "EnhancedPlayerInput.h"
#include "EnhancedInputSubsystems.h"
#include "Engine/GameInstance.h"
#include "Engine/LocalPlayer.h"
@@ -12,34 +13,24 @@
#include "GameFramework/PlayerController.h"
#include "Misc/AutomationTest.h"
#if WITH_EDITOR
/**
* Exercises the Enhanced Input library's active paths against a real EnhancedInput subsystem,
* which requires a live local player. A standalone game instance + local player is built for this;
* if that cannot be created in the headless harness the test skips its assertions rather than failing.
*/
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilInputActivePathTest, "DirectiveUtilities.InputActivePathTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilInputActivePathTest, "DirectiveUtilities.InputActivePathTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilInputActivePathTest::RunTest(const FString& Parameters)
{
// These warnings only fire on the graceful-skip paths; allow (but never require) them.
AddExpectedMessagePlain(TEXT("LocalPlayer not found. Cannot set input mapping contexts."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
AddExpectedMessagePlain(TEXT("EnhancedInput subsystem not found."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
UGameInstance* GameInstance = NewObject<UGameInstance>(GEngine);
if (!GameInstance)
{
AddInfo(TEXT("Could not create a game instance; skipping Enhanced Input active-path assertions."));
return true;
AddError(TEXT("Could not create a game instance for Enhanced Input active-path assertions."));
return false;
}
GameInstance->AddToRoot();
GameInstance->InitializeStandalone();
UWorld* World = GameInstance->GetWorld();
// CreateLocalPlayer ensures when there is no game viewport client (headless), so build the local
// player directly via AddLocalPlayer, which initializes the local-player subsystems without one.
UClass* LocalPlayerClass = GEngine->LocalPlayerClass ? GEngine->LocalPlayerClass.Get() : ULocalPlayer::StaticClass();
ULocalPlayer* LocalPlayer = NewObject<ULocalPlayer>(GEngine, LocalPlayerClass);
if (World && LocalPlayer)
@@ -49,33 +40,40 @@ bool FDirectiveUtilInputActivePathTest::RunTest(const FString& Parameters)
if (!World || !LocalPlayer)
{
AddInfo(TEXT("Local player unavailable in the headless harness; skipping Enhanced Input active-path assertions."));
AddError(TEXT("Local player unavailable in the Enhanced Input test harness."));
GameInstance->Shutdown();
GameInstance->RemoveFromRoot();
return true;
return false;
}
APlayerController* PlayerController = World->SpawnActor<APlayerController>();
if (!PlayerController)
{
AddInfo(TEXT("Could not spawn a player controller; skipping Enhanced Input active-path assertions."));
AddError(TEXT("Could not spawn a player controller for Enhanced Input active-path assertions."));
GameInstance->Shutdown();
GameInstance->RemoveFromRoot();
return true;
return false;
}
PlayerController->SetPlayer(LocalPlayer);
PlayerController->InitInputSystem();
PlayerController->PlayerInput = NewObject<UEnhancedPlayerInput>(PlayerController);
UEnhancedInputLocalPlayerSubsystem* Subsystem = UDirectiveUtilInputFunctionLibrary::GetEnhancedInputSubsystem(PlayerController);
if (!Subsystem)
{
AddInfo(TEXT("Enhanced Input subsystem unavailable for the synthetic local player; skipping active-path assertions."));
AddError(TEXT("Enhanced Input subsystem unavailable for the synthetic local player."));
GameInstance->Shutdown();
GameInstance->RemoveFromRoot();
return true;
return false;
}
// We have a live subsystem: exercise the active add/active/swap/remove/clear paths for real.
TestNotNull("GetEnhancedInputSubsystem returns the subsystem for a live local player", Subsystem);
auto ApplyPendingMappings = [Subsystem]
{
FModifyContextOptions Options;
Options.bForceImmediately = true;
Subsystem->RequestRebuildControlMappings(Options);
};
UInputMappingContext* ContextA = NewObject<UInputMappingContext>(GetTransientPackage());
UInputMappingContext* ContextB = NewObject<UInputMappingContext>(GetTransientPackage());
@@ -84,7 +82,6 @@ bool FDirectiveUtilInputActivePathTest::RunTest(const FString& Parameters)
const TSoftObjectPtr<UInputMappingContext> SoftA(ContextA);
const TSoftObjectPtr<UInputMappingContext> SoftB(ContextB);
// Add context A.
FDirectiveUtilEnhancedInputContextData DataA;
DataA.InputContext = SoftA;
DataA.Priority = 0;
@@ -93,38 +90,47 @@ bool FDirectiveUtilInputActivePathTest::RunTest(const FString& Parameters)
TestEqual("AddInputMappingContexts succeeds for a live controller",
UDirectiveUtilInputFunctionLibrary::AddInputMappingContexts(PlayerController, ToAdd, false),
EDirectiveUtilSuccessStatus::Success);
ApplyPendingMappings();
TestTrue("Added context is reported active",
UDirectiveUtilInputFunctionLibrary::IsInputMappingContextActive(PlayerController, SoftA));
// Swap A -> B.
TestEqual("SwapInputMappingContexts succeeds",
UDirectiveUtilInputFunctionLibrary::SwapInputMappingContexts(PlayerController, SoftA, SoftB, 0, false),
EDirectiveUtilSuccessStatus::Success);
ApplyPendingMappings();
TestFalse("Swapped-out context is inactive",
UDirectiveUtilInputFunctionLibrary::IsInputMappingContextActive(PlayerController, SoftA));
TestTrue("Swapped-in context is active",
UDirectiveUtilInputFunctionLibrary::IsInputMappingContextActive(PlayerController, SoftB));
// Remove B.
TArray<TSoftObjectPtr<UInputMappingContext>> ToRemove;
ToRemove.Add(SoftB);
TestEqual("RemoveInputMappingContexts succeeds",
UDirectiveUtilInputFunctionLibrary::RemoveInputMappingContexts(PlayerController, ToRemove),
EDirectiveUtilSuccessStatus::Success);
ApplyPendingMappings();
TestFalse("Removed context is inactive",
UDirectiveUtilInputFunctionLibrary::IsInputMappingContextActive(PlayerController, SoftB));
TestEqual("Swap adds the new context when the previous context is inactive",
UDirectiveUtilInputFunctionLibrary::SwapInputMappingContexts(PlayerController, SoftA, SoftB, 7, true),
EDirectiveUtilSuccessStatus::Success);
ApplyPendingMappings();
TestTrue("Fallback swap context is active",
UDirectiveUtilInputFunctionLibrary::IsInputMappingContextActive(PlayerController, SoftB));
UDirectiveUtilInputFunctionLibrary::RemoveInputMappingContexts(PlayerController, {SoftB});
ApplyPendingMappings();
// Clear all (after re-adding A).
UDirectiveUtilInputFunctionLibrary::AddInputMappingContexts(PlayerController, ToAdd, false);
TestEqual("ClearAllInputMappingContexts succeeds",
UDirectiveUtilInputFunctionLibrary::ClearAllInputMappingContexts(PlayerController),
EDirectiveUtilSuccessStatus::Success);
ApplyPendingMappings();
TestFalse("Context is inactive after clear-all",
UDirectiveUtilInputFunctionLibrary::IsInputMappingContextActive(PlayerController, SoftA));
// A clearing add whose every context fails to load must leave the existing mappings untouched.
AddExpectedMessagePlain(TEXT("Input Mapping Contexts failed to load and were not added!"), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
AddExpectedMessagePlain(TEXT("input mapping contexts could not be loaded"), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
UDirectiveUtilInputFunctionLibrary::AddInputMappingContexts(PlayerController, ToAdd, false);
ApplyPendingMappings();
FDirectiveUtilEnhancedInputContextData UnresolvableData;
UnresolvableData.Priority = 0;
TArray<FDirectiveUtilEnhancedInputContextData> UnresolvableToAdd;
@@ -142,5 +148,3 @@ bool FDirectiveUtilInputActivePathTest::RunTest(const FString& Parameters)
return true;
}
#endif // WITH_EDITOR

View File

@@ -1,16 +1,17 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilInputFunctionLibrary.h"
#include "Types/DirectiveUtilTypes.h"
#include "InputMappingContext.h"
#include "Misc/AutomationTest.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilInputFunctionLibraryTest, "DirectiveUtilities.InputFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilInputFunctionLibraryTest, "DirectiveUtilities.InputFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilInputFunctionLibraryTest::RunTest(const FString& Parameters)
{
// The null-controller and invalid-context paths intentionally log warnings. Register them as
// expected (plain match, negative count = consume if present, never required) so the run is clean.
AddExpectedMessagePlain(TEXT("PlayerController is null. Cannot set input mapping contexts."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
AddExpectedMessagePlain(TEXT("Both the previous and new input mapping contexts must be valid."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
// AddInputMappingContexts should return Failure with null controller
TArray<FDirectiveUtilEnhancedInputContextData> Contexts;
@@ -39,8 +40,6 @@ bool FDirectiveUtilInputFunctionLibraryTest::RunTest(const FString& Parameters)
UDirectiveUtilInputFunctionLibrary::RemoveInputMappingContexts(nullptr, EmptyRemoveContexts),
EDirectiveUtilSuccessStatus::Failure);
// SwapInputMappingContexts loads both contexts before using the controller, so invalid
// (unset) soft pointers must return Failure regardless of the (null) controller.
const TSoftObjectPtr<UInputMappingContext> NullContext;
TestEqual("SwapInputMappingContexts should fail when both contexts are invalid",
UDirectiveUtilInputFunctionLibrary::SwapInputMappingContexts(nullptr, NullContext, NullContext, 0, false),

View File

@@ -1,8 +1,10 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilMapFunctionLibrary.h"
#include "Tests/DirectiveUtilTestObject.h"
#include "Misc/AutomationTest.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilMapFunctionLibraryTest, "DirectiveUtilities.MapFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilMapFunctionLibraryTest, "DirectiveUtilities.MapFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilMapFunctionLibraryTest::RunTest(const FString& Parameters)
{
@@ -144,5 +146,11 @@ bool FDirectiveUtilMapFunctionLibraryTest::RunTest(const FString& Parameters)
TestEqual("Append with null or mismatched properties should preserve the value", *UntouchedValue, 10);
}
TestObject->TestMap = {{1, 10}, {2, 20}};
UDirectiveUtilMapFunctionLibrary::GenericMap_Append(&TestObject->TestMap, MapProperty, &TestObject->TestMap, MapProperty, true);
TestEqual("Append with the same map should preserve its size", TestObject->TestMap.Num(), 2);
TestEqual("Append with the same map should preserve the first value", TestObject->TestMap.FindRef(1), 10);
TestEqual("Append with the same map should preserve the second value", TestObject->TestMap.FindRef(2), 20);
return true;
}

View File

@@ -0,0 +1,342 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Misc/AutomationTest.h"
#include "EdGraph/EdGraph.h"
#include "EdGraph/EdGraphNode.h"
#include "EdGraph/EdGraphPin.h"
#include "EdGraphSchema_K2.h"
#include "Engine/Blueprint.h"
#include "K2Node_CallFunction.h"
#include "K2Node_TemporaryVariable.h"
#include "Kismet2/BlueprintEditorUtils.h"
#include "Kismet2/CompilerResultsLog.h"
#include "Libraries/DirectiveUtilMapFunctionLibrary.h"
#include "Nodes/DirectiveUtilMapNodeMigration.h"
#include "Nodes/K2Node_DirectiveUtilMapAppend.h"
#include "Tests/DirectiveUtilTestObject.h"
namespace DirectiveUtilMapNodeTest
{
struct FDependentPin
{
FName Name;
FName Category;
EPinContainerType ContainerType;
};
struct FMapNodeCase
{
FName FunctionName;
TArray<FDependentPin> DependentPins;
};
FEdGraphPinType MakeMapType(
const FName KeyCategory,
const FName ValueCategory,
UObject* KeySubCategoryObject = nullptr,
UObject* ValueSubCategoryObject = nullptr)
{
FEdGraphPinType PinType;
PinType.PinCategory = KeyCategory;
PinType.PinSubCategoryObject = KeySubCategoryObject;
PinType.PinValueType.TerminalCategory = ValueCategory;
PinType.PinValueType.TerminalSubCategoryObject = ValueSubCategoryObject;
PinType.ContainerType = EPinContainerType::Map;
return PinType;
}
UEdGraph* MakeGraph()
{
UBlueprint* Blueprint = NewObject<UBlueprint>();
Blueprint->GeneratedClass = UObject::StaticClass();
Blueprint->SkeletonGeneratedClass = UObject::StaticClass();
UEdGraph* Graph = NewObject<UEdGraph>(Blueprint);
Graph->Schema = UEdGraphSchema_K2::StaticClass();
Blueprint->UbergraphPages.Add(Graph);
return Graph;
}
UEdGraphPin* AddMapOutput(UEdGraph& Graph, const FName Name, const FEdGraphPinType& PinType)
{
UK2Node_TemporaryVariable* SourceNode = NewObject<UK2Node_TemporaryVariable>(&Graph);
SourceNode->VariableType = PinType;
Graph.AddNode(SourceNode);
SourceNode->AllocateDefaultPins();
UEdGraphPin* OutputPin = SourceNode->GetVariablePin();
OutputPin->PinName = Name;
return OutputPin;
}
UK2Node_CallFunction* AddFunctionNode(UEdGraph& Graph, const FName FunctionName)
{
UK2Node_CallFunction* Node = NewObject<UK2Node_CallFunction>(&Graph);
Graph.AddNode(Node);
const UFunction* Function = UDirectiveUtilMapFunctionLibrary::StaticClass()->FindFunctionByName(FunctionName);
Node->FunctionReference.SetExternalMember(FunctionName, UDirectiveUtilMapFunctionLibrary::StaticClass());
Node->bDefaultsToPureFunc = Function && Function->HasAnyFunctionFlags(FUNC_BlueprintPure);
Node->AllocateDefaultPins();
return Node;
}
void Connect(UEdGraphPin& OutputPin, UK2Node& Node, UEdGraphPin& InputPin)
{
OutputPin.MakeLinkTo(&InputPin);
Node.PinConnectionListChanged(&InputPin);
}
void Disconnect(UEdGraphPin& OutputPin, UK2Node& Node, UEdGraphPin& InputPin)
{
OutputPin.BreakLinkTo(&InputPin);
Node.PinConnectionListChanged(&InputPin);
}
bool HasMapType(const UEdGraphPin& Pin, const FName KeyCategory, const FName ValueCategory)
{
return Pin.PinType.IsMap()
&& Pin.PinType.PinCategory == KeyCategory
&& Pin.PinType.PinValueType.TerminalCategory == ValueCategory;
}
bool HasMapType(const UEdGraphPin& Pin, const FEdGraphPinType& ExpectedType)
{
return Pin.PinType.IsMap()
&& Pin.PinType.PinCategory == ExpectedType.PinCategory
&& Pin.PinType.PinSubCategory == ExpectedType.PinSubCategory
&& Pin.PinType.PinSubCategoryObject == ExpectedType.PinSubCategoryObject
&& Pin.PinType.PinValueType == ExpectedType.PinValueType;
}
bool HasElementType(
const UEdGraphPin& Pin,
const FName Category,
UObject* SubCategoryObject,
const EPinContainerType ContainerType)
{
return Pin.PinType.PinCategory == Category
&& Pin.PinType.PinSubCategoryObject == SubCategoryObject
&& Pin.PinType.ContainerType == ContainerType;
}
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilMapNodeWildcardTest,
"DirectiveUtilities.MapNodeWildcardTests",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilMapNodeWildcardTest::RunTest(const FString& Parameters)
{
using namespace DirectiveUtilMapNodeTest;
const TArray<FMapNodeCase> NodeCases = {
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMapFunctionLibrary, Map_FindOrAdd), {{TEXT("Key"), UEdGraphSchema_K2::PC_String, EPinContainerType::None}, {TEXT("Value"), UEdGraphSchema_K2::PC_Int, EPinContainerType::None}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMapFunctionLibrary, Map_ClearValues), {}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMapFunctionLibrary, Map_GetKeysByValue), {{TEXT("Value"), UEdGraphSchema_K2::PC_Int, EPinContainerType::None}, {TEXT("Keys"), UEdGraphSchema_K2::PC_String, EPinContainerType::Array}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMapFunctionLibrary, Map_HasValue), {{TEXT("Value"), UEdGraphSchema_K2::PC_Int, EPinContainerType::None}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMapFunctionLibrary, Map_RemoveKeys), {{TEXT("Keys"), UEdGraphSchema_K2::PC_String, EPinContainerType::Array}}},
};
const FEdGraphPinType StringIntMap = MakeMapType(UEdGraphSchema_K2::PC_String, UEdGraphSchema_K2::PC_Int);
for (const FMapNodeCase& NodeCase : NodeCases)
{
UEdGraph* Graph = MakeGraph();
UK2Node_CallFunction* Node = AddFunctionNode(*Graph, NodeCase.FunctionName);
UEdGraphPin* TargetMap = Node->FindPin(TEXT("TargetMap"));
TestNotNull(*FString::Printf(TEXT("%s should have a TargetMap pin"), *NodeCase.FunctionName.ToString()), TargetMap);
if (!TargetMap)
{
continue;
}
TestTrue(*FString::Printf(TEXT("%s should begin as a wildcard map"), *NodeCase.FunctionName.ToString()), HasMapType(*TargetMap, UEdGraphSchema_K2::PC_Wildcard, UEdGraphSchema_K2::PC_Wildcard));
UEdGraphPin* MapOutput = AddMapOutput(*Graph, TEXT("MapOutput"), StringIntMap);
Connect(*MapOutput, *Node, *TargetMap);
TestTrue(*FString::Printf(TEXT("%s should resolve its map type"), *NodeCase.FunctionName.ToString()), HasMapType(*TargetMap, UEdGraphSchema_K2::PC_String, UEdGraphSchema_K2::PC_Int));
for (const FDependentPin& ExpectedPin : NodeCase.DependentPins)
{
const UEdGraphPin* DependentPin = Node->FindPin(ExpectedPin.Name);
TestNotNull(*FString::Printf(TEXT("%s should have a %s pin"), *NodeCase.FunctionName.ToString(), *ExpectedPin.Name.ToString()), DependentPin);
if (DependentPin)
{
TestEqual(*FString::Printf(TEXT("%s should resolve %s"), *NodeCase.FunctionName.ToString(), *ExpectedPin.Name.ToString()), DependentPin->PinType.PinCategory, ExpectedPin.Category);
TestEqual(*FString::Printf(TEXT("%s should preserve the %s container"), *NodeCase.FunctionName.ToString(), *ExpectedPin.Name.ToString()), DependentPin->PinType.ContainerType, ExpectedPin.ContainerType);
}
}
Disconnect(*MapOutput, *Node, *TargetMap);
TestTrue(*FString::Printf(TEXT("%s should reset after disconnect"), *NodeCase.FunctionName.ToString()), HasMapType(*TargetMap, UEdGraphSchema_K2::PC_Wildcard, UEdGraphSchema_K2::PC_Wildcard));
for (const FDependentPin& ExpectedPin : NodeCase.DependentPins)
{
const UEdGraphPin* DependentPin = Node->FindPin(ExpectedPin.Name);
if (DependentPin)
{
TestEqual(*FString::Printf(TEXT("%s should reset %s after disconnect"), *NodeCase.FunctionName.ToString(), *ExpectedPin.Name.ToString()), DependentPin->PinType.PinCategory, UEdGraphSchema_K2::PC_Wildcard);
}
}
}
const FEdGraphPinType ObjectStructMap = MakeMapType(
UEdGraphSchema_K2::PC_Object,
UEdGraphSchema_K2::PC_Struct,
UDirectiveUtilTestObject::StaticClass(),
FDirectiveUtilCollisionValue::StaticStruct());
for (const FMapNodeCase& NodeCase : NodeCases)
{
UEdGraph* Graph = MakeGraph();
UK2Node_CallFunction* Node = AddFunctionNode(*Graph, NodeCase.FunctionName);
UEdGraphPin* TargetMap = Node->FindPin(TEXT("TargetMap"));
if (!TestNotNull(*FString::Printf(TEXT("%s should have a typed TargetMap pin"), *NodeCase.FunctionName.ToString()), TargetMap))
{
continue;
}
UEdGraphPin* MapOutput = AddMapOutput(*Graph, TEXT("ObjectStructMapOutput"), ObjectStructMap);
Connect(*MapOutput, *Node, *TargetMap);
TestTrue(
*FString::Printf(TEXT("%s should preserve map subtype objects"), *NodeCase.FunctionName.ToString()),
HasMapType(*TargetMap, ObjectStructMap));
for (const FDependentPin& ExpectedPin : NodeCase.DependentPins)
{
const UEdGraphPin* DependentPin = Node->FindPin(ExpectedPin.Name);
if (!DependentPin)
{
continue;
}
const bool bUsesKeyType = ExpectedPin.Name == TEXT("Key") || ExpectedPin.Name == TEXT("Keys");
TestTrue(
*FString::Printf(TEXT("%s should preserve %s subtype"), *NodeCase.FunctionName.ToString(), *ExpectedPin.Name.ToString()),
HasElementType(
*DependentPin,
bUsesKeyType ? UEdGraphSchema_K2::PC_Object : UEdGraphSchema_K2::PC_Struct,
bUsesKeyType
? static_cast<UObject*>(UDirectiveUtilTestObject::StaticClass())
: static_cast<UObject*>(FDirectiveUtilCollisionValue::StaticStruct()),
ExpectedPin.ContainerType));
}
Disconnect(*MapOutput, *Node, *TargetMap);
TestTrue(
*FString::Printf(TEXT("%s should reset after a typed disconnect"), *NodeCase.FunctionName.ToString()),
HasMapType(*TargetMap, UEdGraphSchema_K2::PC_Wildcard, UEdGraphSchema_K2::PC_Wildcard));
}
UEdGraph* AppendGraph = MakeGraph();
UK2Node_DirectiveUtilMapAppend* AppendNode = NewObject<UK2Node_DirectiveUtilMapAppend>(AppendGraph);
AppendGraph->AddNode(AppendNode);
AppendNode->AllocateDefaultPins();
TestTrue(
"Append node should be defined in an uncooked-only module",
AppendNode->GetClass()->GetOutermost()->HasAnyPackageFlags(PKG_UncookedOnly));
FCompilerResultsLog ModuleValidationLog;
FBlueprintEditorUtils::ValidateEditorOnlyNodes(AppendNode, ModuleValidationLog);
TestEqual("Append should be valid in a runtime Blueprint", ModuleValidationLog.NumWarnings, 0);
TestEqual(
"Append should call the runtime append function",
AppendNode->GetTargetFunction(),
UDirectiveUtilMapFunctionLibrary::StaticClass()->FindFunctionByName(GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMapFunctionLibrary, Map_Append)));
UEdGraphPin* TargetMap = AppendNode->FindPin(TEXT("TargetMap"));
UEdGraphPin* SourceMap = AppendNode->FindPin(TEXT("SourceMap"));
TestNotNull("Append should have a TargetMap pin", TargetMap);
TestNotNull("Append should have a SourceMap pin", SourceMap);
if (!TargetMap || !SourceMap)
{
return false;
}
TestTrue("Append TargetMap should begin as a wildcard map", HasMapType(*TargetMap, UEdGraphSchema_K2::PC_Wildcard, UEdGraphSchema_K2::PC_Wildcard));
TestTrue("Append SourceMap should begin as a wildcard map", HasMapType(*SourceMap, UEdGraphSchema_K2::PC_Wildcard, UEdGraphSchema_K2::PC_Wildcard));
UEdGraphPin* SourceOutput = AddMapOutput(*AppendGraph, TEXT("SourceOutput"), StringIntMap);
Connect(*SourceOutput, *AppendNode, *SourceMap);
TestTrue("Append TargetMap should resolve from SourceMap", HasMapType(*TargetMap, UEdGraphSchema_K2::PC_String, UEdGraphSchema_K2::PC_Int));
TestTrue("Append SourceMap should resolve from SourceMap", HasMapType(*SourceMap, UEdGraphSchema_K2::PC_String, UEdGraphSchema_K2::PC_Int));
FString DisallowReason;
const FEdGraphPinType NameIntMap = MakeMapType(UEdGraphSchema_K2::PC_Name, UEdGraphSchema_K2::PC_Int);
UEdGraphPin* MismatchedKeyOutput = AddMapOutput(*AppendGraph, TEXT("MismatchedKeyOutput"), NameIntMap);
TestTrue("Append should reject a different key type", AppendNode->IsConnectionDisallowed(TargetMap, MismatchedKeyOutput, DisallowReason));
const FEdGraphPinType StringStringMap = MakeMapType(UEdGraphSchema_K2::PC_String, UEdGraphSchema_K2::PC_String);
UEdGraphPin* MismatchedValueOutput = AddMapOutput(*AppendGraph, TEXT("MismatchedValueOutput"), StringStringMap);
TestTrue("Append should reject a different value type", AppendNode->IsConnectionDisallowed(TargetMap, MismatchedValueOutput, DisallowReason));
TestFalse("Append should accept matching map types", AppendNode->IsConnectionDisallowed(TargetMap, SourceOutput, DisallowReason));
Disconnect(*SourceOutput, *AppendNode, *SourceMap);
TestTrue("Append TargetMap should reset after disconnect", HasMapType(*TargetMap, UEdGraphSchema_K2::PC_Wildcard, UEdGraphSchema_K2::PC_Wildcard));
TestTrue("Append SourceMap should reset after disconnect", HasMapType(*SourceMap, UEdGraphSchema_K2::PC_Wildcard, UEdGraphSchema_K2::PC_Wildcard));
UEdGraphPin* TypedSourceOutput = AddMapOutput(*AppendGraph, TEXT("TypedSourceOutput"), ObjectStructMap);
Connect(*TypedSourceOutput, *AppendNode, *SourceMap);
TestTrue("Append TargetMap should preserve subtype objects", HasMapType(*TargetMap, ObjectStructMap));
TestTrue("Append SourceMap should preserve subtype objects", HasMapType(*SourceMap, ObjectStructMap));
Disconnect(*TypedSourceOutput, *AppendNode, *SourceMap);
UEdGraph* MigrationGraph = MakeGraph();
UBlueprint* MigrationBlueprint = CastChecked<UBlueprint>(MigrationGraph->GetOuter());
UK2Node_CallFunction* LegacyAppendNode = AddFunctionNode(
*MigrationGraph,
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMapFunctionLibrary, Map_Append));
LegacyAppendNode->NodePosX = 320;
LegacyAppendNode->NodePosY = 180;
UEdGraphPin* LegacyTargetMap = LegacyAppendNode->FindPin(TEXT("TargetMap"));
UEdGraphPin* LegacyOverwrite = LegacyAppendNode->FindPin(TEXT("bOverwriteExisting"));
TestNotNull("Legacy Append should have a TargetMap pin", LegacyTargetMap);
TestNotNull("Legacy Append should have an overwrite pin", LegacyOverwrite);
if (!LegacyTargetMap || !LegacyOverwrite)
{
return false;
}
LegacyOverwrite->DefaultValue = TEXT("false");
UEdGraphPin* LegacyMapOutput = AddMapOutput(*MigrationGraph, TEXT("LegacyMapOutput"), StringIntMap);
Connect(*LegacyMapOutput, *LegacyAppendNode, *LegacyTargetMap);
TestTrue("Legacy Append should be upgraded", DirectiveUtilMapNodeMigration::UpgradeLegacyAppendNodes(*MigrationBlueprint));
TArray<UK2Node_DirectiveUtilMapAppend*> MigratedAppendNodes;
MigrationGraph->GetNodesOfClass(MigratedAppendNodes);
TestEqual("Migration should create one Append node", MigratedAppendNodes.Num(), 1);
if (MigratedAppendNodes.Num() != 1)
{
return false;
}
UK2Node_DirectiveUtilMapAppend* MigratedAppendNode = MigratedAppendNodes[0];
TestEqual("Migration should preserve the X position", MigratedAppendNode->NodePosX, 320);
TestEqual("Migration should preserve the Y position", MigratedAppendNode->NodePosY, 180);
TestEqual("Migration should preserve the overwrite default", MigratedAppendNode->FindPinChecked(TEXT("bOverwriteExisting"))->DefaultValue, FString(TEXT("false")));
TestTrue("Migrated TargetMap should preserve its connection", HasMapType(*MigratedAppendNode->FindPinChecked(TEXT("TargetMap")), UEdGraphSchema_K2::PC_String, UEdGraphSchema_K2::PC_Int));
TestTrue("Migrated SourceMap should resolve from TargetMap", HasMapType(*MigratedAppendNode->FindPinChecked(TEXT("SourceMap")), UEdGraphSchema_K2::PC_String, UEdGraphSchema_K2::PC_Int));
UEdGraph* FailedMigrationGraph = MakeGraph();
UBlueprint* FailedMigrationBlueprint = CastChecked<UBlueprint>(FailedMigrationGraph->GetOuter());
UK2Node_CallFunction* IncompatibleLegacyNode = AddFunctionNode(
*FailedMigrationGraph,
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMapFunctionLibrary, Map_Append));
UEdGraphPin* IncompatibleTargetMap = IncompatibleLegacyNode->FindPinChecked(TEXT("TargetMap"));
UEdGraphPin* LegacyOnlyPin = IncompatibleLegacyNode->CreatePin(
EGPD_Input,
UEdGraphSchema_K2::PC_Int,
TEXT("LegacyOnly"));
LegacyOnlyPin->ParentPin = IncompatibleTargetMap;
IncompatibleTargetMap->SubPins.Add(LegacyOnlyPin);
UEdGraphPin* IncompatibleMapOutput = AddMapOutput(*FailedMigrationGraph, TEXT("IncompatibleMapOutput"), StringIntMap);
Connect(*IncompatibleMapOutput, *IncompatibleLegacyNode, *IncompatibleTargetMap);
AddExpectedMessagePlain(
TEXT("BackwardCompatibilityNodeConversion Error 'cannot find pin LegacyOnly"),
ELogVerbosity::Warning,
EAutomationExpectedMessageFlags::Contains,
1);
TestFalse(
"An incompatible legacy Append node should not be replaced",
DirectiveUtilMapNodeMigration::UpgradeLegacyAppendNodes(*FailedMigrationBlueprint));
TestTrue(
"A failed migration should preserve the legacy connection",
IncompatibleMapOutput->LinkedTo.Contains(IncompatibleTargetMap));
TestTrue(
"A failed migration should preserve the legacy node",
FailedMigrationGraph->Nodes.Contains(IncompatibleLegacyNode));
return true;
}

View File

@@ -0,0 +1,252 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilMapFunctionLibrary.h"
#include "Tests/DirectiveUtilTestObject.h"
#include "Misc/AutomationTest.h"
namespace
{
template <typename PropertyType>
PropertyType* GetTestProperty(const FName PropertyName)
{
return FindFProperty<PropertyType>(UDirectiveUtilTestObject::StaticClass(), PropertyName);
}
TSet<int32> MakeIntegerSet(const TArray<int32>& Values)
{
TSet<int32> Result;
for (const int32 Value : Values)
{
Result.Add(Value);
}
return Result;
}
TSet<FString> MakeStringSet(const TArray<FString>& Values)
{
TSet<FString> Result;
for (const FString& Value : Values)
{
Result.Add(Value);
}
return Result;
}
TSet<FName> MakeNameSet(const TArray<FName>& Values)
{
TSet<FName> Result;
for (const FName Value : Values)
{
Result.Add(Value);
}
return Result;
}
template <typename ValueType>
bool HaveSameValues(const TSet<ValueType>& Left, const TSet<ValueType>& Right)
{
if (Left.Num() != Right.Num())
{
return false;
}
for (const ValueType& Value : Left)
{
if (!Right.Contains(Value))
{
return false;
}
}
return true;
}
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilMapScenarioTest,
"DirectiveUtilities.MapScenarios.CardinalityAndTypes",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilMapScenarioTest::RunTest(const FString& Parameters)
{
FMapProperty* IntegerMapProperty = GetTestProperty<FMapProperty>(GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestMap));
FArrayProperty* IntegerArrayProperty = GetTestProperty<FArrayProperty>(GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestArray));
FMapProperty* StringMapProperty = GetTestProperty<FMapProperty>(GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestStringMap));
FMapProperty* StringMapProperty2 = GetTestProperty<FMapProperty>(GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestStringMap2));
FArrayProperty* StringArrayProperty = GetTestProperty<FArrayProperty>(GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestStringArray));
FMapProperty* StructMapProperty = GetTestProperty<FMapProperty>(GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestStructValueMap));
FArrayProperty* NameArrayProperty = GetTestProperty<FArrayProperty>(GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestNameArray));
if (!TestNotNull("Integer map property should be available", IntegerMapProperty)
|| !TestNotNull("Integer array property should be available", IntegerArrayProperty)
|| !TestNotNull("String map property should be available", StringMapProperty)
|| !TestNotNull("Second string map property should be available", StringMapProperty2)
|| !TestNotNull("String array property should be available", StringArrayProperty)
|| !TestNotNull("Struct map property should be available", StructMapProperty)
|| !TestNotNull("Name array property should be available", NameArrayProperty))
{
return false;
}
UDirectiveUtilTestObject* TestObject = NewObject<UDirectiveUtilTestObject>();
for (const int32 ItemCount : {0, 1, 2, 16, 257, 4096})
{
TestObject->TestMap.Reset();
for (int32 Key = 0; Key < ItemCount; ++Key)
{
TestObject->TestMap.Add(Key, Key % 7);
}
const FString Label = FString::Printf(TEXT("integer items=%d"), ItemCount);
const int32 QueryValue = 3;
TArray<int32> ExpectedKeys;
for (int32 Key = 0; Key < ItemCount; ++Key)
{
if (Key % 7 == QueryValue)
{
ExpectedKeys.Add(Key);
}
}
TestObject->TestArray.Reset();
UDirectiveUtilMapFunctionLibrary::GenericMap_GetKeysByValue(
&TestObject->TestMap,
IntegerMapProperty,
&QueryValue,
&TestObject->TestArray,
IntegerArrayProperty);
TestEqual(Label + TEXT(" GetKeysByValue count"), TestObject->TestArray.Num(), ExpectedKeys.Num());
TestTrue(Label + TEXT(" GetKeysByValue values"), HaveSameValues(MakeIntegerSet(TestObject->TestArray), MakeIntegerSet(ExpectedKeys)));
TestEqual(
Label + TEXT(" HasValue"),
UDirectiveUtilMapFunctionLibrary::GenericMap_HasValue(&TestObject->TestMap, IntegerMapProperty, &QueryValue),
!ExpectedKeys.IsEmpty());
const int32 ExistingKey = ItemCount > 0 ? ItemCount / 2 : 0;
int32 FoundValue = -1;
UDirectiveUtilMapFunctionLibrary::GenericMap_FindOrAdd(
&TestObject->TestMap,
IntegerMapProperty,
&ExistingKey,
&FoundValue);
TestEqual(Label + TEXT(" FindOrAdd value"), FoundValue, ItemCount > 0 ? ExistingKey % 7 : 0);
UDirectiveUtilMapFunctionLibrary::GenericMap_ClearValues(&TestObject->TestMap, IntegerMapProperty);
TestEqual(Label + TEXT(" ClearValues count"), TestObject->TestMap.Num(), FMath::Max(ItemCount, 1));
for (const TPair<int32, int32>& Pair : TestObject->TestMap)
{
TestEqual(Label + TEXT(" ClearValues value"), Pair.Value, 0);
}
TestObject->TestArray.Reset();
for (int32 Key = 0; Key < ItemCount; Key += 3)
{
TestObject->TestArray.Add(Key);
}
const int32 ExpectedRemoved = TestObject->TestArray.Num();
const int32 Removed = UDirectiveUtilMapFunctionLibrary::GenericMap_RemoveKeys(
&TestObject->TestMap,
IntegerMapProperty,
&TestObject->TestArray,
IntegerArrayProperty);
TestEqual(Label + TEXT(" RemoveKeys count"), Removed, ExpectedRemoved);
TestEqual(Label + TEXT(" RemoveKeys remaining"), TestObject->TestMap.Num(), FMath::Max(ItemCount, 1) - ExpectedRemoved);
}
for (const int32 ItemCount : {0, 1, 16, 257})
{
TestObject->TestStringMap.Reset();
TestObject->TestStringMap2.Reset();
for (int32 Index = 0; Index < ItemCount; ++Index)
{
const FString Key = FString::Printf(TEXT("Key%d"), Index);
TestObject->TestStringMap.Add(Key, FString::Printf(TEXT("Original%d"), Index));
TestObject->TestStringMap2.Add(Key, FString::Printf(TEXT("Replacement%d"), Index));
}
TestObject->TestStringMap2.Add(TEXT("Added"), TEXT("AddedValue"));
const FString Label = FString::Printf(TEXT("string items=%d"), ItemCount);
UDirectiveUtilMapFunctionLibrary::GenericMap_Append(
&TestObject->TestStringMap,
StringMapProperty,
&TestObject->TestStringMap2,
StringMapProperty2,
false);
TestEqual(Label + TEXT(" Append count"), TestObject->TestStringMap.Num(), ItemCount + 1);
TestEqual(Label + TEXT(" Append new value"), TestObject->TestStringMap.FindRef(TEXT("Added")), FString(TEXT("AddedValue")));
if (ItemCount > 0)
{
TestEqual(Label + TEXT(" Append preserve"), TestObject->TestStringMap.FindRef(TEXT("Key0")), FString(TEXT("Original0")));
}
UDirectiveUtilMapFunctionLibrary::GenericMap_Append(
&TestObject->TestStringMap,
StringMapProperty,
&TestObject->TestStringMap2,
StringMapProperty2,
true);
if (ItemCount > 0)
{
TestEqual(Label + TEXT(" Append overwrite"), TestObject->TestStringMap.FindRef(TEXT("Key0")), FString(TEXT("Replacement0")));
}
const int32 CountBeforeSelfAppend = TestObject->TestStringMap.Num();
UDirectiveUtilMapFunctionLibrary::GenericMap_Append(
&TestObject->TestStringMap,
StringMapProperty,
&TestObject->TestStringMap,
StringMapProperty,
true);
TestEqual(Label + TEXT(" self append"), TestObject->TestStringMap.Num(), CountBeforeSelfAppend);
const FString QueryValue = ItemCount > 0 ? TEXT("Replacement0") : TEXT("missing");
TestObject->TestStringArray.Reset();
UDirectiveUtilMapFunctionLibrary::GenericMap_GetKeysByValue(
&TestObject->TestStringMap,
StringMapProperty,
&QueryValue,
&TestObject->TestStringArray,
StringArrayProperty);
const TArray<FString> ExpectedKeys = ItemCount > 0 ? TArray<FString>({TEXT("Key0")}) : TArray<FString>();
TestTrue(Label + TEXT(" managed GetKeysByValue"), HaveSameValues(MakeStringSet(TestObject->TestStringArray), MakeStringSet(ExpectedKeys)));
UDirectiveUtilMapFunctionLibrary::GenericMap_ClearValues(&TestObject->TestStringMap, StringMapProperty);
for (const TPair<FString, FString>& Pair : TestObject->TestStringMap)
{
TestTrue(Label + TEXT(" managed ClearValues"), Pair.Value.IsEmpty());
}
}
FDirectiveUtilCollisionValue FirstStructValue;
FirstStructValue.Value = 1;
FDirectiveUtilCollisionValue SecondStructValue;
SecondStructValue.Value = 2;
TestObject->TestStructValueMap = {
{TEXT("Alpha"), FirstStructValue},
{TEXT("Beta"), SecondStructValue},
{TEXT("Gamma"), FirstStructValue}
};
const FDirectiveUtilCollisionValue StructQuery = FirstStructValue;
TestObject->TestNameArray.Reset();
UDirectiveUtilMapFunctionLibrary::GenericMap_GetKeysByValue(
&TestObject->TestStructValueMap,
StructMapProperty,
&StructQuery,
&TestObject->TestNameArray,
NameArrayProperty);
TestTrue(
"Struct GetKeysByValue",
HaveSameValues(
MakeNameSet(TestObject->TestNameArray),
MakeNameSet(TArray<FName>({TEXT("Alpha"), TEXT("Gamma")}))));
TestTrue(
"Struct HasValue",
UDirectiveUtilMapFunctionLibrary::GenericMap_HasValue(
&TestObject->TestStructValueMap,
StructMapProperty,
&StructQuery));
UDirectiveUtilMapFunctionLibrary::GenericMap_ClearValues(&TestObject->TestStructValueMap, StructMapProperty);
for (const TPair<FName, FDirectiveUtilCollisionValue>& Pair : TestObject->TestStructValueMap)
{
TestEqual("Struct ClearValues", Pair.Value.Value, 0);
}
return true;
}

View File

@@ -0,0 +1,540 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilMathFunctionLibrary.h"
#include "Async/ParallelFor.h"
#include "Misc/AutomationTest.h"
#include <limits>
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilMathExtendedFunctionLibraryTest,
"DirectiveUtilities.Math.ExtendedFunctionLibrary",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilMathExtendedFunctionLibraryTest::RunTest(const FString& Parameters)
{
float FloatResult = 0.0f;
float Strength = 0.0f;
TestTrue("Angle array average accepts values across the degree seam",
UDirectiveUtilMathFunctionLibrary::GetAngleArrayAverage({350.0f, 10.0f}, FloatResult, Strength));
TestTrue("Angle array average crosses the degree seam",
FMath::IsNearlyZero(FloatResult, 1.e-4f));
TestTrue("Angle array average reports concentration",
FMath::IsNearlyEqual(Strength, FMath::Cos(FMath::DegreesToRadians(10.0f)), 1.e-4f));
TestFalse("Angle array average rejects an undefined antipodal mean",
UDirectiveUtilMathFunctionLibrary::GetAngleArrayAverage({0.0f, 180.0f}, FloatResult, Strength));
TestFalse("Angle array average rejects non-finite values",
UDirectiveUtilMathFunctionLibrary::GetAngleArrayAverage(
{0.0f, std::numeric_limits<float>::quiet_NaN()}, FloatResult, Strength));
TestTrue("Weighted float average accepts aligned arrays",
UDirectiveUtilMathFunctionLibrary::GetWeightedFloatArrayAverage(
{10.0f, 20.0f}, {1.0f, 3.0f}, FloatResult));
TestTrue("Weighted float average applies weights",
FMath::IsNearlyEqual(FloatResult, 17.5f, 1.e-4f));
TestTrue("Weighted float average ignores negative weights",
UDirectiveUtilMathFunctionLibrary::GetWeightedFloatArrayAverage(
{10.0f, 20.0f}, {-1.0f, 2.0f}, FloatResult)
&& FMath::IsNearlyEqual(FloatResult, 20.0f, 1.e-4f));
TestFalse("Weighted float average rejects mismatched arrays",
UDirectiveUtilMathFunctionLibrary::GetWeightedFloatArrayAverage(
{10.0f}, {1.0f, 2.0f}, FloatResult));
FVector VectorResult = FVector::ZeroVector;
TestTrue("Weighted vector average accepts aligned arrays",
UDirectiveUtilMathFunctionLibrary::GetWeightedVectorArrayAverage(
{FVector::ForwardVector, FVector::RightVector}, {1.0f, 1.0f}, VectorResult));
TestTrue("Weighted vector average applies weights",
VectorResult.Equals(FVector(0.5, 0.5, 0.0), 1.e-6));
TestTrue("Weighted vector average preserves large finite values",
UDirectiveUtilMathFunctionLibrary::GetWeightedVectorArrayAverage(
{FVector(1.e300, 0.0, 0.0)}, {1.e20f}, VectorResult)
&& FMath::IsNearlyEqual(VectorResult.X / 1.e300, 1.0, 1.e-12));
TestFalse("Weighted vector average rejects non-finite values",
UDirectiveUtilMathFunctionLibrary::GetWeightedVectorArrayAverage(
{FVector::ForwardVector, FVector(std::numeric_limits<double>::infinity(), 0.0, 0.0)},
{1.0f, 1.0f}, VectorResult));
TArray<float> FloatArrayResult;
TestTrue("Float array normalization accepts finite values",
UDirectiveUtilMathFunctionLibrary::NormalizeFloatArrayToRange(
{2.0f, 4.0f, 6.0f}, -1.0f, 1.0f, FloatArrayResult));
TestTrue("Float array normalization maps the full range",
FloatArrayResult.Num() == 3
&& FMath::IsNearlyEqual(FloatArrayResult[0], -1.0f)
&& FMath::IsNearlyZero(FloatArrayResult[1])
&& FMath::IsNearlyEqual(FloatArrayResult[2], 1.0f));
TestTrue("Float array normalization accepts reversed output bounds",
UDirectiveUtilMathFunctionLibrary::NormalizeFloatArrayToRange(
{2.0f, 4.0f, 6.0f}, 1.0f, -1.0f, FloatArrayResult)
&& FMath::IsNearlyEqual(FloatArrayResult[0], 1.0f)
&& FMath::IsNearlyEqual(FloatArrayResult[2], -1.0f));
TestTrue("Float array normalization maps a constant array to the output minimum",
UDirectiveUtilMathFunctionLibrary::NormalizeFloatArrayToRange(
{4.0f, 4.0f}, 5.0f, 10.0f, FloatArrayResult)
&& FloatArrayResult == TArray<float>({5.0f, 5.0f}));
TestFalse("Float array normalization rejects an empty array",
UDirectiveUtilMathFunctionLibrary::NormalizeFloatArrayToRange(
{}, 0.0f, 1.0f, FloatArrayResult));
TArray<float> InPlaceValues = {2.0f, 4.0f, 6.0f};
TestTrue("Float array normalization supports the same input and output array",
UDirectiveUtilMathFunctionLibrary::NormalizeFloatArrayToRange(
InPlaceValues, -1.0f, 1.0f, InPlaceValues)
&& InPlaceValues == TArray<float>({-1.0f, 0.0f, 1.0f}));
TestTrue("Weight normalization accepts positive weights",
UDirectiveUtilMathFunctionLibrary::NormalizeWeights({1.0f, 3.0f}, FloatArrayResult));
TestTrue("Weight normalization sums to one",
FloatArrayResult.Num() == 2
&& FMath::IsNearlyEqual(FloatArrayResult[0], 0.25f)
&& FMath::IsNearlyEqual(FloatArrayResult[1], 0.75f));
TestTrue("Weight normalization clears negative weights",
UDirectiveUtilMathFunctionLibrary::NormalizeWeights({-2.0f, 2.0f}, FloatArrayResult)
&& FMath::IsNearlyZero(FloatArrayResult[0])
&& FMath::IsNearlyEqual(FloatArrayResult[1], 1.0f));
TestFalse("Weight normalization rejects all-zero weights",
UDirectiveUtilMathFunctionLibrary::NormalizeWeights({0.0f, -1.0f}, FloatArrayResult));
TArray<float> InPlaceWeights = {1.0f, 3.0f};
TestTrue("Weight normalization supports the same input and output array",
UDirectiveUtilMathFunctionLibrary::NormalizeWeights(InPlaceWeights, InPlaceWeights)
&& InPlaceWeights == TArray<float>({0.25f, 0.75f}));
TestTrue("Float array percentile accepts finite values",
UDirectiveUtilMathFunctionLibrary::GetFloatArrayPercentile(
{4.0f, 1.0f, 3.0f, 2.0f}, 25.0f, FloatResult));
TestTrue("Float array percentile interpolates adjacent values",
FMath::IsNearlyEqual(FloatResult, 1.75f, 1.e-4f));
TestTrue("Float array percentile clamps above one hundred",
UDirectiveUtilMathFunctionLibrary::GetFloatArrayPercentile(
{1.0f, 4.0f}, 125.0f, FloatResult)
&& FMath::IsNearlyEqual(FloatResult, 4.0f));
TestTrue("Float array percentile uses the Type 7 sample position",
UDirectiveUtilMathFunctionLibrary::GetFloatArrayPercentile(
{10.0f, 1.0f, 8.0f, 2.0f, 7.0f, 3.0f, 6.0f, 4.0f, 9.0f, 5.0f}, 40.0f, FloatResult)
&& FMath::IsNearlyEqual(FloatResult, 4.6f, 1.e-4f));
TestFalse("Float array percentile rejects non-finite values",
UDirectiveUtilMathFunctionLibrary::GetFloatArrayPercentile(
{1.0f, std::numeric_limits<float>::infinity()}, 50.0f, FloatResult));
TestTrue("Root mean square accepts finite values",
UDirectiveUtilMathFunctionLibrary::GetFloatArrayRootMeanSquare({3.0f, 4.0f}, FloatResult));
TestTrue("Root mean square uses the arithmetic mean of squares",
FMath::IsNearlyEqual(FloatResult, FMath::Sqrt(12.5f), 1.e-4f));
TestFalse("Root mean square rejects an empty array",
UDirectiveUtilMathFunctionLibrary::GetFloatArrayRootMeanSquare({}, FloatResult));
TestTrue("Smooth Step clamps below its range",
FMath::IsNearlyZero(UDirectiveUtilMathFunctionLibrary::SmoothStep(-1.0f, 0.0f, 1.0f)));
TestTrue("Smooth Step reaches its midpoint",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SmoothStep(0.5f, 0.0f, 1.0f), 0.5f));
TestTrue("Smooth Step accepts reversed bounds",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SmoothStep(0.25f, 1.0f, 0.0f), 0.15625f));
TestTrue("Smooth Step treats equal bounds as a step",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SmoothStep(2.0f, 2.0f, 2.0f), 1.0f));
TestTrue("Smoother Step reaches its midpoint",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SmootherStep(0.5f, 0.0f, 1.0f), 0.5f));
TestTrue("Smoother Step has quintic shaping",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SmootherStep(0.25f, 0.0f, 1.0f), 0.103515625f));
TestTrue("Range Falloff applies linear attenuation",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::RangeFalloff(5.0f, 0.0f, 10.0f), 0.5f));
TestTrue("Range Falloff applies its exponent",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::RangeFalloff(5.0f, 0.0f, 10.0f, 2.0f), 0.25f));
TestTrue("Range Falloff remains one inside the inner radius",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::RangeFalloff(2.0f, 3.0f, 10.0f), 1.0f));
TestTrue("Range Falloff reaches zero at the outer radius",
FMath::IsNearlyZero(UDirectiveUtilMathFunctionLibrary::RangeFalloff(10.0f, 3.0f, 10.0f)));
double Distance = 0.0;
TestTrue("Direction and distance accepts distinct points",
UDirectiveUtilMathFunctionLibrary::GetDirectionAndDistance(
FVector::ZeroVector, FVector(3.0, 4.0, 0.0), VectorResult, Distance));
TestTrue("Direction and distance returns a unit direction",
VectorResult.Equals(FVector(0.6, 0.8, 0.0), 1.e-6));
TestTrue("Direction and distance returns the length",
FMath::IsNearlyEqual(Distance, 5.0));
TestFalse("Direction and distance rejects equal points",
UDirectiveUtilMathFunctionLibrary::GetDirectionAndDistance(
FVector::ZeroVector, FVector::ZeroVector, VectorResult, Distance));
TestTrue("Direction and distance handles large finite coordinates",
UDirectiveUtilMathFunctionLibrary::GetDirectionAndDistance(
FVector::ZeroVector, FVector(1.e200, 0.0, 0.0), VectorResult, Distance)
&& VectorResult.Equals(FVector::ForwardVector, 1.e-12)
&& FMath::IsNearlyEqual(Distance / 1.e200, 1.0, 1.e-12));
TestTrue("Signed angle handles large finite vectors",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SignedAngleBetweenVectors(
FVector(1.e200, 0.0, 0.0), FVector(0.0, 1.e200, 0.0), FVector::UpVector), 90.0f, 1.e-4f));
TestTrue("Direction Within Cone handles large finite vectors",
UDirectiveUtilMathFunctionLibrary::IsDirectionWithinCone(
FVector(1.e200, 0.0, 0.0), FVector(1.e200, 0.0, 0.0), 0.0f));
TestTrue("Direction Within Cone includes an identical non-axis direction at zero width",
UDirectiveUtilMathFunctionLibrary::IsDirectionWithinCone(
FVector(3.e200, 2.e200, 1.e200), FVector(3.e200, 2.e200, 1.e200), 0.0f));
TestFalse("Direction Within Cone excludes a measurable angle from a zero-width cone",
UDirectiveUtilMathFunctionLibrary::IsDirectionWithinCone(
FVector(FMath::Cos(FMath::DegreesToRadians(0.005)), FMath::Sin(FMath::DegreesToRadians(0.005)), 0.0),
FVector::ForwardVector, 0.0f));
const FVector2D RotatedPoint = UDirectiveUtilMathFunctionLibrary::RotatePointAroundPivot2D(
FVector2D(2.0, 1.0), FVector2D(1.0, 1.0), 90.0f);
TestTrue("Rotate Point Around Pivot 2D preserves the pivot offset",
RotatedPoint.Equals(FVector2D(1.0, 2.0), 1.e-6));
TestTrue("Rotate Point Around Pivot 2D rejects non-finite input",
UDirectiveUtilMathFunctionLibrary::RotatePointAroundPivot2D(
FVector2D(std::numeric_limits<double>::infinity(), 0.0), FVector2D::ZeroVector, 90.0f).IsZero());
TestTrue("Signed Distance To Plane is positive in front of the plane",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SignedDistanceToPlane(
FVector(0.0, 0.0, 5.0), FVector(0.0, 0.0, 2.0), FVector::UpVector), 3.0));
TestTrue("Signed Distance To Plane follows the normal direction",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SignedDistanceToPlane(
FVector(0.0, 0.0, 5.0), FVector(0.0, 0.0, 2.0), -FVector::UpVector), -3.0));
TestTrue("Signed Distance To Plane rejects a zero normal",
FMath::IsNearlyZero(UDirectiveUtilMathFunctionLibrary::SignedDistanceToPlane(
FVector::UpVector, FVector::ZeroVector, FVector::ZeroVector)));
TestTrue("Signed Distance To Plane handles a large finite normal",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SignedDistanceToPlane(
FVector::ForwardVector, FVector::ZeroVector, FVector(1.e200, 0.0, 0.0)), 1.0, 1.e-12));
TestTrue("Point Within Cone includes a point inside the cone",
UDirectiveUtilMathFunctionLibrary::IsPointWithinCone(
FVector(10.0, 0.0, 0.0), FVector::ZeroVector, FVector::ForwardVector, 10.0f, 20.0));
TestFalse("Point Within Cone excludes a point outside the cone",
UDirectiveUtilMathFunctionLibrary::IsPointWithinCone(
FVector(0.0, 10.0, 0.0), FVector::ZeroVector, FVector::ForwardVector, 10.0f, 20.0));
TestFalse("Point Within Cone applies the maximum distance",
UDirectiveUtilMathFunctionLibrary::IsPointWithinCone(
FVector(10.0, 0.0, 0.0), FVector::ZeroVector, FVector::ForwardVector, 10.0f, 5.0));
TestTrue("Point Within Cone treats zero maximum distance as unlimited",
UDirectiveUtilMathFunctionLibrary::IsPointWithinCone(
FVector(10.0, 0.0, 0.0), FVector::ZeroVector, FVector::ForwardVector, 10.0f));
TestFalse("Point Within Cone applies maximum distance to large finite coordinates",
UDirectiveUtilMathFunctionLibrary::IsPointWithinCone(
FVector(1.5e200, 0.0, 0.0), FVector::ZeroVector, FVector::ForwardVector, 180.0f, 1.e200));
bool bCircleSamplesValid = true;
bool bAnnulusSamplesValid = true;
bool bSphereSamplesValid = true;
for (int32 Index = 0; Index < 100; ++Index)
{
const FVector2D CirclePoint = UDirectiveUtilMathFunctionLibrary::RandomPointInCircle(5.0f);
const FVector2D AnnulusPoint = UDirectiveUtilMathFunctionLibrary::RandomPointInAnnulus(2.0f, 5.0f);
const FVector SpherePoint = UDirectiveUtilMathFunctionLibrary::RandomPointInSphere(5.0f);
bCircleSamplesValid &= CirclePoint.Size() <= 5.0 + 1.e-6;
bAnnulusSamplesValid &= AnnulusPoint.Size() >= 2.0 - 1.e-6 && AnnulusPoint.Size() <= 5.0 + 1.e-6;
bSphereSamplesValid &= SpherePoint.Size() <= 5.0 + 1.e-6;
}
TestTrue("Random Point In Circle stays within its radius", bCircleSamplesValid);
TestTrue("Random Point In Annulus stays between its radii", bAnnulusSamplesValid);
TestTrue("Random Point In Sphere stays within its radius", bSphereSamplesValid);
FRandomStream FirstStream(12345);
FRandomStream SecondStream(12345);
TestTrue("Random Point In Circle stream variant is deterministic",
UDirectiveUtilMathFunctionLibrary::RandomPointInCircleFromStream(FirstStream, 5.0f).Equals(
UDirectiveUtilMathFunctionLibrary::RandomPointInCircleFromStream(SecondStream, 5.0f), 1.e-9));
TestTrue("Random Point In Annulus stream variant is deterministic",
UDirectiveUtilMathFunctionLibrary::RandomPointInAnnulusFromStream(FirstStream, 2.0f, 5.0f).Equals(
UDirectiveUtilMathFunctionLibrary::RandomPointInAnnulusFromStream(SecondStream, 2.0f, 5.0f), 1.e-9));
TestTrue("Random Point In Sphere stream variant is deterministic",
UDirectiveUtilMathFunctionLibrary::RandomPointInSphereFromStream(FirstStream, 5.0f).Equals(
UDirectiveUtilMathFunctionLibrary::RandomPointInSphereFromStream(SecondStream, 5.0f), 1.e-9));
#if WITH_EDITOR
const TArray<FName> StreamRandomFunctions = {
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GetRandomIndexFromWeightsFromStream),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, RandomPointInCircleFromStream),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, RandomPointInAnnulusFromStream),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, RandomPointInSphereFromStream)
};
for (const FName FunctionName : StreamRandomFunctions)
{
const UFunction* Function = UDirectiveUtilMathFunctionLibrary::StaticClass()->FindFunctionByName(FunctionName);
TestTrue(*FString::Printf(TEXT("%s should be exposed to Blueprint"), *FunctionName.ToString()), Function != nullptr);
if (Function)
{
TestFalse(
*FString::Printf(TEXT("%s should not advertise inert Blueprint thread safety"), *FunctionName.ToString()),
Function->HasMetaData(TEXT("BlueprintThreadSafe")));
}
}
#endif
TArray<FVector> ParallelRandomResults;
ParallelRandomResults.SetNumUninitialized(64);
ParallelFor(ParallelRandomResults.Num(), [&ParallelRandomResults](const int32 TaskIndex)
{
FRandomStream Stream(99173);
const FVector2D Circle = UDirectiveUtilMathFunctionLibrary::RandomPointInCircleFromStream(Stream, 5.0f);
const FVector2D Annulus = UDirectiveUtilMathFunctionLibrary::RandomPointInAnnulusFromStream(Stream, 2.0f, 5.0f);
ParallelRandomResults[TaskIndex] = UDirectiveUtilMathFunctionLibrary::RandomPointInSphereFromStream(Stream, 5.0f)
+ FVector(Circle.X, Circle.Y, Annulus.X + Annulus.Y);
});
bool bParallelRandomResultsMatch = true;
for (int32 Index = 1; Index < ParallelRandomResults.Num(); ++Index)
{
bParallelRandomResultsMatch &= ParallelRandomResults[Index].Equals(ParallelRandomResults[0], 1.e-12);
}
TestTrue("Seeded random nodes should remain deterministic across worker tasks", bParallelRandomResultsMatch);
FRandomStream ExpectedCircleStream(24680);
const double ExpectedCircleAngle = static_cast<double>(ExpectedCircleStream.FRand()) * UE_TWO_PI;
const double ExpectedCircleRadius = FMath::Sqrt(static_cast<double>(ExpectedCircleStream.FRand())) * 5.0;
const FVector2D ExpectedCirclePoint(
FMath::Cos(ExpectedCircleAngle) * ExpectedCircleRadius,
FMath::Sin(ExpectedCircleAngle) * ExpectedCircleRadius);
FRandomStream ActualCircleStream(24680);
const FVector2D ActualCirclePoint =
UDirectiveUtilMathFunctionLibrary::RandomPointInCircleFromStream(ActualCircleStream, 5.0f);
TestTrue("Random Point In Circle consumes angle before radius",
ActualCirclePoint.Equals(ExpectedCirclePoint, 1.e-6));
TestEqual("Random Point In Circle consumes two stream samples",
ActualCircleStream.GetCurrentSeed(), ExpectedCircleStream.GetCurrentSeed());
FRandomStream ExpectedAnnulusStream(13579);
const double ExpectedAnnulusAngle = static_cast<double>(ExpectedAnnulusStream.FRand()) * UE_TWO_PI;
const double ExpectedAnnulusRadius = FMath::Sqrt(FMath::Lerp(
4.0, 25.0, static_cast<double>(ExpectedAnnulusStream.FRand())));
const FVector2D ExpectedAnnulusPoint(
FMath::Cos(ExpectedAnnulusAngle) * ExpectedAnnulusRadius,
FMath::Sin(ExpectedAnnulusAngle) * ExpectedAnnulusRadius);
FRandomStream ActualAnnulusStream(13579);
const FVector2D ActualAnnulusPoint =
UDirectiveUtilMathFunctionLibrary::RandomPointInAnnulusFromStream(ActualAnnulusStream, 2.0f, 5.0f);
TestTrue("Random Point In Annulus consumes angle before radius",
ActualAnnulusPoint.Equals(ExpectedAnnulusPoint, 1.e-6));
TestEqual("Random Point In Annulus consumes two stream samples",
ActualAnnulusStream.GetCurrentSeed(), ExpectedAnnulusStream.GetCurrentSeed());
FRandomStream ExpectedSphereStream(97531);
FVector ExpectedSpherePoint;
double ExpectedSphereSizeSquared;
do
{
const double X = static_cast<double>(ExpectedSphereStream.FRand()) * 2.0 - 1.0;
const double Y = static_cast<double>(ExpectedSphereStream.FRand()) * 2.0 - 1.0;
const double Z = static_cast<double>(ExpectedSphereStream.FRand()) * 2.0 - 1.0;
ExpectedSpherePoint = FVector(X, Y, Z);
ExpectedSphereSizeSquared = ExpectedSpherePoint.SizeSquared();
}
while (ExpectedSphereSizeSquared > 1.0);
ExpectedSpherePoint *= 5.0;
FRandomStream ActualSphereStream(97531);
TestTrue("Random Point In Sphere consumes coordinates in XYZ order",
UDirectiveUtilMathFunctionLibrary::RandomPointInSphereFromStream(ActualSphereStream, 5.0f).Equals(
ExpectedSpherePoint, 1.e-12)
&& ActualSphereStream.GetCurrentSeed() == ExpectedSphereStream.GetCurrentSeed());
FRandomStream UnchangedStream(86420);
const int32 UnchangedSeed = UnchangedStream.GetCurrentSeed();
UDirectiveUtilMathFunctionLibrary::RandomPointInCircleFromStream(UnchangedStream, 0.0f);
UDirectiveUtilMathFunctionLibrary::RandomPointInAnnulusFromStream(UnchangedStream, 0.0f, 0.0f);
UDirectiveUtilMathFunctionLibrary::RandomPointInSphereFromStream(
UnchangedStream, std::numeric_limits<float>::infinity());
TestEqual("Invalid and zero radii do not advance random streams",
UnchangedStream.GetCurrentSeed(), UnchangedSeed);
FRandomStream DistributionStream(112358);
double CircleDistributionMean = 0.0;
double AnnulusDistributionMean = 0.0;
double SphereDistributionMean = 0.0;
constexpr int32 DistributionSampleCount = 10000;
for (int32 Index = 0; Index < DistributionSampleCount; ++Index)
{
const FVector2D CirclePoint = UDirectiveUtilMathFunctionLibrary::RandomPointInCircleFromStream(
DistributionStream, 5.0f);
const FVector2D AnnulusPoint = UDirectiveUtilMathFunctionLibrary::RandomPointInAnnulusFromStream(
DistributionStream, 2.0f, 5.0f);
const FVector SpherePoint = UDirectiveUtilMathFunctionLibrary::RandomPointInSphereFromStream(
DistributionStream, 5.0f);
CircleDistributionMean += CirclePoint.SizeSquared() / 25.0;
AnnulusDistributionMean += (AnnulusPoint.SizeSquared() - 4.0) / 21.0;
SphereDistributionMean += FMath::Pow(SpherePoint.Size() / 5.0, 3.0);
}
CircleDistributionMean /= DistributionSampleCount;
AnnulusDistributionMean /= DistributionSampleCount;
SphereDistributionMean /= DistributionSampleCount;
TestTrue("Random Point In Circle is uniform by area",
FMath::IsNearlyEqual(CircleDistributionMean, 0.5, 0.02));
TestTrue("Random Point In Annulus is uniform by area",
FMath::IsNearlyEqual(AnnulusDistributionMean, 0.5, 0.02));
TestTrue("Random Point In Sphere is uniform by volume",
FMath::IsNearlyEqual(SphereDistributionMean, 0.5, 0.02));
TestTrue("Random point functions reject non-finite radii",
UDirectiveUtilMathFunctionLibrary::RandomPointInCircle(
std::numeric_limits<float>::quiet_NaN()).IsZero()
&& UDirectiveUtilMathFunctionLibrary::RandomPointInAnnulus(
0.0f, std::numeric_limits<float>::infinity()).IsZero()
&& UDirectiveUtilMathFunctionLibrary::RandomPointInSphere(
std::numeric_limits<float>::infinity()).IsZero());
TestTrue("Angle array average accepts equivalent angles across multiple turns",
UDirectiveUtilMathFunctionLibrary::GetAngleArrayAverage({730.0f, -710.0f}, FloatResult, Strength)
&& FMath::IsNearlyEqual(FloatResult, 10.0f, 1.e-4f)
&& FMath::IsNearlyEqual(Strength, 1.0f, 1.e-4f));
FloatResult = 123.0f;
Strength = 123.0f;
TestFalse("Angle array average rejects an empty array",
UDirectiveUtilMathFunctionLibrary::GetAngleArrayAverage({}, FloatResult, Strength));
TestTrue("A rejected angle average resets both outputs",
FMath::IsNearlyZero(FloatResult) && FMath::IsNearlyZero(Strength));
FloatResult = 123.0f;
TestTrue("Weighted float average ignores unusable weights",
UDirectiveUtilMathFunctionLibrary::GetWeightedFloatArrayAverage(
{10.0f, 20.0f, 30.0f, 40.0f},
{std::numeric_limits<float>::quiet_NaN(), -1.0f, std::numeric_limits<float>::infinity(), 2.0f},
FloatResult)
&& FMath::IsNearlyEqual(FloatResult, 40.0f));
FloatResult = 123.0f;
TestFalse("Weighted float average rejects arrays without a usable weight",
UDirectiveUtilMathFunctionLibrary::GetWeightedFloatArrayAverage(
{10.0f, 20.0f}, {-1.0f, std::numeric_limits<float>::quiet_NaN()}, FloatResult));
TestTrue("A rejected weighted float average resets its output", FMath::IsNearlyZero(FloatResult));
VectorResult = FVector(123.0);
TestTrue("Weighted vector average ignores unusable weights",
UDirectiveUtilMathFunctionLibrary::GetWeightedVectorArrayAverage(
{FVector(10.0, 20.0, 30.0), FVector(-4.0, 5.0, -6.0)},
{std::numeric_limits<float>::infinity(), 3.0f}, VectorResult)
&& VectorResult.Equals(FVector(-4.0, 5.0, -6.0), 1.e-9));
VectorResult = FVector(123.0);
TestFalse("Weighted vector average rejects arrays without a usable weight",
UDirectiveUtilMathFunctionLibrary::GetWeightedVectorArrayAverage(
{FVector::ForwardVector}, {-1.0f}, VectorResult));
TestTrue("A rejected weighted vector average resets its output", VectorResult.IsZero());
FloatArrayResult = {123.0f};
TestFalse("Float array normalization rejects a non-finite source value",
UDirectiveUtilMathFunctionLibrary::NormalizeFloatArrayToRange(
{1.0f, std::numeric_limits<float>::quiet_NaN()}, 0.0f, 1.0f, FloatArrayResult));
TestTrue("Rejected float array normalization clears its output", FloatArrayResult.IsEmpty());
TestFalse("Float array normalization rejects a non-finite output bound",
UDirectiveUtilMathFunctionLibrary::NormalizeFloatArrayToRange(
{1.0f, 2.0f}, 0.0f, std::numeric_limits<float>::infinity(), FloatArrayResult));
TestTrue("Weight normalization ignores non-finite and negative weights",
UDirectiveUtilMathFunctionLibrary::NormalizeWeights(
{std::numeric_limits<float>::quiet_NaN(), std::numeric_limits<float>::infinity(), -2.0f, 4.0f},
FloatArrayResult)
&& FloatArrayResult == TArray<float>({0.0f, 0.0f, 0.0f, 1.0f}));
FloatArrayResult = {123.0f};
TestFalse("Weight normalization rejects an empty array",
UDirectiveUtilMathFunctionLibrary::NormalizeWeights({}, FloatArrayResult));
TestTrue("Rejected weight normalization clears its output", FloatArrayResult.IsEmpty());
TestTrue("Percentiles clamp below zero",
UDirectiveUtilMathFunctionLibrary::GetFloatArrayPercentile({7.0f, 3.0f, 11.0f}, -50.0f, FloatResult)
&& FMath::IsNearlyEqual(FloatResult, 3.0f));
TestTrue("Percentiles return the maximum at one hundred",
UDirectiveUtilMathFunctionLibrary::GetFloatArrayPercentile({7.0f, 3.0f, 11.0f}, 100.0f, FloatResult)
&& FMath::IsNearlyEqual(FloatResult, 11.0f));
TestTrue("A single-value percentile is stable at every finite percentile",
UDirectiveUtilMathFunctionLibrary::GetFloatArrayPercentile({-7.5f}, 37.25f, FloatResult)
&& FMath::IsNearlyEqual(FloatResult, -7.5f));
FloatResult = 123.0f;
TestFalse("Percentiles reject a non-finite percentile",
UDirectiveUtilMathFunctionLibrary::GetFloatArrayPercentile(
{1.0f, 2.0f}, std::numeric_limits<float>::quiet_NaN(), FloatResult));
TestTrue("A rejected percentile resets its output", FMath::IsNearlyZero(FloatResult));
const float LargeFiniteValue = std::numeric_limits<float>::max() * 0.25f;
TestTrue("Root mean square remains finite near the float limit",
UDirectiveUtilMathFunctionLibrary::GetFloatArrayRootMeanSquare(
{LargeFiniteValue, -LargeFiniteValue}, FloatResult)
&& FMath::IsFinite(FloatResult)
&& FMath::IsNearlyEqual(FloatResult / LargeFiniteValue, 1.0f, 1.e-5f));
FloatResult = 123.0f;
TestFalse("Root mean square rejects non-finite values",
UDirectiveUtilMathFunctionLibrary::GetFloatArrayRootMeanSquare(
{1.0f, std::numeric_limits<float>::infinity()}, FloatResult));
TestTrue("A rejected root mean square resets its output", FMath::IsNearlyZero(FloatResult));
TestTrue("Signed angle is invariant under positive vector scaling",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SignedAngleBetweenVectors(
FVector(20.0, 0.0, 5.0), FVector(0.0, 30.0, -7.0), FVector(0.0, 0.0, 9.0)), 90.0f, 1.e-4f));
TestTrue("A half-turn signed angle has the expected magnitude",
FMath::IsNearlyEqual(FMath::Abs(UDirectiveUtilMathFunctionLibrary::SignedAngleBetweenVectors(
FVector::ForwardVector, -FVector::ForwardVector, FVector::UpVector)), 180.0f, 1.e-4f));
TestEqual("Signed angle rejects non-finite vectors",
UDirectiveUtilMathFunctionLibrary::SignedAngleBetweenVectors(
FVector(std::numeric_limits<double>::infinity(), 0.0, 0.0), FVector::RightVector, FVector::UpVector),
0.0f);
TestTrue("Delta angle ignores complete turns",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::DeltaAngle(-1080.0f + 15.0f, 1440.0f - 25.0f), -40.0f));
TestTrue("Angle interpolation permits negative extrapolation",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::LerpAngle(10.0f, 350.0f, -1.0f), 30.0f));
TestTrue("Angle interpolation ignores complete turns in the delta",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::LerpAngle(
-1080.0f + 15.0f, 1440.0f - 25.0f, 0.5f), -1085.0f));
TestTrue("Ping Pong repeats across multiple positive periods",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::PingPong(123.0f, -2.0f, 3.0f), 3.0f));
TestTrue("Ping Pong repeats across multiple negative periods",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::PingPong(-122.0f, -2.0f, 3.0f), -2.0f));
TestTrue("Smooth Step clamps above its range",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SmoothStep(100.0f, -2.0f, 3.0f), 1.0f));
TestTrue("Smoother Step accepts reversed bounds",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SmootherStep(0.25f, 1.0f, 0.0f), 0.103515625f));
TestTrue("Step functions reject non-finite values",
FMath::IsNearlyZero(UDirectiveUtilMathFunctionLibrary::SmoothStep(
std::numeric_limits<float>::quiet_NaN(), 0.0f, 1.0f))
&& FMath::IsNearlyZero(UDirectiveUtilMathFunctionLibrary::SmootherStep(
0.5f, 0.0f, std::numeric_limits<float>::infinity())));
TestTrue("Range Falloff accepts reversed radii",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::RangeFalloff(5.0f, 10.0f, 0.0f), 0.5f));
TestTrue("Range Falloff clamps negative distances to zero",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::RangeFalloff(-5.0f, 2.0f, 10.0f), 1.0f));
TestTrue("Range Falloff treats non-positive exponents as a hard inner range",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::RangeFalloff(9.0f, 2.0f, 10.0f, -3.0f), 1.0f));
TestTrue("Range Falloff is full strength at a collapsed shared radius",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::RangeFalloff(5.0f, 5.0f, 5.0f), 1.0f));
TestTrue("Range Falloff is full strength at the origin when both radii are zero",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::RangeFalloff(0.0f, 0.0f, 0.0f), 1.0f));
TestTrue("Range Falloff is zero outside a collapsed shared radius",
FMath::IsNearlyZero(UDirectiveUtilMathFunctionLibrary::RangeFalloff(6.0f, 5.0f, 5.0f)));
TestTrue("A full-width cone includes the opposite direction",
UDirectiveUtilMathFunctionLibrary::IsDirectionWithinCone(
-FVector::ForwardVector, FVector::ForwardVector, 180.0f));
TestFalse("A zero-width cone excludes the opposite direction",
UDirectiveUtilMathFunctionLibrary::IsDirectionWithinCone(
-FVector::ForwardVector, FVector::ForwardVector, 0.0f));
TestTrue("A point on both cone boundaries is included",
UDirectiveUtilMathFunctionLibrary::IsPointWithinCone(
FVector(5.0, 5.0, 0.0), FVector::ZeroVector, FVector::ForwardVector, 45.0f,
FMath::Sqrt(50.0)));
TestTrue("Negative cone distance is treated as unlimited",
UDirectiveUtilMathFunctionLibrary::IsPointWithinCone(
FVector(100.0, 0.0, 0.0), FVector::ZeroVector, FVector::ForwardVector, 0.0f, -1.0));
TestTrue("Rotating by a complete turn preserves a translated point",
UDirectiveUtilMathFunctionLibrary::RotatePointAroundPivot2D(
FVector2D(1000003.0, -1999995.0), FVector2D(1000000.0, -2000000.0), 1080.0f)
.Equals(FVector2D(1000003.0, -1999995.0), 1.e-8));
TestTrue("Signed plane distance is translation invariant",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SignedDistanceToPlane(
FVector(1000000.0, -2000000.0, 3000007.0), FVector(1000000.0, -2000000.0, 3000000.0),
FVector(0.0, 0.0, 123.0)), 7.0, 1.e-9));
FRandomStream PositiveRadiusStream(424242);
FRandomStream NegativeRadiusStream(424242);
TestTrue("Random circle stream treats negative radius as magnitude",
UDirectiveUtilMathFunctionLibrary::RandomPointInCircleFromStream(PositiveRadiusStream, 5.0f).Equals(
UDirectiveUtilMathFunctionLibrary::RandomPointInCircleFromStream(NegativeRadiusStream, -5.0f), 1.e-12));
FRandomStream OrderedAnnulusStream(31337);
FRandomStream ReversedAnnulusStream(31337);
TestTrue("Random annulus stream accepts negative reversed radii",
UDirectiveUtilMathFunctionLibrary::RandomPointInAnnulusFromStream(OrderedAnnulusStream, 2.0f, 5.0f).Equals(
UDirectiveUtilMathFunctionLibrary::RandomPointInAnnulusFromStream(ReversedAnnulusStream, -5.0f, -2.0f),
1.e-12));
FRandomStream PositiveSphereStream(8675309);
FRandomStream NegativeSphereStream(8675309);
TestTrue("Random sphere stream treats negative radius as magnitude",
UDirectiveUtilMathFunctionLibrary::RandomPointInSphereFromStream(PositiveSphereStream, 5.0f).Equals(
UDirectiveUtilMathFunctionLibrary::RandomPointInSphereFromStream(NegativeSphereStream, -5.0f), 1.e-12));
return !HasAnyErrors();
}

View File

@@ -1,7 +1,11 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilMathFunctionLibrary.h"
#include "Misc/AutomationTest.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilMathFunctionLibraryTest, "DirectiveUtilities.MathFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
#include <limits>
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilMathFunctionLibraryTest, "DirectiveUtilities.MathFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilMathFunctionLibraryTest::RunTest(const FString& Parameters)
{
@@ -11,8 +15,84 @@ bool FDirectiveUtilMathFunctionLibraryTest::RunTest(const FString& Parameters)
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::AngleBetweenVectors(FVector::ForwardVector, FVector::RightVector), 90.0f, 0.01f));
TestTrue("AngleBetweenVectors should return ~180 for opposite vectors",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::AngleBetweenVectors(FVector::ForwardVector, -FVector::ForwardVector), 180.0f, 0.01f));
TestTrue("AngleBetweenVectors should handle zero vector gracefully",
FMath::IsFinite(UDirectiveUtilMathFunctionLibrary::AngleBetweenVectors(FVector::ZeroVector, FVector::ForwardVector)));
TestEqual("AngleBetweenVectors should return 0 for a zero vector",
UDirectiveUtilMathFunctionLibrary::AngleBetweenVectors(FVector::ZeroVector, FVector::ForwardVector), 0.0f);
TestEqual("AngleBetweenVectors should return 0 for two zero vectors",
UDirectiveUtilMathFunctionLibrary::AngleBetweenVectors(FVector::ZeroVector, FVector::ZeroVector), 0.0f);
TestTrue("SignedAngleBetweenVectors returns a positive counterclockwise angle around the axis",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SignedAngleBetweenVectors(
FVector::ForwardVector, FVector::RightVector, FVector::UpVector), 90.0f, 1.e-4f));
TestTrue("SignedAngleBetweenVectors returns a negative clockwise angle around the axis",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SignedAngleBetweenVectors(
FVector::RightVector, FVector::ForwardVector, FVector::UpVector), -90.0f, 1.e-4f));
TestTrue("SignedAngleBetweenVectors reverses sign with the axis",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SignedAngleBetweenVectors(
FVector::ForwardVector, FVector::RightVector, -FVector::UpVector), -90.0f, 1.e-4f));
TestTrue("SignedAngleBetweenVectors projects directions onto the axis plane",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SignedAngleBetweenVectors(
FVector(1.0, 0.0, 4.0), FVector(0.0, 1.0, -3.0), FVector::UpVector), 90.0f, 1.e-4f));
TestEqual("SignedAngleBetweenVectors returns zero for a zero direction",
UDirectiveUtilMathFunctionLibrary::SignedAngleBetweenVectors(FVector::ZeroVector, FVector::RightVector, FVector::UpVector), 0.0f);
TestEqual("SignedAngleBetweenVectors returns zero for a direction parallel to the axis",
UDirectiveUtilMathFunctionLibrary::SignedAngleBetweenVectors(FVector::UpVector, FVector::RightVector, FVector::UpVector), 0.0f);
TestEqual("SignedAngleBetweenVectors returns zero for a zero axis",
UDirectiveUtilMathFunctionLibrary::SignedAngleBetweenVectors(FVector::ForwardVector, FVector::RightVector, FVector::ZeroVector), 0.0f);
TestTrue("DeltaAngle crosses the positive angle seam by the shortest path",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::DeltaAngle(350.0f, 10.0f), 20.0f, 1.e-4f));
TestTrue("DeltaAngle crosses the negative angle seam by the shortest path",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::DeltaAngle(10.0f, 350.0f), -20.0f, 1.e-4f));
TestEqual("DeltaAngle returns zero for equivalent wrapped angles",
UDirectiveUtilMathFunctionLibrary::DeltaAngle(-180.0f, 180.0f), 0.0f);
TestTrue("DeltaAngle canonicalizes an exactly opposite pair to +180",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::DeltaAngle(0.0f, 180.0f), 180.0f, 1.e-4f)
&& FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::DeltaAngle(0.0f, -180.0f), 180.0f, 1.e-4f)
&& FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::DeltaAngle(0.0f, 540.0f), 180.0f, 1.e-4f)
&& FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::DeltaAngle(0.0f, -540.0f), 180.0f, 1.e-4f));
TestEqual("DeltaAngle returns zero for non-finite input",
UDirectiveUtilMathFunctionLibrary::DeltaAngle(std::numeric_limits<float>::infinity(), 0.0f), 0.0f);
TestTrue("LerpAngle crosses the angle seam by the shortest path",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::LerpAngle(350.0f, 10.0f, 0.5f), 360.0f, 1.e-4f));
TestTrue("LerpAngle returns A at Alpha 0",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::LerpAngle(350.0f, 10.0f, 0.0f), 350.0f, 1.e-4f));
TestTrue("LerpAngle reaches A plus the shortest delta at Alpha 1",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::LerpAngle(350.0f, 10.0f, 1.0f), 370.0f, 1.e-4f));
TestTrue("LerpAngle permits extrapolation without wrapping the result",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::LerpAngle(0.0f, 90.0f, 2.0f), 180.0f, 1.e-4f)
&& FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::LerpAngle(0.0f, 90.0f, 3.0f), 270.0f, 1.e-4f));
TestEqual("LerpAngle returns zero for non-finite input",
UDirectiveUtilMathFunctionLibrary::LerpAngle(0.0f, 90.0f, std::numeric_limits<float>::quiet_NaN()), 0.0f);
TestTrue("PingPong reaches the middle of an ascending range",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::PingPong(0.5f, 0.0f, 1.0f), 0.5f, 1.e-4f));
TestTrue("PingPong reverses after the upper bound",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::PingPong(1.5f, 0.0f, 1.0f), 0.5f, 1.e-4f));
TestTrue("PingPong supports negative values",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::PingPong(-0.25f, 0.0f, 1.0f), 0.25f, 1.e-4f));
TestTrue("PingPong accepts reversed bounds",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::PingPong(12.5f, 20.0f, 10.0f), 12.5f, 1.e-4f));
TestEqual("PingPong returns the shared bound for a zero-sized range",
UDirectiveUtilMathFunctionLibrary::PingPong(100.0f, 7.0f, 7.0f), 7.0f);
TestEqual("PingPong returns zero for non-finite input",
UDirectiveUtilMathFunctionLibrary::PingPong(std::numeric_limits<float>::infinity(), 0.0f, 1.0f), 0.0f);
TestTrue("IsDirectionWithinCone includes a direction inside the cone",
UDirectiveUtilMathFunctionLibrary::IsDirectionWithinCone(FVector(1.0, 1.0, 0.0), FVector::ForwardVector, 46.0f));
TestTrue("IsDirectionWithinCone includes a direction on the cone boundary",
UDirectiveUtilMathFunctionLibrary::IsDirectionWithinCone(FVector(1.0, 1.0, 0.0), FVector::ForwardVector, 45.0f));
TestFalse("IsDirectionWithinCone excludes a direction outside the cone",
UDirectiveUtilMathFunctionLibrary::IsDirectionWithinCone(FVector::RightVector, FVector::ForwardVector, 45.0f));
TestTrue("IsDirectionWithinCone clamps angles above 180 degrees",
UDirectiveUtilMathFunctionLibrary::IsDirectionWithinCone(-FVector::ForwardVector, FVector::ForwardVector, 270.0f));
TestFalse("IsDirectionWithinCone clamps negative angles to zero",
UDirectiveUtilMathFunctionLibrary::IsDirectionWithinCone(FVector(1.0, 0.1, 0.0), FVector::ForwardVector, -20.0f));
TestFalse("IsDirectionWithinCone rejects a zero direction",
UDirectiveUtilMathFunctionLibrary::IsDirectionWithinCone(FVector::ZeroVector, FVector::ForwardVector, 45.0f));
TestFalse("IsDirectionWithinCone rejects a non-finite angle",
UDirectiveUtilMathFunctionLibrary::IsDirectionWithinCone(
FVector::ForwardVector, FVector::ForwardVector, std::numeric_limits<float>::quiet_NaN()));
{
const FVector2D Sample2D(12.34f, 56.78f);
@@ -75,15 +155,16 @@ bool FDirectiveUtilMathFunctionLibraryTest::RunTest(const FString& Parameters)
const TArray<EDirectiveUtilEaseType> AllEaseTypes = {
EDirectiveUtilEaseType::BackIn, EDirectiveUtilEaseType::BackOut, EDirectiveUtilEaseType::BackInOut,
EDirectiveUtilEaseType::ElasticIn, EDirectiveUtilEaseType::ElasticOut, EDirectiveUtilEaseType::ElasticInOut,
EDirectiveUtilEaseType::BounceIn, EDirectiveUtilEaseType::BounceOut, EDirectiveUtilEaseType::BounceInOut
EDirectiveUtilEaseType::BounceIn, EDirectiveUtilEaseType::BounceOut, EDirectiveUtilEaseType::BounceInOut,
EDirectiveUtilEaseType::Linear
};
for (const EDirectiveUtilEaseType EaseType : AllEaseTypes)
{
const FString TypeName = FString::FromInt(static_cast<int32>(EaseType));
TestTrue(FString::Printf(TEXT("EaseAlpha(0) should be ~0 for type %s"), *TypeName),
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::EaseAlpha(0.0f, EaseType), 0.0f, 1.e-3f));
TestTrue(FString::Printf(TEXT("EaseAlpha(1) should be ~1 for type %s"), *TypeName),
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::EaseAlpha(1.0f, EaseType), 1.0f, 1.e-3f));
TestEqual(FString::Printf(TEXT("EaseAlpha(0) should be exactly 0 for type %s"), *TypeName),
UDirectiveUtilMathFunctionLibrary::EaseAlpha(0.0f, EaseType), 0.0f);
TestEqual(FString::Printf(TEXT("EaseAlpha(1) should be exactly 1 for type %s"), *TypeName),
UDirectiveUtilMathFunctionLibrary::EaseAlpha(1.0f, EaseType), 1.0f);
for (const float Sample : {0.0f, 0.25f, 0.5f, 0.75f, 1.0f})
{
TestTrue(FString::Printf(TEXT("EaseAlpha(%.2f) should be finite for type %s"), Sample, *TypeName),
@@ -94,6 +175,22 @@ bool FDirectiveUtilMathFunctionLibraryTest::RunTest(const FString& Parameters)
TestEqual("EaseAlpha should clamp alpha below 0",
UDirectiveUtilMathFunctionLibrary::EaseAlpha(-1.0f, EDirectiveUtilEaseType::BounceOut),
UDirectiveUtilMathFunctionLibrary::EaseAlpha(0.0f, EDirectiveUtilEaseType::BounceOut));
TestEqual("Linear ease should pass the clamped alpha through",
UDirectiveUtilMathFunctionLibrary::EaseAlpha(0.3f, EDirectiveUtilEaseType::Linear), 0.3f);
const FTransform EaseStart(FRotator::ZeroRotator, FVector::ZeroVector, FVector::OneVector);
const FTransform EaseTarget(FRotator(0.0, 90.0, 0.0), FVector(10.0, 0.0, 0.0), FVector(3.0));
const FTransform EasedMidpoint = UDirectiveUtilMathFunctionLibrary::EaseTransform(
EaseStart, EaseTarget, 0.5f, EDirectiveUtilEaseType::Linear);
TestTrue("EaseTransform should blend location, rotation, and scale",
EasedMidpoint.GetLocation().Equals(FVector(5.0, 0.0, 0.0), 1.e-4)
&& EasedMidpoint.GetRotation().Equals(FRotator(0.0, 45.0, 0.0).Quaternion(), 1.e-4)
&& EasedMidpoint.GetScale3D().Equals(FVector(2.0), 1.e-4));
TestTrue("EaseTransform should return its endpoints at alpha 0 and 1",
UDirectiveUtilMathFunctionLibrary::EaseTransform(
EaseStart, EaseTarget, 0.0f, EDirectiveUtilEaseType::BounceOut).Equals(EaseStart, 1.e-4)
&& UDirectiveUtilMathFunctionLibrary::EaseTransform(
EaseStart, EaseTarget, 1.0f, EDirectiveUtilEaseType::BounceOut).Equals(EaseTarget, 1.e-4));
TestEqual("EaseAlpha should clamp alpha above 1",
UDirectiveUtilMathFunctionLibrary::EaseAlpha(2.0f, EDirectiveUtilEaseType::BounceOut),
UDirectiveUtilMathFunctionLibrary::EaseAlpha(1.0f, EDirectiveUtilEaseType::BounceOut));
@@ -147,6 +244,10 @@ bool FDirectiveUtilMathFunctionLibraryTest::RunTest(const FString& Parameters)
UDirectiveUtilMathFunctionLibrary::GetRandomIndexFromWeights(TArray<float>()), static_cast<int32>(INDEX_NONE));
TestEqual("Weighted random with all-zero weights returns INDEX_NONE",
UDirectiveUtilMathFunctionLibrary::GetRandomIndexFromWeights({0.0f, 0.0f, 0.0f}), static_cast<int32>(INDEX_NONE));
TestEqual("Weighted random ignores non-finite weights",
UDirectiveUtilMathFunctionLibrary::GetRandomIndexFromWeights({std::numeric_limits<float>::quiet_NaN(), 1.0f, std::numeric_limits<float>::infinity()}), 1);
TestTrue("Weighted random handles large finite totals",
UDirectiveUtilMathFunctionLibrary::GetRandomIndexFromWeights({MAX_flt, MAX_flt}) != INDEX_NONE);
for (int32 Iteration = 0; Iteration < 25; ++Iteration)
{
TestEqual("Weighted random {0,1,0} always selects index 1",
@@ -172,6 +273,10 @@ bool FDirectiveUtilMathFunctionLibraryTest::RunTest(const FString& Parameters)
UDirectiveUtilMathFunctionLibrary::FormatDuration(45.0f).ToString(), FString(TEXT("45s")));
TestEqual("FormatDuration(-90) is \"-1m 30s\"",
UDirectiveUtilMathFunctionLibrary::FormatDuration(-90.0f).ToString(), FString(TEXT("-1m 30s")));
TestEqual("FormatDuration(-45, false) is \"0m\"",
UDirectiveUtilMathFunctionLibrary::FormatDuration(-45.0f, false).ToString(), FString(TEXT("0m")));
TestFalse("FormatDuration handles the largest finite float without wrapping negative",
UDirectiveUtilMathFunctionLibrary::FormatDuration(MAX_flt).ToString().StartsWith(TEXT("-")));
TestEqual("FormatRelativeTime 5 minutes back reads \"5 minutes ago\"",
UDirectiveUtilMathFunctionLibrary::FormatRelativeTime(FDateTime::Now() - FTimespan::FromMinutes(5)).ToString(), FString(TEXT("5 minutes ago")));
@@ -231,6 +336,50 @@ bool FDirectiveUtilMathFunctionLibraryTest::RunTest(const FString& Parameters)
UDirectiveUtilMathFunctionLibrary::GetFloatArrayStandardDeviation(EmptyFloats), 0.0f);
}
TestTrue("GetIntArrayMedian should average the full int32 range without overflow",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::GetIntArrayMedian({MIN_int32, MAX_int32}), -0.5f, 1.e-4f));
TestEqual("GetIntArrayMedian should handle repeated values",
UDirectiveUtilMathFunctionLibrary::GetIntArrayMedian({7, 7, 7, 7, 7}), 7.0f);
TestEqual("GetFloatArrayMedian should handle repeated values",
UDirectiveUtilMathFunctionLibrary::GetFloatArrayMedian({7.5f, 7.5f, 7.5f, 7.5f}), 7.5f);
TestTrue("GetFloatArrayMedian should return NaN when any input is NaN",
FMath::IsNaN(UDirectiveUtilMathFunctionLibrary::GetFloatArrayMedian({1.0f, std::numeric_limits<float>::quiet_NaN(), 3.0f})));
FRandomStream MedianStream(481516);
for (int32 Iteration = 0; Iteration < 200; ++Iteration)
{
const int32 Count = MedianStream.RandRange(1, 257);
TArray<int32> IntValues;
TArray<float> FloatValues;
IntValues.Reserve(Count);
FloatValues.Reserve(Count);
for (int32 Index = 0; Index < Count; ++Index)
{
const int32 Value = MedianStream.RandRange(-1000, 1000);
IntValues.Add(Value);
FloatValues.Add(static_cast<float>(Value) * 0.25f);
}
TArray<int32> SortedInts = IntValues;
SortedInts.Sort();
const int32 Middle = SortedInts.Num() / 2;
const float ExpectedIntMedian = SortedInts.Num() % 2 == 0
? static_cast<float>((static_cast<double>(SortedInts[Middle - 1]) + static_cast<double>(SortedInts[Middle])) * 0.5)
: static_cast<float>(SortedInts[Middle]);
TestTrue(
FString::Printf(TEXT("Integer median fuzz case %d"), Iteration),
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::GetIntArrayMedian(IntValues), ExpectedIntMedian, 1.e-4f));
TArray<float> SortedFloats = FloatValues;
SortedFloats.Sort();
const float ExpectedFloatMedian = SortedFloats.Num() % 2 == 0
? static_cast<float>((static_cast<double>(SortedFloats[Middle - 1]) + static_cast<double>(SortedFloats[Middle])) * 0.5)
: SortedFloats[Middle];
TestTrue(
FString::Printf(TEXT("Float median fuzz case %d"), Iteration),
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::GetFloatArrayMedian(FloatValues), ExpectedFloatMedian, 1.e-4f));
}
FRandomStream StreamA(12345);
FRandomStream StreamB(12345);
const int32 StreamIndexA = UDirectiveUtilMathFunctionLibrary::GetRandomIndexFromWeightsFromStream(StreamA, {1.0f, 1.0f, 1.0f, 1.0f});

View File

@@ -0,0 +1,83 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilMathFunctionLibrary.h"
#include "Misc/AutomationTest.h"
namespace
{
template <typename ValueType>
double GetSortedMedian(TArray<ValueType> Values)
{
Values.Sort();
const int32 Middle = Values.Num() / 2;
if (Values.Num() % 2 != 0)
{
return static_cast<double>(Values[Middle]);
}
return (static_cast<double>(Values[Middle - 1]) + static_cast<double>(Values[Middle])) * 0.5;
}
TArray<int32> MakeMedianValues(const int32 Count, const int32 Pattern)
{
TArray<int32> Values;
Values.Reserve(Count);
for (int32 Index = 0; Index < Count; ++Index)
{
switch (Pattern)
{
case 0:
Values.Add(Index - Count / 2);
break;
case 1:
Values.Add(Count - Index);
break;
case 2:
Values.Add(Index % 7);
break;
default:
Values.Add(Index % 2 == 0 ? MIN_int32 : MAX_int32);
break;
}
}
return Values;
}
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilMathCardinalityTest,
"DirectiveUtilities.MathScenarios.MedianCardinality",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilMathCardinalityTest::RunTest(const FString& Parameters)
{
for (const int32 ItemCount : {1, 2, 3, 4, 5, 16, 17, 256, 257, 4096, 4097})
{
for (int32 Pattern = 0; Pattern < 4; ++Pattern)
{
const TArray<int32> IntegerValues = MakeMedianValues(ItemCount, Pattern);
TArray<float> FloatValues;
FloatValues.Reserve(ItemCount);
for (const int32 Value : IntegerValues)
{
FloatValues.Add(static_cast<float>(Value) * 0.25f);
}
const FString Label = FString::Printf(TEXT("items=%d pattern=%d"), ItemCount, Pattern);
TestTrue(
Label + TEXT(" integer median"),
FMath::IsNearlyEqual(
UDirectiveUtilMathFunctionLibrary::GetIntArrayMedian(IntegerValues),
static_cast<float>(GetSortedMedian(IntegerValues)),
1.e-4f));
TestTrue(
Label + TEXT(" float median"),
FMath::IsNearlyEqual(
UDirectiveUtilMathFunctionLibrary::GetFloatArrayMedian(FloatValues),
static_cast<float>(GetSortedMedian(FloatValues)),
1.e-4f));
}
}
return true;
}

View File

@@ -0,0 +1,837 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilMathFunctionLibrary.h"
#include "Components/SplineComponent.h"
#include "Misc/AutomationTest.h"
#include "UObject/Class.h"
#include <limits>
namespace
{
bool PointsEqual(const TArray<FVector>& A, const TArray<FVector>& B, const double Tolerance = 1.e-9)
{
if (A.Num() != B.Num())
{
return false;
}
for (int32 Index = 0; Index < A.Num(); ++Index)
{
if (!A[Index].Equals(B[Index], Tolerance))
{
return false;
}
}
return true;
}
bool PointsEqualAfterTranslation(const TArray<FVector>& BasePoints, const TArray<FVector>& TranslatedPoints,
const FVector& Translation, const double Tolerance = 1.e-6)
{
if (BasePoints.Num() != TranslatedPoints.Num())
{
return false;
}
for (int32 Index = 0; Index < BasePoints.Num(); ++Index)
{
if (!(TranslatedPoints[Index] - Translation).Equals(BasePoints[Index], Tolerance))
{
return false;
}
}
return true;
}
bool PointsMatchLocation(const TArray<FVector>& Points, const FVector& Location, const double Tolerance = 1.e-9)
{
for (const FVector& Point : Points)
{
if (!Point.Equals(Location, Tolerance))
{
return false;
}
}
return true;
}
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilPointGenerationTest,
"DirectiveUtilities.Math.PointGeneration",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilPointGenerationTest::RunTest(const FString& Parameters)
{
const FName CallableGeneratorNames[] = {
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GenerateGridPoints2D),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GenerateGridPoints3D),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GenerateGridTransforms2D),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GenerateGridTransforms3D),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GenerateRectangularHexGrid),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GenerateRectangularHexGridTransforms),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GetRectangularHexGridCoordinates),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GenerateHexagonalHexGrid),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GenerateHexagonalHexGridTransforms),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GetHexesInRange),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GetHexRing),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GetHexLine),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GeneratePointsAlongDirection),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GeneratePointsBetweenLocations),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GeneratePointsOnCircle),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GenerateTransformsOnCircle),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GeneratePointsOnArc),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GenerateTransformsOnArc),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GeneratePointsOnDisc),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GeneratePointsOnSphere)
};
for (const FName FunctionName : CallableGeneratorNames)
{
const UFunction* Function = UDirectiveUtilMathFunctionLibrary::StaticClass()->FindFunctionByName(FunctionName);
TestNotNull(*FString::Printf(TEXT("%s should be exposed to Blueprint"), *FunctionName.ToString()), Function);
if (Function)
{
TestTrue(*FString::Printf(TEXT("%s should be callable"), *FunctionName.ToString()),
Function->HasAnyFunctionFlags(FUNC_BlueprintCallable));
TestFalse(*FString::Printf(TEXT("%s should not execute as a pure node"), *FunctionName.ToString()),
Function->HasAnyFunctionFlags(FUNC_BlueprintPure));
#if WITH_EDITOR
TestFalse(*FString::Printf(TEXT("%s should not advertise inert Blueprint thread safety"), *FunctionName.ToString()),
Function->HasMetaData(TEXT("BlueprintThreadSafe")));
#endif
}
}
const TArray<FVector> Grid2D = UDirectiveUtilMathFunctionLibrary::GenerateGridPoints2D(
FVector::ZeroVector, FRotator::ZeroRotator, FIntPoint(3, 2), FVector2D(10.0, 20.0), true);
const TArray<FVector> ExpectedGrid2D = {
FVector(-10.0, -10.0, 0.0), FVector(0.0, -10.0, 0.0), FVector(10.0, -10.0, 0.0),
FVector(-10.0, 10.0, 0.0), FVector(0.0, 10.0, 0.0), FVector(10.0, 10.0, 0.0)
};
TestTrue(TEXT("2D grid is centered and ordered by X then Y"), PointsEqual(Grid2D, ExpectedGrid2D));
const TArray<FVector> RotatedGrid2D = UDirectiveUtilMathFunctionLibrary::GenerateGridPoints2D(
FVector(5.0, 7.0, 9.0), FRotator(0.0, 90.0, 0.0), FIntPoint(2, 1), FVector2D(3.0, 0.0), false);
TestTrue(TEXT("2D grid rotation places its local X axis in world space"),
RotatedGrid2D.Num() == 2
&& RotatedGrid2D[0].Equals(FVector(5.0, 7.0, 9.0), 1.e-9)
&& RotatedGrid2D[1].Equals(FVector(5.0, 10.0, 9.0), 1.e-9));
const TArray<FVector> Grid3D = UDirectiveUtilMathFunctionLibrary::GenerateGridPoints3D(
FVector::ZeroVector, FRotator::ZeroRotator, FIntVector(2, 2, 2), FVector(2.0, 4.0, 6.0), true);
TestTrue(TEXT("3D grid is centered and ordered by X then Y then Z"),
Grid3D.Num() == 8
&& Grid3D[0].Equals(FVector(-1.0, -2.0, -3.0), 1.e-9)
&& Grid3D[1].Equals(FVector(1.0, -2.0, -3.0), 1.e-9)
&& Grid3D[2].Equals(FVector(-1.0, 2.0, -3.0), 1.e-9)
&& Grid3D.Last().Equals(FVector(1.0, 2.0, 3.0), 1.e-9));
TestTrue(TEXT("Grid generation rejects non-positive dimensions"),
UDirectiveUtilMathFunctionLibrary::GenerateGridPoints2D(
FVector::ZeroVector, FRotator::ZeroRotator, FIntPoint(2, 0), FVector2D(1.0), true).IsEmpty());
TestTrue(TEXT("Grid generation rejects point-count overflow"),
UDirectiveUtilMathFunctionLibrary::GenerateGridPoints3D(
FVector::ZeroVector, FRotator::ZeroRotator, FIntVector(MAX_int32, 2, 2), FVector::OneVector, true).IsEmpty());
const FRotator GridInstanceRotation(10.0, 20.0, 30.0);
const FQuat GridInstanceQuaternion = GridInstanceRotation.Quaternion();
const FVector GridInstanceScale(0.25, 0.5, 0.75);
const TArray<FTransform> GridTransforms2D = UDirectiveUtilMathFunctionLibrary::GenerateGridTransforms2D(
FVector::ZeroVector, FRotator::ZeroRotator, FIntPoint(3, 2), FVector2D(10.0, 20.0), true,
GridInstanceRotation, GridInstanceScale);
bool bGridTransforms2DValid = GridTransforms2D.Num() == Grid2D.Num();
for (int32 Index = 0; Index < GridTransforms2D.Num(); ++Index)
{
bGridTransforms2DValid &= GridTransforms2D[Index].GetLocation().Equals(Grid2D[Index], 1.e-9);
bGridTransforms2DValid &= GridTransforms2D[Index].GetRotation().Equals(GridInstanceQuaternion, 1.e-12);
bGridTransforms2DValid &= GridTransforms2D[Index].GetScale3D() == GridInstanceScale;
}
TestTrue(TEXT("2D grid transforms match point locations and broadcast rotation and scale"),
bGridTransforms2DValid);
const TArray<FTransform> GridTransforms3D = UDirectiveUtilMathFunctionLibrary::GenerateGridTransforms3D(
FVector::ZeroVector, FRotator::ZeroRotator, FIntVector(2, 2, 2), FVector(2.0, 4.0, 6.0), true,
GridInstanceRotation, GridInstanceScale);
bool bGridTransforms3DValid = GridTransforms3D.Num() == Grid3D.Num();
for (int32 Index = 0; Index < GridTransforms3D.Num(); ++Index)
{
bGridTransforms3DValid &= GridTransforms3D[Index].GetLocation().Equals(Grid3D[Index], 1.e-9);
bGridTransforms3DValid &= GridTransforms3D[Index].GetRotation().Equals(GridInstanceQuaternion, 1.e-12);
bGridTransforms3DValid &= GridTransforms3D[Index].GetScale3D() == GridInstanceScale;
}
TestTrue(TEXT("3D grid transforms match point locations and broadcast rotation and scale"),
bGridTransforms3DValid);
TestTrue(TEXT("Grid transform generation rejects invalid dimensions and non-finite scale"),
UDirectiveUtilMathFunctionLibrary::GenerateGridTransforms2D(
FVector::ZeroVector, FRotator::ZeroRotator, FIntPoint(2, 0), FVector2D(1.0), true).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GenerateGridTransforms3D(
FVector::ZeroVector, FRotator::ZeroRotator, FIntVector(2, 2, 2), FVector::OneVector, true,
FRotator::ZeroRotator, FVector(std::numeric_limits<double>::infinity())).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GenerateGridTransforms3D(
FVector::ZeroVector, FRotator::ZeroRotator, FIntVector(MAX_int32, 2, 2), FVector::OneVector, true)
.IsEmpty());
const double HexRadius = 10.0;
const FVector PointyQ = UDirectiveUtilMathFunctionLibrary::HexCoordinateToLocation(
FIntPoint(1, 0), FVector::ZeroVector, FRotator::ZeroRotator, HexRadius,
EDirectiveUtilHexOrientation::PointyTop);
const FVector PointyR = UDirectiveUtilMathFunctionLibrary::HexCoordinateToLocation(
FIntPoint(0, 1), FVector::ZeroVector, FRotator::ZeroRotator, HexRadius,
EDirectiveUtilHexOrientation::PointyTop);
TestTrue(TEXT("Pointy-top axial coordinates use the expected basis"),
PointyQ.Equals(FVector(UE_DOUBLE_SQRT_3 * HexRadius, 0.0, 0.0), 1.e-9)
&& PointyR.Equals(FVector(UE_DOUBLE_SQRT_3 * 0.5 * HexRadius, 15.0, 0.0), 1.e-9));
const FVector FlatQ = UDirectiveUtilMathFunctionLibrary::HexCoordinateToLocation(
FIntPoint(1, 0), FVector::ZeroVector, FRotator::ZeroRotator, HexRadius,
EDirectiveUtilHexOrientation::FlatTop);
const FVector FlatR = UDirectiveUtilMathFunctionLibrary::HexCoordinateToLocation(
FIntPoint(0, 1), FVector::ZeroVector, FRotator::ZeroRotator, HexRadius,
EDirectiveUtilHexOrientation::FlatTop);
TestTrue(TEXT("Flat-top axial coordinates use the expected basis"),
FlatQ.Equals(FVector(15.0, UE_DOUBLE_SQRT_3 * 0.5 * HexRadius, 0.0), 1.e-9)
&& FlatR.Equals(FVector(0.0, UE_DOUBLE_SQRT_3 * HexRadius, 0.0), 1.e-9));
const FVector HexOrigin(11.0, 13.0, 17.0);
const FRotator HexRotation(23.0, 37.0, 11.0);
const FVector HexPlaneNormal = HexRotation.Quaternion().GetAxisZ();
for (const EDirectiveUtilHexOrientation HexOrientation : {
EDirectiveUtilHexOrientation::PointyTop, EDirectiveUtilHexOrientation::FlatTop })
{
const FIntPoint Coordinate(-7, 4);
const FVector Location = UDirectiveUtilMathFunctionLibrary::HexCoordinateToLocation(
Coordinate, HexOrigin, HexRotation, 25.0, HexOrientation, 3.0);
TestEqual(TEXT("Hex coordinate conversion round trips through a rotated layout"),
UDirectiveUtilMathFunctionLibrary::LocationToHexCoordinate(
Location, HexOrigin, HexRotation, 25.0, HexOrientation, 3.0), Coordinate);
TestEqual(TEXT("Location conversion projects onto the hex plane"),
UDirectiveUtilMathFunctionLibrary::LocationToHexCoordinate(
Location + HexPlaneNormal * 500.0, HexOrigin, HexRotation, 25.0, HexOrientation, 3.0), Coordinate);
}
const FVector GappedHex = UDirectiveUtilMathFunctionLibrary::HexCoordinateToLocation(
FIntPoint(1, 0), FVector::ZeroVector, FRotator::ZeroRotator, HexRadius,
EDirectiveUtilHexOrientation::PointyTop, 2.0);
TestTrue(TEXT("Hex gap adds to the adjacent edge distance"),
FMath::IsNearlyEqual(GappedHex.Size(), UE_DOUBLE_SQRT_3 * HexRadius + 2.0, 1.e-9));
const TArray<FIntPoint> HexNeighbors = UDirectiveUtilMathFunctionLibrary::GetHexNeighbors(FIntPoint(3, -2));
const TArray<FIntPoint> ExpectedHexNeighbors = {
FIntPoint(4, -2), FIntPoint(4, -3), FIntPoint(3, -3),
FIntPoint(2, -2), FIntPoint(2, -1), FIntPoint(3, -1)
};
TestTrue(TEXT("Hex neighbors use stable axial direction order"), HexNeighbors == ExpectedHexNeighbors);
TestEqual(TEXT("Hex distance counts axial grid steps"),
UDirectiveUtilMathFunctionLibrary::GetHexDistance(FIntPoint(0, 0), FIntPoint(3, -5)), 5LL);
TestEqual(TEXT("Hex distance uses 64-bit intermediates"),
UDirectiveUtilMathFunctionLibrary::GetHexDistance(
FIntPoint(MAX_int32, MAX_int32), FIntPoint(MIN_int32, MIN_int32)), 8589934590LL);
const TArray<FVector> RectangularHexGrid = UDirectiveUtilMathFunctionLibrary::GenerateRectangularHexGrid(
FVector::ZeroVector, FRotator::ZeroRotator, FIntPoint(3, 2), HexRadius,
EDirectiveUtilHexOrientation::PointyTop, 0.0, true);
TestTrue(TEXT("Rectangular hex grid is centered and ordered by row then column"),
RectangularHexGrid.Num() == 6
&& RectangularHexGrid[0].Equals(-RectangularHexGrid.Last(), 1.e-9)
&& RectangularHexGrid[1].X < RectangularHexGrid[2].X
&& RectangularHexGrid[2].Y < RectangularHexGrid[3].Y);
const TArray<FVector> UncenteredHexGrid = UDirectiveUtilMathFunctionLibrary::GenerateRectangularHexGrid(
HexOrigin, FRotator::ZeroRotator, FIntPoint(2, 2), HexRadius,
EDirectiveUtilHexOrientation::FlatTop, 0.0, false);
TestTrue(TEXT("Uncentered rectangular hex grid starts at its origin"),
UncenteredHexGrid.Num() == 4 && UncenteredHexGrid[0] == HexOrigin);
const TArray<FVector> HexagonalGrid = UDirectiveUtilMathFunctionLibrary::GenerateHexagonalHexGrid(
FVector::ZeroVector, FRotator::ZeroRotator, 2, HexRadius,
EDirectiveUtilHexOrientation::PointyTop);
TSet<FIntPoint> HexagonalCoordinates;
bool bHexagonalGridValid = HexagonalGrid.Num() == 19;
for (const FVector& Point : HexagonalGrid)
{
const FIntPoint Coordinate = UDirectiveUtilMathFunctionLibrary::LocationToHexCoordinate(
Point, FVector::ZeroVector, FRotator::ZeroRotator, HexRadius,
EDirectiveUtilHexOrientation::PointyTop);
bHexagonalGridValid &= UDirectiveUtilMathFunctionLibrary::GetHexDistance(FIntPoint::ZeroValue, Coordinate) <= 2;
HexagonalCoordinates.Add(Coordinate);
}
TestTrue(TEXT("Hexagonal grid contains every coordinate through its requested radius"),
bHexagonalGridValid && HexagonalCoordinates.Num() == 19);
TestEqual(TEXT("A zero-radius hexagonal grid contains its center"),
UDirectiveUtilMathFunctionLibrary::GenerateHexagonalHexGrid(
HexOrigin, FRotator::ZeroRotator, 0, HexRadius).Num(), 1);
TestTrue(TEXT("Hex generators reject invalid layouts and counts"),
UDirectiveUtilMathFunctionLibrary::GenerateRectangularHexGrid(
FVector::ZeroVector, FRotator::ZeroRotator, FIntPoint(0, 2), HexRadius).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GenerateRectangularHexGrid(
FVector::ZeroVector, FRotator::ZeroRotator, FIntPoint(2, 2), 0.0).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GenerateHexagonalHexGrid(
FVector::ZeroVector, FRotator::ZeroRotator, -1, HexRadius).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GenerateHexagonalHexGrid(
FVector::ZeroVector, FRotator::ZeroRotator, 30000, HexRadius).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::HexCoordinateToLocation(
FIntPoint(1, 0), FVector::ZeroVector, FRotator::ZeroRotator, HexRadius,
EDirectiveUtilHexOrientation::PointyTop, -UE_DOUBLE_SQRT_3 * HexRadius).IsZero()
&& UDirectiveUtilMathFunctionLibrary::GetHexNeighbors(FIntPoint(MAX_int32, 0)).IsEmpty());
const TArray<FIntPoint> HexesInRange = UDirectiveUtilMathFunctionLibrary::GetHexesInRange(FIntPoint(2, -1), 1);
const TArray<FIntPoint> ExpectedHexesInRange = {
FIntPoint(2, -2), FIntPoint(3, -2), FIntPoint(1, -1), FIntPoint(2, -1),
FIntPoint(3, -1), FIntPoint(1, 0), FIntPoint(2, 0)
};
TestTrue(TEXT("Hexes in range cover the center and its neighbors ordered by R then Q"),
HexesInRange == ExpectedHexesInRange);
const TArray<FIntPoint> HexagonalGridCoordinates =
UDirectiveUtilMathFunctionLibrary::GetHexesInRange(FIntPoint::ZeroValue, 2);
bool bHexagonalOrderValid = HexagonalGridCoordinates.Num() == HexagonalGrid.Num();
for (int32 Index = 0; bHexagonalOrderValid && Index < HexagonalGrid.Num(); ++Index)
{
bHexagonalOrderValid &= HexagonalGrid[Index].Equals(
UDirectiveUtilMathFunctionLibrary::HexCoordinateToLocation(
HexagonalGridCoordinates[Index], FVector::ZeroVector, FRotator::ZeroRotator, HexRadius,
EDirectiveUtilHexOrientation::PointyTop), 1.e-9);
}
TestTrue(TEXT("Hexes in range around zero pair with hexagonal grid cells by index"), bHexagonalOrderValid);
const TArray<FIntPoint> RectangularCoordinates =
UDirectiveUtilMathFunctionLibrary::GetRectangularHexGridCoordinates(
FIntPoint(2, 2), EDirectiveUtilHexOrientation::FlatTop);
bool bRectangularOrderValid = RectangularCoordinates.Num() == UncenteredHexGrid.Num();
for (int32 Index = 0; bRectangularOrderValid && Index < UncenteredHexGrid.Num(); ++Index)
{
bRectangularOrderValid &= UncenteredHexGrid[Index].Equals(
UDirectiveUtilMathFunctionLibrary::HexCoordinateToLocation(
RectangularCoordinates[Index], HexOrigin, FRotator::ZeroRotator, HexRadius,
EDirectiveUtilHexOrientation::FlatTop), 1.e-9);
}
TestTrue(TEXT("Rectangular hex grid coordinates pair with grid cells by index"), bRectangularOrderValid);
const TArray<FIntPoint> HexRing = UDirectiveUtilMathFunctionLibrary::GetHexRing(FIntPoint(1, 1), 2);
bool bRingValid = HexRing.Num() == 12;
for (int32 Index = 0; bRingValid && Index < HexRing.Num(); ++Index)
{
bRingValid &= UDirectiveUtilMathFunctionLibrary::GetHexDistance(FIntPoint(1, 1), HexRing[Index]) == 2;
bRingValid &= UDirectiveUtilMathFunctionLibrary::GetHexDistance(
HexRing[Index], HexRing[(Index + 1) % HexRing.Num()]) == 1;
}
TestTrue(TEXT("A hex ring traces adjacent cells at the requested radius"), bRingValid);
const TArray<FIntPoint> ZeroHexRing = UDirectiveUtilMathFunctionLibrary::GetHexRing(FIntPoint(4, 5), 0);
TestTrue(TEXT("A zero-radius hex ring returns the center"),
ZeroHexRing.Num() == 1 && ZeroHexRing[0] == FIntPoint(4, 5));
const TArray<FIntPoint> HexLine = UDirectiveUtilMathFunctionLibrary::GetHexLine(
FIntPoint(0, 0), FIntPoint(3, -3));
const TArray<FIntPoint> ExpectedHexLine = {
FIntPoint(0, 0), FIntPoint(1, -1), FIntPoint(2, -2), FIntPoint(3, -3)
};
TestTrue(TEXT("A hex line follows a straight axial direction"), HexLine == ExpectedHexLine);
const TArray<FIntPoint> DiagonalHexLine = UDirectiveUtilMathFunctionLibrary::GetHexLine(
FIntPoint(-1, 2), FIntPoint(1, 3));
bool bDiagonalLineValid = DiagonalHexLine.Num() == 4
&& DiagonalHexLine[0] == FIntPoint(-1, 2) && DiagonalHexLine.Last() == FIntPoint(1, 3);
for (int32 Index = 0; bDiagonalLineValid && Index < DiagonalHexLine.Num() - 1; ++Index)
{
bDiagonalLineValid &= UDirectiveUtilMathFunctionLibrary::GetHexDistance(
DiagonalHexLine[Index], DiagonalHexLine[Index + 1]) == 1;
}
TestTrue(TEXT("A hex line steps through adjacent cells between its endpoints"), bDiagonalLineValid);
const TArray<FIntPoint> SingleHexLine = UDirectiveUtilMathFunctionLibrary::GetHexLine(
FIntPoint(7, -2), FIntPoint(7, -2));
TestTrue(TEXT("A zero-length hex line returns its cell"),
SingleHexLine.Num() == 1 && SingleHexLine[0] == FIntPoint(7, -2));
const TArray<FVector> HexCorners = UDirectiveUtilMathFunctionLibrary::GetHexCellCorners(
FIntPoint::ZeroValue, FVector::ZeroVector, FRotator::ZeroRotator, HexRadius,
EDirectiveUtilHexOrientation::PointyTop);
bool bCornersValid = HexCorners.Num() == 6
&& HexCorners[0].Equals(FVector(UE_DOUBLE_SQRT_3 * 0.5 * HexRadius, 0.5 * HexRadius, 0.0), 1.e-9);
for (int32 Index = 0; bCornersValid && Index < 6; ++Index)
{
bCornersValid &= FMath::IsNearlyEqual(HexCorners[Index].Size(), HexRadius, 1.e-9);
bCornersValid &= FMath::IsNearlyEqual(
FVector::Distance(HexCorners[Index], HexCorners[(Index + 1) % 6]), HexRadius, 1.e-9);
}
TestTrue(TEXT("Pointy-top cell corners lie at the cell radius with matching side length"), bCornersValid);
const TArray<FVector> FlatHexCorners = UDirectiveUtilMathFunctionLibrary::GetHexCellCorners(
FIntPoint::ZeroValue, FVector::ZeroVector, FRotator::ZeroRotator, HexRadius,
EDirectiveUtilHexOrientation::FlatTop);
TestTrue(TEXT("Flat-top cell corners start on the local X axis"),
FlatHexCorners.Num() == 6 && FlatHexCorners[0].Equals(FVector(HexRadius, 0.0, 0.0), 1.e-9));
const TArray<FVector> GappedHexCorners = UDirectiveUtilMathFunctionLibrary::GetHexCellCorners(
FIntPoint(1, 0), FVector::ZeroVector, FRotator::ZeroRotator, HexRadius,
EDirectiveUtilHexOrientation::PointyTop, 2.0);
TestTrue(TEXT("Hex gap moves the cell center but not the corner distance"),
GappedHexCorners.Num() == 6
&& FMath::IsNearlyEqual(FVector::Distance(GappedHexCorners[0], GappedHex), HexRadius, 1.e-9));
TestTrue(TEXT("Hex queries reject invalid input"),
UDirectiveUtilMathFunctionLibrary::GetHexesInRange(FIntPoint::ZeroValue, -1).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GetHexesInRange(FIntPoint(MAX_int32, 0), 1).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GetHexRing(FIntPoint::ZeroValue, -1).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GetHexRing(FIntPoint(MAX_int32, 0), 1).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GetHexLine(
FIntPoint(MIN_int32, MIN_int32), FIntPoint(MAX_int32, MAX_int32)).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GetHexCellCorners(FIntPoint(1, 1), FVector::ZeroVector,
FRotator::ZeroRotator, 0.0).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GetRectangularHexGridCoordinates(FIntPoint(0, 3)).IsEmpty());
const int32 MaximumGeneratedElementCount = UDirectiveUtilMathFunctionLibrary::MaximumGeneratedElementCount;
TestEqual(
TEXT("The maximum supported rectangular grid size remains available"),
UDirectiveUtilMathFunctionLibrary::GetRectangularHexGridCoordinates(FIntPoint(1000, 1000)).Num(),
MaximumGeneratedElementCount);
TestTrue(TEXT("Generated collections reject the first unsupported count"),
UDirectiveUtilMathFunctionLibrary::GenerateGridPoints2D(
FVector::ZeroVector, FRotator::ZeroRotator,
FIntPoint(MaximumGeneratedElementCount + 1, 1), FVector2D(1.0, 1.0)).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GetHexesInRange(FIntPoint::ZeroValue, 577).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GetHexRing(
FIntPoint::ZeroValue, MaximumGeneratedElementCount / 6 + 1).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GetHexLine(
FIntPoint::ZeroValue, FIntPoint(MaximumGeneratedElementCount, 0)).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongDirection(
FVector::ZeroVector, FVector::ForwardVector,
MaximumGeneratedElementCount + 1, 1.0).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsOnSphere(
FVector::ZeroVector, FRotator::ZeroRotator, 1.0,
MaximumGeneratedElementCount + 1).IsEmpty());
const TArray<FVector> NoiseBase = {
FVector::ZeroVector, FVector(37.0, 11.0, 5.0), FVector(250.0, -90.0, 40.0)
};
const TArray<FVector> NoisePoints = UDirectiveUtilMathFunctionLibrary::OffsetLocationsByNoise(
NoiseBase, 100.0, 25.0);
bool bNoiseValid = NoisePoints.Num() == 3;
bool bAnyNoiseOffset = false;
for (int32 Index = 0; bNoiseValid && Index < NoisePoints.Num(); ++Index)
{
const FVector NoiseDelta = NoisePoints[Index] - NoiseBase[Index];
bNoiseValid &= FMath::IsNearlyZero(NoiseDelta.X) && FMath::IsNearlyZero(NoiseDelta.Y)
&& FMath::Abs(NoiseDelta.Z) <= 25.0 + 1.e-6;
bAnyNoiseOffset |= !FMath::IsNearlyZero(NoiseDelta.Z);
}
TestTrue(TEXT("Noise offsets displace along the requested direction within the amplitude"),
bNoiseValid && bAnyNoiseOffset);
TestTrue(TEXT("Noise offsets are deterministic"),
UDirectiveUtilMathFunctionLibrary::OffsetLocationsByNoise(NoiseBase, 100.0, 25.0) == NoisePoints);
TestTrue(TEXT("A zero noise amplitude leaves locations unchanged"),
UDirectiveUtilMathFunctionLibrary::OffsetLocationsByNoise(NoiseBase, 100.0, 0.0) == NoiseBase);
TestTrue(TEXT("Noise offsets reject invalid input"),
UDirectiveUtilMathFunctionLibrary::OffsetLocationsByNoise(NoiseBase, 0.0, 25.0).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::OffsetLocationsByNoise(
NoiseBase, 100.0, 25.0, FVector::ZeroVector).IsEmpty());
const TArray<FVector> DirectionPoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongDirection(
FVector::ZeroVector, FVector(10.0, 0.0, 0.0), 4, 2.0, true);
const TArray<FVector> ExpectedDirectionPoints = {
FVector(-3.0, 0.0, 0.0), FVector(-1.0, 0.0, 0.0),
FVector(1.0, 0.0, 0.0), FVector(3.0, 0.0, 0.0)
};
TestTrue(TEXT("Direction points normalize once and center around the origin"),
PointsEqual(DirectionPoints, ExpectedDirectionPoints));
const TArray<FVector> ReversedDirectionPoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongDirection(
FVector::ZeroVector, FVector::ForwardVector, 3, -2.0, false);
TestTrue(TEXT("Direction points preserve signed spacing"),
ReversedDirectionPoints.Num() == 3
&& ReversedDirectionPoints[0].Equals(FVector::ZeroVector)
&& ReversedDirectionPoints[2].Equals(FVector(-4.0, 0.0, 0.0)));
TestTrue(TEXT("Direction points reject a zero direction"),
UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongDirection(
FVector::ZeroVector, FVector::ZeroVector, 3, 1.0, false).IsEmpty());
const TArray<FVector> SegmentPoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsBetweenLocations(
FVector::ZeroVector, FVector(10.0, 0.0, 0.0), 3, true);
TestTrue(TEXT("Segment points include exact endpoints"),
SegmentPoints.Num() == 3
&& SegmentPoints[0] == FVector::ZeroVector
&& SegmentPoints[1].Equals(FVector(5.0, 0.0, 0.0))
&& SegmentPoints[2] == FVector(10.0, 0.0, 0.0));
const TArray<FVector> InteriorSegmentPoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsBetweenLocations(
FVector::ZeroVector, FVector(9.0, 0.0, 0.0), 2, false);
TestTrue(TEXT("Segment points can exclude both endpoints"),
InteriorSegmentPoints.Num() == 2
&& InteriorSegmentPoints[0].Equals(FVector(3.0, 0.0, 0.0))
&& InteriorSegmentPoints[1].Equals(FVector(6.0, 0.0, 0.0)));
const TArray<FVector> SingleSegmentPoint = UDirectiveUtilMathFunctionLibrary::GeneratePointsBetweenLocations(
FVector(2.0, 4.0, 6.0), FVector(6.0, 8.0, 10.0), 1, true);
TestTrue(TEXT("A single segment point is the midpoint"),
SingleSegmentPoint.Num() == 1 && SingleSegmentPoint[0].Equals(FVector(4.0, 6.0, 8.0)));
USplineComponent* Spline = NewObject<USplineComponent>();
Spline->SetSplinePoints({ FVector::ZeroVector, FVector(100.0, 0.0, 0.0) },
ESplineCoordinateSpace::Local, false);
Spline->SetSplinePointType(0, ESplinePointType::Linear, false);
Spline->SetSplinePointType(1, ESplinePointType::Linear, true);
TestTrue(TEXT("Spline generators reject unsupported sample counts"),
UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSplineByCount(
Spline, MaximumGeneratedElementCount + 1).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GenerateTransformsAlongSplineByCount(
Spline, MaximumGeneratedElementCount + 1).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(
Spline, 0.00005).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GenerateTransformsAlongSpline(
Spline, 0.00005).IsEmpty());
const TArray<FVector> SplinePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(
Spline, 30.0, true, EDirectiveUtilSplineSpacingMode::Fixed);
TestTrue(TEXT("Spline points use fixed spacing and append the exact open endpoint"),
SplinePoints.Num() == 5
&& SplinePoints[0].Equals(FVector::ZeroVector)
&& SplinePoints[1].Equals(FVector(30.0, 0.0, 0.0), 1.e-4)
&& SplinePoints[3].Equals(FVector(90.0, 0.0, 0.0), 1.e-4)
&& SplinePoints[4].Equals(FVector(100.0, 0.0, 0.0), 1.e-4));
TestEqual(TEXT("Spline endpoint can be excluded"),
UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(
Spline, 30.0, false, EDirectiveUtilSplineSpacingMode::Fixed).Num(), 4);
const TArray<FVector> EvenSplinePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(
Spline, 30.0, true, EDirectiveUtilSplineSpacingMode::Even);
TestTrue(TEXT("Even spline spacing divides the range without a short final interval"),
EvenSplinePoints.Num() == 5
&& EvenSplinePoints[1].Equals(FVector(25.0, 0.0, 0.0), 1.e-4)
&& EvenSplinePoints[3].Equals(FVector(75.0, 0.0, 0.0), 1.e-4)
&& EvenSplinePoints[4].Equals(FVector(100.0, 0.0, 0.0), 1.e-4));
const TArray<FVector> RangedSplinePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(
Spline, 25.0, true, EDirectiveUtilSplineSpacingMode::Fixed, ESplineCoordinateSpace::World, 20.0, 80.0);
TestTrue(TEXT("Spline sampling honors a start and end distance"),
RangedSplinePoints.Num() == 4
&& RangedSplinePoints[0].Equals(FVector(20.0, 0.0, 0.0), 1.e-4)
&& RangedSplinePoints[1].Equals(FVector(45.0, 0.0, 0.0), 1.e-4)
&& RangedSplinePoints[3].Equals(FVector(80.0, 0.0, 0.0), 1.e-4));
TestEqual(TEXT("Spline sampling drops a regular sample that lands on the endpoint"),
UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(
Spline, 25.0, true, EDirectiveUtilSplineSpacingMode::Fixed, ESplineCoordinateSpace::World,
0.0, 50.0 + 1.e-10).Num(), 3);
const TArray<FVector> CountedSplinePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSplineByCount(
Spline, 5, true);
TestTrue(TEXT("Spline sampling by count includes both exact endpoints"),
CountedSplinePoints.Num() == 5
&& CountedSplinePoints[0].Equals(FVector::ZeroVector, 1.e-4)
&& CountedSplinePoints[2].Equals(FVector(50.0, 0.0, 0.0), 1.e-4)
&& CountedSplinePoints[4].Equals(FVector(100.0, 0.0, 0.0), 1.e-4));
const TArray<FVector> InteriorSplinePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSplineByCount(
Spline, 4, false);
TestTrue(TEXT("Spline sampling by count can exclude both endpoints"),
InteriorSplinePoints.Num() == 4
&& InteriorSplinePoints[0].Equals(FVector(20.0, 0.0, 0.0), 1.e-4)
&& InteriorSplinePoints[3].Equals(FVector(80.0, 0.0, 0.0), 1.e-4));
const TArray<FVector> SingleSplinePoint = UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSplineByCount(
Spline, 1, true);
TestTrue(TEXT("A single spline point by count is the range midpoint"),
SingleSplinePoint.Num() == 1 && SingleSplinePoint[0].Equals(FVector(50.0, 0.0, 0.0), 1.e-4));
Spline->SetClosedLoop(true, true);
const TArray<FVector> ClosedSplinePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(
Spline, 30.0, true, EDirectiveUtilSplineSpacingMode::Fixed);
TestTrue(TEXT("Closed spline sampling does not repeat its first point"),
ClosedSplinePoints.Num() > 1 && !ClosedSplinePoints[0].Equals(ClosedSplinePoints.Last(), 1.e-4));
const TArray<FVector> ClosedCountedPoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSplineByCount(
Spline, 4, true);
TestTrue(TEXT("Closed spline sampling by count spreads points around the loop"),
ClosedCountedPoints.Num() == 4
&& !ClosedCountedPoints[0].Equals(ClosedCountedPoints.Last(), 1.e-4));
USplineComponent* SinglePointSpline = NewObject<USplineComponent>();
SinglePointSpline->SetSplinePoints({ FVector(3.0, 4.0, 5.0) }, ESplineCoordinateSpace::Local, true);
const TArray<FVector> ZeroLengthSplinePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(
SinglePointSpline, 10.0);
TestTrue(TEXT("A zero-length spline returns its only point"),
ZeroLengthSplinePoints.Num() == 1 && ZeroLengthSplinePoints[0].Equals(FVector(3.0, 4.0, 5.0)));
TestTrue(TEXT("Spline sampling rejects invalid input"),
UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(nullptr, 10.0).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(Spline, 0.0).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(
Spline, 10.0, true, static_cast<EDirectiveUtilSplineSpacingMode>(255)).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(
Spline, 10.0, true, EDirectiveUtilSplineSpacingMode::Fixed,
static_cast<ESplineCoordinateSpace::Type>(255)).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(
Spline, 10.0, true, EDirectiveUtilSplineSpacingMode::Fixed,
ESplineCoordinateSpace::World, 80.0, 20.0).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSplineByCount(Spline, 0).IsEmpty());
const TArray<FVector> CirclePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnCircle(
FVector::ZeroVector, FRotator::ZeroRotator, 10.0, 4, 0.0);
const TArray<FVector> ExpectedCirclePoints = {
FVector(10.0, 0.0, 0.0), FVector(0.0, 10.0, 0.0),
FVector(-10.0, 0.0, 0.0), FVector(0.0, -10.0, 0.0)
};
TestTrue(TEXT("Circle points are evenly spaced without repeating the first point"),
PointsEqual(CirclePoints, ExpectedCirclePoints, 1.e-8));
const FRotator PlaneRotation(17.0, 31.0, 43.0);
const FVector PlaneNormal = PlaneRotation.Quaternion().GetAxisZ();
const FVector PlaneCenter(11.0, 13.0, 17.0);
const TArray<FVector> RotatedCircle = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnCircle(
PlaneCenter, PlaneRotation, -7.0, 64, 15.0);
bool bCirclePlaneValid = RotatedCircle.Num() == 64;
for (const FVector& Point : RotatedCircle)
{
const FVector Offset = Point - PlaneCenter;
bCirclePlaneValid &= FMath::IsNearlyEqual(Offset.Size(), 7.0, 1.e-8);
bCirclePlaneValid &= FMath::IsNearlyZero(FVector::DotProduct(Offset, PlaneNormal), 1.e-8);
}
TestTrue(TEXT("Circle points honor rotation and negative radius"), bCirclePlaneValid);
const TArray<FVector> ArcPoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnArc(
FVector::ZeroVector, FRotator::ZeroRotator, 10.0, 3, 0.0, 90.0, true);
TestTrue(TEXT("Arc points include the requested endpoint"),
ArcPoints.Num() == 3
&& ArcPoints[0].Equals(FVector(10.0, 0.0, 0.0), 1.e-8)
&& ArcPoints[1].Equals(FVector(UE_DOUBLE_INV_SQRT_2 * 10.0, UE_DOUBLE_INV_SQRT_2 * 10.0, 0.0), 1.e-8)
&& ArcPoints[2].Equals(FVector(0.0, 10.0, 0.0), 1.e-8));
const TArray<FVector> OpenArcPoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnArc(
FVector::ZeroVector, FRotator::ZeroRotator, 10.0, 3, 0.0, 90.0, false);
TestTrue(TEXT("Open arc points exclude the requested endpoint"),
OpenArcPoints.Num() == 3
&& OpenArcPoints.Last().Equals(
FVector(FMath::Cos(UE_DOUBLE_PI / 3.0) * 10.0, FMath::Sin(UE_DOUBLE_PI / 3.0) * 10.0, 0.0), 1.e-8));
const TArray<FVector> DiscPoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnDisc(
PlaneCenter, PlaneRotation, 25.0, 1024, 27.0);
const TArray<FVector> RepeatedDiscPoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnDisc(
PlaneCenter, PlaneRotation, 25.0, 1024, 27.0);
bool bDiscValid = DiscPoints.Num() == 1024;
for (const FVector& Point : DiscPoints)
{
const FVector Offset = Point - PlaneCenter;
bDiscValid &= Offset.Size() < 25.0;
bDiscValid &= FMath::IsNearlyZero(FVector::DotProduct(Offset, PlaneNormal), 1.e-8);
}
TestTrue(TEXT("Disc points are deterministic and remain inside the rotated disc"),
bDiscValid && PointsEqual(DiscPoints, RepeatedDiscPoints));
const TArray<FVector> SingleDiscPoint = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnDisc(
PlaneCenter, PlaneRotation, 25.0, 1, 27.0);
TestTrue(TEXT("A single disc point is its center"),
SingleDiscPoint.Num() == 1 && SingleDiscPoint[0] == PlaneCenter);
const TArray<FVector> SpherePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnSphere(
FVector::ZeroVector, PlaneRotation, 25.0, 1024, 27.0);
const TArray<FVector> RepeatedSpherePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnSphere(
FVector::ZeroVector, PlaneRotation, 25.0, 1024, 27.0);
FVector SphereMean = FVector::ZeroVector;
bool bSphereValid = SpherePoints.Num() == 1024;
for (const FVector& Point : SpherePoints)
{
bSphereValid &= FMath::IsNearlyEqual(Point.Size(), 25.0, 1.e-8);
SphereMean += Point;
}
if (!SpherePoints.IsEmpty())
{
SphereMean /= SpherePoints.Num();
}
TestTrue(TEXT("Sphere points are deterministic and remain on the surface"),
bSphereValid && SphereMean.Size() < 0.01 && PointsEqual(SpherePoints, RepeatedSpherePoints));
const FVector SphereCenter(-1200.0, 3400.0, -5600.0);
const FRotator SphereRotation(-37.0, 123.0, 71.0);
const FQuat SphereRotationQuaternion = SphereRotation.Quaternion();
const TArray<FVector> LocalSpherePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnSphere(
FVector::ZeroVector, FRotator::ZeroRotator, 17.0, 257, 23.5);
const TArray<FVector> RotatedSpherePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnSphere(
SphereCenter, SphereRotation, 17.0, 257, 23.5);
bool bSphereRotationValid = LocalSpherePoints.Num() == RotatedSpherePoints.Num();
for (int32 Index = 0; Index < LocalSpherePoints.Num() && bSphereRotationValid; ++Index)
{
const FVector ExpectedPoint = SphereCenter
+ SphereRotationQuaternion.RotateVector(LocalSpherePoints[Index]);
bSphereRotationValid &= RotatedSpherePoints[Index].Equals(ExpectedPoint, 1.e-8);
}
TestTrue(TEXT("Sphere rotation transforms every local distribution point"), bSphereRotationValid);
constexpr double SphereAngleOffset = 1153.25;
const TArray<FVector> UnoffsetSpherePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnSphere(
FVector::ZeroVector, FRotator::ZeroRotator, 17.0, 257, 0.0);
const TArray<FVector> OffsetSpherePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnSphere(
FVector::ZeroVector, FRotator::ZeroRotator, 17.0, 257, SphereAngleOffset);
const FQuat SphereOffsetRotation(FVector::UpVector,
FMath::DegreesToRadians(FMath::Fmod(SphereAngleOffset, 360.0)));
bool bSphereAngleOffsetValid = UnoffsetSpherePoints.Num() == OffsetSpherePoints.Num();
for (int32 Index = 0; Index < UnoffsetSpherePoints.Num() && bSphereAngleOffsetValid; ++Index)
{
bSphereAngleOffsetValid &= OffsetSpherePoints[Index].Equals(
SphereOffsetRotation.RotateVector(UnoffsetSpherePoints[Index]), 1.e-8);
}
TestTrue(TEXT("Sphere angle offset rotates the distribution around local Z"),
bSphereAngleOffsetValid);
const TArray<FVector> SingleSpherePoint = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnSphere(
PlaneCenter, PlaneRotation, 25.0, 1, 27.0);
TestTrue(TEXT("A single sphere point follows the rotated local Z axis"),
SingleSpherePoint.Num() == 1
&& SingleSpherePoint[0].Equals(PlaneCenter + PlaneNormal * 25.0, 1.e-8));
const FVector LargeTranslation(1000000.0, -2000000.0, 3000000.0);
const FRotator AuditRotation(-37.0, 123.0, 71.0);
TestTrue(TEXT("A translated center offsets every generated arc point exactly once"),
PointsEqualAfterTranslation(
UDirectiveUtilMathFunctionLibrary::GeneratePointsOnArc(
FVector::ZeroVector, AuditRotation, -13.5, 11, 1080.25, -450.5, true),
UDirectiveUtilMathFunctionLibrary::GeneratePointsOnArc(
LargeTranslation, AuditRotation, -13.5, 11, 1080.25, -450.5, true),
LargeTranslation));
TestTrue(TEXT("Translated origins preserve every spatial generator's local offsets"),
PointsEqualAfterTranslation(
UDirectiveUtilMathFunctionLibrary::GenerateGridPoints2D(
FVector::ZeroVector, AuditRotation, FIntPoint(4, 3), FVector2D(-7.0, 11.0), true),
UDirectiveUtilMathFunctionLibrary::GenerateGridPoints2D(
LargeTranslation, AuditRotation, FIntPoint(4, 3), FVector2D(-7.0, 11.0), true),
LargeTranslation)
&& PointsEqualAfterTranslation(
UDirectiveUtilMathFunctionLibrary::GenerateGridPoints3D(
FVector::ZeroVector, AuditRotation, FIntVector(3, 2, 2), FVector(5.0, -7.0, 0.0), false),
UDirectiveUtilMathFunctionLibrary::GenerateGridPoints3D(
LargeTranslation, AuditRotation, FIntVector(3, 2, 2), FVector(5.0, -7.0, 0.0), false),
LargeTranslation)
&& PointsEqualAfterTranslation(
UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongDirection(
FVector::ZeroVector, FVector(-2.0, 3.0, -5.0), 7, -3.25, true),
UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongDirection(
LargeTranslation, FVector(-2.0, 3.0, -5.0), 7, -3.25, true),
LargeTranslation)
&& PointsEqualAfterTranslation(
UDirectiveUtilMathFunctionLibrary::GeneratePointsBetweenLocations(
FVector(-11.0, 5.0, 8.0), FVector(17.0, -9.0, 3.0), 8, false),
UDirectiveUtilMathFunctionLibrary::GeneratePointsBetweenLocations(
LargeTranslation + FVector(-11.0, 5.0, 8.0),
LargeTranslation + FVector(17.0, -9.0, 3.0), 8, false),
LargeTranslation)
&& PointsEqualAfterTranslation(
UDirectiveUtilMathFunctionLibrary::GeneratePointsOnCircle(
FVector::ZeroVector, AuditRotation, -13.5, 17, -725.25),
UDirectiveUtilMathFunctionLibrary::GeneratePointsOnCircle(
LargeTranslation, AuditRotation, -13.5, 17, -725.25),
LargeTranslation)
&& PointsEqualAfterTranslation(
UDirectiveUtilMathFunctionLibrary::GeneratePointsOnDisc(
FVector::ZeroVector, AuditRotation, -13.5, 257, 1080.25),
UDirectiveUtilMathFunctionLibrary::GeneratePointsOnDisc(
LargeTranslation, AuditRotation, -13.5, 257, 1080.25),
LargeTranslation)
&& PointsEqualAfterTranslation(
UDirectiveUtilMathFunctionLibrary::GeneratePointsOnSphere(
FVector::ZeroVector, AuditRotation, -13.5, 257, -1080.25),
UDirectiveUtilMathFunctionLibrary::GeneratePointsOnSphere(
LargeTranslation, AuditRotation, -13.5, 257, -1080.25),
LargeTranslation));
for (const EDirectiveUtilHexOrientation Orientation : {
EDirectiveUtilHexOrientation::PointyTop, EDirectiveUtilHexOrientation::FlatTop })
{
TestTrue(TEXT("Translated origins preserve rectangular and hexagonal grid offsets"),
PointsEqualAfterTranslation(
UDirectiveUtilMathFunctionLibrary::GenerateRectangularHexGrid(
FVector::ZeroVector, AuditRotation, FIntPoint(4, 3), 9.5, Orientation, -1.25, true),
UDirectiveUtilMathFunctionLibrary::GenerateRectangularHexGrid(
LargeTranslation, AuditRotation, FIntPoint(4, 3), 9.5, Orientation, -1.25, true),
LargeTranslation)
&& PointsEqualAfterTranslation(
UDirectiveUtilMathFunctionLibrary::GenerateHexagonalHexGrid(
FVector::ZeroVector, AuditRotation, 4, 9.5, Orientation, -1.25),
UDirectiveUtilMathFunctionLibrary::GenerateHexagonalHexGrid(
LargeTranslation, AuditRotation, 4, 9.5, Orientation, -1.25),
LargeTranslation));
for (const FIntPoint Coordinate : {
FIntPoint::ZeroValue, FIntPoint(-17, 29), FIntPoint(1234, -987), FIntPoint(-4096, -2048) })
{
const FVector Location = UDirectiveUtilMathFunctionLibrary::HexCoordinateToLocation(
Coordinate, LargeTranslation, AuditRotation, 9.5, Orientation, -1.25);
TestEqual(TEXT("Odd signed hex coordinates round trip through translated rotated layouts"),
UDirectiveUtilMathFunctionLibrary::LocationToHexCoordinate(
Location, LargeTranslation, AuditRotation, 9.5, Orientation, -1.25), Coordinate);
}
}
const TArray<FVector> ZeroRadiusCircle = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnCircle(
LargeTranslation, AuditRotation, 0.0, 9, 123.0);
const TArray<FVector> ZeroRadiusArc = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnArc(
LargeTranslation, AuditRotation, 0.0, 9, -30.0, -720.0, false);
const TArray<FVector> ZeroRadiusDisc = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnDisc(
LargeTranslation, AuditRotation, 0.0, 9, 123.0);
const TArray<FVector> ZeroRadiusSphere = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnSphere(
LargeTranslation, AuditRotation, 0.0, 9, 123.0);
TestTrue(TEXT("Zero-radius generators preserve their requested count at the center"),
ZeroRadiusCircle.Num() == 9 && PointsMatchLocation(ZeroRadiusCircle, LargeTranslation)
&& ZeroRadiusArc.Num() == 9 && PointsMatchLocation(ZeroRadiusArc, LargeTranslation)
&& ZeroRadiusDisc.Num() == 9 && PointsMatchLocation(ZeroRadiusDisc, LargeTranslation)
&& ZeroRadiusSphere.Num() == 9 && PointsMatchLocation(ZeroRadiusSphere, LargeTranslation));
TestTrue(TEXT("Degenerate linear generators preserve their requested count and location"),
PointsMatchLocation(UDirectiveUtilMathFunctionLibrary::GenerateGridPoints3D(
LargeTranslation, AuditRotation, FIntVector(2, 3, 4), FVector::ZeroVector, true), LargeTranslation)
&& PointsMatchLocation(UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongDirection(
LargeTranslation, FVector(2.0, -3.0, 4.0), 7, 0.0, true), LargeTranslation)
&& PointsMatchLocation(UDirectiveUtilMathFunctionLibrary::GeneratePointsBetweenLocations(
LargeTranslation, LargeTranslation, 7, false), LargeTranslation));
USplineComponent* TransformedSpline = NewObject<USplineComponent>();
TransformedSpline->SetWorldLocation(LargeTranslation);
TransformedSpline->SetWorldRotation(AuditRotation);
TransformedSpline->SetWorldScale3D(FVector(2.0, 3.0, 0.5));
TransformedSpline->SetSplinePoints({ FVector::ZeroVector, FVector(100.0, 0.0, 0.0) },
ESplineCoordinateSpace::Local, false);
TransformedSpline->SetSplinePointType(0, ESplinePointType::Linear, false);
TransformedSpline->SetSplinePointType(1, ESplinePointType::Linear, true);
const TArray<FVector> TransformedSplinePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(
TransformedSpline, 60.0);
TestEqual(TEXT("Scaled spline sampling produces the expected point count"), TransformedSplinePoints.Num(), 5);
if (TransformedSplinePoints.Num() == 5)
{
TestTrue(TEXT("Spline sampling returns its world-space start"),
TransformedSplinePoints[0].Equals(LargeTranslation, 1.e-4));
TestTrue(TEXT("Spline sampling preserves regular world-space intervals"),
FMath::IsNearlyEqual(FVector::Distance(TransformedSplinePoints[0], TransformedSplinePoints[1]), 60.0, 1.e-4)
&& FMath::IsNearlyEqual(FVector::Distance(TransformedSplinePoints[1], TransformedSplinePoints[2]), 60.0, 1.e-4)
&& FMath::IsNearlyEqual(FVector::Distance(TransformedSplinePoints[2], TransformedSplinePoints[3]), 60.0, 1.e-4));
TestTrue(TEXT("Spline sampling appends its exact world-space endpoint"),
TransformedSplinePoints.Last().Equals(TransformedSpline->GetLocationAtDistanceAlongSpline(
TransformedSpline->GetSplineLength(), ESplineCoordinateSpace::World), 1.e-4));
}
const TArray<FVector> LocalSplinePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(
TransformedSpline, 60.0, true, EDirectiveUtilSplineSpacingMode::Fixed, ESplineCoordinateSpace::Local);
TestTrue(TEXT("Spline sampling can return local-space points"),
LocalSplinePoints.Num() == TransformedSplinePoints.Num()
&& LocalSplinePoints[0].Equals(FVector::ZeroVector, 1.e-4)
&& LocalSplinePoints.Last().Equals(FVector(100.0, 0.0, 0.0), 1.e-4));
const double Infinity = std::numeric_limits<double>::infinity();
const double NaN = std::numeric_limits<double>::quiet_NaN();
const FRotator InvalidRotation(Infinity, 0.0, 0.0);
TestTrue(TEXT("Point generators reject non-finite values"),
UDirectiveUtilMathFunctionLibrary::GenerateGridPoints2D(
FVector::ZeroVector, FRotator::ZeroRotator, FIntPoint(2, 2), FVector2D(Infinity, 1.0), true).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongDirection(
FVector::ZeroVector, FVector::ForwardVector, 2, Infinity, false).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsOnCircle(
FVector::ZeroVector, InvalidRotation, 1.0, 4, 0.0).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsOnArc(
FVector::ZeroVector, FRotator::ZeroRotator, 1.0, 4, 0.0, Infinity, true).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsOnDisc(
FVector(Infinity, 0.0, 0.0), FRotator::ZeroRotator, 1.0, 4, 0.0).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsOnSphere(
FVector::ZeroVector, FRotator::ZeroRotator, Infinity, 4, 0.0).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GenerateRectangularHexGrid(
FVector::ZeroVector, FRotator::ZeroRotator, FIntPoint(2, 2), Infinity).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(Spline, Infinity).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(
Spline, 10.0, true, EDirectiveUtilSplineSpacingMode::Fixed,
ESplineCoordinateSpace::World, Infinity, -1.0).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSplineByCount(
Spline, 2, true, ESplineCoordinateSpace::World, 0.0, NaN).IsEmpty());
TestTrue(TEXT("Every spatial generator rejects a non-finite origin or center"),
UDirectiveUtilMathFunctionLibrary::GenerateGridPoints3D(
FVector(NaN, 0.0, 0.0), FRotator::ZeroRotator, FIntVector(1), FVector::OneVector, true).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsBetweenLocations(
FVector::ZeroVector, FVector(Infinity, 0.0, 0.0), 2, true).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsOnArc(
FVector(NaN, 0.0, 0.0), FRotator::ZeroRotator, 1.0, 2, 0.0, 90.0, true).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GenerateHexagonalHexGrid(
FVector(Infinity, 0.0, 0.0), FRotator::ZeroRotator, 1, 1.0).IsEmpty());
TestTrue(TEXT("Spline sampling rejects negative spacing"),
UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(Spline, -1.0).IsEmpty());
TestTrue(TEXT("Point generators return empty arrays for non-positive counts"),
UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongDirection(
FVector::ZeroVector, FVector::ForwardVector, 0, 1.0, false).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsBetweenLocations(
FVector::ZeroVector, FVector::OneVector, -1, true).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsOnCircle(
FVector::ZeroVector, FRotator::ZeroRotator, 1.0, 0, 0.0).IsEmpty());
return !HasAnyErrors();
}

View File

@@ -1,7 +1,9 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilRegexFunctionLibrary.h"
#include "Misc/AutomationTest.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilRegexFunctionLibraryTest, "DirectiveUtilities.RegexFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilRegexFunctionLibraryTest, "DirectiveUtilities.RegexFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilRegexFunctionLibraryTest::RunTest(const FString& Parameters)
{

View File

@@ -0,0 +1,104 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilArrayFunctionLibrary.h"
#include "Libraries/DirectiveUtilFunctionLibrary.h"
#include "Libraries/DirectiveUtilGameplayTagFunctionLibrary.h"
#include "Libraries/DirectiveUtilInputFunctionLibrary.h"
#include "Libraries/DirectiveUtilMapFunctionLibrary.h"
#include "Libraries/DirectiveUtilMathFunctionLibrary.h"
#include "Libraries/DirectiveUtilRegexFunctionLibrary.h"
#include "Libraries/DirectiveUtilSaveGameFunctionLibrary.h"
#include "Libraries/DirectiveUtilStringFunctionLibrary.h"
#include "Libraries/DirectiveUtilTextFunctionLibrary.h"
#include "Misc/AutomationTest.h"
#include "Modules/ModuleManager.h"
#include "Tasks/DirectiveUtilTask_AsyncLoadAsset.h"
#include "Tasks/DirectiveUtilTask_AsyncTrace.h"
#include "Tasks/DirectiveUtilTask_Delay.h"
#include "Tasks/DirectiveUtilTask_Flow.h"
#include "Tasks/DirectiveUtilTask_MoveToLocation.h"
#include "UObject/Class.h"
#include "UObject/Package.h"
#include "UObject/UnrealType.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilRuntimeSurfaceTest,
"DirectiveUtilities.Runtime.Surface",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilRuntimeSurfaceTest::RunTest(const FString& Parameters)
{
struct FClassExpectation
{
UClass* Class;
};
const FClassExpectation Expectations[] = {
{ UDirectiveUtilArrayFunctionLibrary::StaticClass() },
{ UDirectiveUtilFunctionLibrary::StaticClass() },
{ UDirectiveUtilGameplayTagFunctionLibrary::StaticClass() },
{ UDirectiveUtilInputFunctionLibrary::StaticClass() },
{ UDirectiveUtilMapFunctionLibrary::StaticClass() },
{ UDirectiveUtilMathFunctionLibrary::StaticClass() },
{ UDirectiveUtilRegexFunctionLibrary::StaticClass() },
{ UDirectiveUtilSaveGameFunctionLibrary::StaticClass() },
{ UDirectiveUtilStringFunctionLibrary::StaticClass() },
{ UDirectiveUtilTextFunctionLibrary::StaticClass() },
{ UDirectiveUtilTask_AsyncLoadAsset::StaticClass() },
{ UDirectiveUtilTask_AsyncLoadClass::StaticClass() },
{ UDirectiveUtilTask_AsyncLoadAssets::StaticClass() },
{ UDirectiveUtilTask_AsyncTrace::StaticClass() },
{ UDirectiveUtilTask_Delay::StaticClass() },
{ UDirectiveUtilTask_UpdateForDuration::StaticClass() },
{ UDirectiveUtilTask_RepeatWithInterval::StaticClass() },
{ UDirectiveUtilTask_MoveToLocation::StaticClass() },
{ UDirectiveUtilTask_MoveToActor::StaticClass() }
};
TestTrue(TEXT("The runtime module is loaded"), FModuleManager::Get().IsModuleLoaded(TEXT("DirectiveUtilitiesRuntime")));
#if !WITH_EDITOR
TestFalse(TEXT("The editor module is absent from a game target"), FModuleManager::Get().IsModuleLoaded(TEXT("DirectiveUtilitiesEditor")));
TestFalse(TEXT("The uncooked node module is absent from a game target"), FModuleManager::Get().IsModuleLoaded(TEXT("DirectiveUtilitiesBlueprintNodes")));
#endif
for (const FClassExpectation& Expectation : Expectations)
{
TestNotNull(TEXT("Runtime class is reflected"), Expectation.Class);
if (!Expectation.Class)
{
continue;
}
TestEqual(
FString::Printf(TEXT("%s belongs to the runtime script package"), *Expectation.Class->GetName()),
Expectation.Class->GetOutermost()->GetName(),
FString(TEXT("/Script/DirectiveUtilitiesRuntime")));
TestFalse(
FString::Printf(TEXT("%s is not in an editor-only package"), *Expectation.Class->GetName()),
Expectation.Class->GetOutermost()->HasAnyPackageFlags(PKG_EditorOnly | PKG_UncookedOnly | PKG_Developer));
for (TFieldIterator<UFunction> FunctionIterator(Expectation.Class, EFieldIteratorFlags::ExcludeSuper); FunctionIterator; ++FunctionIterator)
{
const UFunction* Function = *FunctionIterator;
if (!Function->HasAnyFunctionFlags(FUNC_BlueprintCallable | FUNC_BlueprintPure))
{
continue;
}
TestFalse(
FString::Printf(TEXT("%s.%s is available outside the editor"), *Expectation.Class->GetName(), *Function->GetName()),
Function->HasAnyFunctionFlags(FUNC_EditorOnly));
#if WITH_EDITOR
const FString Category = Function->GetMetaData(TEXT("Category"));
TestTrue(
FString::Printf(TEXT("%s.%s has a Directive Utilities category"), *Expectation.Class->GetName(), *Function->GetName()),
Category == TEXT("Directive Utilities") || Category.StartsWith(TEXT("Directive Utilities|")));
#endif
}
}
return !HasAnyErrors();
}

View File

@@ -1,10 +1,12 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilSaveGameFunctionLibrary.h"
#include "Tests/DirectiveUtilTestObject.h"
#include "Kismet/GameplayStatics.h"
#include "GameFramework/SaveGame.h"
#include "Misc/AutomationTest.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilSaveGameFunctionLibraryTest, "DirectiveUtilities.SaveGameFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilSaveGameFunctionLibraryTest, "DirectiveUtilities.SaveGameFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilSaveGameFunctionLibraryTest::RunTest(const FString& Parameters)
{
@@ -84,6 +86,11 @@ bool FDirectiveUtilSaveGameFunctionLibraryTest::RunTest(const FString& Parameter
UDirectiveUtilSaveGameFunctionLibrary::DoesSaveSlotExist(SlotB));
TestNotNull("A renamed slot should still deserialize",
UGameplayStatics::LoadGameFromSlot(SlotB, 0));
const FString SlotBCaseVariant = SlotB.ToLower();
TestTrue("RenameSaveSlot should accept a case-only spelling change",
UDirectiveUtilSaveGameFunctionLibrary::RenameSaveSlot(SlotB, SlotBCaseVariant));
TestNotNull("A case-only renamed slot should still deserialize",
UGameplayStatics::LoadGameFromSlot(SlotBCaseVariant, 0));
USaveGame* OtherSave = NewObject<UDirectiveUtilTestSaveGame>();
TestTrue("SaveGameToSlot should write the collision test slot",
@@ -99,12 +106,18 @@ bool FDirectiveUtilSaveGameFunctionLibraryTest::RunTest(const FString& Parameter
UDirectiveUtilSaveGameFunctionLibrary::DoesSaveSlotExist(TEXT("../escape")));
TestFalse("DeleteSaveSlot should reject an invalid slot name",
UDirectiveUtilSaveGameFunctionLibrary::DeleteSaveSlot(TEXT("../escape")));
TestFalse("DoesSaveSlotExist should reject a nested slot path",
UDirectiveUtilSaveGameFunctionLibrary::DoesSaveSlotExist(TEXT("Profiles/Slot1")));
TestFalse("RenameSaveSlot should reject an invalid source slot name",
UDirectiveUtilSaveGameFunctionLibrary::RenameSaveSlot(TEXT("../escape"), SlotC));
TestFalse("RenameSaveSlot should reject an invalid destination slot name",
UDirectiveUtilSaveGameFunctionLibrary::RenameSaveSlot(SlotC, TEXT("../escape")));
TestTrue("A rejected rename should leave the source slot intact",
UDirectiveUtilSaveGameFunctionLibrary::DoesSaveSlotExist(SlotC));
TestFalse("DoesSaveSlotExist should reject the reserved device name CON",
UDirectiveUtilSaveGameFunctionLibrary::DoesSaveSlotExist(TEXT("CON")));
TestFalse("DoesSaveSlotExist should reject CON with an extension",
UDirectiveUtilSaveGameFunctionLibrary::DoesSaveSlotExist(TEXT("CON.sav")));
TestTrue("DeleteSaveSlot should delete an existing slot",
UDirectiveUtilSaveGameFunctionLibrary::DeleteSaveSlot(SlotB));
@@ -115,6 +128,7 @@ bool FDirectiveUtilSaveGameFunctionLibraryTest::RunTest(const FString& Parameter
// Clean up
UGameplayStatics::DeleteGameInSlot(SlotA, 0);
UGameplayStatics::DeleteGameInSlot(SlotB, 0);
UGameplayStatics::DeleteGameInSlot(SlotB.ToLower(), 0);
UGameplayStatics::DeleteGameInSlot(SlotC, 0);
}

View File

@@ -1,7 +1,37 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilStringFunctionLibrary.h"
#include "Misc/AutomationTest.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilStringFunctionLibraryTest, "DirectiveUtilities.StringFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
namespace
{
int32 ReferenceLevenshteinDistance(const FString& Left, const FString& Right)
{
TArray<int32> PreviousRow;
TArray<int32> CurrentRow;
PreviousRow.SetNumUninitialized(Right.Len() + 1);
CurrentRow.SetNumUninitialized(Right.Len() + 1);
for (int32 ColumnIndex = 0; ColumnIndex <= Right.Len(); ++ColumnIndex)
{
PreviousRow[ColumnIndex] = ColumnIndex;
}
for (int32 RowIndex = 1; RowIndex <= Left.Len(); ++RowIndex)
{
CurrentRow[0] = RowIndex;
for (int32 ColumnIndex = 1; ColumnIndex <= Right.Len(); ++ColumnIndex)
{
CurrentRow[ColumnIndex] = FMath::Min3(
PreviousRow[ColumnIndex] + 1,
CurrentRow[ColumnIndex - 1] + 1,
PreviousRow[ColumnIndex - 1] + (Left[RowIndex - 1] == Right[ColumnIndex - 1] ? 0 : 1));
}
Swap(PreviousRow, CurrentRow);
}
return PreviousRow.Last();
}
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilStringFunctionLibraryTest, "DirectiveUtilities.StringFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilStringFunctionLibraryTest::RunTest(const FString& Parameters)
{
@@ -232,11 +262,26 @@ bool FDirectiveUtilStringFunctionLibraryTest::RunTest(const FString& Parameters)
TestFalse("IsValidFileName should reject a backslash", UDirectiveUtilStringFunctionLibrary::IsValidFileName(TEXT("a\\b")));
TestFalse("IsValidFileName should reject an empty string", UDirectiveUtilStringFunctionLibrary::IsValidFileName(TEXT("")));
TestFalse("IsValidFileName should reject a reserved character", UDirectiveUtilStringFunctionLibrary::IsValidFileName(TEXT("a:b")));
TestFalse("IsValidFileName should reject the current-directory segment", UDirectiveUtilStringFunctionLibrary::IsValidFileName(TEXT(".")));
TestFalse("IsValidFileName should reject the parent-directory segment", UDirectiveUtilStringFunctionLibrary::IsValidFileName(TEXT("..")));
TestFalse("IsValidFileName should reject the reserved device name CON", UDirectiveUtilStringFunctionLibrary::IsValidFileName(TEXT("CON")));
TestFalse("IsValidFileName should reject CON with an extension", UDirectiveUtilStringFunctionLibrary::IsValidFileName(TEXT("CON.sav")));
TestFalse("IsValidFileName should reject NUL case-insensitively", UDirectiveUtilStringFunctionLibrary::IsValidFileName(TEXT("nul")));
TestFalse("IsValidFileName should reject COM1", UDirectiveUtilStringFunctionLibrary::IsValidFileName(TEXT("COM1")));
TestFalse("IsValidFileName should reject LPT9", UDirectiveUtilStringFunctionLibrary::IsValidFileName(TEXT("LPT9")));
TestFalse("IsValidFileName should reject a trailing dot", UDirectiveUtilStringFunctionLibrary::IsValidFileName(TEXT("trailing.")));
TestFalse("IsValidFileName should reject a run of dots", UDirectiveUtilStringFunctionLibrary::IsValidFileName(TEXT("...")));
TestTrue("IsValidFileName should accept console, which is not a reserved device name",
UDirectiveUtilStringFunctionLibrary::IsValidFileName(TEXT("console")));
{
const FString Sanitized = UDirectiveUtilStringFunctionLibrary::SanitizeFileName(TEXT("../a/b?.sav"));
TestTrue("SanitizeFileName should produce a name that IsValidFileName accepts",
UDirectiveUtilStringFunctionLibrary::IsValidFileName(Sanitized));
const FString SanitizedReserved = UDirectiveUtilStringFunctionLibrary::SanitizeFileName(TEXT("CON"));
TestTrue("SanitizeFileName should rewrite a reserved device name into a valid file name",
UDirectiveUtilStringFunctionLibrary::IsValidFileName(SanitizedReserved)
&& SanitizedReserved == TEXT("_CON"));
const FString Replaced = UDirectiveUtilStringFunctionLibrary::SanitizeFileName(TEXT("a/b"), TEXT("_"));
TestTrue("SanitizeFileName should substitute the replacement character for stripped characters",
Replaced.Contains(TEXT("_")));
@@ -271,6 +316,43 @@ bool FDirectiveUtilStringFunctionLibraryTest::RunTest(const FString& Parameters)
UDirectiveUtilStringFunctionLibrary::FindBestStringMatch(TEXT(""), {TEXT("abc")}, Similarity), 0);
TestTrue("FindBestStringMatch should report similarity 0 for an empty input against a non-empty candidate",
FMath::IsNearlyEqual(Similarity, 0.0f, 1.e-4f));
TestEqual("FindBestStringMatch should keep the first equally close candidate",
UDirectiveUtilStringFunctionLibrary::FindBestStringMatch(TEXT("cat"), {TEXT("bat"), TEXT("hat")}, Similarity), 0);
}
TestEqual("Levenshtein should trim equal prefixes and suffixes without changing the result",
UDirectiveUtilStringFunctionLibrary::GetLevenshteinDistance(TEXT("shared-prefix-A-shared-suffix"), TEXT("shared-prefix-B-shared-suffix")), 1);
TestEqual("Levenshtein should support Unicode code units",
UDirectiveUtilStringFunctionLibrary::GetLevenshteinDistance(TEXT("café-one"), TEXT("café-two")), 3);
FRandomStream LevenshteinStream(90210);
for (int32 Iteration = 0; Iteration < 200; ++Iteration)
{
FString Left;
FString Right;
const int32 LeftLength = LevenshteinStream.RandRange(0, 64);
const int32 RightLength = LevenshteinStream.RandRange(0, 64);
Left.Reserve(LeftLength);
Right.Reserve(RightLength);
for (int32 Index = 0; Index < LeftLength; ++Index)
{
Left.AppendChar(static_cast<TCHAR>(TEXT('a') + LevenshteinStream.RandRange(0, 5)));
}
for (int32 Index = 0; Index < RightLength; ++Index)
{
Right.AppendChar(static_cast<TCHAR>(TEXT('a') + LevenshteinStream.RandRange(0, 5)));
}
const int32 ExpectedDistance = ReferenceLevenshteinDistance(Left, Right);
TestEqual(
FString::Printf(TEXT("Levenshtein fuzz case %d"), Iteration),
UDirectiveUtilStringFunctionLibrary::GetLevenshteinDistance(Left, Right),
ExpectedDistance);
TestEqual(
FString::Printf(TEXT("Levenshtein symmetry case %d"), Iteration),
UDirectiveUtilStringFunctionLibrary::GetLevenshteinDistance(Right, Left),
ExpectedDistance);
}
return true;

View File

@@ -0,0 +1,82 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilStringFunctionLibrary.h"
#include "Misc/AutomationTest.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilStringCardinalityTest,
"DirectiveUtilities.StringScenarios.Cardinality",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilStringCardinalityTest::RunTest(const FString& Parameters)
{
for (const int32 CharacterCount : {0, 1, 2, 31, 32, 255, 256, 1024})
{
FString Source;
Source.Reserve(CharacterCount);
for (int32 Index = 0; Index < CharacterCount; ++Index)
{
Source.AppendChar(TEXT('a'));
}
const FString Label = FString::Printf(TEXT("characters=%d"), CharacterCount);
TestEqual(
Label + TEXT(" identical"),
UDirectiveUtilStringFunctionLibrary::GetLevenshteinDistance(Source, Source),
0);
TestEqual(
Label + TEXT(" empty comparison"),
UDirectiveUtilStringFunctionLibrary::GetLevenshteinDistance(Source, FString()),
CharacterCount);
if (!Source.IsEmpty())
{
for (const int32 ChangedIndex : {0, CharacterCount / 2, CharacterCount - 1})
{
FString Changed = Source;
Changed[ChangedIndex] = TEXT('b');
TestEqual(
FString::Printf(TEXT("characters=%d changed=%d"), CharacterCount, ChangedIndex),
UDirectiveUtilStringFunctionLibrary::GetLevenshteinDistance(Source, Changed),
1);
}
}
}
for (const int32 CandidateCount : {0, 1, 2, 31, 32, 256, 1000})
{
TArray<FString> Candidates;
Candidates.Reserve(CandidateCount);
for (int32 Index = 0; Index < CandidateCount; ++Index)
{
Candidates.Add(FString::Printf(TEXT("Candidate%04d"), Index));
}
float Similarity = -1.0f;
if (Candidates.IsEmpty())
{
TestEqual(
"empty candidate set index",
UDirectiveUtilStringFunctionLibrary::FindBestStringMatch(TEXT("Candidate0000"), Candidates, Similarity),
INDEX_NONE);
TestEqual("empty candidate set similarity", Similarity, 0.0f);
continue;
}
const TArray<int32> ExpectedIndices = {0, CandidateCount / 2, CandidateCount - 1};
for (const int32 ExpectedIndex : ExpectedIndices)
{
Similarity = -1.0f;
const int32 MatchIndex = UDirectiveUtilStringFunctionLibrary::FindBestStringMatch(
Candidates[ExpectedIndex],
Candidates,
Similarity);
const FString Label = FString::Printf(TEXT("candidates=%d expected=%d"), CandidateCount, ExpectedIndex);
TestEqual(Label + TEXT(" index"), MatchIndex, ExpectedIndex);
TestEqual(Label + TEXT(" similarity"), Similarity, 1.0f);
}
}
return true;
}

View File

@@ -1,7 +1,9 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilTextFunctionLibrary.h"
#include "Misc/AutomationTest.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilTextFunctionLibraryTest, "DirectiveUtilities.TextFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilTextFunctionLibraryTest, "DirectiveUtilities.TextFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilTextFunctionLibraryTest::RunTest(const FString& Parameters)
{

Some files were not shown because too many files have changed in this diff Show More