RoadIntersectionComponent/RoadIntersectionComponent.cs
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace RedSnail.RoadTool;

public enum IntersectionShape
{
	/// <summary>
	/// Rectangle mode is allowing you to toggle between 4 differents road exits
	/// </summary>
	Rectangle,

	/// <summary>
	/// Circular mode is allowing you to create roundabout like type of intersection
	/// </summary>
	Circle
}

/// <summary>
/// Represents a road intersection component allowing you to select specific exit points
/// </summary>
[Icon("roundabout_left")]
public partial class RoadIntersectionComponent : Component, Component.ExecuteInEditor
{
	public bool m_IsDirty;

	private const string IntersectionRoadTag = "intersection_road";
	private const string IntersectionSidewalkTag = "intersection_sidewalk";

	[Property, Feature("General", Icon = "public", Tint = EditorTint.White), Order(0)] private IntersectionShape Shape { get; set { field = value; m_IsDirty = true; } } = IntersectionShape.Rectangle;

	[Property(Title = "Material"), Feature("General"), Order(0)] private Material RoadMaterial { get; set { field = value; m_IsDirty = true; } }
	[Property(Title = "Texture Repeat"), Feature("General"), Order(0)] private float RoadTextureRepeat { get; set { field = value; m_IsDirty = true; } } = 500.0f;
	
	[Property(Title = "Material"), Feature("General"), Category("Sidewalk"), Order(3)] private Material SidewalkMaterial { get; set { field = value; m_IsDirty = true; } }
	[Property(Title = "Width"), Feature("General"), Category("Sidewalk"), Order(3)] private float SidewalkWidth { get; set { field = value; m_IsDirty = true; } } = 150.0f;
	[Property(Title = "Height"), Feature("General"), Category("Sidewalk"), Order(3)] private float SidewalkHeight { get; set { field = value; m_IsDirty = true; } } = 5.0f;
	[Property(Title = "Texture Repeat"), Feature("General"), Category("Sidewalk"), Order(3)] private float SidewalkTextureRepeat { get; set { field = value; m_IsDirty = true; } } = 200.0f;



	protected override void OnEnabled()
	{
		BuildAllMeshes();
		CreateTrafficLights();
	}



	protected override void OnDisabled()
	{
		DestroyMeshChildren();
		RemoveTrafficLights();
	}



	protected override void OnUpdate()
	{
		if (m_IsDirty)
		{
			if (!SandboxUtility.IsInPlayMode)
			{
				DestroyMeshChildren();
				BuildAllMeshes();
			}

			m_IsDirty = false;
		}

		UpdateTrafficLights();
	}



	protected override void DrawGizmos()
	{
		if (!Gizmo.IsSelected)
			return;

		Gizmo.Draw.LineThickness = 2.0f;

		if (Shape == IntersectionShape.Rectangle)
		{
			Gizmo.Draw.Color = Color.White;
			Gizmo.Draw.LineBBox(new BBox(new Vector3(-Length / 2, -Width / 2, 0), new Vector3(Length / 2, Width / 2, SidewalkHeight)));

			Gizmo.Draw.Color = Color.Yellow;
			Gizmo.Draw.LineBBox(new BBox(new Vector3(-Length / 2 - SidewalkWidth, -Width / 2 - SidewalkWidth, 0), new Vector3(Length / 2 + SidewalkWidth, Width / 2 + SidewalkWidth, SidewalkHeight)));
		}
		else
		{
			Gizmo.Draw.Color = Color.White;
			Gizmo.Draw.LineCylinder(Vector3.Zero, Vector3.Up * SidewalkHeight, Radius, Radius, 32);

			Gizmo.Draw.Color = Color.Yellow;
			Gizmo.Draw.LineCylinder(Vector3.Zero, Vector3.Up * SidewalkHeight, Radius + SidewalkWidth, Radius + SidewalkWidth, 32);
		}

		if (Shape == IntersectionShape.Rectangle)
		{
			foreach (RectangleExit val in System.Enum.GetValues<RectangleExit>())
			{
				if (val == RectangleExit.None || !RectangleExits.HasFlag(val))
					continue;

				Transform transform = GetRectangleExitLocalTransform(val);
				Gizmo.Draw.Color = Color.Cyan;
				Gizmo.Draw.Arrow(transform.Position, transform.Position + transform.Forward * 100.0f);

				transform = GetRectangleExitLocalTransform(val, true);
				Gizmo.Draw.Color = Color.Green;
				Gizmo.Draw.Arrow(transform.Position, transform.Position + transform.Forward * 100.0f);
			}
		}
	}



	private void DestroyMeshChildren()
	{
		var toRemove = GameObject.Children
			.Where(c => c.Tags.Has(IntersectionRoadTag) || c.Tags.Has(IntersectionSidewalkTag))
			.ToList();

		foreach (var child in toRemove)
			child.Destroy();
	}



	private void BuildAllMeshes()
	{
		if (SandboxUtility.IsInPlayMode)
			return;

		var roadMat = RoadMaterial ?? Material.Load("materials/dev/reflectivity_30.vmat");
		var sidewalkMat = SidewalkMaterial ?? Material.Load("materials/dev/reflectivity_70.vmat");

		var roadMesh = new PolygonMesh();
		BuildRoad(roadMesh, roadMat);

		var roadChild = new GameObject(GameObject, true, "Intersection_Road");
		roadChild.Tags.Add(IntersectionRoadTag);
		var roadMC = roadChild.AddComponent<MeshComponent>();
		roadMC.Mesh = roadMesh;
		roadMC.SmoothingAngle = 40.0f;

		if (SidewalkWidth > 0 && SidewalkHeight > 0)
		{
			var sidewalkMesh = new PolygonMesh();
			BuildSidewalk(sidewalkMesh, sidewalkMat);

			var swChild = new GameObject(GameObject, true, "Intersection_Sidewalk");
			swChild.Tags.Add(IntersectionSidewalkTag);
			var swMC = swChild.AddComponent<MeshComponent>();
			swMC.Mesh = sidewalkMesh;
			swMC.SmoothingAngle = 40.0f;
		}
	}



	private void BuildRoad(PolygonMesh _Mesh, Material _Material)
	{
		if (Shape == IntersectionShape.Rectangle)
			BuildRectangleRoad(_Mesh, _Material);
		else
			BuildCircleRoad(_Mesh, _Material);
	}



	private void BuildSidewalk(PolygonMesh _Mesh, Material _Material)
	{
		if (SidewalkWidth <= 0 || SidewalkHeight <= 0)
			return;

		if (Shape == IntersectionShape.Rectangle)
			BuildRectangleSidewalk(_Mesh, _Material);
		else
			BuildCircleSidewalk(_Mesh, _Material);
	}



	[Button("Snap Nearby Roads"), Feature("General"), Order(10)]
	public void SnapNearbyRoads()
	{
		var roads = Scene.GetAll<RoadComponent>().ToList();

		const float snapDistance = 300.0f;

		if (Shape == IntersectionShape.Rectangle)
		{
			foreach (RectangleExit side in System.Enum.GetValues<RectangleExit>())
			{
				if (side == RectangleExit.None || !RectangleExits.HasFlag(side))
					continue;

				Transform exitTransform = GetRectangleExitTransform(side, true);
				float roadWidth = side is RectangleExit.North or RectangleExit.South ? Width : Length;

				SnapRoadsToExit(roads, exitTransform, roadWidth, snapDistance);
			}
		}
		else
		{
			for (int i = 0; i < CircleExits.Length; i++)
			{
				Transform exitTransform = GetCircleExitTransform(i, true);
				float roadWidth = CircleExits[i].RoadWidth;

				SnapRoadsToExit(roads, exitTransform, roadWidth, snapDistance);
			}
		}

		SandboxUtility.ShowEditorNotification("Snapped Nearby Roads Succesfully");
	}



	private static void SnapRoadsToExit(List<RoadComponent> _Roads, Transform _ExitTransform, float _RoadWidth, float _SnapDistance)
	{
		foreach (RoadComponent road in _Roads)
		{
			// Snap start: first spline point is at local origin, so WorldPosition == its world position
			if (Vector3.DistanceBetween(road.WorldPosition, _ExitTransform.Position) < _SnapDistance)
			{
				road.WorldPosition = _ExitTransform.Position;
				road.RoadWidth = _RoadWidth;
				continue;
			}

			// Snap end: check the last spline point's world position
			if (road.Spline.PointCount > 0)
			{
				int lastIdx = road.Spline.PointCount - 1;
				Vector3 lastWorldPos = road.WorldTransform.PointToWorld(road.Spline.GetPoint(lastIdx).Position);

				if (Vector3.DistanceBetween(lastWorldPos, _ExitTransform.Position) < _SnapDistance)
				{
					var point = road.Spline.GetPoint(lastIdx);
					point.Position = road.WorldTransform.PointToLocal(_ExitTransform.Position);
					road.Spline.UpdatePoint(lastIdx, point);
					road.RoadWidth = _RoadWidth;
				}
			}
		}
	}
}