Editor tool and sidebar widget for selecting and editing road network nodes and segments. Handles picking, dragging nodes and tangent handles, gizmo interaction, inserting/deleting nodes/segments, drawing hover/selection gizmos, and building the inspector UI with undo scopes.
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;
namespace RedSnail.RoadTool2.Editor;
/// <summary>
/// Which frame a node's position is shown and edited in.
/// </summary>
public enum RoadNodeSpace
{
/// <summary>
/// The network's own axes.
/// </summary>
Global,
/// <summary>
/// The node's road: X along it, Y across it. Height stays height either way.
/// </summary>
Local
}
/// <summary>
/// Select and edit what is already there: click a node or a road to select it, drag a node along the ground (or use
/// its move gizmo), drag a node's tangent handles to reshape the roads through it, Ctrl+click a road to insert a
/// node, Delete to remove the selection.
/// </summary>
[Title("Edit")]
[Icon("edit")]
[Group("1")]
[Order(1)]
public class RoadSelectTool : EditorTool
{
private enum DragKind
{
None,
Node,
Handle,
Gizmo
}
private const float MinEndDistance = 25.0f;
public int SelectedNodeId { get; private set; } = -1;
public int SelectedSegmentId { get; private set; } = -1;
/// <summary>
/// Bumped whenever the selection changes, so the sidebar knows to rebuild itself.
/// </summary>
public int SelectionVersion { get; private set; }
public RoadNetwork Network => Root?.Network;
public RoadNode SelectedNode => Network.IsValid() ? Network.FindNode(SelectedNodeId) : null;
public RoadSegment SelectedSegment => Network.IsValid() ? Network.FindSegment(SelectedSegmentId) : null;
/// <summary>
/// Which frame the selected node's position is shown and edited in, the same choice the move gizmo offers.
/// Local puts X along the road through the node and Y across it, so a value typed there slides the node down
/// its own road rather than along a world axis.
/// </summary>
[Property, Title("Space")] public RoadNodeSpace NodeSpace { get; set; } = RoadNodeSpace.Global;
/// <summary>
/// The selected node's position, in whichever frame <see cref="NodeSpace"/> asks for.
/// </summary>
[Property, Title("Position")]
public Vector3 NodePosition
{
get
{
var network = Network;
if (!network.IsValid() || SelectedNode is not { } node)
return Vector3.Zero;
return NodeSpace == RoadNodeSpace.Local ? NodeAxes(network, node).Inverse * node.Position : node.Position;
}
set
{
var network = Network;
if (!network.IsValid() || SelectedNode is not { } node)
return;
network.MoveNode(node.Id, NodeSpace == RoadNodeSpace.Local ? NodeAxes(network, node) * value : value);
}
}
private RoadNetworkTool Root => Manager?.CurrentTool as RoadNetworkTool;
/// <summary>
/// The node's own axes in the network's space: forward along the road through it, laid flat so that Z stays
/// height and a number typed into it does not drag the node along the road's grade. World axes when no road at
/// the node gives a direction.
/// </summary>
private static Rotation NodeAxes(RoadNetwork _Network, RoadNode _Node)
{
var segment = _Network.GetSegmentsAt(_Node.Id).FirstOrDefault();
if (segment is null)
return Rotation.Identity;
// The handle points from the node into its road: at the road's end that is against the road's direction.
Vector3 handle = RoadNetwork.GetHandle(segment, _Node.Id);
Vector3 forward = (segment.EndNodeId == _Node.Id ? -handle : handle).WithZ(0.0f);
return forward.IsNearZeroLength ? Rotation.Identity : Rotation.LookAt(forward.Normal, Vector3.Up);
}
private DragKind m_Drag;
private int m_DragSegmentId = -1;
private Vector3 m_DragStartWorld;
private Vector3 m_MoveDelta;
private IDisposable m_UndoScope;
public override void OnDisabled()
{
EndDrag();
}
public override Widget CreateToolSidebar()
{
return new RoadSelectToolWidget(this);
}
public override void OnUpdate()
{
var network = Network;
if (!network.IsValid())
{
EndDrag();
ClearSelection();
return;
}
// Undo can remove whatever we had selected.
if (SelectedNodeId >= 0 && network.FindNode(SelectedNodeId) is null)
ClearSelection();
if (SelectedSegmentId >= 0 && network.FindSegment(SelectedSegmentId) is null)
ClearSelection();
if (m_Drag is DragKind.Node or DragKind.Handle)
{
UpdateDirectDrag(network);
DrawSelection(network);
return;
}
bool canPick = Gizmo.HasMouseFocus && !Gizmo.HasHovered && !Gizmo.Pressed.Any;
RoadNode hoverNode = null;
RoadSegmentPick? hoverSegment = null;
int hoverHandleSegment = -1;
if (canPick)
{
var ray = Gizmo.CurrentRay;
if (SelectedNode is { } selected)
hoverHandleSegment = PickHandle(network, selected, ray);
if (hoverHandleSegment < 0)
hoverNode = RoadPicker.PickNode(network, ray);
if (hoverHandleSegment < 0 && hoverNode is null)
hoverSegment = RoadPicker.PickSegment(network, ray);
}
DrawHover(network, hoverNode, hoverSegment);
DrawSelection(network, hoverHandleSegment);
if (canPick && Gizmo.WasLeftMousePressed)
OnPress(network, hoverNode, hoverSegment, hoverHandleSegment);
if (SelectedNode is { } node)
UpdateNodeGizmo(network, node);
}
public void Select(RoadNode _Node)
{
SetSelection(_Node?.Id ?? -1, -1);
}
public void Select(RoadSegment _Segment)
{
SetSelection(-1, _Segment?.Id ?? -1);
}
public void ClearSelection()
{
SetSelection(-1, -1);
}
private void SetSelection(int _NodeId, int _SegmentId)
{
if (SelectedNodeId == _NodeId && SelectedSegmentId == _SegmentId)
return;
SelectedNodeId = _NodeId;
SelectedSegmentId = _SegmentId;
SelectionVersion++;
}
public void DeleteSelection()
{
var network = Network;
if (!network.IsValid())
return;
if (SelectedNode is { } node)
{
// A node in the middle of a road is dissolved so the road carries on in one piece; anything else
// (a road end, a junction) takes its roads with it.
bool passThrough = network.GetDegree(node.Id) == 2;
using (SceneEditorSession.Active.UndoScope(passThrough ? "Dissolve Node" : "Delete Node").WithComponentChanges(network).Push())
{
if (!passThrough || network.DissolveNode(node.Id) is null)
network.RemoveNode(node.Id);
}
}
else if (SelectedSegment is { } segment)
{
using (SceneEditorSession.Active.UndoScope("Delete Road").WithComponentChanges(network).Push())
network.RemoveSegment(segment.Id);
}
ClearSelection();
}
/// <summary>
/// Sidebar edits of the selected node land on the node object directly; this pushes them through the network
/// so tangents and chunks follow.
/// </summary>
public void OnNodeEdited(RoadNode _Node, string _PropertyName)
{
var network = Network;
if (!network.IsValid() || _Node is null)
return;
if (_PropertyName == nameof(RoadNode.AutoTangents))
{
network.SetAutoTangents(_Node.Id, _Node.AutoTangents);
return;
}
// The value is already written on the node; only the consequences are left to apply.
if (_PropertyName == nameof(RoadNode.Position))
network.RefreshTangentsAround(_Node.Id);
network.MarkDirty();
}
public void OnSegmentEdited()
{
var network = Network;
if (!network.IsValid())
return;
// Turning a road into a ramp (or back) changes how the roads at its ends flow through their nodes.
if (SelectedSegment is { } segment)
{
network.RefreshTangentsAround(segment.StartNodeId);
network.RefreshTangentsAround(segment.EndNodeId);
}
network.MarkDirty();
}
private void OnPress(RoadNetwork _Network, RoadNode _HoverNode, RoadSegmentPick? _HoverSegment, int _HoverHandleSegment)
{
if (_HoverHandleSegment >= 0)
{
m_Drag = DragKind.Handle;
m_DragSegmentId = _HoverHandleSegment;
return;
}
if (_HoverNode is not null)
{
Select(_HoverNode);
m_Drag = DragKind.Node;
m_DragStartWorld = _Network.WorldTransform.PointToWorld(_HoverNode.Position);
return;
}
if (_HoverSegment is { } pick)
{
if (Gizmo.IsCtrlPressed)
{
RoadNode inserted;
using (SceneEditorSession.Active.UndoScope("Insert Node").WithComponentChanges(_Network).Push())
inserted = _Network.SplitSegment(pick.Segment.Id, pick.T, MinEndDistance);
Select(inserted);
}
else
{
Select(pick.Segment);
}
return;
}
ClearSelection();
}
/// <summary>
/// A node pressed directly slides along the ground under the cursor; a handle slides on the horizontal plane
/// through its current position, so a road's vertical profile is only changed on purpose (through the gizmo).
/// </summary>
private void UpdateDirectDrag(RoadNetwork _Network)
{
if (!Gizmo.IsLeftMouseDown)
{
EndDrag();
return;
}
// A press that never travels is a click, not a drag: don't record an undo step for it.
if (Vector2.Distance(Gizmo.CursorPosition, Gizmo.Pressed.CursorPosition) < 2.0f && m_UndoScope is null)
return;
var transform = _Network.WorldTransform;
if (m_Drag == DragKind.Node && SelectedNode is { } node)
{
if (GroundHit(_Network, transform.PointToWorld(node.Position)) is not Vector3 hit)
return;
m_UndoScope ??= SceneEditorSession.Active.UndoScope("Move Node").WithComponentChanges(_Network).Push();
_Network.MoveNode(node.Id, transform.PointToLocal(hit));
}
else if (m_Drag == DragKind.Handle && SelectedNode is { } handleNode && _Network.FindSegment(m_DragSegmentId) is { } segment)
{
Vector3 handleWorld = transform.PointToWorld(handleNode.Position + RoadNetwork.GetHandle(segment, handleNode.Id));
var plane = new Plane(handleWorld, Vector3.Up);
if (plane.Trace(Gizmo.CurrentRay, true) is not Vector3 hit)
return;
m_UndoScope ??= SceneEditorSession.Active.UndoScope("Move Tangent").WithComponentChanges(_Network).Push();
// Alt breaks the mirror so a pass-through node can be turned into a corner.
Vector3 handle = transform.PointToLocal(hit) - handleNode.Position;
_Network.SetHandle(segment.Id, handleNode.Id, handle, !Gizmo.IsAltPressed);
}
else
{
EndDrag();
}
}
/// <summary>
/// The standard move gizmo on the selected node. Its centre square drags along the ground (the raycast hook),
/// the arrows move on world axes, or in Local space (the editor's Global/Local toggle) along and across the
/// road through the node, with grid snapping in whichever frame the arrows are in.
/// </summary>
private void UpdateNodeGizmo(RoadNetwork _Network, RoadNode _Node)
{
if (m_Drag is DragKind.Node or DragKind.Handle)
return;
var transform = _Network.WorldTransform;
Vector3 nodeWorld = transform.PointToWorld(_Node.Position);
var handleRotation = Gizmo.Settings.GlobalSpace ? Rotation.Identity : NodeFrame(_Network, _Node);
using (Gizmo.Scope("roadtool2_node_gizmo", new Transform(nodeWorld)))
{
Gizmo.Hitbox.DepthBias = 0.01f;
Vector3? surfaceHit = null;
bool moved = Gizmo.Control.Position("position", Vector3.Zero, out Vector3 delta, handleRotation, centerRaycast: () =>
{
surfaceHit = GroundHit(_Network, nodeWorld);
return surfaceHit;
});
if (moved)
{
if (m_Drag != DragKind.Gizmo)
{
m_Drag = DragKind.Gizmo;
m_DragStartWorld = nodeWorld;
m_MoveDelta = Vector3.Zero;
m_UndoScope ??= SceneEditorSession.Active.UndoScope("Move Node").WithComponentChanges(_Network).Push();
}
Vector3 target;
if (surfaceHit is Vector3 hit)
{
target = hit;
}
else
{
m_MoveDelta += delta;
target = Gizmo.Snap(m_DragStartWorld, m_MoveDelta, handleRotation);
}
_Network.MoveNode(_Node.Id, transform.PointToLocal(target));
}
}
if (m_Drag == DragKind.Gizmo && !Gizmo.IsLeftMouseDown)
EndDrag();
}
/// <summary>
/// The node's own frame for Local space: forward is the way the road through it runs (its first road's
/// direction, which at a smooth pass-through node is the same from either side), so the arrows point along
/// and across the road. World axes when no road at the node gives a direction.
/// </summary>
private static Rotation NodeFrame(RoadNetwork _Network, RoadNode _Node)
{
var segment = _Network.GetSegmentsAt(_Node.Id).FirstOrDefault();
if (segment is null)
return Rotation.Identity;
// The handle points from the node into its road: at the road's end that is against the road's direction.
Vector3 handle = RoadNetwork.GetHandle(segment, _Node.Id);
Vector3 forward = _Network.WorldTransform.NormalToWorld(segment.EndNodeId == _Node.Id ? -handle : handle);
if (forward.IsNearZeroLength || MathF.Abs(forward.Normal.z) > 0.99f)
return Rotation.Identity;
return Rotation.LookAt(forward.Normal, Vector3.Up);
}
private void EndDrag()
{
m_Drag = DragKind.None;
m_DragSegmentId = -1;
m_UndoScope?.Dispose();
m_UndoScope = null;
}
/// <summary>
/// Where the cursor ray meets the ground, ignoring the road meshes themselves (the node sits right on them).
/// Falls back to a horizontal plane through the point being dragged when nothing is under the cursor.
/// </summary>
private Vector3? GroundHit(RoadNetwork _Network, Vector3 _Fallback)
{
var trace = Trace.WithoutTags(RoadNetwork.ChunkTag).Run();
if (trace.Hit)
return trace.EndPosition;
return new Plane(_Fallback, Vector3.Up).Trace(Gizmo.CurrentRay, true);
}
/// <summary>
/// The segment whose tangent handle at the selected node is under the ray, or -1.
/// </summary>
private static int PickHandle(RoadNetwork _Network, RoadNode _Node, Ray _Ray)
{
var transform = _Network.WorldTransform;
int best = -1;
float bestDistance = float.MaxValue;
foreach (var segment in _Network.GetSegmentsAt(_Node.Id))
{
Vector3 world = transform.PointToWorld(_Node.Position + RoadNetwork.GetHandle(segment, _Node.Id));
var sphere = new Sphere(world, RoadPicker.NodeRadius(world) * 0.7f);
if (!sphere.Trace(_Ray, float.MaxValue, out float distance) || distance >= bestDistance)
continue;
best = segment.Id;
bestDistance = distance;
}
return best;
}
private static void DrawHover(RoadNetwork _Network, RoadNode _HoverNode, RoadSegmentPick? _HoverSegment)
{
var transform = _Network.WorldTransform;
using (Gizmo.Scope("roadtool2_hover"))
{
Gizmo.Draw.IgnoreDepth = true;
Gizmo.Draw.LineThickness = 2.0f;
Gizmo.Draw.Color = Color.Cyan.WithAlpha(0.8f);
if (_HoverNode is not null)
RoadGizmos.DrawNode(transform.PointToWorld(_HoverNode.Position), false);
if (_HoverSegment is { } pick && _Network.TryGetCurve(pick.Segment, out var curve))
{
RoadGizmos.DrawRoadOutline(curve, _Network.WidthOf(pick.Segment), transform);
// Ctrl turns the click into "insert a node here": show where.
if (Gizmo.IsCtrlPressed)
{
Gizmo.Draw.Color = Color.Orange;
RoadGizmos.DrawNode(transform.PointToWorld(pick.LocalPoint), true);
}
}
}
}
private void DrawSelection(RoadNetwork _Network, int _HoverHandleSegment = -1)
{
var transform = _Network.WorldTransform;
using (Gizmo.Scope("roadtool2_selection"))
{
Gizmo.Draw.IgnoreDepth = true;
Gizmo.Draw.LineThickness = 2.0f;
if (SelectedSegment is { } segment && _Network.TryGetCurve(segment, out var curve))
{
Gizmo.Draw.Color = Color.Yellow;
RoadGizmos.DrawRoadOutline(curve, _Network.WidthOf(segment), transform);
}
if (SelectedNode is not { } node)
return;
Vector3 nodeWorld = transform.PointToWorld(node.Position);
Gizmo.Draw.Color = Color.Yellow;
RoadGizmos.DrawNode(nodeWorld, true);
// Tangent handles: one per road leaving the node.
foreach (var attached in _Network.GetSegmentsAt(node.Id))
{
Vector3 handleWorld = transform.PointToWorld(node.Position + RoadNetwork.GetHandle(attached, node.Id));
bool active = attached.Id == _HoverHandleSegment || (m_Drag == DragKind.Handle && attached.Id == m_DragSegmentId);
Gizmo.Draw.Color = node.AutoTangents ? Color.White.WithAlpha(0.5f) : Color.White;
Gizmo.Draw.LineThickness = 1.0f;
Gizmo.Draw.Line(nodeWorld, handleWorld);
Gizmo.Draw.Color = active ? Color.Cyan : (node.AutoTangents ? Color.White.WithAlpha(0.6f) : Color.White);
Gizmo.Draw.SolidSphere(handleWorld, RoadPicker.NodeRadius(handleWorld) * 0.45f);
}
}
}
}
file class RoadSelectToolWidget : ToolSidebarWidget
{
private readonly RoadSelectTool m_Tool;
private int m_ShownVersion = -1;
private IDisposable m_UndoScope;
public RoadSelectToolWidget(RoadSelectTool _Tool)
{
m_Tool = _Tool;
Rebuild();
}
[EditorEvent.Frame]
public void Frame()
{
if (m_Tool.SelectionVersion != m_ShownVersion)
Rebuild();
}
private void Rebuild()
{
m_ShownVersion = m_Tool.SelectionVersion;
Layout.Clear(true);
AddTitle("Edit Road", "edit");
var network = m_Tool.Network;
if (network.IsValid() && m_Tool.SelectedNode is { } node)
BuildNodeSection(network, node);
else if (network.IsValid() && m_Tool.SelectedSegment is { } segment)
BuildSegmentSection(network, segment);
else
AddGroup("Selection").Add(new Label("Click a node or a road to select it."));
AddShortcuts(
("Select", "LMB"),
("Drag Node / Tangent", "LMB Drag"),
("Straight Tangent (no mirror)", "ALT + Drag"),
("Insert Node On Road", "CTRL + LMB"),
("Delete Selection", "DEL"),
("Deselect", "ESC")
);
Layout.AddStretchCell();
}
private void BuildNodeSection(RoadNetwork _Network, RoadNode _Node)
{
var group = AddGroup($"Node #{_Node.Id}");
var serialized = _Node.GetSerialized();
serialized.OnPropertyStartEdit += _ => BeginEdit("Edit Node");
serialized.OnPropertyFinishEdit += _ => EndEdit();
serialized.OnPropertyChanged += property => m_Tool.OnNodeEdited(_Node, property?.Name);
// The position is read off the tool rather than the node, so it can be shown in the node's own frame as
// well as the network's; its setter moves the node either way.
var tool = m_Tool.GetSerialized();
tool.OnPropertyStartEdit += _ => BeginEdit("Move Node");
tool.OnPropertyFinishEdit += _ => EndEdit();
SidebarRows.Add(group, tool.GetProperty(nameof(RoadSelectTool.NodeSpace)));
SidebarRows.AddVector(group, tool.GetProperty(nameof(RoadSelectTool.NodePosition)));
SidebarRows.Add(group, serialized.GetProperty(nameof(RoadNode.AutoTangents)));
SidebarRows.Add(group, serialized.GetProperty(nameof(RoadNode.CornerRadius)));
group.Add(new Label($"Roads at this node: {_Network.GetDegree(_Node.Id)}"));
var row = group.AddRow();
row.Spacing = 4;
row.Add(new Button("Delete Node", "delete") { Clicked = m_Tool.DeleteSelection });
}
private void BuildSegmentSection(RoadNetwork _Network, RoadSegment _Segment)
{
var group = AddGroup($"Road #{_Segment.Id}");
var serialized = _Segment.GetSerialized();
serialized.OnPropertyStartEdit += _ => BeginEdit("Edit Road");
serialized.OnPropertyFinishEdit += _ => EndEdit();
serialized.OnPropertyChanged += _ => m_Tool.OnSegmentEdited();
SidebarRows.AddWide(group, serialized.GetProperty(nameof(RoadSegment.Profile)));
SidebarRows.AddWide(group, serialized.GetProperty(nameof(RoadSegment.Bridge)));
SidebarRows.AddWide(group, serialized.GetProperty(nameof(RoadSegment.Props)));
SidebarRows.Add(group, serialized.GetProperty(nameof(RoadSegment.Width)));
float length = _Network.GetCurveTable(_Segment)?.Length ?? 0.0f;
group.Add(new Label($"Length: {length:0} units"));
var row = group.AddRow();
row.Spacing = 4;
row.Add(new Button("Delete Road", "delete") { Clicked = m_Tool.DeleteSelection });
// How the edges run: extra width per side at each end (a lane appearing over a taper), and the ramp
// treatment for a road leaving another from its side.
var shape = AddGroup("Edges");
SidebarRows.Add(shape, serialized.GetProperty(nameof(RoadSegment.LeftExtraStart)));
SidebarRows.Add(shape, serialized.GetProperty(nameof(RoadSegment.LeftExtraEnd)));
SidebarRows.Add(shape, serialized.GetProperty(nameof(RoadSegment.RightExtraStart)));
SidebarRows.Add(shape, serialized.GetProperty(nameof(RoadSegment.RightExtraEnd)));
SidebarRows.Add(shape, serialized.GetProperty(nameof(RoadSegment.Ramp)));
}
private void BeginEdit(string _Name)
{
var network = m_Tool.Network;
if (!network.IsValid())
return;
m_UndoScope ??= SceneEditorSession.Active.UndoScope(_Name).WithComponentChanges(network).Push();
}
private void EndEdit()
{
m_UndoScope?.Dispose();
m_UndoScope = null;
}
[Shortcut("roadtool2.delete-selection", "DEL", typeof(SceneViewWidget))]
private void DeleteSelection()
{
m_Tool.DeleteSelection();
}
[Shortcut("roadtool2.deselect", "ESC", typeof(SceneViewWidget))]
private void Deselect()
{
m_Tool.ClearSelection();
}
}