Building/LakeOutline.cs

Geometry code for lake and river junctions. Defines LakeOpening (metadata about a river opening into a lake), LakeJet (a read-only struct modeling a widening/slowing jet from a river into a lake with evaluation and presence math), and LakeOutline (static methods building polygonal outlines for lakes and mouths, adding flares between shore and river rows, and helper math).

Native Interop
using System;
using System.Collections.Generic;
using Sandbox;

namespace RedSnail.WaterTool2;

/// <summary>
/// Where a river opens into a lake (or out of it): where round the shore, how wide the river is and how far the shore
/// flares into it, and the river's own row where the lake hands over to it. The flares are part of the lake's outline,
/// so the lake's surface fills the corners between the shore and the river, and its outline runs across the river's
/// row point for point.
/// </summary>
internal sealed class LakeOpening
{
	public int NodeId;
	public WaterRiver River;

	/// <summary>
	/// The river runs into the lake (otherwise it runs out of it).
	/// </summary>
	public bool Inflow;

	/// <summary>
	/// The middle of the opening, as a distance round the shore.
	/// </summary>
	public float At;

	public float HalfWidth;
	public float Flare;

	/// <summary>
	/// The river's row where the lake hands over to it, in the order the shore runs.
	/// </summary>
	public RiverRow Row;

	/// <summary>
	/// The way the river heads away from the lake at its row, level.
	/// </summary>
	public Vector3 Away;

	/// <summary>
	/// How much shore the opening and its flares take either side of its middle.
	/// </summary>
	public float Reach => HalfWidth + Flare;
}



/// <summary>
/// How a river's water spreads into a lake: a jet that leaves the river's mouth, widens and slows as it goes, so a river
/// flowing in pushes a plume of current out into the lake and the water by a lake's outlet is drawn towards it.
/// </summary>
internal readonly struct LakeJet
{
	/// <summary>
	/// How fast the jet widens: this much either side for every unit it travels.
	/// </summary>
	private const float Spread = 0.3f;

	public readonly Vector2 Mouth;
	public readonly Vector2 Inward;
	public readonly Vector2 Across;
	public readonly float HalfWidth;
	public readonly Vector2 Velocity;
	public readonly float Foam;
	public readonly float Length;



	public LakeJet(Vector2 _Mouth, Vector2 _Inward, float _HalfWidth, Vector2 _Velocity, float _Foam, float _Length)
	{
		Mouth = _Mouth;
		Inward = _Inward;
		Across = new Vector2(-_Inward.y, _Inward.x);
		HalfWidth = MathF.Max(1.0f, _HalfWidth);
		Velocity = _Velocity;
		Foam = _Foam;
		Length = MathF.Max(1.0f, _Length);
	}



	public Vector2 Evaluate(Vector2 _Point, out float _Foam)
	{
		float strength = Strength(_Point, out float half);

		_Foam = Foam * strength;

		// As the jet widens the same water spreads over more of the lake, so it slows.
		return Velocity * (strength * HalfWidth / half);
	}



	/// <summary>
	/// How much of the river's own water is at a point: all of it in the jet's middle at its mouth, fading out across it
	/// and along it.
	/// </summary>
	public float Presence(Vector2 _Point)
	{
		return Strength(_Point, out _);
	}



	private float Strength(Vector2 _Point, out float _Half)
	{
		Vector2 offset = _Point - Mouth;
		float along = MathF.Max(0.0f, Vector2.Dot(offset, Inward));
		float side = MathF.Abs(Vector2.Dot(offset, Across));

		_Half = HalfWidth + along * Spread;

		// Full strength across the middle of the jet, fading out past its edges.
		float profile = 1.0f - SmoothStep(0.7f, 1.4f, side / _Half);
		float decay = MathF.Exp(-along / Length);

		return profile * decay;
	}



	public static float SmoothStep(float _From, float _To, float _Value)
	{
		float t = Math.Clamp((_Value - _From) / (_To - _From), 0.0f, 1.0f);

		return t * t * (3.0f - 2.0f * t);
	}
}



/// <summary>
/// A lake's outline with its openings cut in: the shore, and at every river that meets it a flare out to the river's
/// row, the row itself point for point, and a flare back to the shore.
/// </summary>
internal static class LakeOutline
{
	/// <summary>
	/// Flares are rounded in about this many steps.
	/// </summary>
	private const int FlareSteps = 6;



	public static List<OutlineVertex> Build(LakeShore _Shore, IReadOnlyList<LakeOpening> _Openings, int _FirstRowId)
	{
		var outline = new List<OutlineVertex>();

		if (_Openings.Count == 0)
		{
			foreach (var point in _Shore.Points)
				outline.Add(OutlineVertex.Free(point));

			return SurfaceMesher.RemoveDuplicates(outline);
		}

		int count = _Openings.Count;

		for (int i = 0; i < count; i++)
		{
			var opening = _Openings[i];
			var previous = _Openings[(i + count - 1) % count];

			float leaves = opening.At - opening.Reach;
			float returns = opening.At + opening.Reach;

			// The shore from where the previous opening came back to it, to where this one leaves it.
			foreach (var point in _Shore.Between(previous.At + previous.Reach, leaves))
				outline.Add(OutlineVertex.Free(point));

			_Shore.At(leaves, out Vector3 leavesPoint, out _, out Vector3 leavesDirection);
			_Shore.At(returns, out Vector3 returnsPoint, out _, out Vector3 returnsDirection);

			var row = opening.Row;

			// Round into the river, across its row, and back out to the shore on the other side.
			AddFlare(outline, leavesPoint, leavesDirection, row.Points[0], opening.Away);

			for (int k = 0; k < row.Count; k++)
				outline.Add(OutlineVertex.OnRow(row, k, _FirstRowId + i));

			AddFlare(outline, row.Points[^1], -opening.Away, returnsPoint, returnsDirection);
		}

		return SurfaceMesher.RemoveDuplicates(outline);
	}



	/// <summary>
	/// The outline of a river's mouth into the sea: the coast either side of the opening, out to where it leaves the
	/// block of sea the mouth is built in, with the opening cut in as a lake's (flares and the river's row); then the
	/// block's edge out at sea (<paramref name="_Edge"/>, its points counter-clockwise, on the grid the sea is drawn on)
	/// back to where the coast came in. The water is inside, as in a lake's outline. Null when the coast never leaves the
	/// block, or the opening's flares don't fit inside it.
	/// </summary>
	public static List<OutlineVertex> BuildMouth(LakeShore _Shore, LakeOpening _Opening, Func<Vector3, bool> _Inside, IReadOnlyList<Vector3> _Edge)
	{
		float leaves = _Opening.At - _Opening.Reach;
		float returns = _Opening.At + _Opening.Reach;

		if (_Edge.Count < 4)
			return null;

		if (!_Shore.TryLeave(returns, true, _Inside, out float to) || !_Shore.TryLeave(leaves, false, _Inside, out float from))
			return null;

		// The two walks must not meet round the far side of a small coast.
		if (to - from >= _Shore.Length - 1.0f)
			return null;

		var outline = new List<OutlineVertex>();

		foreach (var point in _Shore.Between(from, leaves))
			outline.Add(OutlineVertex.Free(point));

		_Shore.At(leaves, out Vector3 leavesPoint, out _, out Vector3 leavesDirection);
		_Shore.At(returns, out Vector3 returnsPoint, out _, out Vector3 returnsDirection);

		var row = _Opening.Row;

		AddFlare(outline, leavesPoint, leavesDirection, row.Points[0], _Opening.Away);

		for (int k = 0; k < row.Count; k++)
			outline.Add(OutlineVertex.OnRow(row, k, 0));

		AddFlare(outline, row.Points[^1], -_Opening.Away, returnsPoint, returnsDirection);

		foreach (var point in _Shore.Between(returns, to))
			outline.Add(OutlineVertex.Free(point));

		// Out at sea: along the block's edge (the water is on the left of the coast, so counter-clockwise) from where the
		// coast left it back to where it came in.
		_Shore.At(to, out Vector3 end, out _, out _);
		_Shore.At(from, out Vector3 start, out _, out _);

		var lengths = new float[_Edge.Count + 1];

		for (int k = 0; k < _Edge.Count; k++)
			lengths[k + 1] = lengths[k] + (_Edge[(k + 1) % _Edge.Count] - _Edge[k]).WithZ(0.0f).Length;

		float perimeter = lengths[^1];
		float leaveAt = EdgeParameter(_Edge, lengths, end);
		float target = Wrap(EdgeParameter(_Edge, lengths, start) - leaveAt, perimeter);

		// The edge's points in the order the walk meets them.
		int first = 0;
		float firstAhead = float.MaxValue;

		for (int k = 0; k < _Edge.Count; k++)
		{
			float ahead = Wrap(lengths[k] - leaveAt, perimeter);

			if (ahead > 0.01f && ahead < firstAhead)
			{
				first = k;
				firstAhead = ahead;
			}
		}

		for (int step = 0; step < _Edge.Count; step++)
		{
			int k = (first + step) % _Edge.Count;
			float ahead = Wrap(lengths[k] - leaveAt, perimeter);

			if (ahead <= 0.01f || ahead >= target - 0.01f)
				break;

			outline.Add(OutlineVertex.OnSeaEdge(_Edge[k]));
		}

		return SurfaceMesher.RemoveDuplicates(outline);
	}



	/// <summary>
	/// How far round a closed edge (from its first point) the point of it nearest to another is.
	/// </summary>
	private static float EdgeParameter(IReadOnlyList<Vector3> _Edge, float[] _Lengths, Vector3 _Point)
	{
		float best = float.MaxValue;
		float parameter = 0.0f;
		var target = new Vector2(_Point.x, _Point.y);

		for (int k = 0; k < _Edge.Count; k++)
		{
			var from = new Vector2(_Edge[k].x, _Edge[k].y);
			var span = new Vector2(_Edge[(k + 1) % _Edge.Count].x, _Edge[(k + 1) % _Edge.Count].y) - from;
			float lengthSquared = span.LengthSquared;
			float t = lengthSquared > 0.0f ? Math.Clamp(Vector2.Dot(target - from, span) / lengthSquared, 0.0f, 1.0f) : 0.0f;
			float distance = (from + span * t).Distance(target);

			if (distance >= best)
				continue;

			best = distance;
			parameter = _Lengths[k] + MathF.Sqrt(lengthSquared) * t;
		}

		return parameter;
	}



	private static float Wrap(float _Value, float _Length)
	{
		if (_Length <= 0.0f)
			return 0.0f;

		float value = _Value % _Length;

		return value < 0.0f ? value + _Length : value;
	}



	/// <summary>
	/// The shore turning out into the side of a river (or back from it): a curve that leaves along one direction and
	/// arrives along another, its two ends left out (they are the shore's and the row's own points). The pulls are
	/// kept level, so the curve's height runs smoothly from one end to the other.
	/// </summary>
	private static void AddFlare(List<OutlineVertex> _Outline, Vector3 _From, Vector3 _FromDirection, Vector3 _To, Vector3 _ToDirection)
	{
		float span = _From.Distance(_To);

		if (span <= 0.01f)
			return;

		Vector3 fromDirection = Flat(_FromDirection);
		Vector3 toDirection = Flat(_ToDirection);

		// A quarter turn's worth of pull at each end, which is what makes the curve read as an arc.
		float pull = span * 0.39f;
		Vector3 controlFrom = _From + fromDirection * pull;
		Vector3 controlTo = _To - toDirection * pull;

		for (int i = 1; i < FlareSteps; i++)
		{
			float t = (float)i / FlareSteps;
			float u = 1.0f - t;

			Vector3 point = u * u * u * _From + 3.0f * u * u * t * controlFrom + 3.0f * u * t * t * controlTo + t * t * t * _To;

			_Outline.Add(OutlineVertex.Free(point));
		}
	}



	private static Vector3 Flat(Vector3 _Direction)
	{
		Vector3 flat = _Direction.WithZ(0.0f);

		return flat.IsNearZeroLength ? _Direction.Normal : flat.Normal;
	}
}