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