Add PS_BehaviorEditor bridge plugin with AI Spline actor

New plugin that bridges PS_Editor and PS_AI_Behavior:
- APS_BehaviorEditor_AISpline inherits from APS_AI_Behavior_SplinePath
  (detected by SplineNetwork, usable by BT tasks)
- Implements IPS_Editor_PointPlaceable (click-to-place flow)
- Full tube visualization + handles (adapted from PS_Editor_SplineActor)
- SpawnableComponent (catalogue category "AI") + EditableComponent
- Editable AI properties: SplineCategory, bBidirectional, SplineWalkSpeed, Priority
- Custom serialization for spline points + AI properties
- No modifications to PS_Editor or PS_AI_Behavior plugins

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 15:00:19 +02:00
parent c04bc2149b
commit 21e39ad3fa
6 changed files with 654 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
{
"FileVersion": 3,
"Version": 1,
"VersionName": "1.0",
"FriendlyName": "PS Behavior Editor",
"Description": "Bridge plugin: AI Behavior splines editable via PS_Editor runtime editor.",
"Category": "PS Tools",
"CreatedBy": "Asterion",
"Modules": [
{
"Name": "PS_BehaviorEditor",
"Type": "Runtime",
"LoadingPhase": "Default"
}
],
"Plugins": [
{
"Name": "PS_Editor",
"Enabled": true
},
{
"Name": "PS_AI_Behavior",
"Enabled": true
},
{
"Name": "ProceduralMeshComponent",
"Enabled": true
}
]
}

View File

@@ -0,0 +1,19 @@
using UnrealBuildTool;
public class PS_BehaviorEditor : ModuleRules
{
public PS_BehaviorEditor(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PublicDependencyModuleNames.AddRange(new string[]
{
"Core",
"CoreUObject",
"Engine",
"ProceduralMeshComponent",
"PS_Editor",
"PS_AI_Behavior"
});
}
}

View File

@@ -0,0 +1,3 @@
#include "PS_BehaviorEditor.h"
IMPLEMENT_MODULE(FPS_BehaviorEditorModule, PS_BehaviorEditor)

View File

@@ -0,0 +1,10 @@
#pragma once
#include "Modules/ModuleManager.h"
class FPS_BehaviorEditorModule : public IModuleInterface
{
public:
virtual void StartupModule() override {}
virtual void ShutdownModule() override {}
};

View File

@@ -0,0 +1,474 @@
#include "PS_BehaviorEditor_AISpline.h"
#include "PS_Editor_SpawnableComponent.h"
#include "PS_Editor_EditableComponent.h"
#include "Components/SplineComponent.h"
#include "ProceduralMeshComponent.h"
#include "Components/StaticMeshComponent.h"
#include "Engine/StaticMesh.h"
#include "Materials/MaterialInstanceDynamic.h"
APS_BehaviorEditor_AISpline::APS_BehaviorEditor_AISpline()
{
PrimaryActorTick.bCanEverTick = false;
// SplineComp is already created by APS_AI_Behavior_SplinePath as root.
// Clear default editor-placed points so we start empty for runtime placement.
if (SplineComp)
{
SplineComp->ClearSplinePoints(false);
SplineComp->SetClosedLoop(false);
}
// Line mesh
SplineMeshComp = CreateDefaultSubobject<UProceduralMeshComponent>(TEXT("SplineMesh"));
SplineMeshComp->SetupAttachment(RootComponent);
SplineMeshComp->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
SplineMeshComp->SetCollisionResponseToAllChannels(ECR_Ignore);
SplineMeshComp->SetCollisionResponseToChannel(ECC_Camera, ECR_Block);
SplineMeshComp->bUseComplexAsSimpleCollision = true;
SplineMeshComp->SetCastShadow(false);
// Handle parent
HandleRoot = CreateDefaultSubobject<USceneComponent>(TEXT("HandleRoot"));
HandleRoot->SetupAttachment(RootComponent);
// Spawnable: makes this actor appear in the editor catalogue
SpawnableComp = CreateDefaultSubobject<UPS_Editor_SpawnableComponent>(TEXT("PS_Editor_Spawnable"));
SpawnableComp->DisplayName = TEXT("AI Spline");
SpawnableComp->Category = TEXT("AI");
// Editable: exposes AI + visual properties in the runtime property panel
EditableComp = CreateDefaultSubobject<UPS_Editor_EditableComponent>(TEXT("PS_Editor_Editable"));
EditableComp->EditableProperties.Add({FName(TEXT("SplineCategory"))});
EditableComp->EditableProperties.Add({FName(TEXT("bBidirectional"))});
EditableComp->EditableProperties.Add({FName(TEXT("SplineWalkSpeed"))});
EditableComp->EditableProperties.Add({FName(TEXT("Priority"))});
EditableComp->EditableProperties.Add({FName(TEXT("SplineColor"))});
EditableComp->EditableProperties.Add({FName(TEXT("SplineSelectedColor"))});
}
void APS_BehaviorEditor_AISpline::BeginPlay()
{
Super::BeginPlay();
// Gizmo material (no depth test) for handles and selected state
UMaterialInterface* GizmoMat = LoadObject<UMaterialInterface>(
nullptr, TEXT("/PS_Editor/M_PS_Editor_Gizmo.M_PS_Editor_Gizmo"));
// Opaque depth-tested material for spline line
UMaterialInterface* SplineLineMat = LoadObject<UMaterialInterface>(
nullptr, TEXT("/PS_Editor/M_PS_Editor_SplineLine.M_PS_Editor_SplineLine"));
if (!SplineLineMat)
{
SplineLineMat = LoadObject<UMaterialInterface>(nullptr, TEXT("/PS_Editor/M_PS_Editor_SplineLine"));
}
if (SplineLineMat)
{
SplineMat = UMaterialInstanceDynamic::Create(SplineLineMat, this);
SplineMat->SetVectorParameterValue(TEXT("Color"), SplineColor);
}
if (GizmoMat)
{
SplineMatSelected = UMaterialInstanceDynamic::Create(GizmoMat, this);
SplineMatSelected->SetVectorParameterValue(TEXT("Color"), SplineSelectedColor);
HandleMat = UMaterialInstanceDynamic::Create(GizmoMat, this);
HandleMat->SetVectorParameterValue(TEXT("Color"), FLinearColor(1.0f, 1.0f, 1.0f));
HandleHighlightMat = UMaterialInstanceDynamic::Create(GizmoMat, this);
HandleHighlightMat->SetVectorParameterValue(TEXT("Color"), FLinearColor(1.0f, 1.0f, 0.0f));
CenterCubeMat = UMaterialInstanceDynamic::Create(GizmoMat, this);
CenterCubeMat->SetVectorParameterValue(TEXT("Color"), SplineColor);
}
// Center cube (selection handle)
CenterCube = NewObject<UStaticMeshComponent>(this);
CenterCube->SetupAttachment(GetRootComponent());
CenterCube->RegisterComponent();
UStaticMesh* CubeMesh = LoadObject<UStaticMesh>(nullptr, TEXT("/Engine/BasicShapes/Cube.Cube"));
if (CubeMesh)
{
CenterCube->SetStaticMesh(CubeMesh);
}
CenterCube->SetWorldScale3D(FVector(CenterCubeScale));
CenterCube->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
CenterCube->SetCollisionResponseToAllChannels(ECR_Ignore);
CenterCube->SetCollisionResponseToChannel(ECC_Camera, ECR_Block);
CenterCube->SetCastShadow(false);
if (CenterCubeMat)
{
CenterCube->SetMaterial(0, CenterCubeMat);
}
}
// ---- Point manipulation ----
void APS_BehaviorEditor_AISpline::AddPoint(const FVector& WorldPosition)
{
SplineComp->AddSplinePoint(WorldPosition, ESplineCoordinateSpace::World, true);
PointHandles.Add(CreatePointHandle(WorldPosition));
UpdateVisualization();
}
void APS_BehaviorEditor_AISpline::MovePoint(int32 Index, const FVector& NewWorldPosition)
{
if (Index < 0 || Index >= GetNumPoints()) return;
SplineComp->SetLocationAtSplinePoint(Index, NewWorldPosition, ESplineCoordinateSpace::World, true);
if (PointHandles.IsValidIndex(Index) && PointHandles[Index])
{
PointHandles[Index]->SetWorldLocation(NewWorldPosition);
}
UpdateVisualization();
}
void APS_BehaviorEditor_AISpline::RemovePoint(int32 Index)
{
if (Index < 0 || Index >= GetNumPoints()) return;
SplineComp->RemoveSplinePoint(Index, true);
if (PointHandles.IsValidIndex(Index))
{
if (PointHandles[Index]) PointHandles[Index]->DestroyComponent();
PointHandles.RemoveAt(Index);
}
UpdateVisualization();
}
int32 APS_BehaviorEditor_AISpline::GetNumPoints() const
{
return SplineComp ? SplineComp->GetNumberOfSplinePoints() : 0;
}
FVector APS_BehaviorEditor_AISpline::GetPointLocation(int32 Index) const
{
if (!SplineComp || Index < 0 || Index >= GetNumPoints()) return FVector::ZeroVector;
return SplineComp->GetLocationAtSplinePoint(Index, ESplineCoordinateSpace::World);
}
TArray<FVector> APS_BehaviorEditor_AISpline::GetAllPointLocations() const
{
TArray<FVector> Result;
const int32 Num = GetNumPoints();
Result.Reserve(Num);
for (int32 i = 0; i < Num; i++)
{
Result.Add(GetPointLocation(i));
}
return Result;
}
void APS_BehaviorEditor_AISpline::SetAllPointLocations(const TArray<FVector>& Points)
{
if (!SplineComp) return;
SplineComp->ClearSplinePoints(false);
for (const FVector& WorldPos : Points)
{
SplineComp->AddSplinePoint(WorldPos, ESplineCoordinateSpace::World, false);
}
SplineComp->UpdateSpline();
RebuildHandles();
UpdateVisualization();
}
void APS_BehaviorEditor_AISpline::InsertPoint(int32 Index, const FVector& WorldPosition)
{
if (!SplineComp) return;
Index = FMath::Clamp(Index, 0, GetNumPoints());
TArray<FVector> Points = GetAllPointLocations();
Points.Insert(WorldPosition, Index);
SetAllPointLocations(Points);
}
int32 APS_BehaviorEditor_AISpline::FindInsertIndex(const FVector& WorldLocation) const
{
if (!SplineComp || GetNumPoints() < 2) return GetNumPoints();
const float InputKey = SplineComp->FindInputKeyClosestToWorldLocation(WorldLocation);
return FMath::Clamp(FMath::FloorToInt(InputKey) + 1, 1, GetNumPoints());
}
FVector APS_BehaviorEditor_AISpline::GetCentroid() const
{
const int32 Num = GetNumPoints();
if (Num == 0) return GetActorLocation();
FVector Centroid = FVector::ZeroVector;
for (int32 i = 0; i < Num; i++)
{
Centroid += GetPointLocation(i);
}
return Centroid / Num;
}
// ---- Visualization ----
void APS_BehaviorEditor_AISpline::UpdateVisualization()
{
if (!SplineMeshComp || !SplineComp || GetNumPoints() < 2)
{
if (SplineMeshComp) SplineMeshComp->ClearAllMeshSections();
return;
}
const float SplineLength = SplineComp->GetSplineLength();
const int32 NumSamples = FMath::Max(2, FMath::CeilToInt(SplineLength / SampleInterval));
const int32 Segs = TubeSegments;
TArray<FVector> Vertices;
TArray<int32> Triangles;
TArray<FVector> Normals;
TArray<FVector2D> UVs;
Vertices.Reserve(NumSamples * Segs);
Normals.Reserve(NumSamples * Segs);
UVs.Reserve(NumSamples * Segs);
for (int32 i = 0; i < NumSamples; i++)
{
const float Distance = (static_cast<float>(i) / (NumSamples - 1)) * SplineLength;
const FVector Location = SplineComp->GetLocationAtDistanceAlongSpline(Distance, ESplineCoordinateSpace::Local);
const FVector SplineRight = SplineComp->GetRightVectorAtDistanceAlongSpline(Distance, ESplineCoordinateSpace::Local);
const FVector SplineUp = SplineComp->GetUpVectorAtDistanceAlongSpline(Distance, ESplineCoordinateSpace::Local);
const float U = static_cast<float>(i) / (NumSamples - 1);
for (int32 j = 0; j < Segs; j++)
{
const float Angle = (static_cast<float>(j) / Segs) * 2.0f * PI;
const FVector Normal = FMath::Cos(Angle) * SplineRight + FMath::Sin(Angle) * SplineUp;
Vertices.Add(Location + Normal * TubeRadius);
Normals.Add(Normal);
UVs.Add(FVector2D(U, static_cast<float>(j) / Segs));
}
}
Triangles.Reserve((NumSamples - 1) * Segs * 6);
for (int32 i = 0; i < NumSamples - 1; i++)
{
for (int32 j = 0; j < Segs; j++)
{
const int32 NextJ = (j + 1) % Segs;
const int32 A = i * Segs + j;
const int32 B = i * Segs + NextJ;
const int32 C = (i + 1) * Segs + j;
const int32 D = (i + 1) * Segs + NextJ;
Triangles.Add(A); Triangles.Add(C); Triangles.Add(B);
Triangles.Add(B); Triangles.Add(C); Triangles.Add(D);
}
}
SplineMeshComp->ClearAllMeshSections();
SplineMeshComp->CreateMeshSection(0, Vertices, Triangles, Normals, UVs, TArray<FColor>(), TArray<FProcMeshTangent>(), true);
SplineMeshComp->UpdateBounds();
SplineMeshComp->MarkRenderStateDirty();
const bool bCurrentlySelected = (PointHandles.Num() > 0 && PointHandles[0] && PointHandles[0]->IsVisible());
UMaterialInstanceDynamic* LineMat = (bCurrentlySelected && SplineMatSelected) ? SplineMatSelected : SplineMat;
if (LineMat)
{
SplineMeshComp->SetMaterial(0, LineMat);
}
UpdateCenterCube();
}
void APS_BehaviorEditor_AISpline::UpdateCenterCube()
{
if (!CenterCube) return;
if (GetNumPoints() == 0)
{
CenterCube->SetVisibility(false);
return;
}
FVector Centroid = FVector::ZeroVector;
for (int32 i = 0; i < GetNumPoints(); i++)
{
Centroid += GetPointLocation(i);
}
Centroid /= GetNumPoints();
CenterCube->SetWorldLocation(Centroid);
CenterCube->SetVisibility(true);
}
void APS_BehaviorEditor_AISpline::UpdateSplineMaterials()
{
if (SplineMat) SplineMat->SetVectorParameterValue(TEXT("Color"), SplineColor);
if (SplineMatSelected) SplineMatSelected->SetVectorParameterValue(TEXT("Color"), SplineSelectedColor);
if (CenterCubeMat) CenterCubeMat->SetVectorParameterValue(TEXT("Color"), SplineColor);
}
// ---- Handle interaction ----
UStaticMeshComponent* APS_BehaviorEditor_AISpline::CreatePointHandle(const FVector& WorldPosition)
{
UStaticMeshComponent* Handle = NewObject<UStaticMeshComponent>(this);
Handle->SetupAttachment(HandleRoot);
Handle->RegisterComponent();
UStaticMesh* SphereMesh = LoadObject<UStaticMesh>(nullptr, TEXT("/Engine/BasicShapes/Sphere.Sphere"));
if (SphereMesh) Handle->SetStaticMesh(SphereMesh);
Handle->SetWorldLocation(WorldPosition);
Handle->SetWorldScale3D(FVector(HandleScale));
Handle->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
Handle->SetCollisionResponseToAllChannels(ECR_Ignore);
Handle->SetCollisionResponseToChannel(ECC_Camera, ECR_Block);
Handle->SetCastShadow(false);
Handle->SetRenderInDepthPass(false);
if (HandleMat) Handle->SetMaterial(0, HandleMat);
return Handle;
}
void APS_BehaviorEditor_AISpline::RebuildHandles()
{
for (UStaticMeshComponent* Handle : PointHandles)
{
if (Handle) Handle->DestroyComponent();
}
PointHandles.Empty();
for (int32 i = 0; i < GetNumPoints(); i++)
{
PointHandles.Add(CreatePointHandle(GetPointLocation(i)));
}
}
int32 APS_BehaviorEditor_AISpline::GetHandleIndexUnderCursor(const FVector& RayOrigin, const FVector& RayDirection, float SphereRadius) const
{
int32 BestIndex = -1;
float BestDist = MAX_FLT;
for (int32 i = 0; i < PointHandles.Num(); i++)
{
if (!PointHandles[i] || !PointHandles[i]->IsVisible()) continue;
const FVector HandlePos = PointHandles[i]->GetComponentLocation();
const FVector ToHandle = HandlePos - RayOrigin;
const float Proj = FVector::DotProduct(ToHandle, RayDirection);
if (Proj < 0.0f) continue;
const FVector ClosestPoint = RayOrigin + RayDirection * Proj;
const float Dist = FVector::Dist(ClosestPoint, HandlePos);
if (Dist < SphereRadius && Proj < BestDist)
{
BestDist = Proj;
BestIndex = i;
}
}
return BestIndex;
}
void APS_BehaviorEditor_AISpline::SetHandlesVisible(bool bVisible)
{
for (UStaticMeshComponent* Handle : PointHandles)
{
if (Handle)
{
Handle->SetVisibility(bVisible);
Handle->SetCollisionEnabled(bVisible ? ECollisionEnabled::QueryOnly : ECollisionEnabled::NoCollision);
}
}
}
void APS_BehaviorEditor_AISpline::SetSelected(bool bSelected)
{
if (SplineMeshComp)
{
UMaterialInstanceDynamic* Mat = bSelected ? SplineMatSelected : SplineMat;
if (Mat) SplineMeshComp->SetMaterial(0, Mat);
}
if (CenterCubeMat)
{
CenterCubeMat->SetVectorParameterValue(TEXT("Color"), bSelected ? SplineSelectedColor : SplineColor);
}
}
bool APS_BehaviorEditor_AISpline::IsCenterCube(const UPrimitiveComponent* Comp) const
{
return Comp && Comp == CenterCube;
}
bool APS_BehaviorEditor_AISpline::IsSplineMesh(const UPrimitiveComponent* Comp) const
{
return Comp && Comp == SplineMeshComp;
}
void APS_BehaviorEditor_AISpline::HighlightHandle(int32 Index)
{
for (int32 i = 0; i < PointHandles.Num(); i++)
{
if (PointHandles[i])
{
PointHandles[i]->SetMaterial(0, (i == Index) ? HandleHighlightMat : HandleMat);
}
}
}
void APS_BehaviorEditor_AISpline::ClearHandleHighlight()
{
for (UStaticMeshComponent* Handle : PointHandles)
{
if (Handle && HandleMat) Handle->SetMaterial(0, HandleMat);
}
}
// ---- Serialization ----
void APS_BehaviorEditor_AISpline::ExportToCustomProperties(TMap<FString, FString>& OutProperties) const
{
// Spline points
const int32 Num = GetNumPoints();
OutProperties.Add(TEXT("SplinePointCount"), FString::FromInt(Num));
for (int32 i = 0; i < Num; i++)
{
const FVector Loc = GetPointLocation(i);
OutProperties.Add(
FString::Printf(TEXT("SplinePoint_%d"), i),
FString::Printf(TEXT("X=%.4f Y=%.4f Z=%.4f"), Loc.X, Loc.Y, Loc.Z));
}
// Visual properties
OutProperties.Add(TEXT("SplineColor"), SplineColor.ToString());
OutProperties.Add(TEXT("SplineSelectedColor"), SplineSelectedColor.ToString());
// AI properties
OutProperties.Add(TEXT("AISplineCategory"), FString::FromInt(static_cast<int32>(SplineCategory)));
OutProperties.Add(TEXT("AIBidirectional"), bBidirectional ? TEXT("1") : TEXT("0"));
OutProperties.Add(TEXT("AIWalkSpeed"), FString::SanitizeFloat(SplineWalkSpeed));
OutProperties.Add(TEXT("AIPriority"), FString::FromInt(Priority));
}
void APS_BehaviorEditor_AISpline::ImportFromCustomProperties(const TMap<FString, FString>& Properties)
{
// Spline points
const FString* CountStr = Properties.Find(TEXT("SplinePointCount"));
if (CountStr)
{
const int32 Num = FCString::Atoi(**CountStr);
if (Num >= 2)
{
TArray<FVector> Points;
for (int32 i = 0; i < Num; i++)
{
const FString Key = FString::Printf(TEXT("SplinePoint_%d"), i);
const FString* ValStr = Properties.Find(Key);
if (!ValStr) continue;
FVector Loc;
if (Loc.InitFromString(*ValStr)) Points.Add(Loc);
}
if (Points.Num() >= 2) SetAllPointLocations(Points);
}
}
// Visual properties
if (const FString* ColorStr = Properties.Find(TEXT("SplineColor")))
SplineColor.InitFromString(*ColorStr);
if (const FString* SelColorStr = Properties.Find(TEXT("SplineSelectedColor")))
SplineSelectedColor.InitFromString(*SelColorStr);
// AI properties
if (const FString* CatStr = Properties.Find(TEXT("AISplineCategory")))
SplineCategory = static_cast<EPS_AI_Behavior_NPCType>(FCString::Atoi(**CatStr));
if (const FString* BiStr = Properties.Find(TEXT("AIBidirectional")))
bBidirectional = FCString::Atoi(**BiStr) != 0;
if (const FString* SpeedStr = Properties.Find(TEXT("AIWalkSpeed")))
SplineWalkSpeed = FCString::Atof(**SpeedStr);
if (const FString* PriStr = Properties.Find(TEXT("AIPriority")))
Priority = FCString::Atoi(**PriStr);
UpdateSplineMaterials();
}

View File

@@ -0,0 +1,118 @@
#pragma once
#include "CoreMinimal.h"
#include "PS_AI_Behavior_SplinePath.h"
#include "PS_Editor_PointPlaceable.h"
#include "PS_BehaviorEditor_AISpline.generated.h"
class UProceduralMeshComponent;
class UPS_Editor_SpawnableComponent;
class UPS_Editor_EditableComponent;
/**
* AI Spline placeable from the PS_Editor runtime editor.
*
* Inherits from APS_AI_Behavior_SplinePath so it is automatically
* detected by the SplineNetwork subsystem and usable by BT tasks.
*
* Implements IPS_Editor_PointPlaceable for click-to-place flow.
* Includes tube visualization + handles from PS_Editor's spline system.
*/
UCLASS(BlueprintType, Blueprintable, meta = (DisplayName = "AI Spline (Editor)"))
class PS_BEHAVIOREDITOR_API APS_BehaviorEditor_AISpline : public APS_AI_Behavior_SplinePath, public IPS_Editor_PointPlaceable
{
GENERATED_BODY()
public:
APS_BehaviorEditor_AISpline();
// ---- IPS_Editor_PointPlaceable interface ----
virtual void AddPlacementPoint(const FVector& WorldPosition) override { AddPoint(WorldPosition); }
virtual int32 GetMinimumPoints() const override { return 2; }
virtual int32 GetCurrentPointCount() const override { return GetNumPoints(); }
// ---- Visual properties (editable at runtime) ----
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spline|Visual")
FLinearColor SplineColor = FLinearColor(0.2f, 0.6f, 1.0f); // Blue for AI splines
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spline|Visual")
FLinearColor SplineSelectedColor = FLinearColor(0.4f, 0.8f, 1.0f);
// ---- Point manipulation ----
void AddPoint(const FVector& WorldPosition);
void MovePoint(int32 Index, const FVector& NewWorldPosition);
void RemovePoint(int32 Index);
int32 GetNumPoints() const;
FVector GetPointLocation(int32 Index) const;
TArray<FVector> GetAllPointLocations() const;
void SetAllPointLocations(const TArray<FVector>& Points);
void InsertPoint(int32 Index, const FVector& WorldPosition);
int32 FindInsertIndex(const FVector& WorldLocation) const;
FVector GetCentroid() const;
// ---- Visualization ----
void UpdateVisualization();
UFUNCTION(BlueprintCallable, Category = "PS Editor|Spline")
void UpdateSplineMaterials();
// ---- Handle interaction ----
int32 GetHandleIndexUnderCursor(const FVector& RayOrigin, const FVector& RayDirection, float SphereRadius = 15.0f) const;
void SetHandlesVisible(bool bVisible);
void SetSelected(bool bSelected);
bool IsCenterCube(const UPrimitiveComponent* Comp) const;
bool IsSplineMesh(const UPrimitiveComponent* Comp) const;
void HighlightHandle(int32 Index);
void ClearHandleHighlight();
// ---- Serialization ----
void ExportToCustomProperties(TMap<FString, FString>& OutProperties) const;
void ImportFromCustomProperties(const TMap<FString, FString>& Properties);
protected:
virtual void BeginPlay() override;
private:
UPROPERTY(VisibleAnywhere)
TObjectPtr<UProceduralMeshComponent> SplineMeshComp;
UPROPERTY(VisibleAnywhere)
TObjectPtr<USceneComponent> HandleRoot;
UPROPERTY()
TArray<TObjectPtr<UStaticMeshComponent>> PointHandles;
UPROPERTY()
TObjectPtr<UMaterialInstanceDynamic> SplineMat;
UPROPERTY()
TObjectPtr<UMaterialInstanceDynamic> SplineMatSelected;
UPROPERTY()
TObjectPtr<UMaterialInstanceDynamic> HandleMat;
UPROPERTY()
TObjectPtr<UMaterialInstanceDynamic> HandleHighlightMat;
UPROPERTY()
TObjectPtr<UStaticMeshComponent> CenterCube;
UPROPERTY()
TObjectPtr<UMaterialInstanceDynamic> CenterCubeMat;
UPROPERTY(VisibleAnywhere)
TObjectPtr<UPS_Editor_SpawnableComponent> SpawnableComp;
UPROPERTY(VisibleAnywhere)
TObjectPtr<UPS_Editor_EditableComponent> EditableComp;
UStaticMeshComponent* CreatePointHandle(const FVector& WorldPosition);
void RebuildHandles();
void UpdateCenterCube();
float TubeRadius = 2.0f;
int32 TubeSegments = 8;
float SampleInterval = 10.0f;
float HandleScale = 0.12f;
float CenterCubeScale = 0.10f;
};