Network/WaterNetwork.Lakes.cs

Part of the WaterNetwork class that manages lakes and their shores. It stores the list of WaterLake, caches computed LakeShore objects, finds/queries lakes by position or opening nodes, and provides APIs to add/move/remove lakes and synchronize openings and coastal behavior with sea level and river geometry.

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

namespace RedSnail.WaterTool2;

public sealed partial class WaterNetwork
{
	/// <summary>
	/// The lakes of the network. Like the nodes and rivers, 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<WaterLake> Lakes
	{
		get;
		set
		{
			field = value ?? new List<WaterLake>();
			MarkDirty();
		}
	} = [];

	private readonly Dictionary<int, (long Key, LakeShore Shore)> m_Shores = new();



	public WaterLake FindLake(int _Id)
	{
		return Lakes.FirstOrDefault(lake => lake.Id == _Id);
	}



	/// <summary>
	/// The lake a node opens into, or null when the node is not on a lake's shore.
	/// </summary>
	public WaterLake LakeAtOpening(int _NodeId)
	{
		foreach (var lake in Lakes)
		{
			if (lake.Openings.Contains(_NodeId))
				return lake;
		}

		return null;
	}



	/// <summary>
	/// A lake's shore, rebuilt only when the lake's shape changed.
	/// </summary>
	public LakeShore GetShore(WaterLake _Lake)
	{
		if (_Lake is null)
			return null;

		var hash = StableHash.Create();
		_Lake.AddToHash(ref hash);
		hash.Add(CellSize);

		if (m_Shores.TryGetValue(_Lake.Id, out var cached) && cached.Key == hash.Value)
			return cached.Shore;

		var shore = LakeShore.Build(_Lake, CellSize);
		m_Shores[_Lake.Id] = (hash.Value, shore);

		return shore;
	}



	/// <summary>
	/// Adds a lake with the given shore points (network space) at a water level. The points are kept in the order they
	/// were laid, turned counter-clockwise if they were laid the other way.
	/// </summary>
	public WaterLake AddLake(IEnumerable<Vector3> _LocalPoints, float _Height, float _Waves = 1.0f, float _Depth = 500.0f, bool _Smooth = true)
	{
		var lake = new WaterLake
		{
			Id = NextId++,
			Points = _LocalPoints.Select(point => point.WithZ(_Height)).ToList(),
			Height = _Height,
			Waves = _Waves,
			Depth = _Depth,
			Smooth = _Smooth
		};

		if (lake.SignedArea() < 0.0f)
			lake.Points.Reverse();

		Lakes.Add(lake);
		MarkDirty();

		return lake;
	}



	/// <summary>
	/// The coast whose land a point (network space, in plan) is on, or null out at sea.
	/// </summary>
	public WaterLake CoastAt(Vector3 _Local)
	{
		foreach (var lake in Lakes)
		{
			if (lake.IsCoast && GetShore(lake) is { } shore && shore.Contains(_Local))
				return lake;
		}

		return null;
	}



	/// <summary>
	/// For a point out at sea (network space, in plan): the coast nearest to it, and the point of that coast's shore
	/// nearest to it, where a river would open into the sea. Null on land (unless <paramref name="_FromLand"/>: a river
	/// end inland of the coast), or when there is no coast.
	/// </summary>
	public WaterLake NearestCoast(Vector3 _Local, out Vector3 _OnShore, bool _FromLand = false)
	{
		_OnShore = default;

		if (!_FromLand && CoastAt(_Local) is not null)
			return null;

		WaterLake best = null;
		float bestOffset = float.MaxValue;

		foreach (var lake in Lakes)
		{
			if (!lake.IsCoast || GetShore(lake) is not { } shore)
				continue;

			float at = shore.NearestDistance(_Local, out float offset);

			if (offset >= bestOffset)
				continue;

			shore.At(at, out _OnShore, out _, out _);
			best = lake;
			bestOffset = offset;
		}

		return best;
	}



	/// <summary>
	/// Keeps every coast at sea level: when the level changes, the coasts and the rivers opening into the sea through
	/// them rise or fall with it.
	/// </summary>
	private void SyncCoasts()
	{
		foreach (var coast in Lakes)
		{
			if (!coast.IsCoast || MathF.Abs(coast.Height - SeaLevel) < 0.001f)
				continue;

			float rise = SeaLevel - coast.Height;

			coast.Height = SeaLevel;

			for (int i = 0; i < coast.Points.Count; i++)
				coast.Points[i] = coast.Points[i].WithZ(SeaLevel);

			// The shore moved: everything worked out from it is stale.
			InvalidateIndex();

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

				node.Position += Vector3.Up * rise;
				RefreshTangentsAround(nodeId);
			}
		}
	}



	/// <summary>
	/// Adds a node on a lake's shore for a river to open into it at, at the point of the shore nearest to where it is
	/// asked for. Returns the node, or null when the lake is gone.
	/// </summary>
	public WaterNode AddOpening(int _LakeId, Vector3 _NearLocal)
	{
		var lake = FindLake(_LakeId);
		var shore = GetShore(lake);

		if (lake is null || shore is null)
			return null;

		shore.At(shore.NearestDistance(_NearLocal), out Vector3 point, out _, out _);

		var node = new WaterNode { Id = NextId++, Position = point, AutoTangents = false };

		Nodes.Add(node);
		InvalidateIndex();

		lake.Openings.Add(node.Id);
		MarkDirty();

		return node;
	}



	/// <summary>
	/// Makes a river end that is already there open into a lake: it moves onto the shore, and the river turns to meet
	/// the shore square on. Nothing happens if the node already opens into a lake, or carries more than one river.
	/// </summary>
	public bool JoinOpening(int _LakeId, int _NodeId)
	{
		var lake = FindLake(_LakeId);
		var node = FindNode(_NodeId);

		if (lake is null || node is null || LakeAtOpening(_NodeId) is not null || GetDegree(_NodeId) > 1)
			return false;

		lake.Openings.Add(_NodeId);
		node.AutoTangents = false;

		SnapOpenings(lake);
		RefreshTangentsAround(_NodeId);
		MarkDirty();

		return true;
	}



	/// <summary>
	/// Unhooks a node from the lake it opens into. The node and its river stay where they are.
	/// </summary>
	public void RemoveOpening(int _NodeId)
	{
		foreach (var lake in Lakes)
			lake.Openings.Remove(_NodeId);

		MarkDirty();
	}



	/// <summary>
	/// Puts every one of a lake's opening nodes back on its shore, at its water level (a lake that has been moved or
	/// reshaped takes its openings with it), and drops the ones whose nodes have gone.
	/// </summary>
	public void SnapOpenings(WaterLake _Lake)
	{
		if (_Lake is null)
			return;

		var shore = GetShore(_Lake);

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

			if (node is null || GetDegree(node.Id) > 1)
			{
				_Lake.Openings.RemoveAt(i);
				continue;
			}

			if (shore is null)
				continue;

			shore.At(shore.NearestDistance(node.Position), out Vector3 point, out _, out _);

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

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



	public void SnapAllOpenings()
	{
		foreach (var lake in Lakes)
			SnapOpenings(lake);
	}



	/// <summary>
	/// Moves a lake (its height too), taking its openings with it, so the rivers into it bend to follow. A coast only
	/// moves across: it stays at sea level.
	/// </summary>
	public void MoveLake(int _LakeId, Vector3 _Centre)
	{
		var lake = FindLake(_LakeId);

		if (lake is null)
			return;

		Vector3 delta = _Centre - lake.Centre;

		if (lake.IsCoast)
			delta = delta.WithZ(0.0f);

		// A drag that is holding still would otherwise rebuild the lake every frame over float noise.
		if (delta.Length < 0.01f)
			return;

		for (int i = 0; i < lake.Points.Count; i++)
			lake.Points[i] += delta.WithZ(0.0f);

		lake.Height += delta.z;

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

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

		SnapOpenings(lake);
		MarkDirty();
	}



	/// <summary>
	/// Changes a lake's water level; its openings rise or fall with it. A coast's level is the sea's.
	/// </summary>
	public void SetLakeHeight(int _LakeId, float _Height)
	{
		var lake = FindLake(_LakeId);

		if (lake is null || lake.IsCoast)
			return;

		MoveLake(_LakeId, lake.Centre.WithZ(_Height));
	}



	/// <summary>
	/// Moves one of a lake's shore points (only its X and Y count).
	/// </summary>
	public void SetLakePoint(int _LakeId, int _Index, Vector3 _Local)
	{
		var lake = FindLake(_LakeId);

		if (lake is null || _Index < 0 || _Index >= lake.Points.Count)
			return;

		if (lake.Points[_Index].WithZ(0.0f).Distance(_Local.WithZ(0.0f)) < 0.01f)
			return;

		lake.Points[_Index] = _Local.WithZ(lake.Height);

		SnapOpenings(lake);
		MarkDirty();
	}



	/// <summary>
	/// Inserts a shore point before the given index.
	/// </summary>
	public void InsertLakePoint(int _LakeId, int _Index, Vector3 _Local)
	{
		var lake = FindLake(_LakeId);

		if (lake is null)
			return;

		lake.Points.Insert(Math.Clamp(_Index, 0, lake.Points.Count), _Local.WithZ(lake.Height));

		SnapOpenings(lake);
		MarkDirty();
	}



	/// <summary>
	/// Removes a shore point. A lake keeps at least three.
	/// </summary>
	public bool RemoveLakePoint(int _LakeId, int _Index)
	{
		var lake = FindLake(_LakeId);

		if (lake is null || lake.Points.Count <= WaterLake.MinPoints || _Index < 0 || _Index >= lake.Points.Count)
			return false;

		lake.Points.RemoveAt(_Index);

		SnapOpenings(lake);
		MarkDirty();

		return true;
	}



	/// <summary>
	/// Removes a lake. Its opening nodes stay where they are, with their rivers; they are simply river ends again.
	/// </summary>
	public void RemoveLake(int _LakeId)
	{
		var lake = FindLake(_LakeId);

		if (lake is null)
			return;

		Lakes.Remove(lake);
		m_Shores.Remove(_LakeId);
		MarkDirty();
	}



	/// <summary>
	/// The way out of the lake a node opens into (or the sea), at that node: what a river leaving there has to run along,
	/// so it meets the shore square on. Every curve through the node asks, many times an edit, and a coast's shore is long:
	/// the answer is kept until the graph or the node changes.
	/// </summary>
	public bool TryLakeOutward(int _NodeId, out Vector3 _Outward)
	{
		_Outward = Vector3.Zero;

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

		var lake = LakeAtOpening(_NodeId);
		var node = FindNode(_NodeId);

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

		if (m_Outwards.TryGetValue(_NodeId, out var cached) && cached.Revision == m_GraphRevision && cached.LakeId == lake.Id && cached.Position == node.Position)
		{
			_Outward = cached.Outward;
			return cached.Found;
		}

		bool found = FindLakeOutward(lake, node, out _Outward);

		m_Outwards[_NodeId] = (m_GraphRevision, lake.Id, node.Position, _Outward, found);

		return found;
	}



	private readonly Dictionary<int, (int Revision, int LakeId, Vector3 Position, Vector3 Outward, bool Found)> m_Outwards = new();



	private bool FindLakeOutward(WaterLake _Lake, WaterNode _Node, out Vector3 _Outward)
	{
		_Outward = Vector3.Zero;

		var shore = GetShore(_Lake);

		if (shore is null)
			return false;

		shore.At(shore.NearestDistance(_Node.Position), out _, out Vector3 outward, out _);

		_Outward = outward.WithZ(0.0f);

		if (_Outward.IsNearZeroLength)
			return false;

		_Outward = _Outward.Normal;

		return true;
	}



	/// <summary>
	/// How far a lake's shore flares into the river that opens into it at a node: what the lake asks for, but never
	/// more than the river can give or the shore can spare.
	/// </summary>
	public float OpeningFlare(WaterLake _Lake, int _NodeId)
	{
		float flare = _Lake.EntranceRadius;

		var river = GetRiversAt(_NodeId).FirstOrDefault();
		var table = river is null ? null : GetCurveTable(river);

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

		var shore = GetShore(_Lake);

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

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



	/// <summary>
	/// How far a river is held back from the shore of the lake it opens into: the lake's surface fills the flares
	/// between the two, so the river starts at their far end. At a coast it is the estuary that fills them, so with the
	/// sea off the river runs all the way.
	/// </summary>
	public float LakeTrimAt(int _NodeId)
	{
		var lake = LakeAtOpening(_NodeId);

		if (lake is null || (lake.IsCoast && !SeaEnabled))
			return 0.0f;

		return OpeningFlare(lake, _NodeId);
	}



	/// <summary>
	/// The lake under a point in network space (in plan), if any. Coasts are not lakes: their water is outside them.
	/// </summary>
	public WaterLake LakeUnder(Vector3 _Local)
	{
		foreach (var lake in Lakes)
		{
			if (lake.IsCoast)
				continue;

			var shore = GetShore(lake);

			if (shore is not null && shore.Contains(_Local))
				return lake;
		}

		return null;
	}
}