Code/Network/RoadNetwork.Lots.cs

RoadNetwork partial class handling parking lots (RoadLot) management, openings between lots and nodes, and building/updating lot geometry chunks (surface, border, markings). It provides CRUD for lots, snapping/moving openings, computing signatures for rebuild jobs, reconciling pending/generated chunks, and creating chunk objects.

File AccessNetworking
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace RedSnail.RoadTool2;

public sealed partial class RoadNetwork
{
	/// <summary>
	/// The parking lots and yards of the network. Like the nodes and segments, a whole new list lands here on undo
	/// and reload, so the setter is where "the graph changed" is noticed.
	/// </summary>
	[Property, Hide]
	public List<RoadLot> Lots
	{
		get;
		set
		{
			field = value ?? new List<RoadLot>();
			MarkDirty();
		}
	} = [];



	public RoadLot FindLot(int _Id)
	{
		return Lots.FirstOrDefault(lot => lot.Id == _Id);
	}



	/// <summary>
	/// The lot a node opens into, or null when the node is not on a lot's edge.
	/// </summary>
	public RoadLot LotAtOpening(int _NodeId)
	{
		return Lots.FirstOrDefault(lot => lot.Openings.Contains(_NodeId));
	}



	/// <summary>
	/// The lot type a lot is surfaced as: its own, else the network's default, else plain asphalt.
	/// </summary>
	public RoadProfile ProfileOf(RoadLot _Lot)
	{
		return _Lot?.Profile ?? DefaultProfile ?? RoadProfile.Fallback;
	}



	/// <summary>
	/// Adds a lot reaching away from a node: the node ends up on the middle of the lot's near edge, and the road
	/// at it opens into the lot. Returns null when the node cannot carry one (it is not a road end).
	/// </summary>
	public RoadLot AddLot(int _NodeId, float _Width, float _Depth, RoadProfile _Profile = null, ParkingStyle _Parking = null)
	{
		var node = FindNode(_NodeId);

		if (node is null || GetDegree(_NodeId) > 1 || LotAtOpening(_NodeId) is not null)
			return null;

		var lot = new RoadLot
		{
			Id = NextId++,
			Width = _Width,
			Depth = _Depth,
			Profile = _Profile,
			Parking = _Parking
		};

		// Facing back down the road the node came from, so the lot lies beyond it.
		var segment = GetSegmentsAt(_NodeId).FirstOrDefault();
		Vector3 away = segment is null ? Vector3.Forward : -LeavingDirection(segment, _NodeId);

		lot.Yaw = away.EulerAngles.yaw;
		lot.Centre = node.Position + away.Normal * (lot.Depth * 0.5f);
		lot.Openings.Add(_NodeId);

		Lots.Add(lot);
		SnapOpenings(lot);
		MarkDirty();

		return lot;
	}



	/// <summary>
	/// Adds a node on a lot's edge for a road to open into, at the point of the edge nearest to where it is asked
	/// for. Returns the node, or null when the lot is gone.
	/// </summary>
	public RoadNode AddOpening(int _LotId, Vector3 _NearPosition)
	{
		var lot = FindLot(_LotId);

		if (lot is null)
			return null;

		int edge = lot.NearestEdge(_NearPosition, out float along);
		var node = new RoadNode { Id = NextId++, Position = lot.EdgePoint(edge, along), AutoTangents = false };

		Nodes.Add(node);
		InvalidateIndex();

		lot.Openings.Add(node.Id);
		SnapOpenings(lot);
		MarkDirty();

		return node;
	}



	/// <summary>
	/// Makes a node that is already there another way into a lot: it moves onto the lot's edge, and the road at
	/// it turns to meet the lot square on. Nothing happens if it already opens into one.
	/// </summary>
	public bool JoinOpening(int _LotId, int _NodeId)
	{
		var lot = FindLot(_LotId);
		var node = FindNode(_NodeId);

		if (lot is null || node is null || LotAtOpening(_NodeId) is not null)
			return false;

		lot.Openings.Add(_NodeId);
		SnapOpenings(lot);
		MarkDirty();

		return true;
	}



	/// <summary>
	/// Puts every one of a lot's opening nodes back on its edge (a lot that has been moved or resized takes its
	/// entrances with it), far enough from the corners for the opening and its flares to fit, and drops the ones
	/// whose nodes have gone.
	/// </summary>
	public void SnapOpenings(RoadLot _Lot)
	{
		if (_Lot is null)
			return;

		for (int i = _Lot.Openings.Count - 1; i >= 0; i--)
		{
			var node = FindNode(_Lot.Openings[i]);

			if (node is null)
			{
				_Lot.Openings.RemoveAt(i);
				continue;
			}

			int edge = _Lot.NearestEdge(node.Position, out float along);

			_Lot.EdgeFrame(edge, out _, out _, out _, out float half);

			float reach = OpeningReach(_Lot, node.Id);
			float limit = MathF.Max(0.0f, half - _Lot.Radius - reach);
			Vector3 point = _Lot.EdgePoint(edge, Math.Clamp(along, -limit, limit));

			if (node.Position.Distance(point) < 0.01f)
				continue;

			node.Position = point;
			RefreshTangentsAround(node.Id);
		}
	}



	/// <summary>
	/// How much room an opening takes either side of its middle: half the road, and the flare that turns the kerb
	/// into it.
	/// </summary>
	private float OpeningReach(RoadLot _Lot, int _NodeId)
	{
		return OpeningHalfWidth(_NodeId) + OpeningFlare(_Lot, _NodeId);
	}



	/// <summary>
	/// Half the width of the road that opens into a lot at a node.
	/// </summary>
	private float OpeningHalfWidth(int _NodeId)
	{
		var segment = GetSegmentsAt(_NodeId).FirstOrDefault();

		return segment is null ? 0.0f : (EdgeAtNode(segment, _NodeId, true) + EdgeAtNode(segment, _NodeId, false)) * 0.5f;
	}



	/// <summary>
	/// How far the kerb flares into one of a lot's roads: what the lot asks for, but never tighter than its own
	/// kerb is wide (the kerb turns on the inside of that corner, and a turn tighter than the pavement folds it
	/// over itself), never more than the lot or the road can give, and the same number everywhere so the outline
	/// and the road's trim agree.
	/// </summary>
	public float OpeningFlare(RoadLot _Lot, int _NodeId)
	{
		float flare = MathF.Max(_Lot.EntranceRadius, ProfileOf(_Lot).FlatBorderWidth * 1.25f);

		flare = MathF.Min(flare, MathF.Min(_Lot.HalfWidth, _Lot.HalfDepth) * 0.5f);

		var segment = GetSegmentsAt(_NodeId).FirstOrDefault();
		var table = segment is null ? null : GetCurveTable(segment);

		if (table is not null)
			flare = MathF.Min(flare, table.Length * 0.4f);

		return MathF.Max(0.0f, flare);
	}



	/// <summary>
	/// How far a road is held back from a lot's edge: the lot's surface fills the corners between the two, so the
	/// road starts at the far side of them.
	/// </summary>
	public float LotTrimAt(int _NodeId)
	{
		var lot = LotAtOpening(_NodeId);

		return lot is null ? 0.0f : OpeningFlare(lot, _NodeId);
	}



	public void SnapAllOpenings()
	{
		foreach (var lot in Lots)
			SnapOpenings(lot);
	}



	/// <summary>
	/// Moves a lot, taking its entrances with it: each opening node travels the same way the edge it sits on does,
	/// so the roads into the lot bend to follow instead of the lot sliding out from under them (and none of them
	/// jumps to another edge on the way, which is what re-snapping alone would do on a long drag).
	/// </summary>
	public void MoveLot(int _LotId, Vector3 _Centre)
	{
		var lot = FindLot(_LotId);

		if (lot is null)
			return;

		// A drag that is holding still would otherwise rebuild the lot every frame over float noise.
		if (Vector3.DistanceBetween(lot.Centre, _Centre) < 0.01f)
			return;

		Vector3 delta = _Centre - lot.Centre;

		lot.Centre = _Centre;

		foreach (int nodeId in lot.Openings)
		{
			if (FindNode(nodeId) is not { } node)
				continue;

			node.Position += delta;
			RefreshTangentsAround(nodeId);
		}

		SnapOpenings(lot);
		MarkDirty();
	}



	/// <summary>
	/// Removes a lot. Its opening nodes stay where they are, with their roads; they are simply no longer openings.
	/// </summary>
	public void RemoveLot(int _LotId)
	{
		var lot = FindLot(_LotId);

		if (lot is null)
			return;

		Lots.Remove(lot);
		MarkDirty();
	}



	/// <summary>
	/// The openings of a lot as its builders want them: which edge each one meets, where along it, how wide the
	/// road is and how far the kerb flares into it.
	/// </summary>
	public List<RoadLotOpening> OpeningsOf(RoadLot _Lot)
	{
		var openings = new List<RoadLotOpening>();

		foreach (int nodeId in _Lot.Openings)
		{
			var node = FindNode(nodeId);

			if (node is null)
				continue;

			int edge = _Lot.NearestEdge(node.Position, out float along);

			_Lot.EdgeFrame(edge, out _, out Vector3 direction, out Vector3 outward, out _);

			var opening = new RoadLotOpening
			{
				NodeId = nodeId,
				Edge = edge,
				Along = along,
				HalfWidth = OpeningHalfWidth(nodeId),
				Flare = OpeningFlare(_Lot, nodeId),
				Mouth = _Lot.EdgePoint(edge, along),
				Outward = outward,
				Away = outward
			};

			// Where the road the lot hands over to actually starts: its own row at the far side of the flares.
			var segment = GetSegmentsAt(nodeId).FirstOrDefault();
			var table = segment is null ? null : GetCurveTable(segment);

			if (table is not null)
			{
				bool atStart = segment.StartNodeId == nodeId;
				float distance = atStart ? opening.Flare : table.Length - opening.Flare;
				var width = new RoadWidth(segment, table.Length);

				RoadSurfaceBuilder.CrossSection(table, distance, width, 0.0f, out Vector3 left, out Vector3 right);

				// Ordered the way the lot's edge runs, so the outline meets them in the order it walks.
				bool leftFirst = Vector3.Dot(left - opening.Mouth, direction) < Vector3.Dot(right - opening.Mouth, direction);

				opening.MouthFrom = leftFirst ? left : right;
				opening.MouthTo = leftFirst ? right : left;
				opening.Away = (table.TangentAt(distance) * (atStart ? 1.0f : -1.0f)).Normal;
			}

			// A road that doubles back over the lot, dives, or is otherwise not where a road leaving the edge
			// should be: the flares fall back to a plain mouth square to the edge rather than tying themselves in
			// a knot reaching for it.
			if (!IsUsableMouth(opening, direction, outward))
			{
				opening.Away = outward;
				opening.MouthFrom = opening.Mouth - direction * opening.HalfWidth + outward * opening.Flare;
				opening.MouthTo = opening.Mouth + direction * opening.HalfWidth + outward * opening.Flare;
			}

			openings.Add(opening);
		}

		return openings;
	}



	/// <summary>
	/// Whether a road's first row is somewhere the lot can flare out to at all: in front of the edge, and the right
	/// way round. A row that fails this would double the outline back over itself, so the flares fall back to a
	/// plain square mouth instead. Anything it does pass — a steep road, a wide one — the flares follow, because
	/// that is the point of taking the road's own row.
	/// </summary>
	private static bool IsUsableMouth(RoadLotOpening _Opening, Vector3 _Direction, Vector3 _Outward)
	{
		if (_Opening.Flare <= 0.01f || _Opening.MouthFrom.IsNearZeroLength || _Opening.MouthTo.IsNearZeroLength)
			return false;

		if (Vector3.Dot(_Opening.MouthFrom - _Opening.Mouth, _Outward) <= 1.0f)
			return false;

		if (Vector3.Dot(_Opening.MouthTo - _Opening.Mouth, _Outward) <= 1.0f)
			return false;

		return Vector3.Dot(_Opening.MouthTo - _Opening.MouthFrom, _Direction) > 1.0f;
	}



	/// <summary>
	/// The way out of the lot a node opens into, at that node: what a road leaving there has to run along, so the
	/// road meets the lot's edge square and the two surfaces butt together.
	/// </summary>
	public bool TryLotOutward(int _NodeId, out Vector3 _Outward)
	{
		_Outward = Vector3.Zero;

		if (Lots.Count == 0)
			return false;

		var lot = LotAtOpening(_NodeId);
		var node = FindNode(_NodeId);

		if (lot is null || node is null)
			return false;

		lot.EdgeFrame(lot.NearestEdge(node.Position, out _), out _, out _, out _Outward, out _);

		return !_Outward.IsNearZeroLength;
	}



	private readonly Dictionary<(int LotId, RoadChunkKind Kind), PendingGroup> m_PendingLots = new();



	/// <summary>
	/// Queues whatever of the lots is missing or stale, and clears out the pieces of lots that are gone.
	/// </summary>
	private void ReconcileLots(Dictionary<(int LotId, RoadChunkKind Kind), RoadChunk> _Existing)
	{
		var live = new HashSet<int>();

		foreach (var lot in Lots)
		{
			live.Add(lot.Id);

			var profile = ProfileOf(lot);
			var openings = OpeningsOf(lot);
			var outline = RoadLotOutline.Build(lot, openings);

			ReconcileLotPiece(lot, RoadChunkKind.Surface, LotSignature(lot, profile, openings, RoadChunkKind.Surface), _Existing);

			if (profile.HasBorder)
				ReconcileLotPiece(lot, RoadChunkKind.Border, LotSignature(lot, profile, openings, RoadChunkKind.Border), _Existing);
			else if (_Existing.TryGetValue((lot.Id, RoadChunkKind.Border), out var border))
				border.GameObject.Destroy();

			if (lot.Parking is not null)
				ReconcileLotPiece(lot, RoadChunkKind.Marking, LotSignature(lot, profile, openings, RoadChunkKind.Marking), _Existing);
			else if (_Existing.TryGetValue((lot.Id, RoadChunkKind.Marking), out var marking))
				marking.GameObject.Destroy();
		}

		foreach (var (key, chunk) in _Existing)
		{
			if (live.Contains(key.LotId))
				continue;

			chunk.GameObject.Destroy();
			m_PendingLots.Remove(key);
		}
	}



	private void ReconcileLotPiece(RoadLot _Lot, RoadChunkKind _Kind, long _Signature, Dictionary<(int LotId, RoadChunkKind Kind), RoadChunk> _Existing)
	{
		var key = (_Lot.Id, _Kind);

		if (_Existing.TryGetValue(key, out var existing) && (existing.Locked || (existing.Signature == _Signature && !LacksDebugRecord(existing))))
			return;

		if (m_PendingLots.TryGetValue(key, out var pending) && pending.Signature == _Signature)
			return;

		m_Jobs.Enqueue(new RebuildJob(-1, -1, _Kind, _Signature, default, null, RoadWidth.Uniform(0.0f), null, null, null, null, _Lot));
		m_PendingLots[key] = new PendingGroup(_Signature, 1);
	}



	private long LotSignature(RoadLot _Lot, RoadProfile _Profile, List<RoadLotOpening> _Openings, RoadChunkKind _Kind)
	{
		var hash = StableHash.Create();

		hash.Add(GeneratorVersion);
		hash.Add((int)_Kind);
		hash.Add(SurfaceOffset);
		_Lot.AddToHash(ref hash);

		switch (_Kind)
		{
			case RoadChunkKind.Border:
				_Profile.AddBorderToHash(ref hash);
				hash.Add(DebugBorders ? 1 : 0);
				break;

			case RoadChunkKind.Marking:
				hash.Add(_Lot.Parking?.ResourcePath);
				_Lot.Parking?.AddToHash(ref hash);
				break;

			default:
				_Profile.AddSurfaceToHash(ref hash);
				break;
		}

		// The openings cut the kerb and keep the bays clear, so where they are is part of every piece.
		hash.Add(_Openings.Count);

		foreach (var opening in _Openings)
			opening.AddToHash(ref hash);

		return hash.Value;
	}



	private RoadChunk FindLotChunk(int _LotId, RoadChunkKind _Kind)
	{
		return GetChunks().FirstOrDefault(chunk => chunk.IsLot && chunk.LotId == _LotId && chunk.Kind == _Kind);
	}



	/// <summary>
	/// Builds one piece of a lot: its surface, its kerb or its bays.
	/// </summary>
	private void BuildLotPiece(in RebuildJob _Job)
	{
		var lot = _Job.Lot;
		var existing = FindLotChunk(lot.Id, _Job.Kind);

		if (existing is not null && existing.Locked)
			return;

		var profile = ProfileOf(lot);
		var openings = OpeningsOf(lot);
		var outline = RoadLotOutline.Build(lot, openings);
		PolygonMesh mesh;
		string name;
		BorderDebug debug = null;

		switch (_Job.Kind)
		{
			case RoadChunkKind.Border:
				debug = DebugBorders ? new BorderDebug() : null;
				mesh = RoadLotBuilder.BuildBorder(lot, outline, profile, openings, SurfaceOffset, debug);
				name = $"Lot {lot.Id} border";
				break;

			case RoadChunkKind.Marking:
				mesh = RoadLotBuilder.BuildMarkings(lot, lot.Parking, openings, SurfaceOffset);
				name = $"Lot {lot.Id} markings";
				break;

			default:
				mesh = RoadLotBuilder.BuildSurface(lot, outline, profile, SurfaceOffset);
				name = $"Lot {lot.Id}";
				break;
		}

		CreateChunkObject(name, -1, -1, _Job.Kind, 0, 1, _Job.GroupSignature, mesh, debug, lot.Id);

		existing?.GameObject.Destroy();
	}
}