editor/AdvancedMappingTools/Procedural/RoadSection.cs

Editor-side model of a road cross section for the procedural road tool. RoadSide builds the shoulder, gutter, curb, verge and sidewalk as closed polygon strips in (outward, up) coordinates for each curb shape, and RoadSection describes the carriageway deck with crown and crossfall as a closed polygon, places side strips on the left or right, and mirrors the section for the other end of a road.

namespace AdvancedMappingTools;
public enum CurbShape
{
	Square,
	Chamfered,
	Rounded,
	Mountable
}

public enum RoadPaving
{
	Asphalt,
	Concrete,
	Gravel,
	Dirt,
	Cobbles
}

/// <summary>
/// One strip along the side of a road. <see cref="Section"/> is a closed shape in (outward, up)
/// from the edge of the carriageway, counter clockwise for the left side of a road.
/// </summary>
public sealed record RoadStrip( string Name, string Title, string Kind, string Category, float Width, Vector2[] Section );

/// <summary>
/// What runs along one side of a road, going outward from the carriageway. A width of zero leaves a strip out.
/// </summary>
public sealed class RoadSide
{
	const float ShoulderFall = 0.03f;
	const float GutterDip = 1.5f;
	const float VergeDrop = 1.0f;
	const float SmallestWidth = 0.25f;

	public float Shoulder { get; set; }
	public float Gutter { get; set; }
	public float CurbWidth { get; set; }
	public float CurbHeight { get; set; }
	public CurbShape CurbShape { get; set; }
	public float Verge { get; set; }
	public float Sidewalk { get; set; }
	public float SidewalkHeight { get; set; }

	public float Width => Shoulder + Gutter + CurbWidth + Verge + Sidewalk;

	float ShoulderEnd => -Shoulder * ShoulderFall;
	float CurbTop => CurbWidth > 0.0f ? CurbHeight : ShoulderEnd;
	float VergeTop => CurbWidth > 0.0f ? MathF.Max( CurbHeight - VergeDrop, ShoulderEnd ) : ShoulderEnd;

	/// <summary>
	/// The height of whatever is on top at a distance from the carriageway.
	/// </summary>
	public float TopAt( float outward )
	{
		if ( outward < Shoulder ) return outward * -ShoulderFall;
		if ( outward < Shoulder + Gutter ) return ShoulderEnd;
		if ( outward < Shoulder + Gutter + CurbWidth ) return CurbTop;
		if ( outward < Shoulder + Gutter + CurbWidth + Verge ) return VergeTop;

		return Sidewalk > 0.0f ? SidewalkHeight : Verge > 0.0f ? VergeTop : CurbTop;
	}

	/// <summary>
	/// A side on its way from one to another, for a corner between two different roads.
	/// </summary>
	public static RoadSide Lerp( RoadSide a, RoadSide b, float t )
	{
		float Mix( float x, float y ) => x + (y - x) * t;

		// A strip only one of the two has runs out, but keeps a hair of width: it has to end in a face.
		float Width( float x, float y ) => x > 0.0f || y > 0.0f ? MathF.Max( Mix( x, y ), SmallestWidth ) : 0.0f;

		return new RoadSide
		{
			Shoulder = Width( a.Shoulder, b.Shoulder ),
			Gutter = Width( a.Gutter, b.Gutter ),
			CurbWidth = Width( a.CurbWidth, b.CurbWidth ),
			CurbHeight = Mix( a.CurbHeight > 0.0f ? a.CurbHeight : b.CurbHeight, b.CurbHeight > 0.0f ? b.CurbHeight : a.CurbHeight ),
			CurbShape = a.CurbWidth > 0.0f ? a.CurbShape : b.CurbShape,
			Verge = Width( a.Verge, b.Verge ),
			Sidewalk = Width( a.Sidewalk, b.Sidewalk ),
			SidewalkHeight = Mix( a.Sidewalk > 0.0f ? a.SidewalkHeight : b.SidewalkHeight, b.Sidewalk > 0.0f ? b.SidewalkHeight : a.SidewalkHeight )
		};
	}

	/// <summary>
	/// All strips of this side. Every strip always has the same number of points, even with a width of
	/// zero, so that a section can change along a corner. Where a strip touches a lower neighbour it
	/// has a corner at that height: both then share a face instead of two warped ones crossing.
	/// </summary>
	public List<RoadStrip> Strips( float thickness )
	{
		const float nothing = float.MaxValue;

		var bottom = -thickness;
		var strips = new List<RoadStrip>();
		var edge = 0.0f;
		var a0 = Shoulder;
		var a1 = a0 + Gutter;
		var a2 = a1 + CurbWidth;
		var a3 = a2 + Verge;
		var a4 = a3 + Sidewalk;

		Vector2[] Body( float inner, float outer, float insideNeighbour, float outsideNeighbour, params Vector2[] top )
		{
			var first = top[^1].y;
			var last = top[0].y;

			return [new( inner, bottom ), new( outer, bottom ), new( outer, MathF.Min( outsideNeighbour, last ) ), .. top, new( inner, MathF.Min( insideNeighbour, first ) )];
		}

		var afterShoulder = Gutter > 0.0f ? ShoulderEnd : CurbWidth > 0.0f ? ShoulderEnd : Verge > 0.0f ? VergeTop : Sidewalk > 0.0f ? SidewalkHeight : ShoulderEnd;

		strips.Add( new RoadStrip( "shoulder", "Shoulder", "Shoulder", "road", Shoulder,
			Body( edge, a0, 0.0f, afterShoulder, new Vector2( a0, ShoulderEnd ), new Vector2( edge, 0.0f ) ) ) );

		var afterGutter = CurbWidth > 0.0f ? ShoulderEnd : Verge > 0.0f ? VergeTop : Sidewalk > 0.0f ? SidewalkHeight : ShoulderEnd;

		strips.Add( new RoadStrip( "gutter", "Gutter", "Gutter", "curb", Gutter,
			Body( a0, a1, ShoulderEnd, afterGutter, new Vector2( a1, ShoulderEnd ), new Vector2( (a0 + a1) * 0.5f, ShoulderEnd - GutterDip ), new Vector2( a0, ShoulderEnd ) ) ) );

		var w = CurbWidth;
		var h = CurbTop;
		var low = ShoulderEnd;
		var rise = MathF.Max( h - low, 0.0f );
		var soften = MathF.Min( w, rise ) * 0.4f;

		Vector2[] curbTop = CurbShape switch
		{
			CurbShape.Chamfered => [new( a2, h ), new( a1 + soften * 1.5f, h ), new( a1 + soften, h ), new( a1, h - soften )],
			CurbShape.Rounded => [new( a2, h ), new( a1 + soften, h ), new( a1 + soften * 0.3f, h - soften * 0.3f ), new( a1, h - soften )],
			CurbShape.Mountable => [new( a2, h ), new( a1 + w * 0.7f, h ), new( a1 + w * 0.35f, low + rise * 0.55f ), new( a1, low + rise * 0.1f )],
			_ => [new( a2, h ), new( a1 + w * 0.66f, h ), new( a1 + w * 0.33f, h ), new( a1, h )]
		};

		strips.Add( new RoadStrip( "curb", "Curb", "Curb", "curb", CurbWidth,
			Body( a1, a2, low, Verge > 0.0f ? VergeTop : Sidewalk > 0.0f ? SidewalkHeight : nothing, curbTop ) ) );

		strips.Add( new RoadStrip( "verge", "Verge", "Verge", "sidewalk", Verge,
			Body( a2, a3, CurbTop, Sidewalk > 0.0f ? SidewalkHeight : nothing, new Vector2( a3, VergeTop ), new Vector2( a2, VergeTop ) ) ) );

		strips.Add( new RoadStrip( "sidewalk", "Sidewalk", "Sidewalk", "sidewalk", Sidewalk,
			Body( a3, a4, Verge > 0.0f ? VergeTop : CurbTop, nothing, new Vector2( a4, SidewalkHeight ), new Vector2( a3, SidewalkHeight ) ) ) );

		return strips;
	}
}

/// <summary>
/// The cross section of a road: the carriageway and what runs along it. It is plain data, so a
/// junction can keep the section of every road that reaches it and build on from there.
/// </summary>
public sealed class RoadSection
{
	const int CrownPieces = 4;

	public float Half { get; set; } = 120.0f;
	public float Thickness { get; set; } = 8.0f;

	/// <summary>
	/// How high the middle stands over the edges. Below zero the road falls to a channel in its middle.
	/// </summary>
	public float Crown { get; set; }

	/// <summary>
	/// Rise per unit towards the left.
	/// </summary>
	public float Crossfall { get; set; }

	/// <summary>
	/// How many pieces the top is cut into across the road. A climbing bend twists a road, and
	/// narrow pieces stay close to that shape where a single wide one would fold.
	/// </summary>
	public int Columns { get; set; } = 1;

	public RoadPaving Paving { get; set; }
	public RoadSide Left { get; set; } = new();
	public RoadSide Right { get; set; } = new();

	public string RoadMaterial { get; set; }
	public string ShoulderMaterial { get; set; }
	public string CurbMaterial { get; set; }
	public string VergeMaterial { get; set; }
	public string SidewalkMaterial { get; set; }

	/// <summary>
	/// The same section seen from the other end of the road.
	/// </summary>
	public RoadSection Mirrored() => new()
	{
		Half = Half,
		Thickness = Thickness,
		Crown = Crown,
		Crossfall = -Crossfall,
		Columns = Columns,
		Paving = Paving,
		Left = Right,
		Right = Left,
		RoadMaterial = RoadMaterial,
		ShoulderMaterial = ShoulderMaterial,
		CurbMaterial = CurbMaterial,
		VergeMaterial = VergeMaterial,
		SidewalkMaterial = SidewalkMaterial
	};

	public float Height( float left )
	{
		var share = Half > 0.0f ? left / Half : 0.0f;

		return Crown * (1.0f - share * share) + Crossfall * left;
	}

	/// <summary>
	/// The side offsets of the top of the carriageway, from its left edge to its right edge.
	/// </summary>
	public float[] TopPoints => [.. Enumerable.Range( 0, Pieces + 1 ).Select( i => Half - Half * 2.0f * i / Pieces )];

	int Pieces => Math.Max( Columns, Crown != 0.0f ? CrownPieces : 1 );

	/// <summary>
	/// The height of the top as it is really built: straight between its points. Paint has to lie on this.
	/// </summary>
	public float Surface( float left )
	{
		var step = Half * 2.0f / Pieces;
		var from = Math.Clamp( (Half - left) / MathF.Max( step, 0.001f ), 0.0f, Pieces );
		var index = Math.Min( (int)MathF.Floor( from ), Pieces - 1 );
		var a = Half - step * index;

		return Height( a ) + (Height( a - step ) - Height( a )) * (from - index);
	}

	/// <summary>
	/// The carriageway as a closed shape in (left, up), counter clockwise.
	/// </summary>
	public List<Vector2> Deck()
	{
		// A channel in the middle reaches down: the underside stays below it.
		var below = Thickness + MathF.Max( -Crown, 0.0f );
		var top = TopPoints;
		// Both skins need the same transverse stations. A single wide underside can
		// fold through the narrow top strips on a twisted climb, even when all edges close.
		// Keep the old straight underside: the crown changes the top, not the bottom.
		var section = top.Reverse().Select( x => new Vector2( x, Crossfall * x - below ) ).ToList();
		section.AddRange( top.Select( x => new Vector2( x, Height( x ) ) ) );
		return section;
	}

	/// <summary>
	/// A strip of a side as a closed shape in (left, up), on the left or the right of the road.
	/// </summary>
	public IReadOnlyList<Vector2> Place( RoadStrip strip, float sign )
	{
		var edge = Height( sign * Half );
		var points = strip.Section.Select( p => new Vector2( sign * (Half + p.x), edge + p.y ) );

		return sign > 0.0f ? [.. points] : [.. points.Reverse()];
	}
}