LifePlay-Revival/Source/LifePlayEditorTools/LPAnimCatalogCommandlet.cpp
edujiro5972 04b053554f Sandbox addon : brancher le jeu sur LifePlay-Sandbox (plugin LPSandbox).
Charge le plugin via AdditionalPluginDirectories, conserve ShowCouple et les commandlets anim côté jeu, et documente les lots S0–S8 (CHG-212 à CHG-220).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 21:10:53 +02:00

348 lines
13 KiB
C++

// Lot AP6 - voir LPAnimCatalogCommandlet.h
#include "LPAnimCatalogCommandlet.h"
#include "Animation/AnimData/IAnimationDataModel.h"
#include "Animation/AnimSequence.h"
#include "Dom/JsonObject.h"
#include "LPPoseMeasure.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonSerializer.h"
#include "Serialization/JsonWriter.h"
DEFINE_LOG_CATEGORY_STATIC(LogLPAnimCatalog, Log, All);
namespace LPAnimCatalogImpl
{
// Seuils de classement (definitions : voir l'en-tete).
constexpr float LyingTilt = 60.f; // degres
constexpr float KneelHeight = 12.f; // cm
constexpr float LowHips = 0.72f; // fraction de la hauteur de hanche debout (gardes de combat : debout)
constexpr float SeatBehind = 20.f; // cm
constexpr float Majority = 0.6f;
constexpr float FootContact = 3.f; // cm
constexpr float HandRest = 15.f; // cm/s
constexpr float HandRaised = 0.3f;
int32 BoneOf(const FLPPose& Pose, const TCHAR* Role, int32 Index = 0)
{
const FName Name = LPPoseMeasure::RoleBone(Pose, Role, Index);
return Name.IsNone() ? INDEX_NONE : Pose.Names.IndexOfByKey(Name);
}
FVector At(const FLPPose& Pose, int32 Bone)
{
return Pose.Component.IsValidIndex(Bone) ? Pose.Component[Bone].GetLocation() : FVector::ZeroVector;
}
FVector RestAt(const FLPPose& Pose, int32 Bone)
{
return Pose.Rest.IsValidIndex(Bone) ? Pose.Rest[Bone].GetLocation() : FVector::ZeroVector;
}
FVector Flat(FVector V)
{
V.Z = 0.f;
return V;
}
struct FBones
{
int32 Hips, Head, ThighL, ThighR, CalfL, CalfR, FootL, FootR, ToeL, ToeR, HandL, HandR;
bool Bind(const FLPPose& P)
{
Hips = BoneOf(P, TEXT("hips")); Head = BoneOf(P, TEXT("head"));
ThighL = BoneOf(P, TEXT("thigh_l")); ThighR = BoneOf(P, TEXT("thigh_r"));
CalfL = BoneOf(P, TEXT("calf_l")); CalfR = BoneOf(P, TEXT("calf_r"));
FootL = BoneOf(P, TEXT("foot_l")); FootR = BoneOf(P, TEXT("foot_r"));
ToeL = BoneOf(P, TEXT("toe_l")); ToeR = BoneOf(P, TEXT("toe_r"));
HandL = BoneOf(P, TEXT("hand_l")); HandR = BoneOf(P, TEXT("hand_r"));
const int32 All[] = { Hips, Head, ThighL, ThighR, CalfL, CalfR, FootL, FootR, ToeL, ToeR, HandL, HandR };
for (const int32 B : All)
{
if (B == INDEX_NONE)
{
return false;
}
}
return true;
}
};
FVector ForwardOf(const FLPPose& P, const FBones& B)
{
// Haut x gauche = avant (verifie sur Roll, qui avance, et Sitting_Enter, dont le bassin recule).
const FVector Side = Flat(At(P, B.ThighL) - At(P, B.ThighR));
return FVector::CrossProduct(FVector::UpVector, Side.GetSafeNormal()).GetSafeNormal();
}
const TCHAR* PostureOf(const FLPPose& P, const FBones& B, float StandingHips, float Floor)
{
const FVector Trunk = At(P, B.Head) - At(P, B.Hips);
const float Tilt = static_cast<float>(FMath::RadiansToDegrees(FMath::Acos(FMath::Clamp(Trunk.GetSafeNormal().Z, -1.0, 1.0))));
if (Tilt > LyingTilt)
{
return TEXT("allonge");
}
const float Hips = static_cast<float>(At(P, B.Hips).Z) - Floor;
if (Hips >= LowHips * StandingHips)
{
return TEXT("debout");
}
const float Knee = static_cast<float>(FMath::Min(At(P, B.CalfL).Z, At(P, B.CalfR).Z)) - Floor;
if (Knee < KneelHeight)
{
return TEXT("a genoux");
}
const FVector Ankles = (At(P, B.FootL) + At(P, B.FootR)) * 0.5;
const float Behind = static_cast<float>(FVector::DotProduct(Flat(Ankles - At(P, B.Hips)), ForwardOf(P, B)));
return Behind > SeatBehind ? TEXT("assis") : TEXT("accroupi");
}
bool ReadJson(const FString& Path, TSharedPtr<FJsonValue>& Out)
{
FString Text;
if (!FFileHelper::LoadFileToString(Text, *Path))
{
return false;
}
const TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Text);
return FJsonSerializer::Deserialize(Reader, Out) && Out.IsValid();
}
struct FClipInfo
{
int32 Frames = 0;
double Rate = 0.0;
float Duration = 0.f;
};
bool ClipInfo(const FString& Path, FClipInfo& Out)
{
const UAnimSequence* Anim = LoadObject<UAnimSequence>(nullptr, *Path);
const IAnimationDataModel* Model = Anim ? Anim->GetDataModel() : nullptr;
if (!Model)
{
return false;
}
Out.Frames = Model->GetNumberOfKeys();
Out.Rate = Model->GetFrameRate().AsDecimal();
Out.Duration = static_cast<float>(Model->GetPlayLength());
return Out.Frames > 0;
}
TSharedRef<FJsonObject> Measure(const FString& Path, const FClipInfo& Info, FString& Error)
{
TSharedRef<FJsonObject> Card = MakeShared<FJsonObject>();
TArray<FLPPose> Poses;
Poses.SetNum(Info.Frames);
for (int32 Key = 0; Key < Info.Frames; ++Key)
{
const float Time = FMath::Min(static_cast<float>(Key / Info.Rate), Info.Duration);
if (!LPPoseMeasure::Sample(Path, Time, Poses[Key]))
{
Error = FString::Printf(TEXT("echantillon impossible a %.3f s"), Time);
return Card;
}
}
FBones B;
if (!B.Bind(Poses[0]))
{
Error = FString::Printf(TEXT("roles d'os manquants (profil %s)"), *Poses[0].Profile);
return Card;
}
const FLPPose& First = Poses[0];
const FLPPose& Last = Poses.Last();
const float Floor = static_cast<float>(FMath::Min(RestAt(First, B.FootL).Z, RestAt(First, B.FootR).Z));
const float StandingHips = static_cast<float>(RestAt(First, B.Hips).Z) - Floor;
const float Height = FMath::Max(1.f, static_cast<float>(RestAt(First, B.Head).Z) - Floor);
Card->SetNumberField(TEXT("images"), Info.Frames);
Card->SetNumberField(TEXT("cadence"), FMath::RoundToFloat(static_cast<float>(Info.Rate) * 100.f) / 100.f);
Card->SetNumberField(TEXT("duree_s"), FMath::RoundToFloat(Info.Duration * 100.f) / 100.f);
// Boucle : extremites relatives a la hanche, premiere et derniere image.
const int32 Ends[] = { B.HandL, B.HandR, B.FootL, B.FootR, B.Head };
float LoopGap = 0.f;
for (const int32 E : Ends)
{
const FVector A = At(First, E) - At(First, B.Hips);
const FVector Z = At(Last, E) - At(Last, B.Hips);
LoopGap = FMath::Max(LoopGap, static_cast<float>((A - Z).Size()));
}
const float LoopPct = 100.f * LoopGap / Height;
Card->SetNumberField(TEXT("boucle_ecart_pct"), FMath::RoundToFloat(LoopPct * 100.f) / 100.f);
// Deplacement horizontal de la hanche.
const FVector Travel = Flat(At(Last, B.Hips) - At(First, B.Hips));
const float Distance = static_cast<float>(Travel.Size());
Card->SetNumberField(TEXT("deplacement_cm"), FMath::RoundToFloat(Distance));
Card->SetNumberField(TEXT("vitesse_cm_s"), Info.Duration > 0.f ? FMath::RoundToFloat(Distance / Info.Duration) : 0.f);
const float Ahead = Distance > 0.f ? static_cast<float>(FVector::DotProduct(Travel / Distance, ForwardOf(First, B))) : 0.f;
Card->SetStringField(TEXT("deplacement_sens"), Ahead > 0.7f ? TEXT("avant") : Ahead < -0.7f ? TEXT("arriere") : TEXT("cote"));
// Posture image par image.
TMap<FString, int32> Count;
TArray<float> SeatHeights;
int32 FeetDown[2] = { 0, 0 };
int32 Raised[2] = { 0, 0 };
float HandPath[2] = { 0.f, 0.f };
const float RestFoot[2] = { static_cast<float>(RestAt(First, B.FootL).Z), static_cast<float>(RestAt(First, B.FootR).Z) };
const float RestToe[2] = { static_cast<float>(RestAt(First, B.ToeL).Z), static_cast<float>(RestAt(First, B.ToeR).Z) };
for (int32 Key = 0; Key < Info.Frames; ++Key)
{
const FLPPose& P = Poses[Key];
const FString Posture = PostureOf(P, B, StandingHips, Floor);
++Count.FindOrAdd(Posture);
if (Posture == TEXT("assis"))
{
SeatHeights.Add(static_cast<float>((At(P, B.ThighL).Z + At(P, B.ThighR).Z) * 0.5) - Floor);
}
const int32 Feet[2] = { B.FootL, B.FootR };
const int32 Toes[2] = { B.ToeL, B.ToeR };
const int32 Hands[2] = { B.HandL, B.HandR };
for (int32 Side = 0; Side < 2; ++Side)
{
const float Lift = FMath::Min(static_cast<float>(At(P, Feet[Side]).Z) - RestFoot[Side],
static_cast<float>(At(P, Toes[Side]).Z) - RestToe[Side]);
FeetDown[Side] += Lift < FootContact ? 1 : 0;
Raised[Side] += At(P, Hands[Side]).Z > At(P, B.Head).Z ? 1 : 0;
if (Key > 0)
{
const FLPPose& Q = Poses[Key - 1];
HandPath[Side] += static_cast<float>(((At(P, Hands[Side]) - At(P, B.Hips)) - (At(Q, Hands[Side]) - At(Q, B.Hips))).Size());
}
}
}
FString Main = TEXT("variable");
for (const TPair<FString, int32>& It : Count)
{
if (It.Value >= Majority * Info.Frames)
{
Main = It.Key;
}
}
Card->SetStringField(TEXT("posture"), Main);
Card->SetStringField(TEXT("posture_debut"), PostureOf(First, B, StandingHips, Floor));
Card->SetStringField(TEXT("posture_fin"), PostureOf(Last, B, StandingHips, Floor));
TSharedRef<FJsonObject> Split = MakeShared<FJsonObject>();
for (const TPair<FString, int32>& It : Count)
{
Split->SetNumberField(It.Key, FMath::RoundToFloat(100.f * It.Value / Info.Frames));
}
Card->SetObjectField(TEXT("posture_pct"), Split);
if (SeatHeights.Num() > 0)
{
SeatHeights.Sort();
Card->SetNumberField(TEXT("hauteur_assise_cm"), FMath::RoundToFloat(SeatHeights[SeatHeights.Num() / 2]));
}
else
{
Card->SetField(TEXT("hauteur_assise_cm"), MakeShared<FJsonValueNull>());
}
const TCHAR* SideNames[2] = { TEXT("gauche"), TEXT("droite") };
TSharedRef<FJsonObject> Feet = MakeShared<FJsonObject>();
TSharedRef<FJsonObject> Hands = MakeShared<FJsonObject>();
for (int32 Side = 0; Side < 2; ++Side)
{
Feet->SetNumberField(SideNames[Side], FMath::RoundToFloat(100.f * FeetDown[Side] / Info.Frames));
const float Speed = Info.Duration > 0.f ? HandPath[Side] / Info.Duration : 0.f;
const TCHAR* State = Raised[Side] >= HandRaised * Info.Frames ? TEXT("levee") : Speed < HandRest ? TEXT("repos") : TEXT("mouvement");
Hands->SetStringField(SideNames[Side], State);
}
Card->SetObjectField(TEXT("pieds_au_sol_pct"), Feet);
Card->SetObjectField(TEXT("mains"), Hands);
return Card;
}
}
ULPAnimCatalogCommandlet::ULPAnimCatalogCommandlet()
{
IsClient = false;
IsServer = false;
IsEditor = true;
LogToConsole = true;
}
int32 ULPAnimCatalogCommandlet::Main(const FString& Params)
{
using namespace LPAnimCatalogImpl;
FString Livraison = FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("AnimPipeline/quaternius/livraison.json"));
FParse::Value(*Params, TEXT("Livraison="), Livraison, false);
FString Out = FPaths::GetPath(Livraison);
FParse::Value(*Params, TEXT("Out="), Out, false);
float SeuilBoucle = 1.f;
float SeuilSurPlace = 5.f;
FParse::Value(*Params, TEXT("SeuilBoucle="), SeuilBoucle);
FParse::Value(*Params, TEXT("SeuilSurPlace="), SeuilSurPlace);
TSharedPtr<FJsonValue> Root;
if (!ReadJson(Livraison, Root) || Root->Type != EJson::Array)
{
UE_LOG(LogLPAnimCatalog, Error, TEXT("livraison.json illisible : %s"), *Livraison);
return 1;
}
TSharedRef<FJsonObject> Cards = MakeShared<FJsonObject>();
TArray<FString> Failures;
for (const TSharedPtr<FJsonValue>& Entry : Root->AsArray())
{
const TSharedPtr<FJsonObject> Item = Entry->AsObject();
const FString Key = Item->GetStringField(TEXT("cle"));
const FString PathF = Item->GetStringField(TEXT("source_F"));
const FString PathM = Item->GetStringField(TEXT("source_M"));
FClipInfo F, M;
if (!ClipInfo(PathF, F) || !ClipInfo(PathM, M))
{
Failures.Add(FString::Printf(TEXT("%s : clip absent ou vide"), *Key));
continue;
}
if (F.Frames != M.Frames || !FMath::IsNearlyEqual(F.Duration, M.Duration, 0.001f))
{
Failures.Add(FString::Printf(TEXT("%s : F %d images %.3f s, M %d images %.3f s"), *Key, F.Frames, F.Duration, M.Frames, M.Duration));
}
FString Error;
TSharedRef<FJsonObject> Card = Measure(PathF, F, Error);
if (!Error.IsEmpty())
{
Failures.Add(FString::Printf(TEXT("%s : %s"), *Key, *Error));
continue;
}
Card->SetBoolField(TEXT("boucle"), Card->GetNumberField(TEXT("boucle_ecart_pct")) <= SeuilBoucle);
if (Card->GetNumberField(TEXT("deplacement_cm")) < SeuilSurPlace)
{
Card->SetStringField(TEXT("deplacement_sens"), TEXT("sur place"));
}
UE_LOG(LogLPAnimCatalog, Display, TEXT("%-28s %5.2f s %3d img boucle %5.2f %% depl %4.0f cm %-9s posture %-9s (%s -> %s)"),
*Key, F.Duration, F.Frames, Card->GetNumberField(TEXT("boucle_ecart_pct")), Card->GetNumberField(TEXT("deplacement_cm")),
*Card->GetStringField(TEXT("deplacement_sens")), *Card->GetStringField(TEXT("posture")),
*Card->GetStringField(TEXT("posture_debut")), *Card->GetStringField(TEXT("posture_fin")));
Cards->SetObjectField(Key, Card);
}
TSharedRef<FJsonObject> Report = MakeShared<FJsonObject>();
Report->SetStringField(TEXT("livraison"), Livraison);
Report->SetNumberField(TEXT("seuil_boucle_pct"), SeuilBoucle);
Report->SetNumberField(TEXT("seuil_sur_place_cm"), SeuilSurPlace);
Report->SetObjectField(TEXT("fiches"), Cards);
TArray<TSharedPtr<FJsonValue>> Errors;
for (const FString& Failure : Failures)
{
Errors.Add(MakeShared<FJsonValueString>(Failure));
UE_LOG(LogLPAnimCatalog, Error, TEXT("%s"), *Failure);
}
Report->SetArrayField(TEXT("echecs"), Errors);
const FString OutPath = FPaths::Combine(Out, TEXT("fiches_calculees.json"));
FString Text;
const TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&Text);
FJsonSerializer::Serialize(Report, Writer);
if (!FFileHelper::SaveStringToFile(Text, *OutPath, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM))
{
UE_LOG(LogLPAnimCatalog, Error, TEXT("ecriture impossible : %s"), *OutPath);
return 1;
}
UE_LOG(LogLPAnimCatalog, Warning, TEXT("CATALOGUE fiches %d echecs %d -> %s"), Cards->Values.Num(), Failures.Num(), *OutPath);
return Failures.Num() > 0 ? 1 : 0;
}