This post is the first part of two on how to create a spline based cable tool, based of Cubic Beezier curves, editable in the scene. In this post we will cover how to create the base scripts and an editor for it. The next post will cover how we can generate a cable mesh to surround the spline.


Full source can be found at https://github.com/bonahona/CableSpline


Create the Cable Spline

Create a new file named CableSpline.cs in your project, and empty out anything apart from the class declaration.

using System.Collections.Generic;
using System.Linq;
using UnityEngine;

public class CableSpline : MonoBehaviour {
}


The spline will consists of a series of control points along which the cable will run. Each of these points will need a relative position and a direction they are facing. Add this class as a nested class inside the

CableSpline
class.

[System.Serializable]
public class SplineControlPoint {
  public Vector3 Position;
  public Quaternion Direction = Quaternion.identity;
}

Note: The SplineControlPoint does not inherit from MonoBehaviour nor UnityEngine.Object but we still want it serialized. The [System.Serializable] attribute does just that.

https://docs.microsoft.com/en-us/dotnet/api/system.serializableattribute?view=netcore-3.1


When woring with the spline we cant make it easier to work with the control points as a set of pairs so lets create a class to hold this information. Add this as another nested class inside

CableSpline
.


These control point pairs is only needed when working with the spline and can be created when needed. There is no need to serialize it so we'll skip the

[System.Serializable]
attribute.

public class ControlPointPair {
  public SplineControlPointFirst;
  public SplineControlPointSecond;

  public ControlPointPair(SplineControlPointfirst, SplineControlPointsecond) {
    First = first;
    Second = second;
  }
}


Add this constant at the very top of the CableSpline class.

public const float DefaultDiameter = 0.25f;


In order to manipulate the look of the cable mesh later, we need to have some public variables available. None of the cable settings will matter until we create a mesh but it's easier if we add them now. We'll also add a flag whether it's currently being edited.

public int SmoothnessLevel = 5;
public int RoundSegments = 10;
public float Diameter = DefaultDiameter;
public bool IsEditable = false;


We need to hold a list of control points as everything regarding the shape will be inferred from this list. The reset method is called internally by Unity, and we're using it to create a default start point for the spline at the center (relative position (0, 0, 0)).

public List<ConnectionControlPoint> ControlPoints;

public void Reset() {
  ControlPoints = new List<ConnectionControlPoint> {
    new ConnectionControlPoint {
      Position = new Vector3(0f, 0f, 0f), Direction = Quaternion.LookRotation(Vector3.right)
    }
  };
}


We need a way to convert the list of control points to a list of ordered pairs. It will used both internally to later generate the mesh and externally to display a preview so it needs to be public.

public List<ControlPointPair> GetControlPointPairs(List<ConnectionControlPoint> controlPoints) {
  var result = new List<ControlPointPair>();

  if (controlPoints.Count < 2) {
     return result;
  }

  for (int i = 0; i < controlPoints.Count - 1; i++) {
     result.Add(new ControlPointPair(controlPoints[i], controlPoints[i + 1]));
  }

  return result;
}


Next we create the Editor class is responsible for manipulating the control points. The editor will rely on a few method present in the

CableSpline
class so we'll create the declaration for them, but leave them empty for now.

public void AddControlPoint(Vector3 position) {
  // TODO: Implement me
}

public void InsertControlPoint(ControlPointPair controlPoints, Vector3 position) {
  // TODO: Implement me
}

public void RemoveControlPoint(SplineControlPoint controlPoint) {
  // TODO: Implement me
}

public void UpdateMesh() {
  // TODO: Implement me
}


Create the editor

In an editor folder, create CableSplineEditor.cs. Remove all default stuff in the class, make it inherit from

Editor
(found in the
UnityEditor
namespace) and add the
[CustomEditor]
attribute to it.

using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEditor;

[CustomEditor(typeof(CableSpline))]
public class CableSplineEditor: Editor {
}


First we need to add a way to easily create a cable spline object in out scene. Currently you have to create a new empty game object and manually add the CableSpline component to it. Lets instead add a way to create it from Unity's GameObject->3D Object menu. Along we'll also create some utility methods.


private static CableSpline CreateCableSplineObject(Vector3 position) {
   var connectionGameObject = new GameObject("CableSpline");
   var connectionSystem = connectionGameObject.AddComponent<CableSpline>();
   connectionSystem.transform.position = position;

   return connectionSystem;
}


private static Vector3 GetMiddleOfViewPort() {
   var middleOfViewRay = SceneView.lastActiveSceneView.camera.ViewportPointToRay(new Vector3(0.5f, 0.5f, 1));
   if (Physics.Raycast(middleOfViewRay, out RaycastHit rayCasthit)) {
      return rayCasthit.point;
   } else {
      return new Vector3(0, 0, 0);
   }
}


[MenuItem("GameObject/3D Object/Cable Spline")]
public static void CreateCableSpline() {
   var position = GetMiddleOfViewPort();
   var connectionSystem = CreateCableSplineObject(position);

   connectionSystem.IsEditable = true;
   Selection.activeGameObject = connectionSystem.gameObject;

}

CreateCableSpline()
is a static method that, thanks to the [MenuItem(string)] attribute, will be called when the menu item GameObject -> 3D Objects -> Cable Spline is pressed.

https://docs.unity3d.com/ScriptReference/MenuItem.html


GetMiddleOfViewPort()
is a pretty self explanatory method. It tries to place the new cable of the ground in the middle of the current viewport.


CreateCableSplineObject()
is responsible for creating a new game object at the correct position and attach the CableSpline component to it. In the future, default values for new cable systems will be applied to it in this method.


Create the new Inspector

Usually you will have to care about what public variables are available in a MonoBehaviour because they will all be exposed in the inspector. For the cable spline we will create a new inspector editor from scratch so we don't have to care about that at all.

 public override void OnInspectorGUI() {
   var cableSpline = target as CableSpline;

   EditorGUI.BeginChangeCheck();
   cableSpline.SmoothnessLevel = Mathf.Clamp(EditorGUILayout.IntField("Smoothness Level",   cableSpline.SmoothnessLevel), 0, 10);
   cableSpline.RoundSegments = Mathf.Clamp(EditorGUILayout.IntField("Roundness", cableSpline.RoundSegments), 3, 30);
   cableSpline.Diameter = Mathf.Clamp(EditorGUILayout.FloatField("Diameter", cableSpline.Diameter), 0.01f, 10);
   EditorGUILayout.Space();

    // The "Button" style will make this toggle look like a button instead of a normal checkbox.
   cableSpline.IsEditable = GUILayout.Toggle(cableSpline.IsEditable, "Edit path", "Button", GUILayout.Height(24));

   if (EditorGUI.EndChangeCheck()) {
      cableSpline.UpdateMesh();
   }
}


Create the scene view editor

The available changes in the editor is very limited, and all only relate to the mesh generation. There is nothing to create or edit control points. They will be edited in the scene via some position and rotation handles. To make that possible we need to hook a new custom method into Unity's Scene view update.


First we need some working variables added to the CableSplineEditor class.

private readonly RaycastHit[] RaycastHits = new RaycastHit[128];
private GUIStyle EditorTextStyle;


Next well create a method that will run when the scene is updated and make sure to hook it up.

public void OnEnable() {
  SceneView.duringSceneGui += OnSceneView;
  }

public void OnDisable() {
  SceneView.duringSceneGui -= OnSceneView;
}

public void OnSceneView(SceneView sceneView) {
}

When we deselect a cable we want to stop edit it. Add the following lines at the end of the

OnDisable()
callback.

var connection = target as CableSpline;
if (connection == null) {
   return;
}

connection.IsEditable = Selection.activeGameObject == connection.gameObject;


Now it's time to implement the editing of the spline. There is no perfect way to split this code into smaller chunks but I will try to keep change small and self contained.


We have to add an implementation of the OnSceneView method. Most of its work is deferred to other (for now empty) methods.

public void OnSceneView(SceneView sceneView) {
   if(EditorTextStyle == null) {
      EditorTextStyle = CreateInSceneTextStyle();
   }

   var cable = target as CableSpline;
   if (!cable.IsEditable) {
      return;
   }

   // Get lone rights to capture the input
   HandleUtility.AddDefaultControl(GUIUtility.GetControlID(FocusType.Passive));

   ShowControlPoints(cable);
   DrawCurvedLine(cable);
   HandleEvent(Event.current, cable);

   sceneView.Repaint();
}

private GUIStyle CreateInSceneTextStyle(){
}

// Shows a position and rotation handle for each control point
private void ShowControlPoints(CableSpline cable){
}

// Shows a preview of the spline
private void DrawCurvedLine(CableSpline cable){
}

// Handles input
private void HandleEvent(Event currentEvent, CableSpline cable) {
}


Now we can implement the placeholder methods one by one.

private GUIStyle CreateInSceneTextStyle(){
  return new GUIStyle() {
    normal = new GUIStyleState {
      textColor = Color.white,
    },
    alignment = TextAnchor.MiddleCenter,
    fontStyle = FontStyle.Bold,
    fontSize = 12
  };
}


This method will show a rotation and position handles for every control points and is the way the spline path will be edited. Every control point will use a local position and rotation but the handles uses scene space so we need to convert that.

private void ShowControlPoints(CableSpline cable) {
   foreach (var point in cable.ControlPoints) {
      ShowControlPoint(point, cable);
   }
}

private void ShowControlPoint(CableSpline.SplineControlPoint controlPoint, CableSpline cable){
   var position = cable.transform.position + controlPoint.Position;
   float size = HandleUtility.GetHandleSize(position) * 1f;

   EditorGUI.BeginChangeCheck();
   controlPoint.Position = Handles.DoPositionHandle(position, controlPoint.Direction) - cable.transform.position;
   controlPoint.Direction = Handles.DoRotationHandle(controlPoint.Direction, position);
   if (EditorGUI.EndChangeCheck()) {
      Undo.RecordObject(cable, "Edited Cable control point");
      cable.UpdateMesh();
      EditorUtility.SetDirty(cable);
   }
}

https://docs.unity3d.com/ScriptReference/Handles.PositionHandle.html

https://docs.unity3d.com/ScriptReference/Handles.RotationHandle.html


This method draws a preview of the spline by drawing cubic bezier curves between the control points and two additional, on-the-fly generated, control points, both placed 1/3 of the total length of the segment. This will smooth the curve out nicely.

private void DrawCurvedLine(CableSpline cable){
   var leftRotation = Quaternion.LookRotation(Vector3.left);
   var rightotation = Quaternion.LookRotation(Vector3.right);

   foreach (var pair in cable.GetControlPointPairs(cable.ControlPoints)) {
      var distance = (pair.Second.Position - pair.First.Position).magnitude / 3;
      var firstPoint = pair.First.Position + cable.transform.position;
      var lastPoint = pair.Second.Position + cable.transform.position;
      var extraPosition01 = pair.First.Position + pair.First.Direction * Vector3.forward * distance + cable.transform.position;
      var extraPosition02 = pair.Second.Position + pair.Second.Direction * Vector3.back * distance + cable.transform.position;

      // Draws a cubic bezier curve
      Handles.DrawBezier(firstPoint, lastPoint, extraPosition01, extraPosition02, Color.green, null, 2);
   }
}


This method is run to handle the current Unity Event. It will be used to find what buttons are pressed and where in the scene we have pressed. In order to find a good place to place control points we are using ray casts, but they need something to be cast against.

Make sure there is come collider in the scene to ray cast against.

private void AddContolPoint(Event currentEvent, CableSpline cable, InScenePosition inScenePosition){
   var lastPoint = cable.ControlPoints.Last();
   var lastPointPosition = cable.transform.position + lastPoint.Position; 

   if (inScenePosition.IsInCable) {
      if (currentEvent.type == EventType.MouseDown) {
         Undo.RecordObject(cable, "Inserted control point");
         cable.InsertControlPoint(inScenePosition.ControlPoints, inScenePosition.Position);
         currentEvent.Use();
      }
   } else {
      Handles.DrawLine(lastPointPosition, inScenePosition.Position);

      if (currentEvent.type == EventType.MouseDown) {
         Undo.RecordObject(cable, "Added additional control point");
         cable.AddControlPoint(inScenePosition.Position);
         currentEvent.Use();
      }
   }
}

private void RemoveControlPoint(Event currentEvent, CableSpline cable, InScenePosition inScenePosition){
   if (currentEvent.type == EventType.MouseDown) {
      Undo.RecordObject(cable, "Removed control point");
      cable.RemoveControlPoint(inScenePosition.ControlPoints.First);
      currentEvent.Use();
   }
}

private void HandleEvent(Event currentEvent, CableSpline cable){
   var inScenePosition = GetInScenePoint(Event.current.mousePosition, cable);

   if (currentEvent.control) {
      AddContolPoint(currentEvent, cable, inScenePosition);
   } else if (currentEvent.shift) {
      RemoveControlPoint(currentEvent, cable, inScenePosition);
   } else {
      Handles.Label(inScenePosition.Position + Vector3.down * 2, "Hold control to place point\nHold shift to remove a point\nPress space to release", EditorTextStyle);
   }

   // Space releases the editing of this cable
   if (currentEvent.type == EventType.KeyDown && currentEvent.keyCode == KeyCode.Space) {
      cable.IsEditable = false;
      currentEvent.Use();
   }
}


When control points are added or removed that logic is deferred to the CableSpline class's

AddControlPoint()
,
InsertControlPoint()
and
RemoveControlPoint()
, methods we created earlier but left empty. They need to get implementations now.


public void AddControlPoint(Vector3 position){
   var lastControlPoint = ControlPoints.Last();
   var directionOffset = position - (transform.position + lastControlPoint.Position);
   directionOffset.y = 0;
   var direction = Quaternion.LookRotation(directionOffset);

   var targetPosition = position - transform.position;
   targetPosition.y = lastControlPoint.Position.y;

   var controlPoint = new ConnectionControlPoint { Position = targetPosition, Direction = direction };
   ControlPoints.Add(controlPoint);
   UpdateMesh();
}


public void InsertControlPoint(ControlPointPair controlPoints, Vector3 position){
   var targetPosition = position - transform.position;
   var direction = Quaternion.Slerp(controlPoints.First.Direction, controlPoints.Second.Direction, 0.5f);

   var controlPoint = new ConnectionControlPoint { Position = targetPosition, Direction = direction };

   var insertIndex = Mathf.Max(ControlPoints.IndexOf(controlPoints.First), ControlPoints.IndexOf(controlPoints.Second));
   ControlPoints.Insert(insertIndex, controlPoint);
   UpdateMesh();
}


public void RemoveControlPoint(ConnectionControlPoint controlPoint) {
   ControlPoints.Remove(controlPoint);
   UpdateMesh();
}


That's is! We are done with the creation of control points. When selected, the

ShowControlPoints()
method will give is a quick preview of the path the spline will run along. Press the "Edit Path" button to start edit a spline. Hold down Control and press the left mouse button to add new points and use the position and rotation tool to edit existing points. In the future Shift will be used to remove points, but that requires a mesh to work with, something we don't have yet.



Next post will cover how we can use this spline and generate a mesh around it.