editor/AdvancedMappingTools/Placement/RoadEditors.cs

Editor tooling for road authoring in the Advanced Mapping Tools package. Road Studio draws roads along a 3D line, optionally draping them on terrain with a ground trace and snapping ends onto existing roads to form junctions. Junction stamps a multi arm junction from a set of layout presets with per arm angle, length and lane overrides, and Parking Lot builds a rectangular lot from part settings.

namespace AdvancedMappingTools;
/// <summary>
/// Roads of independent parts along a drawn 3D line. A road that starts, ends or crosses on another
/// road gets a junction there by itself.
/// </summary>
[ToolCategory( "Roads & Paths" )]
[Title( "Road Studio" ), Icon( "add_road" )]
public sealed class RoadStudioEditor : PathEditor
{
	protected override bool SnapsToLines => true;

	const float SearchHeight = 2048.0f;
	const float SearchDepth = 8192.0f;

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

	readonly RoadGenerator _generator = new();
	bool _docked;

	public RoadStudioEditor( PrimitiveTool tool ) : base( tool ) => Smooth = true;

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

	public override object ExtraSettings => Settings;

	[Property, Group( "Terrain" ), Title( "Follow Ground" ), WideMode, Description( "Lays the road on whatever is below its line, instead of straight between the clicked points." )]
	public bool FollowGround { get; set; } = false;

	[Property, ShowIf( nameof( FollowGround ), true ), Group( "Terrain" ), Title( "Ground Step" ), Range( 16, 1024, slider: false ), WideMode, Description( "How often the ground is looked up along the line." )]
	public float GroundStep { get; set; } = 128.0f;

	[Property, ShowIf( nameof( FollowGround ), true ), Group( "Terrain" ), Title( "Ground Offset" ), Range( -128, 128, slider: false ), WideMode, Description( "Height of the road over the ground." )]
	public float GroundOffset { get; set; } = 0.0f;

	protected override int MinPoints => 2;
	protected override bool CanClose => true;
	protected override bool PlacesObjects => true;
	protected override bool AllowsHeight => true;
	protected override bool IsShapeValid => Points.Count >= 2 && Points[0].Position.Distance( Points[1].Position ) > 1.0f;

	protected override IEnumerable<string> Hints => [.. base.Hints, "Shift + drag a point: raise or lower it", "A point near a road snaps onto it and makes a junction", "Click the green point: close loop"];

	public override bool ChangesOtherSettings( string property ) => property is nameof( RoadSettings.Type ) or nameof( RoadSettings.CustomDirectionWidths ) or nameof( RoadSettings.SeparateSidewalkWidths );

	protected override bool KeepsHeight( Vector3 point ) => _docked;

	protected override Vector3 Dock( Vector3 point )
	{
		var snapped = RoadLinker.Snap( SceneEditorSession.Active.Scene, point, DockRadius, out _docked );

		return _docked ? snapped : base.Dock( point );
	}

	protected override IReadOnlyList<GameObject> ObjectsRemovedByPlacing() => RoadLinker.Removed( SceneEditorSession.Active.Scene, [Prepare()] );

	protected override IReadOnlyList<GameObject> ObjectsChangedByPlacing() =>
		[.. RoadLinker.Network( SceneEditorSession.Active.Scene ).Except( ObjectsRemovedByPlacing() )];

	protected override void BuildMesh( MeshKit kit ) { }

	/// <summary>
	/// The drawn line laid on the ground. Its ends stay where they were clicked, that is where a road may connect.
	/// </summary>
	PathCurve OnGround( PathCurve curve )
	{
		var scene = SceneEditorSession.Active.Scene;
		var samples = curve.Sample( MathF.Max( GroundStep, 16.0f ), 12.0f );
		var points = samples.Select( ( sample, i ) =>
		{
			if ( !curve.Closed && (i == 0 || i == samples.Count - 1) ) return sample.Position;

			var hit = scene.Trace.Ray( sample.Position + Vector3.Up * SearchHeight, sample.Position + Vector3.Down * SearchDepth ).Run();

			return hit.Hit ? sample.Position.WithZ( hit.EndPosition.z + GroundOffset ) : sample.Position;
		} );

		return PathCurve.FromPoints( points, PathInterpolation.CatmullRom, curve.Closed );
	}

	RoadSettings Prepare()
	{
		Settings.Path = FollowGround ? OnGround( Curve() ) : Curve();

		return Settings;
	}

	protected override PolygonMesh BuildPreview()
	{
		// Cutting potholes takes too long for every mouse move, they come when the road is placed.
		var holes = Settings.Potholes;

		Settings.Potholes = false;

		try
		{
			return PartService.Plan( _generator, Prepare() ).Flatten( new Transform( Vector3.Zero, Rotation.Identity ) );
		}
		finally
		{
			Settings.Potholes = holes;
		}
	}

	protected override void PlaceObjects() => PartTool.PlaceRoads( _generator, [Prepare()] );
}

public enum JunctionLayout
{
	[Title( "T Junction" )] T,
	[Title( "Four Way" )] Cross,
	[Title( "Y Junction" )] Y,
	[Title( "Five Way" )] Five,
	[Title( "Six Way" )] Six
}

/// <summary>
/// A finished junction with short roads leading away from it, to draw on from. It is
/// nothing but roads that meet in one point, so it stays a normal part of the network.
/// </summary>
[ToolCategory( "Roads & Paths" )]
[Title( "Junction" ), Icon( "fork_right" )]
public sealed class JunctionEditor( PrimitiveTool tool ) : StampEditor( tool )
{
	const float ArmReach = 4.0f;

	public override IReadOnlySet<string> UnusedOptions
	{
		get
		{
			var hidden = new HashSet<string>
			{
				"Surface",
				"Trim",
				"Variation",
				"RandomTurn",
				"Seed",
				"Rise",
				"EaseEnds",
				"Banking"
			};
			for ( var i = Angles.Length + 1; i <= 6; i++ )
				hidden.UnionWith( [$"Arm{i}Angle", $"Arm{i}Length", $"Arm{i}Lanes"] );
			return hidden;
		}
	}

	readonly RoadGenerator _generator = new();

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

	public override object ExtraSettings => Settings;

	[Property, Group( "Junction" ), WideMode]
	public JunctionLayout Layout { get; set; } = JunctionLayout.Cross;

	[Property, Group( "Junction" ), Title( "Arm Length" ), Range( 128, 4096, slider: false ), WideMode, Description( "How long the roads leading away are." )]
	public float ArmLength { get; set; } = 768.0f;

	[Property, Group( "Arm 1" ), Title( "Angle Offset" ), Range( -25, 25, slider: false ), WideMode, Description( "Degrees added to this arm's layout angle, before the whole junction's Yaw." )]
	public float Arm1Angle { get; set; } = 0.0f;

	[Property, Group( "Arm 1" ), Title( "Length Override" ), Range( 0, 8192, slider: false ), WideMode, Description( "Zero uses Arm Length. Short arms are extended automatically to clear the junction." )]
	public float Arm1Length { get; set; } = 0.0f;

	[Property, Group( "Arm 1" ), Title( "Lanes Override" ), Range( 0, 12, slider: false ), WideMode, Description( "Zero uses the shared Lanes setting. Other values affect only this arm." )]
	public int Arm1Lanes { get; set; } = 0;

	[Property, Group( "Arm 2" ), Title( "Angle Offset" ), Range( -25, 25, slider: false ), WideMode, Description( "Degrees added to this arm's layout angle, before the whole junction's Yaw." )]
	public float Arm2Angle { get; set; } = 0.0f;

	[Property, Group( "Arm 2" ), Title( "Length Override" ), Range( 0, 8192, slider: false ), WideMode, Description( "Zero uses Arm Length. Short arms are extended automatically to clear the junction." )]
	public float Arm2Length { get; set; } = 0.0f;

	[Property, Group( "Arm 2" ), Title( "Lanes Override" ), Range( 0, 12, slider: false ), WideMode, Description( "Zero uses the shared Lanes setting. Other values affect only this arm." )]
	public int Arm2Lanes { get; set; } = 0;

	[Property, Group( "Arm 3" ), Title( "Angle Offset" ), Range( -25, 25, slider: false ), WideMode, Description( "Degrees added to this arm's layout angle, before the whole junction's Yaw." )]
	public float Arm3Angle { get; set; } = 0.0f;

	[Property, Group( "Arm 3" ), Title( "Length Override" ), Range( 0, 8192, slider: false ), WideMode, Description( "Zero uses Arm Length. Short arms are extended automatically to clear the junction." )]
	public float Arm3Length { get; set; } = 0.0f;

	[Property, Group( "Arm 3" ), Title( "Lanes Override" ), Range( 0, 12, slider: false ), WideMode, Description( "Zero uses the shared Lanes setting. Other values affect only this arm." )]
	public int Arm3Lanes { get; set; } = 0;

	[Property, Group( "Arm 4" ), Title( "Angle Offset" ), Range( -25, 25, slider: false ), WideMode, Description( "Degrees added to this arm's layout angle, before the whole junction's Yaw." )]
	public float Arm4Angle { get; set; } = 0.0f;

	[Property, Group( "Arm 4" ), Title( "Length Override" ), Range( 0, 8192, slider: false ), WideMode, Description( "Zero uses Arm Length. Short arms are extended automatically to clear the junction." )]
	public float Arm4Length { get; set; } = 0.0f;

	[Property, Group( "Arm 4" ), Title( "Lanes Override" ), Range( 0, 12, slider: false ), WideMode, Description( "Zero uses the shared Lanes setting. Other values affect only this arm." )]
	public int Arm4Lanes { get; set; } = 0;

	[Property, Group( "Arm 5" ), Title( "Angle Offset" ), Range( -25, 25, slider: false ), WideMode, Description( "Degrees added to this arm's layout angle, before the whole junction's Yaw." )]
	public float Arm5Angle { get; set; } = 0.0f;

	[Property, Group( "Arm 5" ), Title( "Length Override" ), Range( 0, 8192, slider: false ), WideMode, Description( "Zero uses Arm Length. Short arms are extended automatically to clear the junction." )]
	public float Arm5Length { get; set; } = 0.0f;

	[Property, Group( "Arm 5" ), Title( "Lanes Override" ), Range( 0, 12, slider: false ), WideMode, Description( "Zero uses the shared Lanes setting. Other values affect only this arm." )]
	public int Arm5Lanes { get; set; } = 0;

	[Property, Group( "Arm 6" ), Title( "Angle Offset" ), Range( -25, 25, slider: false ), WideMode, Description( "Degrees added to this arm's layout angle, before the whole junction's Yaw." )]
	public float Arm6Angle { get; set; } = 0.0f;

	[Property, Group( "Arm 6" ), Title( "Length Override" ), Range( 0, 8192, slider: false ), WideMode, Description( "Zero uses Arm Length. Short arms are extended automatically to clear the junction." )]
	public float Arm6Length { get; set; } = 0.0f;

	[Property, Group( "Arm 6" ), Title( "Lanes Override" ), Range( 0, 12, slider: false ), WideMode, Description( "Zero uses the shared Lanes setting. Other values affect only this arm." )]
	public int Arm6Lanes { get; set; } = 0;

	protected override bool PlacesObjects => true;

	public override bool ChangesOtherSettings( string property ) => property is nameof( RoadSettings.Type ) or nameof( Layout );

	protected override IReadOnlyList<GameObject> ObjectsRemovedByPlacing() => RoadLinker.Removed( SceneEditorSession.Active.Scene, Roads() );

	protected override IReadOnlyList<GameObject> ObjectsChangedByPlacing() =>
		[.. RoadLinker.Network( SceneEditorSession.Active.Scene ).Except( ObjectsRemovedByPlacing() )];

	protected override void Stamp( MeshKit kit, Transform frame, Random random ) { }

	float[] Angles => Layout switch
	{
		JunctionLayout.T => [0.0f, 90.0f, 180.0f],
		JunctionLayout.Y => [90.0f, 210.0f, 330.0f],
		JunctionLayout.Five => [.. Enumerable.Range( 0, 5 ).Select( i => i * 72.0f )],
		JunctionLayout.Six => [.. Enumerable.Range( 0, 6 ).Select( i => i * 60.0f )],
		_ => [0.0f, 90.0f, 180.0f, 270.0f]
	};

	List<RoadSettings> Roads()
	{
		var roads = new List<RoadSettings>();
		var angles = Angles;
		float[] offsets = [Arm1Angle, Arm2Angle, Arm3Angle, Arm4Angle, Arm5Angle, Arm6Angle];
		float[] lengths = [Arm1Length, Arm2Length, Arm3Length, Arm4Length, Arm5Length, Arm6Length];
		int[] lanes = [Arm1Lanes, Arm2Lanes, Arm3Lanes, Arm4Lanes, Arm5Lanes, Arm6Lanes];
		var templates = new List<RoadSettings>();
		for ( var i = 0; i < angles.Length; i++ )
		{
			var road = (RoadSettings)GeneratorRegistry.Load( _generator, GeneratorRegistry.Save( Settings ) );
			if ( lanes[i] > 0 )
				road.Lanes = Math.Clamp( lanes[i], 1, 12 );
			templates.Add( road );
		}

		// Every arm must clear even the widest road crossing it.
		var minimumReach = templates.Max( road => road.Half * ArmReach + road.CornerRadius );
		var directions = angles.Select( ( angle, i ) => angle + Math.Clamp( offsets[i], -25.0f, 25.0f ) ).OrderBy( angle => angle ).ToArray();
		var narrowest = Enumerable.Range( 0, directions.Length ).Min( i => (directions[(i + 1) % directions.Length] - directions[i] + 360.0f) % 360.0f );
		// Acute approaches need longer arms so their inner corners can meet outside the centre.
		var angleReach = 1.0f + 1.0f / MathF.Tan( narrowest * MathF.PI / 360.0f );
		minimumReach = MathF.Max( minimumReach, templates.Max( road => road.Half * angleReach + road.CornerRadius ) );
		foreach ( var point in Points )
		{
			for ( var i = 0; i < angles.Length; i++ )
			{
				var direction = Rotation.FromYaw( Yaw + angles[i] + Math.Clamp( offsets[i], -25.0f, 25.0f ) ).Forward;
				var road = (RoadSettings)GeneratorRegistry.Load( _generator, GeneratorRegistry.Save( templates[i] ) );
				var requestedLength = lengths[i] > 0.0f ? Math.Clamp( lengths[i], 128.0f, 8192.0f ) : ArmLength;
				var reach = MathF.Max( requestedLength, minimumReach );
				road.Path = PathCurve.FromPoints( [point.Position, point.Position + direction * reach] );
				road.Rise = 0.0f;
				road.Stretches = [];
				roads.Add( road );
			}
		}

		return roads;
	}

	protected override PolygonMesh BuildPreview() => RoadLinker.Preview( _generator, Roads() );

	protected override void PlaceObjects() => PartTool.PlaceRoads( _generator, Roads() );
}

/// <summary>
/// A parking lot on a rectangle, with an opening in its curb for a road.
/// </summary>
[ToolCategory( "Roads & Paths" )]
[Title( "Parking Lot" ), Icon( "local_parking" )]
public sealed class ParkingLotEditor( PrimitiveTool tool ) : PartBuildingEditor( tool )
{
	public override IReadOnlySet<string> UnusedOptions { get; } = new HashSet<string> { "Surface", "Trim", "Variation" };

	readonly ParkingLotGenerator _generator = new();

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

	protected override IPartGenerator Generator => _generator;
	protected override GeneratorSettings Parts => Settings;

	protected override void Prepare()
	{
		Settings.Width = RectLength;
		Settings.Depth = RectDepth;
	}
}