RoadIntersectionComponent/RoadIntersectionComponent.cs

Editor component for a road intersection in a map tool. It manages rectangle or circle intersection shapes, builds road and sidewalk meshes in-editor, snaps nearby RoadComponent splines to exits, and draws gizmos and child mesh objects for visualization.

File Access
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 = "Inner Bevel"), Feature("General"), Category("Sidewalk"), Order(3)] private float SidewalkBevel { get; set { field = value; m_IsDirty = true; } } = 0.0f;
	[Property(Title = "Texture Repeat"), Feature("General"), Category("Sidewalk"), Order(3)] private float SidewalkTextureRepeat { get; set { field = value; m_IsDirty = true; } } = 200.0f;

	// Inner-edge curb bevel, shared by every sidewalk builder (circle disc, circle exits, rectangle). CurbBevel is the
	// width the inner-top vertices are pushed OUTWARD by, turning the vertical curb face into a slope; CurbBevelV is that
	// slope's length in UV space, so the curb-face texture rides the slope instead of stretching. 0 = the old vertical curb.
	private float CurbBevel => SidewalkBevel.Clamp(0.0f, SidewalkWidth);
	private float CurbBevelV => new Vector2(CurbBevel, SidewalkHeight).Length / SidewalkTextureRepeat;



	protected override void OnEnabled()
	{
		if (Shape == IntersectionShape.Rectangle)
			EnsureRectangleExits();

		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)
		{
			EnsureRectangleExits();

			foreach (var exit in Exits)
			{
				if (exit is null)
					continue;

				Transform inner = GetRectangleExitLocalTransform(exit.Side, false, exit.Offset);
				Transform outer = GetRectangleExitLocalTransform(exit.Side, true, exit.Offset);

				float half = exit.Width * 0.5f;

				// Road edge: the opening's span across the side + its outward direction.
				Gizmo.Draw.Color = Color.Cyan;
				Gizmo.Draw.Line(inner.Position - inner.Rotation.Right * half, inner.Position + inner.Rotation.Right * half);
				Gizmo.Draw.Arrow(inner.Position, inner.Position + inner.Rotation.Forward * 100.0f);

				// Sidewalk edge (where roads snap): same span + direction.
				Gizmo.Draw.Color = Color.Green;
				Gizmo.Draw.Line(outer.Position - outer.Rotation.Right * half, outer.Position + outer.Rotation.Right * half);
				Gizmo.Draw.Arrow(outer.Position, outer.Position + outer.Rotation.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)
		{
			EnsureRectangleExits();

			foreach (var exit in Exits)
			{
				if (exit is null)
					continue;

				Transform exitTransform = GetRectangleExitTransform(exit.Side, true, exit.Offset);

				SnapRoadsToExit(roads, exitTransform, exit.Width, 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;
				}
			}
		}
	}
}