Editor/TerrainLineTool.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Editor.TerrainEditor;
using Sandbox;
namespace TerrainLines;
[Title( "Lines" ), Icon( "route" ), Alias( "tools.terrain.lines" )]
[Description( "Draw terrain lines. Drag to draw; click to extend. Edit Points adjusts the preview. Apply commits, Cancel restores." )]
public sealed partial class TerrainLineTool : EditorTool
{
internal LineSettings Settings { get; set; } = new();
internal IReadOnlyList<Vector3> Points => _points;
private readonly List<Vector3> _points = [];
private readonly List<bool> _corners = [];
private IReadOnlyList<Vector3> _path = [];
private LineEdit _edit;
private LineHistory _history;
private sealed record PointState( Vector3[] Points, bool[] Corners );
private PointState _gestureBefore;
private bool _drawing, _dirty, _straightGesture, _editingPoints;
private int _settingsHash, _movingPoint = -1;
private double _nextPreview;
private Label _status;
private Button _apply, _cancel, _editMode;
private Terrain _sidebarTerrain;
private LineMaterialPicker _surfaceMaterials, _shoulderMaterials;
private Widget _shapingSettings, _paintingSettings;
private string _message = "Select a terrain, then drag to draw.";
public override void OnEnabled()
{
_settingsHash = Settings.Fingerprint;
// Native brush tools leave their parent's preview object alive between subtool switches.
if ( Manager.CurrentTool is TerrainEditorTool parent ) parent.OnDisabled();
}
public override Widget CreateShortcutsWidget() => new LineShortcuts( this );
private sealed class LineShortcuts : Widget
{
private readonly TerrainLineTool _tool;
internal LineShortcuts( TerrainLineTool tool ) => _tool = tool;
[Shortcut( "terrain-lines.apply", "enter", typeof( SceneViewWidget ) )]
private void ApplyLine() => _tool.Apply();
[Shortcut( "terrain-lines.cancel", "ESC", typeof( SceneViewWidget ) )]
private void CancelLine() => _tool.Cancel();
}
public override void OnDisabled() => Cancel();
public override void OnSelectionChanged()
{
if ( _edit is not null && GetSelectedComponent<Terrain>() != _edit.Terrain ) Cancel();
}
public override Widget CreateToolSidebar()
{
var sidebar = new ToolSidebarWidget();
sidebar.AddTitle( "Terrain Lines", "route" );
_sidebarTerrain = GetSelectedComponent<Terrain>();
AddHeading( sidebar.Layout, "Brush" );
AddControls( sidebar.Layout, nameof( Settings.PathWidth ), nameof( Settings.ShoulderWidth ) );
sidebar.Layout.AddSpacingCell( 12 );
AddControls( sidebar.Layout, nameof( Settings.ShapeTerrain ) );
_shapingSettings = sidebar.Layout.Add( new Widget( sidebar ) { Layout = Layout.Column() } );
AddControls( _shapingSettings.Layout, nameof( Settings.EvenRamp ), nameof( Settings.SlopeLimit ), nameof( Settings.Elevation ) );
var curve = new ComboBox();
foreach ( var (value, label) in new[] { (ShoulderCurve.Smooth, "Smooth"), (ShoulderCurve.Linear, "Linear"), (ShoulderCurve.RoundIn, "Round In"), (ShoulderCurve.RoundOut, "Round Out") } )
AddCurveItem( curve, value, label );
AddControlRow( _shapingSettings.Layout, "Shoulder Curve", curve );
sidebar.Layout.AddSpacingCell( 12 );
AddControls( sidebar.Layout, nameof( Settings.PaintMaterials ) );
_paintingSettings = sidebar.Layout.Add( new Widget( sidebar ) { Layout = Layout.Column() } );
var painting = _paintingSettings.Layout;
AddControls( painting, nameof( Settings.PaintStrength ) );
if ( _sidebarTerrain.IsValid() && _sidebarTerrain.Storage is not null )
{
painting.Add( new Label( "Surface" ) );
_surfaceMaterials = painting.Add( new LineMaterialPicker( _paintingSettings, _sidebarTerrain, false, GetSurfaceMaterial, SetSurfaceMaterial ) );
painting.AddSpacingCell( 8 );
painting.Add( new Label( "Shoulder" ) );
_shoulderMaterials = painting.Add( new LineMaterialPicker( _paintingSettings, _sidebarTerrain, true, GetShoulderMaterial, SetShoulderMaterial ) );
}
else painting.Add( new Label( "Select a terrain to choose materials." ) { WordWrap = true } );
RefreshSettingsVisibility();
AddHeading( sidebar.Layout, "Active Line" );
var actions = sidebar.Layout;
_editMode = actions.Add( new Button( "Edit Points" ) );
_editMode.Clicked = TogglePointEditing;
var row = actions.AddRow();
_apply = row.Add( new Button( "Apply" ) );
_apply.Clicked = Apply;
_cancel = row.Add( new Button( "Cancel" ) );
_cancel.Clicked = Cancel;
_status = actions.Add( new Label( _message ) { WordWrap = true } );
sidebar.Layout.Add( new Label( "Drag: freehand\nClick: extend straight\nShift + drag: straight segment\nEdit Points: drag points over terrain\nUndo/Redo: preview gestures\nEnter: apply Escape: cancel" ) { WordWrap = true } );
sidebar.Layout.AddStretchCell();
RefreshStatus();
return sidebar;
}
private static int GetSurfaceMaterial() => PaintTextureTool.SplatChannel;
private static void SetSurfaceMaterial( int index ) => PaintTextureTool.SplatChannel = index;
private int GetShoulderMaterial() => Settings.ShoulderMaterial;
private void SetShoulderMaterial( int index ) => Settings.ShoulderMaterial = index;
private void TogglePointEditing() { EndGesture(); _editingPoints = !_editingPoints; RefreshStatus(); }
private void AddCurveItem( ComboBox combo, ShoulderCurve value, string label )
=> combo.AddItem( label, onSelected: () => Settings.ShoulderCurve = value, selected: Settings.ShoulderCurve == value );
private static void AddHeading( Layout layout, string title )
{
layout.AddSpacingCell( 12 );
layout.Add( new Label.Header( title ) );
}
private static void AddControlRow( Layout layout, string title, Widget control, string tooltip = null )
{
var container = layout.Add( new Widget { FixedHeight = Theme.RowHeight, Layout = Layout.Row() } );
var row = container.Layout;
row.Spacing = 6;
row.Add( new Label( title ) { FixedWidth = 108, MinimumHeight = Theme.RowHeight, ToolTip = tooltip } );
control.ToolTip = tooltip;
control.FixedHeight = Theme.RowHeight;
if ( control is BoolControlWidget )
{
control.FixedWidth = Theme.RowHeight;
row.Add( control );
row.AddStretchCell();
}
else
{
control.HorizontalSizeMode = SizeMode.Flexible;
row.Add( control, 1 );
}
}
private void AddControls( Layout layout, params string[] names )
{
var serialized = Settings.GetSerialized();
foreach ( var name in names )
{
var property = serialized.GetProperty( name );
var control = ControlWidget.Create( property );
if ( property.TryGetAttribute<RangeAttribute>( out _ ) )
{
layout.Add( new Label( property.DisplayName ) { FixedHeight = Theme.RowHeight, ToolTip = property.Description } );
control.FixedHeight = Theme.RowHeight;
control.ToolTip = property.Description;
layout.Add( control );
}
else AddControlRow( layout, property.DisplayName, control, property.Description );
}
}
internal void RefreshSettingsVisibility()
{
if ( _shapingSettings.IsValid() ) _shapingSettings.Visible = Settings.ShapeTerrain;
if ( _paintingSettings.IsValid() ) _paintingSettings.Visible = Settings.PaintMaterials;
}
internal void SyncSurfaceMaterial()
{
Settings.SurfaceMaterial = PaintTextureTool.SplatChannel;
if ( _surfaceMaterials.IsValid() ) _surfaceMaterials.Refresh();
if ( _shoulderMaterials.IsValid() ) _shoulderMaterials.Refresh();
}
public override void OnUpdate()
{
RefreshSettingsVisibility();
var terrain = GetSelectedComponent<Terrain>();
if ( _edit is not null && (!_edit.IsCurrent || terrain != _edit.Terrain || Scene.IsEditor == false) )
{
Cancel();
_message = "Terrain changed; the preview was cancelled.";
}
if ( !Usable( terrain ) )
{
_message = "Select one native terrain with a positive uniform scale.";
RefreshStatus();
return;
}
SyncSurfaceMaterial();
if ( Settings.Fingerprint != _settingsHash )
{
_settingsHash = Settings.Fingerprint;
_dirty = true;
}
bool hitTerrain = terrain.RayIntersects( Gizmo.CurrentRay, Gizmo.RayDepth, out var hit );
if ( _editingPoints ) UpdatePointEditing( terrain, hitTerrain, hit );
else if ( Gizmo.HasMouseFocus && !Gizmo.IsAltPressed && !Gizmo.IsRightMouseDown )
{
if ( Gizmo.WasLeftMousePressed && hitTerrain )
{
BeginGesture( terrain );
_drawing = true;
_straightGesture = Gizmo.IsShiftPressed;
if ( !_straightGesture || _points.Count == 0 ) AddPoint( hit, true );
}
if ( _drawing && Gizmo.IsLeftMouseDown && hitTerrain && !_straightGesture ) AddPoint( hit, false );
if ( _drawing && Gizmo.WasLeftMouseReleased )
{
if ( hitTerrain ) AddPoint( hit, true );
EndGesture();
}
}
if ( _drawing && !Gizmo.IsLeftMouseDown ) EndGesture();
if ( _dirty && _edit is not null && RealTime.Now >= _nextPreview ) RebuildPreview();
DrawGuide( terrain, hitTerrain, hit );
RefreshStatus();
}
internal static bool Usable( Terrain terrain )
{
if ( !terrain.IsValid() || terrain.Storage is null || !terrain.Scene.IsEditor ) return false;
var s = terrain.WorldScale;
return float.IsFinite( s.x ) && s.x > 0 && Math.Abs( s.x - s.y ) < 0.0001f && Math.Abs( s.x - s.z ) < 0.0001f;
}
internal void Begin( Terrain terrain )
{
if ( _edit is not null ) return;
if ( !Usable( terrain ) ) throw new InvalidOperationException( "Select an editable terrain." );
_edit = new LineEdit( terrain );
_history = new LineHistory( Manager.CurrentSession );
_message = "Draw or extend the active line.";
}
internal void BeginGesture( Terrain terrain )
{
Begin( terrain );
_gestureBefore = new PointState( _points.ToArray(), _corners.ToArray() );
}
internal void EndGesture( string name = "Extend terrain line" )
{
_drawing = false;
if ( _gestureBefore is null ) return;
var before = _gestureBefore;
var after = new PointState( _points.ToArray(), _corners.ToArray() );
_gestureBefore = null;
if ( !before.Points.SequenceEqual( after.Points ) || !before.Corners.SequenceEqual( after.Corners ) )
_history.Push( name, () => RestorePoints( before ), () => RestorePoints( after ) );
}
private void RestorePoints( PointState state )
{
if ( _edit is null || !_edit.IsCurrent ) return;
_points.Clear();
_points.AddRange( state.Points );
_corners.Clear();
_corners.AddRange( state.Corners );
_gestureBefore = null;
_drawing = false;
_movingPoint = -1;
RebuildPreview();
RefreshStatus();
}
internal void AddPoint( Vector3 point, bool endpoint )
{
if ( _edit is null || !_edit.IsCurrent ) return;
var storage = _edit.Terrain.Storage;
point.z = LineRaster.SampleHeight( storage, point.x, point.y );
if ( _points.Count > 0 )
{
float length = LineRaster.Distance2D( _points[^1], point );
float spacing = Math.Max( storage.TerrainSize / storage.Resolution * 0.5f, Math.Min( Settings.PathWidth / 8, 32 ) / _edit.Terrain.WorldScale.x );
if ( length < (endpoint ? 0.001f : spacing) )
{
if ( endpoint ) { _corners[^1] = true; _dirty = true; }
return;
}
}
// Retain authoring points separately from the smoothed freehand path.
_points.Add( point );
_corners.Add( endpoint );
_dirty = true;
}
internal void MovePoint( int index, Vector3 point )
{
if ( _edit is null || !_edit.IsCurrent || index < 0 || index >= _points.Count ) return;
var storage = _edit.Terrain.Storage;
point.z = LineRaster.SampleHeight( storage, point.x, point.y );
_points[index] = point;
_dirty = true;
}
internal void RebuildPreview()
{
_dirty = false;
_nextPreview = RealTime.Now + 0.08;
try
{
_path = LinePath.Build( _points, _corners, _edit.Terrain.Storage.TerrainSize / _edit.Terrain.Storage.Resolution );
_edit.Preview( _path, Settings );
_message = _points.Count < 2 ? "Extend the line to preview." : _edit.HasChanges ? "Preview ready. Apply to keep this line." : "This line makes no terrain changes.";
}
catch ( Exception e )
{
Cancel();
_message = e.Message;
Log.Warning( $"Terrain Lines: {e.Message}" );
}
}
public void Apply()
{
if ( _edit is null ) return;
EndGesture();
SyncSurfaceMaterial();
RebuildPreview();
if ( _edit is null ) return;
try
{
_history.Clear();
bool applied = _edit.Apply( Manager.CurrentSession );
Cancel();
_message = applied ? "Line applied. Draw the next line." : "No changes to apply.";
}
catch ( Exception e )
{
Cancel();
_message = e.Message;
Log.Warning( $"Terrain Lines: {e.Message}" );
}
RefreshStatus();
}
public void Cancel()
{
_history?.Clear();
_history = null;
_edit?.Dispose();
_edit = null;
_points.Clear();
_corners.Clear();
_path = [];
_gestureBefore = null;
_drawing = _dirty = _editingPoints = false;
_movingPoint = -1;
_message = "Drag to draw a new line.";
RefreshStatus();
}
private void RefreshStatus()
{
if ( _status.IsValid() ) _status.Text = _message;
if ( _apply.IsValid() ) _apply.Enabled = _points.Count > 1 && (_edit?.HasChanges ?? false);
if ( _cancel.IsValid() ) _cancel.Enabled = _edit is not null;
if ( _editMode.IsValid() )
{
_editMode.Enabled = _points.Count > 0;
_editMode.Text = _editingPoints ? "Continue Drawing" : "Edit Points";
}
}
}