Compare commits

...

2 Commits

Author SHA1 Message Date
93b9c7d927 template demo 2026-04-24 10:44:44 +02:00
050ea4eb2f Gizmo follow, cooker-friendly assets, and proper ground snapping
Gizmo usability:
- Refresh the gizmo every tick while simulation is running so it glues to
  animated / AI-driven / timeline-driven actors instead of freezing at the
  pose they had when selected
- Clear the pawn's active sub-element drag state on FinishPointPlacement /
  CancelPointPlacement — previously, after clicking Done on a spline, the
  gizmo jumped to the center cube but the internal ActiveSplinePointIndex
  stayed on the last clicked CP, so dragging the gizmo moved that CP
  instead of the whole actor
- Expose UpdateGizmo() publicly on the SelectionManager so the
  PlayerController Tick can drive it

Package cooking (fix gray gizmos / missing outline in packaged builds):
- Convert every runtime LoadObject<> soft-string path into a UPROPERTY
  TObjectPtr<> filled via ConstructorHelpers::FObjectFinder at CDO
  construction time. Hard refs are picked up by the cooker so plugin
  Content assets (M_PS_Editor_Gizmo, M_PS_Editor_SelectionOutline,
  M_PS_Editor_SplineLine, and the engine basic shapes the spline actor
  uses) are reliably staged
- Touches APS_Editor_Gizmo, APS_Editor_Pawn (outline post-process
  material), APS_Editor_SplineActor (gizmo material, spline line
  material, center cube mesh, handle sphere mesh)

Ground placement (PS_Editor_GroundSnap):
- New PS_Editor_GroundSnap utility centralising placement logic for
  catalogue spawns, End-key snap-to-ground, and spline CP placement
- ProjectCameraRayToGround: aim down from a point in front of the camera
  when the camera ray misses (no more "looking at sky spawns object in
  mid-air"); classify the primary hit by normal to re-cast from below a
  ceiling / step back from a wall so the object lands on the floor
  underneath / at the foot of the surface rather than embedded in it
- SnapActorToGround: start the downward ray from INSIDE the actor's
  volume (pivot + small buffer), ignoring the actor itself — avoids the
  "raised a bit high, pressed End, teleports to upper story" bug where
  starting above the actor's bbox could cross the current room's ceiling
  and find the upper floor as the first floor-like surface
- Uses GetActorBounds(bOnlyCollidingComponents=true) with a fallback so
  characters snap from their capsule bounds rather than bbox including
  non-colliding meshes / attached weapons / child actors that used to
  lift pawns into the air
- Multi-hit trace + normal.Z >= 0.3 filter skips walls, ceilings, and
  backfaces when finding the first true floor below

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:43:45 +02:00
32 changed files with 434 additions and 111 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -2,6 +2,8 @@
#include "Camera/CameraComponent.h" #include "Camera/CameraComponent.h"
#include "GameFramework/SpringArmComponent.h" #include "GameFramework/SpringArmComponent.h"
#include "Components/PostProcessComponent.h" #include "Components/PostProcessComponent.h"
#include "Materials/MaterialInterface.h"
#include "UObject/ConstructorHelpers.h"
#include "EnhancedInputComponent.h" #include "EnhancedInputComponent.h"
#include "EnhancedInputSubsystems.h" #include "EnhancedInputSubsystems.h"
#include "InputAction.h" #include "InputAction.h"
@@ -10,6 +12,7 @@
#include "InputTriggers.h" #include "InputTriggers.h"
#include "GameFramework/PlayerController.h" #include "GameFramework/PlayerController.h"
#include "PS_Editor_SelectionManager.h" #include "PS_Editor_SelectionManager.h"
#include "PS_Editor_GroundSnap.h"
#include "PS_Editor_PlayerController.h" #include "PS_Editor_PlayerController.h"
#include "PS_Editor_Gizmo.h" #include "PS_Editor_Gizmo.h"
#include "PS_Editor_UndoManager.h" #include "PS_Editor_UndoManager.h"
@@ -43,24 +46,27 @@ APS_Editor_Pawn::APS_Editor_Pawn()
OutlinePostProcess = CreateDefaultSubobject<UPostProcessComponent>(TEXT("OutlinePostProcess")); OutlinePostProcess = CreateDefaultSubobject<UPostProcessComponent>(TEXT("OutlinePostProcess"));
OutlinePostProcess->SetupAttachment(RootScene); OutlinePostProcess->SetupAttachment(RootScene);
OutlinePostProcess->bUnbound = true; // Apply to entire scene OutlinePostProcess->bUnbound = true; // Apply to entire scene
// Hard-ref the outline material at CDO-construction time so the cooker follows it into
// packaged builds. Replaces the old runtime LoadObject<> soft-string path.
static ConstructorHelpers::FObjectFinder<UMaterialInterface> OutlineMatFinder(
TEXT("/PS_Editor/M_PS_Editor_SelectionOutline.M_PS_Editor_SelectionOutline"));
if (OutlineMatFinder.Succeeded())
{
OutlineMaterial = OutlineMatFinder.Object;
}
} }
void APS_Editor_Pawn::BeginPlay() void APS_Editor_Pawn::BeginPlay()
{ {
Super::BeginPlay(); Super::BeginPlay();
// Load outline post-process material // OutlineMaterial is resolved in the ctor via ConstructorHelpers (hard cook ref).
UMaterialInterface* OutlineMat = LoadObject<UMaterialInterface>( if (OutlineMaterial && OutlinePostProcess)
nullptr, TEXT("/PS_Editor/M_PS_Editor_SelectionOutline.M_PS_Editor_SelectionOutline"));
if (OutlineMat && OutlinePostProcess)
{ {
OutlinePostProcess->Settings.WeightedBlendables.Array.Add(FWeightedBlendable(1.0f, OutlineMat)); OutlinePostProcess->Settings.WeightedBlendables.Array.Add(FWeightedBlendable(1.0f, OutlineMaterial));
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Selection outline material loaded"));
} }
else if (!OutlineMaterial)
// TODO: Spline occluded PP material — disabled for now, depth comparison unreliable
// Consider translucent second-pass approach instead of post-process
else
{ {
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Outline material /PS_Editor/M_PS_Editor_SelectionOutline not found. " UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Outline material /PS_Editor/M_PS_Editor_SelectionOutline not found. "
"Create a Post Process material in Plugins/PS_Editor/Content/")); "Create a Post Process material in Plugins/PS_Editor/Content/"));
@@ -769,22 +775,18 @@ void APS_Editor_Pawn::HandleLeftClickStarted(const FInputActionValue& Value)
{ {
if (IPS_Editor_SplineEditable* Spline = Cast<IPS_Editor_SplineEditable>(EdPC3->GetActivePlacementActor())) if (IPS_Editor_SplineEditable* Spline = Cast<IPS_Editor_SplineEditable>(EdPC3->GetActivePlacementActor()))
{ {
// Raycast to get world click position // Project the click ray onto ground. Uses the shared helper so that a click
FHitResult AddHit; // pointing at the sky still lands the control point on the ground below the
FCollisionQueryParams AddParams; // camera's forward vector (instead of floating 1000 units ahead).
AddParams.AddIgnoredActor(this); TArray<AActor*> Ignored;
AddParams.AddIgnoredActor(Cast<AActor>(Spline)); Ignored.Add(this);
const FVector AddEnd = RayOrigin + RayDir * 100000.0f; if (AActor* SplineActor = Cast<AActor>(Spline)) Ignored.Add(SplineActor);
FVector ClickPos; FVector ClickPos = PS_Editor_GroundSnap::ProjectCameraRayToGround(
if (GetWorld()->LineTraceSingleByChannel(AddHit, RayOrigin, AddEnd, ECC_Camera, AddParams)) GetWorld(), RayOrigin, RayDir, Ignored);
{ // Lift the CP slightly above the surface so the tube mesh renders cleanly
ClickPos = AddHit.ImpactPoint + FVector(0.0f, 0.0f, 5.0f); // (otherwise Z-fighting with the ground).
} ClickPos.Z += 5.0f;
else
{
ClickPos = RayOrigin + RayDir * 1000.0f;
}
TArray<FVector> OldPoints = Spline->GetAllPointLocations(); TArray<FVector> OldPoints = Spline->GetAllPointLocations();
Spline->AddPoint(ClickPos); Spline->AddPoint(ClickPos);
@@ -1023,6 +1025,12 @@ void APS_Editor_Pawn::HandleScaleMode(const FInputActionValue& Value)
} }
} }
void APS_Editor_Pawn::ClearActiveSubElementDrag()
{
ActiveSplinePointIndex = -1;
DraggedSplineActor = nullptr;
}
void APS_Editor_Pawn::HandleDelete(const FInputActionValue& Value) void APS_Editor_Pawn::HandleDelete(const FInputActionValue& Value)
{ {
if (CameraMode != EPS_Editor_CameraMode::Idle) return; if (CameraMode != EPS_Editor_CameraMode::Idle) return;

View File

@@ -11,6 +11,7 @@ class USpringArmComponent;
class UPostProcessComponent; class UPostProcessComponent;
class UInputAction; class UInputAction;
class UInputMappingContext; class UInputMappingContext;
class UMaterialInterface;
DECLARE_DELEGATE_TwoParams(FPS_Editor_OnClick, FVector2D /*ScreenPos*/, bool /*bAdditive*/); DECLARE_DELEGATE_TwoParams(FPS_Editor_OnClick, FVector2D /*ScreenPos*/, bool /*bAdditive*/);
@@ -68,6 +69,11 @@ public:
/** Currently selected spline point index (-1 = none). Used by UI for Delete Point button. */ /** Currently selected spline point index (-1 = none). Used by UI for Delete Point button. */
int32 ActiveSplinePointIndex = -1; int32 ActiveSplinePointIndex = -1;
/** Clear any active sub-element drag state (spline point / ChildMovable index + target actor).
* Call this when leaving placement / edit mode so a subsequent gizmo drag on the newly
* selected parent actor moves the whole actor instead of the last clicked point. */
void ClearActiveSubElementDrag();
protected: protected:
virtual void BeginPlay() override; virtual void BeginPlay() override;
@@ -85,6 +91,12 @@ protected:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera") UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera")
TObjectPtr<UPostProcessComponent> OutlinePostProcess; TObjectPtr<UPostProcessComponent> OutlinePostProcess;
/** Selection outline post-process material (from /PS_Editor/M_PS_Editor_SelectionOutline).
* Hard-ref so the cooker picks it up in packaged builds. Resolved via ConstructorHelpers
* in the ctor. */
UPROPERTY()
TObjectPtr<UMaterialInterface> OutlineMaterial;
// ---- Camera Settings ---- // ---- Camera Settings ----
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Fly") UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Fly")

View File

@@ -186,6 +186,17 @@ void APS_Editor_PlayerController::Tick(float DeltaTime)
{ {
SpawnManager->TickEditorMode(DeltaTime); SpawnManager->TickEditorMode(DeltaTime);
} }
// Keep the gizmo glued to its selection every frame while simulating — otherwise
// animated actors (AI walking, timeline-driven properties, ragdoll) leave the gizmo
// behind at the pose it had when the actor was last selected / dragged. Outside of
// simulation the selection doesn't move on its own so the normal mutation-driven
// UpdateGizmo calls (click / drag / undo) are enough — and skipping this avoids any
// feedback with the pawn's in-progress gizmo drag.
if (bSimulating && SelectionManager && SelectionManager->IsAnythingSelected())
{
SelectionManager->UpdateGizmo();
}
} }
void APS_Editor_PlayerController::OnPossess(APawn* InPawn) void APS_Editor_PlayerController::OnPossess(APawn* InPawn)
@@ -554,6 +565,14 @@ void APS_Editor_PlayerController::FinishPointPlacement()
IPS_Editor_ChildMovable::Execute_ClearChildHighlight(PlacementActor); IPS_Editor_ChildMovable::Execute_ClearChildHighlight(PlacementActor);
} }
// Clear the pawn's sub-element drag state (last clicked spline point / child index).
// Without this, grabbing the gizmo right after Done would move the last clicked point
// instead of the whole actor, because the drag logic keys on ActiveSplinePointIndex.
if (APS_Editor_Pawn* EditorPawn = Cast<APS_Editor_Pawn>(GetPawn()))
{
EditorPawn->ClearActiveSubElementDrag();
}
if (PlacementActor && SelectionManager) if (PlacementActor && SelectionManager)
{ {
SelectionManager->ClearSelection(); SelectionManager->ClearSelection();

View File

@@ -26,6 +26,15 @@ APS_Editor_Gizmo::APS_Editor_Gizmo()
{ {
PrimaryActorTick.bCanEverTick = true; PrimaryActorTick.bCanEverTick = true;
// Hard-ref the gizmo base material at CDO-construction time so the cooker follows it
// into packaged builds. Replaces the old runtime LoadObject<> soft-string path.
static ConstructorHelpers::FObjectFinder<UMaterialInterface> GizmoBaseMatFinder(
TEXT("/PS_Editor/M_PS_Editor_Gizmo.M_PS_Editor_Gizmo"));
if (GizmoBaseMatFinder.Succeeded())
{
GizmoBaseMaterial = GizmoBaseMatFinder.Object;
}
GizmoRoot = CreateDefaultSubobject<USceneComponent>(TEXT("GizmoRoot")); GizmoRoot = CreateDefaultSubobject<USceneComponent>(TEXT("GizmoRoot"));
SetRootComponent(GizmoRoot); SetRootComponent(GizmoRoot);
@@ -92,9 +101,8 @@ void APS_Editor_Gizmo::BeginPlay()
{ {
Super::BeginPlay(); Super::BeginPlay();
// Load gizmo material // GizmoBaseMaterial is resolved in the ctor via ConstructorHelpers (hard cook ref).
UMaterialInterface* GizmoBaseMat = LoadObject<UMaterialInterface>( UMaterialInterface* GizmoBaseMat = GizmoBaseMaterial.Get();
nullptr, TEXT("/PS_Editor/M_PS_Editor_Gizmo.M_PS_Editor_Gizmo"));
auto MakeColorMat = [&](const FLinearColor& Color) -> UMaterialInstanceDynamic* auto MakeColorMat = [&](const FLinearColor& Color) -> UMaterialInstanceDynamic*
{ {

View File

@@ -8,6 +8,7 @@
class UStaticMeshComponent; class UStaticMeshComponent;
class UProceduralMeshComponent; class UProceduralMeshComponent;
class UMaterialInstanceDynamic; class UMaterialInstanceDynamic;
class UMaterialInterface;
/** /**
* Custom runtime transform gizmo for PS_Editor. * Custom runtime transform gizmo for PS_Editor.
@@ -109,6 +110,12 @@ private:
TObjectPtr<UStaticMeshComponent> ScaleCube_Center; TObjectPtr<UStaticMeshComponent> ScaleCube_Center;
// ---- Materials ---- // ---- Materials ----
/** Base gizmo material (from /PS_Editor/M_PS_Editor_Gizmo). Hard-ref so the cooker
* picks it up in packaged builds. Resolved via ConstructorHelpers in the ctor. */
UPROPERTY()
TObjectPtr<UMaterialInterface> GizmoBaseMaterial;
UPROPERTY() UPROPERTY()
TObjectPtr<UMaterialInstanceDynamic> Mat_X; TObjectPtr<UMaterialInstanceDynamic> Mat_X;
UPROPERTY() UPROPERTY()

View File

@@ -0,0 +1,195 @@
#include "PS_Editor_GroundSnap.h"
#include "PS_Editor_SpawnableComponent.h"
#include "Engine/World.h"
#include "GameFramework/Actor.h"
#include "CollisionQueryParams.h"
namespace PS_Editor_GroundSnap
{
// Normal.Z threshold that classifies a hit surface as "floor-like" (roughly walkable
// slope — cos(72°) ≈ 0.3). Below this, we treat the surface as a wall / ceiling /
// overhang and keep searching.
static constexpr float GFloorNormalThreshold = 0.3f;
/** Single downward trace that returns the first floor-like hit. Returns false if
* nothing below qualifies (all walls, ceiling-like geometry, etc.). */
static bool FindFloorBelow(UWorld* World, const FVector& Above, float Distance,
const FCollisionQueryParams& Params, FVector& OutImpact)
{
TArray<FHitResult> Hits;
if (!World->LineTraceMultiByChannel(Hits, Above, Above - FVector::UpVector * Distance,
ECC_Visibility, Params))
{
return false;
}
for (const FHitResult& H : Hits)
{
if (H.ImpactNormal.Z >= GFloorNormalThreshold)
{
OutImpact = H.ImpactPoint;
return true;
}
}
return false;
}
FVector ProjectCameraRayToGround(UWorld* World,
const FVector& Start,
const FVector& Direction,
const TArray<AActor*>& IgnoreActors,
float MaxCameraDistance,
float DefaultDistance,
float DownTraceDistance)
{
if (!World)
{
return Start + Direction * DefaultDistance;
}
FCollisionQueryParams Params(SCENE_QUERY_STAT(PS_Editor_GroundSnap), false);
for (AActor* A : IgnoreActors)
{
if (A) Params.AddIgnoredActor(A);
}
// 1) Primary camera-aligned trace.
FHitResult Hit;
if (World->LineTraceSingleByChannel(Hit, Start,
Start + Direction.GetSafeNormal() * MaxCameraDistance,
ECC_Visibility, Params))
{
const float NormalZ = Hit.ImpactNormal.Z;
// Ceiling / overhang: normal points DOWN. Re-cast downward from a point just
// BELOW the ceiling impact (not on the surface itself — avoids re-hitting the
// backface due to floating-point slop).
if (NormalZ <= -0.3f)
{
const FVector StartBelowCeiling = Hit.ImpactPoint - FVector::UpVector * 2.0f;
FVector FloorImpact;
if (FindFloorBelow(World, StartBelowCeiling, DownTraceDistance, Params, FloorImpact))
{
return FloorImpact;
}
return Hit.ImpactPoint;
}
// Wall: roughly horizontal normal. Step BACK along the wall normal (away from
// the surface) by a small distance so the placed object doesn't end up with its
// bbox half-buried in the wall, then cast down to find the floor at the foot
// of the wall. Without this, clicking on a wall drops the object inside it.
if (NormalZ < 0.3f) // not floor-like either
{
constexpr float WallStandoff = 30.0f;
const FVector StartAwayFromWall = Hit.ImpactPoint + Hit.ImpactNormal * WallStandoff;
FVector FloorImpact;
if (FindFloorBelow(World, StartAwayFromWall, DownTraceDistance, Params, FloorImpact))
{
return FloorImpact;
}
// No floor below — at least return the stood-off point so we're not inside
// the wall.
return StartAwayFromWall;
}
// Floor-like: use impact directly.
return Hit.ImpactPoint;
}
// 2) Camera missed. Aim DOWN from a fallback point in front of the camera so the
// object still lands on ground instead of hovering in the sky.
const FVector FallbackTarget = Start + Direction.GetSafeNormal() * DefaultDistance;
FVector FloorImpact;
if (FindFloorBelow(World, FallbackTarget, DownTraceDistance, Params, FloorImpact))
{
return FloorImpact;
}
// 3) Nothing at all — give up and return the in-front point.
return FallbackTarget;
}
bool SnapActorToGround(AActor* Actor,
FVector& OutLocation,
float OverheadRaise,
float MaxDistance,
const TArray<AActor*>& ExtraIgnored)
{
if (!Actor || !Actor->GetWorld())
{
return false;
}
// Measure the bbox bottom relative to the pivot. Use COLLIDING components only so
// a Character's capsule drives the calculation — not its skeletal mesh (often with
// collision disabled) or attached weapons / child actors whose meshes may extend
// below the capsule and artificially lift the pawn by that overflow.
// Falls back to the full actor bounds if no colliding component is found (e.g. a
// pure-mesh prop without collision — rare but possible).
FVector BoundsOrigin, BoundsExtent;
Actor->GetActorBounds(/*bOnlyCollidingComponents*/ true, BoundsOrigin, BoundsExtent);
if (BoundsExtent.IsNearlyZero())
{
Actor->GetActorBounds(false, BoundsOrigin, BoundsExtent);
}
const float ActorZ = Actor->GetActorLocation().Z;
const float BBoxBottomZ = BoundsOrigin.Z - BoundsExtent.Z;
const float BBoxTopZ = BoundsOrigin.Z + BoundsExtent.Z;
const float PivotAboveBottom = ActorZ - BBoxBottomZ;
float ExtraOffset = 0.0f;
if (UPS_Editor_SpawnableComponent* SpawnComp = Actor->FindComponentByClass<UPS_Editor_SpawnableComponent>())
{
ExtraOffset = SpawnComp->GroundOffset;
}
// Start INSIDE the actor (at its pivot + a small buffer), not above its bbox top.
// Why: if we started above the bbox top and the actor was raised even slightly into
// a ceiling, the ray would cross the ceiling and the FIRST floor-like hit (filtered
// by normal.Z >= 0.3) would be the CEILING'S TOP FACE — i.e. the floor of the
// upper story. That made "End" teleport the actor up to the next story when the
// user only nudged it a bit too high.
//
// Starting at the pivot keeps the ray origin within the actor's own volume (pivot
// is always inside or at the edge of the bbox for normal meshes / characters). The
// actor is in the ignore list, so the trace passes straight through its own body
// and finds the first floor-like surface UNDERNEATH the actor — i.e. the floor of
// its current room.
//
// OverheadRaise is unused now (kept for API compat); the 2.0f buffer just avoids
// a degenerate "ray starts exactly on ground surface" edge case when the pivot
// coincides with the ground.
(void)BBoxTopZ; (void)OverheadRaise;
const FVector Start = Actor->GetActorLocation() + FVector(0.f, 0.f, 2.0f);
const FVector End = Start - FVector::UpVector * MaxDistance;
FCollisionQueryParams Params(SCENE_QUERY_STAT(PS_Editor_SnapActorToGround), false);
Params.AddIgnoredActor(Actor);
for (AActor* A : ExtraIgnored)
{
if (A) Params.AddIgnoredActor(A);
}
// Multi-hit trace + filter by normal so we only snap to floor-like surfaces.
// Accepts slopes up to ~72 degrees (normal.Z > 0.3). Rejects walls, ceilings,
// overhangs, and any backface we might cross on the way down.
TArray<FHitResult> Hits;
if (!Actor->GetWorld()->LineTraceMultiByChannel(Hits, Start, End, ECC_Visibility, Params))
{
return false;
}
constexpr float FloorNormalThreshold = 0.3f; // cos(~72 deg)
for (const FHitResult& H : Hits)
{
if (H.ImpactNormal.Z >= FloorNormalThreshold)
{
OutLocation = Actor->GetActorLocation();
OutLocation.Z = H.ImpactPoint.Z + PivotAboveBottom + ExtraOffset;
return true;
}
}
// Trace hit stuff but nothing floor-like — don't move the actor.
return false;
}
}

View File

@@ -0,0 +1,67 @@
#pragma once
#include "CoreMinimal.h"
class AActor;
class UWorld;
/**
* Ground-placement helpers used across catalogue spawn, End-key snap, and spline point
* placement. The goal is that every object the user drops / snaps ends up on the ground
* by default, even when the camera is pointing at the sky.
*/
namespace PS_Editor_GroundSnap
{
/**
* Project a camera-style ray onto ground.
*
* Sequence:
* 1. Line-trace from Start along Direction for up to MaxCameraDistance.
* - Hit a surface whose normal points UP / sideways -> return ImpactPoint.
* - Hit a surface whose normal points DOWN (ceiling, overhang) -> re-cast straight
* down from the impact point so the object lands on the floor underneath the
* overhead surface instead of glued to the ceiling.
* 2. Camera ray missed entirely (user aiming at the sky). Fall back to a point at
* DefaultDistance in front of the camera, then cast DOWN from there for
* DownTraceDistance. If THAT hits, return the downward impact.
* 3. Still nothing (infinite drop) -> return the in-front fallback unmodified so the
* caller at least gets a point in world space instead of a zero vector.
*
* @param IgnoreActors Actors the line trace must skip (typically the player pawn and,
* for re-snapping an existing actor, the actor itself — otherwise
* the trace hits its own mesh and nothing moves).
*/
PS_EDITOR_API FVector ProjectCameraRayToGround(UWorld* World,
const FVector& Start,
const FVector& Direction,
const TArray<AActor*>& IgnoreActors = {},
float MaxCameraDistance = 20000.0f,
float DefaultDistance = 1500.0f,
float DownTraceDistance = 50000.0f);
/**
* Compute a world position such that Actor's bounding-box bottom lands on the ground
* directly under its current XY position. Optionally adds a SpawnableComponent's
* GroundOffset for props that look better floating slightly (rugs, characters, etc.).
*
* Properly handles pivots not at bbox center (e.g. characters with pivot at feet) by
* measuring the pivot-to-bbox-bottom delta from GetActorBounds.
*
* The downward ray starts AT the actor's pivot (+ small buffer), not above its bbox.
* This keeps the trace origin within the current room (the pivot is always inside or
* on the edge of the actor's own volume) so the snap lands on the floor the actor is
* currently in, even when the actor has been nudged slightly into a ceiling. A multi-
* hit trace + a normal.Z >= 0.3 floor filter skips walls, ceilings, and backfaces
* encountered on the way down.
*
* OverheadRaise is kept for API compatibility but is no longer used — starting above
* the bbox was exactly what caused "End teleports to the upper story" bugs.
*
* @return true + OutLocation filled when ground was found; false when nothing is below.
*/
PS_EDITOR_API bool SnapActorToGround(AActor* Actor,
FVector& OutLocation,
float OverheadRaise = 50.0f,
float MaxDistance = 50000.0f,
const TArray<AActor*>& ExtraIgnored = {});
}

View File

@@ -3,6 +3,7 @@
#include "PS_Editor_UndoManager.h" #include "PS_Editor_UndoManager.h"
#include "PS_Editor_PlayerController.h" #include "PS_Editor_PlayerController.h"
#include "PS_Editor_SpawnManager.h" #include "PS_Editor_SpawnManager.h"
#include "PS_Editor_GroundSnap.h"
#include "PS_Editor_EditableComponent.h" #include "PS_Editor_EditableComponent.h"
#include "PS_Editor_SpawnableComponent.h" #include "PS_Editor_SpawnableComponent.h"
#include "PS_Editor_SplineActor.h" #include "PS_Editor_SplineActor.h"
@@ -448,6 +449,11 @@ void UPS_Editor_SelectionManager::SnapSelectionToGround()
TSharedPtr<FPS_Editor_TransformAction> UndoAction = MakeShared<FPS_Editor_TransformAction>(); TSharedPtr<FPS_Editor_TransformAction> UndoAction = MakeShared<FPS_Editor_TransformAction>();
UndoAction->Description = FString::Printf(TEXT("Snap to ground %d actor(s)"), SelectedActors.Num()); UndoAction->Description = FString::Printf(TEXT("Snap to ground %d actor(s)"), SelectedActors.Num());
// Extra ignores shared across every actor in the batch.
TArray<AActor*> ExtraIgnored;
if (APawn* Pwn = PC->GetPawn()) ExtraIgnored.Add(Pwn);
if (Gizmo) ExtraIgnored.Add(Gizmo);
for (const TWeakObjectPtr<AActor>& Weak : SelectedActors) for (const TWeakObjectPtr<AActor>& Weak : SelectedActors)
{ {
AActor* Actor = Weak.Get(); AActor* Actor = Weak.Get();
@@ -456,36 +462,11 @@ void UPS_Editor_SelectionManager::SnapSelectionToGround()
UndoAction->Actors.Add(Actor); UndoAction->Actors.Add(Actor);
UndoAction->OldTransforms.Add(Actor->GetActorTransform()); UndoAction->OldTransforms.Add(Actor->GetActorTransform());
FVector Origin, Extent; FVector NewLocation;
Actor->GetActorBounds(false, Origin, Extent); if (PS_Editor_GroundSnap::SnapActorToGround(Actor, NewLocation,
float BottomOffset = Extent.Z; /*OverheadRaise*/ 50.0f, /*MaxDistance*/ 50000.0f, ExtraIgnored))
// Add custom ground offset from SpawnableComponent if available
if (UPS_Editor_SpawnableComponent* SpawnComp = Actor->FindComponentByClass<UPS_Editor_SpawnableComponent>())
{ {
BottomOffset += SpawnComp->GroundOffset;
}
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_Camera, Params))
{
FVector NewLocation = Hit.ImpactPoint;
NewLocation.Z += BottomOffset;
Actor->SetActorLocation(NewLocation); Actor->SetActorLocation(NewLocation);
UE_LOG(LogTemp, Log, TEXT("PS_Editor: SnapToGround '%s' -> Hit '%s' (%s) at Z=%.1f, BottomOffset=%.1f, NewZ=%.1f"),
*Actor->GetName(),
*Hit.GetActor()->GetName(),
*Hit.GetComponent()->GetName(),
Hit.ImpactPoint.Z, BottomOffset, NewLocation.Z);
} }
else else
{ {

View File

@@ -106,6 +106,11 @@ public:
FPS_Editor_OnSelectionChanged OnSelectionChanged; FPS_Editor_OnSelectionChanged OnSelectionChanged;
/** Update gizmo visibility, position, orientation, and axis constraints from the
* current selection. Called on every selection mutation, and per-tick during
* simulation so the gizmo tracks animated pawns. */
void UpdateGizmo();
private: private:
UPROPERTY() UPROPERTY()
TWeakObjectPtr<APlayerController> OwnerPC; TWeakObjectPtr<APlayerController> OwnerPC;
@@ -127,7 +132,4 @@ private:
/** Clean up any stale weak pointers in the selection array. */ /** Clean up any stale weak pointers in the selection array. */
void CleanupStaleReferences(); void CleanupStaleReferences();
/** Update gizmo visibility and position based on current selection. */
void UpdateGizmo();
}; };

View File

@@ -5,6 +5,7 @@
#include "PS_Editor_UndoManager.h" #include "PS_Editor_UndoManager.h"
#include "PS_Editor_PlayerController.h" #include "PS_Editor_PlayerController.h"
#include "PS_Editor_TimelineSubsystem.h" #include "PS_Editor_TimelineSubsystem.h"
#include "PS_Editor_GroundSnap.h"
#include "AIController.h" #include "AIController.h"
#include "BrainComponent.h" #include "BrainComponent.h"
#include "GameFramework/PlayerController.h" #include "GameFramework/PlayerController.h"
@@ -159,27 +160,19 @@ AActor* UPS_Editor_SpawnManager::SpawnFromCatalogAtLocation(int32 Index, FVector
SpawnedActors.Add(SpawnedActor); SpawnedActors.Add(SpawnedActor);
RegisterSpawnedActorId(PC->GetWorld(), SpawnedActor); RegisterSpawnedActorId(PC->GetWorld(), SpawnedActor);
// Auto-snap to ground if configured // Auto-snap to ground if configured — uses the shared helper so it handles pivot
// positions (characters with pivot at feet) + ignores the fresh actor itself.
UPS_Editor_SpawnableComponent* SpawnComp = SpawnedActor->FindComponentByClass<UPS_Editor_SpawnableComponent>(); UPS_Editor_SpawnableComponent* SpawnComp = SpawnedActor->FindComponentByClass<UPS_Editor_SpawnableComponent>();
if (SpawnComp && SpawnComp->bAutoSnapToGround && PC->GetWorld()) if (SpawnComp && SpawnComp->bAutoSnapToGround && PC->GetWorld())
{ {
FVector Origin, Extent; TArray<AActor*> ExtraIgnored;
SpawnedActor->GetActorBounds(false, Origin, Extent); if (APawn* Pwn = PC->GetPawn()) ExtraIgnored.Add(Pwn);
float BottomOffset = Extent.Z + SpawnComp->GroundOffset;
const FVector Start = SpawnedActor->GetActorLocation(); FVector SnappedLoc;
const FVector End = Start - FVector::UpVector * 100000.0f; if (PS_Editor_GroundSnap::SnapActorToGround(SpawnedActor, SnappedLoc,
/*OverheadRaise*/ 50.0f, /*MaxDistance*/ 50000.0f, ExtraIgnored))
FHitResult Hit;
FCollisionQueryParams SnapParams;
SnapParams.AddIgnoredActor(SpawnedActor);
SnapParams.AddIgnoredActor(PC->GetPawn());
if (PC->GetWorld()->LineTraceSingleByChannel(Hit, Start, End, ECC_Camera, SnapParams))
{ {
FVector NewLoc = Hit.ImpactPoint; SpawnedActor->SetActorLocation(SnappedLoc);
NewLoc.Z += BottomOffset;
SpawnedActor->SetActorLocation(NewLoc);
} }
} }
@@ -273,23 +266,14 @@ FVector UPS_Editor_SpawnManager::ComputeSpawnLocation() const
FRotator CameraRotation; FRotator CameraRotation;
PC->GetPlayerViewPoint(CameraLocation, CameraRotation); PC->GetPlayerViewPoint(CameraLocation, CameraRotation);
const FVector Direction = CameraRotation.Vector(); // Unified placement: camera ray first, and if the camera is pointing at the sky / empty
const float MaxDistance = 5000.0f; // space, fall back to a downward trace from a point in front of the camera so the spawn
const float DefaultDistance = 500.0f; // still lands on ground. Avoids the "aim up, object hovers in the sky" bug.
const float SurfaceOffset = 5.0f; TArray<AActor*> Ignored;
if (APawn* Pwn = PC->GetPawn()) Ignored.Add(Pwn);
FHitResult Hit; return PS_Editor_GroundSnap::ProjectCameraRayToGround(PC->GetWorld(), CameraLocation,
FCollisionQueryParams Params; CameraRotation.Vector(), Ignored);
Params.AddIgnoredActor(PC->GetPawn());
if (PC->GetWorld()->LineTraceSingleByChannel(Hit, CameraLocation, CameraLocation + Direction * MaxDistance, ECC_Visibility, Params))
{
// Hit something — place at impact point, slightly offset along the surface normal
return Hit.ImpactPoint + Hit.ImpactNormal * SurfaceOffset;
}
// Nothing hit — fallback to default distance
return CameraLocation + Direction * DefaultDistance;
} }
AActor* UPS_Editor_SpawnManager::SpawnActorDirect(UClass* ActorClass, FVector Location, FRotator Rotation, FVector Scale) AActor* UPS_Editor_SpawnManager::SpawnActorDirect(UClass* ActorClass, FVector Location, FRotator Rotation, FVector Scale)

View File

@@ -5,7 +5,9 @@
#include "ProceduralMeshComponent.h" #include "ProceduralMeshComponent.h"
#include "Components/StaticMeshComponent.h" #include "Components/StaticMeshComponent.h"
#include "Engine/StaticMesh.h" #include "Engine/StaticMesh.h"
#include "Materials/MaterialInterface.h"
#include "Materials/MaterialInstanceDynamic.h" #include "Materials/MaterialInstanceDynamic.h"
#include "UObject/ConstructorHelpers.h"
APS_Editor_SplineActor::APS_Editor_SplineActor() APS_Editor_SplineActor::APS_Editor_SplineActor()
{ {
@@ -50,25 +52,46 @@ APS_Editor_SplineActor::APS_Editor_SplineActor()
EditableComp->EditableProperties.Add({FName(TEXT("SplineColor"))}); EditableComp->EditableProperties.Add({FName(TEXT("SplineColor"))});
EditableComp->EditableProperties.Add({FName(TEXT("SplineSelectedColor"))}); EditableComp->EditableProperties.Add({FName(TEXT("SplineSelectedColor"))});
EditableComp->EditableProperties.Add({FName(TEXT("SplineTag"))}); EditableComp->EditableProperties.Add({FName(TEXT("SplineTag"))});
// Hard-ref plugin + engine assets at CDO-construction time so the cooker follows them
// into packaged builds. Replaces the old runtime LoadObject<> soft-string paths.
static ConstructorHelpers::FObjectFinder<UMaterialInterface> GizmoBaseMatFinder(
TEXT("/PS_Editor/M_PS_Editor_Gizmo.M_PS_Editor_Gizmo"));
if (GizmoBaseMatFinder.Succeeded())
{
GizmoBaseMaterial = GizmoBaseMatFinder.Object;
}
static ConstructorHelpers::FObjectFinder<UMaterialInterface> SplineLineMatFinder(
TEXT("/PS_Editor/M_PS_Editor_SplineLine.M_PS_Editor_SplineLine"));
if (SplineLineMatFinder.Succeeded())
{
SplineLineMaterial = SplineLineMatFinder.Object;
}
static ConstructorHelpers::FObjectFinder<UStaticMesh> CubeFinder(
TEXT("/Engine/BasicShapes/Cube.Cube"));
if (CubeFinder.Succeeded())
{
CenterCubeMesh = CubeFinder.Object;
}
static ConstructorHelpers::FObjectFinder<UStaticMesh> SphereFinder(
TEXT("/Engine/BasicShapes/Sphere.Sphere"));
if (SphereFinder.Succeeded())
{
HandleSphereMesh = SphereFinder.Object;
}
} }
void APS_Editor_SplineActor::BeginPlay() void APS_Editor_SplineActor::BeginPlay()
{ {
Super::BeginPlay(); Super::BeginPlay();
// Gizmo material (no depth test, renders on top) for handles and selected state // GizmoBaseMaterial / SplineLineMaterial / meshes are resolved in the ctor via
UMaterialInterface* GizmoMat = LoadObject<UMaterialInterface>( // ConstructorHelpers — hard cook references, available here.
nullptr, TEXT("/PS_Editor/M_PS_Editor_Gizmo.M_PS_Editor_Gizmo")); UMaterialInterface* GizmoMat = GizmoBaseMaterial.Get();
UMaterialInterface* SplineLineMat = SplineLineMaterial.Get();
// Auto-generated opaque unlit material (depth tested) for spline line
UMaterialInterface* SplineLineMat = LoadObject<UMaterialInterface>(
nullptr, TEXT("/PS_Editor/M_PS_Editor_SplineLine.M_PS_Editor_SplineLine"));
if (!SplineLineMat)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: M_PS_Editor_SplineLine not found, trying without double name..."));
SplineLineMat = LoadObject<UMaterialInterface>(nullptr, TEXT("/PS_Editor/M_PS_Editor_SplineLine"));
}
// Spline line: normal depth-tested material (uses SplineColor property) // Spline line: normal depth-tested material (uses SplineColor property)
if (SplineLineMat) if (SplineLineMat)
@@ -102,10 +125,9 @@ void APS_Editor_SplineActor::BeginPlay()
CenterCube = NewObject<UStaticMeshComponent>(this); CenterCube = NewObject<UStaticMeshComponent>(this);
CenterCube->SetupAttachment(GetRootComponent()); CenterCube->SetupAttachment(GetRootComponent());
CenterCube->RegisterComponent(); CenterCube->RegisterComponent();
UStaticMesh* CubeMesh = LoadObject<UStaticMesh>(nullptr, TEXT("/Engine/BasicShapes/Cube.Cube")); if (CenterCubeMesh)
if (CubeMesh)
{ {
CenterCube->SetStaticMesh(CubeMesh); CenterCube->SetStaticMesh(CenterCubeMesh);
} }
CenterCube->SetWorldScale3D(FVector(CenterCubeScale)); CenterCube->SetWorldScale3D(FVector(CenterCubeScale));
CenterCube->SetCollisionEnabled(ECollisionEnabled::QueryOnly); CenterCube->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
@@ -310,11 +332,10 @@ UStaticMeshComponent* APS_Editor_SplineActor::CreatePointHandle(const FVector& W
Handle->SetupAttachment(HandleRoot); Handle->SetupAttachment(HandleRoot);
Handle->RegisterComponent(); Handle->RegisterComponent();
// Load sphere mesh // HandleSphereMesh resolved via ConstructorHelpers (hard cook ref).
UStaticMesh* SphereMesh = LoadObject<UStaticMesh>(nullptr, TEXT("/Engine/BasicShapes/Sphere.Sphere")); if (HandleSphereMesh)
if (SphereMesh)
{ {
Handle->SetStaticMesh(SphereMesh); Handle->SetStaticMesh(HandleSphereMesh);
} }
Handle->SetWorldLocation(WorldPosition); Handle->SetWorldLocation(WorldPosition);

View File

@@ -10,6 +10,8 @@ class USplineComponent;
class UProceduralMeshComponent; class UProceduralMeshComponent;
class UPS_Editor_SpawnableComponent; class UPS_Editor_SpawnableComponent;
class UPS_Editor_EditableComponent; class UPS_Editor_EditableComponent;
class UMaterialInterface;
class UStaticMesh;
/** /**
* Runtime spline actor for path/route editing in PS_Editor. * Runtime spline actor for path/route editing in PS_Editor.
@@ -166,6 +168,23 @@ private:
UPROPERTY() UPROPERTY()
TObjectPtr<UMaterialInstanceDynamic> CenterCubeMat; TObjectPtr<UMaterialInstanceDynamic> CenterCubeMat;
// ---- Hard-ref source assets (cooked into packaged builds via ConstructorHelpers) ----
/** Gizmo base material for spline handles / selected state (renders on top). */
UPROPERTY()
TObjectPtr<UMaterialInterface> GizmoBaseMaterial;
/** Depth-tested material for the normal spline line. */
UPROPERTY()
TObjectPtr<UMaterialInterface> SplineLineMaterial;
/** Engine basic shapes used for the center cube and point handles. */
UPROPERTY()
TObjectPtr<UStaticMesh> CenterCubeMesh;
UPROPERTY()
TObjectPtr<UStaticMesh> HandleSphereMesh;
UPROPERTY(VisibleAnywhere) UPROPERTY(VisibleAnywhere)
TObjectPtr<UPS_Editor_SpawnableComponent> SpawnableComp; TObjectPtr<UPS_Editor_SpawnableComponent> SpawnableComp;