Unreal Engine - Actor Actions

These submodules are part of a custom Editor Utility Toolset I built in C++ for Unreal Engine. It's designed to speed up repetitive actor manipulation tasks—snapping, rotating, duplicating, and distributing—directly in the editor viewport with minimal UI interruption. I developed these tools to streamline level design workflows and empower designers and artists to make bulk transformations with precision and speed, using familiar editor interaction patterns.

I wanted the tools to feel native to Unreal—not like a separate mode, but more like a natural extension of how designers already work. By embedding it in the viewport context and using Unreal’s existing selection + transaction systems, the tool becomes intuitive for users and easy to maintain or extend for future features.

Because it’s written in C++, it’s highly extensible—new commands can be mapped in seconds, and additional menu entries can be added via BuildSubMenu(). You can find further breakdowns in the breakdown section.

Hotkey Transforms

Actor Actions

Overview of Implemented Commands


Actor Actions

Duplicate Actor to Mouse Cursors closest surface

Actor Actions

Place Actor on Mouse Cursors closest surface

Actor Actions

Rotate Actor in Random Rotation

Actor Actions

Rotate Actor 90 degrees in positive or negative direction

Actor Actions

Select all actors within the editor viewport frustum that share the same Static Mesh component.

Breakdown

Modular Tool Architecture

All editor tools are structured into independent submodules. Each submodule is initialized from a central plugin entry point

 void FBUtilitiesModule::StartupModule()
{
	FBJUtilsUICommands::Register();

	ActorTransformEditorSubModule = MakeUnique&l<FActorTransformEditorSubModule>();
	ActorTransformEditorSubModule->Startup();
        ...
}

This example tool is wrapped in a class FActorTransformEditorSubModule that exposes Startup() and Shutdown() functions—called during the main plugin/module's initialization StartupModule().

 class FActorTransformEditorSubModule
{
public:
	/** Initialize the submodule (register commands, etc.) */
	void Startup();

	/** Cleanup when the submodule is unloaded */
	void Shutdown();
}

Hotkey-Driven Command System

Editor actions are registered using FUICommandList, allowing for fast keyboard interaction and custom menu bindings. Commands are mapped to functions using FExecuteAction::CreateRaw.

 void FActorTransformEditorSubModule::Startup()
{
    // Register menu entries, hotkeys, etc. here if needed

    // Create our command list
    CustomUICommands = MakeShareable(new FUICommandList());

    // Map hotkeys to actions
    CustomUICommands->MapAction(
    FBJUtilsUICommands::Get().SnapSelectedActorsLocationToHoveredSurface,
    FExecuteAction::CreateRaw(this, &FActorTransformEditorSubModule::SnapSelectedActorsToSurfaceUnderCursor)
    );

    CustomUICommands->MapAction(
    FBJUtilsUICommands::Get().SnapActorsBottomToSurfaceUnderCursor,
    FExecuteAction::CreateRaw(this, &FActorTransformEditorSubModule::SnapActorsBottomToSurfaceUnderCursor)
    );
        ...
}

Commands are defined with descriptions and hotkeys:

 void FBJUtilsUICommands::RegisterCommands()
{
    UI_COMMAND(
        SnapSelectedActorsLocationToHoveredSurface,
        "Snap Selected Actors to Surface",
        "Snaps all selected actors to the surface hovered by the cursor.",
        EUserInterfaceActionType::Button, FInputChord(EKeys::Q, EModifierKey::Alt | EModifierKey::Shift)
    );

    UI_COMMAND(
        SnapActorsBottomToSurfaceUnderCursor,
        "Snap Actor Bottom to Surface",
        "Aligns the base of actors with the surface under the cursor.",
        EUserInterfaceActionType::Button, FInputChord(EKeys::Q, EModifierKey::Alt)
    );
        ...
}

Snap Actor Bottoms to Surface Under Cursor

Here's a breakdown on one of the functions that is in the submodule

1. Identify the active viewport

We determine which level editor viewport currently has focus. This ensures we're interacting with the correct mouse cursor and scene.

for (FEditorViewportClient* Client : GEditor->GetAllViewportClients())
{
	if (Client && Client->IsLevelEditorClient() && Client->Viewport && Client->Viewport->HasFocus())
	{
		ViewportClient = Client;
		break;
	}
}

2. Cast a ray from the mouse cursor into the world

We compute a world-space ray from the mouse position to find the hit location on geometry.

FViewportCursorLocation Cursor(View, ViewportClient, ViewportClient->Viewport->GetMouseX(),
                                                    ViewportClient->Viewport->GetMouseY());
FVector RayOrigin = Cursor.GetOrigin();
FVector RayDirection = Cursor.GetDirection();
FVector RayEnd = RayOrigin + RayDirection * HALF_WORLD_MAX;

3. Perform a Line Trace

We ignore selected actors (to avoid self-intersection) and trace for visibility

for (FSelectionIterator It(GEditor->GetSelectedActorIterator()); It; ++It)
{
	if (AActor* Actor = Cast<AActor>(*It))
	{
		Params.AddIgnoredActor(Actor);
	}
}

If no surface is hit, we log a warning and exit

if (!ViewportClient->GetWorld()->LineTraceSingleByChannel(Hit, RayOrigin, RayEnd, ECC_Visibility, Params))
{
	UE_LOG(LogTemp, Warning, TEXT("Snap failed: No surface detected under cursor. Try adjusting the view."));
	return;
} 

4. Begin a Scoped Transaction

GEditor->BeginTransaction(LOCTEXT("SnapActors", "Snap Actor(s) To Surface (Bottom Alignment)")); 

5. Align each actor's base to the hit surface

FBox Bounds = Actor->GetComponentsBoundingBox(true); // Include non-colliding components
float OffsetZ = Actor->GetActorLocation().Z - Bounds.Min.Z;
FVector NewLocation = Hit.Location + FVector(0, 0, OffsetZ);
Actor->SetActorLocation(NewLocation); 

6. Refresh Selection State

To ensure the gizmos update and selection stays stable, we deselect and reselect the actors

GEditor->GetSelectedActors()->Deselect(Actor);
GEditor->GetSelectedActors()->Select(Actor); 

7. Final Notification

We notify the user visually that the snap was successful

BDebugHeader::BShowNotifyInfo("Snapped Actors origin to surface");
View Full Actor Function
void FActorTransformEditorSubModule::SnapSelectedActorsToSurfaceUnderCursor()
{
	FEditorViewportClient* ViewportClient = nullptr;

	// Find the active level editor viewport
	for (FEditorViewportClient* Client : GEditor->GetAllViewportClients())
	{
		if (Client && Client->IsLevelEditorClient() && Client->Viewport && Client->Viewport->HasFocus())
		{
			ViewportClient = Client;
			break;
		}
	}

	if (!ViewportClient)
	{
		UE_LOG(LogTemp, Warning, TEXT("No active level viewport found."));
		return;
	}

	// Build a world ray from the mouse position
	FSceneViewFamilyContext ViewFamily(FSceneViewFamily::ConstructionValues(
			ViewportClient->Viewport,
			ViewportClient->GetScene(),
			ViewportClient->EngineShowFlags)
		.SetRealtimeUpdate(ViewportClient->IsRealtime()));

	FSceneView* View = ViewportClient->CalcSceneView(&ViewFamily);
	FViewportCursorLocation Cursor(View, ViewportClient, ViewportClient->Viewport->GetMouseX(),
	                               ViewportClient->Viewport->GetMouseY());

	const FVector RayOrigin = Cursor.GetOrigin();
	const FVector RayDirection = Cursor.GetDirection();
	const FVector RayEnd = RayOrigin + RayDirection * HALF_WORLD_MAX;

	// Trace the world
	FHitResult Hit;
	FCollisionQueryParams Params(SCENE_QUERY_STAT(SnapTrace), true);
	for (FSelectionIterator It(GEditor->GetSelectedActorIterator()); It; ++It)
	{
		if (AActor* Actor = Cast<AActor>(*It))
		{
			Params.AddIgnoredActor(Actor);
		}
	}

	if (!ViewportClient->GetWorld()->LineTraceSingleByChannel(Hit, RayOrigin, RayEnd, ECC_Visibility, Params))
	{
		UE_LOG(LogTemp, Warning, TEXT("No surface hit under cursor."));
		return;
	}

	// Apply the new location to all selected actors
	GEditor->BeginTransaction(LOCTEXT("SnapActors", "Snap Actor(s) To Surface"));

	TArray<AActor*> ReselectedActors;

	for (FSelectionIterator It(GEditor->GetSelectedActorIterator()); It; ++It)
	{
		if (AActor* Actor = Cast<AActor>(*It))
		{
			Actor->Modify();
			Actor->SetActorLocation(Hit.Location);
			ReselectedActors.Add(Actor);
		}
	}

	GEditor->EndTransaction();

	// Refresh gizmo
	for (AActor* Actor : ReselectedActors)
	{
		GEditor->GetSelectedActors()->Deselect(Actor);
		GEditor->GetSelectedActors()->Select(Actor);
	}

	BDebugHeader::BShowNotifyInfo(TEXT("Snapped Actors bottom to surface"));
}