From 11945e90d20f53f8334e3aac06ab0fa38cf5e5c5 Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Sat, 11 Apr 2026 13:08:13 +0200 Subject: [PATCH] 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) --- Unreal/Content/test.umap | Bin 57754 -> 59857 bytes .../PS_Editor/GameMode/PS_Editor_Pawn.cpp | 52 ++++++ .../PS_Editor/GameMode/PS_Editor_Pawn.h | 12 ++ .../Selection/PS_Editor_SelectionManager.cpp | 163 ++++++++++++++++++ .../Selection/PS_Editor_SelectionManager.h | 26 +++ .../Selection/PS_Editor_UndoManager.h | 2 + .../Spawn/PS_Editor_SpawnManager.cpp | 8 + .../PS_Editor/Spawn/PS_Editor_SpawnManager.h | 3 + 8 files changed, 266 insertions(+) diff --git a/Unreal/Content/test.umap b/Unreal/Content/test.umap index 72715db10eea194022d92cede40397ea57c8d57a..1e71822e007973c5131c8b32598ac939a2b447a9 100644 GIT binary patch delta 4410 zcmZ{n3s6(p8OQGpWYr2I_z2<~sj>=^7kMeL_vQtJ1PHmH$?l2~B0)n4?}!f;A1Kxr z9$jDcf$pM!0ursLtSjo;)lPTUakg$}+Oh78TW#I8wzFe*`mpDmoY>&iJ9D|;`ThUj zcfNCT?oBv!BjWD8i2iJP&h=V~8cR`>ILle-64$wE>)=2@4bl|l7NCft^iohsC#yYD ziju?O?=mT>7+%phr6_254+0N7*TVDfKi&*1^`FT*>fK|{Dh5{Y6+Kai5(mK(+BSPX zv>bT?hhfJHc%VjnaMq3|fu#a-Qb7{b#RhMAh1Vt4I%_LRZEmN}RT~ytoOQ063a5u+ z{;;Mu{tb3r{c;zz^2!fi2fF8-qD3zYy-apSePa)plWz;@bs$rkD7*xFS&O3!Fd7e> zof9UZyMT^n*dIE64V6CVnHn7;D2q?}M|e=Zk)8mL^t>b=kh!iwauU}GkxwBxi^6id3%)PqrM@f0x}zOEHhJ!oVdq82zGnsPKe#|eX} z{(6wWO#!qveToc{4PlmJK?k*^30tbQoN(?M$Uw_*9C)Z@L>t5U-%ZTh*8-K!F+!3P z+q8Ayuj~{stYbu*!!mD^%>4COmg*T%E0iM$o=h5@+>E>EATs*OMC*tqHpwO|#~K(B zdLN+rNOq8kGJ@HY!}{H2AW+QFbu1X zVJgjKMCbtz(Af9cB*Lz z`?n59fsDdzLG=wh>?jQG^owCLgdW7*Pp~D{wwIQGPFpQ~2h7|R2PB*Z9M2pDDmc|! zKKK*8vleu6y2wXU(Y{|iD-d1ur?~kJrMW0uEpsdN$%cl4ddHHLVt2!8_wu^jVm8~d zN?crREi325^%4#wRI<(ahT2kT)e42e%9>nRaxG_HmB;Ehbd->ub)}L?mxoaAA zT5BGc?DuPGOfnCXeoHF>|u$? zR#oUw%9n8N+`4>|-jr-t)JSZ( zr42>q?8<^h79Z9eUoc>GtV=>dgVqCfFH2}{avBUp-kc)4oAnpiYa6Po%gR>itxY;s zn$zgBTP;47s>UuiSNU0Q73ZtcRr$*eI(y|}jYsA6)mvGE%&j$=RvWW&tQJmYHY=;W zOB}4k(pa8nv*a!|=yP4kN?G1&rNwGe8kA19+G5wTl_pPv!=+G0E?>oJL3{C}SZfjN zOsg+jZ1S+ILCdmW_bMaE_s$-rNtMb{2zp zWT5Sm(F-u|>e`IGsmKeA0Y%K-(}trL-&dQ?f2&LSPSd9c?Hv=N$G}wlCPDv?f@$5! zbRUTBl?43V#WekS;LDy20o@;n?p-Gk-iFIh!m~U*lM=YsKT7~oyW`F0AhwV%SBLN+JQpqG@$6k1qI zBiE`}m||qrtXdI}4ITAeF7!T$JrsMcT xqZF(Od`rs%OV2+Nz$v1mm}LwVchTCm$i?`9v=)_~1!i8@1VvF`-eC2-{{gwR<39iZ delta 3496 zcmZvfdr(t%7RT?s5QPDzU`Pn4gMcn#P>4JR6l*RQ!ovg-AgR*YX?PVaG$|1T`Vh4E zSYP02ZKYP!qG&5GFLvC4R#du0ZFgnbo!xeau zjY<}&t<_yDLu%Uz!zl|x+G)MWMqeoJPT~<<8eq- zg^aYy$RSytE}7=AOw%bNAR~b?qLh&nG7>K%J%q6B@XhLIxStX*Yo&T+YC%TM$jDh4 z8ITd9j10?2os3+R5yvcY1$U);7Wo*-=~?6>B)eo}L^_9u=qVz*+W~B5Jo|>k5Bu=l z66vIbye~&2a+H!uB;qXcDUvrO@)p%hAUP)0@D3&9KsyOx-+|3*;zN9S1Lgg{2yAt04l57x%xr)ll%(ORTRPtdx^O6-wHj}O_4M5$_~s5)C)hhL z#SQqx?FY+~aSBqw2a3w2y0Jpwe z-Rk37MO{M>mq2j_EieM*{=;wB=al88Gh^lqMYJTphqfR*eJ69O3X2J;z zvTac-u{&2Oe{;gl>~i&MI35`?0NWD^cc|Y>5N3-yV)KVkAtNdo;SL{Fk6XknX!5Aq z1hZne2{HWpMg-g!^y)UBS#(l;L^EXPl+SXdP+4Cyu!q$+nm>G&qgU_riM~Ojzi#nL zvU2sRT`2LRE)6aFJ+KP`e3`3}yyQjY3zh067MYioC&O1mZ2HLrqHF5rank z1|?+APmv_}#I}0}|IMIRlULo7;5WB|xxUM@8qT~d)e)a0+o|FTum+`jQT^dgURGoh zI^OjP8K#4dmJZVfC5-0>5V1KwEAluM5q~z#Ho6B5X}uSp3xWX%gxmRg?$I7k70Cr= zwihgU0!$PnvJTMY3y@kEF72ig^U;*vd^K-+BHIeH)a2DB3bI7ORC>;<_@U6ujU4sp zpW9&OKJoT$Z!oj}fvG|Ps*D;Hxez>guF>hmLyQr($(YF9JLSo-#AN0gPkVZurYse? z3_Otwh=I>7=)%aQMTy)uz1|I}E7GgU?cfpr;MOvG&v?>(Ta=~hqbbO)UZ4s*bSCeH z7>o7l_fbTOB(sjD0ZaQlX~v7Q;2A@3;3N$fVA>==nOUPEmkg1UxL6t&1ePN2CZ04W zF8c@GEHsi?j>aGrl8Qd2OS2Mm#RBwN64~z|%q&27iALqCW9Ui$5!%h(%AYAoX8#VG z%?ks6rTKq^DGRQDX?P@g`RjZJDyNEjz?a}2lxoyNo_f*q>pZtx81c+`Ntu~l12Lrn zjFg2(kpnw|dJ;nB>Up&`O;i05YbU!3x~wkt7ED={u*Bw?_bPt-)H&doP2egc#k;m( z%?e&yZ7a#t<+&QP#dXhT)Ia6i;xwl=rRJp9RFoDMB$@c+;*un5W%^bKcknyPa$S`= zM@?FqC1*o!S!K34?}b`kXlbsjU0vbWXyqG=4bIwz?LxMtFpaln+g#eGs`%s$w(7== z?Z%21`0U(fV}6l6Ut4YETWWJt__AhafrWRuHWpOXryF#(sKqcSpdNAKX@uZVbHCzmi=6Q~itZbErENmiITYiO|zugrB4z zJJ119Vk9eyj=|lm;szF+4_$keV#x3xICcaagUMo*XyOz%(Gw4Ri$?s*B_jtbMkC3p za)s^zpBr5)Ewb3HgNvizN&o4bfuX-fii!(this, TEXT("IA_Redo")); IA_Redo->ValueType = EInputActionValueType::Boolean; EditorMappingContext->MapKey(IA_Redo, EKeys::Y); + + // Duplicate / Copy / Paste (Ctrl+D / Ctrl+C / Ctrl+V) + IA_Duplicate = NewObject(this, TEXT("IA_Duplicate")); + IA_Duplicate->ValueType = EInputActionValueType::Boolean; + EditorMappingContext->MapKey(IA_Duplicate, EKeys::D); + + IA_Copy = NewObject(this, TEXT("IA_Copy")); + IA_Copy->ValueType = EInputActionValueType::Boolean; + EditorMappingContext->MapKey(IA_Copy, EKeys::C); + + IA_Paste = NewObject(this, TEXT("IA_Paste")); + IA_Paste->ValueType = EInputActionValueType::Boolean; + EditorMappingContext->MapKey(IA_Paste, EKeys::V); } 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_Undo, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleUndo); 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 ---- @@ -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(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(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(GetController())) + { + if (UPS_Editor_SelectionManager* SM = EditorPC->GetSelectionManager()) + { + SM->PasteClipboard(); + } + } +} + // ---- Helpers ---- void APS_Editor_Pawn::SetCameraMode(EPS_Editor_CameraMode NewMode) diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_Pawn.h b/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_Pawn.h index 4535fcf..b6c8f84 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_Pawn.h +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_Pawn.h @@ -171,6 +171,15 @@ private: UPROPERTY() TObjectPtr IA_Redo; + UPROPERTY() + TObjectPtr IA_Duplicate; + + UPROPERTY() + TObjectPtr IA_Copy; + + UPROPERTY() + TObjectPtr IA_Paste; + // ---- Modifier key state ---- bool bAltHeld = false; bool bCtrlHeld = false; @@ -204,6 +213,9 @@ private: void HandleSnapToGround(const FInputActionValue& Value); void HandleUndo(const FInputActionValue& Value); void HandleRedo(const FInputActionValue& Value); + void HandleDuplicate(const FInputActionValue& Value); + void HandleCopy(const FInputActionValue& Value); + void HandlePaste(const FInputActionValue& Value); // ---- Helpers ---- void CreateInputActions(); diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Selection/PS_Editor_SelectionManager.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Selection/PS_Editor_SelectionManager.cpp index ecf17a3..8136f40 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Selection/PS_Editor_SelectionManager.cpp +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Selection/PS_Editor_SelectionManager.cpp @@ -2,6 +2,7 @@ #include "PS_Editor_Gizmo.h" #include "PS_Editor_UndoManager.h" #include "PS_Editor_PlayerController.h" +#include "PS_Editor_SpawnManager.h" #include "GameFramework/PlayerController.h" #include "Engine/World.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(PC); + if (!EditorPC) return; + + static const FVector DuplicateOffset(50.0f, 50.0f, 0.0f); + TArray NewActors; + + for (const TWeakObjectPtr& Weak : SelectedActors) + { + AActor* Original = Weak.Get(); + if (!Original) continue; + + FActorSpawnParameters SpawnParams; + SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn; + + AActor* NewActor = PC->GetWorld()->SpawnActor( + 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 Action = MakeShared(); + 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& 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(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 NewActors; + + for (const FPS_Editor_ClipboardEntry& Entry : Clipboard) + { + if (!Entry.ActorClass) continue; + + FActorSpawnParameters SpawnParams; + SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn; + + AActor* NewActor = PC->GetWorld()->SpawnActor( + 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 Action = MakeShared(); + 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() { SelectedActors.RemoveAll([](const TWeakObjectPtr& Weak) diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Selection/PS_Editor_SelectionManager.h b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Selection/PS_Editor_SelectionManager.h index 60c65aa..e2ece7c 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Selection/PS_Editor_SelectionManager.h +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Selection/PS_Editor_SelectionManager.h @@ -8,6 +8,17 @@ class APlayerController; 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); /** @@ -39,6 +50,18 @@ public: /** Snap all selected actors to the ground below them. */ 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 ---- const TArray>& GetSelectedActors() const { return SelectedActors; } @@ -95,6 +118,9 @@ private: EPS_Editor_TransformMode CurrentTransformMode = EPS_Editor_TransformMode::Translate; EPS_Editor_Space CurrentSpace = EPS_Editor_Space::World; + /** Clipboard for copy/paste. */ + TArray Clipboard; + /** Toggle custom depth stencil rendering on an actor's primitive components. */ void SetActorHighlight(AActor* Actor, bool bHighlight); diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Selection/PS_Editor_UndoManager.h b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Selection/PS_Editor_UndoManager.h index 03b0848..c85af53 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Selection/PS_Editor_UndoManager.h +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Selection/PS_Editor_UndoManager.h @@ -119,6 +119,7 @@ struct FPS_Editor_SpawnAction : public FPS_Editor_Action FPS_Editor_SpawnAction() : FPS_Editor_Action(EPS_Editor_ActionType::Spawn) {} TArray> Actors; + FString Description; // Optional override (e.g. "Duplicate", "Paste") virtual void Execute() override { @@ -150,6 +151,7 @@ struct FPS_Editor_SpawnAction : public FPS_Editor_Action virtual FString GetDescription() const override { + if (!Description.IsEmpty()) return Description; return FString::Printf(TEXT("Spawn %d actor(s)"), Actors.Num()); } }; diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_SpawnManager.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_SpawnManager.cpp index 84ccec2..bde80aa 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_SpawnManager.cpp +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_SpawnManager.cpp @@ -203,6 +203,14 @@ void UPS_Editor_SpawnManager::DestroyAllSpawnedActors() UE_LOG(LogTemp, Log, TEXT("PS_Editor: All spawned actors destroyed")); } +void UPS_Editor_SpawnManager::TrackSpawnedActor(AActor* Actor) +{ + if (Actor) + { + SpawnedActors.Add(Actor); + } +} + TArray UPS_Editor_SpawnManager::GetCategories() const { TArray Categories; diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_SpawnManager.h b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_SpawnManager.h index b45321e..86a7041 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_SpawnManager.h +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_SpawnManager.h @@ -60,6 +60,9 @@ public: /** Get unique categories for filtering. */ TArray GetCategories() const; + /** Register an externally-spawned actor for tracking (used by duplicate/paste). */ + void TrackSpawnedActor(AActor* Actor); + private: UPROPERTY() TWeakObjectPtr OwnerPC;