Add runtime property editing, scene outliner, and spawned-only selection

Property Editing:
- New EditableComponent with two-step dropdown (Select Component → Add Property)
  to declare which properties are exposed in the runtime editor
- Dynamic property panel below Transform: supports float, bool, string, FVector,
  FRotator, FLinearColor with per-tick refresh and keyboard focus guard
- PropertyAction undo/redo via ExportText/ImportText reflection
- Custom properties serialized to JSON (TMap in ActorData, version 2)
- Copy/paste/duplicate propagate custom property values

Scene Outliner:
- Scrollable list of spawned actors in the right panel
- Click to select, selected actor highlighted in blue
- Auto-refreshes when actors are added/removed

Selection Filter:
- Only actors spawned by the editor (catalogue, duplicate, paste, load)
  are selectable — static scene geometry is no longer clickable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 21:15:07 +02:00
parent 11945e90d2
commit 50087e5dfb
14 changed files with 1104 additions and 8 deletions

Binary file not shown.

View File

@@ -3,10 +3,89 @@
#include "PS_Editor_UndoManager.h"
#include "PS_Editor_PlayerController.h"
#include "PS_Editor_SpawnManager.h"
#include "PS_Editor_EditableComponent.h"
#include "GameFramework/PlayerController.h"
#include "Engine/World.h"
#include "GameFramework/Pawn.h"
#include "Components/PrimitiveComponent.h"
#include "UObject/UnrealType.h"
// Helper: export all editable property values from an actor
static TMap<FString, FString> ExportEditableProps(AActor* Actor)
{
TMap<FString, FString> Result;
if (!Actor) return Result;
UPS_Editor_EditableComponent* EditComp = Actor->FindComponentByClass<UPS_Editor_EditableComponent>();
if (!EditComp) return Result;
for (const FName& Path : EditComp->EditableProperties)
{
FString CompName, PropName;
UPS_Editor_EditableComponent::ParsePropertyPath(Path, CompName, PropName);
const FString Key = Path.ToString();
UObject* Target = CompName.IsEmpty() ? static_cast<UObject*>(Actor) : nullptr;
if (!Target)
{
TArray<UActorComponent*> Comps;
Actor->GetComponents(Comps);
for (UActorComponent* Comp : Comps)
{
if (Comp && Comp->GetName() == CompName) { Target = Comp; break; }
}
}
if (!Target) continue;
FProperty* Prop = FindFProperty<FProperty>(Target->GetClass(), *PropName);
if (!Prop) continue;
FString ValStr;
Prop->ExportText_InContainer(0, ValStr, Target, Target, nullptr, PPF_None);
Result.Add(Key, ValStr);
}
return Result;
}
// Helper: import custom property values onto an actor
static void ImportEditableProps(AActor* Actor, const TMap<FString, FString>& Properties)
{
if (!Actor || Properties.Num() == 0) return;
UPS_Editor_EditableComponent* EditComp = Actor->FindComponentByClass<UPS_Editor_EditableComponent>();
if (!EditComp) return;
for (const FName& Path : EditComp->EditableProperties)
{
FString CompName, PropName;
UPS_Editor_EditableComponent::ParsePropertyPath(Path, CompName, PropName);
const FString Key = Path.ToString();
const FString* ValStr = Properties.Find(Key);
if (!ValStr) continue;
UObject* Target = CompName.IsEmpty() ? static_cast<UObject*>(Actor) : nullptr;
if (!Target)
{
TArray<UActorComponent*> Comps;
Actor->GetComponents(Comps);
for (UActorComponent* Comp : Comps)
{
if (Comp && Comp->GetName() == CompName) { Target = Comp; break; }
}
}
if (!Target) continue;
FProperty* Prop = FindFProperty<FProperty>(Target->GetClass(), *PropName);
if (!Prop) continue;
Prop->ImportText_InContainer(**ValStr, Target, Target, PPF_None);
if (UActorComponent* AsComp = Cast<UActorComponent>(Target))
{
AsComp->MarkRenderStateDirty();
}
}
}
void UPS_Editor_SelectionManager::Initialize(APlayerController* InOwnerPC)
{
@@ -53,9 +132,15 @@ void UPS_Editor_SelectionManager::HandleClickAtScreenPosition(FVector2D ScreenPo
{
AActor* HitActor = Hit.GetActor();
// Only allow selection of Movable actors (skip static scene geometry like floors/walls)
USceneComponent* RootComp = HitActor->GetRootComponent();
const bool bIsSelectable = RootComp && RootComp->Mobility == EComponentMobility::Movable;
// Only allow selection of actors spawned by the editor (catalogue, duplicate, paste, load)
bool bIsSelectable = false;
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(PC))
{
if (UPS_Editor_SpawnManager* SpawnMgr = EditorPC->GetSpawnManager())
{
bIsSelectable = SpawnMgr->IsActorSpawned(HitActor);
}
}
if (bIsSelectable)
{
@@ -410,6 +495,10 @@ void UPS_Editor_SelectionManager::DuplicateSelection()
{
Root->SetMobility(EComponentMobility::Movable);
}
// Copy custom editable properties from original
ImportEditableProps(NewActor, ExportEditableProps(Original));
NewActors.Add(NewActor);
if (UPS_Editor_SpawnManager* SpawnMgr = EditorPC->GetSpawnManager())
@@ -462,6 +551,7 @@ void UPS_Editor_SelectionManager::CopySelection()
Entry.RelativeLocation = Actor->GetActorLocation() - Pivot;
Entry.Rotation = Actor->GetActorRotation();
Entry.Scale = Actor->GetActorScale3D();
Entry.CustomProperties = ExportEditableProps(Actor);
Clipboard.Add(Entry);
}
@@ -506,6 +596,10 @@ void UPS_Editor_SelectionManager::PasteClipboard()
{
Root->SetMobility(EComponentMobility::Movable);
}
// Restore custom editable properties
ImportEditableProps(NewActor, Entry.CustomProperties);
NewActors.Add(NewActor);
if (UPS_Editor_SpawnManager* SpawnMgr = EditorPC->GetSpawnManager())

View File

@@ -17,6 +17,7 @@ struct FPS_Editor_ClipboardEntry
FVector RelativeLocation = FVector::ZeroVector; // Relative to clipboard pivot
FRotator Rotation = FRotator::ZeroRotator;
FVector Scale = FVector::OneVector;
TMap<FString, FString> CustomProperties; // Editable property values
};
DECLARE_MULTICAST_DELEGATE(FPS_Editor_OnSelectionChanged);

View File

@@ -1,4 +1,44 @@
#include "PS_Editor_UndoManager.h"
#include "UObject/UnrealType.h"
#include "Components/ActorComponent.h"
void FPS_Editor_PropertyAction::ApplyValue(const FString& ValueStr)
{
AActor* Act = Actor.Get();
if (!Act) return;
UObject* TargetObject = nullptr;
if (ComponentName.IsEmpty())
{
TargetObject = Act;
}
else
{
TArray<UActorComponent*> Components;
Act->GetComponents(Components);
for (UActorComponent* Comp : Components)
{
if (Comp && Comp->GetName() == ComponentName)
{
TargetObject = Comp;
break;
}
}
}
if (!TargetObject) return;
FProperty* Prop = FindFProperty<FProperty>(TargetObject->GetClass(), *PropertyName);
if (!Prop) return;
Prop->ImportText_InContainer(*ValueStr, TargetObject, TargetObject, PPF_None);
// Refresh rendering for visual updates
if (UActorComponent* Comp = Cast<UActorComponent>(TargetObject))
{
Comp->MarkRenderStateDirty();
}
}
void UPS_Editor_UndoManager::RecordAction(TSharedPtr<FPS_Editor_Action> Action)
{

View File

@@ -8,7 +8,8 @@ enum class EPS_Editor_ActionType : uint8
{
Transform,
Delete,
Spawn
Spawn,
PropertyChange
};
/**
@@ -156,6 +157,29 @@ struct FPS_Editor_SpawnAction : public FPS_Editor_Action
}
};
/**
* Records a single property value change on a component or actor.
* Stores text-serialized old/new values for undo/redo via ImportText.
*/
struct FPS_Editor_PropertyAction : public FPS_Editor_Action
{
FPS_Editor_PropertyAction() : FPS_Editor_Action(EPS_Editor_ActionType::PropertyChange) {}
TWeakObjectPtr<AActor> Actor;
FString ComponentName; // Empty = actor property
FString PropertyName;
FString OldValue; // Text-serialized via ExportText
FString NewValue; // Text-serialized via ExportText
FString Description;
virtual void Execute() override { ApplyValue(NewValue); }
virtual void Undo() override { ApplyValue(OldValue); }
virtual FString GetDescription() const override { return Description; }
private:
void ApplyValue(const FString& ValueStr);
};
/**
* Runtime undo/redo manager for PS_Editor.
* Maintains a linear action stack with a cursor.

View File

@@ -21,6 +21,10 @@ struct FPS_Editor_ActorData
UPROPERTY()
FVector Scale = FVector::OneVector;
/** Custom property values: key = "ComponentName.PropertyName", value = text-serialized value. */
UPROPERTY()
TMap<FString, FString> CustomProperties;
};
/** Serialized data for a complete scene. */
@@ -30,7 +34,7 @@ struct FPS_Editor_SceneData
GENERATED_BODY()
UPROPERTY()
int32 Version = 1;
int32 Version = 2;
UPROPERTY()
FString SceneName;

View File

@@ -1,10 +1,13 @@
#include "PS_Editor_SceneSerializer.h"
#include "PS_Editor_SpawnManager.h"
#include "PS_Editor_SelectionManager.h"
#include "PS_Editor_EditableComponent.h"
#include "JsonObjectConverter.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#include "HAL/PlatformFileManager.h"
#include "UObject/UnrealType.h"
#include "Components/ActorComponent.h"
bool UPS_Editor_SceneSerializer::SaveScene(const FString& SceneName, UPS_Editor_SpawnManager* SpawnManager)
{
@@ -33,6 +36,43 @@ bool UPS_Editor_SceneSerializer::SaveScene(const FString& SceneName, UPS_Editor_
ActorData.Rotation = Actor->GetActorRotation();
ActorData.Scale = Actor->GetActorScale3D();
// Export custom editable properties
if (UPS_Editor_EditableComponent* EditComp = Actor->FindComponentByClass<UPS_Editor_EditableComponent>())
{
for (const FName& Path : EditComp->EditableProperties)
{
FString CompName, PropName;
UPS_Editor_EditableComponent::ParsePropertyPath(Path, CompName, PropName);
const FString Key = Path.ToString();
UObject* Target = CompName.IsEmpty() ? static_cast<UObject*>(Actor) : nullptr;
if (!Target)
{
TArray<UActorComponent*> Components;
Actor->GetComponents(Components);
for (UActorComponent* Comp : Components)
{
if (Comp && Comp->GetName() == CompName)
{
Target = Comp;
break;
}
}
}
if (Target)
{
FProperty* Prop = FindFProperty<FProperty>(Target->GetClass(), *PropName);
if (Prop)
{
FString ValueStr;
Prop->ExportText_InContainer(0, ValueStr, Target, Target, nullptr, PPF_None);
ActorData.CustomProperties.Add(Key, ValueStr);
}
}
}
}
SceneData.Actors.Add(ActorData);
}
@@ -105,6 +145,51 @@ bool UPS_Editor_SceneSerializer::LoadScene(const FString& SceneName, UPS_Editor_
AActor* SpawnedActor = SpawnManager->SpawnActorDirect(LoadedClass, ActorData.Location, ActorData.Rotation, ActorData.Scale);
if (SpawnedActor)
{
// Import custom properties
if (ActorData.CustomProperties.Num() > 0)
{
if (UPS_Editor_EditableComponent* EditComp = SpawnedActor->FindComponentByClass<UPS_Editor_EditableComponent>())
{
for (const FName& Path : EditComp->EditableProperties)
{
FString CompName, PropName;
UPS_Editor_EditableComponent::ParsePropertyPath(Path, CompName, PropName);
const FString Key = Path.ToString();
const FString* ValueStr = ActorData.CustomProperties.Find(Key);
if (!ValueStr) continue;
UObject* Target = CompName.IsEmpty() ? static_cast<UObject*>(SpawnedActor) : nullptr;
if (!Target)
{
TArray<UActorComponent*> Components;
SpawnedActor->GetComponents(Components);
for (UActorComponent* Comp : Components)
{
if (Comp && Comp->GetName() == CompName)
{
Target = Comp;
break;
}
}
}
if (Target)
{
FProperty* Prop = FindFProperty<FProperty>(Target->GetClass(), *PropName);
if (Prop)
{
Prop->ImportText_InContainer(**ValueStr, Target, Target, PPF_None);
if (UActorComponent* AsComp = Cast<UActorComponent>(Target))
{
AsComp->MarkRenderStateDirty();
}
}
}
}
}
}
SpawnedCount++;
}
}

View File

@@ -0,0 +1,206 @@
#include "PS_Editor_EditableComponent.h"
#include "UObject/UnrealType.h"
#include "Engine/Blueprint.h"
UPS_Editor_EditableComponent::UPS_Editor_EditableComponent()
{
PrimaryComponentTick.bCanEverTick = false;
}
// ---- Internal helpers ----
static UClass* FindOwnerClass(const UActorComponent* Comp)
{
if (AActor* Owner = Comp->GetOwner())
{
return Owner->GetClass();
}
for (UObject* Outer = Comp->GetOuter(); Outer; Outer = Outer->GetOuter())
{
if (AActor* AsActor = Cast<AActor>(Outer))
{
return AsActor->GetClass();
}
if (UClass* AsClass = Cast<UClass>(Outer))
{
if (AsClass->IsChildOf(AActor::StaticClass()))
{
return AsClass;
}
}
if (UBlueprint* AsBP = Cast<UBlueprint>(Outer))
{
return AsBP->GeneratedClass;
}
}
return nullptr;
}
static TArray<UActorComponent*> CollectComponentTemplates(UClass* ActorClass, const UActorComponent* Exclude)
{
TArray<UActorComponent*> Result;
if (!ActorClass) return Result;
if (AActor* CDO = Cast<AActor>(ActorClass->GetDefaultObject()))
{
TArray<UActorComponent*> CDOComps;
CDO->GetComponents(CDOComps);
for (UActorComponent* Comp : CDOComps)
{
if (Comp && Comp != Exclude)
{
Result.Add(Comp);
}
}
}
TArray<UObject*> SubObjects;
GetObjectsWithOuter(ActorClass, SubObjects, true);
for (UObject* Sub : SubObjects)
{
UActorComponent* Comp = Cast<UActorComponent>(Sub);
if (Comp && Comp != Exclude && !Result.Contains(Comp))
{
Result.Add(Comp);
}
}
return Result;
}
/** Strip Blueprint template suffixes (e.g. "_GEN_VARIABLE") so stored names match runtime names. */
static FString CleanComponentName(const FString& Name)
{
FString Clean = Name;
Clean.RemoveFromEnd(TEXT("_GEN_VARIABLE"));
return Clean;
}
// ---- Dropdown functions ----
TArray<FString> UPS_Editor_EditableComponent::GetAvailableComponentNames() const
{
TArray<FString> Names;
Names.Add(TEXT("(Actor)")); // Actor-level properties
UClass* OwnerClass = FindOwnerClass(this);
if (!OwnerClass) return Names;
TArray<UActorComponent*> Components = CollectComponentTemplates(OwnerClass, this);
for (UActorComponent* Comp : Components)
{
Names.AddUnique(CleanComponentName(Comp->GetName()));
}
Names.Sort();
return Names;
}
TArray<FString> UPS_Editor_EditableComponent::GetFilteredPropertyNames() const
{
TArray<FString> Names;
Names.Add(TEXT("")); // Allow clearing
UClass* OwnerClass = FindOwnerClass(this);
if (!OwnerClass) return Names;
UClass* TargetClass = nullptr;
const FString SelComp = SelectedComponent.ToString();
if (SelectedComponent.IsNone() || SelComp.IsEmpty() || SelComp == TEXT("(Actor)"))
{
// Actor-level properties
TargetClass = OwnerClass;
}
else
{
// Find the component's class (match cleaned names)
TArray<UActorComponent*> Components = CollectComponentTemplates(OwnerClass, this);
for (UActorComponent* Comp : Components)
{
if (CleanComponentName(Comp->GetName()) == SelComp)
{
TargetClass = Comp->GetClass();
break;
}
}
}
if (!TargetClass) return Names;
for (TFieldIterator<FProperty> PropIt(TargetClass, EFieldIterationFlags::IncludeSuper); PropIt; ++PropIt)
{
FProperty* Prop = *PropIt;
if (Prop->HasAnyPropertyFlags(CPF_Edit))
{
Names.AddUnique(Prop->GetName());
}
}
Names.Sort();
return Names;
}
#if WITH_EDITOR
void UPS_Editor_EditableComponent::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent)
{
Super::PostEditChangeProperty(PropertyChangedEvent);
if (PropertyChangedEvent.GetPropertyName() == GET_MEMBER_NAME_CHECKED(UPS_Editor_EditableComponent, AddProperty))
{
if (!AddProperty.IsNone())
{
const FString SelComp = SelectedComponent.ToString();
const bool bIsActor = SelectedComponent.IsNone() || SelComp.IsEmpty() || SelComp == TEXT("(Actor)");
// Build full path: "ComponentName.PropertyName" or just "PropertyName"
FName FullPath;
if (bIsActor)
{
FullPath = AddProperty;
}
else
{
FullPath = FName(SelComp + TEXT(".") + AddProperty.ToString());
}
if (!EditableProperties.Contains(FullPath))
{
EditableProperties.Add(FullPath);
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Added editable property: %s"), *FullPath.ToString());
}
AddProperty = NAME_None; // Reset for next pick
}
}
}
#endif
// ---- Helpers for consumers ----
void UPS_Editor_EditableComponent::ParsePropertyPath(const FName& Path, FString& OutComponentName, FString& OutPropertyName)
{
const FString PathStr = Path.ToString();
if (!PathStr.Split(TEXT("."), &OutComponentName, &OutPropertyName))
{
OutComponentName = TEXT("");
OutPropertyName = PathStr;
}
}
FString UPS_Editor_EditableComponent::GetDisplayName(const FName& Path) const
{
if (const FString* Override = DisplayNames.Find(Path))
{
if (!Override->IsEmpty())
{
return *Override;
}
}
FString CompName, PropName;
ParsePropertyPath(Path, CompName, PropName);
return PropName;
}

View File

@@ -0,0 +1,67 @@
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "PS_Editor_EditableComponent.generated.h"
/**
* Marker component that declares which properties are editable in the PS_Editor runtime editor.
* Separate from SpawnableComponent (which handles catalogue metadata).
*
* Usage:
* 1. Pick a component from "Select Component"
* 2. Pick a property from "Add Property" (filtered to that component)
* 3. It auto-adds to "Editable Properties" as "Component.Property"
*/
UCLASS(ClassGroup = (PSEditor), meta = (BlueprintSpawnableComponent, DisplayName = "PS Editor Editable"))
class PS_EDITOR_API UPS_Editor_EditableComponent : public UActorComponent
{
GENERATED_BODY()
public:
UPS_Editor_EditableComponent();
/** Step 1: Pick a component (leave empty for actor-level properties). */
UPROPERTY(EditAnywhere, Category = "Add Property", meta = (GetOptions = "GetAvailableComponentNames"))
FName SelectedComponent;
/** Step 2: Pick a property from the selected component. Auto-adds to list below. */
UPROPERTY(EditAnywhere, Category = "Add Property", meta = (GetOptions = "GetFilteredPropertyNames"))
FName AddProperty;
/**
* Properties exposed in the runtime editor.
* Format: "ComponentName.PropertyName" or just "PropertyName" for actor-level.
* Populated via the dropdowns above, or editable manually.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Editable Properties")
TArray<FName> EditableProperties;
/**
* Optional display name overrides for the runtime editor UI.
* Key = property path (same as in EditableProperties), Value = label shown in UI.
* If not set, the property name is used as label.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Editable Properties")
TMap<FName, FString> DisplayNames;
/** Lists available component names on the owning actor. */
UFUNCTION()
TArray<FString> GetAvailableComponentNames() const;
/** Lists editable properties for the currently SelectedComponent. */
UFUNCTION()
TArray<FString> GetFilteredPropertyNames() const;
#if WITH_EDITOR
virtual void PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent) override;
#endif
// ---- Helpers for consumers ----
/** Parse a property path into component name and property name. */
static void ParsePropertyPath(const FName& Path, FString& OutComponentName, FString& OutPropertyName);
/** Get the display name for a property path. */
FString GetDisplayName(const FName& Path) const;
};

View File

@@ -211,6 +211,20 @@ void UPS_Editor_SpawnManager::TrackSpawnedActor(AActor* Actor)
}
}
bool UPS_Editor_SpawnManager::IsActorSpawned(AActor* Actor) const
{
if (!Actor) return false;
for (const TWeakObjectPtr<AActor>& Weak : SpawnedActors)
{
if (Weak.Get() == Actor)
{
return true;
}
}
return false;
}
TArray<FString> UPS_Editor_SpawnManager::GetCategories() const
{
TArray<FString> Categories;

View File

@@ -63,6 +63,9 @@ public:
/** Register an externally-spawned actor for tracking (used by duplicate/paste). */
void TrackSpawnedActor(AActor* Actor);
/** Returns true if this actor was spawned/tracked by the editor (catalogue, duplicate, paste, load). */
bool IsActorSpawned(AActor* Actor) const;
private:
UPROPERTY()
TWeakObjectPtr<APlayerController> OwnerPC;

View File

@@ -4,15 +4,19 @@
#include "PS_Editor_SceneSerializer.h"
#include "PS_Editor_UndoManager.h"
#include "PS_Editor_PlayerController.h"
#include "PS_Editor_EditableComponent.h"
#include "PS_Editor_SpawnableComponent.h"
#include "Widgets/SBoxPanel.h"
#include "Widgets/Text/STextBlock.h"
#include "Widgets/Layout/SBorder.h"
#include "Widgets/Layout/SScrollBox.h"
#include "Widgets/Input/SButton.h"
#include "Widgets/Images/SImage.h"
#include "Widgets/Input/SCheckBox.h"
#include "Engine/Texture2D.h"
#include "Widgets/Input/SEditableTextBox.h"
#include "Widgets/SOverlay.h"
#include "UObject/UnrealType.h"
void UPS_Editor_MainWidget::SetSelectionManager(UPS_Editor_SelectionManager* InSelectionManager)
{
@@ -92,13 +96,24 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::RebuildWidget()
[
BuildSpawnCatalog()
]
// Right panel: Properties
// Right panel: Properties + Outliner
+ SOverlay::Slot()
.HAlign(HAlign_Right)
.VAlign(VAlign_Top)
.Padding(16.0f)
.Padding(16.0f, 16.0f, 16.0f, 60.0f)
[
BuildPropertiesPanel()
SNew(SVerticalBox)
+ SVerticalBox::Slot()
.AutoHeight()
[
BuildPropertiesPanel()
]
+ SVerticalBox::Slot()
.FillHeight(1.0f)
.Padding(0.0f, 4.0f, 0.0f, 0.0f)
[
BuildOutliner()
]
]
// Bottom help bar
+ SOverlay::Slot()
@@ -328,6 +343,10 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildPropertiesPanel()
]
+ SVerticalBox::Slot().AutoHeight().Padding(RowPadding)
[ MakeRow(FText::GetEmpty(), ScaX, ScaY, ScaZ, RedX, GreenY, BlueZ) ]
+ SVerticalBox::Slot().AutoHeight()
[
SAssignNew(DynamicPropertiesContainer, SVerticalBox)
]
];
}
@@ -649,6 +668,7 @@ void UPS_Editor_MainWidget::NativeTick(const FGeometry& MyGeometry, float InDelt
{
Super::NativeTick(MyGeometry, InDeltaTime);
RefreshPropertiesFromSelection();
RefreshOutliner();
}
void UPS_Editor_MainWidget::RefreshPropertiesFromSelection()
@@ -702,6 +722,13 @@ void UPS_Editor_MainWidget::RefreshPropertiesFromSelection()
ClearField(LocX); ClearField(LocY); ClearField(LocZ);
ClearField(RotX); ClearField(RotY); ClearField(RotZ);
ClearField(ScaX); ClearField(ScaY); ClearField(ScaZ);
if (LastSelectedActor.IsValid())
{
LastSelectedActor = nullptr;
RebuildDynamicProperties();
LastOutlinerActorCount = -1; // Force outliner refresh
}
bIsRefreshing = false;
return;
}
@@ -737,6 +764,16 @@ void UPS_Editor_MainWidget::RefreshPropertiesFromSelection()
SetField(RotX, Rot.Roll); SetField(RotY, Rot.Pitch); SetField(RotZ, Rot.Yaw);
SetField(ScaX, Sca.X); SetField(ScaY, Sca.Y); SetField(ScaZ, Sca.Z);
// Detect selection change → rebuild dynamic properties + refresh outliner highlight
if (Actor != LastSelectedActor.Get())
{
LastSelectedActor = Actor;
RebuildDynamicProperties();
LastOutlinerActorCount = -1; // Force outliner refresh for highlight update
}
RefreshDynamicPropertyValues();
bIsRefreshing = false;
}
@@ -785,6 +822,496 @@ void UPS_Editor_MainWidget::ApplyTransformFromUI()
}
}
TSharedRef<SWidget> UPS_Editor_MainWidget::BuildOutliner()
{
return SNew(SBorder)
.BorderBackgroundColor(FLinearColor(0.05f, 0.05f, 0.05f, 0.85f))
.Padding(FMargin(10.0f, 8.0f))
[
SNew(SVerticalBox)
+ SVerticalBox::Slot().AutoHeight()
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Scene Objects")))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 10))
.ColorAndOpacity(FLinearColor(0.8f, 0.8f, 0.8f))
]
+ SVerticalBox::Slot().FillHeight(1.0f).Padding(0.0f, 4.0f, 0.0f, 0.0f)
[
SNew(SScrollBox)
+ SScrollBox::Slot()
[
SAssignNew(OutlinerListContainer, SVerticalBox)
]
]
];
}
void UPS_Editor_MainWidget::RefreshOutliner()
{
UPS_Editor_SpawnManager* SM = SpawnManager.Get();
if (!SM || !OutlinerListContainer.IsValid()) return;
// Count visible (non-hidden) spawned actors
const TArray<TWeakObjectPtr<AActor>>& SpawnedActors = SM->GetSpawnedActors();
int32 VisibleCount = 0;
for (const TWeakObjectPtr<AActor>& Weak : SpawnedActors)
{
if (AActor* Actor = Weak.Get())
{
if (!Actor->IsHidden()) VisibleCount++;
}
}
// Only rebuild if count changed (avoid per-tick rebuild)
if (VisibleCount == LastOutlinerActorCount) return;
LastOutlinerActorCount = VisibleCount;
OutlinerListContainer->ClearChildren();
UPS_Editor_SelectionManager* SelMgr = SelectionManager.Get();
const FSlateFontInfo ItemFont = FCoreStyle::GetDefaultFontStyle("Regular", 9);
for (int32 i = 0; i < SpawnedActors.Num(); i++)
{
AActor* Actor = SpawnedActors[i].Get();
if (!Actor || Actor->IsHidden()) continue;
// Build display name: SpawnableComponent DisplayName > Class name (cleaned)
FString DisplayName;
if (UActorComponent* SpawnComp = Actor->FindComponentByClass(UPS_Editor_SpawnableComponent::StaticClass()))
{
if (UPS_Editor_SpawnableComponent* SC = Cast<UPS_Editor_SpawnableComponent>(SpawnComp))
{
if (!SC->DisplayName.IsEmpty())
{
DisplayName = SC->DisplayName;
}
}
}
if (DisplayName.IsEmpty())
{
DisplayName = Actor->GetClass()->GetName();
DisplayName.RemoveFromEnd(TEXT("_C"));
}
// Append index for disambiguation
DisplayName = FString::Printf(TEXT("%s #%d"), *DisplayName, i + 1);
const bool bIsSelected = SelMgr && SelMgr->IsActorSelected(Actor);
const FLinearColor TextColor = bIsSelected ? FLinearColor(0.2f, 0.7f, 1.0f) : FLinearColor(0.7f, 0.7f, 0.7f);
const FLinearColor BgColor = bIsSelected ? FLinearColor(0.15f, 0.25f, 0.35f, 0.8f) : FLinearColor(0.0f, 0.0f, 0.0f, 0.0f);
TWeakObjectPtr<AActor> WeakActor = Actor;
OutlinerListContainer->AddSlot().AutoHeight().Padding(0.0f, 1.0f)
[
SNew(SButton)
.ButtonStyle(FCoreStyle::Get(), "NoBorder")
.ContentPadding(FMargin(6.0f, 3.0f))
.OnClicked_Lambda([this, WeakActor]() -> FReply
{
AActor* Act = WeakActor.Get();
UPS_Editor_SelectionManager* SM = SelectionManager.Get();
if (Act && SM)
{
SM->ClearSelection();
SM->SelectActor(Act);
}
return FReply::Handled();
})
[
SNew(SBorder)
.BorderBackgroundColor(BgColor)
.Padding(FMargin(4.0f, 2.0f))
[
SNew(STextBlock)
.Text(FText::FromString(DisplayName))
.Font(ItemFont)
.ColorAndOpacity(TextColor)
]
]
];
}
}
UObject* UPS_Editor_MainWidget::FindPropertyTarget(AActor* Actor, const FString& ComponentName)
{
if (!Actor) return nullptr;
if (ComponentName.IsEmpty()) return Actor;
TArray<UActorComponent*> Components;
Actor->GetComponents(Components);
for (UActorComponent* Comp : Components)
{
if (Comp && Comp->GetName() == ComponentName)
{
return Comp;
}
}
return nullptr;
}
void UPS_Editor_MainWidget::RebuildDynamicProperties()
{
DynamicPropertyRows.Empty();
if (!DynamicPropertiesContainer.IsValid()) return;
DynamicPropertiesContainer->ClearChildren();
UPS_Editor_SelectionManager* SM = SelectionManager.Get();
if (!SM || !SM->IsAnythingSelected()) return;
AActor* Actor = SM->GetSelectedActors()[0].Get();
if (!Actor) return;
UPS_Editor_EditableComponent* EditComp = Actor->FindComponentByClass<UPS_Editor_EditableComponent>();
if (!EditComp)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: No EditableComponent on %s"), *Actor->GetName());
return;
}
if (EditComp->EditableProperties.Num() == 0)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: EditableComponent has 0 properties on %s"), *Actor->GetName());
return;
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Building %d dynamic property rows for %s"), EditComp->EditableProperties.Num(), *Actor->GetName());
const FSlateFontInfo LabelFont = FCoreStyle::GetDefaultFontStyle("Regular", 9);
const FSlateFontInfo ValueFont = FCoreStyle::GetDefaultFontStyle("Regular", 9);
// Separator line
DynamicPropertiesContainer->AddSlot().AutoHeight().Padding(0.0f, 6.0f, 0.0f, 2.0f)
[
SNew(SBorder)
.BorderBackgroundColor(FLinearColor(0.3f, 0.3f, 0.3f, 0.5f))
.Padding(FMargin(0.0f, 1.0f))
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Properties")))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 9))
.ColorAndOpacity(FLinearColor(0.6f, 0.6f, 0.6f))
]
];
for (int32 i = 0; i < EditComp->EditableProperties.Num(); i++)
{
const FName& Path = EditComp->EditableProperties[i];
FPS_Editor_PropertyRowWidgets Row;
UPS_Editor_EditableComponent::ParsePropertyPath(Path, Row.ComponentName, Row.PropertyName);
Row.DisplayName = EditComp->GetDisplayName(Path);
Row.Key = Path.ToString();
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Resolving [%s] comp='%s' prop='%s'"), *Row.Key, *Row.ComponentName, *Row.PropertyName);
UObject* Target = FindPropertyTarget(Actor, Row.ComponentName);
if (!Target)
{
// Log available components for debugging
TArray<UActorComponent*> AllComps;
Actor->GetComponents(AllComps);
for (UActorComponent* C : AllComps)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Available component: '%s' (%s)"), *C->GetName(), *C->GetClass()->GetName());
}
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Component '%s' not found on %s"), *Row.ComponentName, *Actor->GetName());
continue;
}
Row.CachedProperty = FindFProperty<FProperty>(Target->GetClass(), *Row.PropertyName);
if (!Row.CachedProperty)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Property '%s' not found on %s (%s)"), *Row.PropertyName, *Target->GetName(), *Target->GetClass()->GetName());
continue;
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Resolved OK → %s on %s"), *Row.CachedProperty->GetName(), *Target->GetClass()->GetName());
const int32 RowIndex = DynamicPropertyRows.Num();
// Label
DynamicPropertiesContainer->AddSlot().AutoHeight().Padding(0.0f, 3.0f, 0.0f, 0.0f)
[
SNew(STextBlock)
.Text(FText::FromString(Row.DisplayName))
.Font(LabelFont)
.ColorAndOpacity(FLinearColor(0.5f, 0.5f, 0.5f))
];
// Value widget depends on property type
TSharedRef<SWidget> ValueWidget = SNullWidget::NullWidget;
if (CastField<FBoolProperty>(Row.CachedProperty))
{
ValueWidget = SNew(SHorizontalBox)
+ SHorizontalBox::Slot().AutoWidth()
[
SAssignNew(Row.BoolField, SCheckBox)
.OnCheckStateChanged_Lambda([this, RowIndex](ECheckBoxState)
{
ApplyPropertyFromUI(RowIndex);
})
];
}
else if (FStructProperty* StructProp = CastField<FStructProperty>(Row.CachedProperty))
{
auto MakeTextField = [&](TSharedPtr<SEditableTextBox>& Field) -> TSharedRef<SWidget>
{
return SAssignNew(Field, SEditableTextBox)
.Font(ValueFont)
.MinDesiredWidth(55.0f)
.OnTextCommitted_Lambda([this, RowIndex](const FText&, ETextCommit::Type)
{
ApplyPropertyFromUI(RowIndex);
});
};
if (StructProp->Struct == TBaseStructure<FVector>::Get()
|| StructProp->Struct == TBaseStructure<FRotator>::Get())
{
ValueWidget = SNew(SHorizontalBox)
+ SHorizontalBox::Slot().FillWidth(1.0f).Padding(2.0f, 0.0f)
[ MakeTextField(Row.VecX) ]
+ SHorizontalBox::Slot().FillWidth(1.0f).Padding(2.0f, 0.0f)
[ MakeTextField(Row.VecY) ]
+ SHorizontalBox::Slot().FillWidth(1.0f).Padding(2.0f, 0.0f)
[ MakeTextField(Row.VecZ) ];
}
else if (StructProp->Struct == TBaseStructure<FLinearColor>::Get())
{
ValueWidget = SNew(SHorizontalBox)
+ SHorizontalBox::Slot().FillWidth(1.0f).Padding(2.0f, 0.0f)
[ MakeTextField(Row.ColR) ]
+ SHorizontalBox::Slot().FillWidth(1.0f).Padding(2.0f, 0.0f)
[ MakeTextField(Row.ColG) ]
+ SHorizontalBox::Slot().FillWidth(1.0f).Padding(2.0f, 0.0f)
[ MakeTextField(Row.ColB) ]
+ SHorizontalBox::Slot().FillWidth(1.0f).Padding(2.0f, 0.0f)
[ MakeTextField(Row.ColA) ];
}
else
{
// Unsupported struct: single text field with ExportText format
ValueWidget = SAssignNew(Row.SingleField, SEditableTextBox)
.Font(ValueFont)
.MinDesiredWidth(70.0f)
.OnTextCommitted_Lambda([this, RowIndex](const FText&, ETextCommit::Type)
{
ApplyPropertyFromUI(RowIndex);
});
}
}
else
{
// Default: single text field (float, int, string, etc.)
ValueWidget = SAssignNew(Row.SingleField, SEditableTextBox)
.Font(ValueFont)
.MinDesiredWidth(70.0f)
.OnTextCommitted_Lambda([this, RowIndex](const FText&, ETextCommit::Type)
{
ApplyPropertyFromUI(RowIndex);
});
}
DynamicPropertiesContainer->AddSlot().AutoHeight().Padding(0.0f, 2.0f)
[ ValueWidget ];
DynamicPropertyRows.Add(Row);
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Built %d dynamic property row(s)"), DynamicPropertyRows.Num());
}
void UPS_Editor_MainWidget::RefreshDynamicPropertyValues()
{
if (DynamicPropertyRows.Num() == 0) return;
UPS_Editor_SelectionManager* SM = SelectionManager.Get();
if (!SM || !SM->IsAnythingSelected()) return;
AActor* Actor = SM->GetSelectedActors()[0].Get();
if (!Actor) return;
for (FPS_Editor_PropertyRowWidgets& Row : DynamicPropertyRows)
{
if (!Row.CachedProperty) continue;
UObject* Target = FindPropertyTarget(Actor, Row.ComponentName);
if (!Target) continue;
if (FBoolProperty* BoolProp = CastField<FBoolProperty>(Row.CachedProperty))
{
if (Row.BoolField.IsValid())
{
const bool Val = BoolProp->GetPropertyValue_InContainer(Target);
Row.BoolField->SetIsChecked(Val ? ECheckBoxState::Checked : ECheckBoxState::Unchecked);
}
}
else if (FStructProperty* StructProp = CastField<FStructProperty>(Row.CachedProperty))
{
void* ValuePtr = Row.CachedProperty->ContainerPtrToValuePtr<void>(Target);
if (!ValuePtr) continue;
auto SetIfNoFocus = [this](TSharedPtr<SEditableTextBox>& Field, float Val)
{
if (Field.IsValid() && !Field->HasKeyboardFocus())
{
Field->SetText(FText::FromString(FloatToString(Val)));
}
};
if (StructProp->Struct == TBaseStructure<FVector>::Get())
{
const FVector& Vec = *static_cast<const FVector*>(ValuePtr);
SetIfNoFocus(Row.VecX, Vec.X);
SetIfNoFocus(Row.VecY, Vec.Y);
SetIfNoFocus(Row.VecZ, Vec.Z);
}
else if (StructProp->Struct == TBaseStructure<FRotator>::Get())
{
const FRotator& Rot = *static_cast<const FRotator*>(ValuePtr);
SetIfNoFocus(Row.VecX, Rot.Pitch);
SetIfNoFocus(Row.VecY, Rot.Yaw);
SetIfNoFocus(Row.VecZ, Rot.Roll);
}
else if (StructProp->Struct == TBaseStructure<FLinearColor>::Get())
{
const FLinearColor& Col = *static_cast<const FLinearColor*>(ValuePtr);
SetIfNoFocus(Row.ColR, Col.R);
SetIfNoFocus(Row.ColG, Col.G);
SetIfNoFocus(Row.ColB, Col.B);
SetIfNoFocus(Row.ColA, Col.A);
}
else if (Row.SingleField.IsValid() && !Row.SingleField->HasKeyboardFocus())
{
FString ExportedVal;
Row.CachedProperty->ExportText_InContainer(0, ExportedVal, Target, Target, nullptr, PPF_None);
Row.SingleField->SetText(FText::FromString(ExportedVal));
}
}
else if (Row.SingleField.IsValid() && !Row.SingleField->HasKeyboardFocus())
{
// float, double, int, string, etc.
if (CastField<FFloatProperty>(Row.CachedProperty))
{
const float Val = *Row.CachedProperty->ContainerPtrToValuePtr<float>(Target);
Row.SingleField->SetText(FText::FromString(FloatToString(Val)));
}
else if (CastField<FDoubleProperty>(Row.CachedProperty))
{
const double Val = *Row.CachedProperty->ContainerPtrToValuePtr<double>(Target);
Row.SingleField->SetText(FText::FromString(FloatToString(static_cast<float>(Val))));
}
else if (CastField<FIntProperty>(Row.CachedProperty))
{
const int32 Val = *Row.CachedProperty->ContainerPtrToValuePtr<int32>(Target);
Row.SingleField->SetText(FText::FromString(FString::FromInt(Val)));
}
else
{
// Generic: ExportText
FString ExportedVal;
Row.CachedProperty->ExportText_InContainer(0, ExportedVal, Target, Target, nullptr, PPF_None);
Row.SingleField->SetText(FText::FromString(ExportedVal));
}
}
}
}
void UPS_Editor_MainWidget::ApplyPropertyFromUI(int32 RowIndex)
{
if (bIsRefreshing) return;
if (!DynamicPropertyRows.IsValidIndex(RowIndex)) return;
FPS_Editor_PropertyRowWidgets& Row = DynamicPropertyRows[RowIndex];
if (!Row.CachedProperty) return;
UPS_Editor_SelectionManager* SM = SelectionManager.Get();
if (!SM || !SM->IsAnythingSelected()) return;
AActor* Actor = SM->GetSelectedActors()[0].Get();
if (!Actor) return;
UObject* Target = FindPropertyTarget(Actor, Row.ComponentName);
if (!Target) return;
// Export old value for undo
FString OldValue;
Row.CachedProperty->ExportText_InContainer(0, OldValue, Target, Target, nullptr, PPF_None);
// Build new value text from UI and import
FString NewValueText;
if (CastField<FBoolProperty>(Row.CachedProperty))
{
NewValueText = Row.BoolField.IsValid() && Row.BoolField->IsChecked() ? TEXT("True") : TEXT("False");
}
else if (FStructProperty* StructProp = CastField<FStructProperty>(Row.CachedProperty))
{
auto GetText = [](TSharedPtr<SEditableTextBox>& F) -> FString
{
return F.IsValid() ? F->GetText().ToString() : TEXT("0");
};
if (StructProp->Struct == TBaseStructure<FVector>::Get())
{
NewValueText = FString::Printf(TEXT("(X=%s,Y=%s,Z=%s)"), *GetText(Row.VecX), *GetText(Row.VecY), *GetText(Row.VecZ));
}
else if (StructProp->Struct == TBaseStructure<FRotator>::Get())
{
NewValueText = FString::Printf(TEXT("(Pitch=%s,Yaw=%s,Roll=%s)"), *GetText(Row.VecX), *GetText(Row.VecY), *GetText(Row.VecZ));
}
else if (StructProp->Struct == TBaseStructure<FLinearColor>::Get())
{
NewValueText = FString::Printf(TEXT("(R=%s,G=%s,B=%s,A=%s)"), *GetText(Row.ColR), *GetText(Row.ColG), *GetText(Row.ColB), *GetText(Row.ColA));
}
else if (Row.SingleField.IsValid())
{
NewValueText = Row.SingleField->GetText().ToString();
}
}
else if (Row.SingleField.IsValid())
{
NewValueText = Row.SingleField->GetText().ToString();
}
if (NewValueText.IsEmpty()) return;
// Apply the new value
Row.CachedProperty->ImportText_InContainer(*NewValueText, Target, Target, PPF_None);
// Refresh rendering
if (UActorComponent* Comp = Cast<UActorComponent>(Target))
{
Comp->MarkRenderStateDirty();
}
// Export new value after import (normalized form for undo)
FString NewValue;
Row.CachedProperty->ExportText_InContainer(0, NewValue, Target, Target, nullptr, PPF_None);
// Record undo
if (APlayerController* PC = GetOwningPlayer())
{
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(PC))
{
if (UPS_Editor_UndoManager* UM = EditorPC->GetUndoManager())
{
TSharedPtr<FPS_Editor_PropertyAction> Action = MakeShared<FPS_Editor_PropertyAction>();
Action->Actor = Actor;
Action->ComponentName = Row.ComponentName;
Action->PropertyName = Row.PropertyName;
Action->OldValue = OldValue;
Action->NewValue = NewValue;
Action->Description = FString::Printf(TEXT("Edit %s"), *Row.DisplayName);
UM->RecordAction(Action);
}
}
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Property %s changed: %s → %s"), *Row.Key, *OldValue, *NewValue);
}
FString UPS_Editor_MainWidget::FloatToString(float Value) const
{
return FString::Printf(TEXT("%.2f"), Value);

View File

@@ -9,6 +9,22 @@ class UPS_Editor_SelectionManager;
class UPS_Editor_SpawnManager;
class UPS_Editor_SceneSerializer;
/** Widget state for a single dynamic property row in the property panel. */
struct FPS_Editor_PropertyRowWidgets
{
FString ComponentName;
FString PropertyName;
FString DisplayName;
FString Key; // "ComponentName.PropertyName"
FProperty* CachedProperty = nullptr;
// Widget refs (only relevant ones are used based on property type)
TSharedPtr<SEditableTextBox> SingleField; // float, double, int32, FString
TSharedPtr<SCheckBox> BoolField; // bool
TSharedPtr<SEditableTextBox> VecX, VecY, VecZ; // FVector, FRotator
TSharedPtr<SEditableTextBox> ColR, ColG, ColB, ColA; // FLinearColor
};
/**
* Main editor widget for PS_Editor.
* Contains: title bar, transform mode toolbar, properties panel, help bar.
@@ -62,15 +78,30 @@ private:
TSharedPtr<SEditableTextBox> RotX, RotY, RotZ;
TSharedPtr<SEditableTextBox> ScaX, ScaY, ScaZ;
// Dynamic property editing
TSharedPtr<SVerticalBox> DynamicPropertiesContainer;
TArray<FPS_Editor_PropertyRowWidgets> DynamicPropertyRows;
TWeakObjectPtr<AActor> LastSelectedActor;
// Outliner
TSharedPtr<SVerticalBox> OutlinerListContainer;
int32 LastOutlinerActorCount = -1;
// ---- Helpers ----
TSharedRef<SWidget> BuildToolbar();
TSharedRef<SWidget> BuildPropertiesPanel();
TSharedRef<SWidget> BuildSpawnCatalog();
TSharedRef<SWidget> BuildSceneButtons();
TSharedRef<SWidget> BuildOutliner();
void RefreshOutliner();
void PopulateSceneList();
void PopulateSpawnCatalog();
void RefreshPropertiesFromSelection();
void ApplyTransformFromUI();
void RebuildDynamicProperties();
void RefreshDynamicPropertyValues();
void ApplyPropertyFromUI(int32 RowIndex);
static UObject* FindPropertyTarget(AActor* Actor, const FString& ComponentName);
FString FloatToString(float Value) const;
void OnModeButtonClicked(EPS_Editor_TransformMode Mode);