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