Editor/LineRaster.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace TerrainLines;

// Rasterization only reads storage. Preview and commit use the same tile results.
internal static class LineRaster
{
	internal const int TileSize = 32;

	internal static float Distance2D( Vector3 a, Vector3 b )
	{
		float x = a.x - b.x, y = a.y - b.y;
		return MathF.Sqrt( x * x + y * y );
	}

	internal static float SampleHeight( TerrainStorage storage, float x, float y, Func<int, int, ushort> read = null )
	{
		int n = storage.Resolution;
		float px = Math.Clamp( x / storage.TerrainSize * n, 0, n - 1 );
		float py = Math.Clamp( y / storage.TerrainSize * n, 0, n - 1 );
		int x0 = (int)px, y0 = (int)py;
		int x1 = Math.Min( x0 + 1, n - 1 ), y1 = Math.Min( y0 + 1, n - 1 );
		float top = Lerp( read is null ? storage.HeightMap[y0 * n + x0] : read( x0, y0 ), read is null ? storage.HeightMap[y0 * n + x1] : read( x1, y0 ), px - x0 );
		float bottom = Lerp( read is null ? storage.HeightMap[y1 * n + x0] : read( x0, y1 ), read is null ? storage.HeightMap[y1 * n + x1] : read( x1, y1 ), px - x0 );
		return Lerp( top, bottom, py - y0 ) * storage.TerrainHeight / ushort.MaxValue;
	}

	internal static List<LineTile> Build( TerrainStorage storage, IReadOnlyList<Vector3> points, LineSettings settings, float worldScale )
	{
		if ( !settings.IsFinite || !float.IsFinite( worldScale ) || worldScale <= 0 )
			throw new ArgumentException( "Line settings must be finite and terrain scale must be positive." );
		if ( storage.Resolution < 2 || storage.TerrainSize <= 0 || storage.TerrainHeight <= 0 )
			throw new ArgumentException( "Terrain has invalid dimensions." );
		if ( points.Count < 2 ) return [];
		int resolution = storage.Resolution;
		float texel = storage.TerrainSize / resolution;
		float halfWidth = Math.Max( 1, settings.PathWidth ) * 0.5f / worldScale;
		float shoulder = Math.Max( 0, settings.ShoulderWidth ) / worldScale;
		float radius = halfWidth + shoulder + (settings.PaintMaterials ? texel : 0);
		float totalLength = 0;
		for ( int i = 1; i < points.Count; i++ ) totalLength += Distance2D( points[i - 1], points[i] );
		if ( totalLength < 0.001f ) return [];

		bool paint = settings.PaintMaterials && settings.PaintStrength > 0;
		if ( paint && (settings.SurfaceMaterial < 0 || settings.SurfaceMaterial >= Math.Min( 32, storage.Materials.Count )) )
			throw new ArgumentException( "Select a surface material already assigned to this terrain." );
		if ( paint && (settings.ShoulderMaterial < -1 || settings.ShoulderMaterial >= Math.Min( 32, storage.Materials.Count )) )
			throw new ArgumentException( "Select a shoulder material already assigned to this terrain." );
		if ( !settings.ShapeTerrain && !paint ) return [];

		var tiles = new Dictionary<(int, int), WorkingTile>();
		float travelled = 0;
		var profile = settings.ShapeTerrain ? new LineProfile( storage, points, totalLength, settings.EvenRamp, settings.SlopeLimit ) : null;
		for ( int segment = 1; segment < points.Count; segment++ )
		{
			var a = points[segment - 1];
			var b = points[segment];
			float length = Distance2D( a, b );
			if ( length < 0.001f ) continue;
			// Bound work along diagonal straight lines instead of visiting their whole bounding rectangle.
			int steps = Math.Max( 1, (int)MathF.Ceiling( length / Math.Max( texel * 4, radius ) ) );
			for ( int step = 0; step < steps; step++ )
			{
				float t0 = (float)step / steps, t1 = (float)(step + 1) / steps;
				var from = a + (b - a) * t0;
				var to = a + (b - a) * t1;
				int left = Math.Max( 0, (int)MathF.Floor( (Math.Min( from.x, to.x ) - radius) / texel ) );
				int right = Math.Min( resolution - 1, (int)MathF.Ceiling( (Math.Max( from.x, to.x ) + radius) / texel ) );
				int top = Math.Max( 0, (int)MathF.Floor( (Math.Min( from.y, to.y ) - radius) / texel ) );
				int bottom = Math.Min( resolution - 1, (int)MathF.Ceiling( (Math.Max( from.y, to.y ) + radius) / texel ) );
				float dx = to.x - from.x, dy = to.y - from.y;
				float squaredLength = dx * dx + dy * dy;
				for ( int y = top; y <= bottom; y++ )
				for ( int x = left; x <= right; x++ )
				{
					int index = y * resolution + x;
					if ( new CompactTerrainMaterial( storage.ControlMap[index] ).IsHole ) continue;
					float t = Math.Clamp( ((x * texel - from.x) * dx + (y * texel - from.y) * dy) / squaredLength, 0, 1 );
					float cx = from.x + dx * t, cy = from.y + dy * t;
					float distanceSquared = MathF.Pow( x * texel - cx, 2 ) + MathF.Pow( y * texel - cy, 2 );
					if ( distanceSquared > radius * radius ) continue;
					var key = (x / TileSize, y / TileSize);
					if ( !tiles.TryGetValue( key, out var tile ) )
					{
						tile = new WorkingTile( storage, key.Item1 * TileSize, key.Item2 * TileSize, settings.ShapeTerrain, paint );
						tiles.Add( key, tile );
					}
					int local = (y - tile.Result.Y) * tile.Result.Width + x - tile.Result.X;
					// Nearest segment wins at crossings; later segments win exact ties. Never accumulate stamps.
					if ( distanceSquared > tile.Distance[local] ) continue;
					tile.Distance[local] = distanceSquared;
					float segmentT = Lerp( t0, t1, t );
					if ( profile is not null )
						tile.Height[local] = profile.Sample( travelled + length * segmentT ) + settings.Elevation / worldScale;
				}
			}
			travelled += length;
		}

		var result = new List<LineTile>();
		foreach ( var tile in tiles.Values )
		{
			var output = tile.Result;
			for ( int i = 0; i < tile.Distance.Length; i++ )
			{
				if ( !float.IsFinite( tile.Distance[i] ) ) continue;
				float distance = MathF.Sqrt( tile.Distance[i] );
				float edge = distance <= halfWidth ? 1 : shoulder > 0 ? Math.Clamp( 1 - (distance - halfWidth) / shoulder, 0, 1 ) : 0;
				float weight = ShoulderWeight( edge, settings.ShoulderCurve );
				if ( output.AfterHeight is not null )
				{
					float target = Math.Clamp( tile.Height[i] / storage.TerrainHeight, 0, 1 ) * ushort.MaxValue;
					output.AfterHeight[i] = (ushort)Math.Clamp( (int)MathF.Round( Lerp( output.BeforeHeight[i], target, weight ) ), 0, ushort.MaxValue );
				}
				if ( output.AfterControl is not null )
				{
					// Paint uses a filtered edge at the path boundary, independently of the sculpt shoulder.
					float core = Coverage( distance, halfWidth, texel );
					float strength = Math.Clamp( settings.PaintStrength, 0, 1 );
					float outer = Coverage( distance, halfWidth + shoulder, texel );
					output.AfterControl[i] = shoulder > 0 && settings.ShoulderMaterial >= 0
						? PaintBands( output.BeforeControl[i], settings.SurfaceMaterial, settings.ShoulderMaterial, core, outer, strength )
						: Paint( output.BeforeControl[i], settings.SurfaceMaterial, core * strength );
				}
			}
			if ( output.BeforeHeight is not null && output.BeforeHeight.SequenceEqual( output.AfterHeight ) )
				output.BeforeHeight = output.AfterHeight = null;
			if ( output.BeforeControl is not null && output.BeforeControl.SequenceEqual( output.AfterControl ) )
				output.BeforeControl = output.AfterControl = null;
			if ( output.Flags != 0 ) result.Add( output );
		}
		return result;
	}

	internal static float ShoulderWeight( float edge, ShoulderCurve curve ) => curve switch
	{
		ShoulderCurve.Linear => edge,
		ShoulderCurve.RoundIn => edge * edge,
		ShoulderCurve.RoundOut => 1 - (1 - edge) * (1 - edge),
		_ => edge * edge * (3 - 2 * edge)
	};

	internal static float Coverage( float distance, float radius, float texel )
	{
		float t = Math.Clamp( (radius - distance) / texel + 0.5f, 0, 1 );
		return t * t * (3 - 2 * t);
	}

	internal static uint PaintBands( uint packed, int surface, int shoulder, float core, float outer, float strength )
	{
		if ( surface == shoulder ) return Paint( packed, surface, outer * strength );
		if ( core >= 1 ) return Paint( packed, surface, strength );
		if ( core <= 0 ) return Paint( packed, shoulder, outer * strength );
		var material = new CompactTerrainMaterial( packed );
		if ( material.IsHole || strength <= 0 ) return packed;
		// Blend both bands once, retaining the two strongest layers supported by native terrain.
		Span<float> weights = stackalloc float[32];
		weights.Clear();
		float retained = 1 - outer * strength;
		float overlay = material.BlendFactor / 255f;
		weights[material.BaseTextureId] += (1 - overlay) * retained;
		weights[material.OverlayTextureId] += overlay * retained;
		weights[surface] += core * strength;
		weights[shoulder] += (outer - core) * strength;
		int first = 0;
		for ( int i = 1; i < 32; i++ ) if ( weights[i] > weights[first] ) first = i;
		int second = first == 0 ? 1 : 0;
		for ( int i = 0; i < 32; i++ ) if ( i != first && weights[i] > weights[second] ) second = i;
		material.BaseTextureId = (byte)first;
		material.OverlayTextureId = (byte)second;
		material.BlendFactor = (byte)Math.Clamp( (int)MathF.Round( weights[second] / (weights[first] + weights[second]) * 255 ), 0, 255 );
		return material.Packed;
	}

	internal static uint Paint( uint packed, int id, float strength )
	{
		if ( strength <= 0 ) return packed;
		var material = new CompactTerrainMaterial( packed );
		if ( material.IsHole ) return packed;
		float overlay = material.BlendFactor / 255f;
		if ( material.BaseTextureId == id ) overlay *= 1 - strength;
		else if ( material.OverlayTextureId == id ) overlay = Lerp( overlay, 1, strength );
		else
		{
			if ( overlay > 0.5f ) material.BaseTextureId = material.OverlayTextureId;
			material.OverlayTextureId = (byte)id;
			overlay = strength;
		}
		if ( overlay > 0.5f )
		{
			(material.BaseTextureId, material.OverlayTextureId) = (material.OverlayTextureId, material.BaseTextureId);
			overlay = 1 - overlay;
		}
		material.BlendFactor = (byte)Math.Clamp( (int)MathF.Round( overlay * 255 ), 0, 255 );
		return material.Packed;
	}

	private static float Lerp( float a, float b, float t ) => a + (b - a) * t;

	private sealed class WorkingTile
	{
		internal readonly LineTile Result;
		internal readonly float[] Distance;
		internal readonly float[] Height;
		internal WorkingTile( TerrainStorage storage, int x, int y, bool shape, bool paint )
		{
			Result = new LineTile( storage, x, y, shape, paint );
			Distance = new float[Result.Width * Result.Height];
			Array.Fill( Distance, float.PositiveInfinity );
			Height = new float[Distance.Length];
		}
	}
}

internal sealed class LineTile
{
	internal readonly int X, Y, Width, Height;
	internal ushort[] BeforeHeight, AfterHeight;
	internal uint[] BeforeControl, AfterControl;
	internal Terrain.SyncFlags Flags => (BeforeHeight is null ? 0 : Terrain.SyncFlags.Height) | (BeforeControl is null ? 0 : Terrain.SyncFlags.Control);

	internal LineTile( TerrainStorage storage, int x, int y, bool shape, bool paint )
	{
		X = x; Y = y;
		Width = Math.Min( LineRaster.TileSize, storage.Resolution - x );
		Height = Math.Min( LineRaster.TileSize, storage.Resolution - y );
		if ( shape )
		{
			BeforeHeight = Copy( storage.HeightMap, storage.Resolution );
			AfterHeight = (ushort[])BeforeHeight.Clone();
		}
		if ( paint )
		{
			BeforeControl = Copy( storage.ControlMap, storage.Resolution );
			AfterControl = (uint[])BeforeControl.Clone();
		}
	}

	private T[] Copy<T>( T[] source, int stride )
	{
		var result = new T[Width * Height];
		for ( int row = 0; row < Height; row++ ) Array.Copy( source, (Y + row) * stride + X, result, row * Width, Width );
		return result;
	}

	internal bool Matches( TerrainStorage storage )
	{
		for ( int row = 0; row < Height; row++ )
		for ( int column = 0; column < Width; column++ )
		{
			int local = row * Width + column, index = (Y + row) * storage.Resolution + X + column;
			if ( BeforeHeight is not null && BeforeHeight[local] != AfterHeight[local] && storage.HeightMap[index] != BeforeHeight[local] ) return false;
			if ( BeforeControl is not null && BeforeControl[local] != AfterControl[local] && storage.ControlMap[index] != BeforeControl[local] ) return false;
		}
		return true;
	}
	internal void UploadPreview( Terrain terrain )
	{
		if ( BeforeHeight is not null ) terrain.HeightMap.Update<ushort>( AfterHeight, X, Y, Width, Height );
		if ( BeforeControl is not null ) terrain.ControlMap.Update<uint>( AfterControl, X, Y, Width, Height );
	}

	internal void WriteStorage( TerrainStorage storage, bool after )
	{
		// Only restore changed samples, preserving unrelated edits inside the same snapshot tile.
		for ( int row = 0; row < Height; row++ )
		for ( int column = 0; column < Width; column++ )
		{
			int local = row * Width + column, index = (Y + row) * storage.Resolution + X + column;
			if ( BeforeHeight is not null && BeforeHeight[local] != AfterHeight[local] ) storage.HeightMap[index] = after ? AfterHeight[local] : BeforeHeight[local];
			if ( BeforeControl is not null && BeforeControl[local] != AfterControl[local] ) storage.ControlMap[index] = after ? AfterControl[local] : BeforeControl[local];
		}
	}
	internal void RestorePreview( Terrain terrain )
	{
		if ( BeforeHeight is not null ) terrain.HeightMap.Update<ushort>( Copy( terrain.Storage.HeightMap, terrain.Storage.Resolution ), X, Y, Width, Height );
		if ( BeforeControl is not null ) terrain.ControlMap.Update<uint>( Copy( terrain.Storage.ControlMap, terrain.Storage.Resolution ), X, Y, Width, Height );
	}
}