Add runtime spline creation and editing (WIP)

Spline placement: New Spline button (S key) enters placement mode where clicks
add control points. Right-click/Enter finishes, Escape cancels. Min 2 points.
Spline visualization as cross-shaped quad strip (vertical + horizontal ribbons).

Point editing: select spline to show handles, click a handle to position gizmo
on it, then use translate gizmo to move individual points. Undo supported via
full-state snapshots (SplinePointAction).

Visual feedback: auto-generated M_PS_Editor_SplineLine material (unlit opaque
with depth test) for normal state. Gizmo material (no depth test) for selected
state. Center cube at centroid for whole-spline selection. Handles highlight
on selection.

Serialization: spline points exported to CustomProperties (PointCount + Point_N).
C++ class fallback via FindObject for LoadScene. Spline appears in scene outliner.

Still WIP: depth material swap on selection, serialization edge cases, and
point editing polish to be refined.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 22:44:04 +02:00
parent 50087e5dfb
commit 1f4b3994ff
15 changed files with 1222 additions and 25 deletions

View File

@@ -13,6 +13,7 @@
#include "PS_Editor_PlayerController.h"
#include "PS_Editor_Gizmo.h"
#include "PS_Editor_UndoManager.h"
#include "PS_Editor_SplineActor.h"
#include "DrawDebugHelpers.h"
#include "PS_Editor_ConfirmDialog.h"
@@ -199,6 +200,19 @@ void APS_Editor_Pawn::CreateInputActions()
IA_Paste = NewObject<UInputAction>(this, TEXT("IA_Paste"));
IA_Paste->ValueType = EInputActionValueType::Boolean;
EditorMappingContext->MapKey(IA_Paste, EKeys::V);
// Spline mode / Confirm / Cancel
IA_SplineMode = NewObject<UInputAction>(this, TEXT("IA_SplineMode"));
IA_SplineMode->ValueType = EInputActionValueType::Boolean;
EditorMappingContext->MapKey(IA_SplineMode, EKeys::S);
IA_Confirm = NewObject<UInputAction>(this, TEXT("IA_Confirm"));
IA_Confirm->ValueType = EInputActionValueType::Boolean;
EditorMappingContext->MapKey(IA_Confirm, EKeys::Enter);
IA_Cancel = NewObject<UInputAction>(this, TEXT("IA_Cancel"));
IA_Cancel->ValueType = EInputActionValueType::Boolean;
EditorMappingContext->MapKey(IA_Cancel, EKeys::Escape);
}
void APS_Editor_Pawn::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
@@ -242,6 +256,9 @@ void APS_Editor_Pawn::SetupPlayerInputComponent(UInputComponent* PlayerInputComp
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);
EIC->BindAction(IA_SplineMode, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleSplineMode);
EIC->BindAction(IA_Confirm, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleConfirm);
EIC->BindAction(IA_Cancel, ETriggerEvent::Started, this, &APS_Editor_Pawn::HandleCancel);
}
// ---- Input Handlers ----
@@ -357,11 +374,26 @@ void APS_Editor_Pawn::HandleLook(const FInputActionValue& Value)
}
const FVector ConstrainedDelta = DisplacementAlongAxis * AxisDir;
for (int32 i = 0; i < Selected.Num() && i < GizmoDragInitialTransforms.Num(); ++i)
// Spline point mode: move the single point instead of actors
if (ActiveSplinePointIndex >= 0 && DraggedSplineActor.IsValid())
{
if (AActor* Actor = Selected[i].Get())
if (APS_Editor_SplineActor* SplineActor = Cast<APS_Editor_SplineActor>(DraggedSplineActor.Get()))
{
Actor->SetActorLocation(GizmoDragInitialTransforms[i].GetLocation() + ConstrainedDelta);
if (SplinePointDragInitialState.IsValidIndex(ActiveSplinePointIndex))
{
const FVector NewPos = SplinePointDragInitialState[ActiveSplinePointIndex] + ConstrainedDelta;
SplineActor->MovePoint(ActiveSplinePointIndex, NewPos);
}
}
}
else
{
for (int32 i = 0; i < Selected.Num() && i < GizmoDragInitialTransforms.Num(); ++i)
{
if (AActor* Actor = Selected[i].Get())
{
Actor->SetActorLocation(GizmoDragInitialTransforms[i].GetLocation() + ConstrainedDelta);
}
}
}
}
@@ -432,10 +464,33 @@ void APS_Editor_Pawn::HandleLook(const FInputActionValue& Value)
}
}
// In translate mode, gizmo follows the selection. In rotate/scale, stay at initial position.
// In translate mode, gizmo follows the target
if (Mode == EPS_Editor_TransformMode::Translate)
{
Gizmo->SetActorLocation(SM->GetSelectionPivot());
if (ActiveSplinePointIndex >= 0 && DraggedSplineActor.IsValid())
{
// Following a single spline point
if (APS_Editor_SplineActor* SplineActor = Cast<APS_Editor_SplineActor>(DraggedSplineActor.Get()))
{
Gizmo->SetActorLocation(SplineActor->GetPointLocation(ActiveSplinePointIndex));
}
}
else if (Selected.Num() == 1)
{
// Single actor: use centroid for splines, location for others
if (APS_Editor_SplineActor* SplineActor = Cast<APS_Editor_SplineActor>(Selected[0].Get()))
{
Gizmo->SetActorLocation(SplineActor->GetCentroid());
}
else
{
Gizmo->SetActorLocation(SM->GetSelectionPivot());
}
}
else
{
Gizmo->SetActorLocation(SM->GetSelectionPivot());
}
}
// Update local orientation in real-time during rotation drag
@@ -447,6 +502,25 @@ void APS_Editor_Pawn::HandleLook(const FInputActionValue& Value)
}
}
}
else if (CameraMode == EPS_Editor_CameraMode::SplinePointDrag)
{
// Drag spline point along horizontal plane
APS_Editor_SplineActor* Spline = Cast<APS_Editor_SplineActor>(DraggedSplineActor.Get());
if (!Spline || ActiveSplinePointIndex < 0) return;
FVector RayOrigin, RayDir;
if (!GetMouseWorldRay(RayOrigin, RayDir)) return;
// Ray-plane intersection
const float Denom = FVector::DotProduct(RayDir, SplinePointDragPlaneNormal);
if (FMath::Abs(Denom) < KINDA_SMALL_NUMBER) return;
const float T = FVector::DotProduct(SplinePointDragPlaneOrigin - RayOrigin, SplinePointDragPlaneNormal) / Denom;
if (T < 0.0f) return;
const FVector NewPos = RayOrigin + RayDir * T;
Spline->MovePoint(ActiveSplinePointIndex, NewPos);
}
}
void APS_Editor_Pawn::HandleScroll(const FInputActionValue& Value)
@@ -502,6 +576,60 @@ void APS_Editor_Pawn::HandleLeftClickStarted(const FInputActionValue& Value)
}
}
// Check for spline point handle hit → select point, move gizmo to it (no drag yet)
if (APS_Editor_PlayerController* EdPC = Cast<APS_Editor_PlayerController>(GetController()))
{
if (EdPC->GetEditorMode() == EPS_Editor_EditorMode::Normal)
{
if (UPS_Editor_SelectionManager* SM = EdPC->GetSelectionManager())
{
for (const TWeakObjectPtr<AActor>& Weak : SM->GetSelectedActors())
{
if (APS_Editor_SplineActor* Spline = Cast<APS_Editor_SplineActor>(Weak.Get()))
{
// First check: did we hit the center cube? If so, skip handle detection
// (let the click fall through to normal selection → gizmo at actor pivot)
FHitResult SplineHit;
FCollisionQueryParams SplineParams;
SplineParams.AddIgnoredActor(this);
const FVector TraceEnd = RayOrigin + RayDir * 100000.0f;
if (GetWorld()->LineTraceSingleByChannel(SplineHit, RayOrigin, TraceEnd, ECC_Visibility, SplineParams))
{
if (Spline->IsCenterCube(SplineHit.GetComponent()))
{
// Center cube hit → reset point selection, let normal flow handle it
ActiveSplinePointIndex = -1;
DraggedSplineActor = nullptr;
Spline->ClearHandleHighlight();
break;
}
}
const int32 HandleIdx = Spline->GetHandleIndexUnderCursor(RayOrigin, RayDir);
if (HandleIdx >= 0)
{
ActiveSplinePointIndex = HandleIdx;
DraggedSplineActor = Spline;
Spline->HighlightHandle(HandleIdx);
if (APS_Editor_Gizmo* Gizmo = SM->GetGizmo())
{
Gizmo->SetActorLocation(Spline->GetPointLocation(HandleIdx));
}
SM->SetTransformMode(EPS_Editor_TransformMode::Translate);
return;
}
}
}
}
}
}
// Clicking elsewhere resets spline point selection
ActiveSplinePointIndex = -1;
DraggedSplineActor = nullptr;
// No gizmo hit: enter PendingClick
PendingClickScreenPos = GetMouseScreenPosition();
SetCameraMode(EPS_Editor_CameraMode::PendingClick);
@@ -522,8 +650,22 @@ void APS_Editor_Pawn::HandleLeftClickCompleted(const FInputActionValue& Value)
{
if (UPS_Editor_UndoManager* UM = EditorPC->GetUndoManager())
{
if (UPS_Editor_SelectionManager* SM = EditorPC->GetSelectionManager())
// Spline point drag: record SplinePointAction
if (ActiveSplinePointIndex >= 0 && DraggedSplineActor.IsValid())
{
if (APS_Editor_SplineActor* SplineActor = Cast<APS_Editor_SplineActor>(DraggedSplineActor.Get()))
{
TSharedPtr<FPS_Editor_SplinePointAction> Action = MakeShared<FPS_Editor_SplinePointAction>();
Action->SplineActor = SplineActor;
Action->OldPoints = SplinePointDragInitialState;
Action->NewPoints = SplineActor->GetAllPointLocations();
Action->Description = TEXT("Move spline point");
UM->RecordAction(Action);
}
}
else if (UPS_Editor_SelectionManager* SM = EditorPC->GetSelectionManager())
{
// Normal: record TransformAction
TSharedPtr<FPS_Editor_TransformAction> Action = MakeShared<FPS_Editor_TransformAction>();
const TArray<TWeakObjectPtr<AActor>>& Selected = SM->GetSelectedActors();
@@ -555,17 +697,53 @@ 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)
// Refresh gizmo: keep at spline point if editing, else refresh orientation
if (APS_Editor_PlayerController* PC2 = Cast<APS_Editor_PlayerController>(GetController()))
{
if (UPS_Editor_SelectionManager* SM2 = PC2->GetSelectionManager())
{
SM2->RefreshGizmoOrientation();
if (ActiveSplinePointIndex >= 0 && DraggedSplineActor.IsValid())
{
// Keep gizmo at the active spline point
if (APS_Editor_SplineActor* SplineActor = Cast<APS_Editor_SplineActor>(DraggedSplineActor.Get()))
{
if (APS_Editor_Gizmo* G = SM2->GetGizmo())
{
G->SetActorLocation(SplineActor->GetPointLocation(ActiveSplinePointIndex));
}
}
}
else
{
SM2->RefreshGizmoOrientation();
}
}
}
SetCameraMode(EPS_Editor_CameraMode::Idle);
}
else if (CameraMode == EPS_Editor_CameraMode::SplinePointDrag)
{
// Record undo for spline point move
if (APS_Editor_SplineActor* Spline = Cast<APS_Editor_SplineActor>(DraggedSplineActor.Get()))
{
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController()))
{
if (UPS_Editor_UndoManager* UM = EditorPC->GetUndoManager())
{
TSharedPtr<FPS_Editor_SplinePointAction> Action = MakeShared<FPS_Editor_SplinePointAction>();
Action->SplineActor = Spline;
Action->OldPoints = SplinePointDragInitialState;
Action->NewPoints = Spline->GetAllPointLocations();
Action->Description = TEXT("Move spline point");
UM->RecordAction(Action);
}
}
}
ActiveSplinePointIndex = -1;
DraggedSplineActor = nullptr;
SetCameraMode(EPS_Editor_CameraMode::Idle);
}
else if (CameraMode == EPS_Editor_CameraMode::Pan || CameraMode == EPS_Editor_CameraMode::Orbit)
{
SetCameraMode(EPS_Editor_CameraMode::Idle);
@@ -574,6 +752,15 @@ void APS_Editor_Pawn::HandleLeftClickCompleted(const FInputActionValue& Value)
void APS_Editor_Pawn::HandleRightClickStarted(const FInputActionValue& Value)
{
// In spline placement mode, right-click finishes the spline
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController()))
{
if (EditorPC->GetEditorMode() == EPS_Editor_EditorMode::SplinePlacement)
{
EditorPC->FinishSplinePlacement();
return;
}
}
SetCameraMode(EPS_Editor_CameraMode::Fly);
}
@@ -760,6 +947,44 @@ void APS_Editor_Pawn::HandlePaste(const FInputActionValue& Value)
}
}
void APS_Editor_Pawn::HandleSplineMode(const FInputActionValue& Value)
{
if (CameraMode != EPS_Editor_CameraMode::Idle) return;
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController()))
{
if (EditorPC->GetEditorMode() == EPS_Editor_EditorMode::SplinePlacement)
{
EditorPC->FinishSplinePlacement();
}
else
{
EditorPC->BeginSplinePlacement();
}
}
}
void APS_Editor_Pawn::HandleConfirm(const FInputActionValue& Value)
{
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController()))
{
if (EditorPC->GetEditorMode() == EPS_Editor_EditorMode::SplinePlacement)
{
EditorPC->FinishSplinePlacement();
}
}
}
void APS_Editor_Pawn::HandleCancel(const FInputActionValue& Value)
{
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetController()))
{
if (EditorPC->GetEditorMode() == EPS_Editor_EditorMode::SplinePlacement)
{
EditorPC->CancelSplinePlacement();
}
}
}
// ---- Helpers ----
void APS_Editor_Pawn::SetCameraMode(EPS_Editor_CameraMode NewMode)
@@ -944,5 +1169,14 @@ void APS_Editor_Pawn::BeginGizmoDrag(EPS_Editor_GizmoAxis Axis)
}
}
// Store initial spline state if editing a point
if (ActiveSplinePointIndex >= 0 && DraggedSplineActor.IsValid())
{
if (APS_Editor_SplineActor* Spline = Cast<APS_Editor_SplineActor>(DraggedSplineActor.Get()))
{
SplinePointDragInitialState = Spline->GetAllPointLocations();
}
}
SetCameraMode(EPS_Editor_CameraMode::GizmoDrag);
}

View File

@@ -31,7 +31,9 @@ enum class EPS_Editor_CameraMode : uint8
/** Alt + left-click or middle-click: orbit around focal point. */
Orbit,
/** Dragging a gizmo handle. */
GizmoDrag
GizmoDrag,
/** Dragging a spline point handle. */
SplinePointDrag
};
/**
@@ -180,6 +182,22 @@ private:
UPROPERTY()
TObjectPtr<UInputAction> IA_Paste;
UPROPERTY()
TObjectPtr<UInputAction> IA_SplineMode;
UPROPERTY()
TObjectPtr<UInputAction> IA_Confirm;
UPROPERTY()
TObjectPtr<UInputAction> IA_Cancel;
// ---- Spline point drag state ----
int32 ActiveSplinePointIndex = -1;
TWeakObjectPtr<AActor> DraggedSplineActor;
TArray<FVector> SplinePointDragInitialState;
FVector SplinePointDragPlaneOrigin;
FVector SplinePointDragPlaneNormal;
// ---- Modifier key state ----
bool bAltHeld = false;
bool bCtrlHeld = false;
@@ -216,6 +234,9 @@ private:
void HandleDuplicate(const FInputActionValue& Value);
void HandleCopy(const FInputActionValue& Value);
void HandlePaste(const FInputActionValue& Value);
void HandleSplineMode(const FInputActionValue& Value);
void HandleConfirm(const FInputActionValue& Value);
void HandleCancel(const FInputActionValue& Value);
// ---- Helpers ----
void CreateInputActions();

View File

@@ -5,6 +5,7 @@
#include "PS_Editor_SceneSerializer.h"
#include "PS_Editor_Gizmo.h"
#include "PS_Editor_Pawn.h"
#include "PS_Editor_SplineActor.h"
APS_Editor_PlayerController::APS_Editor_PlayerController()
{
@@ -80,8 +81,116 @@ void APS_Editor_PlayerController::OnPossess(APawn* InPawn)
}
}
void APS_Editor_PlayerController::BeginSplinePlacement()
{
if (SelectionManager)
{
SelectionManager->ClearSelection();
}
ActiveSplineActor = nullptr;
EditorMode = EPS_Editor_EditorMode::SplinePlacement;
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Spline placement mode started"));
}
void APS_Editor_PlayerController::FinishSplinePlacement()
{
if (!ActiveSplineActor || ActiveSplineActor->GetNumPoints() < 2)
{
CancelSplinePlacement();
return;
}
// Hide handles after placement (shown again on selection)
ActiveSplineActor->SetHandlesVisible(false);
// Track in SpawnManager — CRITICAL for outliner, save/load, and scene clear
if (SpawnManager)
{
SpawnManager->TrackSpawnedActor(ActiveSplineActor);
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Spline tracked by SpawnManager (total: %d)"), SpawnManager->GetSpawnedActors().Num());
}
else
{
UE_LOG(LogTemp, Error, TEXT("PS_Editor: SpawnManager is null! Spline will NOT be tracked."));
}
// Record spawn action for undo
if (UndoManager)
{
TSharedPtr<FPS_Editor_SpawnAction> Action = MakeShared<FPS_Editor_SpawnAction>();
Action->Actors.Add(ActiveSplineActor);
Action->Description = FString::Printf(TEXT("Create spline (%d points)"), ActiveSplineActor->GetNumPoints());
UndoManager->RecordAction(Action);
}
// Select the new spline
if (SelectionManager)
{
SelectionManager->SelectActor(ActiveSplineActor);
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Spline placed with %d points, class=%s"),
ActiveSplineActor->GetNumPoints(), *ActiveSplineActor->GetClass()->GetPathName());
ActiveSplineActor = nullptr;
EditorMode = EPS_Editor_EditorMode::Normal;
}
void APS_Editor_PlayerController::CancelSplinePlacement()
{
if (ActiveSplineActor)
{
ActiveSplineActor->Destroy();
ActiveSplineActor = nullptr;
}
EditorMode = EPS_Editor_EditorMode::Normal;
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Spline placement cancelled"));
}
void APS_Editor_PlayerController::HandleEditorClick(FVector2D ScreenPos, bool bAdditive)
{
if (EditorMode == EPS_Editor_EditorMode::SplinePlacement)
{
// Deproject click to world position
FVector WorldOrigin, WorldDirection;
if (!DeprojectScreenPositionToWorld(ScreenPos.X, ScreenPos.Y, WorldOrigin, WorldDirection))
return;
FHitResult Hit;
FCollisionQueryParams Params;
Params.AddIgnoredActor(GetPawn());
if (ActiveSplineActor) Params.AddIgnoredActor(ActiveSplineActor);
const FVector End = WorldOrigin + WorldDirection * 100000.0f;
FVector ClickWorldPos;
if (GetWorld()->LineTraceSingleByChannel(Hit, WorldOrigin, End, ECC_Visibility, Params))
{
ClickWorldPos = Hit.ImpactPoint;
}
else
{
ClickWorldPos = WorldOrigin + WorldDirection * 1000.0f;
}
if (!ActiveSplineActor)
{
// First click: spawn the spline actor
FActorSpawnParameters SpawnParams;
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
ActiveSplineActor = GetWorld()->SpawnActor<APS_Editor_SplineActor>(
APS_Editor_SplineActor::StaticClass(), ClickWorldPos, FRotator::ZeroRotator, SpawnParams);
}
if (ActiveSplineActor)
{
ActiveSplineActor->AddPoint(ClickWorldPos);
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Spline point %d added at %s"),
ActiveSplineActor->GetNumPoints(), *ClickWorldPos.ToString());
}
return;
}
// Normal mode: selection
if (SelectionManager)
{
SelectionManager->HandleClickAtScreenPosition(ScreenPos, bAdditive);

View File

@@ -2,6 +2,7 @@
#include "CoreMinimal.h"
#include "GameFramework/PlayerController.h"
#include "PS_Editor_SelectionTypes.h"
#include "PS_Editor_PlayerController.generated.h"
class UPS_Editor_SelectionManager;
@@ -9,6 +10,7 @@ class UPS_Editor_UndoManager;
class UPS_Editor_SpawnManager;
class UPS_Editor_SpawnCatalog;
class UPS_Editor_SceneSerializer;
class APS_Editor_SplineActor;
/**
* Player controller for PS_Editor runtime editing.
@@ -38,6 +40,23 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS Editor")
TSoftObjectPtr<UPS_Editor_SpawnCatalog> SpawnCatalogAsset;
// ---- Editor Mode (Normal / SplinePlacement) ----
UFUNCTION(BlueprintCallable, Category = "PS Editor")
EPS_Editor_EditorMode GetEditorMode() const { return EditorMode; }
/** Enter spline placement mode. Clicks will add points. */
void BeginSplinePlacement();
/** Finish current spline (min 2 points required). Returns to Normal mode. */
void FinishSplinePlacement();
/** Cancel current spline placement (destroys in-progress spline). */
void CancelSplinePlacement();
/** Get the spline currently being placed (nullptr if not in SplinePlacement mode). */
APS_Editor_SplineActor* GetActiveSplineActor() const { return ActiveSplineActor; }
protected:
virtual void BeginPlay() override;
virtual void OnPossess(APawn* InPawn) override;
@@ -55,5 +74,10 @@ private:
UPROPERTY()
TObjectPtr<UPS_Editor_SceneSerializer> SceneSerializer;
EPS_Editor_EditorMode EditorMode = EPS_Editor_EditorMode::Normal;
UPROPERTY()
TObjectPtr<APS_Editor_SplineActor> ActiveSplineActor;
void HandleEditorClick(FVector2D ScreenPos, bool bAdditive);
};

View File

@@ -17,6 +17,7 @@ public class PS_Editor : ModuleRules
PublicIncludePaths.Add(Path.Combine(SourceDir, "Gizmo"));
PublicIncludePaths.Add(Path.Combine(SourceDir, "Spawn"));
PublicIncludePaths.Add(Path.Combine(SourceDir, "Serialization"));
PublicIncludePaths.Add(Path.Combine(SourceDir, "Spline"));
PublicDependencyModuleNames.AddRange(new string[]
{

View File

@@ -15,6 +15,7 @@
#include "Materials/MaterialExpressionLinearInterpolate.h"
#include "Materials/MaterialExpressionComponentMask.h"
#include "Materials/MaterialExpressionAppendVector.h"
#include "Materials/MaterialExpressionVectorParameter.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "UObject/SavePackage.h"
#include "Misc/PackageName.h"
@@ -218,6 +219,48 @@ static void CreateOutlineMaterialAsset()
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Selection outline material auto-generated (8-sample, dynamic resolution)"));
}
static void CreateSplineLineMaterialAsset()
{
const FString MaterialPath = TEXT("/PS_Editor/M_PS_Editor_SplineLine");
const FString AssetName = TEXT("M_PS_Editor_SplineLine");
if (LoadObject<UMaterial>(nullptr, *(MaterialPath + TEXT(".") + AssetName)))
{
return; // Already exists
}
UPackage* Package = CreatePackage(*MaterialPath);
UMaterial* Mat = NewObject<UMaterial>(Package, *AssetName, RF_Public | RF_Standalone);
Mat->MaterialDomain = EMaterialDomain::MD_Surface;
Mat->SetShadingModel(EMaterialShadingModel::MSM_Unlit);
Mat->BlendMode = EBlendMode::BLEND_Opaque;
Mat->TwoSided = true;
auto AddExpr = [&](UMaterialExpression* Expr) { Mat->GetExpressionCollection().AddExpression(Expr); };
// Vector parameter "Color" (default orange)
UMaterialExpressionVectorParameter* ColorParam = NewObject<UMaterialExpressionVectorParameter>(Mat);
ColorParam->ParameterName = TEXT("Color");
ColorParam->DefaultValue = FLinearColor(1.0f, 0.4f, 0.0f, 1.0f);
AddExpr(ColorParam);
ColorParam->MaterialExpressionEditorX = -200;
ColorParam->MaterialExpressionEditorY = 0;
// Connect Color → Emissive
Mat->GetEditorOnlyData()->EmissiveColor.Connect(0, ColorParam);
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: Spline line material auto-generated (unlit opaque with Color parameter)"));
}
#endif
void FPS_EditorModule::StartupModule()
@@ -226,6 +269,7 @@ void FPS_EditorModule::StartupModule()
#if WITH_EDITOR
FCoreDelegates::OnPostEngineInit.AddStatic(&CreateOutlineMaterialAsset);
FCoreDelegates::OnPostEngineInit.AddStatic(&CreateSplineLineMaterialAsset);
#endif
}

View File

@@ -4,6 +4,7 @@
#include "PS_Editor_PlayerController.h"
#include "PS_Editor_SpawnManager.h"
#include "PS_Editor_EditableComponent.h"
#include "PS_Editor_SplineActor.h"
#include "GameFramework/PlayerController.h"
#include "Engine/World.h"
#include "GameFramework/Pawn.h"
@@ -185,6 +186,13 @@ void UPS_Editor_SelectionManager::SelectActor(AActor* Actor)
UpdateGizmo();
OnSelectionChanged.Broadcast();
// Show spline handles and mark selected
if (APS_Editor_SplineActor* Spline = Cast<APS_Editor_SplineActor>(Actor))
{
Spline->SetHandlesVisible(true);
Spline->SetSelected(true);
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Selected %s"), *Actor->GetName());
}
@@ -206,6 +214,13 @@ void UPS_Editor_SelectionManager::DeselectActor(AActor* Actor)
UpdateGizmo();
OnSelectionChanged.Broadcast();
// Hide spline handles and unmark
if (APS_Editor_SplineActor* Spline = Cast<APS_Editor_SplineActor>(Actor))
{
Spline->SetHandlesVisible(false);
Spline->SetSelected(false);
}
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Deselected %s"), *Actor->GetName());
}
}
@@ -269,7 +284,15 @@ FVector UPS_Editor_SelectionManager::GetSelectionPivot() const
{
if (AActor* Actor = Weak.Get())
{
Sum += Actor->GetActorLocation();
// For SplineActors, use the centroid of all points as pivot
if (APS_Editor_SplineActor* Spline = Cast<APS_Editor_SplineActor>(Actor))
{
Sum += Spline->GetCentroid();
}
else
{
Sum += Actor->GetActorLocation();
}
Count++;
}
}
@@ -448,7 +471,18 @@ void UPS_Editor_SelectionManager::UpdateGizmo()
if (IsAnythingSelected())
{
Gizmo->SetGizmoActive(true, GetSelectionPivot());
FVector Pivot = GetSelectionPivot();
// Override pivot for SplineActors: use centroid so gizmo aligns with center cube
if (SelectedActors.Num() == 1)
{
if (APS_Editor_SplineActor* Spline = Cast<APS_Editor_SplineActor>(SelectedActors[0].Get()))
{
Pivot = Spline->GetCentroid();
}
}
Gizmo->SetGizmoActive(true, Pivot);
// Update gizmo orientation
if (AActor* Actor = SelectedActors[0].Get())

View File

@@ -33,3 +33,11 @@ enum class EPS_Editor_Space : uint8
World,
Local
};
/** Top-level editor mode (separate from transform mode). */
UENUM(BlueprintType)
enum class EPS_Editor_EditorMode : uint8
{
Normal, // Standard select/transform behavior
SplinePlacement // Clicks add points to current spline
};

View File

@@ -1,7 +1,24 @@
#include "PS_Editor_UndoManager.h"
#include "PS_Editor_SplineActor.h"
#include "UObject/UnrealType.h"
#include "Components/ActorComponent.h"
void FPS_Editor_SplinePointAction::Execute()
{
if (APS_Editor_SplineActor* Spline = SplineActor.Get())
{
Spline->SetAllPointLocations(NewPoints);
}
}
void FPS_Editor_SplinePointAction::Undo()
{
if (APS_Editor_SplineActor* Spline = SplineActor.Get())
{
Spline->SetAllPointLocations(OldPoints);
}
}
void FPS_Editor_PropertyAction::ApplyValue(const FString& ValueStr)
{
AActor* Act = Actor.Get();

View File

@@ -9,7 +9,8 @@ enum class EPS_Editor_ActionType : uint8
Transform,
Delete,
Spawn,
PropertyChange
PropertyChange,
SplinePointChange
};
/**
@@ -157,6 +158,26 @@ struct FPS_Editor_SpawnAction : public FPS_Editor_Action
}
};
class APS_Editor_SplineActor;
/**
* Records a spline point state change (move, add, remove).
* Stores full state snapshots (all points before/after) for simplicity.
*/
struct FPS_Editor_SplinePointAction : public FPS_Editor_Action
{
FPS_Editor_SplinePointAction() : FPS_Editor_Action(EPS_Editor_ActionType::SplinePointChange) {}
TWeakObjectPtr<APS_Editor_SplineActor> SplineActor;
TArray<FVector> OldPoints;
TArray<FVector> NewPoints;
FString Description;
virtual void Execute() override;
virtual void Undo() override;
virtual FString GetDescription() const override { return Description; }
};
/**
* Records a single property value change on a component or actor.
* Stores text-serialized old/new values for undo/redo via ImportText.

View File

@@ -2,6 +2,7 @@
#include "PS_Editor_SpawnManager.h"
#include "PS_Editor_SelectionManager.h"
#include "PS_Editor_EditableComponent.h"
#include "PS_Editor_SplineActor.h"
#include "JsonObjectConverter.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
@@ -36,6 +37,14 @@ bool UPS_Editor_SceneSerializer::SaveScene(const FString& SceneName, UPS_Editor_
ActorData.Rotation = Actor->GetActorRotation();
ActorData.Scale = Actor->GetActorScale3D();
// Export spline data
if (APS_Editor_SplineActor* Spline = Cast<APS_Editor_SplineActor>(Actor))
{
Spline->ExportToCustomProperties(ActorData.CustomProperties);
UE_LOG(LogTemp, Log, TEXT("PS_Editor: Exported spline with %d points, class=%s"),
Spline->GetNumPoints(), *ActorData.ClassPath);
}
// Export custom editable properties
if (UPS_Editor_EditableComponent* EditComp = Actor->FindComponentByClass<UPS_Editor_EditableComponent>())
{
@@ -137,6 +146,11 @@ bool UPS_Editor_SceneSerializer::LoadScene(const FString& SceneName, UPS_Editor_
{
UClass* LoadedClass = LoadObject<UClass>(nullptr, *ActorData.ClassPath);
if (!LoadedClass)
{
// Fallback for C++ native classes (e.g. /Script/PS_Editor.PS_Editor_SplineActor)
LoadedClass = FindObject<UClass>(nullptr, *ActorData.ClassPath);
}
if (!LoadedClass)
{
UE_LOG(LogTemp, Warning, TEXT("PS_Editor: Failed to load class: %s"), *ActorData.ClassPath);
continue;
@@ -190,6 +204,12 @@ bool UPS_Editor_SceneSerializer::LoadScene(const FString& SceneName, UPS_Editor_
}
}
// Import spline data
if (APS_Editor_SplineActor* Spline = Cast<APS_Editor_SplineActor>(SpawnedActor))
{
Spline->ImportFromCustomProperties(ActorData.CustomProperties);
}
SpawnedCount++;
}
}

View File

@@ -0,0 +1,493 @@
#include "PS_Editor_SplineActor.h"
#include "Components/SplineComponent.h"
#include "ProceduralMeshComponent.h"
#include "Components/StaticMeshComponent.h"
#include "Engine/StaticMesh.h"
#include "Materials/MaterialInstanceDynamic.h"
APS_Editor_SplineActor::APS_Editor_SplineActor()
{
PrimaryActorTick.bCanEverTick = false;
// Root
USceneComponent* Root = CreateDefaultSubobject<USceneComponent>(TEXT("Root"));
SetRootComponent(Root);
Root->SetMobility(EComponentMobility::Movable);
// Spline data
SplineComp = CreateDefaultSubobject<USplineComponent>(TEXT("SplineComp"));
SplineComp->SetupAttachment(Root);
SplineComp->ClearSplinePoints(false);
SplineComp->SetClosedLoop(false);
// Line mesh
SplineMeshComp = CreateDefaultSubobject<UProceduralMeshComponent>(TEXT("SplineMesh"));
SplineMeshComp->SetupAttachment(Root);
SplineMeshComp->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
SplineMeshComp->SetCollisionResponseToAllChannels(ECR_Ignore);
SplineMeshComp->SetCollisionResponseToChannel(ECC_Visibility, ECR_Block);
SplineMeshComp->bUseComplexAsSimpleCollision = true;
SplineMeshComp->SetCastShadow(false);
// Handle parent
HandleRoot = CreateDefaultSubobject<USceneComponent>(TEXT("HandleRoot"));
HandleRoot->SetupAttachment(Root);
}
void APS_Editor_SplineActor::BeginPlay()
{
Super::BeginPlay();
// Gizmo material (no depth test, renders on top) for handles and selected state
UMaterialInterface* GizmoMat = LoadObject<UMaterialInterface>(
nullptr, TEXT("/PS_Editor/M_PS_Editor_Gizmo.M_PS_Editor_Gizmo"));
// 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"));
// Spline line: normal depth-tested material (orange)
if (SplineLineMat)
{
SplineMat = UMaterialInstanceDynamic::Create(SplineLineMat, this);
SplineMat->SetVectorParameterValue(TEXT("Color"), FLinearColor(1.0f, 0.4f, 0.0f));
}
// Spline line selected: no depth test (on top, bright orange)
if (GizmoMat)
{
SplineMatSelected = UMaterialInstanceDynamic::Create(GizmoMat, this);
SplineMatSelected->SetVectorParameterValue(TEXT("Color"), FLinearColor(1.0f, 0.6f, 0.0f));
// Handles always use gizmo material (on top, clickable)
HandleMat = UMaterialInstanceDynamic::Create(GizmoMat, this);
HandleMat->SetVectorParameterValue(TEXT("Color"), FLinearColor(1.0f, 1.0f, 1.0f)); // White
HandleHighlightMat = UMaterialInstanceDynamic::Create(GizmoMat, this);
HandleHighlightMat->SetVectorParameterValue(TEXT("Color"), FLinearColor(1.0f, 1.0f, 0.0f)); // Yellow
CenterCubeMat = UMaterialInstanceDynamic::Create(GizmoMat, this);
CenterCubeMat->SetVectorParameterValue(TEXT("Color"), FLinearColor(1.0f, 0.4f, 0.0f)); // Orange
}
// Create center cube (selection handle for whole spline)
CenterCube = NewObject<UStaticMeshComponent>(this);
CenterCube->SetupAttachment(GetRootComponent());
CenterCube->RegisterComponent();
UStaticMesh* CubeMesh = LoadObject<UStaticMesh>(nullptr, TEXT("/Engine/BasicShapes/Cube.Cube"));
if (CubeMesh)
{
CenterCube->SetStaticMesh(CubeMesh);
}
CenterCube->SetWorldScale3D(FVector(CenterCubeScale));
CenterCube->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
CenterCube->SetCollisionResponseToAllChannels(ECR_Ignore);
CenterCube->SetCollisionResponseToChannel(ECC_Visibility, ECR_Block);
CenterCube->SetCastShadow(false);
if (CenterCubeMat)
{
CenterCube->SetMaterial(0, CenterCubeMat);
}
}
// ---- Point manipulation ----
void APS_Editor_SplineActor::AddPoint(const FVector& WorldPosition)
{
// Convert world to local space
const FVector LocalPos = GetActorTransform().InverseTransformPosition(WorldPosition);
SplineComp->AddSplinePoint(LocalPos, ESplineCoordinateSpace::Local, true);
// Create handle
UStaticMeshComponent* Handle = CreatePointHandle(WorldPosition);
PointHandles.Add(Handle);
UpdateVisualization();
}
void APS_Editor_SplineActor::MovePoint(int32 Index, const FVector& NewWorldPosition)
{
if (Index < 0 || Index >= GetNumPoints()) return;
const FVector LocalPos = GetActorTransform().InverseTransformPosition(NewWorldPosition);
SplineComp->SetLocationAtSplinePoint(Index, LocalPos, ESplineCoordinateSpace::Local, true);
if (PointHandles.IsValidIndex(Index) && PointHandles[Index])
{
PointHandles[Index]->SetWorldLocation(NewWorldPosition);
}
UpdateVisualization();
}
void APS_Editor_SplineActor::RemovePoint(int32 Index)
{
if (Index < 0 || Index >= GetNumPoints()) return;
SplineComp->RemoveSplinePoint(Index, true);
if (PointHandles.IsValidIndex(Index))
{
if (PointHandles[Index])
{
PointHandles[Index]->DestroyComponent();
}
PointHandles.RemoveAt(Index);
}
UpdateVisualization();
}
int32 APS_Editor_SplineActor::GetNumPoints() const
{
return SplineComp ? SplineComp->GetNumberOfSplinePoints() : 0;
}
FVector APS_Editor_SplineActor::GetPointLocation(int32 Index) const
{
if (!SplineComp || Index < 0 || Index >= GetNumPoints()) return FVector::ZeroVector;
return SplineComp->GetLocationAtSplinePoint(Index, ESplineCoordinateSpace::World);
}
TArray<FVector> APS_Editor_SplineActor::GetAllPointLocations() const
{
TArray<FVector> Result;
const int32 Num = GetNumPoints();
Result.Reserve(Num);
for (int32 i = 0; i < Num; i++)
{
Result.Add(GetPointLocation(i));
}
return Result;
}
void APS_Editor_SplineActor::SetAllPointLocations(const TArray<FVector>& Points)
{
if (!SplineComp) return;
SplineComp->ClearSplinePoints(false);
for (const FVector& WorldPos : Points)
{
const FVector LocalPos = GetActorTransform().InverseTransformPosition(WorldPos);
SplineComp->AddSplinePoint(LocalPos, ESplineCoordinateSpace::Local, false);
}
SplineComp->UpdateSpline();
RebuildHandles();
UpdateVisualization();
}
// ---- Visualization ----
void APS_Editor_SplineActor::UpdateVisualization()
{
if (!SplineMeshComp || !SplineComp || GetNumPoints() < 2)
{
if (SplineMeshComp) SplineMeshComp->ClearAllMeshSections();
return;
}
const float SplineLength = SplineComp->GetSplineLength();
const int32 NumSamples = FMath::Max(2, FMath::CeilToInt(SplineLength / SampleInterval));
TArray<FVector> Vertices;
TArray<int32> Triangles;
TArray<FVector> Normals;
TArray<FVector2D> UVs;
// 4 vertices per sample: 2 for vertical ribbon + 2 for horizontal ribbon (cross-section)
Vertices.Reserve(NumSamples * 4);
Normals.Reserve(NumSamples * 4);
UVs.Reserve(NumSamples * 4);
for (int32 i = 0; i < NumSamples; i++)
{
const float Distance = (static_cast<float>(i) / (NumSamples - 1)) * SplineLength;
const FVector Location = SplineComp->GetLocationAtDistanceAlongSpline(Distance, ESplineCoordinateSpace::Local);
const FVector Right = SplineComp->GetRightVectorAtDistanceAlongSpline(Distance, ESplineCoordinateSpace::Local);
const FVector Up = FVector(0.0f, 0.0f, LineHalfWidth);
const float U = static_cast<float>(i) / (NumSamples - 1);
// Vertical ribbon (visible from sides)
Vertices.Add(Location - Up);
Vertices.Add(Location + Up);
Normals.Add(Right);
Normals.Add(Right);
UVs.Add(FVector2D(U, 0.0f));
UVs.Add(FVector2D(U, 1.0f));
// Horizontal ribbon (visible from above/below)
Vertices.Add(Location - Right * LineHalfWidth);
Vertices.Add(Location + Right * LineHalfWidth);
Normals.Add(FVector::UpVector);
Normals.Add(FVector::UpVector);
UVs.Add(FVector2D(U, 0.0f));
UVs.Add(FVector2D(U, 1.0f));
}
// Generate triangles for both ribbons (double-sided)
Triangles.Reserve((NumSamples - 1) * 24);
for (int32 i = 0; i < NumSamples - 1; i++)
{
// Vertical ribbon indices
const int32 V0 = i * 4;
const int32 V1 = i * 4 + 1;
const int32 V2 = (i + 1) * 4;
const int32 V3 = (i + 1) * 4 + 1;
// Horizontal ribbon indices
const int32 H0 = i * 4 + 2;
const int32 H1 = i * 4 + 3;
const int32 H2 = (i + 1) * 4 + 2;
const int32 H3 = (i + 1) * 4 + 3;
// Vertical: front + back
Triangles.Add(V0); Triangles.Add(V2); Triangles.Add(V1);
Triangles.Add(V1); Triangles.Add(V2); Triangles.Add(V3);
Triangles.Add(V0); Triangles.Add(V1); Triangles.Add(V2);
Triangles.Add(V1); Triangles.Add(V3); Triangles.Add(V2);
// Horizontal: front + back
Triangles.Add(H0); Triangles.Add(H2); Triangles.Add(H1);
Triangles.Add(H1); Triangles.Add(H2); Triangles.Add(H3);
Triangles.Add(H0); Triangles.Add(H1); Triangles.Add(H2);
Triangles.Add(H1); Triangles.Add(H3); Triangles.Add(H2);
}
SplineMeshComp->ClearAllMeshSections();
SplineMeshComp->CreateMeshSection(0, Vertices, Triangles, Normals, UVs, TArray<FColor>(), TArray<FProcMeshTangent>(), false);
// Apply the current material (normal or selected)
// Check if handles are visible as a proxy for "selected" state
const bool bCurrentlySelected = (PointHandles.Num() > 0 && PointHandles[0] && PointHandles[0]->IsVisible());
UMaterialInstanceDynamic* LineMat = (bCurrentlySelected && SplineMatSelected) ? SplineMatSelected : SplineMat;
if (LineMat)
{
SplineMeshComp->SetMaterial(0, LineMat);
}
UpdateCenterCube();
}
void APS_Editor_SplineActor::UpdateCenterCube()
{
if (!CenterCube) return;
const int32 Num = GetNumPoints();
if (Num == 0)
{
CenterCube->SetVisibility(false);
return;
}
// Position at centroid of all points
FVector Centroid = FVector::ZeroVector;
for (int32 i = 0; i < Num; i++)
{
Centroid += GetPointLocation(i);
}
Centroid /= Num;
CenterCube->SetWorldLocation(Centroid);
CenterCube->SetVisibility(true);
}
// ---- Handle interaction ----
UStaticMeshComponent* APS_Editor_SplineActor::CreatePointHandle(const FVector& WorldPosition)
{
UStaticMeshComponent* Handle = NewObject<UStaticMeshComponent>(this);
Handle->SetupAttachment(HandleRoot);
Handle->RegisterComponent();
// Load sphere mesh
UStaticMesh* SphereMesh = LoadObject<UStaticMesh>(nullptr, TEXT("/Engine/BasicShapes/Sphere.Sphere"));
if (SphereMesh)
{
Handle->SetStaticMesh(SphereMesh);
}
Handle->SetWorldLocation(WorldPosition);
Handle->SetWorldScale3D(FVector(HandleScale));
// Collision for raycast
Handle->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
Handle->SetCollisionResponseToAllChannels(ECR_Ignore);
Handle->SetCollisionResponseToChannel(ECC_Visibility, ECR_Block);
Handle->SetCastShadow(false);
Handle->SetRenderInDepthPass(false);
if (HandleMat)
{
Handle->SetMaterial(0, HandleMat);
}
return Handle;
}
void APS_Editor_SplineActor::RebuildHandles()
{
// Destroy existing handles
for (UStaticMeshComponent* Handle : PointHandles)
{
if (Handle)
{
Handle->DestroyComponent();
}
}
PointHandles.Empty();
// Create new handles at each point
const int32 Num = GetNumPoints();
for (int32 i = 0; i < Num; i++)
{
UStaticMeshComponent* Handle = CreatePointHandle(GetPointLocation(i));
PointHandles.Add(Handle);
}
}
int32 APS_Editor_SplineActor::GetHandleIndexUnderCursor(const FVector& RayOrigin, const FVector& RayDirection, float SphereRadius) const
{
int32 BestIndex = -1;
float BestDist = MAX_FLT;
for (int32 i = 0; i < PointHandles.Num(); i++)
{
if (!PointHandles[i] || !PointHandles[i]->IsVisible()) continue;
const FVector HandlePos = PointHandles[i]->GetComponentLocation();
// Sphere-ray intersection
const FVector ToHandle = HandlePos - RayOrigin;
const float Proj = FVector::DotProduct(ToHandle, RayDirection);
if (Proj < 0.0f) continue; // Behind camera
const FVector ClosestPoint = RayOrigin + RayDirection * Proj;
const float Dist = FVector::Dist(ClosestPoint, HandlePos);
if (Dist < SphereRadius && Proj < BestDist)
{
BestDist = Proj;
BestIndex = i;
}
}
return BestIndex;
}
void APS_Editor_SplineActor::SetHandlesVisible(bool bVisible)
{
for (UStaticMeshComponent* Handle : PointHandles)
{
if (Handle)
{
Handle->SetVisibility(bVisible);
Handle->SetCollisionEnabled(bVisible ? ECollisionEnabled::QueryOnly : ECollisionEnabled::NoCollision);
}
}
}
void APS_Editor_SplineActor::HighlightHandle(int32 Index)
{
for (int32 i = 0; i < PointHandles.Num(); i++)
{
if (PointHandles[i])
{
PointHandles[i]->SetMaterial(0, (i == Index) ? HandleHighlightMat : HandleMat);
}
}
}
void APS_Editor_SplineActor::ClearHandleHighlight()
{
for (UStaticMeshComponent* Handle : PointHandles)
{
if (Handle && HandleMat)
{
Handle->SetMaterial(0, HandleMat);
}
}
}
void APS_Editor_SplineActor::SetSelected(bool bSelected)
{
// Swap line material: depth-tested (normal) vs no-depth-test (on top when selected)
if (SplineMeshComp)
{
UMaterialInstanceDynamic* Mat = bSelected ? SplineMatSelected : SplineMat;
if (Mat)
{
SplineMeshComp->SetMaterial(0, Mat);
}
}
// Center cube: bright when selected
if (CenterCubeMat)
{
const FLinearColor Color = bSelected ? FLinearColor(1.0f, 0.8f, 0.0f) : FLinearColor(1.0f, 0.4f, 0.0f);
CenterCubeMat->SetVectorParameterValue(TEXT("Color"), Color);
}
}
bool APS_Editor_SplineActor::IsCenterCube(const UPrimitiveComponent* Comp) const
{
return Comp && Comp == CenterCube;
}
FVector APS_Editor_SplineActor::GetCentroid() const
{
const int32 Num = GetNumPoints();
if (Num == 0) return GetActorLocation();
FVector Centroid = FVector::ZeroVector;
for (int32 i = 0; i < Num; i++)
{
Centroid += GetPointLocation(i);
}
return Centroid / Num;
}
// ---- Serialization ----
void APS_Editor_SplineActor::ExportToCustomProperties(TMap<FString, FString>& OutProperties) const
{
const int32 Num = GetNumPoints();
OutProperties.Add(TEXT("SplinePointCount"), FString::FromInt(Num));
for (int32 i = 0; i < Num; i++)
{
const FVector Loc = GetPointLocation(i);
OutProperties.Add(
FString::Printf(TEXT("SplinePoint_%d"), i),
FString::Printf(TEXT("X=%.4f Y=%.4f Z=%.4f"), Loc.X, Loc.Y, Loc.Z));
}
}
void APS_Editor_SplineActor::ImportFromCustomProperties(const TMap<FString, FString>& Properties)
{
const FString* CountStr = Properties.Find(TEXT("SplinePointCount"));
if (!CountStr) return;
const int32 Num = FCString::Atoi(**CountStr);
if (Num < 2) return;
TArray<FVector> Points;
for (int32 i = 0; i < Num; i++)
{
const FString Key = FString::Printf(TEXT("SplinePoint_%d"), i);
const FString* ValStr = Properties.Find(Key);
if (!ValStr) continue;
FVector Loc;
if (Loc.InitFromString(*ValStr))
{
Points.Add(Loc);
}
}
if (Points.Num() >= 2)
{
SetAllPointLocations(Points);
}
}

View File

@@ -0,0 +1,130 @@
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "PS_Editor_SplineActor.generated.h"
class USplineComponent;
class UProceduralMeshComponent;
/**
* Runtime spline actor for path/route editing in PS_Editor.
* Renders as a visible quad strip with clickable point handles.
*/
UCLASS()
class PS_EDITOR_API APS_Editor_SplineActor : public AActor
{
GENERATED_BODY()
public:
APS_Editor_SplineActor();
// ---- Point manipulation ----
/** Add a point at the end of the spline (world space). */
void AddPoint(const FVector& WorldPosition);
/** Move an existing point to a new world position. */
void MovePoint(int32 Index, const FVector& NewWorldPosition);
/** Remove a point by index. */
void RemovePoint(int32 Index);
/** Get the number of spline points. */
int32 GetNumPoints() const;
/** Get the world position of a spline point. */
FVector GetPointLocation(int32 Index) const;
/** Get all point world positions (for undo snapshots). */
TArray<FVector> GetAllPointLocations() const;
/** Set all point positions at once (for undo restore). Rebuilds handles and visualization. */
void SetAllPointLocations(const TArray<FVector>& Points);
// ---- Visualization ----
/** Regenerate the line mesh and reposition all handles. */
void UpdateVisualization();
// ---- Handle interaction ----
/** Find which point handle is under the cursor ray. Returns -1 if none. */
int32 GetHandleIndexUnderCursor(const FVector& RayOrigin, const FVector& RayDirection, float SphereRadius = 15.0f) const;
/** Show or hide all point handles and center cube. */
void SetHandlesVisible(bool bVisible);
/** Set the spline's selected state (changes line + cube color). */
void SetSelected(bool bSelected);
/** Returns true if the given component is the center cube. */
bool IsCenterCube(const UPrimitiveComponent* Comp) const;
/** Highlight a specific handle (yellow), others go back to normal (white). */
void HighlightHandle(int32 Index);
/** Clear all handle highlights. */
void ClearHandleHighlight();
// ---- Serialization ----
/** Get the centroid of all spline points (world space). Used as pivot for gizmo. */
FVector GetCentroid() const;
/** Export spline data to a key-value map (for JSON serialization). */
void ExportToCustomProperties(TMap<FString, FString>& OutProperties) const;
/** Import spline data from a key-value map (for scene loading). */
void ImportFromCustomProperties(const TMap<FString, FString>& Properties);
protected:
virtual void BeginPlay() override;
private:
UPROPERTY(VisibleAnywhere)
TObjectPtr<USplineComponent> SplineComp;
UPROPERTY(VisibleAnywhere)
TObjectPtr<UProceduralMeshComponent> SplineMeshComp;
UPROPERTY(VisibleAnywhere)
TObjectPtr<USceneComponent> HandleRoot;
UPROPERTY()
TArray<TObjectPtr<UStaticMeshComponent>> PointHandles;
// Materials
UPROPERTY()
TObjectPtr<UMaterialInstanceDynamic> SplineMat; // Normal (depth tested)
UPROPERTY()
TObjectPtr<UMaterialInstanceDynamic> SplineMatSelected; // Selected (no depth test, on top)
UPROPERTY()
TObjectPtr<UMaterialInstanceDynamic> HandleMat;
UPROPERTY()
TObjectPtr<UMaterialInstanceDynamic> HandleHighlightMat;
UPROPERTY()
TObjectPtr<UStaticMeshComponent> CenterCube;
/** Create a handle sphere for a point at the given world position. */
UStaticMeshComponent* CreatePointHandle(const FVector& WorldPosition);
/** Rebuild all handles from current spline points. */
void RebuildHandles();
/** Update center cube position to the centroid of all points. */
void UpdateCenterCube();
UPROPERTY()
TObjectPtr<UMaterialInstanceDynamic> CenterCubeMat;
/** Line rendering settings. */
float LineHalfWidth = 2.0f;
float SampleInterval = 10.0f;
float HandleScale = 0.12f;
float CenterCubeScale = 0.10f;
};

View File

@@ -6,6 +6,7 @@
#include "PS_Editor_PlayerController.h"
#include "PS_Editor_EditableComponent.h"
#include "PS_Editor_SpawnableComponent.h"
#include "PS_Editor_SplineActor.h"
#include "Widgets/SBoxPanel.h"
#include "Widgets/Text/STextBlock.h"
#include "Widgets/Layout/SBorder.h"
@@ -239,6 +240,31 @@ TSharedRef<SWidget> UPS_Editor_MainWidget::BuildToolbar()
})
]
]
// New Spline button
+ SHorizontalBox::Slot().AutoWidth().Padding(8.0f, 0.0f, 2.0f, 0.0f)
[
SNew(SButton)
.OnClicked_Lambda([this]() -> FReply
{
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
{
if (EditorPC->GetEditorMode() == EPS_Editor_EditorMode::SplinePlacement)
{
EditorPC->FinishSplinePlacement();
}
else
{
EditorPC->BeginSplinePlacement();
}
}
return FReply::Handled();
})
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("New Spline (S)")))
.Font(FCoreStyle::GetDefaultFontStyle("Regular", 10))
]
]
];
}
@@ -685,14 +711,28 @@ void UPS_Editor_MainWidget::RefreshPropertiesFromSelection()
// Update mode text
if (ModeText.IsValid())
{
FString ModeStr;
switch (SM->GetTransformMode())
// Check for spline placement mode first
bool bSplineMode = false;
if (APS_Editor_PlayerController* EditorPC = Cast<APS_Editor_PlayerController>(GetOwningPlayer()))
{
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;
if (EditorPC->GetEditorMode() == EPS_Editor_EditorMode::SplinePlacement)
{
ModeText->SetText(FText::FromString(TEXT("Spline Placement (click to add, RMB/Enter to finish, Esc to cancel)")));
bSplineMode = true;
}
}
if (!bSplineMode)
{
FString ModeStr;
switch (SM->GetTransformMode())
{
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));
}
ModeText->SetText(FText::FromString(ModeStr));
}
// Update space button text
@@ -877,16 +917,17 @@ void UPS_Editor_MainWidget::RefreshOutliner()
AActor* Actor = SpawnedActors[i].Get();
if (!Actor || Actor->IsHidden()) continue;
// Build display name: SpawnableComponent DisplayName > Class name (cleaned)
// Build display name
FString DisplayName;
if (UActorComponent* SpawnComp = Actor->FindComponentByClass(UPS_Editor_SpawnableComponent::StaticClass()))
if (Cast<APS_Editor_SplineActor>(Actor))
{
if (UPS_Editor_SpawnableComponent* SC = Cast<UPS_Editor_SpawnableComponent>(SpawnComp))
DisplayName = TEXT("Spline");
}
else if (UPS_Editor_SpawnableComponent* SC = Actor->FindComponentByClass<UPS_Editor_SpawnableComponent>())
{
if (!SC->DisplayName.IsEmpty())
{
if (!SC->DisplayName.IsEmpty())
{
DisplayName = SC->DisplayName;
}
DisplayName = SC->DisplayName;
}
}
if (DisplayName.IsEmpty())