Code/Network/WaterNetwork.Lakes.cs

Component of the WaterNetwork that manages lakes and their shores. It stores the list of lakes, builds and caches shore representations, finds lakes by position or opening nodes, manages openings (nodes where rivers join lakes/coast), moves and edits lake geometry and heights, and provides helpers for river-shore interactions (outward direction, flare, trimming).

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;
	}
}