Code/Mask/MaskModifiers.cs


using System.Collections.Generic;
using Sandbox.Utility;

namespace Sandbox.Mask;
using System.Linq;
using System;

#region SplineMask

/// <summary>
/// Outputs 1 inside the spline boundary, 0 outside.
/// Uses signed distance to polygon for soft edge support.
/// </summary>
[Serializable]
public class SplineMask : MaskModifier
{
    [Property] public ApexWorld.Spline.SplineComponent Spline { get; set; }
    [Property, Range( 0f, 500f )] public float EdgeSoftness { get; set; } = 0f;

    public override void Apply( MaskField field )
    {
        if ( Spline == null ) { Log.Warning( "SplineMask: no spline" ); return; }

        var polyLocal = new List<Vector3>();
        Spline.Spline.ConvertToPolyline( ref polyLocal );
        if ( polyLocal.Count < 3 ) return;

        var poly2D = new Vector2[polyLocal.Count];
        for ( int i = 0; i < polyLocal.Count; i++ )
        {
            var worldPos = Spline.WorldTransform.PointToWorld( polyLocal[i] );
            var local    = Terrain != null
                ? Terrain.WorldTransform.PointToLocal( worldPos )
                : worldPos;
            poly2D[i] = new Vector2( local.x, local.y );
        }

        for ( int y = 0; y < field.Resolution; y++ )
        {
            for ( int x = 0; x < field.Resolution; x++ )
            {
                var   texelPos   = field.TexelToWorld( x, y );
                float signedDist = SignedDistToPolygon( texelPos, poly2D );
                float value      = signedDist < 0f ? 1f : 0f;

                if ( EdgeSoftness > 0.001f )
                    value = Math.Clamp( -signedDist / EdgeSoftness, 0f, 1f );

                field.Set( x, y, value );
            }
        }
    }

    private static float SignedDistToPolygon( Vector2 p, Vector2[] poly )
    {
	    float minDist = float.MaxValue;
	    bool  inside  = false;
	    int   n       = poly.Length;

	    for ( int i = 0, j = n - 1; i < n; j = i++ )
	    {
		    var a = poly[i];
		    var b = poly[j];

		    if ( (a.y > p.y) != (b.y > p.y) &&
		         p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x )
			    inside = !inside;

		    var   ab    = b - a;
		    float lenSq = Vector2.Dot( ab, ab );
		    if ( lenSq < 0.0001f ) continue;

		    var   ap      = p - a;
		    float t       = Math.Clamp( Vector2.Dot( ap, ab ) / lenSq, 0f, 1f );
		    var   closest = a + ab * t;
		    minDist = MathF.Min( minDist, (p - closest).Length );
	    }
	    

	    return inside ? -minDist : minDist;
    }
}

#endregion

#region HeightMask

/// <summary>
/// Outputs 1 where terrain height is within [MinHeight, MaxHeight], 0 outside.
/// Softness controls the falloff width at each edge.
/// </summary>
[Serializable]
public class HeightMask : MaskModifier
{
	[Property] public float MinHeight  { get; set; } = 0f;
	[Property] public float MaxHeight  { get; set; } = 500f;
	[Property, Range(0f, 1f)] public float Softness { get; set; } = 0.1f;

	public override void Apply( MaskField field )
	{
		if ( Terrain?.Storage == null )
			return;

		var storage = Terrain.Storage;

		int res = storage.Resolution;

		float hScale = storage.TerrainHeight / (float)ushort.MaxValue;

		float soft = Softness * (MaxHeight - MinHeight);

		for ( int y = 0; y < field.Resolution; y++ )
		{
			for ( int x = 0; x < field.Resolution; x++ )
			{
				var local = field.TexelToWorld( x, y );

				int tx = (int)(local.x / storage.TerrainSize * res);
				int ty = (int)(local.y / storage.TerrainSize * res);

				tx = Math.Clamp( tx, 0, res - 1 );
				ty = Math.Clamp( ty, 0, res - 1 );

				float worldHeight =
					storage.HeightMap[ty * res + tx] * hScale;

				float lo = SmoothStep(
					MinHeight - soft,
					MinHeight,
					worldHeight
				);

				float hi = SmoothStep(
					MaxHeight + soft,
					MaxHeight,
					worldHeight
				);

				field.Set(
					x,
					y,
					Math.Clamp( lo * hi, 0f, 1f )
				);
			}
		}
	}

	private static float SmoothStep( float edge0, float edge1, float x )
	{
		float t = Math.Clamp( (x - edge0) / (edge1 - edge0), 0f, 1f );
		return t * t * (3f - 2f * t);
	}
}

#endregion

#region SlopeMask

/// <summary>
/// Outputs 1 where terrain slope angle is within [MinAngle, MaxAngle] degrees.
/// Requires the terrain height storage to compute gradients.
/// </summary>
[Serializable]
public class SlopeMask : MaskModifier
{
	[Property, Range(0f, 90f)] public float MinAngle  { get; set; } = 0f;
	[Property, Range(0f, 90f)] public float MaxAngle  { get; set; } = 30f;
	[Property, Range(0f, 1f)]  public float Softness  { get; set; } = 0.1f;

	public override void Apply( MaskField field )
	{
		if ( Terrain?.Storage == null ) return;

		var storage   = Terrain.Storage;
		int  res      = storage.Resolution;
		float hScale  = storage.TerrainHeight / (float)ushort.MaxValue;
		float sScale  = storage.TerrainSize   / (float)res;
		float soft    = Softness * (MaxAngle - MinAngle);

		for ( int y = 0; y < field.Resolution; y++ )
		{
			for ( int x = 0; x < field.Resolution; x++ )
			{
				var local = field.TexelToWorld( x, y );

				int tx = (int)(local.x / storage.TerrainSize * res);
				int ty = (int)(local.y / storage.TerrainSize * res);

				tx = Math.Clamp( tx, 1, res - 2 );
				ty = Math.Clamp( ty, 1, res - 2 );

				float c = storage.HeightMap[ty * res + tx]       * hScale;
				float r = storage.HeightMap[ty * res + tx + 1]   * hScale;
				float u = storage.HeightMap[(ty + 1) * res + tx] * hScale;

				float slopeX     = MathF.Abs( r - c ) / sScale;
				float slopeY     = MathF.Abs( u - c ) / sScale;
				float slope = MathF.Sqrt(
					slopeX * slopeX +
					slopeY * slopeY
				);

				float slopeAngle =
					MathF.Atan( slope ) * (180f / MathF.PI);

				float lo = SmoothStep( MinAngle - soft, MinAngle, slopeAngle );
				float hi = SmoothStep( MaxAngle + soft, MaxAngle, slopeAngle );

				field.Set( x, y, Math.Clamp( lo * hi, 0f, 1f ) );
			}
		}
	}

	private static float SmoothStep( float edge0, float edge1, float x )
	{
		float t = Math.Clamp( (x - edge0) / (edge1 - edge0), 0f, 1f );
		return t * t * (3f - 2f * t);
	}
}

#endregion

#region NoiseMask

/// <summary>
/// Fills the field with layered Perlin noise (FBM).
/// </summary>
[Serializable]
public class NoiseMask : MaskModifier
{
	[Property]
	[Range( 2f, 2000f )]
	public float Scale { get; set; } = 55;

	[Property]
	[Range( 0.25f, 8f )]
	public float Contrast { get; set; } = 2f;

	[Property]
	[Range( 0f, 1f)]
	public float Threshold { get; set; } = 0.58f;

	[Property]
	[Range( 0.001f, 0.5f )]
	public float Blend { get; set; } = 0.05f;

	[Property]
	public int Seed { get; set; } = 12345;

	[Property]
	public bool Invert { get; set; }

	public override void Apply( MaskField field )
	{
		int resolution = field.Resolution;

		float invScale = 1.0f / MathF.Max( Scale, 0.001f );

		for ( int x = 0; x < resolution; x++ )
		{
			for ( int y = 0; y < resolution; y++ )
			{
				float u = x / (float)(resolution - 1);
				float v = y / (float)(resolution - 1);

				float worldX = field.WorldOffset.x + (u * field.WorldSize);
				float worldY = field.WorldOffset.y + (v * field.WorldSize);

				float nx = (worldX + Seed * 17.13f) * invScale;
				float ny = (worldY + Seed * 9.37f) * invScale;

				float noise = 0f;

				float amplitude = 1f;
				float frequency = 1f;
				float totalAmplitude = 0f;

				for ( int i = 0; i < 4; i++ )
				{
					float n = Noise.Simplex(
						nx * frequency,
						ny * frequency
					);

					// remap -1..1 to 0..1
					n = (n * 0.5f) + 0.5f;

					noise += n * amplitude;

					totalAmplitude += amplitude;

					amplitude *= 0.5f;
					frequency *= 2f;
				}

				noise /= totalAmplitude;

				noise = MathF.Pow(
					Math.Clamp( noise, 0f, 1f ),
					Contrast
				);

				float value = Math.Clamp(
					(noise - (Threshold - Blend)) / ((Threshold + Blend) - (Threshold - Blend)),
					0f,
					1f
				);

				if ( Invert )
					value = 1f - value;

				float current = field.Get( x, y );

				field.Set(
					x,
					y,
					current * value
				);
			}
		}
	}
}

#endregion

#region DistanceMask

/// <summary>
/// Outputs 1 at the origin world point, falling off to 0 at Radius.
/// Useful for spawn exclusion zones or point-of-interest weighting.
/// </summary>
[Serializable]
public class DistanceMask : MaskModifier
{
	[Property] public float InnerRadius { get; set; } = 200f;
	[Property] public float OuterRadius { get; set; } = 1000f;
	[Property] public bool Invert { get; set; }

	public override void Apply( MaskField field )
	{
		var center = new Vector2(
			field.WorldOffset.x + field.WorldSize * 0.5f,
			field.WorldOffset.y + field.WorldSize * 0.5f
		);

		for ( int y = 0; y < field.Resolution; y++ )
		{
			for ( int x = 0; x < field.Resolution; x++ )
			{
				var pos = field.TexelToWorld( x, y );

				float dist = Vector2.DistanceBetween(
					pos,
					center
				);

				float t = (dist - InnerRadius) /
				          (OuterRadius - InnerRadius);

				t = Math.Clamp( t, 0f, 1f );

				float value = 1f - t;

				value = Math.Clamp( value, 0f, 1f );

				if ( Invert )
					value = 1f - value;

				field.Set( x, y, value );
			}
		}
	}
}

#endregion

#region CurvatureMask

/// <summary>
/// Outputs high values at concave areas (valleys) or convex areas (ridges)
/// depending on Mode.
/// </summary>
[Serializable]
public sealed class CurvatureMask : MaskModifier
{
	[Property, Range(16f, 1024f)]
	public float Radius { get; set; } = 256f;

	[Property, Range(0f, 64f)]
	public float Strength { get; set; } = 8f;

	[Property] public bool RidgesOnly { get; set; }
	[Property] public bool ValleysOnly { get; set; }

	public override void Apply( MaskField field )
	{
		var storage = Terrain.Storage;
		int res = storage.Resolution;

		float terrainSize = storage.TerrainSize;
		float hScale = storage.TerrainHeight;

		float texelWorldSize =
			field.WorldSize / field.Resolution;

		int sampleOffset = Math.Max(
			1,
			(int)(Radius / texelWorldSize)
		);

		for ( int y = 0; y < field.Resolution; y++ )
		{
			for ( int x = 0; x < field.Resolution; x++ )
			{
				float center = SampleHeight(
					field,
					storage,
					x,
					y,
					res,
					hScale
				);

				float left = SampleHeight(
					field,
					storage,
					x - sampleOffset,
					y,
					res,
					hScale
				);

				float right = SampleHeight(
					field,
					storage,
					x + sampleOffset,
					y,
					res,
					hScale
				);

				float down = SampleHeight(
					field,
					storage,
					x,
					y - sampleOffset,
					res,
					hScale
				);

				float up = SampleHeight(
					field,
					storage,
					x,
					y + sampleOffset,
					res,
					hScale
				);

				float average =
					(left + right + up + down) * 0.25f;

				float curvature =
					(center - average) * Strength;

				if ( RidgesOnly )
					curvature = Math.Max( curvature, 0f );

				if ( ValleysOnly )
					curvature = Math.Max( -curvature, 0f );

				float value = Math.Clamp(
					(curvature * 0.5f) + 0.5f,
					0f,
					1f
				);

				field.Set( x, y, value );
			}
		}
	}

	private float SampleHeight(
		MaskField field,
		TerrainStorage storage,
		int x,
		int y,
		int res,
		float hScale
	)
	{
		x = Math.Clamp(
			x,
			0,
			field.Resolution - 1
		);

		y = Math.Clamp(
			y,
			0,
			field.Resolution - 1
		);

		var local = field.TexelToWorld( x, y );

		int tx = (int)(
			local.x / storage.TerrainSize * res
		);

		int ty = (int)(
			local.y / storage.TerrainSize * res
		);

		tx = Math.Clamp( tx, 0, res - 1 );
		ty = Math.Clamp( ty, 0, res - 1 );

		return storage.HeightMap[
			ty * res + tx
		] * hScale;
	}
}

#endregion

#region ErosionMask

/// <summary>
/// Simulates simple hydraulic erosion by computing flow accumulation.
/// High values = erosion channels / valleys where water flows.
/// </summary>
[Serializable]
public class ErosionMask : MaskModifier
{
	[Property, Range( 32f, 1024f )]
	public float Radius { get; set; } = 256f;

	[Property, Range( 0f, 32f )]
	public float Strength { get; set; } = 8f;

	[Property, Range( 0f, 90f )]
	public float MinSlope { get; set; } = 5f;

	[Property, Range( 0f, 90f )]
	public float MaxSlope { get; set; } = 45f;

	public override void Apply( MaskField field )
	{
		if ( Terrain?.Storage == null )
			return;

		var storage = Terrain.Storage;

		int res = storage.Resolution;

		float terrainHeight = storage.TerrainHeight;

		float texelWorldSize =
			field.WorldSize / field.Resolution;

		int sampleOffset = Math.Max(
			1,
			(int)(Radius / texelWorldSize)
		);

		for ( int y = 0; y < field.Resolution; y++ )
		{
			for ( int x = 0; x < field.Resolution; x++ )
			{
				float center =
					SampleHeight(
						field,
						storage,
						x,
						y,
						res,
						terrainHeight
					);

				float left =
					SampleHeight(
						field,
						storage,
						x - sampleOffset,
						y,
						res,
						terrainHeight
					);

				float right =
					SampleHeight(
						field,
						storage,
						x + sampleOffset,
						y,
						res,
						terrainHeight
					);

				float down =
					SampleHeight(
						field,
						storage,
						x,
						y - sampleOffset,
						res,
						terrainHeight
					);

				float up =
					SampleHeight(
						field,
						storage,
						x,
						y + sampleOffset,
						res,
						terrainHeight
					);

				float dx =
					(right - left) /
					(sampleOffset * texelWorldSize * 2f);

				float dy =
					(up - down) /
					(sampleOffset * texelWorldSize * 2f);

				float slope =
					MathF.Sqrt( dx * dx + dy * dy );

				float slopeAngle =
					MathF.Atan( slope ) *
					(180f / MathF.PI);

				float average =
					(left + right + up + down) * 0.25f;

				float curvature =
					average - center;

				curvature =
					MathF.Max( curvature, 0f );

				float slopeMask =
					SmoothStep(
						MinSlope,
						MaxSlope,
						slopeAngle
					);

				float erosion =
					curvature *
					slopeMask *
					Strength;

				field.Set(
					x,
					y,
					Math.Clamp(
						erosion,
						0f,
						1f
					)
				);
			}
		}
	}

	private float SampleHeight(
		MaskField field,
		TerrainStorage storage,
		int x,
		int y,
		int res,
		float terrainHeight
	)
	{
		x = Math.Clamp(
			x,
			0,
			field.Resolution - 1
		);

		y = Math.Clamp(
			y,
			0,
			field.Resolution - 1
		);

		var local =
			field.TexelToWorld( x, y );

		int tx = (int)(
			local.x /
			storage.TerrainSize *
			res
		);

		int ty = (int)(
			local.y /
			storage.TerrainSize *
			res
		);

		tx = Math.Clamp( tx, 0, res - 1 );
		ty = Math.Clamp( ty, 0, res - 1 );

		return (
			storage.HeightMap[
				ty * res + tx
			] / 65535f
		) * terrainHeight;
	}

	private float SmoothStep(
		float edge0,
		float edge1,
		float x
	)
	{
		float t = Math.Clamp(
			(x - edge0) /
			(edge1 - edge0),
			0f,
			1f
		);

		return t * t * (3f - 2f * t);
	}
}
#endregion

#region FlowMask

/// <summary>
/// Marks areas where water would pool or flow based on height + curvature.
/// Good for wetness / mud / river bed placement.
/// </summary>
[Serializable]
public class FlowMask : MaskModifier
{
	[Property, Range( 1, 128 )]
	public int Iterations { get; set; } = 32;

	[Property, Range( 0f, 32f )]
	public float Strength { get; set; } = 8f;

	public override void Apply( MaskField field )
	{
		if ( Terrain?.Storage == null )
			return;

		var storage = Terrain.Storage;

		int res = field.Resolution;

		float[] heights = new float[res * res];
		float[] flow = new float[res * res];

		// Cache terrain heights
		for ( int y = 0; y < res; y++ )
		{
			for ( int x = 0; x < res; x++ )
			{
				heights[y * res + x] =
					SampleHeight(
						field,
						storage,
						x,
						y
					);

				flow[y * res + x] = 1f;
			}
		}

		// Flow simulation
		for ( int i = 0; i < Iterations; i++ )
		{
			for ( int y = 1; y < res - 1; y++ )
			{
				for ( int x = 1; x < res - 1; x++ )
				{
					int index = y * res + x;

					float current =
						heights[index];

					int bestX = x;
					int bestY = y;

					float lowest = current;

					// 8-neighbor downhill search
					for ( int oy = -1; oy <= 1; oy++ )
					{
						for ( int ox = -1; ox <= 1; ox++ )
						{
							if ( ox == 0 && oy == 0 )
								continue;

							int nx = x + ox;
							int ny = y + oy;

							float neighbor =
								heights[
									ny * res + nx
								];

							if ( neighbor < lowest )
							{
								lowest = neighbor;
								bestX = nx;
								bestY = ny;
							}
						}
					}

					if ( bestX != x || bestY != y )
					{
						int dst =
							bestY * res + bestX;

						flow[dst] +=
							flow[index] * 0.25f;
					}
				}
			}
		}

		// Normalize
		float maxFlow = 0f;

		for ( int i = 0; i < flow.Length; i++ )
			maxFlow = MathF.Max(
				maxFlow,
				flow[i]
			);

		if ( maxFlow <= 0f )
			maxFlow = 1f;

		for ( int y = 0; y < res; y++ )
		{
			for ( int x = 0; x < res; x++ )
			{
				float value =
					flow[y * res + x] /
					maxFlow;

				value *= Strength;

				field.Set(
					x,
					y,
					Math.Clamp(
						value,
						0f,
						1f
					)
				);
			}
		}
	}

	private float SampleHeight(
		MaskField field,
		TerrainStorage storage,
		int x,
		int y
	)
	{
		x = Math.Clamp(
			x,
			0,
			field.Resolution - 1
		);

		y = Math.Clamp(
			y,
			0,
			field.Resolution - 1
		);

		var world =
			field.TexelToWorld( x, y );

		int tx = (int)(
			world.x /
			storage.TerrainSize *
			storage.Resolution
		);

		int ty = (int)(
			world.y /
			storage.TerrainSize *
			storage.Resolution
		);

		tx = Math.Clamp(
			tx,
			0,
			storage.Resolution - 1
		);

		ty = Math.Clamp(
			ty,
			0,
			storage.Resolution - 1
		);

		return (
			storage.HeightMap[
				ty * storage.Resolution + tx
			] / 65535f
		) * storage.TerrainHeight;
	}
}

#endregion