Fixes for removal mode

This commit is contained in:
2026-09-12 15:25:34 +02:00
parent 4e4aa006f0
commit 861657d7ce
33 changed files with 330 additions and 70 deletions

View File

@@ -4,7 +4,6 @@
#include "MoveActorsComponent.h"
#include "../System/Subsystem/ObjectPersistenceSubsystem.h"
#include "../System/MainBlueprintFunctionLibrary.h"
#include "Kismet/KismetMathLibrary.h"
#include "Kismet/GameplayStatics.h"
#include "EnhancedInputSubsystems.h"
#include "../GameObjects/RemovableStaticMeshActor.h"
@@ -14,6 +13,17 @@
#include "Engine/StaticMeshActor.h"
#include "LandscapeProxy.h"
namespace
{
/** Snaps a single coordinate onto the grid lattice. The grid lines run through the world origin,
* exactly like UKismetMathLibrary::Vector_SnappedToGrid, but on one axis at a time and in double
* precision so that the result does not depend on the size of the world. */
double SnapCoordinateToGrid(double Coordinate, double GridSnap)
{
return GridSnap <= 0.0 ? Coordinate : FMath::RoundToDouble(Coordinate / GridSnap) * GridSnap;
}
}
// Sets default values for this component's properties
UMoveActorsComponent::UMoveActorsComponent()
{
@@ -66,17 +76,27 @@ void UMoveActorsComponent::TickComponent(float DeltaTime, ELevelTick TickType, F
// Prefer the per-actor tuning values, falling back to the component defaults.
const float Distance = MovingActor->DistanceFromActor > 0.f ? MovingActor->DistanceFromActor : PlacementDistance;
const float GridSnap = MovingActor->GridScale > 0.f ? MovingActor->GridScale : GridSnapSize;
FVector PosVector = PlayerCharacter->GetActorLocation() + (ForwardVec * Distance) + FVector(0.f, 0.f, PlacementHeightOffset);
if (bGridMovementActive)
{
PosVector = UKismetMathLibrary::Vector_SnappedToGrid(PosVector, GridSnap);
}
// The grid pitch is the base cell size of this component grown by the whole number multiplier of the
// object that is being moved, so an object that reserves more room still lands on the same lattice.
const double GridSnap = GetGridPitchForActor(MovingActor);
const FVector TargetScale = (MovingActor->ScaleOverride != FVector::ZeroVector) ? MovingActor->ScaleOverride : MovingActor->GetActorScale();
MovingActor->SetActorTransform(FTransform(MovingActor->GetActorRotation(), PosVector, TargetScale));
// The object is only snapped in the grid plane. Its height is decided by the surface probe below, so
// snapping the hover position on Z as well would make the object jump in grid sized steps while
// aiming, long before it ever touches a surface.
FVector PosVector = PlayerCharacter->GetActorLocation() + (ForwardVec * Distance) + FVector(0.f, 0.f, PlacementHeightOffset);
FRotator TargetRotation = MovingActor->GetActorRotation();
if (bGridMovementActive && GridRotationSnapDegrees > 0.f)
{
// A rotated object does not cover whole cells, which is what pulls it out of sync with the
// objects that are placed around it, so its yaw is quantized while the grid is active.
TargetRotation.Yaw = SnapCoordinateToGrid(TargetRotation.Yaw, GridRotationSnapDegrees);
}
MovingActor->SetActorLocation(SnapFootprintToGrid(PosVector, TargetScale, TargetRotation, GridSnap));
// Look for a surface to rest the object on. The probe starts above the object so it can
// also land on top of already placed actors (allowing objects to be stacked on each other)
@@ -262,6 +282,126 @@ FBoxSphereBounds UMoveActorsComponent::GetMovingActorMeshBounds() const
return FBoxSphereBounds(MovingActor->GetActorLocation(), FVector::ZeroVector, 0.0);
}
FVector UMoveActorsComponent::SnapFootprintToGrid(const FVector& InLocation, const FVector& TargetScale, const FRotator& TargetRotation, double GridSnap)
{
if (!bGridMovementActive || GridSnap <= 0.0 || !IsValid(MovingActor))
{
return InLocation;
}
// The rotation and the scale are applied before the footprint is measured: both of them change the
// bounds that the footprint is derived from.
MovingActor->SetActorTransform(FTransform(TargetRotation, InLocation, TargetScale));
if (GridFitMode == EPlaceableGridFitMode::Pivot)
{
return FVector(
SnapCoordinateToGrid(InLocation.X, GridSnap),
SnapCoordinateToGrid(InLocation.Y, GridSnap),
InLocation.Z);
}
FBoxSphereBounds MeshBounds = GetMovingActorMeshBounds();
const FVector Size = MeshBounds.BoxExtent * 2.0;
// An object without a mesh has no footprint to align, so it keeps the plain pivot snap.
if (Size.IsNearlyZero())
{
return FVector(
SnapCoordinateToGrid(InLocation.X, GridSnap),
SnapCoordinateToGrid(InLocation.Y, GridSnap),
InLocation.Z);
}
// A mesh that is not an exact multiple of the grid still owns whole cells: 175cm on a 50cm grid is
// four cells. Whole cells keep every object on the same lattice, so neighbours are always a whole
// number of cells apart and can never drift. Rounding up (instead of to the nearest cell) is what
// guarantees that an object fits inside the cells it claims and can never overlap the object next to
// it - the price is a symmetric margin inside the cell instead of a different gap for every object.
// This is what makes it possible to use meshes that were not modelled to the grid.
const double CellsX = GetGridCellCount(Size.X, GridSnap);
const double CellsY = GetGridCellCount(Size.Y, GridSnap);
if (GridFitMode == EPlaceableGridFitMode::FootprintFitted)
{
// The smaller of the two axis factors is used so that the mesh never spills over a cell and its
// proportions are kept. A mesh that would need a bigger change than GridFitMaxScaleDelta keeps
// its margin instead of being distorted.
const double MaxDelta = GridFitMaxScaleDelta;
const double FitScale = FMath::Min(
(CellsX * GridSnap) / FMath::Max(Size.X, 1.0),
(CellsY * GridSnap) / FMath::Max(Size.Y, 1.0));
const double ClampedFitScale = FMath::Clamp(FitScale, 1.0 - MaxDelta, 1.0 + MaxDelta);
if (!FMath::IsNearlyEqual(ClampedFitScale, 1.0, 0.001))
{
MovingActor->SetActorScale3D(TargetScale * ClampedFitScale);
MeshBounds = GetMovingActorMeshBounds();
}
}
// The bounding box is centered in the cells it occupies instead of the pivot being put on a grid
// line, so meshes whose pivot sits in a corner line up with the rest of the world. Z is untouched.
const double BlockMinX = SnapCoordinateToGrid(MeshBounds.Origin.X - MeshBounds.BoxExtent.X, GridSnap);
const double BlockMinY = SnapCoordinateToGrid(MeshBounds.Origin.Y - MeshBounds.BoxExtent.Y, GridSnap);
const FVector DesiredCenter(
BlockMinX + (CellsX * GridSnap) * 0.5,
BlockMinY + (CellsY * GridSnap) * 0.5,
MeshBounds.Origin.Z);
return InLocation + (DesiredCenter - MeshBounds.Origin);
}
FVector2D UMoveActorsComponent::GetMovingActorGridFootprint() const
{
const double GridSnap = GetGridPitchForActor(MovingActor);
if (GridSnap <= 0.0)
{
return FVector2D::ZeroVector;
}
const FVector Size = GetMovingActorMeshBounds().BoxExtent * 2.0;
if (Size.IsNearlyZero())
{
return FVector2D::ZeroVector;
}
return FVector2D(
GetGridCellCount(Size.X, GridSnap),
GetGridCellCount(Size.Y, GridSnap));
}
double UMoveActorsComponent::GetGridPitchForActor(const ARemovableStaticMeshActor* Actor) const
{
return GetGridPitch(Actor, GridSnapSize);
}
double UMoveActorsComponent::GetGridPitch(const ARemovableStaticMeshActor* Actor, double BaseGridSnapSize)
{
// The multiplier is clamped here instead of being trusted from the property: it is writable from
// Blueprint, where the editor-only ClampMin does not apply, and a zero multiplier would turn the cell
// size into zero, which reads as "no grid" in every bit of maths below.
const double Multiplier = Actor ? static_cast<double>(Actor->GetGridSizeMultiplier()) : 1.0;
return BaseGridSnapSize * Multiplier;
}
double UMoveActorsComponent::GetGridCellCount(double Size, double GridSnap)
{
if (GridSnap <= 0.0)
{
return 1.0;
}
// The slack keeps a mesh that is exactly as wide as a whole number of cells (or a hair wider, since
// mesh bounds are never exact) from claiming an extra cell, which would leave a gap the size of a
// whole cell next to it.
constexpr double CellCountTolerance = 0.001;
return FMath::Max(1.0, FMath::CeilToDouble(Size / GridSnap - CellCountTolerance));
}
void UMoveActorsComponent::ToggleGridMovement()
{
bGridMovementActive = !bGridMovementActive;

View File

@@ -22,6 +22,22 @@ enum class ERemovalAction : uint8
MAX
};
/** How the object being moved is aligned with the placement grid. Meshes are rarely exact multiples of
* the grid pitch, which is what makes placed objects drift out of sync with the objects around them. */
UENUM(BlueprintType)
enum class EPlaceableGridFitMode : uint8
{
/** Snap the actor pivot to the grid. Only correct when every pivot sits at the center of its mesh. */
Pivot,
/** Quantize the mesh' footprint to whole grid cells (rounded up, so the mesh always fits inside the
* cells it claims) and center it in them. A 175cm mesh on a 50cm grid claims four cells (200cm) and
* keeps a 12.5cm margin on either side, so neighbours can never drift apart. */
FootprintCentered,
/** As FootprintCentered, but the mesh is also grown (within GridFitMaxScaleDelta) so that it fills
* the cells it occupies exactly, which removes the margin. */
FootprintFitted
};
class ARemovableStaticMeshActor;
class AEleriPlayerController;
class AMyCharacter;
@@ -53,8 +69,33 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement")
float PlacementHeightOffset = 100.f;
/** Default size of one grid cell, in Unreal units: 50 units is half a metre. The placed actors fall
* back on this value when they report their footprint while this component is not around yet (in the
* editor), so both use the same base size. */
static constexpr float DefaultGridSnapSize = 50.f;
/** Size of one grid cell, in Unreal units. Every object is placed on the lattice this defines, grown
* by the whole number multiplier that the placed actor carries (see ARemovableStaticMeshActor::
* GridScale). Keeping the base size in one place is what keeps objects of every size on a single
* lattice instead of letting each of them drift on a lattice of its own. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement", meta = (ClampMin = "1.0", UIMin = "1.0"))
float GridSnapSize = DefaultGridSnapSize;
/** How the object being moved is aligned with the grid, see EPlaceableGridFitMode. The pivot based
* mode is only correct when every mesh' pivot sits at the center of the mesh. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement")
float GridSnapSize = 250.f;
EPlaceableGridFitMode GridFitMode = EPlaceableGridFitMode::FootprintCentered;
/** Yaw steps the object is quantized to while the grid is active. An object that is rotated freely
* does not cover whole cells, which pushes it out of sync with the objects placed around it.
* Use 0 to keep free rotation. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement", meta = (ClampMin = "0.0"))
float GridRotationSnapDegrees = 90.f;
/** Largest relative size change FootprintFitted is allowed to apply so that a mesh fills its cells
* exactly. 0.15 = up to 15%. A mesh that would need more than this keeps its cell margin. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement", meta = (ClampMin = "0.0"))
float GridFitMaxScaleDelta = 0.15f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement")
float GroundTraceLength = 1000.f;
@@ -106,6 +147,12 @@ protected:
* the detection box and the interaction widget, which do not match the mesh that is placed. */
FBoxSphereBounds GetMovingActorMeshBounds() const;
/** Aligns the footprint of the object being moved with the placement grid and applies the rotation
* and the scale it is going to be placed with. Returns the location the actor has to be moved to.
* The pivot is not used for the alignment, because it is not guaranteed to sit at the center (or
* at the edge) of the mesh. */
FVector SnapFootprintToGrid(const FVector& InLocation, const FVector& TargetScale, const FRotator& TargetRotation, double GridSnap);
public:
// Called every frame
@@ -115,6 +162,28 @@ public:
UFUNCTION(BlueprintCallable, BlueprintPure)
FORCEINLINE bool IsGridMovementActive() const { return bGridMovementActive; }
/** Footprint of the object that is currently being moved, in whole grid cells (X = cells along X,
* Y = cells along Y). Handy for showing the size of an object in a build UI. Zero when nothing is
* being moved. */
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Placement")
FVector2D GetMovingActorGridFootprint() const;
/** Amount of whole grid cells a size spans. The footprint is rounded up, so that a mesh always fits
* inside the cells it claims, with a small tolerance against the float noise that mesh bounds carry.
* The placement and the footprint report both use this, so the two can never disagree. */
static double GetGridCellCount(double Size, double GridSnap);
/** Size of one grid cell for an actor: the base cell size of this component scaled by the whole
* number GridScale multiplier of the actor, so 1 keeps the base grid and 3 gives 150 units cells on
* a 50 units grid. The multiplier is applied here instead of being trusted from the property, so a
* stray zero (the property is writable from Blueprint as well) can not shrink the grid to nothing. */
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Placement")
double GetGridPitchForActor(const ARemovableStaticMeshActor* Actor) const;
/** As GetGridPitchForActor, but against an explicit base cell size. The actors use this to report
* their footprint while the component that owns the base size is not available (in the editor). */
static double GetGridPitch(const ARemovableStaticMeshActor* Actor, double BaseGridSnapSize);
UFUNCTION(BlueprintCallable, BlueprintPure)
bool IsRemovingModePossible() const { return bRemovalModePossible; }
UFUNCTION(BlueprintCallable, BlueprintPure)

View File

@@ -154,10 +154,44 @@ void ARemovableStaticMeshActor::Place(bool ReturnToOldLocation, bool bForce)
void ARemovableStaticMeshActor::Rotate(bool bPositive)
{
FRotator CurrentRotation = this->GetActorRotation();
CurrentRotation.Yaw += bPositive ? 45.f : -45.f;
CurrentRotation.Yaw += bPositive ? 90.f : -90.f;
this->SetActorRotation(CurrentRotation);
}
void ARemovableStaticMeshActor::ReportGridFootprint()
{
// The base cell size is a property of the Move Actors component, which only exists at runtime. While
// editing a placed actor the component is missing, so the class default is used, which is the value
// the placement uses unless the level overrides it.
const double BaseGridSnapSize = MoveableComponent ? MoveableComponent->GridSnapSize : UMoveActorsComponent::DefaultGridSnapSize;
const double Pitch = UMoveActorsComponent::GetGridPitch(this, BaseGridSnapSize);
if (Pitch <= 0.0)
{
UE_LOG(LogTemp, Warning, TEXT("%s: no grid pitch to report against. Set GridSnapSize on the Move Actors component."), *GetName());
return;
}
const UStaticMeshComponent* MeshComponent = GetStaticMeshComponent();
if (!MeshComponent || !MeshComponent->GetStaticMesh())
{
UE_LOG(LogTemp, Warning, TEXT("%s: no static mesh assigned."), *GetName());
return;
}
const FBoxSphereBounds MeshBounds = MeshComponent->CalcBounds(MeshComponent->GetComponentTransform());
const FVector Size = MeshBounds.BoxExtent * 2.0;
// The same cell rule that the placement uses, so the reported margin is the margin that the object
// will actually keep on the grid.
const double CellsX = UMoveActorsComponent::GetGridCellCount(Size.X, Pitch);
const double CellsY = UMoveActorsComponent::GetGridCellCount(Size.Y, Pitch);
const double FillScale = FMath::Max((CellsX * Pitch) / FMath::Max(Size.X, 1.0), (CellsY * Pitch) / FMath::Max(Size.Y, 1.0));
UE_LOG(LogTemp, Display, TEXT("%s: mesh %.1f x %.1f on a %.1f grid (%d x %.1f base) -> %.0f x %.0f cells (margin %.1f x %.1f, fill scale %.2f)"),
*GetName(), Size.X, Size.Y, Pitch, GetGridSizeMultiplier(), BaseGridSnapSize, CellsX, CellsY,
CellsX * Pitch - Size.X, CellsY * Pitch - Size.Y, FillScale);
}
void ARemovableStaticMeshActor::ReturnToInventory()
{
if (InventoryRef)

View File

@@ -68,8 +68,19 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadWrite)
float DistanceFromActor;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
float GridScale;
/** Whole number multiplier on the placement grid: the object is placed on cells that are
* GridSnapSize * GridScale units, so 1 (the default) keeps the grid the Move Actors component
* defines and 3 reserves 150 units blocks on a 50 units grid. Keep it a whole number: a fractional
* multiplier puts the object on a lattice of its own that only lines up with everything around it
* every now and then, which is exactly what makes placed objects drift. The value is clamped to at
* least 1 where it is used, a zero sized cell would mean "no grid at all". */
UPROPERTY(EditAnywhere, BlueprintReadWrite, meta = (ClampMin = "1", UIMin = "1"))
int32 GridScale = 1;
/** GridScale with its lower bound applied. The property is writable from Blueprint, where ClampMin
* does not apply, so the value is sanitised at the point of use instead of being trusted. */
UFUNCTION(BlueprintCallable, BlueprintPure)
FORCEINLINE int32 GetGridSizeMultiplier() const { return FMath::Max(1, GridScale); }
UPROPERTY(EditAnywhere, BlueprintReadWrite)
UMaterialInterface* CanPlaceMaterial;
@@ -92,6 +103,12 @@ public:
UFUNCTION(BlueprintCallable, Category = "Removable Object")
void UpdateOverlapBoxExtent();
/** Logs how the mesh of this actor relates to the placement grid: its size, the amount of cells it
* occupies and the scale that would make it fill those cells. Meshes do not have to be modelled to
* the grid, this only makes the margin they keep visible so that near misses stand out. */
UFUNCTION(CallInEditor, BlueprintCallable, Category = "Removable Object")
void ReportGridFootprint();
//Used for movable actors to override their box extent for checking collision
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Removable Object")
FVector BoxExtentOverride = FVector::ZeroVector;