editor/AdvancedMappingTools/Architecture/HedgeRunEditor.cs

Editor tool that draws a hedge or a row of planters along a path. HedgeRunGenerator sweeps a rounded, box or tapered cross section along the curve (with seeded bulges), or places square/round planters with rim, soil and a shrub or tree lathe; HedgeRunEditor wires it into the primitive path tool with live preview and object placement.

namespace AdvancedMappingTools;

public enum HedgeRunStyle
{
	Hedge,
	Planter
}

public enum HedgeShape
{
	Rounded,
	Box,
	Tapered
}

public enum PlanterShape
{
	Square,
	Round
}

public enum PlanterPlant
{
	Shrub,
	Tree
}

public sealed class HedgeRunSettings : GeneratorSettings
{
	public PathCurve Path { get; set; } = PathCurve.FromPoints( [Vector3.Zero, new Vector3( 512.0f, 0.0f, 0.0f )] );

	[Property, Group( "Hedge Run" ), Title( "Style" ), WideMode]
	public HedgeRunStyle Style { get; set; } = HedgeRunStyle.Hedge;

	[Property, Group( "Hedge Run" ), Range( 8, 256, slider: false ), WideMode, Description( "Height of the hedge, or of the plants above the planters." )]
	public float Height { get; set; } = 64.0f;

	[Property, Group( "Hedge Run" ), Range( 8, 128, slider: false ), WideMode, Description( "Width of the hedge at its foot, or the size of a planter." )]
	public float Width { get; set; } = 32.0f;

	[Property, ShowIf( nameof( Style ), HedgeRunStyle.Planter ), Group( "Hedge Run" ), Title( "Planter Spacing" ), Range( 16, 256, slider: false ), WideMode, Description( "Distance between planters when the style is Planter." )]
	public float Spacing { get; set; } = 64.0f;

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

	[Property, ShowIf( nameof( Style ), HedgeRunStyle.Hedge ), Group( "Hedge Run" ), Title( "Hedge Shape" ), WideMode, Description( "Rounded crown, a sharp cut box, or narrowing towards the top." )]
	public HedgeShape Shape { get; set; } = HedgeShape.Rounded;

	[Property, ShowIf( nameof( Style ), HedgeRunStyle.Hedge ), Group( "Hedge Run" ), Title( "Unevenness" ), Range( 0, 1 ), WideMode, Description( "How much the hedge bulges and dips along its length." )]
	public float Unevenness { get; set; } = 0.5f;

	[Property, ShowIf( nameof( Style ), HedgeRunStyle.Planter ), Group( "Hedge Run" ), Title( "Planter Shape" ), WideMode]
	public PlanterShape Planter { get; set; } = PlanterShape.Square;

	[Property, ShowIf( nameof( Style ), HedgeRunStyle.Planter ), Group( "Hedge Run" ), Title( "Plant" ), WideMode, Description( "What grows in the planters." )]
	public PlanterPlant Plant { get; set; } = PlanterPlant.Shrub;

	[Property, ShowIf( nameof( Style ), HedgeRunStyle.Planter ), Group( "Hedge Run" ), Title( "Planter Height" ), Range( 8, 96, slider: false ), WideMode]
	public float PlanterHeight { get; set; } = 28.0f;

	[Property, Advanced, Group( "Hedge Run" ), Title( "Curve Detail" ), Range( 4, 128, slider: false ), WideMode, Description( "Longest straight piece in a curve. Smaller is rounder." )]
	public float CurveDetail { get; set; } = 16.0f;
}

/// <summary>
/// A trimmed hedge along a line, one body through corners and curves with a rounded, square or
/// tapered section that bulges a little along its length; or a row of square or round planters
/// with rim, soil and a shrub or small tree in each.
/// </summary>
public sealed class HedgeRunGenerator : IPartGenerator
{
	public string Id => "hedgerun";
	public string Title => "Hedge & Planter Run";
	public Type SettingsType => typeof( HedgeRunSettings );

	const string HedgeCategory = "hedge";
	const int CrownPoints = 5;

	public PartPlan Generate( GeneratorSettings settings, GenContext context )
	{
		var s = (HedgeRunSettings)settings;
		var root = context.Group( "root", "Hedge Run", "Building", HedgeCategory );

		if ( s.Path is null || s.Path.Nodes.Count < 2 ) return root;

		var height = MathF.Max( s.Height, 8.0f );
		var width = MathF.Max( s.Width, 8.0f );
		var leaves = context.Material( null );
		var track = s.Path.Track( Math.Clamp( s.CurveDetail, 4.0f, 128.0f ), FrameMode.Upright, 2.0f ).Plumbed().Reach( width, width );
		if ( track.Count < 2 || track.Length < 1.0f ) return root;

		if ( s.Style == HedgeRunStyle.Hedge ) Hedge( s, context, root, track, width, height, leaves );
		else Planters( s, context, root, track, width, height, leaves );

		return root;
	}

	/// <summary>
	/// A smooth wave from the seed, between -1 and 1, the same for the same distance.
	/// </summary>
	static float Wave( int seed, float d, float salt )
	{
		var a = (seed * 0.137f + salt) % 6.283f;
		return (MathF.Sin( d * 0.031f + a ) * 0.55f + MathF.Sin( d * 0.077f + a * 2.3f ) * 0.3f + MathF.Sin( d * 0.19f + a * 3.1f ) * 0.15f);
	}

	static void Hedge( HedgeRunSettings s, GenContext context, PartPlan root, PathTrack track, float width, float height, Material leaves )
	{
		var closed = s.Path.Closed;
		var frames = closed ? track.Count - 1 : track.Count;
		var uneven = Math.Clamp( s.Unevenness, 0.0f, 1.0f );

		// Section across the hedge, counter clockwise in the left/up plane, scaled per station.
		List<Vector2> Section( float half, float h )
		{
			var top = s.Shape switch { HedgeShape.Tapered => half * 0.55f, HedgeShape.Box => half, _ => half * 0.92f };
			var points = new List<Vector2> { new( -half, 0.0f ), new( half, 0.0f ) };

			if ( s.Shape == HedgeShape.Rounded )
			{
				// The crown is a quarter circle on each side, its radius a third of the width.
				var r = MathF.Min( top * 0.66f, h * 0.4f );
				points.Add( new( top, h - r ) );
				// Up to a quarter turn, so the crown reaches the full height.
				for ( var i = 1; i <= CrownPoints + 1; i++ )
				{
					var a = MathF.PI * 0.5f * i / (CrownPoints + 1);
					points.Add( new( top - r + MathF.Cos( a ) * r, h - r + MathF.Sin( a ) * r ) );
				}
				for ( var i = CrownPoints + 1; i >= 1; i-- )
				{
					var a = MathF.PI * 0.5f * i / (CrownPoints + 1);
					points.Add( new( -(top - r + MathF.Cos( a ) * r), h - r + MathF.Sin( a ) * r ) );
				}
				points.Add( new( -top, h - r ) );
			}
			else
			{
				points.Add( new( top, h ) );
				points.Add( new( -top, h ) );
			}

			return points;
		}

		var count = Section( 1.0f, 1.0f ).Count;
		var rings = new Vector3[count][];
		for ( var p = 0; p < count; p++ ) rings[p] = new Vector3[frames];

		for ( var i = 0; i < frames; i++ )
		{
			var d = track.Frames[i].Distance;
			var half = width * 0.5f * (1.0f + 0.08f * uneven * Wave( s.Seed, d, 0.0f ));
			var h = height * (1.0f + 0.07f * uneven * Wave( s.Seed, d, 1.7f ));

			// A closed hedge has to meet itself where it started.
			if ( closed && i == 0 ) { half = width * 0.5f; h = height; }

			var section = Section( half, h );
			for ( var p = 0; p < count; p++ )
				rings[p][i] = track.PlaceBetween( Math.Min( i, track.Count - 2 ), i == track.Count - 1 ? 1.0f : 0.0f, section[p].x, section[p].y );
		}

		root.Add( context.Solid( "hedge", "Hedge", "Hedge", HedgeCategory, leaves, kit => { kit.SmoothingAngle = 60.0f; kit.Sweep( rings, closed, true ); } ) );
	}

	static void Planters( HedgeRunSettings s, GenContext context, PartPlan root, PathTrack track, float width, float height, Material leaves )
	{
		var size = width;
		var spacing = MathF.Max( s.Spacing, size * 1.25f );
		var count = Math.Max( 1, (int)MathF.Floor( (track.Length - size) / spacing ) + 1 );
		var start = (track.Length - (count - 1) * spacing) * 0.5f;
		var body = context.Material( null );
		var pot = MathF.Max( s.PlanterHeight, 8.0f );
		var stream = context.Stream( HedgeCategory, "plants" );

		// Planters keep their distance in the plane as well: at a corner two stations along the line come closer.
		// Square planters turned against each other need their diagonal, trees the width of two crowns.
		var footprint = s.Planter == PlanterShape.Square ? (size + 2.0f) * 1.42f : size + 2.0f;
		var crowns = s.Plant == PlanterPlant.Tree ? MathF.Min( size * 0.8f, height * 0.45f ) * 2.0f * 1.15f + 1.0f : 0.0f;
		var clear = MathF.Max( MathF.Max( size * 1.25f, footprint + 1.0f ), crowns );
		var kept = new List<PathFrame>();
		for ( var n = 0; n < count; n++ )
		{
			var candidate = track.FrameAt( Math.Clamp( start + n * spacing, 0.0f, track.Length ) );
			if ( kept.All( k => (k.Position - candidate.Position).WithZ( 0 ).Length >= clear ) ) kept.Add( candidate );
		}

		for ( var n = 0; n < kept.Count; n++ )
		{
			var frame = kept[n];
			var forward = frame.Forward.WithZ( 0.0f ).Normal;
			var yaw = MathF.Atan2( forward.y, forward.x ) * 180.0f / MathF.PI;
			var at = new Transform( frame.Position, Rotation.FromYaw( yaw ) );
			var grow = stream.Range( 0.85f, 1.15f );
			var id = $"planter{n:00}";
			var half = size * 0.5f;
			var rim = 2.5f;

			root.Add( context.Solid( id, "Planter", "Planter", HedgeCategory, body, kit =>
			{
				// Body and rim bars meet face to face on purpose.
				kit.LiftFlushFaces = false;
				kit.Frame = at;
				if ( s.Planter == PlanterShape.Round )
				{
					kit.Cylinder( Vector3.Zero, half * 0.82f, half, 0.0f, pot, 20 );
					kit.Ring( Vector3.Zero, half + 1.0f, half - rim, pot, pot + 2.0f, 20 );
				}
				else
				{
					var low = half * 0.85f;
					kit.Hex( new Vector3( -low, -low, 0 ), new Vector3( low, -low, 0 ), new Vector3( low, low, 0 ), new Vector3( -low, low, 0 ),
						new Vector3( -half, -half, pot ), new Vector3( half, -half, pot ), new Vector3( half, half, pot ), new Vector3( -half, half, pot ) );

					// The rim: four bars around the soil, the front and back ones full length.
					kit.Box( new Vector3( -half - 1, -half - 1, pot ), new Vector3( half + 1, -half + rim, pot + 2 ) );
					kit.Box( new Vector3( -half - 1, half - rim, pot ), new Vector3( half + 1, half + 1, pot + 2 ) );
					kit.Box( new Vector3( -half - 1, -half + rim, pot ), new Vector3( -half + rim, half - rim, pot + 2 ) );
					kit.Box( new Vector3( half - rim, -half + rim, pot ), new Vector3( half + 1, half - rim, pot + 2 ) );
				}
			} ) );

			root.Add( context.Solid( $"{id}/soil", "Soil", "Soil", HedgeCategory, body, kit =>
			{
				kit.Frame = at;
				if ( s.Planter == PlanterShape.Round ) kit.Cylinder( Vector3.Zero, half - rim, half - rim, pot, pot + 1.0f, 20 );
				else kit.Box( new Vector3( -half + rim, -half + rim, pot ), new Vector3( half - rim, half - rim, pot + 1.0f ) );
			} ) );

			var soil = pot + 1.0f;
			var plantHeight = height * grow;

			if ( s.Plant == PlanterPlant.Tree )
			{
				var trunk = MathF.Max( size * 0.05f, 1.0f );
				var crown = MathF.Min( half * 1.6f, plantHeight * 0.45f );
				var crownBottom = soil + plantHeight - crown * 1.8f;

				root.Add( context.Solid( $"{id}/trunk", "Trunk", "Trunk", HedgeCategory, body, kit =>
				{
					kit.Frame = at;
					kit.Cylinder( Vector3.Zero, trunk * 1.2f, trunk, soil, crownBottom, 8 );
				} ) );
				root.Add( context.Solid( $"{id}/crown", "Crown", "Leaves", HedgeCategory, leaves, kit =>
				{
					kit.Frame = at;
					kit.SmoothingAngle = 70.0f;
					kit.Lathe( Vector3.Zero, [new( 0, crownBottom ), new( crown * 0.7f, crownBottom + crown * 0.25f ), new( crown, crownBottom + crown * 0.8f ), new( crown * 0.85f, crownBottom + crown * 1.35f ), new( crown * 0.45f, crownBottom + crown * 1.7f ), new( 0, crownBottom + crown * 1.8f )], 14 );
				} ) );
			}
			else
			{
				var r = MathF.Min( half - rim - 0.5f, plantHeight * 0.6f ) * 1.15f;
				root.Add( context.Solid( $"{id}/shrub", "Shrub", "Leaves", HedgeCategory, leaves, kit =>
				{
					kit.Frame = at;
					kit.SmoothingAngle = 70.0f;
					kit.Lathe( Vector3.Zero, [new( 0, soil ), new( r * 0.75f, soil + plantHeight * 0.1f ), new( r, soil + plantHeight * 0.4f ), new( r * 0.92f, soil + plantHeight * 0.72f ), new( r * 0.6f, soil + plantHeight * 0.93f ), new( 0, soil + plantHeight )], 16 );
				} ) );
			}
		}
	}
}

/// <summary>
/// Draws a hedge or a planter row along a line, straight, with bends, as a curve or as a closed loop. The line is the centre.
/// </summary>
[ToolCategory( "Vegetation" )]
[Title( "Hedge & Planter Run" ), Icon( "park" )]
public sealed class HedgeRunEditor : PathEditor
{
	readonly HedgeRunGenerator _generator = new();

	public HedgeRunEditor( PrimitiveTool tool ) : base( tool ) { }

	public HedgeRunSettings Settings { get; } = new();

	public override object ExtraSettings => Settings;

	protected override int MinPoints => 2;

	protected override bool CanClose => true;

	protected override bool PlacesObjects => true;

	protected override bool AllowsHeight => true;

	protected override IEnumerable<string> Hints => [.. base.Hints, "Hedge or planter row, depending on Style"];

	protected override void BuildMesh( MeshKit kit ) { }

	void Prepare() => Settings.Path = Curve();

	protected override PolygonMesh BuildPreview()
	{
		Prepare();
		return PartService.Plan( _generator, Settings ).Flatten( new Transform( Vector3.Zero, Rotation.Identity ) );
	}

	protected override void PlaceObjects()
	{
		Prepare();
		PartTool.Place( _generator, Settings, new Transform( Vector3.Zero, Rotation.Identity ) );
	}
}