Code/RoadComponent/RoadComponent.cs

An editor/runtime component that represents a road spline and manages generated meshes and related elements (road surface, sidewalk, lines, decals, lampposts, bridge, crosswalks). It subscribes to spline changes, tracks dirty state, and rebuilds or removes generated children in the editor when needed.

Native Interop
using System.Linq;
using Sandbox;

namespace RedSnail.RoadTool;

/// <summary>
/// Represents a road component that can be manipulated within the editor and at runtime.
/// </summary>
[Icon("signpost")]
public partial class RoadComponent : Component, Component.ExecuteInEditor, Component.IHasBounds, ISplineComponent
{
	/// <summary>
	/// Undo/redo and prefab reloads deserialize a brand new <see cref="Sandbox.Spline"/> into this component
	/// instead of mutating the existing one, so the setter has to move our change listener over. Otherwise the
	/// subscription stays on the discarded instance and later edits never mark the mesh dirty again.
	/// </summary>
	[Property, Feature("General"), Hide]
	public Spline Spline
	{
		get;
		set
		{
			field = value;

			SubscribeToSpline();
			UpdateData();
		}
	} = new();

	private Spline m_SubscribedSpline;

	private bool m_DoesRoadMeshNeedRebuild;

	private const string RoadMeshTag = "road_mesh";
	private const string RoadSurfaceTag = "road_surface";
	private const string SidewalkSurfaceTag = "road_sidewalk";
	private const string LineSurfaceTag = "road_lines";

	[Property, Feature("General", Icon = "public", Tint = EditorTint.White), Category("Optimization")] private bool AutoSimplify { get; set { field = value; IsDirty = true; } } = false;
	[Property, Feature("General"), Category("Optimization"), Range(0.1f, 10.0f)] private float StraightThreshold { get; set { field = value; IsDirty = true; } } = 1.0f; // Degrees - how straight before merging
	[Property, Feature("General"), Category("Optimization"), Range(2, 50)] private int MinSegmentsToMerge { get; set { field = value; IsDirty = true; } } = 3; // Minimum consecutive straight segments before merging

	[Property, Feature("General"), Category("Miscellaneous")] public bool UseRotationMinimizingFrames { get; set { field = value; IsDirty = true; } }

	[Property, FeatureEnabled("Bridge", Icon = "architecture", Tint = EditorTint.Blue)]
	private bool HasBridge { get; set { field = value; m_DoesBridgeNeedRebuild = true; } } = false;

	/// <summary>
	/// This will prevent the bridge from being rebuilt if any property is edited or if the road component get disable and re-enabled.
	/// Really useful if you plan to edit the mesh with the mapping tool so you don't accidently erase/rebuild the bridge.
	/// </summary>
	[Property(Title = "🔒 Locked"), Feature("Bridge")]
	private bool IsLocked { get; set; } = false;

	/// <summary>
	/// This is your bridge material you wanna use.
	/// I recommend using a tileable texture for better result.
	/// </summary>
	[Property(Title = "Material"), Feature("Bridge"), Group("Texturing"), Order(1)]
	private Material BridgeMaterial { get; set { field = value; m_DoesBridgeNeedRebuild = true; } }

	[Property(Title = "Texture Repeat"), Feature("Bridge"), Group("Texturing"), Order(1), Step(1)]
	private float BridgeTextureRepeat { get; set { field = value.Clamp(10.0f, 10000.0f); m_DoesBridgeNeedRebuild = true; } } = 500.0f;

	[Property(Title = "Border Width"), Feature("Bridge"), Group("Shape"), Order(0), Range(10.0f, 500.0f)]
	private float BridgeBorderWidth { get; set { field = value.Clamp(10.0f, 500.0f); m_DoesBridgeNeedRebuild = true; } } = 80.0f;

	[Property(Title = "Border Height"), Feature("Bridge"), Group("Shape"), Range(10.0f, 500.0f)]
	private float BridgeBorderHeight { get; set { field = value.Clamp(10.0f, 500.0f); m_DoesBridgeNeedRebuild = true; } } = 80.0f;

	[Property(Title = "Bottom Depth"), Feature("Bridge"), Group("Shape"), Range(10.0f, 500.0f)]
	private float BridgeBottomDepth { get; set { field = value.Clamp(10.0f, 500.0f); m_DoesBridgeNeedRebuild = true; } } = 80.0f;

	[Property(Title = "Close Caps"), Feature("Bridge"), Group("Shape")]
	private bool BridgeCloseCaps { get; set { field = value; m_DoesBridgeNeedRebuild = true; } } = true;

	[Property(Title = "Pillars"), Feature("Bridge"), ToggleGroup("Pillars"), Order(2)]
	private bool Pillars { get; set { field = value; m_DoesBridgeNeedRebuild = true; } } = true;

	[Property(Title = "Shape"), Feature("Bridge"), ToggleGroup("Pillars")]
	private BridgePillarShape BridgePillarType { get; set { field = value; m_DoesBridgeNeedRebuild = true; } } = BridgePillarShape.Square;

	[Property(Title = "Size"), Feature("Bridge"), ToggleGroup("Pillars"), Range(10.0f, 1000.0f)]
	private float BridgePillarSize { get; set { field = value; m_DoesBridgeNeedRebuild = true; } } = 200.0f;

	[Property(Title = "Height"), Feature("Bridge"), ToggleGroup("Pillars"), Range(10.0f, 5000.0f)]
	private float BridgePillarHeight { get; set { field = value; m_DoesBridgeNeedRebuild = true; } } = 600.0f;

	[Property(Title = "Spacing"), Feature("Bridge"), ToggleGroup("Pillars"), Range(100.0f, 10000.0f)]
	private float BridgePillarSpacing { get; set { field = value.Clamp(100.0f, 100000.0f); m_DoesBridgeNeedRebuild = true; } } = 1200.0f;

	[Property(Title = "Inset"), Feature("Bridge"), ToggleGroup("Pillars"), Range(0.0f, 200.0f)]
	private float BridgePillarInset { get; set { field = value; m_DoesBridgeNeedRebuild = true; } } = 20.0f;

	[Property(Title = "Segments"), Feature("Bridge"), ToggleGroup("Pillars"), Range(3, 24), ShowIf(nameof(BridgePillarType), BridgePillarShape.Cylinder)]
	private int BridgePillarRoundSegments { get; set { field = value.Clamp(3, 64); m_DoesBridgeNeedRebuild = true; } } = 12;

	/// <summary>
	/// Does the pillars follow world up vector or follow the road shape ?
	/// </summary>
	[Property(Title = "Keep Vertical (World Up)"), Feature("Bridge"), ToggleGroup("Pillars")]
	private bool BridgePillarsKeepVertical { get; set { field = value; m_DoesBridgeNeedRebuild = true; } } = true;
	
	[Property, FeatureEnabled("Crosswalks", Icon = "menu", Tint = EditorTint.Pink), Change] private bool HasCrosswalks { get; set; } = false;
	[Property(Title = "Config"), Feature("Crosswalks")] public CrosswalkConfig CrosswalkConfig { get; set { field = value; m_DoesCrosswalksNeedsRebuild = true; } } = CrosswalkConfig.Both;
	[Property(Title = "Decal Definition"), Feature("Crosswalks")] public DecalDefinition CrosswalkDefinition { get; set { field = value; m_DoesCrosswalksNeedsRebuild = true; } }
	[Property(Title = "Decal Size"), Feature("Crosswalks"), Range(0.1f, 10.0f)] private Vector2 CrosswalkSize { get; set { field = value; m_DoesCrosswalksNeedsRebuild = true; } } = Vector2.One;
	
	private bool IsDirty
	{
		get;
		set
		{
			field = value;

			m_DoesRoadMeshNeedRebuild = value;
			m_DoesLamppostsNeedRebuild = value;
		}
	}

	public BBox LocalBounds => Spline.Bounds;



	public RoadComponent()
	{
		Spline.InsertPoint(Spline.PointCount, new Spline.Point { Position = new Vector3(0, 0, 0) });
		Spline.InsertPoint(Spline.PointCount, new Spline.Point { Position = new Vector3(1000, 0, 0) });
		Spline.InsertPoint(Spline.PointCount, new Spline.Point { Position = new Vector3(1600, 1000, 0) });
	}



	protected override void OnEnabled()
	{
		SubscribeToSpline();

		EnsureLanes(); // migrate/seed the lane layout before anything that reads it (lines mesh, traffic graph) runs

		EnsureRoadMeshExist();
		EnsureSidewalkMeshExist();
		EnsureBridgeMeshExist();

		CreateLines();
		CreateDecals();
		CreateLampposts();
		CreateCrosswalks();
	}



	protected override void OnDisabled()
	{
		UnsubscribeFromSpline();

		RemoveRoadMesh();
		RemoveSidewalkMesh();
		RemoveLines();
		RemoveDecals();
		RemoveLampposts();
		RemoveCrosswalks();
		RemoveBridge();
	}



	/// <summary>
	/// Undo/redo restores the serialized spline data in place, which does not raise
	/// <see cref="Sandbox.Spline.SplineChanged"/>. Rebuilding from here is what makes the mesh follow an undo.
	/// </summary>
	protected override void OnValidate()
	{
		SubscribeToSpline();
		UpdateData();
	}



	protected override void OnUpdate()
	{
		SyncSplineSubscription();

		UpdateRoadMeshes();
		UpdateLines();
		UpdateDecals();
		UpdateLampposts();
		UpdateCrosswalks();
		UpdateBridge();
	}



	private void UpdateRoadMeshes()
	{
		if (!m_DoesRoadMeshNeedRebuild)
			return;

		RebuildRoadMesh();
		RebuildSidewalkMesh();
		RebuildLinesMesh();

		m_DoesRoadMeshNeedRebuild = false;
	}



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

		if (IsRoadLocked)
			return;

		RemoveGeneratedMeshChildren(RoadSurfaceTag);
		BuildRoadMesh();
	}



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

		if (IsSidewalkLocked)
			return;

		RemoveGeneratedMeshChildren(SidewalkSurfaceTag);
		BuildSidewalkMesh();
	}



	private void RemoveRoadMesh()
	{
		if (IsRoadLocked)
			return;

		RemoveGeneratedMeshChildren(RoadSurfaceTag);
	}



	private void RemoveSidewalkMesh()
	{
		if (IsSidewalkLocked)
			return;

		RemoveGeneratedMeshChildren(SidewalkSurfaceTag);
	}



	private void RemoveGeneratedMeshChildren(string _Tag)
	{
		var toRemove = GameObject.Children.Where(child => child.Tags.Has(_Tag)).ToList();

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



	private bool HasGeneratedMeshChildren(string _Tag)
	{
		return GameObject.Children.Any(child => child.Tags.Has(_Tag));
	}



	/// <summary>
	/// Safety net for editor state changes that swap the spline instance without ever touching the
	/// property setter or <see cref="OnValidate"/>. A reference compare per frame is cheap enough to
	/// be worth never silently losing the subscription again.
	/// </summary>
	private void SyncSplineSubscription()
	{
		if (ReferenceEquals(m_SubscribedSpline, Spline))
			return;

		SubscribeToSpline();
		UpdateData();
	}



	private void SubscribeToSpline()
	{
		if (ReferenceEquals(m_SubscribedSpline, Spline))
			return;

		UnsubscribeFromSpline();

		m_SubscribedSpline = Spline;

		if (m_SubscribedSpline is not null)
			m_SubscribedSpline.SplineChanged += UpdateData;
	}



	private void UnsubscribeFromSpline()
	{
		if (m_SubscribedSpline is null)
			return;

		m_SubscribedSpline.SplineChanged -= UpdateData;
		m_SubscribedSpline = null;
	}



	private void UpdateData()
	{
		if (!GameObject.IsValid() || !Scene.IsEditor)
			return;

		IsDirty = true;
	}
}