Editor/WaterPicker.cs

Editor utility for picking water-related objects in the editor. It provides functions to pick nodes, rivers, lakes, lake control points and shore insertion locations by tracing rays against the network geometry and transformed world/local coordinates.

File Access
using System;
using Sandbox;

namespace RedSnail.WaterTool2.Editor;

/// <summary>
/// A river hit by the cursor: the river, where along it (<see cref="T"/>) and the local-space point on its curve.
/// </summary>
internal readonly record struct WaterRiverPick(WaterRiver River, float T, Vector3 LocalPoint);

/// <summary>
/// Cursor picking against the water graph, done against the network's own geometry so it works the same whether or
/// not the pieces under the cursor have been built yet.
/// </summary>
internal static class WaterPicker
{
	/// <summary>
	/// Handles keep a constant on-screen size, so their pick radius grows with distance from the camera.
	/// </summary>
	public static float NodeRadius(Vector3 _WorldPosition)
	{
		return MathF.Max(8.0f, Gizmo.Camera.Position.Distance(_WorldPosition) * 0.012f);
	}



	/// <summary>
	/// The node whose handle the ray passes through, nearest to the camera first.
	/// </summary>
	public static WaterNode PickNode(WaterNetwork _Network, Ray _Ray, float _RadiusScale = 1.0f)
	{
		var transform = _Network.WorldTransform;
		WaterNode best = null;
		float bestDistance = float.MaxValue;

		foreach (var node in _Network.Nodes)
		{
			Vector3 world = transform.PointToWorld(node.Position);
			var sphere = new Sphere(world, NodeRadius(world) * _RadiusScale);

			if (!sphere.Trace(_Ray, float.MaxValue, out float distance) || distance >= bestDistance)
				continue;

			best = node;
			bestDistance = distance;
		}

		return best;
	}



	/// <summary>
	/// The river under the cursor: the one whose surface (its curve widened to its width, plus a margin) comes closest
	/// to the ray. Null when the ray misses every river.
	/// </summary>
	public static WaterRiverPick? PickRiver(WaterNetwork _Network, Ray _Ray, float _Margin = 0.0f)
	{
		var transform = _Network.WorldTransform;
		var localRay = _Ray.ToLocal(transform);
		WaterRiverPick? best = null;
		float bestLateral = float.MaxValue;

		foreach (var river in _Network.Rivers)
		{
			var table = _Network.GetCurveTable(river);

			if (table is null)
				continue;

			float reach = _Network.WidthOf(river).MaxHalf + _Margin;

			if (!table.Bounds.Grow(reach).Trace(localRay, float.MaxValue, out _))
				continue;

			Vector3 previous = transform.PointToWorld(table.SamplePosition(0));

			for (int i = 1; i < table.SampleCount; i++)
			{
				Vector3 current = transform.PointToWorld(table.SamplePosition(i));
				var line = new Line(previous, current);

				if (line.ClosestPoint(_Ray, out Vector3 onLine, out Vector3 onRay))
				{
					float lateral = Vector3.DistanceBetween(onLine, onRay);

					if (lateral <= reach && lateral < bestLateral)
					{
						float span = Vector3.DistanceBetween(previous, current);
						float fraction = span > 0.0f ? Vector3.DistanceBetween(previous, onLine) / span : 0.0f;
						float distance = table.SampleDistance(i - 1) + fraction * (table.SampleDistance(i) - table.SampleDistance(i - 1));
						float t = table.DistanceToT(distance);

						best = new WaterRiverPick(river, t, table.Curve.Evaluate(t));
						bestLateral = lateral;
					}
				}

				previous = current;
			}
		}

		return best;
	}



	/// <summary>
	/// The lake under the cursor: where the ray meets each lake's water level, inside its shore. The nearest hit wins.
	/// The sea's coast is no lake (its water is outside it): see <see cref="PickSea"/>.
	/// </summary>
	public static WaterLake PickLake(WaterNetwork _Network, Ray _Ray, out Vector3 _LocalHit)
	{
		var transform = _Network.WorldTransform;
		WaterLake best = null;
		float bestDistance = float.MaxValue;

		_LocalHit = default;

		foreach (var lake in _Network.Lakes)
		{
			var shore = lake.IsCoast ? null : _Network.GetShore(lake);

			if (shore is null)
				continue;

			var plane = new Plane(transform.PointToWorld(lake.Centre), transform.NormalToWorld(Vector3.Up));

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

			Vector3 local = transform.PointToLocal(hit);

			if (!shore.Contains(local))
				continue;

			float distance = _Ray.Position.Distance(hit);

			if (distance >= bestDistance)
				continue;

			best = lake;
			bestDistance = distance;
			_LocalHit = local;
		}

		return best;
	}



	/// <summary>
	/// The coast a river would open into the sea through, when the cursor is over the sea: the ray reaches the sea's
	/// level before the ground (or meets no ground at all), out on the water's side of the coast.
	/// <paramref name="_LocalOnShore"/> is the point of the coast nearest to where the ray meets the sea. Null when the
	/// sea is off, the cursor is over land, or no coast has been built for the sea to have an edge at.
	/// </summary>
	public static WaterLake PickSea(WaterNetwork _Network, Ray _Ray, Vector3? _GroundWorld, out Vector3 _LocalOnShore)
	{
		_LocalOnShore = default;

		if (!_Network.SeaEnabled)
			return null;

		float level = _Network.WorldSeaLevel;

		if (_GroundWorld is Vector3 ground && ground.z > level - 1.0f)
			return null;

		if (new Plane(new Vector3(0.0f, 0.0f, level), Vector3.Up).Trace(_Ray, true) is not Vector3 hit)
			return null;

		return _Network.NearestCoast(_Network.WorldTransform.PointToLocal(hit), out _LocalOnShore);
	}



	/// <summary>
	/// The control point of a lake whose handle the ray passes through, or -1.
	/// </summary>
	public static int PickLakePoint(WaterNetwork _Network, WaterLake _Lake, Ray _Ray)
	{
		var transform = _Network.WorldTransform;
		int best = -1;
		float bestDistance = float.MaxValue;

		for (int i = 0; i < _Lake.Points.Count; i++)
		{
			Vector3 world = transform.PointToWorld(_Lake.PointAt(i));
			var sphere = new Sphere(world, NodeRadius(world) * 0.8f);

			if (!sphere.Trace(_Ray, float.MaxValue, out float distance) || distance >= bestDistance)
				continue;

			best = i;
			bestDistance = distance;
		}

		return best;
	}



	/// <summary>
	/// Where on a lake's outline the cursor is, for inserting a point there: the edge of the control polygon nearest to
	/// where the ray meets the lake's level, within a handle's reach of the shore. <paramref name="_InsertIndex"/> is
	/// the index the new point takes.
	/// </summary>
	public static bool PickShore(WaterNetwork _Network, WaterLake _Lake, Ray _Ray, out int _InsertIndex, out Vector3 _LocalPoint)
	{
		_InsertIndex = -1;
		_LocalPoint = default;

		var shore = _Network.GetShore(_Lake);

		if (shore is null || _Lake.Points.Count < 2)
			return false;

		var transform = _Network.WorldTransform;
		var plane = new Plane(transform.PointToWorld(_Lake.Centre), transform.NormalToWorld(Vector3.Up));

		if (plane.Trace(_Ray, true) is not Vector3 hit)
			return false;

		Vector3 local = transform.PointToLocal(hit);

		shore.NearestDistance(local, out float offset);

		if (offset > NodeRadius(hit) * 2.0f)
			return false;

		float best = float.MaxValue;
		var point = new Vector2(local.x, local.y);

		for (int i = 0; i < _Lake.Points.Count; i++)
		{
			Vector3 a = _Lake.Points[i];
			Vector3 b = _Lake.Points[(i + 1) % _Lake.Points.Count];

			var from = new Vector2(a.x, a.y);
			var span = new Vector2(b.x, b.y) - from;
			float lengthSquared = span.LengthSquared;
			float t = lengthSquared > 0.0f ? Math.Clamp(Vector2.Dot(point - from, span) / lengthSquared, 0.0f, 1.0f) : 0.0f;
			float distance = (from + span * t).Distance(point);

			if (distance >= best)
				continue;

			best = distance;
			_InsertIndex = i + 1;
		}

		_LocalPoint = local.WithZ(_Lake.Height);

		return _InsertIndex >= 0;
	}
}