Compare commits

..

7 Commits

Author SHA1 Message Date
11945e90d2 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>
2026-04-11 13:08:13 +02:00
7ef566f3f3 Update test level and engine config
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:51:13 +02:00
b33b7cf9d3 Revert rotation to simple perpendicular approach
The magnitude+sign approach caused confusing direction changes.
Back to perpendicular-only: only the mouse axis perpendicular to
the projected rotation axis drives rotation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:46:49 +02:00
76d2ccd249 Update project memory with Phase 2 gizmo details
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:46:02 +02:00
8dba2ab0fc Add World/Local space toggle, snap system, and gizmo polish
World/Local:
- Toggle button in toolbar (World/Local)
- Local mode: translate/rotate gizmo aligns with actor's local axes
- Rotation arcs dynamically follow local orientation during drag
- Scale always in local space
- RefreshGizmoOrientation after every drag completion

Snap System:
- Toggle button + configurable value field in toolbar
- Translation: snaps displacement along axis (no world-grid staircase)
- Rotation: snaps accumulated angle to nearest increment
- Scale: snaps scale values

Rotation improvements:
- Screen-space direction with magnitude-based speed (all mouse axes contribute)
- Fixed axis direction stored at drag start (prevents feedback loop)
- Smooth fallback when camera looks along rotation axis
- Keyboard shortcuts: Z/E/R (AZERTY convention)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:45:12 +02:00
48505bfb82 Add rotation arc rings, scale cubes, and gizmo improvements
Rotation gizmo:
- Quarter-arc rings via UProceduralMeshComponent (3D solid with depth)
- Dynamic arc facing: arcs flip to face camera based on octant
- Grey guide lines from center to arc endpoints
- Screen-space rotation direction (correct from any camera angle)

Scale gizmo:
- Arrow shafts + cube tips for per-axis scale
- Center cube (grey) for uniform XYZ scale
- Local space orientation (gizmo aligns with actor rotation)

Other:
- Runtime confirmation dialog (replaces FMessageDialog)
- ProceduralMeshComponent plugin dependency added

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:47:03 +02:00
03c79a2610 Add auto-generated selection outline post-process and gizmo polish
Selection Outline:
- Post-process material auto-generated in C++ (no manual UE editor work)
- 8-neighbor edge detection on CustomStencil buffer
- Dynamic pixel-accurate offsets via SceneTexture InvSize (any resolution/ratio)
- Lerp blending (no overexposure), color AF1500, 1px thickness
- BL_SceneColorAfterDOF placement (before TAA, matches stencil resolution)
- Deferred creation via OnPostEngineInit (no startup crash)
- Material saved as persistent .uasset, loaded at runtime

Gizmo Polish:
- Arrow shaft thickness halved (0.03), cone tips halved (0.075x0.1)
- Gizmo stays at initial position during rotation/scale (no jitter)
- Gizmo follows selection during translation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 10:36:53 +02:00
21 changed files with 1427 additions and 205 deletions

View File

@@ -8,11 +8,12 @@ PS_Editor is a runtime editor plugin for Unreal Engine 5.5 for the PROSERVE proj
Key decisions:
- UI: UMG driven by C++ (Slate)
- Gizmos: Custom actor with BasicShapes + unlit material (ITF is editor-only)
- Gizmos: Custom actor (BasicShapes + ProceduralMeshComponent for arcs)
- Serialization: JSON via Json/JsonUtilities (FJsonObjectConverter)
- Naming: PS_Editor_ prefix on all files/classes
- Plugin path: Unreal/Plugins/PS_Editor/
- AZERTY keyboard: arrows + A/Q for movement, E/R/T for modes
- AZERTY keyboard: arrows + A/Q movement, Z/E/R for translate/rotate/scale
- Selection outline: auto-generated post-process material (C++), 8-neighbor edge detection on CustomStencil
## Completed Phases
@@ -22,26 +23,32 @@ Key decisions:
### Phase 2 - Selection + Transform + Undo
- Click-to-select (Movable actors only), Ctrl+click multi-select
- Custom gizmo (R/G/B arrows + cones, SDPG_Foreground, constant screen size)
- Translate/Rotate/Scale via gizmo drag, hotkeys E/R/T
- Snap to ground (End), soft-delete (Delete + confirmation dialog)
- Gizmo modes:
- Translate (Z): arrows + cones, world or local space
- Rotate (E): quarter-arc rings (ProceduralMesh), dynamic octant facing, local orientation during drag
- Scale (R): arrows + cubes + center cube (uniform), always local space
- World/Local space toggle (UI button), Scale always local
- Snap system: configurable translation/rotation/scale snapping (UI toggle + value field)
- Snap to ground (End), soft-delete (Delete + in-game confirmation dialog)
- Undo/Redo (Ctrl+Z/Y, 50 action stack)
- Properties panel + toolbar UI
- Material: M_PS_Editor_Gizmo (Unlit, "Color" param, Translucent, Disable Depth Test)
- Selection outline: auto-generated PP material, BL_SceneColorAfterDOF, color AF1500, 1px
- Rotation: screen-space direction with magnitude speed, fixed axis during drag
### Phase 3 - Object Spawning
- SpawnableComponent: marker on BPs (name, category, thumbnail)
- SpawnCatalog DataAsset + Factory for easy creation
- SpawnManager: spawns in front of camera, tracks all spawned actors
- Catalogue UI: thumbnail cards, click to spawn, grouped by category
- CaptureAllThumbnails on DataAsset: auto-generates from UE thumbnails
- Spawn undo/redo, auto-deselect hidden actors
- SpawnCatalog DataAsset + Factory
- CaptureAllThumbnails on DataAsset
- Catalogue UI: thumbnail cards (80x80), grouped by category
### Phase 4 - JSON Serialization
- SceneData USTRUCT (Version, SceneName, Timestamp, Actors[ClassPath, Location, Rotation, Scale])
- SceneSerializer: Save/Load/Clear/GetSavedSceneNames
- Files in Saved/PS_Editor/Scenes/{name}.json
- UI: Save field + button, New Scene, saved scenes list with click-to-load
- UI: Save field + button, New Scene, saved scenes list
## Materials (plugin Content/)
- M_PS_Editor_Gizmo: Unlit, "Color" Vector Parameter, Translucent, Disable Depth Test
- M_PS_Editor_SelectionOutline: auto-generated via C++ OnPostEngineInit
## Next: Phase 5 - Advanced Editing
- Splines, lighting, AI characters, property editing
- Splines, lighting, AI characters, property editing, duplication (Ctrl+D)

View File

@@ -20,7 +20,7 @@ r.CustomDepth=3
r.RayTracing=True
r.Shadow.Virtual.Enable=1
r.Shadow.Virtual.Enable=0
r.DefaultFeature.AutoExposure.ExtendDefaultLuminanceRange=True

Binary file not shown.

View File

@@ -20,6 +20,10 @@
{
"Name": "EnhancedInput",
"Enabled": true
},
{
"Name": "ProceduralMeshComponent",
"Enabled": true
}
]
}

View File

@@ -14,7 +14,7 @@
#include "PS_Editor_Gizmo.h"
#include "PS_Editor_UndoManager.h"
#include "DrawDebugHelpers.h"
#include "Misc/MessageDialog.h"
#include "PS_Editor_ConfirmDialog.h"
APS_Editor_Pawn::APS_Editor_Pawn()
{
@@ -160,15 +160,15 @@ void APS_Editor_Pawn::CreateInputActions()
// Transform mode hotkeys (E/R/T)
IA_TranslateMode = NewObject<UInputAction>(this, TEXT("IA_TranslateMode"));
IA_TranslateMode->ValueType = EInputActionValueType::Boolean;
EditorMappingContext->MapKey(IA_TranslateMode, EKeys::E);
EditorMappingContext->MapKey(IA_TranslateMode, EKeys::Z);
IA_RotateMode = NewObject<UInputAction>(this, TEXT("IA_RotateMode"));
IA_RotateMode->ValueType = EInputActionValueType::Boolean;
EditorMappingContext->MapKey(IA_RotateMode, EKeys::R);
EditorMappingContext->MapKey(IA_RotateMode, EKeys::E);
IA_ScaleMode = NewObject<UInputAction>(this, TEXT("IA_ScaleMode"));
IA_ScaleMode->ValueType = EInputActionValueType::Boolean;
EditorMappingContext->MapKey(IA_ScaleMode, EKeys::T);
EditorMappingContext->MapKey(IA_ScaleMode, EKeys::R);
IA_Delete = NewObject<UInputAction>(this, TEXT("IA_Delete"));
IA_Delete->ValueType = EInputActionValueType::Boolean;
@@ -186,6 +186,19 @@ void APS_Editor_Pawn::CreateInputActions()
IA_Redo = NewObject<UInputAction>(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<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)
@@ -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 ----
@@ -318,7 +334,8 @@ void APS_Editor_Pawn::HandleLook(const FInputActionValue& Value)
APS_Editor_Gizmo* Gizmo = SM->GetGizmo();
if (!Gizmo) return;
const FVector AxisDir = Gizmo->GetAxisDirection(ActiveGizmoAxis);
// Use fixed axis direction from drag start (avoid feedback loop with local orientation updates)
const FVector AxisDir = GizmoDragAxisDir;
const EPS_Editor_TransformMode Mode = SM->GetTransformMode();
const TArray<TWeakObjectPtr<AActor>>& Selected = SM->GetSelectedActors();
@@ -331,7 +348,14 @@ void APS_Editor_Pawn::HandleLook(const FInputActionValue& Value)
if (!RayPlaneIntersection(RayOrigin, RayDir, GizmoDragPlaneOrigin, GizmoDragPlaneNormal, CurrentIntersection)) return;
const FVector TotalDelta = CurrentIntersection - GizmoDragInitialIntersection;
const FVector ConstrainedDelta = FVector::DotProduct(TotalDelta, AxisDir) * AxisDir;
float DisplacementAlongAxis = FVector::DotProduct(TotalDelta, AxisDir);
// Snap displacement along axis, not world position
if (SM->bSnapTranslation && SM->TranslationSnapSize > 0.0f)
{
DisplacementAlongAxis = FMath::RoundToFloat(DisplacementAlongAxis / SM->TranslationSnapSize) * SM->TranslationSnapSize;
}
const FVector ConstrainedDelta = DisplacementAlongAxis * AxisDir;
for (int32 i = 0; i < Selected.Num() && i < GizmoDragInitialTransforms.Num(); ++i)
{
@@ -343,14 +367,32 @@ void APS_Editor_Pawn::HandleLook(const FInputActionValue& Value)
}
else if (Mode == EPS_Editor_TransformMode::Rotate)
{
// Use combined mouse X+Y screen movement to drive rotation angle
const float RotationSpeed = 1.0f;
const float AngleDelta = (LookInput.X + LookInput.Y) * RotationSpeed;
// Accumulate total angle since drag start
// Project the rotation axis onto screen space to determine correct mouse mapping.
// This ensures consistent rotation direction from any camera angle.
APlayerController* DragPC = Cast<APlayerController>(GetController());
float AngleDelta = 0.0f;
if (DragPC)
{
FVector2D ScreenStart, ScreenEnd;
DragPC->ProjectWorldLocationToScreen(GizmoDragPlaneOrigin, ScreenStart);
DragPC->ProjectWorldLocationToScreen(GizmoDragPlaneOrigin + AxisDir * 100.0f, ScreenEnd);
FVector2D ScreenAxisDir = (ScreenEnd - ScreenStart).GetSafeNormal();
FVector2D ScreenPerp(-ScreenAxisDir.Y, ScreenAxisDir.X);
AngleDelta = -(LookInput.X * ScreenPerp.X - LookInput.Y * ScreenPerp.Y) * RotationSpeed;
}
GizmoDragAccumulator += AngleDelta;
const FQuat RotationQuat(AxisDir, FMath::DegreesToRadians(GizmoDragAccumulator));
float SnapAngle = GizmoDragAccumulator;
if (SM->bSnapRotation && SM->RotationSnapSize > 0.0f)
{
SnapAngle = FMath::RoundToFloat(SnapAngle / SM->RotationSnapSize) * SM->RotationSnapSize;
}
const FQuat RotationQuat(AxisDir, FMath::DegreesToRadians(SnapAngle));
const FVector GizmoLoc = GizmoDragPlaneOrigin;
for (int32 i = 0; i < Selected.Num() && i < GizmoDragInitialTransforms.Num(); ++i)
@@ -378,16 +420,33 @@ void APS_Editor_Pawn::HandleLook(const FInputActionValue& Value)
if (AActor* Actor = Selected[i].Get())
{
FVector NewScale = GizmoDragInitialTransforms[i].GetScale3D();
if (ActiveGizmoAxis == EPS_Editor_GizmoAxis::X) NewScale.X *= ScaleFactor;
if (ActiveGizmoAxis == EPS_Editor_GizmoAxis::XYZ)
{
NewScale *= ScaleFactor; // Uniform scale
}
else if (ActiveGizmoAxis == EPS_Editor_GizmoAxis::X) NewScale.X *= ScaleFactor;
else if (ActiveGizmoAxis == EPS_Editor_GizmoAxis::Y) NewScale.Y *= ScaleFactor;
else if (ActiveGizmoAxis == EPS_Editor_GizmoAxis::Z) NewScale.Z *= ScaleFactor;
Actor->SetActorScale3D(NewScale);
Actor->SetActorScale3D(SM->SnapScale(NewScale));
}
}
}
// In translate mode, gizmo follows the selection. In rotate/scale, stay at initial position.
if (Mode == EPS_Editor_TransformMode::Translate)
{
Gizmo->SetActorLocation(SM->GetSelectionPivot());
}
// Update local orientation in real-time during rotation drag
if (Mode == EPS_Editor_TransformMode::Rotate && SM->GetTransformSpace() == EPS_Editor_Space::Local)
{
if (AActor* FirstActor = Selected.Num() > 0 ? Selected[0].Get() : nullptr)
{
Gizmo->SetLocalOrientation(FirstActor->GetActorRotation());
}
}
}
}
void APS_Editor_Pawn::HandleScroll(const FInputActionValue& Value)
@@ -495,6 +554,16 @@ void APS_Editor_Pawn::HandleLeftClickCompleted(const FInputActionValue& Value)
}
}
ActiveGizmoAxis = EPS_Editor_GizmoAxis::None;
// Refresh gizmo orientation (local mode needs to track actor's new rotation)
if (APS_Editor_PlayerController* PC2 = Cast<APS_Editor_PlayerController>(GetController()))
{
if (UPS_Editor_SelectionManager* SM2 = PC2->GetSelectionManager())
{
SM2->RefreshGizmoOrientation();
}
}
SetCameraMode(EPS_Editor_CameraMode::Idle);
}
else if (CameraMode == EPS_Editor_CameraMode::Pan || CameraMode == EPS_Editor_CameraMode::Orbit)
@@ -577,19 +646,27 @@ void APS_Editor_Pawn::HandleDelete(const FInputActionValue& Value)
UPS_Editor_SelectionManager* SM = EditorPC->GetSelectionManager();
if (!SM || !SM->IsAnythingSelected()) return;
// Confirmation dialog
// Show in-game confirmation dialog (runtime compatible)
const int32 Count = SM->GetSelectedActors().Num();
const FText Message = FText::Format(
NSLOCTEXT("PS_Editor", "DeleteConfirm", "Delete {0} selected object(s)?"),
FText::AsNumber(Count));
EAppReturnType::Type Result = FMessageDialog::Open(EAppMsgType::YesNo, Message);
if (Result != EAppReturnType::Yes)
UPS_Editor_ConfirmDialog* Dialog = CreateWidget<UPS_Editor_ConfirmDialog>(
Cast<APlayerController>(GetController()), UPS_Editor_ConfirmDialog::StaticClass());
if (!Dialog) return;
// Weak refs to avoid dangling pointers in the callback
TWeakObjectPtr<APS_Editor_PlayerController> WeakPC = EditorPC;
Dialog->Show(Message, FPS_Editor_OnConfirmResult::CreateLambda([WeakPC](bool bConfirmed)
{
return;
}
if (!bConfirmed || !WeakPC.IsValid()) return;
UPS_Editor_SelectionManager* SM = WeakPC->GetSelectionManager();
if (!SM || !SM->IsAnythingSelected()) return;
// Soft-delete: hide actors instead of destroying (enables undo)
TSharedPtr<FPS_Editor_DeleteAction> Action = MakeShared<FPS_Editor_DeleteAction>();
for (const TWeakObjectPtr<AActor>& Weak : SM->GetSelectedActors())
{
@@ -602,12 +679,13 @@ void APS_Editor_Pawn::HandleDelete(const FInputActionValue& Value)
SM->ClearSelection();
Action->Execute();
if (UPS_Editor_UndoManager* UM = EditorPC->GetUndoManager())
if (UPS_Editor_UndoManager* UM = WeakPC->GetUndoManager())
{
UM->RecordAction(Action);
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Soft-deleted %d actor(s)"), Count);
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Soft-deleted %d actor(s)"), Action->Actors.Num());
}));
}
void APS_Editor_Pawn::HandleUndo(const FInputActionValue& Value)
@@ -646,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 ----
void APS_Editor_Pawn::SetCameraMode(EPS_Editor_CameraMode NewMode)
@@ -782,7 +896,9 @@ void APS_Editor_Pawn::BeginGizmoDrag(EPS_Editor_GizmoAxis Axis)
ActiveGizmoAxis = Axis;
const FVector AxisDir = Gizmo->GetAxisDirection(Axis);
// Store axis direction at drag start - used for entire drag duration
GizmoDragAxisDir = Gizmo->GetAxisDirection(Axis);
const FVector AxisDir = GizmoDragAxisDir;
const EPS_Editor_TransformMode Mode = SM->GetTransformMode();
FVector PlaneNormal;

View File

@@ -171,6 +171,15 @@ private:
UPROPERTY()
TObjectPtr<UInputAction> IA_Redo;
UPROPERTY()
TObjectPtr<UInputAction> IA_Duplicate;
UPROPERTY()
TObjectPtr<UInputAction> IA_Copy;
UPROPERTY()
TObjectPtr<UInputAction> IA_Paste;
// ---- Modifier key state ----
bool bAltHeld = false;
bool bCtrlHeld = false;
@@ -185,6 +194,7 @@ private:
FVector GizmoDragInitialIntersection = FVector::ZeroVector;
TArray<FTransform> GizmoDragInitialTransforms;
float GizmoDragAccumulator = 0.0f;
FVector GizmoDragAxisDir = FVector::ZeroVector; // Fixed axis direction for the entire drag
// ---- Input Handlers ----
void HandleMove(const FInputActionValue& Value);
@@ -203,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();

View File

@@ -1,9 +1,26 @@
#include "PS_Editor_Gizmo.h"
#include "Components/StaticMeshComponent.h"
#include "ProceduralMeshComponent.h"
#include "Engine/StaticMesh.h"
#include "Materials/MaterialInstanceDynamic.h"
#include "UObject/ConstructorHelpers.h"
#include "GameFramework/PlayerController.h"
#include "DrawDebugHelpers.h"
// ---- Shared component setup ----
static void ConfigureGizmoComponent(UPrimitiveComponent* Comp)
{
Comp->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
Comp->SetCollisionResponseToAllChannels(ECR_Ignore);
Comp->SetCollisionResponseToChannel(ECC_Visibility, ECR_Block);
Comp->SetCollisionObjectType(ECC_WorldDynamic);
Comp->SetCastShadow(false);
Comp->SetRenderCustomDepth(false);
Comp->SetDepthPriorityGroup(SDPG_Foreground);
}
// ---- Constructor ----
APS_Editor_Gizmo::APS_Editor_Gizmo()
{
@@ -12,39 +29,73 @@ APS_Editor_Gizmo::APS_Editor_Gizmo()
GizmoRoot = CreateDefaultSubobject<USceneComponent>(TEXT("GizmoRoot"));
SetRootComponent(GizmoRoot);
TranslateRoot = CreateDefaultSubobject<USceneComponent>(TEXT("TranslateRoot"));
TranslateRoot->SetupAttachment(GizmoRoot);
const FRotator RotToX = FRotationMatrix::MakeFromZ(FVector::ForwardVector).Rotator();
const FRotator RotToY = FRotationMatrix::MakeFromZ(FVector::RightVector).Rotator();
const FRotator RotToZ = FRotator::ZeroRotator;
// ---- Translate ----
TranslateRoot = CreateDefaultSubobject<USceneComponent>(TEXT("TranslateRoot"));
TranslateRoot->SetupAttachment(GizmoRoot);
Arrow_X = CreateArrow(TEXT("Arrow_X"), TranslateRoot, RotToX);
Arrow_Y = CreateArrow(TEXT("Arrow_Y"), TranslateRoot, RotToY);
Arrow_Z = CreateArrow(TEXT("Arrow_Z"), TranslateRoot, RotToZ);
Cone_X = CreateCone(TEXT("Cone_X"), TranslateRoot, RotToX);
Cone_Y = CreateCone(TEXT("Cone_Y"), TranslateRoot, RotToY);
Cone_Z = CreateCone(TEXT("Cone_Z"), TranslateRoot, RotToZ);
// ---- Rotate (arcs created in BeginPlay via procedural mesh) ----
RotateRoot = CreateDefaultSubobject<USceneComponent>(TEXT("RotateRoot"));
RotateRoot->SetupAttachment(GizmoRoot);
Arc_X = CreateDefaultSubobject<UProceduralMeshComponent>(TEXT("Arc_X"));
Arc_X->SetupAttachment(RotateRoot);
ConfigureGizmoComponent(Arc_X);
Arc_Y = CreateDefaultSubobject<UProceduralMeshComponent>(TEXT("Arc_Y"));
Arc_Y->SetupAttachment(RotateRoot);
ConfigureGizmoComponent(Arc_Y);
Arc_Z = CreateDefaultSubobject<UProceduralMeshComponent>(TEXT("Arc_Z"));
Arc_Z->SetupAttachment(RotateRoot);
ConfigureGizmoComponent(Arc_Z);
// Guide lines (grey, from center to arc endpoints)
GuideLine_A = CreateGuideLine(TEXT("GuideLine_A"), RotateRoot);
GuideLine_B = CreateGuideLine(TEXT("GuideLine_B"), RotateRoot);
GuideLine_C = CreateGuideLine(TEXT("GuideLine_C"), RotateRoot);
// ---- Scale ----
ScaleRoot = CreateDefaultSubobject<USceneComponent>(TEXT("ScaleRoot"));
ScaleRoot->SetupAttachment(GizmoRoot);
ScaleArrow_X = CreateArrow(TEXT("ScaleArrow_X"), ScaleRoot, RotToX);
ScaleArrow_Y = CreateArrow(TEXT("ScaleArrow_Y"), ScaleRoot, RotToY);
ScaleArrow_Z = CreateArrow(TEXT("ScaleArrow_Z"), ScaleRoot, RotToZ);
ScaleCube_X = CreateCube(TEXT("ScaleCube_X"), ScaleRoot, RotToX);
ScaleCube_Y = CreateCube(TEXT("ScaleCube_Y"), ScaleRoot, RotToY);
ScaleCube_Z = CreateCube(TEXT("ScaleCube_Z"), ScaleRoot, RotToZ);
// Center cube for uniform scale
ScaleCube_Center = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("ScaleCube_Center"));
ScaleCube_Center->SetupAttachment(ScaleRoot);
static ConstructorHelpers::FObjectFinder<UStaticMesh> CenterCubeMesh(TEXT("/Engine/BasicShapes/Cube"));
if (CenterCubeMesh.Succeeded()) ScaleCube_Center->SetStaticMesh(CenterCubeMesh.Object);
ScaleCube_Center->SetRelativeScale3D(FVector(0.08f, 0.08f, 0.08f));
ScaleCube_Center->SetRelativeLocation(FVector::ZeroVector);
ConfigureGizmoComponent(ScaleCube_Center);
}
// ---- BeginPlay ----
void APS_Editor_Gizmo::BeginPlay()
{
Super::BeginPlay();
// Load the gizmo base material.
// The user must create: /PS_Editor/M_PS_Editor_Gizmo
// (Unlit material with a Vector Parameter named "Color" connected to Emissive Color)
// Load gizmo material
UMaterialInterface* GizmoBaseMat = LoadObject<UMaterialInterface>(
nullptr, TEXT("/PS_Editor/M_PS_Editor_Gizmo.M_PS_Editor_Gizmo"));
if (!GizmoBaseMat)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Material /PS_Editor/M_PS_Editor_Gizmo not found! "
"Create an Unlit material with a Vector Parameter 'Color' connected to Emissive Color "
"in Plugins/PS_Editor/Content/"));
}
auto MakeColorMat = [&](const FLinearColor& Color) -> UMaterialInstanceDynamic*
{
if (!GizmoBaseMat) return nullptr;
@@ -54,54 +105,64 @@ void APS_Editor_Gizmo::BeginPlay()
};
// UE editor gizmo colors
Mat_X = MakeColorMat(FLinearColor(0.594f, 0.0197f, 0.0f)); // Dark red
Mat_Y = MakeColorMat(FLinearColor(0.1255f, 0.372f, 0.0f)); // Dark green
Mat_Z = MakeColorMat(FLinearColor(0.0392f, 0.0784f, 0.594f)); // Dark blue
Mat_X = MakeColorMat(FLinearColor(0.594f, 0.0197f, 0.0f));
Mat_Y = MakeColorMat(FLinearColor(0.1255f, 0.372f, 0.0f));
Mat_Z = MakeColorMat(FLinearColor(0.0392f, 0.0784f, 0.594f));
Mat_Highlight = MakeColorMat(FLinearColor::Yellow);
// Apply materials to shafts and cones
auto ApplyToAxis = [](UStaticMeshComponent* Shaft, UStaticMeshComponent* Tip, UMaterialInstanceDynamic* Mat)
// Apply materials to translate components
auto ApplyMat = [](UPrimitiveComponent* Comp, UMaterialInstanceDynamic* Mat)
{
if (Mat)
{
if (Shaft) Shaft->SetMaterial(0, Mat);
if (Tip) Tip->SetMaterial(0, Mat);
}
if (Comp && Mat) Comp->SetMaterial(0, Mat);
};
ApplyToAxis(Arrow_X, Cone_X, Mat_X);
ApplyToAxis(Arrow_Y, Cone_Y, Mat_Y);
ApplyToAxis(Arrow_Z, Cone_Z, Mat_Z);
ApplyMat(Arrow_X, Mat_X); ApplyMat(Cone_X, Mat_X);
ApplyMat(Arrow_Y, Mat_Y); ApplyMat(Cone_Y, Mat_Y);
ApplyMat(Arrow_Z, Mat_Z); ApplyMat(Cone_Z, Mat_Z);
// Generate arc geometry and apply materials
// Grey material for guide lines
UMaterialInstanceDynamic* Mat_Grey = MakeColorMat(FLinearColor(0.3f, 0.3f, 0.3f));
ApplyMat(GuideLine_A, Mat_Grey);
ApplyMat(GuideLine_B, Mat_Grey);
ApplyMat(GuideLine_C, Mat_Grey);
// Arc geometry will be generated dynamically in UpdateArcFacing() based on camera direction
LastCameraOctant = FVector(1, 1, 1);
UpdateArcFacing();
ApplyMat(Arc_X, Mat_X);
ApplyMat(Arc_Y, Mat_Y);
ApplyMat(Arc_Z, Mat_Z);
// Apply materials to scale components
ApplyMat(ScaleArrow_X, Mat_X); ApplyMat(ScaleCube_X, Mat_X);
ApplyMat(ScaleArrow_Y, Mat_Y); ApplyMat(ScaleCube_Y, Mat_Y);
ApplyMat(ScaleArrow_Z, Mat_Z); ApplyMat(ScaleCube_Z, Mat_Z);
// Grey center cube for uniform scale
Mat_Center = MakeColorMat(FLinearColor(0.5f, 0.5f, 0.5f));
ApplyMat(ScaleCube_Center, Mat_Center);
// Start hidden
SetGizmoVisible(false);
}
// ---- Tick ----
void APS_Editor_Gizmo::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
if (bGizmoVisible)
{
UpdateConstantScreenSize();
if (CurrentMode == EPS_Editor_TransformMode::Rotate)
{
UpdateArcFacing();
}
}
}
static void ConfigureGizmoComponent(UStaticMeshComponent* Comp)
{
Comp->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
Comp->SetCollisionResponseToAllChannels(ECR_Ignore);
Comp->SetCollisionResponseToChannel(ECC_Visibility, ECR_Block);
Comp->SetCollisionObjectType(ECC_WorldDynamic);
Comp->SetCastShadow(false);
Comp->SetRenderCustomDepth(false);
// Render on top of everything
Comp->SetDepthPriorityGroup(SDPG_Foreground);
Comp->bUseViewOwnerDepthPriorityGroup = false;
Comp->SetTranslucentSortPriority(10000);
}
// ---- Component creation helpers ----
UStaticMeshComponent* APS_Editor_Gizmo::CreateArrow(const FName& Name, USceneComponent* Parent, const FRotator& Rotation)
{
@@ -109,15 +170,10 @@ UStaticMeshComponent* APS_Editor_Gizmo::CreateArrow(const FName& Name, USceneCom
Comp->SetupAttachment(Parent);
static ConstructorHelpers::FObjectFinder<UStaticMesh> CylinderMesh(TEXT("/Engine/BasicShapes/Cylinder"));
if (CylinderMesh.Succeeded())
{
Comp->SetStaticMesh(CylinderMesh.Object);
}
if (CylinderMesh.Succeeded()) Comp->SetStaticMesh(CylinderMesh.Object);
// Shaft: radius ~3 units, length ~60 units
Comp->SetRelativeScale3D(FVector(0.06f, 0.06f, 0.6f));
Comp->SetRelativeScale3D(FVector(0.03f, 0.03f, 0.6f));
Comp->SetRelativeRotation(Rotation);
const FVector AxisDir = Rotation.RotateVector(FVector::UpVector);
Comp->SetRelativeLocation(AxisDir * 30.0f);
@@ -131,14 +187,10 @@ UStaticMeshComponent* APS_Editor_Gizmo::CreateCone(const FName& Name, USceneComp
Comp->SetupAttachment(Parent);
static ConstructorHelpers::FObjectFinder<UStaticMesh> ConeMesh(TEXT("/Engine/BasicShapes/Cone"));
if (ConeMesh.Succeeded())
{
Comp->SetStaticMesh(ConeMesh.Object);
}
if (ConeMesh.Succeeded()) Comp->SetStaticMesh(ConeMesh.Object);
Comp->SetRelativeScale3D(FVector(0.15f, 0.15f, 0.2f));
Comp->SetRelativeScale3D(FVector(0.075f, 0.075f, 0.1f));
Comp->SetRelativeRotation(Rotation);
const FVector AxisDir = Rotation.RotateVector(FVector::UpVector);
Comp->SetRelativeLocation(AxisDir * 65.0f);
@@ -146,6 +198,120 @@ UStaticMeshComponent* APS_Editor_Gizmo::CreateCone(const FName& Name, USceneComp
return Comp;
}
UStaticMeshComponent* APS_Editor_Gizmo::CreateCube(const FName& Name, USceneComponent* Parent, const FRotator& Rotation)
{
UStaticMeshComponent* Comp = CreateDefaultSubobject<UStaticMeshComponent>(Name);
Comp->SetupAttachment(Parent);
static ConstructorHelpers::FObjectFinder<UStaticMesh> CubeMesh(TEXT("/Engine/BasicShapes/Cube"));
if (CubeMesh.Succeeded()) Comp->SetStaticMesh(CubeMesh.Object);
Comp->SetRelativeScale3D(FVector(0.072f, 0.072f, 0.072f));
Comp->SetRelativeRotation(Rotation);
const FVector AxisDir = Rotation.RotateVector(FVector::UpVector);
Comp->SetRelativeLocation(AxisDir * 63.0f);
ConfigureGizmoComponent(Comp);
return Comp;
}
UStaticMeshComponent* APS_Editor_Gizmo::CreateGuideLine(const FName& Name, USceneComponent* Parent)
{
UStaticMeshComponent* Comp = CreateDefaultSubobject<UStaticMeshComponent>(Name);
Comp->SetupAttachment(Parent);
static ConstructorHelpers::FObjectFinder<UStaticMesh> CylinderMesh(TEXT("/Engine/BasicShapes/Cylinder"));
if (CylinderMesh.Succeeded()) Comp->SetStaticMesh(CylinderMesh.Object);
// Very thin, length matches arc radius. Will be repositioned in UpdateArcFacing.
Comp->SetRelativeScale3D(FVector(0.01f, 0.01f, 0.55f));
ConfigureGizmoComponent(Comp);
return Comp;
}
void APS_Editor_Gizmo::CreateArcRing(UProceduralMeshComponent* Mesh, const FVector& AxisNormal, const FVector& ArcStart, const FVector& ArcEnd, float Radius, float Width, int32 Segments)
{
// Generate a 3D solid quarter-arc (90 degrees) with rectangular cross-section.
// ArcStart/ArcEnd define the directions the arc sweeps between (must be perpendicular to AxisNormal).
const float InnerRadius = Radius - Width * 0.5f;
const float OuterRadius = Radius + Width * 0.5f;
const float HalfDepth = Width * 0.3f;
// Extend slightly past 90° to close gaps between connecting arcs
const float StartAngle = FMath::DegreesToRadians(-2.0f);
const float EndAngle = FMath::DegreesToRadians(92.0f);
const FVector AxisA = ArcStart.GetSafeNormal();
const FVector AxisB = ArcEnd.GetSafeNormal();
TArray<FVector> Vertices;
TArray<int32> Triangles;
TArray<FVector> Normals;
TArray<FVector2D> UVs;
TArray<FColor> Colors;
// For each angle step, create 4 vertices (rectangular cross-section):
// 0: inner-bottom, 1: outer-bottom, 2: outer-top, 3: inner-top
for (int32 i = 0; i <= Segments; ++i)
{
const float Alpha = (float)i / (float)Segments;
const float Angle = FMath::Lerp(StartAngle, EndAngle, Alpha);
const FVector Dir = AxisA * FMath::Cos(Angle) + AxisB * FMath::Sin(Angle);
const FVector Up = AxisNormal;
Vertices.Add(Dir * InnerRadius - Up * HalfDepth); // 0: inner-bottom
Vertices.Add(Dir * OuterRadius - Up * HalfDepth); // 1: outer-bottom
Vertices.Add(Dir * OuterRadius + Up * HalfDepth); // 2: outer-top
Vertices.Add(Dir * InnerRadius + Up * HalfDepth); // 3: inner-top
// Normals (approximate - will be per-face below)
Normals.Add(-Up); // bottom faces down
Normals.Add(-Up);
Normals.Add(Up); // top faces up
Normals.Add(Up);
UVs.Add(FVector2D(Alpha, 0.0f));
UVs.Add(FVector2D(Alpha, 0.33f));
UVs.Add(FVector2D(Alpha, 0.66f));
UVs.Add(FVector2D(Alpha, 1.0f));
Colors.Add(FColor::White);
Colors.Add(FColor::White);
Colors.Add(FColor::White);
Colors.Add(FColor::White);
}
// Generate quads between adjacent angle steps
for (int32 i = 0; i < Segments; ++i)
{
const int32 A = i * 4; // Current step
const int32 B = (i + 1) * 4; // Next step
// Top face (vertices 3,2 of current and next)
Triangles.Append({ A+3, B+3, A+2, A+2, B+3, B+2 });
// Bottom face (vertices 0,1 of current and next)
Triangles.Append({ A+0, A+1, B+0, B+0, A+1, B+1 });
// Outer face (vertices 1,2 of current and next)
Triangles.Append({ A+1, A+2, B+1, B+1, A+2, B+2 });
// Inner face (vertices 0,3 of current and next)
Triangles.Append({ A+0, B+0, A+3, A+3, B+0, B+3 });
}
// Start cap (first angle step, quad 0-1-2-3)
Triangles.Append({ 0, 1, 2, 0, 2, 3 });
// End cap (last angle step)
const int32 Last = Segments * 4;
Triangles.Append({ Last+0, Last+3, Last+2, Last+0, Last+2, Last+1 });
Mesh->CreateMeshSection(0, Vertices, Triangles, Normals, UVs, Colors, TArray<FProcMeshTangent>(), true);
}
// ---- Gizmo control ----
void APS_Editor_Gizmo::SetGizmoActive(bool bActive, FVector WorldLocation)
{
if (bActive)
@@ -153,7 +319,6 @@ void APS_Editor_Gizmo::SetGizmoActive(bool bActive, FVector WorldLocation)
SetActorLocation(WorldLocation);
SetTransformMode(CurrentMode);
}
SetGizmoVisible(bActive);
SetActorEnableCollision(bActive);
}
@@ -161,34 +326,37 @@ void APS_Editor_Gizmo::SetGizmoActive(bool bActive, FVector WorldLocation)
void APS_Editor_Gizmo::SetGizmoVisible(bool bVisible)
{
bGizmoVisible = bVisible;
// Control visibility per-component instead of SetActorHiddenInGame
// so we don't override individual component visibility settings.
TranslateRoot->SetVisibility(bVisible && CurrentMode == EPS_Editor_TransformMode::Translate, true);
RotateRoot->SetVisibility(bVisible && CurrentMode == EPS_Editor_TransformMode::Rotate, true);
ScaleRoot->SetVisibility(bVisible && CurrentMode == EPS_Editor_TransformMode::Scale, true);
}
void APS_Editor_Gizmo::SetTransformMode(EPS_Editor_TransformMode NewMode)
{
CurrentMode = NewMode;
TranslateRoot->SetVisibility(bGizmoVisible && NewMode == EPS_Editor_TransformMode::Translate, true);
RotateRoot->SetVisibility(bGizmoVisible && NewMode == EPS_Editor_TransformMode::Rotate, true);
ScaleRoot->SetVisibility(bGizmoVisible && NewMode == EPS_Editor_TransformMode::Scale, true);
}
// ---- Hit detection ----
EPS_Editor_GizmoAxis APS_Editor_Gizmo::GetAxisUnderCursor(const FVector& RayOrigin, const FVector& RayDirection) const
{
if (!bGizmoVisible)
{
return EPS_Editor_GizmoAxis::None;
}
if (!bGizmoVisible) return EPS_Editor_GizmoAxis::None;
const float SweepRadius = 8.0f * GetActorScale3D().X;
float BestDistance = TNumericLimits<float>::Max();
EPS_Editor_GizmoAxis BestAxis = EPS_Editor_GizmoAxis::None;
// Use a sphere sweep with a generous radius for easier clicking
const float SweepRadius = 8.0f * GetActorScale3D().X;
auto TestComponent = [&](UStaticMeshComponent* Comp, EPS_Editor_GizmoAxis Axis)
auto TestAxis = [&](EPS_Editor_GizmoAxis Axis)
{
if (!Comp) return;
TArray<UPrimitiveComponent*> Components;
GetComponentsForAxis(Axis, Components);
for (UPrimitiveComponent* Comp : Components)
{
if (!Comp || !Comp->IsVisible()) continue;
FHitResult Hit;
const FVector RayEnd = RayOrigin + RayDirection * 100000.0f;
@@ -202,37 +370,44 @@ EPS_Editor_GizmoAxis APS_Editor_Gizmo::GetAxisUnderCursor(const FVector& RayOrig
BestAxis = Axis;
}
}
}
};
TestComponent(Arrow_X, EPS_Editor_GizmoAxis::X);
TestComponent(Cone_X, EPS_Editor_GizmoAxis::X);
TestComponent(Arrow_Y, EPS_Editor_GizmoAxis::Y);
TestComponent(Cone_Y, EPS_Editor_GizmoAxis::Y);
TestComponent(Arrow_Z, EPS_Editor_GizmoAxis::Z);
TestComponent(Cone_Z, EPS_Editor_GizmoAxis::Z);
TestAxis(EPS_Editor_GizmoAxis::X);
TestAxis(EPS_Editor_GizmoAxis::Y);
TestAxis(EPS_Editor_GizmoAxis::Z);
TestAxis(EPS_Editor_GizmoAxis::XYZ); // Center cube for uniform scale
return BestAxis;
}
void APS_Editor_Gizmo::GetComponentsForAxis(EPS_Editor_GizmoAxis Axis, TArray<UPrimitiveComponent*>& OutComponents) const
{
switch (Axis)
{
case EPS_Editor_GizmoAxis::X:
OutComponents.Append({ Arrow_X, Cone_X, Arc_X, ScaleArrow_X, ScaleCube_X });
break;
case EPS_Editor_GizmoAxis::Y:
OutComponents.Append({ Arrow_Y, Cone_Y, Arc_Y, ScaleArrow_Y, ScaleCube_Y });
break;
case EPS_Editor_GizmoAxis::Z:
OutComponents.Append({ Arrow_Z, Cone_Z, Arc_Z, ScaleArrow_Z, ScaleCube_Z });
break;
case EPS_Editor_GizmoAxis::XYZ:
OutComponents.Add(ScaleCube_Center);
break;
default:
break;
}
}
// ---- Highlighting ----
void APS_Editor_Gizmo::SetHighlightedAxis(EPS_Editor_GizmoAxis Axis)
{
if (HighlightedAxis == Axis) return;
auto SetAxisMat = [this](EPS_Editor_GizmoAxis A, UMaterialInstanceDynamic* Mat)
{
UStaticMeshComponent* Shaft = GetComponentForAxis(A);
UStaticMeshComponent* Tip = nullptr;
switch (A)
{
case EPS_Editor_GizmoAxis::X: Tip = Cone_X; break;
case EPS_Editor_GizmoAxis::Y: Tip = Cone_Y; break;
case EPS_Editor_GizmoAxis::Z: Tip = Cone_Z; break;
default: break;
}
SetComponentMaterial(Shaft, Mat);
SetComponentMaterial(Tip, Mat);
};
// Restore previous
if (HighlightedAxis != EPS_Editor_GizmoAxis::None)
{
@@ -242,29 +417,144 @@ void APS_Editor_Gizmo::SetHighlightedAxis(EPS_Editor_GizmoAxis Axis)
case EPS_Editor_GizmoAxis::X: OrigMat = Mat_X; break;
case EPS_Editor_GizmoAxis::Y: OrigMat = Mat_Y; break;
case EPS_Editor_GizmoAxis::Z: OrigMat = Mat_Z; break;
case EPS_Editor_GizmoAxis::XYZ: OrigMat = Mat_Center; break;
default: break;
}
SetAxisMat(HighlightedAxis, OrigMat);
SetAxisMaterial(HighlightedAxis, OrigMat);
}
HighlightedAxis = Axis;
// Highlight new
if (Axis != EPS_Editor_GizmoAxis::None)
{
SetAxisMat(Axis, Mat_Highlight);
SetAxisMaterial(Axis, Mat_Highlight);
}
}
void APS_Editor_Gizmo::SetAxisMaterial(EPS_Editor_GizmoAxis Axis, UMaterialInstanceDynamic* Mat)
{
if (!Mat) return;
TArray<UPrimitiveComponent*> Components;
GetComponentsForAxis(Axis, Components);
for (UPrimitiveComponent* Comp : Components)
{
if (Comp) Comp->SetMaterial(0, Mat);
}
}
// ---- Helpers ----
void APS_Editor_Gizmo::UpdateArcFacing()
{
APlayerController* PC = GetWorld()->GetFirstPlayerController();
if (!PC) return;
FVector CameraLocation;
FRotator CameraRotation;
PC->GetPlayerViewPoint(CameraLocation, CameraRotation);
// Compute local axes (world axes rotated by LocalOrientation)
const FVector LocalX = LocalOrientation.RotateVector(FVector::ForwardVector);
const FVector LocalY = LocalOrientation.RotateVector(FVector::RightVector);
const FVector LocalZ = LocalOrientation.RotateVector(FVector::UpVector);
// Direction from gizmo to camera in world space
const FVector CamDir = (CameraLocation - GetActorLocation()).GetSafeNormal();
// Project camera onto local axes to determine octant
const FVector NewOctant(
FVector::DotProduct(CamDir, LocalX) >= 0 ? 1.0f : -1.0f,
FVector::DotProduct(CamDir, LocalY) >= 0 ? 1.0f : -1.0f,
FVector::DotProduct(CamDir, LocalZ) >= 0 ? 1.0f : -1.0f
);
if (NewOctant.Equals(LastCameraOctant))
{
return;
}
LastCameraOctant = NewOctant;
// Arc directions in world space using local axes
const FVector ArcX_Start = LocalY * NewOctant.Y;
const FVector ArcX_End = LocalZ * NewOctant.Z;
const FVector ArcY_Start = LocalX * NewOctant.X;
const FVector ArcY_End = LocalZ * NewOctant.Z;
const FVector ArcZ_Start = LocalX * NewOctant.X;
const FVector ArcZ_End = LocalY * NewOctant.Y;
CreateArcRing(Arc_X, LocalX, ArcX_Start, ArcX_End, ArcRadius, ArcWidth, ArcSegments);
CreateArcRing(Arc_Y, LocalY, ArcY_Start, ArcY_End, ArcRadius, ArcWidth, ArcSegments);
CreateArcRing(Arc_Z, LocalZ, ArcZ_Start, ArcZ_End, ArcRadius, ArcWidth, ArcSegments);
// Re-apply materials
if (Mat_X) Arc_X->SetMaterial(0, Mat_X);
if (Mat_Y) Arc_Y->SetMaterial(0, Mat_Y);
if (Mat_Z) Arc_Z->SetMaterial(0, Mat_Z);
if (HighlightedAxis != EPS_Editor_GizmoAxis::None)
{
SetAxisMaterial(HighlightedAxis, Mat_Highlight);
}
// Guide lines toward arc connection points (world space)
auto PositionGuideLine = [](UStaticMeshComponent* Line, const FVector& Dir)
{
if (!Line) return;
Line->SetRelativeRotation(FRotationMatrix::MakeFromZ(Dir).Rotator());
Line->SetRelativeLocation(Dir * 27.5f);
};
PositionGuideLine(GuideLine_A, LocalX * NewOctant.X);
PositionGuideLine(GuideLine_B, LocalY * NewOctant.Y);
PositionGuideLine(GuideLine_C, LocalZ * NewOctant.Z);
// Re-apply materials (mesh sections are recreated)
if (Mat_X) Arc_X->SetMaterial(0, Mat_X);
if (Mat_Y) Arc_Y->SetMaterial(0, Mat_Y);
if (Mat_Z) Arc_Z->SetMaterial(0, Mat_Z);
// Restore highlight if needed
if (HighlightedAxis != EPS_Editor_GizmoAxis::None)
{
SetAxisMaterial(HighlightedAxis, Mat_Highlight);
}
}
FVector APS_Editor_Gizmo::GetAxisDirection(EPS_Editor_GizmoAxis Axis) const
{
FVector LocalDir;
switch (Axis)
{
case EPS_Editor_GizmoAxis::X: return FVector::ForwardVector;
case EPS_Editor_GizmoAxis::Y: return FVector::RightVector;
case EPS_Editor_GizmoAxis::Z: return FVector::UpVector;
case EPS_Editor_GizmoAxis::X: LocalDir = FVector::ForwardVector; break;
case EPS_Editor_GizmoAxis::Y: LocalDir = FVector::RightVector; break;
case EPS_Editor_GizmoAxis::Z: LocalDir = FVector::UpVector; break;
default: return FVector::ZeroVector;
}
// Return axis in local space if orientation is set
if (!LocalOrientation.IsNearlyZero())
{
return LocalOrientation.RotateVector(LocalDir);
}
return LocalDir;
}
void APS_Editor_Gizmo::SetLocalOrientation(FRotator ActorRotation)
{
LocalOrientation = ActorRotation;
if (TranslateRoot) TranslateRoot->SetRelativeRotation(ActorRotation);
// RotateRoot stays at identity - arcs are generated in world space using LocalOrientation
if (RotateRoot) RotateRoot->SetRelativeRotation(FRotator::ZeroRotator);
if (ScaleRoot) ScaleRoot->SetRelativeRotation(ActorRotation);
// Force arc regeneration
LastCameraOctant = FVector::ZeroVector;
}
void APS_Editor_Gizmo::UpdateConstantScreenSize()
@@ -282,22 +572,3 @@ void APS_Editor_Gizmo::UpdateConstantScreenSize()
const float Scale = Distance * DesiredScreenSize * 0.01f;
SetActorScale3D(FVector(Scale));
}
UStaticMeshComponent* APS_Editor_Gizmo::GetComponentForAxis(EPS_Editor_GizmoAxis Axis) const
{
switch (Axis)
{
case EPS_Editor_GizmoAxis::X: return Arrow_X;
case EPS_Editor_GizmoAxis::Y: return Arrow_Y;
case EPS_Editor_GizmoAxis::Z: return Arrow_Z;
default: return nullptr;
}
}
void APS_Editor_Gizmo::SetComponentMaterial(UStaticMeshComponent* Comp, UMaterialInstanceDynamic* Mat)
{
if (Comp && Mat)
{
Comp->SetMaterial(0, Mat);
}
}

View File

@@ -6,14 +6,14 @@
#include "PS_Editor_Gizmo.generated.h"
class UStaticMeshComponent;
class UProceduralMeshComponent;
class UMaterialInstanceDynamic;
/**
* Custom runtime transform gizmo for PS_Editor.
* Uses engine BasicShapes (Cone, Cylinder, Cube) to build translate/rotate/scale handles.
* Maintains constant screen size by adjusting scale based on camera distance.
*
* Phase 1: Translate mode only (3 axis arrows + 3 plane handles).
* - Translate: arrows (cylinders + cones)
* - Rotate: quarter-arc rings (procedural mesh)
* - Scale: arrows (cylinders + cubes)
*/
UCLASS()
class PS_EDITOR_API APS_Editor_Gizmo : public AActor
@@ -25,21 +25,17 @@ public:
virtual void Tick(float DeltaTime) override;
/** Show/hide the gizmo and set its position. */
void SetGizmoActive(bool bActive, FVector WorldLocation = FVector::ZeroVector);
/** Switch between Translate/Rotate/Scale modes. */
void SetTransformMode(EPS_Editor_TransformMode NewMode);
/** Test which gizmo axis is under the given world ray. */
EPS_Editor_GizmoAxis GetAxisUnderCursor(const FVector& RayOrigin, const FVector& RayDirection) const;
/** Highlight the hovered axis (change material color). */
void SetHighlightedAxis(EPS_Editor_GizmoAxis Axis);
/** Get the world-space direction for a given axis. */
FVector GetAxisDirection(EPS_Editor_GizmoAxis Axis) const;
/** Set the orientation for local space mode (matches selected actor's rotation). */
void SetLocalOrientation(FRotator ActorRotation);
/** Stored local orientation for axis direction computation. */
FRotator LocalOrientation = FRotator::ZeroRotator;
EPS_Editor_TransformMode GetTransformMode() const { return CurrentMode; }
protected:
@@ -50,53 +46,101 @@ private:
UPROPERTY(VisibleAnywhere)
TObjectPtr<USceneComponent> GizmoRoot;
// ---- Translate handles ----
// ---- Translate components ----
UPROPERTY(VisibleAnywhere)
TObjectPtr<USceneComponent> TranslateRoot;
UPROPERTY(VisibleAnywhere)
TObjectPtr<UStaticMeshComponent> Arrow_X;
UPROPERTY(VisibleAnywhere)
TObjectPtr<UStaticMeshComponent> Arrow_Y;
UPROPERTY(VisibleAnywhere)
TObjectPtr<UStaticMeshComponent> Arrow_Z;
UPROPERTY(VisibleAnywhere)
TObjectPtr<UStaticMeshComponent> Cone_X;
UPROPERTY(VisibleAnywhere)
TObjectPtr<UStaticMeshComponent> Cone_Y;
UPROPERTY(VisibleAnywhere)
TObjectPtr<UStaticMeshComponent> Cone_Z;
// ---- Rotate components (quarter-arc rings) ----
UPROPERTY(VisibleAnywhere)
TObjectPtr<USceneComponent> RotateRoot;
UPROPERTY(VisibleAnywhere)
TObjectPtr<UProceduralMeshComponent> Arc_X;
UPROPERTY(VisibleAnywhere)
TObjectPtr<UProceduralMeshComponent> Arc_Y;
UPROPERTY(VisibleAnywhere)
TObjectPtr<UProceduralMeshComponent> Arc_Z;
// ---- Rotate guide lines (grey lines from center to arc endpoints) ----
UPROPERTY(VisibleAnywhere)
TObjectPtr<UStaticMeshComponent> GuideLine_A;
UPROPERTY(VisibleAnywhere)
TObjectPtr<UStaticMeshComponent> GuideLine_B;
UPROPERTY(VisibleAnywhere)
TObjectPtr<UStaticMeshComponent> GuideLine_C;
// ---- Scale components ----
UPROPERTY(VisibleAnywhere)
TObjectPtr<USceneComponent> ScaleRoot;
UPROPERTY(VisibleAnywhere)
TObjectPtr<UStaticMeshComponent> ScaleArrow_X;
UPROPERTY(VisibleAnywhere)
TObjectPtr<UStaticMeshComponent> ScaleArrow_Y;
UPROPERTY(VisibleAnywhere)
TObjectPtr<UStaticMeshComponent> ScaleArrow_Z;
UPROPERTY(VisibleAnywhere)
TObjectPtr<UStaticMeshComponent> ScaleCube_X;
UPROPERTY(VisibleAnywhere)
TObjectPtr<UStaticMeshComponent> ScaleCube_Y;
UPROPERTY(VisibleAnywhere)
TObjectPtr<UStaticMeshComponent> ScaleCube_Z;
/** Center cube for uniform scaling. */
UPROPERTY(VisibleAnywhere)
TObjectPtr<UStaticMeshComponent> ScaleCube_Center;
// ---- Materials ----
UPROPERTY()
TObjectPtr<UMaterialInstanceDynamic> Mat_X;
UPROPERTY()
TObjectPtr<UMaterialInstanceDynamic> Mat_Y;
UPROPERTY()
TObjectPtr<UMaterialInstanceDynamic> Mat_Z;
UPROPERTY()
TObjectPtr<UMaterialInstanceDynamic> Mat_Highlight;
UPROPERTY()
TObjectPtr<UMaterialInstanceDynamic> Mat_Center;
// ---- State ----
EPS_Editor_TransformMode CurrentMode = EPS_Editor_TransformMode::Translate;
EPS_Editor_GizmoAxis HighlightedAxis = EPS_Editor_GizmoAxis::None;
bool bGizmoVisible = false;
float DesiredScreenSize = 0.25f;
// ---- Helpers ----
UStaticMeshComponent* CreateArrow(const FName& Name, USceneComponent* Parent, const FRotator& Rotation);
UStaticMeshComponent* CreateCone(const FName& Name, USceneComponent* Parent, const FRotator& Rotation);
UStaticMeshComponent* CreateCube(const FName& Name, USceneComponent* Parent, const FRotator& Rotation);
void CreateArcRing(UProceduralMeshComponent* Mesh, const FVector& AxisNormal, const FVector& ArcStart, const FVector& ArcEnd, float Radius, float Width, int32 Segments);
UStaticMeshComponent* CreateGuideLine(const FName& Name, USceneComponent* Parent);
void UpdateConstantScreenSize();
void SetGizmoVisible(bool bVisible);
UStaticMeshComponent* GetComponentForAxis(EPS_Editor_GizmoAxis Axis) const;
void SetComponentMaterial(UStaticMeshComponent* Comp, UMaterialInstanceDynamic* Mat);
void UpdateArcFacing();
// Arc parameters (stored for regeneration)
float ArcRadius = 55.0f;
float ArcWidth = 5.0f;
int32 ArcSegments = 24;
FVector LastCameraOctant = FVector::ZeroVector;
/** Get all components for a given axis (across all modes) for hit testing. */
void GetComponentsForAxis(EPS_Editor_GizmoAxis Axis, TArray<UPrimitiveComponent*>& OutComponents) const;
void SetAxisMaterial(EPS_Editor_GizmoAxis Axis, UMaterialInstanceDynamic* Mat);
};

View File

@@ -27,7 +27,8 @@ public class PS_Editor : ModuleRules
"EnhancedInput",
"UMG",
"Json",
"JsonUtilities"
"JsonUtilities",
"ProceduralMeshComponent"
});
PrivateDependencyModuleNames.AddRange(new string[]

View File

@@ -1,10 +1,232 @@
#include "PS_Editor.h"
#if WITH_EDITOR
#include "Materials/Material.h"
#include "MaterialDomain.h"
#include "Materials/MaterialExpressionSceneTexture.h"
#include "Materials/MaterialExpressionAdd.h"
#include "Materials/MaterialExpressionSubtract.h"
#include "Materials/MaterialExpressionMultiply.h"
#include "Materials/MaterialExpressionAbs.h"
#include "Materials/MaterialExpressionSaturate.h"
#include "Materials/MaterialExpressionConstant.h"
#include "Materials/MaterialExpressionConstant4Vector.h"
#include "Materials/MaterialExpressionTextureCoordinate.h"
#include "Materials/MaterialExpressionLinearInterpolate.h"
#include "Materials/MaterialExpressionComponentMask.h"
#include "Materials/MaterialExpressionAppendVector.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "UObject/SavePackage.h"
#include "Misc/PackageName.h"
#endif
#define LOCTEXT_NAMESPACE "FPS_EditorModule"
#if WITH_EDITOR
static void CreateOutlineMaterialAsset()
{
const FString MaterialPath = TEXT("/PS_Editor/M_PS_Editor_SelectionOutline");
const FString AssetName = TEXT("M_PS_Editor_SelectionOutline");
if (LoadObject<UMaterial>(nullptr, *(MaterialPath + TEXT(".") + AssetName)))
{
return;
}
UPackage* Package = CreatePackage(*MaterialPath);
UMaterial* Mat = NewObject<UMaterial>(Package, *AssetName, RF_Public | RF_Standalone);
Mat->MaterialDomain = EMaterialDomain::MD_PostProcess;
Mat->BlendableLocation = BL_SceneColorAfterDOF; // Before TAA, matches stencil buffer resolution
auto AddExpr = [&](UMaterialExpression* Expr) { Mat->GetExpressionCollection().AddExpression(Expr); };
const float ThicknessPixels = 1.0f;
// ---- TexCoord ----
UMaterialExpressionTextureCoordinate* TexCoord = NewObject<UMaterialExpressionTextureCoordinate>(Mat);
AddExpr(TexCoord);
TexCoord->MaterialExpressionEditorX = -1600;
TexCoord->MaterialExpressionEditorY = 400;
// ---- Center stencil sample ----
UMaterialExpressionSceneTexture* StencilCenter = NewObject<UMaterialExpressionSceneTexture>(Mat);
StencilCenter->SceneTextureId = PPI_CustomStencil;
AddExpr(StencilCenter);
StencilCenter->Coordinates.Connect(0, TexCoord);
StencilCenter->MaterialExpressionEditorX = -1200;
StencilCenter->MaterialExpressionEditorY = 0;
// ---- Get InvSize from SceneTexture (output index 2) = 1/resolution ----
// This gives dynamic pixel size regardless of resolution/aspect ratio
UMaterialExpressionMultiply* PixelOffset = NewObject<UMaterialExpressionMultiply>(Mat);
AddExpr(PixelOffset);
PixelOffset->A.Connect(2, StencilCenter); // Output 2 = InvSize (float2)
PixelOffset->ConstB = ThicknessPixels;
PixelOffset->MaterialExpressionEditorX = -1000;
PixelOffset->MaterialExpressionEditorY = 200;
// Split into X and Y components
UMaterialExpressionComponentMask* OffsetX = NewObject<UMaterialExpressionComponentMask>(Mat);
AddExpr(OffsetX);
OffsetX->Input.Connect(0, PixelOffset);
OffsetX->R = true; OffsetX->G = false; OffsetX->B = false; OffsetX->A = false;
OffsetX->MaterialExpressionEditorX = -800;
OffsetX->MaterialExpressionEditorY = 100;
UMaterialExpressionComponentMask* OffsetY = NewObject<UMaterialExpressionComponentMask>(Mat);
AddExpr(OffsetY);
OffsetY->Input.Connect(0, PixelOffset);
OffsetY->R = false; OffsetY->G = true; OffsetY->B = false; OffsetY->A = false;
OffsetY->MaterialExpressionEditorX = -800;
OffsetY->MaterialExpressionEditorY = 300;
UMaterialExpressionConstant* ZeroConst = NewObject<UMaterialExpressionConstant>(Mat);
ZeroConst->R = 0.0f;
AddExpr(ZeroConst);
ZeroConst->MaterialExpressionEditorX = -800;
ZeroConst->MaterialExpressionEditorY = 500;
// Negative offsets
UMaterialExpressionMultiply* NegX = NewObject<UMaterialExpressionMultiply>(Mat);
AddExpr(NegX);
NegX->A.Connect(0, OffsetX);
NegX->ConstB = -1.0f;
NegX->MaterialExpressionEditorX = -800;
NegX->MaterialExpressionEditorY = 0;
UMaterialExpressionMultiply* NegY = NewObject<UMaterialExpressionMultiply>(Mat);
AddExpr(NegY);
NegY->A.Connect(0, OffsetY);
NegY->ConstB = -1.0f;
NegY->MaterialExpressionEditorX = -800;
NegY->MaterialExpressionEditorY = 400;
// ---- 8 direction samples ----
struct FDirDef { UMaterialExpression* XE; UMaterialExpression* YE; };
FDirDef Dirs[8] = {
{ OffsetX, ZeroConst }, // right
{ NegX, ZeroConst }, // left
{ ZeroConst, OffsetY }, // down
{ ZeroConst, NegY }, // up
{ OffsetX, OffsetY }, // bottom-right
{ NegX, OffsetY }, // bottom-left
{ OffsetX, NegY }, // top-right
{ NegX, NegY }, // top-left
};
UMaterialExpression* EdgeAccum = nullptr;
for (int32 i = 0; i < 8; ++i)
{
int32 PosY = 100 + i * 160;
// Append(X, Y) -> float2 offset
UMaterialExpressionAppendVector* OffsetVec = NewObject<UMaterialExpressionAppendVector>(Mat);
AddExpr(OffsetVec);
OffsetVec->A.Connect(0, Dirs[i].XE);
OffsetVec->B.Connect(0, Dirs[i].YE);
OffsetVec->MaterialExpressionEditorX = -600;
OffsetVec->MaterialExpressionEditorY = PosY;
// TexCoord + offset
UMaterialExpressionAdd* UVAdd = NewObject<UMaterialExpressionAdd>(Mat);
AddExpr(UVAdd);
UVAdd->A.Connect(0, TexCoord);
UVAdd->B.Connect(0, OffsetVec);
UVAdd->MaterialExpressionEditorX = -400;
UVAdd->MaterialExpressionEditorY = PosY;
// Sample stencil at offset
UMaterialExpressionSceneTexture* StencilN = NewObject<UMaterialExpressionSceneTexture>(Mat);
StencilN->SceneTextureId = PPI_CustomStencil;
AddExpr(StencilN);
StencilN->Coordinates.Connect(0, UVAdd);
StencilN->MaterialExpressionEditorX = -200;
StencilN->MaterialExpressionEditorY = PosY;
// abs(center - neighbor)
UMaterialExpressionSubtract* Sub = NewObject<UMaterialExpressionSubtract>(Mat);
AddExpr(Sub);
Sub->A.Connect(0, StencilCenter);
Sub->B.Connect(0, StencilN);
Sub->MaterialExpressionEditorX = 0;
Sub->MaterialExpressionEditorY = PosY;
UMaterialExpressionAbs* AbsN = NewObject<UMaterialExpressionAbs>(Mat);
AddExpr(AbsN);
AbsN->Input.Connect(0, Sub);
AbsN->MaterialExpressionEditorX = 200;
AbsN->MaterialExpressionEditorY = PosY;
if (!EdgeAccum)
{
EdgeAccum = AbsN;
}
else
{
UMaterialExpressionAdd* AccumAdd = NewObject<UMaterialExpressionAdd>(Mat);
AddExpr(AccumAdd);
AccumAdd->A.Connect(0, EdgeAccum);
AccumAdd->B.Connect(0, AbsN);
AccumAdd->MaterialExpressionEditorX = 400;
AccumAdd->MaterialExpressionEditorY = PosY;
EdgeAccum = AccumAdd;
}
}
// ---- Saturate edge mask ----
UMaterialExpressionSaturate* Saturate = NewObject<UMaterialExpressionSaturate>(Mat);
AddExpr(Saturate);
Saturate->Input.Connect(0, EdgeAccum);
Saturate->MaterialExpressionEditorX = 600;
Saturate->MaterialExpressionEditorY = 500;
// ---- Outline color (orange, float4) ----
UMaterialExpressionConstant4Vector* OutlineColor = NewObject<UMaterialExpressionConstant4Vector>(Mat);
OutlineColor->Constant = FColor(0xAF, 0x15, 0x00, 0xFF).ReinterpretAsLinear();
AddExpr(OutlineColor);
OutlineColor->MaterialExpressionEditorX = 600;
OutlineColor->MaterialExpressionEditorY = 700;
// ---- Scene input ----
UMaterialExpressionSceneTexture* SceneInput = NewObject<UMaterialExpressionSceneTexture>(Mat);
SceneInput->SceneTextureId = PPI_PostProcessInput0;
AddExpr(SceneInput);
SceneInput->MaterialExpressionEditorX = 600;
SceneInput->MaterialExpressionEditorY = 300;
// ---- Lerp(Scene, OutlineColor, EdgeMask) ----
UMaterialExpressionLinearInterpolate* Lerp = NewObject<UMaterialExpressionLinearInterpolate>(Mat);
AddExpr(Lerp);
Lerp->A.Connect(0, SceneInput);
Lerp->B.Connect(0, OutlineColor);
Lerp->Alpha.Connect(0, Saturate);
Lerp->MaterialExpressionEditorX = 800;
Lerp->MaterialExpressionEditorY = 500;
// ---- Output ----
Mat->GetEditorOnlyData()->EmissiveColor.Connect(0, Lerp);
Mat->PreEditChange(nullptr);
Mat->PostEditChange();
FString PackageFilename = FPackageName::LongPackageNameToFilename(MaterialPath, FPackageName::GetAssetPackageExtension());
FSavePackageArgs SaveArgs;
SaveArgs.TopLevelFlags = RF_Public | RF_Standalone;
UPackage::SavePackage(Package, Mat, *PackageFilename, SaveArgs);
FAssetRegistryModule::AssetCreated(Mat);
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Selection outline material auto-generated (8-sample, dynamic resolution)"));
}
#endif
void FPS_EditorModule::StartupModule()
{
UE_LOG(LogTemp, Log, TEXT("PS_Editor module started."));
#if WITH_EDITOR
FCoreDelegates::OnPostEngineInit.AddStatic(&CreateOutlineMaterialAsset);
#endif
}
void FPS_EditorModule::ShutdownModule()

View File

@@ -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"
@@ -194,15 +195,93 @@ FVector UPS_Editor_SelectionManager::GetSelectionPivot() const
void UPS_Editor_SelectionManager::SetTransformMode(EPS_Editor_TransformMode NewMode)
{
CurrentTransformMode = NewMode;
if (Gizmo)
{
Gizmo->SetTransformMode(NewMode);
// Update gizmo orientation: Scale always local, others depend on space setting
if (IsAnythingSelected())
{
if (AActor* Actor = SelectedActors[0].Get())
{
bool bUseLocal = (NewMode == EPS_Editor_TransformMode::Scale) || (CurrentSpace == EPS_Editor_Space::Local);
Gizmo->SetLocalOrientation(bUseLocal ? Actor->GetActorRotation() : FRotator::ZeroRotator);
}
}
}
OnSelectionChanged.Broadcast();
}
void UPS_Editor_SelectionManager::SetTransformSpace(EPS_Editor_Space NewSpace)
{
CurrentSpace = NewSpace;
// Update gizmo orientation based on space
if (Gizmo && IsAnythingSelected())
{
if (NewSpace == EPS_Editor_Space::Local)
{
if (AActor* Actor = SelectedActors[0].Get())
{
Gizmo->SetLocalOrientation(Actor->GetActorRotation());
}
}
else
{
Gizmo->SetLocalOrientation(FRotator::ZeroRotator);
}
}
OnSelectionChanged.Broadcast();
}
void UPS_Editor_SelectionManager::RefreshGizmoOrientation()
{
if (!Gizmo || !IsAnythingSelected()) return;
if (AActor* Actor = SelectedActors[0].Get())
{
bool bUseLocal = (CurrentTransformMode == EPS_Editor_TransformMode::Scale) || (CurrentSpace == EPS_Editor_Space::Local);
Gizmo->SetLocalOrientation(bUseLocal ? Actor->GetActorRotation() : FRotator::ZeroRotator);
Gizmo->SetActorLocation(GetSelectionPivot());
}
}
void UPS_Editor_SelectionManager::ToggleTransformSpace()
{
SetTransformSpace(CurrentSpace == EPS_Editor_Space::World ? EPS_Editor_Space::Local : EPS_Editor_Space::World);
}
FVector UPS_Editor_SelectionManager::SnapPosition(const FVector& Position) const
{
if (!bSnapTranslation || TranslationSnapSize <= 0.0f) return Position;
return FVector(
FMath::RoundToFloat(Position.X / TranslationSnapSize) * TranslationSnapSize,
FMath::RoundToFloat(Position.Y / TranslationSnapSize) * TranslationSnapSize,
FMath::RoundToFloat(Position.Z / TranslationSnapSize) * TranslationSnapSize
);
}
FRotator UPS_Editor_SelectionManager::SnapRotation(const FRotator& Rotation) const
{
if (!bSnapRotation || RotationSnapSize <= 0.0f) return Rotation;
return FRotator(
FMath::RoundToFloat(Rotation.Pitch / RotationSnapSize) * RotationSnapSize,
FMath::RoundToFloat(Rotation.Yaw / RotationSnapSize) * RotationSnapSize,
FMath::RoundToFloat(Rotation.Roll / RotationSnapSize) * RotationSnapSize
);
}
FVector UPS_Editor_SelectionManager::SnapScale(const FVector& Scale) const
{
if (!bSnapScale || ScaleSnapSize <= 0.0f) return Scale;
return FVector(
FMath::RoundToFloat(Scale.X / ScaleSnapSize) * ScaleSnapSize,
FMath::RoundToFloat(Scale.Y / ScaleSnapSize) * ScaleSnapSize,
FMath::RoundToFloat(Scale.Z / ScaleSnapSize) * ScaleSnapSize
);
}
void UPS_Editor_SelectionManager::SetActorHighlight(AActor* Actor, bool bHighlight)
{
if (!Actor)
@@ -285,6 +364,13 @@ void UPS_Editor_SelectionManager::UpdateGizmo()
if (IsAnythingSelected())
{
Gizmo->SetGizmoActive(true, GetSelectionPivot());
// Update gizmo orientation
if (AActor* Actor = SelectedActors[0].Get())
{
bool bUseLocal = (CurrentTransformMode == EPS_Editor_TransformMode::Scale) || (CurrentSpace == EPS_Editor_Space::Local);
Gizmo->SetLocalOrientation(bUseLocal ? Actor->GetActorRotation() : FRotator::ZeroRotator);
}
}
else
{
@@ -292,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()
{
SelectedActors.RemoveAll([](const TWeakObjectPtr<AActor>& Weak)

View File

@@ -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<TWeakObjectPtr<AActor>>& GetSelectedActors() const { return SelectedActors; }
@@ -55,6 +78,28 @@ public:
EPS_Editor_Space GetTransformSpace() const { return CurrentSpace; }
void SetTransformSpace(EPS_Editor_Space NewSpace);
void ToggleTransformSpace();
/** Refresh gizmo orientation to match current selection (call after transforms change). */
void RefreshGizmoOrientation();
// ---- Snap Settings ----
bool bSnapTranslation = false;
float TranslationSnapSize = 10.0f; // cm
bool bSnapRotation = false;
float RotationSnapSize = 10.0f; // degrees
bool bSnapScale = false;
float ScaleSnapSize = 0.1f;
/** Apply snap to a position value. */
FVector SnapPosition(const FVector& Position) const;
/** Apply snap to a rotation value. */
FRotator SnapRotation(const FRotator& Rotation) const;
/** Apply snap to a scale value. */
FVector SnapScale(const FVector& Scale) const;
// ---- Delegates ----
@@ -73,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<FPS_Editor_ClipboardEntry> Clipboard;
/** Toggle custom depth stencil rendering on an actor's primitive components. */
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) {}
TArray<TWeakObjectPtr<AActor>> 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());
}
};

View File

@@ -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<FString> UPS_Editor_SpawnManager::GetCategories() const
{
TArray<FString> Categories;

View File

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

View File

@@ -0,0 +1,122 @@
#include "PS_Editor_ConfirmDialog.h"
#include "Widgets/SBoxPanel.h"
#include "Widgets/Text/STextBlock.h"
#include "Widgets/Input/SButton.h"
#include "Widgets/Layout/SBorder.h"
#include "Widgets/SOverlay.h"
#include "Widgets/Layout/SBox.h"
void UPS_Editor_ConfirmDialog::Show(const FText& Message, FPS_Editor_OnConfirmResult InCallback)
{
DialogMessage = Message;
OnResult = InCallback;
if (MessageText.IsValid())
{
MessageText->SetText(DialogMessage);
}
AddToViewport(100); // High Z-order to be on top of everything
}
TSharedRef<SWidget> UPS_Editor_ConfirmDialog::RebuildWidget()
{
return SNew(SOverlay)
// Dark overlay background
+ SOverlay::Slot()
[
SNew(SBorder)
.BorderBackgroundColor(FLinearColor(0.0f, 0.0f, 0.0f, 0.5f))
]
// Centered dialog box
+ SOverlay::Slot()
.HAlign(HAlign_Center)
.VAlign(VAlign_Center)
[
SNew(SBorder)
.BorderBackgroundColor(FLinearColor(0.1f, 0.1f, 0.1f, 0.95f))
.Padding(FMargin(24.0f, 16.0f))
[
SNew(SVerticalBox)
// Message
+ SVerticalBox::Slot()
.AutoHeight()
.HAlign(HAlign_Center)
.Padding(0.0f, 0.0f, 0.0f, 16.0f)
[
SAssignNew(MessageText, STextBlock)
.Text(DialogMessage)
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 14))
.ColorAndOpacity(FLinearColor::White)
.Justification(ETextJustify::Center)
]
// Buttons
+ SVerticalBox::Slot()
.AutoHeight()
.HAlign(HAlign_Center)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(8.0f, 0.0f)
[
SNew(SBox)
.MinDesiredWidth(100.0f)
[
SNew(SButton)
.HAlign(HAlign_Center)
.OnClicked_Lambda([this]() -> FReply
{
OnYesClicked();
return FReply::Handled();
})
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Yes")))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 12))
.Justification(ETextJustify::Center)
]
]
]
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(8.0f, 0.0f)
[
SNew(SBox)
.MinDesiredWidth(100.0f)
[
SNew(SButton)
.HAlign(HAlign_Center)
.OnClicked_Lambda([this]() -> FReply
{
OnNoClicked();
return FReply::Handled();
})
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("No")))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 12))
.Justification(ETextJustify::Center)
]
]
]
]
]
];
}
void UPS_Editor_ConfirmDialog::OnYesClicked()
{
CloseDialog(true);
}
void UPS_Editor_ConfirmDialog::OnNoClicked()
{
CloseDialog(false);
}
void UPS_Editor_ConfirmDialog::CloseDialog(bool bConfirmed)
{
RemoveFromParent();
OnResult.ExecuteIfBound(bConfirmed);
}

View File

@@ -0,0 +1,34 @@
#pragma once
#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "PS_Editor_ConfirmDialog.generated.h"
DECLARE_DELEGATE_OneParam(FPS_Editor_OnConfirmResult, bool /*bConfirmed*/);
/**
* Simple in-game confirmation dialog (Yes/No).
* Works in packaged builds unlike FMessageDialog.
*/
UCLASS()
class PS_EDITOR_API UPS_Editor_ConfirmDialog : public UUserWidget
{
GENERATED_BODY()
public:
/** Show the dialog with a message. Callback fired when user clicks Yes or No. */
void Show(const FText& Message, FPS_Editor_OnConfirmResult InCallback);
FPS_Editor_OnConfirmResult OnResult;
protected:
virtual TSharedRef<SWidget> RebuildWidget() override;
private:
FText DialogMessage;
TSharedPtr<STextBlock> MessageText;
void OnYesClicked();
void OnNoClicked();
void CloseDialog(bool bConfirmed);
};

View File

@@ -63,7 +63,7 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::RebuildWidget()
.Padding(12.0f, 0.0f, 0.0f, 0.0f)
[
SAssignNew(ModeText, STextBlock)
.Text(FText::FromString(TEXT("Translate (E)")))
.Text(FText::FromString(TEXT("Translate (Z)")))
.ColorAndOpacity(FLinearColor(0.7f, 0.7f, 0.7f, 1.0f))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 11))
]
@@ -111,7 +111,7 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::RebuildWidget()
.Padding(FMargin(16.0f, 6.0f))
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("LMB: Select/Pan | RMB: Fly | Alt+LMB/MMB: Orbit | E/R/T: Translate/Rotate/Scale | End: Snap | Del: Delete")))
.Text(FText::FromString(TEXT("LMB: Select/Pan | RMB: Fly | Alt+LMB/MMB: Orbit | Z/E/R: Translate/Rotate/Scale | End: Snap | Del: Delete")))
.ColorAndOpacity(FLinearColor(0.6f, 0.6f, 0.6f, 1.0f))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 9))
]
@@ -142,11 +142,11 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildToolbar()
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot().AutoWidth().Padding(2.0f, 0.0f)
[ MakeButton(FText::FromString(TEXT("Translate (E)")), EPS_Editor_TransformMode::Translate) ]
[ MakeButton(FText::FromString(TEXT("Translate (Z)")), EPS_Editor_TransformMode::Translate) ]
+ SHorizontalBox::Slot().AutoWidth().Padding(2.0f, 0.0f)
[ MakeButton(FText::FromString(TEXT("Rotate (R)")), EPS_Editor_TransformMode::Rotate) ]
[ MakeButton(FText::FromString(TEXT("Rotate (E)")), EPS_Editor_TransformMode::Rotate) ]
+ SHorizontalBox::Slot().AutoWidth().Padding(2.0f, 0.0f)
[ MakeButton(FText::FromString(TEXT("Scale (T)")), EPS_Editor_TransformMode::Scale) ]
[ MakeButton(FText::FromString(TEXT("Scale (R)")), EPS_Editor_TransformMode::Scale) ]
+ SHorizontalBox::Slot().AutoWidth().Padding(8.0f, 0.0f, 2.0f, 0.0f)
[
SNew(SButton)
@@ -164,6 +164,66 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildToolbar()
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 10))
]
]
// World/Local toggle
+ SHorizontalBox::Slot().AutoWidth().Padding(8.0f, 0.0f, 2.0f, 0.0f)
[
SNew(SButton)
.OnClicked_Lambda([this]() -> FReply
{
if (UPS_Editor_SelectionManager* SM = SelectionManager.Get())
{
SM->ToggleTransformSpace();
}
return FReply::Handled();
})
[
SAssignNew(SpaceText, STextBlock)
.Text(FText::FromString(TEXT("World")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 10))
]
]
// Snap toggle + value
+ SHorizontalBox::Slot().AutoWidth().Padding(8.0f, 0.0f, 2.0f, 0.0f)
[
SNew(SButton)
.OnClicked_Lambda([this]() -> FReply
{
if (UPS_Editor_SelectionManager* SM = SelectionManager.Get())
{
SM->bSnapTranslation = !SM->bSnapTranslation;
SM->bSnapRotation = SM->bSnapTranslation;
SM->bSnapScale = SM->bSnapTranslation;
}
return FReply::Handled();
})
[
SAssignNew(SnapText, STextBlock)
.Text(FText::FromString(TEXT("Snap: Off")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 10))
]
]
+ SHorizontalBox::Slot().AutoWidth().Padding(2.0f, 0.0f).VAlign(VAlign_Center)
[
SNew(SBox).MinDesiredWidth(40.0f)
[
SAssignNew(SnapSizeField, SEditableTextBox)
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 9))
.HintText(FText::FromString(TEXT("10")))
.OnTextCommitted_Lambda([this](const FText& Text, ETextCommit::Type)
{
if (UPS_Editor_SelectionManager* SM = SelectionManager.Get())
{
float Val = FCString::Atof(*Text.ToString());
if (Val > 0.0f)
{
SM->TranslationSnapSize = Val;
SM->RotationSnapSize = Val;
SM->ScaleSnapSize = Val * 0.01f;
}
}
})
]
]
];
}
@@ -608,13 +668,27 @@ void UPS_Editor_MainWidget::RefreshPropertiesFromSelection()
FString ModeStr;
switch (SM->GetTransformMode())
{
case EPS_Editor_TransformMode::Translate: ModeStr = TEXT("Translate (E)"); break;
case EPS_Editor_TransformMode::Rotate: ModeStr = TEXT("Rotate (R)"); break;
case EPS_Editor_TransformMode::Scale: ModeStr = TEXT("Scale (T)"); break;
case EPS_Editor_TransformMode::Translate: ModeStr = TEXT("Translate (Z)"); break;
case EPS_Editor_TransformMode::Rotate: ModeStr = TEXT("Rotate (E)"); break;
case EPS_Editor_TransformMode::Scale: ModeStr = TEXT("Scale (R)"); break;
}
ModeText->SetText(FText::FromString(ModeStr));
}
// Update space button text
if (SpaceText.IsValid())
{
SpaceText->SetText(FText::FromString(
SM->GetTransformSpace() == EPS_Editor_Space::World ? TEXT("World") : TEXT("Local")));
}
// Update snap button text
if (SnapText.IsValid())
{
SnapText->SetText(FText::FromString(
SM->bSnapTranslation ? TEXT("Snap: On") : TEXT("Snap: Off")));
}
if (!SM->IsAnythingSelected())
{
if (SelectionInfoText.IsValid())

View File

@@ -44,6 +44,11 @@ private:
TSharedPtr<STextBlock> ModeText;
TSharedPtr<STextBlock> SelectionInfoText;
// Toolbar refs
TSharedPtr<STextBlock> SpaceText;
TSharedPtr<STextBlock> SnapText;
TSharedPtr<SEditableTextBox> SnapSizeField;
// Spawn catalog
TSharedPtr<SVerticalBox> SpawnListContainer;