From a06fed1cba54f3089097123946e2f16afdc84c72 Mon Sep 17 00:00:00 2001 From: "adnan.mujkic" Date: Sat, 15 Aug 2026 21:06:43 +0200 Subject: [PATCH] Update directive utilities plugin --- .../DA_BazaarIntroDialogueSimple.uasset | 3 + .../Config/FilterPlugin.ini | 3 - .../DirectiveUtilities.uplugin | 36 +- .../DirectiveUtilitiesBlueprintNodes.Build.cs | 24 + .../DirectiveUtilitiesBlueprintNodes.cpp | 34 + .../Nodes/DirectiveUtilMapNodeMigration.cpp | 127 + .../Nodes/K2Node_DirectiveUtilMapAppend.cpp | 207 ++ .../Public/DirectiveUtilitiesBlueprintNodes.h | 20 + .../Nodes/DirectiveUtilMapNodeMigration.h | 12 + .../Nodes/K2Node_DirectiveUtilMapAppend.h | 34 + .../DirectiveUtilitiesEditor.Build.cs | 4 +- .../DirectiveUtilAssetRegistry.h | 21 + .../DirectiveUtilDependencyCycleFinder.cpp | 123 + .../DirectiveUtilDependencyCycleFinder.h | 29 + .../Private/DirectiveUtilitiesEditor.cpp | 2 + .../DirectiveUtilEditorAssetAuditLibrary.cpp | 433 +++ .../DirectiveUtilEditorAssetLibrary.cpp | 20 +- .../DirectiveUtilEditorBlueprintLibrary.cpp | 268 ++ .../DirectiveUtilEditorTaskLibrary.cpp | 65 + .../DirectiveUtilEditorActorSubsystem.cpp | 510 +++- .../Tasks/DirectiveUtilEditorSlowTask.cpp | 85 + .../DirectiveUtilEditorAssetAuditLibrary.h | 36 + .../DirectiveUtilEditorAssetLibrary.h | 4 +- .../DirectiveUtilEditorBlueprintLibrary.h | 49 + .../DirectiveUtilEditorTaskLibrary.h | 25 + .../DirectiveUtilEditorActorSubsystem.h | 28 +- .../Tasks/DirectiveUtilEditorSlowTask.h | 33 + .../Types/DirectiveUtilEditorAuditTypes.h | 112 + .../Types/DirectiveUtilEditorBlueprintTypes.h | 34 + .../Types/DirectiveUtilEditorTaskTypes.h | 14 + .../Public/Types/DirectiveUtilEditorTypes.h | 45 + .../DirectiveUtilitiesRuntime.Build.cs | 7 +- .../Private/DirectiveUtilLogChannels.cpp | 6 +- .../DirectiveUtilArrayFunctionLibrary.cpp | 1032 +++++++- .../DirectiveUtilFunctionLibrary.cpp | 137 + ...irectiveUtilGameplayTagFunctionLibrary.cpp | 78 +- .../DirectiveUtilInputFunctionLibrary.cpp | 54 +- .../DirectiveUtilMapFunctionLibrary.cpp | 1 - ...DirectiveUtilMathEasingFunctionLibrary.cpp | 214 ++ .../DirectiveUtilMathFunctionLibrary.cpp | 2331 ++++++++++++++--- ...DirectiveUtilMathRandomFunctionLibrary.cpp | 219 ++ ...ctiveUtilMathStatisticsFunctionLibrary.cpp | 576 ++++ .../DirectiveUtilSaveGameFunctionLibrary.cpp | 76 +- .../DirectiveUtilStringFunctionLibrary.cpp | 201 +- .../Tasks/DirectiveUtilAsyncActionBase.cpp | 102 + .../DirectiveUtilTask_AsyncLoadAsset.cpp | 9 +- .../Private/Tasks/DirectiveUtilTask_Delay.cpp | 34 +- .../Private/Tasks/DirectiveUtilTask_Flow.cpp | 286 ++ .../DirectiveUtilTask_MoveToLocation.cpp | 134 +- .../DirectiveUtilArrayFunctionLibrary.h | 431 ++- .../Libraries/DirectiveUtilFunctionLibrary.h | 45 +- .../DirectiveUtilGameplayTagFunctionLibrary.h | 8 +- .../DirectiveUtilInputFunctionLibrary.h | 26 +- .../DirectiveUtilMapFunctionLibrary.h | 2 +- .../DirectiveUtilMathFunctionLibrary.h | 608 ++++- .../DirectiveUtilSaveGameFunctionLibrary.h | 15 +- .../DirectiveUtilStringFunctionLibrary.h | 6 +- .../DirectiveUtilTextFunctionLibrary.h | 2 +- .../Tasks/DirectiveUtilAsyncActionBase.h | 48 + .../Tasks/DirectiveUtilTask_AsyncLoadAsset.h | 8 +- .../Tasks/DirectiveUtilTask_AsyncTrace.h | 4 +- .../Public/Tasks/DirectiveUtilTask_Delay.h | 20 +- .../Public/Tasks/DirectiveUtilTask_Flow.h | 121 + .../Tasks/DirectiveUtilTask_MoveToLocation.h | 22 +- .../Public/Types/DirectiveUtilInputTypes.h | 4 +- .../Public/Types/DirectiveUtilMathTypes.h | 32 +- .../Public/Types/DirectiveUtilTypes.h | 42 +- .../DirectiveUtilitiesTests.Build.cs | 10 +- .../DirectiveUtilArrayBlueprintVmTest.cpp | 320 +++ .../DirectiveUtilArrayFunctionLibraryTest.cpp | 1309 ++++++++- .../DirectiveUtilArrayNodeWildcardTest.cpp | 207 ++ .../Tests/DirectiveUtilArrayScenarioTest.cpp | 801 ++++++ .../Tests/DirectiveUtilAsyncTaskTest.cpp | 874 +++++- .../Tests/DirectiveUtilAsyncTraceTest.cpp | 234 +- .../DirectiveUtilBlueprintCategoryTest.cpp | 15 +- ...DirectiveUtilDependencyCycleFinderTest.cpp | 64 + .../DirectiveUtilEditorActorLayoutTest.cpp | 167 ++ .../DirectiveUtilEditorActorSubsystemTest.cpp | 235 +- ...rectiveUtilEditorAssetAuditLibraryTest.cpp | 65 + .../DirectiveUtilEditorAssetLibraryTest.cpp | 82 + ...irectiveUtilEditorBlueprintLibraryTest.cpp | 191 ++ .../DirectiveUtilEditorTaskLibraryTest.cpp | 98 + .../DirectiveUtilFunctionLibraryTest.cpp | 367 ++- ...tiveUtilGameplayTagFunctionLibraryTest.cpp | 22 +- .../DirectiveUtilInputActivePathTest.cpp | 60 +- .../DirectiveUtilInputFunctionLibraryTest.cpp | 7 +- .../DirectiveUtilMapFunctionLibraryTest.cpp | 10 +- .../DirectiveUtilMapNodeWildcardTest.cpp | 342 +++ .../Tests/DirectiveUtilMapScenarioTest.cpp | 252 ++ ...iveUtilMathExtendedFunctionLibraryTest.cpp | 540 ++++ .../DirectiveUtilMathFunctionLibraryTest.cpp | 165 +- .../Tests/DirectiveUtilMathScenarioTest.cpp | 83 + .../DirectiveUtilPointGenerationTest.cpp | 837 ++++++ .../DirectiveUtilRegexFunctionLibraryTest.cpp | 4 +- .../DirectiveUtilRuntimePerformanceTest.cpp | 1517 +++++++++++ .../Tests/DirectiveUtilRuntimeSurfaceTest.cpp | 104 + ...rectiveUtilSaveGameFunctionLibraryTest.cpp | 16 +- ...DirectiveUtilStringFunctionLibraryTest.cpp | 84 +- .../Tests/DirectiveUtilStringScenarioTest.cpp | 82 + .../DirectiveUtilTextFunctionLibraryTest.cpp | 4 +- .../Tests/DirectiveUtilTransformArrayTest.cpp | 485 ++++ .../Public/DirectiveUtilitiesTests.h | 4 +- .../Public/Tests/DirectiveUtilTestObject.h | 244 +- 103 files changed, 18211 insertions(+), 1071 deletions(-) create mode 100644 Content/GameData/Dialogue/Bazaar/IntroductoryDialogues/DA_BazaarIntroDialogueSimple.uasset create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesBlueprintNodes/DirectiveUtilitiesBlueprintNodes.Build.cs create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesBlueprintNodes/Private/DirectiveUtilitiesBlueprintNodes.cpp create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesBlueprintNodes/Private/Nodes/DirectiveUtilMapNodeMigration.cpp create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesBlueprintNodes/Private/Nodes/K2Node_DirectiveUtilMapAppend.cpp create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesBlueprintNodes/Public/DirectiveUtilitiesBlueprintNodes.h create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesBlueprintNodes/Public/Nodes/DirectiveUtilMapNodeMigration.h create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesBlueprintNodes/Public/Nodes/K2Node_DirectiveUtilMapAppend.h create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/AssetRegistry/DirectiveUtilAssetRegistry.h create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/AssetRegistry/DirectiveUtilDependencyCycleFinder.cpp create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/AssetRegistry/DirectiveUtilDependencyCycleFinder.h create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/Libraries/DirectiveUtilEditorAssetAuditLibrary.cpp create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/Libraries/DirectiveUtilEditorBlueprintLibrary.cpp create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/Libraries/DirectiveUtilEditorTaskLibrary.cpp create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/Tasks/DirectiveUtilEditorSlowTask.cpp create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Libraries/DirectiveUtilEditorAssetAuditLibrary.h create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Libraries/DirectiveUtilEditorBlueprintLibrary.h create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Libraries/DirectiveUtilEditorTaskLibrary.h create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Tasks/DirectiveUtilEditorSlowTask.h create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Types/DirectiveUtilEditorAuditTypes.h create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Types/DirectiveUtilEditorBlueprintTypes.h create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Types/DirectiveUtilEditorTaskTypes.h create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilMathEasingFunctionLibrary.cpp create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilMathRandomFunctionLibrary.cpp create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilMathStatisticsFunctionLibrary.cpp create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Tasks/DirectiveUtilAsyncActionBase.cpp create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Tasks/DirectiveUtilTask_Flow.cpp create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilAsyncActionBase.h create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilTask_Flow.h create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilArrayBlueprintVmTest.cpp create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilArrayNodeWildcardTest.cpp create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilArrayScenarioTest.cpp create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilDependencyCycleFinderTest.cpp create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilEditorActorLayoutTest.cpp create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilEditorAssetAuditLibraryTest.cpp create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilEditorBlueprintLibraryTest.cpp create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilEditorTaskLibraryTest.cpp create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilMapNodeWildcardTest.cpp create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilMapScenarioTest.cpp create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilMathExtendedFunctionLibraryTest.cpp create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilMathScenarioTest.cpp create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilPointGenerationTest.cpp create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilRuntimePerformanceTest.cpp create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilRuntimeSurfaceTest.cpp create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilStringScenarioTest.cpp create mode 100644 Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilTransformArrayTest.cpp diff --git a/Content/GameData/Dialogue/Bazaar/IntroductoryDialogues/DA_BazaarIntroDialogueSimple.uasset b/Content/GameData/Dialogue/Bazaar/IntroductoryDialogues/DA_BazaarIntroDialogueSimple.uasset new file mode 100644 index 00000000..cd289c63 --- /dev/null +++ b/Content/GameData/Dialogue/Bazaar/IntroductoryDialogues/DA_BazaarIntroDialogueSimple.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:33ff4fea2dfcde28c1a0971b0dfcf69444579adac6a782fc172bf3d49ae69d7d +size 1312 diff --git a/Plugins/DirectiveUtilities/Config/FilterPlugin.ini b/Plugins/DirectiveUtilities/Config/FilterPlugin.ini index a83a8150..43e8ffba 100644 --- a/Plugins/DirectiveUtilities/Config/FilterPlugin.ini +++ b/Plugins/DirectiveUtilities/Config/FilterPlugin.ini @@ -6,8 +6,5 @@ ; /README.txt ; /Extras/... ; /Binaries/ThirdParty/*.dll -/README.md -/LICENSE -/CHANGELOG.md /Documentation/... /Config/DefaultDirectiveUtilities.ini diff --git a/Plugins/DirectiveUtilities/DirectiveUtilities.uplugin b/Plugins/DirectiveUtilities/DirectiveUtilities.uplugin index 36826517..aa717178 100644 --- a/Plugins/DirectiveUtilities/DirectiveUtilities.uplugin +++ b/Plugins/DirectiveUtilities/DirectiveUtilities.uplugin @@ -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", diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesBlueprintNodes/DirectiveUtilitiesBlueprintNodes.Build.cs b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesBlueprintNodes/DirectiveUtilitiesBlueprintNodes.Build.cs new file mode 100644 index 00000000..6a4a261f --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesBlueprintNodes/DirectiveUtilitiesBlueprintNodes.Build.cs @@ -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"); + } +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesBlueprintNodes/Private/DirectiveUtilitiesBlueprintNodes.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesBlueprintNodes/Private/DirectiveUtilitiesBlueprintNodes.cpp new file mode 100644 index 00000000..3dca5d89 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesBlueprintNodes/Private/DirectiveUtilitiesBlueprintNodes.cpp @@ -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 Blueprint; Blueprint; ++Blueprint) + { + HandleAssetLoaded(*Blueprint); + } +} + +void FDirectiveUtilitiesBlueprintNodesModule::ShutdownModule() +{ + FCoreUObjectDelegates::OnAssetLoaded.Remove(AssetLoadedHandle); +} + +void FDirectiveUtilitiesBlueprintNodesModule::HandleAssetLoaded(UObject* Asset) +{ + UBlueprint* Blueprint = Cast(Asset); + if (Blueprint && !Blueprint->HasAnyFlags(RF_Transient)) + { + DirectiveUtilMapNodeMigration::UpgradeLegacyAppendNodes(*Blueprint); + } +} + +IMPLEMENT_MODULE(FDirectiveUtilitiesBlueprintNodesModule, DirectiveUtilitiesBlueprintNodes) diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesBlueprintNodes/Private/Nodes/DirectiveUtilMapNodeMigration.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesBlueprintNodes/Private/Nodes/DirectiveUtilMapNodeMigration.cpp new file mode 100644 index 00000000..8f1beace --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesBlueprintNodes/Private/Nodes/DirectiveUtilMapNodeMigration.cpp @@ -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() + && 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(Graph->GetSchema()) : nullptr; + if (!Graph || !Schema) + { + return false; + } + + UK2Node_DirectiveUtilMapAppend* NewNode = NewObject(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 Graphs; + Blueprint.GetAllGraphs(Graphs); + + bool bModified = false; + for (UEdGraph* Graph : Graphs) + { + if (!Graph) + { + continue; + } + + TArray FunctionNodes; + Graph->GetNodesOfClass(FunctionNodes); + for (UK2Node_CallFunction* FunctionNode : FunctionNodes) + { + if (FunctionNode && IsLegacyAppendNode(*FunctionNode)) + { + bModified |= ReplaceLegacyAppendNode(*FunctionNode); + } + } + } + + if (bModified) + { + FBlueprintEditorUtils::MarkBlueprintAsModified(&Blueprint); + } + + return bModified; +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesBlueprintNodes/Private/Nodes/K2Node_DirectiveUtilMapAppend.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesBlueprintNodes/Private/Nodes/K2Node_DirectiveUtilMapAppend.cpp new file mode 100644 index 00000000..173fa7f4 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesBlueprintNodes/Private/Nodes/K2Node_DirectiveUtilMapAppend.cpp @@ -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 diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesBlueprintNodes/Public/DirectiveUtilitiesBlueprintNodes.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesBlueprintNodes/Public/DirectiveUtilitiesBlueprintNodes.h new file mode 100644 index 00000000..e257abc0 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesBlueprintNodes/Public/DirectiveUtilitiesBlueprintNodes.h @@ -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; +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesBlueprintNodes/Public/Nodes/DirectiveUtilMapNodeMigration.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesBlueprintNodes/Public/Nodes/DirectiveUtilMapNodeMigration.h new file mode 100644 index 00000000..3fe4b870 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesBlueprintNodes/Public/Nodes/DirectiveUtilMapNodeMigration.h @@ -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); +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesBlueprintNodes/Public/Nodes/K2Node_DirectiveUtilMapAppend.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesBlueprintNodes/Public/Nodes/K2Node_DirectiveUtilMapAppend.h new file mode 100644 index 00000000..2067ea2c --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesBlueprintNodes/Public/Nodes/K2Node_DirectiveUtilMapAppend.h @@ -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); +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/DirectiveUtilitiesEditor.Build.cs b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/DirectiveUtilitiesEditor.Build.cs index e1d09503..7194d1bd 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/DirectiveUtilitiesEditor.Build.cs +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/DirectiveUtilitiesEditor.Build.cs @@ -16,6 +16,7 @@ public class DirectiveUtilitiesEditor : ModuleRules "Engine", "EditorSubsystem", "EditorScriptingUtilities", + "AssetRegistry", "DirectiveUtilitiesRuntime", } ); @@ -26,9 +27,8 @@ public class DirectiveUtilitiesEditor : ModuleRules "Slate", "SlateCore", "UnrealEd", - "AssetRegistry", "AssetTools", } ); } -} \ No newline at end of file +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/AssetRegistry/DirectiveUtilAssetRegistry.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/AssetRegistry/DirectiveUtilAssetRegistry.h new file mode 100644 index 00000000..ac16b52c --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/AssetRegistry/DirectiveUtilAssetRegistry.h @@ -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(); + } + } +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/AssetRegistry/DirectiveUtilDependencyCycleFinder.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/AssetRegistry/DirectiveUtilDependencyCycleFinder.cpp new file mode 100644 index 00000000..7b321158 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/AssetRegistry/DirectiveUtilDependencyCycleFinder.cpp @@ -0,0 +1,123 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#include "AssetRegistry/DirectiveUtilDependencyCycleFinder.h" + +namespace DirectiveUtilitiesEditor +{ + FDependencyCycleFinder::FDependencyCycleFinder(const TMap>& InGraph) + : Graph(InGraph) + { + } + + TArray FDependencyCycleFinder::Find() const + { + const TArray FinishOrder = BuildFinishOrder(); + const TMap> ReverseGraph = BuildReverseGraph(); + TSet Visited; + TArray Cycles; + for (int32 Index = FinishOrder.Num() - 1; Index >= 0; --Index) + { + const FName Root = FinishOrder[Index]; + if (Visited.Contains(Root)) + { + continue; + } + + FDirectiveUtilAssetDependencyCycle Cycle; + TArray Pending = {Root}; + Visited.Add(Root); + while (!Pending.IsEmpty()) + { + const FName Package = Pending.Pop(EAllowShrinking::No); + Cycle.Packages.Add(Package); + const TArray* Referencers = ReverseGraph.Find(Package); + if (!Referencers) + { + continue; + } + for (const FName Referencer : *Referencers) + { + if (!Visited.Contains(Referencer)) + { + Visited.Add(Referencer); + Pending.Add(Referencer); + } + } + } + + const TArray* 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 FDependencyCycleFinder::BuildFinishOrder() const + { + TArray Packages; + Graph.GetKeys(Packages); + Packages.Sort(FNameLexicalLess()); + + TArray FinishOrder; + FinishOrder.Reserve(Packages.Num()); + TSet Visited; + for (const FName Root : Packages) + { + if (Visited.Contains(Root)) + { + continue; + } + + TArray Pending = {{Root, 0}}; + Visited.Add(Root); + while (!Pending.IsEmpty()) + { + FTraversalFrame& Frame = Pending.Last(); + const TArray* 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> FDependencyCycleFinder::BuildReverseGraph() const + { + TMap> ReverseGraph; + for (const TPair>& Node : Graph) + { + ReverseGraph.FindOrAdd(Node.Key); + for (const FName Dependency : Node.Value) + { + ReverseGraph.FindOrAdd(Dependency).Add(Node.Key); + } + } + + for (TPair>& Node : ReverseGraph) + { + Node.Value.Sort(FNameLexicalLess()); + } + return ReverseGraph; + } +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/AssetRegistry/DirectiveUtilDependencyCycleFinder.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/AssetRegistry/DirectiveUtilDependencyCycleFinder.h new file mode 100644 index 00000000..cba942da --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/AssetRegistry/DirectiveUtilDependencyCycleFinder.h @@ -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>& InGraph); + + TArray Find() const; + + private: + struct FTraversalFrame + { + FName Package; + int32 NextDependencyIndex = 0; + }; + + TArray BuildFinishOrder() const; + TMap> BuildReverseGraph() const; + + const TMap>& Graph; + }; +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/DirectiveUtilitiesEditor.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/DirectiveUtilitiesEditor.cpp index c7ec7563..a4ceb1a6 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/DirectiveUtilitiesEditor.cpp +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/DirectiveUtilitiesEditor.cpp @@ -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) diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/Libraries/DirectiveUtilEditorAssetAuditLibrary.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/Libraries/DirectiveUtilEditorAssetAuditLibrary.cpp new file mode 100644 index 00000000..859462e9 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/Libraries/DirectiveUtilEditorAssetAuditLibrary.cpp @@ -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 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 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 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& GetAssets() const + { + return Assets; + } + + const TArray& GetDependencies(const FName PackageName) + { + if (const TArray* Existing = Dependencies.Find(PackageName)) + { + return *Existing; + } + + TArray& Result = Dependencies.Add(PackageName); + AssetRegistry.GetDependencies(FAssetIdentifier(PackageName), Result, EDependencyCategory::All); + return Result; + } + + const TArray& GetReferencers(const FName PackageName) + { + if (const TArray* Existing = Referencers.Find(PackageName)) + { + return *Existing; + } + + TArray& 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 Assets; + TMap> Dependencies; + TMap> 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 FindUnreferencedCandidates(FAssetAuditScan& Scan) + { + TArray Candidates; + for (const FAssetData& Asset : Scan.GetAssets()) + { + if ((!IsPrimaryAsset(Asset) || Scan.IncludesPrimaryAssets()) && Scan.GetReferencers(Asset.PackageName).IsEmpty()) + { + Candidates.Add(Asset); + } + } + return Candidates; + } + + TArray FindMissingReferences(FAssetAuditScan& Scan) + { + TArray 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 FindDependencyCycles(FAssetAuditScan& Scan) + { + TSet ScannedPackages; + for (const FAssetData& Asset : Scan.GetAssets()) + { + ScannedPackages.Add(Asset.PackageName); + } + + TMap> Graph; + for (const FName Package : ScannedPackages) + { + TArray& 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 UDirectiveUtilEditorAssetAuditLibrary::FindUnreferencedAssetCandidates( + const FDirectiveUtilAssetAuditOptions& Options) +{ + IAssetRegistry* AssetRegistry = IAssetRegistry::Get(); + if (!AssetRegistry) + { + return {}; + } + + FAssetAuditScan Scan(*AssetRegistry, Options); + return FindUnreferencedCandidates(Scan); +} + +TArray UDirectiveUtilEditorAssetAuditLibrary::FindMissingAssetReferences( + const FDirectiveUtilAssetAuditOptions& Options) +{ + IAssetRegistry* AssetRegistry = IAssetRegistry::Get(); + if (!AssetRegistry) + { + return {}; + } + + FAssetAuditScan Scan(*AssetRegistry, Options); + return FindMissingReferences(Scan); +} + +TArray 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 Candidates = FindUnreferencedCandidates(Scan); + Report.MissingReferences = FindMissingReferences(Scan); + Report.DependencyCycles = FindDependencyCycles(Scan); + + TSet CandidatePaths; + for (const FAssetData& Candidate : Candidates) + { + CandidatePaths.Add(Candidate.GetSoftObjectPath()); + } + + TMap MissingCounts; + for (const FDirectiveUtilMissingAssetReference& MissingReference : Report.MissingReferences) + { + ++MissingCounts.FindOrAdd(MissingReference.ReferencingAsset.PackageName); + } + + TSet 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(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 Rows; + Rows.Add(TEXT("Asset,Package,Path,Class,Disk Size,Dependencies,Referencers,Primary Asset,Findings")); + for (const FDirectiveUtilAssetAuditEntry& Entry : Report.Assets) + { + const TArray 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 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 Packages; + for (const FName Package : Cycle.Packages) + { + Packages.Add(Package.ToString()); + } + Rows.Add(EscapeCsv(FString::Join(Packages, TEXT(" -> ")))); + } + + return FString::Join(Rows, TEXT("\n")); + } diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/Libraries/DirectiveUtilEditorAssetLibrary.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/Libraries/DirectiveUtilEditorAssetLibrary.cpp index 41a125c9..e45b365a 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/Libraries/DirectiveUtilEditorAssetLibrary.cpp +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/Libraries/DirectiveUtilEditorAssetLibrary.cpp @@ -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("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(); diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/Libraries/DirectiveUtilEditorBlueprintLibrary.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/Libraries/DirectiveUtilEditorBlueprintLibrary.cpp new file mode 100644 index 00000000..3cdbb954 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/Libraries/DirectiveUtilEditorBlueprintLibrary.cpp @@ -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 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 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(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(FBlueprintTags::ParentClassPath); + return FPackageName::ExportTextPathToObjectPath(ParentTag) == GetClassObjectPath(ParentClass); + } + + bool HasComponentClass(const UBlueprint* Blueprint, const UClass* ComponentClass, const bool bIncludeDerivedComponents) + { + TSet 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 UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsByCompileStatus( + const EDirectiveUtilBlueprintCompileStatus CompileStatus, + const FDirectiveUtilBlueprintSearchOptions& Options) +{ + TArray Matches; + for (const FAssetData& Asset : GetBlueprintAssets(Options)) + { + if (const UBlueprint* Blueprint = LoadBlueprint(Asset)) + { + if (GetBlueprintCompileStatus(Blueprint) == CompileStatus) + { + Matches.Add(Asset); + } + } + } + return Matches; +} + +TArray UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsByParentClass( + UClass* ParentClass, + const FDirectiveUtilBlueprintSearchOptions& Options, + const bool bIncludeDescendants) +{ + TArray 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 UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsImplementingInterface( + UClass* InterfaceClass, + const FDirectiveUtilBlueprintSearchOptions& Options) +{ + TArray 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(FBlueprintTags::ImplementedInterfaces); + const FString ParentTag = Asset.GetTagValueRef(FBlueprintTags::ParentClassPath); + UClass* TaggedParent = FindObject(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 UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsContainingComponentClass( + UClass* ComponentClass, + const FDirectiveUtilBlueprintSearchOptions& Options, + const bool bIncludeDerivedComponents) +{ + TArray 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 UDirectiveUtilEditorBlueprintLibrary::GetUnusedBlueprintVariables( + UBlueprint* Blueprint, + const bool bIncludeExternallyAccessibleVariables) +{ + TArray Names; + if (!IsValid(Blueprint)) + { + return Names; + } + + TArray UsedVariables; + TArray 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; +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/Libraries/DirectiveUtilEditorTaskLibrary.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/Libraries/DirectiveUtilEditorTaskLibrary.cpp new file mode 100644 index 00000000..6c81ca6a --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/Libraries/DirectiveUtilEditorTaskLibrary.cpp @@ -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(); + 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 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; +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/Subsystems/DirectiveUtilEditorActorSubsystem.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/Subsystems/DirectiveUtilEditorActorSubsystem.cpp index a112d08e..5a2c97dd 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/Subsystems/DirectiveUtilEditorActorSubsystem.cpp +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/Subsystems/DirectiveUtilEditorActorSubsystem.cpp @@ -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& 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 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 GetLayoutActors( + const TArray& Actors, + FDirectiveUtilActorOperationResult& Result) + { + TArray LayoutActors; + TSet 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& LayoutActors, + const TMap& 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 Actors, const bool bInstant) { - if (Actors.Num() == 0) { return; } - if (!GEditor) { return; } - - FViewport* ActiveViewport = GEditor->GetActiveViewport(); - if (!ActiveViewport) { return; } - - FEditorViewportClient* ViewportClient = static_cast(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 UDirectiveUtilEditorActorSubsystem::GetAllLevelClasses() @@ -84,6 +250,236 @@ TArray UDirectiveUtilEditorActorSubsystem::GetAllLevelClasses() return ActorClasses; } +FDirectiveUtilActorOperationResult UDirectiveUtilEditorActorSubsystem::AlignActors( + const TArray& Actors, + const EDirectiveUtilActorLayoutAxis Axis, + const EDirectiveUtilActorAlignment Alignment) +{ + FDirectiveUtilActorOperationResult Result; + TArray 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 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& Actors, + const EDirectiveUtilActorLayoutAxis Axis, + const EDirectiveUtilActorDistribution Distribution) +{ + FDirectiveUtilActorOperationResult Result; + TArray 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 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& Actors, + const FVector TraceDirection, + const float MaximumDistance, + const TEnumAsByte TraceChannel, + const EDirectiveUtilSurfacePlacement Placement, + const bool bAlignToNormal) +{ + FDirectiveUtilActorOperationResult Result; + TArray 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 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& OutStaticMeshActors, TArray ActorsToFilter) const @@ -735,11 +1131,11 @@ void UDirectiveUtilEditorActorSubsystem::FilterActorsByTexture( { if (const UMaterialExpressionTextureSample* TextureSample = Cast(Expression)) { - if (TextureSample->Texture == TextureReference) { return true; } + if (MatchesTextureReference(TextureSample->Texture, TextureReference)) { return true; } } if (const UMaterialExpressionTextureObject* TextureObject = Cast(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(Expression)) { - if (TextureSample->Texture == TextureReference) { return true; } + if (MatchesTextureReference(TextureSample->Texture, TextureReference)) { return true; } } if (const UMaterialExpressionTextureObject* TextureObject = Cast(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 SourceActors = SelectionMethod == Selection ? GetSelectedLevelActors() : GetAllLevelActors(); - - TArray 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 SourceActors = SelectionMethod == Selection ? GetSelectedLevelActors() : GetAllLevelActors(); - - TArray 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(Expression)) { - if (TextureSample->Texture == Texture) { return true; } + if (MatchesTextureReference(TextureSample->Texture, Texture)) { return true; } } if (const UMaterialExpressionTextureObject* TextureObject = Cast(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& 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>& 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()); diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/Tasks/DirectiveUtilEditorSlowTask.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/Tasks/DirectiveUtilEditorSlowTask.cpp new file mode 100644 index 00000000..3a1e0f62 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Private/Tasks/DirectiveUtilEditorSlowTask.cpp @@ -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(); + } +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Libraries/DirectiveUtilEditorAssetAuditLibrary.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Libraries/DirectiveUtilEditorAssetAuditLibrary.h new file mode 100644 index 00000000..8fde999c --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Libraries/DirectiveUtilEditorAssetAuditLibrary.h @@ -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 FindUnreferencedAssetCandidates(const FDirectiveUtilAssetAuditOptions& Options); + + /** Returns Asset Registry dependencies whose packages cannot be found. */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Editor|Asset Audit") + static TArray FindMissingAssetReferences(const FDirectiveUtilAssetAuditOptions& Options); + + /** Returns dependency cycles between assets in the scanned paths. */ + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Editor|Asset Audit") + static TArray 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); +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Libraries/DirectiveUtilEditorAssetLibrary.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Libraries/DirectiveUtilEditorAssetLibrary.h index d9b14738..d558c0a7 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Libraries/DirectiveUtilEditorAssetLibrary.h +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Libraries/DirectiveUtilEditorAssetLibrary.h @@ -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). diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Libraries/DirectiveUtilEditorBlueprintLibrary.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Libraries/DirectiveUtilEditorBlueprintLibrary.h new file mode 100644 index 00000000..66ea3e8b --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Libraries/DirectiveUtilEditorBlueprintLibrary.h @@ -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 FindBlueprintsByCompileStatus( + EDirectiveUtilBlueprintCompileStatus CompileStatus, + const FDirectiveUtilBlueprintSearchOptions& Options); + + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Editor|Blueprint Inspection") + static TArray FindBlueprintsByParentClass( + UClass* ParentClass, + const FDirectiveUtilBlueprintSearchOptions& Options, + bool bIncludeDescendants = true); + + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Editor|Blueprint Inspection") + static TArray FindBlueprintsImplementingInterface( + UClass* InterfaceClass, + const FDirectiveUtilBlueprintSearchOptions& Options); + + UFUNCTION(BlueprintCallable, Category = "Directive Utilities|Editor|Blueprint Inspection") + static TArray FindBlueprintsContainingComponentClass( + UClass* ComponentClass, + const FDirectiveUtilBlueprintSearchOptions& Options, + bool bIncludeDerivedComponents = true); + + UFUNCTION(BlueprintPure, Category = "Directive Utilities|Editor|Blueprint Inspection") + static TArray GetUnusedBlueprintVariables( + UBlueprint* Blueprint, + bool bIncludeExternallyAccessibleVariables = false); +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Libraries/DirectiveUtilEditorTaskLibrary.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Libraries/DirectiveUtilEditorTaskLibrary.h new file mode 100644 index 00000000..551ba821 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Libraries/DirectiveUtilEditorTaskLibrary.h @@ -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); +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Subsystems/DirectiveUtilEditorActorSubsystem.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Subsystems/DirectiveUtilEditorActorSubsystem.h index 0623ec4f..60678d18 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Subsystems/DirectiveUtilEditorActorSubsystem.h +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Subsystems/DirectiveUtilEditorActorSubsystem.h @@ -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 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& 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& 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& Actors, + FVector TraceDirection, + float MaximumDistance, + TEnumAsByte 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& FoundActors); //----------------------------- diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Tasks/DirectiveUtilEditorSlowTask.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Tasks/DirectiveUtilEditorSlowTask.h new file mode 100644 index 00000000..4b4bc5ab --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Tasks/DirectiveUtilEditorSlowTask.h @@ -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; +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Types/DirectiveUtilEditorAuditTypes.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Types/DirectiveUtilEditorAuditTypes.h new file mode 100644 index 00000000..2e07de70 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Types/DirectiveUtilEditorAuditTypes.h @@ -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 PackagePaths; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Directive Utilities|Asset Audit") + TArray 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 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 Findings; +}; + +USTRUCT(BlueprintType) +struct FDirectiveUtilAssetAuditReport +{ + GENERATED_BODY() + + UPROPERTY(BlueprintReadOnly, Category = "Directive Utilities|Asset Audit") + TArray Assets; + + UPROPERTY(BlueprintReadOnly, Category = "Directive Utilities|Asset Audit") + TArray MissingReferences; + + UPROPERTY(BlueprintReadOnly, Category = "Directive Utilities|Asset Audit") + TArray DependencyCycles; +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Types/DirectiveUtilEditorBlueprintTypes.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Types/DirectiveUtilEditorBlueprintTypes.h new file mode 100644 index 00000000..eb11e83b --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Types/DirectiveUtilEditorBlueprintTypes.h @@ -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 PackagePaths; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Directive Utilities|Blueprint Inspection") + TArray ExcludedPackagePaths; + + FDirectiveUtilBlueprintSearchOptions() + { + PackagePaths.Add(TEXT("/Game")); + } +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Types/DirectiveUtilEditorTaskTypes.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Types/DirectiveUtilEditorTaskTypes.h new file mode 100644 index 00000000..9084d3e8 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Types/DirectiveUtilEditorTaskTypes.h @@ -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 +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Types/DirectiveUtilEditorTypes.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Types/DirectiveUtilEditorTypes.h index ef97d0c1..e26191de 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Types/DirectiveUtilEditorTypes.h +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesEditor/Public/Types/DirectiveUtilEditorTypes.h @@ -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> ChangedActors; + + UPROPERTY(BlueprintReadOnly, Category = "Directive Utilities|Actor Layout") + TArray> SkippedActors; +}; /** * EDirectiveUtilSelectionMethod diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/DirectiveUtilitiesRuntime.Build.cs b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/DirectiveUtilitiesRuntime.Build.cs index d912401f..4e290119 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/DirectiveUtilitiesRuntime.Build.cs +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/DirectiveUtilitiesRuntime.Build.cs @@ -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", } @@ -39,4 +40,4 @@ public class DirectiveUtilitiesRuntime : ModuleRules } ); } -} \ No newline at end of file +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/DirectiveUtilLogChannels.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/DirectiveUtilLogChannels.cpp index f95f5cdd..ffdc1221 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/DirectiveUtilLogChannels.cpp +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/DirectiveUtilLogChannels.cpp @@ -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); \ No newline at end of file +DEFINE_LOG_CATEGORY(LogDirectiveUtilEditor); diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilArrayFunctionLibrary.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilArrayFunctionLibrary.cpp index bec527eb..c0c771d9 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilArrayFunctionLibrary.cpp +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilArrayFunctionLibrary.cpp @@ -3,6 +3,187 @@ #include "Libraries/DirectiveUtilArrayFunctionLibrary.h" +#include "Algo/BinarySearch.h" +#include "Algo/Sort.h" +#include "Containers/BitArray.h" +#include "Containers/ScriptArray.h" +#include "Kismet/KismetArrayLibrary.h" +#include "Misc/ComparisonUtility.h" +#include "UObject/Class.h" + +namespace +{ + constexpr int32 MaxSampleCount = 1'000'000; + + class FArrayResultBuilder + { + public: + FArrayResultBuilder(const FArrayProperty* InArrayProperty, const int32 Count) + : ArrayProperty(InArrayProperty) + , ArrayHelper(InArrayProperty, &Array) + { + ArrayHelper.AddValues(Count); + } + + ~FArrayResultBuilder() + { + ArrayHelper.EmptyValues(); + } + + uint8* GetRawPtr(const int32 Index) + { + return ArrayHelper.GetRawPtr(Index); + } + + void MoveTo(void* OutArray) + { + FScriptArrayHelper OutHelper(ArrayProperty, OutArray); + OutHelper.MoveAssign(&Array); + } + + private: + FScriptArray Array; + const FArrayProperty* ArrayProperty; + FScriptArrayHelper ArrayHelper; + }; + + struct FPropertyValueKey + { + const FProperty* Property; + const void* Value; + uint32 Hash; + + friend uint32 GetTypeHash(const FPropertyValueKey& Key) + { + return Key.Hash; + } + + friend bool operator==(const FPropertyValueKey& Left, const FPropertyValueKey& Right) + { + return Left.Property == Right.Property && Left.Property->Identical(Left.Value, Right.Value); + } + }; + + struct FValueGroup + { + int32 SourceIndex; + int32 Count; + int32 HashNext = INDEX_NONE; + }; + + struct FWeightedSampleCandidate + { + double Priority; + int32 SourceIndex; + }; + + bool HaveMatchingElementTypes(const FArrayProperty* SourceProperty, const FArrayProperty* OutProperty) + { + return SourceProperty && OutProperty && SourceProperty->Inner->SameType(OutProperty->Inner); + } + + void BuildValueGroups( + FScriptArrayHelper& SourceHelper, + const FProperty* InnerProperty, + TArray& OutGroups) + { + const int32 Num = SourceHelper.Num(); + OutGroups.Reset(Num); + if (InnerProperty->HasAllPropertyFlags(CPF_HasGetValueTypeHash)) + { + TMap GroupIndices; + GroupIndices.Reserve(Num); + for (int32 SourceIndex = 0; SourceIndex < Num; ++SourceIndex) + { + const void* Value = SourceHelper.GetRawPtr(SourceIndex); + const FPropertyValueKey Key{InnerProperty, Value, InnerProperty->GetValueTypeHash(Value)}; + if (const int32* GroupIndex = GroupIndices.Find(Key)) + { + ++OutGroups[*GroupIndex].Count; + continue; + } + + const int32 GroupIndex = OutGroups.Num(); + OutGroups.Add({SourceIndex, 1}); + GroupIndices.Add(Key, GroupIndex); + } + return; + } + + const FStructProperty* StructProperty = CastField(InnerProperty); + const UScriptStruct::ICppStructOps* StructOps = StructProperty ? StructProperty->Struct->GetCppStructOps() : nullptr; + if (StructProperty && (!StructOps || !StructOps->HasIdentical())) + { + TMap GroupHeads; + GroupHeads.Reserve(Num); + for (int32 SourceIndex = 0; SourceIndex < Num; ++SourceIndex) + { + const void* Value = SourceHelper.GetRawPtr(SourceIndex); + FString ExportedValue; + InnerProperty->ExportTextItem_Direct(ExportedValue, Value, nullptr, nullptr, PPF_None); + const uint32 Hash = GetTypeHash(ExportedValue); + const int32* Head = GroupHeads.Find(Hash); + int32 GroupIndex = Head ? *Head : INDEX_NONE; + while (GroupIndex != INDEX_NONE && + !InnerProperty->Identical(Value, SourceHelper.GetRawPtr(OutGroups[GroupIndex].SourceIndex))) + { + GroupIndex = OutGroups[GroupIndex].HashNext; + } + + if (GroupIndex != INDEX_NONE) + { + ++OutGroups[GroupIndex].Count; + continue; + } + + const int32 NewGroupIndex = OutGroups.Add({SourceIndex, 1, Head ? *Head : INDEX_NONE}); + GroupHeads.Add(Hash, NewGroupIndex); + } + return; + } + + for (int32 SourceIndex = 0; SourceIndex < Num; ++SourceIndex) + { + const void* Value = SourceHelper.GetRawPtr(SourceIndex); + int32 GroupIndex = INDEX_NONE; + for (int32 CandidateIndex = 0; CandidateIndex < OutGroups.Num(); ++CandidateIndex) + { + if (InnerProperty->Identical(Value, SourceHelper.GetRawPtr(OutGroups[CandidateIndex].SourceIndex))) + { + GroupIndex = CandidateIndex; + break; + } + } + + if (GroupIndex == INDEX_NONE) + { + OutGroups.Add({SourceIndex, 1}); + } + else + { + ++OutGroups[GroupIndex].Count; + } + } + } + + void CopyValues( + FScriptArrayHelper& SourceHelper, + const FProperty* SourceProperty, + const TArray& SourceIndices, + void* OutArray, + const FArrayProperty* OutArrayProperty) + { + FArrayResultBuilder Result(OutArrayProperty, SourceIndices.Num()); + for (int32 OutIndex = 0; OutIndex < SourceIndices.Num(); ++OutIndex) + { + SourceProperty->CopySingleValueToScriptVM( + Result.GetRawPtr(OutIndex), + SourceHelper.GetRawPtr(SourceIndices[OutIndex])); + } + Result.MoveTo(OutArray); + } +} + int32 UDirectiveUtilArrayFunctionLibrary::Array_NextIndex(const TArray& TargetArray, const int32 Index, const bool bLoop) { checkNoEntry(); @@ -30,21 +211,19 @@ int32 UDirectiveUtilArrayFunctionLibrary::GenericArray_NextIndex( } const FScriptArrayHelper ArrayHelper(ArrayProperty, TargetArray); - const int32 NextIndex = Index + 1; - - if(ArrayHelper.Num() == 0) + if (ArrayHelper.Num() == 0) { return INDEX_NONE; } - if(NextIndex < 0) + if (Index < -1) { return 0; } - if (NextIndex <= ArrayHelper.Num() - 1) + if (Index < ArrayHelper.Num() - 1) { - return NextIndex; + return Index + 1; } if (bLoop) @@ -55,11 +234,27 @@ int32 UDirectiveUtilArrayFunctionLibrary::GenericArray_NextIndex( return ArrayHelper.Num() - 1; } -void UDirectiveUtilArrayFunctionLibrary::Array_RemoveDuplicates(const TArray& TargetArray) +void UDirectiveUtilArrayFunctionLibrary::Array_RemoveDuplicates(TArray& TargetArray) { checkNoEntry(); } +void UDirectiveUtilArrayFunctionLibrary::Array_AppendOptimized( + TArray& TargetArray, + const TArray& SourceArray) +{ + checkNoEntry(); +} + +bool UDirectiveUtilArrayFunctionLibrary::Array_InsertOptimized( + TArray& TargetArray, + const TArray& SourceArray, + const int32 Index) +{ + checkNoEntry(); + return false; +} + void UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveDuplicates( void* TargetArray, const FArrayProperty* ArrayProperty) @@ -71,18 +266,159 @@ void UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveDuplicates( FScriptArrayHelper ArrayHelper(ArrayProperty, TargetArray); const FProperty* InnerProp = ArrayProperty->Inner; - - for (int32 OuterIndex = ArrayHelper.Num() - 1; OuterIndex > 0; --OuterIndex) + if (ArrayHelper.Num() <= 1) { - for (int32 InnerIndex = 0; InnerIndex < OuterIndex; ++InnerIndex) + return; + } + + TArray Groups; + BuildValueGroups(ArrayHelper, InnerProp, Groups); + if (Groups.Num() == ArrayHelper.Num()) + { + return; + } + + TArray SourceIndices; + SourceIndices.Reserve(Groups.Num()); + for (const FValueGroup& Group : Groups) + { + SourceIndices.Add(Group.SourceIndex); + } + CopyValues(ArrayHelper, InnerProp, SourceIndices, TargetArray, ArrayProperty); +} + +void UDirectiveUtilArrayFunctionLibrary::GenericArray_AppendOptimized( + void* TargetArray, + const FArrayProperty* TargetArrayProperty, + const void* SourceArray, + const FArrayProperty* SourceArrayProperty) +{ + if (!TargetArray + || !SourceArray + || !HaveMatchingElementTypes(TargetArrayProperty, SourceArrayProperty)) + { + return; + } + + FScriptArrayHelper TargetArrayHelper(TargetArrayProperty, TargetArray); + FScriptArrayHelper SourceArrayHelper(SourceArrayProperty, SourceArray); + const int32 SourceCount = SourceArrayHelper.Num(); + if (SourceCount == 0) + { + return; + } + + constexpr int32 MaxIndex = TNumericLimits::Max() - 1; + const int32 AddCount = FMath::Min( + SourceCount, + MaxIndex - UKismetArrayLibrary::GetLastIndex(TargetArrayHelper)); + if (AddCount > 0) + { + FProperty* InnerProperty = TargetArrayProperty->Inner; + const bool bCanBulkCopy = InnerProperty->HasAnyPropertyFlags(CPF_IsPlainOldData); + const int32 StartIndex = bCanBulkCopy + ? TargetArrayHelper.AddUninitializedValues(AddCount) + : TargetArrayHelper.AddValues(AddCount); + if (bCanBulkCopy) { - if (InnerProp->Identical(ArrayHelper.GetElementPtr(OuterIndex), ArrayHelper.GetElementPtr(InnerIndex))) + FMemory::Memcpy( + TargetArrayHelper.GetRawPtr(StartIndex), + SourceArrayHelper.GetRawPtr(0), + static_cast(AddCount) * InnerProperty->GetElementSize()); + } + else + { + for (int32 SourceIndex = 0; SourceIndex < AddCount; ++SourceIndex) { - ArrayHelper.RemoveValues(OuterIndex); - break; + InnerProperty->CopySingleValueToScriptVM( + TargetArrayHelper.GetRawPtr(StartIndex + SourceIndex), + SourceArrayHelper.GetRawPtr(SourceIndex)); } } } + + if (AddCount < SourceCount) + { + FFrame::KismetExecutionMessage( + *FString::Printf( + TEXT("Attempted append to array '%s' beyond the maximum supported capacity!"), + *TargetArrayProperty->GetName()), + ELogVerbosity::Warning, + UKismetArrayLibrary::ReachedMaximumContainerSizeWarning); + } +} + +bool UDirectiveUtilArrayFunctionLibrary::GenericArray_InsertOptimized( + void* TargetArray, + const FArrayProperty* TargetArrayProperty, + const void* SourceArray, + const FArrayProperty* SourceArrayProperty, + const int32 Index) +{ + if (!TargetArray + || !SourceArray + || !HaveMatchingElementTypes(TargetArrayProperty, SourceArrayProperty)) + { + return false; + } + + FScriptArrayHelper TargetArrayHelper(TargetArrayProperty, TargetArray); + FScriptArrayHelper SourceArrayHelper(SourceArrayProperty, SourceArray); + const int32 TargetCount = TargetArrayHelper.Num(); + const int32 SourceCount = SourceArrayHelper.Num(); + if (Index < 0 || Index > TargetCount) + { + return false; + } + if (SourceCount == 0) + { + return false; + } + if (SourceCount > TNumericLimits::Max() - TargetCount) + { + FFrame::KismetExecutionMessage( + *FString::Printf( + TEXT("Attempted insert into array '%s' beyond the maximum supported capacity!"), + *TargetArrayProperty->GetName()), + ELogVerbosity::Warning, + UKismetArrayLibrary::ReachedMaximumContainerSizeWarning); + return false; + } + + FProperty* InnerProperty = TargetArrayProperty->Inner; + const bool bSourceIsTarget = TargetArray == SourceArray; + TargetArrayHelper.InsertValues(Index, SourceCount); + + if (bSourceIsTarget) + { + for (int32 SourceIndex = 0; SourceIndex < SourceCount; ++SourceIndex) + { + const int32 ShiftedSourceIndex = SourceIndex < Index + ? SourceIndex + : SourceIndex + SourceCount; + InnerProperty->CopySingleValueToScriptVM( + TargetArrayHelper.GetRawPtr(Index + SourceIndex), + TargetArrayHelper.GetRawPtr(ShiftedSourceIndex)); + } + } + else if (InnerProperty->HasAnyPropertyFlags(CPF_IsPlainOldData)) + { + FMemory::Memcpy( + TargetArrayHelper.GetRawPtr(Index), + SourceArrayHelper.GetRawPtr(0), + static_cast(SourceCount) * InnerProperty->GetElementSize()); + } + else + { + for (int32 SourceIndex = 0; SourceIndex < SourceCount; ++SourceIndex) + { + InnerProperty->CopySingleValueToScriptVM( + TargetArrayHelper.GetRawPtr(Index + SourceIndex), + SourceArrayHelper.GetRawPtr(SourceIndex)); + } + } + + return true; } int32 UDirectiveUtilArrayFunctionLibrary::GenericArray_PreviousIndex( @@ -97,21 +433,19 @@ int32 UDirectiveUtilArrayFunctionLibrary::GenericArray_PreviousIndex( } const FScriptArrayHelper ArrayHelper(ArrayProperty, TargetArray); - const int32 PreviousIndex = Index - 1; - - if(ArrayHelper.Num() == 0) + if (ArrayHelper.Num() == 0) { return INDEX_NONE; } - if(PreviousIndex > ArrayHelper.Num() - 1) + if (Index > ArrayHelper.Num()) { return ArrayHelper.Num() - 1; } - if (PreviousIndex >= 0) + if (Index > 0) { - return PreviousIndex; + return Index - 1; } if (bLoop) @@ -151,19 +485,33 @@ void UDirectiveUtilArrayFunctionLibrary::Array_LastValue(const TArray& Ta checkNoEntry(); } -bool UDirectiveUtilArrayFunctionLibrary::Array_Pop(const TArray& TargetArray, int32& OutItem) +bool UDirectiveUtilArrayFunctionLibrary::Array_Pop(TArray& TargetArray, int32& OutItem) { checkNoEntry(); return false; } -bool UDirectiveUtilArrayFunctionLibrary::Array_PopFirst(const TArray& TargetArray, int32& OutItem) +bool UDirectiveUtilArrayFunctionLibrary::Array_PopFirst(TArray& TargetArray, int32& OutItem) { checkNoEntry(); return false; } -bool UDirectiveUtilArrayFunctionLibrary::Array_RemoveAtSwap(const TArray& TargetArray, const int32 Index) +bool UDirectiveUtilArrayFunctionLibrary::Array_RemoveAtSwap(TArray& TargetArray, const int32 Index) +{ + checkNoEntry(); + return false; +} + +int32 UDirectiveUtilArrayFunctionLibrary::Array_RemoveAtIndices( + TArray& TargetArray, + const TArray& Indices) +{ + checkNoEntry(); + return 0; +} + +bool UDirectiveUtilArrayFunctionLibrary::Array_RemoveAllOccurrences(TArray& TargetArray, const int32& Item) { checkNoEntry(); return false; @@ -342,12 +690,161 @@ bool UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveAtSwap( return true; } +int32 UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveAtIndices( + void* TargetArray, + const FArrayProperty* ArrayProperty, + const TArray& Indices) +{ + if (!TargetArray || !ArrayProperty || Indices.IsEmpty()) + { + return 0; + } + + FScriptArrayHelper ArrayHelper(ArrayProperty, TargetArray); + const int32 ElementCount = ArrayHelper.Num(); + if (ElementCount == 0) + { + return 0; + } + + TBitArray<> RemoveFlags; + RemoveFlags.Init(false, ElementCount); + int32 RemovedCount = 0; + for (const int32 Index : Indices) + { + if (ArrayHelper.IsValidIndex(Index) && !RemoveFlags[Index]) + { + RemoveFlags[Index] = true; + ++RemovedCount; + } + } + if (RemovedCount == 0) + { + return 0; + } + + int32 WriteIndex = 0; + while (!RemoveFlags[WriteIndex]) + { + ++WriteIndex; + } + + const FProperty* InnerProperty = ArrayProperty->Inner; + const bool bCanBulkMove = InnerProperty->HasAnyPropertyFlags(CPF_IsPlainOldData); + const int32 ElementSize = InnerProperty->GetElementSize(); + int32 ReadIndex = WriteIndex + 1; + while (ReadIndex < ElementCount) + { + while (ReadIndex < ElementCount && RemoveFlags[ReadIndex]) + { + ++ReadIndex; + } + + const int32 RunStart = ReadIndex; + while (ReadIndex < ElementCount && !RemoveFlags[ReadIndex]) + { + ++ReadIndex; + } + + const int32 RunCount = ReadIndex - RunStart; + if (RunCount == 0) + { + continue; + } + + if (bCanBulkMove) + { + FMemory::Memmove( + ArrayHelper.GetRawPtr(WriteIndex), + ArrayHelper.GetRawPtr(RunStart), + static_cast(RunCount) * ElementSize); + } + else + { + for (int32 RunIndex = 0; RunIndex < RunCount; ++RunIndex) + { + InnerProperty->CopySingleValue( + ArrayHelper.GetRawPtr(WriteIndex + RunIndex), + ArrayHelper.GetRawPtr(RunStart + RunIndex)); + } + } + WriteIndex += RunCount; + } + + ArrayHelper.RemoveValues(WriteIndex, RemovedCount); + return RemovedCount; +} + +bool UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveAllOccurrences( + void* TargetArray, + const FArrayProperty* ArrayProperty, + const void* Item) +{ + if (!TargetArray || !ArrayProperty || !Item) + { + return false; + } + + FScriptArrayHelper ArrayHelper(ArrayProperty, TargetArray); + const FProperty* InnerProperty = ArrayProperty->Inner; + const int32 ElementCount = ArrayHelper.Num(); + int32 WriteIndex = UKismetArrayLibrary::GenericArray_Find(TargetArray, ArrayProperty, Item); + if (WriteIndex == INDEX_NONE) + { + return false; + } + + int32 ReadIndex = WriteIndex + 1; + const bool bCanBulkMove = InnerProperty->HasAnyPropertyFlags(CPF_IsPlainOldData); + const int32 ElementSize = InnerProperty->GetElementSize(); + while (ReadIndex < ElementCount) + { + while (ReadIndex < ElementCount && InnerProperty->Identical(Item, ArrayHelper.GetRawPtr(ReadIndex))) + { + ++ReadIndex; + } + + const int32 RunStart = ReadIndex; + while (ReadIndex < ElementCount && !InnerProperty->Identical(Item, ArrayHelper.GetRawPtr(ReadIndex))) + { + ++ReadIndex; + } + + const int32 RunCount = ReadIndex - RunStart; + if (RunCount == 0) + { + continue; + } + + if (bCanBulkMove) + { + FMemory::Memmove( + ArrayHelper.GetRawPtr(WriteIndex), + ArrayHelper.GetRawPtr(RunStart), + static_cast(RunCount) * ElementSize); + } + else + { + for (int32 RunIndex = 0; RunIndex < RunCount; ++RunIndex) + { + InnerProperty->CopySingleValue( + ArrayHelper.GetRawPtr(WriteIndex + RunIndex), + ArrayHelper.GetRawPtr(RunStart + RunIndex)); + } + } + WriteIndex += RunCount; + } + + ArrayHelper.RemoveValues(WriteIndex, ElementCount - WriteIndex); + return true; +} + void UDirectiveUtilArrayFunctionLibrary::Array_Slice(const TArray& TargetArray, const int32 StartIndex, const int32 Count, TArray& OutArray) { checkNoEntry(); } -void UDirectiveUtilArrayFunctionLibrary::Array_Rotate(const TArray& TargetArray, const int32 Shift) +void UDirectiveUtilArrayFunctionLibrary::Array_Rotate(TArray& TargetArray, const int32 Shift) { checkNoEntry(); } @@ -377,29 +874,29 @@ void UDirectiveUtilArrayFunctionLibrary::GenericArray_Slice( void* OutArray, const FArrayProperty* OutArrayProperty) { - if (!TargetArray || !OutArray || !TargetArrayProperty || !OutArrayProperty) + if (!TargetArray || !OutArray || !HaveMatchingElementTypes(TargetArrayProperty, OutArrayProperty)) { return; } FScriptArrayHelper SourceHelper(TargetArrayProperty, TargetArray); - FScriptArrayHelper OutHelper(OutArrayProperty, OutArray); - OutHelper.EmptyValues(); - const int32 Num = SourceHelper.Num(); if (Num == 0 || Count <= 0) { + FArrayResultBuilder EmptyResult(OutArrayProperty, 0); + EmptyResult.MoveTo(OutArray); return; } const FProperty* InnerProp = TargetArrayProperty->Inner; const int32 Start = FMath::Clamp(StartIndex, 0, Num); const int32 NumToCopy = FMath::Min(Count, Num - Start); + FArrayResultBuilder Result(OutArrayProperty, NumToCopy); for (int32 Offset = 0; Offset < NumToCopy; ++Offset) { - const int32 OutIndex = OutHelper.AddValue(); - InnerProp->CopySingleValueToScriptVM(OutHelper.GetRawPtr(OutIndex), SourceHelper.GetRawPtr(Start + Offset)); + InnerProp->CopySingleValueToScriptVM(Result.GetRawPtr(Offset), SourceHelper.GetRawPtr(Start + Offset)); } + Result.MoveTo(OutArray); } void UDirectiveUtilArrayFunctionLibrary::GenericArray_Rotate( @@ -429,6 +926,30 @@ void UDirectiveUtilArrayFunctionLibrary::GenericArray_Rotate( return; } + const FProperty* InnerProperty = ArrayProperty->Inner; + if (InnerProperty->HasAllPropertyFlags(CPF_IsPlainOldData)) + { + const int32 ElementSize = InnerProperty->GetElementSize(); + const int32 LeftCount = Num - Normalized; + const int32 ScratchCount = FMath::Min(LeftCount, Normalized); + TArray Scratch; + Scratch.SetNumUninitialized(ScratchCount * ElementSize); + uint8* Data = ArrayHelper.GetRawPtr(0); + if (Normalized <= LeftCount) + { + FMemory::Memcpy(Scratch.GetData(), Data + LeftCount * ElementSize, Scratch.Num()); + FMemory::Memmove(Data + Normalized * ElementSize, Data, LeftCount * ElementSize); + FMemory::Memcpy(Data, Scratch.GetData(), Scratch.Num()); + } + else + { + FMemory::Memcpy(Scratch.GetData(), Data, Scratch.Num()); + FMemory::Memmove(Data, Data + LeftCount * ElementSize, Normalized * ElementSize); + FMemory::Memcpy(Data + Normalized * ElementSize, Scratch.GetData(), Scratch.Num()); + } + return; + } + auto ReverseRange = [&ArrayHelper](int32 Low, int32 High) { while (Low < High) @@ -449,37 +970,23 @@ void UDirectiveUtilArrayFunctionLibrary::GenericArray_GetDistinct( void* OutArray, const FArrayProperty* OutArrayProperty) { - if (!TargetArray || !OutArray || !TargetArrayProperty || !OutArrayProperty) + if (!TargetArray || !OutArray || !HaveMatchingElementTypes(TargetArrayProperty, OutArrayProperty)) { return; } FScriptArrayHelper SourceHelper(TargetArrayProperty, TargetArray); - FScriptArrayHelper OutHelper(OutArrayProperty, OutArray); - OutHelper.EmptyValues(); - const FProperty* InnerProp = TargetArrayProperty->Inner; - const int32 Num = SourceHelper.Num(); - for (int32 SourceIndex = 0; SourceIndex < Num; ++SourceIndex) + TArray Groups; + BuildValueGroups(SourceHelper, InnerProp, Groups); + + TArray SourceIndices; + SourceIndices.Reserve(Groups.Num()); + for (const FValueGroup& Group : Groups) { - const uint8* SourceElement = SourceHelper.GetRawPtr(SourceIndex); - - bool bIsDuplicate = false; - for (int32 ExistingIndex = 0; ExistingIndex < OutHelper.Num(); ++ExistingIndex) - { - if (InnerProp->Identical(SourceElement, OutHelper.GetRawPtr(ExistingIndex))) - { - bIsDuplicate = true; - break; - } - } - - if (!bIsDuplicate) - { - const int32 OutIndex = OutHelper.AddValue(); - InnerProp->CopySingleValueToScriptVM(OutHelper.GetRawPtr(OutIndex), SourceElement); - } + SourceIndices.Add(Group.SourceIndex); } + CopyValues(SourceHelper, InnerProp, SourceIndices, OutArray, OutArrayProperty); } int32 UDirectiveUtilArrayFunctionLibrary::GenericArray_CountOccurrences( @@ -534,32 +1041,421 @@ bool UDirectiveUtilArrayFunctionLibrary::GenericArray_GetMostCommon( return false; } - int32 BestIndex = 0; - int32 BestCount = 0; - for (int32 Index = 0; Index < Num; ++Index) + TArray Groups; + BuildValueGroups(ArrayHelper, InnerProp, Groups); + int32 BestGroupIndex = 0; + for (int32 GroupIndex = 1; GroupIndex < Groups.Num(); ++GroupIndex) { - int32 CurrentCount = 0; - for (int32 CompareIndex = 0; CompareIndex < Num; ++CompareIndex) + if (Groups[GroupIndex].Count > Groups[BestGroupIndex].Count) { - if (InnerProp->Identical(ArrayHelper.GetRawPtr(Index), ArrayHelper.GetRawPtr(CompareIndex))) - { - ++CurrentCount; - } - } - if (CurrentCount > BestCount) - { - BestCount = CurrentCount; - BestIndex = Index; + BestGroupIndex = GroupIndex; } } + const FValueGroup& BestGroup = Groups[BestGroupIndex]; if (OutItemPtr) { - InnerProp->CopyCompleteValueFromScriptVM(OutItemPtr, ArrayHelper.GetRawPtr(BestIndex)); + InnerProp->CopyCompleteValueFromScriptVM(OutItemPtr, ArrayHelper.GetRawPtr(BestGroup.SourceIndex)); } if (OutCount) { - *OutCount = BestCount; + *OutCount = BestGroup.Count; } return true; } + +void UDirectiveUtilArrayFunctionLibrary::Array_Sample( + const TArray& TargetArray, + const int32 Count, + const bool bWithReplacement, + TArray& OutArray) +{ + checkNoEntry(); +} + +void UDirectiveUtilArrayFunctionLibrary::Array_SampleFromStream( + const TArray& TargetArray, + const int32 Count, + const bool bWithReplacement, + FRandomStream& RandomStream, + TArray& OutArray) +{ + checkNoEntry(); +} + +bool UDirectiveUtilArrayFunctionLibrary::Array_SampleWeighted( + const TArray& TargetArray, + const TArray& Weights, + const int32 Count, + const bool bWithReplacement, + TArray& OutArray) +{ + checkNoEntry(); + return false; +} + +bool UDirectiveUtilArrayFunctionLibrary::Array_SampleWeightedFromStream( + const TArray& TargetArray, + const TArray& Weights, + const int32 Count, + const bool bWithReplacement, + FRandomStream& RandomStream, + TArray& OutArray) +{ + checkNoEntry(); + return false; +} + +void UDirectiveUtilArrayFunctionLibrary::GenericArray_Sample( + const void* TargetArray, + const FArrayProperty* TargetArrayProperty, + const int32 Count, + const bool bWithReplacement, + FRandomStream* RandomStream, + void* OutArray, + const FArrayProperty* OutArrayProperty) +{ + if (!TargetArray || !OutArray || !HaveMatchingElementTypes(TargetArrayProperty, OutArrayProperty)) + { + return; + } + + FScriptArrayHelper SourceHelper(TargetArrayProperty, TargetArray); + const int32 SourceCount = SourceHelper.Num(); + if (Count <= 0 || Count > MaxSampleCount || SourceCount == 0) + { + FArrayResultBuilder EmptyResult(OutArrayProperty, 0); + EmptyResult.MoveTo(OutArray); + return; + } + + const int32 SampleCount = bWithReplacement ? Count : FMath::Min(Count, SourceCount); + FArrayResultBuilder Result(OutArrayProperty, SampleCount); + const FProperty* InnerProperty = TargetArrayProperty->Inner; + auto CopySourceElement = [&](const int32 OutIndex, const int32 SourceIndex) + { + InnerProperty->CopySingleValueToScriptVM(Result.GetRawPtr(OutIndex), SourceHelper.GetRawPtr(SourceIndex)); + }; + auto RandomIndex = [RandomStream](const int32 MaxIndex) + { + return RandomStream ? RandomStream->RandRange(0, MaxIndex) : FMath::RandRange(0, MaxIndex); + }; + + if (bWithReplacement) + { + for (int32 SampleIndex = 0; SampleIndex < Count; ++SampleIndex) + { + CopySourceElement(SampleIndex, RandomIndex(SourceCount - 1)); + } + Result.MoveTo(OutArray); + return; + } + + if (SampleCount <= SourceCount / 4) + { + TMap RemappedIndices; + RemappedIndices.Reserve(SampleCount); + auto ResolveIndex = [&RemappedIndices](const int32 Position) + { + if (const int32* RemappedIndex = RemappedIndices.Find(Position)) + { + return *RemappedIndex; + } + return Position; + }; + + for (int32 SampleIndex = 0; SampleIndex < SampleCount; ++SampleIndex) + { + const int32 LastPosition = SourceCount - SampleIndex - 1; + const int32 SelectedPosition = RandomIndex(LastPosition); + const int32 SelectedSourceIndex = ResolveIndex(SelectedPosition); + if (SelectedPosition != LastPosition) + { + const int32 LastSourceIndex = ResolveIndex(LastPosition); + if (LastSourceIndex == SelectedPosition) + { + RemappedIndices.Remove(SelectedPosition); + } + else + { + RemappedIndices.Add(SelectedPosition, LastSourceIndex); + } + } + RemappedIndices.Remove(LastPosition); + CopySourceElement(SampleIndex, SelectedSourceIndex); + } + Result.MoveTo(OutArray); + return; + } + + TArray AvailableIndices; + AvailableIndices.SetNumUninitialized(SourceCount); + for (int32 Index = 0; Index < SourceCount; ++Index) + { + AvailableIndices[Index] = Index; + } + + for (int32 SampleIndex = 0; SampleIndex < SampleCount; ++SampleIndex) + { + const int32 SelectedIndex = RandomIndex(AvailableIndices.Num() - 1); + CopySourceElement(SampleIndex, AvailableIndices[SelectedIndex]); + AvailableIndices.RemoveAtSwap(SelectedIndex, 1, EAllowShrinking::No); + } + Result.MoveTo(OutArray); +} + +bool UDirectiveUtilArrayFunctionLibrary::GenericArray_SampleWeighted( + const void* TargetArray, + const FArrayProperty* TargetArrayProperty, + const TArray& Weights, + const int32 Count, + const bool bWithReplacement, + FRandomStream* RandomStream, + void* OutArray, + const FArrayProperty* OutArrayProperty) +{ + if (!TargetArray || !OutArray || !HaveMatchingElementTypes(TargetArrayProperty, OutArrayProperty)) + { + return false; + } + + FScriptArrayHelper SourceHelper(TargetArrayProperty, TargetArray); + const int32 SourceCount = SourceHelper.Num(); + if (Count < 0 || Count > MaxSampleCount || Weights.Num() != SourceCount) + { + FArrayResultBuilder EmptyResult(OutArrayProperty, 0); + EmptyResult.MoveTo(OutArray); + return false; + } + if (Count == 0) + { + FArrayResultBuilder EmptyResult(OutArrayProperty, 0); + EmptyResult.MoveTo(OutArray); + return true; + } + + TArray PositiveWeights; + PositiveWeights.SetNumUninitialized(SourceCount); + double TotalWeight = 0.0; + int32 PositiveWeightCount = 0; + for (int32 SourceIndex = 0; SourceIndex < SourceCount; ++SourceIndex) + { + const float Weight = Weights[SourceIndex]; + const double PositiveWeight = FMath::IsFinite(Weight) && Weight > 0.0f + ? static_cast(Weight) + : 0.0; + PositiveWeights[SourceIndex] = PositiveWeight; + TotalWeight += PositiveWeight; + PositiveWeightCount += PositiveWeight > 0.0 ? 1 : 0; + } + + if (PositiveWeightCount == 0) + { + FArrayResultBuilder EmptyResult(OutArrayProperty, 0); + EmptyResult.MoveTo(OutArray); + return false; + } + + const FProperty* InnerProperty = TargetArrayProperty->Inner; + auto RandomFraction = [RandomStream]() + { + return static_cast(RandomStream ? RandomStream->FRand() : FMath::FRand()); + }; + if (bWithReplacement) + { + double PrefixWeight = 0.0; + for (double& Weight : PositiveWeights) + { + PrefixWeight += Weight; + Weight = PrefixWeight; + } + + FArrayResultBuilder Result(OutArrayProperty, Count); + for (int32 SampleIndex = 0; SampleIndex < Count; ++SampleIndex) + { + const int32 SourceIndex = FMath::Min( + Algo::UpperBound(PositiveWeights, RandomFraction() * TotalWeight), + SourceCount - 1); + InnerProperty->CopySingleValueToScriptVM(Result.GetRawPtr(SampleIndex), SourceHelper.GetRawPtr(SourceIndex)); + } + Result.MoveTo(OutArray); + return true; + } + + const int32 SampleCount = FMath::Min(Count, PositiveWeightCount); + auto IsLowerPriority = [](const FWeightedSampleCandidate& Left, const FWeightedSampleCandidate& Right) + { + return Left.Priority < Right.Priority || + (Left.Priority == Right.Priority && Left.SourceIndex > Right.SourceIndex); + }; + auto IsHigherPriority = [&IsLowerPriority](const FWeightedSampleCandidate& Left, const FWeightedSampleCandidate& Right) + { + return IsLowerPriority(Right, Left); + }; + + TArray SelectedCandidates; + SelectedCandidates.Reserve(SampleCount); + for (int32 SourceIndex = 0; SourceIndex < SourceCount; ++SourceIndex) + { + const double Weight = PositiveWeights[SourceIndex]; + if (Weight <= 0.0) + { + continue; + } + + const double RandomValue = FMath::Max(RandomFraction(), TNumericLimits::Min()); + const FWeightedSampleCandidate Candidate{FMath::Loge(RandomValue) / Weight, SourceIndex}; + if (SelectedCandidates.Num() < SampleCount) + { + SelectedCandidates.HeapPush(Candidate, IsLowerPriority); + continue; + } + + if (IsHigherPriority(Candidate, SelectedCandidates[0])) + { + FWeightedSampleCandidate RemovedCandidate; + SelectedCandidates.HeapPop(RemovedCandidate, IsLowerPriority, EAllowShrinking::No); + SelectedCandidates.HeapPush(Candidate, IsLowerPriority); + } + } + Algo::Sort(SelectedCandidates, IsHigherPriority); + + FArrayResultBuilder Result(OutArrayProperty, SampleCount); + for (int32 SampleIndex = 0; SampleIndex < SampleCount; ++SampleIndex) + { + const int32 SourceIndex = SelectedCandidates[SampleIndex].SourceIndex; + InnerProperty->CopySingleValueToScriptVM(Result.GetRawPtr(SampleIndex), SourceHelper.GetRawPtr(SourceIndex)); + } + Result.MoveTo(OutArray); + return true; +} + +bool UDirectiveUtilArrayFunctionLibrary::Array_GetPage( + const TArray& TargetArray, + const int32 PageIndex, + const int32 PageSize, + TArray& OutArray, + int32& OutPageCount) +{ + checkNoEntry(); + return false; +} + +bool UDirectiveUtilArrayFunctionLibrary::GenericArray_GetPage( + const void* TargetArray, + const FArrayProperty* TargetArrayProperty, + const int32 PageIndex, + const int32 PageSize, + void* OutArray, + const FArrayProperty* OutArrayProperty, + int32* OutPageCount) +{ + if (OutPageCount) + { + *OutPageCount = 0; + } + if (!TargetArray || !OutArray || !HaveMatchingElementTypes(TargetArrayProperty, OutArrayProperty)) + { + return false; + } + + FScriptArrayHelper SourceHelper(TargetArrayProperty, TargetArray); + if (PageIndex < 0 || PageSize <= 0) + { + FArrayResultBuilder EmptyResult(OutArrayProperty, 0); + EmptyResult.MoveTo(OutArray); + return false; + } + + const int32 SourceCount = SourceHelper.Num(); + const int32 PageCount = SourceCount == 0 ? 0 : 1 + (SourceCount - 1) / PageSize; + if (OutPageCount) + { + *OutPageCount = PageCount; + } + if (PageIndex >= PageCount) + { + FArrayResultBuilder EmptyResult(OutArrayProperty, 0); + EmptyResult.MoveTo(OutArray); + return false; + } + + const int32 StartIndex = PageIndex * PageSize; + const int32 EndIndex = StartIndex + FMath::Min(PageSize, SourceCount - StartIndex); + const FProperty* InnerProperty = TargetArrayProperty->Inner; + FArrayResultBuilder Result(OutArrayProperty, EndIndex - StartIndex); + for (int32 SourceIndex = StartIndex; SourceIndex < EndIndex; ++SourceIndex) + { + InnerProperty->CopySingleValueToScriptVM(Result.GetRawPtr(SourceIndex - StartIndex), SourceHelper.GetRawPtr(SourceIndex)); + } + Result.MoveTo(OutArray); + return true; +} + +void UDirectiveUtilArrayFunctionLibrary::NaturalSortStringArray(TArray& TargetArray, const bool bDescending) +{ + struct FIndexedString + { + const FString* Value; + int32 OriginalIndex; + }; + + TArray IndexedStrings; + IndexedStrings.Reserve(TargetArray.Num()); + for (int32 Index = 0; Index < TargetArray.Num(); ++Index) + { + IndexedStrings.Add({&TargetArray[Index], Index}); + } + + Algo::Sort(IndexedStrings, [bDescending](const FIndexedString& Left, const FIndexedString& Right) + { + const int32 Comparison = UE::ComparisonUtility::CompareNaturalOrder(*Left.Value, *Right.Value); + if (Comparison == 0) + { + return Left.OriginalIndex < Right.OriginalIndex; + } + return bDescending ? Comparison > 0 : Comparison < 0; + }); + + TArray SortedArray; + SortedArray.Reserve(TargetArray.Num()); + for (const FIndexedString& IndexedString : IndexedStrings) + { + SortedArray.Add(MoveTemp(TargetArray[IndexedString.OriginalIndex])); + } + TargetArray = MoveTemp(SortedArray); +} + +void UDirectiveUtilArrayFunctionLibrary::NaturalSortNameArray(TArray& TargetArray, const bool bDescending) +{ + struct FIndexedName + { + FString Value; + int32 OriginalIndex; + }; + + TArray IndexedNames; + IndexedNames.Reserve(TargetArray.Num()); + for (int32 Index = 0; Index < TargetArray.Num(); ++Index) + { + IndexedNames.Add({TargetArray[Index].ToString(), Index}); + } + + Algo::Sort(IndexedNames, [bDescending](const FIndexedName& Left, const FIndexedName& Right) + { + const int32 Comparison = UE::ComparisonUtility::CompareNaturalOrder(Left.Value, Right.Value); + if (Comparison == 0) + { + return Left.OriginalIndex < Right.OriginalIndex; + } + return bDescending ? Comparison > 0 : Comparison < 0; + }); + + TArray SortedArray; + SortedArray.Reserve(TargetArray.Num()); + for (const FIndexedName& IndexedName : IndexedNames) + { + SortedArray.Add(TargetArray[IndexedName.OriginalIndex]); + } + TargetArray = MoveTemp(SortedArray); +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilFunctionLibrary.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilFunctionLibrary.cpp index 19427041..17ecbc2a 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilFunctionLibrary.cpp +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilFunctionLibrary.cpp @@ -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 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& 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()) diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilGameplayTagFunctionLibrary.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilGameplayTagFunctionLibrary.cpp index 62a47aa7..e7373d62 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilGameplayTagFunctionLibrary.cpp +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilGameplayTagFunctionLibrary.cpp @@ -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 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 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 SegmentsA = GetTagSegments(TagA); - const TArray 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 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) diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilInputFunctionLibrary.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilInputFunctionLibrary.cpp index f8386f80..e27c371e 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilInputFunctionLibrary.cpp +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilInputFunctionLibrary.cpp @@ -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>& 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& 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 PreviousContext, - const TSoftObjectPtr NewContext, - const int32 Priority, - const bool bUsePreviousPriority) + AController* PlayerController, + const TSoftObjectPtr PreviousContext, + const TSoftObjectPtr 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 Context) { - const UInputMappingContext* MappingContext = Context.LoadSynchronous(); + const UInputMappingContext* MappingContext = Context.Get(); if (!MappingContext) { return false; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilMapFunctionLibrary.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilMapFunctionLibrary.cpp index 0951666e..57ef7883 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilMapFunctionLibrary.cpp +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilMapFunctionLibrary.cpp @@ -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; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilMathEasingFunctionLibrary.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilMathEasingFunctionLibrary.cpp new file mode 100644 index 00000000..b2b78751 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilMathEasingFunctionLibrary.cpp @@ -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 + TArray EaseArrays(const TArray& From, const TArray& To, const float Alpha, + const EDirectiveUtilEaseType EaseType, const TArray& 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 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(EasedAlpha)); + } + return Result; + } + +} + +float UDirectiveUtilMathFunctionLibrary::EaseAlpha(const float Alpha, const EDirectiveUtilEaseType EaseType) +{ + const double t = static_cast(FMath::Clamp(Alpha, 0.0f, 1.0f)); + switch (EaseType) + { + case EDirectiveUtilEaseType::BackIn: return static_cast(EaseBackIn(t)); + case EDirectiveUtilEaseType::BackOut: return static_cast(EaseBackOut(t)); + case EDirectiveUtilEaseType::BackInOut: return static_cast(EaseBackInOut(t)); + case EDirectiveUtilEaseType::ElasticIn: return static_cast(EaseElasticIn(t)); + case EDirectiveUtilEaseType::ElasticOut: return static_cast(EaseElasticOut(t)); + case EDirectiveUtilEaseType::ElasticInOut: return static_cast(EaseElasticInOut(t)); + case EDirectiveUtilEaseType::BounceIn: return static_cast(EaseBounceIn(t)); + case EDirectiveUtilEaseType::BounceOut: return static_cast(EaseBounceOut(t)); + case EDirectiveUtilEaseType::BounceInOut: return static_cast(EaseBounceInOut(t)); + case EDirectiveUtilEaseType::Linear: + default: return static_cast(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(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(EaseAlpha(Alpha, EaseType))); +} + +TArray UDirectiveUtilMathFunctionLibrary::EaseLocationArrays(const TArray& From, + const TArray& To, const float Alpha, const EDirectiveUtilEaseType EaseType, + const TArray& PerElementAlphas) +{ + return EaseArrays(From, To, Alpha, EaseType, PerElementAlphas, + [](const FVector& A, const FVector& B, const double EasedAlpha) + { + return FMath::Lerp(A, B, EasedAlpha); + }); +} + +TArray UDirectiveUtilMathFunctionLibrary::EaseTransformArrays(const TArray& From, + const TArray& To, const float Alpha, const EDirectiveUtilEaseType EaseType, + const TArray& PerElementAlphas) +{ + return EaseArrays(From, To, Alpha, EaseType, PerElementAlphas, &BlendEasedTransforms); +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilMathFunctionLibrary.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilMathFunctionLibrary.cpp index b14c0700..eefbcda5 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilMathFunctionLibrary.cpp +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilMathFunctionLibrary.cpp @@ -3,101 +3,1009 @@ #include "Libraries/DirectiveUtilMathFunctionLibrary.h" +#include "Components/SplineComponent.h" +#include "Math/RotationMatrix.h" + +#include + namespace { - double EaseBackIn(double t) + constexpr double DirectionDotTolerance = 8.0 * std::numeric_limits::epsilon(); + constexpr double ProjectionTolerance = 32.0 * std::numeric_limits::epsilon(); + + bool IsSupportedGeneratedElementCount(const int64 Count) { - const double s = 1.70158; - return t * t * ((s + 1.0) * t - s); + return Count > 0 && Count <= UDirectiveUtilMathFunctionLibrary::MaximumGeneratedElementCount; } - double EaseBackOut(double t) + bool IsFiniteVector2D(const FVector2D& Value) { - const double s = 1.70158; - t -= 1.0; - return t * t * ((s + 1.0) * t + s) + 1.0; + return FMath::IsFinite(Value.X) && FMath::IsFinite(Value.Y); } - double EaseBackInOut(double t) + bool IsFiniteVector(const FVector& Value) { - const double s = 1.70158 * 1.525; - t *= 2.0; - if (t < 1.0) + return FMath::IsFinite(Value.X) && FMath::IsFinite(Value.Y) && FMath::IsFinite(Value.Z); + } + + bool TryGetNormalizedVector(const FVector& Value, FVector& Normalized, double* Length = nullptr) + { + Normalized = FVector::ZeroVector; + if (Value.ContainsNaN()) { - return 0.5 * (t * t * ((s + 1.0) * t - s)); + return false; } - 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) + const double MaximumComponent = Value.GetAbsMax(); + if (MaximumComponent == 0.0) { - t -= 1.0; - return -0.5 * (FMath::Pow(2.0, 10.0 * t) * FMath::Sin((t - s) * (2.0 * PI) / p)); + return false; } - t -= 1.0; - return FMath::Pow(2.0, -10.0 * t) * FMath::Sin((t - s) * (2.0 * PI) / p) * 0.5 + 1.0; + + const FVector Scaled = Value / MaximumComponent; + const double ScaledLength = Scaled.Size(); + if (!FMath::IsFinite(ScaledLength) || ScaledLength == 0.0) + { + return false; + } + + Normalized = Scaled / ScaledLength; + if (Length) + { + if (ScaledLength > TNumericLimits::Max() / MaximumComponent) + { + Normalized = FVector::ZeroVector; + return false; + } + *Length = MaximumComponent * ScaledLength; + } + return !Normalized.ContainsNaN(); } - double EaseBounceOut(double t) + bool TryGetProjectedDirection(const FVector& Value, const FVector& NormalizedAxis, FVector& Direction) { - const double n1 = 7.5625; - const double d1 = 2.75; - if (t < 1.0 / d1) + Direction = FVector::ZeroVector; + if (Value.ContainsNaN()) { - return n1 * t * t; + return false; } - if (t < 2.0 / d1) + + const double MaximumComponent = Value.GetAbsMax(); + if (MaximumComponent == 0.0) { - t -= 1.5 / d1; - return n1 * t * t + 0.75; + return false; } - if (t < 2.5 / d1) + + const FVector Scaled = Value / MaximumComponent; + const FVector Projected = FVector::VectorPlaneProject(Scaled, NormalizedAxis); + if (Projected.GetAbsMax() <= ProjectionTolerance) { - t -= 2.25 / d1; - return n1 * t * t + 0.9375; + return false; } - t -= 2.625 / d1; - return n1 * t * t + 0.984375; + return TryGetNormalizedVector(Projected, Direction); } - double EaseBounceIn(double t) + bool IsNormalizedDirectionWithinCone(const FVector& NormalizedDirection, const FVector& ConeDirection, + const float ConeHalfAngleDegrees) { - return 1.0 - EaseBounceOut(1.0 - t); + FVector NormalizedConeDirection; + if (!FMath::IsFinite(ConeHalfAngleDegrees) + || !TryGetNormalizedVector(ConeDirection, NormalizedConeDirection)) + { + return false; + } + + const double Dot = FMath::Clamp( + FVector::DotProduct(NormalizedDirection, NormalizedConeDirection), -1.0, 1.0); + const double ClampedHalfAngle = FMath::Clamp(static_cast(ConeHalfAngleDegrees), 0.0, 180.0); + return Dot + DirectionDotTolerance >= FMath::Cos(FMath::DegreesToRadians(ClampedHalfAngle)); } - double EaseBounceInOut(double t) + bool TryGetRotationQuaternion(const FRotator& Rotation, FQuat& Quaternion) { - return t < 0.5 - ? (1.0 - EaseBounceOut(1.0 - 2.0 * t)) * 0.5 - : (1.0 + EaseBounceOut(2.0 * t - 1.0)) * 0.5; + Quaternion = FQuat::Identity; + if (!FMath::IsFinite(Rotation.Pitch) || !FMath::IsFinite(Rotation.Yaw) || !FMath::IsFinite(Rotation.Roll)) + { + return false; + } + + Quaternion = Rotation.Quaternion(); + return !Quaternion.ContainsNaN(); } + + bool TryGetRotatedAxes(const FRotator& Rotation, FVector& AxisX, FVector& AxisY, FVector& AxisZ) + { + AxisX = FVector::ZeroVector; + AxisY = FVector::ZeroVector; + AxisZ = FVector::ZeroVector; + FQuat Quaternion; + if (!TryGetRotationQuaternion(Rotation, Quaternion)) + { + return false; + } + + AxisX = Quaternion.GetAxisX(); + AxisY = Quaternion.GetAxisY(); + AxisZ = Quaternion.GetAxisZ(); + return !AxisX.ContainsNaN() && !AxisY.ContainsNaN() && !AxisZ.ContainsNaN(); + } + + struct FHexLayout + { + FVector Origin = FVector::ZeroVector; + FVector AxisX = FVector::ZeroVector; + FVector AxisY = FVector::ZeroVector; + FVector StepQ = FVector::ZeroVector; + FVector StepR = FVector::ZeroVector; + double LayoutRadius = 0.0; + EDirectiveUtilHexOrientation Orientation = EDirectiveUtilHexOrientation::PointyTop; + + FVector GetLocation(const int64 Q, const int64 R) const + { + return Origin + StepQ * static_cast(Q) + StepR * static_cast(R); + } + }; + + bool TryMakeHexLayout(const FVector& Origin, const FRotator& Rotation, const double CellRadius, + const double Gap, const EDirectiveUtilHexOrientation Orientation, FHexLayout& Layout) + { + FVector AxisX; + FVector AxisY; + FVector AxisZ; + if (Origin.ContainsNaN() || !FMath::IsFinite(CellRadius) || CellRadius <= 0.0 + || !FMath::IsFinite(Gap) || !TryGetRotatedAxes(Rotation, AxisX, AxisY, AxisZ)) + { + return false; + } + + const double LayoutRadius = CellRadius + Gap / UE_DOUBLE_SQRT_3; + if (!FMath::IsFinite(LayoutRadius) || LayoutRadius <= 0.0) + { + return false; + } + + Layout.Origin = Origin; + Layout.AxisX = AxisX; + Layout.AxisY = AxisY; + Layout.LayoutRadius = LayoutRadius; + Layout.Orientation = Orientation; + switch (Orientation) + { + case EDirectiveUtilHexOrientation::PointyTop: + Layout.StepQ = AxisX * (UE_DOUBLE_SQRT_3 * LayoutRadius); + Layout.StepR = (AxisX * (UE_DOUBLE_SQRT_3 * 0.5) + AxisY * 1.5) * LayoutRadius; + break; + case EDirectiveUtilHexOrientation::FlatTop: + Layout.StepQ = (AxisX * 1.5 + AxisY * (UE_DOUBLE_SQRT_3 * 0.5)) * LayoutRadius; + Layout.StepR = AxisY * (UE_DOUBLE_SQRT_3 * LayoutRadius); + break; + default: + return false; + } + return !Layout.StepQ.ContainsNaN() && !Layout.StepR.ContainsNaN(); + } + + constexpr int32 HexDirections[6][2] = { + { 1, 0 }, { 1, -1 }, { 0, -1 }, { -1, 0 }, { -1, 1 }, { 0, 1 } + }; + + void GetHexOffsetCoordinate(const EDirectiveUtilHexOrientation Orientation, const int32 Column, + const int32 Row, int64& Q, int64& R) + { + if (Orientation == EDirectiveUtilHexOrientation::PointyTop) + { + Q = static_cast(Column) - (Row - (Row & 1)) / 2; + R = Row; + } + else + { + Q = Column; + R = static_cast(Row) - (Column - (Column & 1)) / 2; + } + } + + bool TryMakeHexCoordinate(const int64 Q, const int64 R, FIntPoint& Coordinate) + { + if (Q < MIN_int32 || Q > MAX_int32 || R < MIN_int32 || R > MAX_int32) + { + return false; + } + Coordinate = FIntPoint(static_cast(Q), static_cast(R)); + return true; + } + + bool TryValidateHexLayoutSpan(const FHexLayout& Layout, const int64 MaxAbsQ, const int64 MaxAbsR) + { + if (MaxAbsQ < 0 || MaxAbsR < 0) + { + return false; + } + const FVector Bound = Layout.Origin.GetAbs() + + Layout.StepQ.GetAbs() * static_cast(MaxAbsQ) + + Layout.StepR.GetAbs() * static_cast(MaxAbsR); + return IsFiniteVector(Bound); + } + + void WriteHexLocationUnchecked(const FHexLayout& Layout, const int64 Q, const int64 R, FVector& Destination) + { + Destination = Layout.GetLocation(Q, R); + } + + bool TryApplyRectangularHexCentering(FHexLayout& Layout, const FIntPoint Dimensions, const bool bCentered) + { + if (!bCentered) + { + return true; + } + + const double NeighborSpacing = UE_DOUBLE_SQRT_3 * Layout.LayoutRadius; + double CenterX; + double CenterY; + if (Layout.Orientation == EDirectiveUtilHexOrientation::PointyTop) + { + CenterX = NeighborSpacing * (Dimensions.X - 1 + (Dimensions.Y > 1 ? 0.5 : 0.0)) * 0.5; + CenterY = 1.5 * Layout.LayoutRadius * (Dimensions.Y - 1) * 0.5; + } + else + { + CenterX = 1.5 * Layout.LayoutRadius * (Dimensions.X - 1) * 0.5; + CenterY = NeighborSpacing * (Dimensions.Y - 1 + (Dimensions.X > 1 ? 0.5 : 0.0)) * 0.5; + } + Layout.Origin -= Layout.AxisX * CenterX + Layout.AxisY * CenterY; + return !Layout.Origin.ContainsNaN(); + } + + int64 GetRectangularHexSpan(const FIntPoint Dimensions) + { + // Offset coordinates stay within |Q|,|R| ≤ Columns+Rows for both orientations. + return static_cast(Dimensions.X) + Dimensions.Y; + } + + template + bool VisitHexagonalCoordinates(const int64 Radius, VisitorType Visitor) + { + for (int64 R = -Radius; R <= Radius; ++R) + { + const int64 MinimumQ = FMath::Max(-Radius, -R - Radius); + const int64 MaximumQ = FMath::Min(Radius, -R + Radius); + for (int64 Q = MinimumQ; Q <= MaximumQ; ++Q) + { + if (!Visitor(Q, R)) + { + return false; + } + } + } + return true; + } + + template + bool VisitRectangularHexCoordinates(const FIntPoint Dimensions, + const EDirectiveUtilHexOrientation Orientation, VisitorType Visitor) + { + for (int32 Row = 0; Row < Dimensions.Y; ++Row) + { + for (int32 Column = 0; Column < Dimensions.X; ++Column) + { + int64 Q; + int64 R; + GetHexOffsetCoordinate(Orientation, Column, Row, Q, R); + if (!Visitor(Q, R)) + { + return false; + } + } + } + return true; + } + + bool TryRoundHexCoordinate(const double FractionalQ, const double FractionalR, FIntPoint& Coordinate) + { + const double FractionalS = -FractionalQ - FractionalR; + if (!FMath::IsFinite(FractionalQ) || !FMath::IsFinite(FractionalR) || !FMath::IsFinite(FractionalS)) + { + return false; + } + + double Q = FMath::RoundHalfFromZero(FractionalQ); + double R = FMath::RoundHalfFromZero(FractionalR); + double S = FMath::RoundHalfFromZero(FractionalS); + const double QDifference = FMath::Abs(Q - FractionalQ); + const double RDifference = FMath::Abs(R - FractionalR); + const double SDifference = FMath::Abs(S - FractionalS); + if (QDifference > RDifference && QDifference > SDifference) + { + Q = -R - S; + } + else if (RDifference > SDifference) + { + R = -Q - S; + } + + if (Q < MIN_int32 || Q > MAX_int32 || R < MIN_int32 || R > MAX_int32) + { + return false; + } + Coordinate = FIntPoint(static_cast(Q), static_cast(R)); + return true; + } + + bool TryGetHexCoordinate(const FHexLayout& Layout, const FVector& Location, FIntPoint& Coordinate) + { + if (Location.ContainsNaN()) + { + return false; + } + + const FVector Offset = Location - Layout.Origin; + const double X = FVector::DotProduct(Offset, Layout.AxisX) / Layout.LayoutRadius; + const double Y = FVector::DotProduct(Offset, Layout.AxisY) / Layout.LayoutRadius; + double Q; + double R; + switch (Layout.Orientation) + { + case EDirectiveUtilHexOrientation::PointyTop: + Q = UE_DOUBLE_SQRT_3 / 3.0 * X - Y / 3.0; + R = 2.0 / 3.0 * Y; + break; + case EDirectiveUtilHexOrientation::FlatTop: + Q = 2.0 / 3.0 * X; + R = -X / 3.0 + UE_DOUBLE_SQRT_3 / 3.0 * Y; + break; + default: + return false; + } + return TryRoundHexCoordinate(Q, R, Coordinate); + } + + bool TryGetHexagonalPointCount(const int32 GridRadius, int32& PointCount) + { + PointCount = 0; + if (GridRadius < 0) + { + return false; + } + + const int64 Radius = GridRadius; + const int64 Multiplier = 3 * (Radius + 1); + if (Radius > (MAX_int32 - 1) / Multiplier) + { + return false; + } + const int64 Count = 1 + Radius * Multiplier; + if (!IsSupportedGeneratedElementCount(Count)) + { + return false; + } + PointCount = static_cast(Count); + return true; + } + + bool TryGetGridPointCount(const FIntVector& Dimensions, int32& PointCount) + { + PointCount = 0; + if (Dimensions.X <= 0 || Dimensions.Y <= 0 || Dimensions.Z <= 0) + { + return false; + } + + constexpr int64 MaximumPointCount = UDirectiveUtilMathFunctionLibrary::MaximumGeneratedElementCount; + int64 Count = Dimensions.X; + if (Count > MaximumPointCount / Dimensions.Y) + { + return false; + } + Count *= Dimensions.Y; + if (Count > MaximumPointCount / Dimensions.Z) + { + return false; + } + PointCount = static_cast(Count * Dimensions.Z); + return true; + } + + struct FGridLattice + { + int32 DimX = 0; + int32 DimY = 0; + int32 DimZ = 0; + int32 PointCount = 0; + FVector FirstPoint = FVector::ZeroVector; + FVector StepX = FVector::ZeroVector; + FVector StepY = FVector::ZeroVector; + FVector StepZ = FVector::ZeroVector; + }; + + bool TryMakeGridLattice(const FVector& Origin, const FRotator& Rotation, + const FIntVector& Dimensions, const FVector& Spacing, const bool bCentered, FGridLattice& Lattice) + { + Lattice = {}; + FVector AxisX; + FVector AxisY; + FVector AxisZ; + if (!IsFiniteVector(Origin) || !IsFiniteVector(Spacing) + || !TryGetGridPointCount(Dimensions, Lattice.PointCount) + || !TryGetRotatedAxes(Rotation, AxisX, AxisY, AxisZ)) + { + return false; + } + + Lattice.DimX = Dimensions.X; + Lattice.DimY = Dimensions.Y; + Lattice.DimZ = Dimensions.Z; + Lattice.StepX = AxisX * Spacing.X; + Lattice.StepY = AxisY * Spacing.Y; + Lattice.StepZ = AxisZ * Spacing.Z; + Lattice.FirstPoint = Origin; + if (bCentered) + { + Lattice.FirstPoint -= (Lattice.StepX * (Lattice.DimX - 1) + + Lattice.StepY * (Lattice.DimY - 1) + + Lattice.StepZ * (Lattice.DimZ - 1)) * 0.5; + } + + // Reject if any lattice point can overflow: |p| <= |First| + Σ |Step|*(Dim-1). + const FVector CoordinateBound = Lattice.FirstPoint.GetAbs() + + Lattice.StepX.GetAbs() * (Lattice.DimX - 1) + + Lattice.StepY.GetAbs() * (Lattice.DimY - 1) + + Lattice.StepZ.GetAbs() * (Lattice.DimZ - 1); + return IsFiniteVector(Lattice.FirstPoint) + && IsFiniteVector(Lattice.StepX) + && IsFiniteVector(Lattice.StepY) + && IsFiniteVector(Lattice.StepZ) + && IsFiniteVector(CoordinateBound); + } + + TArray GenerateGridPoints(const FVector& Origin, const FRotator& Rotation, + const FIntVector& Dimensions, const FVector& Spacing, const bool bCentered) + { + FGridLattice Lattice; + if (!TryMakeGridLattice(Origin, Rotation, Dimensions, Spacing, bCentered, Lattice)) + { + return {}; + } + + TArray Points; + Points.SetNumUninitialized(Lattice.PointCount); + FVector* RESTRICT Dest = Points.GetData(); + for (int32 Z = 0; Z < Lattice.DimZ; ++Z) + { + const FVector LayerStart = Lattice.FirstPoint + Lattice.StepZ * Z; + for (int32 Y = 0; Y < Lattice.DimY; ++Y) + { + const FVector RowStart = LayerStart + Lattice.StepY * Y; + for (int32 X = 0; X < Lattice.DimX; ++X) + { + *Dest++ = RowStart + Lattice.StepX * X; + } + } + } + return Points; + } + + TArray GenerateGridTransforms(const FVector& Origin, const FRotator& Rotation, + const FIntVector& Dimensions, const FVector& Spacing, const bool bCentered, + const FRotator& InstanceRotation, const FVector& Scale) + { + FGridLattice Lattice; + FQuat InstanceQuaternion; + if (!IsFiniteVector(Scale) + || !TryGetRotationQuaternion(InstanceRotation, InstanceQuaternion) + || !TryMakeGridLattice(Origin, Rotation, Dimensions, Spacing, bCentered, Lattice)) + { + return {}; + } + + TArray Transforms; + Transforms.SetNumUninitialized(Lattice.PointCount); + FTransform* RESTRICT Dest = Transforms.GetData(); + for (int32 Z = 0; Z < Lattice.DimZ; ++Z) + { + const FVector LayerStart = Lattice.FirstPoint + Lattice.StepZ * Z; + for (int32 Y = 0; Y < Lattice.DimY; ++Y) + { + const FVector RowStart = LayerStart + Lattice.StepY * Y; + for (int32 X = 0; X < Lattice.DimX; ++X) + { + *Dest++ = FTransform(InstanceQuaternion, RowStart + Lattice.StepX * X, Scale); + } + } + } + return Transforms; + } + + TArray GenerateLinearPoints(const FVector& Origin, const FVector& Step, + const int32 Count, const double FirstStep) + { + if (!IsSupportedGeneratedElementCount(Count) + || !FMath::IsFinite(FirstStep) || Origin.ContainsNaN() || Step.ContainsNaN()) + { + return {}; + } + + TArray Points; + Points.SetNumUninitialized(Count); + for (int32 Index = 0; Index < Count; ++Index) + { + const FVector Point = Origin + Step * (FirstStep + Index); + if (Point.ContainsNaN()) + { + return {}; + } + Points[Index] = Point; + } + return Points; + } + + TArray MakeSinglePoint(const FVector& Point) + { + return Point.ContainsNaN() ? TArray() : TArray({ Point }); + } + + struct FAngleStepper + { + explicit FAngleStepper(const double InStartAngle, const double InStepAngle) + : StartAngle(InStartAngle), StepAngle(InStepAngle) + { + FMath::SinCos(&Sine, &Cosine, StartAngle); + FMath::SinCos(&StepSine, &StepCosine, StepAngle); + } + + void Advance() + { + ++Index; + if ((Index & 255) == 0) + { + FMath::SinCos(&Sine, &Cosine, StartAngle + StepAngle * Index); + return; + } + + const double NextSine = Sine * StepCosine + Cosine * StepSine; + Cosine = Cosine * StepCosine - Sine * StepSine; + Sine = NextSine; + } + + double Sine = 0.0; + double Cosine = 1.0; + + private: + double StartAngle; + double StepAngle; + double StepSine = 0.0; + double StepCosine = 1.0; + int32 Index = 0; + }; + + template + TArray GeneratePlanarRadialPoints(const FVector& Center, const FRotator& Rotation, + const int32 Count, const double StartAngle, const double StepAngle, RadiusFunction&& GetRadius) + { + FVector AxisX; + FVector AxisY; + FVector AxisZ; + if (!IsSupportedGeneratedElementCount(Count) + || Center.ContainsNaN() || !FMath::IsFinite(StartAngle) || !FMath::IsFinite(StepAngle) + || !TryGetRotatedAxes(Rotation, AxisX, AxisY, AxisZ)) + { + return {}; + } + + TArray Points; + Points.SetNumUninitialized(Count); + FAngleStepper Angle(StartAngle, StepAngle); + for (int32 Index = 0; Index < Count; ++Index) + { + const double Radius = GetRadius(Index); + const FVector Point = Center + AxisX * (Angle.Cosine * Radius) + AxisY * (Angle.Sine * Radius); + if (!FMath::IsFinite(Radius) || Point.ContainsNaN()) + { + return {}; + } + Points[Index] = Point; + Angle.Advance(); + } + return Points; + } + + bool TryMakeFacingRotationFromNormalized(const FVector& Forward, const FVector& UpDirection, + const FQuat& RotationOffset, FQuat& Rotation) + { + // Forward and RotationOffset are already unit/validated; MakeFromXZ yields a unit quat. + Rotation = FRotationMatrix::MakeFromXZ(Forward, UpDirection).ToQuat() * RotationOffset; + return !Rotation.ContainsNaN(); + } + + bool IsValidRadialOrientation(const EDirectiveUtilRadialOrientation Orientation) + { + switch (Orientation) + { + case EDirectiveUtilRadialOrientation::Fixed: + case EDirectiveUtilRadialOrientation::FaceCenter: + case EDirectiveUtilRadialOrientation::FaceAwayFromCenter: + case EDirectiveUtilRadialOrientation::FollowPath: + case EDirectiveUtilRadialOrientation::FaceAgainstPath: + return true; + default: + return false; + } + } + + bool TryGetRadialRotation(const EDirectiveUtilRadialOrientation Orientation, + const FVector& AxisX, const FVector& AxisY, const FVector& AxisZ, const FQuat& FixedRotation, + const FQuat& RotationOffset, const double Sine, const double Cosine, const double PathDirection, + FQuat& Rotation) + { + switch (Orientation) + { + case EDirectiveUtilRadialOrientation::Fixed: + Rotation = FixedRotation; + return true; + case EDirectiveUtilRadialOrientation::FaceCenter: + return TryMakeFacingRotationFromNormalized( + -AxisX * Cosine - AxisY * Sine, AxisZ, RotationOffset, Rotation); + case EDirectiveUtilRadialOrientation::FaceAwayFromCenter: + return TryMakeFacingRotationFromNormalized( + AxisX * Cosine + AxisY * Sine, AxisZ, RotationOffset, Rotation); + case EDirectiveUtilRadialOrientation::FollowPath: + return TryMakeFacingRotationFromNormalized( + (AxisX * -Sine + AxisY * Cosine) * PathDirection, AxisZ, RotationOffset, Rotation); + case EDirectiveUtilRadialOrientation::FaceAgainstPath: + return TryMakeFacingRotationFromNormalized( + (AxisX * Sine - AxisY * Cosine) * PathDirection, AxisZ, RotationOffset, Rotation); + default: + return false; + } + } + + TArray GeneratePlanarRadialTransforms(const FVector& Center, const FRotator& PlaneRotator, + const double Radius, const int32 Count, const double StartAngle, const double StepAngle, + const double PathDirection, const EDirectiveUtilRadialOrientation Orientation, + const FRotator& RotationOffset, const FVector& Scale) + { + FQuat PlaneRotation; + FQuat RotationOffsetQuaternion; + if (!IsSupportedGeneratedElementCount(Count) || Center.ContainsNaN() || Scale.ContainsNaN() + || !FMath::IsFinite(Radius) || !FMath::IsFinite(StartAngle) || !FMath::IsFinite(StepAngle) + || !FMath::IsFinite(PathDirection) || !IsValidRadialOrientation(Orientation) + || !TryGetRotationQuaternion(PlaneRotator, PlaneRotation) + || !TryGetRotationQuaternion(RotationOffset, RotationOffsetQuaternion)) + { + return {}; + } + + const FVector AxisX = PlaneRotation.GetAxisX(); + const FVector AxisY = PlaneRotation.GetAxisY(); + const FVector AxisZ = PlaneRotation.GetAxisZ(); + const double DirectionSign = PathDirection < 0.0 ? -1.0 : 1.0; + const double AbsoluteRadius = FMath::Abs(Radius); + FQuat FixedRotation = PlaneRotation * RotationOffsetQuaternion; + FixedRotation.Normalize(); + TArray Transforms; + Transforms.SetNumUninitialized(Count); + FAngleStepper Angle(StartAngle, StepAngle); + for (int32 Index = 0; Index < Count; ++Index) + { + const FVector Location = Center + + AxisX * (Angle.Cosine * AbsoluteRadius) + AxisY * (Angle.Sine * AbsoluteRadius); + FQuat TransformRotation; + if (!TryGetRadialRotation(Orientation, AxisX, AxisY, AxisZ, FixedRotation, + RotationOffsetQuaternion, Angle.Sine, Angle.Cosine, DirectionSign, TransformRotation)) + { + return {}; + } + Transforms[Index] = FTransform(TransformRotation, Location, Scale); + Angle.Advance(); + } + return Transforms; + } + + struct FSplineSamplePlan + { + double StartDistance = 0.0; + double EndDistance = 0.0; + double Spacing = 0.0; + float SampleStart = 0.0f; + float SampleSpacing = 0.0f; + float SampleEnd = 0.0f; + int32 RegularSampleCount = 0; + bool bAppendEndpoint = false; + + int32 Num() const + { + return RegularSampleCount + (bAppendEndpoint ? 1 : 0); + } + + void FinalizeSampleDistances() + { + SampleStart = static_cast(StartDistance); + SampleSpacing = static_cast(Spacing); + SampleEnd = static_cast(EndDistance); + } + + float GetDistance(const int32 Index) const + { + return bAppendEndpoint && Index == RegularSampleCount + ? SampleEnd + : SampleStart + static_cast(Index) * SampleSpacing; + } + }; + + bool IsValidSplineCoordinateSpace(const ESplineCoordinateSpace::Type CoordinateSpace) + { + return CoordinateSpace == ESplineCoordinateSpace::Local + || CoordinateSpace == ESplineCoordinateSpace::World; + } + + bool TryResolveSplineSampleRange(const USplineComponent* Spline, const double StartDistance, + const double EndDistance, FSplineSamplePlan& Plan, bool& bOutFullClosedLoop) + { + Plan = {}; + bOutFullClosedLoop = false; + if (!IsValid(Spline) || Spline->GetNumberOfSplinePoints() <= 0 + || !FMath::IsFinite(StartDistance) || !FMath::IsFinite(EndDistance)) + { + return false; + } + + const double SplineLength = Spline->GetSplineLength(); + if (!FMath::IsFinite(SplineLength) || SplineLength < 0.0) + { + return false; + } + + Plan.StartDistance = FMath::Clamp(StartDistance, 0.0, SplineLength); + Plan.EndDistance = EndDistance < 0.0 ? SplineLength : FMath::Clamp(EndDistance, 0.0, SplineLength); + if (Plan.EndDistance < Plan.StartDistance) + { + return false; + } + + bOutFullClosedLoop = Spline->IsClosedLoop() + && Plan.StartDistance == 0.0 && Plan.EndDistance == SplineLength; + return true; + } + + bool TryMakeSplineSpacingPlan(const USplineComponent* Spline, const double Spacing, + const EDirectiveUtilSplineSpacingMode SpacingMode, const bool bIncludeEndpoint, + const double StartDistance, const double EndDistance, FSplineSamplePlan& Plan) + { + bool bFullClosedLoop = false; + if (!FMath::IsFinite(Spacing) || Spacing <= 0.0 + || (SpacingMode != EDirectiveUtilSplineSpacingMode::Fixed + && SpacingMode != EDirectiveUtilSplineSpacingMode::Even) + || !TryResolveSplineSampleRange(Spline, StartDistance, EndDistance, Plan, bFullClosedLoop)) + { + return false; + } + + const double RangeLength = Plan.EndDistance - Plan.StartDistance; + if (RangeLength == 0.0) + { + Plan.RegularSampleCount = 1; + Plan.FinalizeSampleDistances(); + return true; + } + + const double SampleCountValue = FMath::CeilToDouble(RangeLength / Spacing); + if (!FMath::IsFinite(SampleCountValue) || SampleCountValue < 1.0 + || SampleCountValue > UDirectiveUtilMathFunctionLibrary::MaximumGeneratedElementCount) + { + return false; + } + + Plan.RegularSampleCount = static_cast(SampleCountValue); + Plan.Spacing = SpacingMode == EDirectiveUtilSplineSpacingMode::Even + ? RangeLength / SampleCountValue + : Spacing; + Plan.bAppendEndpoint = bIncludeEndpoint && !bFullClosedLoop; + if (Plan.bAppendEndpoint) + { + // Drop a final regular sample that float noise placed within a hair of the endpoint. + const double LastRegularOffset = static_cast(Plan.RegularSampleCount - 1) * Plan.Spacing; + if (RangeLength - LastRegularOffset <= FMath::Max(UE_DOUBLE_KINDA_SMALL_NUMBER, RangeLength * 1.e-9)) + { + --Plan.RegularSampleCount; + } + } + if (!IsSupportedGeneratedElementCount(Plan.Num())) + { + return false; + } + Plan.FinalizeSampleDistances(); + return true; + } + + bool TryMakeSplineCountPlan(const USplineComponent* Spline, const int32 Count, + const bool bIncludeEndpoints, const double StartDistance, const double EndDistance, + FSplineSamplePlan& Plan) + { + bool bFullClosedLoop = false; + if (!IsSupportedGeneratedElementCount(Count) + || !TryResolveSplineSampleRange(Spline, StartDistance, EndDistance, Plan, bFullClosedLoop)) + { + return false; + } + + Plan.RegularSampleCount = Count; + const double RangeLength = Plan.EndDistance - Plan.StartDistance; + if (RangeLength == 0.0 || (bFullClosedLoop && Count == 1)) + { + Plan.FinalizeSampleDistances(); + return true; + } + if (bFullClosedLoop) + { + Plan.Spacing = RangeLength / static_cast(Count); + } + else if (!bIncludeEndpoints) + { + Plan.Spacing = RangeLength / (static_cast(Count) + 1.0); + Plan.StartDistance += Plan.Spacing; + } + else if (Count == 1) + { + Plan.StartDistance += RangeLength * 0.5; + } + else + { + Plan.Spacing = RangeLength / static_cast(Count - 1); + Plan.RegularSampleCount = Count - 1; + Plan.bAppendEndpoint = true; + } + Plan.FinalizeSampleDistances(); + return true; + } + + TArray SampleSplineLocations(const USplineComponent* Spline, const FSplineSamplePlan& Plan, + const ESplineCoordinateSpace::Type CoordinateSpace) + { + TArray Points; + Points.SetNumUninitialized(Plan.Num()); + for (int32 Index = 0; Index < Points.Num(); ++Index) + { + const FVector Point = Spline->GetLocationAtDistanceAlongSpline( + Plan.GetDistance(Index), CoordinateSpace); + if (Point.ContainsNaN()) + { + return {}; + } + Points[Index] = Point; + } + return Points; + } + + TArray SampleSplineTransforms(const USplineComponent* Spline, const FSplineSamplePlan& Plan, + const ESplineCoordinateSpace::Type CoordinateSpace, const bool bUseSplineScale, + const FQuat& RotationOffsetQuaternion, const FVector& ScaleMultiplier) + { + TArray Transforms; + Transforms.SetNumUninitialized(Plan.Num()); + for (int32 Index = 0; Index < Transforms.Num(); ++Index) + { + FTransform Transform = Spline->GetTransformAtDistanceAlongSpline( + Plan.GetDistance(Index), CoordinateSpace, bUseSplineScale); + FQuat Rotation = Transform.GetRotation() * RotationOffsetQuaternion; + Rotation.Normalize(); + Transform.SetRotation(Rotation); + Transform.SetScale3D(Transform.GetScale3D() * ScaleMultiplier); + if (Transform.ContainsNaN()) + { + return {}; + } + Transforms[Index] = Transform; + } + return Transforms; + } + + FTransform BlendTransforms(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 + bool TryFindArraySample(const int32 Count, const bool bClosedLoop, const float Alpha, + PositionType Position, int32& IndexA, int32& IndexB, double& SegmentAlpha) + { + IndexA = 0; + IndexB = 0; + SegmentAlpha = 0.0; + if (Count <= 0 || !FMath::IsFinite(Alpha)) + { + return false; + } + if (Count == 1) + { + return true; + } + + const int32 SegmentCount = bClosedLoop ? Count : Count - 1; + // Reuse capacity across samples on this thread; inline storage covers typical Blueprint paths. + static thread_local TArray> SegmentLengths; + SegmentLengths.SetNumUninitialized(SegmentCount, EAllowShrinking::No); + double TotalLength = 0.0; + for (int32 Index = 0; Index < SegmentCount; ++Index) + { + const int32 NextIndex = (!bClosedLoop || Index + 1 < Count) ? Index + 1 : 0; + const double Length = FVector::Distance(Position(Index), Position(NextIndex)); + if (!FMath::IsFinite(Length)) + { + return false; + } + SegmentLengths[Index] = Length; + TotalLength += Length; + } + if (TotalLength <= 0.0) + { + return true; + } + + const double TargetAlpha = bClosedLoop + ? static_cast(Alpha) - FMath::FloorToDouble(Alpha) + : FMath::Clamp(static_cast(Alpha), 0.0, 1.0); + double TargetDistance = TargetAlpha * TotalLength; + for (int32 Index = 0; Index < SegmentCount; ++Index) + { + const double Length = SegmentLengths[Index]; + if (TargetDistance <= Length || Index == SegmentCount - 1) + { + IndexA = Index; + IndexB = (!bClosedLoop || Index + 1 < Count) ? Index + 1 : 0; + SegmentAlpha = Length > 0.0 + ? FMath::Clamp(TargetDistance / Length, 0.0, 1.0) + : 0.0; + return true; + } + TargetDistance -= Length; + } + return true; + } + + // Perlin noise is exactly zero at every integer lattice point, so bias samples off the lattice. + constexpr double NoiseSampleBias = 0.6180339887498949; + + bool TryOffsetLocationByNoise(const FVector& Location, const FVector& NormalizedDirection, + const double NoiseScale, const double Amplitude, const FVector& NoiseOffset, FVector& OffsetLocation) + { + if (Location.ContainsNaN()) + { + return false; + } + const FVector SamplePosition = (Location + NoiseOffset) / NoiseScale + FVector(NoiseSampleBias); + const double Noise = FMath::PerlinNoise3D(SamplePosition); + OffsetLocation = Location + NormalizedDirection * (Noise * Amplitude); + return !OffsetLocation.ContainsNaN(); + } + + bool TryGetNoiseOffsetInputs(const double NoiseScale, const double Amplitude, const FVector& Direction, + const FVector& NoiseOffset, FVector& NormalizedDirection) + { + return FMath::IsFinite(NoiseScale) && NoiseScale > 0.0 && FMath::IsFinite(Amplitude) + && !NoiseOffset.ContainsNaN() && TryGetNormalizedVector(Direction, NormalizedDirection); + } + + double WrapDegreesAsRadians(const double AngleDegrees) + { + return FMath::DegreesToRadians(FMath::Fmod(AngleDegrees, 360.0)); + } + + double FindWrappedDeltaDegrees(const double From, const double To) + { + double Delta = FMath::Fmod(To - From, 360.0); + if (Delta < 0.0) + { + Delta += 360.0; + } + if (Delta > 180.0) + { + Delta -= 360.0; + } + return Delta; + } + } float UDirectiveUtilMathFunctionLibrary::PerlinNoise2D(const FVector2D Position) @@ -112,355 +1020,1078 @@ float UDirectiveUtilMathFunctionLibrary::PerlinNoise3D(const FVector& Position) float UDirectiveUtilMathFunctionLibrary::AngleBetweenVectors(const FVector& A, const FVector& B) { - return FMath::RadiansToDegrees(FMath::Acos(FMath::Clamp(FVector::DotProduct(A.GetSafeNormal(), B.GetSafeNormal()), -1.0, 1.0))); + FVector NormalizedA; + FVector NormalizedB; + if (!TryGetNormalizedVector(A, NormalizedA) || !TryGetNormalizedVector(B, NormalizedB)) + { + return 0.0f; + } + + return FMath::RadiansToDegrees(FMath::Acos(FMath::Clamp(FVector::DotProduct(NormalizedA, NormalizedB), -1.0, 1.0))); } -float UDirectiveUtilMathFunctionLibrary::EaseAlpha(const float Alpha, const EDirectiveUtilEaseType EaseType) +float UDirectiveUtilMathFunctionLibrary::SignedAngleBetweenVectors(const FVector& From, const FVector& To, const FVector& Axis) { - const double t = static_cast(FMath::Clamp(Alpha, 0.0f, 1.0f)); - double Result = t; - switch (EaseType) + FVector NormalizedAxis; + if (!TryGetNormalizedVector(Axis, NormalizedAxis)) { - case EDirectiveUtilEaseType::BackIn: Result = EaseBackIn(t); break; - case EDirectiveUtilEaseType::BackOut: Result = EaseBackOut(t); break; - case EDirectiveUtilEaseType::BackInOut: Result = EaseBackInOut(t); break; - case EDirectiveUtilEaseType::ElasticIn: Result = EaseElasticIn(t); break; - case EDirectiveUtilEaseType::ElasticOut: Result = EaseElasticOut(t); break; - case EDirectiveUtilEaseType::ElasticInOut: Result = EaseElasticInOut(t); break; - case EDirectiveUtilEaseType::BounceIn: Result = EaseBounceIn(t); break; - case EDirectiveUtilEaseType::BounceOut: Result = EaseBounceOut(t); break; - case EDirectiveUtilEaseType::BounceInOut: Result = EaseBounceInOut(t); break; + return 0.0f; } + + FVector ProjectedFrom; + FVector ProjectedTo; + if (!TryGetProjectedDirection(From, NormalizedAxis, ProjectedFrom) + || !TryGetProjectedDirection(To, NormalizedAxis, ProjectedTo)) + { + return 0.0f; + } + + const double Sine = FVector::DotProduct(NormalizedAxis, FVector::CrossProduct(ProjectedFrom, ProjectedTo)); + const double Cosine = FMath::Clamp(FVector::DotProduct(ProjectedFrom, ProjectedTo), -1.0, 1.0); + return static_cast(FMath::RadiansToDegrees(FMath::Atan2(Sine, Cosine))); +} + +float UDirectiveUtilMathFunctionLibrary::DeltaAngle(const float From, const float To) +{ + if (!FMath::IsFinite(From) || !FMath::IsFinite(To)) + { + return 0.0f; + } + + return static_cast(FindWrappedDeltaDegrees(From, To)); +} + +float UDirectiveUtilMathFunctionLibrary::LerpAngle(const float A, const float B, const float Alpha) +{ + if (!FMath::IsFinite(A) || !FMath::IsFinite(B) || !FMath::IsFinite(Alpha)) + { + return 0.0f; + } + + const double Result = static_cast(A) + + FindWrappedDeltaDegrees(A, B) * static_cast(Alpha); return static_cast(Result); } -float UDirectiveUtilMathFunctionLibrary::EaseFloat(const float A, const float B, const float Alpha, const EDirectiveUtilEaseType EaseType) +float UDirectiveUtilMathFunctionLibrary::PingPong(const float Value, const float Minimum, const float Maximum) { - 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(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)); -} - -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(Decimals)); - return static_cast(FMath::RoundHalfFromZero(static_cast(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(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 int64 TotalSeconds = static_cast(FMath::Abs(static_cast(Seconds))); - const bool bNegative = Seconds < 0.0f && TotalSeconds > 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 to whole seconds so clock-adjacent inputs (e.g. Now() + 2 hours) land in the intended bucket. - const int64 SecondsAbs = static_cast(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& Values) -{ - int64 Sum = 0; - for (const int32 Value : Values) - { - Sum += Value; - } - return Sum; -} - -float UDirectiveUtilMathFunctionLibrary::GetIntArrayAverage(const TArray& Values) -{ - if (Values.IsEmpty()) - { - return 0.0f; - } - return static_cast(static_cast(GetIntArraySum(Values)) / Values.Num()); -} - -float UDirectiveUtilMathFunctionLibrary::GetIntArrayMedian(const TArray& Values) -{ - if (Values.IsEmpty()) + if (!FMath::IsFinite(Value) || !FMath::IsFinite(Minimum) || !FMath::IsFinite(Maximum)) { return 0.0f; } - TArray Sorted = Values; - Sorted.Sort(); - - const int32 Middle = Sorted.Num() / 2; - if (Sorted.Num() % 2 == 0) + const double LowerBound = FMath::Min(static_cast(Minimum), static_cast(Maximum)); + const double UpperBound = FMath::Max(static_cast(Minimum), static_cast(Maximum)); + const double Range = UpperBound - LowerBound; + if (Range == 0.0) { - return static_cast((static_cast(Sorted[Middle - 1]) + static_cast(Sorted[Middle])) * 0.5); + return static_cast(LowerBound); } - return static_cast(Sorted[Middle]); + + const double Period = Range * 2.0; + double Offset = FMath::Fmod(static_cast(Value) - LowerBound, Period); + if (Offset < 0.0) + { + Offset += Period; + } + + const double DistanceFromLowerBound = Offset <= Range ? Offset : Period - Offset; + return static_cast(LowerBound + DistanceFromLowerBound); } -float UDirectiveUtilMathFunctionLibrary::GetIntArrayStandardDeviation(const TArray& Values) +float UDirectiveUtilMathFunctionLibrary::SmoothStep(const float Value, const float Minimum, const float Maximum) { - if (Values.IsEmpty()) + if (!FMath::IsFinite(Value) || !FMath::IsFinite(Minimum) || !FMath::IsFinite(Maximum)) { return 0.0f; } - const double Mean = static_cast(GetIntArraySum(Values)) / Values.Num(); - double SquaredDeltaSum = 0.0; - for (const int32 Value : Values) + const double LowerBound = FMath::Min(static_cast(Minimum), static_cast(Maximum)); + const double UpperBound = FMath::Max(static_cast(Minimum), static_cast(Maximum)); + if (LowerBound == UpperBound) { - const double Delta = static_cast(Value) - Mean; - SquaredDeltaSum += Delta * Delta; + return Value < LowerBound ? 0.0f : 1.0f; } - return static_cast(FMath::Sqrt(SquaredDeltaSum / Values.Num())); + + const double Alpha = FMath::Clamp((static_cast(Value) - LowerBound) / (UpperBound - LowerBound), 0.0, 1.0); + return static_cast(Alpha * Alpha * (3.0 - 2.0 * Alpha)); } -float UDirectiveUtilMathFunctionLibrary::GetFloatArraySum(const TArray& Values) +float UDirectiveUtilMathFunctionLibrary::SmootherStep(const float Value, const float Minimum, const float Maximum) { - double Sum = 0.0; - for (const float Value : Values) - { - Sum += static_cast(Value); - } - return static_cast(Sum); -} - -float UDirectiveUtilMathFunctionLibrary::GetFloatArrayAverage(const TArray& Values) -{ - if (Values.IsEmpty()) + if (!FMath::IsFinite(Value) || !FMath::IsFinite(Minimum) || !FMath::IsFinite(Maximum)) { return 0.0f; } - double Sum = 0.0; - for (const float Value : Values) + const double LowerBound = FMath::Min(static_cast(Minimum), static_cast(Maximum)); + const double UpperBound = FMath::Max(static_cast(Minimum), static_cast(Maximum)); + if (LowerBound == UpperBound) { - Sum += static_cast(Value); + return Value < LowerBound ? 0.0f : 1.0f; } - return static_cast(Sum / Values.Num()); + + const double Alpha = FMath::Clamp((static_cast(Value) - LowerBound) / (UpperBound - LowerBound), 0.0, 1.0); + return static_cast(Alpha * Alpha * Alpha * (Alpha * (Alpha * 6.0 - 15.0) + 10.0)); } -float UDirectiveUtilMathFunctionLibrary::GetFloatArrayMedian(const TArray& Values) +float UDirectiveUtilMathFunctionLibrary::RangeFalloff(const float Distance, const float InnerRadius, + const float OuterRadius, const float FalloffExponent) { - if (Values.IsEmpty()) + if (!FMath::IsFinite(Distance) || !FMath::IsFinite(InnerRadius) + || !FMath::IsFinite(OuterRadius) || !FMath::IsFinite(FalloffExponent)) { return 0.0f; } - TArray Sorted = Values; - Sorted.Sort(); - - const int32 Middle = Sorted.Num() / 2; - if (Sorted.Num() % 2 == 0) + const double FirstRadius = FMath::Max(0.0, static_cast(InnerRadius)); + const double SecondRadius = FMath::Max(0.0, static_cast(OuterRadius)); + const double Inner = FMath::Min(FirstRadius, SecondRadius); + const double Outer = FMath::Max(FirstRadius, SecondRadius); + const double ClampedDistance = FMath::Max(0.0, static_cast(Distance)); + if (Inner == Outer) { - return static_cast((static_cast(Sorted[Middle - 1]) + static_cast(Sorted[Middle])) * 0.5); + return ClampedDistance <= Inner ? 1.0f : 0.0f; } - return Sorted[Middle]; -} - -float UDirectiveUtilMathFunctionLibrary::GetFloatArrayStandardDeviation(const TArray& Values) -{ - if (Values.IsEmpty()) + if (ClampedDistance >= Outer) { return 0.0f; } - - double Sum = 0.0; - for (const float Value : Values) + if (ClampedDistance <= Inner || FalloffExponent <= 0.0f) { - Sum += static_cast(Value); + return 1.0f; } - const double Mean = Sum / Values.Num(); - double SquaredDeltaSum = 0.0; - for (const float Value : Values) + const double Alpha = 1.0 - (ClampedDistance - Inner) / (Outer - Inner); + if (FalloffExponent == 1.0f) { - const double Delta = static_cast(Value) - Mean; - SquaredDeltaSum += Delta * Delta; + return static_cast(Alpha); } - return static_cast(FMath::Sqrt(SquaredDeltaSum / Values.Num())); + if (FalloffExponent == 2.0f) + { + return static_cast(Alpha * Alpha); + } + return static_cast(FMath::Pow(Alpha, static_cast(FalloffExponent))); } -int32 UDirectiveUtilMathFunctionLibrary::GetRandomIndexFromWeights(const TArray& Weights) +bool UDirectiveUtilMathFunctionLibrary::IsDirectionWithinCone(const FVector& Direction, const FVector& ConeDirection, const float ConeHalfAngleDegrees) { - float Total = 0.0f; - for (const float Weight : Weights) + FVector NormalizedDirection; + if (!TryGetNormalizedVector(Direction, NormalizedDirection)) { - Total += FMath::Max(0.0f, Weight); + return false; } - - if (Total <= 0.0f) - { - return INDEX_NONE; - } - - const float Roll = FMath::FRand() * Total; - float Accumulated = 0.0f; - int32 LastPositiveIndex = INDEX_NONE; - for (int32 Index = 0; Index < Weights.Num(); ++Index) - { - const float Weight = FMath::Max(0.0f, Weights[Index]); - if (Weight <= 0.0f) - { - continue; - } - LastPositiveIndex = Index; - Accumulated += Weight; - if (Roll < Accumulated) - { - return Index; - } - } - - return LastPositiveIndex; + return IsNormalizedDirectionWithinCone(NormalizedDirection, ConeDirection, ConeHalfAngleDegrees); } -int32 UDirectiveUtilMathFunctionLibrary::GetRandomIndexFromWeightsFromStream(FRandomStream& Stream, const TArray& Weights) +bool UDirectiveUtilMathFunctionLibrary::GetDirectionAndDistance(const FVector& From, const FVector& To, + FVector& Direction, double& Distance) { - float Total = 0.0f; - for (const float Weight : Weights) + Direction = FVector::ZeroVector; + Distance = 0.0; + if (From.ContainsNaN() || To.ContainsNaN()) { - Total += FMath::Max(0.0f, Weight); + return false; } - if (Total <= 0.0f) + const FVector Delta = To - From; + if (Delta.ContainsNaN()) { - return INDEX_NONE; + return false; } - const float Roll = Stream.FRand() * Total; - float Accumulated = 0.0f; - int32 LastPositiveIndex = INDEX_NONE; - for (int32 Index = 0; Index < Weights.Num(); ++Index) + if (!TryGetNormalizedVector(Delta, Direction, &Distance)) { - const float Weight = FMath::Max(0.0f, Weights[Index]); - if (Weight <= 0.0f) - { - continue; - } - LastPositiveIndex = Index; - Accumulated += Weight; - if (Roll < Accumulated) - { - return Index; - } + Distance = 0.0; + return false; } - - return LastPositiveIndex; + return true; +} + +FVector2D UDirectiveUtilMathFunctionLibrary::RotatePointAroundPivot2D(const FVector2D& Point, + const FVector2D& Pivot, const float AngleDegrees) +{ + if (!IsFiniteVector2D(Point) || !IsFiniteVector2D(Pivot) || !FMath::IsFinite(AngleDegrees)) + { + return FVector2D::ZeroVector; + } + + const FVector2D Offset = Point - Pivot; + const double AngleRadians = FMath::DegreesToRadians(static_cast(AngleDegrees)); + const double Sine = FMath::Sin(AngleRadians); + const double Cosine = FMath::Cos(AngleRadians); + const FVector2D RotatedPoint = Pivot + FVector2D( + Offset.X * Cosine - Offset.Y * Sine, + Offset.X * Sine + Offset.Y * Cosine); + return IsFiniteVector2D(RotatedPoint) ? RotatedPoint : FVector2D::ZeroVector; +} + +double UDirectiveUtilMathFunctionLibrary::SignedDistanceToPlane(const FVector& Point, + const FVector& PlanePoint, const FVector& PlaneNormal) +{ + FVector NormalizedPlaneNormal; + if (Point.ContainsNaN() || PlanePoint.ContainsNaN() + || !TryGetNormalizedVector(PlaneNormal, NormalizedPlaneNormal)) + { + return 0.0; + } + + const FVector Offset = Point - PlanePoint; + if (Offset.ContainsNaN()) + { + return 0.0; + } + + const double MaximumComponent = Offset.GetAbsMax(); + if (MaximumComponent == 0.0) + { + return 0.0; + } + + const double ScaledDistance = FVector::DotProduct(Offset / MaximumComponent, NormalizedPlaneNormal); + if (!FMath::IsFinite(ScaledDistance) + || FMath::Abs(ScaledDistance) > TNumericLimits::Max() / MaximumComponent) + { + return 0.0; + } + return ScaledDistance * MaximumComponent; +} + +bool UDirectiveUtilMathFunctionLibrary::IsPointWithinCone(const FVector& Point, const FVector& ConeOrigin, + const FVector& ConeDirection, const float ConeHalfAngleDegrees, const double MaximumDistance) +{ + if (Point.ContainsNaN() || ConeOrigin.ContainsNaN() || ConeDirection.ContainsNaN() + || !FMath::IsFinite(ConeHalfAngleDegrees) || !FMath::IsFinite(MaximumDistance)) + { + return false; + } + + const FVector PointDirection = Point - ConeOrigin; + FVector NormalizedPointDirection; + double Distance = 0.0; + if (PointDirection.ContainsNaN() + || !TryGetNormalizedVector(PointDirection, NormalizedPointDirection, + MaximumDistance > 0.0 ? &Distance : nullptr)) + { + return false; + } + + if (MaximumDistance > 0.0 && Distance > MaximumDistance) + { + return false; + } + + return IsNormalizedDirectionWithinCone(NormalizedPointDirection, ConeDirection, ConeHalfAngleDegrees); +} + +TArray UDirectiveUtilMathFunctionLibrary::LocationsToTransforms(const TArray& Locations, + const FRotator Rotation, const FVector Scale) +{ + if (Locations.IsEmpty()) + { + return {}; + } + + FQuat RotationQuaternion; + if (Scale.ContainsNaN() || !TryGetRotationQuaternion(Rotation, RotationQuaternion)) + { + return {}; + } + + TArray Transforms; + Transforms.Reserve(Locations.Num()); + for (const FVector& Location : Locations) + { + if (Location.ContainsNaN()) + { + return {}; + } + Transforms.Emplace(RotationQuaternion, Location, Scale); + } + return Transforms; +} + +TArray UDirectiveUtilMathFunctionLibrary::LocationsToFacingTransforms( + const TArray& Locations, const FVector Target, const FVector UpDirection, + const FRotator RotationOffset, const FVector Scale, const bool bFaceAway) +{ + if (Locations.IsEmpty()) + { + return {}; + } + + FVector NormalizedUpDirection; + FQuat RotationOffsetQuaternion; + if (Target.ContainsNaN() || Scale.ContainsNaN() + || !TryGetNormalizedVector(UpDirection, NormalizedUpDirection) + || !TryGetRotationQuaternion(RotationOffset, RotationOffsetQuaternion)) + { + return {}; + } + + TArray Transforms; + Transforms.SetNumUninitialized(Locations.Num()); + for (int32 Index = 0; Index < Locations.Num(); ++Index) + { + const FVector& Location = Locations[Index]; + if (Location.ContainsNaN()) + { + return {}; + } + + const FVector Direction = bFaceAway ? Location - Target : Target - Location; + FQuat Rotation = RotationOffsetQuaternion; + if (Direction.SizeSquared() > UE_DOUBLE_SMALL_NUMBER) + { + FVector Forward; + if (!TryGetNormalizedVector(Direction, Forward) + || !TryMakeFacingRotationFromNormalized( + Forward, NormalizedUpDirection, RotationOffsetQuaternion, Rotation)) + { + return {}; + } + } + Transforms[Index] = FTransform(Rotation, Location, Scale); + } + return Transforms; +} + +bool UDirectiveUtilMathFunctionLibrary::MakeTransformsFromArrays(const TArray& Locations, + const TArray& Rotations, const TArray& Scales, TArray& Transforms) +{ + Transforms.Reset(); + const int32 TransformCount = Locations.Num(); + if ((Rotations.Num() != 0 && Rotations.Num() != 1 && Rotations.Num() != TransformCount) + || (Scales.Num() != 0 && Scales.Num() != 1 && Scales.Num() != TransformCount)) + { + return false; + } + if (TransformCount == 0) + { + return true; + } + + const bool bUsePerTransformRotations = Rotations.Num() == TransformCount && TransformCount > 1; + const bool bUsePerTransformScales = Scales.Num() == TransformCount && TransformCount > 1; + FQuat SharedRotation = FQuat::Identity; + if (!bUsePerTransformRotations && !Rotations.IsEmpty() + && !TryGetRotationQuaternion(Rotations[0], SharedRotation)) + { + return false; + } + const FVector SharedScale = Scales.IsEmpty() ? FVector::OneVector : Scales[0]; + if (!bUsePerTransformScales && SharedScale.ContainsNaN()) + { + return false; + } + + Transforms.Reserve(TransformCount); + for (int32 Index = 0; Index < TransformCount; ++Index) + { + const FVector& Location = Locations[Index]; + if (Location.ContainsNaN()) + { + Transforms.Reset(); + return false; + } + + FQuat ItemRotation; + const FQuat* RotationQuaternion = &SharedRotation; + if (bUsePerTransformRotations) + { + if (!TryGetRotationQuaternion(Rotations[Index], ItemRotation)) + { + Transforms.Reset(); + return false; + } + RotationQuaternion = &ItemRotation; + } + + const FVector& Scale = bUsePerTransformScales ? Scales[Index] : SharedScale; + if (bUsePerTransformScales && Scale.ContainsNaN()) + { + Transforms.Reset(); + return false; + } + Transforms.Emplace(*RotationQuaternion, Location, Scale); + } + return true; +} + +FVector UDirectiveUtilMathFunctionLibrary::SampleLocationArray(const TArray& Locations, + const float Alpha, const bool bClosedLoop) +{ + int32 IndexA; + int32 IndexB; + double SegmentAlpha; + if (!TryFindArraySample(Locations.Num(), bClosedLoop, Alpha, + [&Locations](const int32 Index) { return Locations[Index]; }, IndexA, IndexB, SegmentAlpha)) + { + return FVector::ZeroVector; + } + + const FVector Result = FMath::Lerp(Locations[IndexA], Locations[IndexB], SegmentAlpha); + return Result.ContainsNaN() ? FVector::ZeroVector : Result; +} + +FTransform UDirectiveUtilMathFunctionLibrary::SampleTransformArray(const TArray& Transforms, + const float Alpha, const bool bClosedLoop) +{ + int32 IndexA; + int32 IndexB; + double SegmentAlpha; + if (!TryFindArraySample(Transforms.Num(), bClosedLoop, Alpha, + [&Transforms](const int32 Index) { return Transforms[Index].GetLocation(); }, + IndexA, IndexB, SegmentAlpha)) + { + return FTransform::Identity; + } + + const FTransform Result = BlendTransforms(Transforms[IndexA], Transforms[IndexB], SegmentAlpha); + return Result.ContainsNaN() ? FTransform::Identity : Result; +} + +TArray UDirectiveUtilMathFunctionLibrary::GenerateGridPoints2D(const FVector& Origin, + const FRotator& Rotation, const FIntPoint Dimensions, const FVector2D& Spacing, const bool bCentered) +{ + if (!IsFiniteVector2D(Spacing)) + { + return {}; + } + return GenerateGridPoints(Origin, Rotation, FIntVector(Dimensions.X, Dimensions.Y, 1), + FVector(Spacing.X, Spacing.Y, 0.0), bCentered); +} + +TArray UDirectiveUtilMathFunctionLibrary::GenerateGridPoints3D(const FVector& Origin, + const FRotator& Rotation, const FIntVector Dimensions, const FVector& Spacing, const bool bCentered) +{ + return GenerateGridPoints(Origin, Rotation, Dimensions, Spacing, bCentered); +} + +TArray UDirectiveUtilMathFunctionLibrary::GenerateGridTransforms2D(const FVector& Origin, + const FRotator& Rotation, const FIntPoint Dimensions, const FVector2D& Spacing, const bool bCentered, + const FRotator InstanceRotation, const FVector Scale) +{ + if (!IsFiniteVector2D(Spacing)) + { + return {}; + } + return GenerateGridTransforms(Origin, Rotation, FIntVector(Dimensions.X, Dimensions.Y, 1), + FVector(Spacing.X, Spacing.Y, 0.0), bCentered, InstanceRotation, Scale); +} + +TArray UDirectiveUtilMathFunctionLibrary::GenerateGridTransforms3D(const FVector& Origin, + const FRotator& Rotation, const FIntVector Dimensions, const FVector& Spacing, const bool bCentered, + const FRotator InstanceRotation, const FVector Scale) +{ + return GenerateGridTransforms(Origin, Rotation, Dimensions, Spacing, bCentered, InstanceRotation, Scale); +} + +TArray UDirectiveUtilMathFunctionLibrary::GenerateRectangularHexGrid(const FVector& Origin, + const FRotator& Rotation, const FIntPoint Dimensions, const double CellRadius, + const EDirectiveUtilHexOrientation Orientation, const double Gap, const bool bCentered) +{ + int32 PointCount; + FHexLayout Layout; + if (!TryGetGridPointCount(FIntVector(Dimensions.X, Dimensions.Y, 1), PointCount) + || !TryMakeHexLayout(Origin, Rotation, CellRadius, Gap, Orientation, Layout) + || !TryApplyRectangularHexCentering(Layout, Dimensions, bCentered) + || !TryValidateHexLayoutSpan(Layout, GetRectangularHexSpan(Dimensions), GetRectangularHexSpan(Dimensions))) + { + return {}; + } + + TArray Points; + Points.SetNumUninitialized(PointCount); + int32 PointIndex = 0; + if (!VisitRectangularHexCoordinates(Dimensions, Orientation, + [&Layout, &Points, &PointIndex](const int64 Q, const int64 R) + { + WriteHexLocationUnchecked(Layout, Q, R, Points[PointIndex++]); + return true; + })) + { + return {}; + } + return Points; +} + +TArray UDirectiveUtilMathFunctionLibrary::GenerateRectangularHexGridTransforms(const FVector& Origin, + const FRotator& Rotation, const FIntPoint Dimensions, const double CellRadius, + const EDirectiveUtilHexOrientation Orientation, const double Gap, const bool bCentered, + const FRotator InstanceRotation, const FVector Scale) +{ + int32 PointCount; + FHexLayout Layout; + FQuat InstanceQuaternion; + if (!IsFiniteVector(Scale) + || !TryGetRotationQuaternion(InstanceRotation, InstanceQuaternion) + || !TryGetGridPointCount(FIntVector(Dimensions.X, Dimensions.Y, 1), PointCount) + || !TryMakeHexLayout(Origin, Rotation, CellRadius, Gap, Orientation, Layout) + || !TryApplyRectangularHexCentering(Layout, Dimensions, bCentered) + || !TryValidateHexLayoutSpan(Layout, GetRectangularHexSpan(Dimensions), GetRectangularHexSpan(Dimensions))) + { + return {}; + } + + TArray Transforms; + Transforms.SetNumUninitialized(PointCount); + int32 PointIndex = 0; + if (!VisitRectangularHexCoordinates(Dimensions, Orientation, + [&Layout, &Transforms, &PointIndex, &InstanceQuaternion, &Scale](const int64 Q, const int64 R) + { + FVector Location; + WriteHexLocationUnchecked(Layout, Q, R, Location); + Transforms[PointIndex++] = FTransform(InstanceQuaternion, Location, Scale); + return true; + })) + { + return {}; + } + return Transforms; +} + +TArray UDirectiveUtilMathFunctionLibrary::GetRectangularHexGridCoordinates(const FIntPoint Dimensions, + const EDirectiveUtilHexOrientation Orientation) +{ + int32 PointCount; + if ((Orientation != EDirectiveUtilHexOrientation::PointyTop + && Orientation != EDirectiveUtilHexOrientation::FlatTop) + || !TryGetGridPointCount(FIntVector(Dimensions.X, Dimensions.Y, 1), PointCount)) + { + return {}; + } + + TArray Coordinates; + Coordinates.SetNumUninitialized(PointCount); + int32 PointIndex = 0; + if (!VisitRectangularHexCoordinates(Dimensions, Orientation, + [&Coordinates, &PointIndex](const int64 Q, const int64 R) + { + return TryMakeHexCoordinate(Q, R, Coordinates[PointIndex++]); + })) + { + return {}; + } + return Coordinates; +} + +TArray UDirectiveUtilMathFunctionLibrary::GenerateHexagonalHexGrid(const FVector& Origin, + const FRotator& Rotation, const int32 GridRadius, const double CellRadius, + const EDirectiveUtilHexOrientation Orientation, const double Gap) +{ + int32 PointCount; + FHexLayout Layout; + if (!TryGetHexagonalPointCount(GridRadius, PointCount) + || !TryMakeHexLayout(Origin, Rotation, CellRadius, Gap, Orientation, Layout) + || !TryValidateHexLayoutSpan(Layout, GridRadius, GridRadius)) + { + return {}; + } + + TArray Points; + Points.SetNumUninitialized(PointCount); + int32 PointIndex = 0; + if (!VisitHexagonalCoordinates(GridRadius, + [&Layout, &Points, &PointIndex](const int64 Q, const int64 R) + { + WriteHexLocationUnchecked(Layout, Q, R, Points[PointIndex++]); + return true; + })) + { + return {}; + } + return Points; +} + +TArray UDirectiveUtilMathFunctionLibrary::GenerateHexagonalHexGridTransforms(const FVector& Origin, + const FRotator& Rotation, const int32 GridRadius, const double CellRadius, + const EDirectiveUtilHexOrientation Orientation, const double Gap, + const FRotator InstanceRotation, const FVector Scale) +{ + int32 PointCount; + FHexLayout Layout; + FQuat InstanceQuaternion; + if (!IsFiniteVector(Scale) + || !TryGetRotationQuaternion(InstanceRotation, InstanceQuaternion) + || !TryGetHexagonalPointCount(GridRadius, PointCount) + || !TryMakeHexLayout(Origin, Rotation, CellRadius, Gap, Orientation, Layout) + || !TryValidateHexLayoutSpan(Layout, GridRadius, GridRadius)) + { + return {}; + } + + TArray Transforms; + Transforms.SetNumUninitialized(PointCount); + int32 PointIndex = 0; + if (!VisitHexagonalCoordinates(GridRadius, + [&Layout, &Transforms, &PointIndex, &InstanceQuaternion, &Scale](const int64 Q, const int64 R) + { + FVector Location; + WriteHexLocationUnchecked(Layout, Q, R, Location); + Transforms[PointIndex++] = FTransform(InstanceQuaternion, Location, Scale); + return true; + })) + { + return {}; + } + return Transforms; +} + +FVector UDirectiveUtilMathFunctionLibrary::HexCoordinateToLocation(const FIntPoint Coordinate, + const FVector& Origin, const FRotator& Rotation, const double CellRadius, + const EDirectiveUtilHexOrientation Orientation, const double Gap) +{ + FHexLayout Layout; + if (!TryMakeHexLayout(Origin, Rotation, CellRadius, Gap, Orientation, Layout)) + { + return FVector::ZeroVector; + } + + const FVector Location = Layout.GetLocation(Coordinate.X, Coordinate.Y); + return Location.ContainsNaN() ? FVector::ZeroVector : Location; +} + +FIntPoint UDirectiveUtilMathFunctionLibrary::LocationToHexCoordinate(const FVector& Location, + const FVector& Origin, const FRotator& Rotation, const double CellRadius, + const EDirectiveUtilHexOrientation Orientation, const double Gap) +{ + FHexLayout Layout; + FIntPoint Coordinate = FIntPoint::ZeroValue; + if (!TryMakeHexLayout(Origin, Rotation, CellRadius, Gap, Orientation, Layout) + || !TryGetHexCoordinate(Layout, Location, Coordinate)) + { + return FIntPoint::ZeroValue; + } + return Coordinate; +} + +TArray UDirectiveUtilMathFunctionLibrary::GetHexNeighbors(const FIntPoint Coordinate) +{ + TArray Neighbors; + Neighbors.SetNumUninitialized(6); + for (int32 Index = 0; Index < 6; ++Index) + { + const int64 Q = static_cast(Coordinate.X) + HexDirections[Index][0]; + const int64 R = static_cast(Coordinate.Y) + HexDirections[Index][1]; + if (!TryMakeHexCoordinate(Q, R, Neighbors[Index])) + { + return {}; + } + } + return Neighbors; +} + +int64 UDirectiveUtilMathFunctionLibrary::GetHexDistance(const FIntPoint A, const FIntPoint B) +{ + const int64 DeltaQ = static_cast(A.X) - B.X; + const int64 DeltaR = static_cast(A.Y) - B.Y; + return FMath::Max3(FMath::Abs(DeltaQ), FMath::Abs(DeltaR), FMath::Abs(DeltaQ + DeltaR)); +} + +TArray UDirectiveUtilMathFunctionLibrary::GetHexesInRange(const FIntPoint Center, const int32 Range) +{ + int32 PointCount; + if (!TryGetHexagonalPointCount(Range, PointCount)) + { + return {}; + } + + TArray Hexes; + Hexes.SetNumUninitialized(PointCount); + int32 PointIndex = 0; + if (!VisitHexagonalCoordinates(Range, + [&Center, &Hexes, &PointIndex](const int64 DeltaQ, const int64 DeltaR) + { + return TryMakeHexCoordinate(Center.X + DeltaQ, Center.Y + DeltaR, Hexes[PointIndex++]); + })) + { + return {}; + } + return Hexes; +} + +TArray UDirectiveUtilMathFunctionLibrary::GetHexRing(const FIntPoint Center, const int32 Radius) +{ + const int64 PointCount = static_cast(Radius) * 6; + if (Radius < 0 || (Radius > 0 && !IsSupportedGeneratedElementCount(PointCount))) + { + return {}; + } + if (Radius == 0) + { + return { Center }; + } + + TArray Ring; + Ring.SetNumUninitialized(static_cast(PointCount)); + int32 PointIndex = 0; + int64 Q = static_cast(Center.X) + static_cast(HexDirections[4][0]) * Radius; + int64 R = static_cast(Center.Y) + static_cast(HexDirections[4][1]) * Radius; + for (int32 Side = 0; Side < 6; ++Side) + { + for (int32 Step = 0; Step < Radius; ++Step) + { + if (!TryMakeHexCoordinate(Q, R, Ring[PointIndex++])) + { + return {}; + } + Q += HexDirections[Side][0]; + R += HexDirections[Side][1]; + } + } + return Ring; +} + +TArray UDirectiveUtilMathFunctionLibrary::GetHexLine(const FIntPoint Start, const FIntPoint End) +{ + const int64 Distance = GetHexDistance(Start, End); + if (!IsSupportedGeneratedElementCount(Distance + 1)) + { + return {}; + } + + TArray Line; + Line.SetNumUninitialized(static_cast(Distance) + 1); + Line[0] = Start; + if (Distance == 0) + { + return Line; + } + Line.Last() = End; + + // Nudging both endpoints off the cell edges makes ties round to a consistent side. + constexpr double Nudge = 1.e-6; + const double StartQ = static_cast(Start.X) + Nudge; + const double StartR = static_cast(Start.Y) + Nudge; + const double EndQ = static_cast(End.X) + Nudge; + const double EndR = static_cast(End.Y) + Nudge; + for (int64 Step = 1; Step < Distance; ++Step) + { + const double Alpha = static_cast(Step) / static_cast(Distance); + FIntPoint Coordinate; + if (!TryRoundHexCoordinate( + FMath::Lerp(StartQ, EndQ, Alpha), FMath::Lerp(StartR, EndR, Alpha), Coordinate)) + { + return {}; + } + Line[static_cast(Step)] = Coordinate; + } + return Line; +} + +TArray UDirectiveUtilMathFunctionLibrary::GetHexCellCorners(const FIntPoint Coordinate, + const FVector& Origin, const FRotator& Rotation, const double CellRadius, + const EDirectiveUtilHexOrientation Orientation, const double Gap) +{ + FHexLayout Layout; + if (!TryMakeHexLayout(Origin, Rotation, CellRadius, Gap, Orientation, Layout)) + { + return {}; + } + + const FVector Center = Layout.GetLocation(Coordinate.X, Coordinate.Y); + if (Center.ContainsNaN()) + { + return {}; + } + + const double StartAngle = Orientation == EDirectiveUtilHexOrientation::PointyTop + ? UE_DOUBLE_PI / 6.0 + : 0.0; + TArray Corners; + Corners.SetNumUninitialized(6); + for (int32 Index = 0; Index < 6; ++Index) + { + const double Angle = StartAngle + Index * (UE_DOUBLE_PI / 3.0); + const FVector Corner = Center + + (Layout.AxisX * FMath::Cos(Angle) + Layout.AxisY * FMath::Sin(Angle)) * CellRadius; + if (Corner.ContainsNaN()) + { + return {}; + } + Corners[Index] = Corner; + } + return Corners; +} + +TArray UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongDirection(const FVector& Origin, + const FVector& Direction, const int32 Count, const double Spacing, const bool bCentered) +{ + FVector NormalizedDirection; + if (Count <= 0 || !FMath::IsFinite(Spacing) || Origin.ContainsNaN() + || !TryGetNormalizedVector(Direction, NormalizedDirection)) + { + return {}; + } + + const FVector Step = NormalizedDirection * Spacing; + const double FirstStep = bCentered ? -0.5 * static_cast(Count - 1) : 0.0; + return GenerateLinearPoints(Origin, Step, Count, FirstStep); +} + +TArray UDirectiveUtilMathFunctionLibrary::GeneratePointsBetweenLocations(const FVector& Start, + const FVector& End, const int32 Count, const bool bIncludeEndpoints) +{ + if (Count <= 0 || Start.ContainsNaN() || End.ContainsNaN()) + { + return {}; + } + if (Count == 1) + { + return MakeSinglePoint(Start * 0.5 + End * 0.5); + } + + const FVector Delta = End - Start; + if (Delta.ContainsNaN()) + { + return {}; + } + const double Divisor = bIncludeEndpoints ? static_cast(Count - 1) : static_cast(Count) + 1.0; + TArray Points = GenerateLinearPoints(Start, Delta / Divisor, Count, bIncludeEndpoints ? 0.0 : 1.0); + if (bIncludeEndpoints && Points.Num() == Count) + { + Points[0] = Start; + Points.Last() = End; + } + return Points; +} + +TArray UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(const USplineComponent* Spline, + const double Spacing, const bool bIncludeEndpoint, const EDirectiveUtilSplineSpacingMode SpacingMode, + const ESplineCoordinateSpace::Type CoordinateSpace, const double StartDistance, const double EndDistance) +{ + FSplineSamplePlan Plan; + if (!IsValidSplineCoordinateSpace(CoordinateSpace) + || !TryMakeSplineSpacingPlan(Spline, Spacing, SpacingMode, bIncludeEndpoint, + StartDistance, EndDistance, Plan)) + { + return {}; + } + return SampleSplineLocations(Spline, Plan, CoordinateSpace); +} + +TArray UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSplineByCount( + const USplineComponent* Spline, const int32 Count, const bool bIncludeEndpoints, + const ESplineCoordinateSpace::Type CoordinateSpace, const double StartDistance, const double EndDistance) +{ + FSplineSamplePlan Plan; + if (!IsValidSplineCoordinateSpace(CoordinateSpace) + || !TryMakeSplineCountPlan(Spline, Count, bIncludeEndpoints, StartDistance, EndDistance, Plan)) + { + return {}; + } + return SampleSplineLocations(Spline, Plan, CoordinateSpace); +} + +TArray UDirectiveUtilMathFunctionLibrary::GenerateTransformsAlongSpline( + const USplineComponent* Spline, const double Spacing, const bool bIncludeEndpoint, + const EDirectiveUtilSplineSpacingMode SpacingMode, const ESplineCoordinateSpace::Type CoordinateSpace, + const bool bUseSplineScale, const FRotator RotationOffset, const FVector ScaleMultiplier, + const double StartDistance, const double EndDistance) +{ + FSplineSamplePlan Plan; + FQuat RotationOffsetQuaternion; + if (ScaleMultiplier.ContainsNaN() + || !IsValidSplineCoordinateSpace(CoordinateSpace) + || !TryGetRotationQuaternion(RotationOffset, RotationOffsetQuaternion) + || !TryMakeSplineSpacingPlan(Spline, Spacing, SpacingMode, bIncludeEndpoint, + StartDistance, EndDistance, Plan)) + { + return {}; + } + return SampleSplineTransforms(Spline, Plan, CoordinateSpace, bUseSplineScale, + RotationOffsetQuaternion, ScaleMultiplier); +} + +TArray UDirectiveUtilMathFunctionLibrary::GenerateTransformsAlongSplineByCount( + const USplineComponent* Spline, const int32 Count, const bool bIncludeEndpoints, + const ESplineCoordinateSpace::Type CoordinateSpace, const bool bUseSplineScale, + const FRotator RotationOffset, const FVector ScaleMultiplier, + const double StartDistance, const double EndDistance) +{ + FSplineSamplePlan Plan; + FQuat RotationOffsetQuaternion; + if (ScaleMultiplier.ContainsNaN() + || !IsValidSplineCoordinateSpace(CoordinateSpace) + || !TryGetRotationQuaternion(RotationOffset, RotationOffsetQuaternion) + || !TryMakeSplineCountPlan(Spline, Count, bIncludeEndpoints, StartDistance, EndDistance, Plan)) + { + return {}; + } + return SampleSplineTransforms(Spline, Plan, CoordinateSpace, bUseSplineScale, + RotationOffsetQuaternion, ScaleMultiplier); +} + +TArray UDirectiveUtilMathFunctionLibrary::GeneratePointsOnCircle(const FVector& Center, + const FRotator& Rotation, const double Radius, const int32 Count, const double StartAngleDegrees) +{ + if (Count <= 0 || !FMath::IsFinite(Radius) || !FMath::IsFinite(StartAngleDegrees)) + { + return {}; + } + const double AbsoluteRadius = FMath::Abs(Radius); + return GeneratePlanarRadialPoints(Center, Rotation, Count, WrapDegreesAsRadians(StartAngleDegrees), + UE_DOUBLE_TWO_PI / Count, [AbsoluteRadius](const int32) { return AbsoluteRadius; }); +} + +TArray UDirectiveUtilMathFunctionLibrary::GenerateTransformsOnCircle(const FVector& Center, + const FRotator& Rotation, const double Radius, const int32 Count, const double StartAngleDegrees, + const EDirectiveUtilRadialOrientation Orientation, const FRotator RotationOffset, const FVector Scale) +{ + if (Count <= 0 || !FMath::IsFinite(StartAngleDegrees)) + { + return {}; + } + return GeneratePlanarRadialTransforms(Center, Rotation, Radius, Count, + WrapDegreesAsRadians(StartAngleDegrees), UE_DOUBLE_TWO_PI / Count, 1.0, + Orientation, RotationOffset, Scale); +} + +TArray UDirectiveUtilMathFunctionLibrary::GeneratePointsOnArc(const FVector& Center, + const FRotator& Rotation, const double Radius, const int32 Count, const double StartAngleDegrees, + const double ArcAngleDegrees, const bool bIncludeEndpoint) +{ + if (Count <= 0 || !FMath::IsFinite(Radius) || !FMath::IsFinite(StartAngleDegrees) + || !FMath::IsFinite(ArcAngleDegrees)) + { + return {}; + } + + const double Divisor = bIncludeEndpoint && Count > 1 ? Count - 1.0 : static_cast(Count); + const double StepAngle = Count == 1 ? 0.0 : WrapDegreesAsRadians(ArcAngleDegrees / Divisor); + const double AbsoluteRadius = FMath::Abs(Radius); + return GeneratePlanarRadialPoints(Center, Rotation, Count, WrapDegreesAsRadians(StartAngleDegrees), + StepAngle, [AbsoluteRadius](const int32) { return AbsoluteRadius; }); +} + +TArray UDirectiveUtilMathFunctionLibrary::GenerateTransformsOnArc(const FVector& Center, + const FRotator& Rotation, const double Radius, const int32 Count, const double StartAngleDegrees, + const double ArcAngleDegrees, const bool bIncludeEndpoint, + const EDirectiveUtilRadialOrientation Orientation, const FRotator RotationOffset, const FVector Scale) +{ + if (Count <= 0 || !FMath::IsFinite(StartAngleDegrees) || !FMath::IsFinite(ArcAngleDegrees)) + { + return {}; + } + + const double Divisor = bIncludeEndpoint && Count > 1 ? Count - 1.0 : static_cast(Count); + const double StepAngle = Count == 1 ? 0.0 : WrapDegreesAsRadians(ArcAngleDegrees / Divisor); + return GeneratePlanarRadialTransforms(Center, Rotation, Radius, Count, + WrapDegreesAsRadians(StartAngleDegrees), StepAngle, ArcAngleDegrees, + Orientation, RotationOffset, Scale); +} + +TArray UDirectiveUtilMathFunctionLibrary::GeneratePointsOnDisc(const FVector& Center, + const FRotator& Rotation, const double Radius, const int32 Count, const double AngleOffsetDegrees) +{ + if (Count <= 0 || !FMath::IsFinite(Radius) || !FMath::IsFinite(AngleOffsetDegrees)) + { + return {}; + } + if (Count == 1) + { + return MakeSinglePoint(Center); + } + + const double AbsoluteRadius = FMath::Abs(Radius); + const double InverseCount = 1.0 / Count; + const double GoldenAngle = UE_DOUBLE_PI * (3.0 - FMath::Sqrt(5.0)); + return GeneratePlanarRadialPoints(Center, Rotation, Count, WrapDegreesAsRadians(AngleOffsetDegrees), + GoldenAngle, [AbsoluteRadius, InverseCount](const int32 Index) + { + return AbsoluteRadius * FMath::Sqrt((Index + 0.5) * InverseCount); + }); +} + +TArray UDirectiveUtilMathFunctionLibrary::GeneratePointsOnSphere(const FVector& Center, + const FRotator& Rotation, const double Radius, const int32 Count, const double AngleOffsetDegrees) +{ + if (!IsSupportedGeneratedElementCount(Count) + || Center.ContainsNaN() || !FMath::IsFinite(Radius) || !FMath::IsFinite(AngleOffsetDegrees)) + { + return {}; + } + + FVector AxisX; + FVector AxisY; + FVector AxisZ; + if (!TryGetRotatedAxes(Rotation, AxisX, AxisY, AxisZ)) + { + return {}; + } + + const double AbsoluteRadius = FMath::Abs(Radius); + if (Count == 1) + { + return MakeSinglePoint(Center + AxisZ * AbsoluteRadius); + } + + TArray Points; + Points.SetNumUninitialized(Count); + const double InverseCount = 1.0 / Count; + const double GoldenAngle = UE_DOUBLE_PI * (3.0 - FMath::Sqrt(5.0)); + FAngleStepper Angle(WrapDegreesAsRadians(AngleOffsetDegrees), GoldenAngle); + for (int32 Index = 0; Index < Count; ++Index) + { + const double Z = 1.0 - 2.0 * (Index + 0.5) * InverseCount; + const double RadialScale = FMath::Sqrt(FMath::Max(0.0, 1.0 - Z * Z)); + const FVector Point = Center + (AxisX * (Angle.Cosine * RadialScale) + + AxisY * (Angle.Sine * RadialScale) + AxisZ * Z) * AbsoluteRadius; + if (Point.ContainsNaN()) + { + return {}; + } + Points[Index] = Point; + Angle.Advance(); + } + return Points; +} + +TArray UDirectiveUtilMathFunctionLibrary::OffsetLocationsByNoise(const TArray& Locations, + const double NoiseScale, const double Amplitude, const FVector Direction, const FVector NoiseOffset) +{ + FVector NormalizedDirection; + if (!TryGetNoiseOffsetInputs(NoiseScale, Amplitude, Direction, NoiseOffset, NormalizedDirection)) + { + return {}; + } + + TArray Result; + Result.SetNumUninitialized(Locations.Num()); + for (int32 Index = 0; Index < Locations.Num(); ++Index) + { + if (!TryOffsetLocationByNoise(Locations[Index], NormalizedDirection, NoiseScale, Amplitude, + NoiseOffset, Result[Index])) + { + return {}; + } + } + return Result; +} + +TArray UDirectiveUtilMathFunctionLibrary::OffsetTransformsByNoise( + const TArray& Transforms, const double NoiseScale, const double Amplitude, + const FVector Direction, const FVector NoiseOffset) +{ + FVector NormalizedDirection; + if (!TryGetNoiseOffsetInputs(NoiseScale, Amplitude, Direction, NoiseOffset, NormalizedDirection)) + { + return {}; + } + + TArray Result; + Result.SetNumUninitialized(Transforms.Num()); + for (int32 Index = 0; Index < Transforms.Num(); ++Index) + { + FVector OffsetLocation; + if (!TryOffsetLocationByNoise(Transforms[Index].GetLocation(), NormalizedDirection, NoiseScale, + Amplitude, NoiseOffset, OffsetLocation)) + { + return {}; + } + Result[Index] = Transforms[Index]; + Result[Index].SetLocation(OffsetLocation); + } + return Result; } diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilMathRandomFunctionLibrary.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilMathRandomFunctionLibrary.cpp new file mode 100644 index 00000000..c859fa9e --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilMathRandomFunctionLibrary.cpp @@ -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 + FVector MakeRandomPointInSphere(const double Radius, RandomFractionFunction&& RandomFraction) + { + FVector Point; + double SizeSquared; + do + { + const double X = static_cast(RandomFraction()) * 2.0 - 1.0; + const double Y = static_cast(RandomFraction()) * 2.0 - 1.0; + const double Z = static_cast(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& 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(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& 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(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(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(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(InnerRadius)); + const double SecondRadius = FMath::Abs(static_cast(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(InnerRadius)); + const double SecondRadius = FMath::Abs(static_cast(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(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(Radius)); + if (AbsoluteRadius == 0.0) + { + return FVector::ZeroVector; + } + return MakeRandomPointInSphere(AbsoluteRadius, [&Stream] + { + return Stream.FRand(); + }); +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilMathStatisticsFunctionLibrary.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilMathStatisticsFunctionLibrary.cpp new file mode 100644 index 00000000..4a682015 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilMathStatisticsFunctionLibrary.cpp @@ -0,0 +1,576 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#include "Libraries/DirectiveUtilMathFunctionLibrary.h" + +#include + +namespace +{ + float GetUsableStatisticsWeight(const float Weight) + { + return FMath::IsFinite(Weight) && Weight > 0.0f ? Weight : 0.0f; + } + + template + ValueType SelectStatisticsNth(TArray& Values, const int32 NthIndex) + { + int32 Left = 0; + int32 Right = Values.Num() - 1; + int32 RemainingDepth = FMath::FloorLog2(static_cast(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 + double CalculateStatisticsMedian(TArray& Values) + { + const int32 Middle = Values.Num() / 2; + const ValueType UpperMiddle = SelectStatisticsNth(Values, Middle); + if (Values.Num() % 2 != 0) + { + return static_cast(UpperMiddle); + } + + ValueType LowerMiddle = Values[0]; + for (int32 Index = 1; Index < Middle; ++Index) + { + LowerMiddle = FMath::Max(LowerMiddle, Values[Index]); + } + return (static_cast(LowerMiddle) + static_cast(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(Decimals)); + return static_cast(FMath::RoundHalfFromZero(static_cast(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(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(Seconds)); + const int64 TotalSeconds = AbsoluteSeconds >= static_cast(TNumericLimits::Max()) + ? TNumericLimits::Max() + : static_cast(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(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& Values) +{ + int64 Sum = 0; + for (const int32 Value : Values) + { + Sum += Value; + } + return Sum; +} + +float UDirectiveUtilMathFunctionLibrary::GetIntArrayAverage(const TArray& Values) +{ + if (Values.IsEmpty()) + { + return 0.0f; + } + return static_cast(static_cast(GetIntArraySum(Values)) / Values.Num()); +} + +float UDirectiveUtilMathFunctionLibrary::GetIntArrayMedian(const TArray& Values) +{ + if (Values.IsEmpty()) + { + return 0.0f; + } + + TArray WorkingValues = Values; + return static_cast(CalculateStatisticsMedian(WorkingValues)); +} + +float UDirectiveUtilMathFunctionLibrary::GetIntArrayStandardDeviation(const TArray& Values) +{ + if (Values.IsEmpty()) + { + return 0.0f; + } + + const double Mean = static_cast(GetIntArraySum(Values)) / Values.Num(); + double SquaredDeltaSum = 0.0; + for (const int32 Value : Values) + { + const double Delta = static_cast(Value) - Mean; + SquaredDeltaSum += Delta * Delta; + } + return static_cast(FMath::Sqrt(SquaredDeltaSum / Values.Num())); +} + +float UDirectiveUtilMathFunctionLibrary::GetFloatArraySum(const TArray& Values) +{ + double Sum = 0.0; + for (const float Value : Values) + { + Sum += static_cast(Value); + } + return static_cast(Sum); +} + +float UDirectiveUtilMathFunctionLibrary::GetFloatArrayAverage(const TArray& Values) +{ + if (Values.IsEmpty()) + { + return 0.0f; + } + + double Sum = 0.0; + for (const float Value : Values) + { + Sum += static_cast(Value); + } + return static_cast(Sum / Values.Num()); +} + +float UDirectiveUtilMathFunctionLibrary::GetFloatArrayMedian(const TArray& Values) +{ + if (Values.IsEmpty()) + { + return 0.0f; + } + + TArray WorkingValues = Values; + if (WorkingValues.ContainsByPredicate([](const float Value) { return FMath::IsNaN(Value); })) + { + return std::numeric_limits::quiet_NaN(); + } + return static_cast(CalculateStatisticsMedian(WorkingValues)); +} + +float UDirectiveUtilMathFunctionLibrary::GetFloatArrayStandardDeviation(const TArray& Values) +{ + if (Values.IsEmpty()) + { + return 0.0f; + } + + double Sum = 0.0; + for (const float Value : Values) + { + Sum += static_cast(Value); + } + const double Mean = Sum / Values.Num(); + + double SquaredDeltaSum = 0.0; + for (const float Value : Values) + { + const double Delta = static_cast(Value) - Mean; + SquaredDeltaSum += Delta * Delta; + } + return static_cast(FMath::Sqrt(SquaredDeltaSum / Values.Num())); +} + +bool UDirectiveUtilMathFunctionLibrary::GetAngleArrayAverage(const TArray& 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(Angle), 360.0)); + SineSum += FMath::Sin(Radians); + CosineSum += FMath::Cos(Radians); + } + + const double Magnitude = FMath::Sqrt(SineSum * SineSum + CosineSum * CosineSum); + ResultantStrength = static_cast(FMath::Clamp(Magnitude / Angles.Num(), 0.0, 1.0)); + if (ResultantStrength <= UE_DOUBLE_SMALL_NUMBER) + { + ResultantStrength = 0.0f; + return false; + } + + AverageAngle = static_cast(FMath::RadiansToDegrees(FMath::Atan2(SineSum, CosineSum))); + return true; +} + +bool UDirectiveUtilMathFunctionLibrary::GetWeightedFloatArrayAverage(const TArray& Values, + const TArray& 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(Values[Index]) * Weight; + WeightSum += Weight; + } + + if (WeightSum <= 0.0) + { + return false; + } + + Average = static_cast(WeightedSum / WeightSum); + return FMath::IsFinite(Average); +} + +bool UDirectiveUtilMathFunctionLibrary::GetWeightedVectorArrayAverage(const TArray& Values, + const TArray& 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& Values, + const float OutputMinimum, const float OutputMaximum, TArray& NormalizedValues) +{ + TArray ValuesCopy; + const TArray* 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(OutputMaximum) - OutputMinimum) + / (static_cast(InputMaximum) - InputMinimum); + for (int32 Index = 0; Index < SourceValues->Num(); ++Index) + { + NormalizedValues[Index] = static_cast(OutputMinimum + + (static_cast((*SourceValues)[Index]) - InputMinimum) * Scale); + } + return true; +} + +bool UDirectiveUtilMathFunctionLibrary::NormalizeWeights(const TArray& Weights, + TArray& NormalizedWeights) +{ + TArray WeightsCopy; + const TArray* 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(GetUsableStatisticsWeight((*SourceWeights)[Index]) / WeightSum); + } + return true; +} + +bool UDirectiveUtilMathFunctionLibrary::GetFloatArrayPercentile(const TArray& 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 WorkingValues = Values; + + const double Position = FMath::Clamp(static_cast(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(FMath::Lerp( + static_cast(LowerValue), + static_cast(UpperValue), + Position - LowerIndex)); + return true; +} + +bool UDirectiveUtilMathFunctionLibrary::GetFloatArrayRootMeanSquare(const TArray& 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(Value) * Value; + } + + RootMeanSquare = static_cast(FMath::Sqrt(SquaredSum / Values.Num())); + return FMath::IsFinite(RootMeanSquare); +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilSaveGameFunctionLibrary.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilSaveGameFunctionLibrary.cpp index 1b3fdbfa..6164d3a3 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilSaveGameFunctionLibrary.cpp +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilSaveGameFunctionLibrary.cpp @@ -7,6 +7,10 @@ #include "GameFramework/SaveGame.h" #include "HAL/FileManager.h" #include "Misc/Paths.h" +#include "PlatformFeatures.h" +#include "SaveGameSystem.h" + +#include 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(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 UDirectiveUtilSaveGameFunctionLibrary::GetAllSaveSlotNames() { TArray SlotNames; + if (ISaveGameSystem* SaveSystem = GetSaveGameSystem()) + { + if (SaveSystem->GetSaveGameNames(SlotNames, 0)) + { + return SlotNames; + } + } TArray 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 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; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilStringFunctionLibrary.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilStringFunctionLibrary.cpp index f4af8ade..c0dbb6c4 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilStringFunctionLibrary.cpp +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Libraries/DirectiveUtilStringFunctionLibrary.cpp @@ -14,6 +14,80 @@ namespace const FTCHARToUTF8 Converter(*String, String.Len()); return TArray(reinterpret_cast(Converter.Get()), Converter.Length()); } + + int32 CalculateLevenshteinDistance( + FStringView Left, + FStringView Right, + TArray& PreviousRow, + TArray& 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& PreviousRow, + TArray& CurrentRow) + { + if (MaxLength == 0) + { + return 1.0f; + } + const int32 Distance = CalculateLevenshteinDistance(Left, Right, PreviousRow, CurrentRow); + return 1.0f - static_cast(Distance) / static_cast(MaxLength); + } } bool UDirectiveUtilStringFunctionLibrary::ContainsLetters(const FString& String) @@ -219,50 +293,27 @@ TArray 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 PreviousRow; TArray 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(Distance) / static_cast(MaxLength)); + const FString NormalizedA = bCaseSensitive ? FString() : A.ToLower(); + const FString NormalizedB = bCaseSensitive ? FString() : B.ToLower(); + TArray PreviousRow; + TArray 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& SearchTerms, const bool bCaseSensitive) @@ -394,28 +445,96 @@ int32 UDirectiveUtilStringFunctionLibrary::Crc32Bytes(const TArray& Bytes return static_cast(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& 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 PreviousRow; + TArray 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; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Tasks/DirectiveUtilAsyncActionBase.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Tasks/DirectiveUtilAsyncActionBase.cpp new file mode 100644 index 00000000..3e741d34 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Tasks/DirectiveUtilAsyncActionBase.cpp @@ -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(); +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Tasks/DirectiveUtilTask_AsyncLoadAsset.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Tasks/DirectiveUtilTask_AsyncLoadAsset.cpp index fd7d8d5f..bcd238e3 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Tasks/DirectiveUtilTask_AsyncLoadAsset.cpp +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Tasks/DirectiveUtilTask_AsyncLoadAsset.cpp @@ -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(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 PathsToLoad; for (const TSoftObjectPtr& 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(); diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Tasks/DirectiveUtilTask_Delay.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Tasks/DirectiveUtilTask_Delay.cpp index bd083ed1..6cb79c4d 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Tasks/DirectiveUtilTask_Delay.cpp +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Tasks/DirectiveUtilTask_Delay.cpp @@ -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(); } diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Tasks/DirectiveUtilTask_Flow.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Tasks/DirectiveUtilTask_Flow.cpp new file mode 100644 index 00000000..dad67b85 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Tasks/DirectiveUtilTask_Flow.cpp @@ -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(); + 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(); + 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); + } +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Tasks/DirectiveUtilTask_MoveToLocation.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Tasks/DirectiveUtilTask_MoveToLocation.cpp index 0a1a056b..dce27d7a 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Tasks/DirectiveUtilTask_MoveToLocation.cpp +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Private/Tasks/DirectiveUtilTask_MoveToLocation.cpp @@ -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(); 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(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(); 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(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); diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilArrayFunctionLibrary.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilArrayFunctionLibrary.h index a959f74d..08892c33 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilArrayFunctionLibrary.h +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilArrayFunctionLibrary.h @@ -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& TargetArray); + static void Array_RemoveDuplicates(UPARAM(ref) TArray& 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& TargetArray, const TArray& 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& TargetArray, const TArray& 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& TargetArray, int32& OutItem); + static bool Array_Pop(UPARAM(ref) TArray& 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& TargetArray, int32& OutItem); + static bool Array_PopFirst(UPARAM(ref) TArray& 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& TargetArray, const int32 Index); + static bool Array_RemoveAtSwap(UPARAM(ref) TArray& 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& TargetArray, const TArray& 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& 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& TargetArray, const int32 Shift); + static void Array_Rotate(UPARAM(ref) TArray& 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& 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& TargetArray, int32 Count, bool bWithReplacement, TArray& 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& TargetArray, int32 Count, bool bWithReplacement, UPARAM(ref) FRandomStream& RandomStream, TArray& 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& TargetArray, const TArray& Weights, int32 Count, bool bWithReplacement, TArray& 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& TargetArray, const TArray& Weights, int32 Count, bool bWithReplacement, UPARAM(ref) FRandomStream& RandomStream, TArray& 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& TargetArray, int32 PageIndex, int32 PageSize, TArray& 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& 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& 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& 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& 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(nullptr); + void* TargetArrayAddr = Stack.MostRecentPropertyAddress; + FArrayProperty* TargetArrayProperty = CastField(Stack.MostRecentProperty); + if (!TargetArrayProperty) + { + Stack.bArrayContextFailed = true; + return; + } + + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + const void* SourceArrayAddr = Stack.MostRecentPropertyAddress; + const FArrayProperty* SourceArrayProperty = CastField(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(nullptr); + void* TargetArrayAddr = Stack.MostRecentPropertyAddress; + FArrayProperty* TargetArrayProperty = CastField(Stack.MostRecentProperty); + if (!TargetArrayProperty) + { + Stack.bArrayContextFailed = true; + return; + } + + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + const void* SourceArrayAddr = Stack.MostRecentPropertyAddress; + const FArrayProperty* SourceArrayProperty = CastField(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(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(nullptr); + void* ArrayAddr = Stack.MostRecentPropertyAddress; + FArrayProperty* ArrayProperty = CastField(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(RESULT_PARAM) = RemovedCount; + P_NATIVE_END; + } + + DECLARE_FUNCTION(execArray_RemoveAllOccurrences) + { + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + void* ArrayAddr = Stack.MostRecentPropertyAddress; + FArrayProperty* ArrayProperty = CastField(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(StorageSpace); + + P_FINISH; + + if (const FBoolProperty* BoolProperty = CastField(InnerProp)) + { + ensure(PropertySize == sizeof(uint8)); + BoolProperty->SetPropertyValue(StorageSpace, *static_cast(StorageSpace) != 0); + } + + P_NATIVE_BEGIN; + const bool bRemoved = GenericArray_RemoveAllOccurrences(ArrayAddr, ArrayProperty, StorageSpace); + if (bRemoved) + { + MARK_PROPERTY_DIRTY(Stack.Object, ArrayProperty); + } + *static_cast(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(nullptr); + const void* SourceArrayAddr = Stack.MostRecentPropertyAddress; + const FArrayProperty* SourceArrayProperty = CastField(Stack.MostRecentProperty); + if (!SourceArrayProperty) + { + Stack.bArrayContextFailed = true; + return; + } + P_GET_PROPERTY(FIntProperty, Count); + P_GET_UBOOL(bWithReplacement); + + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + void* OutArrayAddr = Stack.MostRecentPropertyAddress; + const FArrayProperty* OutArrayProperty = CastField(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(nullptr); + const void* SourceArrayAddr = Stack.MostRecentPropertyAddress; + const FArrayProperty* SourceArrayProperty = CastField(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(nullptr); + void* OutArrayAddr = Stack.MostRecentPropertyAddress; + const FArrayProperty* OutArrayProperty = CastField(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(nullptr); + const void* SourceArrayAddr = Stack.MostRecentPropertyAddress; + const FArrayProperty* SourceArrayProperty = CastField(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(nullptr); + void* OutArrayAddr = Stack.MostRecentPropertyAddress; + const FArrayProperty* OutArrayProperty = CastField(Stack.MostRecentProperty); + if (!OutArrayProperty) + { + Stack.bArrayContextFailed = true; + return; + } + + P_FINISH; + P_NATIVE_BEGIN; + *static_cast(RESULT_PARAM) = GenericArray_SampleWeighted( + SourceArrayAddr, + SourceArrayProperty, + Weights, + Count, + bWithReplacement, + nullptr, + OutArrayAddr, + OutArrayProperty); + P_NATIVE_END; + } + + DECLARE_FUNCTION(execArray_SampleWeightedFromStream) + { + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + const void* SourceArrayAddr = Stack.MostRecentPropertyAddress; + const FArrayProperty* SourceArrayProperty = CastField(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(nullptr); + void* OutArrayAddr = Stack.MostRecentPropertyAddress; + const FArrayProperty* OutArrayProperty = CastField(Stack.MostRecentProperty); + if (!OutArrayProperty) + { + Stack.bArrayContextFailed = true; + return; + } + + P_FINISH; + P_NATIVE_BEGIN; + *static_cast(RESULT_PARAM) = GenericArray_SampleWeighted( + SourceArrayAddr, + SourceArrayProperty, + Weights, + Count, + bWithReplacement, + &RandomStream, + OutArrayAddr, + OutArrayProperty); + P_NATIVE_END; + } + + DECLARE_FUNCTION(execArray_GetPage) + { + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + const void* SourceArrayAddr = Stack.MostRecentPropertyAddress; + const FArrayProperty* SourceArrayProperty = CastField(Stack.MostRecentProperty); + if (!SourceArrayProperty) + { + Stack.bArrayContextFailed = true; + return; + } + P_GET_PROPERTY(FIntProperty, PageIndex); + P_GET_PROPERTY(FIntProperty, PageSize); + + Stack.MostRecentProperty = nullptr; + Stack.StepCompiledIn(nullptr); + void* OutArrayAddr = Stack.MostRecentPropertyAddress; + const FArrayProperty* OutArrayProperty = CastField(Stack.MostRecentProperty); + if (!OutArrayProperty) + { + Stack.bArrayContextFailed = true; + return; + } + + Stack.MostRecentProperty = nullptr; + Stack.MostRecentPropertyAddress = nullptr; + Stack.StepCompiledIn(nullptr); + int32* OutPageCount = reinterpret_cast(Stack.MostRecentPropertyAddress); + + P_FINISH; + P_NATIVE_BEGIN; + *static_cast(RESULT_PARAM) = GenericArray_GetPage(SourceArrayAddr, SourceArrayProperty, PageIndex, PageSize, OutArrayAddr, OutArrayProperty, OutPageCount); + P_NATIVE_END; + } }; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilFunctionLibrary.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilFunctionLibrary.h index a65c5714..b1fb7d8b 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilFunctionLibrary.h +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilFunctionLibrary.h @@ -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); diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilGameplayTagFunctionLibrary.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilGameplayTagFunctionLibrary.h index f48d10c3..4c4a5117 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilGameplayTagFunctionLibrary.h +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilGameplayTagFunctionLibrary.h @@ -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); /** diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilInputFunctionLibrary.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilInputFunctionLibrary.h index 81ad20b6..a834b90e 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilInputFunctionLibrary.h +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilInputFunctionLibrary.h @@ -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>& 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 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")) diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilMapFunctionLibrary.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilMapFunctionLibrary.h index b48277ca..17f0e126 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilMapFunctionLibrary.h +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilMapFunctionLibrary.h @@ -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& TargetMap, const TMap& SourceMap, bool bOverwriteExisting = true); /*~ diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilMathFunctionLibrary.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilMathFunctionLibrary.h index 665689e7..f27ecacd 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilMathFunctionLibrary.h +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilMathFunctionLibrary.h @@ -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& 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 LocationsToTransforms(const TArray& 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 LocationsToFacingTransforms(const TArray& 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& Locations, const TArray& Rotations, + const TArray& Scales, TArray& 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& 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 OffsetLocationsByNoise(const TArray& 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 OffsetTransformsByNoise(const TArray& 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 EaseLocationArrays(const TArray& From, const TArray& To, + float Alpha, EDirectiveUtilEaseType EaseType, const TArray& 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 EaseTransformArrays(const TArray& From, const TArray& To, + float Alpha, EDirectiveUtilEaseType EaseType, const TArray& 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& 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& 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& Values, const TArray& 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& Values, const TArray& 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& Values, float OutputMinimum, float OutputMaximum, + TArray& 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& Weights, TArray& 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& 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& 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& 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); }; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilSaveGameFunctionLibrary.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilSaveGameFunctionLibrary.h index 8154eed4..39ede002 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilSaveGameFunctionLibrary.h +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilSaveGameFunctionLibrary.h @@ -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. diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilStringFunctionLibrary.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilStringFunctionLibrary.h index 435ba310..3ea7a107 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilStringFunctionLibrary.h +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilStringFunctionLibrary.h @@ -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. diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilTextFunctionLibrary.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilTextFunctionLibrary.h index f6234129..eb21fc8a 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilTextFunctionLibrary.h +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Libraries/DirectiveUtilTextFunctionLibrary.h @@ -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); }; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilAsyncActionBase.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilAsyncActionBase.h new file mode 100644 index 00000000..597644eb --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilAsyncActionBase.h @@ -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 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 RegisteredWorld; + FDelegateHandle WorldCleanupHandle; +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilTask_AsyncLoadAsset.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilTask_AsyncLoadAsset.h index 50690b3d..65b29619 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilTask_AsyncLoadAsset.h +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilTask_AsyncLoadAsset.h @@ -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() diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilTask_AsyncTrace.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilTask_AsyncTrace.h index 080b5983..23658c71 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilTask_AsyncTrace.h +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilTask_AsyncTrace.h @@ -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() diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilTask_Delay.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilTask_Delay.h index a8167372..c9e54f6e 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilTask_Delay.h +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilTask_Delay.h @@ -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 WorldContextObject; float Duration = 0.0f; + bool bFinished = false; FTimerHandle TimerHandle; void OnDelayComplete(); diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilTask_Flow.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilTask_Flow.h new file mode 100644 index 00000000..cf946534 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilTask_Flow.h @@ -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 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 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(); +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilTask_MoveToLocation.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilTask_MoveToLocation.h index d5756b94..73596b0f 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilTask_MoveToLocation.h +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Tasks/DirectiveUtilTask_MoveToLocation.h @@ -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 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 TimerWorld; + bool bHasCompleted = false; void CheckMoveToActor(); diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Types/DirectiveUtilInputTypes.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Types/DirectiveUtilInputTypes.h index dc901a71..4e7535e2 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Types/DirectiveUtilInputTypes.h +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Types/DirectiveUtilInputTypes.h @@ -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" diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Types/DirectiveUtilMathTypes.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Types/DirectiveUtilMathTypes.h index 664a5675..08c05567 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Types/DirectiveUtilMathTypes.h +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Types/DirectiveUtilMathTypes.h @@ -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."), }; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Types/DirectiveUtilTypes.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Types/DirectiveUtilTypes.h index b17d71f8..4ad322bd 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Types/DirectiveUtilTypes.h +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesRuntime/Public/Types/DirectiveUtilTypes.h @@ -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" @@ -11,4 +13,40 @@ enum class EDirectiveUtilSuccessStatus : uint8 { Success, Failure, -}; \ No newline at end of file +}; + +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, +}; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/DirectiveUtilitiesTests.Build.cs b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/DirectiveUtilitiesTests.Build.cs index 162ec6c0..e592f64a 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/DirectiveUtilitiesTests.Build.cs +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/DirectiveUtilitiesTests.Build.cs @@ -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"); } } -} \ No newline at end of file +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilArrayBlueprintVmTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilArrayBlueprintVmTest.cpp new file mode 100644 index 00000000..8c920939 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilArrayBlueprintVmTest.cpp @@ -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 + NodeType* AddNode(UEdGraph& Graph) + { + NodeType* Node = NewObject(&Graph); + Graph.AddNode(Node); + Node->CreateNewGuid(); + Node->PostPlacedNewNode(); + return Node; + } + + UK2Node_VariableGet* AddVariableGet(UEdGraph& Graph, const FName PropertyName) + { + UK2Node_VariableGet* Node = AddNode(Graph); + Node->VariableReference.SetSelfMember(PropertyName); + Node->AllocateDefaultPins(); + return Node; + } + + UK2Node_VariableSet* AddVariableSet(UEdGraph& Graph, const FName PropertyName) + { + UK2Node_VariableSet* Node = AddNode(Graph); + Node->VariableReference.SetSelfMember(PropertyName); + Node->AllocateDefaultPins(); + return Node; + } + + UK2Node_CallArrayFunction* AddArrayCall(UEdGraph& Graph, const FName FunctionName) + { + UK2Node_CallArrayFunction* Node = AddNode(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(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(*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( + 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({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({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({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({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({{3}, {2}, {3}})); + } + } + } + + if (UBlueprint* Blueprint = BuildScenarioBlueprint( + *this, TEXT("TestObjectArray"), TEXT("TestObjectSourceArray"), TEXT("TestObjectItem"))) + { + if (UDirectiveUtilTestObject* Instance = CreateScenarioInstance(*this, *Blueprint)) + { + UObject* A = NewObject(Instance); + UObject* B = NewObject(Instance); + UObject* C = NewObject(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; +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilArrayFunctionLibraryTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilArrayFunctionLibraryTest.cpp index 4593907e..763f8475 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilArrayFunctionLibraryTest.cpp +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilArrayFunctionLibraryTest.cpp @@ -1,8 +1,67 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + #include "Libraries/DirectiveUtilArrayFunctionLibrary.h" +#include "Async/ParallelFor.h" #include "Tests/DirectiveUtilTestObject.h" + +#include #include "Misc/AutomationTest.h" -IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilArrayFunctionLibraryTest, "DirectiveUtilities.ArrayFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) +namespace +{ + TArray BuildReferenceSample(const int32 SourceCount, const int32 Count, const int32 Seed) + { + TArray AvailableIndices; + AvailableIndices.SetNumUninitialized(SourceCount); + for (int32 Index = 0; Index < SourceCount; ++Index) + { + AvailableIndices[Index] = Index; + } + + FRandomStream RandomStream(Seed); + TArray Result; + const int32 SampleCount = FMath::Min(SourceCount, Count); + Result.Reserve(SampleCount); + 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; + } + + TArray BuildReferenceDistinct(const TArray& Values) + { + TArray Result; + for (const int32 Value : Values) + { + if (!Result.Contains(Value)) + { + Result.Add(Value); + } + } + return Result; + } + + TArray BuildReferenceRemoval( + const TArray& Values, + const TArray& Indices) + { + TArray Result; + Result.Reserve(Values.Num()); + for (int32 Index = 0; Index < Values.Num(); ++Index) + { + if (!Indices.Contains(Index)) + { + Result.Add(Values[Index]); + } + } + return Result; + } +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilArrayFunctionLibraryTest, "DirectiveUtilities.ArrayFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter) bool FDirectiveUtilArrayFunctionLibraryTest::RunTest(const FString& Parameters) { @@ -50,6 +109,10 @@ bool FDirectiveUtilArrayFunctionLibraryTest::RunTest(const FString& Parameters) UDirectiveUtilArrayFunctionLibrary::GenericArray_PreviousIndex(&TestObject->TestArray, ArrayProperty, 10, true), 4); TestEqual("Array_PreviousIndex should wrap a negative input index to the last index when looping", UDirectiveUtilArrayFunctionLibrary::GenericArray_PreviousIndex(&TestObject->TestArray, ArrayProperty, -1, true), 4); + TestEqual("Array_NextIndex should handle the maximum integer index", + UDirectiveUtilArrayFunctionLibrary::GenericArray_NextIndex(&TestObject->TestArray, ArrayProperty, MAX_int32, false), 4); + TestEqual("Array_PreviousIndex should handle the minimum integer index", + UDirectiveUtilArrayFunctionLibrary::GenericArray_PreviousIndex(&TestObject->TestArray, ArrayProperty, MIN_int32, false), 0); TestObject->TestArray = {1, 2, 3, 4, 5}; @@ -138,6 +201,225 @@ bool FDirectiveUtilArrayFunctionLibraryTest::RunTest(const FString& Parameters) TestEqual("RemoveAtSwap on the last element should shrink the array", TestObject->TestArray.Num(), 2); TestEqual("RemoveAtSwap on the last element should preserve the order of the rest", TestObject->TestArray[1], 20); + int32 ItemToRemove = 2; + TestObject->TestArray = {1, 2, 3, 2, 4, 2}; + TestTrue( + "RemoveAllOccurrences should report matching values", + UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveAllOccurrences(&TestObject->TestArray, ArrayProperty, &ItemToRemove)); + TestEqual( + "RemoveAllOccurrences should remove every match and preserve survivor order", + TestObject->TestArray, + TArray({1, 3, 4})); + + ItemToRemove = 9; + const TArray UnchangedValues = TestObject->TestArray; + TestFalse( + "RemoveAllOccurrences should report an absent value", + UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveAllOccurrences(&TestObject->TestArray, ArrayProperty, &ItemToRemove)); + TestEqual("RemoveAllOccurrences should not change an array without matches", TestObject->TestArray, UnchangedValues); + + TestObject->TestArray.Empty(); + TestFalse( + "RemoveAllOccurrences should report false for an empty array", + UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveAllOccurrences(&TestObject->TestArray, ArrayProperty, &ItemToRemove)); + + ItemToRemove = 5; + TestObject->TestArray = {5, 5, 5}; + TestTrue( + "RemoveAllOccurrences should remove an all-matching array", + UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveAllOccurrences(&TestObject->TestArray, ArrayProperty, &ItemToRemove)); + TestTrue("RemoveAllOccurrences should leave an all-matching array empty", TestObject->TestArray.IsEmpty()); + + TestObject->TestArray = {0, 1, 2, 3, 4, 5, 6}; + TestEqual( + "RemoveAtIndices should remove each valid index once", + UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveAtIndices( + &TestObject->TestArray, + ArrayProperty, + TArray({5, 1, 5, -1, 99, 3})), + 3); + TestEqual( + "RemoveAtIndices should preserve survivor order", + TestObject->TestArray, + TArray({0, 2, 4, 6})); + + const TArray BeforeInvalidIndices = TestObject->TestArray; + TestEqual( + "RemoveAtIndices should report zero for invalid indices", + UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveAtIndices( + &TestObject->TestArray, + ArrayProperty, + TArray({-5, 10, 10})), + 0); + TestEqual( + "RemoveAtIndices should not modify the target when no indices are valid", + TestObject->TestArray, + BeforeInvalidIndices); + + TestObject->TestArray = {10, 20, 30}; + TestEqual( + "RemoveAtIndices should remove the full array", + UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveAtIndices( + &TestObject->TestArray, + ArrayProperty, + TArray({2, 0, 1, 1})), + 3); + TestTrue("RemoveAtIndices should leave the array empty when every index is removed", TestObject->TestArray.IsEmpty()); + + TestObject->TestArray = {0, 2, 4}; + TestEqual( + "RemoveAtIndices should support using the target array as the index array", + UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveAtIndices( + &TestObject->TestArray, + ArrayProperty, + TestObject->TestArray), + 2); + TestEqual( + "RemoveAtIndices should read aliased indices before modifying the target", + TestObject->TestArray, + TArray({2})); + + TestTrue("Integer arrays should use the bulk append path", ArrayProperty->Inner->HasAnyPropertyFlags(CPF_IsPlainOldData)); + const TArray IntegerAppendSource = {3, 4, 5}; + TestObject->TestArray = {1, 2}; + UDirectiveUtilArrayFunctionLibrary::GenericArray_AppendOptimized( + &TestObject->TestArray, + ArrayProperty, + &IntegerAppendSource, + ArrayProperty); + TestEqual( + "AppendOptimized should append POD values in order", + TestObject->TestArray, + TArray({1, 2, 3, 4, 5})); + TestEqual( + "AppendOptimized should not modify the source array", + IntegerAppendSource, + TArray({3, 4, 5})); + + TestObject->TestArray.Empty(); + UDirectiveUtilArrayFunctionLibrary::GenericArray_AppendOptimized( + &TestObject->TestArray, + ArrayProperty, + &IntegerAppendSource, + ArrayProperty); + TestEqual( + "AppendOptimized should append into an empty target array", + TestObject->TestArray, + IntegerAppendSource); + + const TArray EmptyAppendSource; + const TArray BeforeEmptyAppend = TestObject->TestArray; + UDirectiveUtilArrayFunctionLibrary::GenericArray_AppendOptimized( + &TestObject->TestArray, + ArrayProperty, + &EmptyAppendSource, + ArrayProperty); + TestEqual("AppendOptimized should ignore an empty source array", TestObject->TestArray, BeforeEmptyAppend); + + TestObject->TestArray = {7, 8, 9}; + UDirectiveUtilArrayFunctionLibrary::GenericArray_AppendOptimized( + &TestObject->TestArray, + ArrayProperty, + &TestObject->TestArray, + ArrayProperty); + TestEqual( + "AppendOptimized should support appending an array to itself", + TestObject->TestArray, + TArray({7, 8, 9, 7, 8, 9})); + + const TArray IntegerInsertSource = {7, 8}; + TestObject->TestArray = {1, 2, 3}; + TestTrue( + "InsertOptimized should insert POD values", + UDirectiveUtilArrayFunctionLibrary::GenericArray_InsertOptimized( + &TestObject->TestArray, + ArrayProperty, + &IntegerInsertSource, + ArrayProperty, + 1)); + TestEqual( + "InsertOptimized should preserve target and source order", + TestObject->TestArray, + TArray({1, 7, 8, 2, 3})); + TestEqual( + "InsertOptimized should not modify the source array", + IntegerInsertSource, + TArray({7, 8})); + + TestObject->TestArray = {1, 2, 3}; + TestTrue( + "InsertOptimized should insert at the front", + UDirectiveUtilArrayFunctionLibrary::GenericArray_InsertOptimized( + &TestObject->TestArray, + ArrayProperty, + &IntegerInsertSource, + ArrayProperty, + 0)); + TestEqual( + "InsertOptimized should preserve order at the front", + TestObject->TestArray, + TArray({7, 8, 1, 2, 3})); + + TestObject->TestArray = {1, 2, 3}; + TestTrue( + "InsertOptimized should insert at the end", + UDirectiveUtilArrayFunctionLibrary::GenericArray_InsertOptimized( + &TestObject->TestArray, + ArrayProperty, + &IntegerInsertSource, + ArrayProperty, + TestObject->TestArray.Num())); + TestEqual( + "InsertOptimized should preserve order at the end", + TestObject->TestArray, + TArray({1, 2, 3, 7, 8})); + + const TArray EmptyInsertSource; + const TArray BeforeEmptyInsert = TestObject->TestArray; + TestFalse( + "InsertOptimized should report an empty source", + UDirectiveUtilArrayFunctionLibrary::GenericArray_InsertOptimized( + &TestObject->TestArray, + ArrayProperty, + &EmptyInsertSource, + ArrayProperty, + 1)); + TestEqual("InsertOptimized should ignore an empty source", TestObject->TestArray, BeforeEmptyInsert); + + TestFalse( + "InsertOptimized should reject a negative index", + UDirectiveUtilArrayFunctionLibrary::GenericArray_InsertOptimized( + &TestObject->TestArray, + ArrayProperty, + &IntegerInsertSource, + ArrayProperty, + -1)); + TestEqual("InsertOptimized should not modify the target for a negative index", TestObject->TestArray, BeforeEmptyInsert); + + TestFalse( + "InsertOptimized should reject an index beyond the array end", + UDirectiveUtilArrayFunctionLibrary::GenericArray_InsertOptimized( + &TestObject->TestArray, + ArrayProperty, + &IntegerInsertSource, + ArrayProperty, + TestObject->TestArray.Num() + 1)); + TestEqual("InsertOptimized should not modify the target for a large index", TestObject->TestArray, BeforeEmptyInsert); + + TestObject->TestArray = {4, 5, 6}; + TestTrue( + "InsertOptimized should support inserting an array into itself", + UDirectiveUtilArrayFunctionLibrary::GenericArray_InsertOptimized( + &TestObject->TestArray, + ArrayProperty, + &TestObject->TestArray, + ArrayProperty, + 1)); + TestEqual( + "InsertOptimized should preserve self-inserted values", + TestObject->TestArray, + TArray({4, 4, 5, 6, 5, 6})); + TestObject->TestArray = {1, 2, 2, 3, 1, 4}; UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveDuplicates(&TestObject->TestArray, ArrayProperty); TestEqual("RemoveDuplicates should remove all duplicate entries", TestObject->TestArray.Num(), 4); @@ -171,6 +453,9 @@ bool FDirectiveUtilArrayFunctionLibraryTest::RunTest(const FString& Parameters) TestEqual("Slice should clamp a negative start index to 0", SliceOut, TArray({10, 20})); UDirectiveUtilArrayFunctionLibrary::GenericArray_Slice(&TestObject->TestArray, ArrayProperty, 0, 0, &SliceOut, ArrayProperty); TestEqual("Slice with Count 0 should be empty", SliceOut.Num(), 0); + TestObject->TestArray = {10, 20, 30, 40}; + UDirectiveUtilArrayFunctionLibrary::GenericArray_Slice(&TestObject->TestArray, ArrayProperty, 1, 2, &TestObject->TestArray, ArrayProperty); + TestEqual("Slice should support using the source array as its output", TestObject->TestArray, TArray({20, 30})); TestObject->TestArray = {1, 2, 3, 4, 5}; UDirectiveUtilArrayFunctionLibrary::GenericArray_Rotate(&TestObject->TestArray, ArrayProperty, 2); @@ -187,6 +472,22 @@ bool FDirectiveUtilArrayFunctionLibraryTest::RunTest(const FString& Parameters) UDirectiveUtilArrayFunctionLibrary::GenericArray_GetDistinct(&TestObject->TestArray, ArrayProperty, &DistinctOut, ArrayProperty); TestEqual("GetDistinct should keep first occurrences in order", DistinctOut, TArray({1, 2, 3, 4})); TestEqual("GetDistinct should not modify the source array", TestObject->TestArray.Num(), 6); + TestObject->TestArray = {1, 2, 2, 3, 1}; + UDirectiveUtilArrayFunctionLibrary::GenericArray_GetDistinct(&TestObject->TestArray, ArrayProperty, &TestObject->TestArray, ArrayProperty); + TestEqual("GetDistinct should support using the source array as its output", TestObject->TestArray, TArray({1, 2, 3})); + + FArrayProperty* DistinctStringArrayProperty = FindFProperty(UDirectiveUtilTestObject::StaticClass(), GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestStringArray)); + TestNotNull("TestStringArray property should be found", DistinctStringArrayProperty); + if (DistinctStringArrayProperty) + { + TestObject->TestStringArray = {TEXT("Alpha"), TEXT("Beta"), TEXT("Beta")}; + UDirectiveUtilArrayFunctionLibrary::GenericArray_GetDistinct( + &TestObject->TestStringArray, + DistinctStringArrayProperty, + &TestObject->TestStringArray, + DistinctStringArrayProperty); + TestEqual("GetDistinct should preserve in-place string values", TestObject->TestStringArray, TArray({TEXT("Alpha"), TEXT("Beta")})); + } TestObject->TestArray = {5, 1, 5, 2, 5, 3}; int32 ItemToCount = 5; @@ -207,5 +508,1011 @@ bool FDirectiveUtilArrayFunctionLibraryTest::RunTest(const FString& Parameters) TestFalse("GetMostCommon should fail on an empty array", UDirectiveUtilArrayFunctionLibrary::GenericArray_GetMostCommon(&TestObject->TestArray, ArrayProperty, &EmptyMostItem, &EmptyMostCount)); TestEqual("GetMostCommon should reset the count on an empty array", EmptyMostCount, 0); + const TArray MutatingFunctions = { + GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_RemoveDuplicates), + GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_AppendOptimized), + GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_InsertOptimized), + GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_Pop), + GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_PopFirst), + GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_RemoveAtSwap), + GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_RemoveAtIndices), + GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_RemoveAllOccurrences), + GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_Rotate) + }; + for (const FName FunctionName : MutatingFunctions) + { + const UFunction* Function = UDirectiveUtilArrayFunctionLibrary::StaticClass()->FindFunctionByName(FunctionName); + const FArrayProperty* TargetArrayProperty = Function ? FindFProperty(Function, TEXT("TargetArray")) : nullptr; + TestNotNull(*FString::Printf(TEXT("%s should expose a TargetArray parameter"), *FunctionName.ToString()), TargetArrayProperty); + if (TargetArrayProperty) + { + TestFalse(*FString::Printf(TEXT("%s should expose TargetArray as mutable"), *FunctionName.ToString()), TargetArrayProperty->HasAnyPropertyFlags(CPF_ConstParm)); + } +#if WITH_EDITOR + if (Function) + { + TestFalse( + *FString::Printf(TEXT("%s should not advertise inert Blueprint thread safety"), *FunctionName.ToString()), + Function->HasMetaData(TEXT("BlueprintThreadSafe"))); + } +#endif + } + + TArray ParallelResults; + ParallelResults.SetNumZeroed(64); + ParallelFor(ParallelResults.Num(), [&ParallelResults, ArrayProperty](const int32 TaskIndex) + { + const int32 RemovedValue = TaskIndex + 1000; + TArray Values = {RemovedValue, 7, RemovedValue, 11}; + const TArray Appended = {13, RemovedValue}; + UDirectiveUtilArrayFunctionLibrary::GenericArray_AppendOptimized( + &Values, ArrayProperty, &Appended, ArrayProperty); + const bool bRemoved = UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveAllOccurrences( + &Values, ArrayProperty, &RemovedValue); + ParallelResults[TaskIndex] = bRemoved && Values == TArray({7, 11, 13}) ? 1 : 0; + }); + TestTrue("Independent array operations should remain correct on worker tasks", ParallelResults.Find(0) == INDEX_NONE); + + TestObject->TestArray = {10, 20, 30, 40, 50}; + TArray SampledValues; + FRandomStream FirstStream(1337); + UDirectiveUtilArrayFunctionLibrary::GenericArray_Sample(&TestObject->TestArray, ArrayProperty, 3, false, &FirstStream, &SampledValues, ArrayProperty); + TestEqual("Sample without replacement should return the requested count", SampledValues.Num(), 3); + TestEqual("Sample without replacement should contain unique values", TSet(SampledValues).Num(), 3); + + TArray RepeatedSample; + FRandomStream SecondStream(1337); + UDirectiveUtilArrayFunctionLibrary::GenericArray_Sample(&TestObject->TestArray, ArrayProperty, 3, false, &SecondStream, &RepeatedSample, ArrayProperty); + TestEqual("Sample from stream should be deterministic", RepeatedSample, SampledValues); + + FRandomStream ClampedStream(42); + UDirectiveUtilArrayFunctionLibrary::GenericArray_Sample(&TestObject->TestArray, ArrayProperty, 20, false, &ClampedStream, &SampledValues, ArrayProperty); + TestEqual("Sample without replacement should clamp to the source length", SampledValues.Num(), TestObject->TestArray.Num()); + + FRandomStream ReplacementStream(7); + UDirectiveUtilArrayFunctionLibrary::GenericArray_Sample(&TestObject->TestArray, ArrayProperty, 20, true, &ReplacementStream, &SampledValues, ArrayProperty); + TestEqual("Sample with replacement should return the requested count", SampledValues.Num(), 20); + + TestObject->TestArray = {99}; + FRandomStream SingleValueStream(7); + UDirectiveUtilArrayFunctionLibrary::GenericArray_Sample(&TestObject->TestArray, ArrayProperty, 4, true, &SingleValueStream, &SampledValues, ArrayProperty); + TestEqual("Sample with replacement should repeat the only available value", SampledValues, TArray({99, 99, 99, 99})); + + TestObject->TestArray.Empty(); + UDirectiveUtilArrayFunctionLibrary::GenericArray_Sample(&TestObject->TestArray, ArrayProperty, 4, false, nullptr, &SampledValues, ArrayProperty); + TestTrue("Sampling an empty array should return an empty array", SampledValues.IsEmpty()); + + TestObject->TestArray = {10, 20, 30, 40, 50}; + UDirectiveUtilArrayFunctionLibrary::GenericArray_Sample(&TestObject->TestArray, ArrayProperty, 0, false, nullptr, &SampledValues, ArrayProperty); + TestTrue("Sampling zero values should return an empty array", SampledValues.IsEmpty()); + UDirectiveUtilArrayFunctionLibrary::GenericArray_Sample(&TestObject->TestArray, ArrayProperty, 1'000'001, true, nullptr, &SampledValues, ArrayProperty); + TestTrue("Sampling should reject an unsafe output count", SampledValues.IsEmpty()); + + const TArray DeterministicWeights = {1.0f, 2.0f, 3.0f, 4.0f, 5.0f}; + TArray WeightedSample; + TArray RepeatedWeightedSample; + FRandomStream FirstWeightedStream(90210); + FRandomStream SecondWeightedStream(90210); + TestTrue( + "Weighted sampling should accept matching inputs", + UDirectiveUtilArrayFunctionLibrary::GenericArray_SampleWeighted( + &TestObject->TestArray, + ArrayProperty, + DeterministicWeights, + 4, + true, + &FirstWeightedStream, + &WeightedSample, + ArrayProperty)); + TestTrue( + "Weighted sampling from a stream should accept matching inputs", + UDirectiveUtilArrayFunctionLibrary::GenericArray_SampleWeighted( + &TestObject->TestArray, + ArrayProperty, + DeterministicWeights, + 4, + true, + &SecondWeightedStream, + &RepeatedWeightedSample, + ArrayProperty)); + TestEqual("Weighted sampling from a stream should be deterministic", RepeatedWeightedSample, WeightedSample); + TestFalse( + "Weighted sampling should reject an unsafe output count", + UDirectiveUtilArrayFunctionLibrary::GenericArray_SampleWeighted( + &TestObject->TestArray, + ArrayProperty, + DeterministicWeights, + 1'000'001, + true, + nullptr, + &WeightedSample, + ArrayProperty)); + TestTrue("Rejected weighted sampling should clear the output", WeightedSample.IsEmpty()); + + FRandomStream FirstUniqueWeightedStream(31415); + FRandomStream SecondUniqueWeightedStream(31415); + TestTrue( + "Weighted sampling without replacement should accept matching inputs", + UDirectiveUtilArrayFunctionLibrary::GenericArray_SampleWeighted( + &TestObject->TestArray, + ArrayProperty, + DeterministicWeights, + 5, + false, + &FirstUniqueWeightedStream, + &WeightedSample, + ArrayProperty)); + TestTrue( + "Repeated weighted sampling without replacement should accept matching inputs", + UDirectiveUtilArrayFunctionLibrary::GenericArray_SampleWeighted( + &TestObject->TestArray, + ArrayProperty, + DeterministicWeights, + 5, + false, + &SecondUniqueWeightedStream, + &RepeatedWeightedSample, + ArrayProperty)); + TestEqual("Weighted sampling without replacement should be deterministic", RepeatedWeightedSample, WeightedSample); + TestEqual("Weighted sampling without replacement should select each available value once", TSet(WeightedSample).Num(), 5); + + FRandomStream UnchangedWeightedStream(8675309); + const int32 InitialWeightedSeed = UnchangedWeightedStream.GetCurrentSeed(); + TestTrue( + "A zero weighted sample should succeed", + UDirectiveUtilArrayFunctionLibrary::GenericArray_SampleWeighted( + &TestObject->TestArray, + ArrayProperty, + DeterministicWeights, + 0, + false, + &UnchangedWeightedStream, + &WeightedSample, + ArrayProperty)); + TestEqual("A zero weighted sample should not advance its random stream", UnchangedWeightedStream.GetCurrentSeed(), InitialWeightedSeed); + + TestObject->TestArray = {0, 1}; + FRandomStream DistributionStream(1187); + TestTrue( + "Weighted sampling should accept proportional weights", + UDirectiveUtilArrayFunctionLibrary::GenericArray_SampleWeighted( + &TestObject->TestArray, + ArrayProperty, + TArray({1.0f, 9.0f}), + 10000, + true, + &DistributionStream, + &WeightedSample, + ArrayProperty)); + int32 HeavySelectionCount = 0; + for (const int32 Value : WeightedSample) + { + HeavySelectionCount += Value == 1 ? 1 : 0; + } + TestTrue("Weighted sampling should follow the supplied proportions", HeavySelectionCount > 8500 && HeavySelectionCount < 9500); + + TestObject->TestArray = {10, 20, 30, 40, 50}; + const TArray SparseWeights = { + 0.0f, + 1.0f, + -1.0f, + std::numeric_limits::quiet_NaN(), + std::numeric_limits::infinity() + }; + FRandomStream SparseStream(17); + TestTrue( + "Weighted sampling should ignore non-positive and non-finite weights", + UDirectiveUtilArrayFunctionLibrary::GenericArray_SampleWeighted( + &TestObject->TestArray, + ArrayProperty, + SparseWeights, + 8, + true, + &SparseStream, + &WeightedSample, + ArrayProperty)); + TestEqual("Weighted sampling should select the only positive-weight value", WeightedSample, TArray({20, 20, 20, 20, 20, 20, 20, 20})); + + const TArray TwoPositiveWeights = {1.0f, 0.0f, 2.0f, 0.0f, 0.0f}; + FRandomStream UniqueWeightedStream(44); + TestTrue( + "Weighted sampling without replacement should succeed", + UDirectiveUtilArrayFunctionLibrary::GenericArray_SampleWeighted( + &TestObject->TestArray, + ArrayProperty, + TwoPositiveWeights, + 8, + false, + &UniqueWeightedStream, + &WeightedSample, + ArrayProperty)); + TestEqual("Weighted sampling without replacement should clamp to positive-weight entries", WeightedSample.Num(), 2); + TestEqual("Weighted sampling without replacement should not repeat source indices", TSet(WeightedSample).Num(), 2); + TestTrue("Weighted sampling without replacement should include the first weighted value", WeightedSample.Contains(10)); + TestTrue("Weighted sampling without replacement should include the second weighted value", WeightedSample.Contains(30)); + + WeightedSample = {999}; + TestFalse( + "Weighted sampling should reject a mismatched weights array", + UDirectiveUtilArrayFunctionLibrary::GenericArray_SampleWeighted( + &TestObject->TestArray, + ArrayProperty, + TArray({1.0f}), + 1, + false, + nullptr, + &WeightedSample, + ArrayProperty)); + TestTrue("Weighted sampling should clear output after a weights mismatch", WeightedSample.IsEmpty()); + + TestFalse( + "Weighted sampling should reject a negative count", + UDirectiveUtilArrayFunctionLibrary::GenericArray_SampleWeighted( + &TestObject->TestArray, + ArrayProperty, + TwoPositiveWeights, + -1, + false, + nullptr, + &WeightedSample, + ArrayProperty)); + TestTrue("Weighted sampling should clear output after a negative count", WeightedSample.IsEmpty()); + + TestTrue( + "Weighted sampling should accept a zero count", + UDirectiveUtilArrayFunctionLibrary::GenericArray_SampleWeighted( + &TestObject->TestArray, + ArrayProperty, + TwoPositiveWeights, + 0, + false, + nullptr, + &WeightedSample, + ArrayProperty)); + TestTrue("Weighted sampling should return an empty zero-count result", WeightedSample.IsEmpty()); + + const TArray EmptyWeights = {0.0f, 0.0f, 0.0f, 0.0f, 0.0f}; + TestFalse( + "Weighted sampling should reject a positive count when all weights are zero", + UDirectiveUtilArrayFunctionLibrary::GenericArray_SampleWeighted( + &TestObject->TestArray, + ArrayProperty, + EmptyWeights, + 1, + false, + nullptr, + &WeightedSample, + ArrayProperty)); + TestTrue("Weighted sampling should clear output when no selectable entries exist", WeightedSample.IsEmpty()); + + TestObject->TestArray.Empty(); + TestTrue( + "Weighted sampling should accept an empty source for a zero count", + UDirectiveUtilArrayFunctionLibrary::GenericArray_SampleWeighted( + &TestObject->TestArray, + ArrayProperty, + TArray(), + 0, + false, + nullptr, + &WeightedSample, + ArrayProperty)); + TestFalse( + "Weighted sampling should reject a positive count for an empty source", + UDirectiveUtilArrayFunctionLibrary::GenericArray_SampleWeighted( + &TestObject->TestArray, + ArrayProperty, + TArray(), + 1, + false, + nullptr, + &WeightedSample, + ArrayProperty)); + TestTrue("A failed empty weighted sample should leave an empty output", WeightedSample.IsEmpty()); + + TestObject->TestArray = {10, 20, 30, 40}; + FRandomStream LeadingZeroStream(101); + TestTrue( + "Weighted sampling should handle leading zero weights", + UDirectiveUtilArrayFunctionLibrary::GenericArray_SampleWeighted( + &TestObject->TestArray, + ArrayProperty, + TArray({0.0f, 0.0f, 0.0f, 1.0f}), + 4, + true, + &LeadingZeroStream, + &WeightedSample, + ArrayProperty)); + TestEqual("Leading zero weights should not select an unavailable value", WeightedSample, TArray({40, 40, 40, 40})); + + FRandomStream ExtremeWeightStream(2026); + TestTrue( + "Weighted sampling should handle maximum finite weights", + UDirectiveUtilArrayFunctionLibrary::GenericArray_SampleWeighted( + &TestObject->TestArray, + ArrayProperty, + TArray({std::numeric_limits::max(), std::numeric_limits::max(), 0.0f, 0.0f}), + 2, + false, + &ExtremeWeightStream, + &WeightedSample, + ArrayProperty)); + TestEqual("Maximum finite weights should return both selectable values", WeightedSample.Num(), 2); + TestTrue("Maximum finite weights should include the first value", WeightedSample.Contains(10)); + TestTrue("Maximum finite weights should include the second value", WeightedSample.Contains(20)); + + TestObject->TestArray = {10, 20, 30}; + FRandomStream MixedRangeWeightStream(2027); + TestTrue( + "Weighted sampling should handle mixed finite weight ranges", + UDirectiveUtilArrayFunctionLibrary::GenericArray_SampleWeighted( + &TestObject->TestArray, + ArrayProperty, + TArray({std::numeric_limits::max(), 1.0f, 2.0f}), + 3, + false, + &MixedRangeWeightStream, + &WeightedSample, + ArrayProperty)); + TestEqual("Mixed finite weights should return every selectable value", WeightedSample.Num(), 3); + TestEqual("Mixed finite weights should not repeat values", TSet(WeightedSample).Num(), 3); + TestTrue("Mixed finite weights should include the first value", WeightedSample.Contains(10)); + TestTrue("Mixed finite weights should include the second value", WeightedSample.Contains(20)); + TestTrue("Mixed finite weights should include the third value", WeightedSample.Contains(30)); + + TestObject->TestArray = {1, 2, 3, 4}; + FRandomStream AliasedWeightedStream(81); + TestTrue( + "Weighted sampling should support the source array as output", + UDirectiveUtilArrayFunctionLibrary::GenericArray_SampleWeighted( + &TestObject->TestArray, + ArrayProperty, + TArray({1.0f, 1.0f, 1.0f, 1.0f}), + 2, + false, + &AliasedWeightedStream, + &TestObject->TestArray, + ArrayProperty)); + TestEqual("Aliased weighted sampling should return the requested count", TestObject->TestArray.Num(), 2); + + TestObject->TestArray = {10, 20, 30, 40, 50}; + TArray Page; + int32 PageCount = 0; + TestTrue("GetPage should return an available page", UDirectiveUtilArrayFunctionLibrary::GenericArray_GetPage(&TestObject->TestArray, ArrayProperty, 1, 2, &Page, ArrayProperty, &PageCount)); + TestEqual("GetPage should return the second page", Page, TArray({30, 40})); + TestEqual("GetPage should return the total page count", PageCount, 3); + TestFalse("GetPage should reject a negative page", UDirectiveUtilArrayFunctionLibrary::GenericArray_GetPage(&TestObject->TestArray, ArrayProperty, -1, 2, &Page, ArrayProperty, &PageCount)); + TestTrue("GetPage should clear output for a negative page", Page.IsEmpty()); + TestFalse("GetPage should reject a non-positive page size", UDirectiveUtilArrayFunctionLibrary::GenericArray_GetPage(&TestObject->TestArray, ArrayProperty, 0, 0, &Page, ArrayProperty, &PageCount)); + TestEqual("GetPage should report zero pages for an invalid page size", PageCount, 0); + + TestObject->TestArray = {1, 2, 3, 4}; + FRandomStream InPlaceStream(11); + UDirectiveUtilArrayFunctionLibrary::GenericArray_Sample(&TestObject->TestArray, ArrayProperty, 2, false, &InPlaceStream, &TestObject->TestArray, ArrayProperty); + TestEqual("Sample should support the same source and output array", TestObject->TestArray.Num(), 2); + TestObject->TestArray = {1, 2, 3, 4}; + TestTrue("GetPage should support the same source and output array", UDirectiveUtilArrayFunctionLibrary::GenericArray_GetPage(&TestObject->TestArray, ArrayProperty, 1, 2, &TestObject->TestArray, ArrayProperty, &PageCount)); + TestEqual("In-place GetPage should return the requested values", TestObject->TestArray, TArray({3, 4})); + + TArray NaturalStrings = {TEXT("Item10"), TEXT("Item2"), TEXT("Item1"), TEXT("アイテム2")}; + UDirectiveUtilArrayFunctionLibrary::NaturalSortStringArray(NaturalStrings); + TestEqual("Natural string sort should compare embedded numbers", NaturalStrings[0], FString(TEXT("Item1"))); + TestEqual("Natural string sort should place Item2 before Item10", NaturalStrings[1], FString(TEXT("Item2"))); + TestEqual("Natural string sort should place Item10 after Item2", NaturalStrings[2], FString(TEXT("Item10"))); + TestEqual("Natural string sort should preserve Unicode strings", NaturalStrings[3], FString(TEXT("アイテム2"))); + UDirectiveUtilArrayFunctionLibrary::NaturalSortStringArray(NaturalStrings, true); + TestEqual("Natural string sort should support descending order", NaturalStrings.Last(), FString(TEXT("Item1"))); + + TArray NaturalNames = {FName(TEXT("Actor12")), FName(TEXT("Actor3")), FName(TEXT("Actor1"))}; + UDirectiveUtilArrayFunctionLibrary::NaturalSortNameArray(NaturalNames); + TestEqual("Natural name sort should compare embedded numbers", NaturalNames, TArray({FName(TEXT("Actor1")), FName(TEXT("Actor3")), FName(TEXT("Actor12"))})); + + TestObject->TestArray = {1, 2, 3, 4, 5}; + UDirectiveUtilArrayFunctionLibrary::GenericArray_Slice(&TestObject->TestArray, ArrayProperty, 1, 3, &TestObject->TestArray, ArrayProperty); + TestEqual("Slice should support the same source and output array", TestObject->TestArray, TArray({2, 3, 4})); + + TestObject->TestArray = {3, 1, 3, 2, 1}; + UDirectiveUtilArrayFunctionLibrary::GenericArray_GetDistinct(&TestObject->TestArray, ArrayProperty, &TestObject->TestArray, ArrayProperty); + TestEqual("GetDistinct should support the same source and output array", TestObject->TestArray, TArray({3, 1, 2})); + + FArrayProperty* StringArrayProperty = FindFProperty( + UDirectiveUtilTestObject::StaticClass(), + GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestStringArray)); + FArrayProperty* TextArrayProperty = FindFProperty( + UDirectiveUtilTestObject::StaticClass(), + GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestTextArray)); + FArrayProperty* BoolArrayProperty = FindFProperty( + UDirectiveUtilTestObject::StaticClass(), + GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestBoolArray)); + FArrayProperty* CollisionArrayProperty = FindFProperty( + UDirectiveUtilTestObject::StaticClass(), + GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestCollisionArray)); + FArrayProperty* PodArrayProperty = FindFProperty( + UDirectiveUtilTestObject::StaticClass(), + GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestPodArray)); + FArrayProperty* ObjectArrayProperty = FindFProperty( + UDirectiveUtilTestObject::StaticClass(), + GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestObjectArray)); + TestNotNull("String array property should be available", StringArrayProperty); + TestNotNull("Text array property should be available", TextArrayProperty); + TestNotNull("Boolean array property should be available", BoolArrayProperty); + TestNotNull("Collision array property should be available", CollisionArrayProperty); + TestNotNull("POD struct array property should be available", PodArrayProperty); + TestNotNull("Object array property should be available", ObjectArrayProperty); + + TestFalse("String arrays should use the property-aware append path", StringArrayProperty->Inner->HasAnyPropertyFlags(CPF_IsPlainOldData)); + const TArray StringAppendSource = {TEXT("Three"), TEXT("Four")}; + TestObject->TestStringArray = {TEXT("One"), TEXT("Two")}; + UDirectiveUtilArrayFunctionLibrary::GenericArray_AppendOptimized( + &TestObject->TestStringArray, + StringArrayProperty, + &StringAppendSource, + StringArrayProperty); + TestEqual( + "AppendOptimized should preserve non-trivial values", + TestObject->TestStringArray, + TArray({TEXT("One"), TEXT("Two"), TEXT("Three"), TEXT("Four")})); + + TestObject->TestStringArray = {TEXT("Alpha"), TEXT("Beta")}; + UDirectiveUtilArrayFunctionLibrary::GenericArray_AppendOptimized( + &TestObject->TestStringArray, + StringArrayProperty, + &TestObject->TestStringArray, + StringArrayProperty); + TestEqual( + "AppendOptimized should support self-appending non-trivial values", + TestObject->TestStringArray, + TArray({TEXT("Alpha"), TEXT("Beta"), TEXT("Alpha"), TEXT("Beta")})); + + const TArray StringInsertSource = {TEXT("Two"), TEXT("Three")}; + TestObject->TestStringArray = {TEXT("One"), TEXT("Four")}; + TestTrue( + "InsertOptimized should support non-trivial values", + UDirectiveUtilArrayFunctionLibrary::GenericArray_InsertOptimized( + &TestObject->TestStringArray, + StringArrayProperty, + &StringInsertSource, + StringArrayProperty, + 1)); + TestEqual( + "InsertOptimized should preserve non-trivial value order", + TestObject->TestStringArray, + TArray({TEXT("One"), TEXT("Two"), TEXT("Three"), TEXT("Four")})); + + TestObject->TestStringArray = {TEXT("Alpha"), TEXT("Beta"), TEXT("Gamma")}; + TestTrue( + "InsertOptimized should self-insert non-trivial values", + UDirectiveUtilArrayFunctionLibrary::GenericArray_InsertOptimized( + &TestObject->TestStringArray, + StringArrayProperty, + &TestObject->TestStringArray, + StringArrayProperty, + 2)); + TestEqual( + "InsertOptimized should preserve self-inserted non-trivial values", + TestObject->TestStringArray, + TArray({ + TEXT("Alpha"), + TEXT("Beta"), + TEXT("Alpha"), + TEXT("Beta"), + TEXT("Gamma"), + TEXT("Gamma") + })); + + TestObject->TestStringArray = { + TEXT("Zero"), + TEXT("One"), + TEXT("Two"), + TEXT("Three"), + TEXT("Four") + }; + TestEqual( + "RemoveAtIndices should remove non-trivial values", + UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveAtIndices( + &TestObject->TestStringArray, + StringArrayProperty, + TArray({3, 1, 3})), + 2); + TestEqual( + "RemoveAtIndices should preserve non-trivial survivor order", + TestObject->TestStringArray, + TArray({TEXT("Zero"), TEXT("Two"), TEXT("Four")})); + + const TArray BoolAppendSource = {false, true, false}; + TestObject->TestBoolArray = {true}; + UDirectiveUtilArrayFunctionLibrary::GenericArray_AppendOptimized( + &TestObject->TestBoolArray, + BoolArrayProperty, + &BoolAppendSource, + BoolArrayProperty); + TestEqual( + "AppendOptimized should preserve Boolean values", + TestObject->TestBoolArray, + TArray({true, false, true, false})); + + TestObject->TestBoolArray = {true, false, false}; + UDirectiveUtilArrayFunctionLibrary::GenericArray_AppendOptimized( + &TestObject->TestBoolArray, + BoolArrayProperty, + &TestObject->TestBoolArray, + BoolArrayProperty); + TestEqual( + "AppendOptimized should support self-appending Boolean values", + TestObject->TestBoolArray, + TArray({true, false, false, true, false, false})); + + auto MakePodValue = [](const int32 Index, const float Weight) + { + FDirectiveUtilPodValue Value; + Value.Index = Index; + Value.Weight = Weight; + return Value; + }; + TestTrue("POD struct arrays should use the bulk append path", PodArrayProperty->Inner->HasAnyPropertyFlags(CPF_IsPlainOldData)); + const TArray PodAppendSource = { + MakePodValue(2, 2.5f), + MakePodValue(3, 3.5f) + }; + TestObject->TestPodArray = {MakePodValue(1, 1.5f)}; + UDirectiveUtilArrayFunctionLibrary::GenericArray_AppendOptimized( + &TestObject->TestPodArray, + PodArrayProperty, + &PodAppendSource, + PodArrayProperty); + TestEqual("AppendOptimized should append POD structs", TestObject->TestPodArray.Num(), 3); + for (int32 Index = 0; Index < TestObject->TestPodArray.Num(); ++Index) + { + TestEqual("AppendOptimized should preserve POD struct indices", TestObject->TestPodArray[Index].Index, Index + 1); + TestEqual("AppendOptimized should preserve POD struct weights", TestObject->TestPodArray[Index].Weight, static_cast(Index) + 1.5f); + } + TestEqual("AppendOptimized should not modify the POD struct source", PodAppendSource.Num(), 2); + if (PodAppendSource.Num() == 2) + { + TestEqual("AppendOptimized should preserve the first POD source index", PodAppendSource[0].Index, 2); + TestEqual("AppendOptimized should preserve the second POD source index", PodAppendSource[1].Index, 3); + } + + TestObject->TestPodArray = {MakePodValue(4, 4.5f), MakePodValue(5, 5.5f)}; + UDirectiveUtilArrayFunctionLibrary::GenericArray_AppendOptimized( + &TestObject->TestPodArray, + PodArrayProperty, + &TestObject->TestPodArray, + PodArrayProperty); + TestEqual("AppendOptimized should self-append POD structs", TestObject->TestPodArray.Num(), 4); + if (TestObject->TestPodArray.Num() == 4) + { + TestEqual("AppendOptimized should preserve the first self-appended POD struct", TestObject->TestPodArray[2].Index, 4); + TestEqual("AppendOptimized should preserve the second self-appended POD struct", TestObject->TestPodArray[3].Index, 5); + } + + TestFalse("POD struct grouping should use the reflected-value hash path", PodArrayProperty->Inner->HasAllPropertyFlags(CPF_HasGetValueTypeHash)); + TestObject->TestPodArray = { + MakePodValue(1, 1.5f), + MakePodValue(2, 2.5f), + MakePodValue(1, 1.5f), + MakePodValue(3, 3.5f), + MakePodValue(1, 1.5f) + }; + UDirectiveUtilTestObject* PodResultObject = NewObject(); + UDirectiveUtilArrayFunctionLibrary::GenericArray_GetDistinct( + &TestObject->TestPodArray, + PodArrayProperty, + &PodResultObject->TestPodArray, + PodArrayProperty); + TestEqual("GetDistinct should group unhashable POD structs", PodResultObject->TestPodArray.Num(), 3); + if (PodResultObject->TestPodArray.Num() == 3) + { + TestEqual("GetDistinct should retain the first POD value", PodResultObject->TestPodArray[0].Index, 1); + TestEqual("GetDistinct should retain the second POD value", PodResultObject->TestPodArray[1].Index, 2); + TestEqual("GetDistinct should retain the third POD value", PodResultObject->TestPodArray[2].Index, 3); + } + FDirectiveUtilPodValue MostCommonPod = MakePodValue(INDEX_NONE, 0.0f); + int32 MostCommonPodCount = 0; + TestTrue( + "GetMostCommon should group unhashable POD structs", + UDirectiveUtilArrayFunctionLibrary::GenericArray_GetMostCommon( + &TestObject->TestPodArray, + PodArrayProperty, + &MostCommonPod, + &MostCommonPodCount)); + TestEqual("GetMostCommon should return the repeated POD value", MostCommonPod.Index, 1); + TestEqual("GetMostCommon should report the POD value count", MostCommonPodCount, 3); + + TestObject->TestStringArray = {TEXT("Zero"), TEXT("Selected"), TEXT("Never")}; + TArray WeightedStrings; + FRandomStream WeightedStringStream(55); + TestTrue( + "Weighted sampling should preserve non-trivial array values", + UDirectiveUtilArrayFunctionLibrary::GenericArray_SampleWeighted( + &TestObject->TestStringArray, + StringArrayProperty, + TArray({0.0f, 1.0f, 0.0f}), + 3, + true, + &WeightedStringStream, + &WeightedStrings, + StringArrayProperty)); + TestEqual( + "Weighted sampling should copy the selected non-trivial value", + WeightedStrings, + TArray({TEXT("Selected"), TEXT("Selected"), TEXT("Selected")})); + + TestObject->TestStringArray = {TEXT("One"), TEXT("Two"), TEXT("Three"), TEXT("Four")}; + UDirectiveUtilArrayFunctionLibrary::GenericArray_Rotate(&TestObject->TestStringArray, StringArrayProperty, 2); + TestEqual( + "Rotate should preserve non-trivial values", + TestObject->TestStringArray, + TArray({TEXT("Three"), TEXT("Four"), TEXT("One"), TEXT("Two")})); + UDirectiveUtilArrayFunctionLibrary::GenericArray_Rotate(&TestObject->TestStringArray, StringArrayProperty, -2); + TestEqual( + "Rotate should support negative shifts for non-trivial values", + TestObject->TestStringArray, + TArray({TEXT("One"), TEXT("Two"), TEXT("Three"), TEXT("Four")})); + + UDirectiveUtilArrayFunctionLibrary::GenericArray_Slice( + &TestObject->TestStringArray, + StringArrayProperty, + 1, + 2, + &TestObject->TestStringArray, + StringArrayProperty); + TestEqual("Aliased Slice should preserve string values", TestObject->TestStringArray, TArray({TEXT("Two"), TEXT("Three")})); + + TestObject->TestStringArray = {TEXT("Remove"), TEXT("Keep A"), TEXT("Remove"), TEXT("Keep B")}; + const FString StringToRemove = TEXT("Remove"); + TestTrue( + "RemoveAllOccurrences should support strings", + UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveAllOccurrences( + &TestObject->TestStringArray, + StringArrayProperty, + &StringToRemove)); + TestEqual( + "RemoveAllOccurrences should preserve string survivor order", + TestObject->TestStringArray, + TArray({TEXT("Keep A"), TEXT("Keep B")})); + + UObject* FirstObject = NewObject(TestObject); + UObject* SecondObject = NewObject(TestObject); + UObject* ThirdObject = NewObject(TestObject); + const TArray> ObjectAppendSource = {SecondObject, ThirdObject}; + TestObject->TestObjectArray = {FirstObject}; + UDirectiveUtilArrayFunctionLibrary::GenericArray_AppendOptimized( + &TestObject->TestObjectArray, + ObjectArrayProperty, + &ObjectAppendSource, + ObjectArrayProperty); + TestEqual("AppendOptimized should preserve object references", TestObject->TestObjectArray.Num(), 3); + if (TestObject->TestObjectArray.Num() == 3) + { + TestEqual("AppendOptimized should retain the target object", TestObject->TestObjectArray[0].Get(), FirstObject); + TestEqual("AppendOptimized should append the first source object", TestObject->TestObjectArray[1].Get(), SecondObject); + TestEqual("AppendOptimized should append the second source object", TestObject->TestObjectArray[2].Get(), ThirdObject); + } + + TestObject->TestObjectArray = {FirstObject, SecondObject, FirstObject}; + UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveDuplicates(&TestObject->TestObjectArray, ObjectArrayProperty); + TestEqual("RemoveDuplicates should preserve object references", TestObject->TestObjectArray.Num(), 2); + if (TestObject->TestObjectArray.Num() == 2) + { + TestEqual("RemoveDuplicates should retain the first object", TestObject->TestObjectArray[0].Get(), FirstObject); + TestEqual("RemoveDuplicates should retain the second object", TestObject->TestObjectArray[1].Get(), SecondObject); + } + + TestObject->TestObjectArray = {FirstObject, SecondObject, ThirdObject}; + TestTrue("Aliased object GetPage should succeed", UDirectiveUtilArrayFunctionLibrary::GenericArray_GetPage( + &TestObject->TestObjectArray, + ObjectArrayProperty, + 1, + 2, + &TestObject->TestObjectArray, + ObjectArrayProperty, + &PageCount)); + TestEqual("Aliased object GetPage should preserve the selected reference", TestObject->TestObjectArray.Num(), 1); + if (TestObject->TestObjectArray.Num() == 1) + { + TestEqual("Aliased object GetPage should return the final object", TestObject->TestObjectArray[0].Get(), ThirdObject); + } + + TestObject->TestObjectArray = {FirstObject, nullptr, SecondObject, FirstObject, nullptr}; + TestTrue( + "RemoveAllOccurrences should support object references", + UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveAllOccurrences( + &TestObject->TestObjectArray, + ObjectArrayProperty, + &FirstObject)); + TestEqual("RemoveAllOccurrences should retain object and null survivors", TestObject->TestObjectArray.Num(), 3); + if (TestObject->TestObjectArray.Num() == 3) + { + TestNull("RemoveAllOccurrences should retain the first null", TestObject->TestObjectArray[0].Get()); + TestEqual("RemoveAllOccurrences should retain the unmatched object", TestObject->TestObjectArray[1].Get(), SecondObject); + TestNull("RemoveAllOccurrences should retain the second null", TestObject->TestObjectArray[2].Get()); + } + UObject* NullObject = nullptr; + TestTrue( + "RemoveAllOccurrences should remove null object references", + UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveAllOccurrences( + &TestObject->TestObjectArray, + ObjectArrayProperty, + &NullObject)); + TestEqual("RemoveAllOccurrences should leave the non-null object", TestObject->TestObjectArray.Num(), 1); + + TestObject->TestObjectArray = {FirstObject, nullptr, SecondObject, ThirdObject}; + TestEqual( + "RemoveAtIndices should preserve object references", + UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveAtIndices( + &TestObject->TestObjectArray, + ObjectArrayProperty, + TArray({1, 3})), + 2); + TestEqual("RemoveAtIndices should retain two object references", TestObject->TestObjectArray.Num(), 2); + if (TestObject->TestObjectArray.Num() == 2) + { + TestEqual("RemoveAtIndices should retain the first object", TestObject->TestObjectArray[0].Get(), FirstObject); + TestEqual("RemoveAtIndices should retain the second object", TestObject->TestObjectArray[1].Get(), SecondObject); + } + + TestObject->TestStringArray = {TEXT("unchanged")}; + UDirectiveUtilArrayFunctionLibrary::GenericArray_AppendOptimized( + &TestObject->TestStringArray, + StringArrayProperty, + &TestObject->TestArray, + ArrayProperty); + TestEqual( + "AppendOptimized should reject mismatched array types without changing the target", + TestObject->TestStringArray, + TArray({TEXT("unchanged")})); + + TestFalse( + "InsertOptimized should reject mismatched array types", + UDirectiveUtilArrayFunctionLibrary::GenericArray_InsertOptimized( + &TestObject->TestStringArray, + StringArrayProperty, + &TestObject->TestArray, + ArrayProperty, + 0)); + TestEqual( + "InsertOptimized should not change a target with a mismatched source type", + TestObject->TestStringArray, + TArray({TEXT("unchanged")})); + + UDirectiveUtilArrayFunctionLibrary::GenericArray_Slice( + &TestObject->TestArray, + ArrayProperty, + 0, + 1, + &TestObject->TestStringArray, + StringArrayProperty); + TestEqual("Slice should reject mismatched array types without changing output", TestObject->TestStringArray, TArray({TEXT("unchanged")})); + TestFalse( + "Weighted sampling should reject mismatched output types", + UDirectiveUtilArrayFunctionLibrary::GenericArray_SampleWeighted( + &TestObject->TestArray, + ArrayProperty, + TArray({1.0f, 1.0f, 1.0f}), + 1, + false, + nullptr, + &TestObject->TestStringArray, + StringArrayProperty)); + TestEqual("Weighted sampling should leave a mismatched output unchanged", TestObject->TestStringArray, TArray({TEXT("unchanged")})); + + const FText AlphaText = FText::FromString(TEXT("Alpha")); + const FText BetaText = FText::FromString(TEXT("Beta")); + TestObject->TestTextArray = {AlphaText, BetaText, AlphaText}; + UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveDuplicates(&TestObject->TestTextArray, TextArrayProperty); + TestEqual("RemoveDuplicates should support unhashable property types", TestObject->TestTextArray.Num(), 2); + if (TestObject->TestTextArray.Num() == 2) + { + TestTrue("RemoveDuplicates should retain the first unhashable value", TestObject->TestTextArray[0].IdenticalTo(AlphaText)); + TestTrue("RemoveDuplicates should retain the second unhashable value", TestObject->TestTextArray[1].IdenticalTo(BetaText)); + } + + TestObject->TestTextArray = {AlphaText, BetaText, AlphaText, BetaText}; + TestTrue( + "RemoveAllOccurrences should support text values", + UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveAllOccurrences( + &TestObject->TestTextArray, + TextArrayProperty, + &AlphaText)); + TestEqual("RemoveAllOccurrences should remove matching text values", TestObject->TestTextArray.Num(), 2); + if (TestObject->TestTextArray.Num() == 2) + { + TestTrue("RemoveAllOccurrences should retain the first text survivor", TestObject->TestTextArray[0].IdenticalTo(BetaText)); + TestTrue("RemoveAllOccurrences should retain the second text survivor", TestObject->TestTextArray[1].IdenticalTo(BetaText)); + } + + TestObject->TestBoolArray = {true, false, true, false, true}; + const bool bBoolToRemove = true; + TestTrue( + "RemoveAllOccurrences should support Boolean values", + UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveAllOccurrences( + &TestObject->TestBoolArray, + BoolArrayProperty, + &bBoolToRemove)); + TestEqual("RemoveAllOccurrences should remove matching Boolean values", TestObject->TestBoolArray, TArray({false, false})); + + auto AddCollisionValue = [&TestObject](const int32 Value) + { + FDirectiveUtilCollisionValue& Entry = TestObject->TestCollisionArray.AddDefaulted_GetRef(); + Entry.Value = Value; + }; + TestObject->TestCollisionArray.Reset(); + for (const int32 Value : {1, 2, 1, 3, 2}) + { + AddCollisionValue(Value); + } + TestTrue("Collision test property should expose a value hash", CollisionArrayProperty->Inner->HasAllPropertyFlags(CPF_HasGetValueTypeHash)); + UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveDuplicates(&TestObject->TestCollisionArray, CollisionArrayProperty); + TestEqual("RemoveDuplicates should resolve hash collisions", TestObject->TestCollisionArray.Num(), 3); + if (TestObject->TestCollisionArray.Num() == 3) + { + TestEqual("Hash collision result should retain the first value", TestObject->TestCollisionArray[0].Value, 1); + TestEqual("Hash collision result should retain the second value", TestObject->TestCollisionArray[1].Value, 2); + TestEqual("Hash collision result should retain the third value", TestObject->TestCollisionArray[2].Value, 3); + } + + TestObject->TestCollisionArray.Reset(); + for (const int32 Value : {1, 2, 1, 3, 1, 4}) + { + AddCollisionValue(Value); + } + FDirectiveUtilCollisionValue CollisionToRemove; + CollisionToRemove.Value = 1; + TestTrue( + "RemoveAllOccurrences should support struct equality", + UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveAllOccurrences( + &TestObject->TestCollisionArray, + CollisionArrayProperty, + &CollisionToRemove)); + TestEqual("RemoveAllOccurrences should keep struct survivors", TestObject->TestCollisionArray.Num(), 3); + if (TestObject->TestCollisionArray.Num() == 3) + { + TestEqual("RemoveAllOccurrences should retain the first struct survivor", TestObject->TestCollisionArray[0].Value, 2); + TestEqual("RemoveAllOccurrences should retain the second struct survivor", TestObject->TestCollisionArray[1].Value, 3); + TestEqual("RemoveAllOccurrences should retain the third struct survivor", TestObject->TestCollisionArray[2].Value, 4); + } + + TestObject->TestCollisionArray.Reset(); + for (const int32 Value : {4, 5, 5, 4}) + { + AddCollisionValue(Value); + } + FDirectiveUtilCollisionValue MostCommonCollision; + MostCommonCount = 0; + TestTrue("GetMostCommon should resolve hash collisions", UDirectiveUtilArrayFunctionLibrary::GenericArray_GetMostCommon( + &TestObject->TestCollisionArray, + CollisionArrayProperty, + &MostCommonCollision, + &MostCommonCount)); + TestEqual("GetMostCommon should use first occurrence to break collision ties", MostCommonCollision.Value, 4); + TestEqual("GetMostCommon should report the collision value count", MostCommonCount, 2); + + TestObject->TestArray.SetNumUninitialized(100); + for (int32 Index = 0; Index < TestObject->TestArray.Num(); ++Index) + { + TestObject->TestArray[Index] = Index; + } + for (const int32 Count : {10, 75}) + { + FRandomStream CompatibilityStream(9917); + UDirectiveUtilArrayFunctionLibrary::GenericArray_Sample( + &TestObject->TestArray, + ArrayProperty, + Count, + false, + &CompatibilityStream, + &SampledValues, + ArrayProperty); + TestEqual( + FString::Printf(TEXT("Sampling %d values should match dense Fisher-Yates"), Count), + SampledValues, + BuildReferenceSample(TestObject->TestArray.Num(), Count, 9917)); + } + + TArray SelectionCounts; + SelectionCounts.Init(0, 5); + TestObject->TestArray = {0, 1, 2, 3, 4}; + FRandomStream UniformityStream(77123); + for (int32 Iteration = 0; Iteration < 5000; ++Iteration) + { + UDirectiveUtilArrayFunctionLibrary::GenericArray_Sample( + &TestObject->TestArray, + ArrayProperty, + 1, + false, + &UniformityStream, + &SampledValues, + ArrayProperty); + if (SampledValues.Num() == 1 && SelectionCounts.IsValidIndex(SampledValues[0])) + { + ++SelectionCounts[SampledValues[0]]; + } + } + for (int32 Value = 0; Value < SelectionCounts.Num(); ++Value) + { + TestTrue( + FString::Printf(TEXT("Sampling frequency for value %d should remain within tolerance"), Value), + FMath::Abs(SelectionCounts[Value] - 1000) <= 150); + } + + FRandomStream FuzzStream(18181); + for (int32 Iteration = 0; Iteration < 100; ++Iteration) + { + TArray SourceValues; + const int32 ValueCount = FuzzStream.RandRange(0, 128); + SourceValues.Reserve(ValueCount); + for (int32 Index = 0; Index < ValueCount; ++Index) + { + SourceValues.Add(FuzzStream.RandRange(-12, 12)); + } + const TArray ExpectedDistinct = BuildReferenceDistinct(SourceValues); + TestObject->TestArray = SourceValues; + UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveDuplicates(&TestObject->TestArray, ArrayProperty); + TestEqual(FString::Printf(TEXT("RemoveDuplicates fuzz case %d"), Iteration), TestObject->TestArray, ExpectedDistinct); + TestObject->TestArray = SourceValues; + UDirectiveUtilArrayFunctionLibrary::GenericArray_GetDistinct(&TestObject->TestArray, ArrayProperty, &DistinctOut, ArrayProperty); + TestEqual(FString::Printf(TEXT("GetDistinct fuzz case %d"), Iteration), DistinctOut, ExpectedDistinct); + + TArray InsertValues; + const int32 InsertCount = FuzzStream.RandRange(0, 32); + InsertValues.Reserve(InsertCount); + for (int32 Index = 0; Index < InsertCount; ++Index) + { + InsertValues.Add(FuzzStream.RandRange(-20, 20)); + } + const int32 InsertIndex = FuzzStream.RandRange(0, SourceValues.Num()); + TArray ExpectedInsert = SourceValues; + ExpectedInsert.Insert(InsertValues, InsertIndex); + TestObject->TestArray = SourceValues; + TestEqual( + FString::Printf(TEXT("InsertOptimized fuzz result %d"), Iteration), + UDirectiveUtilArrayFunctionLibrary::GenericArray_InsertOptimized( + &TestObject->TestArray, + ArrayProperty, + &InsertValues, + ArrayProperty, + InsertIndex), + !InsertValues.IsEmpty()); + TestEqual( + FString::Printf(TEXT("InsertOptimized fuzz values %d"), Iteration), + TestObject->TestArray, + ExpectedInsert); + + TArray RemovalIndices; + const int32 RemovalCount = FuzzStream.RandRange(0, 48); + RemovalIndices.Reserve(RemovalCount); + for (int32 Index = 0; Index < RemovalCount; ++Index) + { + RemovalIndices.Add(FuzzStream.RandRange(-8, SourceValues.Num() + 8)); + } + const TArray ExpectedRemoval = BuildReferenceRemoval(SourceValues, RemovalIndices); + TestObject->TestArray = SourceValues; + const int32 RemovedCount = UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveAtIndices( + &TestObject->TestArray, + ArrayProperty, + RemovalIndices); + TestEqual( + FString::Printf(TEXT("RemoveAtIndices fuzz count %d"), Iteration), + RemovedCount, + SourceValues.Num() - ExpectedRemoval.Num()); + TestEqual( + FString::Printf(TEXT("RemoveAtIndices fuzz values %d"), Iteration), + TestObject->TestArray, + ExpectedRemoval); + } + + TArray StableNaturalStrings = {TEXT("ItemA"), TEXT("itema"), TEXT("ItemB"), TEXT("itemb")}; + UDirectiveUtilArrayFunctionLibrary::NaturalSortStringArray(StableNaturalStrings); + TestEqual( + "Natural string sort should preserve equivalent-key order", + StableNaturalStrings, + TArray({TEXT("ItemA"), TEXT("itema"), TEXT("ItemB"), TEXT("itemb")})); + UDirectiveUtilArrayFunctionLibrary::NaturalSortStringArray(StableNaturalStrings, true); + TestEqual( + "Descending natural string sort should preserve equivalent-key order", + StableNaturalStrings, + TArray({TEXT("ItemB"), TEXT("itemb"), TEXT("ItemA"), TEXT("itema")})); + return true; } diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilArrayNodeWildcardTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilArrayNodeWildcardTest.cpp new file mode 100644 index 00000000..21eff9c4 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilArrayNodeWildcardTest.cpp @@ -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 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(); + Blueprint->GeneratedClass = UObject::StaticClass(); + Blueprint->SkeletonGeneratedClass = UObject::StaticClass(); + UEdGraph* Graph = NewObject(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(&Graph); + Graph.AddNode(SourceNode); + return SourceNode->CreatePin(EGPD_Output, PinType, Name); + } + + UK2Node_CallArrayFunction* AddFunctionNode(UEdGraph& Graph, const FName FunctionName) + { + UK2Node_CallArrayFunction* Node = NewObject(&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 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 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; +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilArrayScenarioTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilArrayScenarioTest.cpp new file mode 100644 index 00000000..3839ac5c --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilArrayScenarioTest.cpp @@ -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( + UDirectiveUtilTestObject::StaticClass(), + GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestArray)); + } + + TArray MakeSequentialValues(const int32 Count) + { + TArray Values; + Values.SetNumUninitialized(Count); + for (int32 Index = 0; Index < Count; ++Index) + { + Values[Index] = Index; + } + return Values; + } + + TArray MakeRepeatingValues(const int32 Count, const int32 DistinctCount) + { + TArray Values; + Values.Reserve(Count); + for (int32 Index = 0; Index < Count; ++Index) + { + Values.Add(Index % DistinctCount); + } + return Values; + } + + TArray MakeDistinctReference(const TArray& Values) + { + TArray Result; + for (const int32 Value : Values) + { + Result.AddUnique(Value); + } + return Result; + } + + bool FindMostCommonReference(const TArray& Values, int32& OutValue, int32& OutCount) + { + OutValue = 0; + OutCount = 0; + TMap Counts; + for (const int32 Value : Values) + { + ++Counts.FindOrAdd(Value); + } + for (const TPair& 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& Values, const int32 QueryValue) + { + int32 Count = 0; + for (const int32 Value : Values) + { + Count += Value == QueryValue ? 1 : 0; + } + return Count; + } + + TArray RemoveAllReference(const TArray& Values, const int32 QueryValue) + { + TArray Result; + Result.Reserve(Values.Num()); + for (const int32 Value : Values) + { + if (Value != QueryValue) + { + Result.Add(Value); + } + } + return Result; + } + + TArray SliceReference(const TArray& Values, const int32 StartIndex, const int32 Count) + { + TArray Result; + Result.Reserve(Count); + for (int32 Index = 0; Index < Count; ++Index) + { + Result.Add(Values[StartIndex + Index]); + } + return Result; + } + + TArray MakeSampleReference(const int32 SourceCount, const int32 RequestedCount, const int32 Seed) + { + TArray AvailableIndices = MakeSequentialValues(SourceCount); + TArray 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 + TArray MakeRotationReference(const TArray& Values, const int32 Shift) + { + if (Values.IsEmpty()) + { + return Values; + } + + int32 NormalizedShift = Shift % Values.Num(); + if (NormalizedShift < 0) + { + NormalizedShift += Values.Num(); + } + + TArray 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(); + struct FScenario + { + int32 ItemCount; + int32 DistinctCount; + }; + const TArray 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 Source = MakeRepeatingValues(Scenario.ItemCount, Scenario.DistinctCount); + const TArray 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 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 Source = MakeSequentialValues(SourceCount); + const TArray RequestedCounts = {-1, 0, 1, SourceCount / 4, SourceCount, SourceCount + 5}; + for (const int32 RequestedCount : RequestedCounts) + { + FRandomStream FirstStream(Seed); + FRandomStream SecondStream(Seed); + TArray FirstSample; + TArray 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 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 BoundarySource = MakeSequentialValues(100); + for (const int32 RequestedCount : {24, 25, 26, 75, 100}) + { + FRandomStream RandomStream(Seed); + TArray 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 ReplacementSource = MakeSequentialValues(7); + for (const int32 RequestedCount : {0, 1, 7, 14, 100}) + { + FRandomStream FirstStream(Seed); + FRandomStream SecondStream(Seed); + TArray FirstSample; + TArray 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 Source = MakeSequentialValues(SourceCount); + TArray Weights; + Weights.Reserve(SourceCount); + TSet SelectableValues; + for (int32 Index = 0; Index < SourceCount; ++Index) + { + const float Weight = Index % 3 == 0 ? 0.0f : static_cast((Index % 7) + 1); + Weights.Add(Weight); + if (Weight > 0.0f) + { + SelectableValues.Add(Index); + } + } + + const TArray 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 FirstSample; + TArray 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 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 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 Source = MakeSequentialValues(Scenario.ItemCount); + TArray 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 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 SliceSource = MakeSequentialValues(10); + struct FSliceScenario + { + int32 StartIndex; + int32 Count; + }; + const TArray 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 ExpectedSlice; + for (int32 Offset = 0; Offset < CopyCount; ++Offset) + { + ExpectedSlice.Add(SliceSource[StartIndex + Offset]); + } + + TArray 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 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( + 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 Source = MakeSequentialValues(ItemCount); + const TArray Shifts = { + 0, + 1, + -1, + ItemCount, + ItemCount + 1, + -ItemCount - 1, + MAX_int32, + MIN_int32 + }; + for (const int32 Shift : Shifts) + { + TArray 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 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 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 Strings; + TArray ExpectedStrings; + TArray Names; + TArray 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(); + const TArray 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 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 ExpectedDistinct = MakeDistinctReference(Source); + TestObject->TestArray = Source; + UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveDuplicates(&TestObject->TestArray, ArrayProperty); + TestEqual(Label + TEXT(" RemoveDuplicates"), TestObject->TestArray, ExpectedDistinct); + + TArray 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 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 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 ExpectedSlice = SliceReference(Source, ExpectedStart, ExpectedSliceCount); + TArray 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 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 ExpectedPage = bExpectedValid + ? SliceReference(Source, PageIndex * PageSize, FMath::Min(PageSize, ItemCount - PageIndex * PageSize)) + : TArray(); + 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; +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilAsyncTaskTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilAsyncTaskTest.cpp index c51ca91b..27ba6d47 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilAsyncTaskTest.cpp +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilAsyncTaskTest.cpp @@ -1,62 +1,151 @@ // Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. #include "Tasks/DirectiveUtilTask_Delay.h" +#include "Tasks/DirectiveUtilTask_Flow.h" #include "Tasks/DirectiveUtilTask_AsyncLoadAsset.h" #include "Tests/DirectiveUtilTestObject.h" #include "Engine/World.h" #include "Engine/Engine.h" +#include "Engine/GameInstance.h" #include "Engine/StaticMesh.h" #include "Engine/StaticMeshActor.h" #include "TimerManager.h" #include "UObject/SoftObjectPath.h" #include "UObject/SoftObjectPtr.h" +#include "HAL/PlatformTime.h" #include "Misc/AutomationTest.h" -#if WITH_EDITOR +#include namespace DirectiveUtilAsyncTaskTestHelpers { - /** - * Spawns a transient world, starts a cancellable delay in it, and returns a rooted listener bound - * to the delay's Completed delegate. The world is stored on the listener so the latent command can - * tick its timer manager across frames and tear it down once the scenario settles. - * - * A timer set on a never-ticked FTimerManager is queued as pending and only promoted to active on - * the first tick; it fires on a later tick. Because a manager can be ticked at most once per frame, - * driving the timer to completion requires advancing real frames, hence the latent command below. - */ - UDirectiveUtilDelegateListener* StartDelayScenario(float Duration, bool bCancel) + UDirectiveUtilDelegateListener* CreateScenarioListener() { - UWorld* World = UWorld::CreateWorld(EWorldType::Editor, false); - if (!World) + UGameInstance* GameInstance = NewObject(GEngine); + if (!GameInstance) { return nullptr; } - FWorldContext& WorldContext = GEngine->CreateNewWorldContext(EWorldType::Editor); - WorldContext.SetCurrentWorld(World); + GameInstance->AddToRoot(); + GameInstance->InitializeStandalone(); + UWorld* World = GameInstance->GetWorld(); + if (!World) + { + GameInstance->Shutdown(); + GameInstance->RemoveFromRoot(); + return nullptr; + } UDirectiveUtilDelegateListener* Listener = NewObject(); Listener->AddToRoot(); Listener->ScenarioWorld = World; + Listener->ScenarioGameInstance = GameInstance; + return Listener; + } - UDirectiveUtilTask_Delay* Task = UDirectiveUtilTask_Delay::CancellableDelay(World, Duration); + void DestroyScenario(UDirectiveUtilDelegateListener* Listener) + { + if (!Listener) + { + return; + } + + UGameInstance* GameInstance = Listener->ScenarioGameInstance.Get(); + Listener->Keepalive = nullptr; + Listener->ScenarioWorld = nullptr; + Listener->ScenarioGameInstance = nullptr; + if (GameInstance) + { + GameInstance->Shutdown(); + GameInstance->RemoveFromRoot(); + } + Listener->RemoveFromRoot(); + } + + UDirectiveUtilDelegateListener* StartDelayScenario(float Duration, bool bCancel, bool bUseEndTask = false) + { + UDirectiveUtilDelegateListener* Listener = CreateScenarioListener(); + if (!Listener) + { + return nullptr; + } + + UDirectiveUtilTask_Delay* Task = UDirectiveUtilTask_Delay::CancellableDelay(Listener->ScenarioWorld, Duration); Listener->Keepalive = Task; Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnCompleted); Task->Activate(); if (bCancel) { - Task->EndTask(); + if (bUseEndTask) + { + Task->EndTask(); + } + else + { + Task->Cancel(); + } } return Listener; } + + UDirectiveUtilDelegateListener* StartDurationScenario(float Duration, float UpdateInterval, bool bCancel) + { + UDirectiveUtilDelegateListener* Listener = CreateScenarioListener(); + if (!Listener) + { + return nullptr; + } + + UDirectiveUtilTask_UpdateForDuration* Task = UDirectiveUtilTask_UpdateForDuration::UpdateForDuration( + Listener->ScenarioWorld, + Duration, + UpdateInterval); + Listener->Keepalive = Task; + Task->Updated.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnDurationUpdated); + Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnCompleted); + Task->Activate(); + if (bCancel) + { + Task->Cancel(); + } + return Listener; + } + + UDirectiveUtilDelegateListener* StartRepeatScenario( + int32 Count, + float Interval, + float InitialDelay, + bool bCancel, + int32 NextIndex = 0) + { + UDirectiveUtilDelegateListener* Listener = CreateScenarioListener(); + if (!Listener) + { + return nullptr; + } + + UDirectiveUtilTask_RepeatWithInterval* Task = UDirectiveUtilTask_RepeatWithInterval::RepeatWithInterval( + Listener->ScenarioWorld, + Count, + Interval, + InitialDelay); + Listener->Keepalive = Task; + Task->Iteration.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnRepeatIteration); + Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnCompleted); +#if WITH_DEV_AUTOMATION_TESTS + Task->SetNextIndexForTesting(NextIndex); +#endif + Task->Activate(); + if (bCancel) + { + Task->Cancel(); + } + return Listener; + } } -/** - * Latent command that ticks a delay scenario's world each frame until the delay completes or the - * frame budget runs out, then asserts against the expected outcome and tears the world down. - */ DEFINE_LATENT_AUTOMATION_COMMAND_FOUR_PARAMETER(FDirectiveUtilTickDelayScenario, FAutomationTestBase*, Test, UDirectiveUtilDelegateListener*, Listener, int32, FramesRemaining, bool, bExpectComplete); bool FDirectiveUtilTickDelayScenario::Update() @@ -82,55 +171,79 @@ bool FDirectiveUtilTickDelayScenario::Update() } else { - Test->TestFalse(TEXT("EndTask cancels the delay so Completed never fires"), Listener->bCompleted); + Test->TestFalse(TEXT("Cancel prevents the delay from firing Completed"), Listener->bCompleted); } - if (World) - { - GEngine->DestroyWorldContext(World); - World->DestroyWorld(false); - } - Listener->ScenarioWorld = nullptr; - Listener->Keepalive = nullptr; - Listener->RemoveFromRoot(); + DirectiveUtilAsyncTaskTestHelpers::DestroyScenario(Listener); return true; } return false; } -/** - * DirectiveUtilTask_Delay: verifies the timer-backed completion fires, that EndTask cancels it before it fires, - * and that activating with a null world context is guarded rather than crashing. - */ -IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilDelayTaskTest, "DirectiveUtilities.AsyncTaskDelayTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilDelayTaskTest, "DirectiveUtilities.AsyncTaskDelayTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter) bool FDirectiveUtilDelayTaskTest::RunTest(const FString& Parameters) { - // The null-world activation intentionally logs a warning. AddExpectedMessagePlain(TEXT("Cancellable Delay failed to activate. World is null."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1); - // Completion path: the delay fires once its timer elapses (driven across frames by the latent command). - if (UDirectiveUtilDelegateListener* Completed = DirectiveUtilAsyncTaskTestHelpers::StartDelayScenario(0.05f, /*bCancel=*/false)) + if (UDirectiveUtilDelegateListener* Completed = DirectiveUtilAsyncTaskTestHelpers::StartDelayScenario(0.05f, false)) { - ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickDelayScenario(this, Completed, 600, /*bExpectComplete=*/true)); + if (const UDirectiveUtilTask_Delay* Task = Cast(Completed->Keepalive)) + { + TestTrue(TEXT("Cancellable delay is active after activation"), Task->IsActive()); + } + ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickDelayScenario(this, Completed, 600, true)); } else { AddError(TEXT("Failed to create a transient world for the delay completion scenario.")); } - // Cancellation path: EndTask clears the timer so Completed never fires across a short window. - if (UDirectiveUtilDelegateListener* Cancelled = DirectiveUtilAsyncTaskTestHelpers::StartDelayScenario(0.05f, /*bCancel=*/true)) + if (UDirectiveUtilDelegateListener* Cancelled = DirectiveUtilAsyncTaskTestHelpers::StartDelayScenario(0.05f, true)) { - ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickDelayScenario(this, Cancelled, 10, /*bExpectComplete=*/false)); + if (UDirectiveUtilTask_Delay* Task = Cast(Cancelled->Keepalive)) + { + TestFalse(TEXT("Cancelled delay is inactive"), Task->IsActive()); + Task->Cancel(); + } + ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickDelayScenario(this, Cancelled, 10, false)); } else { AddError(TEXT("Failed to create a transient world for the delay cancellation scenario.")); } - // Null world guard: activating with a null context object logs a warning and does not crash. + if (UDirectiveUtilDelegateListener* LegacyCancelled = DirectiveUtilAsyncTaskTestHelpers::StartDelayScenario(0.05f, true, true)) + { + ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickDelayScenario(this, LegacyCancelled, 10, false)); + } + else + { + AddError(TEXT("Failed to create the EndTask compatibility scenario.")); + } + + if (UDirectiveUtilDelegateListener* Immediate = DirectiveUtilAsyncTaskTestHelpers::StartDelayScenario(0.0f, false)) + { + TestFalse(TEXT("A non-positive delay does not complete during activation"), Immediate->bCompleted); + ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickDelayScenario(this, Immediate, 3, true)); + } + else + { + AddError(TEXT("Failed to create the immediate delay scenario.")); + } + + if (UDirectiveUtilDelegateListener* NonFinite = DirectiveUtilAsyncTaskTestHelpers::StartDelayScenario( + std::numeric_limits::quiet_NaN(), + false)) + { + ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickDelayScenario(this, NonFinite, 3, true)); + } + else + { + AddError(TEXT("Failed to create the non-finite delay scenario.")); + } + UDirectiveUtilTask_Delay* NullWorldTask = UDirectiveUtilTask_Delay::CancellableDelay(nullptr, 0.05f); if (TestNotNull("CancellableDelay returns a task even with a null context", NullWorldTask)) { @@ -142,7 +255,639 @@ bool FDirectiveUtilDelayTaskTest::RunTest(const FString& Parameters) return true; } -/** Latent command that waits for an async-load listener to settle (completed or failed) or times out. */ +DEFINE_LATENT_AUTOMATION_COMMAND_THREE_PARAMETER( + FDirectiveUtilTickDurationCadenceScenario, + FAutomationTestBase*, Test, + UDirectiveUtilDelegateListener*, Listener, + double, Deadline); + +bool FDirectiveUtilTickDurationCadenceScenario::Update() +{ + if (!Listener) + { + Listener = DirectiveUtilAsyncTaskTestHelpers::StartDurationScenario(0.1f, 0.02f, false); + Deadline = FPlatformTime::Seconds() + 2.0; + if (!Listener) + { + Test->AddError(TEXT("Failed to create the Update for Duration cadence scenario.")); + return true; + } + return false; + } + + if (UWorld* World = Listener->ScenarioWorld.Get()) + { + FTimerManager& TimerManager = World->GetTimerManager(); + if (!TimerManager.HasBeenTickedThisFrame()) + { + TimerManager.Tick(0.01f); + } + } + + if (!Listener->bCompleted && FPlatformTime::Seconds() < Deadline) + { + return false; + } + + Test->TestTrue(TEXT("Update for Duration completes with a positive update interval"), Listener->bCompleted); + Test->TestTrue(TEXT("Update for Duration emits repeated interval updates"), Listener->UpdatedCount >= 4); + Test->TestTrue(TEXT("Update for Duration does not emit excessive interval updates"), Listener->UpdatedCount <= 7); + Test->TestTrue(TEXT("Update for Duration emits progress before its final update"), Listener->UpdateAlphas.Num() > 1 && Listener->UpdateAlphas[0] > 0.0f && Listener->UpdateAlphas[0] < 1.0f); + Test->TestEqual(TEXT("Update for Duration interval scenario completes once"), Listener->CompletedCount, 1); + if (const UDirectiveUtilTask_UpdateForDuration* Task = Cast(Listener->Keepalive)) + { + Test->TestFalse(TEXT("Completed Update for Duration is inactive"), Task->IsActive()); + } + DirectiveUtilAsyncTaskTestHelpers::DestroyScenario(Listener); + return true; +} + +DEFINE_LATENT_AUTOMATION_COMMAND_FOUR_PARAMETER( + FDirectiveUtilCancelDurationAfterUpdateScenario, + FAutomationTestBase*, Test, + UDirectiveUtilDelegateListener*, Listener, + double, Deadline, + int32, UpdatesAtCancellation); + +bool FDirectiveUtilCancelDurationAfterUpdateScenario::Update() +{ + if (!Listener) + { + Listener = DirectiveUtilAsyncTaskTestHelpers::StartDurationScenario(0.5f, 0.02f, false); + Deadline = FPlatformTime::Seconds() + 2.0; + if (!Listener) + { + Test->AddError(TEXT("Failed to create the Update for Duration cancellation scenario.")); + return true; + } + return false; + } + + if (UWorld* World = Listener->ScenarioWorld.Get()) + { + FTimerManager& TimerManager = World->GetTimerManager(); + if (!TimerManager.HasBeenTickedThisFrame()) + { + TimerManager.Tick(0.01f); + } + } + + if (UpdatesAtCancellation == 0 && Listener->UpdatedCount > 0) + { + UpdatesAtCancellation = Listener->UpdatedCount; + if (UDirectiveUtilTask_UpdateForDuration* Task = Cast(Listener->Keepalive)) + { + Task->Cancel(); + } + Deadline = FPlatformTime::Seconds() + 0.05; + } + + if (FPlatformTime::Seconds() < Deadline) + { + return false; + } + + Test->TestTrue(TEXT("Update for Duration reached an update before cancellation"), UpdatesAtCancellation > 0); + Test->TestFalse(TEXT("Cancelling Update for Duration after an update prevents completion"), Listener->bCompleted); + Test->TestEqual(TEXT("Cancelling Update for Duration stops later updates"), Listener->UpdatedCount, UpdatesAtCancellation); + if (const UDirectiveUtilTask_UpdateForDuration* Task = Cast(Listener->Keepalive)) + { + Test->TestFalse(TEXT("Cancelled Update for Duration is inactive"), Task->IsActive()); + } + DirectiveUtilAsyncTaskTestHelpers::DestroyScenario(Listener); + return true; +} + +DEFINE_LATENT_AUTOMATION_COMMAND_FIVE_PARAMETER( + FDirectiveUtilTickDurationScenario, + FAutomationTestBase*, Test, + UDirectiveUtilDelegateListener*, Listener, + int32, FramesRemaining, + bool, bExpectComplete, + float, ExpectedDuration); + +bool FDirectiveUtilTickDurationScenario::Update() +{ + if (!Listener) + { + return true; + } + + if (UWorld* World = Listener->ScenarioWorld.Get()) + { + World->GetTimerManager().Tick(0.1f); + } + + const bool bBudgetExhausted = --FramesRemaining <= 0; + if (!Listener->bCompleted && !bBudgetExhausted) + { + return false; + } + + if (bExpectComplete) + { + Test->TestTrue(TEXT("Update for Duration fires Completed"), Listener->bCompleted); + Test->TestEqual(TEXT("Update for Duration fires Completed once"), Listener->CompletedCount, 1); + Test->TestTrue(TEXT("Update for Duration emits at least one update"), Listener->UpdatedCount > 0); + Test->TestEqual(TEXT("Update for Duration ends at alpha 1"), Listener->LastAlpha, 1.0f); + Test->TestEqual(TEXT("Update for Duration reports its final elapsed time"), Listener->LastElapsedTime, FMath::Max(ExpectedDuration, 0.0f)); + float PreviousElapsedTime = 0.0f; + for (int32 UpdateIndex = 0; UpdateIndex < Listener->UpdatedCount; ++UpdateIndex) + { + Test->TestTrue(TEXT("Update for Duration elapsed time is monotonic"), Listener->UpdateElapsedTimes[UpdateIndex] >= PreviousElapsedTime); + Test->TestTrue(TEXT("Update for Duration delta time is non-negative"), Listener->UpdateDeltaTimes[UpdateIndex] >= 0.0f); + Test->TestTrue(TEXT("Update for Duration alpha stays in range"), Listener->UpdateAlphas[UpdateIndex] >= 0.0f && Listener->UpdateAlphas[UpdateIndex] <= 1.0f); + PreviousElapsedTime = Listener->UpdateElapsedTimes[UpdateIndex]; + } + } + else + { + Test->TestFalse(TEXT("Cancel prevents Update for Duration from firing Completed"), Listener->bCompleted); + Test->TestEqual(TEXT("Cancel prevents Update for Duration callbacks"), Listener->UpdatedCount, 0); + } + + DirectiveUtilAsyncTaskTestHelpers::DestroyScenario(Listener); + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FDirectiveUtilUpdateForDurationTaskTest, + "DirectiveUtilities.AsyncTaskUpdateForDurationTests", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter) + +bool FDirectiveUtilUpdateForDurationTaskTest::RunTest(const FString& Parameters) +{ + AddExpectedMessagePlain(TEXT("Update for Duration failed to activate. World is null."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1); + + if (UDirectiveUtilDelegateListener* Completed = DirectiveUtilAsyncTaskTestHelpers::StartDurationScenario(0.01f, 0.002f, false)) + { + ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickDurationScenario(this, Completed, 600, true, 0.01f)); + } + else + { + AddError(TEXT("Failed to create the Update for Duration completion scenario.")); + } + + ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickDurationCadenceScenario(this, nullptr, 0.0)); + ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilCancelDurationAfterUpdateScenario(this, nullptr, 0.0, 0)); + + if (UDirectiveUtilDelegateListener* Immediate = DirectiveUtilAsyncTaskTestHelpers::StartDurationScenario(0.0f, 0.0f, false)) + { + TestFalse(TEXT("Update for Duration does not complete during activation"), Immediate->bCompleted); + ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickDurationScenario(this, Immediate, 3, true, 0.0f)); + } + else + { + AddError(TEXT("Failed to create the immediate Update for Duration scenario.")); + } + + if (UDirectiveUtilDelegateListener* NonFinite = DirectiveUtilAsyncTaskTestHelpers::StartDurationScenario(std::numeric_limits::quiet_NaN(), 0.0f, false)) + { + ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickDurationScenario(this, NonFinite, 3, true, 0.0f)); + } + else + { + AddError(TEXT("Failed to create the non-finite Update for Duration scenario.")); + } + + if (UDirectiveUtilDelegateListener* Cancelled = DirectiveUtilAsyncTaskTestHelpers::StartDurationScenario(1.0f, 0.0f, true)) + { + ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickDurationScenario(this, Cancelled, 3, false, 1.0f)); + } + else + { + AddError(TEXT("Failed to create the cancelled Update for Duration scenario.")); + } + + if (UDirectiveUtilDelegateListener* PerTick = DirectiveUtilAsyncTaskTestHelpers::StartDurationScenario(10.0f, 0.0f, false)) + { + if (UWorld* World = PerTick->ScenarioWorld.Get()) + { + World->GetTimerManager().Tick(1.0f); + TestEqual(TEXT("A zero update interval emits at most one update per timer tick"), PerTick->UpdatedCount, 1); + } + if (UDirectiveUtilTask_UpdateForDuration* Task = Cast(PerTick->Keepalive)) + { + Task->Cancel(); + } + DirectiveUtilAsyncTaskTestHelpers::DestroyScenario(PerTick); + } + else + { + AddError(TEXT("Failed to create the per-tick Update for Duration scenario.")); + } + + UDirectiveUtilTask_UpdateForDuration* NullWorldTask = UDirectiveUtilTask_UpdateForDuration::UpdateForDuration(nullptr, 1.0f); + if (TestNotNull("UpdateForDuration returns a task with a null context", NullWorldTask)) + { + NullWorldTask->AddToRoot(); + NullWorldTask->Activate(); + NullWorldTask->RemoveFromRoot(); + } + + return true; +} + +DEFINE_LATENT_AUTOMATION_COMMAND_FIVE_PARAMETER( + FDirectiveUtilTickRepeatCadenceScenario, + FAutomationTestBase*, Test, + UDirectiveUtilDelegateListener*, Listener, + double, Deadline, + int32, TickCount, + TArray, IterationTicks); + +bool FDirectiveUtilTickRepeatCadenceScenario::Update() +{ + if (!Listener) + { + Listener = DirectiveUtilAsyncTaskTestHelpers::StartRepeatScenario(3, 0.03f, 0.04f, false); + Deadline = FPlatformTime::Seconds() + 2.0; + if (!Listener) + { + Test->AddError(TEXT("Failed to create the Repeat with Interval cadence scenario.")); + return true; + } + return false; + } + + if (UWorld* World = Listener->ScenarioWorld.Get()) + { + FTimerManager& TimerManager = World->GetTimerManager(); + if (!TimerManager.HasBeenTickedThisFrame()) + { + TimerManager.Tick(0.01f); + } + } + ++TickCount; + while (IterationTicks.Num() < Listener->IterationCount) + { + IterationTicks.Add(TickCount); + } + + if (!Listener->bCompleted && FPlatformTime::Seconds() < Deadline) + { + return false; + } + + Test->TestTrue(TEXT("Repeat with Interval completes with positive delays"), Listener->bCompleted); + Test->TestEqual(TEXT("Repeat with Interval emits every delayed iteration"), IterationTicks.Num(), 3); + if (IterationTicks.Num() == 3) + { + Test->TestTrue(TEXT("Repeat with Interval respects its initial delay"), IterationTicks[0] > 1); + Test->TestTrue(TEXT("Repeat with Interval separates its first interval"), IterationTicks[1] > IterationTicks[0]); + Test->TestTrue(TEXT("Repeat with Interval separates its second interval"), IterationTicks[2] > IterationTicks[1]); + } + Test->TestEqual(TEXT("Repeat with Interval delayed scenario completes once"), Listener->CompletedCount, 1); + if (const UDirectiveUtilTask_RepeatWithInterval* Task = Cast(Listener->Keepalive)) + { + Test->TestFalse(TEXT("Completed Repeat with Interval is inactive"), Task->IsActive()); + } + DirectiveUtilAsyncTaskTestHelpers::DestroyScenario(Listener); + return true; +} + +DEFINE_LATENT_AUTOMATION_COMMAND_FOUR_PARAMETER( + FDirectiveUtilCancelRepeatAfterIterationScenario, + FAutomationTestBase*, Test, + UDirectiveUtilDelegateListener*, Listener, + double, Deadline, + int32, IterationsAtCancellation); + +bool FDirectiveUtilCancelRepeatAfterIterationScenario::Update() +{ + if (!Listener) + { + Listener = DirectiveUtilAsyncTaskTestHelpers::StartRepeatScenario(10, 0.02f, 0.0f, false); + Deadline = FPlatformTime::Seconds() + 2.0; + if (!Listener) + { + Test->AddError(TEXT("Failed to create the Repeat with Interval cancellation scenario.")); + return true; + } + return false; + } + + if (UWorld* World = Listener->ScenarioWorld.Get()) + { + FTimerManager& TimerManager = World->GetTimerManager(); + if (!TimerManager.HasBeenTickedThisFrame()) + { + TimerManager.Tick(0.01f); + } + } + + if (IterationsAtCancellation == 0 && Listener->IterationCount > 0) + { + IterationsAtCancellation = Listener->IterationCount; + if (UDirectiveUtilTask_RepeatWithInterval* Task = Cast(Listener->Keepalive)) + { + Task->Cancel(); + } + Deadline = FPlatformTime::Seconds() + 0.05; + } + + if (FPlatformTime::Seconds() < Deadline) + { + return false; + } + + Test->TestTrue(TEXT("Repeat with Interval reached an iteration before cancellation"), IterationsAtCancellation > 0); + Test->TestFalse(TEXT("Cancelling Repeat with Interval after an iteration prevents completion"), Listener->bCompleted); + Test->TestEqual(TEXT("Cancelling Repeat with Interval stops later iterations"), Listener->IterationCount, IterationsAtCancellation); + if (const UDirectiveUtilTask_RepeatWithInterval* Task = Cast(Listener->Keepalive)) + { + Test->TestFalse(TEXT("Cancelled Repeat with Interval is inactive"), Task->IsActive()); + } + DirectiveUtilAsyncTaskTestHelpers::DestroyScenario(Listener); + return true; +} + +DEFINE_LATENT_AUTOMATION_COMMAND_THREE_PARAMETER( + FDirectiveUtilTickInfiniteRepeatScenario, + FAutomationTestBase*, Test, + UDirectiveUtilDelegateListener*, Listener, + int32, FramesRemaining); + +DEFINE_LATENT_AUTOMATION_COMMAND_THREE_PARAMETER( + FDirectiveUtilTickRepeatRolloverScenario, + FAutomationTestBase*, Test, + UDirectiveUtilDelegateListener*, Listener, + int32, FramesRemaining); + +bool FDirectiveUtilTickRepeatRolloverScenario::Update() +{ + if (!Listener) + { + return true; + } + + if (UWorld* World = Listener->ScenarioWorld.Get()) + { + World->GetTimerManager().Tick(0.1f); + } + + if (Listener->IterationCount < 2 && --FramesRemaining > 0) + { + return false; + } + + Test->TestTrue( + TEXT("An infinite Repeat with Interval wraps its index without signed overflow"), + Listener->IterationIndices.Num() >= 2 + && Listener->IterationIndices[0] == MAX_int32 + && Listener->IterationIndices[1] == 0); + if (UDirectiveUtilTask_RepeatWithInterval* Task = Cast(Listener->Keepalive)) + { + Task->Cancel(); + } + DirectiveUtilAsyncTaskTestHelpers::DestroyScenario(Listener); + return true; +} + +bool FDirectiveUtilTickInfiniteRepeatScenario::Update() +{ + if (!Listener) + { + return true; + } + + if (UWorld* World = Listener->ScenarioWorld.Get()) + { + World->GetTimerManager().Tick(0.1f); + } + + if (--FramesRemaining > 0) + { + return false; + } + + Test->TestFalse(TEXT("An infinite Repeat with Interval does not complete on its own"), Listener->bCompleted); + Test->TestTrue(TEXT("An infinite Repeat with Interval keeps iterating"), Listener->IterationCount >= 3); + TArray ExpectedRemaining; + ExpectedRemaining.Init(-1, Listener->IterationCount); + Test->TestEqual(TEXT("An infinite Repeat with Interval reports Remaining as -1"), + Listener->IterationRemaining, ExpectedRemaining); + + if (UDirectiveUtilTask_RepeatWithInterval* Task = Cast(Listener->Keepalive)) + { + const int32 IterationsAtCancel = Listener->IterationCount; + Task->Cancel(); + if (UWorld* World = Listener->ScenarioWorld.Get()) + { + World->GetTimerManager().Tick(0.1f); + World->GetTimerManager().Tick(0.1f); + } + Test->TestEqual(TEXT("Cancel stops further infinite iterations"), Listener->IterationCount, IterationsAtCancel); + Test->TestFalse(TEXT("Cancel does not fire Completed for an infinite Repeat"), Listener->bCompleted); + Test->TestFalse(TEXT("Cancelled infinite Repeat is inactive"), Task->IsActive()); + } + + DirectiveUtilAsyncTaskTestHelpers::DestroyScenario(Listener); + return true; +} + +DEFINE_LATENT_AUTOMATION_COMMAND_FIVE_PARAMETER( + FDirectiveUtilTickRepeatScenario, + FAutomationTestBase*, Test, + UDirectiveUtilDelegateListener*, Listener, + int32, FramesRemaining, + int32, ExpectedIterations, + bool, bExpectComplete); + +bool FDirectiveUtilTickRepeatScenario::Update() +{ + if (!Listener) + { + return true; + } + + if (UWorld* World = Listener->ScenarioWorld.Get()) + { + World->GetTimerManager().Tick(0.1f); + } + + const bool bBudgetExhausted = --FramesRemaining <= 0; + if (!Listener->bCompleted && !bBudgetExhausted) + { + return false; + } + + if (bExpectComplete) + { + Test->TestTrue(TEXT("Repeat with Interval fires Completed"), Listener->bCompleted); + Test->TestEqual(TEXT("Repeat with Interval fires Completed once"), Listener->CompletedCount, 1); + Test->TestEqual(TEXT("Repeat with Interval emits the requested iteration count"), Listener->IterationCount, ExpectedIterations); + TArray ExpectedIndices; + TArray ExpectedRemaining; + for (int32 Index = 0; Index < ExpectedIterations; ++Index) + { + ExpectedIndices.Add(Index); + ExpectedRemaining.Add(ExpectedIterations - Index - 1); + } + Test->TestEqual(TEXT("Repeat with Interval reports zero-based indices"), Listener->IterationIndices, ExpectedIndices); + Test->TestEqual(TEXT("Repeat with Interval reports remaining iterations"), Listener->IterationRemaining, ExpectedRemaining); + } + else + { + Test->TestFalse(TEXT("Cancel prevents Repeat with Interval from firing Completed"), Listener->bCompleted); + Test->TestEqual(TEXT("Cancel prevents Repeat with Interval callbacks"), Listener->IterationCount, 0); + } + + DirectiveUtilAsyncTaskTestHelpers::DestroyScenario(Listener); + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FDirectiveUtilRepeatWithIntervalTaskTest, + "DirectiveUtilities.AsyncTaskRepeatWithIntervalTests", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter) + +bool FDirectiveUtilRepeatWithIntervalTaskTest::RunTest(const FString& Parameters) +{ + AddExpectedMessagePlain(TEXT("Repeat with Interval failed to activate. World is null."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1); + + if (UDirectiveUtilDelegateListener* Repeated = DirectiveUtilAsyncTaskTestHelpers::StartRepeatScenario(3, 0.0f, 0.0f, false)) + { + TestEqual(TEXT("Repeat with Interval does not iterate during activation"), Repeated->IterationCount, 0); + if (UWorld* World = Repeated->ScenarioWorld.Get()) + { + World->GetTimerManager().Tick(1.0f); + TestEqual(TEXT("A zero interval emits at most one iteration per timer tick"), Repeated->IterationCount, 1); + } + ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickRepeatScenario(this, Repeated, 6, 3, true)); + } + else + { + AddError(TEXT("Failed to create the Repeat with Interval scenario.")); + } + + ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickRepeatCadenceScenario(this, nullptr, 0.0, 0, TArray())); + ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilCancelRepeatAfterIterationScenario(this, nullptr, 0.0, 0)); + + if (UDirectiveUtilDelegateListener* Empty = DirectiveUtilAsyncTaskTestHelpers::StartRepeatScenario(0, 0.1f, 0.1f, false)) + { + TestFalse(TEXT("An empty Repeat with Interval does not complete during activation"), Empty->bCompleted); + ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickRepeatScenario(this, Empty, 3, 0, true)); + } + else + { + AddError(TEXT("Failed to create the empty Repeat with Interval scenario.")); + } + + if (UDirectiveUtilDelegateListener* Infinite = DirectiveUtilAsyncTaskTestHelpers::StartRepeatScenario(-1, 0.0f, 0.0f, false)) + { + ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickInfiniteRepeatScenario(this, Infinite, 5)); + } + else + { + AddError(TEXT("Failed to create the infinite Repeat with Interval scenario.")); + } + +#if WITH_DEV_AUTOMATION_TESTS + if (UDirectiveUtilDelegateListener* Rollover = DirectiveUtilAsyncTaskTestHelpers::StartRepeatScenario( + -1, 0.0f, 0.0f, false, MAX_int32)) + { + ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickRepeatRolloverScenario(this, Rollover, 5)); + } + else + { + AddError(TEXT("Failed to create the repeat-index rollover scenario.")); + } +#endif + + if (UDirectiveUtilDelegateListener* NegativeCount = DirectiveUtilAsyncTaskTestHelpers::StartRepeatScenario(-4, 0.1f, 0.1f, false)) + { + ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickRepeatScenario(this, NegativeCount, 3, 0, true)); + } + else + { + AddError(TEXT("Failed to create the negative-count Repeat with Interval scenario.")); + } + + if (UDirectiveUtilDelegateListener* NegativeTiming = DirectiveUtilAsyncTaskTestHelpers::StartRepeatScenario(2, -1.0f, -1.0f, false)) + { + ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickRepeatScenario(this, NegativeTiming, 5, 2, true)); + } + else + { + AddError(TEXT("Failed to create the negative-timing Repeat with Interval scenario.")); + } + + if (UDirectiveUtilDelegateListener* NonFinite = DirectiveUtilAsyncTaskTestHelpers::StartRepeatScenario( + 2, + std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN(), + false)) + { + ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickRepeatScenario(this, NonFinite, 5, 2, true)); + } + else + { + AddError(TEXT("Failed to create the non-finite Repeat with Interval scenario.")); + } + + if (UDirectiveUtilDelegateListener* Cancelled = DirectiveUtilAsyncTaskTestHelpers::StartRepeatScenario(3, 0.0f, 0.0f, true)) + { + ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickRepeatScenario(this, Cancelled, 3, 0, false)); + } + else + { + AddError(TEXT("Failed to create the cancelled Repeat with Interval scenario.")); + } + + UDirectiveUtilTask_RepeatWithInterval* NullWorldTask = UDirectiveUtilTask_RepeatWithInterval::RepeatWithInterval(nullptr, 1, 0.0f); + if (TestNotNull("RepeatWithInterval returns a task with a null context", NullWorldTask)) + { + NullWorldTask->AddToRoot(); + NullWorldTask->Activate(); + NullWorldTask->RemoveFromRoot(); + } + + return true; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FDirectiveUtilAsyncTaskShutdownTest, + "DirectiveUtilities.AsyncTaskShutdownTests", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter) + +bool FDirectiveUtilAsyncTaskShutdownTest::RunTest(const FString& Parameters) +{ + auto VerifyShutdown = [this](UDirectiveUtilDelegateListener* Listener, const FString& ScenarioName) { + if (!Listener) + { + AddError(FString::Printf(TEXT("Failed to create the %s shutdown scenario."), *ScenarioName)); + return; + } + + UCancellableAsyncAction* Task = Cast(Listener->Keepalive); + UGameInstance* GameInstance = Listener->ScenarioGameInstance.Get(); + UWorld* World = Listener->ScenarioWorld.Get(); + if (!TestNotNull(*FString::Printf(TEXT("%s creates a cancellable task"), *ScenarioName), Task) + || !TestNotNull(*FString::Printf(TEXT("%s creates a game instance"), *ScenarioName), GameInstance)) + { + DirectiveUtilAsyncTaskTestHelpers::DestroyScenario(Listener); + return; + } + + FWorldDelegates::OnWorldCleanup.Broadcast(World, true, true); + TestFalse(*FString::Printf(TEXT("%s is inactive after world cleanup"), *ScenarioName), Task->IsActive()); + if (World) + { + World->GetTimerManager().Tick(10.0f); + } + TestFalse(*FString::Printf(TEXT("%s does not complete after world cleanup"), *ScenarioName), Listener->bCompleted); + TestEqual(*FString::Printf(TEXT("%s emits no updates after world cleanup"), *ScenarioName), Listener->UpdatedCount, 0); + TestEqual(*FString::Printf(TEXT("%s emits no iterations after world cleanup"), *ScenarioName), Listener->IterationCount, 0); + DirectiveUtilAsyncTaskTestHelpers::DestroyScenario(Listener); + }; + + VerifyShutdown(DirectiveUtilAsyncTaskTestHelpers::StartDelayScenario(60.0f, false), TEXT("Cancellable Delay")); + VerifyShutdown(DirectiveUtilAsyncTaskTestHelpers::StartDurationScenario(60.0f, 1.0f, false), TEXT("Update for Duration")); + VerifyShutdown(DirectiveUtilAsyncTaskTestHelpers::StartRepeatScenario(60, 1.0f, 1.0f, false), TEXT("Repeat with Interval")); + return true; +} + DEFINE_LATENT_AUTOMATION_COMMAND_THREE_PARAMETER(FDirectiveUtilWaitForAsyncLoad, FAutomationTestBase*, Test, UDirectiveUtilDelegateListener*, Listener, int32, FramesRemaining); bool FDirectiveUtilWaitForAsyncLoad::Update() @@ -179,18 +924,12 @@ bool FDirectiveUtilWaitForAsyncLoad::Update() return false; } -/** - * DirectiveUtilTask_AsyncLoadAsset: verifies the null soft-reference path broadcasts Failed synchronously, and - * that loading a real engine asset eventually broadcasts Completed with the resolved object. - */ -IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilAsyncLoadAssetTest, "DirectiveUtilities.AsyncTaskLoadAssetTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilAsyncLoadAssetTest, "DirectiveUtilities.AsyncTaskLoadAssetTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter) bool FDirectiveUtilAsyncLoadAssetTest::RunTest(const FString& Parameters) { - // The null soft-reference path intentionally logs a warning. AddExpectedMessagePlain(TEXT("Async Load Asset failed to activate. The soft object reference is null."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1); - // Failure path: a null soft reference broadcasts Failed synchronously on activation. { UDirectiveUtilDelegateListener* Listener = NewObject(); Listener->AddToRoot(); @@ -208,7 +947,6 @@ bool FDirectiveUtilAsyncLoadAssetTest::RunTest(const FString& Parameters) Listener->Keepalive = nullptr; } - // Success path: loading a real engine asset broadcasts Completed with the resolved object. { UDirectiveUtilDelegateListener* Listener = NewObject(); Listener->AddToRoot(); @@ -220,14 +958,12 @@ bool FDirectiveUtilAsyncLoadAssetTest::RunTest(const FString& Parameters) Task->Failed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnObjectFailed); Task->Activate(); - // The streamable completion delegate fires on a later engine tick; poll until it settles. ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilWaitForAsyncLoad(this, Listener, 600)); } return true; } -/** Latent command that waits for a batch async-load listener to complete, then asserts the slot contract. */ DEFINE_LATENT_AUTOMATION_COMMAND_THREE_PARAMETER(FDirectiveUtilWaitForBatchAsyncLoad, FAutomationTestBase*, Test, UDirectiveUtilDelegateListener*, Listener, int32, FramesRemaining); bool FDirectiveUtilWaitForBatchAsyncLoad::Update() @@ -263,7 +999,6 @@ bool FDirectiveUtilWaitForBatchAsyncLoad::Update() return false; } -/** Latent command that waits a fixed frame window and then asserts a cancelled batch load never completed. */ DEFINE_LATENT_AUTOMATION_COMMAND_THREE_PARAMETER(FDirectiveUtilVerifyCancelledBatchLoad, FAutomationTestBase*, Test, UDirectiveUtilDelegateListener*, Listener, int32, FramesRemaining); bool FDirectiveUtilVerifyCancelledBatchLoad::Update() @@ -284,15 +1019,10 @@ bool FDirectiveUtilVerifyCancelledBatchLoad::Update() return true; } -/** - * DirectiveUtilTask_AsyncLoadAssets: verifies a batch resolves in input order with null slots for unset references, - * that an empty input completes immediately with an empty array, and that Cancel suppresses Completed. - */ -IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilAsyncLoadAssetsTest, "DirectiveUtilities.AsyncTaskLoadAssetsTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilAsyncLoadAssetsTest, "DirectiveUtilities.AsyncTaskLoadAssetsTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter) bool FDirectiveUtilAsyncLoadAssetsTest::RunTest(const FString& Parameters) { - // Empty input: Completed broadcasts synchronously on activation with an empty array. { UDirectiveUtilDelegateListener* Listener = NewObject(); Listener->AddToRoot(); @@ -310,7 +1040,6 @@ bool FDirectiveUtilAsyncLoadAssetsTest::RunTest(const FString& Parameters) Listener->RemoveFromRoot(); } - // Batch path: two valid engine meshes plus an unset reference resolve in input order. { UDirectiveUtilDelegateListener* Listener = NewObject(); Listener->AddToRoot(); @@ -325,19 +1054,18 @@ bool FDirectiveUtilAsyncLoadAssetsTest::RunTest(const FString& Parameters) Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnObjectsCompleted); Task->Activate(); - // The streamable completion delegate fires on a later engine tick; poll until it settles. ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilWaitForBatchAsyncLoad(this, Listener, 600)); } - // Cancel path: cancelling before completion suppresses the Completed broadcast. Uses a mesh no - // other test loads so the request is genuinely in flight when Cancel arrives; if the asset is - // already in memory the batch completes synchronously and there is nothing left to cancel. { UDirectiveUtilDelegateListener* Listener = NewObject(); Listener->AddToRoot(); TArray> Assets; - Assets.Add(TSoftObjectPtr(FSoftObjectPath(TEXT("/Engine/EngineMeshes/SM_MatPreviewMesh_01.SM_MatPreviewMesh_01")))); + // RuntimeHost always cooks /Engine/BasicShapes. Use an asset that the + // successful batch above did not load so this remains a valid cancellation + // probe without producing a missing-package warning in packaged builds. + Assets.Add(TSoftObjectPtr(FSoftObjectPath(TEXT("/Engine/BasicShapes/Cone.Cone")))); UDirectiveUtilTask_AsyncLoadAssets* Task = UDirectiveUtilTask_AsyncLoadAssets::AsyncLoadAssets(nullptr, Assets); Listener->Keepalive = Task; @@ -360,18 +1088,12 @@ bool FDirectiveUtilAsyncLoadAssetsTest::RunTest(const FString& Parameters) return true; } -/** - * DirectiveUtilTask_AsyncLoadClass: verifies the null soft-class path broadcasts Failed synchronously, and that - * loading a real class broadcasts Completed with the resolved class. - */ -IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilAsyncLoadClassTest, "DirectiveUtilities.AsyncTaskLoadClassTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilAsyncLoadClassTest, "DirectiveUtilities.AsyncTaskLoadClassTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter) bool FDirectiveUtilAsyncLoadClassTest::RunTest(const FString& Parameters) { - // The null soft-class path intentionally logs a warning. AddExpectedMessagePlain(TEXT("Async Load Class failed to activate. The soft class reference is null."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1); - // Failure path: a null soft class reference broadcasts Failed synchronously on activation. { UDirectiveUtilDelegateListener* Listener = NewObject(); Listener->AddToRoot(); @@ -389,7 +1111,6 @@ bool FDirectiveUtilAsyncLoadClassTest::RunTest(const FString& Parameters) Listener->Keepalive = nullptr; } - // Success path: loading a real class broadcasts Completed with the resolved class. { UDirectiveUtilDelegateListener* Listener = NewObject(); Listener->AddToRoot(); @@ -401,11 +1122,8 @@ bool FDirectiveUtilAsyncLoadClassTest::RunTest(const FString& Parameters) Task->Failed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnClassFailed); Task->Activate(); - // The streamable completion delegate fires on a later engine tick; poll until it settles. ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilWaitForAsyncLoad(this, Listener, 600)); } return true; } - -#endif // WITH_EDITOR diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilAsyncTraceTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilAsyncTraceTest.cpp index 0d1f2c64..f5654f14 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilAsyncTraceTest.cpp +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilAsyncTraceTest.cpp @@ -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(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(); Listener->AddToRoot(); @@ -142,8 +154,8 @@ bool FDirectiveUtilAsyncTraceTest::RunTest(const FString& Parameters) UStaticMesh* CubeMesh = LoadObject(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 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 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(); 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(); @@ -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(); @@ -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(); @@ -328,31 +277,44 @@ bool FDirectiveUtilMoveToLocationTest::RunTest(const FString& Parameters) Listener->RemoveFromRoot(); } + { + APlayerController* Controller = World->SpawnActor(); + ADefaultPawn* Pawn = World->SpawnActor(); + if (Controller && Pawn) + { + Controller->SetPawn(Pawn); + UDirectiveUtilTestMoveToLocationTask* Task = NewObject(); + 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(); ADefaultPawn* Pawn = MoveWorld->SpawnActor(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(); 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(); ADefaultPawn* Pawn = World->SpawnActor(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(); @@ -456,21 +416,35 @@ bool FDirectiveUtilMoveToActorTest::RunTest(const FString& Parameters) Listener->RemoveFromRoot(); } + { + APlayerController* Controller = World->SpawnActor(); + ADefaultPawn* Pawn = World->SpawnActor(); + AStaticMeshActor* Goal = World->SpawnActor(); + if (Controller && Pawn && Goal) + { + Controller->SetPawn(Pawn); + UDirectiveUtilTestMoveToActorTask* Task = NewObject(); + 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(); @@ -478,10 +452,10 @@ bool FDirectiveUtilMoveToActorTest::RunTest(const FString& Parameters) AStaticMeshActor* GoalActor = MoveWorld->SpawnActor(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 diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilBlueprintCategoryTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilBlueprintCategoryTest.cpp index bc7b0c8f..6ab1640a 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilBlueprintCategoryTest.cpp +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilBlueprintCategoryTest.cpp @@ -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 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; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilDependencyCycleFinderTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilDependencyCycleFinderTest.cpp new file mode 100644 index 00000000..3bc0b3a9 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilDependencyCycleFinderTest.cpp @@ -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> 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 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 Nodes; + Nodes.Reserve(NodeCount); + for (int32 Index = 0; Index < NodeCount; ++Index) + { + Nodes.Add(*FString::Printf(TEXT("/Game/Deep/%05d"), Index)); + } + + TMap> 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 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 diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilEditorActorLayoutTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilEditorActorLayoutTest.cpp new file mode 100644 index 00000000..7ccef7b2 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilEditorActorLayoutTest.cpp @@ -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 + +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(); + Actor->ClearFlags(RF_Transient); + UBoxComponent* Box = NewObject(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::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::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(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(); + 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 diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilEditorActorSubsystemTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilEditorActorSubsystemTest.cpp index ffe1a811..295ace8d 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilEditorActorSubsystemTest.cpp +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilEditorActorSubsystemTest.cpp @@ -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(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(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& S, TArray& 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 Match = RunFilter([&](const TArray& S, TArray& 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 Match = RunFilter([](const TArray& S, TArray& 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 Near = RunFilter([](const TArray& S, TArray& 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& S, TArray& 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(GetTransientPackage()); + const TArray Inc = RunFilter([&](const TArray& S, TArray& O) + { + UDirectiveUtilEditorActorSubsystem::FilterActorsByTexture(S, O, AbsentTexture, BaseAndOverride, Include); + }); + const TArray Exc = RunFilter([&](const TArray& S, TArray& 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(Cube, GetTransientPackage()); + MissingMaterialMesh->SetMaterial(0, nullptr); + UStaticMeshComponent* MissingMaterialComp = SpawnMesh(MissingMaterialMesh, EComponentMobility::Static); + UStaticMeshComponent* MissingMeshComp = SpawnMesh(nullptr, EComponentMobility::Static); + const TArray MissingSource = { MissingMaterialComp->GetOwner(), MissingMeshComp->GetOwner(), ActorA }; + TArray 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 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(GetTransientPackage()); + UMaterialExpressionTextureSample* MissingTextureExpression = NewObject(MissingTextureMaterial); + MissingTextureMaterial->GetExpressionCollection().AddExpression(MissingTextureExpression); + CompA->SetMaterial(0, MissingTextureMaterial); + const TArray MissingTextures = RunFilter([](const TArray& S, TArray& 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(); + World->DestroyActor(DestroyedActor); + TestFalse("Destroyed actor should be invalid", IsValid(DestroyedActor)); + TArray InvalidActors = {DestroyedActor}; + TArray 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(nullptr, TEXT("/Engine/BasicShapes/Cube.Cube")); - if (!Cube) + UMaterialInterface* BasicMaterial = LoadObject( + 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(); @@ -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(); + TArray 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 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 ByMaterial; + Subsystem->GetActorsByMaterial(ByMaterial, BasicMaterial, OverrideOnly, World, Include); + TestTrue("Material: generic actor with a static mesh component is included", ByMaterial.Contains(MeshActor)); + TArray 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 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 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 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 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 ByMeshSize; + Subsystem->GetActorsByMeshSize(ByMeshSize, MeshSize - 1.0f, MeshSize + 1.0f, World, Include); + TestTrue("MeshSize: enclosing range finds the fixture actor", ByMeshSize.Contains(MeshActor)); + TArray 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 ByLODCount; + Subsystem->GetActorsByLODCount(ByLODCount, LODCount, LODCount, World, Include); + TestTrue("LODCount: exact range finds the fixture actor", ByLODCount.Contains(MeshActor)); + TArray NaniteOff; + TArray 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 ByLightmapResolution; + Subsystem->GetActorsByLightmapResolution( + ByLightmapResolution, SourceLightmapResolution, SourceLightmapResolution, World, Include); + TestTrue("LightmapResolution: exact source resolution finds the fixture actor", ByLightmapResolution.Contains(MeshActor)); + TArray ByMobility; + Subsystem->GetActorsByMobility(ByMobility, EComponentMobility::Movable, World, Include); + TestTrue("Mobility query finds the movable fixture actor", ByMobility.Contains(MeshActor)); + + TArray ByStaticMesh; + Subsystem->GetActorsByStaticMesh(ByStaticMesh, Cube, World, Include); + TestTrue("StaticMesh reference query finds the fixture actor", ByStaticMesh.Contains(MeshActor)); + TArray ByStaticMeshSoft; + Subsystem->GetActorsByStaticMeshSoftReference(ByStaticMeshSoft, Cube, World, Include); + TestTrue("StaticMesh soft-reference query finds the fixture actor", ByStaticMeshSoft.Contains(MeshActor)); + + UTexture2D* AbsentTexture = NewObject(GetTransientPackage()); + TArray ByTexture; + Subsystem->GetActorsByTexture(ByTexture, AbsentTexture, World, Include); + TestFalse("Texture reference query rejects an absent texture", ByTexture.Contains(MeshActor)); + TArray ByTextureSoft; + Subsystem->GetActorsByTextureSoftReference(ByTextureSoft, AbsentTexture, World, Include); + TestFalse("Texture soft-reference query rejects an absent texture", ByTextureSoft.Contains(MeshActor)); + TArray ByTextureName; + Subsystem->GetActorsByTextureName(ByTextureName, TEXT("__udcore_absent_texture__"), World, Include); + TestFalse("Texture-name query rejects an absent texture", ByTextureName.Contains(MeshActor)); + + TArray 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 Source = { MeshActor }; TArray 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( + nullptr, TEXT("/Engine/BasicShapes/BasicShapeMaterial.BasicShapeMaterial")); + UMaterialInterface* OverrideMaterial = LoadObject( + nullptr, TEXT("/Engine/EngineMaterials/WorldGridMaterial.WorldGridMaterial")); + UStaticMesh* Cube = LoadObject(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(Cube, GetTransientPackage()); + StaticMesh->GetStaticMaterials()[0].MaterialInterface = BaseMaterial; + UStaticMeshComponent* StaticMeshComponent = NewObject(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(Cube, GetTransientPackage()); + MeshWithoutOverride->GetStaticMaterials()[0].MaterialInterface = BaseMaterial; + UStaticMeshComponent* ComponentWithoutOverride = NewObject(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(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) diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilEditorAssetAuditLibraryTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilEditorAssetAuditLibraryTest.cpp new file mode 100644 index 00000000..b5b90c66 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilEditorAssetAuditLibraryTest.cpp @@ -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 diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilEditorAssetLibraryTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilEditorAssetLibraryTest.cpp index 722cf2d0..d4b23a1d 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilEditorAssetLibraryTest.cpp +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilEditorAssetLibraryTest.cpp @@ -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() + : 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( + 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 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(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; } diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilEditorBlueprintLibraryTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilEditorBlueprintLibraryTest.cpp new file mode 100644 index 00000000..d0e4857f --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilEditorBlueprintLibraryTest.cpp @@ -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& 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(); + const TArray> 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& 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 diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilEditorTaskLibraryTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilEditorTaskLibraryTest.cpp new file mode 100644 index 00000000..272162e4 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilEditorTaskLibraryTest.cpp @@ -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 + +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::quiet_NaN(), + FText::GetEmpty())); + TestNull( + "StartEditorSlowTask should reject infinite work", + UDirectiveUtilEditorTaskLibrary::StartEditorSlowTask( + std::numeric_limits::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(); + 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::quiet_NaN(), FText::GetEmpty())); + TestFalse("Advance should reject infinite work", Task->Advance(std::numeric_limits::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::quiet_NaN(), FText::GetEmpty(), false, false); + TestFalse("Initialize should leave a task inactive for NaN work", Task->IsActive()); + Task->Initialize(std::numeric_limits::infinity(), FText::GetEmpty(), false, false); + TestFalse("Initialize should leave a task inactive for infinite work", Task->IsActive()); + + UDirectiveUtilEditorSlowTask* FirstTask = NewObject(); + UDirectiveUtilEditorSlowTask* SecondTask = NewObject(); + 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 AbandonedTask = NewObject(); + 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(); + 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::quiet_NaN())); + + return true; +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilFunctionLibraryTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilFunctionLibraryTest.cpp index 892a6c4a..5520b73e 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilFunctionLibraryTest.cpp +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilFunctionLibraryTest.cpp @@ -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(); + 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(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()); + TestNotNull("The build configuration enum should be reflected", StaticEnum()); + TestNotNull("The build target type enum should be reflected", StaticEnum()); + + 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 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 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 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 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 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; } diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilGameplayTagFunctionLibraryTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilGameplayTagFunctionLibraryTest.cpp index 24e40e3d..117cd62f 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilGameplayTagFunctionLibraryTest.cpp +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilGameplayTagFunctionLibraryTest.cpp @@ -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 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; } diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilInputActivePathTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilInputActivePathTest.cpp index 2d7634e1..698c141c 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilInputActivePathTest.cpp +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilInputActivePathTest.cpp @@ -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(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(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(); 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(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(GetTransientPackage()); UInputMappingContext* ContextB = NewObject(GetTransientPackage()); @@ -84,7 +82,6 @@ bool FDirectiveUtilInputActivePathTest::RunTest(const FString& Parameters) const TSoftObjectPtr SoftA(ContextA); const TSoftObjectPtr 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> 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 UnresolvableToAdd; @@ -142,5 +148,3 @@ bool FDirectiveUtilInputActivePathTest::RunTest(const FString& Parameters) return true; } - -#endif // WITH_EDITOR diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilInputFunctionLibraryTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilInputFunctionLibraryTest.cpp index 1748b111..37224a0d 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilInputFunctionLibraryTest.cpp +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilInputFunctionLibraryTest.cpp @@ -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 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 NullContext; TestEqual("SwapInputMappingContexts should fail when both contexts are invalid", UDirectiveUtilInputFunctionLibrary::SwapInputMappingContexts(nullptr, NullContext, NullContext, 0, false), diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilMapFunctionLibraryTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilMapFunctionLibraryTest.cpp index a8e3c5a3..3136c2dc 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilMapFunctionLibraryTest.cpp +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilMapFunctionLibraryTest.cpp @@ -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; } diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilMapNodeWildcardTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilMapNodeWildcardTest.cpp new file mode 100644 index 00000000..3415ff2d --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilMapNodeWildcardTest.cpp @@ -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 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(); + Blueprint->GeneratedClass = UObject::StaticClass(); + Blueprint->SkeletonGeneratedClass = UObject::StaticClass(); + UEdGraph* Graph = NewObject(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(&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(&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 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(UDirectiveUtilTestObject::StaticClass()) + : static_cast(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(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(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 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(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; +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilMapScenarioTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilMapScenarioTest.cpp new file mode 100644 index 00000000..dcd54621 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilMapScenarioTest.cpp @@ -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 + PropertyType* GetTestProperty(const FName PropertyName) + { + return FindFProperty(UDirectiveUtilTestObject::StaticClass(), PropertyName); + } + + TSet MakeIntegerSet(const TArray& Values) + { + TSet Result; + for (const int32 Value : Values) + { + Result.Add(Value); + } + return Result; + } + + TSet MakeStringSet(const TArray& Values) + { + TSet Result; + for (const FString& Value : Values) + { + Result.Add(Value); + } + return Result; + } + + TSet MakeNameSet(const TArray& Values) + { + TSet Result; + for (const FName Value : Values) + { + Result.Add(Value); + } + return Result; + } + + template + bool HaveSameValues(const TSet& Left, const TSet& 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(GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestMap)); + FArrayProperty* IntegerArrayProperty = GetTestProperty(GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestArray)); + FMapProperty* StringMapProperty = GetTestProperty(GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestStringMap)); + FMapProperty* StringMapProperty2 = GetTestProperty(GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestStringMap2)); + FArrayProperty* StringArrayProperty = GetTestProperty(GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestStringArray)); + FMapProperty* StructMapProperty = GetTestProperty(GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestStructValueMap)); + FArrayProperty* NameArrayProperty = GetTestProperty(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(); + 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 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& 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 ExpectedKeys = ItemCount > 0 ? TArray({TEXT("Key0")}) : TArray(); + TestTrue(Label + TEXT(" managed GetKeysByValue"), HaveSameValues(MakeStringSet(TestObject->TestStringArray), MakeStringSet(ExpectedKeys))); + + UDirectiveUtilMapFunctionLibrary::GenericMap_ClearValues(&TestObject->TestStringMap, StringMapProperty); + for (const TPair& 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({TEXT("Alpha"), TEXT("Gamma")})))); + TestTrue( + "Struct HasValue", + UDirectiveUtilMapFunctionLibrary::GenericMap_HasValue( + &TestObject->TestStructValueMap, + StructMapProperty, + &StructQuery)); + UDirectiveUtilMapFunctionLibrary::GenericMap_ClearValues(&TestObject->TestStructValueMap, StructMapProperty); + for (const TPair& Pair : TestObject->TestStructValueMap) + { + TestEqual("Struct ClearValues", Pair.Value.Value, 0); + } + + return true; +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilMathExtendedFunctionLibraryTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilMathExtendedFunctionLibraryTest.cpp new file mode 100644 index 00000000..4271dc40 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilMathExtendedFunctionLibraryTest.cpp @@ -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 + +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::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::infinity(), 0.0, 0.0)}, + {1.0f, 1.0f}, VectorResult)); + + TArray 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({5.0f, 5.0f})); + TestFalse("Float array normalization rejects an empty array", + UDirectiveUtilMathFunctionLibrary::NormalizeFloatArrayToRange( + {}, 0.0f, 1.0f, FloatArrayResult)); + TArray 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({-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 InPlaceWeights = {1.0f, 3.0f}; + TestTrue("Weight normalization supports the same input and output array", + UDirectiveUtilMathFunctionLibrary::NormalizeWeights(InPlaceWeights, InPlaceWeights) + && InPlaceWeights == TArray({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::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::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 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 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(ExpectedCircleStream.FRand()) * UE_TWO_PI; + const double ExpectedCircleRadius = FMath::Sqrt(static_cast(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(ExpectedAnnulusStream.FRand()) * UE_TWO_PI; + const double ExpectedAnnulusRadius = FMath::Sqrt(FMath::Lerp( + 4.0, 25.0, static_cast(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(ExpectedSphereStream.FRand()) * 2.0 - 1.0; + const double Y = static_cast(ExpectedSphereStream.FRand()) * 2.0 - 1.0; + const double Z = static_cast(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::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::quiet_NaN()).IsZero() + && UDirectiveUtilMathFunctionLibrary::RandomPointInAnnulus( + 0.0f, std::numeric_limits::infinity()).IsZero() + && UDirectiveUtilMathFunctionLibrary::RandomPointInSphere( + std::numeric_limits::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::quiet_NaN(), -1.0f, std::numeric_limits::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::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::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::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::infinity(), FloatArrayResult)); + + TestTrue("Weight normalization ignores non-finite and negative weights", + UDirectiveUtilMathFunctionLibrary::NormalizeWeights( + {std::numeric_limits::quiet_NaN(), std::numeric_limits::infinity(), -2.0f, 4.0f}, + FloatArrayResult) + && FloatArrayResult == TArray({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::quiet_NaN(), FloatResult)); + TestTrue("A rejected percentile resets its output", FMath::IsNearlyZero(FloatResult)); + + const float LargeFiniteValue = std::numeric_limits::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::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::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::quiet_NaN(), 0.0f, 1.0f)) + && FMath::IsNearlyZero(UDirectiveUtilMathFunctionLibrary::SmootherStep( + 0.5f, 0.0f, std::numeric_limits::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(); +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilMathFunctionLibraryTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilMathFunctionLibraryTest.cpp index 9e5930b8..d67e75b4 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilMathFunctionLibraryTest.cpp +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilMathFunctionLibraryTest.cpp @@ -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 + +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::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::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::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::quiet_NaN())); { const FVector2D Sample2D(12.34f, 56.78f); @@ -75,15 +155,16 @@ bool FDirectiveUtilMathFunctionLibraryTest::RunTest(const FString& Parameters) const TArray 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(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()), static_cast(INDEX_NONE)); TestEqual("Weighted random with all-zero weights returns INDEX_NONE", UDirectiveUtilMathFunctionLibrary::GetRandomIndexFromWeights({0.0f, 0.0f, 0.0f}), static_cast(INDEX_NONE)); + TestEqual("Weighted random ignores non-finite weights", + UDirectiveUtilMathFunctionLibrary::GetRandomIndexFromWeights({std::numeric_limits::quiet_NaN(), 1.0f, std::numeric_limits::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::quiet_NaN(), 3.0f}))); + + FRandomStream MedianStream(481516); + for (int32 Iteration = 0; Iteration < 200; ++Iteration) + { + const int32 Count = MedianStream.RandRange(1, 257); + TArray IntValues; + TArray 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(Value) * 0.25f); + } + + TArray SortedInts = IntValues; + SortedInts.Sort(); + const int32 Middle = SortedInts.Num() / 2; + const float ExpectedIntMedian = SortedInts.Num() % 2 == 0 + ? static_cast((static_cast(SortedInts[Middle - 1]) + static_cast(SortedInts[Middle])) * 0.5) + : static_cast(SortedInts[Middle]); + TestTrue( + FString::Printf(TEXT("Integer median fuzz case %d"), Iteration), + FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::GetIntArrayMedian(IntValues), ExpectedIntMedian, 1.e-4f)); + + TArray SortedFloats = FloatValues; + SortedFloats.Sort(); + const float ExpectedFloatMedian = SortedFloats.Num() % 2 == 0 + ? static_cast((static_cast(SortedFloats[Middle - 1]) + static_cast(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}); diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilMathScenarioTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilMathScenarioTest.cpp new file mode 100644 index 00000000..143552d4 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilMathScenarioTest.cpp @@ -0,0 +1,83 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#include "Libraries/DirectiveUtilMathFunctionLibrary.h" + +#include "Misc/AutomationTest.h" + +namespace +{ + template + double GetSortedMedian(TArray Values) + { + Values.Sort(); + const int32 Middle = Values.Num() / 2; + if (Values.Num() % 2 != 0) + { + return static_cast(Values[Middle]); + } + return (static_cast(Values[Middle - 1]) + static_cast(Values[Middle])) * 0.5; + } + + TArray MakeMedianValues(const int32 Count, const int32 Pattern) + { + TArray 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 IntegerValues = MakeMedianValues(ItemCount, Pattern); + TArray FloatValues; + FloatValues.Reserve(ItemCount); + for (const int32 Value : IntegerValues) + { + FloatValues.Add(static_cast(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(GetSortedMedian(IntegerValues)), + 1.e-4f)); + TestTrue( + Label + TEXT(" float median"), + FMath::IsNearlyEqual( + UDirectiveUtilMathFunctionLibrary::GetFloatArrayMedian(FloatValues), + static_cast(GetSortedMedian(FloatValues)), + 1.e-4f)); + } + } + + return true; +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilPointGenerationTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilPointGenerationTest.cpp new file mode 100644 index 00000000..bdf7d040 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilPointGenerationTest.cpp @@ -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 + +namespace +{ + bool PointsEqual(const TArray& A, const TArray& 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& BasePoints, const TArray& 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& 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 Grid2D = UDirectiveUtilMathFunctionLibrary::GenerateGridPoints2D( + FVector::ZeroVector, FRotator::ZeroRotator, FIntPoint(3, 2), FVector2D(10.0, 20.0), true); + const TArray 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 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 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 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 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::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 HexNeighbors = UDirectiveUtilMathFunctionLibrary::GetHexNeighbors(FIntPoint(3, -2)); + const TArray 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 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 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 HexagonalGrid = UDirectiveUtilMathFunctionLibrary::GenerateHexagonalHexGrid( + FVector::ZeroVector, FRotator::ZeroRotator, 2, HexRadius, + EDirectiveUtilHexOrientation::PointyTop); + TSet 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 HexesInRange = UDirectiveUtilMathFunctionLibrary::GetHexesInRange(FIntPoint(2, -1), 1); + const TArray 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 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 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 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 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 HexLine = UDirectiveUtilMathFunctionLibrary::GetHexLine( + FIntPoint(0, 0), FIntPoint(3, -3)); + const TArray 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 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 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 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 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 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 NoiseBase = { + FVector::ZeroVector, FVector(37.0, 11.0, 5.0), FVector(250.0, -90.0, 40.0) + }; + const TArray 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 DirectionPoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongDirection( + FVector::ZeroVector, FVector(10.0, 0.0, 0.0), 4, 2.0, true); + const TArray 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 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 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 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 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(); + 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 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 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 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 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 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 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 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 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(); + SinglePointSpline->SetSplinePoints({ FVector(3.0, 4.0, 5.0) }, ESplineCoordinateSpace::Local, true); + const TArray 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(255)).IsEmpty() + && UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline( + Spline, 10.0, true, EDirectiveUtilSplineSpacingMode::Fixed, + static_cast(255)).IsEmpty() + && UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline( + Spline, 10.0, true, EDirectiveUtilSplineSpacingMode::Fixed, + ESplineCoordinateSpace::World, 80.0, 20.0).IsEmpty() + && UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSplineByCount(Spline, 0).IsEmpty()); + + const TArray CirclePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnCircle( + FVector::ZeroVector, FRotator::ZeroRotator, 10.0, 4, 0.0); + const TArray 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 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 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 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 DiscPoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnDisc( + PlaneCenter, PlaneRotation, 25.0, 1024, 27.0); + const TArray 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 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 SpherePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnSphere( + FVector::ZeroVector, PlaneRotation, 25.0, 1024, 27.0); + const TArray 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 LocalSpherePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnSphere( + FVector::ZeroVector, FRotator::ZeroRotator, 17.0, 257, 23.5); + const TArray 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 UnoffsetSpherePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnSphere( + FVector::ZeroVector, FRotator::ZeroRotator, 17.0, 257, 0.0); + const TArray 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 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 ZeroRadiusCircle = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnCircle( + LargeTranslation, AuditRotation, 0.0, 9, 123.0); + const TArray ZeroRadiusArc = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnArc( + LargeTranslation, AuditRotation, 0.0, 9, -30.0, -720.0, false); + const TArray ZeroRadiusDisc = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnDisc( + LargeTranslation, AuditRotation, 0.0, 9, 123.0); + const TArray 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(); + 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 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 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::infinity(); + const double NaN = std::numeric_limits::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(); +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilRegexFunctionLibraryTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilRegexFunctionLibraryTest.cpp index 1990fb4d..a61f8850 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilRegexFunctionLibraryTest.cpp +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilRegexFunctionLibraryTest.cpp @@ -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) { diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilRuntimePerformanceTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilRuntimePerformanceTest.cpp new file mode 100644 index 00000000..9b82595a --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilRuntimePerformanceTest.cpp @@ -0,0 +1,1517 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#include "Libraries/DirectiveUtilArrayFunctionLibrary.h" +#include "Libraries/DirectiveUtilMathFunctionLibrary.h" +#include "Libraries/DirectiveUtilStringFunctionLibrary.h" +#include "Tests/DirectiveUtilTestObject.h" + +#include "Algo/Reverse.h" +#include "HAL/FileManager.h" +#include "HAL/PlatformMisc.h" +#include "HAL/PlatformProperties.h" +#include "HAL/PlatformTime.h" +#include "Interfaces/IPluginManager.h" +#include "Kismet/KismetArrayLibrary.h" +#include "Misc/AutomationTest.h" +#include "Misc/CommandLine.h" +#include "Misc/DateTime.h" +#include "Misc/EngineVersion.h" +#include "Misc/FileHelper.h" +#include "Misc/Parse.h" +#include "Misc/Paths.h" +#include "UObject/UnrealType.h" + +namespace DirectiveUtilRuntimePerformance +{ + struct FResult + { + FString Name; + int32 ElementCount = 0; + int32 Parameter = 0; + double MedianMilliseconds = 0.0; + double MinimumMilliseconds = 0.0; + double MaximumMilliseconds = 0.0; + int32 SampleCount = 0; + + FString GetKey() const + { + return FString::Printf(TEXT("%s|%d|%d"), *Name, ElementCount, Parameter); + } + }; + + struct FComparisonResult + { + FString ElementType; + FString Pattern; + int32 ElementCount = 0; + int32 MatchCount = 0; + double BeforeMedianMilliseconds = 0.0; + double BeforeMinimumMilliseconds = 0.0; + double BeforeMaximumMilliseconds = 0.0; + double AfterMedianMilliseconds = 0.0; + double AfterMinimumMilliseconds = 0.0; + double AfterMaximumMilliseconds = 0.0; + int32 SampleCount = 0; + bool bOutputsMatch = true; + }; + + struct FAppendComparisonResult + { + FString ElementType; + FString Scenario; + int32 SourceCount = 0; + int32 InitialTargetCount = 0; + double BeforeMedianMilliseconds = 0.0; + double BeforeMinimumMilliseconds = 0.0; + double BeforeMaximumMilliseconds = 0.0; + double AfterMedianMilliseconds = 0.0; + double AfterMinimumMilliseconds = 0.0; + double AfterMaximumMilliseconds = 0.0; + int32 SampleCount = 0; + bool bOutputsMatch = true; + }; + + template + FResult Measure( + const FString& Name, + const int32 ElementCount, + const int32 Parameter, + const int32 SampleCount, + PrepareType&& Prepare, + OperationType&& Operation) + { + Prepare(); + Operation(); + const double WarmupStartSeconds = FPlatformTime::Seconds(); + do + { + Prepare(); + Operation(); + } + while (FPlatformTime::Seconds() - WarmupStartSeconds < 0.01); + + TArray Samples; + Samples.Reserve(SampleCount); + for (int32 SampleIndex = 0; SampleIndex < SampleCount; ++SampleIndex) + { + Prepare(); + const uint64 StartCycles = FPlatformTime::Cycles64(); + Operation(); + const uint64 ElapsedCycles = FPlatformTime::Cycles64() - StartCycles; + Samples.Add(FPlatformTime::ToMilliseconds64(ElapsedCycles)); + } + + Samples.Sort(); + FResult Result; + Result.Name = Name; + Result.ElementCount = ElementCount; + Result.Parameter = Parameter; + Result.MedianMilliseconds = Samples[Samples.Num() / 2]; + Result.MinimumMilliseconds = Samples[0]; + Result.MaximumMilliseconds = Samples.Last(); + Result.SampleCount = SampleCount; + return Result; + } + + template + FAppendComparisonResult MeasureAppendComparison( + const FString& ElementType, + const FString& Scenario, + const ArrayType& InitialTarget, + const ArrayType& Source, + const int32 SampleCount, + BeforeOperationType&& BeforeOperation, + AfterOperationType&& AfterOperation) + { + ArrayType BeforeTarget; + ArrayType AfterTarget; + ArrayType BeforeSource; + ArrayType AfterSource; + auto Prepare = [&]() + { + BeforeTarget = InitialTarget; + AfterTarget = InitialTarget; + BeforeSource = Source; + AfterSource = Source; + }; + auto RunBefore = [&]() + { + BeforeOperation(BeforeTarget, BeforeSource); + }; + auto RunAfter = [&]() + { + AfterOperation(AfterTarget, AfterSource); + }; + + Prepare(); + RunBefore(); + RunAfter(); + bool bOutputsMatch = BeforeTarget == AfterTarget + && BeforeSource == Source + && AfterSource == Source; + + TArray BeforeSamples; + TArray AfterSamples; + BeforeSamples.Reserve(SampleCount); + AfterSamples.Reserve(SampleCount); + auto TimeOperation = [](auto&& Operation) + { + const uint64 StartCycles = FPlatformTime::Cycles64(); + Operation(); + return FPlatformTime::ToMilliseconds64(FPlatformTime::Cycles64() - StartCycles); + }; + + for (int32 SampleIndex = 0; SampleIndex < SampleCount; ++SampleIndex) + { + Prepare(); + if (SampleIndex % 2 == 0) + { + BeforeSamples.Add(TimeOperation(RunBefore)); + AfterSamples.Add(TimeOperation(RunAfter)); + } + else + { + AfterSamples.Add(TimeOperation(RunAfter)); + BeforeSamples.Add(TimeOperation(RunBefore)); + } + bOutputsMatch = bOutputsMatch + && BeforeTarget == AfterTarget + && BeforeSource == Source + && AfterSource == Source; + } + + BeforeSamples.Sort(); + AfterSamples.Sort(); + FAppendComparisonResult Result; + Result.ElementType = ElementType; + Result.Scenario = Scenario; + Result.SourceCount = Source.Num(); + Result.InitialTargetCount = InitialTarget.Num(); + Result.BeforeMedianMilliseconds = BeforeSamples[BeforeSamples.Num() / 2]; + Result.BeforeMinimumMilliseconds = BeforeSamples[0]; + Result.BeforeMaximumMilliseconds = BeforeSamples.Last(); + Result.AfterMedianMilliseconds = AfterSamples[AfterSamples.Num() / 2]; + Result.AfterMinimumMilliseconds = AfterSamples[0]; + Result.AfterMaximumMilliseconds = AfterSamples.Last(); + Result.SampleCount = SampleCount; + Result.bOutputsMatch = bOutputsMatch; + return Result; + } + + template + FComparisonResult MeasureComparison( + const FString& ElementType, + const FString& Pattern, + const ArrayType& Source, + const int32 MatchCount, + const int32 SampleCount, + BeforeOperationType&& BeforeOperation, + AfterOperationType&& AfterOperation) + { + ArrayType BeforeValues = Source; + ArrayType AfterValues = Source; + bool bBeforeRemoved = BeforeOperation(BeforeValues); + bool bAfterRemoved = AfterOperation(AfterValues); + + TArray BeforeSamples; + TArray AfterSamples; + BeforeSamples.Reserve(SampleCount); + AfterSamples.Reserve(SampleCount); + + bool bOutputsMatch = bBeforeRemoved == bAfterRemoved && BeforeValues == AfterValues; + auto TimeOperation = [](auto&& Operation) + { + const uint64 StartCycles = FPlatformTime::Cycles64(); + const bool bRemoved = Operation(); + const uint64 ElapsedCycles = FPlatformTime::Cycles64() - StartCycles; + return TPair(FPlatformTime::ToMilliseconds64(ElapsedCycles), bRemoved); + }; + + for (int32 SampleIndex = 0; SampleIndex < SampleCount; ++SampleIndex) + { + BeforeValues = Source; + AfterValues = Source; + + TPair BeforeTiming; + TPair AfterTiming; + if (SampleIndex % 2 == 0) + { + BeforeTiming = TimeOperation([&]() { return BeforeOperation(BeforeValues); }); + AfterTiming = TimeOperation([&]() { return AfterOperation(AfterValues); }); + } + else + { + AfterTiming = TimeOperation([&]() { return AfterOperation(AfterValues); }); + BeforeTiming = TimeOperation([&]() { return BeforeOperation(BeforeValues); }); + } + + BeforeSamples.Add(BeforeTiming.Key); + AfterSamples.Add(AfterTiming.Key); + bOutputsMatch = bOutputsMatch + && BeforeTiming.Value == AfterTiming.Value + && BeforeValues == AfterValues; + } + + BeforeSamples.Sort(); + AfterSamples.Sort(); + FComparisonResult Result; + Result.ElementType = ElementType; + Result.Pattern = Pattern; + Result.ElementCount = Source.Num(); + Result.MatchCount = MatchCount; + Result.BeforeMedianMilliseconds = BeforeSamples[BeforeSamples.Num() / 2]; + Result.BeforeMinimumMilliseconds = BeforeSamples[0]; + Result.BeforeMaximumMilliseconds = BeforeSamples.Last(); + Result.AfterMedianMilliseconds = AfterSamples[AfterSamples.Num() / 2]; + Result.AfterMinimumMilliseconds = AfterSamples[0]; + Result.AfterMaximumMilliseconds = AfterSamples.Last(); + Result.SampleCount = SampleCount; + Result.bOutputsMatch = bOutputsMatch; + return Result; + } + + TArray MakeSequentialIntegers(const int32 Count) + { + TArray Values; + Values.SetNumUninitialized(Count); + for (int32 Index = 0; Index < Count; ++Index) + { + Values[Index] = Index; + } + return Values; + } + + TArray MakeShuffledIndices(const int32 Count) + { + TArray Indices = MakeSequentialIntegers(Count); + FRandomStream RandomStream(1729); + for (int32 Index = Count - 1; Index > 0; --Index) + { + Indices.Swap(Index, RandomStream.RandRange(0, Index)); + } + return Indices; + } + + TArray MakeRepeatingIntegers(const int32 Count, const int32 DistinctCount) + { + TArray Values; + Values.Reserve(Count); + for (int32 Index = 0; Index < Count; ++Index) + { + Values.Add(Index % DistinctCount); + } + return Values; + } + + TArray MakeSequentialPodValues(const int32 Count) + { + TArray Values; + Values.SetNumUninitialized(Count); + for (int32 Index = 0; Index < Count; ++Index) + { + Values[Index].Index = Index; + Values[Index].Weight = static_cast(Index) + 0.5f; + } + return Values; + } + + bool IsRemovalMatch(const FString& Pattern, const int32 Index, const int32 Count) + { + if (Pattern == TEXT("single_tail")) + { + return Index == Count - 1; + } + if (Pattern == TEXT("every_64")) + { + return Index % 64 == 63; + } + if (Pattern == TEXT("clustered")) + { + return Index >= Count / 3 && Index < Count * 2 / 3; + } + if (Pattern == TEXT("alternating")) + { + return Index % 2 == 0; + } + return Pattern == TEXT("all"); + } + + TArray MakeRemovalIntegers( + const int32 Count, + const FString& Pattern, + const int32 ItemToRemove, + int32& OutMatchCount) + { + TArray Values; + Values.SetNumUninitialized(Count); + OutMatchCount = 0; + for (int32 Index = 0; Index < Count; ++Index) + { + const bool bMatches = Pattern != TEXT("no_match") && IsRemovalMatch(Pattern, Index, Count); + Values[Index] = bMatches ? ItemToRemove : Index + 1; + OutMatchCount += bMatches ? 1 : 0; + } + return Values; + } + + TArray MakeRemovalStrings( + const int32 Count, + const FString& Pattern, + const FString& ItemToRemove, + int32& OutMatchCount) + { + TArray Values; + Values.Reserve(Count); + OutMatchCount = 0; + for (int32 Index = 0; Index < Count; ++Index) + { + const bool bMatches = Pattern != TEXT("no_match") && IsRemovalMatch(Pattern, Index, Count); + Values.Add(bMatches ? ItemToRemove : FString::Printf(TEXT("Value%06d"), Index)); + OutMatchCount += bMatches ? 1 : 0; + } + return Values; + } + + TArray MakeNaturalSortStrings(const int32 Count) + { + const TArray Indices = MakeShuffledIndices(Count); + TArray Values; + Values.Reserve(Count); + for (const int32 Index : Indices) + { + Values.Add(FString::Printf(TEXT("Item%d"), Index)); + } + return Values; + } + + TArray MakeNaturalSortNames(const int32 Count) + { + const TArray Indices = MakeShuffledIndices(Count); + TArray Values; + Values.Reserve(Count); + for (const int32 Index : Indices) + { + Values.Add(FName(*FString::Printf(TEXT("Actor%d"), Index))); + } + return Values; + } + + TArray MakeStringMatchCandidates(const int32 Count) + { + TArray Candidates; + Candidates.Reserve(Count); + for (int32 Index = 0; Index < Count; ++Index) + { + Candidates.Add(FString::Printf(TEXT("DirectiveUtilityCandidate%05d"), Index)); + } + return Candidates; + } + + FString GetOutputPath() + { + FString OutputPath; + if (!FParse::Value(FCommandLine::Get(), TEXT("DirectiveUtilitiesPerfOutput="), OutputPath)) + { + OutputPath = FPaths::ProjectSavedDir() / TEXT("Automation/DirectiveUtilities/RuntimePerformance.csv"); + } + return FPaths::ConvertRelativePathToFull(OutputPath); + } + + FString GetComparisonOutputPath() + { + FString OutputPath; + if (!FParse::Value(FCommandLine::Get(), TEXT("DirectiveUtilitiesPerfComparisonOutput="), OutputPath)) + { + const FString RuntimeOutputPath = GetOutputPath(); + OutputPath = FPaths::GetPath(RuntimeOutputPath) + / (FPaths::GetBaseFilename(RuntimeOutputPath) + TEXT("-remove-all-comparison.csv")); + } + return FPaths::ConvertRelativePathToFull(OutputPath); + } + + FString GetAppendComparisonOutputPath() + { + FString OutputPath; + if (!FParse::Value(FCommandLine::Get(), TEXT("DirectiveUtilitiesPerfAppendComparisonOutput="), OutputPath)) + { + const FString RuntimeOutputPath = GetOutputPath(); + OutputPath = FPaths::GetPath(RuntimeOutputPath) + / (FPaths::GetBaseFilename(RuntimeOutputPath) + TEXT("-append-comparison.csv")); + } + return FPaths::ConvertRelativePathToFull(OutputPath); + } + + FString GetInsertComparisonOutputPath() + { + const FString RuntimeOutputPath = GetOutputPath(); + return FPaths::ConvertRelativePathToFull( + FPaths::GetPath(RuntimeOutputPath) + / (FPaths::GetBaseFilename(RuntimeOutputPath) + TEXT("-insert-comparison.csv"))); + } + + FString GetRemoveIndicesComparisonOutputPath() + { + const FString RuntimeOutputPath = GetOutputPath(); + return FPaths::ConvertRelativePathToFull( + FPaths::GetPath(RuntimeOutputPath) + / (FPaths::GetBaseFilename(RuntimeOutputPath) + TEXT("-remove-indices-comparison.csv"))); + } + + FString GetBuildConfigurationName() + { +#if UE_BUILD_DEBUG + return TEXT("Debug"); +#elif UE_BUILD_DEVELOPMENT + return TEXT("Development"); +#elif UE_BUILD_TEST + return TEXT("Test"); +#elif UE_BUILD_SHIPPING + return TEXT("Shipping"); +#else + return TEXT("Unknown"); +#endif + } + + FString GetPluginVersion() + { + const TSharedPtr Plugin = IPluginManager::Get().FindPlugin(TEXT("DirectiveUtilities")); + return Plugin.IsValid() ? Plugin->GetDescriptor().VersionName : TEXT("Unknown"); + } + + FString SanitizeMetadata(FString Value) + { + Value.ReplaceInline(TEXT(","), TEXT(";")); + Value.ReplaceInline(TEXT("\r"), TEXT(" ")); + Value.ReplaceInline(TEXT("\n"), TEXT(" ")); + return Value; + } + + bool LoadBaseline(TMap& OutMedians, FString& OutPath) + { + if (!FParse::Value(FCommandLine::Get(), TEXT("DirectiveUtilitiesPerfBaseline="), OutPath)) + { + return true; + } + + OutPath = FPaths::ConvertRelativePathToFull(OutPath); + FString Contents; + if (!FFileHelper::LoadFileToString(Contents, *OutPath)) + { + return false; + } + + TArray Lines; + Contents.ParseIntoArrayLines(Lines); + for (const FString& Line : Lines) + { + if (Line.IsEmpty() || Line.StartsWith(TEXT("#")) || Line.StartsWith(TEXT("benchmark,"))) + { + continue; + } + + TArray Fields; + Line.ParseIntoArray(Fields, TEXT(","), false); + if (Fields.Num() < 4) + { + continue; + } + + const FString Key = FString::Printf( + TEXT("%s|%d|%d"), + *Fields[0], + FCString::Atoi(*Fields[1]), + FCString::Atoi(*Fields[2])); + OutMedians.Add(Key, FCString::Atod(*Fields[3])); + } + return true; + } + + FString BuildCsv( + const TArray& Results, + const TMap& BaselineMedians, + const FString& BaselinePath) + { + FString Revision; + FParse::Value(FCommandLine::Get(), TEXT("DirectiveUtilitiesPerfRevision="), Revision); + + FString Csv; + Csv += FString::Printf(TEXT("#engine,%s\n"), *SanitizeMetadata(FEngineVersion::Current().ToString())); + Csv += FString::Printf(TEXT("#platform,%hs\n"), FPlatformProperties::PlatformName()); + Csv += FString::Printf(TEXT("#configuration,%s\n"), *GetBuildConfigurationName()); + Csv += FString::Printf(TEXT("#plugin_version,%s\n"), *SanitizeMetadata(GetPluginVersion())); + Csv += FString::Printf(TEXT("#timestamp_utc,%s\n"), *FDateTime::UtcNow().ToIso8601()); + Csv += FString::Printf(TEXT("#revision,%s\n"), *SanitizeMetadata(Revision)); + if (!BaselinePath.IsEmpty()) + { + Csv += FString::Printf(TEXT("#baseline,%s\n"), *BaselinePath); + } + Csv += TEXT("benchmark,element_count,parameter,median_ms,min_ms,max_ms,samples,baseline_median_ms,speedup,change_percent\n"); + + for (const FResult& Result : Results) + { + FString BaselineMedian; + FString Speedup; + FString ChangePercent; + if (const double* Baseline = BaselineMedians.Find(Result.GetKey())) + { + BaselineMedian = FString::Printf(TEXT("%.9f"), *Baseline); + if (*Baseline > 0.0 && Result.MedianMilliseconds > 0.0) + { + Speedup = FString::Printf(TEXT("%.4f"), *Baseline / Result.MedianMilliseconds); + ChangePercent = FString::Printf( + TEXT("%.2f"), + ((*Baseline - Result.MedianMilliseconds) / *Baseline) * 100.0); + } + } + + Csv += FString::Printf( + TEXT("%s,%d,%d,%.9f,%.9f,%.9f,%d,%s,%s,%s\n"), + *Result.Name, + Result.ElementCount, + Result.Parameter, + Result.MedianMilliseconds, + Result.MinimumMilliseconds, + Result.MaximumMilliseconds, + Result.SampleCount, + *BaselineMedian, + *Speedup, + *ChangePercent); + } + return Csv; + } + + FString BuildComparisonCsv(const TArray& Results) + { + FString Revision; + FParse::Value(FCommandLine::Get(), TEXT("DirectiveUtilitiesPerfRevision="), Revision); + + FString Csv; + Csv += FString::Printf(TEXT("#engine,%s\n"), *SanitizeMetadata(FEngineVersion::Current().ToString())); + Csv += FString::Printf(TEXT("#platform,%hs\n"), FPlatformProperties::PlatformName()); + Csv += FString::Printf(TEXT("#cpu,%s\n"), *SanitizeMetadata(FPlatformMisc::GetCPUBrand().TrimStartAndEnd())); + Csv += FString::Printf(TEXT("#configuration,%s\n"), *GetBuildConfigurationName()); + Csv += FString::Printf(TEXT("#plugin_version,%s\n"), *SanitizeMetadata(GetPluginVersion())); + Csv += FString::Printf(TEXT("#timestamp_utc,%s\n"), *FDateTime::UtcNow().ToIso8601()); + Csv += FString::Printf(TEXT("#revision,%s\n"), *SanitizeMetadata(Revision)); + Csv += TEXT("element_type,pattern,element_count,match_count,before_median_ms,before_min_ms,before_max_ms,after_median_ms,after_min_ms,after_max_ms,samples,speedup,time_reduction_percent\n"); + + for (const FComparisonResult& Result : Results) + { + const double Speedup = Result.AfterMedianMilliseconds > 0.0 + ? Result.BeforeMedianMilliseconds / Result.AfterMedianMilliseconds + : 0.0; + const double TimeReductionPercent = Result.BeforeMedianMilliseconds > 0.0 + ? ((Result.BeforeMedianMilliseconds - Result.AfterMedianMilliseconds) / Result.BeforeMedianMilliseconds) * 100.0 + : 0.0; + Csv += FString::Printf( + TEXT("%s,%s,%d,%d,%.9f,%.9f,%.9f,%.9f,%.9f,%.9f,%d,%.4f,%.2f\n"), + *Result.ElementType, + *Result.Pattern, + Result.ElementCount, + Result.MatchCount, + Result.BeforeMedianMilliseconds, + Result.BeforeMinimumMilliseconds, + Result.BeforeMaximumMilliseconds, + Result.AfterMedianMilliseconds, + Result.AfterMinimumMilliseconds, + Result.AfterMaximumMilliseconds, + Result.SampleCount, + Speedup, + TimeReductionPercent); + } + return Csv; + } + + FString BuildAppendComparisonCsv(const TArray& Results) + { + FString Revision; + FParse::Value(FCommandLine::Get(), TEXT("DirectiveUtilitiesPerfRevision="), Revision); + + FString Csv; + Csv += FString::Printf(TEXT("#engine,%s\n"), *SanitizeMetadata(FEngineVersion::Current().ToString())); + Csv += FString::Printf(TEXT("#platform,%hs\n"), FPlatformProperties::PlatformName()); + Csv += FString::Printf(TEXT("#cpu,%s\n"), *SanitizeMetadata(FPlatformMisc::GetCPUBrand().TrimStartAndEnd())); + Csv += FString::Printf(TEXT("#configuration,%s\n"), *GetBuildConfigurationName()); + Csv += FString::Printf(TEXT("#plugin_version,%s\n"), *SanitizeMetadata(GetPluginVersion())); + Csv += FString::Printf(TEXT("#timestamp_utc,%s\n"), *FDateTime::UtcNow().ToIso8601()); + Csv += FString::Printf(TEXT("#revision,%s\n"), *SanitizeMetadata(Revision)); + Csv += TEXT("element_type,scenario,source_count,initial_target_count,before_median_ms,before_min_ms,before_max_ms,after_median_ms,after_min_ms,after_max_ms,samples,speedup,time_reduction_percent\n"); + + for (const FAppendComparisonResult& Result : Results) + { + const double Speedup = Result.AfterMedianMilliseconds > 0.0 + ? Result.BeforeMedianMilliseconds / Result.AfterMedianMilliseconds + : 0.0; + const double TimeReductionPercent = Result.BeforeMedianMilliseconds > 0.0 + ? ((Result.BeforeMedianMilliseconds - Result.AfterMedianMilliseconds) / Result.BeforeMedianMilliseconds) * 100.0 + : 0.0; + Csv += FString::Printf( + TEXT("%s,%s,%d,%d,%.9f,%.9f,%.9f,%.9f,%.9f,%.9f,%d,%.4f,%.2f\n"), + *Result.ElementType, + *Result.Scenario, + Result.SourceCount, + Result.InitialTargetCount, + Result.BeforeMedianMilliseconds, + Result.BeforeMinimumMilliseconds, + Result.BeforeMaximumMilliseconds, + Result.AfterMedianMilliseconds, + Result.AfterMinimumMilliseconds, + Result.AfterMaximumMilliseconds, + Result.SampleCount, + Speedup, + TimeReductionPercent); + } + return Csv; + } +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FDirectiveUtilRuntimePerformanceTest, + "Performance.DirectiveUtilities.Runtime", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::PerfFilter) + +bool FDirectiveUtilRuntimePerformanceTest::RunTest(const FString& Parameters) +{ + using namespace DirectiveUtilRuntimePerformance; + + UDirectiveUtilTestObject* TestObject = NewObject(); + FArrayProperty* ArrayProperty = FindFProperty( + UDirectiveUtilTestObject::StaticClass(), + GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestArray)); + if (!TestNotNull(TEXT("Integer array property is available"), ArrayProperty)) + { + return false; + } + FArrayProperty* StringArrayProperty = FindFProperty( + UDirectiveUtilTestObject::StaticClass(), + GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestStringArray)); + if (!TestNotNull(TEXT("String array property is available"), StringArrayProperty)) + { + return false; + } + FArrayProperty* PodArrayProperty = FindFProperty( + UDirectiveUtilTestObject::StaticClass(), + GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestPodArray)); + if (!TestNotNull(TEXT("POD struct array property is available"), PodArrayProperty)) + { + return false; + } + TestFalse(TEXT("POD struct array uses reflected grouping"), PodArrayProperty->Inner->HasAllPropertyFlags(CPF_HasGetValueTypeHash)); + + constexpr int32 SampleCount = 7; + TArray Results; + TArray ComparisonResults; + TArray AppendComparisonResults; + TArray InsertComparisonResults; + TArray RemoveIndicesComparisonResults; + for (const int32 ElementCount : {1000, 10000, 100000, 250000, 1000000}) + { + const TArray Source = MakeSequentialIntegers(ElementCount); + const TArray EmptyTarget; + const TArray PopulatedTarget = MakeSequentialIntegers(ElementCount); + auto BeforeAppend = [&](TArray& Target, TArray& AppendSource) + { + UKismetArrayLibrary::GenericArray_Append( + &Target, + ArrayProperty, + &AppendSource, + ArrayProperty); + }; + auto AfterAppend = [&](TArray& Target, TArray& AppendSource) + { + UDirectiveUtilArrayFunctionLibrary::GenericArray_AppendOptimized( + &Target, + ArrayProperty, + &AppendSource, + ArrayProperty); + }; + AppendComparisonResults.Add(MeasureAppendComparison( + TEXT("int32"), + TEXT("empty_target"), + EmptyTarget, + Source, + SampleCount, + BeforeAppend, + AfterAppend)); + AppendComparisonResults.Add(MeasureAppendComparison( + TEXT("int32"), + TEXT("populated_target"), + PopulatedTarget, + Source, + SampleCount, + BeforeAppend, + AfterAppend)); + } + + for (const int32 ElementCount : {1000, 10000, 100000}) + { + TArray Source; + Source.Reserve(ElementCount); + for (int32 Index = 0; Index < ElementCount; ++Index) + { + Source.Add(FString::Printf(TEXT("Value%06d"), Index)); + } + const TArray EmptyTarget; + AppendComparisonResults.Add(MeasureAppendComparison( + TEXT("FString"), + TEXT("empty_target"), + EmptyTarget, + Source, + SampleCount, + [&](TArray& Target, TArray& AppendSource) + { + UKismetArrayLibrary::GenericArray_Append( + &Target, + StringArrayProperty, + &AppendSource, + StringArrayProperty); + }, + [&](TArray& Target, TArray& AppendSource) + { + UDirectiveUtilArrayFunctionLibrary::GenericArray_AppendOptimized( + &Target, + StringArrayProperty, + &AppendSource, + StringArrayProperty); + })); + } + + for (const int32 TargetCount : {256, 1024, 4096, 16384}) + { + const TArray InitialTarget = MakeSequentialIntegers(TargetCount); + TArray InsertSource = MakeSequentialIntegers(FMath::Max(1, TargetCount / 4)); + for (int32& Value : InsertSource) + { + Value += TargetCount; + } + + for (const TPair& Scenario : { + TPair(TEXT("front"), 0), + TPair(TEXT("middle"), TargetCount / 2), + TPair(TEXT("end"), TargetCount) + }) + { + const int32 InsertIndex = Scenario.Value; + InsertComparisonResults.Add(MeasureAppendComparison( + TEXT("int32"), + Scenario.Key, + InitialTarget, + InsertSource, + SampleCount, + [&](TArray& Target, TArray& Source) + { + for (int32 SourceIndex = 0; SourceIndex < Source.Num(); ++SourceIndex) + { + UKismetArrayLibrary::GenericArray_Insert( + &Target, + ArrayProperty, + &Source[SourceIndex], + InsertIndex + SourceIndex); + } + }, + [&](TArray& Target, TArray& Source) + { + UDirectiveUtilArrayFunctionLibrary::GenericArray_InsertOptimized( + &Target, + ArrayProperty, + &Source, + ArrayProperty, + InsertIndex); + })); + } + + TArray RemovalIndices; + for (int32 Index = 1; Index < TargetCount; Index += 4) + { + RemovalIndices.Add(Index); + } + RemoveIndicesComparisonResults.Add(MeasureAppendComparison( + TEXT("int32"), + TEXT("every_4"), + InitialTarget, + RemovalIndices, + SampleCount, + [&](TArray& Target, TArray& Indices) + { + for (int32 IndexOffset = Indices.Num() - 1; IndexOffset >= 0; --IndexOffset) + { + UKismetArrayLibrary::GenericArray_Remove( + &Target, + ArrayProperty, + Indices[IndexOffset]); + } + }, + [&](TArray& Target, TArray& Indices) + { + UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveAtIndices( + &Target, + ArrayProperty, + Indices); + })); + } + + const TArray RemovalPatterns = { + TEXT("no_match"), + TEXT("single_tail"), + TEXT("every_64"), + TEXT("clustered"), + TEXT("alternating"), + TEXT("all") + }; + constexpr int32 IntegerToRemove = 0; + for (const int32 ElementCount : {256, 1024, 4096, 16384}) + { + for (const FString& Pattern : RemovalPatterns) + { + int32 MatchCount = 0; + const TArray Source = MakeRemovalIntegers( + ElementCount, + Pattern, + IntegerToRemove, + MatchCount); + ComparisonResults.Add(MeasureComparison( + TEXT("int32"), + Pattern, + Source, + MatchCount, + SampleCount, + [&](TArray& Values) + { + return UKismetArrayLibrary::GenericArray_RemoveItem(&Values, ArrayProperty, &IntegerToRemove); + }, + [&](TArray& Values) + { + return UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveAllOccurrences( + &Values, + ArrayProperty, + &IntegerToRemove); + })); + } + } + + // The scaling curve uses every_64 only to keep the stock path's runtime bounded. + for (const int32 ElementCount : {1000, 10000, 100000, 250000, 1000000}) + { + int32 MatchCount = 0; + const TArray Source = MakeRemovalIntegers( + ElementCount, + TEXT("every_64"), + IntegerToRemove, + MatchCount); + ComparisonResults.Add(MeasureComparison( + TEXT("int32"), + TEXT("every_64"), + Source, + MatchCount, + SampleCount, + [&](TArray& Values) + { + return UKismetArrayLibrary::GenericArray_RemoveItem(&Values, ArrayProperty, &IntegerToRemove); + }, + [&](TArray& Values) + { + return UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveAllOccurrences( + &Values, + ArrayProperty, + &IntegerToRemove); + })); + } + + const FString StringToRemove = TEXT("REMOVE"); + for (const int32 ElementCount : {256, 1024, 4096}) + { + for (const FString& Pattern : RemovalPatterns) + { + int32 MatchCount = 0; + const TArray Source = MakeRemovalStrings( + ElementCount, + Pattern, + StringToRemove, + MatchCount); + ComparisonResults.Add(MeasureComparison( + TEXT("FString"), + Pattern, + Source, + MatchCount, + SampleCount, + [&](TArray& Values) + { + return UKismetArrayLibrary::GenericArray_RemoveItem(&Values, StringArrayProperty, &StringToRemove); + }, + [&](TArray& Values) + { + return UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveAllOccurrences( + &Values, + StringArrayProperty, + &StringToRemove); + })); + } + } + + for (const int32 ElementCount : {16, 256, 1024, 4096, 16384, 65536}) + { + const TArray Source = MakeSequentialIntegers(ElementCount); + const int32 DenseDistinctCount = FMath::Max(1, FMath::Min(64, ElementCount / 4)); + const TArray DenseSource = MakeRepeatingIntegers(ElementCount, DenseDistinctCount); + TArray Output; + int32 MostCommonItem = INDEX_NONE; + int32 MostCommonCount = 0; + + Results.Add(Measure( + TEXT("RemoveDuplicatesUnique"), ElementCount, 0, SampleCount, + [&]() { TestObject->TestArray = Source; }, + [&]() { UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveDuplicates(&TestObject->TestArray, ArrayProperty); })); + + Results.Add(Measure( + TEXT("GetDistinctUnique"), ElementCount, 0, SampleCount, + [&]() { TestObject->TestArray = Source; Output.Reset(); }, + [&]() { UDirectiveUtilArrayFunctionLibrary::GenericArray_GetDistinct(&TestObject->TestArray, ArrayProperty, &Output, ArrayProperty); })); + + Results.Add(Measure( + TEXT("GetMostCommonUnique"), ElementCount, 0, SampleCount, + [&]() { TestObject->TestArray = Source; MostCommonItem = INDEX_NONE; MostCommonCount = 0; }, + [&]() { UDirectiveUtilArrayFunctionLibrary::GenericArray_GetMostCommon(&TestObject->TestArray, ArrayProperty, &MostCommonItem, &MostCommonCount); })); + + Results.Add(Measure( + TEXT("RemoveDuplicatesDense"), ElementCount, DenseDistinctCount, SampleCount, + [&]() { TestObject->TestArray = DenseSource; }, + [&]() { UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveDuplicates(&TestObject->TestArray, ArrayProperty); })); + + Results.Add(Measure( + TEXT("GetDistinctDense"), ElementCount, DenseDistinctCount, SampleCount, + [&]() { TestObject->TestArray = DenseSource; Output.Reset(); }, + [&]() { UDirectiveUtilArrayFunctionLibrary::GenericArray_GetDistinct(&TestObject->TestArray, ArrayProperty, &Output, ArrayProperty); })); + + Results.Add(Measure( + TEXT("GetMostCommonDense"), ElementCount, DenseDistinctCount, SampleCount, + [&]() { TestObject->TestArray = DenseSource; MostCommonItem = INDEX_NONE; MostCommonCount = 0; }, + [&]() { UDirectiveUtilArrayFunctionLibrary::GenericArray_GetMostCommon(&TestObject->TestArray, ArrayProperty, &MostCommonItem, &MostCommonCount); })); + } + + for (const int32 ElementCount : {256, 1024, 4096, 16384}) + { + const TArray Source = MakeSequentialPodValues(ElementCount); + TArray Output; + FDirectiveUtilPodValue MostCommonItem{}; + int32 MostCommonCount = 0; + + Results.Add(Measure( + TEXT("GetDistinctUnhashableStruct"), ElementCount, 0, SampleCount, + [&]() { TestObject->TestPodArray = Source; Output.Reset(); }, + [&]() { UDirectiveUtilArrayFunctionLibrary::GenericArray_GetDistinct(&TestObject->TestPodArray, PodArrayProperty, &Output, PodArrayProperty); })); + + Results.Add(Measure( + TEXT("GetMostCommonUnhashableStruct"), ElementCount, 0, SampleCount, + [&]() { TestObject->TestPodArray = Source; MostCommonCount = 0; }, + [&]() { UDirectiveUtilArrayFunctionLibrary::GenericArray_GetMostCommon(&TestObject->TestPodArray, PodArrayProperty, &MostCommonItem, &MostCommonCount); })); + } + + for (const int32 ElementCount : {100, 1000, 10000, 100000}) + { + const TArray Source = MakeSequentialIntegers(ElementCount); + TArray Weights; + Weights.Reserve(ElementCount); + for (int32 Index = 0; Index < ElementCount; ++Index) + { + Weights.Add(Index % 11 == 0 ? 0.0f : static_cast((Index % 17) + 1)); + } + TArray Output; + FRandomStream RandomStream; + + Results.Add(Measure( + TEXT("SampleWithoutReplacement"), ElementCount, 16, SampleCount, + [&]() { TestObject->TestArray = Source; Output.Reset(); RandomStream.Initialize(1337); }, + [&]() { UDirectiveUtilArrayFunctionLibrary::GenericArray_Sample(&TestObject->TestArray, ArrayProperty, 16, false, &RandomStream, &Output, ArrayProperty); })); + + Results.Add(Measure( + TEXT("SampleWeightedWithoutReplacement"), ElementCount, 16, SampleCount, + [&]() { TestObject->TestArray = Source; Output.Reset(); RandomStream.Initialize(1337); }, + [&]() { UDirectiveUtilArrayFunctionLibrary::GenericArray_SampleWeighted(&TestObject->TestArray, ArrayProperty, Weights, 16, false, &RandomStream, &Output, ArrayProperty); })); + + Results.Add(Measure( + TEXT("SampleWeightedWithReplacement"), ElementCount, 256, SampleCount, + [&]() { TestObject->TestArray = Source; Output.Reset(); RandomStream.Initialize(1337); }, + [&]() { UDirectiveUtilArrayFunctionLibrary::GenericArray_SampleWeighted(&TestObject->TestArray, ArrayProperty, Weights, 256, true, &RandomStream, &Output, ArrayProperty); })); + } + + { + constexpr int32 ElementCount = 100000; + const TArray Source = MakeSequentialIntegers(ElementCount); + TArray Weights; + Weights.Init(1.0f, ElementCount); + TArray Output; + FRandomStream RandomStream; + int32 PageCount = 0; + for (const int32 RequestedCount : {1, 16, 24999, 25000, 25001, 50000, 75000, 100000}) + { + const TCHAR* BenchmarkName = RequestedCount == 75000 + ? TEXT("SampleWithoutReplacementDense") + : TEXT("SampleWithoutReplacementRatio"); + Results.Add(Measure( + BenchmarkName, ElementCount, RequestedCount, SampleCount, + [&]() { TestObject->TestArray = Source; Output.Reset(); RandomStream.Initialize(1337); }, + [&]() { UDirectiveUtilArrayFunctionLibrary::GenericArray_Sample(&TestObject->TestArray, ArrayProperty, RequestedCount, false, &RandomStream, &Output, ArrayProperty); })); + } + + Results.Add(Measure( + TEXT("SampleWithoutReplacementAlias"), ElementCount, 16, SampleCount, + [&]() { TestObject->TestArray = Source; RandomStream.Initialize(1337); }, + [&]() { UDirectiveUtilArrayFunctionLibrary::GenericArray_Sample(&TestObject->TestArray, ArrayProperty, 16, false, &RandomStream, &TestObject->TestArray, ArrayProperty); })); + + Results.Add(Measure( + TEXT("SampleWithReplacement"), ElementCount, 256, SampleCount, + [&]() { TestObject->TestArray = Source; Output.Reset(); RandomStream.Initialize(1337); }, + [&]() { UDirectiveUtilArrayFunctionLibrary::GenericArray_Sample(&TestObject->TestArray, ArrayProperty, 256, true, &RandomStream, &Output, ArrayProperty); })); + + for (const int32 RequestedCount : {1, 16, 1000, 50000}) + { + Results.Add(Measure( + TEXT("SampleWeightedRatio"), ElementCount, RequestedCount, SampleCount, + [&]() { TestObject->TestArray = Source; Output.Reset(); RandomStream.Initialize(1337); }, + [&]() { UDirectiveUtilArrayFunctionLibrary::GenericArray_SampleWeighted(&TestObject->TestArray, ArrayProperty, Weights, RequestedCount, false, &RandomStream, &Output, ArrayProperty); })); + } + + for (const int32 PageSize : {1, 128, 4096}) + { + const int32 PageIndex = PageSize == 128 ? 400 : (ElementCount / PageSize) / 2; + Results.Add(Measure( + TEXT("GetPage"), ElementCount, PageSize, SampleCount, + [&]() { TestObject->TestArray = Source; Output.Reset(); PageCount = 0; }, + [&]() { UDirectiveUtilArrayFunctionLibrary::GenericArray_GetPage(&TestObject->TestArray, ArrayProperty, PageIndex, PageSize, &Output, ArrayProperty, &PageCount); })); + } + + Results.Add(Measure( + TEXT("GetPageAlias"), ElementCount, 128, SampleCount, + [&]() { TestObject->TestArray = Source; PageCount = 0; }, + [&]() { UDirectiveUtilArrayFunctionLibrary::GenericArray_GetPage(&TestObject->TestArray, ArrayProperty, 400, 128, &TestObject->TestArray, ArrayProperty, &PageCount); })); + + Results.Add(Measure( + TEXT("SliceAliasCorrectness"), ElementCount, 128, SampleCount, + [&]() { TestObject->TestArray = Source; }, + [&]() { UDirectiveUtilArrayFunctionLibrary::GenericArray_Slice(&TestObject->TestArray, ArrayProperty, 51200, 128, &TestObject->TestArray, ArrayProperty); })); + + int32 ItemToCount = ElementCount - 1; + int32 OccurrenceCount = 0; + Results.Add(Measure( + TEXT("CountOccurrences"), ElementCount, 0, SampleCount, + [&]() { TestObject->TestArray = Source; OccurrenceCount = 0; }, + [&]() { OccurrenceCount = UDirectiveUtilArrayFunctionLibrary::GenericArray_CountOccurrences(&TestObject->TestArray, ArrayProperty, &ItemToCount); })); + + } + + { + constexpr int32 ElementCount = 1000000; + const TArray Source = MakeSequentialIntegers(ElementCount); + int32 ItemToCount = ElementCount - 1; + int32 OccurrenceCount = 0; + Results.Add(Measure( + TEXT("CountOccurrences"), ElementCount, 0, SampleCount, + [&]() { TestObject->TestArray = Source; OccurrenceCount = 0; }, + [&]() { OccurrenceCount = UDirectiveUtilArrayFunctionLibrary::GenericArray_CountOccurrences(&TestObject->TestArray, ArrayProperty, &ItemToCount); })); + } + + for (const int32 ElementCount : {1000, 100000, 1000000}) + { + const TArray Source = MakeSequentialIntegers(ElementCount); + for (const int32 Shift : {1, ElementCount / 3, ElementCount - 1}) + { + Results.Add(Measure( + TEXT("Rotate"), ElementCount, Shift, SampleCount, + [&]() { TestObject->TestArray = Source; }, + [&]() { UDirectiveUtilArrayFunctionLibrary::GenericArray_Rotate(&TestObject->TestArray, ArrayProperty, Shift); })); + } + } + + for (const int32 ElementCount : {100, 1000, 10000}) + { + const TArray SourceStrings = MakeNaturalSortStrings(ElementCount); + const TArray SourceNames = MakeNaturalSortNames(ElementCount); + TArray WorkingStrings; + TArray WorkingNames; + + Results.Add(Measure( + TEXT("NaturalSortString"), ElementCount, 0, SampleCount, + [&]() { WorkingStrings = SourceStrings; }, + [&]() { UDirectiveUtilArrayFunctionLibrary::NaturalSortStringArray(WorkingStrings); })); + + Results.Add(Measure( + TEXT("NaturalSortName"), ElementCount, 0, SampleCount, + [&]() { WorkingNames = SourceNames; }, + [&]() { UDirectiveUtilArrayFunctionLibrary::NaturalSortNameArray(WorkingNames); })); + } + + for (const int32 ElementCount : {101, 1001, 100001}) + { + const TArray ShuffledIndices = MakeShuffledIndices(ElementCount); + TArray ShuffledFloats; + ShuffledFloats.Reserve(ElementCount); + for (const int32 Value : ShuffledIndices) + { + ShuffledFloats.Add(static_cast(Value) + 0.25f); + } + float MedianResult = 0.0f; + float PercentileResult = 0.0f; + + Results.Add(Measure( + TEXT("IntMedian"), ElementCount, 0, SampleCount, + []() {}, + [&]() { MedianResult = UDirectiveUtilMathFunctionLibrary::GetIntArrayMedian(ShuffledIndices); })); + + Results.Add(Measure( + TEXT("FloatMedian"), ElementCount, 0, SampleCount, + []() {}, + [&]() { MedianResult = UDirectiveUtilMathFunctionLibrary::GetFloatArrayMedian(ShuffledFloats); })); + + Results.Add(Measure( + TEXT("FloatPercentile"), ElementCount, 40, SampleCount, + []() {}, + [&]() { UDirectiveUtilMathFunctionLibrary::GetFloatArrayPercentile(ShuffledFloats, 40.0f, PercentileResult); })); + + if (ElementCount == 100001) + { + const TArray SortedValues = MakeSequentialIntegers(ElementCount); + TArray ReverseValues = SortedValues; + Algo::Reverse(ReverseValues); + Results.Add(Measure( + TEXT("IntMedianSorted"), ElementCount, 0, SampleCount, + []() {}, + [&]() { MedianResult = UDirectiveUtilMathFunctionLibrary::GetIntArrayMedian(SortedValues); })); + Results.Add(Measure( + TEXT("IntMedianReverse"), ElementCount, 0, SampleCount, + []() {}, + [&]() { MedianResult = UDirectiveUtilMathFunctionLibrary::GetIntArrayMedian(ReverseValues); })); + } + } + + for (const int32 ElementCount : {1000, 10000, 100000}) + { + TArray PointOutput; + TArray TransformOutput; + TArray CoordinateOutput; + TArray EaseFromLocations; + TArray EaseToLocations; + EaseFromLocations.Reserve(ElementCount); + EaseToLocations.Reserve(ElementCount); + for (int32 Index = 0; Index < ElementCount; ++Index) + { + const FVector Location(Index, Index * 0.5, -Index); + EaseFromLocations.Add(Location); + EaseToLocations.Add(Location + FVector(100.0, -50.0, 25.0)); + } + + Results.Add(Measure( + TEXT("GenerateGridPoints2D"), ElementCount, 0, SampleCount, + [&]() { PointOutput.Reset(); }, + [&]() { PointOutput = UDirectiveUtilMathFunctionLibrary::GenerateGridPoints2D( + FVector::ZeroVector, FRotator::ZeroRotator, FIntPoint(ElementCount, 1), FVector2D(100.0, 100.0)); })); + Results.Add(Measure( + TEXT("GetRectangularHexGridCoordinates"), ElementCount, 0, SampleCount, + [&]() { CoordinateOutput.Reset(); }, + [&]() { CoordinateOutput = UDirectiveUtilMathFunctionLibrary::GetRectangularHexGridCoordinates( + FIntPoint(ElementCount, 1)); })); + Results.Add(Measure( + TEXT("GeneratePointsOnCircle"), ElementCount, 0, SampleCount, + [&]() { PointOutput.Reset(); }, + [&]() { PointOutput = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnCircle( + FVector::ZeroVector, FRotator::ZeroRotator, 1000.0, ElementCount); })); + Results.Add(Measure( + TEXT("GeneratePointsOnSphere"), ElementCount, 0, SampleCount, + [&]() { PointOutput.Reset(); }, + [&]() { PointOutput = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnSphere( + FVector::ZeroVector, FRotator::ZeroRotator, 1000.0, ElementCount); })); + Results.Add(Measure( + TEXT("GenerateTransformsOnCircle"), ElementCount, 0, SampleCount, + [&]() { TransformOutput.Reset(); }, + [&]() { TransformOutput = UDirectiveUtilMathFunctionLibrary::GenerateTransformsOnCircle( + FVector::ZeroVector, FRotator::ZeroRotator, 1000.0, ElementCount); })); + Results.Add(Measure( + TEXT("EaseLocationArrays"), ElementCount, 0, SampleCount, + [&]() { PointOutput.Reset(); }, + [&]() { PointOutput = UDirectiveUtilMathFunctionLibrary::EaseLocationArrays( + EaseFromLocations, EaseToLocations, 0.5f, EDirectiveUtilEaseType::BackInOut, {}); })); + } + + { + constexpr int32 ElementCount = 1000000; + TArray PointOutput; + Results.Add(Measure( + TEXT("GenerateGridPoints2D"), ElementCount, 0, SampleCount, + [&]() { PointOutput.Reset(); }, + [&]() { PointOutput = UDirectiveUtilMathFunctionLibrary::GenerateGridPoints2D( + FVector::ZeroVector, FRotator::ZeroRotator, FIntPoint(ElementCount, 1), FVector2D(100.0, 100.0)); })); + } + + for (const int32 Radius : {10, 100, 250}) + { + TArray HexOutput; + const int32 ElementCount = 1 + 3 * Radius * (Radius + 1); + Results.Add(Measure( + TEXT("GetHexesInRange"), ElementCount, Radius, SampleCount, + [&]() { HexOutput.Reset(); }, + [&]() { HexOutput = UDirectiveUtilMathFunctionLibrary::GetHexesInRange( + FIntPoint::ZeroValue, Radius); })); + } + + for (const int32 OperationCount : {1000, 100000}) + { + double FalloffSum = 0.0; + Results.Add(Measure( + TEXT("RangeFalloffDefault"), OperationCount, 1, SampleCount, + [&]() { FalloffSum = 0.0; }, + [&]() + { + for (int32 Index = 0; Index < OperationCount; ++Index) + { + FalloffSum += UDirectiveUtilMathFunctionLibrary::RangeFalloff( + static_cast(Index % 1000), 100.0f, 900.0f); + } + })); + + FRandomStream SphereStream; + FVector SphereSum = FVector::ZeroVector; + Results.Add(Measure( + TEXT("RandomPointInSphereStream"), OperationCount, 0, SampleCount, + [&]() { SphereStream.Initialize(1337); SphereSum = FVector::ZeroVector; }, + [&]() + { + for (int32 Index = 0; Index < OperationCount; ++Index) + { + SphereSum += UDirectiveUtilMathFunctionLibrary::RandomPointInSphereFromStream( + SphereStream, 100.0f); + } + })); + } + + for (const int32 CandidateCount : {10, 1000, 10000}) + { + const TArray Candidates = MakeStringMatchCandidates(CandidateCount); + const FString Input = FString::Printf(TEXT("DirectiveUtilityCandidate%05dX"), CandidateCount - 1); + float Similarity = 0.0f; + int32 MatchIndex = INDEX_NONE; + + Results.Add(Measure( + TEXT("FindBestStringMatch"), CandidateCount, Input.Len(), SampleCount, + [&]() { Similarity = 0.0f; MatchIndex = INDEX_NONE; }, + [&]() { MatchIndex = UDirectiveUtilStringFunctionLibrary::FindBestStringMatch(Input, Candidates, Similarity); })); + } + + for (const FComparisonResult& Result : ComparisonResults) + { + if (!Result.bOutputsMatch) + { + AddError(FString::Printf( + TEXT("RemoveAll comparison mismatch for %s elements=%d pattern=%s"), + *Result.ElementType, + Result.ElementCount, + *Result.Pattern)); + } + + const double Speedup = Result.AfterMedianMilliseconds > 0.0 + ? Result.BeforeMedianMilliseconds / Result.AfterMedianMilliseconds + : 0.0; + const double TimeReductionPercent = Result.BeforeMedianMilliseconds > 0.0 + ? ((Result.BeforeMedianMilliseconds - Result.AfterMedianMilliseconds) / Result.BeforeMedianMilliseconds) * 100.0 + : 0.0; + AddInfo(FString::Printf( + TEXT("REMOVE_ALL_PERF type=%s elements=%d pattern=%s matches=%d before=%.6fms after=%.6fms speedup=%.3fx reduction=%.2f%%"), + *Result.ElementType, + Result.ElementCount, + *Result.Pattern, + Result.MatchCount, + Result.BeforeMedianMilliseconds, + Result.AfterMedianMilliseconds, + Speedup, + TimeReductionPercent)); + } + + for (const FAppendComparisonResult& Result : AppendComparisonResults) + { + if (!Result.bOutputsMatch) + { + AddError(FString::Printf( + TEXT("Append comparison mismatch for %s source=%d scenario=%s"), + *Result.ElementType, + Result.SourceCount, + *Result.Scenario)); + } + + const double Speedup = Result.AfterMedianMilliseconds > 0.0 + ? Result.BeforeMedianMilliseconds / Result.AfterMedianMilliseconds + : 0.0; + const double TimeReductionPercent = Result.BeforeMedianMilliseconds > 0.0 + ? ((Result.BeforeMedianMilliseconds - Result.AfterMedianMilliseconds) / Result.BeforeMedianMilliseconds) * 100.0 + : 0.0; + AddInfo(FString::Printf( + TEXT("APPEND_PERF type=%s source=%d initial_target=%d scenario=%s before=%.6fms after=%.6fms speedup=%.3fx reduction=%.2f%%"), + *Result.ElementType, + Result.SourceCount, + Result.InitialTargetCount, + *Result.Scenario, + Result.BeforeMedianMilliseconds, + Result.AfterMedianMilliseconds, + Speedup, + TimeReductionPercent)); + } + + for (const FAppendComparisonResult& Result : InsertComparisonResults) + { + if (!Result.bOutputsMatch) + { + AddError(FString::Printf( + TEXT("Insert comparison mismatch for %s source=%d scenario=%s"), + *Result.ElementType, + Result.SourceCount, + *Result.Scenario)); + } + + const double Speedup = Result.AfterMedianMilliseconds > 0.0 + ? Result.BeforeMedianMilliseconds / Result.AfterMedianMilliseconds + : 0.0; + AddInfo(FString::Printf( + TEXT("INSERT_PERF type=%s source=%d initial_target=%d scenario=%s before=%.6fms after=%.6fms speedup=%.3fx"), + *Result.ElementType, + Result.SourceCount, + Result.InitialTargetCount, + *Result.Scenario, + Result.BeforeMedianMilliseconds, + Result.AfterMedianMilliseconds, + Speedup)); + } + + for (const FAppendComparisonResult& Result : RemoveIndicesComparisonResults) + { + if (!Result.bOutputsMatch) + { + AddError(FString::Printf( + TEXT("Remove At Indices comparison mismatch for %s indices=%d scenario=%s"), + *Result.ElementType, + Result.SourceCount, + *Result.Scenario)); + } + + const double Speedup = Result.AfterMedianMilliseconds > 0.0 + ? Result.BeforeMedianMilliseconds / Result.AfterMedianMilliseconds + : 0.0; + AddInfo(FString::Printf( + TEXT("REMOVE_INDICES_PERF type=%s indices=%d initial_target=%d scenario=%s before=%.6fms after=%.6fms speedup=%.3fx"), + *Result.ElementType, + Result.SourceCount, + Result.InitialTargetCount, + *Result.Scenario, + Result.BeforeMedianMilliseconds, + Result.AfterMedianMilliseconds, + Speedup)); + } + + TMap BaselineMedians; + FString BaselinePath; + if (!LoadBaseline(BaselineMedians, BaselinePath)) + { + AddError(FString::Printf(TEXT("Unable to read performance baseline: %s"), *BaselinePath)); + return false; + } + if (!BaselinePath.IsEmpty()) + { + double MaximumRegressionPercent = 20.0; + double MinimumGatedMilliseconds = 0.5; + FParse::Value( + FCommandLine::Get(), + TEXT("DirectiveUtilitiesPerfMaxRegressionPercent="), + MaximumRegressionPercent); + FParse::Value( + FCommandLine::Get(), + TEXT("DirectiveUtilitiesPerfMinGateMilliseconds="), + MinimumGatedMilliseconds); + if (!FMath::IsFinite(MaximumRegressionPercent) || MaximumRegressionPercent < 0.0) + { + AddError(TEXT("DirectiveUtilitiesPerfMaxRegressionPercent must be finite and non-negative.")); + return false; + } + if (!FMath::IsFinite(MinimumGatedMilliseconds) || MinimumGatedMilliseconds < 0.0) + { + AddError(TEXT("DirectiveUtilitiesPerfMinGateMilliseconds must be finite and non-negative.")); + return false; + } + + for (const FResult& Result : Results) + { + const double* Baseline = BaselineMedians.Find(Result.GetKey()); + if (!Baseline || *Baseline < MinimumGatedMilliseconds) + { + continue; + } + const double RegressionPercent = ((Result.MedianMilliseconds - *Baseline) / *Baseline) * 100.0; + if (RegressionPercent > MaximumRegressionPercent) + { + AddError(FString::Printf( + TEXT("Performance regression for %s elements=%d parameter=%d: %.2f%% exceeds %.2f%%"), + *Result.Name, + Result.ElementCount, + Result.Parameter, + RegressionPercent, + MaximumRegressionPercent)); + } + } + } + + for (const FResult& Result : Results) + { + FString Comparison; + if (const double* Baseline = BaselineMedians.Find(Result.GetKey())) + { + const double Speedup = Result.MedianMilliseconds > 0.0 ? *Baseline / Result.MedianMilliseconds : 0.0; + const double ChangePercent = *Baseline > 0.0 + ? ((*Baseline - Result.MedianMilliseconds) / *Baseline) * 100.0 + : 0.0; + Comparison = FString::Printf(TEXT(" baseline=%.6fms speedup=%.3fx change=%.2f%%"), *Baseline, Speedup, ChangePercent); + } + AddInfo(FString::Printf( + TEXT("RUNTIME_PERF %s elements=%d parameter=%d median=%.6fms min=%.6fms max=%.6fms samples=%d%s"), + *Result.Name, + Result.ElementCount, + Result.Parameter, + Result.MedianMilliseconds, + Result.MinimumMilliseconds, + Result.MaximumMilliseconds, + Result.SampleCount, + *Comparison)); + } + + const FString OutputPath = GetOutputPath(); + IFileManager::Get().MakeDirectory(*FPaths::GetPath(OutputPath), true); + const FString Csv = BuildCsv(Results, BaselineMedians, BaselinePath); + TestTrue( + FString::Printf(TEXT("Performance results saved to %s"), *OutputPath), + FFileHelper::SaveStringToFile(Csv, *OutputPath)); + + const FString ComparisonOutputPath = GetComparisonOutputPath(); + IFileManager::Get().MakeDirectory(*FPaths::GetPath(ComparisonOutputPath), true); + const FString ComparisonCsv = BuildComparisonCsv(ComparisonResults); + TestTrue( + FString::Printf(TEXT("Remove All comparison results saved to %s"), *ComparisonOutputPath), + FFileHelper::SaveStringToFile(ComparisonCsv, *ComparisonOutputPath)); + + const FString AppendComparisonOutputPath = GetAppendComparisonOutputPath(); + IFileManager::Get().MakeDirectory(*FPaths::GetPath(AppendComparisonOutputPath), true); + const FString AppendComparisonCsv = BuildAppendComparisonCsv(AppendComparisonResults); + TestTrue( + FString::Printf(TEXT("Append comparison results saved to %s"), *AppendComparisonOutputPath), + FFileHelper::SaveStringToFile(AppendComparisonCsv, *AppendComparisonOutputPath)); + + const FString InsertComparisonOutputPath = GetInsertComparisonOutputPath(); + IFileManager::Get().MakeDirectory(*FPaths::GetPath(InsertComparisonOutputPath), true); + TestTrue( + FString::Printf(TEXT("Insert comparison results saved to %s"), *InsertComparisonOutputPath), + FFileHelper::SaveStringToFile( + BuildAppendComparisonCsv(InsertComparisonResults), + *InsertComparisonOutputPath)); + + const FString RemoveIndicesComparisonOutputPath = GetRemoveIndicesComparisonOutputPath(); + IFileManager::Get().MakeDirectory(*FPaths::GetPath(RemoveIndicesComparisonOutputPath), true); + TestTrue( + FString::Printf(TEXT("Remove At Indices comparison results saved to %s"), *RemoveIndicesComparisonOutputPath), + FFileHelper::SaveStringToFile( + BuildAppendComparisonCsv(RemoveIndicesComparisonResults), + *RemoveIndicesComparisonOutputPath)); + return !HasAnyErrors(); +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilRuntimeSurfaceTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilRuntimeSurfaceTest.cpp new file mode 100644 index 00000000..11d4ef9b --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilRuntimeSurfaceTest.cpp @@ -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 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(); +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilSaveGameFunctionLibraryTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilSaveGameFunctionLibraryTest.cpp index 3b505680..523132dd 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilSaveGameFunctionLibraryTest.cpp +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilSaveGameFunctionLibraryTest.cpp @@ -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(); 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); } diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilStringFunctionLibraryTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilStringFunctionLibraryTest.cpp index 9a934aff..d84b768d 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilStringFunctionLibraryTest.cpp +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilStringFunctionLibraryTest.cpp @@ -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 PreviousRow; + TArray 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(TEXT('a') + LevenshteinStream.RandRange(0, 5))); + } + for (int32 Index = 0; Index < RightLength; ++Index) + { + Right.AppendChar(static_cast(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; diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilStringScenarioTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilStringScenarioTest.cpp new file mode 100644 index 00000000..256d7dab --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilStringScenarioTest.cpp @@ -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 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 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; +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilTextFunctionLibraryTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilTextFunctionLibraryTest.cpp index 8e9731f4..4de95ded 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilTextFunctionLibraryTest.cpp +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilTextFunctionLibraryTest.cpp @@ -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) { diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilTransformArrayTest.cpp b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilTransformArrayTest.cpp new file mode 100644 index 00000000..e8de8049 --- /dev/null +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilTransformArrayTest.cpp @@ -0,0 +1,485 @@ +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#include "Libraries/DirectiveUtilMathFunctionLibrary.h" + +#include "Components/SplineComponent.h" +#include "Math/RotationMatrix.h" +#include "Misc/AutomationTest.h" + +#include + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FDirectiveUtilTransformArrayTest, + "DirectiveUtilities.Math.TransformArrays", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter) + +bool FDirectiveUtilTransformArrayTest::RunTest(const FString& Parameters) +{ + const TArray Locations = { + FVector(1.0, 2.0, 3.0), + FVector(4.0, 5.0, 6.0), + FVector(7.0, 8.0, 9.0) + }; + const FRotator SharedRotator(10.0, 20.0, 30.0); + const FQuat SharedRotation = SharedRotator.Quaternion(); + const FVector SharedScale(2.0, 3.0, 4.0); + + const TArray SharedTransforms = UDirectiveUtilMathFunctionLibrary::LocationsToTransforms( + Locations, SharedRotator, SharedScale); + bool bSharedTransformsValid = SharedTransforms.Num() == Locations.Num(); + for (int32 Index = 0; Index < SharedTransforms.Num(); ++Index) + { + bSharedTransformsValid &= SharedTransforms[Index].GetLocation() == Locations[Index]; + bSharedTransformsValid &= SharedTransforms[Index].GetRotation().Equals(SharedRotation, 1.e-12); + bSharedTransformsValid &= SharedTransforms[Index].GetScale3D() == SharedScale; + } + TestTrue(TEXT("Locations to transforms preserves order and broadcasts rotation and scale"), + bSharedTransformsValid); + TestTrue(TEXT("Locations to transforms accepts an empty location array"), + UDirectiveUtilMathFunctionLibrary::LocationsToTransforms( + {}, SharedRotator, SharedScale).IsEmpty()); + + TArray Transforms; + TestTrue(TEXT("Empty attribute arrays use identity rotation and scale"), + UDirectiveUtilMathFunctionLibrary::MakeTransformsFromArrays(Locations, {}, {}, Transforms)); + bool bIdentityAttributesValid = Transforms.Num() == Locations.Num(); + for (int32 Index = 0; Index < Transforms.Num(); ++Index) + { + bIdentityAttributesValid &= Transforms[Index].GetLocation() == Locations[Index]; + bIdentityAttributesValid &= Transforms[Index].GetRotation().Equals(FQuat::Identity, 1.e-12); + bIdentityAttributesValid &= Transforms[Index].GetScale3D() == FVector::OneVector; + } + TestTrue(TEXT("Identity attributes preserve every location"), bIdentityAttributesValid); + + TestTrue(TEXT("Single attribute values broadcast across the location array"), + UDirectiveUtilMathFunctionLibrary::MakeTransformsFromArrays( + Locations, { SharedRotator }, { SharedScale }, Transforms)); + bool bBroadcastAttributesValid = Transforms.Num() == Locations.Num(); + for (const FTransform& Transform : Transforms) + { + bBroadcastAttributesValid &= Transform.GetRotation().Equals(SharedRotation, 1.e-12); + bBroadcastAttributesValid &= Transform.GetScale3D() == SharedScale; + } + TestTrue(TEXT("Broadcast attributes are applied to every transform"), bBroadcastAttributesValid); + + const TArray Rotations = { + FRotator::ZeroRotator, + FRotator(0.0, 90.0, 0.0), + FRotator(45.0, 0.0, 0.0) + }; + const TArray Scales = { + FVector::OneVector, + FVector(2.0), + FVector(-1.0, 1.0, 0.5) + }; + TestTrue(TEXT("Full attribute arrays map element by element"), + UDirectiveUtilMathFunctionLibrary::MakeTransformsFromArrays( + Locations, Rotations, Scales, Transforms)); + bool bPerTransformAttributesValid = Transforms.Num() == Locations.Num(); + for (int32 Index = 0; Index < Transforms.Num(); ++Index) + { + bPerTransformAttributesValid &= Transforms[Index].GetLocation() == Locations[Index]; + bPerTransformAttributesValid &= Transforms[Index].GetRotation().Equals( + Rotations[Index].Quaternion(), 1.e-12); + bPerTransformAttributesValid &= Transforms[Index].GetScale3D() == Scales[Index]; + } + TestTrue(TEXT("Per-transform attributes preserve index alignment"), bPerTransformAttributesValid); + + Transforms = { FTransform::Identity }; + TestFalse(TEXT("Mismatched rotation counts are rejected"), + UDirectiveUtilMathFunctionLibrary::MakeTransformsFromArrays( + Locations, { FRotator::ZeroRotator, FRotator::ZeroRotator }, {}, Transforms)); + TestTrue(TEXT("A rejected attribute count clears the output"), Transforms.IsEmpty()); + TestFalse(TEXT("Mismatched scale counts are rejected"), + UDirectiveUtilMathFunctionLibrary::MakeTransformsFromArrays( + Locations, {}, { FVector::OneVector, FVector::OneVector }, Transforms)); + TestTrue(TEXT("A rejected scale count clears the output"), Transforms.IsEmpty()); + + const double Infinity = std::numeric_limits::infinity(); + const FRotator InvalidRotation(Infinity, 0.0, 0.0); + TestTrue(TEXT("Locations to transforms rejects non-finite values"), + UDirectiveUtilMathFunctionLibrary::LocationsToTransforms( + { FVector(Infinity, 0.0, 0.0) }, FRotator::ZeroRotator, FVector::OneVector).IsEmpty() + && UDirectiveUtilMathFunctionLibrary::LocationsToTransforms( + Locations, InvalidRotation, FVector::OneVector).IsEmpty() + && UDirectiveUtilMathFunctionLibrary::LocationsToTransforms( + Locations, FRotator::ZeroRotator, FVector(Infinity)).IsEmpty()); + TestFalse(TEXT("Transform arrays reject a non-finite location"), + UDirectiveUtilMathFunctionLibrary::MakeTransformsFromArrays( + { FVector::ZeroVector, FVector(Infinity) }, {}, {}, Transforms)); + TestTrue(TEXT("A non-finite location clears partial output"), Transforms.IsEmpty()); + TestFalse(TEXT("Transform arrays reject a non-finite rotation"), + UDirectiveUtilMathFunctionLibrary::MakeTransformsFromArrays( + Locations, { InvalidRotation }, {}, Transforms)); + TestTrue(TEXT("A non-finite rotation leaves no output"), Transforms.IsEmpty()); + TestFalse(TEXT("Transform arrays reject a non-finite scale"), + UDirectiveUtilMathFunctionLibrary::MakeTransformsFromArrays( + Locations, {}, { FVector(Infinity) }, Transforms)); + TestTrue(TEXT("A non-finite scale leaves no output"), Transforms.IsEmpty()); + + TestTrue(TEXT("Empty locations produce a valid empty transform array"), + UDirectiveUtilMathFunctionLibrary::MakeTransformsFromArrays( + {}, { SharedRotator }, { SharedScale }, Transforms)); + TestTrue(TEXT("An empty transform result contains no values"), Transforms.IsEmpty()); + + const FVector LargeLocation(1000000.0, -2000000.0, 3000000.0); + const FRotator WrappedRotation(-1080.0, 1440.0, 720.0); + const FVector SignedScale(-2.0, 0.0, 4.0); + const TArray OddTransforms = UDirectiveUtilMathFunctionLibrary::LocationsToTransforms( + {LargeLocation, -LargeLocation}, WrappedRotation, SignedScale); + TestTrue(TEXT("Locations to transforms preserves large locations and signed scales"), + OddTransforms.Num() == 2 + && OddTransforms[0].GetLocation() == LargeLocation + && OddTransforms[1].GetLocation() == -LargeLocation + && OddTransforms[0].GetScale3D() == SignedScale + && OddTransforms[1].GetScale3D() == SignedScale + && OddTransforms[0].GetRotation().Equals(WrappedRotation.Quaternion(), 1.e-12)); + + const FVector SingleLocation(-7.0, 11.0, -13.0); + const FRotator SingleRotation(17.0, -29.0, 43.0); + const FVector SingleScale(0.0, -1.0, 2.0); + TestTrue(TEXT("Single-element attribute arrays map without special-case drift"), + UDirectiveUtilMathFunctionLibrary::MakeTransformsFromArrays( + {SingleLocation}, {SingleRotation}, {SingleScale}, Transforms) + && Transforms.Num() == 1 + && Transforms[0].GetLocation() == SingleLocation + && Transforms[0].GetRotation().Equals(SingleRotation.Quaternion(), 1.e-12) + && Transforms[0].GetScale3D() == SingleScale); + + Transforms = {FTransform::Identity}; + TestFalse(TEXT("Empty locations still reject an impossible rotation count"), + UDirectiveUtilMathFunctionLibrary::MakeTransformsFromArrays( + {}, {FRotator::ZeroRotator, SharedRotator}, {}, Transforms)); + TestTrue(TEXT("Rejected empty-location attributes clear the output"), Transforms.IsEmpty()); + Transforms = {FTransform::Identity}; + TestFalse(TEXT("Empty locations still reject an impossible scale count"), + UDirectiveUtilMathFunctionLibrary::MakeTransformsFromArrays( + {}, {}, {FVector::OneVector, SharedScale}, Transforms)); + TestTrue(TEXT("Rejected empty-location scales clear the output"), Transforms.IsEmpty()); + + const FVector FacingTarget(100.0, -200.0, 300.0); + const TArray FacingLocations = { + FacingTarget + FVector(10.0, 0.0, 0.0), + FacingTarget + FVector(0.0, -20.0, 0.0), + FacingTarget + }; + const TArray FacingTransforms = + UDirectiveUtilMathFunctionLibrary::LocationsToFacingTransforms( + FacingLocations, FacingTarget, FVector::UpVector, FRotator::ZeroRotator, SharedScale, false); + TestTrue(TEXT("Facing transforms preserve locations and face their target"), + FacingTransforms.Num() == 3 + && FacingTransforms[0].GetLocation() == FacingLocations[0] + && FacingTransforms[0].GetRotation().GetAxisX().Equals(FVector::BackwardVector, 1.e-8) + && FacingTransforms[1].GetRotation().GetAxisX().Equals(FVector::RightVector, 1.e-8) + && FacingTransforms[2].GetRotation().Equals(FQuat::Identity, 1.e-12) + && FacingTransforms[0].GetScale3D() == SharedScale); + + const TArray AwayTransforms = + UDirectiveUtilMathFunctionLibrary::LocationsToFacingTransforms( + FacingLocations, FacingTarget, FVector::UpVector, FRotator::ZeroRotator, FVector::OneVector, true); + TestTrue(TEXT("Facing transforms can point away from their target"), + AwayTransforms.Num() == 3 + && AwayTransforms[0].GetRotation().GetAxisX().Equals(FVector::ForwardVector, 1.e-8) + && AwayTransforms[1].GetRotation().GetAxisX().Equals(FVector::LeftVector, 1.e-8)); + + const FRotator FacingOffset(13.0, 17.0, 19.0); + const FQuat ExpectedFacingOffset = FRotationMatrix::MakeFromXZ( + FVector::BackwardVector, FVector::UpVector).ToQuat() * FacingOffset.Quaternion(); + const TArray OffsetFacingTransforms = + UDirectiveUtilMathFunctionLibrary::LocationsToFacingTransforms( + {FacingLocations[0]}, FacingTarget, FVector::UpVector, FacingOffset); + TestTrue(TEXT("Facing transforms apply their rotation offset in local space"), + OffsetFacingTransforms.Num() == 1 + && OffsetFacingTransforms[0].GetRotation().Equals(ExpectedFacingOffset, 1.e-12)); + + TestTrue(TEXT("Facing transforms reject invalid shared inputs"), + UDirectiveUtilMathFunctionLibrary::LocationsToFacingTransforms( + FacingLocations, FacingTarget, FVector::ZeroVector).IsEmpty() + && UDirectiveUtilMathFunctionLibrary::LocationsToFacingTransforms( + FacingLocations, FVector(Infinity), FVector::UpVector).IsEmpty() + && UDirectiveUtilMathFunctionLibrary::LocationsToFacingTransforms( + FacingLocations, FacingTarget, FVector::UpVector, InvalidRotation).IsEmpty() + && UDirectiveUtilMathFunctionLibrary::LocationsToFacingTransforms( + FacingLocations, FacingTarget, FVector::UpVector, FRotator::ZeroRotator, + FVector(Infinity)).IsEmpty()); + + const FVector RadialCenter(1000.0, -2000.0, 3000.0); + const FRotator RadialPlane(17.0, 31.0, 43.0); + const FQuat RadialPlaneQuaternion = RadialPlane.Quaternion(); + const FVector RadialNormal = RadialPlaneQuaternion.GetAxisZ(); + const TArray CircleLocations = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnCircle( + RadialCenter, RadialPlane, 25.0, 12, 11.0); + const TArray InwardCircleTransforms = + UDirectiveUtilMathFunctionLibrary::GenerateTransformsOnCircle( + RadialCenter, RadialPlane, 25.0, 12, 11.0, + EDirectiveUtilRadialOrientation::FaceCenter, FRotator::ZeroRotator, SharedScale); + bool bInwardCircleValid = InwardCircleTransforms.Num() == CircleLocations.Num(); + for (int32 Index = 0; Index < InwardCircleTransforms.Num(); ++Index) + { + const FVector Inward = (RadialCenter - CircleLocations[Index]).GetSafeNormal(); + bInwardCircleValid &= InwardCircleTransforms[Index].GetLocation().Equals(CircleLocations[Index], 1.e-8); + bInwardCircleValid &= InwardCircleTransforms[Index].GetRotation().GetAxisX().Equals(Inward, 1.e-8); + bInwardCircleValid &= InwardCircleTransforms[Index].GetScale3D() == SharedScale; + } + TestTrue(TEXT("Circle transforms match point locations and face their center"), bInwardCircleValid); + + const TArray OutwardCircleTransforms = + UDirectiveUtilMathFunctionLibrary::GenerateTransformsOnCircle( + RadialCenter, RadialPlane, 25.0, 12, 11.0, + EDirectiveUtilRadialOrientation::FaceAwayFromCenter); + const TArray ForwardCircleTransforms = + UDirectiveUtilMathFunctionLibrary::GenerateTransformsOnCircle( + RadialCenter, RadialPlane, 25.0, 12, 11.0, + EDirectiveUtilRadialOrientation::FollowPath); + const TArray ReverseCircleTransforms = + UDirectiveUtilMathFunctionLibrary::GenerateTransformsOnCircle( + RadialCenter, RadialPlane, 25.0, 12, 11.0, + EDirectiveUtilRadialOrientation::FaceAgainstPath); + bool bCircleOrientationsValid = OutwardCircleTransforms.Num() == CircleLocations.Num() + && ForwardCircleTransforms.Num() == CircleLocations.Num() + && ReverseCircleTransforms.Num() == CircleLocations.Num(); + for (int32 Index = 0; Index < CircleLocations.Num() && bCircleOrientationsValid; ++Index) + { + const FVector Radial = (CircleLocations[Index] - RadialCenter).GetSafeNormal(); + const FVector Tangent = FVector::CrossProduct(RadialNormal, Radial).GetSafeNormal(); + bCircleOrientationsValid &= OutwardCircleTransforms[Index].GetRotation().GetAxisX().Equals(Radial, 1.e-8); + bCircleOrientationsValid &= ForwardCircleTransforms[Index].GetRotation().GetAxisX().Equals(Tangent, 1.e-8); + bCircleOrientationsValid &= ReverseCircleTransforms[Index].GetRotation().GetAxisX().Equals(-Tangent, 1.e-8); + } + TestTrue(TEXT("Circle transforms support outward and both path orientations"), bCircleOrientationsValid); + + const TArray FixedCircleTransforms = + UDirectiveUtilMathFunctionLibrary::GenerateTransformsOnCircle( + RadialCenter, RadialPlane, 25.0, 4, 0.0, + EDirectiveUtilRadialOrientation::Fixed, FacingOffset); + FQuat ExpectedFixedRotation = RadialPlaneQuaternion * FacingOffset.Quaternion(); + ExpectedFixedRotation.Normalize(); + TestTrue(TEXT("Fixed circle transforms preserve the plane rotation and local offset"), + FixedCircleTransforms.Num() == 4 + && FixedCircleTransforms[0].GetRotation().Equals(ExpectedFixedRotation, 1.e-12) + && FixedCircleTransforms[3].GetRotation().Equals(ExpectedFixedRotation, 1.e-12)); + + const TArray NegativeArcTransforms = + UDirectiveUtilMathFunctionLibrary::GenerateTransformsOnArc( + RadialCenter, RadialPlane, 25.0, 3, 0.0, -90.0, true, + EDirectiveUtilRadialOrientation::FollowPath); + TestTrue(TEXT("A negative arc reverses follow-path orientation"), + NegativeArcTransforms.Num() == 3 + && NegativeArcTransforms[0].GetRotation().GetAxisX().Equals( + -RadialPlaneQuaternion.GetAxisY(), 1.e-8)); + const TArray NegativeArcLocations = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnArc( + RadialCenter, RadialPlane, 25.0, 3, 0.0, -90.0, true); + TestTrue(TEXT("Arc transforms match translated rotated point generation"), + NegativeArcTransforms.Num() == NegativeArcLocations.Num() + && NegativeArcTransforms[0].GetLocation().Equals(NegativeArcLocations[0], 1.e-8) + && NegativeArcTransforms[1].GetLocation().Equals(NegativeArcLocations[1], 1.e-8) + && NegativeArcTransforms[2].GetLocation().Equals(NegativeArcLocations[2], 1.e-8)); + + const TArray ZeroRadiusArcTransforms = + UDirectiveUtilMathFunctionLibrary::GenerateTransformsOnArc( + RadialCenter, RadialPlane, 0.0, 3, 0.0, 90.0, true, + EDirectiveUtilRadialOrientation::FaceCenter); + TestTrue(TEXT("Zero-radius arc transforms retain a deterministic radial orientation"), + ZeroRadiusArcTransforms.Num() == 3 + && ZeroRadiusArcTransforms[0].GetLocation() == RadialCenter + && ZeroRadiusArcTransforms[0].GetRotation().GetAxisX().Equals( + -RadialPlaneQuaternion.GetAxisX(), 1.e-8)); + + TestTrue(TEXT("Radial transform generators reject invalid input"), + UDirectiveUtilMathFunctionLibrary::GenerateTransformsOnCircle( + RadialCenter, RadialPlane, Infinity, 3).IsEmpty() + && UDirectiveUtilMathFunctionLibrary::GenerateTransformsOnArc( + RadialCenter, RadialPlane, 1.0, 3, 0.0, Infinity).IsEmpty() + && UDirectiveUtilMathFunctionLibrary::GenerateTransformsOnCircle( + RadialCenter, RadialPlane, 1.0, 3, 0.0, + static_cast(255)).IsEmpty()); + + USplineComponent* Spline = NewObject(); + 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, false); + Spline->SetScaleAtSplinePoint(0, FVector(1.0, 2.0, 3.0), false); + Spline->SetScaleAtSplinePoint(1, FVector(3.0, 4.0, 5.0), true); + const FVector SplineScaleMultiplier(2.0, 0.5, -1.0); + const TArray SplineTransforms = + UDirectiveUtilMathFunctionLibrary::GenerateTransformsAlongSpline( + Spline, 30.0, true, EDirectiveUtilSplineSpacingMode::Fixed, + ESplineCoordinateSpace::World, true, FacingOffset, SplineScaleMultiplier); + const double SplineDistances[] = {0.0, 30.0, 60.0, 90.0, 100.0}; + bool bSplineTransformsValid = SplineTransforms.Num() == 5; + for (int32 Index = 0; Index < SplineTransforms.Num(); ++Index) + { + FTransform Expected = Spline->GetTransformAtDistanceAlongSpline( + static_cast(SplineDistances[Index]), ESplineCoordinateSpace::World, true); + FQuat ExpectedRotation = Expected.GetRotation() * FacingOffset.Quaternion(); + ExpectedRotation.Normalize(); + bSplineTransformsValid &= SplineTransforms[Index].GetLocation().Equals(Expected.GetLocation(), 1.e-8); + bSplineTransformsValid &= SplineTransforms[Index].GetRotation().Equals(ExpectedRotation, 1.e-8); + bSplineTransformsValid &= SplineTransforms[Index].GetScale3D().Equals( + Expected.GetScale3D() * SplineScaleMultiplier, 1.e-8); + } + TestTrue(TEXT("Spline transforms preserve sampling, spline rotation, and spline scale"), + bSplineTransformsValid); + + const TArray UnscaledSplineTransforms = + UDirectiveUtilMathFunctionLibrary::GenerateTransformsAlongSpline( + Spline, 1000.0, true, EDirectiveUtilSplineSpacingMode::Fixed, + ESplineCoordinateSpace::World, false, FRotator::ZeroRotator, SharedScale); + TestTrue(TEXT("Spline scale can be replaced by a shared multiplier"), + UnscaledSplineTransforms.Num() == 2 + && UnscaledSplineTransforms[0].GetScale3D() == SharedScale + && UnscaledSplineTransforms[1].GetScale3D() == SharedScale); + + const TArray CountedSplineTransforms = + UDirectiveUtilMathFunctionLibrary::GenerateTransformsAlongSplineByCount(Spline, 3, true); + TestTrue(TEXT("Spline transforms by count include both exact endpoints"), + CountedSplineTransforms.Num() == 3 + && CountedSplineTransforms[0].GetLocation().Equals(FVector::ZeroVector, 1.e-4) + && CountedSplineTransforms[1].GetLocation().Equals(FVector(50.0, 0.0, 0.0), 1.e-4) + && CountedSplineTransforms[2].GetLocation().Equals(FVector(100.0, 0.0, 0.0), 1.e-4)); + + USplineComponent* SinglePointSpline = NewObject(); + SinglePointSpline->SetSplinePoints({FVector(3.0, 4.0, 5.0)}, ESplineCoordinateSpace::Local, true); + const TArray SinglePointSplineTransforms = + UDirectiveUtilMathFunctionLibrary::GenerateTransformsAlongSpline( + SinglePointSpline, 10.0); + TestTrue(TEXT("A zero-length spline returns one transform"), + SinglePointSplineTransforms.Num() == 1 + && SinglePointSplineTransforms[0].GetLocation().Equals(FVector(3.0, 4.0, 5.0), 1.e-8)); + + TestTrue(TEXT("Spline transform generation rejects invalid input"), + UDirectiveUtilMathFunctionLibrary::GenerateTransformsAlongSpline( + nullptr, 10.0).IsEmpty() + && UDirectiveUtilMathFunctionLibrary::GenerateTransformsAlongSpline( + Spline, 0.0).IsEmpty() + && UDirectiveUtilMathFunctionLibrary::GenerateTransformsAlongSpline( + Spline, 10.0, true, EDirectiveUtilSplineSpacingMode::Fixed, + ESplineCoordinateSpace::World, true, InvalidRotation).IsEmpty() + && UDirectiveUtilMathFunctionLibrary::GenerateTransformsAlongSpline( + Spline, 10.0, true, EDirectiveUtilSplineSpacingMode::Fixed, + ESplineCoordinateSpace::World, true, FRotator::ZeroRotator, FVector(Infinity)).IsEmpty() + && UDirectiveUtilMathFunctionLibrary::GenerateTransformsAlongSplineByCount( + Spline, 0).IsEmpty() + && UDirectiveUtilMathFunctionLibrary::GenerateTransformsAlongSplineByCount( + Spline, 3, true, ESplineCoordinateSpace::World, true, InvalidRotation).IsEmpty()); + + const TArray HexGridTransforms = + UDirectiveUtilMathFunctionLibrary::GenerateRectangularHexGridTransforms( + RadialCenter, FRotator::ZeroRotator, FIntPoint(2, 2), 25.0, + EDirectiveUtilHexOrientation::PointyTop, 0.0, false, FacingOffset, SharedScale); + const TArray HexGridPoints = UDirectiveUtilMathFunctionLibrary::GenerateRectangularHexGrid( + RadialCenter, FRotator::ZeroRotator, FIntPoint(2, 2), 25.0, + EDirectiveUtilHexOrientation::PointyTop, 0.0, false); + bool bHexTransformsValid = HexGridTransforms.Num() == 4 && HexGridPoints.Num() == 4; + for (int32 Index = 0; bHexTransformsValid && Index < HexGridTransforms.Num(); ++Index) + { + bHexTransformsValid &= HexGridTransforms[Index].GetLocation().Equals(HexGridPoints[Index], 1.e-9) + && HexGridTransforms[Index].GetRotation().Equals(FacingOffset.Quaternion(), 1.e-8) + && HexGridTransforms[Index].GetScale3D() == SharedScale; + } + TestTrue(TEXT("Rectangular hex grid transforms share instance rotation and scale over grid cells"), + bHexTransformsValid); + TestEqual(TEXT("Hexagonal hex grid transforms cover the requested rings"), + UDirectiveUtilMathFunctionLibrary::GenerateHexagonalHexGridTransforms( + RadialCenter, FRotator::ZeroRotator, 1, 25.0).Num(), 7); + TestTrue(TEXT("Hex grid transform generators reject invalid input"), + UDirectiveUtilMathFunctionLibrary::GenerateRectangularHexGridTransforms( + RadialCenter, FRotator::ZeroRotator, FIntPoint(2, 2), 25.0, + EDirectiveUtilHexOrientation::PointyTop, 0.0, true, InvalidRotation).IsEmpty() + && UDirectiveUtilMathFunctionLibrary::GenerateHexagonalHexGridTransforms( + RadialCenter, FRotator::ZeroRotator, 1, 0.0).IsEmpty()); + + const TArray NoiseBaseLocations = { + FVector::ZeroVector, FVector(37.0, 11.0, 5.0), FVector(250.0, -90.0, 40.0) + }; + const TArray NoiseBaseTransforms = UDirectiveUtilMathFunctionLibrary::LocationsToTransforms( + NoiseBaseLocations, FacingOffset, SharedScale); + const TArray NoisedTransforms = UDirectiveUtilMathFunctionLibrary::OffsetTransformsByNoise( + NoiseBaseTransforms, 100.0, 25.0); + const TArray NoisedLocations = UDirectiveUtilMathFunctionLibrary::OffsetLocationsByNoise( + NoiseBaseLocations, 100.0, 25.0); + bool bNoiseTransformsValid = NoisedTransforms.Num() == 3 && NoisedLocations.Num() == 3; + for (int32 Index = 0; bNoiseTransformsValid && Index < NoisedTransforms.Num(); ++Index) + { + bNoiseTransformsValid &= NoisedTransforms[Index].GetLocation().Equals(NoisedLocations[Index], 1.e-9) + && NoisedTransforms[Index].GetRotation().Equals(NoiseBaseTransforms[Index].GetRotation(), 1.e-9) + && NoisedTransforms[Index].GetScale3D() == NoiseBaseTransforms[Index].GetScale3D(); + } + TestTrue(TEXT("Transform noise offsets match location noise offsets and preserve rotation and scale"), + bNoiseTransformsValid); + TestTrue(TEXT("Transform noise offsets reject invalid input"), + UDirectiveUtilMathFunctionLibrary::OffsetTransformsByNoise(NoiseBaseTransforms, -1.0, 25.0).IsEmpty()); + + const TArray EaseFromLocations = { + FVector::ZeroVector, FVector(10.0, 0.0, 0.0), FVector(20.0, 0.0, 0.0) + }; + const TArray EaseToLocations = { + FVector(0.0, 10.0, 0.0), FVector(10.0, 10.0, 0.0), FVector(20.0, 10.0, 0.0) + }; + const TArray EasedLocations = UDirectiveUtilMathFunctionLibrary::EaseLocationArrays( + EaseFromLocations, EaseToLocations, 0.5f, EDirectiveUtilEaseType::Linear, {}); + TestTrue(TEXT("Eased location arrays blend element-wise"), + EasedLocations.Num() == 3 + && EasedLocations[0].Equals(FVector(0.0, 5.0, 0.0), 1.e-4) + && EasedLocations[2].Equals(FVector(20.0, 5.0, 0.0), 1.e-4)); + const TArray StaggeredLocations = UDirectiveUtilMathFunctionLibrary::EaseLocationArrays( + EaseFromLocations, EaseToLocations, 0.0f, EDirectiveUtilEaseType::Linear, { 0.0f, 0.5f, 1.0f }); + TestTrue(TEXT("Per-element alphas stagger the blend"), + StaggeredLocations.Num() == 3 + && StaggeredLocations[0].Equals(EaseFromLocations[0], 1.e-4) + && StaggeredLocations[1].Equals(FVector(10.0, 5.0, 0.0), 1.e-4) + && StaggeredLocations[2].Equals(EaseToLocations[2], 1.e-4)); + TestTrue(TEXT("Eased arrays reject mismatched lengths"), + UDirectiveUtilMathFunctionLibrary::EaseLocationArrays( + EaseFromLocations, { FVector::ZeroVector }, 0.5f, EDirectiveUtilEaseType::Linear, {}).IsEmpty() + && UDirectiveUtilMathFunctionLibrary::EaseLocationArrays( + EaseFromLocations, EaseToLocations, 0.5f, EDirectiveUtilEaseType::Linear, { 0.5f, 0.5f }).IsEmpty()); + + const TArray EaseFromTransforms = UDirectiveUtilMathFunctionLibrary::LocationsToTransforms( + EaseFromLocations, FRotator::ZeroRotator, FVector::OneVector); + const TArray EaseToTransforms = UDirectiveUtilMathFunctionLibrary::LocationsToTransforms( + EaseToLocations, FRotator(0.0, 90.0, 0.0), FVector(3.0)); + const TArray EasedTransforms = UDirectiveUtilMathFunctionLibrary::EaseTransformArrays( + EaseFromTransforms, EaseToTransforms, 0.5f, EDirectiveUtilEaseType::Linear, {}); + TestTrue(TEXT("Eased transform arrays blend location, rotation, and scale element-wise"), + EasedTransforms.Num() == 3 + && EasedTransforms[1].GetLocation().Equals(FVector(10.0, 5.0, 0.0), 1.e-4) + && EasedTransforms[1].GetRotation().Equals(FRotator(0.0, 45.0, 0.0).Quaternion(), 1.e-4) + && EasedTransforms[1].GetScale3D().Equals(FVector(2.0), 1.e-4)); + + const TArray SamplePath = { + FVector::ZeroVector, FVector(10.0, 0.0, 0.0), FVector(10.0, 10.0, 0.0) + }; + TestTrue(TEXT("Location array sampling is distance-weighted"), + UDirectiveUtilMathFunctionLibrary::SampleLocationArray(SamplePath, 0.75f).Equals( + FVector(10.0, 5.0, 0.0), 1.e-4) + && UDirectiveUtilMathFunctionLibrary::SampleLocationArray(SamplePath, 0.0f).Equals( + SamplePath[0], 1.e-4) + && UDirectiveUtilMathFunctionLibrary::SampleLocationArray(SamplePath, 1.0f).Equals( + SamplePath.Last(), 1.e-4) + && UDirectiveUtilMathFunctionLibrary::SampleLocationArray(SamplePath, 1.5f).Equals( + SamplePath.Last(), 1.e-4)); + TestTrue(TEXT("Closed-loop sampling wraps alpha back to the start"), + UDirectiveUtilMathFunctionLibrary::SampleLocationArray(SamplePath, 1.0f, true).Equals( + SamplePath[0], 1.e-4)); + TestTrue(TEXT("Degenerate location array sampling returns the only point or zero"), + UDirectiveUtilMathFunctionLibrary::SampleLocationArray({ FVector(3.0, 4.0, 5.0) }, 0.7f).Equals( + FVector(3.0, 4.0, 5.0), 1.e-4) + && UDirectiveUtilMathFunctionLibrary::SampleLocationArray({}, 0.5f).IsZero()); + + const TArray SampleTransformPath = { + FTransform(FRotator::ZeroRotator, FVector::ZeroVector, FVector::OneVector), + FTransform(FRotator(0.0, 90.0, 0.0), FVector(10.0, 0.0, 0.0), FVector(3.0)) + }; + const FTransform SampledTransform = UDirectiveUtilMathFunctionLibrary::SampleTransformArray( + SampleTransformPath, 0.5f); + TestTrue(TEXT("Transform array sampling blends location, rotation, and scale"), + SampledTransform.GetLocation().Equals(FVector(5.0, 0.0, 0.0), 1.e-4) + && SampledTransform.GetRotation().Equals(FRotator(0.0, 45.0, 0.0).Quaternion(), 1.e-4) + && SampledTransform.GetScale3D().Equals(FVector(2.0), 1.e-4)); + TestTrue(TEXT("Empty transform array sampling returns the identity"), + UDirectiveUtilMathFunctionLibrary::SampleTransformArray({}, 0.5f).Equals(FTransform::Identity)); + + return !HasAnyErrors(); +} diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Public/DirectiveUtilitiesTests.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Public/DirectiveUtilitiesTests.h index 0bd1adba..100ddd6e 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Public/DirectiveUtilitiesTests.h +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Public/DirectiveUtilitiesTests.h @@ -1,4 +1,6 @@ -#pragma once +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#pragma once #include "CoreMinimal.h" #include "Modules/ModuleManager.h" diff --git a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Public/Tests/DirectiveUtilTestObject.h b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Public/Tests/DirectiveUtilTestObject.h index 1954999b..16d61bc5 100644 --- a/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Public/Tests/DirectiveUtilTestObject.h +++ b/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Public/Tests/DirectiveUtilTestObject.h @@ -1,12 +1,70 @@ -#pragma once +// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License. + +#pragma once #include "CoreMinimal.h" +#include "UObject/Interface.h" #include "UObject/Object.h" #include "GameFramework/SaveGame.h" #include "Engine/HitResult.h" +#include "Tasks/DirectiveUtilTask_MoveToLocation.h" #include "DirectiveUtilTestObject.generated.h" class UWorld; +class UGameInstance; + +UINTERFACE() +class UDirectiveUtilTestInterface : public UInterface +{ + GENERATED_BODY() +}; + +class IDirectiveUtilTestInterface +{ + GENERATED_BODY() +}; + +USTRUCT(BlueprintType) +struct FDirectiveUtilCollisionValue +{ + GENERATED_BODY() + + UPROPERTY(BlueprintReadWrite, Category = "Directive Utilities|Tests") + int32 Value = 0; + + bool operator==(const FDirectiveUtilCollisionValue& Other) const + { + return Value == Other.Value; + } + + friend uint32 GetTypeHash(const FDirectiveUtilCollisionValue&) + { + return 0; + } +}; + +template <> +struct TStructOpsTypeTraits : TStructOpsTypeTraitsBase2 +{ + enum + { + WithIdenticalViaEquality = true + }; +}; + +USTRUCT() +struct FDirectiveUtilPodValue +{ + GENERATED_BODY() + + UPROPERTY() + int32 Index; + + UPROPERTY() + float Weight; +}; + +static_assert(TIsPODType::Value); UCLASS() class UDirectiveUtilTestObject : public UObject @@ -17,6 +75,66 @@ public: UPROPERTY() TArray TestArray; + UPROPERTY(BlueprintReadWrite, Category = "Directive Utilities|Tests") + TArray TestStringArray; + + UPROPERTY() + TArray TestNameArray; + + UPROPERTY() + TArray TestTextArray; + + UPROPERTY(BlueprintReadWrite, Category = "Directive Utilities|Tests") + TArray TestBoolArray; + + UPROPERTY(BlueprintReadWrite, Category = "Directive Utilities|Tests") + TArray TestCollisionArray; + + UPROPERTY() + TArray TestPodArray; + + UPROPERTY(BlueprintReadWrite, Category = "Directive Utilities|Tests") + TArray> TestObjectArray; + + UPROPERTY(BlueprintReadWrite, Category = "Directive Utilities|Tests") + TArray TestBoolSourceArray; + + UPROPERTY(BlueprintReadWrite, Category = "Directive Utilities|Tests") + TArray TestStringSourceArray; + + UPROPERTY(BlueprintReadWrite, Category = "Directive Utilities|Tests") + TArray TestCollisionSourceArray; + + UPROPERTY(BlueprintReadWrite, Category = "Directive Utilities|Tests") + TArray> TestObjectSourceArray; + + UPROPERTY(BlueprintReadWrite, Category = "Directive Utilities|Tests") + TArray TestIndices; + + UPROPERTY(BlueprintReadWrite, Category = "Directive Utilities|Tests") + bool TestBoolItem = false; + + UPROPERTY(BlueprintReadWrite, Category = "Directive Utilities|Tests") + FString TestStringItem; + + UPROPERTY(BlueprintReadWrite, Category = "Directive Utilities|Tests") + FDirectiveUtilCollisionValue TestCollisionItem; + + UPROPERTY(BlueprintReadWrite, Category = "Directive Utilities|Tests") + TObjectPtr TestObjectItem; + + UPROPERTY(BlueprintReadWrite, Category = "Directive Utilities|Tests") + bool TestInsertResult = false; + + UPROPERTY(BlueprintReadWrite, Category = "Directive Utilities|Tests") + int32 TestRemovedCount = 0; + + UPROPERTY(BlueprintReadWrite, Category = "Directive Utilities|Tests") + bool TestRemoveAllResult = false; + + UFUNCTION(BlueprintImplementableEvent) + void RunArrayThunkScenario(); + UPROPERTY() TMap TestMap; @@ -25,9 +143,17 @@ public: UPROPERTY() TMap TestStringKeyMap; + + UPROPERTY() + TMap TestStringMap; + + UPROPERTY() + TMap TestStringMap2; + + UPROPERTY() + TMap TestStructValueMap; }; -/** Concrete save-game subclass for tests (USaveGame itself is abstract and cannot be instantiated). */ UCLASS() class UDirectiveUtilTestSaveGame : public USaveGame { @@ -38,11 +164,6 @@ public: int32 TestValue = 0; }; -/** - * Listener for exercising the async-task dynamic multicast delegates from automation tests. - * Dynamic delegates can only bind to UFUNCTIONs, so the handlers live on a UObject. The - * Keepalive property lets a rooted listener keep the async task itself alive across frames. - */ UCLASS() class UDirectiveUtilDelegateListener : public UObject { @@ -58,58 +179,139 @@ public: UPROPERTY() int32 CompletedCount = 0; - /** Number of hits reported by the most recent trace completion. */ UPROPERTY() int32 HitCount = 0; - /** Success flag from the most recent bool-payload completion (e.g. move-to-location). */ + UPROPERTY() + int32 UpdatedCount = 0; + + UPROPERTY() + int32 IterationCount = 0; + + UPROPERTY() + float LastElapsedTime = 0.0f; + + UPROPERTY() + float LastDeltaTime = 0.0f; + + UPROPERTY() + float LastAlpha = 0.0f; + + UPROPERTY() + TArray IterationIndices; + + UPROPERTY() + TArray IterationRemaining; + + UPROPERTY() + TArray UpdateElapsedTimes; + + UPROPERTY() + TArray UpdateDeltaTimes; + + UPROPERTY() + TArray UpdateAlphas; + UPROPERTY() bool bLastSuccess = false; UPROPERTY() TObjectPtr LastObject = nullptr; - /** The object array from the most recent batch completion (e.g. async load assets). */ UPROPERTY() TArray> LastObjects; - /** Holds a strong reference to the async task so it survives GC while the test waits. */ UPROPERTY() TObjectPtr Keepalive = nullptr; - /** The transient world a latent delay scenario ticks and tears down when it settles. */ UPROPERTY() TObjectPtr ScenarioWorld = nullptr; - /** Handler for parameterless completion delegates (e.g. the cancellable delay). */ + UPROPERTY() + TObjectPtr ScenarioGameInstance = nullptr; + UFUNCTION() void OnCompleted() { bCompleted = true; ++CompletedCount; } - /** Handler for object-payload completion delegates (e.g. async load asset). */ UFUNCTION() void OnObjectCompleted(UObject* Object) { bCompleted = true; ++CompletedCount; LastObject = Object; } - /** Handler for object-payload failure delegates. */ UFUNCTION() void OnObjectFailed(UObject* Object) { bFailed = true; } - /** Handler for object-array-payload completion delegates (e.g. async load assets). */ UFUNCTION() void OnObjectsCompleted(const TArray& Objects) { bCompleted = true; ++CompletedCount; LastObjects.Reset(); LastObjects.Append(Objects); } - /** Handler for class-payload completion delegates (e.g. async load class). */ UFUNCTION() void OnClassCompleted(UClass* Class) { bCompleted = true; ++CompletedCount; LastObject = Class; } - /** Handler for class-payload failure delegates. */ UFUNCTION() void OnClassFailed(UClass* Class) { bFailed = true; } - /** Handler for trace completion delegates. */ UFUNCTION() void OnTraceCompleted(const TArray& Hits) { bCompleted = true; ++CompletedCount; HitCount = Hits.Num(); } - /** Handler for bool-payload completion delegates (e.g. move-to-location). */ UFUNCTION() void OnBoolCompleted(bool bSuccess) { bCompleted = true; ++CompletedCount; bLastSuccess = bSuccess; } -}; \ No newline at end of file + + UFUNCTION() + void OnDurationUpdated(float ElapsedTime, float DeltaTime, float Alpha) { ++UpdatedCount; LastElapsedTime = ElapsedTime; LastDeltaTime = DeltaTime; LastAlpha = Alpha; UpdateElapsedTimes.Add(ElapsedTime); UpdateDeltaTimes.Add(DeltaTime); UpdateAlphas.Add(Alpha); } + + UFUNCTION() + void OnRepeatIteration(int32 Index, int32 Remaining) { ++IterationCount; IterationIndices.Add(Index); IterationRemaining.Add(Remaining); } + +}; + +UCLASS() +class UDirectiveUtilTestMoveToLocationTask : public UDirectiveUtilTask_MoveToLocation +{ + GENERATED_BODY() + +public: + void Configure(AController* InController, const FVector InDestination, const bool bInCheckStuckMovement) + { + Controller = InController; + Destination = InDestination; + bCheckStuckMovement = bInCheckStuckMovement; + } + + void ClearController() + { + Controller = nullptr; + } + + void Complete() + { + ExecuteCompleted(false); + } + + void RegisterTimersForTest(UWorld* World); + bool HasRegisteredTimers() const; +}; + +UCLASS() +class UDirectiveUtilTestMoveToActorTask : public UDirectiveUtilTask_MoveToActor +{ + GENERATED_BODY() + +public: + void Configure(AController* InController, AActor* InGoal, const bool bInCheckStuckMovement) + { + Controller = InController; + Goal = InGoal; + bCheckStuckMovement = bInCheckStuckMovement; + } + + void ClearController() + { + Controller = nullptr; + } + + void Complete() + { + ExecuteCompleted(false); + } + + void RegisterTimersForTest(UWorld* World); + bool HasRegisteredTimers() const; +};