You've already forked UnrealEngineUWP
mirror of
https://github.com/izzy2lost/UnrealEngineUWP.git
synced 2026-03-26 18:15:20 -07:00
Move asset PostLoad call into the if() block that needs it when loading an anim graph node, as in this code path not all subobjects are loaded for the various assets that are referenced by the pose asset This could still fail for very old assets, but at least unblocks this issue. #jira UE-190467 #rb Nicholas.Frechette [FYI] Jurre.deBaare [CL 26700547 by thomas sarkanen in ue5-main branch]
501 lines
16 KiB
C++
501 lines
16 KiB
C++
// Copyright Epic Games, Inc. All Rights Reserved.
|
|
|
|
#include "AnimGraphNode_PoseDriver.h"
|
|
#include "Kismet2/CompilerResultsLog.h"
|
|
#include "AnimNodeEditModes.h"
|
|
#include "RBF/RBFSolver.h"
|
|
#include "Kismet2/BlueprintEditorUtils.h"
|
|
#include "Components/SkeletalMeshComponent.h"
|
|
#include "Animation/AnimInstance.h"
|
|
|
|
#define LOCTEXT_NAMESPACE "PoseDriver"
|
|
|
|
struct FPoseDriverCustomVersion
|
|
{
|
|
enum Type
|
|
{
|
|
// Before any version changes were made
|
|
BeforeCustomVersionWasAdded = 0,
|
|
// Add RBFData
|
|
AddRBFData = 1,
|
|
// Add multi-bone input support
|
|
MultiBoneInput = 2,
|
|
|
|
// -----<new versions can be added above this line>-------------------------------------------------
|
|
VersionPlusOne,
|
|
LatestVersion = VersionPlusOne - 1
|
|
};
|
|
|
|
// The GUID for this custom version number
|
|
const static FGuid GUID;
|
|
|
|
private:
|
|
FPoseDriverCustomVersion() {}
|
|
};
|
|
|
|
const FGuid FPoseDriverCustomVersion::GUID(0xAFE08691, 0x3A0D4952, 0xB673673B, 0x7CF22D1E);
|
|
FCustomVersionRegistration GRegisterPoseDriverCustomVersion(FPoseDriverCustomVersion::GUID, FPoseDriverCustomVersion::LatestVersion, TEXT("PoseDriverVer"));
|
|
|
|
|
|
//////////////////////////////////////////////////////////////////////////
|
|
|
|
UAnimGraphNode_PoseDriver::UAnimGraphNode_PoseDriver(const FObjectInitializer& ObjectInitializer)
|
|
:Super(ObjectInitializer)
|
|
{
|
|
SelectedTargetIndex = INDEX_NONE;
|
|
AxisLength = 20.0f;
|
|
bDrawDebugCones = true;
|
|
ConeSubdivision = 32;
|
|
}
|
|
|
|
FText UAnimGraphNode_PoseDriver::GetTooltipText() const
|
|
{
|
|
return LOCTEXT("UAnimGraphNode_PoseDriver_ToolTip", "Drive parameters base on a bones distance from a set of defined poses.");
|
|
}
|
|
|
|
FText UAnimGraphNode_PoseDriver::GetNodeTitle(ENodeTitleType::Type TitleType) const
|
|
{
|
|
static const FText DescriptionAll = LOCTEXT("PoseDriver", "Pose Driver");
|
|
static const FText DescriptionSolo = LOCTEXT("PoseDriverSolo", "Pose Driver [Solo]");
|
|
|
|
const FText& Description = Node.SoloTargetIndex == INDEX_NONE ? DescriptionAll : DescriptionSolo;
|
|
|
|
const FName FirstSourceBone = (Node.SourceBones.Num() > 0) ? Node.SourceBones[0].BoneName : NAME_None;
|
|
if ((TitleType == ENodeTitleType::ListView || TitleType == ENodeTitleType::MenuTitle) && (FirstSourceBone == NAME_None))
|
|
{
|
|
return Description;
|
|
}
|
|
else
|
|
{
|
|
FFormatNamedArguments Args;
|
|
Args.Add(TEXT("ControllerDescription"), Description);
|
|
const int32 TotalNumBones = Node.SourceBones.Num();
|
|
Args.Add(TEXT("FirstSourceBone"), FText::FromName(FirstSourceBone));
|
|
|
|
// FText::Format() is slow, so we cache this to save on performance
|
|
if (TitleType == ENodeTitleType::ListView || TitleType == ENodeTitleType::MenuTitle)
|
|
{
|
|
CachedNodeTitles.SetCachedTitle(TitleType, FText::Format(LOCTEXT("AnimGraphNode_PoseDriver_ListTitle", "{ControllerDescription} - Source: {FirstSourceBone}"), Args), this);
|
|
}
|
|
else
|
|
{
|
|
if (TotalNumBones > 1)
|
|
{
|
|
Args.Add(TEXT("MoreJoint"), FText::AsNumber(TotalNumBones - 1));
|
|
CachedNodeTitles.SetCachedTitle(TitleType, FText::Format(LOCTEXT("AnimGraphNode_PoseDriver_TitleWithBones", "{ControllerDescription}\nSource: {FirstSourceBone} and {MoreJoint} more"), Args), this);
|
|
}
|
|
else
|
|
{
|
|
CachedNodeTitles.SetCachedTitle(TitleType, FText::Format(LOCTEXT("AnimGraphNode_PoseDriver_Title", "{ControllerDescription}\nSource: {FirstSourceBone}"), Args), this);
|
|
}
|
|
}
|
|
}
|
|
return CachedNodeTitles[TitleType];
|
|
}
|
|
|
|
FText UAnimGraphNode_PoseDriver::GetMenuCategory() const
|
|
{
|
|
return LOCTEXT("PoseAssetCategory_Label", "Animation|Poses");
|
|
}
|
|
|
|
|
|
FLinearColor UAnimGraphNode_PoseDriver::GetNodeBodyTintColor() const
|
|
{
|
|
return Node.SoloTargetIndex == INDEX_NONE ?
|
|
Super::GetNodeBodyTintColor() :
|
|
FLinearColor::Green;
|
|
}
|
|
|
|
void UAnimGraphNode_PoseDriver::ValidateAnimNodeDuringCompilation(USkeleton* ForSkeleton, FCompilerResultsLog& MessageLog)
|
|
{
|
|
if (Node.SourceBones.Num() == 0)
|
|
{
|
|
MessageLog.Warning(*LOCTEXT("PoseDriver_NoSourceBone", "@@ - You must specify at least one Source Bone").ToString(), this);
|
|
}
|
|
|
|
FName MissingBoneName = NAME_None;
|
|
if (ForSkeleton)
|
|
{
|
|
for (const FBoneReference& BoneRef : Node.SourceBones)
|
|
{
|
|
if (ForSkeleton->GetReferenceSkeleton().FindBoneIndex(BoneRef.BoneName) == INDEX_NONE)
|
|
{
|
|
MissingBoneName = BoneRef.BoneName;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (MissingBoneName != NAME_None)
|
|
{
|
|
MessageLog.Warning(*LOCTEXT("SourceBoneNotFound", "@@ - Entry in SourceBones not found").ToString(), this);
|
|
}
|
|
|
|
TArray<FRBFTarget> RBFTargets;
|
|
Node.GetRBFTargets(RBFTargets, nullptr);
|
|
TArray<int> InvalidTargets;
|
|
if (!FRBFSolver::ValidateTargets(Node.RBFParams, RBFTargets, InvalidTargets))
|
|
{
|
|
for (int TargetIdx : InvalidTargets)
|
|
{
|
|
MessageLog.Error(*LOCTEXT("PoseDriver_InvalidTarget", "@@ - '@@' is an invalid or duplicate target.").ToString(),
|
|
this, GetData(Node.PoseTargets[TargetIdx].DrivenName.ToString()));
|
|
}
|
|
}
|
|
|
|
if (Node.SoloTargetIndex != INDEX_NONE)
|
|
{
|
|
MessageLog.Warning(*LOCTEXT("PoseDriver_SoloEnabled", "@@ - Solo enabled on target '@@'").ToString(),
|
|
this, GetData(Node.PoseTargets[Node.SoloTargetIndex].DrivenName.ToString()));
|
|
}
|
|
|
|
// Note: UAnimGraphNode_PoseHandler::ValidateAnimNodeDuringCompilation checks if PoseAsset is valid,
|
|
// This check is only necessary when using DrivePoses
|
|
if (Node.DriveOutput == EPoseDriverOutput::DrivePoses)
|
|
{
|
|
Super::ValidateAnimNodeDuringCompilation(ForSkeleton, MessageLog);
|
|
}
|
|
else
|
|
{
|
|
UAnimGraphNode_AssetPlayerBase::ValidateAnimNodeDuringCompilation(ForSkeleton, MessageLog);
|
|
}
|
|
}
|
|
|
|
FEditorModeID UAnimGraphNode_PoseDriver::GetEditorMode() const
|
|
{
|
|
return AnimNodeEditModes::PoseDriver;
|
|
}
|
|
|
|
EAnimAssetHandlerType UAnimGraphNode_PoseDriver::SupportsAssetClass(const UClass* AssetClass) const
|
|
{
|
|
if (AssetClass->IsChildOf(UPoseAsset::StaticClass()))
|
|
{
|
|
return EAnimAssetHandlerType::Supported;
|
|
}
|
|
else
|
|
{
|
|
return EAnimAssetHandlerType::NotSupported;
|
|
}
|
|
}
|
|
|
|
void UAnimGraphNode_PoseDriver::PostLoad()
|
|
{
|
|
Super::PostLoad();
|
|
|
|
if (GetLinkerCustomVersion(FPoseDriverCustomVersion::GUID) < FPoseDriverCustomVersion::MultiBoneInput)
|
|
{
|
|
if (Node.SourceBone_DEPRECATED.BoneName != NAME_None)
|
|
{
|
|
Node.SourceBones.Add(Node.SourceBone_DEPRECATED);
|
|
}
|
|
}
|
|
|
|
if (GetLinkerCustomVersion(FPoseDriverCustomVersion::GUID) < FPoseDriverCustomVersion::AddRBFData)
|
|
{
|
|
// since this is postload, sometimes pose asset post load isn't finished yet
|
|
// we mmake sure it finishes since this needs post info
|
|
if (Node.PoseAsset)
|
|
{
|
|
Node.PoseAsset->ConditionalPostLoad();
|
|
}
|
|
|
|
// Convert distance method
|
|
if (Node.Type_DEPRECATED == EPoseDriverType::SwingAndTwist)
|
|
{
|
|
Node.DriveSource = EPoseDriverSource::Rotation;
|
|
Node.RBFParams.DistanceMethod = ERBFDistanceMethod::Quaternion;
|
|
}
|
|
else if (Node.Type_DEPRECATED == EPoseDriverType::SwingOnly)
|
|
{
|
|
Node.DriveSource = EPoseDriverSource::Rotation;
|
|
Node.RBFParams.DistanceMethod = ERBFDistanceMethod::SwingAngle;
|
|
}
|
|
else
|
|
{
|
|
Node.DriveSource = EPoseDriverSource::Translation;
|
|
Node.RBFParams.DistanceMethod = ERBFDistanceMethod::Euclidean;
|
|
}
|
|
|
|
// Copy twist axis
|
|
Node.RBFParams.TwistAxis = Node.TwistAxis_DEPRECATED;
|
|
|
|
// Copy target data from pose asset
|
|
CopyTargetsFromPoseAsset();
|
|
|
|
// Set per-target scales
|
|
float MaxDistance;
|
|
AutoSetTargetScales(MaxDistance);
|
|
|
|
// Set radiust to be max distance, and apply old overall radius scaling
|
|
Node.RBFParams.Radius = MaxDistance * Node.RadialScaling_DEPRECATED;
|
|
|
|
// Recompile if required to propagate changes to AnimInstance Class
|
|
UAnimBlueprint* AnimBP = GetAnimBlueprint();
|
|
if (AnimBP)
|
|
{
|
|
FBlueprintEditorUtils::MarkBlueprintAsModified(AnimBP);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
void UAnimGraphNode_PoseDriver::Serialize(FArchive& Ar)
|
|
{
|
|
Super::Serialize(Ar);
|
|
Ar.UsingCustomVersion(FPoseDriverCustomVersion::GUID);
|
|
}
|
|
|
|
void UAnimGraphNode_PoseDriver::CopyNodeDataToPreviewNode(FAnimNode_Base* InPreviewNode) {
|
|
FAnimNode_PoseDriver* PreviewPoseDriver = static_cast<FAnimNode_PoseDriver*>(InPreviewNode);
|
|
|
|
PreviewPoseDriver->RBFParams.SolverType = Node.RBFParams.SolverType;
|
|
PreviewPoseDriver->RBFParams.Radius = Node.RBFParams.Radius;
|
|
PreviewPoseDriver->RBFParams.bAutomaticRadius = Node.RBFParams.bAutomaticRadius;
|
|
PreviewPoseDriver->RBFParams.Function = Node.RBFParams.Function;
|
|
PreviewPoseDriver->RBFParams.DistanceMethod = Node.RBFParams.DistanceMethod;
|
|
PreviewPoseDriver->RBFParams.TwistAxis = Node.RBFParams.TwistAxis;
|
|
PreviewPoseDriver->RBFParams.WeightThreshold = Node.RBFParams.WeightThreshold;
|
|
PreviewPoseDriver->RBFParams.NormalizeMethod = Node.RBFParams.NormalizeMethod;
|
|
PreviewPoseDriver->RBFParams.MedianReference = Node.RBFParams.MedianReference;
|
|
PreviewPoseDriver->RBFParams.MedianMin = Node.RBFParams.MedianMin;
|
|
PreviewPoseDriver->RBFParams.MedianMax = Node.RBFParams.MedianMax;
|
|
PreviewPoseDriver->DriveOutput = Node.DriveOutput;
|
|
PreviewPoseDriver->DriveSource = Node.DriveSource;
|
|
PreviewPoseDriver->PoseTargets = Node.PoseTargets;
|
|
PreviewPoseDriver->bCachedDrivenIDsAreDirty = true;
|
|
PreviewPoseDriver->SoloTargetIndex = Node.SoloTargetIndex;
|
|
PreviewPoseDriver->bSoloDrivenOnly = Node.bSoloDrivenOnly;
|
|
}
|
|
|
|
FAnimNode_PoseDriver* UAnimGraphNode_PoseDriver::GetPreviewPoseDriverNode() const
|
|
{
|
|
FAnimNode_PoseDriver* PreviewNode = nullptr;
|
|
USkeletalMeshComponent* Component = nullptr;
|
|
|
|
// look for a valid component in the object being debugged,
|
|
// we might be set to something other than the preview.
|
|
UObject* ObjectBeingDebugged = GetAnimBlueprint()->GetObjectBeingDebugged();
|
|
if (ObjectBeingDebugged)
|
|
{
|
|
UAnimInstance* InstanceBeingDebugged = Cast<UAnimInstance>(ObjectBeingDebugged);
|
|
if (InstanceBeingDebugged)
|
|
{
|
|
Component = InstanceBeingDebugged->GetSkelMeshComponent();
|
|
}
|
|
}
|
|
|
|
// Fall back to the LastPreviewComponent
|
|
if (Component == nullptr)
|
|
{
|
|
Component = LastPreviewComponent;
|
|
}
|
|
if (Component != nullptr && Component->GetAnimInstance() != nullptr)
|
|
{
|
|
PreviewNode = static_cast<FAnimNode_PoseDriver*>(FindDebugAnimNode(Component));
|
|
}
|
|
|
|
return PreviewNode;
|
|
}
|
|
|
|
void UAnimGraphNode_PoseDriver::CopyTargetsFromPoseAsset()
|
|
{
|
|
UPoseAsset* PoseAsset = Node.PoseAsset;
|
|
|
|
// store the previous targets for re-use
|
|
TMap< FName, FPoseDriverTarget > PreviousTargets;
|
|
for (int32 i = 0; i < Node.PoseTargets.Num(); i++)
|
|
{
|
|
PreviousTargets.Add(Node.PoseTargets[i].DrivenName, Node.PoseTargets[i]);
|
|
}
|
|
|
|
if (PoseAsset && PoseAsset->GetSkeleton()) // Use PoseAsset here not CurrentPoseAsset, because we want to be able to run this on on nodes that have not been init'd yet
|
|
{
|
|
Node.PoseTargets.Empty();
|
|
|
|
// For each pose we create a target
|
|
const TArray<FName>& PoseNames = PoseAsset->GetPoseFNames();
|
|
for (int32 PoseIdx = 0; PoseIdx < PoseAsset->GetNumPoses(); PoseIdx++)
|
|
{
|
|
FPoseDriverTarget PoseTarget;
|
|
PoseTarget.DrivenName = PoseNames[PoseIdx];
|
|
|
|
// Create entry for each bone
|
|
for (const FBoneReference& SourceBoneRef : Node.SourceBones)
|
|
{
|
|
FTransform SourceBoneTransform = FTransform::Identity;
|
|
|
|
// Don't want to create target for base pose in additive case
|
|
bool bIsBasePose = (PoseAsset->IsValidAdditive() && PoseIdx == PoseAsset->GetBasePoseIndex());
|
|
if (!bIsBasePose)
|
|
{
|
|
// Get transforms from pose (this also converts from additive if necessary)
|
|
TArray<FTransform> PoseTransforms;
|
|
if (PoseAsset->GetFullPose(PoseIdx, PoseTransforms))
|
|
{
|
|
// If eval'ing in different space (and that space is valid)
|
|
if (Node.EvalSpaceBone.BoneName != NAME_None)
|
|
{
|
|
FTransform SourceCompSpace = PoseAsset->GetComponentSpaceTransform(SourceBoneRef.BoneName, PoseTransforms);
|
|
FTransform EvalCompSpace = PoseAsset->GetComponentSpaceTransform(Node.EvalSpaceBone.BoneName, PoseTransforms);
|
|
|
|
SourceBoneTransform = SourceCompSpace.GetRelativeTransform(EvalCompSpace);
|
|
}
|
|
else
|
|
{
|
|
// Check we have a track for the source bone
|
|
int32 SourceTrackIndex = PoseAsset->GetTrackIndexByName(SourceBoneRef.BoneName);
|
|
if (SourceTrackIndex != INDEX_NONE)
|
|
{
|
|
SourceBoneTransform = PoseTransforms[SourceTrackIndex];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we got a valid transform, add a pose target now
|
|
FPoseDriverTransform PoseTransform;
|
|
PoseTransform.TargetTranslation = SourceBoneTransform.GetTranslation();
|
|
PoseTransform.TargetRotation = SourceBoneTransform.Rotator();
|
|
|
|
PoseTarget.BoneTransforms.Add(PoseTransform);
|
|
}
|
|
|
|
// re-apply the same setting in case we have seen this target before
|
|
const FPoseDriverTarget* PreviousTarget = PreviousTargets.Find(PoseTarget.DrivenName);
|
|
if (PreviousTarget)
|
|
{
|
|
PoseTarget.TargetScale = PreviousTarget->TargetScale;
|
|
PoseTarget.bApplyCustomCurve = PreviousTarget->bApplyCustomCurve;
|
|
PoseTarget.CustomCurve = PreviousTarget->CustomCurve;
|
|
PoseTarget.DistanceMethod = PreviousTarget->DistanceMethod;
|
|
PoseTarget.FunctionType = PreviousTarget->FunctionType;
|
|
PoseTarget.bIsHidden = PreviousTarget->bIsHidden;
|
|
}
|
|
|
|
Node.PoseTargets.Add(PoseTarget);
|
|
}
|
|
|
|
Node.bCachedDrivenIDsAreDirty = true;
|
|
}
|
|
}
|
|
|
|
void UAnimGraphNode_PoseDriver::SetSourceBones(const TArray<FName>& BoneNames)
|
|
{
|
|
Node.SourceBones.Empty(BoneNames.Num());
|
|
for (const FName& BoneName : BoneNames)
|
|
{
|
|
Node.SourceBones.Add(BoneName);
|
|
}
|
|
}
|
|
|
|
void UAnimGraphNode_PoseDriver::GetSourceBoneNames(TArray<FName>& BoneNames)
|
|
{
|
|
for (const FBoneReference& SourceBone : Node.SourceBones)
|
|
{
|
|
BoneNames.Add(SourceBone.BoneName);
|
|
}
|
|
}
|
|
|
|
void UAnimGraphNode_PoseDriver::SetDrivingBones(const TArray<FName>& BoneNames)
|
|
{
|
|
Node.OnlyDriveBones.Empty(BoneNames.Num());
|
|
for (const FName& BoneName : BoneNames)
|
|
{
|
|
Node.OnlyDriveBones.Add(BoneName);
|
|
}
|
|
}
|
|
|
|
void UAnimGraphNode_PoseDriver::GetDrivingBoneNames(TArray<FName>& BoneNames)
|
|
{
|
|
for (const FBoneReference& SourceBone : Node.OnlyDriveBones)
|
|
{
|
|
BoneNames.Add(SourceBone.BoneName);
|
|
}
|
|
}
|
|
|
|
void UAnimGraphNode_PoseDriver::SetRBFParameters(FRBFParams Parameters)
|
|
{
|
|
Node.RBFParams = Parameters;
|
|
}
|
|
|
|
FRBFParams& UAnimGraphNode_PoseDriver::GetRBFParameters()
|
|
{
|
|
return Node.RBFParams;
|
|
}
|
|
|
|
void UAnimGraphNode_PoseDriver::SetPoseDriverSource(EPoseDriverSource DriverSource)
|
|
{
|
|
Node.DriveSource = DriverSource;
|
|
}
|
|
|
|
EPoseDriverSource& UAnimGraphNode_PoseDriver::GetPoseDriverSource()
|
|
{
|
|
return Node.DriveSource;
|
|
}
|
|
|
|
void UAnimGraphNode_PoseDriver::SetPoseDriverOutput(EPoseDriverOutput DriverOutput)
|
|
{
|
|
Node.DriveOutput = DriverOutput;
|
|
}
|
|
|
|
EPoseDriverOutput& UAnimGraphNode_PoseDriver::GetPoseDriverOutput()
|
|
{
|
|
return Node.DriveOutput;
|
|
}
|
|
|
|
void UAnimGraphNode_PoseDriver::AddNewTarget()
|
|
{
|
|
FPoseDriverTarget& NewTarget = Node.PoseTargets[Node.PoseTargets.Add(FPoseDriverTarget())];
|
|
|
|
// Create entry for each bone
|
|
NewTarget.BoneTransforms.AddDefaulted(Node.SourceBones.Num());
|
|
}
|
|
|
|
|
|
void UAnimGraphNode_PoseDriver::ReserveTargetTransforms()
|
|
{
|
|
// reallocate transforms array in each target
|
|
for (FPoseDriverTarget& PoseTarget : Node.PoseTargets)
|
|
{
|
|
PoseTarget.BoneTransforms.SetNum(Node.SourceBones.Num());
|
|
}
|
|
}
|
|
|
|
FLinearColor UAnimGraphNode_PoseDriver::GetColorFromWeight(float InWeight)
|
|
{
|
|
return FMath::Lerp(FLinearColor::Blue.Desaturate(0.5), FLinearColor::Red, InWeight);
|
|
}
|
|
|
|
void UAnimGraphNode_PoseDriver::AutoSetTargetScales(float& OutMaxDistance)
|
|
{
|
|
if (LastPreviewComponent && LastPreviewComponent->AnimScriptInstance)
|
|
{
|
|
const FBoneContainer& RequiredBones = LastPreviewComponent->AnimScriptInstance->GetRequiredBones();
|
|
|
|
TArray<FRBFTarget> RBFTargets;
|
|
Node.GetRBFTargets(RBFTargets, &RequiredBones);
|
|
|
|
// Find distances from targets to nearest neighbours
|
|
TArray<float> Distances;
|
|
bool bSuccess = FRBFSolver::FindTargetNeighbourDistances(Node.RBFParams, RBFTargets, Distances);
|
|
if (bSuccess)
|
|
{
|
|
// Find overall largest distance
|
|
OutMaxDistance = KINDA_SMALL_NUMBER; // ensure result > 0
|
|
for (float Distance : Distances)
|
|
{
|
|
OutMaxDistance = FMath::Max(OutMaxDistance, Distance);
|
|
}
|
|
|
|
// Set scales so largest distance is 1.0, and others are less than that
|
|
for (int32 TargetIdx = 0; TargetIdx < Node.PoseTargets.Num(); TargetIdx++)
|
|
{
|
|
FPoseDriverTarget& PoseTarget = Node.PoseTargets[TargetIdx];
|
|
PoseTarget.TargetScale = Distances[TargetIdx] / OutMaxDistance;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#undef LOCTEXT_NAMESPACE
|