Component for an intersection editor tool. It builds and destroys road and sidewalk meshes, draws editor gizmos, manages rectangle vs circle exit modes, snaps nearby RoadComponent splines to exits, and creates/updates simple traffic light placeholders.
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;
}
}
}
}
}