From 7f2600f0b8b8793dd58c59225b07eb1459b08d15 Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Wed, 29 Apr 2026 16:07:39 +0200 Subject: [PATCH] ChildMovable: rotation support + ChildMovable-aware help bar + clear previous highlight - IPS_Editor_ChildMovable gains GetChildRotation / RotateChild and the GetAll/SetAll rotation pair for undo snapshots. BP impls can clamp axes (e.g. Yaw-only) inside RotateChild. - Pawn captures initial rotations at gizmo drag start and applies the rotate gizmo's delta-quat onto the active child only (no longer rotating every selected actor when a child handle is active). - FPS_Editor_SplinePointAction carries optional Old/NewRotations arrays applied alongside positions on Undo/Redo for ChildMovable actors. - Pawn now calls ClearChildHighlight before HighlightChild so picking a new child no longer leaves the previous one stuck in highlight state. - MainWidget + Legacy widget detect when ActivePlacementActor is a ChildMovable in SplinePlacement mode and swap the help bar text ("CHILDREN: ...") + hide the irrelevant Delete-point button. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../PS_Editor/GameMode/PS_Editor_Pawn.cpp | 49 ++++++++++++++++--- .../PS_Editor/GameMode/PS_Editor_Pawn.h | 5 +- .../Selection/PS_Editor_UndoManager.cpp | 9 ++++ .../Selection/PS_Editor_UndoManager.h | 5 ++ .../PS_Editor/Spawn/PS_Editor_ChildMovable.h | 20 ++++++++ .../UI/Widgets/PS_Editor_MainWidget.cpp | 29 ++++++++++- .../Widgets/PS_Editor_MainWidget_Legacy.cpp | 26 +++++++++- 7 files changed, 131 insertions(+), 12 deletions(-) diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_Pawn.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_Pawn.cpp index de57cda..369895e 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_Pawn.cpp +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/GameMode/PS_Editor_Pawn.cpp @@ -448,15 +448,31 @@ void APS_Editor_Pawn::HandleLook(const FInputActionValue& Value) const FQuat RotationQuat(AxisDir, FMath::DegreesToRadians(SnapAngle)); const FVector GizmoLoc = GizmoDragPlaneOrigin; - for (int32 i = 0; i < Selected.Num() && i < GizmoDragInitialTransforms.Num(); ++i) + // ChildMovable rotation: rotate only the active child instead of all selected actors. + // We compose the delta quat onto the initial rotation captured at drag start — + // reading the live value each frame would feed back through GetChildRotation and + // drift if the BP impl clamps axes. + if (ActiveSplinePointIndex >= 0 + && DraggedSplineActor.IsValid() + && DraggedSplineActor->GetClass()->ImplementsInterface(UPS_Editor_ChildMovable::StaticClass()) + && SplinePointDragInitialRotations.IsValidIndex(ActiveSplinePointIndex)) { - if (AActor* Actor = Selected[i].Get()) + const FQuat OrigRot = SplinePointDragInitialRotations[ActiveSplinePointIndex].Quaternion(); + const FRotator NewRot = (RotationQuat * OrigRot).Rotator(); + IPS_Editor_ChildMovable::Execute_RotateChild(DraggedSplineActor.Get(), ActiveSplinePointIndex, NewRot); + } + else + { + for (int32 i = 0; i < Selected.Num() && i < GizmoDragInitialTransforms.Num(); ++i) { - const FVector Offset = GizmoDragInitialTransforms[i].GetLocation() - GizmoLoc; - Actor->SetActorLocation(GizmoLoc + RotationQuat.RotateVector(Offset)); + if (AActor* Actor = Selected[i].Get()) + { + const FVector Offset = GizmoDragInitialTransforms[i].GetLocation() - GizmoLoc; + Actor->SetActorLocation(GizmoLoc + RotationQuat.RotateVector(Offset)); - const FQuat OrigRot = GizmoDragInitialTransforms[i].GetRotation(); - Actor->SetActorRotation((RotationQuat * OrigRot).Rotator()); + const FQuat OrigRot = GizmoDragInitialTransforms[i].GetRotation(); + Actor->SetActorRotation((RotationQuat * OrigRot).Rotator()); + } } } } @@ -680,6 +696,9 @@ void APS_Editor_Pawn::HandleLeftClickStarted(const FInputActionValue& Value) { ActiveSplinePointIndex = ChildIdx; DraggedSplineActor = ActiveActor; + // Clear any previous highlight before applying the new one — without this, picking + // a different child leaves the prior one stuck in highlight state. + IPS_Editor_ChildMovable::Execute_ClearChildHighlight(ActiveActor); IPS_Editor_ChildMovable::Execute_HighlightChild(ActiveActor, ChildIdx); if (UPS_Editor_SelectionManager* SM = EdPC_CM->GetSelectionManager()) @@ -849,6 +868,7 @@ void APS_Editor_Pawn::HandleLeftClickCompleted(const FInputActionValue& Value) if (ActiveSplinePointIndex >= 0 && DraggedSplineActor.IsValid()) { TArray NewPoints; + TArray NewRotations; FString Desc; if (IPS_Editor_SplineEditable* SplineActor = Cast(DraggedSplineActor.Get())) @@ -859,7 +879,16 @@ void APS_Editor_Pawn::HandleLeftClickCompleted(const FInputActionValue& Value) else if (DraggedSplineActor->GetClass()->ImplementsInterface(UPS_Editor_ChildMovable::StaticClass())) { NewPoints = IPS_Editor_ChildMovable::Execute_GetAllChildLocations(DraggedSplineActor.Get()); - Desc = TEXT("Move child element"); + // Snapshot rotations so the undo system restores both translation and rotation. + // The arrays may be empty if the BP didn't implement rotation — that's fine, + // the undo Execute/Undo only applies when both old and new are non-empty. + NewRotations = IPS_Editor_ChildMovable::Execute_GetAllChildRotations(DraggedSplineActor.Get()); + bool bIsRotate = false; + if (UPS_Editor_SelectionManager* SMDesc = EditorPC->GetSelectionManager()) + { + bIsRotate = (SMDesc->GetTransformMode() == EPS_Editor_TransformMode::Rotate); + } + Desc = bIsRotate ? TEXT("Rotate child element") : TEXT("Move child element"); } if (NewPoints.Num() > 0) @@ -868,6 +897,8 @@ void APS_Editor_Pawn::HandleLeftClickCompleted(const FInputActionValue& Value) Action->SplineActor = DraggedSplineActor.Get(); Action->OldPoints = SplinePointDragInitialState; Action->NewPoints = NewPoints; + Action->OldRotations = SplinePointDragInitialRotations; + Action->NewRotations = NewRotations; Action->Description = Desc; UM->RecordAction(Action); } @@ -1523,6 +1554,7 @@ void APS_Editor_Pawn::BeginGizmoDrag(EPS_Editor_GizmoAxis Axis) } // Store initial sub-element state if editing a point/child + SplinePointDragInitialRotations.Reset(); if (ActiveSplinePointIndex >= 0 && DraggedSplineActor.IsValid()) { if (IPS_Editor_SplineEditable* Spline = Cast(DraggedSplineActor.Get())) @@ -1532,6 +1564,9 @@ void APS_Editor_Pawn::BeginGizmoDrag(EPS_Editor_GizmoAxis Axis) else if (DraggedSplineActor->GetClass()->ImplementsInterface(UPS_Editor_ChildMovable::StaticClass())) { SplinePointDragInitialState = IPS_Editor_ChildMovable::Execute_GetAllChildLocations(DraggedSplineActor.Get()); + // Snapshot rotations too so the rotate gizmo can compute deltas from a fixed + // reference and the undo system can restore them. + SplinePointDragInitialRotations = IPS_Editor_ChildMovable::Execute_GetAllChildRotations(DraggedSplineActor.Get()); } } 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 438599a..b9bd685 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 @@ -209,9 +209,12 @@ private: UPROPERTY() TObjectPtr IA_Cancel; - // ---- Spline point drag state ---- + // ---- Spline point / ChildMovable drag state ---- TWeakObjectPtr DraggedSplineActor; TArray SplinePointDragInitialState; + // Initial rotations captured at drag start when dragging a ChildMovable sub-element. + // Empty for spline points (which do not have a rotation concept). + TArray SplinePointDragInitialRotations; FVector SplinePointDragPlaneOrigin; FVector SplinePointDragPlaneNormal; diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Selection/PS_Editor_UndoManager.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Selection/PS_Editor_UndoManager.cpp index cc41674..023add6 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Selection/PS_Editor_UndoManager.cpp +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Selection/PS_Editor_UndoManager.cpp @@ -13,6 +13,11 @@ void FPS_Editor_SplinePointAction::Execute() else if (SplineActor.IsValid() && SplineActor->GetClass()->ImplementsInterface(UPS_Editor_ChildMovable::StaticClass())) { IPS_Editor_ChildMovable::Execute_SetAllChildLocations(SplineActor.Get(), NewPoints); + // Apply rotations only if the BP captured them (otherwise we'd reset to identity). + if (NewRotations.Num() > 0) + { + IPS_Editor_ChildMovable::Execute_SetAllChildRotations(SplineActor.Get(), NewRotations); + } } } @@ -25,6 +30,10 @@ void FPS_Editor_SplinePointAction::Undo() else if (SplineActor.IsValid() && SplineActor->GetClass()->ImplementsInterface(UPS_Editor_ChildMovable::StaticClass())) { IPS_Editor_ChildMovable::Execute_SetAllChildLocations(SplineActor.Get(), OldPoints); + if (OldRotations.Num() > 0) + { + IPS_Editor_ChildMovable::Execute_SetAllChildRotations(SplineActor.Get(), OldRotations); + } } } 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 42a8154..308de93 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 @@ -170,6 +170,11 @@ struct FPS_Editor_SplinePointAction : public FPS_Editor_Action TWeakObjectPtr SplineActor; TArray OldPoints; TArray NewPoints; + // ChildMovable-only: rotation snapshots. Empty for spline points and for ChildMovable BPs + // that don't implement rotation. Execute/Undo only apply rotations when both arrays match + // the points array length. + TArray OldRotations; + TArray NewRotations; FString Description; virtual void Execute() override; diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_ChildMovable.h b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_ChildMovable.h index 5452f57..780fc19 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_ChildMovable.h +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/Spawn/PS_Editor_ChildMovable.h @@ -44,6 +44,26 @@ public: UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Child Movable") void SetAllChildLocations(const TArray& Positions); + /** World rotation of the sub-element at the given index. Implement to constrain the + * rotation axes you actually expose (e.g. yaw only) — the plugin gizmo writes whatever + * the user drags, so any clamping must happen in RotateChild on the BP side. */ + UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Child Movable") + FRotator GetChildRotation(int32 Index) const; + + /** Apply a new world rotation to the sub-element at the given index. The BP impl is the + * right place to drop unsupported axes (e.g. keep only Yaw, force Pitch/Roll to zero). */ + UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Child Movable") + void RotateChild(int32 Index, const FRotator& NewWorldRotation); + + /** Return all sub-element rotations (used for undo snapshots). Empty array allowed if + * rotation is not supported on this actor — the undo system then ignores rotation. */ + UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Child Movable") + TArray GetAllChildRotations() const; + + /** Restore all sub-element rotations (used for undo). */ + UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Child Movable") + void SetAllChildRotations(const TArray& Rotations); + /** Index of the sub-element under the cursor ray (-1 if none). * Implement with sphere-ray intersection or any hit detection that suits your visuals. */ UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS Editor|Child Movable") diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.cpp index 09e05bb..fb015a0 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.cpp +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget.cpp @@ -1821,9 +1821,33 @@ void UPS_Editor_MainWidget::RefreshPropertiesFromSelection() bPointSelected = (Pawn->ActiveSplinePointIndex >= 0); } + // SplinePlacement mode is shared between IPS_Editor_SplineEditable and + // IPS_Editor_ChildMovable — show ChildMovable-specific help when the active + // placement actor implements the child interface (no point insertion, no Del, + // rotation gizmo available via R key). + bool bChildMovableActive = false; + if (APS_Editor_PlayerController* EditorPC2 = Cast(GetOwningPlayer())) + { + if (AActor* ActiveActor = EditorPC2->GetActivePlacementActor()) + { + bChildMovableActive = ActiveActor->GetClass()->ImplementsInterface(UPS_Editor_ChildMovable::StaticClass()); + } + } + if (HelpBarText.IsValid()) { - if (bPointSelected) + if (bChildMovableActive) + { + if (bPointSelected) + { + HelpBarText->SetText(FText::FromString(TEXT("CHILDREN: Drag gizmo to move | R: rotate | T: translate | Esc/Done: finish"))); + } + else + { + HelpBarText->SetText(FText::FromString(TEXT("CHILDREN: Click a child to select | Esc/Done: finish"))); + } + } + else if (bPointSelected) { HelpBarText->SetText(FText::FromString(TEXT("SPLINE: Drag gizmo to move | Del: delete point | Click line: insert | Click outside: add | Esc/Done: finish"))); } @@ -1835,7 +1859,8 @@ void UPS_Editor_MainWidget::RefreshPropertiesFromSelection() } if (SplineEditButton.IsValid()) SplineEditButton->SetVisibility(EVisibility::Collapsed); if (ChildEditButton.IsValid()) ChildEditButton->SetVisibility(EVisibility::Collapsed); - if (SplineDeletePointButton.IsValid()) SplineDeletePointButton->SetVisibility(bPointSelected ? EVisibility::Visible : EVisibility::Collapsed); + // Delete-point button is meaningless for ChildMovable (fixed child set) + if (SplineDeletePointButton.IsValid()) SplineDeletePointButton->SetVisibility((bPointSelected && !bChildMovableActive) ? EVisibility::Visible : EVisibility::Collapsed); if (SplineFinishButton.IsValid()) SplineFinishButton->SetVisibility(EVisibility::Visible); } else if (bChildMovableSelected) diff --git a/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget_Legacy.cpp b/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget_Legacy.cpp index cc99e94..583e5ca 100644 --- a/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget_Legacy.cpp +++ b/Unreal/Plugins/PS_Editor/Source/PS_Editor/UI/Widgets/PS_Editor_MainWidget_Legacy.cpp @@ -1573,9 +1573,30 @@ void UPS_Editor_MainWidget_Legacy::RefreshPropertiesFromSelection() bPointSelected = (Pawn->ActiveSplinePointIndex >= 0); } + // SplinePlacement mode is shared with IPS_Editor_ChildMovable — show the right help. + bool bChildMovableActive = false; + if (APS_Editor_PlayerController* EditorPC2 = Cast(GetOwningPlayer())) + { + if (AActor* ActiveActor = EditorPC2->GetActivePlacementActor()) + { + bChildMovableActive = ActiveActor->GetClass()->ImplementsInterface(UPS_Editor_ChildMovable::StaticClass()); + } + } + if (HelpBarText.IsValid()) { - if (bPointSelected) + if (bChildMovableActive) + { + if (bPointSelected) + { + HelpBarText->SetText(FText::FromString(TEXT("CHILDREN: Drag gizmo to move | R: rotate | T: translate | Esc/Done: finish"))); + } + else + { + HelpBarText->SetText(FText::FromString(TEXT("CHILDREN: Click a child to select | Esc/Done: finish"))); + } + } + else if (bPointSelected) { HelpBarText->SetText(FText::FromString(TEXT("SPLINE: Drag gizmo to move | Del: delete point | Click line: insert | Click outside: add | Esc/Done: finish"))); } @@ -1587,7 +1608,8 @@ void UPS_Editor_MainWidget_Legacy::RefreshPropertiesFromSelection() } if (SplineEditButton.IsValid()) SplineEditButton->SetVisibility(EVisibility::Collapsed); if (ChildEditButton.IsValid()) ChildEditButton->SetVisibility(EVisibility::Collapsed); - if (SplineDeletePointButton.IsValid()) SplineDeletePointButton->SetVisibility(bPointSelected ? EVisibility::Visible : EVisibility::Collapsed); + // Delete-point is meaningless for ChildMovable (fixed child set) + if (SplineDeletePointButton.IsValid()) SplineDeletePointButton->SetVisibility((bPointSelected && !bChildMovableActive) ? EVisibility::Visible : EVisibility::Collapsed); if (SplineFinishButton.IsValid()) SplineFinishButton->SetVisibility(EVisibility::Visible); } else if (bChildMovableSelected)