This class implements the UI for editing Waymaker Splines in the engine editor. It provides a control sheet for modifying spline point properties and renders 3D gizmos for spatial manipulation of points and tangents.
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;
namespace FluidPlay.Waymaker.Editor;
/// <summary>
/// Waymaker-owned spline control sheet and gizmos.
/// </summary>
public sealed class WaymakerSplineEditorWindow : WidgetWindow
{
private const float WaymakerHeaderHeight = 32.0f;
private const float GizmoBoxSize = 2.0f;
private const float LineThickness = 2.0f;
private const float TangentLineThickness = 0.8f;
private const float GeneratedWireThickness = 1.0f;
private static bool s_IsClosed;
private Waymaker m_Target;
private ControlWidget m_InTangentControl;
private ControlWidget m_OutTangentControl;
private bool m_InTangentSelected;
private bool m_OutTangentSelected;
private bool m_DraggingOutNewPoint;
private bool m_MoveInProgress;
private List<Vector3> m_Polyline = [];
private IDisposable m_MovementUndoScope;
/// <summary>
/// Editor-facing tangent mode enum with registered icons; Spline.HandleMode itself has no editor enum control.
/// </summary>
public enum WaymakerHandleMode
{
[Icon("auto_fix_high")]
Auto,
[Icon("show_chart")]
Linear,
[Icon("open_in_full")]
Mirrored,
[Icon("call_split")]
Split
}
public WaymakerSplineEditorWindow()
{
ContentMargins = 0;
Layout = Layout.Column();
MaximumWidth = 500;
MinimumWidth = 300;
Rebuild();
}
public void OnDisabled()
{
m_MovementUndoScope?.Dispose();
m_MovementUndoScope = null;
}
public void OnUpdate()
{
if (m_Target.IsValid())
DrawGizmos();
}
public void OnSelectionChanged(Waymaker target)
{
m_Target = target;
SelectedPointIndex = 0;
m_InTangentSelected = false;
m_OutTangentSelected = false;
Rebuild();
}
[Title("Tangent Mode")]
private WaymakerHandleMode _selectedPointTangentMode
{
get => IsSelectedPointValid() ? (WaymakerHandleMode)SelectedPoint.Mode : WaymakerHandleMode.Auto;
set => UpdateSelectedPoint(SelectedPoint with { Mode = (Spline.HandleMode)value });
}
[Title("Position")]
private Vector3 _selectedPointPosition
{
get => IsSelectedPointValid() ? SelectedPoint.Position : Vector3.Zero;
set => UpdateSelectedPoint(SelectedPoint with { Position = value });
}
[Title("In")]
private Vector3 _selectedPointIn
{
get => IsSelectedPointValid() ? SelectedPoint.In : Vector3.Zero;
set => UpdateSelectedPoint(SelectedPoint with { In = value });
}
[Title("Out")]
private Vector3 _selectedPointOut
{
get => IsSelectedPointValid() ? SelectedPoint.Out : Vector3.Zero;
set => UpdateSelectedPoint(SelectedPoint with { Out = value });
}
[Title("Roll (Degrees)")]
private float _selectedPointRoll
{
get => IsSelectedPointValid() ? SelectedPoint.Roll : 0.0f;
set => UpdateSelectedPoint(SelectedPoint with { Roll = value });
}
[Title("Scale (_, Width, Height)")]
private Vector3 _selectedPointScale
{
get => IsSelectedPointValid() ? SelectedPoint.Scale : Vector3.One;
set => UpdateSelectedPoint(SelectedPoint with { Scale = value });
}
[Title("Up Vector")]
private Vector3 _selectedPointUp
{
get => IsSelectedPointValid() ? SelectedPoint.Up : Vector3.Up;
set => UpdateSelectedPoint(SelectedPoint with { Up = value });
}
private int SelectedPointIndex { get; set; }
private Spline.Point SelectedPoint => IsSelectedPointValid() ? m_Target.Spline.GetPoint(SelectedPointIndex) : new Spline.Point();
private bool IsSelectedPointValid() => m_Target.IsValid() && SelectedPointIndex >= 0 && SelectedPointIndex < m_Target.Spline.PointCount;
private void UpdateSelectedPoint(Spline.Point point)
{
if (!IsSelectedPointValid())
return;
using (CreateUndoScope("Update Waymaker Spline Point"))
m_Target.Spline.UpdatePoint(SelectedPointIndex, point);
ToggleTangentInput();
}
private void Rebuild()
{
Layout.Clear(true);
Layout.Margin = 0;
Icon = s_IsClosed ? "" : "route";
WindowTitle = s_IsClosed ? "" : $"Waymaker Spline Editor - {m_Target?.GameObject?.Name ?? ""}";
IsGrabbable = !s_IsClosed;
if (s_IsClosed)
{
var closedRow = Layout.AddRow();
closedRow.Add(new IconButton("route", ReopenWindow)
{
ToolTip = "Open Waymaker Spline Editor",
FixedHeight = WaymakerHeaderHeight,
FixedWidth = WaymakerHeaderHeight,
Background = Color.Transparent
});
MinimumWidth = 0;
return;
}
MinimumWidth = 400;
BuildHeader();
if (m_Target.IsValid())
BuildControlSheet();
Layout.Margin = 4;
}
private void BuildHeader()
{
var header = Layout.AddRow();
header.AddStretchCell();
header.Add(new IconButton("info")
{
ToolTip = "Edit spline points with the control sheet or 3D gizmos. Click a segment to insert a point; Shift-drag a point to extend the spline.",
FixedHeight = WaymakerHeaderHeight,
FixedWidth = WaymakerHeaderHeight,
Background = Color.Transparent
});
header.Add(new IconButton("close", CloseWindow)
{
ToolTip = "Close Editor",
FixedHeight = WaymakerHeaderHeight,
FixedWidth = WaymakerHeaderHeight,
Background = Color.Transparent
});
}
private void BuildControlSheet()
{
var serialized = this.GetSerialized();
var controlSheet = new ControlSheet();
controlSheet.AddRow(serialized.GetProperty(nameof(_selectedPointTangentMode)));
controlSheet.AddRow(serialized.GetProperty(nameof(_selectedPointPosition)));
m_InTangentControl = controlSheet.AddRow(serialized.GetProperty(nameof(_selectedPointIn)));
m_OutTangentControl = controlSheet.AddRow(serialized.GetProperty(nameof(_selectedPointOut)));
controlSheet.AddGroup("Advanced", [
serialized.GetProperty(nameof(_selectedPointRoll)),
serialized.GetProperty(nameof(_selectedPointScale)),
serialized.GetProperty(nameof(_selectedPointUp))
]);
controlSheet.AddLayout(BuildControlButtons());
Layout.Add(controlSheet);
ToggleTangentInput();
}
private Layout BuildControlButtons()
{
var row = Layout.Row();
row.Spacing = 16;
row.Margin = 8;
row.Add(new IconButton("skip_previous", SelectPreviousPoint) { ToolTip = "Go to previous point" });
row.Add(new IconButton("skip_next", SelectNextPoint) { ToolTip = "Go to next point" });
row.Add(new IconButton("delete", DeleteSelectedPoint) { ToolTip = "Delete point (at least two points are kept)" });
row.Add(new IconButton("add", AddPointAfterSelection) { ToolTip = "Insert point after current point" });
return row;
}
private void ReopenWindow()
{
s_IsClosed = false;
Rebuild();
}
private void SelectPreviousPoint() => SelectPoint(Math.Max(0, SelectedPointIndex - 1));
private void SelectNextPoint() => SelectPoint(Math.Min(m_Target.Spline.PointCount - 1, SelectedPointIndex + 1));
private void DeleteSelectedPoint()
{
if (!IsSelectedPointValid() || m_Target.Spline.PointCount <= 2)
return;
using (CreateUndoScope("Delete Waymaker Spline Point"))
m_Target.Spline.RemovePoint(SelectedPointIndex);
SelectPoint(Math.Min(SelectedPointIndex, m_Target.Spline.PointCount - 1));
}
private void AddPointAfterSelection()
{
if (!IsSelectedPointValid())
return;
using (CreateUndoScope("Add Waymaker Spline Point"))
{
var spline = m_Target.Spline;
if (SelectedPointIndex == spline.PointCount - 1)
{
float distance = spline.GetDistanceAtPoint(SelectedPointIndex);
Vector3 tangent = spline.SampleAtDistance(distance).Tangent;
var newPoint = SelectedPoint with { Position = SelectedPoint.Position + tangent * 200.0f };
spline.InsertPoint(SelectedPointIndex + 1, newPoint);
}
else
{
float current = spline.GetDistanceAtPoint(SelectedPointIndex);
float next = spline.GetDistanceAtPoint(SelectedPointIndex + 1);
spline.AddPointAtDistance((current + next) * 0.5f, true);
}
}
SelectPoint(Math.Min(SelectedPointIndex + 1, m_Target.Spline.PointCount - 1));
}
private void SelectPoint(int index)
{
SelectedPointIndex = Math.Clamp(index, 0, Math.Max(0, m_Target.Spline.PointCount - 1));
m_InTangentSelected = false;
m_OutTangentSelected = false;
WindowTitle = $"Waymaker Spline Point [{SelectedPointIndex}] Editor - {m_Target.GameObject.Name}";
ToggleTangentInput();
}
private void ToggleTangentInput()
{
if (!m_Target.IsValid())
return;
bool editable = SelectedPoint.Mode is not (Spline.HandleMode.Auto or Spline.HandleMode.Linear);
if (m_InTangentControl is not null)
m_InTangentControl.Enabled = editable;
if (m_OutTangentControl is not null)
m_OutTangentControl.Enabled = editable;
}
private IDisposable CreateUndoScope(string name)
{
return SceneEditorSession.Active.UndoScope(name).WithComponentChanges(m_Target).Push();
}
private void CloseWindow()
{
s_IsClosed = true;
Rebuild();
Position = Parent.Size - 32;
}
private void DrawGizmos()
{
using (Gizmo.Scope("waymaker_spline_editor", m_Target.WorldTransform))
{
DrawSplineSegments();
if (IsViewportWireframeEnabled())
DrawGeneratedMeshWireframe();
DrawPositionGizmo();
DrawPointControls();
}
}
private bool IsViewportWireframeEnabled()
{
var editorCamera = m_Target.Scene.GetAllComponents<CameraComponent>()
.FirstOrDefault(camera => camera.GameObject.Name == "editor_camera");
return editorCamera?.WireframeMode == true;
}
/// <summary>
/// Draw the baked quad topology while editing the Waymaker spline. The mesh is a generated child, so it would
/// otherwise only show a wireframe when that child is selected directly.
/// </summary>
private void DrawGeneratedMeshWireframe()
{
foreach (var child in m_Target.GameObject.Children)
{
if (!child.Tags.Has("waymaker_generated_mesh"))
continue;
var meshComponent = child.Components.Get<MeshComponent>();
var mesh = meshComponent?.Mesh;
if (mesh is null)
continue;
Gizmo.Draw.Color = Color.Cyan;
Gizmo.Draw.LineThickness = GeneratedWireThickness;
Gizmo.Draw.IgnoreDepth = true;
foreach (var face in mesh.FaceHandles)
{
foreach (var halfEdge in mesh.GetFaceEdges(face))
{
if (!halfEdge.NextEdge.IsValid)
continue;
Vector3 start = mesh.GetVertexPosition(halfEdge.Vertex);
Vector3 end = mesh.GetVertexPosition(halfEdge.NextEdge.Vertex);
start = m_Target.WorldTransform.PointToLocal(meshComponent.WorldTransform.PointToWorld(start));
end = m_Target.WorldTransform.PointToLocal(meshComponent.WorldTransform.PointToWorld(end));
Gizmo.Draw.Line(start, end);
}
}
}
}
private void DrawSplineSegments()
{
m_Target.Spline.ConvertToPolyline(ref m_Polyline);
for (int i = 0; i < m_Polyline.Count - 1; i++)
{
Vector3 start = m_Polyline[i];
Vector3 end = m_Polyline[i + 1];
using (Gizmo.Scope("segment" + i))
using (Gizmo.Hitbox.LineScope())
{
Gizmo.Draw.LineThickness = LineThickness;
Gizmo.Hitbox.AddPotentialLine(start, end, LineThickness * 2.0f);
Gizmo.Draw.Line(start, end);
if (Gizmo.IsHovered && Gizmo.HasMouseFocus && Gizmo.HasClicked && Gizmo.Pressed.This)
InsertPointAtClosestPosition(start, end);
}
}
}
private void InsertPointAtClosestPosition(Vector3 start, Vector3 end)
{
if (!new Line(start, end).ClosestPoint(Gizmo.CurrentRay.ToLocal(Gizmo.Transform), out Vector3 pointOnLine, out _))
return;
float distance = m_Target.Spline.SampleAtClosestPosition(pointOnLine).Distance;
using (CreateUndoScope("Add Waymaker Spline Point"))
SelectedPointIndex = m_Target.Spline.AddPointAtDistance(distance, true);
m_InTangentSelected = false;
m_OutTangentSelected = false;
ToggleTangentInput();
}
private void DrawPositionGizmo()
{
if (!Gizmo.IsShiftPressed)
m_DraggingOutNewPoint = false;
if (!IsSelectedPointValid() || (SelectedPointIndex == 0 && !m_InTangentSelected && !m_OutTangentSelected))
return;
m_MoveInProgress = false;
Vector3 gizmoPosition = SelectedPoint.Position;
if (m_InTangentSelected)
gizmoPosition += SelectedPoint.In;
else if (m_OutTangentSelected)
gizmoPosition += SelectedPoint.Out;
// The handle is drawn at the current scope's transform, so the point/tangent position has to go into
// a child scope. Passing it as the control's value instead leaves the handle on the component origin,
// where it sits under the GameObject's own move gizmo and can never be grabbed. Translation-only
// scope, so the delta still comes back in component-local space.
using (Gizmo.Scope("position", new Transform(gizmoPosition)))
{
if (Gizmo.Control.Position("waymaker_spline_control_", Vector3.Zero, out Vector3 delta, GetHandleRotation()))
{
m_MoveInProgress = true;
m_MovementUndoScope ??= CreateUndoScope("Move Waymaker Spline Point");
MoveSelectedControl(delta);
}
if (!m_MoveInProgress && Gizmo.WasLeftMouseReleased)
{
m_MovementUndoScope?.Dispose();
m_MovementUndoScope = null;
}
}
}
private void MoveSelectedControl(Vector3 delta)
{
var updated = SelectedPoint;
if (m_InTangentSelected)
{
updated.In += delta;
if (updated.Mode == Spline.HandleMode.Auto)
updated.Mode = Spline.HandleMode.Mirrored;
if (updated.Mode == Spline.HandleMode.Mirrored)
updated.Out = -updated.In;
}
else if (m_OutTangentSelected)
{
updated.Out += delta;
if (updated.Mode == Spline.HandleMode.Auto)
updated.Mode = Spline.HandleMode.Mirrored;
if (updated.Mode == Spline.HandleMode.Mirrored)
updated.In = -updated.Out;
}
else
{
if (Gizmo.IsShiftPressed && !m_DraggingOutNewPoint)
{
m_DraggingOutNewPoint = true;
m_Target.Spline.InsertPoint(SelectedPointIndex + 1, updated);
SelectedPointIndex++;
updated = SelectedPoint;
}
updated.Position += delta;
}
m_Target.Spline.UpdatePoint(SelectedPointIndex, updated);
if (!Gizmo.IsShiftPressed)
m_DraggingOutNewPoint = false;
}
private Rotation GetHandleRotation()
{
if (Gizmo.Settings.GlobalSpace || !IsSelectedPointValid())
return Rotation.Identity;
float distance = m_Target.Spline.GetDistanceAtPoint(SelectedPointIndex);
var sample = m_Target.Spline.SampleAtDistance(distance);
if (sample.Tangent.IsNearlyZero())
return Rotation.Identity;
Vector3 up = sample.Up.IsNearZeroLength ? Vector3.Up : sample.Up;
up = Rotation.FromAxis(sample.Tangent, sample.Roll) * up;
return Rotation.LookAt(sample.Tangent, up);
}
private void DrawPointControls()
{
var spline = m_Target.Spline;
for (int i = 0; i < spline.PointCount; i++)
{
if (spline.IsLoop && i == spline.SegmentCount)
continue;
using (Gizmo.Scope("point_controls" + i, new Transform(spline.GetPoint(i).Position)))
{
Gizmo.Draw.IgnoreDepth = true;
DrawPointPositionHandle(i);
if (SelectedPointIndex == i)
DrawTangentHandles(spline.GetPoint(i));
}
}
}
private void DrawPointPositionHandle(int index)
{
using (Gizmo.Scope("position"))
using (Gizmo.GizmoControls.PushFixedScale())
{
Gizmo.Hitbox.DepthBias = 0.1f;
Gizmo.Hitbox.BBox(BBox.FromPositionAndSize(Vector3.Zero, GizmoBoxSize));
if (Gizmo.IsHovered || (index == SelectedPointIndex && !m_InTangentSelected && !m_OutTangentSelected))
Gizmo.Draw.Color = Color.Cyan;
Gizmo.Draw.SolidBox(BBox.FromPositionAndSize(Vector3.Zero, GizmoBoxSize));
if (Gizmo.HasClicked && Gizmo.Pressed.This)
SelectPoint(index);
}
}
private void DrawTangentHandles(Spline.Point point)
{
Gizmo.Draw.Color = Color.White;
Gizmo.Draw.LineThickness = TangentLineThickness;
DrawTangentHandle("in_tangent", point.In, ref m_InTangentSelected, ref m_OutTangentSelected);
DrawTangentHandle("out_tangent", point.Out, ref m_OutTangentSelected, ref m_InTangentSelected);
}
private void DrawTangentHandle(string name, Vector3 offset, ref bool selected, ref bool otherSelected)
{
using (Gizmo.Scope(name, new Transform(offset)))
{
Gizmo.Draw.Line(-offset, Vector3.Zero);
if (SelectedPoint.Mode == Spline.HandleMode.Linear)
return;
using (Gizmo.GizmoControls.PushFixedScale())
{
Gizmo.Hitbox.DepthBias = 0.1f;
Gizmo.Hitbox.BBox(BBox.FromPositionAndSize(Vector3.Zero, GizmoBoxSize));
if (Gizmo.IsHovered || selected)
Gizmo.Draw.Color = Color.Cyan;
Gizmo.Draw.SolidBox(BBox.FromPositionAndSize(Vector3.Zero, GizmoBoxSize));
if (Gizmo.HasClicked && Gizmo.Pressed.This)
{
selected = true;
otherSelected = false;
}
}
}
}
}