Code/RoadIntersectionComponent/RoadIntersectionComponent.Circle.cs
using System;
using System.Collections.Generic;
using Sandbox;
namespace RedSnail.RoadTool;
public class CircleExit
{
[Property, Hide] public RoadIntersectionComponent Reference { get; set; }
[Property] public float AngleDegrees { get; set { field = value; Reference?.m_IsDirty = true; } } = 0.0f;
[Property] public float RoadWidth { get; set { field = value; Reference?.m_IsDirty = true; } } = 500.0f;
}
public partial class RoadIntersectionComponent
{
private const float SegmentsMultiplier = 3.0f / 32.0f;
[Property, Feature("General"), ShowIf(nameof(Shape), IntersectionShape.Circle), Order(1)] private float Radius { get; set { field = value; m_IsDirty = true; } } = 1000.0f;
[Property, Feature("General"), ShowIf(nameof(Shape), IntersectionShape.Circle), Order(1), Range(4, 8)] private int DiscSegmentsPower { get; set { field = value.Clamp(4, 8); m_IsDirty = true; } } = 6;
[Property(Title = "Exits"), Feature("General"), ShowIf(nameof(Shape), IntersectionShape.Circle), Order(1), Change] private CircleExit[] CircleExits { get; set; } = [];
private int DiscSegments => 1 << DiscSegmentsPower;
private void OnCircleExitsChanged(CircleExit[] _OldValue, CircleExit[] _NewValue)
{
if (_NewValue.Length > 0)
{
_NewValue[^1] = new CircleExit
{
Reference = this
};
}
if (_NewValue.Length > 1 && _NewValue.Length > _OldValue.Length)
{
float prevAngle = _OldValue.Length > 0 && _OldValue[^1] is not null ? _OldValue[^1].AngleDegrees : 0.0f;
_NewValue[^1].AngleDegrees = prevAngle + 90.0f;
}
m_IsDirty = true;
}
// Samples a curved wall from inner (on disc) to outer (at corridor outer corner) using a quadratic Bezier
// tangent to the disc at the inner end and tangent to the exit direction at the outer end.
// Returns N+1 points where N = ExitCornerSegments.
private List<Vector3> BuildExitWallVerts(Vector3 _Inner, Vector3 _Outer, Vector3 _DiscTangent, Vector3 _ExitDir)
{
var verts = new List<Vector3>();
int n = (int)Math.Max(2, DiscSegments * SegmentsMultiplier);
if (n == 1)
{
verts.Add(_Inner);
verts.Add(_Outer);
return verts;
}
TryBezierControl(_Inner, _DiscTangent, _Outer, _ExitDir, out Vector3 b1);
for (int i = 0; i <= n; i++)
{
float t = (float)i / n;
verts.Add(SampleQuadBezier(_Inner, b1, _Outer, t));
}
return verts;
}
private void BuildCircleRoad(PolygonMesh _Mesh, Material _Material)
{
var cache = new Dictionary<Vector3, HalfEdgeMesh.VertexHandle>();
int segments = DiscSegments;
float step = 360.0f / segments;
var vCenter = MeshUtility.GetOrAddVertex(_Mesh, cache, Vector3.Zero);
// Exits are handled by the corridor mesh extending outward from the arc.
for (int i = 0; i < segments; i++)
{
float a0 = i * step;
float a1 = (i + 1) * step;
Vector3 d0 = Rotation.FromYaw(a0).Forward * Radius;
Vector3 d1 = Rotation.FromYaw(a1).Forward * Radius;
var vD0 = MeshUtility.GetOrAddVertex(_Mesh, cache, d0);
var vD1 = MeshUtility.GetOrAddVertex(_Mesh, cache, d1);
MeshUtility.AddTexturedTriangle(_Mesh, _Material, vCenter, vD0, vD1,
Vector2.Zero, new Vector2(0, 1), new Vector2(1, 1));
}
// Flat exit corridor extending from the disc's arc out into the exterior
foreach (var exit in CircleExits ?? Array.Empty<CircleExit>())
AddCircleExitCorridor(_Mesh, _Material, cache, exit);
}
// Snap target sits SW beyond the ring's outer boundary, clearly exterior to the intersection
private float GetCircleExitSnapDistance() => Radius + SidewalkWidth * 2.0f;
// Returns the disc grid angles bounding the exit gap.
// The gap span is fixed at (2 * CornerSegments) disc grid steps centered on the exit angle, so the arc
// vertex count matches the wall vertex count (CornerSegments + 1 per side), required for the zigzag strip
// triangulation. The exit road's physical width should fit inside this angular gap; if it doesn't, increase
// DiscSegments or decrease CornerSegments.
private void GetCircleExitGridAngles(CircleExit _Exit, out float _GridAngleCw, out float _GridAngleCcw)
{
int segments = DiscSegments;
float step = 360.0f / segments;
// Snap exit angle to nearest grid index, then offset by ±CornerSegments grid steps.
int n = (int)Math.Max(2, DiscSegments * SegmentsMultiplier);
int iCenter = (int)MathF.Round(_Exit.AngleDegrees / step);
int iCw = iCenter - n;
int iCcw = iCenter + n;
// Normalize to [0, segments) so we use the same grid angles the disc loop uses
int iCwNorm = ((iCw % segments) + segments) % segments;
int iCcwNorm = ((iCcw % segments) + segments) % segments;
_GridAngleCcw = iCcwNorm * step;
_GridAngleCw = iCwNorm * step;
}
private void AddCircleExitCorridor(PolygonMesh _Mesh, Material _Material, Dictionary<Vector3, HalfEdgeMesh.VertexHandle> _Cache, CircleExit _Exit)
{
float halfRoadWidth = _Exit.RoadWidth * 0.5f;
float snapDistance = GetCircleExitSnapDistance();
int segments = DiscSegments;
float step = 360.0f / segments;
Vector3 exitDir = Rotation.FromYaw(_Exit.AngleDegrees).Forward;
Vector3 perpDir = Vector3.Cross(exitDir, Vector3.Up).Normal;
Vector3 outerMinus = exitDir * snapDistance + perpDir * halfRoadWidth;
Vector3 outerPlus = exitDir * snapDistance - perpDir * halfRoadWidth;
// Inner corners on the disc (grid-aligned so they weld with disc vertices)
GetCircleExitGridAngles(_Exit, out float gridAngleCw, out float gridAngleCcw);
Vector3 innerMinus = Rotation.FromYaw(gridAngleCw).Forward * Radius;
Vector3 innerPlus = Rotation.FromYaw(gridAngleCcw).Forward * Radius;
// Curved walls: tangent to the disc at the inner end, tangent to exitDir at the outer end.
Vector3 tangentMinus = Rotation.FromYaw(gridAngleCw + 90.0f).Forward;
Vector3 tangentPlus = Rotation.FromYaw(gridAngleCcw - 90.0f).Forward;
var wallMinus = BuildExitWallVerts(innerMinus, outerMinus, tangentMinus, exitDir);
var wallPlus = BuildExitWallVerts(innerPlus, outerPlus, tangentPlus, exitDir);
// Arc vertices: gap span is exactly (2 * CornerSegments) disc steps, so arc has (2n + 1) vertices.
// arc[0] = innerMinus, arc[n] = arcMidpoint, arc[2n] = innerPlus. All weld with disc through the cache.
int n = (int)Math.Max(2, DiscSegments * SegmentsMultiplier);
int iCenter = (int)MathF.Round(_Exit.AngleDegrees / step);
var arcPositions = new List<Vector3>(2 * n + 1);
for (int k = -n; k <= n; k++)
{
int idx = ((iCenter + k) % segments + segments) % segments;
arcPositions.Add(Rotation.FromYaw(idx * step).Forward * Radius);
}
Vector3 arcMidpoint = arcPositions[n];
Vector3 outerMidpoint = exitDir * snapDistance;
// Helper: add a CCW triangle (viewed from +Z = above) with planar UVs scaled by RoadTextureRepeat.
void AddTri(Vector3 a, Vector3 b, Vector3 c)
{
var vA = MeshUtility.GetOrAddVertex(_Mesh, _Cache, a);
var vB = MeshUtility.GetOrAddVertex(_Mesh, _Cache, b);
var vC = MeshUtility.GetOrAddVertex(_Mesh, _Cache, c);
Vector2 uvA = new Vector2(a.x, a.y) / RoadTextureRepeat;
Vector2 uvB = new Vector2(b.x, b.y) / RoadTextureRepeat;
Vector2 uvC = new Vector2(c.x, c.y) / RoadTextureRepeat;
MeshUtility.AddTexturedTriangle(_Mesh, _Material, vA, vB, vC, uvA, uvB, uvC);
}
// ── LEFT zigzag strip ──
// Rails: arc[0..n] (innerMinus → arcMidpoint) ←→ wallMinus[0..n] (innerMinus → outerMinus).
// Both rails START at innerMinus (i = 0) — they share that vertex, so iteration 0 produces only ONE
// triangle (the wedge tip), not two. The would-be first triangle (a0, w0, w1) is degenerate because
// a0 = w0 = innerMinus.
// CCW winding (interior on the left, arc is on the disc side which is CCW around the corridor interior):
// triangle (arc[i], wall[i], wall[i+1]) — skipped at i = 0 (degenerate)
// triangle (arc[i], wall[i+1], arc[i+1])
for (int i = 0; i < n; i++)
{
Vector3 a0 = arcPositions[i];
Vector3 a1 = arcPositions[i + 1];
Vector3 w0 = wallMinus[i];
Vector3 w1 = wallMinus[i + 1];
if (i > 0)
AddTri(a0, w0, w1);
AddTri(a0, w1, a1);
}
// ── RIGHT zigzag strip ──
// Mirror of the left strip. Rails: arc[2n..n] (innerPlus → arcMidpoint, walking arc in REVERSE) ←→
// wallPlus[0..n] (innerPlus → outerPlus). Both rails START at innerPlus (i = 0) — same wedge-tip
// degeneracy as the left strip, so the first (a0, w1, w0) triangle is skipped at i = 0.
// CCW winding is the mirror of the left strip (arc is now on the CW side relative to the wall):
// triangle (arc[i], wall[i+1], wall[i]) — skipped at i = 0 (degenerate)
// triangle (arc[i], arc[i+1], wall[i+1])
// where arc[i] means arcPositions[2n - i] (so arc[0] = innerPlus, arc[n] = arcMidpoint).
for (int i = 0; i < n; i++)
{
Vector3 a0 = arcPositions[2 * n - i];
Vector3 a1 = arcPositions[2 * n - i - 1];
Vector3 w0 = wallPlus[i];
Vector3 w1 = wallPlus[i + 1];
if (i > 0)
AddTri(a0, w1, w0);
AddTri(a0, a1, w1);
}
// ── Bottom closure ──
// At the bottom-middle, the two strips have reached (outerMinus on the left wall, arcMidpoint at the apex,
// outerPlus on the right wall). Close the bottom with two triangles meeting at outerMidpoint:
// (arcMidpoint, outerMinus, outerMidpoint) ← left closure
// (arcMidpoint, outerMidpoint, outerPlus) ← right closure
// Both are CCW from above. arcMidpoint is the shared "top" of the closure region; outerMidpoint splits the
// outer edge so each side gets its own triangle (no degenerate collinear edges).
AddTri(arcMidpoint, outerMinus, outerMidpoint);
AddTri(arcMidpoint, outerMidpoint, outerPlus);
}
private void BuildCircleSidewalk(PolygonMesh _Mesh, Material _Material)
{
var cache = new Dictionary<Vector3, HalfEdgeMesh.VertexHandle>();
int segments = DiscSegments;
float step = 360f / segments;
Vector3 up = Vector3.Up;
float innerR = Radius;
float outerR = Radius + SidewalkWidth;
float heightUV = SidewalkHeight / SidewalkTextureRepeat;
for (int i = 0; i < segments; i++)
{
float a0 = i * step;
float a1 = (i + 1) * step;
if (ArcBlockedByExit(a0, a1))
continue;
float arcDist0 = innerR * (a0 * MathF.PI / 180f);
float arcDist1 = innerR * (a1 * MathF.PI / 180f);
float v0 = arcDist0 / SidewalkTextureRepeat;
float v1 = arcDist1 / SidewalkTextureRepeat;
Vector3 d0V = Rotation.FromYaw(a0).Forward;
Vector3 d1V = Rotation.FromYaw(a1).Forward;
Vector3 i0 = d0V * innerR;
Vector3 i1 = d1V * innerR;
Vector3 o0 = d0V * outerR;
Vector3 o1 = d1V * outerR;
var vI0 = MeshUtility.GetOrAddVertex(_Mesh, cache, i0);
var vI1 = MeshUtility.GetOrAddVertex(_Mesh, cache, i1);
var vO0 = MeshUtility.GetOrAddVertex(_Mesh, cache, o0);
var vO1 = MeshUtility.GetOrAddVertex(_Mesh, cache, o1);
var vTI0 = MeshUtility.GetOrAddVertex(_Mesh, cache, i0 + up * SidewalkHeight);
var vTI1 = MeshUtility.GetOrAddVertex(_Mesh, cache, i1 + up * SidewalkHeight);
var vTO0 = MeshUtility.GetOrAddVertex(_Mesh, cache, o0 + up * SidewalkHeight);
var vTO1 = MeshUtility.GetOrAddVertex(_Mesh, cache, o1 + up * SidewalkHeight);
// Top face
MeshUtility.AddTexturedQuad(_Mesh, _Material, vTO0, vTO1, vTI1, vTI0,
new Vector2(1, v0), new Vector2(1, v1), new Vector2(0, v1), new Vector2(0, v0));
// Curb face
MeshUtility.AddTexturedQuad(_Mesh, _Material, vTI0, vTI1, vI1, vI0,
new Vector2(0, v0), new Vector2(0, v1), new Vector2(heightUV, v1), new Vector2(heightUV, v0));
}
// Sidewalk strips alongside each exit corridor, curb walls on both sides of the exit road
foreach (var exit in CircleExits ?? [])
AddCircleExitSidewalk(_Mesh, _Material, cache, exit);
}
private void AddCircleExitSidewalk(PolygonMesh _Mesh, Material _Material, Dictionary<Vector3, HalfEdgeMesh.VertexHandle> _Cache, CircleExit _Exit)
{
float halfRoadWidth = _Exit.RoadWidth * 0.5f;
float snapDistance = GetCircleExitSnapDistance();
GetCircleExitGridAngles(_Exit, out float gridAngleCw, out float gridAngleCcw);
Vector3 exitDir = Rotation.FromYaw(_Exit.AngleDegrees).Forward;
Vector3 perpDir = Vector3.Cross(exitDir, Vector3.Up).Normal;
Vector3 innerMinus = Rotation.FromYaw(gridAngleCw).Forward * Radius;
Vector3 innerPlus = Rotation.FromYaw(gridAngleCcw).Forward * Radius;
Vector3 outerMinus = exitDir * snapDistance + perpDir * halfRoadWidth;
Vector3 outerPlus = exitDir * snapDistance - perpDir * halfRoadWidth;
Vector3 radialMinus = Rotation.FromYaw(gridAngleCw).Forward;
Vector3 radialPlus = Rotation.FromYaw(gridAngleCcw).Forward;
Vector3 tangentMinus = Rotation.FromYaw(gridAngleCw + 90.0f).Forward;
Vector3 tangentPlus = Rotation.FromYaw(gridAngleCcw - 90.0f).Forward;
// Curved inner edges (matches the corridor walls) and outer edges (offset outward).
// Inner-edge tangents start with the disc tangent, end with exitDir → smooth blend.
// Outer-edge tangents start with the disc radial (so it lands on ring outer corner)
// and end with perpDir (so it lands on road sidewalk outer corner) → no spike.
var innerMinusVerts = BuildExitWallVerts(innerMinus, outerMinus, tangentMinus, exitDir);
var outerMinusVerts = BuildExitWallVerts(
innerMinus + radialMinus * SidewalkWidth,
outerMinus + perpDir * SidewalkWidth,
tangentMinus, exitDir);
var innerPlusVerts = BuildExitWallVerts(innerPlus, outerPlus, tangentPlus, exitDir);
var outerPlusVerts = BuildExitWallVerts(
innerPlus + radialPlus * SidewalkWidth,
outerPlus - perpDir * SidewalkWidth,
tangentPlus, exitDir);
// Accumulate V coordinate along each inner curve (= cumulative arc length / SidewalkTextureRepeat).
// Both inner and outer edges share the same V at each curve parameter, so the texture flows along the curve
// (same scheme as the rectangle's rounded sidewalk corners).
float[] vMinus = new float[innerMinusVerts.Count];
for (int i = 1; i < innerMinusVerts.Count; i++)
vMinus[i] = vMinus[i - 1] + Vector3.DistanceBetween(innerMinusVerts[i - 1], innerMinusVerts[i]) / SidewalkTextureRepeat;
float[] vPlus = new float[innerPlusVerts.Count];
for (int i = 1; i < innerPlusVerts.Count; i++)
vPlus[i] = vPlus[i - 1] + Vector3.DistanceBetween(innerPlusVerts[i - 1], innerPlusVerts[i]) / SidewalkTextureRepeat;
int n = innerMinusVerts.Count - 1;
// Minus side: walk inner edge outer→inner so A→B→C→D is CCW for top-up normal.
for (int i = n - 1; i >= 0; i--)
AddCircleExitSidewalkSegment(_Mesh, _Material, _Cache,
innerMinusVerts[i + 1], innerMinusVerts[i],
outerMinusVerts[i], outerMinusVerts[i + 1],
vMinus[i + 1], vMinus[i]);
// Plus side: walk inner edge inner→outer (opposite direction to the minus side).
for (int i = 0; i < n; i++)
AddCircleExitSidewalkSegment(_Mesh, _Material, _Cache,
innerPlusVerts[i], innerPlusVerts[i + 1],
outerPlusVerts[i + 1], outerPlusVerts[i],
vPlus[i], vPlus[i + 1]);
}
// Builds one trapezoidal sidewalk segment given 4 ground-level corners in CCW order (top face up-facing).
// A-B = corridor wall edge (curb face faces road); C-D = outer edge (outer face faces away from road).
// A and D share V_A (one end of the segment along the curve); B and C share V_B (other end).
// U = perpendicular width from inner edge to outer edge (variable along the curve since the strip isn't strictly parallel).
// This matches the rectangle's rounded-corner UV scheme so the texture flows along the curve without stretching.
private void AddCircleExitSidewalkSegment(PolygonMesh _Mesh, Material _Material, Dictionary<Vector3, HalfEdgeMesh.VertexHandle> _Cache,
Vector3 _A, Vector3 _B, Vector3 _C, Vector3 _D, float _VA, float _VB)
{
Vector3 up = Vector3.Up;
float h = SidewalkHeight;
float hHeight = h / SidewalkTextureRepeat;
float uA = Vector3.DistanceBetween(_A, _D) / SidewalkTextureRepeat;
float uB = Vector3.DistanceBetween(_B, _C) / SidewalkTextureRepeat;
Vector3 aT = _A + up * h;
Vector3 bT = _B + up * h;
Vector3 cT = _C + up * h;
Vector3 dT = _D + up * h;
var vA = MeshUtility.GetOrAddVertex(_Mesh, _Cache, _A);
var vB = MeshUtility.GetOrAddVertex(_Mesh, _Cache, _B);
var vC = MeshUtility.GetOrAddVertex(_Mesh, _Cache, _C);
var vD = MeshUtility.GetOrAddVertex(_Mesh, _Cache, _D);
var vAT = MeshUtility.GetOrAddVertex(_Mesh, _Cache, aT);
var vBT = MeshUtility.GetOrAddVertex(_Mesh, _Cache, bT);
var vCT = MeshUtility.GetOrAddVertex(_Mesh, _Cache, cT);
var vDT = MeshUtility.GetOrAddVertex(_Mesh, _Cache, dT);
// Top face — U = 0 on inner edge (A, B), U = uA/uB on outer edge (D, C); V follows the curve.
MeshUtility.AddTexturedQuad(_Mesh, _Material, vAT, vBT, vCT, vDT,
new Vector2(0, _VA), new Vector2(0, _VB), new Vector2(uB, _VB), new Vector2(uA, _VA));
// Curb face along A-B edge — U is the wall height (0 at top, hHeight at ground), V follows the curve.
MeshUtility.AddTexturedQuad(_Mesh, _Material, vAT, vA, vB, vBT,
new Vector2(0, _VA), new Vector2(hHeight, _VA), new Vector2(hHeight, _VB), new Vector2(0, _VB));
// Outer face along C-D edge.
MeshUtility.AddTexturedQuad(_Mesh, _Material, vCT, vC, vD, vDT,
new Vector2(0, _VB), new Vector2(hHeight, _VB), new Vector2(hHeight, _VA), new Vector2(0, _VA));
}
private static float AngleDelta(float _A, float _B)
{
float d = (_A - _B) % 360.0f;
if (d > 180.0f) d -= 360.0f;
if (d < -180.0f) d += 360.0f;
return MathF.Abs(d);
}
// True if either endpoint of arc segment [_A0, _A1] falls inside an exit's grid-aligned gap.
// The gap span is the same (2 * CornerSegments * step) used by GetCircleExitGridAngles, so disc segments are
// skipped exactly where the corridor takes over — vertices weld cleanly at the gap boundary.
private bool ArcBlockedByExit(float _A0, float _A1)
{
int segments = DiscSegments;
float step = 360.0f / segments;
int n = (int)Math.Max(2, DiscSegments * SegmentsMultiplier);
float halfGap = n * step;
foreach (var exit in CircleExits ?? Array.Empty<CircleExit>())
{
// Use the same grid-snapped center that GetCircleExitGridAngles uses so the blocked window
// aligns exactly with the corridor bounds even when the exit angle isn't on a grid line.
int iCenter = (int)MathF.Round(exit.AngleDegrees / step);
float snappedCenter = iCenter * step;
if (AngleDelta(_A0, snappedCenter) < halfGap || AngleDelta(_A1, snappedCenter) < halfGap)
return true;
}
return false;
}
public Transform GetCircleExitTransform(int _Index, bool _IncludeSidewalk = false)
{
var exit = CircleExits[_Index];
Vector3 dir = WorldRotation * Rotation.FromYaw(exit.AngleDegrees).Forward;
// When sidewalk is included, snap target sits at the corridor's outer edge — well beyond R+SW
float dist = _IncludeSidewalk ? GetCircleExitSnapDistance() : Radius;
return new Transform
{
Position = WorldPosition + dir * dist,
Rotation = Rotation.LookAt(dir, WorldRotation.Up)
};
}
}