editor/AdvancedMappingTools/Geometry/FenceEditor.cs

Editor tool that builds fence geometry along a drawn path. Places posts at path points and spacing intervals, then fills each span with rails, pickets, panels, bars, boards, chain link, concrete or hedge, and can cut in a swinging gate.

namespace AdvancedMappingTools;

public enum FenceStyle
{
	Rails,
	Pickets,
	Panel,
	[Title( "Chain Link" )] ChainLink,
	[Title( "Metal Bars" )] Bars,
	[Title( "Privacy Boards" )] Boards,
	[Title( "Concrete Wall" )] Concrete,
	Hedge
}

/// <summary>
/// Draw a fence with posts along a path. Placed points always get a post.
/// </summary>
[ToolCategory( "Walls & Floors" )]
[Title( "Fence" ), Icon( "fence" )]
public sealed class FenceEditor( PrimitiveTool tool ) : PathEditor( tool )
{
	protected override bool SnapsToLines => true;

	protected override int MinPoints => 2;
	protected override bool CanClose => true;

	protected override IEnumerable<string> Hints => [.. base.Hints, "Click the green point: close loop"];

	[Property, WideMode]
	public FenceStyle Style { get; set; } = FenceStyle.Rails;

	[Property, Range( 4, 1024, slider: false ), WideMode]
	public float Height { get; set; } = 48.0f;

	[Property, Title( "Post Spacing" ), Range( 8, 1024, slider: false ), WideMode]
	public float PostSpacing { get; set; } = 64.0f;

	[Property, Title( "Post Size" ), Range( 0.5f, 64, slider: false ), WideMode]
	public float PostSize { get; set; } = 4.0f;

	[Property, Title( "Rail Count" ), Range( 1, 16, slider: false ), WideMode]
	public int RailCount { get; set; } = 2;

	[Property, Title( "Rail Size" ), Range( 0.25f, 32, slider: false ), WideMode]
	public float RailSize { get; set; } = 2.0f;

	[Property, Title( "Picket Spacing" ), Range( 1, 64, slider: false ), WideMode]
	public float PicketSpacing { get; set; } = 6.0f;

	[Property, Title( "Gate" ), WideMode, Description( "Leaves one span open and builds a gate into it." )]
	public bool Gate { get; set; } = false;

	[Property, ShowIf( nameof( Gate ), true ), Title( "Gate Width" ), Range( 24, 1024, slider: false ), WideMode]
	public float GateWidth { get; set; } = 96.0f;

	[Property, ShowIf( nameof( Gate ), true ), Title( "Gate Position" ), Range( 0, 1 ), WideMode, Description( "Where along the fence the gate sits." )]
	public float GateAt { get; set; } = 0.5f;

	[Property, ShowIf( nameof( Gate ), true ), Title( "Gate Swing" ), Range( 0, 90, slider: false ), WideMode, Description( "How far the two leaves stand open. Zero keeps the gate closed." )]
	public float GateSwing { get; set; } = 0.0f;

	[Property, ShowIf( nameof( Gate ), true ), Title( "Single Leaf" ), WideMode, Description( "One gate leaf across the whole opening instead of two." )]
	public bool GateSingleLeaf { get; set; } = false;

	[Property, ShowIf( nameof( GateSingleLeaf ), true ), Title( "Hinge Right" ), WideMode, Description( "Hangs a single leaf on the right gate post instead of the left one." )]
	public bool GateHingeRight { get; set; } = false;

	[Property, Title( "Post Caps" ), WideMode, Description( "A small cap on top of every fence post." )]
	public bool PostCaps { get; set; } = false;

	protected override void BuildMesh( MeshKit kit )
	{
		var posts = CollectPosts();
		var spans = Closed ? posts.Count : posts.Count - 1;

		var gateSpan = -1;

		if ( Gate && spans > 0 )
		{
			var total = 0.0f;

			for ( var i = 0; i < spans; i++ )
				total += posts[i].Distance( posts[(i + 1) % posts.Count] );

			var target = Math.Clamp( GateAt, 0.0f, 1.0f ) * total;
			var walked = 0.0f;

			for ( var i = 0; i < spans; i++ )
			{
				var part = posts[i].Distance( posts[(i + 1) % posts.Count] );

				if ( target >= walked && target <= walked + part )
				{
					gateSpan = i;
					break;
				}

				walked += part;
			}
		}

		for ( var i = 0; i < spans; i++ )
		{
			var a = posts[i];
			var b = posts[(i + 1) % posts.Count];
			var direction = (b - a).WithZ( 0.0f );

			if ( direction.Length < 0.01f ) continue;

			kit.Frame = new Transform( a, Rotation.LookAt( direction.Normal, Vector3.Up ) );
			kit.Material = SurfaceMaterial;
			BuildPost();

			if ( i == spans - 1 && !Closed )
			{
				kit.Frame = new Transform( b, Rotation.LookAt( direction.Normal, Vector3.Up ) );
				BuildPost();
				kit.Frame = new Transform( a, Rotation.LookAt( direction.Normal, Vector3.Up ) );
			}

			kit.Material = TrimMaterial;

			if ( i == gateSpan ) BuildGate( direction.Length );
			else BuildSpan( direction.Length );
		}

		void BuildGate( float length )
		{
			var half = MathF.Min( GateWidth * 0.5f, (length - PostSize) * 0.5f );

			if ( half < 8.0f )
			{
				BuildSpan( length );
				return;
			}

			var rail = RailSize * 0.5f;
			var center = length * 0.5f;
			var left = center - half;
			var right = center + half;
			var post = MathF.Max( PostSize * 0.4f, 1.0f );
			var bottom = MathF.Max( 4.0f, Height * 0.08f );
			var top = Height - MathF.Max( 4.0f, Height * 0.06f );
			var bar = MathF.Max( RailSize * 0.35f, 0.5f );

			// One leaf between two posts: two rails, two stiles, bars and a brace.
			void LeafFrame( float from, float to )
			{
				if ( to - from < 2.0f ) return;

				kit.Box( new Vector3( from, -rail, bottom ), new Vector3( to, rail, bottom + rail * 2.0f ) );
				kit.Box( new Vector3( from, -rail, top - rail * 2.0f ), new Vector3( to, rail, top ) );
				kit.Box( new Vector3( from, -rail, bottom ), new Vector3( from + rail * 2.0f, rail, top ) );
				kit.Box( new Vector3( to - rail * 2.0f, -rail, bottom ), new Vector3( to, rail, top ) );

				var gap = MathF.Max( PicketSpacing, 2.0f );

				for ( var x = from + rail * 2.0f + gap; x < to - rail * 2.0f - gap * 0.5f; x += gap )
					kit.Box( new Vector3( x - bar, -bar, bottom + rail * 2.0f ), new Vector3( x + bar, bar, top - rail * 2.0f ) );

				kit.Beam( new Vector3( from + rail * 2.0f, 0.0f, bottom + rail * 2.0f ), new Vector3( to - rail * 2.0f, 0.0f, top - rail * 2.0f ), 1.0f, 1.2f );
			}

			// The gate posts stand a little higher than the fence.
			kit.Box( new Vector3( left - post, -post, 0.0f ), new Vector3( left + post, post, Height + 2.0f ) );
			kit.Box( new Vector3( right - post, -post, 0.0f ), new Vector3( right + post, post, Height + 2.0f ) );

			// Open leaves hang on their hinge posts and swing out of the fence line.
			var swing = Math.Clamp( GateSwing, 0.0f, 90.0f ) * (MathF.PI / 180.0f);

			if ( swing > 0.01f )
			{
				var leaf = GateSingleLeaf ? right - left : half - rail * 2.0f;
				var low = bottom + rail * 2.0f;
				var high = top - rail * 2.0f;
				var mid = (low + high) * 0.5f;
				var reach = MathF.Cos( swing ) * leaf;
				var swingOut = MathF.Sin( swing ) * leaf;
				var leafBar = MathF.Max( RailSize * 0.35f, 0.5f );
				var leafGap = MathF.Max( PicketSpacing, 2.0f );
				var count = Math.Max( 1, (int)MathF.Floor( (high - low) / leafGap ) );

				var hinges = GateSingleLeaf
					? (GateHingeRight ? new[] { (right, right - reach) } : new[] { (left, left + reach) })
					: new[] { (left, left + reach), (right, right - reach) };

				foreach ( var (hinge, end) in hinges )
				{
					kit.Beam( new Vector3( hinge, 0.0f, mid ), new Vector3( end, -swingOut, mid ), rail * 2.0f, high - low );
					kit.Box( new Vector3( hinge - rail, -rail, low ), new Vector3( hinge + rail, rail, high ) );
					kit.Box( new Vector3( end - rail, -swingOut - rail, low ), new Vector3( end + rail, -swingOut + rail, high ) );

					for ( var i = 1; i < count; i++ )
					{
						var t = i / (float)count;
						var x = hinge + (end - hinge) * t;
						var y = -swingOut * t;

						kit.Box( new Vector3( x - leafBar, y - leafBar, low ), new Vector3( x + leafBar, y + leafBar, high ) );
					}
				}

				return;
			}

			if ( GateSingleLeaf )
			{
				LeafFrame( left, right );
			}
			else
			{
				LeafFrame( left, center - 1.0f );
				LeafFrame( center + 1.0f, right );
			}
		}

		void BuildPost()
		{
			var half = PostSize * 0.5f;
			kit.Box( new Vector3( -half, -half, 0.0f ), new Vector3( half, half, Height ) );

			if ( !PostCaps ) return;

			var cap = half * 1.35f;

			kit.Box( new Vector3( -cap, -cap, Height ), new Vector3( cap, cap, Height + 1.6f ) );
		}

		void BuildSpan( float length )
		{
			var half = PostSize * 0.5f;
			var rail = RailSize * 0.5f;
			var start = half;
			var end = length - half;

			if ( end <= start ) return;

			if ( Style == FenceStyle.Panel )
			{
				kit.Box( new Vector3( start, -rail, 0.0f ), new Vector3( end, rail, Height ) );
				return;
			}

			if ( Style == FenceStyle.Concrete )
			{
				kit.Box( new Vector3( start, -half * 0.6f, 0.0f ), new Vector3( end, half * 0.6f, Height ) );
				kit.Box( new Vector3( start, -half, Height ), new Vector3( end, half, Height + 2.0f ) );
				return;
			}

			if ( Style == FenceStyle.Hedge )
			{
				kit.Box( new Vector3( start, -half * 1.5f, 0.0f ), new Vector3( end, half * 1.5f, Height ) );
				return;
			}

			if ( Style == FenceStyle.ChainLink )
			{
				// The wire is one thin sheet, its look comes from the material on it.
				kit.Box( new Vector3( start, -0.1f, rail ), new Vector3( end, 0.1f, Height - rail * 2.0f ) );
				kit.Box( new Vector3( start, -rail, Height - rail * 2.0f ), new Vector3( end, rail, Height ) );
				return;
			}

			if ( Style == FenceStyle.Bars )
			{
				var gap = MathF.Max( PicketSpacing, 1.0f );
				var bars = (int)MathF.Max( 1.0f, MathF.Floor( (end - start) / gap ) );

				kit.Box( new Vector3( start, -rail, 2.0f ), new Vector3( end, rail, 2.0f + rail * 2.0f ) );
				kit.Box( new Vector3( start, -rail, Height - rail * 2.0f ), new Vector3( end, rail, Height ) );

				for ( var b = 0; b < bars; b++ )
				{
					var x = start + (end - start) * (b + 0.5f) / bars;

					kit.Box( new Vector3( x - 0.5f, -0.5f, 2.0f + rail * 2.0f ), new Vector3( x + 0.5f, 0.5f, Height - rail * 2.0f ) );
				}

				return;
			}

			if ( Style == FenceStyle.Boards )
			{
				var width = MathF.Max( PicketSpacing, 1.0f );
				var boards = (int)MathF.Max( 1.0f, MathF.Floor( (end - start) / width ) );
				var each = (end - start) / boards;

				for ( var r = 0; r < 2; r++ )
				{
					var z = Height * (r + 1.0f) / 3.0f;
					kit.Box( new Vector3( start, -rail, z - rail ), new Vector3( end, rail, z + rail ) );
				}

				for ( var b = 0; b < boards; b++ )
					kit.Box( new Vector3( start + b * each + 0.1f, rail, 0.0f ), new Vector3( start + (b + 1) * each - 0.1f, rail + 0.75f, Height ) );

				return;
			}

			var rails = Style == FenceStyle.Pickets ? 2 : Math.Clamp( RailCount, 1, 16 );

			for ( var r = 0; r < rails; r++ )
			{
				var z = Height * (r + 1.0f) / (rails + 1.0f);
				kit.Box( new Vector3( start, -rail, z - rail ), new Vector3( end, rail, z + rail ) );
			}

			if ( Style != FenceStyle.Pickets ) return;

			var spacing = MathF.Max( PicketSpacing, 1.0f );
			var count = (int)MathF.Max( 1.0f, MathF.Floor( (end - start) / spacing ) );
			var picket = spacing * 0.25f;

			for ( var p = 0; p < count; p++ )
			{
				var x = start + (end - start) * (p + 0.5f) / count;
				kit.Box( new Vector3( x - picket, rail, 0.0f ), new Vector3( x + picket, rail + 0.5f, Height ) );
			}
		}
	}

	/// <summary>
	/// Spread posts evenly over each leg of the path so corners keep theirs.
	/// </summary>
	List<Vector3> CollectPosts()
	{
		var posts = new List<Vector3>();

		void Add( Vector3 post )
		{
			if ( posts.Count == 0 || !posts[^1].AlmostEqual( post, 0.01f ) )
				posts.Add( post );
		}

		foreach ( var leg in SampleLegs() )
		{
			var length = 0.0f;
			for ( var i = 0; i < leg.Count - 1; i++ )
				length += leg[i].Distance( leg[i + 1] );

			var spans = (int)MathF.Max( 1.0f, MathF.Round( length / MathF.Max( PostSpacing, 8.0f ) ) );
			var step = length / spans;
			var next = 0.0f;
			var walked = 0.0f;

			for ( var i = 0; i < leg.Count - 1; i++ )
			{
				var part = leg[i].Distance( leg[i + 1] );

				while ( part > 0.0f && next <= walked + part && next < length - 0.01f )
				{
					Add( Vector3.Lerp( leg[i], leg[i + 1], (next - walked) / part ) );
					next += step;
				}

				walked += part;
			}

			Add( leg[^1] );
		}

		if ( Closed && posts.Count > 1 && posts[0].AlmostEqual( posts[^1], 0.01f ) )
			posts.RemoveAt( posts.Count - 1 );

		return posts;
	}
}