Editor-side extensions for a RoadComponent. Defines RoadExtensionDefinition and methods to bake/clear extension meshes (flat strips or walls) along spline frames, create PolygonMesh geometry, apply straight-edge adjustments, and spawn mesh children tagged as road_extension.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace RedSnail.RoadTool;
public class RoadExtensionDefinition
{
[Property] public Material Material { get; set; }
[Property, Range(10.0f, 2000.0f)] public float Width { get; set; } = 300.0f;
[Property, Range(0.0f, 2000.0f)] public float Height { get; set; } = 0.0f;
[Property, Range(-100.0f, 100.0f)] public float HeightOffset { get; set; } = 0.0f;
[Property] public float TextureRepeat { get; set; } = 500.0f;
[Property] public bool LeftSide { get; set; } = true;
[Property] public bool RightSide { get; set; } = true;
[Property] public bool WallMode { get; set; } = false;
[Property] public bool StraightEdge { get; set; } = false;
[Property] public bool HasCollision { get; set; } = true;
}
public partial class RoadComponent
{
private const string ExtensionTag = "road_extension";
[Property, FeatureEnabled("Extensions", Icon = "dashboard", Tint = EditorTint.Yellow)] private bool HasExtensions { get; set { field = value; IsDirty = true; } } = false;
/// <summary>
/// This will prevent the extensions 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 extensions.
/// </summary>
[Property(Title = "🔒 Locked"), Feature("Extensions")]
private bool AreExtensionsLocked { get; set; } = false;
[Property, Feature("Extensions")] private List<RoadExtensionDefinition> ExtensionDefinitions { get; set { field = value; IsDirty = true; } } = new();
[Button("Bake Extensions"), Feature("Extensions")]
private void BakeExtensions()
{
if (AreExtensionsLocked)
return;
if (!Scene.IsEditor)
return;
if (!HasExtensions || ExtensionDefinitions == null || ExtensionDefinitions.Count == 0)
return;
ClearBakedExtensions();
GetSplineFrameData(out var sampledFrames, out var segmentsToKeep);
var frames = segmentsToKeep.Select(index => sampledFrames[index]).ToArray();
int totalSegments = frames.Length - 1;
if (totalSegments <= 0)
return;
float roadEdgeOffset = RoadWidth * 0.5f;
float sidewalkOffset = HasSidewalk ? SidewalkWidth : 0.0f;
float sidewalkUp = HasSidewalk ? SidewalkHeight : 0.0f;
float baseInnerOffset = roadEdgeOffset + sidewalkOffset;
// Segments already claimed by range-limited extensions, per side — full-range extensions fill only the gaps.
var occupiedLeft = new bool[totalSegments];
var occupiedRight = new bool[totalSegments];
for (int d = 0; d < ExtensionDefinitions.Count; d++)
{
var def = ExtensionDefinitions[d];
if (def == null)
continue;
// Build each side over its own gaps so two full-range defs don't overlap.
if (def.LeftSide)
{
var runs = GetUnoccupiedRuns(occupiedLeft, totalSegments);
for (int r = 0; r < runs.Count; r++)
BakeExtensionRange($"Extension_{d}_r{r}_L", def, frames, runs[r].From, runs[r].To, baseInnerOffset, sidewalkUp, _ForceLeftOnly: true);
}
if (def.RightSide)
{
var runs = GetUnoccupiedRuns(occupiedRight, totalSegments);
for (int r = 0; r < runs.Count; r++)
BakeExtensionRange($"Extension_{d}_r{r}_R", def, frames, runs[r].From, runs[r].To, baseInnerOffset, sidewalkUp, _ForceRightOnly: true);
}
}
}
private static List<(int From, int To)> GetUnoccupiedRuns(bool[] _Occupied, int _TotalSegments)
{
var runs = new List<(int From, int To)>();
int runStart = -1;
for (int i = 0; i < _TotalSegments; i++)
{
if (!_Occupied[i])
{
if (runStart < 0)
runStart = i;
}
else if (runStart >= 0)
{
runs.Add((runStart, i - 1));
runStart = -1;
}
}
if (runStart >= 0)
runs.Add((runStart, _TotalSegments - 1));
return runs;
}
private void BakeExtensionRange(string _NamePrefix, RoadExtensionDefinition _Def, Transform[] _AllFrames, int _SegFrom, int _SegTo, float _BaseInnerOffset, float _SidewalkUp, bool _ForceLeftOnly = false, bool _ForceRightOnly = false)
{
int rangeSegmentCount = _SegTo - _SegFrom + 1;
int rangeFrameCount = rangeSegmentCount + 1;
var rangeFrames = new Transform[rangeFrameCount];
Array.Copy(_AllFrames, _SegFrom, rangeFrames, 0, rangeFrameCount);
float innerOffset = _BaseInnerOffset;
float textureRepeat = Math.Max(1.0f, _Def.TextureRepeat);
float heightOffset = _SidewalkUp + _Def.HeightOffset;
var material = _Def.Material ?? Material.Load("materials/dev/reflectivity_50.vmat");
bool buildLeft = _Def.LeftSide && !_ForceRightOnly;
bool buildRight = _Def.RightSide && !_ForceLeftOnly;
if (_Def.WallMode)
{
float wallHeight = Math.Max(1.0f, _Def.Height > 0.0f ? _Def.Height : _Def.Width);
if (buildLeft)
CreateWallBakedMesh($"{_NamePrefix}_Wall_Left", _Def, material, rangeFrames, rangeSegmentCount, innerOffset, wallHeight, heightOffset, textureRepeat, _IsLeftSide: true);
if (buildRight)
CreateWallBakedMesh($"{_NamePrefix}_Wall_Right", _Def, material, rangeFrames, rangeSegmentCount, innerOffset, wallHeight, heightOffset, textureRepeat, _IsLeftSide: false);
}
else
{
float outerOffset = innerOffset + _Def.Width;
if (buildLeft)
CreateFlatBakedMesh($"{_NamePrefix}_Flat_Left", _Def, material, rangeFrames, rangeSegmentCount, innerOffset, outerOffset, heightOffset, textureRepeat, _IsLeftSide: true);
if (buildRight)
CreateFlatBakedMesh($"{_NamePrefix}_Flat_Right", _Def, material, rangeFrames, rangeSegmentCount, innerOffset, outerOffset, heightOffset, textureRepeat, _IsLeftSide: false);
}
}
private void CreateFlatBakedMesh(string _Name, RoadExtensionDefinition _Def, Material _Material, Transform[] _Frames, int _SegmentCount, float _InnerOffset, float _OuterOffset, float _HeightOffset, float _TextureRepeat, bool _IsLeftSide)
{
var polygonMesh = new PolygonMesh();
float sign = _IsLeftSide ? -1.0f : 1.0f;
int frameCount = _SegmentCount + 1;
var positions = new Vector3[frameCount * 2];
for (int i = 0; i < frameCount; i++)
{
var frame = _Frames[i];
var position = frame.Position;
var right = frame.Rotation.Right;
var up = frame.Rotation.Up;
positions[i * 2] = position + right * (sign * _InnerOffset) + up * _HeightOffset;
positions[i * 2 + 1] = position + right * (sign * _OuterOffset) + up * _HeightOffset;
}
if (_Def.StraightEdge && frameCount >= 2)
ApplyStraightEdgeFlat(positions, frameCount);
var vertices = polygonMesh.AddVertices(positions);
float uvDistance = 0.0f;
for (int i = 0; i < _SegmentCount; i++)
{
var inner0 = vertices[i * 2];
var outer0 = vertices[i * 2 + 1];
var inner1 = vertices[(i + 1) * 2];
var outer1 = vertices[(i + 1) * 2 + 1];
float segmentLength = (Vector3.DistanceBetween(positions[i * 2], positions[(i + 1) * 2]) + Vector3.DistanceBetween(positions[i * 2 + 1], positions[(i + 1) * 2 + 1])) * 0.5f;
float v0 = uvDistance;
uvDistance += segmentLength / _TextureRepeat;
float v1 = uvDistance;
if (_IsLeftSide)
MeshUtility.AddTexturedQuad(polygonMesh, _Material, inner0, inner1, outer1, outer0,
new Vector2(0, v0), new Vector2(0, v1), new Vector2(1, v1), new Vector2(1, v0));
else
MeshUtility.AddTexturedQuad(polygonMesh, _Material, inner0, outer0, outer1, inner1,
new Vector2(0, v0), new Vector2(1, v0), new Vector2(1, v1), new Vector2(0, v1));
}
CreateExtensionChild(_Name, _Def, polygonMesh);
}
private void CreateWallBakedMesh(string _Name, RoadExtensionDefinition _Def, Material _Material, Transform[] _Frames, int _SegmentCount, float _EdgeOffset, float _WallHeight, float _HeightOffset, float _TextureRepeat, bool _IsLeftSide)
{
var polygonMesh = new PolygonMesh();
float sign = _IsLeftSide ? -1.0f : 1.0f;
float uvHeight = _WallHeight / _TextureRepeat;
int frameCount = _SegmentCount + 1;
var positions = new Vector3[frameCount * 2];
for (int i = 0; i < frameCount; i++)
{
var frame = _Frames[i];
var position = frame.Position;
var right = frame.Rotation.Right;
var up = frame.Rotation.Up;
var bottom = position + right * (sign * _EdgeOffset) + up * _HeightOffset;
positions[i * 2] = bottom;
positions[i * 2 + 1] = bottom + up * _WallHeight;
}
if (_Def.StraightEdge && frameCount >= 2)
ApplyStraightEdgeWall(positions, frameCount);
var vertices = polygonMesh.AddVertices(positions);
float uvDistance = 0.0f;
for (int i = 0; i < _SegmentCount; i++)
{
var bottom0 = vertices[i * 2];
var top0 = vertices[i * 2 + 1];
var bottom1 = vertices[(i + 1) * 2];
var top1 = vertices[(i + 1) * 2 + 1];
float segmentLength = (Vector3.DistanceBetween(positions[i * 2], positions[(i + 1) * 2]) + Vector3.DistanceBetween(positions[i * 2 + 1], positions[(i + 1) * 2 + 1])) * 0.5f;
float v0 = uvDistance;
uvDistance += segmentLength / _TextureRepeat;
float v1 = uvDistance;
if (_IsLeftSide)
MeshUtility.AddTexturedQuad(polygonMesh, _Material, bottom0, bottom1, top1, top0,
new Vector2(0, v0), new Vector2(0, v1), new Vector2(uvHeight, v1), new Vector2(uvHeight, v0));
else
MeshUtility.AddTexturedQuad(polygonMesh, _Material, bottom0, top0, top1, bottom1,
new Vector2(0, v0), new Vector2(uvHeight, v0), new Vector2(uvHeight, v1), new Vector2(0, v1));
}
CreateExtensionChild(_Name, _Def, polygonMesh);
}
/// <summary>
/// Snaps the flat strip's outer edge to a single axis-aligned line at the farthest perpendicular distance, so the
/// outer border reads as straight even though the road curves. Each outer point keeps its inner point's primary axis.
/// </summary>
private static void ApplyStraightEdgeFlat(Vector3[] _Positions, int _FrameCount)
{
var firstOuter = _Positions[1];
var lastOuter = _Positions[(_FrameCount - 1) * 2 + 1];
Vector3 lineDir = lastOuter - firstOuter;
bool roadAlongX = MathF.Abs(lineDir.x) >= MathF.Abs(lineDir.y);
float extremePerp = roadAlongX ? _Positions[1].y : _Positions[1].x;
for (int i = 0; i < _FrameCount; i++)
{
float perp = roadAlongX ? _Positions[i * 2 + 1].y : _Positions[i * 2 + 1].x;
float innerPerp = roadAlongX ? _Positions[i * 2].y : _Positions[i * 2].x;
if (MathF.Abs(perp - innerPerp) > MathF.Abs(extremePerp - innerPerp))
extremePerp = perp;
}
for (int i = 0; i < _FrameCount; i++)
{
var inner = _Positions[i * 2];
_Positions[i * 2 + 1] = roadAlongX
? new Vector3(inner.x, extremePerp, inner.z)
: new Vector3(extremePerp, inner.y, inner.z);
}
}
/// <summary>Levels the wall's top edge to a single height (the highest top), keeping each top above its own bottom.</summary>
private static void ApplyStraightEdgeWall(Vector3[] _Positions, int _FrameCount)
{
float maxTopZ = float.MinValue;
for (int i = 0; i < _FrameCount; i++)
maxTopZ = MathF.Max(maxTopZ, _Positions[i * 2 + 1].z);
for (int i = 0; i < _FrameCount; i++)
{
var bottom = _Positions[i * 2];
_Positions[i * 2 + 1] = new Vector3(bottom.x, bottom.y, maxTopZ);
}
}
private void CreateExtensionChild(string _Name, RoadExtensionDefinition _Def, PolygonMesh _PolygonMesh)
{
if (AreExtensionsLocked)
return;
var child = new GameObject(GameObject, true, _Name);
child.Tags.Add(ExtensionTag);
var meshComponent = child.AddComponent<MeshComponent>();
meshComponent.Mesh = _PolygonMesh;
meshComponent.SmoothingAngle = 40.0f;
if (!_Def.HasCollision)
meshComponent.Collision = MeshComponent.CollisionType.None;
}
[Button("Clear Extensions"), Feature("Extensions")]
private void ClearBakedExtensions()
{
if (AreExtensionsLocked)
return;
if (!Scene.IsEditor)
return;
RemoveGeneratedMeshChildren(ExtensionTag);
}
}