Add snap to ground, screen-space rotate/scale, and input refinements

- End key snaps selected actors to ground (downward trace + bounds offset)
- Rotate/Scale now use screen-space mouse delta (X+Y combined) for intuitive control
- Accumulator-based drag for smooth incremental rotation and scaling
- Fixed rotation drag plane (perpendicular to axis instead of parallel)
- Movement: arrows + A (up) / Q (down), letter keys freed for hotkeys

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 18:49:04 +02:00
parent 30c0078a1d
commit 20017ff3fd
4 changed files with 102 additions and 48 deletions

View File

@@ -168,6 +168,10 @@ void APS_Editor_Pawn::CreateInputActions()
IA_Delete = NewObject<UInputAction>(this, TEXT("IA_Delete")); IA_Delete = NewObject<UInputAction>(this, TEXT("IA_Delete"));
IA_Delete->ValueType = EInputActionValueType::Boolean; IA_Delete->ValueType = EInputActionValueType::Boolean;
EditorMappingContext->MapKey(IA_Delete, EKeys::Delete); EditorMappingContext->MapKey(IA_Delete, EKeys::Delete);
IA_SnapToGround = NewObject<UInputAction>(this, TEXT("IA_SnapToGround"));
IA_SnapToGround->ValueType = EInputActionValueType::Boolean;
EditorMappingContext->MapKey(IA_SnapToGround, EKeys::End);
} }
void APS_Editor_Pawn::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent) void APS_Editor_Pawn::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
@@ -205,6 +209,7 @@ void APS_Editor_Pawn::SetupPlayerInputComponent(UInputComponent* PlayerInputComp
EIC->BindAction(IA_RotateMode, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleRotateMode); EIC->BindAction(IA_RotateMode, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleRotateMode);
EIC->BindAction(IA_ScaleMode, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleScaleMode); EIC->BindAction(IA_ScaleMode, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleScaleMode);
EIC->BindAction(IA_Delete, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleDelete); EIC->BindAction(IA_Delete, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleDelete);
EIC->BindAction(IA_SnapToGround, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleSnapToGround);
} }
// ---- Input Handlers ---- // ---- Input Handlers ----
@@ -290,19 +295,6 @@ void APS_Editor_Pawn::HandleLook(const FInputActionValue& Value)
} }
else if (CameraMode == EPS_Editor_CameraMode::GizmoDrag) else if (CameraMode == EPS_Editor_CameraMode::GizmoDrag)
{ {
// Intersect mouse ray with drag plane, constrain delta to axis, apply to actors
FVector RayOrigin, RayDir;
if (!GetMouseWorldRay(RayOrigin, RayDir))
{
return;
}
FVector CurrentIntersection;
if (!RayPlaneIntersection(RayOrigin, RayDir, GizmoDragPlaneOrigin, GizmoDragPlaneNormal, CurrentIntersection))
{
return;
}
APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController()); APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController());
if (!EditorPC) return; if (!EditorPC) return;
UPS_Editor_SelectionManager* SM = EditorPC->GetSelectionManager(); UPS_Editor_SelectionManager* SM = EditorPC->GetSelectionManager();
@@ -311,14 +303,20 @@ void APS_Editor_Pawn::HandleLook(const FInputActionValue& Value)
if (!Gizmo) return; if (!Gizmo) return;
const FVector AxisDir = Gizmo->GetAxisDirection(ActiveGizmoAxis); const FVector AxisDir = Gizmo->GetAxisDirection(ActiveGizmoAxis);
const FVector TotalDelta = CurrentIntersection - GizmoDragInitialIntersection;
const EPS_Editor_TransformMode Mode = SM->GetTransformMode(); const EPS_Editor_TransformMode Mode = SM->GetTransformMode();
const TArray<TWeakObjectPtr<AActor>>& Selected = SM->GetSelectedActors(); const TArray<TWeakObjectPtr<AActor>>& Selected = SM->GetSelectedActors();
if (Mode == EPS_Editor_TransformMode::Translate) if (Mode == EPS_Editor_TransformMode::Translate)
{ {
// Ray-plane intersection for precise axis-constrained translation
FVector RayOrigin, RayDir;
if (!GetMouseWorldRay(RayOrigin, RayDir)) return;
FVector CurrentIntersection;
if (!RayPlaneIntersection(RayOrigin, RayDir, GizmoDragPlaneOrigin, GizmoDragPlaneNormal, CurrentIntersection)) return;
const FVector TotalDelta = CurrentIntersection - GizmoDragInitialIntersection;
const FVector ConstrainedDelta = FVector::DotProduct(TotalDelta, AxisDir) * AxisDir; const FVector ConstrainedDelta = FVector::DotProduct(TotalDelta, AxisDir) * AxisDir;
for (int32 i = 0; i < Selected.Num() && i < GizmoDragInitialTransforms.Num(); ++i) for (int32 i = 0; i < Selected.Num() && i < GizmoDragInitialTransforms.Num(); ++i)
{ {
if (AActor* Actor = Selected[i].Get()) if (AActor* Actor = Selected[i].Get())
@@ -329,29 +327,23 @@ void APS_Editor_Pawn::HandleLook(const FInputActionValue& Value)
} }
else if (Mode == EPS_Editor_TransformMode::Rotate) else if (Mode == EPS_Editor_TransformMode::Rotate)
{ {
// Compute rotation angle from initial to current intersection around the axis // 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
GizmoDragAccumulator += AngleDelta;
const FQuat RotationQuat(AxisDir, FMath::DegreesToRadians(GizmoDragAccumulator));
const FVector GizmoLoc = GizmoDragPlaneOrigin; const FVector GizmoLoc = GizmoDragPlaneOrigin;
const FVector InitDir = (GizmoDragInitialIntersection - GizmoLoc).GetSafeNormal();
const FVector CurrDir = (CurrentIntersection - GizmoLoc).GetSafeNormal();
// Signed angle via cross product
float Angle = FMath::Atan2(
FVector::DotProduct(FVector::CrossProduct(InitDir, CurrDir), AxisDir),
FVector::DotProduct(InitDir, CurrDir));
Angle = FMath::RadiansToDegrees(Angle);
const FQuat RotationQuat(AxisDir, FMath::DegreesToRadians(Angle));
for (int32 i = 0; i < Selected.Num() && i < GizmoDragInitialTransforms.Num(); ++i) for (int32 i = 0; i < Selected.Num() && i < GizmoDragInitialTransforms.Num(); ++i)
{ {
if (AActor* Actor = Selected[i].Get()) if (AActor* Actor = Selected[i].Get())
{ {
// Rotate around gizmo pivot
const FVector Offset = GizmoDragInitialTransforms[i].GetLocation() - GizmoLoc; const FVector Offset = GizmoDragInitialTransforms[i].GetLocation() - GizmoLoc;
const FVector RotatedOffset = RotationQuat.RotateVector(Offset); Actor->SetActorLocation(GizmoLoc + RotationQuat.RotateVector(Offset));
Actor->SetActorLocation(GizmoLoc + RotatedOffset);
// Also rotate the actor itself
const FQuat OrigRot = GizmoDragInitialTransforms[i].GetRotation(); const FQuat OrigRot = GizmoDragInitialTransforms[i].GetRotation();
Actor->SetActorRotation((RotationQuat * OrigRot).Rotator()); Actor->SetActorRotation((RotationQuat * OrigRot).Rotator());
} }
@@ -359,22 +351,17 @@ void APS_Editor_Pawn::HandleLook(const FInputActionValue& Value)
} }
else if (Mode == EPS_Editor_TransformMode::Scale) else if (Mode == EPS_Editor_TransformMode::Scale)
{ {
// Scale factor from mouse movement along the axis // Use combined mouse X+Y screen movement to drive scale
const float InitialDist = FVector::DotProduct(GizmoDragInitialIntersection - GizmoDragPlaneOrigin, AxisDir); const float ScaleSpeed = 0.01f;
const float CurrentDist = FVector::DotProduct(CurrentIntersection - GizmoDragPlaneOrigin, AxisDir); GizmoDragAccumulator += (LookInput.X + LookInput.Y) * ScaleSpeed;
float ScaleFactor = 1.0f; const float ScaleFactor = FMath::Clamp(1.0f + GizmoDragAccumulator, 0.01f, 100.0f);
if (FMath::Abs(InitialDist) > KINDA_SMALL_NUMBER)
{
ScaleFactor = FMath::Clamp(CurrentDist / InitialDist, 0.01f, 100.0f);
}
for (int32 i = 0; i < Selected.Num() && i < GizmoDragInitialTransforms.Num(); ++i) for (int32 i = 0; i < Selected.Num() && i < GizmoDragInitialTransforms.Num(); ++i)
{ {
if (AActor* Actor = Selected[i].Get()) if (AActor* Actor = Selected[i].Get())
{ {
FVector NewScale = GizmoDragInitialTransforms[i].GetScale3D(); FVector NewScale = GizmoDragInitialTransforms[i].GetScale3D();
// Scale only on the dragged axis
if (ActiveGizmoAxis == EPS_Editor_GizmoAxis::X) NewScale.X *= ScaleFactor; 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::Y) NewScale.Y *= ScaleFactor;
else if (ActiveGizmoAxis == EPS_Editor_GizmoAxis::Z) NewScale.Z *= ScaleFactor; else if (ActiveGizmoAxis == EPS_Editor_GizmoAxis::Z) NewScale.Z *= ScaleFactor;
@@ -383,7 +370,6 @@ void APS_Editor_Pawn::HandleLook(const FInputActionValue& Value)
} }
} }
// Update gizmo position
Gizmo->SetActorLocation(SM->GetSelectionPivot()); Gizmo->SetActorLocation(SM->GetSelectionPivot());
} }
} }
@@ -548,6 +534,18 @@ void APS_Editor_Pawn::HandleDelete(const FInputActionValue& Value)
} }
} }
void APS_Editor_Pawn::HandleSnapToGround(const FInputActionValue& Value)
{
if (CameraMode != EPS_Editor_CameraMode::Idle) return;
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController()))
{
if (UPS_Editor_SelectionManager* SM = EditorPC->GetSelectionManager())
{
SM->SnapSelectionToGround();
}
}
}
// ---- Helpers ---- // ---- Helpers ----
void APS_Editor_Pawn::SetCameraMode(EPS_Editor_CameraMode NewMode) void APS_Editor_Pawn::SetCameraMode(EPS_Editor_CameraMode NewMode)
@@ -684,21 +682,30 @@ void APS_Editor_Pawn::BeginGizmoDrag(EPS_Editor_GizmoAxis Axis)
ActiveGizmoAxis = Axis; ActiveGizmoAxis = Axis;
// Get the axis direction in world space
const FVector AxisDir = Gizmo->GetAxisDirection(Axis); const FVector AxisDir = Gizmo->GetAxisDirection(Axis);
const EPS_Editor_TransformMode Mode = SM->GetTransformMode();
// Compute the drag plane: contains the axis and faces the camera as much as possible FVector PlaneNormal;
if (Mode == EPS_Editor_TransformMode::Rotate)
{
// For rotation: plane perpendicular to the rotation axis
PlaneNormal = AxisDir;
}
else
{
// For translate/scale: plane containing the axis, facing the camera
const FVector CameraForward = GetActorForwardVector(); const FVector CameraForward = GetActorForwardVector();
FVector PlaneNormal = FVector::CrossProduct(AxisDir, CameraForward).GetSafeNormal(); PlaneNormal = FVector::CrossProduct(AxisDir, CameraForward).GetSafeNormal();
if (PlaneNormal.IsNearlyZero()) if (PlaneNormal.IsNearlyZero())
{ {
// Camera looking along the axis - use camera up instead
PlaneNormal = FVector::CrossProduct(AxisDir, FVector::UpVector).GetSafeNormal(); PlaneNormal = FVector::CrossProduct(AxisDir, FVector::UpVector).GetSafeNormal();
} }
PlaneNormal = FVector::CrossProduct(AxisDir, PlaneNormal).GetSafeNormal(); PlaneNormal = FVector::CrossProduct(AxisDir, PlaneNormal).GetSafeNormal();
}
GizmoDragPlaneNormal = PlaneNormal; GizmoDragPlaneNormal = PlaneNormal;
GizmoDragPlaneOrigin = Gizmo->GetActorLocation(); GizmoDragPlaneOrigin = Gizmo->GetActorLocation();
GizmoDragAccumulator = 0.0f;
// Get initial mouse-plane intersection // Get initial mouse-plane intersection
FVector RayOrigin, RayDir; FVector RayOrigin, RayDir;

View File

@@ -158,6 +158,9 @@ private:
UPROPERTY() UPROPERTY()
TObjectPtr<UInputAction> IA_Delete; TObjectPtr<UInputAction> IA_Delete;
UPROPERTY()
TObjectPtr<UInputAction> IA_SnapToGround;
// ---- Modifier key state ---- // ---- Modifier key state ----
bool bAltHeld = false; bool bAltHeld = false;
bool bCtrlHeld = false; bool bCtrlHeld = false;
@@ -171,6 +174,7 @@ private:
FVector GizmoDragPlaneOrigin = FVector::ZeroVector; FVector GizmoDragPlaneOrigin = FVector::ZeroVector;
FVector GizmoDragInitialIntersection = FVector::ZeroVector; FVector GizmoDragInitialIntersection = FVector::ZeroVector;
TArray<FTransform> GizmoDragInitialTransforms; TArray<FTransform> GizmoDragInitialTransforms;
float GizmoDragAccumulator = 0.0f;
// ---- Input Handlers ---- // ---- Input Handlers ----
void HandleMove(const FInputActionValue& Value); void HandleMove(const FInputActionValue& Value);
@@ -186,6 +190,7 @@ private:
void HandleRotateMode(const FInputActionValue& Value); void HandleRotateMode(const FInputActionValue& Value);
void HandleScaleMode(const FInputActionValue& Value); void HandleScaleMode(const FInputActionValue& Value);
void HandleDelete(const FInputActionValue& Value); void HandleDelete(const FInputActionValue& Value);
void HandleSnapToGround(const FInputActionValue& Value);
// ---- Helpers ---- // ---- Helpers ----
void CreateInputActions(); void CreateInputActions();

View File

@@ -218,6 +218,45 @@ void UPS_Editor_SelectionManager::SetActorHighlight(AActor* Actor, bool bHighlig
} }
} }
void UPS_Editor_SelectionManager::SnapSelectionToGround()
{
APlayerController* PC = OwnerPC.Get();
if (!PC) return;
for (const TWeakObjectPtr<AActor>& Weak : SelectedActors)
{
AActor* Actor = Weak.Get();
if (!Actor) continue;
// Get actor bounds to compute the bottom of the actor
FVector Origin, Extent;
Actor->GetActorBounds(false, Origin, Extent);
const float BottomOffset = Extent.Z; // Distance from actor origin to bottom
// Trace downward from actor location
const FVector Start = Actor->GetActorLocation();
const FVector End = Start - FVector::UpVector * 100000.0f;
FHitResult Hit;
FCollisionQueryParams Params;
Params.AddIgnoredActor(Actor);
Params.AddIgnoredActor(PC->GetPawn());
if (Gizmo) Params.AddIgnoredActor(Gizmo);
if (Actor->GetWorld()->LineTraceSingleByChannel(Hit, Start, End, ECC_Visibility, Params))
{
// Place actor so its bottom sits on the ground
FVector NewLocation = Hit.ImpactPoint;
NewLocation.Z += BottomOffset;
Actor->SetActorLocation(NewLocation);
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Snapped %s to ground"), *Actor->GetName());
}
}
UpdateGizmo();
}
void UPS_Editor_SelectionManager::UpdateGizmo() void UPS_Editor_SelectionManager::UpdateGizmo()
{ {
if (!Gizmo) if (!Gizmo)

View File

@@ -36,6 +36,9 @@ public:
void ToggleActor(AActor* Actor); void ToggleActor(AActor* Actor);
void ClearSelection(); void ClearSelection();
/** Snap all selected actors to the ground below them. */
void SnapSelectionToGround();
// ---- Queries ---- // ---- Queries ----
const TArray<TWeakObjectPtr<AActor>>& GetSelectedActors() const { return SelectedActors; } const TArray<TWeakObjectPtr<AActor>>& GetSelectedActors() const { return SelectedActors; }