Editor/FenceLibrary/FenceTool.cs
using System;
using System.Collections.Generic;
namespace Editor;
[EditorTool( "tools.fence-tool" )]
[Title( "Fence" )]
[Icon( "fence" )]
[Group( "Scene" )]
public sealed class FenceTool : EditorTool
{
private const string DefinitionCookie = "FenceTool.Definition";
private readonly FenceToolSettings settings = new();
private readonly List<FencePrototype> prototypes = [];
private readonly List<FenceSplineControlPoint> splinePoints = [];
private GameObject previewRoot;
private FencePlacementPlan currentPlan;
private Vector3? startPoint;
private Vector3 currentHoverPoint;
private Vector3 currentHoverNormal = Vector3.Up;
private bool hasHoverPoint;
private bool isDrawingSpline;
private bool escapeWasDown;
private int lastDefinitionHash;
private FenceToolPlacementMode lastMode;
private Vector3 lastPreviewStart;
private Vector3 lastPreviewEnd;
private int lastPreviewPathHash;
private int lastPreviewSettingsHash;
private string persistedDefinitionPath;
public FenceTool()
{
RebuildSidebarOnSelectionChange = false;
AllowContextMenu = false;
settings.Definition = LoadSavedDefinition();
persistedDefinitionPath = settings.Definition?.ResourcePath ?? string.Empty;
lastMode = settings.Mode;
}
public override Widget CreateToolSidebar()
{
var sidebar = new ToolSidebarWidget();
sidebar.MinimumWidth = 308;
sidebar.Layout.Spacing = 4;
sidebar.AddTitle( "Fence Placement", "fence" );
var serialized = settings.GetSerialized();
var definitionGroup = sidebar.AddGroup( "Definition" );
definitionGroup.Spacing = 2;
definitionGroup.Add( new FenceSidebarField( serialized.GetProperty( nameof( FenceToolSettings.Definition ) ) ) );
var settingsGroup = sidebar.AddGroup( "Placement" );
settingsGroup.Spacing = 2;
settingsGroup.Add( new FenceSidebarField( serialized.GetProperty( nameof( FenceToolSettings.Mode ) ) ) );
settingsGroup.Add( new FenceSidebarField( serialized.GetProperty( nameof( FenceToolSettings.SnapToGround ) ) ) );
settingsGroup.Add( new FenceSidebarField( serialized.GetProperty( nameof( FenceToolSettings.GroundTraceHeight ) ) ) );
settingsGroup.Add( new FenceSidebarField( serialized.GetProperty( nameof( FenceToolSettings.SurfaceTag ) ) ) );
settingsGroup.Add( new FenceSidebarField( serialized.GetProperty( nameof( FenceToolSettings.KeepUpright ) ) ) );
settingsGroup.Add( new FenceSidebarField( serialized.GetProperty( nameof( FenceToolSettings.SplinePointSpacing ) ) ) );
settingsGroup.Add( new FenceSidebarField( serialized.GetProperty( nameof( FenceToolSettings.SplineInterpolationSteps ) ) ) );
settingsGroup.Add( new FenceSidebarField( serialized.GetProperty( nameof( FenceToolSettings.Seed ) ) ) );
settingsGroup.Add( new FenceSidebarField( serialized.GetProperty( nameof( FenceToolSettings.IncrementSeedAfterPlacement ) ) ) );
var usageGroup = sidebar.AddGroup( "Usage" );
var usage = new Label( "Line: LMB start, LMB place. Spline: hold LMB and drag, release to place. Esc cancel.", sidebar );
usage.WordWrap = true;
usageGroup.Add( usage );
var actionsGroup = sidebar.AddGroup( "Actions" );
var cancelButton = new Button( "Reset Current Run", "close" );
cancelButton.Clicked += CancelCurrentRun;
actionsGroup.Add( cancelButton );
sidebar.Layout.AddStretchCell();
return sidebar;
}
public override void OnUpdate()
{
Gizmo.Hitbox.BBox( BBox.FromPositionAndSize( Vector3.Zero, 999999.0f ) );
SyncSavedDefinition();
RefreshPrototypesIfNeeded();
UpdateHoverPoint();
if ( settings.Mode != lastMode )
{
lastMode = settings.Mode;
CancelCurrentRun();
}
DrawGuidePreview();
var escapeDown = Application.IsKeyDown( KeyCode.Escape );
if ( escapeDown && !escapeWasDown )
{
escapeWasDown = true;
CancelCurrentRun();
return;
}
escapeWasDown = escapeDown;
if ( settings.Mode == FenceToolPlacementMode.Spline )
{
UpdateSplineMode();
return;
}
UpdateLineMode();
}
private void UpdateLineMode()
{
if ( startPoint.HasValue )
{
UpdateLinePlacementPreview();
}
else
{
ClearPreviewRoot();
currentPlan = null;
}
if ( !Gizmo.WasLeftMousePressed || !hasHoverPoint )
return;
if ( !startPoint.HasValue )
{
startPoint = currentHoverPoint;
lastPreviewStart = Vector3.Zero;
lastPreviewEnd = Vector3.Zero;
return;
}
if ( currentPlan is null || currentPlan.Segments.Count == 0 )
return;
CommitCurrentPlan();
}
private void UpdateSplineMode()
{
if ( Gizmo.WasLeftMousePressed && hasHoverPoint )
{
BeginSplineRun();
}
if ( isDrawingSpline && hasHoverPoint )
{
AddSplinePoint( currentHoverPoint, currentHoverNormal, force: false );
UpdateSplinePlacementPreview( includeHoverPoint: true );
}
else if ( !isDrawingSpline )
{
ClearPreviewRoot();
currentPlan = null;
}
if ( isDrawingSpline && (Gizmo.WasLeftMouseReleased || (!Gizmo.IsLeftMouseDown && !Gizmo.WasLeftMousePressed)) )
{
FinishSplineRun();
}
}
public override void OnDisabled()
{
SyncSavedDefinition();
CancelCurrentRun();
}
[Shortcut( "tools.fence-tool", "CTRL+SHIFT+B", typeof( SceneViewWidget ) )]
public static void ActivateTool()
{
if ( EditorToolManager.CurrentModeName == nameof( FenceTool ) )
return;
EditorToolManager.SetTool( nameof( FenceTool ) );
}
private void RefreshPrototypesIfNeeded()
{
var definitionHash = FenceDefinitionEditorUtility.ComputeDefinitionHash( settings.Definition );
if ( definitionHash == lastDefinitionHash )
return;
lastDefinitionHash = definitionHash;
prototypes.Clear();
if ( settings.Definition is null )
return;
using var sceneScope = Scene.Push();
prototypes.AddRange( FenceDefinitionEditorUtility.BuildPrototypes( settings.Definition ) );
lastPreviewSettingsHash = 0;
}
private void UpdateHoverPoint()
{
hasHoverPoint = false;
var trace = Scene.Trace.Ray( Gizmo.CurrentRay, Gizmo.RayDepth )
.UseRenderMeshes( true )
.UsePhysicsWorld( false )
.WithoutTags( FenceDefinitionEditorUtility.PreviewTag )
.WithoutTags( "hidden" );
var result = default(SceneTraceResult);
if ( !string.IsNullOrWhiteSpace( settings.SurfaceTag ) )
{
result = trace.WithTag( settings.SurfaceTag ).Run();
}
if ( !result.Hit )
{
result = trace.Run();
}
if ( !result.Hit )
return;
currentHoverPoint = result.HitPosition;
currentHoverNormal = result.Normal.Length < 0.001f ? Vector3.Up : result.Normal.Normal;
hasHoverPoint = true;
}
private void UpdateLinePlacementPreview()
{
if ( settings.Definition is null || prototypes.Count == 0 || !hasHoverPoint )
{
ClearPreviewRoot();
currentPlan = null;
return;
}
var settingsHash = HashCode.Combine(
lastDefinitionHash,
settings.Mode,
settings.SnapToGround,
settings.GroundTraceHeight,
settings.SurfaceTag ?? string.Empty,
settings.KeepUpright,
settings.Seed );
var start = startPoint.Value;
var end = currentHoverPoint;
if ( currentPlan is not null
&& lastPreviewSettingsHash == settingsHash
&& Vector3.DistanceBetween( lastPreviewStart, start ) < 0.01f
&& Vector3.DistanceBetween( lastPreviewEnd, end ) < 0.5f )
{
return;
}
using var sceneScope = Scene.Push();
currentPlan = FenceDefinitionEditorUtility.BuildPlacementPlan( settings.Definition, prototypes, settings, Scene, start, end );
if ( !FenceDefinitionEditorUtility.TryUpdatePreviewHierarchy( previewRoot, currentPlan ) )
{
ClearPreviewRoot();
previewRoot = FenceDefinitionEditorUtility.SpawnPreviewHierarchy( currentPlan );
}
lastPreviewStart = start;
lastPreviewEnd = end;
lastPreviewSettingsHash = settingsHash;
}
private void UpdateSplinePlacementPreview( bool includeHoverPoint )
{
var points = BuildActiveSplinePoints( includeHoverPoint );
if ( settings.Definition is null || prototypes.Count == 0 || points.Count < 2 )
{
ClearPreviewRoot();
currentPlan = null;
return;
}
var settingsHash = HashCode.Combine(
lastDefinitionHash,
settings.Mode,
settings.KeepUpright,
Math.Clamp( settings.SplineInterpolationSteps, 1, 32 ),
settings.Seed );
var pathHash = ComputeSplinePointHash( points );
if ( currentPlan is not null
&& lastPreviewSettingsHash == settingsHash
&& lastPreviewPathHash == pathHash )
{
return;
}
using var sceneScope = Scene.Push();
currentPlan = FenceDefinitionEditorUtility.BuildSplinePlacementPlan( settings.Definition, prototypes, settings, Scene, points );
if ( !FenceDefinitionEditorUtility.TryUpdatePreviewHierarchy( previewRoot, currentPlan ) )
{
ClearPreviewRoot();
previewRoot = FenceDefinitionEditorUtility.SpawnPreviewHierarchy( currentPlan );
}
lastPreviewPathHash = pathHash;
lastPreviewSettingsHash = settingsHash;
}
private void BeginSplineRun()
{
isDrawingSpline = true;
startPoint = null;
splinePoints.Clear();
ResetPreviewCache();
AddSplinePoint( currentHoverPoint, currentHoverNormal, force: true );
}
private void FinishSplineRun()
{
if ( hasHoverPoint )
{
AddSplinePoint( currentHoverPoint, currentHoverNormal, force: true );
}
UpdateSplinePlacementPreview( includeHoverPoint: false );
isDrawingSpline = false;
if ( currentPlan is not null && currentPlan.Segments.Count > 0 )
{
CommitCurrentPlan();
return;
}
CancelCurrentRun();
}
private void AddSplinePoint( Vector3 position, Vector3 normal, bool force )
{
var resolvedNormal = normal.Length < 0.001f ? Vector3.Up : normal.Normal;
var point = new FenceSplineControlPoint( position, resolvedNormal );
if ( splinePoints.Count == 0 )
{
splinePoints.Add( point );
return;
}
var minimumSpacing = Math.Max( settings.SplinePointSpacing, 1.0f );
var distance = Vector3.DistanceBetween( splinePoints[^1].Position, position );
if ( !force && distance < minimumSpacing )
return;
if ( distance < 0.01f )
return;
splinePoints.Add( point );
}
private List<FenceSplineControlPoint> BuildActiveSplinePoints( bool includeHoverPoint )
{
var points = new List<FenceSplineControlPoint>( splinePoints );
if ( includeHoverPoint && hasHoverPoint )
{
var normal = currentHoverNormal.Length < 0.001f ? Vector3.Up : currentHoverNormal.Normal;
var hoverPoint = new FenceSplineControlPoint( currentHoverPoint, normal );
if ( points.Count == 0 || Vector3.DistanceBetween( points[^1].Position, hoverPoint.Position ) > 0.01f )
{
points.Add( hoverPoint );
}
}
return points;
}
private static int ComputeSplinePointHash( IReadOnlyList<FenceSplineControlPoint> points )
{
var hash = new HashCode();
hash.Add( points?.Count ?? 0 );
if ( points is not null )
{
foreach ( var point in points )
{
hash.Add( MathF.Round( point.Position.x, 2 ) );
hash.Add( MathF.Round( point.Position.y, 2 ) );
hash.Add( MathF.Round( point.Position.z, 2 ) );
hash.Add( MathF.Round( point.Normal.x, 3 ) );
hash.Add( MathF.Round( point.Normal.y, 3 ) );
hash.Add( MathF.Round( point.Normal.z, 3 ) );
}
}
return hash.ToHashCode();
}
private void DrawGuidePreview()
{
if ( hasHoverPoint )
{
using ( Gizmo.Scope( "FenceHover" ) )
{
Gizmo.Draw.Color = Color.White.WithAlpha( 0.8f );
Gizmo.Draw.LineBBox( BBox.FromPositionAndSize( currentHoverPoint, 2.0f ) );
}
}
if ( !startPoint.HasValue )
{
if ( settings.Mode == FenceToolPlacementMode.Spline )
{
DrawSplineGuidePreview();
}
return;
}
if ( settings.Mode != FenceToolPlacementMode.Line )
return;
using ( Gizmo.Scope( "FenceRun" ) )
{
Gizmo.Draw.Color = Color.Parse( "#f8a64c" ) ?? Color.Orange;
Gizmo.Draw.LineBBox( BBox.FromPositionAndSize( startPoint.Value, 2.5f ) );
if ( hasHoverPoint )
{
Gizmo.Draw.Line( startPoint.Value, currentHoverPoint );
}
}
}
private void DrawSplineGuidePreview()
{
var points = BuildActiveSplinePoints( includeHoverPoint: isDrawingSpline );
if ( points.Count == 0 )
return;
using ( Gizmo.Scope( "FenceSplineRun" ) )
{
Gizmo.Draw.Color = Color.Parse( "#f8a64c" ) ?? Color.Orange;
Gizmo.Draw.LineBBox( BBox.FromPositionAndSize( points[0].Position, 2.5f ) );
var samples = FenceDefinitionEditorUtility.BuildSplinePreviewPath( points, settings.SplineInterpolationSteps );
for ( var index = 1; index < samples.Count; index++ )
{
Gizmo.Draw.Line( samples[index - 1].Position, samples[index].Position );
}
}
}
private void CommitCurrentPlan()
{
using var sessionScope = SceneEditorSession.Scope();
using var sceneScope = Scene.Push();
using var undoScope = SceneEditorSession.Active.UndoScope( "Place Fence" ).WithGameObjectCreations().Push();
var placedRoot = FenceDefinitionEditorUtility.CommitPlacementPlan( currentPlan );
if ( !placedRoot.IsValid() )
return;
EditorScene.Selection.Clear();
EditorScene.Selection.Add( placedRoot );
if ( settings.IncrementSeedAfterPlacement )
{
settings.Seed++;
}
CancelCurrentRun();
}
private void CancelCurrentRun()
{
startPoint = null;
isDrawingSpline = false;
splinePoints.Clear();
currentPlan = null;
ResetPreviewCache();
ClearPreviewRoot();
}
private void ResetPreviewCache()
{
lastPreviewStart = Vector3.Zero;
lastPreviewEnd = Vector3.Zero;
lastPreviewPathHash = 0;
lastPreviewSettingsHash = 0;
}
private void ClearPreviewRoot()
{
FenceDefinitionEditorUtility.DestroyHierarchy( previewRoot );
previewRoot = null;
}
private void SyncSavedDefinition()
{
var currentPath = settings.Definition?.ResourcePath ?? string.Empty;
if ( string.Equals( currentPath, persistedDefinitionPath, StringComparison.Ordinal ) )
return;
persistedDefinitionPath = currentPath;
ProjectCookie.Set( DefinitionCookie, persistedDefinitionPath );
}
private static FenceDefinition LoadSavedDefinition()
{
var savedPath = ProjectCookie.Get( DefinitionCookie, string.Empty );
if ( string.IsNullOrWhiteSpace( savedPath ) )
return null;
var asset = AssetSystem.FindByPath( savedPath );
return asset?.LoadResource<FenceDefinition>();
}
}
file sealed class FenceSidebarField : Widget
{
public FenceSidebarField( SerializedProperty property ) : base( null )
{
SetSizeMode( SizeMode.Expand, SizeMode.CanShrink );
VerticalSizeMode = SizeMode.CanShrink;
Layout = Layout.Column();
Layout.Spacing = 2;
Layout.Margin = new Sandbox.UI.Margin( 2, 3, 2, 5 );
var title = Layout.Add( new Label( GetLabelText( property ), this ) );
title.HorizontalSizeMode = SizeMode.Expand;
title.WordWrap = false;
title.Alignment = TextFlag.LeftTop;
title.SetStyles( "font-size: 11px; font-weight: 500; color: rgba(255,255,255,0.72);" );
if ( !string.IsNullOrWhiteSpace( property.Description ) )
{
title.ToolTip = property.Description;
}
var editor = ControlWidget.Create( property );
editor.HorizontalSizeMode = property.PropertyType == typeof( bool ) ? SizeMode.Default : SizeMode.Flexible;
editor.VerticalSizeMode = SizeMode.CanShrink;
editor.MinimumHeight = 0;
editor.FixedHeight = Theme.ControlHeight;
if ( property.PropertyType == typeof( bool ) )
{
editor.FixedWidth = Theme.ControlHeight;
var row = Layout.AddRow();
row.Add( editor );
row.AddStretchCell();
return;
}
Layout.Add( editor );
}
private static string GetLabelText( SerializedProperty property )
{
return property.Name switch
{
nameof( FenceToolSettings.SnapToGround ) => "Ground Snap",
nameof( FenceToolSettings.KeepUpright ) => "Upright",
nameof( FenceToolSettings.SplinePointSpacing ) => "Spline Point Spacing",
nameof( FenceToolSettings.SplineInterpolationSteps ) => "Spline Smoothing",
nameof( FenceToolSettings.IncrementSeedAfterPlacement ) => "Advance Seed",
_ => property.DisplayName
};
}
}