Code/RoadComponent/RoadComponent.Sidewalk.cs

Part of a RoadComponent that defines sidewalk styles and properties and builds sidewalk geometry. It exposes properties for city and countryside sidewalks, computes sidewalk centerlines, and constructs polygon meshes and mesh components for the sidewalk surface.

File AccessNative Interop
using System.Collections.Generic;
using Sandbox;

namespace RedSnail.RoadTool;

/// <summary>How a road's sidewalks are shaped.</summary>
public enum SidewalkStyle
{
	/// <summary>Classic raised kerb slab (the original sidewalk).</summary>
	City,

	/// <summary>A road-material shoulder that steps down to an undulating slope which merges into the terrain.</summary>
	CountrySide,
}

public partial class RoadComponent
{
	/// <summary>
	/// This will prevent the sidewalk 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 sidewalk.
	/// </summary>
	[Property(Title = "🔒 Locked"), Feature("Sidewalk")] private bool IsSidewalkLocked { get; set; } = false;
	[Property, FeatureEnabled("Sidewalk", Icon = "directions_walk", Tint = EditorTint.Blue)] public bool HasSidewalk { get; set { field = value; IsDirty = true; } } = true;
	[Property(Title = "Style"), Feature("Sidewalk")] public SidewalkStyle SidewalkStyle { get; set { field = value; IsDirty = true; } } = SidewalkStyle.City;
	[Property(Title = "Material"), Feature("Sidewalk")] private Material SidewalkMaterial { get; set { field = value; IsDirty = true; } }
	[Property(Title = "Width"), Feature("Sidewalk"), Range(10.0f, 500.0f)] public float SidewalkWidth { get; set { field = value; IsDirty = true; } } = 150.0f;
	[Property(Title = "Height"), Feature("Sidewalk"), ShowIf(nameof(SidewalkStyle), SidewalkStyle.City), Range(0.1f, 100.0f)] public float SidewalkHeight { get; set { field = value; IsDirty = true; } } = 5.0f;
	[Property(Title = "Inner Bevel"), Feature("Sidewalk"), ShowIf(nameof(SidewalkStyle), SidewalkStyle.City), Range(0.0f, 100.0f)] private float SidewalkBevel { get; set { field = value; IsDirty = true; } } = 0.0f;
	[Property(Title = "Texture Repeat"), Feature("Sidewalk")] private float SidewalkTextureRepeat { get; set { field = value.Clamp(1.0f, 100000.0f); IsDirty = true; } } = 200.0f;

	// Country Side style — a road-material shoulder, a small drop, then an undulating slope that falls into the terrain.
	[Property(Title = "Drop"), Feature("Sidewalk"), ShowIf(nameof(SidewalkStyle), SidewalkStyle.CountrySide), Range(0.0f, 100.0f)] private float CountrySideDrop { get; set { field = value; IsDirty = true; } } = 8.0f;
	[Property(Title = "Verge Width"), Feature("Sidewalk"), ShowIf(nameof(SidewalkStyle), SidewalkStyle.CountrySide), Range(10.0f, 1000.0f)] private float CountrySideVergeWidth { get; set { field = value; IsDirty = true; } } = 200.0f;
	[Property(Title = "Verge Depth"), Feature("Sidewalk"), ShowIf(nameof(SidewalkStyle), SidewalkStyle.CountrySide), Range(0.0f, 500.0f)] private float CountrySideVergeDepth { get; set { field = value; IsDirty = true; } } = 60.0f;
	[Property(Title = "Slope Segments"), Feature("Sidewalk"), ShowIf(nameof(SidewalkStyle), SidewalkStyle.CountrySide), Range(1, 12)] private int VergeSlopeSegments { get; set { field = value; IsDirty = true; } } = 3;
	[Property(Title = "Chaos"), Feature("Sidewalk"), ShowIf(nameof(SidewalkStyle), SidewalkStyle.CountrySide), Range(0.0f, 200.0f)] private float VergeChaosAmount { get; set { field = value; IsDirty = true; } } = 30.0f;
	[Property(Title = "Chaos Scale"), Feature("Sidewalk"), ShowIf(nameof(SidewalkStyle), SidewalkStyle.CountrySide), Range(0.001f, 0.2f)] private float VergeChaosScale { get; set { field = value; IsDirty = true; } } = 0.02f;



	/// <summary>
	/// Samples the walking line down the MIDDLE of each pavement, in world space — the pedestrian equivalent of
	/// <see cref="GetTrafficCenterline"/>. Both lists are cleared first, and both come back empty when this road
	/// has no sidewalks.
	///
	/// Offsets and height come from the same numbers <see cref="BuildSidewalkMesh"/> uses, so the points sit on
	/// the slab you can actually see rather than near it. That matters: guessing a pavement position by pushing
	/// a lane waypoint sideways lands in the gutter as often as not, and anything that then snaps the result to
	/// a navmesh quietly drags it back into the road.
	/// </summary>
	public void GetSidewalkCenterlines(float _Spacing, List<Vector3> _Left, List<Vector3> _Right)
	{
		_Left.Clear();
		_Right.Clear();

		if (!HasSidewalk)
			return;

		var positions = new List<Vector3>();
		var rights = new List<Vector3>();

		GetTrafficCenterline(_Spacing, positions, rights);

		// Middle of the slab, on top of the kerb.
		float offset = RoadWidth * 0.5f + SidewalkWidth * 0.5f;
		Vector3 lift = Vector3.Up * SidewalkHeight;

		for (int i = 0; i < positions.Count; i++)
		{
			_Left.Add(positions[i] - rights[i] * offset + lift);
			_Right.Add(positions[i] + rights[i] * offset + lift);
		}
	}



	private void BuildSidewalkMesh()
	{
		if (!HasSidewalk)
			return;

		GetSplineFrameData(out var frames, out var segmentsToKeep);

		if (segmentsToKeep.Count < 2)
			return;

		switch (SidewalkStyle)
		{
			case SidewalkStyle.CountrySide:
				BuildCountrySideSidewalk(frames, segmentsToKeep);
				break;

			default:
				BuildCitySidewalk(frames, segmentsToKeep);
				break;
		}
	}



	private void BuildCitySidewalk(Transform[] frames, List<int> segmentsToKeep)
	{
		var polygonMesh = new PolygonMesh();
		var material = SidewalkMaterial ?? Material.Load("materials/dev/reflectivity_70.vmat");
		var frameVertices = new HalfEdgeMesh.VertexHandle[segmentsToKeep.Count][];

		float roadEdgeOffset = RoadWidth * 0.5f;

		float leftInnerEdge = -roadEdgeOffset;
		float leftOuterEdge = -(roadEdgeOffset + SidewalkWidth);

		float rightInnerEdge = roadEdgeOffset;
		float rightOuterEdge = roadEdgeOffset + SidewalkWidth;

		// Inner-edge bevel: push the inner-top vertices OUTWARD so the vertical curb face becomes a slope. Only the inner
		// edge moves — the top and outer faces (and their UVs) are untouched. bevelV is the slope's length in UV space,
		// so the curb-face texture follows the slope instead of stretching. bevel = 0 reproduces the old vertical curb.
		float bevel = SidewalkBevel.Clamp(0.0f, SidewalkWidth);
		float bevelV = new Vector2(bevel, SidewalkHeight).Length / SidewalkTextureRepeat;

		float leftAvgUVDist = 0f;
		float rightAvgUVDist = 0f;

		for (int i = 0; i < segmentsToKeep.Count; i++)
		{
			Transform frame = frames[segmentsToKeep[i]];
			Vector3 u = frame.Rotation.Up;
			Vector3 r = frame.Rotation.Right;
			Vector3 p = frame.Position;

			Vector3 lb = p + r * leftInnerEdge;
			Vector3 lo = p + r * leftOuterEdge;
			Vector3 lt = p + r * (leftInnerEdge - bevel) + u * SidewalkHeight;  // inner-top pushed outward → sloped curb face
			Vector3 lto = lo + u * SidewalkHeight;

			Vector3 rb = p + r * rightInnerEdge;
			Vector3 ro = p + r * rightOuterEdge;
			Vector3 rt = p + r * (rightInnerEdge + bevel) + u * SidewalkHeight;
			Vector3 rto = ro + u * SidewalkHeight;

			frameVertices[i] = polygonMesh.AddVertices(lb, lo, lt, lto, rb, ro, rt, rto);
		}

		for (int i = 0; i < segmentsToKeep.Count - 1; i++)
		{
			int idx0 = segmentsToKeep[i];
			int idx1 = segmentsToKeep[i + 1];

			float v2 = SidewalkHeight / SidewalkTextureRepeat;

			Transform f0 = frames[idx0];
			Transform f1 = frames[idx1];

			Vector3 r0 = f0.Rotation.Right;
			Vector3 r1 = f1.Rotation.Right;

			Vector3 p0 = f0.Position;
			Vector3 p1 = f1.Position;

			// Left sidewalk positions
			Vector3 lb0 = p0 + r0 * leftInnerEdge;
			Vector3 lb1 = p1 + r1 * leftInnerEdge;

			Vector3 lo0 = p0 + r0 * leftOuterEdge;
			Vector3 lo1 = p1 + r1 * leftOuterEdge;

			// Right sidewalk positions
			Vector3 rb0 = p0 + r0 * rightInnerEdge;
			Vector3 rb1 = p1 + r1 * rightInnerEdge;

			Vector3 ro0 = p0 + r0 * rightOuterEdge;
			Vector3 ro1 = p1 + r1 * rightOuterEdge;

			float leftInnerLen3D = Vector3.DistanceBetween(lb0, lb1);
			float leftOuterLen3D = Vector3.DistanceBetween(lo0, lo1);
			float rightInnerLen3D = Vector3.DistanceBetween(rb0, rb1);
			float rightOuterLen3D = Vector3.DistanceBetween(ro0, ro1);

			float leftAvgV0 = leftAvgUVDist;
			float rightAvgV0 = rightAvgUVDist;

			leftAvgUVDist += ((leftInnerLen3D + leftOuterLen3D) * 0.5f) / SidewalkTextureRepeat;
			rightAvgUVDist += ((rightInnerLen3D + rightOuterLen3D) * 0.5f) / SidewalkTextureRepeat;

			float leftAvgV1 = leftAvgUVDist;
			float rightAvgV1 = rightAvgUVDist;

			MeshUtility.AddTexturedQuad(
				polygonMesh,
				material,
				frameVertices[i][2], frameVertices[i + 1][2], frameVertices[i + 1][3], frameVertices[i][3],
				new Vector2(0, leftAvgV0), new Vector2(0, leftAvgV1), new Vector2(1, leftAvgV1), new Vector2(1, leftAvgV0));

			MeshUtility.AddTexturedQuad(
				polygonMesh,
				material,
				frameVertices[i][0], frameVertices[i + 1][0], frameVertices[i + 1][2], frameVertices[i][2],
				new Vector2(bevelV, leftAvgV0), new Vector2(bevelV, leftAvgV1), new Vector2(0, leftAvgV1), new Vector2(0, leftAvgV0));

			MeshUtility.AddTexturedQuad(
				polygonMesh,
				material,
				frameVertices[i][1], frameVertices[i][3], frameVertices[i + 1][3], frameVertices[i + 1][1],
				new Vector2(1 - v2, 1 - leftAvgV0), new Vector2(1, 1 - leftAvgV0), new Vector2(1, 1 - leftAvgV1), new Vector2(1 - v2, 1 - leftAvgV1));

			MeshUtility.AddTexturedQuad(
				polygonMesh,
				material,
				frameVertices[i][6], frameVertices[i][7], frameVertices[i + 1][7], frameVertices[i + 1][6],
				new Vector2(0, rightAvgV0), new Vector2(1, rightAvgV0), new Vector2(1, rightAvgV1), new Vector2(0, rightAvgV1));

			MeshUtility.AddTexturedQuad(
				polygonMesh,
				material,
				frameVertices[i][4], frameVertices[i][6], frameVertices[i + 1][6], frameVertices[i + 1][4],
				new Vector2(bevelV, rightAvgV0), new Vector2(0, rightAvgV0), new Vector2(0, rightAvgV1), new Vector2(bevelV, rightAvgV1));

			MeshUtility.AddTexturedQuad(
				polygonMesh,
				material,
				frameVertices[i][5], frameVertices[i + 1][5], frameVertices[i + 1][7], frameVertices[i][7],
				new Vector2(1 - v2, 1 - rightAvgV0), new Vector2(1 - v2, 1 - rightAvgV1), new Vector2(1, 1 - rightAvgV1), new Vector2(1, 1 - rightAvgV0));
		}

		CreateSidewalkMeshComponent(polygonMesh);
	}



	private void CreateSidewalkMeshComponent(PolygonMesh _PolygonMesh)
	{
		var child = new GameObject(GameObject, true, "Sidewalk");
		child.Tags.Add(RoadMeshTag);
		child.Tags.Add(SidewalkSurfaceTag);

		var meshComponent = child.AddComponent<MeshComponent>();
		meshComponent.Mesh = _PolygonMesh;
		meshComponent.SmoothingAngle = 40.0f;
	}



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

		if (IsSidewalkLocked)
			return;

		if (HasGeneratedMeshChildren(SidewalkSurfaceTag))
			return;

		BuildSidewalkMesh();
	}
}