RoadComponent/RoadComponent.Sidewalk.cs

RoadComponent partial class handling sidewalk configuration and mesh generation. Defines SidewalkStyle enum, inspector properties for sidewalk appearance, computes sidewalk centerlines, and builds city and country-style sidewalk meshes and mesh components.

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();
	}
}