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