Network/RoadCapBuilder.cs

Builder for a road end cap mesh. It computes a rounded half-ellipse cap (outline, surface and border) for a road end, producing PolygonMesh geometry and an outline for tooling, using road width/profile and curve frame data.

File Access
using System;
using System.Collections.Generic;
using Sandbox;

namespace RedSnail.RoadTool2;

/// <summary>
/// Generates the rounded end of a road that stops: a half disc of surface carried on past the last row, with the
/// border swept round it, so a dead end reads as a turning head rather than a road sliced through. The piece is
/// built beyond the road's own end, on the end row itself, so its seam with the last chunk is exact and nothing
/// about the road's length changes.
/// </summary>
internal static class RoadCapBuilder
{
	/// <summary>
	/// The arc is drawn in steps of about this many degrees.
	/// </summary>
	private const float ArcStepDegrees = 9.0f;

	/// <summary>
	/// How deep a road's rounded end reaches past it, as a share of the road's half width. 1 is a half circle.
	/// </summary>
	public const float DepthFactor = 1.0f;



	/// <summary>
	/// How far the rounded end of a road reaches past the node, or 0 where the road type does not round its ends.
	/// </summary>
	public static float Depth(RoadProfile _Profile, in RoadWidth _Width, float _Distance)
	{
		if (_Profile is null || !_Profile.RoundedEnds)
			return 0.0f;

		return (_Width.LeftAt(_Distance) + _Width.RightAt(_Distance)) * 0.5f * DepthFactor;
	}



	/// <summary>
	/// The rounded end at one end of a road. <paramref name="_Distance"/> is the end's own distance along the curve
	/// (0 or the table's length) and <paramref name="_AtStart"/> which end it is; the cap reaches out from there.
	/// </summary>
	public static PolygonMesh Build(RoadCurveTable _Table, float _Distance, bool _AtStart, in RoadWidth _Width, RoadProfile _Profile, float _Lift, float _Depth)
	{
		var mesh = new PolygonMesh();

		if (_Depth <= 0.01f)
			return mesh;

		_Table.Frame(_Distance, out Vector3 centre, out Vector3 tangent);

		centre += Vector3.Up * _Lift;

		// Out of the road, and the road's own right at that end. The arc keeps the road's grade because the
		// direction it reaches along carries the curve's climb with it.
		Vector3 forward = (_AtStart ? -tangent : tangent).Normal;
		Vector3 right = RoadCurve.RightOf(tangent);
		float leftHalf = _Width.LeftAt(_Distance);
		float rightHalf = _Width.RightAt(_Distance);

		if (leftHalf + rightHalf <= 0.01f)
			return mesh;

		int steps = Math.Max(4, (int)MathF.Ceiling(180.0f / ArcStepDegrees));
		var outline = new List<Vector3>(steps + 1);
		var outwards = new List<Vector3>(steps + 1);
		var offsets = new List<float>(steps + 1);
		var aheads = new List<float>(steps + 1);

		Arc(centre, forward, right, leftHalf, rightHalf, _Depth, steps, outline, outwards, offsets, aheads);

		AddSurface(mesh, centre, outline, offsets, aheads, _Distance, _AtStart, _Profile);
		AddBorder(mesh, outline, outwards, _Profile);

		return mesh;
	}



	/// <summary>
	/// The outline of a road's rounded end in network space, lifted onto the road surface, for the tools that need
	/// to know where road is (the ground under it is held down and brought up to its kerb like a junction's).
	/// Empty where the road does not round that end.
	/// </summary>
	public static List<Vector3> Outline(RoadCurveTable _Table, float _Distance, bool _AtStart, in RoadWidth _Width, RoadProfile _Profile, float _Lift, out Vector3 _Centre)
	{
		var outline = new List<Vector3>();
		float depth = Depth(_Profile, _Width, _Distance);

		_Table.Frame(_Distance, out _Centre, out Vector3 tangent);

		_Centre += Vector3.Up * _Lift;

		float leftHalf = _Width.LeftAt(_Distance);
		float rightHalf = _Width.RightAt(_Distance);

		if (depth <= 0.01f || leftHalf + rightHalf <= 0.01f)
			return outline;

		int steps = Math.Max(4, (int)MathF.Ceiling(180.0f / ArcStepDegrees));

		Arc(_Centre, (_AtStart ? -tangent : tangent).Normal, RoadCurve.RightOf(tangent), leftHalf, rightHalf, depth, steps, outline, null, null, null);

		return outline;
	}



	/// <summary>
	/// The half ellipse from the road's left edge, round the tip, to its right edge: where each point is, which way
	/// is out of the road there, and how far across and ahead of the end row it sits.
	/// </summary>
	private static void Arc(Vector3 _Centre, Vector3 _Forward, Vector3 _Right, float _LeftHalf, float _RightHalf, float _Depth, int _Steps,
		List<Vector3> _Points, List<Vector3> _Outwards, List<float> _Offsets, List<float> _Aheads)
	{
		for (int i = 0; i <= _Steps; i++)
		{
			float angle = MathF.PI * (i / (float)_Steps - 0.5f);
			float sin = MathF.Sin(angle);
			float cos = MathF.Cos(angle);
			float offset = sin * (sin >= 0.0f ? _RightHalf : _LeftHalf);
			float ahead = _Depth * cos;

			_Points.Add(_Centre + _Right * offset + _Forward * ahead);
			_Outwards?.Add((_Right * sin + _Forward * cos).Normal);
			_Offsets?.Add(offset);
			_Aheads?.Add(ahead);
		}
	}



	/// <summary>
	/// The half disc of road surface: a fan from the middle of the end row out to the arc, so every triangle is
	/// planar and the row itself is the fan's own base.
	/// </summary>
	private static void AddSurface(PolygonMesh _Mesh, Vector3 _Centre, List<Vector3> _Outline, List<float> _Offsets, List<float> _Aheads,
		float _Distance, bool _AtStart, RoadProfile _Profile)
	{
		float repeat = MathF.Max(1.0f, _Profile.RoadTextureRepeat);
		var material = _Profile.ResolvedRoadMaterial;
		var centre = _Mesh.AddVertices(_Centre)[0];
		var ring = new HalfEdgeMesh.VertexHandle[_Outline.Count];

		for (int i = 0; i < _Outline.Count; i++)
			ring[i] = _Mesh.AddVertices(_Outline[i])[0];

		// U runs across the road from its centreline and V along it, the same as the road's own surface, so the
		// asphalt carries on through the seam instead of restarting at it.
		Vector2 centreUv = new(0.0f, _Distance / repeat);

		// Which way round the fan closes, so it faces up like the road it carries on from.
		Vector3 first = _Outline[0] - _Centre;
		Vector3 second = _Outline[1] - _Centre;
		bool flip = Vector3.Cross(first, second).z < 0.0f;

		for (int i = 0; i < _Outline.Count - 1; i++)
		{
			var uvA = new Vector2(_Offsets[i] / repeat, (_Distance + (_AtStart ? -_Aheads[i] : _Aheads[i])) / repeat);
			var uvB = new Vector2(_Offsets[i + 1] / repeat, (_Distance + (_AtStart ? -_Aheads[i + 1] : _Aheads[i + 1])) / repeat);

			if (flip)
				MeshUtility.AddTexturedTriangle(_Mesh, material, centre, ring[i + 1], ring[i], centreUv, uvB, uvA);
			else
				MeshUtility.AddTexturedTriangle(_Mesh, material, centre, ring[i], ring[i + 1], centreUv, uvA, uvB);
		}
	}



	/// <summary>
	/// The border round the arc, open at both ends: what closes it off is the road's own border, which runs into
	/// it at the end row.
	/// </summary>
	private static void AddBorder(PolygonMesh _Mesh, List<Vector3> _Outline, List<Vector3> _Outwards, RoadProfile _Profile)
	{
		if (!_Profile.HasBorder)
			return;

		var path = new List<RoadBorderBuilder.PathPoint>(_Outline.Count);
		float along = 0.0f;

		for (int i = 0; i < _Outline.Count; i++)
		{
			if (i > 0)
				along += _Outline[i].Distance(_Outline[i - 1]);

			path.Add(new RoadBorderBuilder.PathPoint(_Outline[i], _Outwards[i], along));
		}

		RoadBorderBuilder.BuildPath(_Mesh, _Profile, path, false, false);
	}
}