editor/AdvancedMappingTools/Geometry/PathBuilders.cs

...

using System.Globalization;
using System.Text.Json;

namespace AdvancedMappingTools;

public enum ProfileStyle
{
	Baseboard,
	Cornice,
	Beam,
	Pipe,
	Curb,
	Gutter,
	[Title( "Jersey Barrier" )] JerseyBarrier
}

/// <summary>
/// Run a moulding, beam, pipe or barrier along a path, for trim, cables and street furniture.
/// </summary>
[ToolCategory( "Props" )]
[Title( "Profile" ), Icon( "timeline" )]
public sealed class ProfileEditor( PrimitiveTool tool ) : PathEditor( tool )
{
	protected override bool SnapsToLines => true;

	public override IReadOnlySet<string> UnusedOptions { get; } = new HashSet<string> { "Trim" };

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

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

	[Property, Group( "Profile" ), WideMode]
	public ProfileStyle Style { get; set; } = ProfileStyle.Baseboard;

	[Property, Group( "Profile" ), Range( 0.25f, 512, slider: false ), WideMode]
	public float Width { get; set; } = 4.0f;

	[Property, Group( "Profile" ), Range( 0.25f, 512, slider: false ), WideMode]
	public float Height { get; set; } = 8.0f;

	[Property, Group( "Profile" ), Range( -4096, 4096, slider: false ), WideMode]
	public float Elevation { get; set; } = 0.0f;

	[Property, Group( "Profile" ), Title( "Pipe Sides" ), Range( 3, 32, slider: false ), WideMode]
	public int PipeSides { get; set; } = 10;

	protected override bool IsShapeValid => SamplePath().Count >= 2;

	Vector2[] Section()
	{
		var w = Width;
		var h = Height;
		var z = Elevation;

		Vector2[] shape = Style switch
		{
			ProfileStyle.Baseboard => [new( 0, 0 ), new( w, 0 ), new( w, h * 0.7f ), new( w * 0.5f, h ), new( 0, h )],
			ProfileStyle.Cornice => [new( 0, 0 ), new( w * 0.3f, 0 ), new( w * 0.6f, h * 0.3f ), new( w, h * 0.6f ), new( w, h ), new( 0, h )],
			ProfileStyle.Beam => [new( -w / 2, 0 ), new( w / 2, 0 ), new( w / 2, h ), new( -w / 2, h )],
			ProfileStyle.Curb => [new( -w / 2, 0 ), new( w / 2, 0 ), new( w / 2, h * 0.8f ), new( w * 0.35f, h ), new( -w / 2, h )],
			ProfileStyle.Gutter => [new( -w / 2, 0 ), new( w / 2, 0 ), new( w / 2, h ), new( w * 0.35f, h ), new( w * 0.35f, h * 0.2f ), new( -w * 0.35f, h * 0.2f ), new( -w * 0.35f, h ), new( -w / 2, h )],
			ProfileStyle.JerseyBarrier => [new( -w / 2, 0 ), new( w / 2, 0 ), new( w / 2, h * 0.1f ), new( w * 0.3f, h * 0.3f ), new( w * 0.12f, h ), new( -w * 0.12f, h ), new( -w * 0.3f, h * 0.3f ), new( -w / 2, h * 0.1f )],
			_ => [.. Enumerable.Range( 0, Math.Clamp( PipeSides, 3, 32 ) ).Select( i =>
			{
				var angle = MathF.PI * 2.0f * i / Math.Clamp( PipeSides, 3, 32 );
				return new Vector2( MathF.Cos( angle ) * w * 0.5f, MathF.Sin( angle ) * w * 0.5f + w * 0.5f );
			} )]
		};

		return [.. shape.Select( p => new Vector2( p.x, p.y + z ) )];
	}

	protected override void BuildMesh( MeshKit kit ) => SweepPath( kit, SamplePath(), Closed, Section() );
}

/// <summary>
/// A road or path with optional raised kerbs on both sides. It climbs and falls with its points.
/// </summary>
[ToolCategory( "Roads & Paths" )]
[Title( "Road" ), Icon( "add_road" )]
public sealed class RoadEditor( PrimitiveTool tool ) : PathEditor( tool )
{
	protected override int MinPoints => 2;
	protected override bool CanClose => true;
	protected override bool AllowsHeight => true;
	protected override float HeightStep => Climb;

	protected override IEnumerable<string> Hints => [.. base.Hints, "Shift + drag a point: raise or lower it", "Click the green point: close loop"];

	[Property, Group( "Road" ), Title( "Climb Per Point" ), Range( -1024, 1024, slider: false ), WideMode, Description( "How much higher each new point lands than the one before it." )]
	public float Climb { get; set; } = 0.0f;

	[Property, Group( "Road" ), Range( 8, 2048, slider: false ), WideMode]
	public float Width { get; set; } = 192.0f;

	[Property, Group( "Road" ), Range( 0.25f, 128, slider: false ), WideMode]
	public float Thickness { get; set; } = 4.0f;

	[Property, Group( "Road" ), WideMode]
	public bool Kerbs { get; set; } = true;

	[Property, ShowIf( nameof( Kerbs ), true ), Group( "Road" ), Title( "Kerb Width" ), Range( 1, 256, slider: false ), WideMode]
	public float KerbWidth { get; set; } = 12.0f;

	[Property, ShowIf( nameof( Kerbs ), true ), Group( "Road" ), Title( "Kerb Height" ), Range( 0.5f, 64, slider: false ), WideMode]
	public float KerbHeight { get; set; } = 6.0f;

	[Property, Group( "Road" ), Title( "Center Line" ), WideMode, Description( "Dashes along the middle in the trim material." )]
	public bool CenterLine { get; set; } = false;

	protected override bool IsShapeValid => SamplePath().Count >= 2;

	protected override void BuildMesh( MeshKit kit )
	{
		var path = SamplePath();
		var half = Width * 0.5f;
		var top = Thickness;

		// Road and kerbs are one section. As separate bodies they would share a wall inside.
		var surface = SurfaceMaterial;
		var trim = TrimMaterial;

		if ( Kerbs )
		{
			var outer = half + KerbWidth;
			var high = top + KerbHeight;

			// The underside has the same corners as the top: on a climbing turn both fold along the same lines.
			SweepPath( kit, path, Closed,
				[new( -outer, 0 ), new( -half, 0 ), new( half, 0 ), new( outer, 0 ), new( outer, high ), new( half, high ), new( half, top ), new( -half, top ), new( -half, high ), new( -outer, high )],
				[trim, trim, trim, trim, trim, trim, surface, trim, trim, trim] );
		}
		else
		{
			SweepPath( kit, path, Closed, [new( -half, 0 ), new( half, 0 ), new( half, top ), new( -half, top )] );
		}

		kit.Material = TrimMaterial;

		if ( !CenterLine ) return;

		foreach ( var (position, direction) in WalkPath( path, Closed, 96.0f ) )
			kit.Beam( position + Vector3.Up * (top + 0.05f), position + direction * 48.0f + Vector3.Up * (top + 0.05f), 4.0f, 0.1f );
	}
}

/// <summary>
/// A dug in trench: two revetted walls and a floor. It sits below the drawn line.
/// </summary>
[ToolCategory( "Roads & Paths" )]
[Title( "Trench" ), Icon( "horizontal_rule" )]
public sealed class TrenchEditor( PrimitiveTool tool ) : PathEditor( tool )
{
	const float MinimumMeet = 0.3f;
	const float MinimumMeetLength = 8.0f;

	protected override bool SnapsToLines => true;
	protected override bool CanClose => true;

	protected override int MinPoints => 2;

	[Property, Group( "Trench" ), Range( 16, 512, slider: false ), WideMode]
	public float Width { get; set; } = 64.0f;

	[Property, Group( "Trench" ), Range( 16, 512, slider: false ), WideMode]
	public float Depth { get; set; } = 80.0f;

	[Property, Group( "Trench" ), Title( "Wall Thickness" ), Range( 1, 64, slider: false ), WideMode]
	public float WallThickness { get; set; } = 6.0f;

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

	/// <summary>
	/// Where other trenches come in from the side, as "x y z half" separated by ";": a spot on the
	/// wall in the coordinates of the points, and half the width of the gap there.
	/// </summary>
	[Property]
	public string Branches { get; set; } = "";

	public override IReadOnlySet<string> UnusedOptions { get; } = new HashSet<string> { nameof( Branches ) };

	readonly List<(GameObject Owner, Vector3 Spot, float Half)> _meets = [];

	protected override bool IsShapeValid => SamplePath().Count >= 2;

	float Half => Width * 0.5f;
	float Outer => Half + WallThickness;

	IEnumerable<(Vector3 Spot, float Half)> ReadBranches() =>
		(Branches ?? "").Split( ';', StringSplitOptions.RemoveEmptyEntries ).Select( x => x.Split( ' ' ).Select( y => float.Parse( y, CultureInfo.InvariantCulture ) ).ToArray() ).Select( x => (new Vector3( x[0], x[1], x[2] ), x[3]) );

	public void AddBranch( Vector3 spot, float half ) =>
		Branches = (Branches ?? "") + string.Create( CultureInfo.InvariantCulture, $"{spot.x} {spot.y} {spot.z} {half};" );

	/// <summary>
	/// An end on the side of a finished trench stops at the back of its wall, and the wall gets a
	/// gap as wide as this trench. Floor meets floor, the corners stay open to walk through.
	/// </summary>
	protected override void JoinSides( Scene scene )
	{
		_meets.Clear();

		if ( Closed || Points.Count < 2 ) return;

		foreach ( var (index, next) in new[] { (0, 1), (Points.Count - 1, Points.Count - 2) } )
		{
			var end = Points[index].Position;

			if ( LineSnap.Side( scene, end, GetType().Name ) is not { } hit ) continue;

			var toward = (Points[next].Position - end).WithZ( 0.0f ).Normal;
			var flat = hit.Along.WithZ( 0.0f ).Normal;
			var across = (toward - flat * toward.Dot( flat )).Normal;
			var square = toward.Dot( across );

			if ( square < MinimumMeet ) continue;

			var other = hit.Record.Settings;
			var half = Read( other, nameof( Width ), Width ) * 0.5f;
			var wall = Read( other, nameof( WallThickness ), WallThickness );
			var pull = (half + wall) / square;

			if ( end.Distance( Points[next].Position ) <= pull + MinimumMeetLength ) continue;

			MovePoint( index, hit.On + toward * pull );
			_meets.Add( (hit.Owner, hit.On + across * (half + wall * 0.5f), Half / square) );
		}
	}

	static float Read( Dictionary<string, string> settings, string name, float fallback ) =>
		settings.TryGetValue( name, out var text ) && float.TryParse( text, NumberStyles.Float, CultureInfo.InvariantCulture, out var value ) ? value : fallback;

	protected override void TakeOver( GameObject go, BuilderRecord record )
	{
		if ( record.Settings.TryGetValue( nameof( Branches ), out var text ) && !string.IsNullOrEmpty( text ) )
			Branches = (Branches ?? "") + JsonSerializer.Deserialize<string>( text );
	}

	protected override void OnBuilt( MeshComponent component )
	{
		foreach ( var (owner, spot, half) in _meets )
		{
			var record = BuilderStore.LoadRecord( owner.Id );
			var origin = new Vector3( record.Origin[0], record.Origin[1], record.Origin[2] );
			var local = owner.WorldTransform.PointToLocal( spot ) + origin;

			Rebuild( owner, x => ((TrenchEditor)x).AddBranch( local, half ) );
		}

		_meets.Clear();
	}

	static float Project( IReadOnlyList<Vector3> path, bool closed, Vector3 point, out Vector3 tangent, out Vector3 on )
	{
		var best = float.MaxValue;
		var walked = 0.0f;
		var result = 0.0f;
		var steps = closed ? path.Count : path.Count - 1;

		tangent = Vector3.Forward;
		on = point;

		for ( var i = 0; i < steps; i++ )
		{
			var a = path[i];
			var along = path[(i + 1) % path.Count] - a;
			var length = along.Length;
			var t = length > 0.0f ? Math.Clamp( (point - a).WithZ( 0.0f ).Dot( along.WithZ( 0.0f ) ) / along.WithZ( 0.0f ).LengthSquared, 0.0f, 1.0f ) : 0.0f;
			var spot = a + along * t;
			var distance = spot.WithZ( 0.0f ).Distance( point.WithZ( 0.0f ) );

			if ( distance < best )
			{
				best = distance;
				result = walked + length * t;
				tangent = along.Normal;
				on = spot;
			}

			walked += length;
		}

		return result;
	}

	static List<Vector3> Slice( IReadOnlyList<Vector3> path, bool closed, float from, float to )
	{
		var result = new List<Vector3>();
		var count = closed ? path.Count + 1 : path.Count;
		var walked = 0.0f;

		// Twice round for a closed line, a piece may run over its seam.
		for ( var round = 0; round < (closed ? 2 : 1); round++ )
		{
			for ( var i = 0; i + 1 < count; i++ )
			{
				var a = path[i];
				var b = path[(i + 1) % path.Count];
				var length = a.Distance( b );
				var stop = walked + length;

				if ( stop > from && walked < to && length > 0.0f )
				{
					if ( result.Count == 0 ) result.Add( Vector3.Lerp( a, b, (MathF.Max( from, walked ) - walked) / length ) );

					result.Add( Vector3.Lerp( a, b, (MathF.Min( to, stop ) - walked) / length ) );
				}

				walked = stop;
			}
		}

		return result;
	}

	/// <summary>
	/// One side wall, in pieces if trenches come in on that side.
	/// </summary>
	void Wall( MeshKit kit, List<Vector3> path, float sign, IReadOnlyList<Vector2> section, List<(Vector3 Spot, float Half)> gaps )
	{
		var cuts = gaps
			.Select( x => (Along: Project( path, Closed, x.Spot, out var tangent, out var on ), x.Half, Side: new Vector3( -tangent.y, tangent.x, 0.0f ).Dot( x.Spot - on ) * sign) )
			.Where( x => x.Side > 0.0f ).OrderBy( x => x.Along ).ToList();

		if ( cuts.Count == 0 )
		{
			SweepPath( kit, path, Closed, section );
			return;
		}

		var total = 0.0f;
		var steps = Closed ? path.Count : path.Count - 1;

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

		var pieces = new List<(float From, float To)>();

		if ( !Closed ) pieces.Add( (0.0f, cuts[0].Along - cuts[0].Half) );

		for ( var i = 0; i + 1 < cuts.Count; i++ )
			pieces.Add( (cuts[i].Along + cuts[i].Half, cuts[i + 1].Along - cuts[i + 1].Half) );

		pieces.Add( Closed ? (cuts[^1].Along + cuts[^1].Half, cuts[0].Along - cuts[0].Half + total) : (cuts[^1].Along + cuts[^1].Half, total) );

		foreach ( var (from, to) in pieces )
		{
			if ( to - from < 1.0f ) continue;

			var piece = Slice( path, Closed, from, to );

			if ( piece.Count >= 2 ) SweepPath( kit, piece, false, section );
		}
	}

	protected override void BuildMesh( MeshKit kit )
	{
		var path = SamplePath();
		var half = Half;
		var low = -Depth;
		var wall = Outer;
		var gaps = ReadBranches().ToList();

		// Floor and walls as three simple rectangles. The compound U section swept into
		// itself and put the floor on the wrong side, which showed up as huge plates.
		SweepPath( kit, path, Closed, [new( -wall, low - 4 ), new( wall, low - 4 ), new( wall, low ), new( -wall, low )] );
		Wall( kit, path, 1.0f, [new( half, low ), new( wall, low ), new( wall, 0 ), new( half, 0 )], gaps );
		Wall( kit, path, -1.0f, [new( -wall, low ), new( -half, low ), new( -half, 0 ), new( -wall, 0 )], gaps );

		kit.Material = TrimMaterial;

		var rib = MathF.Max( WallThickness * 0.4f, 1.0f );

		foreach ( var (position, direction) in WalkPath( path, Closed, MathF.Max( PostSpacing, 8.0f ) ) )
		{
			// A pilaster in the middle of each wall, flush with the rim, instead of a fin
			// standing in the trench.
			var left = new Vector3( -direction.y, direction.x, 0.0f ).Normal * (half + WallThickness * 0.5f);

			// Near a corner the spot beside the line is not wall any more, a post there would
			// stand alone in the open. Where a trench comes in there is no wall at all.
			foreach ( var post in new[] { position + left, position - left } )
			{
				if ( !InWall( path, post, half, wall ) || gaps.Any( x => x.Spot.WithZ( 0.0f ).Distance( post.WithZ( 0.0f ) ) < x.Half + rib ) ) continue;

				kit.Box( post + new Vector3( -rib, -rib, low ), post + new Vector3( rib, rib, 1.0f ) );
			}
		}
	}

	bool InWall( IReadOnlyList<Vector3> path, Vector3 point, float inner, float outer )
	{
		var nearest = float.MaxValue;
		var steps = Closed ? path.Count : path.Count - 1;
		var flat = point.WithZ( 0.0f );

		for ( var i = 0; i < steps; i++ )
		{
			var a = path[i].WithZ( 0.0f );
			var along = path[(i + 1) % path.Count].WithZ( 0.0f ) - a;
			var t = along.LengthSquared > 0.0f ? Math.Clamp( (flat - a).Dot( along ) / along.LengthSquared, 0.0f, 1.0f ) : 0.0f;

			nearest = MathF.Min( nearest, (a + along * t).Distance( flat ) );
		}

		return nearest >= inner && nearest <= outer;
	}
}

/// <summary>
/// A balustrade or handrail along a path.
/// </summary>
public enum RailingInfill
{
	[Title( "None" )] None,
	[Title( "Vertical Bars" )] Bars,
	[Title( "Glass Band" )] Glass,
	[Title( "Horizontal Wires" )] Wires
}

[ToolCategory( "Walls & Floors" )]
[Title( "Railing" ), Icon( "drag_handle" )]
public sealed class RailingEditor( PrimitiveTool tool ) : PathEditor( tool )
{
	protected override bool SnapsToLines => true;

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

	[Property, Group( "Railing" ), Range( 8, 256, slider: false ), WideMode]
	public float Height { get; set; } = 40.0f;

	[Property, Group( "Railing" ), Title( "Post Spacing" ), Range( 4, 512, slider: false ), WideMode]
	public float PostSpacing { get; set; } = 8.0f;

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

	[Property, Group( "Railing" ), Title( "Rail Width" ), Range( 0.5f, 64, slider: false ), WideMode]
	public float RailWidth { get; set; } = 4.0f;

	[Property, Group( "Railing" ), Title( "Lower Rail" ), WideMode]
	public bool LowerRail { get; set; } = true;

	[Property, Group( "Railing" ), Title( "Infill" ), WideMode, Description( "What fills the space between the rails." )]
	public RailingInfill Infill { get; set; } = RailingInfill.None;

	[Property, HideIf( nameof( Infill ), RailingInfill.None ), Group( "Railing" ), Title( "Infill Spacing" ), Range( 0.5f, 64, slider: false ), WideMode]
	public float InfillSpacing { get; set; } = 6.0f;

	[Property, HideIf( nameof( Infill ), RailingInfill.None ), Group( "Railing" ), Title( "Infill Size" ), Range( 0.2f, 8, slider: false ), WideMode]
	public float InfillSize { get; set; } = 1.0f;

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

	protected override bool IsShapeValid => SamplePath().Count >= 2;

	protected override void BuildMesh( MeshKit kit )
	{
		var path = SamplePath();
		var half = RailWidth * 0.5f;

		foreach ( var (position, _) in WalkPath( path, Closed, MathF.Max( PostSpacing, 4.0f ) ) )
		{
			var size = PostSize * 0.5f;
			var top = Height - half;

			kit.Box( position + new Vector3( -size, -size, 0.0f ), position + new Vector3( size, size, top ) );

			if ( !PostCaps ) continue;

			var cap = size * 1.4f;

			kit.Box( position + new Vector3( -cap, -cap, top ), position + new Vector3( cap, cap, top + 1.4f ) );
		}

		kit.Material = TrimMaterial;
		SweepPath( kit, path, Closed, [new( -half, Height - half ), new( half, Height - half ), new( half, Height ), new( -half, Height )] );

		if ( LowerRail )
			SweepPath( kit, path, Closed, [new( -half, 4 ), new( half, 4 ), new( half, 4 + half ), new( -half, 4 + half )] );

		if ( Infill == RailingInfill.Bars )
		{
			var bar = MathF.Max( InfillSize, 0.2f ) * 0.5f;
			var bottom = LowerRail ? 4.0f + half : 2.0f;
			var top = Height - half;

			foreach ( var (position, _) in WalkPath( path, Closed, MathF.Max( InfillSpacing, 1.0f ) ) )
			{
				if ( top - bottom < 0.5f ) break;

				kit.Box( position + new Vector3( -bar, -bar, bottom ), position + new Vector3( bar, bar, top ) );
			}
		}
		else if ( Infill == RailingInfill.Glass )
		{
			var glass = MathF.Max( InfillSize, 0.2f ) * 0.5f;
			var bottom = LowerRail ? 4.0f + half : 2.0f;

			SweepPath( kit, path, Closed, [new( -glass, bottom ), new( glass, bottom ), new( glass, Height - half ), new( -glass, Height - half )] );
		}
		else if ( Infill == RailingInfill.Wires )
		{
			var wire = MathF.Max( InfillSize, 0.2f ) * 0.5f;
			var bottom = LowerRail ? 4.0f + half : 2.0f;
			var top = Height - half;
			var steps = Math.Max( 1, (int)MathF.Round( (top - bottom) / MathF.Max( InfillSpacing, 1.0f ) ) );

			for ( var w = 1; w <= steps; w++ )
			{
				var z = bottom + (top - bottom) * w / (steps + 1.0f);

				SweepPath( kit, path, Closed, [new( -wire, z ), new( wire, z ), new( wire, z + wire * 2.0f ), new( -wire, z + wire * 2.0f )] );
			}
		}
	}
}

/// <summary>
/// Barbed wire on posts, with slack strands.
/// </summary>
[ToolCategory( "Defense" )]
[Title( "Barbed Wire" ), Icon( "gesture" )]
public sealed class BarbedWireEditor( PrimitiveTool tool ) : PathEditor( tool )
{
	protected override bool SnapsToLines => true;
	protected override bool CanClose => true;

	protected override int MinPoints => 2;

	[Property, Group( "Barbed Wire" ), Range( 8, 256, slider: false ), WideMode]
	public float Height { get; set; } = 48.0f;

	[Property, Group( "Barbed Wire" ), Title( "Post Spacing" ), Range( 16, 512, slider: false ), WideMode]
	public float PostSpacing { get; set; } = 96.0f;

	[Property, Group( "Barbed Wire" ), Range( 1, 12, slider: false ), WideMode]
	public int Strands { get; set; } = 4;

	[Property, Group( "Barbed Wire" ), Range( 0, 16, slider: false ), WideMode]
	public float Slack { get; set; } = 3.0f;

	[Property, Group( "Barbed Wire" ), Title( "Post Lean" ), Range( 0, 32, slider: false ), WideMode, Description( "How far the tops of the posts lean along the line. Zero keeps them upright." )]
	public float PostLean { get; set; } = 6.0f;

	[Property, Group( "Barbed Wire" ), Range( 0, 9999, slider: false ), WideMode]
	public int Seed { get; set; } = 1;

	protected override bool IsShapeValid => SamplePath().Count >= 2;

	protected override void BuildMesh( MeshKit kit )
	{
		var path = SamplePath();
		var random = new Random( Seed );
		var posts = WalkPath( path, Closed, MathF.Max( PostSpacing, 16.0f ) );

		if ( !Closed && !posts[^1].Position.AlmostEqual( path[^1], 0.01f ) )
			posts.Add( (path[^1], (path[^1] - path[^2]).Normal) );

		foreach ( var (position, direction) in posts )
		{
			var lean = direction * PostLean * ((float)random.NextDouble() * 2.0f - 1.0f);
			var foot = position;
			var top = position + lean + Vector3.Up * Height;

			kit.Hex(
				foot + new Vector3( -1, -1, 0 ), foot + new Vector3( 1, -1, 0 ), foot + new Vector3( 1, 1, 0 ), foot + new Vector3( -1, 1, 0 ),
				top + new Vector3( -1, -1, 0 ), top + new Vector3( 1, -1, 0 ), top + new Vector3( 1, 1, 0 ), top + new Vector3( -1, 1, 0 ) );
		}

		kit.Material = TrimMaterial;

		for ( var i = 0; i < posts.Count - 1; i++ )
		{
			var a = posts[i].Position;
			var b = posts[i + 1].Position;

			for ( var s = 1; s <= Strands; s++ )
			{
				var z = Height * s / (Strands + 1f) + Vector3.Up.z;
				var sag = -Slack * (float)random.NextDouble();
				var middle = (a + b) * 0.5f + Vector3.Up * (z + sag);

				kit.Beam( a + Vector3.Up * z, middle, 0.4f, 0.4f );
				kit.Beam( middle, b + Vector3.Up * z, 0.4f, 0.4f );
			}
		}
	}
}

/// <summary>
/// Stacked sandbags along a path, with every other row shifted.
/// </summary>
[ToolCategory( "Defense" )]
[Title( "Sandbags" ), Icon( "view_stream" )]
public sealed class SandbagEditor( PrimitiveTool tool ) : PathEditor( tool )
{
	protected override bool SnapsToLines => true;
	protected override bool CanClose => true;

	public override IReadOnlySet<string> UnusedOptions { get; } = new HashSet<string> { "Trim" };

	protected override int MinPoints => 2;

	[Property, Group( "Sandbags" ), Range( 1, 32, slider: false ), WideMode]
	public int Rows { get; set; } = 4;

	[Property, Group( "Sandbags" ), Title( "Bag Length" ), Range( 4, 128, slider: false ), WideMode]
	public float BagLength { get; set; } = 24.0f;

	[Property, Group( "Sandbags" ), Title( "Bag Width" ), Range( 2, 128, slider: false ), WideMode]
	public float BagWidth { get; set; } = 12.0f;

	[Property, Group( "Sandbags" ), Title( "Bag Height" ), Range( 1, 64, slider: false ), WideMode]
	public float BagHeight { get; set; } = 6.0f;

	[Property, Group( "Sandbags" ), Title( "Random Turn" ), Range( 0, 45, slider: false ), WideMode]
	public float RandomTurn { get; set; } = 6.0f;

	[Property, Group( "Sandbags" ), Range( 0, 9999, slider: false ), WideMode]
	public int Seed { get; set; } = 1;

	protected override bool IsShapeValid => SamplePath().Count >= 2;

	protected override void BuildMesh( MeshKit kit )
	{
		var path = SamplePath();
		var random = new Random( Seed );
		var spots = WalkPath( path, Closed, MathF.Max( BagLength, 4.0f ) );

		var l = BagLength * 0.5f;
		var w = BagWidth * 0.5f;
		var inset = MathF.Min( l, w ) * 0.2f;

		for ( var row = 0; row < Rows; row++ )
		{
			var shift = row % 2 == 1 ? BagLength * 0.5f : 0.0f;

			foreach ( var (position, direction) in spots )
			{
				var yaw = ((float)random.NextDouble() - 0.5f) * RandomTurn * 2.0f;
				var rotation = Rotation.LookAt( direction.WithZ( 0.0f ).Normal, Vector3.Up ) * Rotation.FromYaw( yaw );
				var place = position + direction * shift + Vector3.Up * (row * BagHeight);

				kit.Frame = new Transform( place, rotation );
				kit.Hex(
					new( -l + inset, -w + inset, 0 ), new( l - inset, -w + inset, 0 ), new( l - inset, w - inset, 0 ), new( -l + inset, w - inset, 0 ),
					new( -l, -w, BagHeight * 0.5f ), new( l, -w, BagHeight * 0.5f ), new( l, w, BagHeight * 0.5f ), new( -l, w, BagHeight * 0.5f ) );
				kit.Hex(
					new( -l, -w, BagHeight * 0.5f ), new( l, -w, BagHeight * 0.5f ), new( l, w, BagHeight * 0.5f ), new( -l, w, BagHeight * 0.5f ),
					new( -l + inset, -w + inset, BagHeight ), new( l - inset, -w + inset, BagHeight ), new( l - inset, w - inset, BagHeight ), new( -l + inset, w - inset, BagHeight ) );
			}
		}

		kit.Frame = new Transform( Vector3.Zero, Rotation.Identity );
	}
}

/// <summary>
/// Place copies of a prefab along a path, with random spread.
/// </summary>
[ToolCategory( "Scatter" )]
[Title( "Scatter Prefabs" ), Icon( "scatter_plot" )]
public sealed class ScatterEditor( PrimitiveTool tool ) : PathEditor( tool )
{
	protected override int MinPoints => 2;
	protected override bool CanClose => true;
	protected override bool PlacesObjects => true;

	protected override bool IsShapeValid => Prefab is not null && SamplePath().Count >= 2;

	[Property, Group( "Scatter" ), WideMode]
	public PrefabFile Prefab { get; set; }

	[Property, Group( "Scatter" ), Range( 1, 2048, slider: false ), WideMode]
	public float Spacing { get; set; } = 64.0f;

	[Property, Group( "Scatter" ), Title( "Side Spread" ), Range( 0, 1024, slider: false ), WideMode]
	public float SideSpread { get; set; } = 0.0f;

	[Property, Group( "Scatter" ), Title( "Follow Path" ), WideMode, Description( "Turns each copy along the path." )]
	public bool FollowPath { get; set; } = true;

	[Property, Group( "Scatter" ), Title( "Random Turn" ), Range( 0, 180, slider: false ), WideMode]
	public float RandomTurn { get; set; } = 0.0f;

	[Property, Group( "Scatter" ), Title( "Scale Min" ), Range( 0.05f, 16, slider: false ), WideMode]
	public float ScaleMin { get; set; } = 1.0f;

	[Property, Group( "Scatter" ), Title( "Scale Max" ), Range( 0.05f, 16, slider: false ), WideMode]
	public float ScaleMax { get; set; } = 1.0f;

	[Property, Group( "Scatter" ), Title( "Drop To Ground" ), WideMode]
	public bool DropToGround { get; set; } = true;

	[Property, Group( "Scatter" ), Range( 0, 9999, slider: false ), WideMode]
	public int Seed { get; set; } = 1;

	protected override void BuildMesh( MeshKit kit ) { }

	public override void OnUpdate( SceneTrace trace )
	{
		base.OnUpdate( trace );

		if ( Points.Count < 2 ) return;

		Gizmo.Draw.Color = Color.Cyan;

		foreach ( var (position, _) in WalkPath( SamplePath(), Closed, MathF.Max( Spacing, 1.0f ) ) )
			Gizmo.Draw.SolidSphere( position, 3.0f );
	}

	protected override void PlaceObjects()
	{
		var scene = SceneEditorSession.Active.Scene;
		var source = SceneUtility.GetPrefabScene( Prefab );
		var random = new Random( Seed );
		var root = new GameObject( true, $"Scatter {Prefab.ResourceName}" );

		root.WorldPosition = Points[0].Position;

		foreach ( var (position, direction) in WalkPath( SamplePath(), Closed, MathF.Max( Spacing, 1.0f ) ) )
		{
			var left = new Vector3( -direction.y, direction.x, 0.0f ).Normal;
			var spot = position + left * (((float)random.NextDouble() - 0.5f) * SideSpread * 2.0f);

			if ( DropToGround )
			{
				var hit = scene.Trace.Ray( spot + Vector3.Up * 256.0f, spot + Vector3.Down * 2048.0f ).Run();
				if ( hit.Hit ) spot = hit.EndPosition;
			}

			var yaw = ((float)random.NextDouble() - 0.5f) * RandomTurn * 2.0f;
			var rotation = (FollowPath ? Rotation.LookAt( direction.WithZ( 0.0f ).Normal, Vector3.Up ) : Rotation.Identity) * Rotation.FromYaw( yaw );
			var scale = ScaleMin + (ScaleMax - ScaleMin) * (float)random.NextDouble();

			var copy = source.Clone( new Transform( spot, rotation, scale ) );
			copy.SetParent( root, true );
		}
	}
}