Editor/FloraTool.cs

Editor tool for painting and erasing flora instances into a FloraRenderer. It shows a brush UI, samples the scene under the cursor, scatters entries from the renderer definition respecting spacing and slope, traces to geometry, and writes transforms into the renderer storage.

File AccessNative Interop
using System;
using System.Linq;
using Editor;
using Editor.TerrainEditor;
using Sandbox;

namespace RedSnail.FloraTool.Editor;

/// <summary>
/// Paints flora onto any surface. Each stroke scatters entries from the target renderer's
/// definition, honouring its spacing and slope rules, and bakes the resulting transform into the
/// renderer's storage. Hold Ctrl to erase.
/// </summary>
[EditorTool( "flora" )]
[Title( "Flora" )]
[Icon( "park" )]
public sealed class FloraPaintTool : EditorTool
{
	public BrushSettings BrushSettings { get; private set; } = new();

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

	private readonly Random _random = new();

	// The brush has to travel a fraction of its own radius before depositing again, or holding the
	// mouse still would keep hammering the same spot with traces.
	private float PaintStepDistance => BrushSettings.Size * 0.35f;

	public FloraPaintTool()
	{
		RebuildSidebarOnSelectionChange = false;
	}

	public override Widget CreateToolSidebar()
	{
		var sidebar = new ToolSidebarWidget();
		sidebar.AddTitle( "Flora 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 Flora", "delete_sweep" );
			clear.ToolTip = "Remove every painted instance from the target Flora Renderer";
			clear.Clicked += () =>
			{
				var target = ResolveTarget();
				if ( !target.IsValid() || target.Storage is null )
					return;

				// Wiping the whole painted set has no undo, so this one asks first.
				Dialog.AskConfirm(
					() =>
					{
						target.Storage.ClearAll();
						target.MarkDirty();
					},
					"Are you sure you want to delete all flora? This action cannot be undone.",
					"Delete All Flora",
					"Delete",
					"Cancel" );
			};
			group.Add( clear );
		}

		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 when there is one, otherwise the last used, otherwise the only one
	/// in the scene. Creating one implicitly would leave stray components behind every time someone
	/// opens the tool.
	/// </summary>
	private FloraRenderer ResolveTarget()
	{
		var selected = Selection
			.OfType<GameObject>()
			.Select( go => go.Components.Get<FloraRenderer>( FindMode.EnabledInSelfAndDescendants ) )
			.FirstOrDefault( r => r.IsValid() );

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

		if ( _target.IsValid() )
			return _target;

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

	private void OnPaintUpdate()
	{
		var target = ResolveTarget();
		if ( !target.IsValid() || target.Storage is null || !target.Definition.IsValid() )
			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;

		if ( _erasing )
		{
			if ( target.Storage.Erase( cursor.HitPosition, radius ) > 0 )
				_painted = true;

			return;
		}

		Scatter( target, cursor.HitPosition, radius );
	}

	/// <summary>
	/// Attempts a number of placements inside the brush, tracing each down onto the world so flora
	/// sits on whatever geometry is there. Spacing and slope rules reject most of them, which is what
	/// keeps a stroke from turning into a solid wall of trees.
	/// </summary>
	private void Scatter( FloraRenderer target, Vector3 center, float radius )
	{
		var definition = target.Definition;
		var storage = target.Storage;

		var attempts = Math.Max( (int)(definition.PerStroke * BrushSettings.Opacity), 1 );
		var traceHeight = radius + 2048.0f;

		for ( var i = 0; i < attempts; i++ )
		{
			var entry = definition.PickEntry( _random );
			if ( entry is null )
				return;

			// Square-rooted radius keeps the scatter even across the disc instead of clustering in
			// the middle.
			var angle = _random.NextSingle() * MathF.Tau;
			var distance = MathF.Sqrt( _random.NextSingle() ) * radius;

			var x = center.x + MathF.Cos( angle ) * distance;
			var y = center.y + MathF.Sin( angle ) * distance;

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

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

			if ( !tr.Hit )
				continue;

			if ( tr.Normal.z < definition.SlopeLimit )
				continue;

			if ( !storage.IsClear( tr.HitPosition, definition.Spacing ) )
				continue;

			storage.AddInstance( entry.Model.ResourcePath, BuildPosition( entry, tr ),
				BuildRotation( entry, tr ), BuildScale( entry ) );

			_painted = true;
		}
	}

	private Vector3 BuildPosition( FloraEntry entry, SceneTraceResult tr ) =>
		entry.SinkDepth > 0.0f ? tr.HitPosition - tr.Normal * entry.SinkDepth : tr.HitPosition;

	private Rotation BuildRotation( FloraEntry entry, SceneTraceResult tr )
	{
		var rotation = entry.RandomYaw
			? Rotation.FromYaw( _random.NextSingle() * 360.0f )
			: Rotation.Identity;

		// Blend toward the surface normal. Right for rocks, usually wrong for trunks, so it is
		// authored per entry rather than applied wholesale.
		if ( entry.AlignToNormal > 0.0f )
		{
			var aligned = Rotation.LookAt( tr.Normal ) * Rotation.FromPitch( 90.0f );
			rotation = Rotation.Slerp( rotation, aligned * rotation, entry.AlignToNormal );
		}

		if ( entry.RandomTilt > 0.0f )
		{
			var tiltAngle = _random.NextSingle() * entry.RandomTilt;
			var tiltDirection = _random.NextSingle() * 360.0f;
			rotation *= Rotation.FromAxis( Rotation.FromYaw( tiltDirection ).Forward, tiltAngle );
		}

		return rotation;
	}

	private float BuildScale( FloraEntry entry )
	{
		var scale = entry.Scale;
		return MathX.Lerp( scale.Min, scale.Max, _random.NextSingle() );
	}

	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( "FloraBrush" ) )
		{
			Gizmo.Draw.Color = _erasing
				? Color.FromBytes( 250, 150, 150 )
				: Color.FromBytes( 160, 230, 150 );

			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 );
		}
	}
}