Code/Spawners/SpawnUtils.cs
using System;
using System.Collections.Generic;
using Sandbox.Mask;
using Sandbox.Spawns;

namespace Sandbox.Spawners;

public static class SpawnUtils
{
	public static MaskField BuildCombinedMask(
		List<MaskModifier> masks,
		Terrain terrain,
		BaseSpawner spawner )
	{
		if ( terrain == null )
		{
			Log.Error( "Cannot build mask: terrain is null" );
			return null;
		}

		var resolution       = spawner.MaskResolution;
		var bounds           = spawner.GenerateSpawnerBounds();
		var terrainLocalMins = terrain.WorldTransform.PointToLocal( bounds.Mins );
		var worldOffset      = new Vector2( terrainLocalMins.x, terrainLocalMins.y );
		var field            = new MaskField( resolution, spawner.Range ) { WorldOffset = worldOffset };

		field.Fill( 1f );

		foreach ( var mask in masks )
		{
			mask.Terrain = terrain;
			var temp = new MaskField( resolution, spawner.Range ) { WorldOffset = worldOffset };
			temp.Fill( 1f );
			mask.Apply( temp );
			field = field.Multiply( temp );
		}

		return field;
	}

	/// <summary>Bilinear-sampled terrain world height at a world XY position.</summary>
	public static float GetTerrainHeightAt( Terrain terrain, Vector3 worldPos )
	{
		if ( terrain?.Storage == null ) return 0f;
		var storage = terrain.Storage;
		var local   = terrain.WorldTransform.PointToLocal( worldPos );

		float fx = Math.Clamp( local.x / storage.TerrainSize, 0f, 1f ) * (storage.Resolution - 1);
		float fy = Math.Clamp( local.y / storage.TerrainSize, 0f, 1f ) * (storage.Resolution - 1);

		int   x0 = Math.Clamp( (int)fx, 0, storage.Resolution - 2 );
		int   y0 = Math.Clamp( (int)fy, 0, storage.Resolution - 2 );
		float tx = fx - x0, ty = fy - y0;

		float hScale = storage.TerrainHeight / (float)ushort.MaxValue;
		float h00    = storage.HeightMap[ y0      * storage.Resolution + x0     ] * hScale;
		float h10    = storage.HeightMap[ y0      * storage.Resolution + x0 + 1 ] * hScale;
		float h01    = storage.HeightMap[(y0 + 1) * storage.Resolution + x0     ] * hScale;
		float h11    = storage.HeightMap[(y0 + 1) * storage.Resolution + x0 + 1 ] * hScale;

		return terrain.WorldPosition.z
		       + MathX.Lerp( MathX.Lerp( h00, h10, tx ), MathX.Lerp( h01, h11, tx ), ty );
	}

	/// <summary>Terrain surface normal at a world XY position.</summary>
	public static Vector3 GetTerrainNormalAt( Terrain terrain, Vector3 worldPos )
	{
		if ( terrain?.Storage == null ) return Vector3.Up;
		var storage = terrain.Storage;
		var local   = terrain.WorldTransform.PointToLocal( worldPos );

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

		int x = Math.Clamp( (int)(local.x / storage.TerrainSize * storage.Resolution), 1, storage.Resolution - 2 );
		int y = Math.Clamp( (int)(local.y / storage.TerrainSize * storage.Resolution), 1, storage.Resolution - 2 );

		float c = storage.HeightMap[ y      * storage.Resolution + x     ] * hScale;
		float r = storage.HeightMap[ y      * storage.Resolution + x + 1 ] * hScale;
		float f = storage.HeightMap[(y + 1) * storage.Resolution + x     ] * hScale;

		var tangentX = new Vector3( sScale, 0,      r - c ).Normal;
		var tangentY = new Vector3( 0,      sScale, f - c ).Normal;

		return Vector3.Cross( tangentX, tangentY ).Normal;
	}

	/// <summary>Slope direction = downhill, projected on XY plane.</summary>
	public static Vector3 GetTerrainSlopeDirectionAt( Terrain terrain, Vector3 worldPos )
	{
		if ( terrain?.Storage == null ) return Vector3.Forward;
		var storage = terrain.Storage;
		var local   = terrain.WorldTransform.PointToLocal( worldPos );

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

		int x = Math.Clamp( (int)(local.x / storage.TerrainSize * storage.Resolution), 1, storage.Resolution - 2 );
		int y = Math.Clamp( (int)(local.y / storage.TerrainSize * storage.Resolution), 1, storage.Resolution - 2 );

		float c = storage.HeightMap[ y      * storage.Resolution + x     ] * hScale;
		float r = storage.HeightMap[ y      * storage.Resolution + x + 1 ] * hScale;
		float f = storage.HeightMap[(y + 1) * storage.Resolution + x     ] * hScale;

		var gradient = new Vector3( r - c, f - c, 0f );
		return gradient.IsNearZeroLength ? Vector3.Forward : (-gradient).Normal;
	}

	/// <summary>Sample a MaskField at a world position, correctly accounting for WorldOffset.</summary>
	public static float SampleMaskAt( MaskField mask, Terrain terrain, Vector3 worldPos )
	{
		var local     = terrain.WorldTransform.PointToLocal( worldPos );
		var maskLocal = new Vector2( local.x, local.y ) - mask.WorldOffset;
		return mask.Sample( maskLocal );
	}

	/// <summary>
	/// Rotation for a spawned GameObject. Supports non-uniform axis alignment.
	/// </summary>
	public static Rotation GetSpawnRotation( SpawnDefinition def, Terrain terrain, Vector3 worldPos, Random rng )
	{
		var rot = Rotation.Identity;

		if ( def.AlignToSlope )
		{
			var normal = GetTerrainNormalAt( terrain, worldPos );
			rot = Rotation.FromToRotation( Vector3.Up, normal );
		}

		if ( def.ForwardToSlope )
		{
			var slopeDir = GetTerrainSlopeDirectionAt( terrain, worldPos );
			if ( !slopeDir.IsNearZeroLength )
			{
				float yaw  = MathF.Atan2( slopeDir.y, slopeDir.x ) * (180f / MathF.PI);
				rot        = Rotation.FromYaw( yaw ) * rot;
			}
		}

		var offsets = new Angles(
			MathX.Lerp( def.MinRotationOffset.pitch, def.MaxRotationOffset.pitch, (float)rng.NextDouble() ),
			MathX.Lerp( def.MinRotationOffset.yaw,   def.MaxRotationOffset.yaw,   (float)rng.NextDouble() ),
			MathX.Lerp( def.MinRotationOffset.roll,  def.MaxRotationOffset.roll,  (float)rng.NextDouble() )
		);

		return rot * offsets.ToRotation();
	}

	/// <summary>
	/// Scale for a spawned GameObject (non-uniform Vector3 — width, height, depth).
	/// Use this when setting go.WorldScale.
	/// </summary>
	public static Vector3 GetSpawnScale( SpawnDefinition def, float fitness, Random rng )
	{
		float t = (float)rng.NextDouble();
		float w, h;

		switch ( def.SpawnScale )
		{
			case ScaleMode.Fixed:
				w = def.WidthMinScale;
				h = def.HeightMinScale;
				break;
			case ScaleMode.Random:
				w = MathX.Lerp( def.WidthMinScale,  def.WidthMaxScale,  t );
				h = MathX.Lerp( def.HeightMinScale, def.HeightMaxScale, t );
				break;
			case ScaleMode.FitnessRandom:
				w = MathX.Lerp( def.WidthMinScale,  def.WidthMaxScale,  t ) * (1f + def.WidthRandomPercent  / 100f * fitness);
				h = MathX.Lerp( def.HeightMinScale, def.HeightMaxScale, t ) * (1f + def.HeightRandomPercent / 100f * fitness);
				break;
			default:
				w = h = 1f;
				break;
		}

		return new Vector3( w, h, w );
	}

	/// <summary>
	/// Uniform float scale for clutter instances (Transform.Scale is float — no non-uniform support).
	/// FitnessRandom: fitness drives the lerp so high-fitness areas get larger instances.
	/// </summary>
	public static float GetClutterScale( SpawnDefinition def, float fitness, Random rng )
	{
		float t = (float)rng.NextDouble();

		return def.SpawnScale switch
		{
			ScaleMode.Fixed         => def.WidthMinScale,
			ScaleMode.Random        => MathX.Lerp( def.WidthMinScale, def.WidthMaxScale, t ),
			// fitness acts as the lerp weight so sparse areas get small instances, dense get large
			ScaleMode.FitnessRandom => MathX.Lerp( def.WidthMinScale, def.WidthMaxScale, t * fitness ),
			_                       => 1f
		};
	}
}