Editor/SubTerrainTool.cs

Editor tool for SubTerrain. Defines the main SubTerrainTool which manages brush settings, preview, sidebar UI and status, and several subtools (raise/lower, smooth, flatten, noise, hole, slope, path, set-height) plus UI widgets for brush preview, brush list and keyboard shortcuts.

File AccessNetworking
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
using Sandbox.UI;
using Editor.TerrainEditor;

namespace Editor.SubTerrain;

/// <summary>
/// SubTerrain editor tool. Extends the built-in terrain workflow with larger brushes,
/// slope/path/set-height sculpt modes, and paint texture support.
/// </summary>
[EditorTool]
[Title( "SubTerrain" )]
[Icon( "terrain" )]
[Alias( "subterrain" )]
[Group( "Scene" )]
public class SubTerrainTool : EditorTool
{
	public SubTerrainBrushSettings BrushSettings { get; private set; } = new();

	BrushPreviewSceneObject _previewObject;
	SubTerrainStatusWidget _statusWidget;
	Widget _opacityWidget;

	public bool StatusValid { get; private set; }
	public Terrain StatusTerrain { get; private set; }
	public Vector3 StatusHitLocal { get; private set; }

	public static BrushList BrushList => TerrainEditorTool.BrushList;

	public Texture SelectedBrushTexture => BrushList?.Selected?.Texture;

	public void UpdateStatus( Terrain terrain, Vector3 hitLocal )
	{
		StatusValid = true;
		StatusTerrain = terrain;
		StatusHitLocal = hitLocal;
	}

	public void ClearStatus()
	{
		StatusValid = false;
		StatusTerrain = default;
	}

	public override IEnumerable<EditorTool> GetSubtools()
	{
		yield return new SubRaiseLowerTool( this );
		yield return new SubSmoothTool( this );
		yield return new SubFlattenTool( this );
		yield return new SubTerrainPaintTool( this );
		yield return new SubSlopeTool( this );
		yield return new SubPathTool( this );
		yield return new SubSetHeightTool( this );
		yield return new SubNoiseTool( this );
		yield return new SubHoleTool( this );
	}

	public override void OnEnabled()
	{
		AllowGameObjectSelection = false;

		var selectedTerrain = GetSelectedComponent<Terrain>();
		if ( !selectedTerrain.IsValid() )
		{
			Selection.Clear();
			var first = Scene.GetAllComponents<Terrain>().FirstOrDefault();
			if ( first.IsValid() ) Selection.Add( first.GameObject );
		}
	}

	public override void OnDisabled()
	{
		_previewObject?.Delete();
		_previewObject = null;
		ClearStatus();
	}

	public override Widget CreateShortcutsWidget() => new SubTerrainToolShortcutsWidget();

	public void DrawBrushPreview( Transform transform )
	{
		var brush = BrushList?.Selected?.Texture;
		if ( brush is null )
			return;

		_previewObject ??= new BrushPreviewSceneObject( Gizmo.World );

		var color = Color.FromBytes( 150, 150, 250 );
		if ( Application.KeyboardModifiers.HasFlag( KeyboardModifiers.Ctrl ) )
			color = color.AdjustHue( 90 );

		color.a = BrushSettings.Opacity;

		_previewObject.RenderLayer = SceneRenderLayer.OverlayWithDepth;
		_previewObject.Bounds = BBox.FromPositionAndSize( 0, float.MaxValue );
		_previewObject.Transform = transform;
		_previewObject.Radius = BrushSettings.Size;
		_previewObject.Texture = brush;
		_previewObject.Color = color;
	}

	public override Widget CreateToolSidebar()
	{
		var sidebar = new ToolSidebarWidget();
		sidebar.AddTitle( "SubTerrain", "terrain" );

		{
			var group = sidebar.AddGroup( "Brush Type" );
			group.Add( new SubTerrainBrushPreviewWidget( sidebar ) );
		}

		{
			var group = sidebar.AddGroup( "Brush Properties" );

			var so = BrushSettings.GetSerialized();
			var sheet = new ControlSheet();
			sheet.AddRow( so.GetProperty( nameof( SubTerrainBrushSettings.Size ) ) );
			_opacityWidget = sheet.AddRow( so.GetProperty( nameof( SubTerrainBrushSettings.Opacity ) ) );
			sheet.AddRow( so.GetProperty( nameof( SubTerrainBrushSettings.TargetHeight ) ) );
			group.Add( sheet );
		}

		{
			var group = sidebar.AddGroup( "Active Layer" );

			var tabs = new TabWidget( sidebar );
			tabs.MaximumHeight = 40;
			tabs.StateCookie = "SubTerrainTool.Tabs";

			tabs.AddPage( "Base", "layers", new Widget() );
			tabs.AddPage( "Overlay", "landscape", new Widget() );

			var tabBar = tabs.Children.OfType<SegmentedControl>().FirstOrDefault();
			if ( tabBar is not null )
			{
				tabBar.OnSelectedChanged += selectedName =>
				{
					SubTerrainPaintTool.ActiveLayer = selectedName == "Base" ? SubTerrainPaintLayer.Base : SubTerrainPaintLayer.Overlay;
					UpdateOpacityState();
				};
			}

			group.Add( tabs );
		}

		UpdateOpacityState();

		if ( !GetSelectedComponent<Terrain>().IsValid() )
		{
			var group = sidebar.AddGroup( "No Terrain" );
			group.Add( new Label( "Select a GameObject with a Terrain component." ) );
		}

		{
			var group = sidebar.AddGroup( "Status" );
			_statusWidget = new SubTerrainStatusWidget( this, sidebar );
			group.Add( _statusWidget );
		}

		sidebar.Layout.AddStretchCell();
		return sidebar;
	}

	void UpdateOpacityState()
	{
		if ( _opacityWidget is null )
			return;

		var isOverlay = SubTerrainPaintTool.ActiveLayer == SubTerrainPaintLayer.Overlay;
		var tip = isOverlay
			? "Controls blend strength when painting overlay layers"
			: "Opacity is only supported when painting overlay layers";

		_opacityWidget.Enabled = isOverlay;
		_opacityWidget.ToolTip = tip;
	}
}

[Title( "Raise / Lower" )]
[Icon( "height" )]
[Alias( "subterrain_raise_lower" )]
[Group( "1" )]
[Order( 0 )]
public class SubRaiseLowerTool : SubTerrainBaseBrushTool
{
	public SubRaiseLowerTool( SubTerrainTool parent ) : base( parent )
	{
		Mode = SubTerrainSculptMode.RaiseLower;
		AllowBrushInvert = true;
	}
}

[Title( "Flatten" )]
[Icon( "trending_flat" )]
[Alias( "subterrain_flatten" )]
[Group( "1" )]
[Order( 2 )]
public class SubFlattenTool : SubTerrainBaseBrushTool
{
	public SubFlattenTool( SubTerrainTool parent ) : base( parent )
	{
		Mode = SubTerrainSculptMode.Flatten;
	}

	public override bool GetHitPosition( Terrain terrain, out Vector3 position )
	{
		if ( _dragging )
		{
			var tx = terrain.WorldTransform;
			var hit = StrokePlane.Trace( Gizmo.CurrentRay, true );
			position = tx.PointToLocal( hit.Value );
			return hit.HasValue;
		}

		return base.GetHitPosition( terrain, out position );
	}
}

[Title( "Smooth" )]
[Icon( "rounded_corner" )]
[Alias( "subterrain_smooth" )]
[Group( "1" )]
[Order( 1 )]
public class SubSmoothTool : SubTerrainBaseBrushTool
{
	public SubSmoothTool( SubTerrainTool parent ) : base( parent )
	{
		Mode = SubTerrainSculptMode.Smooth;
	}

	public override bool GetHitPosition( Terrain terrain, out Vector3 position )
	{
		if ( _dragging )
		{
			var tx = terrain.WorldTransform;
			var hit = StrokePlane.Trace( Gizmo.CurrentRay, true );
			position = tx.PointToLocal( hit.Value );
			return hit.HasValue;
		}

		return base.GetHitPosition( terrain, out position );
	}
}

[Title( "Noise" )]
[Icon( "grain" )]
[Alias( "subterrain_noise" )]
[Group( "1" )]
[Order( 7 )]
public class SubNoiseTool : SubTerrainBaseBrushTool
{
	public SubNoiseTool( SubTerrainTool parent ) : base( parent )
	{
		Mode = SubTerrainSculptMode.Noise;
		AllowBrushInvert = true;
	}
}

[Title( "Hole" )]
[Icon( "trip_origin" )]
[Alias( "subterrain_hole" )]
[Group( "1" )]
[Order( 8 )]
public class SubHoleTool : SubTerrainBaseBrushTool
{
	public SubHoleTool( SubTerrainTool parent ) : base( parent )
	{
		Mode = SubTerrainSculptMode.Hole;
		AllowBrushInvert = true;
	}
}

internal class SubTerrainBrushPreviewWidget : Widget
{
	public SubTerrainBrushPreviewWidget( Widget parent ) : base( parent )
	{
		MinimumSize = new( 32, 32 );
		MaximumSize = new( 48, 48 );
		Cursor = CursorShape.Finger;
	}

	protected override void OnPaint()
	{
		base.OnPaint();

		Paint.Antialiasing = true;
		Paint.ClearPen();
		Paint.DrawRect( LocalRect );

		var pixmap = SubTerrainTool.BrushList?.Selected?.Pixmap;
		if ( pixmap != null )
		{
			Paint.Draw( LocalRect.Contain( pixmap.Size ), pixmap );
		}
	}

	protected override void OnMouseClick( MouseEvent e )
	{
		var popup = new PopupWidget( null );
		popup.Position = Application.CursorPosition;
		popup.Visible = true;
		popup.Layout = Layout.Column();
		popup.Layout.Margin = 10;
		popup.MaximumSize = new Vector2( 300, 200 );

		var list = new SubTerrainBrushListWidget();
		list.BrushSelected += () =>
		{
			popup.Close();
			Update();
		};
		popup.Layout.Add( list );
	}

	[EditorEvent.Frame]
	public void UpdatePreview()
	{
		Update();
	}
}

internal class SubTerrainBrushListWidget : Widget
{
	public Action BrushSelected { get; set; }

	public SubTerrainBrushListWidget() : base( null )
	{
		ListView list = new();
		list.SetItems( SubTerrainTool.BrushList.Brushes.Cast<object>() );
		list.ItemSize = new Vector2( 40, 40 );
		list.ItemAlign = Sandbox.UI.Align.SpaceBetween;
		list.OnPaintOverride += () => PaintListBackground( list );
		list.ItemPaint = PaintBrushItem;
		list.ItemSelected = item =>
		{
			if ( item is Brush brush )
			{
				SubTerrainTool.BrushList.Selected = brush;
				BrushSelected?.Invoke();
			}
		};
		list.SelectItem( SubTerrainTool.BrushList.Selected );

		Layout = Layout.Column();

		var label = new Label( "Brushes" );
		label.SetStyles( "font-weight: bold" );
		Layout.Add( label );
		Layout.AddSpacingCell( 8 );
		Layout.Add( list );
	}

	private void PaintBrushItem( VirtualWidget widget )
	{
		var brush = (Brush)widget.Object;

		Paint.Antialiasing = true;
		Paint.TextAntialiasing = true;

		if ( widget.Hovered || widget.Selected )
		{
			Paint.ClearPen();
			Paint.SetBrush( widget.Selected ? Theme.Primary : Color.White.WithAlpha( 0.1f ) );
			Paint.DrawRect( widget.Rect.Grow( 2 ), 3 );
		}

		Paint.Draw( widget.Rect, brush.Pixmap );
	}

	private bool PaintListBackground( Widget widget )
	{
		Paint.ClearPen();
		Paint.SetBrush( Theme.ControlBackground );
		Paint.DrawRect( widget.LocalRect );
		return false;
	}
}

internal class SubTerrainToolShortcutsWidget : Widget
{
	[Shortcut( "tools.subterrain.raise-lower", "1", typeof( SceneViewWidget ) )]
	public void ActivateRaiseLower() => EditorToolManager.SetSubTool( nameof( SubRaiseLowerTool ) );

	[Shortcut( "tools.subterrain.smooth", "2", typeof( SceneViewWidget ) )]
	public void ActivateSmooth() => EditorToolManager.SetSubTool( nameof( SubSmoothTool ) );

	[Shortcut( "tools.subterrain.flatten", "3", typeof( SceneViewWidget ) )]
	public void ActivateFlatten() => EditorToolManager.SetSubTool( nameof( SubFlattenTool ) );

	[Shortcut( "tools.subterrain.paint", "4", typeof( SceneViewWidget ) )]
	public void ActivatePaint() => EditorToolManager.SetSubTool( nameof( SubTerrainPaintTool ) );

	[Shortcut( "tools.subterrain.slope", "5", typeof( SceneViewWidget ) )]
	public void ActivateSlope() => EditorToolManager.SetSubTool( nameof( SubSlopeTool ) );

	[Shortcut( "tools.subterrain.path", "6", typeof( SceneViewWidget ) )]
	public void ActivatePath() => EditorToolManager.SetSubTool( nameof( SubPathTool ) );

	[Shortcut( "tools.subterrain.set-height", "7", typeof( SceneViewWidget ) )]
	public void ActivateSetHeight() => EditorToolManager.SetSubTool( nameof( SubSetHeightTool ) );

	[Shortcut( "tools.subterrain.noise", "8", typeof( SceneViewWidget ) )]
	public void ActivateNoise() => EditorToolManager.SetSubTool( nameof( SubNoiseTool ) );

	[Shortcut( "tools.subterrain.hole", "9", typeof( SceneViewWidget ) )]
	public void ActivateHole() => EditorToolManager.SetSubTool( nameof( SubHoleTool ) );
}