Base class for the editor's primitive builder tools. Handles placing and dragging path points on a plane, grid and vertex snapping, live mesh preview, measurements, undo while drawing, saving and reloading builder settings and records, and the tool sidebar. Also contains PathEditor, which samples points into straight, bent or spline paths and sweeps cross sections along them.
using System.Text.Json;
namespace AdvancedMappingTools;
/// <summary>
/// A placed point. <see cref="Bend"/> is where the curve to the next point passes through its middle.
/// </summary>
public record struct PathPoint( Vector3 Position, Vector3? Bend = null );
/// <summary>
/// Shared behaviour of all builders: placing and dragging points on a horizontal plane,
/// snapping, materials, the live preview, measurements, presets and undo while drawing.
/// </summary>
public abstract class BuilderEditor( PrimitiveTool tool ) : PrimitiveEditor( tool )
{
const string PlacementGroup = "Placement";
const string MaterialGroup = "Materials";
const string GeneratedKey = "__generator";
enum DragTarget { None, Point, Bend }
readonly List<PathPoint> _points = [];
float _planeHeight;
Model _preview;
Material _activeMaterial;
bool _dirty;
Vector3? _cursor;
SceneTrace _trace;
DragTarget _dragTarget;
int _dragIndex = -1;
PathPoint[] _dragBefore;
bool _dragMoved;
protected IReadOnlyList<PathPoint> Points => _points;
protected float PlaneHeight => _planeHeight;
protected bool Closed { get; private set; }
/// <summary>
/// True while building for the viewport, not for the scene.
/// </summary>
protected bool IsPreview { get; private set; }
protected abstract int MinPoints { get; }
protected virtual int MaxPoints => int.MaxValue;
protected virtual bool AlwaysClosed => false;
protected virtual bool CanClose => false;
protected virtual bool UsesBends => false;
protected virtual bool ShowBendHandles => true;
protected virtual bool ConnectPoints => true;
/// <summary>
/// Points of this builder may leave the drawing plane: Shift and drag moves one up or down.
/// </summary>
protected virtual bool AllowsHeight => false;
/// <summary>
/// How much higher each new point of such a builder lands than the one before it.
/// </summary>
protected virtual float HeightStep => 0.0f;
/// <summary>
/// True for a point that landed on something and keeps the height it found there.
/// </summary>
protected virtual bool KeepsHeight( Vector3 point ) => false;
/// <summary>
/// True for a setting that changes other settings. The sidebar then shows their new values.
/// </summary>
public virtual bool ChangesOtherSettings( string property ) => false;
protected virtual bool IsShapeValid => true;
protected virtual IEnumerable<string> Hints => [];
/// <summary>
/// Common options this builder has no use for. They stay out of its sidebar.
/// </summary>
public virtual IReadOnlySet<string> UnusedOptions { get; } = new HashSet<string>();
/// <summary>
/// True for a tool whose points snap onto finished objects, which is what stacking needs.
/// </summary>
protected virtual bool SnapsToLines => false;
/// <summary>
/// True for a tool that can connect to finished objects, which is what the switch is for.
/// </summary>
protected virtual bool Connects => SnapsToLines;
[Property, Group( "Placement" ), Title( "Connect To Finished" ), WideMode, Description( "On, new points snap to finished objects of this kind and join them. Off, the tool places freely and connects to nothing." )]
public virtual bool ConnectFinished { get; set; } = true;
bool? _hasOwnMaterials;
bool HasOwnMaterials => _hasOwnMaterials ??= ExtraSettings?.GetSerialized().Any( x => x.HasAttribute<PropertyAttribute>() && x.GroupName == MaterialGroup ) == true;
/// <summary>
/// What stays out of the sidebar of this builder.
/// </summary>
public IReadOnlySet<string> HiddenOptions
{
get
{
var hidden = new HashSet<string>( UnusedOptions );
if ( !SnapsToLines ) hidden.Add( nameof( PathEditor.StackOnTop ) );
if ( !Connects ) hidden.Add( nameof( ConnectFinished ) );
// A tool whose parts have their own material slots would show two Materials groups
// and two Trim rows. Left empty a slot falls back to the active material all the same.
if ( HasOwnMaterials ) hidden.UnionWith( [nameof( Surface ), nameof( Trim )] );
return hidden;
}
}
/// <summary>Generator context filters apply to the extra settings, not just the builder fields.</summary>
public IReadOnlySet<string> HiddenExtraOptions
{
get
{
var hidden = new HashSet<string>( HiddenOptions );
if ( ExtraSettings is GeneratorSettings settings && settings.UnusedOptions is not null )
hidden.UnionWith( settings.UnusedOptions );
return hidden;
}
}
/// <summary>
/// Create as soon as two points are placed and carry on from the last point.
/// </summary>
protected virtual bool PlacesPiecesInChain => false;
/// <summary>
/// This builder adds objects to the scene itself instead of giving back one mesh.
/// </summary>
protected virtual bool PlacesObjects => false;
protected virtual void PlaceObjects() { }
/// <summary>
/// What is already there and gets changed by placing. It comes back with the same undo.
/// </summary>
protected virtual IReadOnlyList<GameObject> ObjectsChangedByPlacing() => [];
/// <summary>
/// What is already there and goes away by placing. It comes back with the same undo.
/// </summary>
protected virtual IReadOnlyList<GameObject> ObjectsRemovedByPlacing() => [];
protected virtual void OnBuilt( MeshComponent component ) { }
/// <summary>
/// What to show while a builder that places objects itself is being drawn.
/// </summary>
protected virtual PolygonMesh BuildPreview() => default;
/// <summary>
/// A settings object of this builder, shown in the sidebar above the common settings.
/// </summary>
public virtual object ExtraSettings => null;
Plane Plane => new( new Vector3( 0.0f, 0.0f, _planeHeight ), Vector3.Up );
// An outline that is always closed takes more points after its third, it was never closed by hand.
bool IsFull => _points.Count >= MaxPoints || (Closed && !AlwaysClosed);
protected int SegmentCount => _points.Count < 2 ? 0 : Closed ? _points.Count : _points.Count - 1;
protected Material SurfaceMaterial => Surface ?? Tool.ActiveMaterial;
protected Material TrimMaterial => Trim ?? SurfaceMaterial;
public override bool CanBuild => _points.Count >= MinPoints && IsShapeValid;
readonly List<GameObject> _takenIn = [];
public override bool InProgress => _points.Count > 0;
[Property, Group( PlacementGroup ), WideMode, Description( "Which grid new points snap to. Hold Ctrl to toggle the editor grid." )]
public SnapMode Snap { get; set; } = SnapMode.Editor;
[Property, ShowIf( nameof( Snap ), SnapMode.Custom ), Group( PlacementGroup ), Title( "Grid Size" ), Range( 0.25f, 1024, slider: false ), WideMode]
public float CustomGridSize { get; set; } = 16.0f;
[Property, Advanced, Group( PlacementGroup ), Title( "Snap Angle" ), Range( 0, 90, slider: false ), WideMode, Description( "Locks the direction from the previous point to steps of this many degrees. Zero is free." )]
public float AngleStep { get; set; } = 0.0f;
[Property, Advanced, Group( PlacementGroup ), Title( "Dock To Vertices" ), WideMode, Description( "Pulls points onto the corners of meshes under the cursor, so new shapes join existing ones." )]
public bool DockToVertices { get; set; } = true;
[Property, Advanced, Group( PlacementGroup ), Title( "Dock Radius" ), Range( 1, 128, slider: false ), WideMode]
public float DockRadius { get; set; } = 12.0f;
[Property, Advanced, Group( PlacementGroup ), Title( "Floor Level" ), Range( -64, 64, slider: false ), WideMode, Description( "Raises newly started shapes by this many levels." )]
public int FloorLevel { get; set; } = 0;
[Property, Advanced, Group( PlacementGroup ), Title( "Level Height" ), Range( 1, 1024, slider: false ), WideMode]
public float LevelHeight { get; set; } = 128.0f;
[Property, Advanced, Group( PlacementGroup ), Title( "Live Preview" ), WideMode, Description( "Shows the finished shape at the cursor while drawing." )]
public bool LivePreview { get; set; } = true;
[Property, Advanced, Group( PlacementGroup ), Title( "Show Sizes" ), WideMode]
public bool ShowSizes { get; set; } = true;
[Property, Group( MaterialGroup ), WideMode, Description( "Main material. Empty uses the active material of the mesh tool." )]
public Material Surface { get; set; }
[Property, Group( MaterialGroup ), WideMode, Description( "Material for caps, edges and frames. Empty uses the main material." )]
public Material Trim { get; set; }
protected abstract void BuildMesh( MeshKit kit );
public override PolygonMesh Build()
{
if ( !CanBuild ) return default;
if ( !IsPreview )
{
TakeInFinished();
if ( ConnectFinished && Connects && SceneEditorSession.Active is not null ) JoinSides( SceneEditorSession.Active.Scene );
}
if ( PlacesObjects )
{
if ( IsPreview ) return SafePreview();
var chainFrom = PlacesPiecesInChain && _points.Count > 0 ? _points[^1] : default( PathPoint? );
if ( SceneEditorSession.Active is null )
{
Log.Warning( $"{Title}: there is no editor session to place into." );
return default;
}
var changed = ObjectsChangedByPlacing();
var removed = ObjectsRemovedByPlacing();
var undo = SceneEditorSession.Active.UndoScope( $"Place {Title}" ).WithGameObjectCreations();
if ( changed.Count > 0 ) undo = undo.WithGameObjectChanges( changed, GameObjectUndoFlags.All );
if ( removed.Count > 0 ) undo = undo.WithGameObjectDestructions( removed );
try
{
using ( SceneEditorSession.Scope() )
using ( undo.Push() )
PlaceObjects();
}
catch ( Exception e )
{
Log.Warning( e, $"{Title}: could not place the object: {e.Message}" );
Reset();
return default;
}
OnCancel();
if ( chainFrom is PathPoint start )
_points.Add( start with { Bend = null } );
return default;
}
// A broken shape must only stop this builder, never the editor tool around it.
try
{
var kit = new MeshKit { Material = SurfaceMaterial };
BuildMesh( kit );
var mesh = kit.Finish();
if ( kit.FailedFaces > 0 && !IsPreview )
Log.Warning( $"{Title}: {kit.FailedFaces} faces could not be added." );
return mesh;
}
catch ( Exception e )
{
Log.Warning( e, $"{Title} could not be built: {e.Message}" );
return default;
}
}
/// <summary>
/// A broken shape must only stop this builder, never the editor tool around it.
/// </summary>
PolygonMesh SafePreview()
{
try
{
return BuildPreview();
}
catch ( Exception e )
{
Log.Warning( e, $"{Title} could not show a preview: {e.Message}" );
return default;
}
}
/// <summary>
/// A line that starts or stops on the open end of a finished one of the same kind becomes one
/// line with it. Two meshes would leave a gap at the corner, one line has a proper corner.
/// </summary>
void TakeInFinished()
{
_takenIn.Clear();
if ( !ConnectFinished || !SnapsToLines || PlacesObjects || PlacesPiecesInChain || Closed || AlwaysClosed || SceneEditorSession.Active is null ) return;
var scene = SceneEditorSession.Active.Scene;
var settings = CaptureSettings();
if ( LineSnap.Touching( scene, _points[^1].Position, GetType().Name, settings, _takenIn ) is { } after )
{
_points[^1] = _points[^1] with { Bend = after[0].Bend };
_points.AddRange( after.Skip( 1 ) );
}
if ( LineSnap.Touching( scene, _points[0].Position, GetType().Name, settings, _takenIn ) is { } before )
_points.InsertRange( 0, LineSnap.Reversed( before ).SkipLast( 1 ) );
foreach ( var go in _takenIn )
TakeOver( go, BuilderStore.LoadRecord( go.Id ) );
// Both ends of the same object: the line came back to where it started.
if ( CanClose && _takenIn.Count > 0 && _points.Count > MinPoints && _points[0].Position.Distance( _points[^1].Position ) <= 0.5f )
{
_points.RemoveAt( _points.Count - 1 );
Closed = true;
}
}
/// <summary>
/// Meet finished objects whose side an end of this line stopped on.
/// </summary>
protected virtual void JoinSides( Scene scene ) { }
/// <summary>
/// Keep what a finished object brings along when it becomes part of this one.
/// </summary>
protected virtual void TakeOver( GameObject go, BuilderRecord record ) { }
protected void MovePoint( int index, Vector3 position ) => _points[index] = _points[index] with { Position = position };
/// <summary>
/// Put other points in place of the drawn ones, for a finished object built again.
/// </summary>
public void ReplacePoints( IEnumerable<PathPoint> points )
{
_points.Clear();
_points.AddRange( points );
}
/// <summary>
/// Build a finished object again from its record, after <paramref name="change"/> changed
/// something about it. It stays where it stands.
/// </summary>
/// <summary>
/// The settings a builder of this kind starts with.
/// </summary>
public static Dictionary<string, string> Defaults( Type type )
{
var description = ToolCatalog.Tools.FirstOrDefault( x => x.TargetType == type );
return description?.Create<BuilderEditor>( [null] ).CaptureSettings() ?? [];
}
public static void Rebuild( GameObject go, Action<BuilderEditor> change )
{
var record = BuilderStore.LoadRecord( go.Id );
var component = go.Components.Get<MeshComponent>();
var type = record is null ? null : ToolCatalog.Tools.FirstOrDefault( x => x.Name == record.Type );
if ( type is null || component is null ) return;
var editor = type.Create<BuilderEditor>( [null] );
editor.ApplySettings( record.Settings );
editor.Closed = record.Closed;
editor._planeHeight = record.PlaneHeight;
foreach ( var p in record.Points )
editor._points.Add( new PathPoint( new Vector3( p[0], p[1], p[2] ), p.Length > 3 ? new Vector3( p[3], p[4], p[5] ) : null ) );
change( editor );
editor.IsPreview = true;
var mesh = editor.Build();
editor.IsPreview = false;
mesh.ApplyTransform( new Transform( -new Vector3( record.Origin[0], record.Origin[1], record.Origin[2] ) ) );
using var scope = SceneEditorSession.Scope();
using ( SceneEditorSession.Active.UndoScope( $"Rebuild {type.Title}" ).WithGameObjectChanges( [go], GameObjectUndoFlags.All ).Push() )
{
component.Mesh = mesh;
}
record.Settings = editor.CaptureSettings();
record.Points = [.. editor._points.Select( x => x.Bend is Vector3 bend
? new[] { x.Position.x, x.Position.y, x.Position.z, bend.x, bend.y, bend.z }
: new[] { x.Position.x, x.Position.y, x.Position.z } )];
BuilderStore.SaveRecord( go.Id, record );
}
void RemoveTakenIn()
{
var taken = _takenIn.Where( x => x.IsValid() ).ToList();
_takenIn.Clear();
if ( taken.Count == 0 ) return;
using var scope = SceneEditorSession.Scope();
using ( SceneEditorSession.Active.UndoScope( $"Join {Title}" ).WithGameObjectDestructions( taken ).Push() )
{
foreach ( var go in taken )
{
LineSnap.Forget( go.Id );
go.Destroy();
}
}
}
public override void OnCreated( MeshComponent component )
{
BuilderStore.SaveRecord( component.GameObject.Id, MakeRecord( component.WorldPosition ) );
RemoveTakenIn();
OnBuilt( component );
var chainFrom = PlacesPiecesInChain && _points.Count > 0 ? _points[^1] : default( PathPoint? );
PopUndo();
Reset();
if ( chainFrom is PathPoint start )
_points.Add( start with { Bend = null } );
}
public override void OnCancel()
{
PopUndo();
Reset();
}
void Reset()
{
_points.Clear();
Closed = false;
_preview = null;
_cursor = null;
_dragTarget = DragTarget.None;
_dragIndex = -1;
_dragBefore = null;
}
/// <summary>
/// Rebuild the preview next frame.
/// </summary>
public void MarkDirty() => _dirty = true;
void RebuildPreview()
{
var withCursor = _cursor.HasValue && LivePreview && !IsFull && _points.Count > 0;
var wasClosed = Closed;
if ( withCursor )
{
_points.Add( new PathPoint( _cursor.Value ) );
if ( AlwaysClosed ) Closed = _points.Count >= MinPoints;
}
IsPreview = true;
try
{
_preview = Build()?.Rebuild();
}
finally
{
IsPreview = false;
if ( withCursor )
{
_points.RemoveAt( _points.Count - 1 );
Closed = wasClosed;
}
}
}
public override void OnUpdate( SceneTrace trace )
{
_trace = trace;
if ( _activeMaterial != Tool.ActiveMaterial )
{
_activeMaterial = Tool.ActiveMaterial;
_dirty = true;
}
if ( _dirty )
{
_dirty = false;
RebuildPreview();
}
if ( !Gizmo.Pressed.Any )
{
if ( _points.Count == 0 ) StartStage( trace );
else if ( !IsFull ) DrawingStage();
}
DrawPath();
}
void StartStage( SceneTrace trace )
{
var tr = trace.Run();
var point = tr.EndPosition;
if ( !tr.Hit && !new Plane( Vector3.Zero, Vector3.Up ).TryTrace( Gizmo.CurrentRay, out point, true ) )
return;
point = Settle( point, false );
point.z += FloorLevel * LevelHeight;
if ( Gizmo.WasLeftMousePressed && !Gizmo.HasHovered )
{
_planeHeight = point.z;
AddPoint( point );
}
DrawCursor( point );
}
void DrawingStage()
{
// A path that may climb carries on at the height of its last point.
var plane = AllowsHeight ? new Plane( _points[^1].Position, Vector3.Up ) : Plane;
if ( !plane.TryTrace( Gizmo.CurrentRay, out var point, true ) ) return;
point = Settle( point, true );
if ( Gizmo.HasHovered )
{
SetCursor( null );
return;
}
SetCursor( point );
DrawCursor( point );
Gizmo.Draw.Color = Color.White;
Gizmo.Draw.Line( _points[^1].Position, point );
if ( ShowSizes ) DrawLabel( (_points[^1].Position + point) * 0.5f, FormatLength( _points[^1].Position, point ), Color.White );
if ( Gizmo.WasLeftMousePressed )
AddPoint( point );
}
void SetCursor( Vector3? point )
{
if ( _cursor == point ) return;
_cursor = point;
if ( LivePreview ) _dirty = true;
}
static void DrawCursor( Vector3 point )
{
Gizmo.Draw.Color = Color.White;
Gizmo.Draw.SolidSphere( point, HandleSize( point ) );
}
static float HandleSize( Vector3 point ) => 3.0f * Gizmo.Camera.Position.Distance( point ) / 1000.0f;
static string FormatLength( Vector3 a, Vector3 b ) => $"{a.Distance( b ):0.##}";
protected static void DrawLabel( Vector3 position, string text, Color color )
{
var scope = new TextRendering.Scope
{
Text = text,
TextColor = color,
FontSize = 16 * Gizmo.Settings.GizmoScale * Editor.Application.DpiScale,
FontName = "Roboto Mono",
FontWeight = 400,
LineHeight = 1,
Outline = new TextRendering.Outline { Color = Color.Black, Enabled = true, Size = 3 }
};
Gizmo.Draw.ScreenText( scope, position, Vector2.Up * 16 );
}
void DrawPath()
{
Gizmo.Draw.IgnoreDepth = true;
Gizmo.Draw.LineThickness = 2;
if ( _preview.IsValid() && !_preview.IsError )
Gizmo.Draw.Model( _preview );
Gizmo.Draw.Color = CanBuild || _points.Count < MinPoints ? Color.Yellow : Color.Red;
var segments = ConnectPoints ? SegmentCount : 0;
for ( var i = 0; i < segments; i++ )
{
var a = _points[i].Position;
var b = _points[(i + 1) % _points.Count].Position;
Gizmo.Draw.Line( a, b );
if ( ShowSizes ) DrawLabel( (a + b) * 0.5f, FormatLength( a, b ), Color.Yellow );
}
for ( var i = 0; i < _points.Count; i++ )
DrawPointHandle( i );
if ( UsesBends && ShowBendHandles )
{
for ( var i = 0; i < segments; i++ )
DrawBendHandle( i );
}
if ( _dragTarget != DragTarget.None && !Gizmo.Pressed.Any )
EndDrag();
}
void DrawPointHandle( int index )
{
var point = _points[index].Position;
var size = HandleSize( point );
using ( Gizmo.Scope( $"point {index}" ) )
{
Gizmo.Hitbox.DepthBias = 0.01f;
Gizmo.Hitbox.Sphere( new Sphere( point, size * 2 ) );
if ( Gizmo.Pressed.This )
{
BeginDrag( DragTarget.Point, index );
var plane = new Plane( point, Vector3.Up );
var lifting = AllowsHeight && Gizmo.IsShiftPressed;
// Lifting drags on an upright plane facing the camera.
if ( lifting )
{
var facing = Gizmo.Camera.Rotation.Forward.WithZ( 0.0f );
if ( facing.Length < 0.01f ) facing = Gizmo.Camera.Rotation.Up.WithZ( 0.0f );
plane = new Plane( point, facing.Normal );
}
if ( _dragTarget == DragTarget.Point && _dragIndex == index
&& plane.TryTrace( Gizmo.CurrentRay, out var target, true ) )
{
target = lifting
? point.WithZ( Snap == SnapMode.Off ? target.z : Gizmo.Snap( target, new Vector3( 0, 0, 1 ) ).z )
: SnapToGrid( target ).WithZ( point.z );
if ( !point.AlmostEqual( target ) )
{
_points[index] = _points[index] with { Position = target };
_dragMoved = true;
_dirty = true;
point = target;
}
}
}
var canClose = index == 0 && CanClose && !Closed && _points.Count >= MinPoints + 1;
Gizmo.Draw.Color = Gizmo.IsHovered ? Color.Yellow : canClose ? Color.Green : Color.White;
Gizmo.Draw.SolidSphere( point, Gizmo.IsHovered ? size * 2 : size );
}
}
void DrawBendHandle( int index )
{
var a = _points[index].Position;
var b = _points[(index + 1) % _points.Count].Position;
var middle = (a + b) * 0.5f;
var point = _points[index].Bend ?? middle;
var size = HandleSize( point ) * 0.75f;
using ( Gizmo.Scope( $"bend {index}" ) )
{
Gizmo.Hitbox.DepthBias = 0.01f;
Gizmo.Hitbox.Sphere( new Sphere( point, size * 2 ) );
if ( Gizmo.Pressed.This )
{
BeginDrag( DragTarget.Bend, index );
if ( _dragTarget == DragTarget.Bend && _dragIndex == index
&& new Plane( point, Vector3.Up ).TryTrace( Gizmo.CurrentRay, out var target, true ) )
{
target = SnapToGrid( target ).WithZ( point.z );
// Dragging back onto the straight line removes the curve.
var bend = target.Distance( middle ) < 1.0f ? default( Vector3? ) : target;
if ( bend != _points[index].Bend )
{
_points[index] = _points[index] with { Bend = bend };
_dragMoved = true;
_dirty = true;
point = bend ?? middle;
}
}
}
Gizmo.Draw.Color = Gizmo.IsHovered ? Color.Yellow : Color.Cyan;
Gizmo.Draw.SolidSphere( point, Gizmo.IsHovered ? size * 2 : size );
}
}
void BeginDrag( DragTarget target, int index )
{
if ( _dragTarget != DragTarget.None ) return;
_dragTarget = target;
_dragIndex = index;
_dragBefore = [.. _points];
_dragMoved = false;
}
void EndDrag()
{
var before = _dragBefore;
var closes = _dragTarget == DragTarget.Point && _dragIndex == 0 && !_dragMoved
&& CanClose && !Closed && _points.Count >= MinPoints + 1;
var title = _dragTarget == DragTarget.Point ? "Move Point" : "Bend Segment";
_dragTarget = DragTarget.None;
_dragIndex = -1;
_dragBefore = null;
if ( closes )
{
Closed = true;
OnPointsChanged();
Push( "Close Path", before, false );
}
else if ( _dragMoved )
{
Push( title, before, Closed );
}
}
void AddPoint( Vector3 point )
{
var before = _points.ToArray();
if ( AllowsHeight ) point = KeepsHeight( point ) ? point : point.WithZ( _points.Count > 0 ? _points[^1].Position.z + HeightStep : _planeHeight );
else point = point.WithZ( _planeHeight );
_points.Add( new PathPoint( point ) );
if ( AlwaysClosed ) Closed = _points.Count >= MinPoints;
_cursor = null;
OnPointsChanged();
if ( PlacesPiecesInChain && _points.Count >= 2 && CanBuild )
{
Tool.Create();
return;
}
Push( "Add Point", before, false );
}
public void RemovePoint()
{
if ( _points.Count == 0 ) return;
var before = _points.ToArray();
var wasClosed = Closed;
if ( Closed && !AlwaysClosed )
{
Closed = false;
}
else
{
_points.RemoveAt( _points.Count - 1 );
if ( AlwaysClosed ) Closed = _points.Count >= MinPoints;
}
OnPointsChanged();
Push( "Remove Point", before, wasClosed );
}
void OnPointsChanged()
{
_dirty = true;
if ( _points.Count < MinPoints && !LivePreview )
_preview = null;
}
void Push( string title, PathPoint[] before, bool closedBefore )
{
var after = _points.ToArray();
var closedAfter = Closed;
PushUndo( title,
undo: () => Restore( before, closedBefore ),
redo: () => Restore( after, closedAfter ) );
}
void Restore( PathPoint[] points, bool closed )
{
_points.Clear();
_points.AddRange( points );
Closed = closed;
OnPointsChanged();
}
Vector3 SnapToGrid( Vector3 point ) => SnapManager.ToGrid( point, Snap, CustomGridSize );
Vector3 SnapToAngle( Vector3 point ) =>
_points.Count == 0 ? point : SnapManager.ToAngle( point, _points[^1].Position, AngleStep );
/// <summary>
/// Where the cursor ray hits the scene, or null over empty space. Only while the tool updates.
/// </summary>
protected Vector3? CursorHit()
{
var hit = _trace.Run();
return hit.Hit ? hit.EndPosition : null;
}
Vector3 DockToVertex( Vector3 point ) =>
DockToVertices ? Dock( point ) : point;
/// <summary>
/// Where a cursor point ends up. What the cursor itself is on comes before the grid: a house that
/// does not stand square to the grid has its corners between grid points, and a point snapped to
/// the grid first was often out of reach of the corner the cursor was on.
/// </summary>
Vector3 Settle( Vector3 point, bool lockAngle )
{
if ( DockToVertices && UnderCursor( point ) is { } on )
return Dock( on );
return DockToVertex( lockAngle ? SnapToAngle( SnapToGrid( point ) ) : SnapToGrid( point ) );
}
/// <summary>
/// What the cursor itself is in reach of before any grid snapping, or null to go by the grid.
/// </summary>
protected virtual Vector3? UnderCursor( Vector3 point )
{
var vertex = SnapManager.ToVertex( point, _trace.Run().GameObject?.GetComponent<MeshComponent>(), DockRadius );
return vertex.AlmostEqual( point ) ? null : vertex;
}
/// <summary>
/// For builders that sit on wall corners: the cursor point itself while a corner is in reach of it.
/// </summary>
protected Vector3? WallCornerUnderCursor( Vector3 point, float reach ) =>
ConnectFinished && SceneEditorSession.Active is not null && WallTops.Nearest( SceneEditorSession.Active.Scene, point, DockRadius + reach ) is not null ? point : null;
/// <summary>
/// Pull a point onto what is already there.
/// </summary>
protected virtual Vector3 Dock( Vector3 point ) =>
SnapManager.ToVertex( point, _trace.Run().GameObject?.GetComponent<MeshComponent>(), DockRadius );
IEnumerable<PropertyDescription> SavedProperties() =>
EditorTypeLibrary.GetType( GetType() ).Properties.Where( x => x.HasAttribute<PropertyAttribute>() && x.CanWrite );
public Dictionary<string, string> CaptureSettings()
{
var settings = new Dictionary<string, string>();
foreach ( var property in SavedProperties() )
{
var value = property.GetValue( this );
settings[property.Name] = value switch
{
null => null,
Resource resource => resource.ResourcePath,
_ => JsonSerializer.Serialize( value, property.PropertyType )
};
}
if ( ExtraSettings is GeneratorSettings generated )
settings[GeneratedKey] = GeneratorRegistry.Save( generated );
return settings;
}
/// <summary>
/// The type of every saved setting by name, so settings copied from another tool only go
/// where the same name means the same kind of value.
/// </summary>
public Dictionary<string, string> SettingTypes()
{
var types = SavedProperties().ToDictionary( x => x.Name, x => x.PropertyType.FullName );
if ( ExtraSettings is GeneratorSettings generated ) types[GeneratedKey] = generated.GetType().FullName;
return types;
}
public void ApplySettings( Dictionary<string, string> settings )
{
if ( settings is null )
{
Log.Warning( $"{Title}: no settings were supplied." );
return;
}
foreach ( var property in SavedProperties() )
{
if ( !settings.TryGetValue( property.Name, out var text ) )
continue;
try
{
if ( property.PropertyType == typeof(Material) )
property.SetValue( this, string.IsNullOrEmpty( text ) ? null : Material.Load( text ) );
else if ( property.PropertyType == typeof(PrefabFile) )
property.SetValue( this, string.IsNullOrEmpty( text ) ? null : ResourceLibrary.Get<PrefabFile>( text ) );
else if ( text is not null )
{
var value = JsonSerializer.Deserialize( text, property.PropertyType );
var range = property.GetCustomAttribute<RangeAttribute>();
if ( value is float number )
{
if ( !float.IsFinite( number ) )
throw new ArgumentException( "Number is not finite." );
if ( range is not null )
value = Math.Clamp( number, (float)range.Min, (float)range.Max );
}
else if ( value is int integer && range is not null )
value = Math.Clamp( integer, (int)range.Min, (int)range.Max );
if ( value is not null && property.PropertyType.IsEnum && !property.PropertyType.IsDefined( typeof(FlagsAttribute), false ) && !Enum.IsDefined( property.PropertyType, value ) )
throw new ArgumentException( "Unknown option value." );
property.SetValue( this, value );
}
}
catch ( Exception ex )when ( ex is JsonException or ArgumentException or FormatException )
{
Log.Warning( $"{Title}: invalid saved option {property.Name} was skipped ({ex.Message})." );
}
}
if ( ExtraSettings is GeneratorSettings generated && settings.TryGetValue( GeneratedKey, out var saved ) && saved is not null )
{
try
{
var values = (GeneratorSettings)JsonSerializer.Deserialize( saved, generated.GetType() );
if ( values is not null )
generated.CopyFrom( values );
}
catch ( Exception ex )when ( ex is JsonException or ArgumentException )
{
Log.Warning( $"{Title}: invalid generator settings were skipped ({ex.Message})." );
}
}
SettingsApplied( settings );
MarkDirty();
}
/// <summary>
/// Called after saved settings were applied, for tools that read older saves differently.
/// </summary>
protected virtual void SettingsApplied( IReadOnlyDictionary<string, string> settings )
{
}
BuilderRecord MakeRecord( Vector3 origin ) => new()
{
Type = GetType().Name,
Closed = Closed,
PlaneHeight = _planeHeight,
Origin = [origin.x, origin.y, origin.z],
Settings = CaptureSettings(),
Points = [.. _points.Select( x => x.Bend is Vector3 bend
? new[] { x.Position.x, x.Position.y, x.Position.z, bend.x, bend.y, bend.z }
: new[] { x.Position.x, x.Position.y, x.Position.z } )]
};
/// <summary>
/// Take the selected object apart again and carry on drawing it. Undo brings the object back.
/// </summary>
public void EditSelected()
{
var go = SceneEditorSession.Active.Selection.OfType<GameObject>().FirstOrDefault();
if ( !go.IsValid() )
{
Log.Warning( "Select an object made by this builder first." );
return;
}
var record = BuilderStore.LoadRecord( go.Id );
if ( record is null || record.Type != GetType().Name )
{
Log.Warning( $"{go.Name} was not made by the {Title} builder." );
return;
}
var offset = go.WorldPosition - new Vector3( record.Origin[0], record.Origin[1], record.Origin[2] );
using var scope = SceneEditorSession.Scope();
using ( SceneEditorSession.Active.UndoScope( $"Edit {Title}" ).WithGameObjectDestructions( [go] ).Push() )
{
go.Destroy();
}
PopUndo();
Reset();
_planeHeight = record.PlaneHeight + offset.z;
Closed = record.Closed;
foreach ( var p in record.Points )
{
var bend = p.Length > 3 ? new Vector3( p[3], p[4], p[5] ) + offset : default( Vector3? );
_points.Add( new PathPoint( new Vector3( p[0], p[1], p[2] ) + offset, bend ) );
}
ApplySettings( record.Settings );
}
public override Widget CreateWidget() => new BuilderWidget( this );
class BuilderWidget : ToolSidebarWidget
{
readonly BuilderEditor _editor;
SerializedObject _serialized;
SerializedObject _extra;
bool _rebuild;
string TypeName => _editor.GetType().Name;
public BuilderWidget( BuilderEditor editor )
{
_editor = editor;
Layout.Margin = 0;
Build();
}
/// <summary>
/// Not while the control that started it is still handling its change: that control goes away here.
/// </summary>
[EditorEvent.Frame]
public void RebuildIfNeeded()
{
if ( !_rebuild )
return;
var scroll = GetAncestor<ScrollArea>();
var vertical = scroll?.VerticalScrollbar.Value ?? 0;
var horizontal = scroll?.HorizontalScrollbar.Value ?? 0;
// Deleting the focused enum control otherwise transfers focus to a control
// near the end of the new sidebar, which scrolls the selection out of view.
var focused = Editor.Application.FocusWidget;
if ( focused is not null && focused.IsDescendantOf( this ) )
focused.Blur();
_rebuild = false;
Layout.Clear( true );
Build();
DoLayout();
if ( scroll is not null && scroll.IsValid )
{
// The scroll range follows the new content only a frame or two later, so the
// position is set again until it holds.
_scroll = scroll;
_scrollTo = (vertical, horizontal);
_scrollFrames = 4;
RestoreScroll();
}
}
ScrollArea _scroll;
(int Vertical, int Horizontal) _scrollTo;
int _scrollFrames;
[EditorEvent.Frame]
public void RestoreScroll()
{
if ( _scrollFrames <= 0 || _scroll is null || !_scroll.IsValid ) return;
_scrollFrames--;
_scroll.VerticalScrollbar.Value = Math.Clamp( _scrollTo.Vertical, _scroll.VerticalScrollbar.Minimum, _scroll.VerticalScrollbar.Maximum );
_scroll.HorizontalScrollbar.Value = Math.Clamp( _scrollTo.Horizontal, _scroll.HorizontalScrollbar.Minimum, _scroll.HorizontalScrollbar.Maximum );
}
void Build()
{
var editor = _editor;
// When some options only take effect on placing or need another option, the help says so.
string optionHelp = editor switch
{
RoadStudioEditor => "Ground Offset needs Follow Ground and a surface below the road. Stop lines, crosswalks and arrows appear at junction approaches. Number Spacing needs a Road Number. Potholes need Damage and appear when placed. Turning Radius needs a turning-circle end.",
JunctionEditor => "Arms are numbered counterclockwise from the layout's first arm, before Yaw. Each arm has its own Angle Offset, Length and Lanes overrides. Zero length or lanes uses the shared value. Short arms extend to clear the widest road. Junction arms stay level; use Road Studio for climbing roads.",
TunnelCutterEditor => "The preview shows the cutting volume. Ribs, columns, variation and end frames appear in the placed lining. Targets controls which meshes are cut. Open Terrain changes terrain holes when placed.",
_ => null
};
var hints = editor.Hints.Concat( ["Drag a point: move it", "DEL: remove last point", "Enter: create", "Esc: stop"] );
if ( optionHelp is not null ) hints = hints.Append( optionHelp );
Layout.Add( new ToolHeader( this, EditorTypeLibrary.GetType( editor.GetType() ), hints ) );
// Without this a click on a part tool looks like it does nothing.
if ( editor.ExtraSettings is GeneratorSettings && !PartService.CanStoreParts )
Layout.Add( new Notice( this, "Restart needed", "Restart the editor once. Until then this tool would lose its parts on saving, so it places nothing." ) );
// A search over every setting of the tool, and a switch for the changed ones only.
var filter = Layout.Add( new SettingsFilter( this, TypeName ) );
if ( editor.ExtraSettings is not null )
{
_extra = editor.ExtraSettings.GetSerialized();
_extra.OnPropertyChanged += x =>
{
editor.MarkDirty();
_rebuild |= editor.ChangesOtherSettings( x.Name );
};
}
_serialized = editor.GetSerialized();
_serialized.OnPropertyChanged += x =>
{
editor.MarkDirty();
_rebuild |= editor.ChangesOtherSettings( x.Name );
};
filter.Watch( PropertySheet.Fill( this, [_extra, _serialized], $"{editor.Title} Properties", editor.HiddenExtraOptions ) );
var presetSection = Layout.Add( new SettingsSection( this, "Presets", foldedByDefault: true ) );
presetSection.Body.Layout.Add( new PresetPanel( presetSection.Body, editor, () => _rebuild = true ) );
if ( !editor.PlacesObjects )
{
var edit = Layout.Add( new SettingsSection( this, "Edit" ) );
edit.Body.Layout.Add( new ListButton( "Edit Selected", "edit" ) { Clicked = editor.EditSelected, ToolTip = "Rebuild a selected object made with this builder." } );
}
Layout.AddStretchCell();
}
[Shortcut( "editor.delete", "DEL", typeof( SceneViewWidget ) )]
public void DeletePoint() => _editor.RemovePoint();
}
}
/// <summary>
/// A builder whose shape follows a free path, straight, bent segment by segment or smoothed as a spline.
/// </summary>
public abstract class PathEditor( PrimitiveTool tool ) : BuilderEditor( tool )
{
protected override bool UsesBends => true;
protected override bool ShowBendHandles => !Smooth;
protected override IEnumerable<string> Hints => ["Drag a blue handle: curve segment"];
[Property, Group( "Curves" ), Title( "Smooth Spline" ), WideMode, Description( "Runs a spline through all points instead of straight segments." )]
public bool Smooth { get; set; }
[Property, Advanced, Group( "Curves" ), Title( "Curve Segments" ), Range( 1, 64, slider: false ), WideMode]
public int CurveSegments { get; set; } = 12;
[Property, Group( "Placement" ), Title( "Stack On Top" ), WideMode, Description( "A point near the top of a finished wall or fence lands on that top, so the new one stands on it. Off, it connects at the foot." )]
public bool StackOnTop { get; set; } = false;
protected override Vector3? UnderCursor( Vector3 point )
{
if ( SnapsToLines && ConnectFinished && SceneEditorSession.Active is not null
&& LineSnap.TrySnapToFinished( SceneEditorSession.Active.Scene, point, DockRadius, StackOnTop, out var onLine ) )
return onLine;
return base.UnderCursor( point );
}
protected override Vector3 Dock( Vector3 point )
{
if ( !SnapsToLines || !ConnectFinished || SceneEditorSession.Active is null )
return base.Dock( point );
var scene = SceneEditorSession.Active.Scene;
// A finished wall or line is joined on its centre line. The corners of its mesh lie half a
// thickness off that line; docking to one of those first pulled a click on a corner half a
// wall inward. So the line comes first, mesh corners only where no line is in reach.
if ( LineSnap.TrySnapToFinished( scene, point, DockRadius, StackOnTop, out var onLine ) )
return onLine;
return LineSnap.SnapToFinished( scene, base.Dock( point ), DockRadius, StackOnTop );
}
/// <summary>
/// The drawn points as a path of the path engine. A bend is a curve through a point halfway along a segment.
/// </summary>
public PathCurve Curve() => CurveOf( Points, Closed, Smooth );
/// <summary>
/// A line from placed points, for a builder at work and for the record of a finished object.
/// </summary>
public static PathCurve CurveOf( IReadOnlyList<PathPoint> points, bool closed, bool smooth )
{
var nodes = points.Select( x => new PathNode( x.Position ) ).ToList();
if ( smooth && points.Count > 2 )
return new PathCurve { Nodes = nodes, Closed = closed, Interpolation = PathInterpolation.CatmullRom };
var segments = points.Count < 2 ? 0 : closed ? points.Count : points.Count - 1;
for ( var i = 0; i < segments; i++ )
{
if ( points[i].Bend is not Vector3 bend ) continue;
var a = nodes[i];
var b = nodes[(i + 1) % nodes.Count];
var control = bend * 2.0f - (a.Position + b.Position) * 0.5f;
a.TangentOut = (control - a.Position) * (2.0f / 3.0f);
b.TangentIn = (control - b.Position) * (2.0f / 3.0f);
}
return new PathCurve { Nodes = nodes, Closed = closed, Interpolation = PathInterpolation.Bezier };
}
/// <summary>
/// The sampled path between each pair of placed points.
/// </summary>
protected List<List<Vector3>> SampleLegs()
{
var curve = Curve();
var steps = Math.Clamp( CurveSegments, 1, 64 );
var legs = new List<List<Vector3>>( SegmentCount );
for ( var i = 0; i < SegmentCount; i++ )
{
var a = Points[i].Position;
var b = Points[(i + 1) % Points.Count].Position;
var bent = (Smooth && Points.Count > 2) || Points[i].Bend is not null;
var leg = new List<Vector3> { a };
for ( var s = 1; bent && s < steps; s++ )
leg.Add( curve.Evaluate( i, s / (float)steps ) );
leg.Add( b );
legs.Add( leg );
}
return legs;
}
/// <summary>
/// The whole sampled path without repeated points. A closed path does not repeat its start.
/// </summary>
protected List<Vector3> SamplePath()
{
var samples = new List<Vector3>();
foreach ( var point in SampleLegs().SelectMany( x => x ) )
{
if ( samples.Count == 0 || !samples[^1].AlmostEqual( point, 0.01f ) )
samples.Add( point );
}
if ( Closed && samples.Count > 1 && samples[0].AlmostEqual( samples[^1], 0.01f ) )
samples.RemoveAt( samples.Count - 1 );
return samples;
}
/// <summary>
/// Points spread evenly along a sampled path, every <paramref name="spacing"/> units.
/// </summary>
protected static List<(Vector3 Position, Vector3 Direction)> WalkPath( IReadOnlyList<Vector3> path, bool closed, float spacing )
{
var result = new List<(Vector3, Vector3)>();
var steps = closed ? path.Count : path.Count - 1;
var next = 0.0f;
var walked = 0.0f;
for ( var i = 0; i < steps; i++ )
{
var a = path[i];
var b = path[(i + 1) % path.Count];
var part = a.Distance( b );
while ( part > 0.0f && next <= walked + part )
{
result.Add( (Vector3.Lerp( a, b, (next - walked) / part ), (b - a).Normal) );
next += spacing;
}
walked += part;
}
return result;
}
/// <summary>
/// Positive when a closed ring runs counter clockwise seen from above.
/// </summary>
protected static float SignedArea( IReadOnlyList<Vector3> ring )
{
var area = 0.0f;
for ( var i = 0; i < ring.Count; i++ )
{
var a = ring[i];
var b = ring[(i + 1) % ring.Count];
area += a.x * b.y - b.x * a.y;
}
return area * 0.5f;
}
/// <summary>
/// Move a path sideways, to its left for positive distances, with mitred corners.
/// </summary>
protected static Vector3[] OffsetPath( IReadOnlyList<Vector3> path, bool closed, float distance )
{
var track = PathTrack.Along( path, closed );
var result = new Vector3[path.Count];
for ( var i = 0; i < result.Length; i++ )
result[i] = track.Place( i, distance );
return result;
}
/// <summary>
/// Run a cross section, given as (left, up) points, along a path. Up stays up, so a
/// section on a slope stands upright. Its faces can take a material per side.
/// </summary>
protected static void SweepPath( MeshKit kit, IReadOnlyList<Vector3> path, bool closed, IReadOnlyList<Vector2> section, IReadOnlyList<Material> materials = null )
{
var track = PathTrack.Along( path, closed ).Reach( MathF.Max( section.Max( x => x.x ), 0.0f ), MathF.Max( -section.Min( x => x.x ), 0.0f ) );
var rings = section.Select( p =>
{
var ring = new Vector3[path.Count];
for ( var i = 0; i < ring.Length; i++ ) ring[i] = track.Place( i, p.x ) + Vector3.Up * p.y;
return ring;
} ).ToArray();
kit.Sweep( rings, closed, true, materials );
}
}