Editor/RoadLotTool.cs

Editor tool and sidebar widget for placing and editing rectangular parking lots in a road network editor. RoadLotTool handles input: picking free road ends, dragging to create lots, joining roads to lots, moving selected lots with a gizmo, drawing previews and outlines, and managing selection/undo. RoadLotToolWidget builds the sidebar UI to edit new-lot defaults and the currently selected lot and wraps edits in undo scopes.

File Access
using System;
using System.Linq;
using Editor;
using Sandbox;

namespace RedSnail.RoadTool2.Editor;

/// <summary>
/// Parking lots: click the loose end of a road and drag out a rectangle to lay a lot there; the road opens into it
/// and the lot is surfaced, kerbed and marked out in bays. Click a lot to select it and edit its size, its road
/// type and its parking layout.
/// </summary>
[Title("Parking")]
[Icon("local_parking")]
[Group("1")]
[Order(4)]
public class RoadLotTool : EditorTool
{
	/// <summary>
	/// The road type new lots are surfaced as. Empty uses the network's default.
	/// </summary>
	[Property, Title("Profile")] public RoadProfile Profile { get; set; }

	/// <summary>
	/// How new lots are laid out inside.
	/// </summary>
	[Property, Title("Parking")] public ParkingStyle Parking { get; set; }

	public int SelectedLotId { get; private set; } = -1;

	/// <summary>
	/// Bumped whenever the selection changes, so the sidebar knows to rebuild itself.
	/// </summary>
	public int SelectionVersion { get; private set; }

	public RoadNetwork Network => Root?.Network;

	public RoadLot SelectedLot => Network.IsValid() ? Network.FindLot(SelectedLotId) : null;

	private RoadNetworkTool Root => Manager?.CurrentTool as RoadNetworkTool;

	/// <summary>
	/// The node the lot being dragged grows from, or -1 when nothing is being dragged.
	/// </summary>
	private int m_FromNodeId = -1;

	private float m_Width;
	private float m_Depth;

	/// <summary>
	/// The move gizmo's drag: where the lot's centre started, how far the arrows have been pulled since, and the
	/// undo scope the whole drag lands in (one entry, not one per frame).
	/// </summary>
	private bool m_Moving;

	private Vector3 m_MoveStartWorld;
	private Vector3 m_MoveDelta;
	private IDisposable m_UndoScope;



	public override void OnDisabled()
	{
		m_FromNodeId = -1;

		EndMove();
	}



	public override Widget CreateToolSidebar()
	{
		return new RoadLotToolWidget(this);
	}



	public override void OnUpdate()
	{
		var network = Network;

		if (!network.IsValid())
		{
			m_FromNodeId = -1;

			EndMove();

			return;
		}

		if (SelectedLotId >= 0 && network.FindLot(SelectedLotId) is null)
			Select(null);

		DrawLots(network);

		if (m_FromNodeId >= 0)
		{
			UpdateDrag(network);
			return;
		}

		// The gizmo takes the cursor while it is hovered or held, so nothing below picks or deselects under it.
		if (Gizmo.HasMouseFocus && !Gizmo.HasHovered && !m_Moving)
			UpdatePicking(network);

		if (SelectedLot is { } selected)
			UpdateLotGizmo(network, selected);
		else
			EndMove();
	}



	/// <summary>
	/// Laying a lot out, joining a road to one, or picking one: whatever the cursor is over.
	/// </summary>
	private void UpdatePicking(RoadNetwork _Network)
	{
		var node = FreeNodeUnderCursor(_Network);
		var joinable = node is null ? null : LotNear(_Network, node);

		if (node is not null)
			DrawNodeHint(_Network, node, joinable);

		if (!Gizmo.WasLeftMousePressed)
			return;

		if (node is not null && joinable is not null)
		{
			// A road end close to a lot joins it as another way in, rather than starting a lot of its own.
			using (SceneEditorSession.Active.UndoScope("Join Road To Parking Lot").WithComponentChanges(_Network).Push())
				_Network.JoinOpening(joinable.Id, node.Id);

			Select(joinable);

			return;
		}

		if (node is not null)
		{
			m_FromNodeId = node.Id;
			m_Width = 0.0f;
			m_Depth = 0.0f;

			return;
		}

		Select(LotUnderCursor(_Network));
	}



	/// <summary>
	/// The standard move gizmo on the selected lot. Its centre square drags along the ground, the arrows move on
	/// world axes, or in Local space (the editor's Global/Local toggle) along the lot's own sides.
	/// </summary>
	private void UpdateLotGizmo(RoadNetwork _Network, RoadLot _Lot)
	{
		var transform = _Network.WorldTransform;
		Vector3 centreWorld = transform.PointToWorld(_Lot.Centre);
		var handleRotation = Gizmo.Settings.GlobalSpace ? Rotation.Identity : transform.Rotation * _Lot.Rotation;

		using (Gizmo.Scope("roadtool2_lot_gizmo", new Transform(centreWorld)))
		{
			Gizmo.Hitbox.DepthBias = 0.01f;

			Vector3? surfaceHit = null;

			bool moved = Gizmo.Control.Position("position", Vector3.Zero, out Vector3 delta, handleRotation, centerRaycast: () =>
			{
				surfaceHit = GroundHit(centreWorld);
				return surfaceHit;
			});

			if (moved)
			{
				if (!m_Moving)
				{
					m_Moving = true;
					m_MoveStartWorld = centreWorld;
					m_MoveDelta = Vector3.Zero;
					m_UndoScope ??= SceneEditorSession.Active.UndoScope("Move Parking Lot").WithComponentChanges(_Network).Push();
				}

				Vector3 target;

				if (surfaceHit is Vector3 hit)
				{
					target = hit;
				}
				else
				{
					m_MoveDelta += delta;
					target = Gizmo.Snap(m_MoveStartWorld, m_MoveDelta, handleRotation);
				}

				_Network.MoveLot(_Lot.Id, transform.PointToLocal(target));
			}
		}

		if (m_Moving && !Gizmo.IsLeftMouseDown)
			EndMove();
	}



	/// <summary>
	/// Where the ground is under the cursor, ignoring the network's own pieces; the plane through the lot when the
	/// cursor is over nothing at all.
	/// </summary>
	private Vector3? GroundHit(Vector3 _Fallback)
	{
		var trace = Trace.WithoutTags(RoadNetwork.ChunkTag).Run();

		if (trace.Hit)
			return trace.EndPosition;

		return new Plane(_Fallback, Vector3.Up).Trace(Gizmo.CurrentRay, true);
	}



	private void EndMove()
	{
		m_Moving = false;

		m_UndoScope?.Dispose();
		m_UndoScope = null;
	}



	/// <summary>
	/// While dragging: the lot reaches from the node's edge out to the cursor, as wide either side of the node as
	/// the cursor is across from it.
	/// </summary>
	private void UpdateDrag(RoadNetwork _Network)
	{
		var node = _Network.FindNode(m_FromNodeId);

		if (node is null || Gizmo.WasRightMousePressed)
		{
			m_FromNodeId = -1;
			return;
		}

		var transform = _Network.WorldTransform;
		Vector3 nodeWorld = transform.PointToWorld(node.Position);
		var plane = new Plane(nodeWorld, Vector3.Up);

		if (plane.Trace(Gizmo.CurrentRay, true) is not Vector3 hit)
			return;

		Vector3 away = AwayFromRoad(_Network, node);
		Vector3 across = Vector3.Cross(Vector3.Up, away).Normal;
		Vector3 offset = transform.PointToLocal(hit) - node.Position;

		m_Depth = MathF.Max(RoadLot.MinSize, Vector3.Dot(offset, away));
		m_Width = MathF.Max(RoadLot.MinSize, MathF.Abs(Vector3.Dot(offset, across)) * 2.0f);

		DrawPreview(_Network, node, away, across);

		if (!Gizmo.WasLeftMousePressed)
			return;

		RoadLot lot;

		using (SceneEditorSession.Active.UndoScope("Add Parking Lot").WithComponentChanges(_Network).Push())
			lot = _Network.AddLot(node.Id, m_Width, m_Depth, Profile, Parking);

		m_FromNodeId = -1;
		Select(lot);
	}



	public void Select(RoadLot _Lot)
	{
		int id = _Lot?.Id ?? -1;

		if (id == SelectedLotId)
			return;

		SelectedLotId = id;
		SelectionVersion++;
	}



	/// <summary>
	/// The sidebar edited the selected lot directly; push it through the network so its entrances and pieces follow.
	/// </summary>
	public void OnLotEdited()
	{
		var network = Network;

		if (!network.IsValid())
			return;

		network.SnapOpenings(SelectedLot);
		network.MarkDirty();
	}



	public void DeleteSelection()
	{
		var network = Network;

		if (!network.IsValid() || SelectedLot is not { } lot)
			return;

		using (SceneEditorSession.Active.UndoScope("Delete Parking Lot").WithComponentChanges(network).Push())
			network.RemoveLot(lot.Id);

		Select(null);
	}



	/// <summary>
	/// Which way a lot laid at a node reaches: on down the road, away from where it came from.
	/// </summary>
	private static Vector3 AwayFromRoad(RoadNetwork _Network, RoadNode _Node)
	{
		var segment = _Network.GetSegmentsAt(_Node.Id).FirstOrDefault();

		if (segment is null)
			return Vector3.Forward;

		Vector3 handle = RoadNetwork.GetHandle(segment, _Node.Id).WithZ(0.0f);

		return handle.IsNearZeroLength ? Vector3.Forward : -handle.Normal;
	}



	/// <summary>
	/// A loose road end under the cursor: one road at it, and no lot of its own yet.
	/// </summary>
	private RoadNode FreeNodeUnderCursor(RoadNetwork _Network)
	{
		var node = RoadPicker.PickNode(_Network, Gizmo.CurrentRay);

		if (node is null || _Network.GetDegree(node.Id) > 1 || _Network.LotAtOpening(node.Id) is not null)
			return null;

		return node;
	}



	/// <summary>
	/// The lot the cursor is over, from the ground point under it.
	/// </summary>
	private RoadLot LotUnderCursor(RoadNetwork _Network)
	{
		var transform = _Network.WorldTransform;

		foreach (var lot in _Network.Lots)
		{
			var plane = new Plane(transform.PointToWorld(lot.Centre), transform.NormalToWorld(Vector3.Up));

			if (plane.Trace(Gizmo.CurrentRay, true) is not Vector3 hit)
				continue;

			Vector2 local = lot.Local(transform.PointToLocal(hit));

			if (MathF.Abs(local.x) <= lot.HalfWidth && MathF.Abs(local.y) <= lot.HalfDepth)
				return lot;
		}

		return null;
	}



	private void DrawLots(RoadNetwork _Network)
	{
		var transform = _Network.WorldTransform;

		using (Gizmo.Scope("roadtool2_lots"))
		{
			Gizmo.Draw.IgnoreDepth = true;

			foreach (var lot in _Network.Lots)
			{
				bool selected = lot.Id == SelectedLotId;

				Gizmo.Draw.LineThickness = selected ? 3.0f : 1.5f;
				Gizmo.Draw.Color = (selected ? Color.Orange : Color.White).WithAlpha(selected ? 0.9f : 0.4f);

				DrawOutline(lot, transform, _Network);
			}
		}
	}



	private static void DrawOutline(RoadLot _Lot, in Transform _Transform, RoadNetwork _Network = null)
	{
		var outline = RoadLotOutline.Build(_Lot, _Network?.OpeningsOf(_Lot));

		for (int i = 0; i < outline.Count; i++)
		{
			int next = (i + 1) % outline.Count;

			Gizmo.Draw.Line(_Transform.PointToWorld(outline.Points[i]), _Transform.PointToWorld(outline.Points[next]));
		}
	}



	/// <summary>
	/// A lot near enough to a road's loose end to join it to: one whose edge the end is on or within a road's
	/// width of.
	/// </summary>
	private static RoadLot LotNear(RoadNetwork _Network, RoadNode _Node)
	{
		float reach = MathF.Max(200.0f, _Network.GetSegmentsAt(_Node.Id).FirstOrDefault()?.Width ?? 0.0f);

		foreach (var lot in _Network.Lots)
		{
			Vector2 local = lot.Local(_Node.Position);
			float outside = MathF.Max(MathF.Abs(local.x) - lot.HalfWidth, MathF.Abs(local.y) - lot.HalfDepth);

			if (outside <= reach)
				return lot;
		}

		return null;
	}



	private void DrawNodeHint(RoadNetwork _Network, RoadNode _Node, RoadLot _Joinable)
	{
		Vector3 world = _Network.WorldTransform.PointToWorld(_Node.Position);

		using (Gizmo.Scope("roadtool2_lot_hint"))
		{
			Gizmo.Draw.IgnoreDepth = true;
			Gizmo.Draw.LineThickness = 2.0f;
			Gizmo.Draw.Color = Color.Cyan.WithAlpha(0.9f);
			RoadGizmos.DrawNode(world, true);
			Gizmo.Draw.ScreenText(_Joinable is null ? "Drag out a parking lot" : $"Join lot #{_Joinable.Id}", world, new Vector2(12.0f, -12.0f), size: 12.0f);
		}
	}



	private void DrawPreview(RoadNetwork _Network, RoadNode _Node, Vector3 _Away, Vector3 _Across)
	{
		var transform = _Network.WorldTransform;
		Vector3 centre = _Node.Position + _Away * (m_Depth * 0.5f);

		var preview = new RoadLot
		{
			Centre = centre,
			Yaw = _Away.EulerAngles.yaw,
			Width = m_Width,
			Depth = m_Depth
		};

		using (Gizmo.Scope("roadtool2_lot_preview"))
		{
			Gizmo.Draw.IgnoreDepth = true;
			Gizmo.Draw.LineThickness = 2.0f;
			Gizmo.Draw.Color = Color.Cyan.WithAlpha(0.9f);

			DrawOutline(preview, transform);

			Gizmo.Draw.ScreenText($"{m_Width:0} x {m_Depth:0}", transform.PointToWorld(centre), new Vector2(0.0f, 0.0f), size: 12.0f);
		}
	}
}



file class RoadLotToolWidget : ToolSidebarWidget
{
	private readonly RoadLotTool m_Tool;
	private int m_ShownVersion = -1;
	private IDisposable m_UndoScope;



	public RoadLotToolWidget(RoadLotTool _Tool)
	{
		m_Tool = _Tool;

		Rebuild();
	}



	[EditorEvent.Frame]
	public void Frame()
	{
		if (m_Tool.SelectionVersion != m_ShownVersion)
			Rebuild();
	}



	private void Rebuild()
	{
		m_ShownVersion = m_Tool.SelectionVersion;

		Layout.Clear(true);
		AddTitle("Parking", "local_parking");

		var tool = m_Tool.GetSerialized();
		var defaults = AddGroup("New Lots");

		SidebarRows.AddWide(defaults, tool.GetProperty(nameof(RoadLotTool.Profile)));
		SidebarRows.AddWide(defaults, tool.GetProperty(nameof(RoadLotTool.Parking)));

		if (m_Tool.SelectedLot is { } lot)
		{
			var group = AddGroup($"Lot #{lot.Id}");
			var serialized = lot.GetSerialized();

			serialized.OnPropertyStartEdit += _ => BeginEdit();
			serialized.OnPropertyFinishEdit += _ => EndEdit();
			serialized.OnPropertyChanged += _ => m_Tool.OnLotEdited();

			SidebarRows.AddWide(group, serialized.GetProperty(nameof(RoadLot.Profile)));
			SidebarRows.AddWide(group, serialized.GetProperty(nameof(RoadLot.Parking)));
			SidebarRows.Add(group, serialized.GetProperty(nameof(RoadLot.Width)));
			SidebarRows.Add(group, serialized.GetProperty(nameof(RoadLot.Depth)));
			SidebarRows.Add(group, serialized.GetProperty(nameof(RoadLot.Yaw)));
			SidebarRows.Add(group, serialized.GetProperty(nameof(RoadLot.CornerRadius)));
			SidebarRows.Add(group, serialized.GetProperty(nameof(RoadLot.EntranceRadius)));
			SidebarRows.AddVector(group, serialized.GetProperty(nameof(RoadLot.Centre)));

			group.Add(new Label($"Entrances: {lot.Openings.Count}"));

			var row = group.AddRow();
			row.Spacing = 4;
			row.Add(new Button("Delete Lot", "delete") { Clicked = m_Tool.DeleteSelection });
		}
		else
		{
			AddGroup("Lot").Add(new Label("Click the loose end of a road and drag to lay a parking lot there, or click a lot to edit it.") { WordWrap = true });
		}

		AddShortcuts(
			("Start A Lot", "LMB on road end"),
			("Place It", "LMB"),
			("Cancel", "RMB"),
			("Join A Road To A Lot", "LMB on end near lot"),
			("Select A Lot", "LMB on lot")
		);

		Layout.AddStretchCell();
	}



	private void BeginEdit()
	{
		var network = m_Tool.Network;

		if (!network.IsValid())
			return;

		m_UndoScope ??= SceneEditorSession.Active.UndoScope("Edit Parking Lot").WithComponentChanges(network).Push();
	}



	private void EndEdit()
	{
		m_UndoScope?.Dispose();
		m_UndoScope = null;
	}
}