Editor/Tools/PaintTool.cs
// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.

using System;
using Sandbox;
using Editor.TerrainEditor;

namespace Editor.SubTerrain;

public enum SubTerrainPaintLayer
{
	Base = 0,
	Overlay = 1,
	Path = 2
}

internal static class SubTerrainSplatUtil
{
	public static bool TryTexel( Terrain terrain, Vector3 hitLocal, out int x, out int y )
	{
		x = 0;
		y = 0;
		var storage = terrain?.Storage;
		if ( storage == null || storage.Resolution <= 0 || storage.TerrainSize <= 0.001f )
			return false;

		var uv = new Vector2( hitLocal.x, hitLocal.y ) / storage.TerrainSize;
		x = (int)Math.Floor( storage.Resolution * uv.x );
		y = (int)Math.Floor( storage.Resolution * uv.y );
		if ( x < 0 || y < 0 || x >= storage.Resolution || y >= storage.Resolution )
			return false;

		return storage.ControlMap != null && storage.ControlMap.Length == storage.Resolution * storage.Resolution;
	}

	public static CompactTerrainMaterial Read( Terrain terrain, int x, int y )
	{
		return new CompactTerrainMaterial( terrain.Storage.ControlMap[x + y * terrain.Storage.Resolution] );
	}

	public static string MaterialName( Terrain terrain, int index )
	{
		var materials = terrain?.Storage?.Materials;
		if ( materials == null || index < 0 || index >= materials.Count )
			return "empty";

		var material = materials[index];
		if ( material is null )
			return "empty";

		var name = material.ResourceName ?? material.ResourcePath ?? "tmat";
		var slash = Math.Max( name.LastIndexOf( '/' ), name.LastIndexOf( '\\' ) );
		if ( slash >= 0 && slash < name.Length - 1 )
			name = name[(slash + 1)..];

		if ( name.EndsWith( ".tmat", StringComparison.OrdinalIgnoreCase ) )
			name = name[..^5];

		return name.Replace( '_', ' ' );
	}

	public static int FindPathGround( Terrain terrain, SubTerrainPaintSettings settings )
	{
		var materials = terrain?.Storage?.Materials;

		if ( settings != null && settings.HasPathGround )
		{
			if ( materials != null && settings.PathGroundIndex < materials.Count )
				return settings.PathGroundIndex;
		}

		if ( settings != null && settings.HasSampledGround )
		{
			if ( materials != null && settings.SampledGroundIndex < materials.Count )
				return settings.SampledGroundIndex;
		}

		if ( materials == null || materials.Count == 0 )
			return 0;

		return 0;
	}

	/// <summary>
	/// Path wear/path tmat when the current pick is invalid (same as ground).
	/// Prefers the next slot after ground; no material-name heuristics.
	/// </summary>
	public static int FindPathWear( Terrain terrain, int pathGround )
	{
		var materials = terrain?.Storage?.Materials;
		if ( materials == null || materials.Count == 0 )
			return pathGround;

		for ( int i = 0; i < materials.Count; i++ )
		{
			if ( i != pathGround )
				return i;
		}

		return pathGround;
	}

	public static float MixEdge( float mixCap )
	{
		return Math.Clamp( mixCap * 0.25f, 0.04f, mixCap );
	}

	/// <summary>
	/// Same rise/run as cs_subterrain_splat PassesGates so the footer skip matches the stroke.
	/// </summary>
	public static bool TrySlopeDegrees( Terrain terrain, int x, int y, out float degrees )
	{
		degrees = 0f;
		var storage = terrain?.Storage;
		var map = storage?.HeightMap;
		if ( storage == null || map == null || storage.Resolution <= 0 )
			return false;

		int res = storage.Resolution;
		int i = x + y * res;
		if ( i < 0 || i >= map.Length )
			return false;

		int x1 = Math.Min( x + 1, res - 1 );
		int y1 = Math.Min( y + 1, res - 1 );
		float height = storage.TerrainHeight;
		float h0 = map[i] / 65535f * height;
		float hx = map[x1 + y * res] / 65535f * height;
		float hy = map[x + y1 * res] / 65535f * height;
		float dx = hx - h0;
		float dy = hy - h0;
		float rise = MathF.Sqrt( dx * dx + dy * dy );
		float texelWorld = storage.TerrainSize / res;
		degrees = MathF.Atan2( rise, Math.Max( texelWorld, 0.001f ) ) * (180f / MathF.PI);
		return true;
	}

	public static bool HasSeam( Terrain terrain, int x, int y )
	{
		var center = Read( terrain, x, y );
		int res = terrain.Storage.Resolution;
		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;
				if ( nx < 0 || ny < 0 || nx >= res || ny >= res )
					continue;

				var n = Read( terrain, nx, ny );
				if ( n.BaseTextureId != center.BaseTextureId || n.OverlayTextureId != center.OverlayTextureId )
					return true;
			}
		}

		return false;
	}
}

[Title( "Paint Texture" )]
[Description( "Paint ground, wear, or a path. Alt samples. Shift smooths mix." )]
[Icon( "brush" )]
[Alias( "subterrain_paint" )]
[Group( "1" )]
[Order( 4 )]
public class SubTerrainPaintTool : EditorTool
{
	readonly SubTerrainTool _parent;
	bool _dragging;
	bool _sampling;
	RectInt _dirtyRegion;
	Vector3 _lastHitWorldPos;
	Vector3 _lastPaintLocal;
	Vector2? _cursorLockPosition;

	public SubTerrainPaintTool( SubTerrainTool parent )
	{
		_parent = parent;
	}

	public static SubTerrainPaintLayer ActiveLayer
	{
		get => _activeLayer;
		set => _activeLayer = value;
	}

	static SubTerrainPaintLayer _activeLayer = SubTerrainPaintLayer.Base;

	public override void OnEnabled()
	{
		AllowGameObjectSelection = false;
		_parent.SetBrushMode( nameof( SubTerrainPaintTool ) );
		_parent.PaintArmed = true;
		SubTerrainPaintStore.Apply( _parent.PaintSettings, SubTerrainPaintLayer.Base );
		ActiveLayer = SubTerrainPaintLayer.Base;
		_parent.TrySetPaintLayer( SubTerrainPaintLayer.Base );
		_parent.RefreshPaintChrome();
	}

	public override void OnDisabled()
	{
		_parent.PaintArmed = false;
		_parent.RefreshPaintChrome();
		_parent.DetachSidebarForReuse();
	}

	public override void OnUpdate()
	{
		var terrain = GetSelectedComponent<Terrain>();
		if ( !terrain.IsValid() )
		{
			_parent.ClearStatus();
			return;
		}

		if ( !terrain.RayIntersects( Gizmo.CurrentRay, Gizmo.RayDepth, out var hitPosition ) )
		{
			_parent.ClearStatus();
			return;
		}

		var tx = terrain.WorldTransform;
		_parent.UpdateStatus( terrain, hitPosition );

		if ( Application.MouseButtons.HasFlag( MouseButtons.Middle ) )
		{
			_cursorLockPosition ??= Application.UnscaledCursorPosition;
			var d = Application.UnscaledCursorPosition - _cursorLockPosition.Value;
			var brush = _parent.BrushSettings;

			if ( Gizmo.IsShiftPressed )
			{
				brush.Size = SubTerrainBrushSize.Clamp( (int)(brush.Size + d.x * 0.25f) );
				brush.Opacity = Math.Clamp( brush.Opacity - d.y * 0.002f, 0f, 1f );
				_parent.SyncBrushSizeWidget();
			}
			else if ( Gizmo.IsCtrlPressed && !brush.RandomRotation )
			{
				brush.Rotation = ((brush.Rotation + d.x * 0.5f) % 360f + 360f) % 360f;
				_parent.SyncBrushRotationRows();
			}

			Application.UnscaledCursorPosition = _cursorLockPosition.Value;
			SceneOverlay.Parent.Cursor = CursorShape.Blank;
			_parent.DrawBrushPreview( new Transform( _lastHitWorldPos, tx.Rotation ), terrain );
			return;
		}

		if ( _cursorLockPosition.HasValue )
		{
			SceneOverlay.Parent.Cursor = CursorShape.None;
			_cursorLockPosition = null;
		}

		_lastHitWorldPos = tx.PointToWorld( hitPosition );
		_parent.DrawBrushPreview( new Transform( _lastHitWorldPos, tx.Rotation ), terrain );

		bool alt = Application.KeyboardModifiers.HasFlag( KeyboardModifiers.Alt );
		bool left = Gizmo.IsLeftMouseDown;

		if ( left && alt )
		{
			if ( !_sampling )
			{
				_sampling = true;
				Sample( terrain, hitPosition );
			}

			return;
		}

		if ( _sampling )
		{
			if ( !left )
				_sampling = false;
			return;
		}

		if ( left )
		{
			bool shouldPaint = !_dragging || !Application.CursorDelta.IsNearZeroLength;

			if ( !_dragging )
			{
				if ( _parent.BrushSettings.RandomRotation )
					_parent.BrushSettings.Rotation = Random.Shared.NextSingle() * 360f;

				_dragging = true;

				var uv = new Vector2( hitPosition.x, hitPosition.y ) / terrain.Storage.TerrainSize;
				var x = (int)Math.Floor( terrain.Storage.Resolution * uv.x );
				var y = (int)Math.Floor( terrain.Storage.Resolution * uv.y );
				_dirtyRegion = new( new Vector2Int( x, y ) );
				_lastPaintLocal = hitPosition;
				OnPaint( terrain, hitPosition );
			}
			else if ( shouldPaint )
			{
				StrokePaint( terrain, hitPosition );
			}
		}
		else if ( _dragging )
		{
			_dragging = false;
			OnPaintEnded( terrain );
		}
	}

	/// <summary>
	/// Fill stamps along the drag in world space. Pack center Path uses a dense step so
	/// the solid core continues; apron-only Path can step wider.
	/// </summary>
	void StrokePaint( Terrain terrain, Vector3 hitLocal )
	{
		var from = _lastPaintLocal;
		var to = hitLocal;
		float dist = (to - from).Length;
		bool path = _parent.PaintSettings.Layer == SubTerrainPaintLayer.Path;
		bool pack = path && _parent.PaintSettings.PackCenter;
		// Pack core is only the soft-brush center. Step must stay under that diameter or
		// you get apron streaks with a solid disc only at the stroke tip.
		float stepScale = pack ? 0.18f : path ? 0.35f : 0.2f;
		float step = Math.Max( _parent.BrushSettings.Size * stepScale, 8f );

		if ( dist < 0.001f )
			return;

		if ( path && !_parent.BrushSettings.RandomRotation )
			OrientPathBrush( from, to );

		if ( dist <= step )
		{
			OnPaint( terrain, to );
			_lastPaintLocal = to;
			return;
		}

		const int maxStamps = 64;
		int stamps = (int)Math.Ceiling( dist / step );
		if ( stamps > maxStamps )
		{
			stamps = maxStamps;
			step = dist / stamps;
		}

		for ( int i = 1; i <= stamps; i++ )
		{
			float t = i / (float)stamps;
			var p = Vector3.Lerp( from, to, t );
			if ( path && _parent.BrushSettings.RandomRotation )
				_parent.BrushSettings.Rotation = Random.Shared.NextSingle() * 360f;

			OnPaint( terrain, p );
		}

		_lastPaintLocal = to;
	}

	void OrientPathBrush( Vector3 from, Vector3 to )
	{
		var d = to - from;
		float lenSq = d.x * d.x + d.y * d.y;
		if ( lenSq < 0.0001f )
			return;

		float deg = MathF.Atan2( d.y, d.x ) * (180f / MathF.PI);
		_parent.BrushSettings.Rotation = ((deg % 360f) + 360f) % 360f;
	}

	void Sample( Terrain terrain, Vector3 hitPosition )
	{
		if ( !SubTerrainSplatUtil.TryTexel( terrain, hitPosition, out var x, out var y ) )
			return;

		var mat = SubTerrainSplatUtil.Read( terrain, x, y );
		var settings = _parent.PaintSettings;
		settings.SampledGroundIndex = mat.BaseTextureId;

		if ( settings.Layer == SubTerrainPaintLayer.Overlay )
			settings.LimitToSampledGround = true;

		if ( settings.Layer == SubTerrainPaintLayer.Path )
			return;

		int pick = mat.BaseTextureId;
		if ( settings.Layer == SubTerrainPaintLayer.Overlay && mat.BlendFactor > 20 )
			pick = mat.OverlayTextureId;

		settings.MaterialIndex = pick;
		TrySetSplatChannel( pick );
	}

	public static void TrySetSplatChannel( int index )
	{
		PaintTextureTool.SplatChannel = index;
	}

	void OnPaint( Terrain terrain, Vector3 hitPosition )
	{
		var brushTexture = _parent.SelectedBrushTexture;
		if ( brushTexture is null )
			return;

		var settings = _parent.PaintSettings;
		ActiveLayer = settings.Layer;

		var hitUV = new Vector2( hitPosition.x, hitPosition.y ) / terrain.Storage.TerrainSize;
		int size = (int)Math.Floor( _parent.BrushSettings.Size * 2.0f / terrain.Storage.TerrainSize * terrain.Storage.Resolution );
		size = Math.Max( size, 1 );

		bool shift = Application.KeyboardModifiers.HasFlag( KeyboardModifiers.Shift );
		bool ctrl = Gizmo.IsCtrlPressed;
		float strength = shift
			? _parent.BrushSettings.Opacity
			: _parent.BrushSettings.Opacity * (ctrl ? -1.0f : 1.0f);

		if ( settings.Layer == SubTerrainPaintLayer.Path && !shift )
			strength = ctrl ? -1.0f : 1.0f;

		int mode = 0;
		if ( shift )
			mode = 2;
		else if ( settings.Layer == SubTerrainPaintLayer.Path )
			mode = 3;
		else if ( settings.Layer == SubTerrainPaintLayer.Overlay && settings.MixOnly )
			mode = 1;

		int pathGround = SubTerrainSplatUtil.FindPathGround( terrain, settings );
		if ( mode == 3 && pathGround == settings.MaterialIndex )
			return;

		// Path packing uses PaintMode 3 in cs_subterrain_splat.
		int limit = -1;
		if ( settings.Layer != SubTerrainPaintLayer.Path
			&& settings.LimitToSampledGround
			&& settings.HasSampledGround )
		{
			limit = settings.SampledGroundIndex;
		}

		float mixEdge = SubTerrainSplatUtil.MixEdge( settings.MixCap );
		float mixMax = SubTerrainPaintSettings.MixMaxFor( settings.Layer );

		var cs = new ComputeShader( "terrain/cs_subterrain_splat" );
		cs.Attributes.Set( "ControlMap", terrain.ControlMap );
		cs.Attributes.Set( "Heightmap", terrain.HeightMap );
		cs.Attributes.Set( "ControlUV", hitUV );
		cs.Attributes.Set( "BrushStrength", strength );
		cs.Attributes.Set( "BrushSize", size );
		cs.Attributes.Set( "BrushRotation", _parent.BrushSettings.Rotation * MathF.PI / 180f );
		cs.Attributes.Set( "Brush", brushTexture );
		cs.Attributes.Set( "SplatChannel", settings.MaterialIndex );
		cs.Attributes.Set( "PaintLayer", (int)settings.Layer );
		cs.Attributes.Set( "PaintMode", mode );
		cs.Attributes.Set( "MixCap", Math.Clamp( settings.MixCap, 0.05f, mixMax ) );
		cs.Attributes.Set( "MixEdge", mixEdge );
		cs.Attributes.Set( "PathGround", pathGround );
		cs.Attributes.Set( "LimitGround", limit );
		cs.Attributes.Set( "PackCenter", settings.PackCenter ? 1 : 0 );
		cs.Attributes.Set( "PackThreshold", settings.PackThreshold );
		cs.Attributes.Set( "SlopeEnable", settings.OnlyOnSlope ? 1 : 0 );
		cs.Attributes.Set( "SlopeMinDegrees", settings.SlopeDegrees );
		cs.Attributes.Set( "TexelWorld", terrain.Storage.TerrainSize / Math.Max( terrain.Storage.Resolution, 1 ) );
		cs.Attributes.Set( "TerrainHeight", terrain.Storage.TerrainHeight );

		cs.Dispatch( size, size, 1 );

		var x = (int)Math.Floor( terrain.Storage.Resolution * hitUV.x ) - size / 2;
		var y = (int)Math.Floor( terrain.Storage.Resolution * hitUV.y ) - size / 2;
		_dirtyRegion.Add( new RectInt( x, y, size + 1, size + 1 ) );
	}

	void OnPaintEnded( Terrain terrain )
	{
		_dirtyRegion.Left = Math.Clamp( _dirtyRegion.Left, 0, terrain.Storage.Resolution - 1 );
		_dirtyRegion.Right = Math.Clamp( _dirtyRegion.Right, 0, terrain.Storage.Resolution - 1 );
		_dirtyRegion.Top = Math.Clamp( _dirtyRegion.Top, 0, terrain.Storage.Resolution - 1 );
		_dirtyRegion.Bottom = Math.Clamp( _dirtyRegion.Bottom, 0, terrain.Storage.Resolution - 1 );

		var dirtyRegion = _dirtyRegion;

		static uint[] CopyRegion( uint[] data, int stride, RectInt rect )
		{
			uint[] region = new uint[rect.Width * rect.Height];

			for ( int y = 0; y < rect.Height; y++ )
			{
				for ( int x = 0; x < rect.Width; x++ )
				{
					region[x + y * rect.Width] = data[rect.Left + x + (rect.Top + y) * stride];
				}
			}

			return region;
		}

		var regionBefore = CopyRegion( terrain.Storage.ControlMap, terrain.Storage.Resolution, dirtyRegion );
		terrain.SyncCPUTexture( Terrain.SyncFlags.Control, dirtyRegion );
		var regionAfter = CopyRegion( terrain.Storage.ControlMap, terrain.Storage.Resolution, dirtyRegion );

		Action CreateUndoAction( uint[] region ) => () =>
		{
			if ( !terrain.IsValid() )
				return;

			for ( int y = 0; y < dirtyRegion.Height; y++ )
			{
				for ( int x = 0; x < dirtyRegion.Width; x++ )
				{
					terrain.Storage.ControlMap[dirtyRegion.Left + x + (dirtyRegion.Top + y) * terrain.Storage.Resolution] = region[x + y * dirtyRegion.Width];
				}
			}

			terrain.SyncGPUTexture();
			terrain.UpdateCollision( Terrain.SyncFlags.Control, dirtyRegion );
		};

		SceneEditorSession.Active.UndoSystem.Insert( $"Terrain {DisplayInfo.For( this ).Name}",
			CreateUndoAction( regionBefore ),
			CreateUndoAction( regionAfter ) );
	}
}