Editor/GrassTool.cs

An editor tool for the terrain/grass system. It provides a brush UI and sidebar to paint or erase per-cell grass density, traces the world to bake height and normal into the target GrassRenderer storage, and supports clearing the whole map with confirmation.

File AccessNetworking
using System;
using System.Linq;
using Editor;
using Editor.TerrainEditor;
using Sandbox;

namespace RedSnail.GrassTool.Editor;

/// <summary>
/// Paints grass coverage onto any surface. The brush writes density into the target
/// <see cref="GrassRenderer"/>'s density map, baking the surface height and normal under each
/// cell so blades sit on whatever geometry is there. Hold Ctrl to erase.
/// </summary>
[EditorTool( "grass" )]
[Title( "Grass" )]
[Icon( "grass" )]
public sealed class GrassPaintTool : EditorTool
{
	public BrushSettings BrushSettings { get; private set; } = new();

	private GrassRenderer _target;
	private bool _erasing;
	private bool _dragging;
	private bool _painted;
	private Vector3 _lastPaintPosition;

	// Repainting the same spot every frame just burns traces, so the brush has to travel a
	// fraction of its own radius before it deposits again.
	private float PaintStepDistance => BrushSettings.Size * 0.25f;

	public GrassPaintTool()
	{
		RebuildSidebarOnSelectionChange = false;
	}

	public override Widget CreateToolSidebar()
	{
		var sidebar = new ToolSidebarWidget();
		sidebar.AddTitle( "Grass Brush", "brush" );
		sidebar.MinimumWidth = 300;

		{
			var group = sidebar.AddGroup( "Brush" );
			var so = BrushSettings.GetSerialized();
			group.Add( ControlSheet.CreateRow( so.GetProperty( nameof( BrushSettings.Size ) ) ) );
			group.Add( ControlSheet.CreateRow( so.GetProperty( nameof( BrushSettings.Opacity ) ) ) );
		}

		{
			var group = sidebar.AddGroup( "Actions" );

			var clear = new Button( "Clear All Grass", "delete_sweep" );
			clear.ToolTip = "Remove every painted cell from the target Grass Renderer";
			clear.Clicked += () =>
			{
				var target = ResolveTarget();
				if ( !target.IsValid() || target.Storage is null )
					return;

				// Wiping the density map throws away every stroke and there is no undo for it,
				// so this one gets a confirmation.
				Dialog.AskConfirm(
					() =>
					{
						target.Storage.ClearAll();
						target.MarkDirty();
					},
					"Are you sure you want to delete all grass? This action cannot be undone.",
					"Delete All Grass",
					"Delete",
					"Cancel" );
			};
			group.Add( clear );
		}

		// Soaks up the leftover height. Without it the column spreads the groups out to fill the
		// panel instead of stacking them at the top.
		sidebar.Layout.AddStretchCell();

		return sidebar;
	}

	public override void OnUpdate()
	{
		_erasing = Gizmo.IsCtrlPressed;

		DrawBrushPreview();

		Gizmo.Hitbox.BBox( BBox.FromPositionAndSize( Vector3.Zero, 999999 ) );

		if ( Gizmo.IsLeftMouseDown )
		{
			if ( !_dragging )
			{
				_dragging = true;
				_lastPaintPosition = Vector3.Zero;
			}

			OnPaintUpdate();
		}
		else if ( _dragging )
		{
			_dragging = false;
			_lastPaintPosition = Vector3.Zero;

			if ( _painted )
			{
				ResolveTarget()?.MarkDirty();
				_painted = false;
			}
		}
	}

	/// <summary>
	/// Uses the selected renderer if there is one, otherwise the only one in the scene. Creating
	/// it implicitly would leave stray components around every time someone opens the tool.
	/// </summary>
	private GrassRenderer ResolveTarget()
	{
		var selected = Selection
			.OfType<GameObject>()
			.Select( go => go.Components.Get<GrassRenderer>( FindMode.EnabledInSelfAndDescendants ) )
			.FirstOrDefault( r => r.IsValid() );

		if ( selected.IsValid() )
		{
			_target = selected;
			return _target;
		}

		if ( _target.IsValid() )
			return _target;

		_target = Scene.GetAllComponents<GrassRenderer>().FirstOrDefault();
		return _target;
	}

	private void OnPaintUpdate()
	{
		var target = ResolveTarget();
		if ( !target.IsValid() || target.Storage is null )
			return;

		var cursor = TraceCursor();
		if ( !cursor.Hit )
			return;

		if ( _lastPaintPosition != Vector3.Zero &&
			 Vector3.DistanceBetween( cursor.HitPosition, _lastPaintPosition ) < PaintStepDistance )
			return;

		_lastPaintPosition = cursor.HitPosition;

		var radius = (float)BrushSettings.Size;
		var strength = BrushSettings.Opacity;

		if ( _erasing )
		{
			target.Storage.Erase( cursor.HitPosition, radius, strength );
			_painted = true;
			return;
		}

		PaintCells( target, cursor.HitPosition, radius, strength );
		_painted = true;
	}

	/// <summary>
	/// Walks every density cell the brush touches and traces straight down onto the world to bake
	/// the surface height and normal. Blades then follow whatever they were painted onto.
	/// </summary>
	private void PaintCells( GrassRenderer target, Vector3 center, float radius, float strength )
	{
		var radiusSq = radius * radius;

		var minX = (int)MathF.Floor( (center.x - radius) / GrassStorage.CellSize );
		var maxX = (int)MathF.Floor( (center.x + radius) / GrassStorage.CellSize );
		var minY = (int)MathF.Floor( (center.y - radius) / GrassStorage.CellSize );
		var maxY = (int)MathF.Floor( (center.y + radius) / GrassStorage.CellSize );

		// Enough headroom to find the surface from above without punching through overhangs the
		// brush was never aimed at.
		var traceHeight = radius + 512.0f;

		for ( var cy = minY; cy <= maxY; cy++ )
		{
			for ( var cx = minX; cx <= maxX; cx++ )
			{
				var wx = (cx + 0.5f) * GrassStorage.CellSize;
				var wy = (cy + 0.5f) * GrassStorage.CellSize;

				var dx = wx - center.x;
				var dy = wy - center.y;
				var distSq = dx * dx + dy * dy;
				if ( distSq > radiusSq )
					continue;

				var from = new Vector3( wx, wy, center.z + traceHeight );
				var to = new Vector3( wx, wy, center.z - traceHeight );

				var tr = Scene.Trace.Ray( from, to )
					.UseRenderMeshes( true )
					.WithTag( "solid" )
					.Run();

				if ( !tr.Hit )
					continue;

				// Soft edge, so overlapping strokes build up smoothly instead of leaving a disc.
				var falloff = 1.0f - MathF.Sqrt( distSq ) / radius;
				var added = strength * MathF.Pow( falloff, 0.5f );

				var existing = target.Storage.GetCell( wx, wy ).Density;
				var density = Math.Clamp( existing + added, 0.0f, 1.0f );

				target.Storage.SetCell( wx, wy, density, tr.HitPosition.z, tr.Normal );
			}
		}
	}

	private SceneTraceResult TraceCursor() =>
		Scene.Trace.Ray( Gizmo.CurrentRay, 100000 )
			.UseRenderMeshes( true )
			.WithTag( "solid" )
			.Run();

	private void DrawBrushPreview()
	{
		var tr = TraceCursor();
		if ( !tr.Hit )
			return;

		using ( Gizmo.Scope( "GrassBrush" ) )
		{
			Gizmo.Draw.Color = _erasing
				? Color.FromBytes( 250, 150, 150 )
				: Color.FromBytes( 150, 250, 160 );

			Gizmo.Draw.LineCircle( tr.HitPosition + tr.Normal * 1.0f, tr.Normal, BrushSettings.Size );
			Gizmo.Draw.LineCircle( tr.HitPosition + tr.Normal * 1.0f, tr.Normal, BrushSettings.Size * 0.5f );
		}
	}
}