Builder for road surface paint. Computes where lane, centre and edge lines (including dashed lines and crossings) lie on a road mesh, samples the road surface rows, splits lines to follow surface folds, and adds textured quads to a PolygonMesh.
using System;
using System.Collections.Generic;
using Sandbox;
namespace RedSnail.RoadTool2;
/// <summary>
/// Generates the painted lines of a stretch of road: thin strips laid just above the surface along the lane
/// boundaries. Which line goes where follows from the road's lanes; how each kind looks comes from the profile.
/// Dashes are phased from the start of the segment, not the chunk, so a chunk boundary never breaks the rhythm.
/// The lane grid divides the road's base width; where a side carries extra width (a lane added over a taper) the
/// edge line follows the edge out and a lane line appears along the old edge once the extra is most of a lane.
/// </summary>
internal static class RoadMarkingBuilder
{
/// <summary>
/// How far above the road surface the paint sits, so it never fights the asphalt for the pixel.
/// </summary>
public const float Lift = 0.6f;
/// <summary>
/// The road surface as the mesh really is: flat between the rows the chunk was built on, not the curve those
/// rows were taken from. Paint placed on the curve instead hangs in the air over a crest and disappears into
/// the road in a dip, by as much as the surface's own chords cut the corner — which is why the paint's rows
/// are the road's rows, and anything in between (a dash ending mid-row) is placed on the chord.
/// </summary>
private readonly struct Surface
{
/// <summary>
/// Two rows this close together are one: a dash ending a hair from a row must not make a sliver quad.
/// </summary>
private const float RowMargin = 0.5f;
/// <summary>
/// How far a painted line may cut the corner of its own path. Far tighter than the road's own tolerance:
/// a road cutting a couple of units off a bend is invisible across hundreds of units of asphalt, the same
/// couple of units in a line a few units wide is a kink.
/// </summary>
private const float LineDeviation = 0.25f;
/// <summary>
/// How many times a stretch of line may be halved to stay within that.
/// </summary>
private const int MaxSplits = 6;
private readonly RoadCurveTable m_Table;
private readonly IReadOnlyList<float> m_Rows;
private readonly RoadWidth m_Width;
private readonly float m_Lift;
/// <summary>
/// The road's two edges at each row, worked out once: every point of paint stands on one of these cells, and
/// re-deriving its corners per point costs four searches of the curve table each time.
/// </summary>
private readonly Vector3[] m_Left;
private readonly Vector3[] m_Right;
public Surface(RoadCurveTable _Table, IReadOnlyList<float> _Rows, in RoadWidth _Width, float _Lift)
{
m_Table = _Table;
m_Rows = _Rows;
m_Width = _Width;
m_Lift = _Lift;
m_Left = new Vector3[_Rows.Count];
m_Right = new Vector3[_Rows.Count];
for (int i = 0; i < _Rows.Count; i++)
RoadSurfaceBuilder.CrossSection(_Table, _Rows[i], _Width, 0.0f, out m_Left[i], out m_Right[i]);
}
/// <summary>
/// Where a point of a line at an offset across the road goes, and the way across the road there: along the
/// road's own curve in plan, so the line reads smooth, at the height of the road cell it lies over, so it
/// lies flat on the surface as built.
/// </summary>
public void Place(float _Distance, float _Offset, out Vector3 _Point, out Vector3 _Right)
{
m_Table.Frame(_Distance, out Vector3 centre, out Vector3 tangent);
_Right = RoadCurve.RightOf(tangent);
Vector3 point = centre + _Right * _Offset;
_Point = point.WithZ(CellHeight(_Distance, new Vector2(point.x, point.y)) + m_Lift);
}
/// <summary>
/// The rows a stretch of paint is built on: its own two ends, every road row in between and, in each cell,
/// the place where the line crosses that cell's diagonal — so no part of the line spans a fold in the road
/// — and between those, wherever the line's own bend asks for one.
/// </summary>
public List<float> RowsBetween(float _From, float _To, in Line _Line)
{
var rows = new List<float> { _From };
float previous = _From;
foreach (float sticky in FoldsBetween(_From, _To, _Line))
{
Subdivide(rows, previous, sticky, _Line, MaxSplits);
previous = sticky;
}
Subdivide(rows, previous, _To, _Line, MaxSplits);
return rows;
}
/// <summary>
/// The places between two distances where the road folds under the line: its rows, and the diagonals of
/// the cells between them.
/// </summary>
private IEnumerable<float> FoldsBetween(float _From, float _To, Line _Line)
{
for (int i = 0; i + 1 < m_Rows.Count; i++)
{
float from = m_Rows[i];
float to = m_Rows[i + 1];
if (to <= _From || from >= _To)
continue;
if (from > _From + RowMargin && from < _To - RowMargin)
yield return from;
// The cell is split from its near left corner to its far right one, so a line a fraction of the way
// across the road crosses that diagonal the same fraction of the way along the cell.
float middle = (from + to) * 0.5f;
float width = m_Width.WidthAt(middle);
float fraction = width > 0.001f ? Math.Clamp((_Line.OffsetAt(m_Width, middle) + m_Width.LeftAt(middle)) / width, 0.0f, 1.0f) : 0.5f;
float crossing = MathX.Lerp(from, to, fraction);
if (crossing > MathF.Max(_From, from) + RowMargin && crossing < MathF.Min(_To, to) - RowMargin)
yield return crossing;
}
}
/// <summary>
/// Adds the rows a stretch of line needs to stay on its path, and its far end. A stretch straight enough
/// to be drawn in one go adds nothing but that end, which is what makes a dash on a straight road a single
/// quad however long it is.
/// </summary>
private void Subdivide(List<float> _Rows, float _From, float _To, in Line _Line, int _Splits)
{
if (_Splits > 0 && _To - _From > RowMargin * 2.0f)
{
float middle = (_From + _To) * 0.5f;
Vector2 from = Offset(_From, _Line);
Vector2 to = Offset(_To, _Line);
Vector2 centre = Offset(middle, _Line);
if (centre.Distance(Nearest(from, to, centre)) > LineDeviation)
{
Subdivide(_Rows, _From, middle, _Line, _Splits - 1);
Subdivide(_Rows, middle, _To, _Line, _Splits - 1);
return;
}
}
_Rows.Add(_To);
}
/// <summary>
/// Where the line runs in plan at a distance.
/// </summary>
private Vector2 Offset(float _Distance, in Line _Line)
{
m_Table.Frame(_Distance, out Vector3 centre, out Vector3 tangent);
Vector3 point = centre + RoadCurve.RightOf(tangent) * _Line.OffsetAt(m_Width, _Distance);
return new Vector2(point.x, point.y);
}
private static Vector2 Nearest(Vector2 _From, Vector2 _To, Vector2 _Point)
{
Vector2 span = _To - _From;
float lengthSquared = span.LengthSquared;
float t = lengthSquared > 0.0f ? Math.Clamp(Vector2.Dot(_Point - _From, span) / lengthSquared, 0.0f, 1.0f) : 0.0f;
return _From + span * t;
}
/// <summary>
/// The height of the road cell a distance falls in, at a point in plan.
/// </summary>
private float CellHeight(float _Distance, Vector2 _Point)
{
int index = Bracket(_Distance);
return RoadSurfaceBuilder.CellHeight(m_Left[index], m_Right[index], m_Right[index + 1], m_Left[index + 1], _Point);
}
/// <summary>
/// The cell a distance falls in: the last row at or before it, never the very last row (which has no cell
/// beyond it).
/// </summary>
private int Bracket(float _Distance)
{
int low = 0;
int high = m_Rows.Count - 2;
while (low < high)
{
int mid = (low + high + 1) / 2;
if (m_Rows[mid] <= _Distance)
low = mid;
else
high = mid - 1;
}
return low;
}
}
/// <summary>
/// The share of a lane's width a side's extra has to reach before the lane line along the old edge is painted.
/// </summary>
private const float ExtraLaneShare = 0.6f;
/// <summary>
/// Where a segment sits in the run of road its dashes are counted along: the run distance at the segment's
/// start, and whether the run goes against the segment (so its distance counts down from the end). Dashes are
/// counted from the start of the run, not the segment, so a road drawn in several segments dashes as one.
/// </summary>
public readonly record struct DashPhase(float Offset, bool Reversed)
{
public void AddToHash(ref StableHash _Hash)
{
_Hash.Add(Offset);
_Hash.Add(Reversed ? 1 : 0);
}
}
/// <summary>
/// A line to paint: at a fixed distance across the road (from the centreline, negative to the left), or, with
/// <see cref="Follow"/> set, following that edge (-1 left, +1 right) at <see cref="Inset"/> in from it, or (2)
/// at a fraction of the way across the surface from its left edge (<see cref="Offset"/> then holds the fraction).
/// A fixed line is only painted where it lies on the surface, and one with <see cref="MinExtra"/> only where that
/// side's extra width has reached it.
/// </summary>
private readonly record struct Line(float Offset, int Follow, float Inset, int ExtraSide, float MinExtra, RoadLineStyle Style, Material Material)
{
public const int Across = 2;
public float OffsetAt(in RoadWidth _Width, float _Distance)
{
return Follow switch
{
-1 => -_Width.LeftAt(_Distance) + Inset,
1 => _Width.RightAt(_Distance) - Inset,
Across => -_Width.LeftAt(_Distance) + Offset * _Width.WidthAt(_Distance) + Inset,
_ => Offset
};
}
public bool VisibleAt(in RoadWidth _Width, float _Distance)
{
if (Follow == Across)
return _Width.WidthAt(_Distance) > Style.Width;
if (Follow != 0)
return _Width.WidthAt(_Distance) > 2.0f * Inset;
float half = Style.Width * 0.5f;
if (Offset - half < -_Width.LeftAt(_Distance) || Offset + half > _Width.RightAt(_Distance))
return false;
if (ExtraSide == 0)
return true;
float extra = ExtraSide < 0 ? _Width.LeftExtraAt(_Distance) : _Width.RightExtraAt(_Distance);
return extra >= MinExtra;
}
}
/// <summary>
/// True when the road gets any paint at all, so roads without get no marking chunk.
/// </summary>
public static bool HasMarkings(RoadSegment _Segment, RoadProfile _Profile)
{
return CollectLines(_Segment, _Profile).Count > 0;
}
/// <summary>
/// Adds the lines over one stretch of the road to the mesh. An empty or inverted stretch adds nothing.
/// </summary>
public static void Build(PolygonMesh _Mesh, RoadCurveTable _Table, IReadOnlyList<float> _RoadRows, float _StartDistance, float _EndDistance, RoadSegment _Segment, in RoadWidth _Width, RoadProfile _Profile, float _SurfaceOffset, in DashPhase _Phase)
{
if (_EndDistance - _StartDistance < 1.0f || _RoadRows.Count < 2)
return;
var surface = new Surface(_Table, _RoadRows, _Width, _SurfaceOffset + Lift);
foreach (var line in CollectLines(_Segment, _Profile))
{
if (line.Style.IsDouble)
{
float spacing = line.Style.DoubleSpacing * 0.5f;
// The pair straddles the line's place: a fixed line by its offset, the others by their inset (which
// counts inward from the right edge, so it runs the other way there).
var (first, second) = line.Follow switch
{
0 => (line with { Offset = line.Offset - spacing }, line with { Offset = line.Offset + spacing }),
1 => (line with { Inset = line.Inset + spacing }, line with { Inset = line.Inset - spacing }),
_ => (line with { Inset = line.Inset - spacing }, line with { Inset = line.Inset + spacing })
};
BuildLine(_Mesh, _Table, surface, _StartDistance, _EndDistance, first, _Width, _Phase);
BuildLine(_Mesh, _Table, surface, _StartDistance, _EndDistance, second, _Width, _Phase);
}
else
{
BuildLine(_Mesh, _Table, surface, _StartDistance, _EndDistance, line, _Width, _Phase);
}
}
}
/// <summary>
/// Paints a pedestrian crossing over a stretch of the road: stripes laid along the way traffic runs, side by
/// side across the road, as many as the road's width has room for. Each one is built exactly as a solid line
/// is, so it sits on the surface the way the rest of the paint does.
/// </summary>
public static void BuildCrossing(PolygonMesh _Mesh, RoadCurveTable _Table, IReadOnlyList<float> _RoadRows, float _Centre, in RoadWidth _Width, RoadProfile _Profile, float _SurfaceOffset)
{
var material = _Profile.ResolvedCrossingMaterial;
if (material is null || _RoadRows.Count < 2)
return;
float half = _Profile.CrossingDepth * 0.5f;
float from = MathF.Max(0.0f, _Centre - half);
float to = MathF.Min(_Table.Length, _Centre + half);
if (to - from < 1.0f)
return;
// THE ROAD'S OWN ROWS, never a set of its own: the surface is chords between these, and paint laid on
// chords of any other spacing sits under the road wherever the two disagree. Past them at either end lies
// the junction the road leaves, which its fan spans in one straight run from the node's middle out to the
// road's end row — so one more row at the node stands in for it.
var rows = new List<float>(_RoadRows.Count + 2);
if (from < _RoadRows[0] - 0.01f)
rows.Add(0.0f);
rows.AddRange(_RoadRows);
if (to > _RoadRows[^1] + 0.01f)
rows.Add(_Table.Length);
var surface = new Surface(_Table, rows, _Width, _SurfaceOffset + Lift);
float thickness = MathF.Max(1.0f, _Profile.CrossingStripeWidth);
// The band the paint fills: the road as it is halfway through the crossing, less the margin its ends keep
// from the kerbs.
float middle = (from + to) * 0.5f;
float left = -_Width.LeftAt(middle) + _Profile.CrossingInset;
float right = _Width.RightAt(middle) - _Profile.CrossingInset;
if (right - left < thickness)
return;
if (_Profile.CrossingStyle == RoadCrossingStyle.Lines)
{
// Two lines across the road, one at each end of the band.
BuildAcross(_Mesh, surface, from + thickness * 0.5f, thickness, left, right, material);
BuildAcross(_Mesh, surface, to - thickness * 0.5f, thickness, left, right, material);
return;
}
float pitch = thickness + MathF.Max(0.0f, _Profile.CrossingStripeGap);
float usable = right - left;
// As many stripes as fit, spread about the middle of the band, so a wider road simply gets more of them
// and a narrower one fewer without anything being said about the number.
int count = Math.Max(1, (int)MathF.Floor((usable + pitch - thickness) / pitch));
float span = count * pitch - (pitch - thickness);
float first = left + (usable - span) * 0.5f + thickness * 0.5f;
var style = new RoadLineStyle { Width = thickness };
for (int i = 0; i < count; i++)
BuildStrip(_Mesh, surface, from, to, new Line(first + i * pitch, 0, 0.0f, 0, 0.0f, style, material), _Width);
}
/// <summary>
/// How finely a line drawn across the road is cut up, so it follows the surface over the cells it crosses.
/// </summary>
private const float AcrossStep = 40.0f;
/// <summary>
/// A band of paint drawn ACROSS the road at a distance along it, between two offsets. Unlike a line, which the
/// road's own rows cut up for it, this one is cut across instead, since that is the way it runs.
/// </summary>
private static void BuildAcross(PolygonMesh _Mesh, in Surface _Surface, float _Distance, float _Thickness, float _Left, float _Right, Material _Material)
{
float half = _Thickness * 0.5f;
float span = _Right - _Left;
int steps = Math.Max(1, (int)MathF.Ceiling(span / AcrossStep));
var near = new HalfEdgeMesh.VertexHandle[steps + 1];
var far = new HalfEdgeMesh.VertexHandle[steps + 1];
var along = new float[steps + 1];
for (int i = 0; i <= steps; i++)
{
float offset = _Left + span * i / steps;
_Surface.Place(_Distance - half, offset, out Vector3 onNear, out _);
_Surface.Place(_Distance + half, offset, out Vector3 onFar, out _);
var handles = _Mesh.AddVertices(onNear, onFar);
near[i] = handles[0];
far[i] = handles[1];
along[i] = (offset - _Left) / MathF.Max(_Thickness, 1.0f);
}
// Round the quad the way a line's own is: across the band first, then along it. Taken the other way the
// face looks down at the ground.
for (int i = 0; i < steps; i++)
{
MeshUtility.AddTexturedQuad(_Mesh, _Material, near[i], near[i + 1], far[i + 1], far[i],
new Vector2(0.0f, along[i]), new Vector2(0.0f, along[i + 1]), new Vector2(1.0f, along[i + 1]), new Vector2(1.0f, along[i]));
}
}
/// <summary>
/// The lines a road is due, from its lanes: between lanes going the same way a lane line, where the direction
/// changes the centre line, and along both edges the edge line. A road that only ever grows (extras of zero or
/// more) keeps its lanes on the base width and takes each side's extra as a lane of its own, with a lane line
/// along the old edge once the extra is most of a lane; a road that is pulled in anywhere (a negative extra)
/// has been reshaped or moved off its centreline, and its lanes divide whatever surface there is.
/// </summary>
private static List<Line> CollectLines(RoadSegment _Segment, RoadProfile _Profile)
{
var lines = new List<Line>();
int lanes = _Segment.LaneCount;
float laneWidth = _Segment.Width / lanes;
float halfWidth = _Segment.Width * 0.5f;
bool reshaped = IsReshaped(_Segment);
for (int boundary = 1; boundary < lanes; boundary++)
{
bool sameWay = _Segment.IsLaneForward(boundary - 1) == _Segment.IsLaneForward(boundary);
var style = sameWay ? _Profile.LaneLine : _Profile.CentreLine;
if (style is null || !style.Enabled || style.Width <= 0.0f)
continue;
var material = sameWay ? _Profile.ResolvedLaneLineMaterial : _Profile.ResolvedCentreLineMaterial;
if (reshaped)
lines.Add(new Line((float)boundary / lanes, Line.Across, 0.0f, 0, 0.0f, style, material));
else
lines.Add(new Line(-halfWidth + boundary * laneWidth, 0, 0.0f, 0, 0.0f, style, material));
}
var lane = _Profile.LaneLine;
if (!reshaped && lane is not null && lane.Enabled && lane.Width > 0.0f && HasExtra(_Segment))
{
float minExtra = laneWidth * ExtraLaneShare;
lines.Add(new Line(-halfWidth, 0, 0.0f, -1, minExtra, lane, _Profile.ResolvedLaneLineMaterial));
lines.Add(new Line(halfWidth, 0, 0.0f, 1, minExtra, lane, _Profile.ResolvedLaneLineMaterial));
}
var edge = _Profile.EdgeLine;
if (edge is not null && edge.Enabled && edge.Width > 0.0f)
{
// The line's outer side sits the margin in from the road's edge.
float inset = _Profile.EdgeLineMargin + edge.Width * 0.5f + (edge.IsDouble ? edge.DoubleSpacing * 0.5f : 0.0f);
if (inset < halfWidth)
{
lines.Add(new Line(0.0f, -1, inset, 0, 0.0f, edge, _Profile.ResolvedEdgeLineMaterial));
lines.Add(new Line(0.0f, 1, inset, 0, 0.0f, edge, _Profile.ResolvedEdgeLineMaterial));
}
}
return lines;
}
private static bool HasExtra(RoadSegment _Segment)
{
return _Segment.LeftExtraStart > 0.0f || _Segment.LeftExtraEnd > 0.0f || _Segment.RightExtraStart > 0.0f || _Segment.RightExtraEnd > 0.0f;
}
private static bool IsReshaped(RoadSegment _Segment)
{
return _Segment.LeftExtraStart < 0.0f || _Segment.LeftExtraEnd < 0.0f || _Segment.RightExtraStart < 0.0f || _Segment.RightExtraEnd < 0.0f;
}
private static void BuildLine(PolygonMesh _Mesh, RoadCurveTable _Table, in Surface _Surface, float _StartDistance, float _EndDistance, in Line _Line, in RoadWidth _Width, in DashPhase _Phase)
{
if (_EndDistance - _StartDistance < 0.01f)
return;
if (!_Line.Style.IsDashed)
{
BuildStrip(_Mesh, _Surface, _StartDistance, _EndDistance, _Line, _Width);
return;
}
// Dashes are counted along the run the segment belongs to. In run distance this stretch spans
// [runStart, runEnd); the dashes there are found by index, and each is mapped back onto the segment.
float period = _Line.Style.DashLength + _Line.Style.GapLength;
float length = _Table.Length;
float runStart = _Phase.Reversed ? _Phase.Offset + length - _EndDistance : _Phase.Offset + _StartDistance;
float runEnd = _Phase.Reversed ? _Phase.Offset + length - _StartDistance : _Phase.Offset + _EndDistance;
int first = (int)MathF.Floor(runStart / period);
for (int index = first; ; index++)
{
float dashStart = index * period;
if (dashStart >= runEnd)
break;
float runFrom = MathF.Max(dashStart, runStart);
float runTo = MathF.Min(dashStart + _Line.Style.DashLength, runEnd);
if (runTo - runFrom <= 0.5f)
continue;
float from = _Phase.Reversed ? _Phase.Offset + length - runTo : runFrom - _Phase.Offset;
float to = _Phase.Reversed ? _Phase.Offset + length - runFrom : runTo - _Phase.Offset;
BuildStrip(_Mesh, _Surface, from, to, _Line, _Width);
}
}
/// <summary>
/// One painted strip: a quad strip of the line's width, sitting <see cref="Lift"/> above the surface, on the
/// road's own rows so it lies flat on the road however coarse the road is, with the quads left out wherever
/// the line has run off the surface.
/// </summary>
private static void BuildStrip(PolygonMesh _Mesh, in Surface _Surface, float _StartDistance, float _EndDistance, in Line _Line, in RoadWidth _Width)
{
var rows = _Surface.RowsBetween(_StartDistance, _EndDistance, _Line);
int count = rows.Count;
float halfWidth = _Line.Style.Width * 0.5f;
float repeat = MathF.Max(_Line.Style.Width, 1.0f);
var material = _Line.Material;
var left = new HalfEdgeMesh.VertexHandle[count];
var right = new HalfEdgeMesh.VertexHandle[count];
var along = new float[count];
var visible = new bool[count];
for (int i = 0; i < count; i++)
{
float distance = rows[i];
visible[i] = _Line.VisibleAt(_Width, distance);
if (!visible[i])
continue;
_Surface.Place(distance, _Line.OffsetAt(_Width, distance), out Vector3 onLine, out Vector3 across);
var handles = _Mesh.AddVertices(onLine - across * halfWidth, onLine + across * halfWidth);
left[i] = handles[0];
right[i] = handles[1];
along[i] = distance / repeat;
}
for (int i = 0; i < count - 1; i++)
{
if (!visible[i] || !visible[i + 1])
continue;
MeshUtility.AddTexturedQuad(_Mesh, material, left[i], right[i], right[i + 1], left[i + 1],
new Vector2(0.0f, along[i]), new Vector2(1.0f, along[i]), new Vector2(1.0f, along[i + 1]), new Vector2(0.0f, along[i + 1]));
}
}
}