Add duplicate (Ctrl+D) and copy/paste (Ctrl+C/V) with undo support

Duplicate clones selected actors with a small offset and selects the copies.
Copy stores actor class and relative transforms in an internal clipboard.
Paste spawns clipboard contents in front of the camera preserving relative positions.
All operations are tracked by SpawnManager and fully undoable/redoable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 13:08:13 +02:00
parent 7ef566f3f3
commit 11945e90d2
8 changed files with 266 additions and 0 deletions

Binary file not shown.

View File

@@ -186,6 +186,19 @@ void APS_Editor_Pawn::CreateInputActions()
IA_Redo = NewObject<UInputAction>(this, TEXT("IA_Redo")); IA_Redo = NewObject<UInputAction>(this, TEXT("IA_Redo"));
IA_Redo->ValueType = EInputActionValueType::Boolean; IA_Redo->ValueType = EInputActionValueType::Boolean;
EditorMappingContext->MapKey(IA_Redo, EKeys::Y); EditorMappingContext->MapKey(IA_Redo, EKeys::Y);
// Duplicate / Copy / Paste (Ctrl+D / Ctrl+C / Ctrl+V)
IA_Duplicate = NewObject<UInputAction>(this, TEXT("IA_Duplicate"));
IA_Duplicate->ValueType = EInputActionValueType::Boolean;
EditorMappingContext->MapKey(IA_Duplicate, EKeys::D);
IA_Copy = NewObject<UInputAction>(this, TEXT("IA_Copy"));
IA_Copy->ValueType = EInputActionValueType::Boolean;
EditorMappingContext->MapKey(IA_Copy, EKeys::C);
IA_Paste = NewObject<UInputAction>(this, TEXT("IA_Paste"));
IA_Paste->ValueType = EInputActionValueType::Boolean;
EditorMappingContext->MapKey(IA_Paste, EKeys::V);
} }
void APS_Editor_Pawn::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent) void APS_Editor_Pawn::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
@@ -226,6 +239,9 @@ void APS_Editor_Pawn::SetupPlayerInputComponent(UInputComponent* PlayerInputComp
EIC->BindAction(IA_SnapToGround, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleSnapToGround); EIC->BindAction(IA_SnapToGround, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleSnapToGround);
EIC->BindAction(IA_Undo, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleUndo); EIC->BindAction(IA_Undo, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleUndo);
EIC->BindAction(IA_Redo, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleRedo); EIC->BindAction(IA_Redo, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleRedo);
EIC->BindAction(IA_Duplicate, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleDuplicate);
EIC->BindAction(IA_Copy, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleCopy);
EIC->BindAction(IA_Paste, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandlePaste);
} }
// ---- Input Handlers ---- // ---- Input Handlers ----
@@ -708,6 +724,42 @@ void APS_Editor_Pawn::HandleSnapToGround(const FInputActionValue& Value)
} }
} }
void APS_Editor_Pawn::HandleDuplicate(const FInputActionValue& Value)
{
if (!bCtrlHeld || CameraMode != EPS_Editor_CameraMode::Idle) return;
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController()))
{
if (UPS_Editor_SelectionManager* SM = EditorPC->GetSelectionManager())
{
SM->DuplicateSelection();
}
}
}
void APS_Editor_Pawn::HandleCopy(const FInputActionValue& Value)
{
if (!bCtrlHeld || CameraMode != EPS_Editor_CameraMode::Idle) return;
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController()))
{
if (UPS_Editor_SelectionManager* SM = EditorPC->GetSelectionManager())
{
SM->CopySelection();
}
}
}
void APS_Editor_Pawn::HandlePaste(const FInputActionValue& Value)
{
if (!bCtrlHeld || CameraMode != EPS_Editor_CameraMode::Idle) return;
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController()))
{
if (UPS_Editor_SelectionManager* SM = EditorPC->GetSelectionManager())
{
SM->PasteClipboard();
}
}
}
// ---- Helpers ---- // ---- Helpers ----
void APS_Editor_Pawn::SetCameraMode(EPS_Editor_CameraMode NewMode) void APS_Editor_Pawn::SetCameraMode(EPS_Editor_CameraMode NewMode)

View File

@@ -171,6 +171,15 @@ private:
UPROPERTY() UPROPERTY()
TObjectPtr<UInputAction> IA_Redo; TObjectPtr<UInputAction> IA_Redo;
UPROPERTY()
TObjectPtr<UInputAction> IA_Duplicate;
UPROPERTY()
TObjectPtr<UInputAction> IA_Copy;
UPROPERTY()
TObjectPtr<UInputAction> IA_Paste;
// ---- Modifier key state ---- // ---- Modifier key state ----
bool bAltHeld = false; bool bAltHeld = false;
bool bCtrlHeld = false; bool bCtrlHeld = false;
@@ -204,6 +213,9 @@ private:
void HandleSnapToGround(const FInputActionValue& Value); void HandleSnapToGround(const FInputActionValue& Value);
void HandleUndo(const FInputActionValue& Value); void HandleUndo(const FInputActionValue& Value);
void HandleRedo(const FInputActionValue& Value); void HandleRedo(const FInputActionValue& Value);
void HandleDuplicate(const FInputActionValue& Value);
void HandleCopy(const FInputActionValue& Value);
void HandlePaste(const FInputActionValue& Value);
// ---- Helpers ---- // ---- Helpers ----
void CreateInputActions(); void CreateInputActions();

View File

@@ -2,6 +2,7 @@
#include "PS_Editor_Gizmo.h" #include "PS_Editor_Gizmo.h"
#include "PS_Editor_UndoManager.h" #include "PS_Editor_UndoManager.h"
#include "PS_Editor_PlayerController.h" #include "PS_Editor_PlayerController.h"
#include "PS_Editor_SpawnManager.h"
#include "GameFramework/PlayerController.h" #include "GameFramework/PlayerController.h"
#include "Engine/World.h" #include "Engine/World.h"
#include "GameFramework/Pawn.h" #include "GameFramework/Pawn.h"
@@ -377,6 +378,168 @@ void UPS_Editor_SelectionManager::UpdateGizmo()
} }
} }
void UPS_Editor_SelectionManager::DuplicateSelection()
{
APlayerController* PC = OwnerPC.Get();
if (!PC || !IsAnythingSelected()) return;
APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(PC);
if (!EditorPC) return;
static const FVector DuplicateOffset(50.0f, 50.0f, 0.0f);
TArray<AActor*> NewActors;
for (const TWeakObjectPtr<AActor>& Weak : SelectedActors)
{
AActor* Original = Weak.Get();
if (!Original) continue;
FActorSpawnParameters SpawnParams;
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn;
AActor* NewActor = PC->GetWorld()->SpawnActor<AActor>(
Original->GetClass(),
Original->GetActorLocation() + DuplicateOffset,
Original->GetActorRotation(),
SpawnParams);
if (NewActor)
{
NewActor->SetActorScale3D(Original->GetActorScale3D());
if (USceneComponent* Root = NewActor->GetRootComponent())
{
Root->SetMobility(EComponentMobility::Movable);
}
NewActors.Add(NewActor);
if (UPS_Editor_SpawnManager* SpawnMgr = EditorPC->GetSpawnManager())
{
SpawnMgr->TrackSpawnedActor(NewActor);
}
}
}
if (NewActors.Num() == 0) return;
// Record undo action
TSharedPtr<FPS_Editor_SpawnAction> Action = MakeShared<FPS_Editor_SpawnAction>();
for (AActor* Actor : NewActors)
{
Action->Actors.Add(Actor);
}
Action->Description = FString::Printf(TEXT("Duplicate %d actor(s)"), NewActors.Num());
if (UPS_Editor_UndoManager* UM = EditorPC->GetUndoManager())
{
UM->RecordAction(Action);
}
// Select the duplicates (deselect originals)
ClearSelection();
for (AActor* Actor : NewActors)
{
SelectActor(Actor);
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Duplicated %d actor(s)"), NewActors.Num());
}
void UPS_Editor_SelectionManager::CopySelection()
{
Clipboard.Empty();
if (!IsAnythingSelected()) return;
const FVector Pivot = GetSelectionPivot();
for (const TWeakObjectPtr<AActor>& Weak : SelectedActors)
{
AActor* Actor = Weak.Get();
if (!Actor) continue;
FPS_Editor_ClipboardEntry Entry;
Entry.ActorClass = Actor->GetClass();
Entry.RelativeLocation = Actor->GetActorLocation() - Pivot;
Entry.Rotation = Actor->GetActorRotation();
Entry.Scale = Actor->GetActorScale3D();
Clipboard.Add(Entry);
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Copied %d actor(s) to clipboard"), Clipboard.Num());
}
void UPS_Editor_SelectionManager::PasteClipboard()
{
if (Clipboard.Num() == 0) return;
APlayerController* PC = OwnerPC.Get();
if (!PC) return;
APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(PC);
if (!EditorPC) return;
// Paste center: in front of the camera
FVector CamLoc;
FRotator CamRot;
PC->GetPlayerViewPoint(CamLoc, CamRot);
const FVector PasteCenter = CamLoc + CamRot.Vector() * 500.0f;
TArray<AActor*> NewActors;
for (const FPS_Editor_ClipboardEntry& Entry : Clipboard)
{
if (!Entry.ActorClass) continue;
FActorSpawnParameters SpawnParams;
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn;
AActor* NewActor = PC->GetWorld()->SpawnActor<AActor>(
Entry.ActorClass,
PasteCenter + Entry.RelativeLocation,
Entry.Rotation,
SpawnParams);
if (NewActor)
{
NewActor->SetActorScale3D(Entry.Scale);
if (USceneComponent* Root = NewActor->GetRootComponent())
{
Root->SetMobility(EComponentMobility::Movable);
}
NewActors.Add(NewActor);
if (UPS_Editor_SpawnManager* SpawnMgr = EditorPC->GetSpawnManager())
{
SpawnMgr->TrackSpawnedActor(NewActor);
}
}
}
if (NewActors.Num() == 0) return;
// Record undo action
TSharedPtr<FPS_Editor_SpawnAction> Action = MakeShared<FPS_Editor_SpawnAction>();
for (AActor* Actor : NewActors)
{
Action->Actors.Add(Actor);
}
Action->Description = FString::Printf(TEXT("Paste %d actor(s)"), NewActors.Num());
if (UPS_Editor_UndoManager* UM = EditorPC->GetUndoManager())
{
UM->RecordAction(Action);
}
// Select the pasted actors
ClearSelection();
for (AActor* Actor : NewActors)
{
SelectActor(Actor);
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Pasted %d actor(s) from clipboard"), NewActors.Num());
}
void UPS_Editor_SelectionManager::CleanupStaleReferences() void UPS_Editor_SelectionManager::CleanupStaleReferences()
{ {
SelectedActors.RemoveAll([](const TWeakObjectPtr<AActor>& Weak) SelectedActors.RemoveAll([](const TWeakObjectPtr<AActor>& Weak)

View File

@@ -8,6 +8,17 @@
class APlayerController; class APlayerController;
class APS_Editor_Gizmo; class APS_Editor_Gizmo;
/**
* Clipboard entry for copy/paste operations.
*/
struct FPS_Editor_ClipboardEntry
{
UClass* ActorClass = nullptr;
FVector RelativeLocation = FVector::ZeroVector; // Relative to clipboard pivot
FRotator Rotation = FRotator::ZeroRotator;
FVector Scale = FVector::OneVector;
};
DECLARE_MULTICAST_DELEGATE(FPS_Editor_OnSelectionChanged); DECLARE_MULTICAST_DELEGATE(FPS_Editor_OnSelectionChanged);
/** /**
@@ -39,6 +50,18 @@ public:
/** Snap all selected actors to the ground below them. */ /** Snap all selected actors to the ground below them. */
void SnapSelectionToGround(); void SnapSelectionToGround();
/** Duplicate selected actors with a small offset. Selects the duplicates. */
void DuplicateSelection();
/** Copy selected actors to the internal clipboard. */
void CopySelection();
/** Paste clipboard contents in front of the camera. */
void PasteClipboard();
/** Returns true if the clipboard has entries. */
bool HasClipboardData() const { return Clipboard.Num() > 0; }
// ---- Queries ---- // ---- Queries ----
const TArray<TWeakObjectPtr<AActor>>& GetSelectedActors() const { return SelectedActors; } const TArray<TWeakObjectPtr<AActor>>& GetSelectedActors() const { return SelectedActors; }
@@ -95,6 +118,9 @@ private:
EPS_Editor_TransformMode CurrentTransformMode = EPS_Editor_TransformMode::Translate; EPS_Editor_TransformMode CurrentTransformMode = EPS_Editor_TransformMode::Translate;
EPS_Editor_Space CurrentSpace = EPS_Editor_Space::World; EPS_Editor_Space CurrentSpace = EPS_Editor_Space::World;
/** Clipboard for copy/paste. */
TArray<FPS_Editor_ClipboardEntry> Clipboard;
/** Toggle custom depth stencil rendering on an actor's primitive components. */ /** Toggle custom depth stencil rendering on an actor's primitive components. */
void SetActorHighlight(AActor* Actor, bool bHighlight); void SetActorHighlight(AActor* Actor, bool bHighlight);

View File

@@ -119,6 +119,7 @@ struct FPS_Editor_SpawnAction : public FPS_Editor_Action
FPS_Editor_SpawnAction() : FPS_Editor_Action(EPS_Editor_ActionType::Spawn) {} FPS_Editor_SpawnAction() : FPS_Editor_Action(EPS_Editor_ActionType::Spawn) {}
TArray<TWeakObjectPtr<AActor>> Actors; TArray<TWeakObjectPtr<AActor>> Actors;
FString Description; // Optional override (e.g. "Duplicate", "Paste")
virtual void Execute() override virtual void Execute() override
{ {
@@ -150,6 +151,7 @@ struct FPS_Editor_SpawnAction : public FPS_Editor_Action
virtual FString GetDescription() const override virtual FString GetDescription() const override
{ {
if (!Description.IsEmpty()) return Description;
return FString::Printf(TEXT("Spawn %d actor(s)"), Actors.Num()); return FString::Printf(TEXT("Spawn %d actor(s)"), Actors.Num());
} }
}; };

View File

@@ -203,6 +203,14 @@ void UPS_Editor_SpawnManager::DestroyAllSpawnedActors()
UE_LOG(LogTemp, Log, TEXT("PS_Editor: All spawned actors destroyed")); UE_LOG(LogTemp, Log, TEXT("PS_Editor: All spawned actors destroyed"));
} }
void UPS_Editor_SpawnManager::TrackSpawnedActor(AActor* Actor)
{
if (Actor)
{
SpawnedActors.Add(Actor);
}
}
TArray<FString> UPS_Editor_SpawnManager::GetCategories() const TArray<FString> UPS_Editor_SpawnManager::GetCategories() const
{ {
TArray<FString> Categories; TArray<FString> Categories;

View File

@@ -60,6 +60,9 @@ public:
/** Get unique categories for filtering. */ /** Get unique categories for filtering. */
TArray<FString> GetCategories() const; TArray<FString> GetCategories() const;
/** Register an externally-spawned actor for tracking (used by duplicate/paste). */
void TrackSpawnedActor(AActor* Actor);
private: private:
UPROPERTY() UPROPERTY()
TWeakObjectPtr<APlayerController> OwnerPC; TWeakObjectPtr<APlayerController> OwnerPC;