s&box Package Code Search
Search C# source code, UI razor templates, shaders, and configs across s&box packages.
link Raw Facepunch API Link: https://public.facepunch.com/sbox/code/search/1/?ident=redsnail.roadtool&take=20
Showing code results for query:
*
(111 total matches found)
Editor
library
using Sandbox;
using Editor;
namespace RedSnail.RoadTool.Editor;
/// <summary>
/// Create and manage road and road intersection.
/// </summary>
[Title("Create Road/Intersection")]
[Icon("roundabout_left")]
[Alias("intersection")]
[Group("1")]
[Order(0)]
public class IntersectionTool : EditorTool
{
public override void OnEnabled()
{
}
public override Widget CreateToolSidebar()
{
ToolSidebarWidget sidebar = new ToolSidebarWidget();
sidebar.AddTitle("Intersection", "roundabout_left");
Layout group = sidebar.AddGroup("Create");
Layout row = Layout.Row();
IconButton road = sidebar.CreateButton("Create Road", "route", null, CreateRoad, true, row);
IconButton inter = sidebar.CreateButton("Create Intersection", "roundabout_left", null, CreateIntersection, true, row);
row.Spacing = 5;
row.AddStretchCell();
group.Add(row);
sidebar.Layout.Add(group);
sidebar.Layout.AddStretchCell();
return sidebar;
}
private static void CreateRoad()
{
GameObject go = SceneEditorSession.Active.Scene.CreateObject();
go.Name = "Road";
go.AddComponent<RoadComponent>();
}
private static void CreateIntersection()
{
GameObject go = SceneEditorSession.Active.Scene.CreateObject();
go.Name = "Road Intersection";
go.AddComponent<RoadIntersectionComponent>();
}
}
Game
library
using System;
using System.Collections.Generic;
using Sandbox;
namespace RedSnail.RoadTool;
/// <summary>
/// How much of a junction reaches the pedestrian graph. Geometry is never affected — the pavement mesh builds
/// the same either way; this only decides what pedestrians are allowed to route over.
///
/// Ordered most-to-least deliberately, so the zero value is the harmless one: a component that somehow arrives
/// without this field set behaves like every other junction rather than silently vanishing from the graph.
/// </summary>
public enum SidewalkGraphMode
{
/// <summary>Pavement round the corners and a crossing at every arm.</summary>
All,
/// <summary>
/// Corners only. The pavement stays joined all the way round and connects to every road that meets here,
/// but there's nothing to step off the kerb onto — pedestrians walk round the junction instead of over it.
/// </summary>
NoCrossing,
/// <summary>
/// Nothing at all. The roads that meet here keep their own pavements; they just stop being connected
/// THROUGH this junction, so expect their ends to show as dead ends.
/// </summary>
None
}
public partial class RoadIntersectionComponent
{
/// <summary>
/// What this junction contributes to the pedestrian graph — the pedestrian counterpart of
/// <see cref="ExcludeTraffic"/>.
///
/// <see cref="SidewalkGraphMode.None"/> for somewhere nobody should be walking round at all: a slip road, a
/// service yard, a junction whose pavement exists only because the mesh needs an edge.
/// <see cref="SidewalkGraphMode.NoCrossing"/> for somewhere they may walk past but not across — a forecourt
/// or car park entrance, where the kerb should stay continuous and stepping into the vehicle route is the
/// thing you're trying to prevent.
/// </summary>
[Property, Feature("General"), Category("Sidewalk"), Order(3)] public SidewalkGraphMode SidewalkGraph { get; set; } = SidewalkGraphMode.All;
/// <summary>
/// The walking line around this junction's pavement, in world space — a closed loop running down the
/// middle of the sidewalk slab.
///
/// Built from the junction's OWN outline, which is the whole point: a rectangular intersection's pavement
/// runs along its edges and turns at its corners, and approximating that with an arc around the centre
/// bows the path out into the road at the middle of each side and cuts the corners off. A circle really is
/// an arc, so it gets one.
///
/// The loop runs all the way round, arm mouths included — whoever consumes it is expected to split it at
/// the kerbs, because where the pavement is interrupted is the same question as where the crossings go.
/// </summary>
public List<Vector3> GetSidewalkOutline(float _Spacing)
{
var points = new List<Vector3>();
if (!HasSidewalks)
return points;
float spacing = Math.Max(1.0f, _Spacing);
// Centre of the slab, so it sits where someone would actually walk rather than on either kerb.
float outset = SidewalkWidth * 0.5f;
Vector3 lift = Vector3.Up * SidewalkHeight;
if (Shape == IntersectionShape.Circle)
{
float radius = Radius + outset;
int steps = Math.Max(8, (int)MathF.Ceiling(MathF.Tau * radius / spacing));
for (int i = 0; i < steps; i++)
{
float angle = MathF.Tau * i / steps;
points.Add(new Vector3(MathF.Cos(angle) * radius, MathF.Sin(angle) * radius, 0.0f) + lift);
}
}
else
{
// Written with the same right/forward vectors BuildRectangleRoad uses, rather than as raw x/y
// components. Width runs along Right and Length along Forward, which in s&box axes is Y and X —
// spelling that out by hand gets them the wrong way round, and a junction outline rotated 90°
// looks almost plausible until nothing connects to it.
Vector3 right = Vector3.Right;
Vector3 forward = Vector3.Forward;
float hw = Width * 0.5f + outset;
float hl = Length * 0.5f + outset;
// Round the rectangle in order, so the loop has a consistent winding for anything that walks it.
Vector3[] corners =
[
-right * hw - forward * hl,
right * hw - forward * hl,
right * hw + forward * hl,
-right * hw + forward * hl,
];
for (int i = 0; i < corners.Length; i++)
{
Vector3 from = corners[i];
Vector3 to = corners[(i + 1) % corners.Length];
int steps = Math.Max(1, (int)MathF.Ceiling(Vector3.DistanceBetween(from, to) / spacing));
// Last point of each edge is skipped — it's the first of the next one, and a closed loop
// mustn't repeat its corners.
for (int s = 0; s < steps; s++)
points.Add(Vector3.Lerp(from, to, (float)s / steps) + lift);
}
}
for (int i = 0; i < points.Count; i++)
points[i] = WorldTransform.PointToWorld(points[i]);
return points;
}
/// <summary>Whether this junction has pavement to walk on at all.</summary>
public bool HasSidewalks => SidewalkWidth > 0.0f;
}
Game
library
using System;
using System.Linq;
using Sandbox;
namespace RedSnail.RoadTool;
public partial class RoadIntersectionComponent
{
[Property, Feature("Terrain", Icon = "landscape", Tint = EditorTint.Green), Hide]
private Terrain TerrainTarget { get; set; }
[Property, Feature("Terrain"), Range(0f, 2000f)]
public float TerrainFalloffRadius { get; set; } = 500f;
[Property, Feature("Terrain"), Range(-10f, 10f)]
public float TerrainHeightOffset { get; set; } = 0f;
[Property, Feature("Terrain"), Range(0f, 100f)]
public float TerrainRoadInset { get; set; } = 10f;
[Property, Feature("Terrain"), Group("Texture"), Range(100f, 1000f)]
public float TerrainEdgeRadius { get; set; } = 500f;
[Property, Feature("Terrain"), Group("Texture")]
public TerrainTextureLayer TerrainTargetLayer { get; set; } = TerrainTextureLayer.Overlay;
[Property, Feature("Terrain"), Group("Texture"), Range(0f, 1f)]
public float TerrainTextureNoise { get; set; } = 0.2f;
[Property, Feature("Terrain"), Group("Texture")]
public TerrainMaterial[] TerrainEdgeMaterials { get; set; } = Array.Empty<TerrainMaterial>();
[Property, Feature("Terrain"), Group("Texture")]
public Gradient TerrainEdgeBlendGradient = new Gradient(
new Gradient.ColorFrame(0, Color.White),
new Gradient.ColorFrame(1, Color.White.WithAlpha(0f))
);
[Button("Apply to the Ground"), Feature("Terrain")]
private void ApplyTerrainToGround()
{
if (!Scene.IsEditor)
return;
AdaptTerrainToIntersection();
}
public void AdaptTerrainToIntersection()
{
if (!TerrainTarget.IsValid())
{
// Always take the closest terrain
TerrainTarget = Scene.GetAllComponents<Terrain>().OrderBy(x => x.WorldPosition.DistanceSquared(WorldPosition)).FirstOrDefault();
}
if (!TerrainTarget.IsValid())
{
Log.Warning("RoadTool: No Terrain found in scene.");
return;
}
var storage = TerrainTarget.Storage;
if (storage == null || storage.HeightMap == null) return;
// 1. Setup Parameters
int resolution = storage.Resolution;
float terrainSize = storage.TerrainSize;
float terrainMaxHeight = storage.TerrainHeight;
float halfSize = terrainSize * 0.5f;
// Calculate bounds including falloff
float boundSize = (Shape == IntersectionShape.Rectangle ? Math.Max(Width, Length) * 0.5f : Radius) + TerrainFalloffRadius;
BBox worldBounds = new BBox(WorldPosition - new Vector3(boundSize), WorldPosition + new Vector3(boundSize));
var heightMap = storage.HeightMap;
// Capture initial state for Undo
bool hasModified = false;
// Initialize buffers for height calculation
var updatedHeights = new float[heightMap.Length];
var bestDistance = new float[heightMap.Length];
for (int i = 0; i < heightMap.Length; i++)
{
// Decode: Map [0..1] ushort to [0 .. MaxHeight] to match RoadComponent
updatedHeights[i] = (heightMap[i] / (float)ushort.MaxValue) * terrainMaxHeight;
bestDistance[i] = float.MaxValue;
}
BuildRectangleExitCorridors();
// 2. Grid Traversal
for (int ix = 0; ix < resolution; ix++)
{
for (int iy = 0; iy < resolution; iy++)
{
// 1. Adaptive coordinate detection (Center vs Corner) matching RoadComponent
float nodeLocalX_corner = (ix / (float)(resolution - 1)) * terrainSize;
float nodeLocalY_corner = (iy / (float)(resolution - 1)) * terrainSize;
float nodeLocalX = nodeLocalX_corner;
float nodeLocalY = nodeLocalY_corner;
// Check if the intersection is in the centered range
var checkPos = TerrainTarget.Transform.World.PointToLocal(WorldPosition);
if (checkPos.x < 0f || checkPos.x > terrainSize || checkPos.y < 0f || checkPos.y > terrainSize)
{
nodeLocalX = nodeLocalX_corner - halfSize;
nodeLocalY = nodeLocalY_corner - halfSize;
}
Vector3 pixelWorldPos = TerrainTarget.Transform.World.PointToWorld(new Vector3(nodeLocalX, nodeLocalY, 0));
if (!worldBounds.Contains(pixelWorldPos)) continue;
int index = iy * resolution + ix;
// 2. Distance to intersection shape
Vector3 relativePos = WorldTransform.PointToLocal(pixelWorldPos);
float distance = GetDistanceToIntersectionShape(relativePos.WithZ(0));
if (distance > TerrainFalloffRadius) continue;
// 3. Target height matching RoadComponent (0 to MaxHeight range)
Vector3 intersectionLocalPos = TerrainTarget.Transform.World.PointToLocal(WorldPosition);
float roadSurfaceHeight = Math.Clamp(intersectionLocalPos.z + TerrainHeightOffset, 0f, terrainMaxHeight);
float roadInsetHeight = Math.Clamp(roadSurfaceHeight - TerrainRoadInset, 0f, terrainMaxHeight);
float currentPixelHeight = (heightMap[index] / (float)ushort.MaxValue) * terrainMaxHeight;
float candidateHeight;
if (distance <= 0) // Inside the intersection — sink terrain below road to prevent Z-fighting
{
candidateHeight = roadInsetHeight;
}
else if (SidewalkWidth > 0f && distance <= SidewalkWidth) // Sidewalk ring — flush with road surface
{
candidateHeight = roadSurfaceHeight;
}
else // Falloff — blend from surface/inset back to original terrain
{
float transitionStart = SidewalkWidth > 0f ? SidewalkWidth : 0f;
float transitionBaseHeight = SidewalkWidth > 0f ? roadSurfaceHeight : roadInsetHeight;
float t = Math.Clamp((distance - transitionStart) / TerrainFalloffRadius, 0f, 1f);
float smoothT = t * t * (3f - 2f * t);
candidateHeight = MathX.Lerp(transitionBaseHeight, currentPixelHeight, smoothT);
}
if (distance < bestDistance[index])
{
bestDistance[index] = distance;
updatedHeights[index] = candidateHeight;
hasModified = true;
}
}
}
if (hasModified)
{
// 4. Final encoding to ushort (Mapping back to 0..1 without the 0.5 offset)
for (int i = 0; i < heightMap.Length; i++)
{
heightMap[i] = (ushort)MathF.Round(Math.Clamp(updatedHeights[i], 0f, terrainMaxHeight) / terrainMaxHeight * ushort.MaxValue);
}
storage.HeightMap = heightMap;
storage.StateHasChanged();
TerrainTarget.Create();
}
}
public void PaintTerrainToIntersection()
{
if (!TerrainTarget.IsValid() || TerrainEdgeMaterials == null || TerrainEdgeMaterials.Length == 0) return;
var storage = TerrainTarget.Storage;
if (storage == null || storage.ControlMap == null) return;
int resolution = storage.Resolution;
float terrainSize = storage.TerrainSize;
float halfSize = terrainSize * 0.5f;
// Identify all material indices in the terrain storage
bool materialsAdded = false;
var materialIndices = new int[TerrainEdgeMaterials.Length];
for (int m = 0; m < TerrainEdgeMaterials.Length; m++)
{
if (TerrainEdgeMaterials[m] == null) continue;
int idx = storage.Materials.IndexOf(TerrainEdgeMaterials[m]);
if (idx == -1)
{
storage.Materials.Add(TerrainEdgeMaterials[m]);
idx = storage.Materials.Count - 1;
materialsAdded = true;
}
if (idx > 31)
{
Log.Error($"RoadTool: Terrain has too many materials ({idx}). Material '{TerrainEdgeMaterials[m].ResourceName}' cannot be painted.");
idx = 0;
}
materialIndices[m] = idx;
}
if (materialsAdded)
{
storage.StateHasChanged();
TerrainTarget.Create();
}
float boundSize = (Shape == IntersectionShape.Rectangle ? Math.Max(Width, Length) * 0.5f : Radius) + TerrainEdgeRadius; // This line is unchanged
BBox worldBounds = new BBox(WorldPosition - new Vector3(boundSize), WorldPosition + new Vector3(boundSize)); // This line is unchanged
BuildRectangleExitCorridors();
var controlMap = storage.ControlMap;
bool hasModified = false;
for (int ix = 0; ix < resolution; ix++)
{
for (int iy = 0; iy < resolution; iy++)
{
float nodeLocalX = (ix / (float)(resolution - 1)) * terrainSize;
float nodeLocalY = (iy / (float)(resolution - 1)) * terrainSize;
var checkPos = TerrainTarget.Transform.World.PointToLocal(WorldPosition);
if (checkPos.x < 0f || checkPos.x > terrainSize || checkPos.y < 0f || checkPos.y > terrainSize)
{
nodeLocalX -= halfSize;
nodeLocalY -= halfSize;
}
Vector3 pixelWorldPos = TerrainTarget.Transform.World.PointToWorld(new Vector3(nodeLocalX, nodeLocalY, 0));
if (!worldBounds.Contains(pixelWorldPos)) continue;
Vector3 relativePos = WorldTransform.PointToLocal(pixelWorldPos);
float distance = GetDistanceToIntersectionShape(relativePos.WithZ(0));
if (distance > TerrainEdgeRadius) continue;
int index = iy * resolution + ix;
float t = Math.Clamp(distance / TerrainEdgeRadius, 0f, 1f);
float blendStrength = TerrainEdgeBlendGradient.Evaluate(t).a;
if (blendStrength > 0.01f)
{
// Add deterministic noise to blend textures together (Dithering)
float pixelNoise = ((float)((index * 1103515245 + 12345) & 0x7FFFFFFF) / 0x7FFFFFFF) * TerrainTextureNoise - (TerrainTextureNoise * 0.5f);
float noisyT = Math.Clamp(t + pixelNoise, 0f, 1f);
float noisyDistance = distance + (pixelNoise * TerrainEdgeRadius);
int materialIndex;
if (noisyDistance <= 0)
{
materialIndex = materialIndices[0];
}
else
{
int edgeMatCount = materialIndices.Length - 1;
// Using noisyT for index selection
int edgeIdx = edgeMatCount > 0 ? Math.Clamp((int)(noisyT * edgeMatCount), 0, edgeMatCount - 1) + 1 : 0;
materialIndex = materialIndices[edgeIdx];
}
uint packed = controlMap[index];
var mat = new CompactTerrainMaterial(packed);
if (TerrainTargetLayer == TerrainTextureLayer.Base)
{
mat.BaseTextureId = (byte)materialIndex;
mat.BlendFactor = (byte)MathX.Lerp(mat.BlendFactor, 0, blendStrength);
}
else
{
// Otherwise, we place it in Overlay and increase the BlendFactor to display it
mat.OverlayTextureId = (byte)materialIndex;
mat.BlendFactor = (byte)MathX.Lerp(mat.BlendFactor, 255, blendStrength);
}
controlMap[index] = mat.Packed;
hasModified = true;
}
}
}
if (hasModified)
{
storage.ControlMap = controlMap;
storage.StateHasChanged();
TerrainTarget.SyncGPUTexture();
}
}
// Per-opening exit corridors in local space, rebuilt once per flatten so the per-pixel distance test stays cheap.
// Each entry is an opening's road-edge centre, its outward direction, its lateral direction, and half its width.
private (Vector3 Center, Vector3 Outward, Vector3 Lateral, float Half)[] m_RectangleExitCorridors = Array.Empty<(Vector3, Vector3, Vector3, float)>();
private void BuildRectangleExitCorridors()
{
if (Shape != IntersectionShape.Rectangle)
{
m_RectangleExitCorridors = Array.Empty<(Vector3, Vector3, Vector3, float)>();
return;
}
EnsureRectangleExits();
m_RectangleExitCorridors = Exits
.Where(exit => exit != null)
.Select(exit =>
{
Transform t = GetRectangleExitLocalTransform(exit.Side, false, exit.Offset);
return (t.Position, t.Rotation.Forward, t.Rotation.Right, exit.Width * 0.5f);
})
.ToArray();
}
private float GetDistanceToIntersectionShape(Vector3 localPixelPos)
{
if (Shape == IntersectionShape.Rectangle)
{
float hl = Length * 0.5f;
float hw = Width * 0.5f;
float dx = MathF.Max(MathF.Abs(localPixelPos.x) - hl, 0);
float dy = MathF.Max(MathF.Abs(localPixelPos.y) - hw, 0);
float dist = MathF.Sqrt(dx * dx + dy * dy);
// Treat each open exit's corridor as inside, so terrain doesn't poke up through a road opening. A corridor is
// the band beyond an opening's road edge (outward) and within that opening's width (lateral) — one per opening.
if (dist > 0)
{
foreach (var corridor in m_RectangleExitCorridors)
{
Vector3 toPixel = localPixelPos - corridor.Center;
if (Vector3.Dot(toPixel, corridor.Outward) >= 0.0f && MathF.Abs(Vector3.Dot(toPixel, corridor.Lateral)) <= corridor.Half)
return 0;
}
}
return dist;
}
// Circle
float radDist = MathF.Max(localPixelPos.WithZ(0).Length - Radius, 0);
if (radDist > 0 && CircleExits != null && CircleExits.Length > 0)
{
Vector3 pixelDir = localPixelPos.WithZ(0);
if (pixelDir.LengthSquared > 0.0001f)
pixelDir = pixelDir.Normal;
foreach (var exit in CircleExits)
{
// Use dot product to stay independent of angle conventions
Vector3 exitDir = Rotation.FromYaw(exit.AngleDegrees).Forward;
float cosHalfAngle = MathF.Cos(MathF.Atan(exit.RoadWidth / Radius));
if (Vector3.Dot(pixelDir, exitDir) >= cosHalfAngle)
return 0;
}
}
return radDist;
}
}
Game
library
using System;
using System.Collections.Generic;
using Sandbox;
namespace RedSnail.RoadTool;
public partial class RoadIntersectionComponent
{
/// <summary>
/// A single drivable exit of an intersection, expressed in world space.
/// <see cref="Transform"/>.Forward points outward (away from the intersection), matching the snap targets.
/// </summary>
public readonly struct TrafficExit
{
public Transform Transform { get; init; }
public float RoadWidth { get; init; }
/// <summary>Pedestrians get no crossing over this arm's mouth. Vehicles are unaffected.</summary>
public bool NoCrossing { get; init; }
}
/// <summary>
/// When enabled, this intersection is ignored by the traffic system: vehicles will not route through it.
/// </summary>
[Property, Feature("General"), Category("Traffic"), Order(2)] public bool ExcludeTraffic { get; set; } = false;
/// <summary>Speed limit for traffic crossing this intersection, in km/h.</summary>
[Property, Feature("General"), Category("Traffic"), Order(2), Range(5.0f, 130.0f)] public float SpeedLimit { get; set; } = 30.0f;
/// <summary>
/// Enumerates every active exit of this intersection (rectangle or circle) as a world transform plus road width.
/// These are the same outer-edge positions that <see cref="SnapNearbyRoads"/> snaps roads to, so the traffic
/// graph can match road endpoints against them by proximity.
/// </summary>
public List<TrafficExit> GetTrafficExits()
{
var exits = new List<TrafficExit>();
if (Shape == IntersectionShape.Rectangle)
{
EnsureRectangleExits();
foreach (var exit in Exits)
{
if (exit is null)
continue;
exits.Add(new TrafficExit
{
Transform = GetRectangleExitTransform(exit.Side, true, exit.Offset),
RoadWidth = exit.Width,
NoCrossing = exit.NoCrossing
});
}
}
else
{
var circleExits = CircleExits ?? Array.Empty<CircleExit>();
for (int i = 0; i < circleExits.Length; i++)
{
exits.Add(new TrafficExit
{
Transform = GetCircleExitTransform(i, true),
RoadWidth = circleExits[i].RoadWidth,
NoCrossing = circleExits[i].NoCrossing
});
}
}
return exits;
}
}
Game
library
using System;
namespace RedSnail.RoadTool;
public partial class RoadIntersectionComponent
{
// Computes a quadratic Bezier control point at the intersection of the two tangent lines.
// Returns true if the lines intersect, false if parallel (in which case the midpoint is used as a fallback).
// The control distance along _StartTan is clamped to the chord length so asymmetric tangents (e.g. an
// off-grid exit angle whose disc tangent points well past the outer corner) don't drive the bezier past
// the outer endpoint — overshoot produces samples beyond the endpoint and flips downstream triangle winding.
private static bool TryBezierControl(Vector3 _Start, Vector3 _StartTan, Vector3 _End, Vector3 _EndTan, out Vector3 _Control)
{
float det = _StartTan.x * _EndTan.y - _EndTan.x * _StartTan.y;
if (MathF.Abs(det) < 0.0001f)
{
_Control = (_Start + _End) * 0.5f;
return false;
}
Vector3 d = _End - _Start;
float r = (d.x * _EndTan.y - _EndTan.x * d.y) / det;
float rMax = d.Length;
float rClamped = Math.Clamp(r, 0.0f, rMax);
_Control = _Start + rClamped * _StartTan;
return true;
}
private static Vector3 SampleQuadBezier(Vector3 _B0, Vector3 _B1, Vector3 _B2, float _T)
{
float u = 1.0f - _T;
return u * u * _B0 + 2.0f * u * _T * _B1 + _T * _T * _B2;
}
}
Game
library
using System;
using System.Collections.Generic;
using Sandbox;
namespace RedSnail.RoadTool;
/// <summary>
/// Routing queries over the lane graph — how far it actually is to drive from one place to another.
///
/// "Actually" is the point: straight-line distance is useless for anything that has to obey roads. A delivery
/// two blocks away as the crow flies can be a mile of one-way streets, and a job that times you on crow-flight
/// distance is unwinnable in exactly the places that are most interesting to drive.
/// </summary>
public sealed partial class RoadTrafficGraph
{
/// <summary>
/// How far either end of a route may be from a lane and still count as being on it. A road carries a lane
/// per direction, so a single point sits near several — see <see cref="FindNearbyLanes"/> for why taking
/// them all matters.
/// </summary>
public const float DefaultRouteSnapRadius = 1000.0f;
/// <summary>
/// Driving distance from <paramref name="_From"/> to <paramref name="_To"/>, following lanes in their legal
/// direction. False when neither end is anywhere near a road, or when no route exists at all — a one-way
/// system can genuinely have no way round.
///
/// Dijkstra over whole lanes rather than individual waypoints: lanes are the unit the graph is linked in,
/// and a city's worth of them is a few thousand nodes. Costs are measured to the START of each lane, with
/// the partial lengths at both ends added on, so the answer is measured between the actual points.
/// </summary>
public bool TryGetDrivingDistance(Vector3 _From, Vector3 _To, out float _Distance)
{
_Distance = 0.0f;
List<(TrafficLane Lane, int Index)> starts = FindNearbyLanes(_From);
List<(TrafficLane Lane, int Index)> goals = FindNearbyLanes(_To);
if (starts.Count == 0 || goals.Count == 0)
return false;
var goalIndices = new Dictionary<TrafficLane, int>();
foreach ((TrafficLane lane, int index) in goals)
goalIndices[lane] = index;
var best = new Dictionary<TrafficLane, float>();
var queue = new PriorityQueue<TrafficLane, float>();
float bestTotal = float.MaxValue;
foreach ((TrafficLane lane, int index) in starts)
{
// Goal on the same lane and ahead of us: straight down the road, no junction involved. Behind us
// doesn't count — a lane is one-way, so that really does mean driving round and coming back, which
// the search below works out properly.
if (goalIndices.TryGetValue(lane, out int goalIndex) && goalIndex >= index)
bestTotal = Math.Min(bestTotal, lane.DistanceFromStart(goalIndex) - lane.DistanceFromStart(index));
// We start partway along, so what's reachable is the successors, at the cost of finishing this lane.
float toEnd = lane.DistanceToEnd(index);
foreach (TrafficLane next in lane.Successors)
Relax(next, toEnd, best, queue);
}
while (queue.TryDequeue(out TrafficLane lane, out float cost))
{
// Min-ordered, so once the cheapest thing left already costs more than an answer we have, nothing
// better can come out of it.
if (cost >= bestTotal)
break;
if (best.TryGetValue(lane, out float known) && cost > known)
continue;
// Reaching a goal lane doesn't end the search: another route might arrive at a different goal
// candidate — the other side of the same street, say — for less.
if (goalIndices.TryGetValue(lane, out int goalIndex))
bestTotal = Math.Min(bestTotal, cost + lane.DistanceFromStart(goalIndex));
float exit = cost + lane.Length;
foreach (TrafficLane next in lane.Successors)
Relax(next, exit, best, queue);
}
if (bestTotal >= float.MaxValue)
return false;
_Distance = Math.Max(0.0f, bestTotal);
return true;
}
/// <summary>
/// The actual waypoints to DRIVE from one point to another, following lanes in their legal direction. False
/// when there's no route, same as <see cref="TryGetDrivingDistance"/>.
///
/// This is the vehicle counterpart of a navmesh path, and it has to be a separate structure rather than the
/// navmesh itself. A navmesh is baked around a person — it runs over pavements, through doorways and up
/// stairs, and its corridors are person-wide. A route down one is a perfectly valid walk and an impossible
/// drive, and unlike a pedestrian scraping a wall, a car routed somewhere it doesn't fit is stuck for good.
/// Lanes are the drivable surface by construction, and they carry direction, which a navmesh has no concept
/// of at all.
///
/// The list is reused rather than returned, because anything chasing anything re-routes constantly.
/// </summary>
public bool TryGetDrivingRoute(Vector3 _From, Vector3 _To, List<Vector3> _Route)
{
_Route.Clear();
if (!TrySearchRoute(_From, _To, out RouteSearch search))
return false;
BuildRoute(search.Starts, search.GoalIndices, search.CameFrom, search.Goal, search.Start, search.StartIndex, _Route);
return _Route.Count > 0;
}
/// <summary>
/// The same route as <see cref="TryGetDrivingRoute"/>, but as the LANES to drive rather than the waypoints
/// along them — including the one we're starting on.
///
/// This is the form anything that already knows how to drive a lane wants. <see cref="TrafficVehicle"/>
/// follows lanes and picks a successor at every junction; handing it a lane list turns "wander" into "go
/// here" without touching a single line of how it actually steers, brakes or corners.
/// </summary>
public bool TryGetDrivingLaneRoute(Vector3 _From, Vector3 _To, List<TrafficLane> _Route)
{
return TryGetDrivingLaneRoute(FindNearbyLanes(_From), _To, _Route);
}
/// <summary>
/// The same, but starting from the lane a vehicle is ALREADY DRIVING rather than from its position.
///
/// This is the overload anything mid-journey wants, and the difference is not subtle. Asking by position
/// seeds the search with every lane in range — including the one going the other way down the same road —
/// and Dijkstra will happily return the shortest route from whichever of those is cheapest. That route
/// starts on a lane the vehicle is not on, so it never matches, and a driver that can't find itself in its
/// own route falls back to picking turns at random. The symptom is a car that mostly goes the right way and
/// occasionally sets off round the block for no visible reason.
/// </summary>
public bool TryGetDrivingLaneRoute(TrafficLane _FromLane, Vector3 _FromPosition, Vector3 _To, List<TrafficLane> _Route)
{
_Route.Clear();
if (_FromLane is null || _FromLane.Waypoints.Count == 0)
return false;
return TryGetDrivingLaneRoute([(_FromLane, NearestWaypointIndex(_FromLane, _FromPosition))], _To, _Route);
}
/// <summary>Which waypoint of a lane is closest to a point.</summary>
private static int NearestWaypointIndex(TrafficLane _Lane, Vector3 _Point)
{
int best = 0;
float bestDistance = float.MaxValue;
for (int i = 0; i < _Lane.Waypoints.Count; i++)
{
float distance = _Lane.Waypoints[i].DistanceSquared(_Point);
if (distance >= bestDistance)
continue;
bestDistance = distance;
best = i;
}
return best;
}
private bool TryGetDrivingLaneRoute(List<(TrafficLane Lane, int Index)> _Starts, Vector3 _To, List<TrafficLane> _Route)
{
_Route.Clear();
if (!TrySearchRoute(_Starts, _To, out RouteSearch search))
return false;
// Never left the start lane — the route is just that one.
if (search.Goal is null)
{
if (search.Start is null)
return false;
_Route.Add(search.Start);
return true;
}
List<TrafficLane> chain = BuildLaneChain(search.CameFrom, search.Goal);
if (chain.Count == 0)
return false;
// The lane we're ON isn't in the chain (the chain begins at one of its successors), and a driver already
// travelling it needs to see it in the list or its very first junction is an unplanned one.
foreach ((TrafficLane lane, int _) in search.Starts)
{
if (!lane.Successors.Contains(chain[0]))
continue;
_Route.Add(lane);
break;
}
_Route.AddRange(chain);
return true;
}
/// <summary>What a completed search found: the winning route's ends, and the map to walk it back with.</summary>
private struct RouteSearch
{
public List<(TrafficLane Lane, int Index)> Starts;
public Dictionary<TrafficLane, int> GoalIndices;
public Dictionary<TrafficLane, TrafficLane> CameFrom;
/// <summary>The lane the route ends on, or null when it never left the lane it started on.</summary>
public TrafficLane Goal;
public TrafficLane Start;
public int StartIndex;
}
/// <summary>
/// The Dijkstra itself, shared by both route shapes so there's one search to be correct rather than two to
/// keep in step.
/// </summary>
private bool TrySearchRoute(Vector3 _From, Vector3 _To, out RouteSearch _Result)
{
return TrySearchRoute(FindNearbyLanes(_From), _To, out _Result);
}
/// <inheritdoc cref="TrySearchRoute(Vector3, Vector3, out RouteSearch)"/>
private bool TrySearchRoute(List<(TrafficLane Lane, int Index)> _Starts, Vector3 _To, out RouteSearch _Result)
{
_Result = default;
List<(TrafficLane Lane, int Index)> starts = _Starts;
List<(TrafficLane Lane, int Index)> goals = FindNearbyLanes(_To);
if (starts is null || starts.Count == 0 || goals.Count == 0)
return false;
var goalIndices = new Dictionary<TrafficLane, int>();
foreach ((TrafficLane lane, int index) in goals)
goalIndices[lane] = index;
var best = new Dictionary<TrafficLane, float>();
var cameFrom = new Dictionary<TrafficLane, TrafficLane>();
var queue = new PriorityQueue<TrafficLane, float>();
// The winning route so far: where it ends, and which of the several starts it began at.
float bestTotal = float.MaxValue;
TrafficLane bestGoal = null;
TrafficLane bestStart = null;
int bestStartIndex = 0;
foreach ((TrafficLane lane, int index) in starts)
{
// Goal on the same lane and ahead of us — no junction involved, so the route is just this stretch.
if (goalIndices.TryGetValue(lane, out int sameLaneGoal) && sameLaneGoal >= index)
{
float direct = lane.DistanceFromStart(sameLaneGoal) - lane.DistanceFromStart(index);
if (direct < bestTotal)
{
bestTotal = direct;
bestGoal = null; // null goal marks "never left the start lane"
bestStart = lane;
bestStartIndex = index;
}
}
float toEnd = lane.DistanceToEnd(index);
foreach (TrafficLane next in lane.Successors)
{
if (Relax(next, toEnd, best, queue))
cameFrom[next] = lane;
}
}
while (queue.TryDequeue(out TrafficLane lane, out float cost))
{
if (cost >= bestTotal)
break;
if (best.TryGetValue(lane, out float known) && cost > known)
continue;
if (goalIndices.TryGetValue(lane, out int goalIndex))
{
float total = cost + lane.DistanceFromStart(goalIndex);
if (total < bestTotal)
{
bestTotal = total;
bestGoal = lane;
}
}
float exit = cost + lane.Length;
foreach (TrafficLane next in lane.Successors)
{
if (Relax(next, exit, best, queue))
cameFrom[next] = lane;
}
}
if (bestTotal >= float.MaxValue)
return false;
_Result = new RouteSearch
{
Starts = starts,
GoalIndices = goalIndices,
CameFrom = cameFrom,
Goal = bestGoal,
Start = bestStart,
StartIndex = bestStartIndex
};
return true;
}
/// <summary>
/// Walks the predecessor chain back from the winning goal lane to whichever start it came from, then lays
/// the waypoints down in travel order.
///
/// The two ends are partial lanes — we join partway along the first and stop partway along the last — which
/// is why they're handled separately from the whole lanes in between.
/// </summary>
private void BuildRoute(List<(TrafficLane Lane, int Index)> _Starts, Dictionary<TrafficLane, int> _GoalIndices,
Dictionary<TrafficLane, TrafficLane> _CameFrom, TrafficLane _Goal,
TrafficLane _Start, int _StartIndex, List<Vector3> _Route)
{
// Never left the start lane: one straight run down it.
if (_Goal is null)
{
if (_Start is null || !_GoalIndices.TryGetValue(_Start, out int stop))
return;
for (int i = _StartIndex; i <= stop; i++)
_Route.Add(_Start.Waypoints[i]);
return;
}
List<TrafficLane> chain = BuildLaneChain(_CameFrom, _Goal);
if (chain.Count == 0)
return;
// The first lane in the chain is a successor of the start lane, so the start lane itself isn't in it —
// find which of the candidates fed it and lay down the tail of that one first.
TrafficLane head = chain[0];
foreach ((TrafficLane lane, int index) in _Starts)
{
if (!lane.Successors.Contains(head))
continue;
for (int i = index; i < lane.Waypoints.Count; i++)
_Route.Add(lane.Waypoints[i]);
break;
}
for (int c = 0; c < chain.Count; c++)
{
TrafficLane lane = chain[c];
// The last one stops at the goal waypoint rather than running to the end of the road.
int stop = c == chain.Count - 1 && _GoalIndices.TryGetValue(lane, out int goalIndex)
? goalIndex
: lane.Waypoints.Count - 1;
for (int i = 0; i <= stop; i++)
_Route.Add(lane.Waypoints[i]);
}
}
/// <summary>Walks the predecessor map back from a goal lane and returns the chain in travel order.</summary>
private List<TrafficLane> BuildLaneChain(Dictionary<TrafficLane, TrafficLane> _CameFrom, TrafficLane _Goal)
{
var chain = new List<TrafficLane>();
TrafficLane current = _Goal;
// Bounded by the lane count so a cycle in the map can't spin forever.
for (int step = 0; step <= Lanes.Count && current is not null; step++)
{
chain.Add(current);
if (!_CameFrom.TryGetValue(current, out TrafficLane previous))
break;
current = previous;
}
chain.Reverse();
return chain;
}
/// <summary>True when this was an improvement, so the caller knows whether to record the predecessor.</summary>
private static bool Relax(TrafficLane _Lane, float _Cost, Dictionary<TrafficLane, float> _Best, PriorityQueue<TrafficLane, float> _Queue)
{
if (_Best.TryGetValue(_Lane, out float existing) && existing <= _Cost)
return false;
_Best[_Lane] = _Cost;
_Queue.Enqueue(_Lane, _Cost);
return true;
}
/// <summary>
/// Every drivable lane with a waypoint within <paramref name="_Radius"/> of the point, and which waypoint
/// that was — at most one entry per lane.
///
/// Taking ALL of them, rather than just the closest, is what makes routing reliable. A road carries a lane
/// per direction, so any point on it is near at least two; picking only the nearest is a coin flip that can
/// land on the one pointing away from where you're going, or on one nothing feeds into. The route then comes
/// back as impossible even though the lane a few metres over is trivially routable. Seeding the search with
/// every candidate — and accepting any of them at the far end — also gets the natural answer for free:
/// either side of the street will do, whichever is closer to drive.
///
/// Road lanes only. Snapping an endpoint onto an intersection cross-lane would measure from the middle of
/// a junction.
/// </summary>
public List<(TrafficLane Lane, int Index)> FindNearbyLanes(Vector3 _Point, float _Radius = DefaultRouteSnapRadius)
{
var results = new List<(TrafficLane, int)>();
TrafficLane nearestLane = null;
int nearestIndex = 0;
float nearestDistance = float.MaxValue;
float radiusSquared = _Radius * _Radius;
foreach (TrafficLane lane in Lanes)
{
if (!lane.IsRoadLane)
continue;
int laneIndex = -1;
float laneDistance = float.MaxValue;
for (int i = 0; i < lane.Waypoints.Count; i++)
{
float distance = lane.Waypoints[i].DistanceSquared(_Point);
if (distance >= laneDistance)
continue;
laneDistance = distance;
laneIndex = i;
}
if (laneIndex < 0)
continue;
if (laneDistance < nearestDistance)
{
nearestDistance = laneDistance;
nearestLane = lane;
nearestIndex = laneIndex;
}
if (laneDistance <= radiusSquared)
results.Add((lane, laneIndex));
}
// Off-road entirely (a car park, a field) — the closest lane is still the honest answer, so don't come
// back empty and turn a long route into "no route".
if (results.Count == 0 && nearestLane is not null)
results.Add((nearestLane, nearestIndex));
return results;
}
/// <summary>
/// The single drivable lane closest to a point, and which waypoint that was. Prefer
/// <see cref="FindNearbyLanes"/> for routing — one lane is rarely the whole answer for a two-way road.
/// </summary>
public TrafficLane FindNearestLane(Vector3 _Point, out int _Index)
{
TrafficLane bestLane = null;
float bestDistance = float.MaxValue;
_Index = 0;
foreach (TrafficLane lane in Lanes)
{
if (!lane.IsRoadLane)
continue;
for (int i = 0; i < lane.Waypoints.Count; i++)
{
float distance = lane.Waypoints[i].DistanceSquared(_Point);
if (distance >= bestDistance)
continue;
bestDistance = distance;
bestLane = lane;
_Index = i;
}
}
return bestLane;
}
}
Game
library
using System;
using Sandbox;
namespace RedSnail.RoadTool;
/// <summary>
/// A primitive control surface the traffic AI (<see cref="TrafficVehicle"/>) uses to drive ONE vehicle. It's a plain
/// bag of delegates — there is no interface for a vehicle controller to implement, and your vehicle code never needs
/// to reference this library.
///
/// The seam is filled in by whoever uses both this tool AND a vehicle controller — i.e. your GAME — via
/// <see cref="RoadManager.ResolveVehicleDriver"/>. The game maps whatever its controller looks like onto these few
/// delegates. Any field left null is simply skipped. The demo wires <see cref="DemoCarController"/> automatically.
/// </summary>
public sealed class RoadVehicleDriver
{
/// <summary>True while a player is at the wheel — the AI then hands this car over for good and never reclaims it.</summary>
public Func<bool> IsPlayerDriving;
/// <summary>The vehicle body's world velocity. The brain reads it to chase a target speed and to detect being jammed.</summary>
public Func<Vector3> Velocity;
/// <summary>Tell the controller whether the AI is currently driving this vehicle (vs parked / player-driven). Pushed every frame.</summary>
public Action<bool> SetAiControlled;
/// <summary>Push the AI's per-frame inputs: throttle and steer in [-1, 1] (steer + = left), plus handbrake.</summary>
public Action<float, float, bool> Drive;
/// <summary>Optional: max steering angle in degrees, used to widen the entity look-ahead toward where the car is turning.</summary>
public Func<float> MaxSteering;
}
Editor
library
using Sandbox;
namespace RedSnail.RoadTool.Editor;
public partial class RoadToolWindow
{
private const float GIZMO_BOX_SIZE = 2.0f;
private const float LINE_THICKNESS = 2.0f;
private const float TANGENT_LINE_THICKNESS = 0.8f;
private void DrawGizmos()
{
using (Gizmo.Scope("road_editor", _targetComponent.WorldTransform))
{
DrawSplineSegments();
DrawPositionGizmo();
DrawPointControls();
}
}
private void DrawSplineSegments()
{
_targetComponent.Spline.ConvertToPolyline(ref _polyLine);
for (var i = 0; i < _polyLine.Count - 1; i++)
{
DrawSegment(i, _polyLine[i], _polyLine[i + 1]);
}
}
private void DrawSegment(int index, Vector3 start, Vector3 end)
{
using (Gizmo.Scope("segment" + index))
using (Gizmo.Hitbox.LineScope())
{
Gizmo.Draw.LineThickness = LINE_THICKNESS;
Gizmo.Hitbox.AddPotentialLine(start, end, LINE_THICKNESS * 2f);
Gizmo.Draw.Line(start, end);
if (Gizmo.IsHovered && Gizmo.HasMouseFocus)
{
HandleSegmentHover(start, end);
}
}
}
private void HandleSegmentHover(Vector3 start, Vector3 end)
{
Gizmo.Draw.Color = Color.Cyan;
if (!new Line(start, end).ClosestPoint(Gizmo.CurrentRay.ToLocal(Gizmo.Transform),
out Vector3 pointOnLine, out _))
return;
var hoverSample = _targetComponent.Spline.SampleAtClosestPosition(pointOnLine);
DrawHoverHandle(pointOnLine, hoverSample.Tangent);
if (Gizmo.HasClicked && Gizmo.Pressed.This)
{
InsertPointAtHover(hoverSample.Distance);
}
}
private void DrawHoverHandle(Vector3 position, Vector3 tangent)
{
using (Gizmo.Scope("hover_handle", new Transform(position, Rotation.LookAt(tangent))))
using (Gizmo.GizmoControls.PushFixedScale())
{
Gizmo.Draw.SolidBox(BBox.FromPositionAndSize(Vector3.Zero, GIZMO_BOX_SIZE));
}
}
private void InsertPointAtHover(float distance)
{
using (CreateUndoScope("Added spline point"))
{
var newPointIndex = _targetComponent.Spline.AddPointAtDistance(distance, true);
SelectedPointIndex = newPointIndex;
_inTangentSelected = false;
_outTangentSelected = false;
}
}
private void DrawPositionGizmo()
{
// The first point sits on the component origin, so its move handle lands right on top of the GameObject's
// own transform gizmo — two move gizmos fighting over the same spot. Hide the point-body handle there and
// let the object transform gizmo move the whole track instead. Tangent editing still gets its own gizmo.
if (SelectedPointIndex == 0 && !_inTangentSelected && !_outTangentSelected)
return;
var gizmoPosition = CalculateGizmoPosition();
if (!Gizmo.IsShiftPressed)
{
_draggingOutNewPoint = false;
}
using (Gizmo.Scope("position", new Transform(gizmoPosition)))
{
HandlePositionControl();
}
}
private Vector3 CalculateGizmoPosition()
{
var position = _selectedPoint.Position;
if (_inTangentSelected)
position += _selectedPoint.In;
else if (_outTangentSelected)
position += _selectedPoint.Out;
return position;
}
private void HandlePositionControl()
{
_moveInProgress = false;
if (Gizmo.Control.Position("spline_control_", Vector3.Zero, out var delta, GetHandleRotation()))
{
_moveInProgress = true;
_movementUndoScope ??= CreateUndoScope("Moved spline point");
if (_inTangentSelected)
MoveSelectedPointInTangent(delta);
else if (_outTangentSelected)
MoveSelectedPointOutTangent(delta);
else
HandlePointMove(delta);
}
if (!_moveInProgress && Gizmo.WasLeftMouseReleased)
{
_movementUndoScope?.Dispose();
_movementUndoScope = null;
}
}
/// <summary>
/// Orientation for the move gizmo's arrows, honouring the editor's Global/Local space toggle
/// (the same <see cref="Gizmo.Settings.GlobalSpace"/> button the object move tool uses).
/// Global keeps the arrows world-aligned; Local aligns them to the selected point's tangent frame
/// so a point can be dragged straight along the track. We are already inside the component-transform
/// scope, so returning the point's <em>local</em> frame is what places the arrows on the world tangent —
/// and <see cref="Gizmo.Control.Position"/> hands the delta back in component-local space, matching how
/// <see cref="MoveSelectedPoint"/> applies it.
/// </summary>
private Rotation GetHandleRotation()
{
if (Gizmo.Settings.GlobalSpace)
return Rotation.Identity;
if (!IsSelectedPointValid())
return Rotation.Identity;
var spline = _targetComponent.Spline;
var sample = spline.SampleAtDistance(spline.GetDistanceAtPoint(SelectedPointIndex));
if (sample.Tangent.IsNearlyZero())
return Rotation.Identity;
var up = Rotation.FromAxis(sample.Tangent, sample.Roll) * sample.Up;
return Rotation.LookAt(sample.Tangent, up);
}
private void HandlePointMove(Vector3 delta)
{
if (Gizmo.IsShiftPressed && !_draggingOutNewPoint)
{
_draggingOutNewPoint = true;
var currentPoint = _targetComponent.Spline.GetPoint(SelectedPointIndex);
_targetComponent.Spline.InsertPoint(SelectedPointIndex + 1, currentPoint);
SelectedPointIndex++;
}
else
{
MoveSelectedPoint(delta);
}
}
private void DrawPointControls()
{
var spline = _targetComponent.Spline;
for (var i = 0; i < spline.PointCount; i++)
{
if (spline.IsLoop && i == spline.SegmentCount)
continue;
var point = spline.GetPoint(i);
DrawPointControl(i, point);
}
}
private void DrawPointControl(int index, Spline.Point point)
{
using (Gizmo.Scope("point_controls" + index, new Transform(point.Position)))
{
Gizmo.Draw.IgnoreDepth = true;
DrawPointPositionHandle(index);
if (SelectedPointIndex == index)
{
DrawTangentHandles(point);
}
}
}
private void DrawPointPositionHandle(int index)
{
using (Gizmo.Scope("position"))
using (Gizmo.GizmoControls.PushFixedScale())
{
Gizmo.Hitbox.DepthBias = 0.1f;
Gizmo.Hitbox.BBox(BBox.FromPositionAndSize(Vector3.Zero, GIZMO_BOX_SIZE));
bool isSelected = index == SelectedPointIndex && !_inTangentSelected && !_outTangentSelected;
if (Gizmo.IsHovered || isSelected)
{
Gizmo.Draw.Color = Color.Cyan;
}
Gizmo.Draw.SolidBox(BBox.FromPositionAndSize(Vector3.Zero, GIZMO_BOX_SIZE));
if (Gizmo.HasClicked && Gizmo.Pressed.This)
{
SelectPoint(index);
}
}
}
private void DrawTangentHandles(Spline.Point point)
{
Gizmo.Draw.Color = Color.White;
Gizmo.Draw.LineThickness = TANGENT_LINE_THICKNESS;
DrawTangentHandle("in_tangent", point.In, -point.In, ref _inTangentSelected, ref _outTangentSelected);
DrawTangentHandle("out_tangent", point.Out, -point.Out, ref _outTangentSelected, ref _inTangentSelected);
}
private void DrawTangentHandle(string name, Vector3 offset, Vector3 lineStart, ref bool thisSelected, ref bool otherSelected)
{
using (Gizmo.Scope(name, new Transform(offset)))
{
bool isMirroredOrAuto = _selectedPointTangentMode is HandleModeTemp.Mirrored or HandleModeTemp.Auto;
if (isMirroredOrAuto && (thisSelected || otherSelected))
{
Gizmo.Draw.Color = Color.Cyan;
}
Gizmo.Draw.Line(lineStart, Vector3.Zero);
if (_selectedPointTangentMode != HandleModeTemp.Linear)
{
DrawTangentBox(ref thisSelected, ref otherSelected);
}
}
}
private void DrawTangentBox(ref bool thisSelected, ref bool otherSelected)
{
using (Gizmo.GizmoControls.PushFixedScale())
{
Gizmo.Hitbox.DepthBias = 0.1f;
Gizmo.Hitbox.BBox(BBox.FromPositionAndSize(Vector3.Zero, GIZMO_BOX_SIZE));
if (Gizmo.IsHovered || thisSelected)
{
Gizmo.Draw.Color = Color.Cyan;
}
Gizmo.Draw.SolidBox(BBox.FromPositionAndSize(Vector3.Zero, GIZMO_BOX_SIZE));
if (Gizmo.HasClicked && Gizmo.Pressed.This)
{
thisSelected = true;
otherSelected = false;
}
}
}
}
Editor
library
using Sandbox;
using Editor;
namespace RedSnail.RoadTool.Editor;
public partial class RoadToolWindow
{
private const int HEADER_HEIGHT = 32;
private void Rebuild()
{
Layout.Clear(true);
Layout.Margin = 0;
Icon = _isClosed ? "" : "route";
UpdateWindowTitle();
IsGrabbable = !_isClosed;
if (_isClosed)
{
BuildClosedState();
return;
}
MinimumWidth = 400;
BuildHeader();
if (_targetComponent.IsValid())
{
BuildControlSheet();
}
Layout.Margin = 4;
}
private void BuildClosedState()
{
var closedRow = Layout.AddRow();
closedRow.Add(new IconButton("route", () => { _isClosed = false; Rebuild(); })
{
ToolTip = "Open Spline Point Editor",
FixedHeight = HEADER_HEIGHT,
FixedWidth = HEADER_HEIGHT,
Background = Color.Transparent
});
MinimumWidth = 0;
}
private void BuildHeader()
{
var headerRow = Layout.AddRow();
headerRow.AddStretchCell();
headerRow.Add(new IconButton("info")
{
ToolTip = GetInfoTooltip(),
FixedHeight = HEADER_HEIGHT,
FixedWidth = HEADER_HEIGHT,
Background = Color.Transparent
});
headerRow.Add(new IconButton("close", CloseWindow)
{
ToolTip = "Close Editor",
FixedHeight = HEADER_HEIGHT,
FixedWidth = HEADER_HEIGHT,
Background = Color.Transparent
});
}
private string GetInfoTooltip()
{
return "Controls to edit the spline points.\n" +
"In addition to modifying the properties in the control sheet, you can also use the 3D Gizmos.\n" +
"Clicking on the spline between points will split the spline at that position.\n" +
"Holding shift while dragging a point's position will drag out a new point.";
}
private void BuildControlSheet()
{
var serialized = this.GetSerialized();
var controlSheet = new ControlSheet();
// Add property rows
controlSheet.AddRow(serialized.GetProperty(nameof(_selectedPointTangentMode)));
controlSheet.AddRow(serialized.GetProperty(nameof(_selectedPointPosition)));
_inTangentControl = controlSheet.AddRow(serialized.GetProperty(nameof(_selectedPointIn)));
_outTangentControl = controlSheet.AddRow(serialized.GetProperty(nameof(_selectedPointOut)));
// Add advanced group
var roll = serialized.GetProperty(nameof(_selectedPointRoll));
var scale = serialized.GetProperty(nameof(_selectedPointScale));
var up = serialized.GetProperty(nameof(_selectedPointUp));
controlSheet.AddGroup("Advanced", [roll, scale, up]);
// Add control buttons
controlSheet.AddLayout(BuildControlButtons());
Layout.Add(controlSheet);
ToggleTangentInput();
}
private Layout BuildControlButtons()
{
var row = Layout.Row();
row.Spacing = 16;
row.Margin = 8;
row.Add(CreateNavigationButton("skip_previous", -1, "Go to previous point"));
row.Add(CreateNavigationButton("skip_next", 1, "Go to next point"));
row.Add(CreateDeleteButton());
row.Add(CreateAddButton());
return row;
}
private IconButton CreateNavigationButton(string icon, int direction, string tooltip)
{
return new IconButton(icon, () =>
{
if (direction < 0)
SelectedPointIndex = int.Max(0, SelectedPointIndex - 1);
else
SelectedPointIndex = int.Min(_targetComponent.Spline.PointCount - 1, SelectedPointIndex + 1);
UpdateWindowTitle();
Focus();
})
{ ToolTip = tooltip };
}
private IconButton CreateDeleteButton()
{
return new IconButton("delete", () =>
{
using (CreateUndoScope("Delete Spline Point"))
{
_targetComponent.Spline.RemovePoint(SelectedPointIndex);
SelectedPointIndex = int.Max(0, SelectedPointIndex - 1);
}
UpdateWindowTitle();
Focus();
})
{ ToolTip = "Delete point" };
}
private IconButton CreateAddButton()
{
return new IconButton("add", () =>
{
using (CreateUndoScope("Added Spline Point"))
{
InsertNewPoint();
}
SelectedPointIndex++;
UpdateWindowTitle();
Focus();
})
{
ToolTip = "Insert point after current point.\n" +
"You can also hold shift while dragging a point to create a new point."
};
}
private void InsertNewPoint()
{
var spline = _targetComponent.Spline;
if (SelectedPointIndex == spline.PointCount - 1)
{
var distance = spline.GetDistanceAtPoint(SelectedPointIndex);
var tangent = spline.SampleAtDistance(distance).Tangent;
var newPosition = _selectedPoint.Position + tangent * 200;
spline.InsertPoint(SelectedPointIndex + 1, _selectedPoint with { Position = newPosition });
}
else
{
var currentDist = spline.GetDistanceAtPoint(SelectedPointIndex);
var nextDist = spline.GetDistanceAtPoint(SelectedPointIndex + 1);
var midDist = (currentDist + nextDist) / 2;
spline.AddPointAtDistance(midDist, true);
}
}
private void UpdateWindowTitle()
{
WindowTitle = _isClosed ? "" : $"Spline Point [{SelectedPointIndex}] Editor - {_targetComponent?.GameObject?.Name ?? ""}";
}
private void CloseWindow()
{
_isClosed = true;
Rebuild();
Position = Parent.Size - 32;
}
}
Game
library
using System;
using System.Linq;
using System.Collections.Generic;
using Sandbox;
namespace RedSnail.RoadTool;
public partial class RoadComponent
{
private bool m_DoesLamppostsNeedRebuild = false;
[Property, FeatureEnabled("Lampposts", Icon = "light_mode", Tint = EditorTint.Red), Change] private bool HasLampposts { get; set; } = false;
[Property, Feature("Lampposts")] public GameObject LamppostPrefab { get; set { field = value; m_DoesLamppostsNeedRebuild = true; } }
[Property, Feature("Lampposts"), Range(50.0f, 2000.0f)] private float LamppostSpacing { get; set { field = value.Clamp(10.0f, 100000.0f); m_DoesLamppostsNeedRebuild = true; } } = 50.0f;
[Property, Feature("Lampposts"), Range(-200.0f, 200.0f)] private float LamppostOffsetFromSidewalk { get; set { field = value; m_DoesLamppostsNeedRebuild = true; } } = 10.0f;
[Property, Feature("Lampposts"), Range(0.0f, 10.0f)] private float LamppostHeightOffset { get; set { field = value; m_DoesLamppostsNeedRebuild = true; } } = 0.0f;
[Property, Feature("Lampposts")] private LamppostSide LamppostPlacement { get; set { field = value; m_DoesLamppostsNeedRebuild = true; } } = LamppostSide.Both;
[Property, Feature("Lampposts")] private bool AlignToSplineRotation { get; set { field = value; m_DoesLamppostsNeedRebuild = true; } } = true;
[Property(Title = "Keep Vertical (World Up)"), Feature("Lampposts")] private bool KeepVertical { get; set { field = value; m_DoesLamppostsNeedRebuild = true; } } = true;
[Property, Feature("Lampposts"), Range(0.0f, 360.0f)] private float LamppostRotationOffset { get; set { field = value; m_DoesLamppostsNeedRebuild = true; } } = 0.0f;
[Property, Feature("Lampposts"), Range(0.0f, 100.0f)] private float StartOffset { get; set { field = value; m_DoesLamppostsNeedRebuild = true; } } = 0.0f;
[Property, Feature("Lampposts"), Range(0.0f, 100.0f)] private float EndOffset { get; set { field = value; m_DoesLamppostsNeedRebuild = true; } } = 0.0f;
public enum LamppostSide
{
Left,
Right,
Both,
Alternating
}
private void OnHasLamppostsChanged(bool _OldValue, bool _NewValue)
{
m_DoesLamppostsNeedRebuild = true;
}
private void CreateLampposts()
{
RemoveLampposts();
if (!HasLampposts || !LamppostPrefab.IsValid())
return;
BuildLampposts();
}
private void RemoveLampposts()
{
if (SandboxUtility.IsInPlayMode)
return;
GameObject containerObject = GameObject.Children.FirstOrDefault(x => x.Name == "Lampposts");
if (containerObject.IsValid())
{
containerObject.Destroy();
}
}
private void UpdateLampposts()
{
if (m_DoesLamppostsNeedRebuild)
{
CreateLampposts();
m_DoesLamppostsNeedRebuild = false;
}
}
private void BuildLampposts()
{
if (SandboxUtility.IsInPlayMode)
return;
GameObject containerObject = new GameObject(GameObject, true, "Lampposts");
containerObject.Tags.Add("road_props");
float splineLength = Spline.Length;
float effectiveLength = splineLength - StartOffset - EndOffset;
if (effectiveLength <= 0)
return;
GetSplineFrameData(out var frames, out var segmentsToKeep);
var simplifiedPositions = new List<(Transform _Frame, float _Distance)>();
foreach (int index in segmentsToKeep)
{
float t = (float)index / (frames.Length - 1);
float distance = t * splineLength;
simplifiedPositions.Add((frames[index], distance));
}
int lamppostCount = Math.Max(1, (int)MathF.Ceiling(effectiveLength / LamppostSpacing));
int frameCount = lamppostCount + 1;
float roadEdgeOffset = RoadWidth * 0.5f;
float sidewalkOffset = HasSidewalk ? SidewalkWidth : 0.0f;
float totalOffset = roadEdgeOffset + sidewalkOffset + LamppostOffsetFromSidewalk;
for (int i = 0; i < frameCount; i++)
{
float t = (float)i / (frameCount - 1);
float distance = t * splineLength;
// Skip if outside the start/end offset range
if (distance < StartOffset || distance > splineLength - EndOffset)
continue;
// Interpolate frame at this distance along the simplified spline
Transform frame = InterpolateFrameAtDistance(simplifiedPositions, distance);
Vector3 basePosition = frame.Position;
Vector3 forward = frame.Rotation.Forward;
Vector3 up = frame.Rotation.Up;
Vector3 right = frame.Rotation.Right;
bool placeLeft = false;
bool placeRight = false;
switch (LamppostPlacement)
{
case LamppostSide.Left:
placeLeft = true;
break;
case LamppostSide.Right:
placeRight = true;
break;
case LamppostSide.Both:
placeLeft = true;
placeRight = true;
break;
case LamppostSide.Alternating:
if (i % 2 == 0)
placeLeft = true;
else
placeRight = true;
break;
default:
placeLeft = true;
placeRight = true;
break;
}
if (placeLeft)
{
Vector3 leftPosition = basePosition - right * totalOffset + up * (LamppostHeightOffset + (HasSidewalk ? SidewalkHeight : 0.0f));
Rotation leftRotation = CalculateLamppostRotation(forward, up, LamppostRotationOffset);
CreateLamppost(containerObject, leftPosition, leftRotation);
}
if (placeRight)
{
Vector3 rightPosition = basePosition + right * totalOffset + up * (LamppostHeightOffset + (HasSidewalk ? SidewalkHeight : 0.0f));
Rotation rightRotation = CalculateLamppostRotation(forward, up, LamppostRotationOffset + 180.0f);
CreateLamppost(containerObject, rightPosition, rightRotation);
}
}
}
private void CreateLamppost(GameObject _Parent, Vector3 _Position, Rotation _Rotation)
{
if (!LamppostPrefab.IsValid())
return;
GameObject lamppostObject = LamppostPrefab.Clone(_Parent, _Position, _Rotation, Vector3.One);
if (!lamppostObject.IsValid())
return;
lamppostObject.LocalPosition = _Position;
lamppostObject.LocalRotation = _Rotation;
}
private Rotation CalculateLamppostRotation(Vector3 _Forward, Vector3 _SplineUp, float _YawOffset)
{
if (!AlignToSplineRotation)
return Rotation.FromYaw(_YawOffset);
Rotation finalRotation;
if (KeepVertical)
{
Vector3 flatForward = _Forward.WithZ(0).Normal;
if (flatForward.Length > 0.001f)
{
finalRotation = Rotation.LookAt(flatForward, Vector3.Up);
}
else
{
finalRotation = Rotation.FromYaw(_YawOffset);
}
}
else
{
finalRotation = Rotation.LookAt(_Forward, _SplineUp);
}
return finalRotation * Rotation.FromYaw(_YawOffset);
}
}
Game
library
using System;
using Sandbox;
namespace RedSnail.RoadTool;
public partial class RoadComponent
{
[Property, FeatureEnabled("Lines", Icon = "show_chart", Tint = EditorTint.Yellow), Change] private bool HasLines { get; set; } = false;
[Property(Title = "Lines"), Feature("Lines")] public RoadLineDefinition[] LineDefinitions { get; set { field = value; IsDirty = true; } }
[Property(Title = "Offset"), Feature("Lines"), Range(0.01f, 1.0f)] private float LinesOffset { get; set { field = value; IsDirty = true; } } = 0.1f;
[Property(Title = "Width"), Feature("Lines"), Range(1.0f, 50.0f)] private float LinesWidth { get; set { field = value; IsDirty = true; } } = 5.0f;
[Property(Title = "Extra Spacing"), Feature("Lines"), Range(0.0f, 1000.0f)] private float LinesExtraSpacing { get; set { field = value; IsDirty = true; } } = 0.0f;
[Property(Title = "Texture Repeat"), Feature("Lines")] private float LinesTextureRepeat { get; set { field = value.Clamp(1.0f, 100000.0f); IsDirty = true; } } = 10.0f;
private void OnHasLinesChanged(bool _OldValue, bool _NewValue)
{
IsDirty = true;
}
private void CreateLines()
{
EnsureLinesMeshExist();
}
private void UpdateLines()
{
}
private void RemoveLines()
{
RemoveGeneratedMeshChildren(LineSurfaceTag);
}
private void EnsureLinesMeshExist()
{
if (SandboxUtility.IsInPlayMode)
return;
if (HasGeneratedMeshChildren(LineSurfaceTag))
return;
BuildLinesMesh();
}
private void RebuildLinesMesh()
{
if (SandboxUtility.IsInPlayMode)
return;
RemoveGeneratedMeshChildren(LineSurfaceTag);
BuildLinesMesh();
}
private void BuildLinesMesh()
{
if (!HasLines || LineDefinitions == null || LineDefinitions.Length == 0)
return;
GetSplineFrameData(out var frames, out var segmentsToKeep);
int finalSegmentCount = segmentsToKeep.Count - 1;
if (finalSegmentCount <= 0)
return;
float roadWidth = RoadWidth + LinesExtraSpacing;
float lineSpacing = roadWidth / (LineDefinitions.Length + 1);
var polygonMeshes = new PolygonMesh[LineDefinitions.Length];
for (int i = 0; i < LineDefinitions.Length; i++)
polygonMeshes[i] = new PolygonMesh();
float[] lineDistances = new float[LineDefinitions.Length];
for (int i = 0; i < finalSegmentCount; i++)
{
int idx0 = segmentsToKeep[i];
int idx1 = segmentsToKeep[i + 1];
Transform f0 = frames[idx0];
Transform f1 = frames[idx1];
Vector3 p0 = f0.Position;
Vector3 p1 = f1.Position;
Vector3 right0 = f0.Rotation.Right;
for (int line = 0; line < LineDefinitions.Length; line++)
{
float offsetFromCenter = ((line + 1) * lineSpacing) - (roadWidth * 0.5f);
Vector3 center0 =
p0 +
f0.Rotation.Right * offsetFromCenter +
f0.Rotation.Up * LinesOffset;
Vector3 center1 =
p1 +
f1.Rotation.Right * offsetFromCenter +
f1.Rotation.Up * LinesOffset;
float segmentLength = Vector3.DistanceBetween(center0, center1);
Vector3 dir = (center1 - center0).Normal;
float remaining = segmentLength;
Vector3 curCenter = center0;
float dashSpacing = LineDefinitions[line]?.DashSpacing ?? 0.0f;
float dashFillRatio = LineDefinitions[line]?.DashFillRatio ?? 1.0f;
float dashLength = dashSpacing * dashFillRatio;
float halfWidth = LinesWidth * 0.5f;
var polygonMesh = polygonMeshes[line];
var material = LineDefinitions[line]?.Material ?? Material.Load("materials/default.vmat");
while (remaining > 0.001f)
{
float linePos = lineDistances[line];
float cyclePos = dashSpacing > 0 ? linePos % dashSpacing : 0;
if (cyclePos < 0.0001f)
cyclePos = 0.0f;
if (dashSpacing > 0 && dashSpacing - cyclePos < 0.0001f)
cyclePos = dashSpacing;
bool inDash = dashSpacing <= 0 || cyclePos <= dashLength - 0.0001f;
float step;
if (dashSpacing <= 0)
step = remaining;
else if (inDash)
step = dashLength - cyclePos;
else
step = dashSpacing - cyclePos;
step = Math.Max(step, 0.01f);
step = Math.Min(step, remaining);
Vector3 nextCenter = curCenter + dir * step;
if (inDash)
{
Vector3 l0 = curCenter - right0 * halfWidth;
Vector3 r0 = curCenter + right0 * halfWidth;
Vector3 l1 = nextCenter - right0 * halfWidth;
Vector3 r1 = nextCenter + right0 * halfWidth;
float v0 = linePos / LinesTextureRepeat;
float v1 = (linePos + step) / LinesTextureRepeat;
var verts = polygonMesh.AddVertices(l0, r0, r1, l1);
MeshUtility.AddTexturedQuad(polygonMesh, material, verts[0], verts[1], verts[2], verts[3],
new Vector2(0, v0), new Vector2(1, v0),
new Vector2(1, v1), new Vector2(0, v1));
}
lineDistances[line] += step;
curCenter = nextCenter;
remaining -= step;
}
}
}
for (int line = 0; line < LineDefinitions.Length; line++)
{
var child = new GameObject(GameObject, true, $"Line_{line}");
child.Tags.Add(LineSurfaceTag);
var meshComponent = child.AddComponent<MeshComponent>();
meshComponent.Mesh = polygonMeshes[line];
meshComponent.Collision = MeshComponent.CollisionType.None;
meshComponent.RenderType = ModelRenderer.ShadowRenderType.Off;
meshComponent.SmoothingAngle = 40.0f;
meshComponent.Static = true;
}
}
}
Game
library
using System.Linq;
using Sandbox;
namespace RedSnail.RoadTool;
/// <summary>
/// Generates parking lot lines for parking spaces
/// </summary>
[Icon("local_parking")]
public partial class RoadParkingLotComponent : Component, Component.ExecuteInEditor
{
private bool m_IsDirty;
private const string LinesTag = "parking_lines";
private const string CurbsTag = "parking_curbs";
/// <summary>
/// An optional prefab, if non-empty the parking lot will generate a bunch of child gameobjects positioned at each parking spots center.
/// (e.g. this allows you to use a gameobject prefab with a car spawner system component attached to it)
/// </summary>
[Property, Feature("General", Icon = "public", Tint = EditorTint.White)] private GameObject SpotPrefab { get; set; }
/// <summary>
/// The amount of parking spots you want to generate.
/// </summary>
[Property, Feature("General"), Range(1, 50)] private int SpotCount { get; set { field = value; m_IsDirty = true; } } = 10;
/// <summary>
/// Well that's the parking spot length
/// </summary>
[Property, Feature("General"), Range(10.0f, 1000.0f)] private float SpotLength { get; set { field = value; m_IsDirty = true; } } = 250.0f;
/// <summary>
/// and width...
/// </summary>
[Property, Feature("General"), Range(10.0f, 1000.0f)] private float SpotWidth { get; set { field = value; m_IsDirty = true; } } = 150.0f;
/// <summary>
/// The angle of the parking spots in degrees (0 = perpendicular, 45 = angled, 90 = parallel)
/// </summary>
[Property, Feature("General"), Range(-90.0f, 90.0f), Step(1.0f)] private float SpotAngle { get; set { field = value; m_IsDirty = true; } } = 0.0f;
[Property, Feature("General"), Range(0.5f, 1.0f)] private float SpotAngleThreshold { get; set { field = value; m_IsDirty = true; } } = 0.5f;
protected override void OnEnabled()
{
BuildAllMeshes();
}
protected override void OnDisabled()
{
DestroyMeshChildren();
RemoveParkingSpots();
}
protected override void OnUpdate()
{
if (m_IsDirty)
{
if (!SandboxUtility.IsInPlayMode)
{
DestroyMeshChildren();
BuildAllMeshes();
}
m_IsDirty = false;
}
}
private void DestroyMeshChildren()
{
var toRemove = GameObject.Children
.Where(c => c.Tags.Has(LinesTag) || c.Tags.Has(CurbsTag))
.ToList();
foreach (var child in toRemove)
child.Destroy();
}
private void BuildAllMeshes()
{
if (SandboxUtility.IsInPlayMode)
return;
BuildParkingLines();
BuildCurbs();
RemoveParkingSpots();
CreateParkingSpots();
}
protected override void DrawGizmos()
{
if (!Gizmo.IsSelected)
return;
Gizmo.Draw.LineThickness = 2.0f;
Gizmo.Draw.Color = Color.Green.WithAlpha(0.5f);
float angleRad = SpotAngle.DegreeToRadian();
float sinAngle = float.Sin(angleRad);
float cosAngle = float.Cos(angleRad);
float spacing = CalculateSpacing();
// Draw parking spot outlines
for (int i = 0; i < SpotCount; i++)
{
float xPos = i * spacing;
Vector3 frontLeft = new Vector3(xPos, 0, LinesOffset);
Vector3 frontRight = new Vector3(xPos + SpotWidth * cosAngle, SpotWidth * sinAngle, LinesOffset);
Vector3 backLeft = new Vector3(xPos - SpotLength * sinAngle, SpotLength * cosAngle, LinesOffset);
Vector3 backRight = new Vector3(xPos + SpotWidth * cosAngle - SpotLength * sinAngle, SpotWidth * sinAngle + SpotLength * cosAngle, LinesOffset);
Gizmo.Draw.Line(frontLeft, frontRight);
Gizmo.Draw.Line(frontRight, backRight);
Gizmo.Draw.Line(backRight, backLeft);
Gizmo.Draw.Line(backLeft, frontLeft);
}
}
private void CreateParkingSpots()
{
// If we're in play mode, do not build (Since they're already saved in the scene file)
if (LoadingScreen.IsVisible || Game.IsPlaying)
return;
if (!SpotPrefab.IsValid())
return;
GameObject containerObject = GameObject.Children.FirstOrDefault(x => x.Name == "ParkingSpots");
if (!containerObject.IsValid())
containerObject = new GameObject(GameObject, true, "ParkingSpots");
float angleRad = SpotAngle.DegreeToRadian();
float sinAngle = float.Sin(angleRad);
float cosAngle = float.Cos(angleRad);
float spacing = CalculateSpacing();
for (int i = 0; i < SpotCount; i++)
{
float xPos = i * spacing;
float centerX = xPos + (SpotWidth * 0.5f * cosAngle) - (SpotLength * 0.5f * sinAngle);
float centerY = (SpotWidth * 0.5f * sinAngle) + (SpotLength * 0.5f * cosAngle);
Vector3 position = new Vector3(centerX, centerY, 0);
GameObject gameObject = SpotPrefab.Clone(new Transform(), containerObject);
gameObject.LocalPosition = position;
gameObject.LocalRotation = Rotation.FromYaw(SpotAngle);
gameObject.NetworkMode = NetworkMode.Object;
gameObject.Network.SetOrphanedMode(NetworkOrphaned.Host);
}
}
private void RemoveParkingSpots()
{
// If we're in play mode, do not remove (Since they're already saved in the scene file)
if (LoadingScreen.IsVisible || Game.IsPlaying)
return;
GameObject containerObject = GameObject.Children.FirstOrDefault(x => x.Name == "ParkingSpots");
if (!containerObject.IsValid())
return;
foreach (var gameObject in containerObject.Children.Where(x => x.IsValid()))
{
gameObject.Destroy();
}
}
/// <summary>
/// Utility button to directly snap the parking lot to the nearest solid ground
/// </summary>
[Button("Snap to Ground"), Feature("General"), Order(100)]
public void SnapToGround()
{
SceneTraceResult trace = Scene.Trace.Ray(WorldPosition, WorldPosition + Vector3.Down * 10000.0f).Run();
if (trace.Distance < 0.1f) // Ignore really close hits, bcs that mean the parking lot is already properly grounded
return;
if (!trace.Hit)
return;
WorldPosition = trace.HitPosition;
WorldRotation = Rotation.LookAt(trace.Normal, Vector3.Up) * Rotation.FromPitch(90.0f);
}
private float CalculateSpacing()
{
float angleRad = SpotAngle.DegreeToRadian();
float cosAngle = float.Cos(angleRad);
return SpotWidth / float.Max(cosAngle, SpotAngleThreshold);
}
}
Game
library
using Sandbox;
namespace RedSnail.RoadTool;
[AssetType(Name = "Vehicle Spawn", Extension = "vspawn", Category = "Road Tool")]
public sealed class VehicleSpawnResource : GameResource
{
[Property] public GameObject Prefab { get; set; }
[Property, Range(0.0f, 10.0f)] public float Probability { get; set; } = 1.0f;
protected override Bitmap CreateAssetTypeIcon(int _Width, int _Height)
{
return CreateSimpleAssetTypeIcon("car_rental", _Width, _Height, "#00ccff", "black");
}
}
Game
library
using Sandbox;
namespace RedSnail.RoadTool;
/// <summary>
/// A component that owns an editable <see cref="Sandbox.Spline"/>. Lets the spline editor tool/window drive any of
/// them (roads, rails, …) without being tied to a single component type.
/// </summary>
public interface ISplineComponent : IValid
{
Spline Spline { get; }
Transform WorldTransform { get; }
GameObject GameObject { get; }
}
Game
library
using System.Linq;
using Sandbox;
namespace RedSnail.RoadTool;
/// <summary>
/// Represents a road component that can be manipulated within the editor and at runtime.
/// </summary>
[Icon("signpost")]
public partial class RoadComponent : Component, Component.ExecuteInEditor, Component.IHasBounds, ISplineComponent
{
/// <summary>
/// Undo/redo and prefab reloads deserialize a brand new <see cref="Sandbox.Spline"/> into this component
/// instead of mutating the existing one, so the setter has to move our change listener over. Otherwise the
/// subscription stays on the discarded instance and later edits never mark the mesh dirty again.
/// </summary>
[Property, Feature("General"), Hide]
public Spline Spline
{
get;
set
{
field = value;
SubscribeToSpline();
UpdateData();
}
} = new();
private Spline m_SubscribedSpline;
private bool m_DoesRoadMeshNeedRebuild;
private const string RoadMeshTag = "road_mesh";
private const string RoadSurfaceTag = "road_surface";
private const string SidewalkSurfaceTag = "road_sidewalk";
private const string LineSurfaceTag = "road_lines";
[Property, Feature("General", Icon = "public", Tint = EditorTint.White), Category("Optimization")] private bool AutoSimplify { get; set { field = value; IsDirty = true; } } = false;
[Property, Feature("General"), Category("Optimization"), Range(0.1f, 10.0f)] private float StraightThreshold { get; set { field = value; IsDirty = true; } } = 1.0f; // Degrees - how straight before merging
[Property, Feature("General"), Category("Optimization"), Range(2, 50)] private int MinSegmentsToMerge { get; set { field = value; IsDirty = true; } } = 3; // Minimum consecutive straight segments before merging
[Property, Feature("General"), Category("Miscellaneous")] public bool UseRotationMinimizingFrames { get; set { field = value; IsDirty = true; } }
[Property, FeatureEnabled("Bridge", Icon = "architecture", Tint = EditorTint.Blue)]
private bool HasBridge { get; set { field = value; m_DoesBridgeNeedRebuild = true; } } = false;
/// <summary>
/// This will prevent the bridge 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 bridge.
/// </summary>
[Property(Title = "🔒 Locked"), Feature("Bridge")]
private bool IsLocked { get; set; } = false;
/// <summary>
/// This is your bridge material you wanna use.
/// I recommend using a tileable texture for better result.
/// </summary>
[Property(Title = "Material"), Feature("Bridge"), Group("Texturing"), Order(1)]
private Material BridgeMaterial { get; set { field = value; m_DoesBridgeNeedRebuild = true; } }
[Property(Title = "Texture Repeat"), Feature("Bridge"), Group("Texturing"), Order(1), Step(1)]
private float BridgeTextureRepeat { get; set { field = value.Clamp(10.0f, 10000.0f); m_DoesBridgeNeedRebuild = true; } } = 500.0f;
[Property(Title = "Border Width"), Feature("Bridge"), Group("Shape"), Order(0), Range(10.0f, 500.0f)]
private float BridgeBorderWidth { get; set { field = value.Clamp(10.0f, 500.0f); m_DoesBridgeNeedRebuild = true; } } = 80.0f;
[Property(Title = "Border Height"), Feature("Bridge"), Group("Shape"), Range(10.0f, 500.0f)]
private float BridgeBorderHeight { get; set { field = value.Clamp(10.0f, 500.0f); m_DoesBridgeNeedRebuild = true; } } = 80.0f;
[Property(Title = "Bottom Depth"), Feature("Bridge"), Group("Shape"), Range(10.0f, 500.0f)]
private float BridgeBottomDepth { get; set { field = value.Clamp(10.0f, 500.0f); m_DoesBridgeNeedRebuild = true; } } = 80.0f;
[Property(Title = "Close Caps"), Feature("Bridge"), Group("Shape")]
private bool BridgeCloseCaps { get; set { field = value; m_DoesBridgeNeedRebuild = true; } } = true;
[Property(Title = "Pillars"), Feature("Bridge"), ToggleGroup("Pillars"), Order(2)]
private bool Pillars { get; set { field = value; m_DoesBridgeNeedRebuild = true; } } = true;
[Property(Title = "Shape"), Feature("Bridge"), ToggleGroup("Pillars")]
private BridgePillarShape BridgePillarType { get; set { field = value; m_DoesBridgeNeedRebuild = true; } } = BridgePillarShape.Square;
[Property(Title = "Size"), Feature("Bridge"), ToggleGroup("Pillars"), Range(10.0f, 1000.0f)]
private float BridgePillarSize { get; set { field = value; m_DoesBridgeNeedRebuild = true; } } = 200.0f;
[Property(Title = "Height"), Feature("Bridge"), ToggleGroup("Pillars"), Range(10.0f, 5000.0f)]
private float BridgePillarHeight { get; set { field = value; m_DoesBridgeNeedRebuild = true; } } = 600.0f;
[Property(Title = "Spacing"), Feature("Bridge"), ToggleGroup("Pillars"), Range(100.0f, 10000.0f)]
private float BridgePillarSpacing { get; set { field = value.Clamp(100.0f, 100000.0f); m_DoesBridgeNeedRebuild = true; } } = 1200.0f;
[Property(Title = "Inset"), Feature("Bridge"), ToggleGroup("Pillars"), Range(0.0f, 200.0f)]
private float BridgePillarInset { get; set { field = value; m_DoesBridgeNeedRebuild = true; } } = 20.0f;
[Property(Title = "Segments"), Feature("Bridge"), ToggleGroup("Pillars"), Range(3, 24), ShowIf(nameof(BridgePillarType), BridgePillarShape.Cylinder)]
private int BridgePillarRoundSegments { get; set { field = value.Clamp(3, 64); m_DoesBridgeNeedRebuild = true; } } = 12;
/// <summary>
/// Does the pillars follow world up vector or follow the road shape ?
/// </summary>
[Property(Title = "Keep Vertical (World Up)"), Feature("Bridge"), ToggleGroup("Pillars")]
private bool BridgePillarsKeepVertical { get; set { field = value; m_DoesBridgeNeedRebuild = true; } } = true;
[Property, FeatureEnabled("Crosswalks", Icon = "menu", Tint = EditorTint.Pink), Change] private bool HasCrosswalks { get; set; } = false;
[Property(Title = "Config"), Feature("Crosswalks")] public CrosswalkConfig CrosswalkConfig { get; set { field = value; m_DoesCrosswalksNeedsRebuild = true; } } = CrosswalkConfig.Both;
[Property(Title = "Decal Definition"), Feature("Crosswalks")] public DecalDefinition CrosswalkDefinition { get; set { field = value; m_DoesCrosswalksNeedsRebuild = true; } }
[Property(Title = "Decal Size"), Feature("Crosswalks"), Range(0.1f, 10.0f)] private Vector2 CrosswalkSize { get; set { field = value; m_DoesCrosswalksNeedsRebuild = true; } } = Vector2.One;
private bool IsDirty
{
get;
set
{
field = value;
m_DoesRoadMeshNeedRebuild = value;
m_DoesLamppostsNeedRebuild = value;
}
}
public BBox LocalBounds => Spline.Bounds;
public RoadComponent()
{
Spline.InsertPoint(Spline.PointCount, new Spline.Point { Position = new Vector3(0, 0, 0) });
Spline.InsertPoint(Spline.PointCount, new Spline.Point { Position = new Vector3(1000, 0, 0) });
Spline.InsertPoint(Spline.PointCount, new Spline.Point { Position = new Vector3(1600, 1000, 0) });
}
protected override void OnEnabled()
{
SubscribeToSpline();
EnsureLanes(); // migrate/seed the lane layout before anything that reads it (lines mesh, traffic graph) runs
EnsureRoadMeshExist();
EnsureSidewalkMeshExist();
EnsureBridgeMeshExist();
CreateLines();
CreateDecals();
CreateLampposts();
CreateCrosswalks();
}
protected override void OnDisabled()
{
UnsubscribeFromSpline();
RemoveRoadMesh();
RemoveSidewalkMesh();
RemoveLines();
RemoveDecals();
RemoveLampposts();
RemoveCrosswalks();
RemoveBridge();
}
/// <summary>
/// Undo/redo restores the serialized spline data in place, which does not raise
/// <see cref="Sandbox.Spline.SplineChanged"/>. Rebuilding from here is what makes the mesh follow an undo.
/// </summary>
protected override void OnValidate()
{
SubscribeToSpline();
UpdateData();
}
protected override void OnUpdate()
{
SyncSplineSubscription();
UpdateRoadMeshes();
UpdateLines();
UpdateDecals();
UpdateLampposts();
UpdateCrosswalks();
UpdateBridge();
}
private void UpdateRoadMeshes()
{
if (!m_DoesRoadMeshNeedRebuild)
return;
RebuildRoadMesh();
RebuildSidewalkMesh();
RebuildLinesMesh();
m_DoesRoadMeshNeedRebuild = false;
}
private void RebuildRoadMesh()
{
if (SandboxUtility.IsInPlayMode)
return;
if (IsRoadLocked)
return;
RemoveGeneratedMeshChildren(RoadSurfaceTag);
BuildRoadMesh();
}
private void RebuildSidewalkMesh()
{
if (SandboxUtility.IsInPlayMode)
return;
if (IsSidewalkLocked)
return;
RemoveGeneratedMeshChildren(SidewalkSurfaceTag);
BuildSidewalkMesh();
}
private void RemoveRoadMesh()
{
if (IsRoadLocked)
return;
RemoveGeneratedMeshChildren(RoadSurfaceTag);
}
private void RemoveSidewalkMesh()
{
if (IsSidewalkLocked)
return;
RemoveGeneratedMeshChildren(SidewalkSurfaceTag);
}
private void RemoveGeneratedMeshChildren(string _Tag)
{
var toRemove = GameObject.Children.Where(child => child.Tags.Has(_Tag)).ToList();
foreach (var child in toRemove)
child.Destroy();
}
private bool HasGeneratedMeshChildren(string _Tag)
{
return GameObject.Children.Any(child => child.Tags.Has(_Tag));
}
/// <summary>
/// Safety net for editor state changes that swap the spline instance without ever touching the
/// property setter or <see cref="OnValidate"/>. A reference compare per frame is cheap enough to
/// be worth never silently losing the subscription again.
/// </summary>
private void SyncSplineSubscription()
{
if (ReferenceEquals(m_SubscribedSpline, Spline))
return;
SubscribeToSpline();
UpdateData();
}
private void SubscribeToSpline()
{
if (ReferenceEquals(m_SubscribedSpline, Spline))
return;
UnsubscribeFromSpline();
m_SubscribedSpline = Spline;
if (m_SubscribedSpline is not null)
m_SubscribedSpline.SplineChanged += UpdateData;
}
private void UnsubscribeFromSpline()
{
if (m_SubscribedSpline is null)
return;
m_SubscribedSpline.SplineChanged -= UpdateData;
m_SubscribedSpline = null;
}
private void UpdateData()
{
if (!GameObject.IsValid() || !Scene.IsEditor)
return;
IsDirty = true;
}
}
Game
library
using System;
using Sandbox;
namespace RedSnail.RoadTool;
public partial class RoadComponent
{
[Property, FeatureEnabled("Lines", Icon = "show_chart", Tint = EditorTint.Yellow), Change] private bool HasLines { get; set; } = false;
[Property(Title = "Lines"), Feature("Lines")] public RoadLineDefinition[] LineDefinitions { get; set { field = value; IsDirty = true; } }
[Property(Title = "Offset"), Feature("Lines"), Range(0.01f, 1.0f)] private float LinesOffset { get; set { field = value; IsDirty = true; } } = 0.1f;
[Property(Title = "Width"), Feature("Lines"), Range(1.0f, 50.0f)] private float LinesWidth { get; set { field = value; IsDirty = true; } } = 5.0f;
[Property(Title = "Extra Spacing"), Feature("Lines"), Range(0.0f, 1000.0f)] private float LinesExtraSpacing { get; set { field = value; IsDirty = true; } } = 0.0f;
[Property(Title = "Texture Repeat"), Feature("Lines")] private float LinesTextureRepeat { get; set { field = value.Clamp(1.0f, 100000.0f); IsDirty = true; } } = 10.0f;
private void OnHasLinesChanged(bool _OldValue, bool _NewValue)
{
IsDirty = true;
}
private void CreateLines()
{
EnsureLinesMeshExist();
}
private void UpdateLines()
{
}
private void RemoveLines()
{
RemoveGeneratedMeshChildren(LineSurfaceTag);
}
private void EnsureLinesMeshExist()
{
if (SandboxUtility.IsInPlayMode)
return;
if (HasGeneratedMeshChildren(LineSurfaceTag))
return;
BuildLinesMesh();
}
private void RebuildLinesMesh()
{
if (SandboxUtility.IsInPlayMode)
return;
RemoveGeneratedMeshChildren(LineSurfaceTag);
BuildLinesMesh();
}
private void BuildLinesMesh()
{
if (!HasLines || LineDefinitions == null || LineDefinitions.Length == 0)
return;
GetSplineFrameData(out var frames, out var segmentsToKeep);
int finalSegmentCount = segmentsToKeep.Count - 1;
if (finalSegmentCount <= 0)
return;
float roadWidth = RoadWidth + LinesExtraSpacing;
float lineSpacing = roadWidth / (LineDefinitions.Length + 1);
var polygonMeshes = new PolygonMesh[LineDefinitions.Length];
for (int i = 0; i < LineDefinitions.Length; i++)
polygonMeshes[i] = new PolygonMesh();
float[] lineDistances = new float[LineDefinitions.Length];
for (int i = 0; i < finalSegmentCount; i++)
{
int idx0 = segmentsToKeep[i];
int idx1 = segmentsToKeep[i + 1];
Transform f0 = frames[idx0];
Transform f1 = frames[idx1];
Vector3 p0 = f0.Position;
Vector3 p1 = f1.Position;
Vector3 right0 = f0.Rotation.Right;
for (int line = 0; line < LineDefinitions.Length; line++)
{
float offsetFromCenter = ((line + 1) * lineSpacing) - (roadWidth * 0.5f);
Vector3 center0 =
p0 +
f0.Rotation.Right * offsetFromCenter +
f0.Rotation.Up * LinesOffset;
Vector3 center1 =
p1 +
f1.Rotation.Right * offsetFromCenter +
f1.Rotation.Up * LinesOffset;
float segmentLength = Vector3.DistanceBetween(center0, center1);
Vector3 dir = (center1 - center0).Normal;
float remaining = segmentLength;
Vector3 curCenter = center0;
float dashSpacing = LineDefinitions[line]?.DashSpacing ?? 0.0f;
float dashFillRatio = LineDefinitions[line]?.DashFillRatio ?? 1.0f;
float dashLength = dashSpacing * dashFillRatio;
float halfWidth = LinesWidth * 0.5f;
var polygonMesh = polygonMeshes[line];
var material = LineDefinitions[line]?.Material ?? Material.Load("materials/default.vmat");
while (remaining > 0.001f)
{
float linePos = lineDistances[line];
float cyclePos = dashSpacing > 0 ? linePos % dashSpacing : 0;
if (cyclePos < 0.0001f)
cyclePos = 0.0f;
if (dashSpacing > 0 && dashSpacing - cyclePos < 0.0001f)
cyclePos = dashSpacing;
bool inDash = dashSpacing <= 0 || cyclePos <= dashLength - 0.0001f;
float step;
if (dashSpacing <= 0)
step = remaining;
else if (inDash)
step = dashLength - cyclePos;
else
step = dashSpacing - cyclePos;
step = Math.Max(step, 0.01f);
step = Math.Min(step, remaining);
Vector3 nextCenter = curCenter + dir * step;
if (inDash)
{
Vector3 l0 = curCenter - right0 * halfWidth;
Vector3 r0 = curCenter + right0 * halfWidth;
Vector3 l1 = nextCenter - right0 * halfWidth;
Vector3 r1 = nextCenter + right0 * halfWidth;
float v0 = linePos / LinesTextureRepeat;
float v1 = (linePos + step) / LinesTextureRepeat;
var verts = polygonMesh.AddVertices(l0, r0, r1, l1);
MeshUtility.AddTexturedQuad(polygonMesh, material, verts[0], verts[1], verts[2], verts[3],
new Vector2(0, v0), new Vector2(1, v0),
new Vector2(1, v1), new Vector2(0, v1));
}
lineDistances[line] += step;
curCenter = nextCenter;
remaining -= step;
}
}
}
for (int line = 0; line < LineDefinitions.Length; line++)
{
var child = new GameObject(GameObject, true, $"Line_{line}");
child.Tags.Add(LineSurfaceTag);
var meshComponent = child.AddComponent<MeshComponent>();
meshComponent.Mesh = polygonMeshes[line];
meshComponent.Collision = MeshComponent.CollisionType.None;
meshComponent.RenderType = ModelRenderer.ShadowRenderType.Off;
meshComponent.SmoothingAngle = 40.0f;
meshComponent.Static = true;
}
}
}
Game
library
using System.Collections.Generic;
using Sandbox;
namespace RedSnail.RoadTool;
public static class MeshUtility
{
public static HalfEdgeMesh.VertexHandle GetOrAddVertex(PolygonMesh _Mesh, Dictionary<Vector3, HalfEdgeMesh.VertexHandle> _Cache, Vector3 _Pos)
{
if (!_Cache.TryGetValue(_Pos, out var handle))
{
handle = _Mesh.AddVertices(_Pos)[0];
_Cache[_Pos] = handle;
}
return handle;
}
public static void AddTexturedQuad(PolygonMesh _Mesh, Material _Material,
HalfEdgeMesh.VertexHandle _A, HalfEdgeMesh.VertexHandle _B,
HalfEdgeMesh.VertexHandle _C, HalfEdgeMesh.VertexHandle _D,
Vector2 _UvA, Vector2 _UvB, Vector2 _UvC, Vector2 _UvD)
{
if (HasDuplicateVertex(_A, _B, _C, _D))
return;
var face = _Mesh.AddFace(_A, _B, _C, _D);
if (!face.IsValid)
return;
_Mesh.SetFaceMaterial(face, _Material);
_Mesh.SetFaceTextureCoords(face, new List<Vector2> { _UvA, _UvB, _UvC, _UvD });
}
public static void AddTexturedTriangle(PolygonMesh _Mesh, Material _Material,
HalfEdgeMesh.VertexHandle _A, HalfEdgeMesh.VertexHandle _B,
HalfEdgeMesh.VertexHandle _C,
Vector2 _UvA, Vector2 _UvB, Vector2 _UvC)
{
if (HasDuplicateVertex(_A, _B, _C))
return;
var face = _Mesh.AddFace(_A, _B, _C);
if (!face.IsValid)
return;
_Mesh.SetFaceMaterial(face, _Material);
_Mesh.SetFaceTextureCoords(face, new List<Vector2> { _UvA, _UvB, _UvC });
}
private static bool HasDuplicateVertex(HalfEdgeMesh.VertexHandle _A, HalfEdgeMesh.VertexHandle _B, HalfEdgeMesh.VertexHandle _C)
{
return _A.Equals(_B) || _A.Equals(_C) || _B.Equals(_C);
}
private static bool HasDuplicateVertex(HalfEdgeMesh.VertexHandle _A, HalfEdgeMesh.VertexHandle _B, HalfEdgeMesh.VertexHandle _C, HalfEdgeMesh.VertexHandle _D)
{
return _A.Equals(_B) || _A.Equals(_C) || _A.Equals(_D) ||
_B.Equals(_C) || _B.Equals(_D) ||
_C.Equals(_D);
}
}
Game
library
using System;
using Sandbox;
namespace RedSnail.RoadTool;
public partial class RailComponent
{
[Property, FeatureEnabled("Fishplates", Icon = "link", Tint = EditorTint.Green)] private bool HasFishplates { get; set { field = value; IsDirty = true; } } = true;
[Property(Title = "Material"), Feature("Fishplates")] private Material FishplateMaterial { get; set { field = value; IsDirty = true; } }
/// <summary>Distance along the track between joints. Real rail comes in long sections, so this is much larger than the sleeper spacing.</summary>
[Property(Title = "Spacing"), Feature("Fishplates"), Range(100.0f, 5000.0f)] private float FishplateSpacing { get; set { field = value; IsDirty = true; } } = 1200.0f;
/// <summary>Plate length along the track (it straddles the joint between two rail sections).</summary>
[Property(Title = "Length"), Feature("Fishplates"), Range(10.0f, 500.0f)] private float FishplateLength { get; set { field = value; IsDirty = true; } } = 80.0f;
/// <summary>Plate height — sized to sit against the rail web, between the foot and the head.</summary>
[Property(Title = "Height"), Feature("Fishplates"), Range(2.0f, 100.0f)] private float FishplateHeight { get; set { field = value; IsDirty = true; } } = 14.0f;
/// <summary>How far the plate stands out from the rail web.</summary>
[Property(Title = "Thickness"), Feature("Fishplates"), Range(1.0f, 50.0f)] private float FishplateThickness { get; set { field = value; IsDirty = true; } } = 6.0f;
/// <summary>Radius of the hexagonal bolt heads.</summary>
[Property(Title = "Bolt Radius"), Feature("Fishplates"), Range(0.5f, 20.0f)] private float FishplateBoltRadius { get; set { field = value; IsDirty = true; } } = 3.5f;
/// <summary>How far the bolt heads stand out from the plate.</summary>
[Property(Title = "Bolt Depth"), Feature("Fishplates"), Range(0.5f, 20.0f)] private float FishplateBoltDepth { get; set { field = value; IsDirty = true; } } = 3.0f;
[Property(Title = "Texture Repeat"), Feature("Fishplates")] private float FishplateTextureRepeat { get; set { field = value.Clamp(1.0f, 100000.0f); IsDirty = true; } } = 100.0f;
private void BuildFishplates(Transform[] _Frames)
{
float length = Spline.Length;
if (length <= 0.0f)
return;
float spacing = Math.Max(1.0f, FishplateSpacing);
int count = (int)MathF.Floor(length / spacing);
if (count < 1)
return; // track is shorter than one rail section — no joints
var material = FishplateMaterial ?? Material.Load("materials/dev/reflectivity_50.vmat");
var polygonMesh = new PolygonMesh();
float textureRepeat = Math.Max(1.0f, FishplateTextureRepeat);
float baseHeight = HasSleepers ? SleeperHeight : 0.0f;
float halfGauge = RailGauge * 0.5f;
int placed = 0;
for (int s = 1; s <= count; s++)
{
float distance = s * spacing;
if (distance >= length)
break; // don't drop a joint right on the end of the track
var frame = SampleFrameAtDistance(_Frames, distance, length);
// One plate on the OUTER side of each rail (the outward sign flips the side for the left rail).
AddFishplate(polygonMesh, material, frame, halfGauge, 1.0f, baseHeight, textureRepeat);
AddFishplate(polygonMesh, material, frame, halfGauge, -1.0f, baseHeight, textureRepeat);
placed++;
}
if (placed == 0)
return;
CreateFishplateChild("Fishplates", polygonMesh);
}
/// <summary>Adds one bolted joint plate against the outer face of a single rail's web.</summary>
private void AddFishplate(PolygonMesh _Mesh, Material _Material, Transform _Frame, float _HalfGauge, float _OutwardSign, float _BaseHeight, float _TextureRepeat)
{
Vector3 origin = _Frame.Position;
Vector3 right = _Frame.Rotation.Right;
Vector3 forward = _Frame.Rotation.Forward;
Vector3 up = _Frame.Rotation.Up;
Vector3 outward = right * _OutwardSign; // points away from the track centre, toward the outer side of this rail
float halfWeb = RailWidth * RailWebScale * 0.5f;
float webMid = _BaseHeight + RailHeight * 0.5f;
float embed = MathF.Min(1.0f, halfWeb * 0.5f); // bite into the web so the hidden inner face never z-fights it
float innerDist = halfWeb - embed;
float outerDist = halfWeb + FishplateThickness;
float centreDist = (innerDist + outerDist) * 0.5f;
float halfOut = (outerDist - innerDist) * 0.5f;
float halfLength = FishplateLength * 0.5f;
float halfHeight = FishplateHeight * 0.5f;
Vector3 railCentre = origin + right * (_HalfGauge * _OutwardSign);
Vector3 plateCentre = railCentre + outward * centreDist + up * webMid;
AddBox(_Mesh, _Material, plateCentre, outward, forward, up, halfOut, halfLength, halfHeight, _TextureRepeat);
// Two hex bolts standing out of the plate's outer face, spread along the joint.
Vector3 outerFace = plateCentre + outward * halfOut;
float boltOffset = FishplateLength * 0.25f;
AddHexBolt(_Mesh, _Material, outerFace + forward * boltOffset, outward, forward, FishplateBoltRadius, FishplateBoltDepth, _TextureRepeat);
AddHexBolt(_Mesh, _Material, outerFace - forward * boltOffset, outward, forward, FishplateBoltRadius, FishplateBoltDepth, _TextureRepeat);
}
/// <summary>A closed box built from three frame axes, textured with a uniform box (cube) projection.</summary>
private static void AddBox(PolygonMesh _Mesh, Material _Material, Vector3 _Centre, Vector3 _AxisR, Vector3 _AxisF, Vector3 _AxisU, float _HalfR, float _HalfF, float _HalfU, float _TextureRepeat)
{
// The faces below wind outward only for a right-handed basis. The outward axis flips for the left rail, which
// would make it left-handed and flip every face to a backface — mirror one axis to keep it right-handed (the
// box is symmetric, so this changes nothing but the winding).
if (Vector3.Dot(Vector3.Cross(_AxisR, _AxisF), _AxisU) < 0.0f)
_AxisF = -_AxisF;
Vector3 Corner(float r, float f, float u) => _Centre + _AxisR * r + _AxisF * f + _AxisU * u;
var c000 = Corner(-_HalfR, -_HalfF, -_HalfU);
var c100 = Corner(_HalfR, -_HalfF, -_HalfU);
var c110 = Corner(_HalfR, _HalfF, -_HalfU);
var c010 = Corner(-_HalfR, _HalfF, -_HalfU);
var c001 = Corner(-_HalfR, -_HalfF, _HalfU);
var c101 = Corner(_HalfR, -_HalfF, _HalfU);
var c111 = Corner(_HalfR, _HalfF, _HalfU);
var c011 = Corner(-_HalfR, _HalfF, _HalfU);
float r0 = -_HalfR / _TextureRepeat, r1 = _HalfR / _TextureRepeat;
float f0 = -_HalfF / _TextureRepeat, f1 = _HalfF / _TextureRepeat;
float u0 = -_HalfU / _TextureRepeat, u1 = _HalfU / _TextureRepeat;
AddQuad(_Mesh, _Material, c100, c110, c111, c101, new Vector2(f0, u0), new Vector2(f1, u0), new Vector2(f1, u1), new Vector2(f0, u1)); // +R
AddQuad(_Mesh, _Material, c010, c000, c001, c011, new Vector2(f1, u0), new Vector2(f0, u0), new Vector2(f0, u1), new Vector2(f1, u1)); // -R
AddQuad(_Mesh, _Material, c110, c010, c011, c111, new Vector2(r1, u0), new Vector2(r0, u0), new Vector2(r0, u1), new Vector2(r1, u1)); // +F
AddQuad(_Mesh, _Material, c000, c100, c101, c001, new Vector2(r0, u0), new Vector2(r1, u0), new Vector2(r1, u1), new Vector2(r0, u1)); // -F
AddQuad(_Mesh, _Material, c001, c101, c111, c011, new Vector2(r0, f0), new Vector2(r1, f0), new Vector2(r1, f1), new Vector2(r0, f1)); // +U
AddQuad(_Mesh, _Material, c000, c010, c110, c100, new Vector2(r0, f0), new Vector2(r0, f1), new Vector2(r1, f1), new Vector2(r1, f0)); // -U
}
/// <summary>A hexagonal bolt head — a 6-sided prism standing out of the plate along <paramref name="_AxisOut"/>, capped on the outer end.</summary>
private static void AddHexBolt(PolygonMesh _Mesh, Material _Material, Vector3 _Base, Vector3 _AxisOut, Vector3 _Forward, float _Radius, float _Depth, float _TextureRepeat)
{
const int sides = 6;
// t2 completes a right-handed (out, t1, t2) basis so the winding below stays outward-facing on either rail.
Vector3 t1 = _Forward;
Vector3 t2 = Vector3.Cross(_AxisOut, t1);
var baseRing = new Vector3[sides];
var topRing = new Vector3[sides];
for (int k = 0; k < sides; k++)
{
float angle = MathF.PI * 2.0f * k / sides;
Vector3 radial = t1 * MathF.Cos(angle) + t2 * MathF.Sin(angle);
baseRing[k] = _Base + radial * _Radius;
topRing[k] = baseRing[k] + _AxisOut * _Depth;
}
float edge = _Radius; // a regular hexagon's side length equals its radius
float v1 = _Depth / _TextureRepeat;
for (int k = 0; k < sides; k++)
{
int next = (k + 1) % sides;
float u0 = edge * k / _TextureRepeat;
float u1 = edge * (k + 1) / _TextureRepeat;
AddQuad(_Mesh, _Material, baseRing[k], baseRing[next], topRing[next], topRing[k],
new Vector2(u0, 0.0f), new Vector2(u1, 0.0f), new Vector2(u1, v1), new Vector2(u0, v1));
}
Vector2 CapUv(Vector3 _P) => new Vector2(Vector3.Dot(_P - _Base, t1), Vector3.Dot(_P - _Base, t2)) / _TextureRepeat;
for (int k = 1; k < sides - 1; k++)
{
AddTri(_Mesh, _Material, topRing[0], topRing[k], topRing[k + 1],
CapUv(topRing[0]), CapUv(topRing[k]), CapUv(topRing[k + 1]));
}
}
// Own vertices per face so every plate edge and bolt facet stays hard.
private static void AddQuad(PolygonMesh _Mesh, Material _Material, Vector3 _A, Vector3 _B, Vector3 _C, Vector3 _D, Vector2 _UvA, Vector2 _UvB, Vector2 _UvC, Vector2 _UvD)
{
var v = _Mesh.AddVertices(_A, _B, _C, _D);
MeshUtility.AddTexturedQuad(_Mesh, _Material, v[0], v[1], v[2], v[3], _UvA, _UvB, _UvC, _UvD);
}
private static void AddTri(PolygonMesh _Mesh, Material _Material, Vector3 _A, Vector3 _B, Vector3 _C, Vector2 _UvA, Vector2 _UvB, Vector2 _UvC)
{
var v = _Mesh.AddVertices(_A, _B, _C);
MeshUtility.AddTexturedTriangle(_Mesh, _Material, v[0], v[1], v[2], _UvA, _UvB, _UvC);
}
private void CreateFishplateChild(string _Name, PolygonMesh _PolygonMesh)
{
var child = new GameObject(GameObject, true, _Name);
child.Tags.Add(RailMeshTag);
child.Tags.Add(FishplateSurfaceTag);
var meshComponent = child.AddComponent<MeshComponent>();
meshComponent.Mesh = _PolygonMesh;
}
}
Game
library
using System;
using Sandbox;
namespace RedSnail.RoadTool;
public partial class RailComponent
{
private static Transform[] CalculateTangentFramesUsingUpDir(Spline _Spline, int _FrameCount)
{
var frames = new Transform[_FrameCount];
float totalSplineLength = _Spline.Length;
var sample = _Spline.SampleAtDistance(0.0f);
sample.Up = Vector3.Up;
// Fall back to a different up if the tangent runs (nearly) straight up.
if (MathF.Abs(Vector3.Dot(sample.Tangent, sample.Up)) > 0.999f)
sample.Up = Vector3.Right;
for (int i = 0; i < _FrameCount; i++)
{
float t = _FrameCount > 1 ? (float)i / (_FrameCount - 1) : 0.0f;
float distance = t * totalSplineLength;
sample = _Spline.SampleAtDistance(distance);
var up = Rotation.FromAxis(sample.Tangent, sample.Roll) * sample.Up;
Rotation rotation = Rotation.LookAt(sample.Tangent, up);
frames[i] = new Transform(sample.Position, rotation, sample.Scale);
}
return frames;
}
private static Transform[] CalculateRotationMinimizingTangentFrames(Spline _Spline, int _FrameCount)
{
var frames = new Transform[_FrameCount];
float totalSplineLength = _Spline.Length;
var previousSample = _Spline.SampleAtDistance(0.0f);
Vector3 up = Vector3.Up;
if (MathF.Abs(Vector3.Dot(previousSample.Tangent, up)) > 0.999f)
up = Vector3.Right;
up = Rotation.FromAxis(previousSample.Tangent, previousSample.Roll) * up;
frames[0] = new Transform(previousSample.Position, Rotation.LookAt(previousSample.Tangent, up), previousSample.Scale);
for (int i = 1; i < _FrameCount; i++)
{
float t = _FrameCount > 1 ? (float)i / (_FrameCount - 1) : 0.0f;
float distance = t * totalSplineLength;
var sample = _Spline.SampleAtDistance(distance);
// Parallel-transport the up vector so the profile doesn't twist through 3D curves.
up = GetRotationMinimizingNormal(previousSample.Position, previousSample.Tangent, up, sample.Position, sample.Tangent);
float deltaRoll = sample.Roll - previousSample.Roll;
up = Rotation.FromAxis(sample.Tangent, deltaRoll) * up;
Rotation rotation = Rotation.LookAt(sample.Tangent, up);
frames[i] = new Transform(sample.Position, rotation, sample.Scale);
previousSample = sample;
}
return frames;
}
private static Vector3 GetRotationMinimizingNormal(Vector3 _PosA, Vector3 _TangentA, Vector3 _NormalA, Vector3 _PosB, Vector3 _TangentB)
{
// Source: https://www.microsoft.com/en-us/research/wp-content/uploads/2016/12/Computation-of-rotation-minimizing-frames.pdf
Vector3 v1 = _PosB - _PosA;
float v1DotV1Half = Vector3.Dot(v1, v1) / 2.0f;
if (v1DotV1Half <= 0.0001f)
return _NormalA;
float r1 = Vector3.Dot(v1, _NormalA) / v1DotV1Half;
float r2 = Vector3.Dot(v1, _TangentA) / v1DotV1Half;
Vector3 nL = _NormalA - r1 * v1;
Vector3 tL = _TangentA - r2 * v1;
Vector3 v2 = _TangentB - tL;
float r3 = Vector3.Dot(v2, nL) / Vector3.Dot(v2, v2);
return (nL - 2.0f * r3 * v2).Normal;
}
}
Game
library
using System;
using System.Collections.Generic;
using Sandbox;
namespace RedSnail.RoadTool;
/// <summary>
/// How much of a junction reaches the pedestrian graph. Geometry is never affected — the pavement mesh builds
/// the same either way; this only decides what pedestrians are allowed to route over.
///
/// Ordered most-to-least deliberately, so the zero value is the harmless one: a component that somehow arrives
/// without this field set behaves like every other junction rather than silently vanishing from the graph.
/// </summary>
public enum SidewalkGraphMode
{
/// <summary>Pavement round the corners and a crossing at every arm.</summary>
All,
/// <summary>
/// Corners only. The pavement stays joined all the way round and connects to every road that meets here,
/// but there's nothing to step off the kerb onto — pedestrians walk round the junction instead of over it.
/// </summary>
NoCrossing,
/// <summary>
/// Nothing at all. The roads that meet here keep their own pavements; they just stop being connected
/// THROUGH this junction, so expect their ends to show as dead ends.
/// </summary>
None
}
public partial class RoadIntersectionComponent
{
/// <summary>
/// What this junction contributes to the pedestrian graph — the pedestrian counterpart of
/// <see cref="ExcludeTraffic"/>.
///
/// <see cref="SidewalkGraphMode.None"/> for somewhere nobody should be walking round at all: a slip road, a
/// service yard, a junction whose pavement exists only because the mesh needs an edge.
/// <see cref="SidewalkGraphMode.NoCrossing"/> for somewhere they may walk past but not across — a forecourt
/// or car park entrance, where the kerb should stay continuous and stepping into the vehicle route is the
/// thing you're trying to prevent.
/// </summary>
[Property, Feature("General"), Category("Sidewalk"), Order(3)] public SidewalkGraphMode SidewalkGraph { get; set; } = SidewalkGraphMode.All;
/// <summary>
/// The walking line around this junction's pavement, in world space — a closed loop running down the
/// middle of the sidewalk slab.
///
/// Built from the junction's OWN outline, which is the whole point: a rectangular intersection's pavement
/// runs along its edges and turns at its corners, and approximating that with an arc around the centre
/// bows the path out into the road at the middle of each side and cuts the corners off. A circle really is
/// an arc, so it gets one.
///
/// The loop runs all the way round, arm mouths included — whoever consumes it is expected to split it at
/// the kerbs, because where the pavement is interrupted is the same question as where the crossings go.
/// </summary>
public List<Vector3> GetSidewalkOutline(float _Spacing)
{
var points = new List<Vector3>();
if (!HasSidewalks)
return points;
float spacing = Math.Max(1.0f, _Spacing);
// Centre of the slab, so it sits where someone would actually walk rather than on either kerb.
float outset = SidewalkWidth * 0.5f;
Vector3 lift = Vector3.Up * SidewalkHeight;
if (Shape == IntersectionShape.Circle)
{
float radius = Radius + outset;
int steps = Math.Max(8, (int)MathF.Ceiling(MathF.Tau * radius / spacing));
for (int i = 0; i < steps; i++)
{
float angle = MathF.Tau * i / steps;
points.Add(new Vector3(MathF.Cos(angle) * radius, MathF.Sin(angle) * radius, 0.0f) + lift);
}
}
else
{
// Written with the same right/forward vectors BuildRectangleRoad uses, rather than as raw x/y
// components. Width runs along Right and Length along Forward, which in s&box axes is Y and X —
// spelling that out by hand gets them the wrong way round, and a junction outline rotated 90°
// looks almost plausible until nothing connects to it.
Vector3 right = Vector3.Right;
Vector3 forward = Vector3.Forward;
float hw = Width * 0.5f + outset;
float hl = Length * 0.5f + outset;
// Round the rectangle in order, so the loop has a consistent winding for anything that walks it.
Vector3[] corners =
[
-right * hw - forward * hl,
right * hw - forward * hl,
right * hw + forward * hl,
-right * hw + forward * hl,
];
for (int i = 0; i < corners.Length; i++)
{
Vector3 from = corners[i];
Vector3 to = corners[(i + 1) % corners.Length];
int steps = Math.Max(1, (int)MathF.Ceiling(Vector3.DistanceBetween(from, to) / spacing));
// Last point of each edge is skipped — it's the first of the next one, and a closed loop
// mustn't repeat its corners.
for (int s = 0; s < steps; s++)
points.Add(Vector3.Lerp(from, to, (float)s / steps) + lift);
}
}
for (int i = 0; i < points.Count; i++)
points[i] = WorldTransform.PointToWorld(points[i]);
return points;
}
/// <summary>Whether this junction has pavement to walk on at all.</summary>
public bool HasSidewalks => SidewalkWidth > 0.0f;
}
Debug: View Raw JSON Response
{
"TotalCount": 111,
"Files": [
{
"Ident": "redsnail.roadtool",
"Path": "Editor/IntersectionTool.cs",
"FileName": "IntersectionTool.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 342326,
"IsPrivate": false,
"Code": "using Sandbox;\r\nusing Editor;\r\n\r\nnamespace RedSnail.RoadTool.Editor;\r\n\r\n/// <summary>\r\n/// Create and manage road and road intersection.\r\n/// </summary>\r\n[Title(\"Create Road/Intersection\")]\r\n[Icon(\"roundabout_left\")]\r\n[Alias(\"intersection\")]\r\n[Group(\"1\")]\r\n[Order(0)]\r\npublic class IntersectionTool : EditorTool\r\n{\r\n\tpublic override void OnEnabled()\r\n\t{\r\n\r\n\t}\r\n\r\n\tpublic override Widget CreateToolSidebar()\r\n\t{\r\n\t\tToolSidebarWidget sidebar = new ToolSidebarWidget();\r\n\t\tsidebar.AddTitle(\"Intersection\", \"roundabout_left\");\r\n\r\n\t\tLayout group = sidebar.AddGroup(\"Create\");\r\n\t\tLayout row = Layout.Row();\r\n\r\n\t\tIconButton road = sidebar.CreateButton(\"Create Road\", \"route\", null, CreateRoad, true, row);\r\n\t\tIconButton inter = sidebar.CreateButton(\"Create Intersection\", \"roundabout_left\", null, CreateIntersection, true, row);\r\n\r\n\t\trow.Spacing = 5;\r\n\t\trow.AddStretchCell();\r\n\r\n\t\tgroup.Add(row);\r\n\r\n\t\tsidebar.Layout.Add(group);\r\n\t\tsidebar.Layout.AddStretchCell();\r\n\t\treturn sidebar;\r\n\t}\r\n\r\n\tprivate static void CreateRoad()\r\n\t{\r\n\t\tGameObject go = SceneEditorSession.Active.Scene.CreateObject();\r\n\t\tgo.Name = \"Road\";\r\n\t\tgo.AddComponent<RoadComponent>();\r\n\t}\r\n\r\n\tprivate static void CreateIntersection()\r\n\t{\r\n\t\tGameObject go = SceneEditorSession.Active.Scene.CreateObject();\r\n\t\tgo.Name = \"Road Intersection\";\r\n\t\tgo.AddComponent<RoadIntersectionComponent>();\r\n\t}\r\n}\r\n"
},
{
"Ident": "redsnail.roadtool",
"Path": "RoadIntersectionComponent/RoadIntersectionComponent.Sidewalk.cs",
"FileName": "RoadIntersectionComponent.Sidewalk.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 342326,
"IsPrivate": false,
"Code": "using System;\nusing System.Collections.Generic;\nusing Sandbox;\n\nnamespace RedSnail.RoadTool;\n\n/// <summary>\n/// How much of a junction reaches the pedestrian graph. Geometry is never affected \u2014 the pavement mesh builds\n/// the same either way; this only decides what pedestrians are allowed to route over.\n///\n/// Ordered most-to-least deliberately, so the zero value is the harmless one: a component that somehow arrives\n/// without this field set behaves like every other junction rather than silently vanishing from the graph.\n/// </summary>\npublic enum SidewalkGraphMode\n{\n\t/// <summary>Pavement round the corners and a crossing at every arm.</summary>\n\tAll,\n\n\t/// <summary>\n\t/// Corners only. The pavement stays joined all the way round and connects to every road that meets here,\n\t/// but there's nothing to step off the kerb onto \u2014 pedestrians walk round the junction instead of over it.\n\t/// </summary>\n\tNoCrossing,\n\n\t/// <summary>\n\t/// Nothing at all. The roads that meet here keep their own pavements; they just stop being connected\n\t/// THROUGH this junction, so expect their ends to show as dead ends.\n\t/// </summary>\n\tNone\n}\n\n\n\npublic partial class RoadIntersectionComponent\n{\n\t/// <summary>\n\t/// What this junction contributes to the pedestrian graph \u2014 the pedestrian counterpart of\n\t/// <see cref=\"ExcludeTraffic\"/>.\n\t///\n\t/// <see cref=\"SidewalkGraphMode.None\"/> for somewhere nobody should be walking round at all: a slip road, a\n\t/// service yard, a junction whose pavement exists only because the mesh needs an edge.\n\t/// <see cref=\"SidewalkGraphMode.NoCrossing\"/> for somewhere they may walk past but not across \u2014 a forecourt\n\t/// or car park entrance, where the kerb should stay continuous and stepping into the vehicle route is the\n\t/// thing you're trying to prevent.\n\t/// </summary>\n\t[Property, Feature(\"General\"), Category(\"Sidewalk\"), Order(3)] public SidewalkGraphMode SidewalkGraph { get; set; } = SidewalkGraphMode.All;\n\n\n\n\t/// <summary>\n\t/// The walking line around this junction's pavement, in world space \u2014 a closed loop running down the\n\t/// middle of the sidewalk slab.\n\t///\n\t/// Built from the junction's OWN outline, which is the whole point: a rectangular intersection's pavement\n\t/// runs along its edges and turns at its corners, and approximating that with an arc around the centre\n\t/// bows the path out into the road at the middle of each side and cuts the corners off. A circle really is\n\t/// an arc, so it gets one.\n\t///\n\t/// The loop runs all the way round, arm mouths included \u2014 whoever consumes it is expected to split it at\n\t/// the kerbs, because where the pavement is interrupted is the same question as where the crossings go.\n\t/// </summary>\n\tpublic List<Vector3> GetSidewalkOutline(float _Spacing)\n\t{\n\t\tvar points = new List<Vector3>();\n\n\t\tif (!HasSidewalks)\n\t\t\treturn points;\n\n\t\tfloat spacing = Math.Max(1.0f, _Spacing);\n\n\t\t// Centre of the slab, so it sits where someone would actually walk rather than on either kerb.\n\t\tfloat outset = SidewalkWidth * 0.5f;\n\t\tVector3 lift = Vector3.Up * SidewalkHeight;\n\n\t\tif (Shape == IntersectionShape.Circle)\n\t\t{\n\t\t\tfloat radius = Radius + outset;\n\t\t\tint steps = Math.Max(8, (int)MathF.Ceiling(MathF.Tau * radius / spacing));\n\n\t\t\tfor (int i = 0; i < steps; i++)\n\t\t\t{\n\t\t\t\tfloat angle = MathF.Tau * i / steps;\n\n\t\t\t\tpoints.Add(new Vector3(MathF.Cos(angle) * radius, MathF.Sin(angle) * radius, 0.0f) + lift);\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\t// Written with the same right/forward vectors BuildRectangleRoad uses, rather than as raw x/y\n\t\t\t// components. Width runs along Right and Length along Forward, which in s&box axes is Y and X \u2014\n\t\t\t// spelling that out by hand gets them the wrong way round, and a junction outline rotated 90\u00b0\n\t\t\t// looks almost plausible until nothing connects to it.\n\t\t\tVector3 right = Vector3.Right;\n\t\t\tVector3 forward = Vector3.Forward;\n\n\t\t\tfloat hw = Width * 0.5f + outset;\n\t\t\tfloat hl = Length * 0.5f + outset;\n\n\t\t\t// Round the rectangle in order, so the loop has a consistent winding for anything that walks it.\n\t\t\tVector3[] corners =\n\t\t\t[\n\t\t\t\t-right * hw - forward * hl,\n\t\t\t\t right * hw - forward * hl,\n\t\t\t\t right * hw + forward * hl,\n\t\t\t\t-right * hw + forward * hl,\n\t\t\t];\n\n\t\t\tfor (int i = 0; i < corners.Length; i++)\n\t\t\t{\n\t\t\t\tVector3 from = corners[i];\n\t\t\t\tVector3 to = corners[(i + 1) % corners.Length];\n\n\t\t\t\tint steps = Math.Max(1, (int)MathF.Ceiling(Vector3.DistanceBetween(from, to) / spacing));\n\n\t\t\t\t// Last point of each edge is skipped \u2014 it's the first of the next one, and a closed loop\n\t\t\t\t// mustn't repeat its corners.\n\t\t\t\tfor (int s = 0; s < steps; s++)\n\t\t\t\t\tpoints.Add(Vector3.Lerp(from, to, (float)s / steps) + lift);\n\t\t\t}\n\t\t}\n\n\t\tfor (int i = 0; i < points.Count; i++)\n\t\t\tpoints[i] = WorldTransform.PointToWorld(points[i]);\n\n\t\treturn points;\n\t}\n\n\n\n\t/// <summary>Whether this junction has pavement to walk on at all.</summary>\n\tpublic bool HasSidewalks => SidewalkWidth > 0.0f;\n}\n"
},
{
"Ident": "redsnail.roadtool",
"Path": "RoadIntersectionComponent/RoadIntersectionComponent.Terrain.cs",
"FileName": "RoadIntersectionComponent.Terrain.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 342326,
"IsPrivate": false,
"Code": "using System;\r\nusing System.Linq;\r\nusing Sandbox;\r\n\r\nnamespace RedSnail.RoadTool;\r\n\r\npublic partial class RoadIntersectionComponent\r\n{\r\n\t[Property, Feature(\"Terrain\", Icon = \"landscape\", Tint = EditorTint.Green), Hide]\r\n\tprivate Terrain TerrainTarget { get; set; }\r\n\r\n\t[Property, Feature(\"Terrain\"), Range(0f, 2000f)]\r\n\tpublic float TerrainFalloffRadius { get; set; } = 500f;\r\n\r\n\t[Property, Feature(\"Terrain\"), Range(-10f, 10f)]\r\n\tpublic float TerrainHeightOffset { get; set; } = 0f;\r\n\r\n\t[Property, Feature(\"Terrain\"), Range(0f, 100f)]\r\n\tpublic float TerrainRoadInset { get; set; } = 10f;\r\n\r\n\t[Property, Feature(\"Terrain\"), Group(\"Texture\"), Range(100f, 1000f)]\r\n\tpublic float TerrainEdgeRadius { get; set; } = 500f;\r\n\r\n\t[Property, Feature(\"Terrain\"), Group(\"Texture\")]\r\n\tpublic TerrainTextureLayer TerrainTargetLayer { get; set; } = TerrainTextureLayer.Overlay;\r\n\r\n\t[Property, Feature(\"Terrain\"), Group(\"Texture\"), Range(0f, 1f)]\r\n\tpublic float TerrainTextureNoise { get; set; } = 0.2f;\r\n\r\n\t[Property, Feature(\"Terrain\"), Group(\"Texture\")]\r\n\tpublic TerrainMaterial[] TerrainEdgeMaterials { get; set; } = Array.Empty<TerrainMaterial>();\r\n\r\n\t[Property, Feature(\"Terrain\"), Group(\"Texture\")]\r\n\tpublic Gradient TerrainEdgeBlendGradient = new Gradient(\r\n\t\tnew Gradient.ColorFrame(0, Color.White),\r\n\t\tnew Gradient.ColorFrame(1, Color.White.WithAlpha(0f))\r\n\t);\r\n\r\n\r\n\r\n\t[Button(\"Apply to the Ground\"), Feature(\"Terrain\")]\r\n\tprivate void ApplyTerrainToGround()\r\n\t{\r\n\t\tif (!Scene.IsEditor)\r\n\t\t\treturn;\r\n\r\n\t\tAdaptTerrainToIntersection();\r\n\t}\r\n\r\n\r\n\r\n\tpublic void AdaptTerrainToIntersection()\r\n\t{\r\n\t\tif (!TerrainTarget.IsValid())\r\n\t\t{\r\n\t\t\t// Always take the closest terrain\r\n\t\t\tTerrainTarget = Scene.GetAllComponents<Terrain>().OrderBy(x => x.WorldPosition.DistanceSquared(WorldPosition)).FirstOrDefault();\r\n\t\t}\r\n\r\n\t\tif (!TerrainTarget.IsValid())\r\n\t\t{\r\n\t\t\tLog.Warning(\"RoadTool: No Terrain found in scene.\");\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar storage = TerrainTarget.Storage;\r\n\t\tif (storage == null || storage.HeightMap == null) return;\r\n\r\n\t\t// 1. Setup Parameters \r\n\t\tint resolution = storage.Resolution;\r\n\t\tfloat terrainSize = storage.TerrainSize;\r\n\t\tfloat terrainMaxHeight = storage.TerrainHeight;\r\n\t\tfloat halfSize = terrainSize * 0.5f;\r\n\r\n\t\t// Calculate bounds including falloff\r\n\t\tfloat boundSize = (Shape == IntersectionShape.Rectangle ? Math.Max(Width, Length) * 0.5f : Radius) + TerrainFalloffRadius;\r\n\t\tBBox worldBounds = new BBox(WorldPosition - new Vector3(boundSize), WorldPosition + new Vector3(boundSize));\r\n\r\n\t\tvar heightMap = storage.HeightMap;\r\n\r\n\t\t// Capture initial state for Undo\r\n\t\tbool hasModified = false;\r\n\r\n\t\t// Initialize buffers for height calculation \r\n\t\tvar updatedHeights = new float[heightMap.Length];\r\n\t\tvar bestDistance = new float[heightMap.Length];\r\n\r\n\t\tfor (int i = 0; i < heightMap.Length; i++)\r\n\t\t{\r\n\t\t\t// Decode: Map [0..1] ushort to [0 .. MaxHeight] to match RoadComponent\r\n\t\t\tupdatedHeights[i] = (heightMap[i] / (float)ushort.MaxValue) * terrainMaxHeight;\r\n\t\t\tbestDistance[i] = float.MaxValue;\r\n\t\t}\r\n\r\n\t\tBuildRectangleExitCorridors();\r\n\r\n\t\t// 2. Grid Traversal\r\n\t\tfor (int ix = 0; ix < resolution; ix++)\r\n\t\t{\r\n\t\t\tfor (int iy = 0; iy < resolution; iy++)\r\n\t\t\t{\r\n\t\t\t\t// 1. Adaptive coordinate detection (Center vs Corner) matching RoadComponent\r\n\t\t\t\tfloat nodeLocalX_corner = (ix / (float)(resolution - 1)) * terrainSize;\r\n\t\t\t\tfloat nodeLocalY_corner = (iy / (float)(resolution - 1)) * terrainSize;\r\n\r\n\t\t\t\tfloat nodeLocalX = nodeLocalX_corner;\r\n\t\t\t\tfloat nodeLocalY = nodeLocalY_corner;\r\n\r\n\t\t\t\t// Check if the intersection is in the centered range\r\n\t\t\t\tvar checkPos = TerrainTarget.Transform.World.PointToLocal(WorldPosition);\r\n\t\t\t\tif (checkPos.x < 0f || checkPos.x > terrainSize || checkPos.y < 0f || checkPos.y > terrainSize)\r\n\t\t\t\t{\r\n\t\t\t\t\tnodeLocalX = nodeLocalX_corner - halfSize;\r\n\t\t\t\t\tnodeLocalY = nodeLocalY_corner - halfSize;\r\n\t\t\t\t}\r\n\r\n\t\t\t\tVector3 pixelWorldPos = TerrainTarget.Transform.World.PointToWorld(new Vector3(nodeLocalX, nodeLocalY, 0));\r\n\r\n\t\t\t\tif (!worldBounds.Contains(pixelWorldPos)) continue;\r\n\r\n\t\t\t\tint index = iy * resolution + ix;\r\n\r\n\t\t\t\t// 2. Distance to intersection shape \r\n\t\t\t\tVector3 relativePos = WorldTransform.PointToLocal(pixelWorldPos);\r\n\t\t\t\tfloat distance = GetDistanceToIntersectionShape(relativePos.WithZ(0));\r\n\r\n\t\t\t\tif (distance > TerrainFalloffRadius) continue;\r\n\r\n\t\t\t\t// 3. Target height matching RoadComponent (0 to MaxHeight range)\r\n\t\t\t\tVector3 intersectionLocalPos = TerrainTarget.Transform.World.PointToLocal(WorldPosition);\r\n\t\t\t\tfloat roadSurfaceHeight = Math.Clamp(intersectionLocalPos.z + TerrainHeightOffset, 0f, terrainMaxHeight);\r\n\t\t\t\tfloat roadInsetHeight = Math.Clamp(roadSurfaceHeight - TerrainRoadInset, 0f, terrainMaxHeight);\r\n\t\t\t\tfloat currentPixelHeight = (heightMap[index] / (float)ushort.MaxValue) * terrainMaxHeight;\r\n\r\n\t\t\t\tfloat candidateHeight;\r\n\t\t\t\tif (distance <= 0) // Inside the intersection \u2014 sink terrain below road to prevent Z-fighting\r\n\t\t\t\t{\r\n\t\t\t\t\tcandidateHeight = roadInsetHeight;\r\n\t\t\t\t}\r\n\t\t\t\telse if (SidewalkWidth > 0f && distance <= SidewalkWidth) // Sidewalk ring \u2014 flush with road surface\r\n\t\t\t\t{\r\n\t\t\t\t\tcandidateHeight = roadSurfaceHeight;\r\n\t\t\t\t}\r\n\t\t\t\telse // Falloff \u2014 blend from surface/inset back to original terrain\r\n\t\t\t\t{\r\n\t\t\t\t\tfloat transitionStart = SidewalkWidth > 0f ? SidewalkWidth : 0f;\r\n\t\t\t\t\tfloat transitionBaseHeight = SidewalkWidth > 0f ? roadSurfaceHeight : roadInsetHeight;\r\n\t\t\t\t\tfloat t = Math.Clamp((distance - transitionStart) / TerrainFalloffRadius, 0f, 1f);\r\n\t\t\t\t\tfloat smoothT = t * t * (3f - 2f * t);\r\n\t\t\t\t\tcandidateHeight = MathX.Lerp(transitionBaseHeight, currentPixelHeight, smoothT);\r\n\t\t\t\t}\r\n\r\n\t\t\t\tif (distance < bestDistance[index])\r\n\t\t\t\t{\r\n\t\t\t\t\tbestDistance[index] = distance;\r\n\t\t\t\t\tupdatedHeights[index] = candidateHeight;\r\n\t\t\t\t\thasModified = true;\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tif (hasModified)\r\n\t\t{\r\n\t\t\t// 4. Final encoding to ushort (Mapping back to 0..1 without the 0.5 offset)\r\n\t\t\tfor (int i = 0; i < heightMap.Length; i++)\r\n\t\t\t{\r\n\t\t\t\theightMap[i] = (ushort)MathF.Round(Math.Clamp(updatedHeights[i], 0f, terrainMaxHeight) / terrainMaxHeight * ushort.MaxValue);\r\n\t\t\t}\r\n\r\n\t\t\tstorage.HeightMap = heightMap;\r\n\t\t\tstorage.StateHasChanged();\r\n\t\t\tTerrainTarget.Create();\r\n\t\t}\r\n\t}\r\n\r\n\tpublic void PaintTerrainToIntersection()\r\n\t{\r\n\t\tif (!TerrainTarget.IsValid() || TerrainEdgeMaterials == null || TerrainEdgeMaterials.Length == 0) return;\r\n\r\n\t\tvar storage = TerrainTarget.Storage;\r\n\t\tif (storage == null || storage.ControlMap == null) return;\r\n\r\n\t\tint resolution = storage.Resolution;\r\n\t\tfloat terrainSize = storage.TerrainSize;\r\n\t\tfloat halfSize = terrainSize * 0.5f;\r\n\r\n\t\t// Identify all material indices in the terrain storage \r\n\t\tbool materialsAdded = false;\r\n\t\tvar materialIndices = new int[TerrainEdgeMaterials.Length];\r\n\t\tfor (int m = 0; m < TerrainEdgeMaterials.Length; m++)\r\n\t\t{\r\n\t\t\tif (TerrainEdgeMaterials[m] == null) continue;\r\n\r\n\t\t\tint idx = storage.Materials.IndexOf(TerrainEdgeMaterials[m]);\r\n\t\t\tif (idx == -1)\r\n\t\t\t{\r\n\t\t\t\tstorage.Materials.Add(TerrainEdgeMaterials[m]);\r\n\t\t\t\tidx = storage.Materials.Count - 1;\r\n\t\t\t\tmaterialsAdded = true;\r\n\t\t\t}\r\n\r\n\t\t\tif (idx > 31)\r\n\t\t\t{\r\n\t\t\t\tLog.Error($\"RoadTool: Terrain has too many materials ({idx}). Material '{TerrainEdgeMaterials[m].ResourceName}' cannot be painted.\");\r\n\t\t\t\tidx = 0;\r\n\t\t\t}\r\n\r\n\t\t\tmaterialIndices[m] = idx;\r\n\t\t}\r\n\r\n\t\tif (materialsAdded)\r\n\t\t{\r\n\t\t\tstorage.StateHasChanged();\r\n\t\t\tTerrainTarget.Create();\r\n\t\t}\r\n\r\n\t\tfloat boundSize = (Shape == IntersectionShape.Rectangle ? Math.Max(Width, Length) * 0.5f : Radius) + TerrainEdgeRadius; // This line is unchanged \r\n\t\tBBox worldBounds = new BBox(WorldPosition - new Vector3(boundSize), WorldPosition + new Vector3(boundSize)); // This line is unchanged\r\n\r\n\t\tBuildRectangleExitCorridors();\r\n\r\n\t\tvar controlMap = storage.ControlMap;\r\n\t\tbool hasModified = false;\r\n\r\n\t\tfor (int ix = 0; ix < resolution; ix++)\r\n\t\t{\r\n\t\t\tfor (int iy = 0; iy < resolution; iy++)\r\n\t\t\t{\r\n\t\t\t\tfloat nodeLocalX = (ix / (float)(resolution - 1)) * terrainSize;\r\n\t\t\t\tfloat nodeLocalY = (iy / (float)(resolution - 1)) * terrainSize;\r\n\r\n\t\t\t\tvar checkPos = TerrainTarget.Transform.World.PointToLocal(WorldPosition);\r\n\t\t\t\tif (checkPos.x < 0f || checkPos.x > terrainSize || checkPos.y < 0f || checkPos.y > terrainSize)\r\n\t\t\t\t{\r\n\t\t\t\t\tnodeLocalX -= halfSize;\r\n\t\t\t\t\tnodeLocalY -= halfSize;\r\n\t\t\t\t}\r\n\r\n\t\t\t\tVector3 pixelWorldPos = TerrainTarget.Transform.World.PointToWorld(new Vector3(nodeLocalX, nodeLocalY, 0));\r\n\t\t\t\tif (!worldBounds.Contains(pixelWorldPos)) continue;\r\n\r\n\t\t\t\tVector3 relativePos = WorldTransform.PointToLocal(pixelWorldPos);\r\n\t\t\t\tfloat distance = GetDistanceToIntersectionShape(relativePos.WithZ(0));\r\n\r\n\t\t\t\tif (distance > TerrainEdgeRadius) continue;\r\n\r\n\t\t\t\tint index = iy * resolution + ix;\r\n\t\t\t\tfloat t = Math.Clamp(distance / TerrainEdgeRadius, 0f, 1f);\r\n\t\t\t\tfloat blendStrength = TerrainEdgeBlendGradient.Evaluate(t).a;\r\n\r\n\t\t\t\tif (blendStrength > 0.01f)\r\n\t\t\t\t{\r\n\t\t\t\t\t// Add deterministic noise to blend textures together (Dithering)\r\n\t\t\t\t\tfloat pixelNoise = ((float)((index * 1103515245 + 12345) & 0x7FFFFFFF) / 0x7FFFFFFF) * TerrainTextureNoise - (TerrainTextureNoise * 0.5f);\r\n\t\t\t\t\tfloat noisyT = Math.Clamp(t + pixelNoise, 0f, 1f);\r\n\t\t\t\t\tfloat noisyDistance = distance + (pixelNoise * TerrainEdgeRadius);\r\n\r\n\t\t\t\t\tint materialIndex;\r\n\t\t\t\t\tif (noisyDistance <= 0)\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tmaterialIndex = materialIndices[0];\r\n\t\t\t\t\t}\r\n\t\t\t\t\telse\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tint edgeMatCount = materialIndices.Length - 1;\r\n\t\t\t\t\t\t// Using noisyT for index selection \r\n\t\t\t\t\t\tint edgeIdx = edgeMatCount > 0 ? Math.Clamp((int)(noisyT * edgeMatCount), 0, edgeMatCount - 1) + 1 : 0;\r\n\t\t\t\t\t\tmaterialIndex = materialIndices[edgeIdx];\r\n\t\t\t\t\t}\r\n\r\n\t\t\t\t\tuint packed = controlMap[index];\r\n\t\t\t\t\tvar mat = new CompactTerrainMaterial(packed);\r\n\r\n\t\t\t\t\tif (TerrainTargetLayer == TerrainTextureLayer.Base)\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tmat.BaseTextureId = (byte)materialIndex;\r\n\t\t\t\t\t\tmat.BlendFactor = (byte)MathX.Lerp(mat.BlendFactor, 0, blendStrength);\r\n\t\t\t\t\t}\r\n\t\t\t\t\telse\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\t// Otherwise, we place it in Overlay and increase the BlendFactor to display it\r\n\t\t\t\t\t\tmat.OverlayTextureId = (byte)materialIndex;\r\n\t\t\t\t\t\tmat.BlendFactor = (byte)MathX.Lerp(mat.BlendFactor, 255, blendStrength);\r\n\t\t\t\t\t}\r\n\r\n\t\t\t\t\tcontrolMap[index] = mat.Packed;\r\n\t\t\t\t\thasModified = true;\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tif (hasModified)\r\n\t\t{\r\n\t\t\tstorage.ControlMap = controlMap;\r\n\t\t\tstorage.StateHasChanged();\r\n\t\t\tTerrainTarget.SyncGPUTexture();\r\n\t\t}\r\n\t}\r\n\r\n\t// Per-opening exit corridors in local space, rebuilt once per flatten so the per-pixel distance test stays cheap.\r\n\t// Each entry is an opening's road-edge centre, its outward direction, its lateral direction, and half its width.\r\n\tprivate (Vector3 Center, Vector3 Outward, Vector3 Lateral, float Half)[] m_RectangleExitCorridors = Array.Empty<(Vector3, Vector3, Vector3, float)>();\r\n\r\n\tprivate void BuildRectangleExitCorridors()\r\n\t{\r\n\t\tif (Shape != IntersectionShape.Rectangle)\r\n\t\t{\r\n\t\t\tm_RectangleExitCorridors = Array.Empty<(Vector3, Vector3, Vector3, float)>();\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tEnsureRectangleExits();\r\n\r\n\t\tm_RectangleExitCorridors = Exits\r\n\t\t\t.Where(exit => exit != null)\r\n\t\t\t.Select(exit =>\r\n\t\t\t{\r\n\t\t\t\tTransform t = GetRectangleExitLocalTransform(exit.Side, false, exit.Offset);\r\n\t\t\t\treturn (t.Position, t.Rotation.Forward, t.Rotation.Right, exit.Width * 0.5f);\r\n\t\t\t})\r\n\t\t\t.ToArray();\r\n\t}\r\n\r\n\r\n\r\n\tprivate float GetDistanceToIntersectionShape(Vector3 localPixelPos)\r\n\t{\r\n\t\tif (Shape == IntersectionShape.Rectangle)\r\n\t\t{\r\n\t\t\tfloat hl = Length * 0.5f;\r\n\t\t\tfloat hw = Width * 0.5f;\r\n\t\t\tfloat dx = MathF.Max(MathF.Abs(localPixelPos.x) - hl, 0);\r\n\t\t\tfloat dy = MathF.Max(MathF.Abs(localPixelPos.y) - hw, 0);\r\n\t\t\tfloat dist = MathF.Sqrt(dx * dx + dy * dy);\r\n\r\n\t\t\t// Treat each open exit's corridor as inside, so terrain doesn't poke up through a road opening. A corridor is\r\n\t\t\t// the band beyond an opening's road edge (outward) and within that opening's width (lateral) \u2014 one per opening.\r\n\t\t\tif (dist > 0)\r\n\t\t\t{\r\n\t\t\t\tforeach (var corridor in m_RectangleExitCorridors)\r\n\t\t\t\t{\r\n\t\t\t\t\tVector3 toPixel = localPixelPos - corridor.Center;\r\n\r\n\t\t\t\t\tif (Vector3.Dot(toPixel, corridor.Outward) >= 0.0f && MathF.Abs(Vector3.Dot(toPixel, corridor.Lateral)) <= corridor.Half)\r\n\t\t\t\t\t\treturn 0;\r\n\t\t\t\t}\r\n\t\t\t}\r\n\r\n\t\t\treturn dist;\r\n\t\t}\r\n\r\n\t\t// Circle\r\n\t\tfloat radDist = MathF.Max(localPixelPos.WithZ(0).Length - Radius, 0);\r\n\r\n\t\tif (radDist > 0 && CircleExits != null && CircleExits.Length > 0)\r\n\t\t{\r\n\t\t\tVector3 pixelDir = localPixelPos.WithZ(0);\r\n\t\t\tif (pixelDir.LengthSquared > 0.0001f)\r\n\t\t\t\tpixelDir = pixelDir.Normal;\r\n\r\n\t\t\tforeach (var exit in CircleExits)\r\n\t\t\t{\r\n\t\t\t\t// Use dot product to stay independent of angle conventions\r\n\t\t\t\tVector3 exitDir = Rotation.FromYaw(exit.AngleDegrees).Forward;\r\n\t\t\t\tfloat cosHalfAngle = MathF.Cos(MathF.Atan(exit.RoadWidth / Radius));\r\n\t\t\t\tif (Vector3.Dot(pixelDir, exitDir) >= cosHalfAngle)\r\n\t\t\t\t\treturn 0;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn radDist;\r\n\t}\r\n}\r\n"
},
{
"Ident": "redsnail.roadtool",
"Path": "RoadIntersectionComponent/RoadIntersectionComponent.Traffic.cs",
"FileName": "RoadIntersectionComponent.Traffic.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 342326,
"IsPrivate": false,
"Code": "using System;\nusing System.Collections.Generic;\nusing Sandbox;\n\nnamespace RedSnail.RoadTool;\n\npublic partial class RoadIntersectionComponent\n{\n\t/// <summary>\n\t/// A single drivable exit of an intersection, expressed in world space.\n\t/// <see cref=\"Transform\"/>.Forward points outward (away from the intersection), matching the snap targets.\n\t/// </summary>\n\tpublic readonly struct TrafficExit\n\t{\n\t\tpublic Transform Transform { get; init; }\n\t\tpublic float RoadWidth { get; init; }\n\n\t\t/// <summary>Pedestrians get no crossing over this arm's mouth. Vehicles are unaffected.</summary>\n\t\tpublic bool NoCrossing { get; init; }\n\t}\n\n\t/// <summary>\n\t/// When enabled, this intersection is ignored by the traffic system: vehicles will not route through it.\n\t/// </summary>\n\t[Property, Feature(\"General\"), Category(\"Traffic\"), Order(2)] public bool ExcludeTraffic { get; set; } = false;\n\n\t/// <summary>Speed limit for traffic crossing this intersection, in km/h.</summary>\n\t[Property, Feature(\"General\"), Category(\"Traffic\"), Order(2), Range(5.0f, 130.0f)] public float SpeedLimit { get; set; } = 30.0f;\n\n\n\n\t/// <summary>\n\t/// Enumerates every active exit of this intersection (rectangle or circle) as a world transform plus road width.\n\t/// These are the same outer-edge positions that <see cref=\"SnapNearbyRoads\"/> snaps roads to, so the traffic\n\t/// graph can match road endpoints against them by proximity.\n\t/// </summary>\n\tpublic List<TrafficExit> GetTrafficExits()\n\t{\n\t\tvar exits = new List<TrafficExit>();\n\n\t\tif (Shape == IntersectionShape.Rectangle)\n\t\t{\n\t\t\tEnsureRectangleExits();\n\n\t\t\tforeach (var exit in Exits)\n\t\t\t{\n\t\t\t\tif (exit is null)\n\t\t\t\t\tcontinue;\n\n\t\t\t\texits.Add(new TrafficExit\n\t\t\t\t{\n\t\t\t\t\tTransform = GetRectangleExitTransform(exit.Side, true, exit.Offset),\n\t\t\t\t\tRoadWidth = exit.Width,\n\t\t\t\t\tNoCrossing = exit.NoCrossing\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\tvar circleExits = CircleExits ?? Array.Empty<CircleExit>();\n\n\t\t\tfor (int i = 0; i < circleExits.Length; i++)\n\t\t\t{\n\t\t\t\texits.Add(new TrafficExit\n\t\t\t\t{\n\t\t\t\t\tTransform = GetCircleExitTransform(i, true),\n\t\t\t\t\tRoadWidth = circleExits[i].RoadWidth,\n\t\t\t\t\tNoCrossing = circleExits[i].NoCrossing\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\n\t\treturn exits;\n\t}\n}\n"
},
{
"Ident": "redsnail.roadtool",
"Path": "RoadIntersectionComponent/RoadIntersectionComponent.Utility.cs",
"FileName": "RoadIntersectionComponent.Utility.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 342326,
"IsPrivate": false,
"Code": "using System;\r\n\r\nnamespace RedSnail.RoadTool;\r\n\r\npublic partial class RoadIntersectionComponent\r\n{\r\n\t// Computes a quadratic Bezier control point at the intersection of the two tangent lines.\r\n\t// Returns true if the lines intersect, false if parallel (in which case the midpoint is used as a fallback).\r\n\t// The control distance along _StartTan is clamped to the chord length so asymmetric tangents (e.g. an\r\n\t// off-grid exit angle whose disc tangent points well past the outer corner) don't drive the bezier past\r\n\t// the outer endpoint \u2014 overshoot produces samples beyond the endpoint and flips downstream triangle winding.\r\n\tprivate static bool TryBezierControl(Vector3 _Start, Vector3 _StartTan, Vector3 _End, Vector3 _EndTan, out Vector3 _Control)\r\n\t{\r\n\t\tfloat det = _StartTan.x * _EndTan.y - _EndTan.x * _StartTan.y;\r\n\r\n\t\tif (MathF.Abs(det) < 0.0001f)\r\n\t\t{\r\n\t\t\t_Control = (_Start + _End) * 0.5f;\r\n\t\t\treturn false;\r\n\t\t}\r\n\r\n\t\tVector3 d = _End - _Start;\r\n\t\tfloat r = (d.x * _EndTan.y - _EndTan.x * d.y) / det;\r\n\t\tfloat rMax = d.Length;\r\n\t\tfloat rClamped = Math.Clamp(r, 0.0f, rMax);\r\n\t\t_Control = _Start + rClamped * _StartTan;\r\n\t\treturn true;\r\n\t}\r\n\r\n\tprivate static Vector3 SampleQuadBezier(Vector3 _B0, Vector3 _B1, Vector3 _B2, float _T)\r\n\t{\r\n\t\tfloat u = 1.0f - _T;\r\n\t\treturn u * u * _B0 + 2.0f * u * _T * _B1 + _T * _T * _B2;\r\n\t}\r\n}\r\n"
},
{
"Ident": "redsnail.roadtool",
"Path": "RoadManager/RoadTrafficGraph.Routing.cs",
"FileName": "RoadTrafficGraph.Routing.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 342326,
"IsPrivate": false,
"Code": "using System;\nusing System.Collections.Generic;\nusing Sandbox;\n\nnamespace RedSnail.RoadTool;\n\n/// <summary>\n/// Routing queries over the lane graph \u2014 how far it actually is to drive from one place to another.\n///\n/// \"Actually\" is the point: straight-line distance is useless for anything that has to obey roads. A delivery\n/// two blocks away as the crow flies can be a mile of one-way streets, and a job that times you on crow-flight\n/// distance is unwinnable in exactly the places that are most interesting to drive.\n/// </summary>\npublic sealed partial class RoadTrafficGraph\n{\n\t/// <summary>\n\t/// How far either end of a route may be from a lane and still count as being on it. A road carries a lane\n\t/// per direction, so a single point sits near several \u2014 see <see cref=\"FindNearbyLanes\"/> for why taking\n\t/// them all matters.\n\t/// </summary>\n\tpublic const float DefaultRouteSnapRadius = 1000.0f;\n\n\n\n\t/// <summary>\n\t/// Driving distance from <paramref name=\"_From\"/> to <paramref name=\"_To\"/>, following lanes in their legal\n\t/// direction. False when neither end is anywhere near a road, or when no route exists at all \u2014 a one-way\n\t/// system can genuinely have no way round.\n\t///\n\t/// Dijkstra over whole lanes rather than individual waypoints: lanes are the unit the graph is linked in,\n\t/// and a city's worth of them is a few thousand nodes. Costs are measured to the START of each lane, with\n\t/// the partial lengths at both ends added on, so the answer is measured between the actual points.\n\t/// </summary>\n\tpublic bool TryGetDrivingDistance(Vector3 _From, Vector3 _To, out float _Distance)\n\t{\n\t\t_Distance = 0.0f;\n\n\t\tList<(TrafficLane Lane, int Index)> starts = FindNearbyLanes(_From);\n\t\tList<(TrafficLane Lane, int Index)> goals = FindNearbyLanes(_To);\n\n\t\tif (starts.Count == 0 || goals.Count == 0)\n\t\t\treturn false;\n\n\t\tvar goalIndices = new Dictionary<TrafficLane, int>();\n\n\t\tforeach ((TrafficLane lane, int index) in goals)\n\t\t\tgoalIndices[lane] = index;\n\n\t\tvar best = new Dictionary<TrafficLane, float>();\n\t\tvar queue = new PriorityQueue<TrafficLane, float>();\n\n\t\tfloat bestTotal = float.MaxValue;\n\n\t\tforeach ((TrafficLane lane, int index) in starts)\n\t\t{\n\t\t\t// Goal on the same lane and ahead of us: straight down the road, no junction involved. Behind us\n\t\t\t// doesn't count \u2014 a lane is one-way, so that really does mean driving round and coming back, which\n\t\t\t// the search below works out properly.\n\t\t\tif (goalIndices.TryGetValue(lane, out int goalIndex) && goalIndex >= index)\n\t\t\t\tbestTotal = Math.Min(bestTotal, lane.DistanceFromStart(goalIndex) - lane.DistanceFromStart(index));\n\n\t\t\t// We start partway along, so what's reachable is the successors, at the cost of finishing this lane.\n\t\t\tfloat toEnd = lane.DistanceToEnd(index);\n\n\t\t\tforeach (TrafficLane next in lane.Successors)\n\t\t\t\tRelax(next, toEnd, best, queue);\n\t\t}\n\n\t\twhile (queue.TryDequeue(out TrafficLane lane, out float cost))\n\t\t{\n\t\t\t// Min-ordered, so once the cheapest thing left already costs more than an answer we have, nothing\n\t\t\t// better can come out of it.\n\t\t\tif (cost >= bestTotal)\n\t\t\t\tbreak;\n\n\t\t\tif (best.TryGetValue(lane, out float known) && cost > known)\n\t\t\t\tcontinue;\n\n\t\t\t// Reaching a goal lane doesn't end the search: another route might arrive at a different goal\n\t\t\t// candidate \u2014 the other side of the same street, say \u2014 for less.\n\t\t\tif (goalIndices.TryGetValue(lane, out int goalIndex))\n\t\t\t\tbestTotal = Math.Min(bestTotal, cost + lane.DistanceFromStart(goalIndex));\n\n\t\t\tfloat exit = cost + lane.Length;\n\n\t\t\tforeach (TrafficLane next in lane.Successors)\n\t\t\t\tRelax(next, exit, best, queue);\n\t\t}\n\n\t\tif (bestTotal >= float.MaxValue)\n\t\t\treturn false;\n\n\t\t_Distance = Math.Max(0.0f, bestTotal);\n\n\t\treturn true;\n\t}\n\n\n\n\t/// <summary>\n\t/// The actual waypoints to DRIVE from one point to another, following lanes in their legal direction. False\n\t/// when there's no route, same as <see cref=\"TryGetDrivingDistance\"/>.\n\t///\n\t/// This is the vehicle counterpart of a navmesh path, and it has to be a separate structure rather than the\n\t/// navmesh itself. A navmesh is baked around a person \u2014 it runs over pavements, through doorways and up\n\t/// stairs, and its corridors are person-wide. A route down one is a perfectly valid walk and an impossible\n\t/// drive, and unlike a pedestrian scraping a wall, a car routed somewhere it doesn't fit is stuck for good.\n\t/// Lanes are the drivable surface by construction, and they carry direction, which a navmesh has no concept\n\t/// of at all.\n\t///\n\t/// The list is reused rather than returned, because anything chasing anything re-routes constantly.\n\t/// </summary>\n\tpublic bool TryGetDrivingRoute(Vector3 _From, Vector3 _To, List<Vector3> _Route)\n\t{\n\t\t_Route.Clear();\n\n\t\tif (!TrySearchRoute(_From, _To, out RouteSearch search))\n\t\t\treturn false;\n\n\t\tBuildRoute(search.Starts, search.GoalIndices, search.CameFrom, search.Goal, search.Start, search.StartIndex, _Route);\n\n\t\treturn _Route.Count > 0;\n\t}\n\n\n\n\t/// <summary>\n\t/// The same route as <see cref=\"TryGetDrivingRoute\"/>, but as the LANES to drive rather than the waypoints\n\t/// along them \u2014 including the one we're starting on.\n\t///\n\t/// This is the form anything that already knows how to drive a lane wants. <see cref=\"TrafficVehicle\"/>\n\t/// follows lanes and picks a successor at every junction; handing it a lane list turns \"wander\" into \"go\n\t/// here\" without touching a single line of how it actually steers, brakes or corners.\n\t/// </summary>\n\tpublic bool TryGetDrivingLaneRoute(Vector3 _From, Vector3 _To, List<TrafficLane> _Route)\n\t{\n\t\treturn TryGetDrivingLaneRoute(FindNearbyLanes(_From), _To, _Route);\n\t}\n\n\n\n\t/// <summary>\n\t/// The same, but starting from the lane a vehicle is ALREADY DRIVING rather than from its position.\n\t///\n\t/// This is the overload anything mid-journey wants, and the difference is not subtle. Asking by position\n\t/// seeds the search with every lane in range \u2014 including the one going the other way down the same road \u2014\n\t/// and Dijkstra will happily return the shortest route from whichever of those is cheapest. That route\n\t/// starts on a lane the vehicle is not on, so it never matches, and a driver that can't find itself in its\n\t/// own route falls back to picking turns at random. The symptom is a car that mostly goes the right way and\n\t/// occasionally sets off round the block for no visible reason.\n\t/// </summary>\n\tpublic bool TryGetDrivingLaneRoute(TrafficLane _FromLane, Vector3 _FromPosition, Vector3 _To, List<TrafficLane> _Route)\n\t{\n\t\t_Route.Clear();\n\n\t\tif (_FromLane is null || _FromLane.Waypoints.Count == 0)\n\t\t\treturn false;\n\n\t\treturn TryGetDrivingLaneRoute([(_FromLane, NearestWaypointIndex(_FromLane, _FromPosition))], _To, _Route);\n\t}\n\n\n\n\t/// <summary>Which waypoint of a lane is closest to a point.</summary>\n\tprivate static int NearestWaypointIndex(TrafficLane _Lane, Vector3 _Point)\n\t{\n\t\tint best = 0;\n\t\tfloat bestDistance = float.MaxValue;\n\n\t\tfor (int i = 0; i < _Lane.Waypoints.Count; i++)\n\t\t{\n\t\t\tfloat distance = _Lane.Waypoints[i].DistanceSquared(_Point);\n\n\t\t\tif (distance >= bestDistance)\n\t\t\t\tcontinue;\n\n\t\t\tbestDistance = distance;\n\t\t\tbest = i;\n\t\t}\n\n\t\treturn best;\n\t}\n\n\n\n\tprivate bool TryGetDrivingLaneRoute(List<(TrafficLane Lane, int Index)> _Starts, Vector3 _To, List<TrafficLane> _Route)\n\t{\n\t\t_Route.Clear();\n\n\t\tif (!TrySearchRoute(_Starts, _To, out RouteSearch search))\n\t\t\treturn false;\n\n\t\t// Never left the start lane \u2014 the route is just that one.\n\t\tif (search.Goal is null)\n\t\t{\n\t\t\tif (search.Start is null)\n\t\t\t\treturn false;\n\n\t\t\t_Route.Add(search.Start);\n\n\t\t\treturn true;\n\t\t}\n\n\t\tList<TrafficLane> chain = BuildLaneChain(search.CameFrom, search.Goal);\n\n\t\tif (chain.Count == 0)\n\t\t\treturn false;\n\n\t\t// The lane we're ON isn't in the chain (the chain begins at one of its successors), and a driver already\n\t\t// travelling it needs to see it in the list or its very first junction is an unplanned one.\n\t\tforeach ((TrafficLane lane, int _) in search.Starts)\n\t\t{\n\t\t\tif (!lane.Successors.Contains(chain[0]))\n\t\t\t\tcontinue;\n\n\t\t\t_Route.Add(lane);\n\n\t\t\tbreak;\n\t\t}\n\n\t\t_Route.AddRange(chain);\n\n\t\treturn true;\n\t}\n\n\n\n\t/// <summary>What a completed search found: the winning route's ends, and the map to walk it back with.</summary>\n\tprivate struct RouteSearch\n\t{\n\t\tpublic List<(TrafficLane Lane, int Index)> Starts;\n\t\tpublic Dictionary<TrafficLane, int> GoalIndices;\n\t\tpublic Dictionary<TrafficLane, TrafficLane> CameFrom;\n\n\t\t/// <summary>The lane the route ends on, or null when it never left the lane it started on.</summary>\n\t\tpublic TrafficLane Goal;\n\n\t\tpublic TrafficLane Start;\n\t\tpublic int StartIndex;\n\t}\n\n\n\n\t/// <summary>\n\t/// The Dijkstra itself, shared by both route shapes so there's one search to be correct rather than two to\n\t/// keep in step.\n\t/// </summary>\n\tprivate bool TrySearchRoute(Vector3 _From, Vector3 _To, out RouteSearch _Result)\n\t{\n\t\treturn TrySearchRoute(FindNearbyLanes(_From), _To, out _Result);\n\t}\n\n\n\n\t/// <inheritdoc cref=\"TrySearchRoute(Vector3, Vector3, out RouteSearch)\"/>\n\tprivate bool TrySearchRoute(List<(TrafficLane Lane, int Index)> _Starts, Vector3 _To, out RouteSearch _Result)\n\t{\n\t\t_Result = default;\n\n\t\tList<(TrafficLane Lane, int Index)> starts = _Starts;\n\t\tList<(TrafficLane Lane, int Index)> goals = FindNearbyLanes(_To);\n\n\t\tif (starts is null || starts.Count == 0 || goals.Count == 0)\n\t\t\treturn false;\n\n\t\tvar goalIndices = new Dictionary<TrafficLane, int>();\n\n\t\tforeach ((TrafficLane lane, int index) in goals)\n\t\t\tgoalIndices[lane] = index;\n\n\t\tvar best = new Dictionary<TrafficLane, float>();\n\t\tvar cameFrom = new Dictionary<TrafficLane, TrafficLane>();\n\t\tvar queue = new PriorityQueue<TrafficLane, float>();\n\n\t\t// The winning route so far: where it ends, and which of the several starts it began at.\n\t\tfloat bestTotal = float.MaxValue;\n\t\tTrafficLane bestGoal = null;\n\t\tTrafficLane bestStart = null;\n\t\tint bestStartIndex = 0;\n\n\t\tforeach ((TrafficLane lane, int index) in starts)\n\t\t{\n\t\t\t// Goal on the same lane and ahead of us \u2014 no junction involved, so the route is just this stretch.\n\t\t\tif (goalIndices.TryGetValue(lane, out int sameLaneGoal) && sameLaneGoal >= index)\n\t\t\t{\n\t\t\t\tfloat direct = lane.DistanceFromStart(sameLaneGoal) - lane.DistanceFromStart(index);\n\n\t\t\t\tif (direct < bestTotal)\n\t\t\t\t{\n\t\t\t\t\tbestTotal = direct;\n\t\t\t\t\tbestGoal = null; // null goal marks \"never left the start lane\"\n\t\t\t\t\tbestStart = lane;\n\t\t\t\t\tbestStartIndex = index;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tfloat toEnd = lane.DistanceToEnd(index);\n\n\t\t\tforeach (TrafficLane next in lane.Successors)\n\t\t\t{\n\t\t\t\tif (Relax(next, toEnd, best, queue))\n\t\t\t\t\tcameFrom[next] = lane;\n\t\t\t}\n\t\t}\n\n\t\twhile (queue.TryDequeue(out TrafficLane lane, out float cost))\n\t\t{\n\t\t\tif (cost >= bestTotal)\n\t\t\t\tbreak;\n\n\t\t\tif (best.TryGetValue(lane, out float known) && cost > known)\n\t\t\t\tcontinue;\n\n\t\t\tif (goalIndices.TryGetValue(lane, out int goalIndex))\n\t\t\t{\n\t\t\t\tfloat total = cost + lane.DistanceFromStart(goalIndex);\n\n\t\t\t\tif (total < bestTotal)\n\t\t\t\t{\n\t\t\t\t\tbestTotal = total;\n\t\t\t\t\tbestGoal = lane;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tfloat exit = cost + lane.Length;\n\n\t\t\tforeach (TrafficLane next in lane.Successors)\n\t\t\t{\n\t\t\t\tif (Relax(next, exit, best, queue))\n\t\t\t\t\tcameFrom[next] = lane;\n\t\t\t}\n\t\t}\n\n\t\tif (bestTotal >= float.MaxValue)\n\t\t\treturn false;\n\n\t\t_Result = new RouteSearch\n\t\t{\n\t\t\tStarts = starts,\n\t\t\tGoalIndices = goalIndices,\n\t\t\tCameFrom = cameFrom,\n\t\t\tGoal = bestGoal,\n\t\t\tStart = bestStart,\n\t\t\tStartIndex = bestStartIndex\n\t\t};\n\n\t\treturn true;\n\t}\n\n\n\n\t/// <summary>\n\t/// Walks the predecessor chain back from the winning goal lane to whichever start it came from, then lays\n\t/// the waypoints down in travel order.\n\t///\n\t/// The two ends are partial lanes \u2014 we join partway along the first and stop partway along the last \u2014 which\n\t/// is why they're handled separately from the whole lanes in between.\n\t/// </summary>\n\tprivate void BuildRoute(List<(TrafficLane Lane, int Index)> _Starts, Dictionary<TrafficLane, int> _GoalIndices,\n\t Dictionary<TrafficLane, TrafficLane> _CameFrom, TrafficLane _Goal,\n\t TrafficLane _Start, int _StartIndex, List<Vector3> _Route)\n\t{\n\t\t// Never left the start lane: one straight run down it.\n\t\tif (_Goal is null)\n\t\t{\n\t\t\tif (_Start is null || !_GoalIndices.TryGetValue(_Start, out int stop))\n\t\t\t\treturn;\n\n\t\t\tfor (int i = _StartIndex; i <= stop; i++)\n\t\t\t\t_Route.Add(_Start.Waypoints[i]);\n\n\t\t\treturn;\n\t\t}\n\n\t\tList<TrafficLane> chain = BuildLaneChain(_CameFrom, _Goal);\n\n\t\tif (chain.Count == 0)\n\t\t\treturn;\n\n\t\t// The first lane in the chain is a successor of the start lane, so the start lane itself isn't in it \u2014\n\t\t// find which of the candidates fed it and lay down the tail of that one first.\n\t\tTrafficLane head = chain[0];\n\n\t\tforeach ((TrafficLane lane, int index) in _Starts)\n\t\t{\n\t\t\tif (!lane.Successors.Contains(head))\n\t\t\t\tcontinue;\n\n\t\t\tfor (int i = index; i < lane.Waypoints.Count; i++)\n\t\t\t\t_Route.Add(lane.Waypoints[i]);\n\n\t\t\tbreak;\n\t\t}\n\n\t\tfor (int c = 0; c < chain.Count; c++)\n\t\t{\n\t\t\tTrafficLane lane = chain[c];\n\n\t\t\t// The last one stops at the goal waypoint rather than running to the end of the road.\n\t\t\tint stop = c == chain.Count - 1 && _GoalIndices.TryGetValue(lane, out int goalIndex)\n\t\t\t\t? goalIndex\n\t\t\t\t: lane.Waypoints.Count - 1;\n\n\t\t\tfor (int i = 0; i <= stop; i++)\n\t\t\t\t_Route.Add(lane.Waypoints[i]);\n\t\t}\n\t}\n\n\n\n\t/// <summary>Walks the predecessor map back from a goal lane and returns the chain in travel order.</summary>\n\tprivate List<TrafficLane> BuildLaneChain(Dictionary<TrafficLane, TrafficLane> _CameFrom, TrafficLane _Goal)\n\t{\n\t\tvar chain = new List<TrafficLane>();\n\t\tTrafficLane current = _Goal;\n\n\t\t// Bounded by the lane count so a cycle in the map can't spin forever.\n\t\tfor (int step = 0; step <= Lanes.Count && current is not null; step++)\n\t\t{\n\t\t\tchain.Add(current);\n\n\t\t\tif (!_CameFrom.TryGetValue(current, out TrafficLane previous))\n\t\t\t\tbreak;\n\n\t\t\tcurrent = previous;\n\t\t}\n\n\t\tchain.Reverse();\n\n\t\treturn chain;\n\t}\n\n\n\n\t/// <summary>True when this was an improvement, so the caller knows whether to record the predecessor.</summary>\n\tprivate static bool Relax(TrafficLane _Lane, float _Cost, Dictionary<TrafficLane, float> _Best, PriorityQueue<TrafficLane, float> _Queue)\n\t{\n\t\tif (_Best.TryGetValue(_Lane, out float existing) && existing <= _Cost)\n\t\t\treturn false;\n\n\t\t_Best[_Lane] = _Cost;\n\n\t\t_Queue.Enqueue(_Lane, _Cost);\n\n\t\treturn true;\n\t}\n\n\n\n\t/// <summary>\n\t/// Every drivable lane with a waypoint within <paramref name=\"_Radius\"/> of the point, and which waypoint\n\t/// that was \u2014 at most one entry per lane.\n\t///\n\t/// Taking ALL of them, rather than just the closest, is what makes routing reliable. A road carries a lane\n\t/// per direction, so any point on it is near at least two; picking only the nearest is a coin flip that can\n\t/// land on the one pointing away from where you're going, or on one nothing feeds into. The route then comes\n\t/// back as impossible even though the lane a few metres over is trivially routable. Seeding the search with\n\t/// every candidate \u2014 and accepting any of them at the far end \u2014 also gets the natural answer for free:\n\t/// either side of the street will do, whichever is closer to drive.\n\t///\n\t/// Road lanes only. Snapping an endpoint onto an intersection cross-lane would measure from the middle of\n\t/// a junction.\n\t/// </summary>\n\tpublic List<(TrafficLane Lane, int Index)> FindNearbyLanes(Vector3 _Point, float _Radius = DefaultRouteSnapRadius)\n\t{\n\t\tvar results = new List<(TrafficLane, int)>();\n\n\t\tTrafficLane nearestLane = null;\n\t\tint nearestIndex = 0;\n\t\tfloat nearestDistance = float.MaxValue;\n\n\t\tfloat radiusSquared = _Radius * _Radius;\n\n\t\tforeach (TrafficLane lane in Lanes)\n\t\t{\n\t\t\tif (!lane.IsRoadLane)\n\t\t\t\tcontinue;\n\n\t\t\tint laneIndex = -1;\n\t\t\tfloat laneDistance = float.MaxValue;\n\n\t\t\tfor (int i = 0; i < lane.Waypoints.Count; i++)\n\t\t\t{\n\t\t\t\tfloat distance = lane.Waypoints[i].DistanceSquared(_Point);\n\n\t\t\t\tif (distance >= laneDistance)\n\t\t\t\t\tcontinue;\n\n\t\t\t\tlaneDistance = distance;\n\t\t\t\tlaneIndex = i;\n\t\t\t}\n\n\t\t\tif (laneIndex < 0)\n\t\t\t\tcontinue;\n\n\t\t\tif (laneDistance < nearestDistance)\n\t\t\t{\n\t\t\t\tnearestDistance = laneDistance;\n\t\t\t\tnearestLane = lane;\n\t\t\t\tnearestIndex = laneIndex;\n\t\t\t}\n\n\t\t\tif (laneDistance <= radiusSquared)\n\t\t\t\tresults.Add((lane, laneIndex));\n\t\t}\n\n\t\t// Off-road entirely (a car park, a field) \u2014 the closest lane is still the honest answer, so don't come\n\t\t// back empty and turn a long route into \"no route\".\n\t\tif (results.Count == 0 && nearestLane is not null)\n\t\t\tresults.Add((nearestLane, nearestIndex));\n\n\t\treturn results;\n\t}\n\n\n\n\t/// <summary>\n\t/// The single drivable lane closest to a point, and which waypoint that was. Prefer\n\t/// <see cref=\"FindNearbyLanes\"/> for routing \u2014 one lane is rarely the whole answer for a two-way road.\n\t/// </summary>\n\tpublic TrafficLane FindNearestLane(Vector3 _Point, out int _Index)\n\t{\n\t\tTrafficLane bestLane = null;\n\t\tfloat bestDistance = float.MaxValue;\n\n\t\t_Index = 0;\n\n\t\tforeach (TrafficLane lane in Lanes)\n\t\t{\n\t\t\tif (!lane.IsRoadLane)\n\t\t\t\tcontinue;\n\n\t\t\tfor (int i = 0; i < lane.Waypoints.Count; i++)\n\t\t\t{\n\t\t\t\tfloat distance = lane.Waypoints[i].DistanceSquared(_Point);\n\n\t\t\t\tif (distance >= bestDistance)\n\t\t\t\t\tcontinue;\n\n\t\t\t\tbestDistance = distance;\n\t\t\t\tbestLane = lane;\n\n\t\t\t\t_Index = i;\n\t\t\t}\n\t\t}\n\n\t\treturn bestLane;\n\t}\n}\n"
},
{
"Ident": "redsnail.roadtool",
"Path": "RoadManager/RoadVehicleDriver.cs",
"FileName": "RoadVehicleDriver.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 342326,
"IsPrivate": false,
"Code": "using System;\nusing Sandbox;\n\nnamespace RedSnail.RoadTool;\n\n/// <summary>\n/// A primitive control surface the traffic AI (<see cref=\"TrafficVehicle\"/>) uses to drive ONE vehicle. It's a plain\n/// bag of delegates \u2014 there is no interface for a vehicle controller to implement, and your vehicle code never needs\n/// to reference this library.\n///\n/// The seam is filled in by whoever uses both this tool AND a vehicle controller \u2014 i.e. your GAME \u2014 via\n/// <see cref=\"RoadManager.ResolveVehicleDriver\"/>. The game maps whatever its controller looks like onto these few\n/// delegates. Any field left null is simply skipped. The demo wires <see cref=\"DemoCarController\"/> automatically.\n/// </summary>\npublic sealed class RoadVehicleDriver\n{\n\t/// <summary>True while a player is at the wheel \u2014 the AI then hands this car over for good and never reclaims it.</summary>\n\tpublic Func<bool> IsPlayerDriving;\n\n\t/// <summary>The vehicle body's world velocity. The brain reads it to chase a target speed and to detect being jammed.</summary>\n\tpublic Func<Vector3> Velocity;\n\n\t/// <summary>Tell the controller whether the AI is currently driving this vehicle (vs parked / player-driven). Pushed every frame.</summary>\n\tpublic Action<bool> SetAiControlled;\n\n\t/// <summary>Push the AI's per-frame inputs: throttle and steer in [-1, 1] (steer + = left), plus handbrake.</summary>\n\tpublic Action<float, float, bool> Drive;\n\n\t/// <summary>Optional: max steering angle in degrees, used to widen the entity look-ahead toward where the car is turning.</summary>\n\tpublic Func<float> MaxSteering;\n}\n"
},
{
"Ident": "redsnail.roadtool",
"Path": "Editor/RoadToolWindow/RoadToolWindow.Gizmo.cs",
"FileName": "RoadToolWindow.Gizmo.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 342326,
"IsPrivate": false,
"Code": "using Sandbox;\r\n\r\nnamespace RedSnail.RoadTool.Editor;\r\n\r\npublic partial class RoadToolWindow\r\n{\r\n\tprivate const float GIZMO_BOX_SIZE = 2.0f;\r\n\tprivate const float LINE_THICKNESS = 2.0f;\r\n\tprivate const float TANGENT_LINE_THICKNESS = 0.8f;\r\n\r\n\r\n\r\n\tprivate void DrawGizmos()\r\n\t{\r\n\t\tusing (Gizmo.Scope(\"road_editor\", _targetComponent.WorldTransform))\r\n\t\t{\r\n\t\t\tDrawSplineSegments();\r\n\t\t\tDrawPositionGizmo();\r\n\t\t\tDrawPointControls();\r\n\t\t}\r\n\t}\r\n\r\n\r\n\r\n\tprivate void DrawSplineSegments()\r\n\t{\r\n\t\t_targetComponent.Spline.ConvertToPolyline(ref _polyLine);\r\n\r\n\t\tfor (var i = 0; i < _polyLine.Count - 1; i++)\r\n\t\t{\r\n\t\t\tDrawSegment(i, _polyLine[i], _polyLine[i + 1]);\r\n\t\t}\r\n\t}\r\n\r\n\r\n\r\n\tprivate void DrawSegment(int index, Vector3 start, Vector3 end)\r\n\t{\r\n\t\tusing (Gizmo.Scope(\"segment\" + index))\r\n\t\tusing (Gizmo.Hitbox.LineScope())\r\n\t\t{\r\n\t\t\tGizmo.Draw.LineThickness = LINE_THICKNESS;\r\n\t\t\tGizmo.Hitbox.AddPotentialLine(start, end, LINE_THICKNESS * 2f);\r\n\t\t\tGizmo.Draw.Line(start, end);\r\n\r\n\t\t\tif (Gizmo.IsHovered && Gizmo.HasMouseFocus)\r\n\t\t\t{\r\n\t\t\t\tHandleSegmentHover(start, end);\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\r\n\r\n\tprivate void HandleSegmentHover(Vector3 start, Vector3 end)\r\n\t{\r\n\t\tGizmo.Draw.Color = Color.Cyan;\r\n\r\n\t\tif (!new Line(start, end).ClosestPoint(Gizmo.CurrentRay.ToLocal(Gizmo.Transform),\r\n\t\t\tout Vector3 pointOnLine, out _))\r\n\t\t\treturn;\r\n\r\n\t\tvar hoverSample = _targetComponent.Spline.SampleAtClosestPosition(pointOnLine);\r\n\t\tDrawHoverHandle(pointOnLine, hoverSample.Tangent);\r\n\r\n\t\tif (Gizmo.HasClicked && Gizmo.Pressed.This)\r\n\t\t{\r\n\t\t\tInsertPointAtHover(hoverSample.Distance);\r\n\t\t}\r\n\t}\r\n\r\n\r\n\r\n\tprivate void DrawHoverHandle(Vector3 position, Vector3 tangent)\r\n\t{\r\n\t\tusing (Gizmo.Scope(\"hover_handle\", new Transform(position, Rotation.LookAt(tangent))))\r\n\t\tusing (Gizmo.GizmoControls.PushFixedScale())\r\n\t\t{\r\n\t\t\tGizmo.Draw.SolidBox(BBox.FromPositionAndSize(Vector3.Zero, GIZMO_BOX_SIZE));\r\n\t\t}\r\n\t}\r\n\r\n\r\n\r\n\tprivate void InsertPointAtHover(float distance)\r\n\t{\r\n\t\tusing (CreateUndoScope(\"Added spline point\"))\r\n\t\t{\r\n\t\t\tvar newPointIndex = _targetComponent.Spline.AddPointAtDistance(distance, true);\r\n\t\t\tSelectedPointIndex = newPointIndex;\r\n\t\t\t_inTangentSelected = false;\r\n\t\t\t_outTangentSelected = false;\r\n\t\t}\r\n\t}\r\n\r\n\r\n\r\n\tprivate void DrawPositionGizmo()\r\n\t{\r\n\t\t// The first point sits on the component origin, so its move handle lands right on top of the GameObject's\r\n\t\t// own transform gizmo \u2014 two move gizmos fighting over the same spot. Hide the point-body handle there and\r\n\t\t// let the object transform gizmo move the whole track instead. Tangent editing still gets its own gizmo.\r\n\t\tif (SelectedPointIndex == 0 && !_inTangentSelected && !_outTangentSelected)\r\n\t\t\treturn;\r\n\r\n\t\tvar gizmoPosition = CalculateGizmoPosition();\r\n\r\n\t\tif (!Gizmo.IsShiftPressed)\r\n\t\t{\r\n\t\t\t_draggingOutNewPoint = false;\r\n\t\t}\r\n\r\n\t\tusing (Gizmo.Scope(\"position\", new Transform(gizmoPosition)))\r\n\t\t{\r\n\t\t\tHandlePositionControl();\r\n\t\t}\r\n\t}\r\n\r\n\r\n\r\n\tprivate Vector3 CalculateGizmoPosition()\r\n\t{\r\n\t\tvar position = _selectedPoint.Position;\r\n\r\n\t\tif (_inTangentSelected)\r\n\t\t\tposition += _selectedPoint.In;\r\n\t\telse if (_outTangentSelected)\r\n\t\t\tposition += _selectedPoint.Out;\r\n\r\n\t\treturn position;\r\n\t}\r\n\r\n\r\n\r\n\tprivate void HandlePositionControl()\r\n\t{\r\n\t\t_moveInProgress = false;\r\n\r\n\t\tif (Gizmo.Control.Position(\"spline_control_\", Vector3.Zero, out var delta, GetHandleRotation()))\r\n\t\t{\r\n\t\t\t_moveInProgress = true;\r\n\t\t\t_movementUndoScope ??= CreateUndoScope(\"Moved spline point\");\r\n\r\n\t\t\tif (_inTangentSelected)\r\n\t\t\t\tMoveSelectedPointInTangent(delta);\r\n\t\t\telse if (_outTangentSelected)\r\n\t\t\t\tMoveSelectedPointOutTangent(delta);\r\n\t\t\telse\r\n\t\t\t\tHandlePointMove(delta);\r\n\t\t}\r\n\r\n\t\tif (!_moveInProgress && Gizmo.WasLeftMouseReleased)\r\n\t\t{\r\n\t\t\t_movementUndoScope?.Dispose();\r\n\t\t\t_movementUndoScope = null;\r\n\t\t}\r\n\t}\r\n\r\n\r\n\r\n\t/// <summary>\r\n\t/// Orientation for the move gizmo's arrows, honouring the editor's Global/Local space toggle\r\n\t/// (the same <see cref=\"Gizmo.Settings.GlobalSpace\"/> button the object move tool uses).\r\n\t/// Global keeps the arrows world-aligned; Local aligns them to the selected point's tangent frame\r\n\t/// so a point can be dragged straight along the track. We are already inside the component-transform\r\n\t/// scope, so returning the point's <em>local</em> frame is what places the arrows on the world tangent \u2014\r\n\t/// and <see cref=\"Gizmo.Control.Position\"/> hands the delta back in component-local space, matching how\r\n\t/// <see cref=\"MoveSelectedPoint\"/> applies it.\r\n\t/// </summary>\r\n\tprivate Rotation GetHandleRotation()\r\n\t{\r\n\t\tif (Gizmo.Settings.GlobalSpace)\r\n\t\t\treturn Rotation.Identity;\r\n\r\n\t\tif (!IsSelectedPointValid())\r\n\t\t\treturn Rotation.Identity;\r\n\r\n\t\tvar spline = _targetComponent.Spline;\r\n\t\tvar sample = spline.SampleAtDistance(spline.GetDistanceAtPoint(SelectedPointIndex));\r\n\r\n\t\tif (sample.Tangent.IsNearlyZero())\r\n\t\t\treturn Rotation.Identity;\r\n\r\n\t\tvar up = Rotation.FromAxis(sample.Tangent, sample.Roll) * sample.Up;\r\n\r\n\t\treturn Rotation.LookAt(sample.Tangent, up);\r\n\t}\r\n\r\n\r\n\r\n\tprivate void HandlePointMove(Vector3 delta)\r\n\t{\r\n\t\tif (Gizmo.IsShiftPressed && !_draggingOutNewPoint)\r\n\t\t{\r\n\t\t\t_draggingOutNewPoint = true;\r\n\t\t\tvar currentPoint = _targetComponent.Spline.GetPoint(SelectedPointIndex);\r\n\t\t\t_targetComponent.Spline.InsertPoint(SelectedPointIndex + 1, currentPoint);\r\n\t\t\tSelectedPointIndex++;\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\tMoveSelectedPoint(delta);\r\n\t\t}\r\n\t}\r\n\r\n\r\n\r\n\tprivate void DrawPointControls()\r\n\t{\r\n\t\tvar spline = _targetComponent.Spline;\r\n\r\n\t\tfor (var i = 0; i < spline.PointCount; i++)\r\n\t\t{\r\n\t\t\tif (spline.IsLoop && i == spline.SegmentCount)\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tvar point = spline.GetPoint(i);\r\n\t\t\tDrawPointControl(i, point);\r\n\t\t}\r\n\t}\r\n\r\n\r\n\r\n\tprivate void DrawPointControl(int index, Spline.Point point)\r\n\t{\r\n\t\tusing (Gizmo.Scope(\"point_controls\" + index, new Transform(point.Position)))\r\n\t\t{\r\n\t\t\tGizmo.Draw.IgnoreDepth = true;\r\n\t\t\tDrawPointPositionHandle(index);\r\n\r\n\t\t\tif (SelectedPointIndex == index)\r\n\t\t\t{\r\n\t\t\t\tDrawTangentHandles(point);\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\r\n\r\n\tprivate void DrawPointPositionHandle(int index)\r\n\t{\r\n\t\tusing (Gizmo.Scope(\"position\"))\r\n\t\tusing (Gizmo.GizmoControls.PushFixedScale())\r\n\t\t{\r\n\t\t\tGizmo.Hitbox.DepthBias = 0.1f;\r\n\t\t\tGizmo.Hitbox.BBox(BBox.FromPositionAndSize(Vector3.Zero, GIZMO_BOX_SIZE));\r\n\r\n\t\t\tbool isSelected = index == SelectedPointIndex && !_inTangentSelected && !_outTangentSelected;\r\n\r\n\t\t\tif (Gizmo.IsHovered || isSelected)\r\n\t\t\t{\r\n\t\t\t\tGizmo.Draw.Color = Color.Cyan;\r\n\t\t\t}\r\n\r\n\t\t\tGizmo.Draw.SolidBox(BBox.FromPositionAndSize(Vector3.Zero, GIZMO_BOX_SIZE));\r\n\r\n\t\t\tif (Gizmo.HasClicked && Gizmo.Pressed.This)\r\n\t\t\t{\r\n\t\t\t\tSelectPoint(index);\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\r\n\r\n\tprivate void DrawTangentHandles(Spline.Point point)\r\n\t{\r\n\t\tGizmo.Draw.Color = Color.White;\r\n\t\tGizmo.Draw.LineThickness = TANGENT_LINE_THICKNESS;\r\n\r\n\t\tDrawTangentHandle(\"in_tangent\", point.In, -point.In, ref _inTangentSelected, ref _outTangentSelected);\r\n\t\tDrawTangentHandle(\"out_tangent\", point.Out, -point.Out, ref _outTangentSelected, ref _inTangentSelected);\r\n\t}\r\n\r\n\r\n\r\n\tprivate void DrawTangentHandle(string name, Vector3 offset, Vector3 lineStart, ref bool thisSelected, ref bool otherSelected)\r\n\t{\r\n\t\tusing (Gizmo.Scope(name, new Transform(offset)))\r\n\t\t{\r\n\t\t\tbool isMirroredOrAuto = _selectedPointTangentMode is HandleModeTemp.Mirrored or HandleModeTemp.Auto;\r\n\t\t\tif (isMirroredOrAuto && (thisSelected || otherSelected))\r\n\t\t\t{\r\n\t\t\t\tGizmo.Draw.Color = Color.Cyan;\r\n\t\t\t}\r\n\r\n\t\t\tGizmo.Draw.Line(lineStart, Vector3.Zero);\r\n\r\n\t\t\tif (_selectedPointTangentMode != HandleModeTemp.Linear)\r\n\t\t\t{\r\n\t\t\t\tDrawTangentBox(ref thisSelected, ref otherSelected);\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\r\n\r\n\tprivate void DrawTangentBox(ref bool thisSelected, ref bool otherSelected)\r\n\t{\r\n\t\tusing (Gizmo.GizmoControls.PushFixedScale())\r\n\t\t{\r\n\t\t\tGizmo.Hitbox.DepthBias = 0.1f;\r\n\t\t\tGizmo.Hitbox.BBox(BBox.FromPositionAndSize(Vector3.Zero, GIZMO_BOX_SIZE));\r\n\r\n\t\t\tif (Gizmo.IsHovered || thisSelected)\r\n\t\t\t{\r\n\t\t\t\tGizmo.Draw.Color = Color.Cyan;\r\n\t\t\t}\r\n\r\n\t\t\tGizmo.Draw.SolidBox(BBox.FromPositionAndSize(Vector3.Zero, GIZMO_BOX_SIZE));\r\n\r\n\t\t\tif (Gizmo.HasClicked && Gizmo.Pressed.This)\r\n\t\t\t{\r\n\t\t\t\tthisSelected = true;\r\n\t\t\t\totherSelected = false;\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n"
},
{
"Ident": "redsnail.roadtool",
"Path": "Editor/RoadToolWindow/RoadToolWindow.UI.cs",
"FileName": "RoadToolWindow.UI.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 342326,
"IsPrivate": false,
"Code": "using Sandbox;\r\nusing Editor;\r\n\r\nnamespace RedSnail.RoadTool.Editor;\r\n\r\npublic partial class RoadToolWindow\r\n{\r\n\tprivate const int HEADER_HEIGHT = 32;\r\n\r\n\r\n\r\n\tprivate void Rebuild()\r\n\t{\r\n\t\tLayout.Clear(true);\r\n\t\tLayout.Margin = 0;\r\n\t\tIcon = _isClosed ? \"\" : \"route\";\r\n\t\tUpdateWindowTitle();\r\n\t\tIsGrabbable = !_isClosed;\r\n\r\n\t\tif (_isClosed)\r\n\t\t{\r\n\t\t\tBuildClosedState();\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tMinimumWidth = 400;\r\n\t\tBuildHeader();\r\n\r\n\t\tif (_targetComponent.IsValid())\r\n\t\t{\r\n\t\t\tBuildControlSheet();\r\n\t\t}\r\n\r\n\t\tLayout.Margin = 4;\r\n\t}\r\n\r\n\r\n\r\n\tprivate void BuildClosedState()\r\n\t{\r\n\t\tvar closedRow = Layout.AddRow();\r\n\r\n\t\tclosedRow.Add(new IconButton(\"route\", () => { _isClosed = false; Rebuild(); })\r\n\t\t{\r\n\t\t\tToolTip = \"Open Spline Point Editor\",\r\n\t\t\tFixedHeight = HEADER_HEIGHT,\r\n\t\t\tFixedWidth = HEADER_HEIGHT,\r\n\t\t\tBackground = Color.Transparent\r\n\t\t});\r\n\r\n\t\tMinimumWidth = 0;\r\n\t}\r\n\r\n\r\n\r\n\tprivate void BuildHeader()\r\n\t{\r\n\t\tvar headerRow = Layout.AddRow();\r\n\r\n\t\theaderRow.AddStretchCell();\r\n\r\n\t\theaderRow.Add(new IconButton(\"info\")\r\n\t\t{\r\n\t\t\tToolTip = GetInfoTooltip(),\r\n\t\t\tFixedHeight = HEADER_HEIGHT,\r\n\t\t\tFixedWidth = HEADER_HEIGHT,\r\n\t\t\tBackground = Color.Transparent\r\n\t\t});\r\n\r\n\t\theaderRow.Add(new IconButton(\"close\", CloseWindow)\r\n\t\t{\r\n\t\t\tToolTip = \"Close Editor\",\r\n\t\t\tFixedHeight = HEADER_HEIGHT,\r\n\t\t\tFixedWidth = HEADER_HEIGHT,\r\n\t\t\tBackground = Color.Transparent\r\n\t\t});\r\n\t}\r\n\r\n\r\n\r\n\tprivate string GetInfoTooltip()\r\n\t{\r\n\t\treturn \"Controls to edit the spline points.\\n\" +\r\n\t\t\t \"In addition to modifying the properties in the control sheet, you can also use the 3D Gizmos.\\n\" +\r\n\t\t\t \"Clicking on the spline between points will split the spline at that position.\\n\" +\r\n\t\t\t \"Holding shift while dragging a point's position will drag out a new point.\";\r\n\t}\r\n\r\n\r\n\r\n\tprivate void BuildControlSheet()\r\n\t{\r\n\t\tvar serialized = this.GetSerialized();\r\n\t\tvar controlSheet = new ControlSheet();\r\n\r\n\t\t// Add property rows\r\n\t\tcontrolSheet.AddRow(serialized.GetProperty(nameof(_selectedPointTangentMode)));\r\n\t\tcontrolSheet.AddRow(serialized.GetProperty(nameof(_selectedPointPosition)));\r\n\t\t_inTangentControl = controlSheet.AddRow(serialized.GetProperty(nameof(_selectedPointIn)));\r\n\t\t_outTangentControl = controlSheet.AddRow(serialized.GetProperty(nameof(_selectedPointOut)));\r\n\r\n\t\t// Add advanced group\r\n\t\tvar roll = serialized.GetProperty(nameof(_selectedPointRoll));\r\n\t\tvar scale = serialized.GetProperty(nameof(_selectedPointScale));\r\n\t\tvar up = serialized.GetProperty(nameof(_selectedPointUp));\r\n\t\tcontrolSheet.AddGroup(\"Advanced\", [roll, scale, up]);\r\n\r\n\t\t// Add control buttons\r\n\t\tcontrolSheet.AddLayout(BuildControlButtons());\r\n\r\n\t\tLayout.Add(controlSheet);\r\n\t\tToggleTangentInput();\r\n\t}\r\n\r\n\r\n\r\n\tprivate Layout BuildControlButtons()\r\n\t{\r\n\t\tvar row = Layout.Row();\r\n\t\trow.Spacing = 16;\r\n\t\trow.Margin = 8;\r\n\r\n\t\trow.Add(CreateNavigationButton(\"skip_previous\", -1, \"Go to previous point\"));\r\n\t\trow.Add(CreateNavigationButton(\"skip_next\", 1, \"Go to next point\"));\r\n\t\trow.Add(CreateDeleteButton());\r\n\t\trow.Add(CreateAddButton());\r\n\r\n\t\treturn row;\r\n\t}\r\n\r\n\r\n\r\n\tprivate IconButton CreateNavigationButton(string icon, int direction, string tooltip)\r\n\t{\r\n\t\treturn new IconButton(icon, () =>\r\n\t\t{\r\n\t\t\tif (direction < 0)\r\n\t\t\t\tSelectedPointIndex = int.Max(0, SelectedPointIndex - 1);\r\n\t\t\telse\r\n\t\t\t\tSelectedPointIndex = int.Min(_targetComponent.Spline.PointCount - 1, SelectedPointIndex + 1);\r\n\r\n\t\t\tUpdateWindowTitle();\r\n\t\t\tFocus();\r\n\t\t})\r\n\t\t{ ToolTip = tooltip };\r\n\t}\r\n\r\n\r\n\r\n\tprivate IconButton CreateDeleteButton()\r\n\t{\r\n\t\treturn new IconButton(\"delete\", () =>\r\n\t\t{\r\n\t\t\tusing (CreateUndoScope(\"Delete Spline Point\"))\r\n\t\t\t{\r\n\t\t\t\t_targetComponent.Spline.RemovePoint(SelectedPointIndex);\r\n\t\t\t\tSelectedPointIndex = int.Max(0, SelectedPointIndex - 1);\r\n\t\t\t}\r\n\t\t\tUpdateWindowTitle();\r\n\t\t\tFocus();\r\n\t\t})\r\n\t\t{ ToolTip = \"Delete point\" };\r\n\t}\r\n\r\n\r\n\r\n\tprivate IconButton CreateAddButton()\r\n\t{\r\n\t\treturn new IconButton(\"add\", () =>\r\n\t\t{\r\n\t\t\tusing (CreateUndoScope(\"Added Spline Point\"))\r\n\t\t\t{\r\n\t\t\t\tInsertNewPoint();\r\n\t\t\t}\r\n\t\t\tSelectedPointIndex++;\r\n\t\t\tUpdateWindowTitle();\r\n\t\t\tFocus();\r\n\t\t})\r\n\t\t{\r\n\t\t\tToolTip = \"Insert point after current point.\\n\" +\r\n\t\t\t\t\t \"You can also hold shift while dragging a point to create a new point.\"\r\n\t\t};\r\n\t}\r\n\r\n\r\n\r\n\tprivate void InsertNewPoint()\r\n\t{\r\n\t\tvar spline = _targetComponent.Spline;\r\n\r\n\t\tif (SelectedPointIndex == spline.PointCount - 1)\r\n\t\t{\r\n\t\t\tvar distance = spline.GetDistanceAtPoint(SelectedPointIndex);\r\n\t\t\tvar tangent = spline.SampleAtDistance(distance).Tangent;\r\n\t\t\tvar newPosition = _selectedPoint.Position + tangent * 200;\r\n\r\n\t\t\tspline.InsertPoint(SelectedPointIndex + 1, _selectedPoint with { Position = newPosition });\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\tvar currentDist = spline.GetDistanceAtPoint(SelectedPointIndex);\r\n\t\t\tvar nextDist = spline.GetDistanceAtPoint(SelectedPointIndex + 1);\r\n\t\t\tvar midDist = (currentDist + nextDist) / 2;\r\n\r\n\t\t\tspline.AddPointAtDistance(midDist, true);\r\n\t\t}\r\n\t}\r\n\r\n\r\n\r\n\tprivate void UpdateWindowTitle()\r\n\t{\r\n\t\tWindowTitle = _isClosed ? \"\" : $\"Spline Point [{SelectedPointIndex}] Editor - {_targetComponent?.GameObject?.Name ?? \"\"}\";\r\n\t}\r\n\r\n\r\n\r\n\tprivate void CloseWindow()\r\n\t{\r\n\t\t_isClosed = true;\r\n\t\tRebuild();\r\n\t\tPosition = Parent.Size - 32;\r\n\t}\r\n}\r\n"
},
{
"Ident": "redsnail.roadtool",
"Path": "Code/RoadComponent/RoadComponent.Lampposts.cs",
"FileName": "RoadComponent.Lampposts.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 342326,
"IsPrivate": false,
"Code": "using System;\r\nusing System.Linq;\r\nusing System.Collections.Generic;\r\nusing Sandbox;\r\n\r\nnamespace RedSnail.RoadTool;\r\n\r\npublic partial class RoadComponent\r\n{\r\n\tprivate bool m_DoesLamppostsNeedRebuild = false;\r\n\r\n\t[Property, FeatureEnabled(\"Lampposts\", Icon = \"light_mode\", Tint = EditorTint.Red), Change] private bool HasLampposts { get; set; } = false;\r\n\t[Property, Feature(\"Lampposts\")] public GameObject LamppostPrefab { get; set { field = value; m_DoesLamppostsNeedRebuild = true; } }\r\n\t[Property, Feature(\"Lampposts\"), Range(50.0f, 2000.0f)] private float LamppostSpacing { get; set { field = value.Clamp(10.0f, 100000.0f); m_DoesLamppostsNeedRebuild = true; } } = 50.0f;\r\n\t[Property, Feature(\"Lampposts\"), Range(-200.0f, 200.0f)] private float LamppostOffsetFromSidewalk { get; set { field = value; m_DoesLamppostsNeedRebuild = true; } } = 10.0f;\r\n\t[Property, Feature(\"Lampposts\"), Range(0.0f, 10.0f)] private float LamppostHeightOffset { get; set { field = value; m_DoesLamppostsNeedRebuild = true; } } = 0.0f;\r\n\t[Property, Feature(\"Lampposts\")] private LamppostSide LamppostPlacement { get; set { field = value; m_DoesLamppostsNeedRebuild = true; } } = LamppostSide.Both;\r\n\t[Property, Feature(\"Lampposts\")] private bool AlignToSplineRotation { get; set { field = value; m_DoesLamppostsNeedRebuild = true; } } = true;\r\n\t[Property(Title = \"Keep Vertical (World Up)\"), Feature(\"Lampposts\")] private bool KeepVertical { get; set { field = value; m_DoesLamppostsNeedRebuild = true; } } = true;\r\n\t[Property, Feature(\"Lampposts\"), Range(0.0f, 360.0f)] private float LamppostRotationOffset { get; set { field = value; m_DoesLamppostsNeedRebuild = true; } } = 0.0f;\r\n\t[Property, Feature(\"Lampposts\"), Range(0.0f, 100.0f)] private float StartOffset { get; set { field = value; m_DoesLamppostsNeedRebuild = true; } } = 0.0f;\r\n\t[Property, Feature(\"Lampposts\"), Range(0.0f, 100.0f)] private float EndOffset { get; set { field = value; m_DoesLamppostsNeedRebuild = true; } } = 0.0f;\r\n\r\n\tpublic enum LamppostSide\r\n\t{\r\n\t\tLeft,\r\n\t\tRight,\r\n\t\tBoth,\r\n\t\tAlternating\r\n\t}\r\n\r\n\r\n\r\n\tprivate void OnHasLamppostsChanged(bool _OldValue, bool _NewValue)\r\n\t{\r\n\t\tm_DoesLamppostsNeedRebuild = true;\r\n\t}\r\n\r\n\r\n\r\n\tprivate void CreateLampposts()\r\n\t{\r\n\t\tRemoveLampposts();\r\n\r\n\t\tif (!HasLampposts || !LamppostPrefab.IsValid())\r\n\t\t\treturn;\r\n\r\n\t\tBuildLampposts();\r\n\t}\r\n\r\n\r\n\r\n\tprivate void RemoveLampposts()\r\n\t{\r\n\t\tif (SandboxUtility.IsInPlayMode)\r\n\t\t\treturn;\r\n\r\n\t\tGameObject containerObject = GameObject.Children.FirstOrDefault(x => x.Name == \"Lampposts\");\r\n\r\n\t\tif (containerObject.IsValid())\r\n\t\t{\r\n\t\t\tcontainerObject.Destroy();\r\n\t\t}\r\n\t}\r\n\r\n\r\n\r\n\tprivate void UpdateLampposts()\r\n\t{\r\n\t\tif (m_DoesLamppostsNeedRebuild)\r\n\t\t{\r\n\t\t\tCreateLampposts();\r\n\r\n\t\t\tm_DoesLamppostsNeedRebuild = false;\r\n\t\t}\r\n\t}\r\n\r\n\r\n\r\n\tprivate void BuildLampposts()\r\n\t{\r\n\t\tif (SandboxUtility.IsInPlayMode)\r\n\t\t\treturn;\r\n\r\n\t\tGameObject containerObject = new GameObject(GameObject, true, \"Lampposts\");\r\n\t\tcontainerObject.Tags.Add(\"road_props\");\r\n\r\n\t\tfloat splineLength = Spline.Length;\r\n\t\tfloat effectiveLength = splineLength - StartOffset - EndOffset;\r\n\r\n\t\tif (effectiveLength <= 0)\r\n\t\t\treturn;\r\n\r\n\t\tGetSplineFrameData(out var frames, out var segmentsToKeep);\r\n\r\n\t\tvar simplifiedPositions = new List<(Transform _Frame, float _Distance)>();\r\n\r\n\t\tforeach (int index in segmentsToKeep)\r\n\t\t{\r\n\t\t\tfloat t = (float)index / (frames.Length - 1);\r\n\t\t\tfloat distance = t * splineLength;\r\n\r\n\t\t\tsimplifiedPositions.Add((frames[index], distance));\r\n\t\t}\r\n\r\n\t\tint lamppostCount = Math.Max(1, (int)MathF.Ceiling(effectiveLength / LamppostSpacing));\r\n\t\tint frameCount = lamppostCount + 1;\r\n\r\n\t\tfloat roadEdgeOffset = RoadWidth * 0.5f;\r\n\t\tfloat sidewalkOffset = HasSidewalk ? SidewalkWidth : 0.0f;\r\n\t\tfloat totalOffset = roadEdgeOffset + sidewalkOffset + LamppostOffsetFromSidewalk;\r\n\r\n\t\tfor (int i = 0; i < frameCount; i++)\r\n\t\t{\r\n\t\t\tfloat t = (float)i / (frameCount - 1);\r\n\t\t\tfloat distance = t * splineLength;\r\n\r\n\t\t\t// Skip if outside the start/end offset range\r\n\t\t\tif (distance < StartOffset || distance > splineLength - EndOffset)\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\t// Interpolate frame at this distance along the simplified spline\r\n\t\t\tTransform frame = InterpolateFrameAtDistance(simplifiedPositions, distance);\r\n\r\n\t\t\tVector3 basePosition = frame.Position;\r\n\t\t\tVector3 forward = frame.Rotation.Forward;\r\n\t\t\tVector3 up = frame.Rotation.Up;\r\n\t\t\tVector3 right = frame.Rotation.Right;\r\n\r\n\t\t\tbool placeLeft = false;\r\n\t\t\tbool placeRight = false;\r\n\r\n\t\t\tswitch (LamppostPlacement)\r\n\t\t\t{\r\n\t\t\t\tcase LamppostSide.Left:\r\n\t\t\t\t\tplaceLeft = true;\r\n\t\t\t\t\tbreak;\r\n\t\t\t\tcase LamppostSide.Right:\r\n\t\t\t\t\tplaceRight = true;\r\n\t\t\t\t\tbreak;\r\n\t\t\t\tcase LamppostSide.Both:\r\n\t\t\t\t\tplaceLeft = true;\r\n\t\t\t\t\tplaceRight = true;\r\n\t\t\t\t\tbreak;\r\n\t\t\t\tcase LamppostSide.Alternating:\r\n\t\t\t\t\tif (i % 2 == 0)\r\n\t\t\t\t\t\tplaceLeft = true;\r\n\t\t\t\t\telse\r\n\t\t\t\t\t\tplaceRight = true;\r\n\t\t\t\t\tbreak;\r\n\t\t\t\tdefault:\r\n\t\t\t\t\tplaceLeft = true;\r\n\t\t\t\t\tplaceRight = true;\r\n\t\t\t\t\tbreak;\r\n\t\t\t}\r\n\r\n\t\t\tif (placeLeft)\r\n\t\t\t{\r\n\t\t\t\tVector3 leftPosition = basePosition - right * totalOffset + up * (LamppostHeightOffset + (HasSidewalk ? SidewalkHeight : 0.0f));\r\n\t\t\t\tRotation leftRotation = CalculateLamppostRotation(forward, up, LamppostRotationOffset);\r\n\r\n\t\t\t\tCreateLamppost(containerObject, leftPosition, leftRotation);\r\n\t\t\t}\r\n\r\n\t\t\tif (placeRight)\r\n\t\t\t{\r\n\t\t\t\tVector3 rightPosition = basePosition + right * totalOffset + up * (LamppostHeightOffset + (HasSidewalk ? SidewalkHeight : 0.0f));\r\n\t\t\t\tRotation rightRotation = CalculateLamppostRotation(forward, up, LamppostRotationOffset + 180.0f);\r\n\r\n\t\t\t\tCreateLamppost(containerObject, rightPosition, rightRotation);\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\r\n\r\n\tprivate void CreateLamppost(GameObject _Parent, Vector3 _Position, Rotation _Rotation)\r\n\t{\r\n\t\tif (!LamppostPrefab.IsValid())\r\n\t\t\treturn;\r\n\r\n\t\tGameObject lamppostObject = LamppostPrefab.Clone(_Parent, _Position, _Rotation, Vector3.One);\r\n\r\n\t\tif (!lamppostObject.IsValid())\r\n\t\t\treturn;\r\n\r\n\t\tlamppostObject.LocalPosition = _Position;\r\n\t\tlamppostObject.LocalRotation = _Rotation;\r\n\t}\r\n\r\n\r\n\r\n\tprivate Rotation CalculateLamppostRotation(Vector3 _Forward, Vector3 _SplineUp, float _YawOffset)\r\n\t{\r\n\t\tif (!AlignToSplineRotation)\r\n\t\t\treturn Rotation.FromYaw(_YawOffset);\r\n\r\n\t\tRotation finalRotation;\r\n\r\n\t\tif (KeepVertical)\r\n\t\t{\r\n\t\t\tVector3 flatForward = _Forward.WithZ(0).Normal;\r\n\r\n\t\t\tif (flatForward.Length > 0.001f)\r\n\t\t\t{\r\n\t\t\t\tfinalRotation = Rotation.LookAt(flatForward, Vector3.Up);\r\n\t\t\t}\r\n\t\t\telse\r\n\t\t\t{\r\n\t\t\t\tfinalRotation = Rotation.FromYaw(_YawOffset);\r\n\t\t\t}\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\tfinalRotation = Rotation.LookAt(_Forward, _SplineUp);\r\n\t\t}\r\n\r\n\t\treturn finalRotation * Rotation.FromYaw(_YawOffset);\r\n\t}\r\n}\r\n"
},
{
"Ident": "redsnail.roadtool",
"Path": "Code/RoadComponent/RoadComponent.Lines.cs",
"FileName": "RoadComponent.Lines.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 342326,
"IsPrivate": false,
"Code": "using System;\r\nusing Sandbox;\r\n\r\nnamespace RedSnail.RoadTool;\r\n\r\npublic partial class RoadComponent\r\n{\r\n\t[Property, FeatureEnabled(\"Lines\", Icon = \"show_chart\", Tint = EditorTint.Yellow), Change] private bool HasLines { get; set; } = false;\r\n\t[Property(Title = \"Lines\"), Feature(\"Lines\")] public RoadLineDefinition[] LineDefinitions { get; set { field = value; IsDirty = true; } }\r\n\t[Property(Title = \"Offset\"), Feature(\"Lines\"), Range(0.01f, 1.0f)] private float LinesOffset { get; set { field = value; IsDirty = true; } } = 0.1f;\r\n\t[Property(Title = \"Width\"), Feature(\"Lines\"), Range(1.0f, 50.0f)] private float LinesWidth { get; set { field = value; IsDirty = true; } } = 5.0f;\r\n\t[Property(Title = \"Extra Spacing\"), Feature(\"Lines\"), Range(0.0f, 1000.0f)] private float LinesExtraSpacing { get; set { field = value; IsDirty = true; } } = 0.0f;\r\n\t[Property(Title = \"Texture Repeat\"), Feature(\"Lines\")] private float LinesTextureRepeat { get; set { field = value.Clamp(1.0f, 100000.0f); IsDirty = true; } } = 10.0f;\r\n\r\n\r\n\r\n\tprivate void OnHasLinesChanged(bool _OldValue, bool _NewValue)\r\n\t{\r\n\t\tIsDirty = true;\r\n\t}\r\n\r\n\r\n\r\n\tprivate void CreateLines()\r\n\t{\r\n\t\tEnsureLinesMeshExist();\r\n\t}\r\n\r\n\r\n\r\n\tprivate void UpdateLines()\r\n\t{\r\n\t}\r\n\r\n\r\n\r\n\tprivate void RemoveLines()\r\n\t{\r\n\t\tRemoveGeneratedMeshChildren(LineSurfaceTag);\r\n\t}\r\n\r\n\r\n\r\n\tprivate void EnsureLinesMeshExist()\r\n\t{\r\n\t\tif (SandboxUtility.IsInPlayMode)\r\n\t\t\treturn;\r\n\r\n\t\tif (HasGeneratedMeshChildren(LineSurfaceTag))\r\n\t\t\treturn;\r\n\r\n\t\tBuildLinesMesh();\r\n\t}\r\n\r\n\r\n\r\n\tprivate void RebuildLinesMesh()\r\n\t{\r\n\t\tif (SandboxUtility.IsInPlayMode)\r\n\t\t\treturn;\r\n\r\n\t\tRemoveGeneratedMeshChildren(LineSurfaceTag);\r\n\t\tBuildLinesMesh();\r\n\t}\r\n\r\n\r\n\r\n\tprivate void BuildLinesMesh()\r\n\t{\r\n\t\tif (!HasLines || LineDefinitions == null || LineDefinitions.Length == 0)\r\n\t\t\treturn;\r\n\r\n\t\tGetSplineFrameData(out var frames, out var segmentsToKeep);\r\n\r\n\t\tint finalSegmentCount = segmentsToKeep.Count - 1;\r\n\r\n\t\tif (finalSegmentCount <= 0)\r\n\t\t\treturn;\r\n\r\n\t\tfloat roadWidth = RoadWidth + LinesExtraSpacing;\r\n\t\tfloat lineSpacing = roadWidth / (LineDefinitions.Length + 1);\r\n\r\n\t\tvar polygonMeshes = new PolygonMesh[LineDefinitions.Length];\r\n\t\tfor (int i = 0; i < LineDefinitions.Length; i++)\r\n\t\t\tpolygonMeshes[i] = new PolygonMesh();\r\n\r\n\t\tfloat[] lineDistances = new float[LineDefinitions.Length];\r\n\r\n\t\tfor (int i = 0; i < finalSegmentCount; i++)\r\n\t\t{\r\n\t\t\tint idx0 = segmentsToKeep[i];\r\n\t\t\tint idx1 = segmentsToKeep[i + 1];\r\n\r\n\t\t\tTransform f0 = frames[idx0];\r\n\t\t\tTransform f1 = frames[idx1];\r\n\r\n\t\t\tVector3 p0 = f0.Position;\r\n\t\t\tVector3 p1 = f1.Position;\r\n\r\n\t\t\tVector3 right0 = f0.Rotation.Right;\r\n\r\n\t\t\tfor (int line = 0; line < LineDefinitions.Length; line++)\r\n\t\t\t{\r\n\t\t\t\tfloat offsetFromCenter = ((line + 1) * lineSpacing) - (roadWidth * 0.5f);\r\n\r\n\t\t\t\tVector3 center0 =\r\n\t\t\t\t\tp0 +\r\n\t\t\t\t\tf0.Rotation.Right * offsetFromCenter +\r\n\t\t\t\t\tf0.Rotation.Up * LinesOffset;\r\n\r\n\t\t\t\tVector3 center1 =\r\n\t\t\t\t\tp1 +\r\n\t\t\t\t\tf1.Rotation.Right * offsetFromCenter +\r\n\t\t\t\t\tf1.Rotation.Up * LinesOffset;\r\n\r\n\t\t\t\tfloat segmentLength = Vector3.DistanceBetween(center0, center1);\r\n\t\t\t\tVector3 dir = (center1 - center0).Normal;\r\n\r\n\t\t\t\tfloat remaining = segmentLength;\r\n\t\t\t\tVector3 curCenter = center0;\r\n\r\n\t\t\t\tfloat dashSpacing = LineDefinitions[line]?.DashSpacing ?? 0.0f;\r\n\t\t\t\tfloat dashFillRatio = LineDefinitions[line]?.DashFillRatio ?? 1.0f;\r\n\t\t\t\tfloat dashLength = dashSpacing * dashFillRatio;\r\n\t\t\t\tfloat halfWidth = LinesWidth * 0.5f;\r\n\r\n\t\t\t\tvar polygonMesh = polygonMeshes[line];\r\n\t\t\t\tvar material = LineDefinitions[line]?.Material ?? Material.Load(\"materials/default.vmat\");\r\n\r\n\t\t\t\twhile (remaining > 0.001f)\r\n\t\t\t\t{\r\n\t\t\t\t\tfloat linePos = lineDistances[line];\r\n\t\t\t\t\tfloat cyclePos = dashSpacing > 0 ? linePos % dashSpacing : 0;\r\n\r\n\t\t\t\t\tif (cyclePos < 0.0001f)\r\n\t\t\t\t\t\tcyclePos = 0.0f;\r\n\r\n\t\t\t\t\tif (dashSpacing > 0 && dashSpacing - cyclePos < 0.0001f)\r\n\t\t\t\t\t\tcyclePos = dashSpacing;\r\n\r\n\t\t\t\t\tbool inDash = dashSpacing <= 0 || cyclePos <= dashLength - 0.0001f;\r\n\r\n\t\t\t\t\tfloat step;\r\n\r\n\t\t\t\t\tif (dashSpacing <= 0)\r\n\t\t\t\t\t\tstep = remaining;\r\n\t\t\t\t\telse if (inDash)\r\n\t\t\t\t\t\tstep = dashLength - cyclePos;\r\n\t\t\t\t\telse\r\n\t\t\t\t\t\tstep = dashSpacing - cyclePos;\r\n\r\n\t\t\t\t\tstep = Math.Max(step, 0.01f);\r\n\t\t\t\t\tstep = Math.Min(step, remaining);\r\n\r\n\t\t\t\t\tVector3 nextCenter = curCenter + dir * step;\r\n\r\n\t\t\t\t\tif (inDash)\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tVector3 l0 = curCenter - right0 * halfWidth;\r\n\t\t\t\t\t\tVector3 r0 = curCenter + right0 * halfWidth;\r\n\t\t\t\t\t\tVector3 l1 = nextCenter - right0 * halfWidth;\r\n\t\t\t\t\t\tVector3 r1 = nextCenter + right0 * halfWidth;\r\n\r\n\t\t\t\t\t\tfloat v0 = linePos / LinesTextureRepeat;\r\n\t\t\t\t\t\tfloat v1 = (linePos + step) / LinesTextureRepeat;\r\n\r\n\t\t\t\t\t\tvar verts = polygonMesh.AddVertices(l0, r0, r1, l1);\r\n\t\t\t\t\t\tMeshUtility.AddTexturedQuad(polygonMesh, material, verts[0], verts[1], verts[2], verts[3],\r\n\t\t\t\t\t\t\tnew Vector2(0, v0), new Vector2(1, v0),\r\n\t\t\t\t\t\t\tnew Vector2(1, v1), new Vector2(0, v1));\r\n\t\t\t\t\t}\r\n\r\n\t\t\t\t\tlineDistances[line] += step;\r\n\t\t\t\t\tcurCenter = nextCenter;\r\n\t\t\t\t\tremaining -= step;\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tfor (int line = 0; line < LineDefinitions.Length; line++)\r\n\t\t{\r\n\t\t\tvar child = new GameObject(GameObject, true, $\"Line_{line}\");\r\n\t\t\tchild.Tags.Add(LineSurfaceTag);\r\n\r\n\t\t\tvar meshComponent = child.AddComponent<MeshComponent>();\r\n\t\t\tmeshComponent.Mesh = polygonMeshes[line];\r\n\t\t\tmeshComponent.Collision = MeshComponent.CollisionType.None;\r\n\t\t\tmeshComponent.RenderType = ModelRenderer.ShadowRenderType.Off;\r\n\t\t\tmeshComponent.SmoothingAngle = 40.0f;\r\n\t\t\tmeshComponent.Static = true;\r\n\t\t}\r\n\t}\r\n}\r\n"
},
{
"Ident": "redsnail.roadtool",
"Path": "Code/RoadParkingLotComponent/RoadParkingLotComponent.cs",
"FileName": "RoadParkingLotComponent.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 342326,
"IsPrivate": false,
"Code": "using System.Linq;\r\nusing Sandbox;\r\n\r\nnamespace RedSnail.RoadTool;\r\n\r\n/// <summary>\r\n/// Generates parking lot lines for parking spaces\r\n/// </summary>\r\n[Icon(\"local_parking\")]\r\npublic partial class RoadParkingLotComponent : Component, Component.ExecuteInEditor\r\n{\r\n\tprivate bool m_IsDirty;\r\n\r\n\tprivate const string LinesTag = \"parking_lines\";\r\n\tprivate const string CurbsTag = \"parking_curbs\";\r\n\r\n\t/// <summary>\r\n\t/// An optional prefab, if non-empty the parking lot will generate a bunch of child gameobjects positioned at each parking spots center.\r\n\t/// (e.g. this allows you to use a gameobject prefab with a car spawner system component attached to it)\r\n\t/// </summary>\r\n\t[Property, Feature(\"General\", Icon = \"public\", Tint = EditorTint.White)] private GameObject SpotPrefab { get; set; }\r\n\r\n\t/// <summary>\r\n\t/// The amount of parking spots you want to generate.\r\n\t/// </summary>\r\n\t[Property, Feature(\"General\"), Range(1, 50)] private int SpotCount { get; set { field = value; m_IsDirty = true; } } = 10;\r\n\r\n\t/// <summary>\r\n\t/// Well that's the parking spot length\r\n\t/// </summary>\r\n\t[Property, Feature(\"General\"), Range(10.0f, 1000.0f)] private float SpotLength { get; set { field = value; m_IsDirty = true; } } = 250.0f;\r\n\r\n\t/// <summary>\r\n\t/// and width...\r\n\t/// </summary>\r\n\t[Property, Feature(\"General\"), Range(10.0f, 1000.0f)] private float SpotWidth { get; set { field = value; m_IsDirty = true; } } = 150.0f;\r\n\r\n\t/// <summary>\r\n\t/// The angle of the parking spots in degrees (0 = perpendicular, 45 = angled, 90 = parallel)\r\n\t/// </summary>\r\n\t[Property, Feature(\"General\"), Range(-90.0f, 90.0f), Step(1.0f)] private float SpotAngle { get; set { field = value; m_IsDirty = true; } } = 0.0f;\r\n\t[Property, Feature(\"General\"), Range(0.5f, 1.0f)] private float SpotAngleThreshold { get; set { field = value; m_IsDirty = true; } } = 0.5f;\r\n\r\n\r\n\r\n\tprotected override void OnEnabled()\r\n\t{\r\n\t\tBuildAllMeshes();\r\n\t}\r\n\r\n\r\n\r\n\tprotected override void OnDisabled()\r\n\t{\r\n\t\tDestroyMeshChildren();\r\n\t\tRemoveParkingSpots();\r\n\t}\r\n\r\n\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tif (m_IsDirty)\r\n\t\t{\r\n\t\t\tif (!SandboxUtility.IsInPlayMode)\r\n\t\t\t{\r\n\t\t\t\tDestroyMeshChildren();\r\n\t\t\t\tBuildAllMeshes();\r\n\t\t\t}\r\n\r\n\t\t\tm_IsDirty = false;\r\n\t\t}\r\n\t}\r\n\r\n\r\n\r\n\tprivate void DestroyMeshChildren()\r\n\t{\r\n\t\tvar toRemove = GameObject.Children\r\n\t\t\t.Where(c => c.Tags.Has(LinesTag) || c.Tags.Has(CurbsTag))\r\n\t\t\t.ToList();\r\n\r\n\t\tforeach (var child in toRemove)\r\n\t\t\tchild.Destroy();\r\n\t}\r\n\r\n\r\n\r\n\tprivate void BuildAllMeshes()\r\n\t{\r\n\t\tif (SandboxUtility.IsInPlayMode)\r\n\t\t\treturn;\r\n\r\n\t\tBuildParkingLines();\r\n\t\tBuildCurbs();\r\n\r\n\t\tRemoveParkingSpots();\r\n\t\tCreateParkingSpots();\r\n\t}\r\n\r\n\r\n\r\n\tprotected override void DrawGizmos()\r\n\t{\r\n\t\tif (!Gizmo.IsSelected)\r\n\t\t\treturn;\r\n\r\n\t\tGizmo.Draw.LineThickness = 2.0f;\r\n\t\tGizmo.Draw.Color = Color.Green.WithAlpha(0.5f);\r\n\r\n\t\tfloat angleRad = SpotAngle.DegreeToRadian();\r\n\t\tfloat sinAngle = float.Sin(angleRad);\r\n\t\tfloat cosAngle = float.Cos(angleRad);\r\n\r\n\t\tfloat spacing = CalculateSpacing();\r\n\r\n\t\t// Draw parking spot outlines\r\n\t\tfor (int i = 0; i < SpotCount; i++)\r\n\t\t{\r\n\t\t\tfloat xPos = i * spacing;\r\n\r\n\t\t\tVector3 frontLeft = new Vector3(xPos, 0, LinesOffset);\r\n\t\t\tVector3 frontRight = new Vector3(xPos + SpotWidth * cosAngle, SpotWidth * sinAngle, LinesOffset);\r\n\t\t\tVector3 backLeft = new Vector3(xPos - SpotLength * sinAngle, SpotLength * cosAngle, LinesOffset);\r\n\t\t\tVector3 backRight = new Vector3(xPos + SpotWidth * cosAngle - SpotLength * sinAngle, SpotWidth * sinAngle + SpotLength * cosAngle, LinesOffset);\r\n\r\n\t\t\tGizmo.Draw.Line(frontLeft, frontRight);\r\n\t\t\tGizmo.Draw.Line(frontRight, backRight);\r\n\t\t\tGizmo.Draw.Line(backRight, backLeft);\r\n\t\t\tGizmo.Draw.Line(backLeft, frontLeft);\r\n\t\t}\r\n\t}\r\n\r\n\r\n\r\n\tprivate void CreateParkingSpots()\r\n\t{\r\n\t\t// If we're in play mode, do not build (Since they're already saved in the scene file)\r\n\t\tif (LoadingScreen.IsVisible || Game.IsPlaying)\r\n\t\t\treturn;\r\n\r\n\t\tif (!SpotPrefab.IsValid())\r\n\t\t\treturn;\r\n\r\n\t\tGameObject containerObject = GameObject.Children.FirstOrDefault(x => x.Name == \"ParkingSpots\");\r\n\r\n\t\tif (!containerObject.IsValid())\r\n\t\t\tcontainerObject = new GameObject(GameObject, true, \"ParkingSpots\");\r\n\r\n\t\tfloat angleRad = SpotAngle.DegreeToRadian();\r\n\t\tfloat sinAngle = float.Sin(angleRad);\r\n\t\tfloat cosAngle = float.Cos(angleRad);\r\n\r\n\t\tfloat spacing = CalculateSpacing();\r\n\r\n\t\tfor (int i = 0; i < SpotCount; i++)\r\n\t\t{\r\n\t\t\tfloat xPos = i * spacing;\r\n\r\n\t\t\tfloat centerX = xPos + (SpotWidth * 0.5f * cosAngle) - (SpotLength * 0.5f * sinAngle);\r\n\t\t\tfloat centerY = (SpotWidth * 0.5f * sinAngle) + (SpotLength * 0.5f * cosAngle);\r\n\r\n\t\t\tVector3 position = new Vector3(centerX, centerY, 0);\r\n\r\n\t\t\tGameObject gameObject = SpotPrefab.Clone(new Transform(), containerObject);\r\n\t\t\tgameObject.LocalPosition = position;\r\n\t\t\tgameObject.LocalRotation = Rotation.FromYaw(SpotAngle);\r\n\t\t\tgameObject.NetworkMode = NetworkMode.Object;\r\n\t\t\tgameObject.Network.SetOrphanedMode(NetworkOrphaned.Host);\r\n\t\t}\r\n\t}\r\n\r\n\r\n\r\n\tprivate void RemoveParkingSpots()\r\n\t{\r\n\t\t// If we're in play mode, do not remove (Since they're already saved in the scene file)\r\n\t\tif (LoadingScreen.IsVisible || Game.IsPlaying)\r\n\t\t\treturn;\r\n\r\n\t\tGameObject containerObject = GameObject.Children.FirstOrDefault(x => x.Name == \"ParkingSpots\");\r\n\r\n\t\tif (!containerObject.IsValid())\r\n\t\t\treturn;\r\n\r\n\t\tforeach (var gameObject in containerObject.Children.Where(x => x.IsValid()))\r\n\t\t{\r\n\t\t\tgameObject.Destroy();\r\n\t\t}\r\n\t}\r\n\r\n\r\n\r\n\t/// <summary>\r\n\t/// Utility button to directly snap the parking lot to the nearest solid ground\r\n\t/// </summary>\r\n\t[Button(\"Snap to Ground\"), Feature(\"General\"), Order(100)]\r\n\tpublic void SnapToGround()\r\n\t{\r\n\t\tSceneTraceResult trace = Scene.Trace.Ray(WorldPosition, WorldPosition + Vector3.Down * 10000.0f).Run();\r\n\r\n\t\tif (trace.Distance < 0.1f) // Ignore really close hits, bcs that mean the parking lot is already properly grounded\r\n\t\t\treturn;\r\n\r\n\t\tif (!trace.Hit)\r\n\t\t\treturn;\r\n\r\n\t\tWorldPosition = trace.HitPosition;\r\n\t\tWorldRotation = Rotation.LookAt(trace.Normal, Vector3.Up) * Rotation.FromPitch(90.0f);\r\n\t}\r\n\r\n\r\n\r\n\tprivate float CalculateSpacing()\r\n\t{\r\n\t\tfloat angleRad = SpotAngle.DegreeToRadian();\r\n\t\tfloat cosAngle = float.Cos(angleRad);\r\n\r\n\t\treturn SpotWidth / float.Max(cosAngle, SpotAngleThreshold);\r\n\t}\r\n}\r\n"
},
{
"Ident": "redsnail.roadtool",
"Path": "Code/Utility/VehicleSpawnResource.cs",
"FileName": "VehicleSpawnResource.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 342326,
"IsPrivate": false,
"Code": "using Sandbox;\n\nnamespace RedSnail.RoadTool;\n\n[AssetType(Name = \"Vehicle Spawn\", Extension = \"vspawn\", Category = \"Road Tool\")]\npublic sealed class VehicleSpawnResource : GameResource\n{\n\t[Property] public GameObject Prefab { get; set; }\n\n\t[Property, Range(0.0f, 10.0f)] public float Probability { get; set; } = 1.0f;\n\t\n\t\n\t\n\tprotected override Bitmap CreateAssetTypeIcon(int _Width, int _Height)\n\t{\n\t\treturn CreateSimpleAssetTypeIcon(\"car_rental\", _Width, _Height, \"#00ccff\", \"black\");\n\t}\n}\n"
},
{
"Ident": "redsnail.roadtool",
"Path": "ISplineComponent.cs",
"FileName": "ISplineComponent.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 342326,
"IsPrivate": false,
"Code": "using Sandbox;\n\nnamespace RedSnail.RoadTool;\n\n/// <summary>\n/// A component that owns an editable <see cref=\"Sandbox.Spline\"/>. Lets the spline editor tool/window drive any of\n/// them (roads, rails, \u2026) without being tied to a single component type.\n/// </summary>\npublic interface ISplineComponent : IValid\n{\n\tSpline Spline { get; }\n\tTransform WorldTransform { get; }\n\tGameObject GameObject { get; }\n}\n"
},
{
"Ident": "redsnail.roadtool",
"Path": "RoadComponent/RoadComponent.cs",
"FileName": "RoadComponent.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 342326,
"IsPrivate": false,
"Code": "using System.Linq;\r\nusing Sandbox;\r\n\r\nnamespace RedSnail.RoadTool;\r\n\r\n/// <summary>\r\n/// Represents a road component that can be manipulated within the editor and at runtime.\r\n/// </summary>\r\n[Icon(\"signpost\")]\r\npublic partial class RoadComponent : Component, Component.ExecuteInEditor, Component.IHasBounds, ISplineComponent\r\n{\r\n\t/// <summary>\r\n\t/// Undo/redo and prefab reloads deserialize a brand new <see cref=\"Sandbox.Spline\"/> into this component\r\n\t/// instead of mutating the existing one, so the setter has to move our change listener over. Otherwise the\r\n\t/// subscription stays on the discarded instance and later edits never mark the mesh dirty again.\r\n\t/// </summary>\r\n\t[Property, Feature(\"General\"), Hide]\r\n\tpublic Spline Spline\r\n\t{\r\n\t\tget;\r\n\t\tset\r\n\t\t{\r\n\t\t\tfield = value;\r\n\r\n\t\t\tSubscribeToSpline();\r\n\t\t\tUpdateData();\r\n\t\t}\r\n\t} = new();\r\n\r\n\tprivate Spline m_SubscribedSpline;\r\n\r\n\tprivate bool m_DoesRoadMeshNeedRebuild;\r\n\r\n\tprivate const string RoadMeshTag = \"road_mesh\";\r\n\tprivate const string RoadSurfaceTag = \"road_surface\";\r\n\tprivate const string SidewalkSurfaceTag = \"road_sidewalk\";\r\n\tprivate const string LineSurfaceTag = \"road_lines\";\r\n\r\n\t[Property, Feature(\"General\", Icon = \"public\", Tint = EditorTint.White), Category(\"Optimization\")] private bool AutoSimplify { get; set { field = value; IsDirty = true; } } = false;\r\n\t[Property, Feature(\"General\"), Category(\"Optimization\"), Range(0.1f, 10.0f)] private float StraightThreshold { get; set { field = value; IsDirty = true; } } = 1.0f; // Degrees - how straight before merging\r\n\t[Property, Feature(\"General\"), Category(\"Optimization\"), Range(2, 50)] private int MinSegmentsToMerge { get; set { field = value; IsDirty = true; } } = 3; // Minimum consecutive straight segments before merging\r\n\r\n\t[Property, Feature(\"General\"), Category(\"Miscellaneous\")] public bool UseRotationMinimizingFrames { get; set { field = value; IsDirty = true; } }\r\n\r\n\t[Property, FeatureEnabled(\"Bridge\", Icon = \"architecture\", Tint = EditorTint.Blue)]\r\n\tprivate bool HasBridge { get; set { field = value; m_DoesBridgeNeedRebuild = true; } } = false;\r\n\r\n\t/// <summary>\r\n\t/// This will prevent the bridge from being rebuilt if any property is edited or if the road component get disable and re-enabled.\r\n\t/// Really useful if you plan to edit the mesh with the mapping tool so you don't accidently erase/rebuild the bridge.\r\n\t/// </summary>\r\n\t[Property(Title = \"\ud83d\udd12 Locked\"), Feature(\"Bridge\")]\r\n\tprivate bool IsLocked { get; set; } = false;\r\n\r\n\t/// <summary>\r\n\t/// This is your bridge material you wanna use.\r\n\t/// I recommend using a tileable texture for better result.\r\n\t/// </summary>\r\n\t[Property(Title = \"Material\"), Feature(\"Bridge\"), Group(\"Texturing\"), Order(1)]\r\n\tprivate Material BridgeMaterial { get; set { field = value; m_DoesBridgeNeedRebuild = true; } }\r\n\r\n\t[Property(Title = \"Texture Repeat\"), Feature(\"Bridge\"), Group(\"Texturing\"), Order(1), Step(1)]\r\n\tprivate float BridgeTextureRepeat { get; set { field = value.Clamp(10.0f, 10000.0f); m_DoesBridgeNeedRebuild = true; } } = 500.0f;\r\n\r\n\t[Property(Title = \"Border Width\"), Feature(\"Bridge\"), Group(\"Shape\"), Order(0), Range(10.0f, 500.0f)]\r\n\tprivate float BridgeBorderWidth { get; set { field = value.Clamp(10.0f, 500.0f); m_DoesBridgeNeedRebuild = true; } } = 80.0f;\r\n\r\n\t[Property(Title = \"Border Height\"), Feature(\"Bridge\"), Group(\"Shape\"), Range(10.0f, 500.0f)]\r\n\tprivate float BridgeBorderHeight { get; set { field = value.Clamp(10.0f, 500.0f); m_DoesBridgeNeedRebuild = true; } } = 80.0f;\r\n\r\n\t[Property(Title = \"Bottom Depth\"), Feature(\"Bridge\"), Group(\"Shape\"), Range(10.0f, 500.0f)]\r\n\tprivate float BridgeBottomDepth { get; set { field = value.Clamp(10.0f, 500.0f); m_DoesBridgeNeedRebuild = true; } } = 80.0f;\r\n\r\n\t[Property(Title = \"Close Caps\"), Feature(\"Bridge\"), Group(\"Shape\")]\r\n\tprivate bool BridgeCloseCaps { get; set { field = value; m_DoesBridgeNeedRebuild = true; } } = true;\r\n\r\n\t[Property(Title = \"Pillars\"), Feature(\"Bridge\"), ToggleGroup(\"Pillars\"), Order(2)]\r\n\tprivate bool Pillars { get; set { field = value; m_DoesBridgeNeedRebuild = true; } } = true;\r\n\r\n\t[Property(Title = \"Shape\"), Feature(\"Bridge\"), ToggleGroup(\"Pillars\")]\r\n\tprivate BridgePillarShape BridgePillarType { get; set { field = value; m_DoesBridgeNeedRebuild = true; } } = BridgePillarShape.Square;\r\n\r\n\t[Property(Title = \"Size\"), Feature(\"Bridge\"), ToggleGroup(\"Pillars\"), Range(10.0f, 1000.0f)]\r\n\tprivate float BridgePillarSize { get; set { field = value; m_DoesBridgeNeedRebuild = true; } } = 200.0f;\r\n\r\n\t[Property(Title = \"Height\"), Feature(\"Bridge\"), ToggleGroup(\"Pillars\"), Range(10.0f, 5000.0f)]\r\n\tprivate float BridgePillarHeight { get; set { field = value; m_DoesBridgeNeedRebuild = true; } } = 600.0f;\r\n\r\n\t[Property(Title = \"Spacing\"), Feature(\"Bridge\"), ToggleGroup(\"Pillars\"), Range(100.0f, 10000.0f)]\r\n\tprivate float BridgePillarSpacing { get; set { field = value.Clamp(100.0f, 100000.0f); m_DoesBridgeNeedRebuild = true; } } = 1200.0f;\r\n\r\n\t[Property(Title = \"Inset\"), Feature(\"Bridge\"), ToggleGroup(\"Pillars\"), Range(0.0f, 200.0f)]\r\n\tprivate float BridgePillarInset { get; set { field = value; m_DoesBridgeNeedRebuild = true; } } = 20.0f;\r\n\r\n\t[Property(Title = \"Segments\"), Feature(\"Bridge\"), ToggleGroup(\"Pillars\"), Range(3, 24), ShowIf(nameof(BridgePillarType), BridgePillarShape.Cylinder)]\r\n\tprivate int BridgePillarRoundSegments { get; set { field = value.Clamp(3, 64); m_DoesBridgeNeedRebuild = true; } } = 12;\r\n\r\n\t/// <summary>\r\n\t/// Does the pillars follow world up vector or follow the road shape ?\r\n\t/// </summary>\r\n\t[Property(Title = \"Keep Vertical (World Up)\"), Feature(\"Bridge\"), ToggleGroup(\"Pillars\")]\r\n\tprivate bool BridgePillarsKeepVertical { get; set { field = value; m_DoesBridgeNeedRebuild = true; } } = true;\r\n\t\r\n\t[Property, FeatureEnabled(\"Crosswalks\", Icon = \"menu\", Tint = EditorTint.Pink), Change] private bool HasCrosswalks { get; set; } = false;\r\n\t[Property(Title = \"Config\"), Feature(\"Crosswalks\")] public CrosswalkConfig CrosswalkConfig { get; set { field = value; m_DoesCrosswalksNeedsRebuild = true; } } = CrosswalkConfig.Both;\r\n\t[Property(Title = \"Decal Definition\"), Feature(\"Crosswalks\")] public DecalDefinition CrosswalkDefinition { get; set { field = value; m_DoesCrosswalksNeedsRebuild = true; } }\r\n\t[Property(Title = \"Decal Size\"), Feature(\"Crosswalks\"), Range(0.1f, 10.0f)] private Vector2 CrosswalkSize { get; set { field = value; m_DoesCrosswalksNeedsRebuild = true; } } = Vector2.One;\r\n\t\r\n\tprivate bool IsDirty\r\n\t{\r\n\t\tget;\r\n\t\tset\r\n\t\t{\r\n\t\t\tfield = value;\r\n\r\n\t\t\tm_DoesRoadMeshNeedRebuild = value;\r\n\t\t\tm_DoesLamppostsNeedRebuild = value;\r\n\t\t}\r\n\t}\r\n\r\n\tpublic BBox LocalBounds => Spline.Bounds;\r\n\r\n\r\n\r\n\tpublic RoadComponent()\r\n\t{\r\n\t\tSpline.InsertPoint(Spline.PointCount, new Spline.Point { Position = new Vector3(0, 0, 0) });\r\n\t\tSpline.InsertPoint(Spline.PointCount, new Spline.Point { Position = new Vector3(1000, 0, 0) });\r\n\t\tSpline.InsertPoint(Spline.PointCount, new Spline.Point { Position = new Vector3(1600, 1000, 0) });\r\n\t}\r\n\r\n\r\n\r\n\tprotected override void OnEnabled()\r\n\t{\r\n\t\tSubscribeToSpline();\r\n\r\n\t\tEnsureLanes(); // migrate/seed the lane layout before anything that reads it (lines mesh, traffic graph) runs\r\n\r\n\t\tEnsureRoadMeshExist();\r\n\t\tEnsureSidewalkMeshExist();\r\n\t\tEnsureBridgeMeshExist();\r\n\r\n\t\tCreateLines();\r\n\t\tCreateDecals();\r\n\t\tCreateLampposts();\r\n\t\tCreateCrosswalks();\r\n\t}\r\n\r\n\r\n\r\n\tprotected override void OnDisabled()\r\n\t{\r\n\t\tUnsubscribeFromSpline();\r\n\r\n\t\tRemoveRoadMesh();\r\n\t\tRemoveSidewalkMesh();\r\n\t\tRemoveLines();\r\n\t\tRemoveDecals();\r\n\t\tRemoveLampposts();\r\n\t\tRemoveCrosswalks();\r\n\t\tRemoveBridge();\r\n\t}\r\n\r\n\r\n\r\n\t/// <summary>\r\n\t/// Undo/redo restores the serialized spline data in place, which does not raise\r\n\t/// <see cref=\"Sandbox.Spline.SplineChanged\"/>. Rebuilding from here is what makes the mesh follow an undo.\r\n\t/// </summary>\r\n\tprotected override void OnValidate()\r\n\t{\r\n\t\tSubscribeToSpline();\r\n\t\tUpdateData();\r\n\t}\r\n\r\n\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tSyncSplineSubscription();\r\n\r\n\t\tUpdateRoadMeshes();\r\n\t\tUpdateLines();\r\n\t\tUpdateDecals();\r\n\t\tUpdateLampposts();\r\n\t\tUpdateCrosswalks();\r\n\t\tUpdateBridge();\r\n\t}\r\n\r\n\r\n\r\n\tprivate void UpdateRoadMeshes()\r\n\t{\r\n\t\tif (!m_DoesRoadMeshNeedRebuild)\r\n\t\t\treturn;\r\n\r\n\t\tRebuildRoadMesh();\r\n\t\tRebuildSidewalkMesh();\r\n\t\tRebuildLinesMesh();\r\n\r\n\t\tm_DoesRoadMeshNeedRebuild = false;\r\n\t}\r\n\r\n\r\n\r\n\tprivate void RebuildRoadMesh()\r\n\t{\r\n\t\tif (SandboxUtility.IsInPlayMode)\r\n\t\t\treturn;\r\n\r\n\t\tif (IsRoadLocked)\r\n\t\t\treturn;\r\n\r\n\t\tRemoveGeneratedMeshChildren(RoadSurfaceTag);\r\n\t\tBuildRoadMesh();\r\n\t}\r\n\r\n\r\n\r\n\tprivate void RebuildSidewalkMesh()\r\n\t{\r\n\t\tif (SandboxUtility.IsInPlayMode)\r\n\t\t\treturn;\r\n\r\n\t\tif (IsSidewalkLocked)\r\n\t\t\treturn;\r\n\r\n\t\tRemoveGeneratedMeshChildren(SidewalkSurfaceTag);\r\n\t\tBuildSidewalkMesh();\r\n\t}\r\n\r\n\r\n\r\n\tprivate void RemoveRoadMesh()\r\n\t{\r\n\t\tif (IsRoadLocked)\r\n\t\t\treturn;\r\n\r\n\t\tRemoveGeneratedMeshChildren(RoadSurfaceTag);\r\n\t}\r\n\r\n\r\n\r\n\tprivate void RemoveSidewalkMesh()\r\n\t{\r\n\t\tif (IsSidewalkLocked)\r\n\t\t\treturn;\r\n\r\n\t\tRemoveGeneratedMeshChildren(SidewalkSurfaceTag);\r\n\t}\r\n\r\n\r\n\r\n\tprivate void RemoveGeneratedMeshChildren(string _Tag)\r\n\t{\r\n\t\tvar toRemove = GameObject.Children.Where(child => child.Tags.Has(_Tag)).ToList();\r\n\r\n\t\tforeach (var child in toRemove)\r\n\t\t\tchild.Destroy();\r\n\t}\r\n\r\n\r\n\r\n\tprivate bool HasGeneratedMeshChildren(string _Tag)\r\n\t{\r\n\t\treturn GameObject.Children.Any(child => child.Tags.Has(_Tag));\r\n\t}\r\n\r\n\r\n\r\n\t/// <summary>\r\n\t/// Safety net for editor state changes that swap the spline instance without ever touching the\r\n\t/// property setter or <see cref=\"OnValidate\"/>. A reference compare per frame is cheap enough to\r\n\t/// be worth never silently losing the subscription again.\r\n\t/// </summary>\r\n\tprivate void SyncSplineSubscription()\r\n\t{\r\n\t\tif (ReferenceEquals(m_SubscribedSpline, Spline))\r\n\t\t\treturn;\r\n\r\n\t\tSubscribeToSpline();\r\n\t\tUpdateData();\r\n\t}\r\n\r\n\r\n\r\n\tprivate void SubscribeToSpline()\r\n\t{\r\n\t\tif (ReferenceEquals(m_SubscribedSpline, Spline))\r\n\t\t\treturn;\r\n\r\n\t\tUnsubscribeFromSpline();\r\n\r\n\t\tm_SubscribedSpline = Spline;\r\n\r\n\t\tif (m_SubscribedSpline is not null)\r\n\t\t\tm_SubscribedSpline.SplineChanged += UpdateData;\r\n\t}\r\n\r\n\r\n\r\n\tprivate void UnsubscribeFromSpline()\r\n\t{\r\n\t\tif (m_SubscribedSpline is null)\r\n\t\t\treturn;\r\n\r\n\t\tm_SubscribedSpline.SplineChanged -= UpdateData;\r\n\t\tm_SubscribedSpline = null;\r\n\t}\r\n\r\n\r\n\r\n\tprivate void UpdateData()\r\n\t{\r\n\t\tif (!GameObject.IsValid() || !Scene.IsEditor)\r\n\t\t\treturn;\r\n\r\n\t\tIsDirty = true;\r\n\t}\r\n}\r\n"
},
{
"Ident": "redsnail.roadtool",
"Path": "RoadComponent/RoadComponent.Lines.cs",
"FileName": "RoadComponent.Lines.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 342326,
"IsPrivate": false,
"Code": "using System;\r\nusing Sandbox;\r\n\r\nnamespace RedSnail.RoadTool;\r\n\r\npublic partial class RoadComponent\r\n{\r\n\t[Property, FeatureEnabled(\"Lines\", Icon = \"show_chart\", Tint = EditorTint.Yellow), Change] private bool HasLines { get; set; } = false;\r\n\t[Property(Title = \"Lines\"), Feature(\"Lines\")] public RoadLineDefinition[] LineDefinitions { get; set { field = value; IsDirty = true; } }\r\n\t[Property(Title = \"Offset\"), Feature(\"Lines\"), Range(0.01f, 1.0f)] private float LinesOffset { get; set { field = value; IsDirty = true; } } = 0.1f;\r\n\t[Property(Title = \"Width\"), Feature(\"Lines\"), Range(1.0f, 50.0f)] private float LinesWidth { get; set { field = value; IsDirty = true; } } = 5.0f;\r\n\t[Property(Title = \"Extra Spacing\"), Feature(\"Lines\"), Range(0.0f, 1000.0f)] private float LinesExtraSpacing { get; set { field = value; IsDirty = true; } } = 0.0f;\r\n\t[Property(Title = \"Texture Repeat\"), Feature(\"Lines\")] private float LinesTextureRepeat { get; set { field = value.Clamp(1.0f, 100000.0f); IsDirty = true; } } = 10.0f;\r\n\r\n\r\n\r\n\tprivate void OnHasLinesChanged(bool _OldValue, bool _NewValue)\r\n\t{\r\n\t\tIsDirty = true;\r\n\t}\r\n\r\n\r\n\r\n\tprivate void CreateLines()\r\n\t{\r\n\t\tEnsureLinesMeshExist();\r\n\t}\r\n\r\n\r\n\r\n\tprivate void UpdateLines()\r\n\t{\r\n\t}\r\n\r\n\r\n\r\n\tprivate void RemoveLines()\r\n\t{\r\n\t\tRemoveGeneratedMeshChildren(LineSurfaceTag);\r\n\t}\r\n\r\n\r\n\r\n\tprivate void EnsureLinesMeshExist()\r\n\t{\r\n\t\tif (SandboxUtility.IsInPlayMode)\r\n\t\t\treturn;\r\n\r\n\t\tif (HasGeneratedMeshChildren(LineSurfaceTag))\r\n\t\t\treturn;\r\n\r\n\t\tBuildLinesMesh();\r\n\t}\r\n\r\n\r\n\r\n\tprivate void RebuildLinesMesh()\r\n\t{\r\n\t\tif (SandboxUtility.IsInPlayMode)\r\n\t\t\treturn;\r\n\r\n\t\tRemoveGeneratedMeshChildren(LineSurfaceTag);\r\n\t\tBuildLinesMesh();\r\n\t}\r\n\r\n\r\n\r\n\tprivate void BuildLinesMesh()\r\n\t{\r\n\t\tif (!HasLines || LineDefinitions == null || LineDefinitions.Length == 0)\r\n\t\t\treturn;\r\n\r\n\t\tGetSplineFrameData(out var frames, out var segmentsToKeep);\r\n\r\n\t\tint finalSegmentCount = segmentsToKeep.Count - 1;\r\n\r\n\t\tif (finalSegmentCount <= 0)\r\n\t\t\treturn;\r\n\r\n\t\tfloat roadWidth = RoadWidth + LinesExtraSpacing;\r\n\t\tfloat lineSpacing = roadWidth / (LineDefinitions.Length + 1);\r\n\r\n\t\tvar polygonMeshes = new PolygonMesh[LineDefinitions.Length];\r\n\t\tfor (int i = 0; i < LineDefinitions.Length; i++)\r\n\t\t\tpolygonMeshes[i] = new PolygonMesh();\r\n\r\n\t\tfloat[] lineDistances = new float[LineDefinitions.Length];\r\n\r\n\t\tfor (int i = 0; i < finalSegmentCount; i++)\r\n\t\t{\r\n\t\t\tint idx0 = segmentsToKeep[i];\r\n\t\t\tint idx1 = segmentsToKeep[i + 1];\r\n\r\n\t\t\tTransform f0 = frames[idx0];\r\n\t\t\tTransform f1 = frames[idx1];\r\n\r\n\t\t\tVector3 p0 = f0.Position;\r\n\t\t\tVector3 p1 = f1.Position;\r\n\r\n\t\t\tVector3 right0 = f0.Rotation.Right;\r\n\r\n\t\t\tfor (int line = 0; line < LineDefinitions.Length; line++)\r\n\t\t\t{\r\n\t\t\t\tfloat offsetFromCenter = ((line + 1) * lineSpacing) - (roadWidth * 0.5f);\r\n\r\n\t\t\t\tVector3 center0 =\r\n\t\t\t\t\tp0 +\r\n\t\t\t\t\tf0.Rotation.Right * offsetFromCenter +\r\n\t\t\t\t\tf0.Rotation.Up * LinesOffset;\r\n\r\n\t\t\t\tVector3 center1 =\r\n\t\t\t\t\tp1 +\r\n\t\t\t\t\tf1.Rotation.Right * offsetFromCenter +\r\n\t\t\t\t\tf1.Rotation.Up * LinesOffset;\r\n\r\n\t\t\t\tfloat segmentLength = Vector3.DistanceBetween(center0, center1);\r\n\t\t\t\tVector3 dir = (center1 - center0).Normal;\r\n\r\n\t\t\t\tfloat remaining = segmentLength;\r\n\t\t\t\tVector3 curCenter = center0;\r\n\r\n\t\t\t\tfloat dashSpacing = LineDefinitions[line]?.DashSpacing ?? 0.0f;\r\n\t\t\t\tfloat dashFillRatio = LineDefinitions[line]?.DashFillRatio ?? 1.0f;\r\n\t\t\t\tfloat dashLength = dashSpacing * dashFillRatio;\r\n\t\t\t\tfloat halfWidth = LinesWidth * 0.5f;\r\n\r\n\t\t\t\tvar polygonMesh = polygonMeshes[line];\r\n\t\t\t\tvar material = LineDefinitions[line]?.Material ?? Material.Load(\"materials/default.vmat\");\r\n\r\n\t\t\t\twhile (remaining > 0.001f)\r\n\t\t\t\t{\r\n\t\t\t\t\tfloat linePos = lineDistances[line];\r\n\t\t\t\t\tfloat cyclePos = dashSpacing > 0 ? linePos % dashSpacing : 0;\r\n\r\n\t\t\t\t\tif (cyclePos < 0.0001f)\r\n\t\t\t\t\t\tcyclePos = 0.0f;\r\n\r\n\t\t\t\t\tif (dashSpacing > 0 && dashSpacing - cyclePos < 0.0001f)\r\n\t\t\t\t\t\tcyclePos = dashSpacing;\r\n\r\n\t\t\t\t\tbool inDash = dashSpacing <= 0 || cyclePos <= dashLength - 0.0001f;\r\n\r\n\t\t\t\t\tfloat step;\r\n\r\n\t\t\t\t\tif (dashSpacing <= 0)\r\n\t\t\t\t\t\tstep = remaining;\r\n\t\t\t\t\telse if (inDash)\r\n\t\t\t\t\t\tstep = dashLength - cyclePos;\r\n\t\t\t\t\telse\r\n\t\t\t\t\t\tstep = dashSpacing - cyclePos;\r\n\r\n\t\t\t\t\tstep = Math.Max(step, 0.01f);\r\n\t\t\t\t\tstep = Math.Min(step, remaining);\r\n\r\n\t\t\t\t\tVector3 nextCenter = curCenter + dir * step;\r\n\r\n\t\t\t\t\tif (inDash)\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tVector3 l0 = curCenter - right0 * halfWidth;\r\n\t\t\t\t\t\tVector3 r0 = curCenter + right0 * halfWidth;\r\n\t\t\t\t\t\tVector3 l1 = nextCenter - right0 * halfWidth;\r\n\t\t\t\t\t\tVector3 r1 = nextCenter + right0 * halfWidth;\r\n\r\n\t\t\t\t\t\tfloat v0 = linePos / LinesTextureRepeat;\r\n\t\t\t\t\t\tfloat v1 = (linePos + step) / LinesTextureRepeat;\r\n\r\n\t\t\t\t\t\tvar verts = polygonMesh.AddVertices(l0, r0, r1, l1);\r\n\t\t\t\t\t\tMeshUtility.AddTexturedQuad(polygonMesh, material, verts[0], verts[1], verts[2], verts[3],\r\n\t\t\t\t\t\t\tnew Vector2(0, v0), new Vector2(1, v0),\r\n\t\t\t\t\t\t\tnew Vector2(1, v1), new Vector2(0, v1));\r\n\t\t\t\t\t}\r\n\r\n\t\t\t\t\tlineDistances[line] += step;\r\n\t\t\t\t\tcurCenter = nextCenter;\r\n\t\t\t\t\tremaining -= step;\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tfor (int line = 0; line < LineDefinitions.Length; line++)\r\n\t\t{\r\n\t\t\tvar child = new GameObject(GameObject, true, $\"Line_{line}\");\r\n\t\t\tchild.Tags.Add(LineSurfaceTag);\r\n\r\n\t\t\tvar meshComponent = child.AddComponent<MeshComponent>();\r\n\t\t\tmeshComponent.Mesh = polygonMeshes[line];\r\n\t\t\tmeshComponent.Collision = MeshComponent.CollisionType.None;\r\n\t\t\tmeshComponent.RenderType = ModelRenderer.ShadowRenderType.Off;\r\n\t\t\tmeshComponent.SmoothingAngle = 40.0f;\r\n\t\t\tmeshComponent.Static = true;\r\n\t\t}\r\n\t}\r\n}\r\n"
},
{
"Ident": "redsnail.roadtool",
"Path": "Utility/MeshUtility.cs",
"FileName": "MeshUtility.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 342326,
"IsPrivate": false,
"Code": "using System.Collections.Generic;\r\nusing Sandbox;\r\n\r\nnamespace RedSnail.RoadTool;\r\n\r\npublic static class MeshUtility\r\n{\r\n\tpublic static HalfEdgeMesh.VertexHandle GetOrAddVertex(PolygonMesh _Mesh, Dictionary<Vector3, HalfEdgeMesh.VertexHandle> _Cache, Vector3 _Pos)\r\n\t{\r\n\t\tif (!_Cache.TryGetValue(_Pos, out var handle))\r\n\t\t{\r\n\t\t\thandle = _Mesh.AddVertices(_Pos)[0];\r\n\t\t\t_Cache[_Pos] = handle;\r\n\t\t}\r\n\r\n\t\treturn handle;\r\n\t}\r\n\r\n\r\n\r\n\tpublic static void AddTexturedQuad(PolygonMesh _Mesh, Material _Material,\n\t\tHalfEdgeMesh.VertexHandle _A, HalfEdgeMesh.VertexHandle _B,\n\t\tHalfEdgeMesh.VertexHandle _C, HalfEdgeMesh.VertexHandle _D,\n\t\tVector2 _UvA, Vector2 _UvB, Vector2 _UvC, Vector2 _UvD)\n\t{\n\t\tif (HasDuplicateVertex(_A, _B, _C, _D))\n\t\t\treturn;\n\n\t\tvar face = _Mesh.AddFace(_A, _B, _C, _D);\n\r\n\t\tif (!face.IsValid)\r\n\t\t\treturn;\r\n\r\n\t\t_Mesh.SetFaceMaterial(face, _Material);\r\n\t\t_Mesh.SetFaceTextureCoords(face, new List<Vector2> { _UvA, _UvB, _UvC, _UvD });\r\n\t}\r\n\r\n\r\n\r\n\tpublic static void AddTexturedTriangle(PolygonMesh _Mesh, Material _Material,\n\t\tHalfEdgeMesh.VertexHandle _A, HalfEdgeMesh.VertexHandle _B,\n\t\tHalfEdgeMesh.VertexHandle _C,\n\t\tVector2 _UvA, Vector2 _UvB, Vector2 _UvC)\n\t{\n\t\tif (HasDuplicateVertex(_A, _B, _C))\n\t\t\treturn;\n\n\t\tvar face = _Mesh.AddFace(_A, _B, _C);\n\r\n\t\tif (!face.IsValid)\r\n\t\t\treturn;\r\n\r\n\t\t_Mesh.SetFaceMaterial(face, _Material);\n\t\t_Mesh.SetFaceTextureCoords(face, new List<Vector2> { _UvA, _UvB, _UvC });\n\t}\n\n\n\n\tprivate static bool HasDuplicateVertex(HalfEdgeMesh.VertexHandle _A, HalfEdgeMesh.VertexHandle _B, HalfEdgeMesh.VertexHandle _C)\n\t{\n\t\treturn _A.Equals(_B) || _A.Equals(_C) || _B.Equals(_C);\n\t}\n\n\n\n\tprivate static bool HasDuplicateVertex(HalfEdgeMesh.VertexHandle _A, HalfEdgeMesh.VertexHandle _B, HalfEdgeMesh.VertexHandle _C, HalfEdgeMesh.VertexHandle _D)\n\t{\n\t\treturn _A.Equals(_B) || _A.Equals(_C) || _A.Equals(_D) ||\n\t\t\t_B.Equals(_C) || _B.Equals(_D) ||\n\t\t\t_C.Equals(_D);\n\t}\n}\n"
},
{
"Ident": "redsnail.roadtool",
"Path": "Code/RailComponent/RailComponent.Fishplates.cs",
"FileName": "RailComponent.Fishplates.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 342326,
"IsPrivate": false,
"Code": "using System;\nusing Sandbox;\n\nnamespace RedSnail.RoadTool;\n\npublic partial class RailComponent\n{\n\t[Property, FeatureEnabled(\"Fishplates\", Icon = \"link\", Tint = EditorTint.Green)] private bool HasFishplates { get; set { field = value; IsDirty = true; } } = true;\n\t[Property(Title = \"Material\"), Feature(\"Fishplates\")] private Material FishplateMaterial { get; set { field = value; IsDirty = true; } }\n\n\t/// <summary>Distance along the track between joints. Real rail comes in long sections, so this is much larger than the sleeper spacing.</summary>\n\t[Property(Title = \"Spacing\"), Feature(\"Fishplates\"), Range(100.0f, 5000.0f)] private float FishplateSpacing { get; set { field = value; IsDirty = true; } } = 1200.0f;\n\n\t/// <summary>Plate length along the track (it straddles the joint between two rail sections).</summary>\n\t[Property(Title = \"Length\"), Feature(\"Fishplates\"), Range(10.0f, 500.0f)] private float FishplateLength { get; set { field = value; IsDirty = true; } } = 80.0f;\n\n\t/// <summary>Plate height \u2014 sized to sit against the rail web, between the foot and the head.</summary>\n\t[Property(Title = \"Height\"), Feature(\"Fishplates\"), Range(2.0f, 100.0f)] private float FishplateHeight { get; set { field = value; IsDirty = true; } } = 14.0f;\n\n\t/// <summary>How far the plate stands out from the rail web.</summary>\n\t[Property(Title = \"Thickness\"), Feature(\"Fishplates\"), Range(1.0f, 50.0f)] private float FishplateThickness { get; set { field = value; IsDirty = true; } } = 6.0f;\n\n\t/// <summary>Radius of the hexagonal bolt heads.</summary>\n\t[Property(Title = \"Bolt Radius\"), Feature(\"Fishplates\"), Range(0.5f, 20.0f)] private float FishplateBoltRadius { get; set { field = value; IsDirty = true; } } = 3.5f;\n\n\t/// <summary>How far the bolt heads stand out from the plate.</summary>\n\t[Property(Title = \"Bolt Depth\"), Feature(\"Fishplates\"), Range(0.5f, 20.0f)] private float FishplateBoltDepth { get; set { field = value; IsDirty = true; } } = 3.0f;\n\n\t[Property(Title = \"Texture Repeat\"), Feature(\"Fishplates\")] private float FishplateTextureRepeat { get; set { field = value.Clamp(1.0f, 100000.0f); IsDirty = true; } } = 100.0f;\n\n\n\n\tprivate void BuildFishplates(Transform[] _Frames)\n\t{\n\t\tfloat length = Spline.Length;\n\n\t\tif (length <= 0.0f)\n\t\t\treturn;\n\n\t\tfloat spacing = Math.Max(1.0f, FishplateSpacing);\n\n\t\tint count = (int)MathF.Floor(length / spacing);\n\n\t\tif (count < 1)\n\t\t\treturn; // track is shorter than one rail section \u2014 no joints\n\n\t\tvar material = FishplateMaterial ?? Material.Load(\"materials/dev/reflectivity_50.vmat\");\n\t\tvar polygonMesh = new PolygonMesh();\n\n\t\tfloat textureRepeat = Math.Max(1.0f, FishplateTextureRepeat);\n\t\tfloat baseHeight = HasSleepers ? SleeperHeight : 0.0f;\n\t\tfloat halfGauge = RailGauge * 0.5f;\n\n\t\tint placed = 0;\n\n\t\tfor (int s = 1; s <= count; s++)\n\t\t{\n\t\t\tfloat distance = s * spacing;\n\n\t\t\tif (distance >= length)\n\t\t\t\tbreak; // don't drop a joint right on the end of the track\n\n\t\t\tvar frame = SampleFrameAtDistance(_Frames, distance, length);\n\n\t\t\t// One plate on the OUTER side of each rail (the outward sign flips the side for the left rail).\n\t\t\tAddFishplate(polygonMesh, material, frame, halfGauge, 1.0f, baseHeight, textureRepeat);\n\t\t\tAddFishplate(polygonMesh, material, frame, halfGauge, -1.0f, baseHeight, textureRepeat);\n\n\t\t\tplaced++;\n\t\t}\n\n\t\tif (placed == 0)\n\t\t\treturn;\n\n\t\tCreateFishplateChild(\"Fishplates\", polygonMesh);\n\t}\n\n\n\n\t/// <summary>Adds one bolted joint plate against the outer face of a single rail's web.</summary>\n\tprivate void AddFishplate(PolygonMesh _Mesh, Material _Material, Transform _Frame, float _HalfGauge, float _OutwardSign, float _BaseHeight, float _TextureRepeat)\n\t{\n\t\tVector3 origin = _Frame.Position;\n\t\tVector3 right = _Frame.Rotation.Right;\n\t\tVector3 forward = _Frame.Rotation.Forward;\n\t\tVector3 up = _Frame.Rotation.Up;\n\n\t\tVector3 outward = right * _OutwardSign; // points away from the track centre, toward the outer side of this rail\n\n\t\tfloat halfWeb = RailWidth * RailWebScale * 0.5f;\n\t\tfloat webMid = _BaseHeight + RailHeight * 0.5f;\n\n\t\tfloat embed = MathF.Min(1.0f, halfWeb * 0.5f); // bite into the web so the hidden inner face never z-fights it\n\t\tfloat innerDist = halfWeb - embed;\n\t\tfloat outerDist = halfWeb + FishplateThickness;\n\t\tfloat centreDist = (innerDist + outerDist) * 0.5f;\n\t\tfloat halfOut = (outerDist - innerDist) * 0.5f;\n\n\t\tfloat halfLength = FishplateLength * 0.5f;\n\t\tfloat halfHeight = FishplateHeight * 0.5f;\n\n\t\tVector3 railCentre = origin + right * (_HalfGauge * _OutwardSign);\n\t\tVector3 plateCentre = railCentre + outward * centreDist + up * webMid;\n\n\t\tAddBox(_Mesh, _Material, plateCentre, outward, forward, up, halfOut, halfLength, halfHeight, _TextureRepeat);\n\n\t\t// Two hex bolts standing out of the plate's outer face, spread along the joint.\n\t\tVector3 outerFace = plateCentre + outward * halfOut;\n\t\tfloat boltOffset = FishplateLength * 0.25f;\n\n\t\tAddHexBolt(_Mesh, _Material, outerFace + forward * boltOffset, outward, forward, FishplateBoltRadius, FishplateBoltDepth, _TextureRepeat);\n\t\tAddHexBolt(_Mesh, _Material, outerFace - forward * boltOffset, outward, forward, FishplateBoltRadius, FishplateBoltDepth, _TextureRepeat);\n\t}\n\n\n\n\t/// <summary>A closed box built from three frame axes, textured with a uniform box (cube) projection.</summary>\n\tprivate static void AddBox(PolygonMesh _Mesh, Material _Material, Vector3 _Centre, Vector3 _AxisR, Vector3 _AxisF, Vector3 _AxisU, float _HalfR, float _HalfF, float _HalfU, float _TextureRepeat)\n\t{\n\t\t// The faces below wind outward only for a right-handed basis. The outward axis flips for the left rail, which\n\t\t// would make it left-handed and flip every face to a backface \u2014 mirror one axis to keep it right-handed (the\n\t\t// box is symmetric, so this changes nothing but the winding).\n\t\tif (Vector3.Dot(Vector3.Cross(_AxisR, _AxisF), _AxisU) < 0.0f)\n\t\t\t_AxisF = -_AxisF;\n\n\t\tVector3 Corner(float r, float f, float u) => _Centre + _AxisR * r + _AxisF * f + _AxisU * u;\n\n\t\tvar c000 = Corner(-_HalfR, -_HalfF, -_HalfU);\n\t\tvar c100 = Corner(_HalfR, -_HalfF, -_HalfU);\n\t\tvar c110 = Corner(_HalfR, _HalfF, -_HalfU);\n\t\tvar c010 = Corner(-_HalfR, _HalfF, -_HalfU);\n\t\tvar c001 = Corner(-_HalfR, -_HalfF, _HalfU);\n\t\tvar c101 = Corner(_HalfR, -_HalfF, _HalfU);\n\t\tvar c111 = Corner(_HalfR, _HalfF, _HalfU);\n\t\tvar c011 = Corner(-_HalfR, _HalfF, _HalfU);\n\n\t\tfloat r0 = -_HalfR / _TextureRepeat, r1 = _HalfR / _TextureRepeat;\n\t\tfloat f0 = -_HalfF / _TextureRepeat, f1 = _HalfF / _TextureRepeat;\n\t\tfloat u0 = -_HalfU / _TextureRepeat, u1 = _HalfU / _TextureRepeat;\n\n\t\tAddQuad(_Mesh, _Material, c100, c110, c111, c101, new Vector2(f0, u0), new Vector2(f1, u0), new Vector2(f1, u1), new Vector2(f0, u1)); // +R\n\t\tAddQuad(_Mesh, _Material, c010, c000, c001, c011, new Vector2(f1, u0), new Vector2(f0, u0), new Vector2(f0, u1), new Vector2(f1, u1)); // -R\n\t\tAddQuad(_Mesh, _Material, c110, c010, c011, c111, new Vector2(r1, u0), new Vector2(r0, u0), new Vector2(r0, u1), new Vector2(r1, u1)); // +F\n\t\tAddQuad(_Mesh, _Material, c000, c100, c101, c001, new Vector2(r0, u0), new Vector2(r1, u0), new Vector2(r1, u1), new Vector2(r0, u1)); // -F\n\t\tAddQuad(_Mesh, _Material, c001, c101, c111, c011, new Vector2(r0, f0), new Vector2(r1, f0), new Vector2(r1, f1), new Vector2(r0, f1)); // +U\n\t\tAddQuad(_Mesh, _Material, c000, c010, c110, c100, new Vector2(r0, f0), new Vector2(r0, f1), new Vector2(r1, f1), new Vector2(r1, f0)); // -U\n\t}\n\n\n\n\t/// <summary>A hexagonal bolt head \u2014 a 6-sided prism standing out of the plate along <paramref name=\"_AxisOut\"/>, capped on the outer end.</summary>\n\tprivate static void AddHexBolt(PolygonMesh _Mesh, Material _Material, Vector3 _Base, Vector3 _AxisOut, Vector3 _Forward, float _Radius, float _Depth, float _TextureRepeat)\n\t{\n\t\tconst int sides = 6;\n\n\t\t// t2 completes a right-handed (out, t1, t2) basis so the winding below stays outward-facing on either rail.\n\t\tVector3 t1 = _Forward;\n\t\tVector3 t2 = Vector3.Cross(_AxisOut, t1);\n\n\t\tvar baseRing = new Vector3[sides];\n\t\tvar topRing = new Vector3[sides];\n\n\t\tfor (int k = 0; k < sides; k++)\n\t\t{\n\t\t\tfloat angle = MathF.PI * 2.0f * k / sides;\n\t\t\tVector3 radial = t1 * MathF.Cos(angle) + t2 * MathF.Sin(angle);\n\n\t\t\tbaseRing[k] = _Base + radial * _Radius;\n\t\t\ttopRing[k] = baseRing[k] + _AxisOut * _Depth;\n\t\t}\n\n\t\tfloat edge = _Radius; // a regular hexagon's side length equals its radius\n\t\tfloat v1 = _Depth / _TextureRepeat;\n\n\t\tfor (int k = 0; k < sides; k++)\n\t\t{\n\t\t\tint next = (k + 1) % sides;\n\t\t\tfloat u0 = edge * k / _TextureRepeat;\n\t\t\tfloat u1 = edge * (k + 1) / _TextureRepeat;\n\n\t\t\tAddQuad(_Mesh, _Material, baseRing[k], baseRing[next], topRing[next], topRing[k],\n\t\t\t\tnew Vector2(u0, 0.0f), new Vector2(u1, 0.0f), new Vector2(u1, v1), new Vector2(u0, v1));\n\t\t}\n\n\t\tVector2 CapUv(Vector3 _P) => new Vector2(Vector3.Dot(_P - _Base, t1), Vector3.Dot(_P - _Base, t2)) / _TextureRepeat;\n\n\t\tfor (int k = 1; k < sides - 1; k++)\n\t\t{\n\t\t\tAddTri(_Mesh, _Material, topRing[0], topRing[k], topRing[k + 1],\n\t\t\t\tCapUv(topRing[0]), CapUv(topRing[k]), CapUv(topRing[k + 1]));\n\t\t}\n\t}\n\n\n\n\t// Own vertices per face so every plate edge and bolt facet stays hard.\n\tprivate static void AddQuad(PolygonMesh _Mesh, Material _Material, Vector3 _A, Vector3 _B, Vector3 _C, Vector3 _D, Vector2 _UvA, Vector2 _UvB, Vector2 _UvC, Vector2 _UvD)\n\t{\n\t\tvar v = _Mesh.AddVertices(_A, _B, _C, _D);\n\n\t\tMeshUtility.AddTexturedQuad(_Mesh, _Material, v[0], v[1], v[2], v[3], _UvA, _UvB, _UvC, _UvD);\n\t}\n\n\n\n\tprivate static void AddTri(PolygonMesh _Mesh, Material _Material, Vector3 _A, Vector3 _B, Vector3 _C, Vector2 _UvA, Vector2 _UvB, Vector2 _UvC)\n\t{\n\t\tvar v = _Mesh.AddVertices(_A, _B, _C);\n\n\t\tMeshUtility.AddTexturedTriangle(_Mesh, _Material, v[0], v[1], v[2], _UvA, _UvB, _UvC);\n\t}\n\n\n\n\tprivate void CreateFishplateChild(string _Name, PolygonMesh _PolygonMesh)\n\t{\n\t\tvar child = new GameObject(GameObject, true, _Name);\n\t\tchild.Tags.Add(RailMeshTag);\n\t\tchild.Tags.Add(FishplateSurfaceTag);\n\n\t\tvar meshComponent = child.AddComponent<MeshComponent>();\n\t\tmeshComponent.Mesh = _PolygonMesh;\n\t}\n}\n"
},
{
"Ident": "redsnail.roadtool",
"Path": "Code/RailComponent/RailComponent.Utility.cs",
"FileName": "RailComponent.Utility.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 342326,
"IsPrivate": false,
"Code": "using System;\nusing Sandbox;\n\nnamespace RedSnail.RoadTool;\n\npublic partial class RailComponent\n{\n\tprivate static Transform[] CalculateTangentFramesUsingUpDir(Spline _Spline, int _FrameCount)\n\t{\n\t\tvar frames = new Transform[_FrameCount];\n\t\tfloat totalSplineLength = _Spline.Length;\n\n\t\tvar sample = _Spline.SampleAtDistance(0.0f);\n\t\tsample.Up = Vector3.Up;\n\n\t\t// Fall back to a different up if the tangent runs (nearly) straight up.\n\t\tif (MathF.Abs(Vector3.Dot(sample.Tangent, sample.Up)) > 0.999f)\n\t\t\tsample.Up = Vector3.Right;\n\n\t\tfor (int i = 0; i < _FrameCount; i++)\n\t\t{\n\t\t\tfloat t = _FrameCount > 1 ? (float)i / (_FrameCount - 1) : 0.0f;\n\t\t\tfloat distance = t * totalSplineLength;\n\n\t\t\tsample = _Spline.SampleAtDistance(distance);\n\n\t\t\tvar up = Rotation.FromAxis(sample.Tangent, sample.Roll) * sample.Up;\n\t\t\tRotation rotation = Rotation.LookAt(sample.Tangent, up);\n\n\t\t\tframes[i] = new Transform(sample.Position, rotation, sample.Scale);\n\t\t}\n\n\t\treturn frames;\n\t}\n\n\n\n\tprivate static Transform[] CalculateRotationMinimizingTangentFrames(Spline _Spline, int _FrameCount)\n\t{\n\t\tvar frames = new Transform[_FrameCount];\n\t\tfloat totalSplineLength = _Spline.Length;\n\n\t\tvar previousSample = _Spline.SampleAtDistance(0.0f);\n\t\tVector3 up = Vector3.Up;\n\n\t\tif (MathF.Abs(Vector3.Dot(previousSample.Tangent, up)) > 0.999f)\n\t\t\tup = Vector3.Right;\n\n\t\tup = Rotation.FromAxis(previousSample.Tangent, previousSample.Roll) * up;\n\n\t\tframes[0] = new Transform(previousSample.Position, Rotation.LookAt(previousSample.Tangent, up), previousSample.Scale);\n\n\t\tfor (int i = 1; i < _FrameCount; i++)\n\t\t{\n\t\t\tfloat t = _FrameCount > 1 ? (float)i / (_FrameCount - 1) : 0.0f;\n\t\t\tfloat distance = t * totalSplineLength;\n\n\t\t\tvar sample = _Spline.SampleAtDistance(distance);\n\n\t\t\t// Parallel-transport the up vector so the profile doesn't twist through 3D curves.\n\t\t\tup = GetRotationMinimizingNormal(previousSample.Position, previousSample.Tangent, up, sample.Position, sample.Tangent);\n\n\t\t\tfloat deltaRoll = sample.Roll - previousSample.Roll;\n\t\t\tup = Rotation.FromAxis(sample.Tangent, deltaRoll) * up;\n\n\t\t\tRotation rotation = Rotation.LookAt(sample.Tangent, up);\n\t\t\tframes[i] = new Transform(sample.Position, rotation, sample.Scale);\n\n\t\t\tpreviousSample = sample;\n\t\t}\n\n\t\treturn frames;\n\t}\n\n\n\n\tprivate static Vector3 GetRotationMinimizingNormal(Vector3 _PosA, Vector3 _TangentA, Vector3 _NormalA, Vector3 _PosB, Vector3 _TangentB)\n\t{\n\t\t// Source: https://www.microsoft.com/en-us/research/wp-content/uploads/2016/12/Computation-of-rotation-minimizing-frames.pdf\n\t\tVector3 v1 = _PosB - _PosA;\n\n\t\tfloat v1DotV1Half = Vector3.Dot(v1, v1) / 2.0f;\n\n\t\tif (v1DotV1Half <= 0.0001f)\n\t\t\treturn _NormalA;\n\n\t\tfloat r1 = Vector3.Dot(v1, _NormalA) / v1DotV1Half;\n\t\tfloat r2 = Vector3.Dot(v1, _TangentA) / v1DotV1Half;\n\n\t\tVector3 nL = _NormalA - r1 * v1;\n\t\tVector3 tL = _TangentA - r2 * v1;\n\t\tVector3 v2 = _TangentB - tL;\n\n\t\tfloat r3 = Vector3.Dot(v2, nL) / Vector3.Dot(v2, v2);\n\n\t\treturn (nL - 2.0f * r3 * v2).Normal;\n\t}\n}\n"
},
{
"Ident": "redsnail.roadtool",
"Path": "Code/RoadIntersectionComponent/RoadIntersectionComponent.Sidewalk.cs",
"FileName": "RoadIntersectionComponent.Sidewalk.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 342326,
"IsPrivate": false,
"Code": "using System;\nusing System.Collections.Generic;\nusing Sandbox;\n\nnamespace RedSnail.RoadTool;\n\n/// <summary>\n/// How much of a junction reaches the pedestrian graph. Geometry is never affected \u2014 the pavement mesh builds\n/// the same either way; this only decides what pedestrians are allowed to route over.\n///\n/// Ordered most-to-least deliberately, so the zero value is the harmless one: a component that somehow arrives\n/// without this field set behaves like every other junction rather than silently vanishing from the graph.\n/// </summary>\npublic enum SidewalkGraphMode\n{\n\t/// <summary>Pavement round the corners and a crossing at every arm.</summary>\n\tAll,\n\n\t/// <summary>\n\t/// Corners only. The pavement stays joined all the way round and connects to every road that meets here,\n\t/// but there's nothing to step off the kerb onto \u2014 pedestrians walk round the junction instead of over it.\n\t/// </summary>\n\tNoCrossing,\n\n\t/// <summary>\n\t/// Nothing at all. The roads that meet here keep their own pavements; they just stop being connected\n\t/// THROUGH this junction, so expect their ends to show as dead ends.\n\t/// </summary>\n\tNone\n}\n\n\n\npublic partial class RoadIntersectionComponent\n{\n\t/// <summary>\n\t/// What this junction contributes to the pedestrian graph \u2014 the pedestrian counterpart of\n\t/// <see cref=\"ExcludeTraffic\"/>.\n\t///\n\t/// <see cref=\"SidewalkGraphMode.None\"/> for somewhere nobody should be walking round at all: a slip road, a\n\t/// service yard, a junction whose pavement exists only because the mesh needs an edge.\n\t/// <see cref=\"SidewalkGraphMode.NoCrossing\"/> for somewhere they may walk past but not across \u2014 a forecourt\n\t/// or car park entrance, where the kerb should stay continuous and stepping into the vehicle route is the\n\t/// thing you're trying to prevent.\n\t/// </summary>\n\t[Property, Feature(\"General\"), Category(\"Sidewalk\"), Order(3)] public SidewalkGraphMode SidewalkGraph { get; set; } = SidewalkGraphMode.All;\n\n\n\n\t/// <summary>\n\t/// The walking line around this junction's pavement, in world space \u2014 a closed loop running down the\n\t/// middle of the sidewalk slab.\n\t///\n\t/// Built from the junction's OWN outline, which is the whole point: a rectangular intersection's pavement\n\t/// runs along its edges and turns at its corners, and approximating that with an arc around the centre\n\t/// bows the path out into the road at the middle of each side and cuts the corners off. A circle really is\n\t/// an arc, so it gets one.\n\t///\n\t/// The loop runs all the way round, arm mouths included \u2014 whoever consumes it is expected to split it at\n\t/// the kerbs, because where the pavement is interrupted is the same question as where the crossings go.\n\t/// </summary>\n\tpublic List<Vector3> GetSidewalkOutline(float _Spacing)\n\t{\n\t\tvar points = new List<Vector3>();\n\n\t\tif (!HasSidewalks)\n\t\t\treturn points;\n\n\t\tfloat spacing = Math.Max(1.0f, _Spacing);\n\n\t\t// Centre of the slab, so it sits where someone would actually walk rather than on either kerb.\n\t\tfloat outset = SidewalkWidth * 0.5f;\n\t\tVector3 lift = Vector3.Up * SidewalkHeight;\n\n\t\tif (Shape == IntersectionShape.Circle)\n\t\t{\n\t\t\tfloat radius = Radius + outset;\n\t\t\tint steps = Math.Max(8, (int)MathF.Ceiling(MathF.Tau * radius / spacing));\n\n\t\t\tfor (int i = 0; i < steps; i++)\n\t\t\t{\n\t\t\t\tfloat angle = MathF.Tau * i / steps;\n\n\t\t\t\tpoints.Add(new Vector3(MathF.Cos(angle) * radius, MathF.Sin(angle) * radius, 0.0f) + lift);\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\t// Written with the same right/forward vectors BuildRectangleRoad uses, rather than as raw x/y\n\t\t\t// components. Width runs along Right and Length along Forward, which in s&box axes is Y and X \u2014\n\t\t\t// spelling that out by hand gets them the wrong way round, and a junction outline rotated 90\u00b0\n\t\t\t// looks almost plausible until nothing connects to it.\n\t\t\tVector3 right = Vector3.Right;\n\t\t\tVector3 forward = Vector3.Forward;\n\n\t\t\tfloat hw = Width * 0.5f + outset;\n\t\t\tfloat hl = Length * 0.5f + outset;\n\n\t\t\t// Round the rectangle in order, so the loop has a consistent winding for anything that walks it.\n\t\t\tVector3[] corners =\n\t\t\t[\n\t\t\t\t-right * hw - forward * hl,\n\t\t\t\t right * hw - forward * hl,\n\t\t\t\t right * hw + forward * hl,\n\t\t\t\t-right * hw + forward * hl,\n\t\t\t];\n\n\t\t\tfor (int i = 0; i < corners.Length; i++)\n\t\t\t{\n\t\t\t\tVector3 from = corners[i];\n\t\t\t\tVector3 to = corners[(i + 1) % corners.Length];\n\n\t\t\t\tint steps = Math.Max(1, (int)MathF.Ceiling(Vector3.DistanceBetween(from, to) / spacing));\n\n\t\t\t\t// Last point of each edge is skipped \u2014 it's the first of the next one, and a closed loop\n\t\t\t\t// mustn't repeat its corners.\n\t\t\t\tfor (int s = 0; s < steps; s++)\n\t\t\t\t\tpoints.Add(Vector3.Lerp(from, to, (float)s / steps) + lift);\n\t\t\t}\n\t\t}\n\n\t\tfor (int i = 0; i < points.Count; i++)\n\t\t\tpoints[i] = WorldTransform.PointToWorld(points[i]);\n\n\t\treturn points;\n\t}\n\n\n\n\t/// <summary>Whether this junction has pavement to walk on at all.</summary>\n\tpublic bool HasSidewalks => SidewalkWidth > 0.0f;\n}\n"
}
]
}