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