I needed this too, so here’s what I did.
I wanted to:
- Show the splines drawn in the editor regardless of being selected so I could line multiple splines up. That’s where void OnScene () comes into play.
- The nodes were originally built for local coordinates. I wanted both local and world when editing the points.
using System.Collections;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
[CustomEditor(typeof(Spline))]
public class SplineEditor : Editor {
private const int QUAD_SIZE = 12;
private Color CURVE_COLOR = new Color(0.8f, 0.8f, 0.8f);
private Color CURVE_BUTTON_COLOR = new Color(0.8f, 0.8f, 0.8f);
private Color DIRECTION_COLOR = Color.red;
private Color DIRECTION_BUTTON_COLOR = Color.red;
private enum SelectionType {
Node,
Direction,
InverseDirection
}
private SplineNode selection;
private SelectionType selectionType;
private bool mustCreateNewNode = false;
private SerializedProperty nodes;
private Spline spline;
private GUIStyle nodeButtonStyle, directionButtonStyle;
SplineEditor ()
{
SceneView.onSceneGUIDelegate += OnScene;
}
private void OnEnable() {
spline = (Spline)target;
nodes = serializedObject.FindProperty("nodes");
Texture2D t = new Texture2D(1, 1);
t.SetPixel(0, 0, CURVE_BUTTON_COLOR);
t.Apply();
nodeButtonStyle = new GUIStyle();
nodeButtonStyle.normal.background = t;
t = new Texture2D(1, 1);
t.SetPixel(0, 0, DIRECTION_BUTTON_COLOR);
t.Apply();
directionButtonStyle = new GUIStyle();
directionButtonStyle.normal.background = t;
}
SplineNode AddClonedNode(SplineNode node) {
int index = spline.nodes.IndexOf(node);
SplineNode res = new SplineNode(node.position, node.direction);
if (index == spline.nodes.Count - 1) {
spline.AddNode(res);
} else {
spline.InsertNode(index + 1, res);
}
return res;
}
void DeleteNode(SplineNode node)
{
if (spline.nodes.Count > 2)
spline.RemoveNode(node);
}
void OnScene(SceneView scene)
{
if (spline == null)
return;
// draw a bezier curve for each curve in the spline
foreach (CubicBezierCurve curve in spline.GetCurves()) {
Handles.DrawBezier(spline.transform.TransformPoint(curve.n1.position),
spline.transform.TransformPoint(curve.n2.position),
spline.transform.TransformPoint(curve.n1.direction),
spline.transform.TransformPoint(curve.GetInverseDirection()),
CURVE_COLOR,
null,
3);
}
}
void OnSceneGUI()
{
Event e = Event.current;
if (e.type == EventType.mouseDown)
{
Undo.RegisterCompleteObjectUndo(spline, "change spline topography");
// if alt key pressed, we will have to create a new node if node position is changed
if (e.alt) {
mustCreateNewNode = true;
}
}
if (e.type == EventType.mouseUp)
{
mustCreateNewNode = false;
}
// disable game object transform gyzmo
if (Selection.activeGameObject == spline.gameObject) {
Tools.current = Tool.None;
if (selection == null && spline.nodes.Count > 0)
selection = spline.nodes[0];
}
// draw a bezier curve for each curve in the spline
foreach (CubicBezierCurve curve in spline.GetCurves()) {
Handles.DrawBezier(spline.transform.TransformPoint(curve.n1.position),
spline.transform.TransformPoint(curve.n2.position),
spline.transform.TransformPoint(curve.n1.direction),
spline.transform.TransformPoint(curve.GetInverseDirection()),
CURVE_COLOR,
null,
3);
}
// draw the selection handles
switch (selectionType) {
case SelectionType.Node:
// place a handle on the node and manage position change
Vector3 newPosition = spline.transform.InverseTransformPoint(Handles.PositionHandle(spline.transform.TransformPoint(selection.position), Quaternion.identity));
if (newPosition != selection.position) {
// position handle has been moved
if (mustCreateNewNode) {
mustCreateNewNode = false;
selection = AddClonedNode(selection);
selection.SetDirection(selection.direction + newPosition - selection.position);
selection.SetPosition(newPosition);
} else {
selection.SetDirection(selection.direction + newPosition - selection.position);
selection.SetPosition(newPosition);
}
}
break;
case SelectionType.Direction:
selection.SetDirection(spline.transform.InverseTransformPoint(Handles.PositionHandle(spline.transform.TransformPoint(selection.direction), Quaternion.identity)));
break;
case SelectionType.InverseDirection:
selection.SetDirection(2 * selection.position - spline.transform.InverseTransformPoint(Handles.PositionHandle(2 * spline.transform.TransformPoint(selection.position) - spline.transform.TransformPoint(selection.direction), Quaternion.identity)));
break;
}
// draw the handles of all nodes, and manage selection motion
Handles.BeginGUI();
foreach (SplineNode n in spline.nodes)
{
Vector3 guiPos = HandleUtility.WorldToGUIPoint(spline.transform.TransformPoint(n.position));
if (n == selection) {
Vector3 guiDir = HandleUtility.WorldToGUIPoint(spline.transform.TransformPoint(n.direction));
Vector3 guiInvDir = HandleUtility.WorldToGUIPoint(spline.transform.TransformPoint(2 * n.position - n.direction));
// for the selected node, we also draw a line and place two buttons for directions
Handles.color = Color.red;
Handles.DrawLine(guiDir, guiInvDir);
// draw quads direction and inverse direction if they are not selected
if (selectionType != SelectionType.Node) {
if (Button(guiPos, directionButtonStyle)) {
selectionType = SelectionType.Node;
}
}
if (selectionType != SelectionType.Direction) {
if (Button(guiDir, directionButtonStyle)) {
selectionType = SelectionType.Direction;
}
}
if (selectionType != SelectionType.InverseDirection) {
if (Button(guiInvDir, directionButtonStyle)) {
selectionType = SelectionType.InverseDirection;
}
}
} else {
if (Button(guiPos, nodeButtonStyle)) {
selection = n;
selectionType = SelectionType.Node;
}
}
}
Handles.EndGUI();
if (GUI.changed)
EditorUtility.SetDirty(target);
}
bool Button(Vector2 position, GUIStyle style) {
return GUI.Button(new Rect(position - new Vector2(QUAD_SIZE / 2, QUAD_SIZE / 2), new Vector2(QUAD_SIZE, QUAD_SIZE)), GUIContent.none, style);
}
public override void OnInspectorGUI() {
serializedObject.Update();
// hint
EditorGUILayout.HelpBox("Hold Alt and drag a node to create a new one.", MessageType.Info);
// delete button
if(selection == null || spline.nodes.Count <= 2) {
GUI.enabled = false;
}
if (GUILayout.Button("Delete selected node")) {
Undo.RegisterCompleteObjectUndo(spline, "delete spline node");
DeleteNode(selection);
selection = null;
}
GUI.enabled = true;
// nodes
// This special editor prevent the user to modify the list because nodes are listened.
// But I can't understand why these guys are not editable in the inspector...
EditorGUILayout.PropertyField(nodes);
EditorGUI.indentLevel += 1;
if (nodes.isExpanded) {
for (int i = 0; i < nodes.arraySize; i++) {
//I made it editable
Vector3 localPointV3;
EditorGUILayout.LabelField("Node " + i + ":");
EditorGUI.BeginChangeCheck();
localPointV3 = EditorGUILayout.Vector3Field("World Position:", spline.transform.TransformPoint (spline.nodes [i].position));
if (EditorGUI.EndChangeCheck()) {
Undo.RecordObject(spline, "Move Point");
spline.nodes [i].SetPosition (spline.transform.InverseTransformPoint (localPointV3));
EditorUtility.SetDirty(spline);
}
EditorGUI.BeginChangeCheck();
localPointV3 = EditorGUILayout.Vector3Field("World Direction:", spline.transform.TransformPoint (spline.nodes [i].direction));
if (EditorGUI.EndChangeCheck()) {
Undo.RecordObject(spline, "Move Point");
spline.nodes [i].SetDirection (spline.transform.InverseTransformDirection (localPointV3));
EditorUtility.SetDirty(spline);
}
EditorGUI.BeginChangeCheck();
localPointV3 = EditorGUILayout.Vector3Field("Local Position:", spline.nodes [i].position);
if (EditorGUI.EndChangeCheck()) {
Undo.RecordObject(spline, "Move Point");
spline.nodes [i].SetPosition (localPointV3);
EditorUtility.SetDirty(spline);
}
EditorGUI.BeginChangeCheck();
localPointV3 = EditorGUILayout.Vector3Field("Local Direction:", spline.nodes [i].direction);
if (EditorGUI.EndChangeCheck()) {
Undo.RecordObject(spline, "Move Point");
spline.nodes [i].SetDirection (localPointV3);
EditorUtility.SetDirty(spline);
}
// EditorGUILayout.PropertyField(nodes.GetArrayElementAtIndex(i), new GUIContent("Node " + i), true);
}
}
EditorGUI.indentLevel -= 1;
serializedObject.ApplyModifiedProperties();
}
[MenuItem("GameObject/3D Object/Spline")]
public static void CreateSpline() {
new GameObject("Spline", typeof(Spline));
}
public Vector3 ToVector3 (string s)
{
string[] temp = s.Substring (1, s.Length-2).Split (',');
return new Vector3 (float.Parse(temp[0]), float.Parse(temp[1]), float.Parse(temp[2]));
}
}
Next up, I wanted to extrude a shape along a spline and I wanted to have multiple instances that were exclusive. Previously you could only have 1 extruded mesh and they were all the same mesh if you had more than 1 instance. I also made sure to apply this new mesh to the mesh colliders if they existed.
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// Special component to extrude shape along a spline.
///
/// Note : This component is not lightweight and should be used as-is mostly for prototyping. It allows to quickly create meshes by
/// drawing only the 2D shape to extrude along the spline. The result is not intended to be used in a production context and you will most likely
/// create eventualy the mesh you need in a modeling tool to save performances and have better control.
///
/// The special editor of this component allow you to draw a 2D shape with vertices, normals and U texture coordinate. The V coordinate is set
/// for the whole spline, by setting the number of times the texture must be repeated.
///
/// All faces of the resulting mesh are smoothed. If you want to obtain an edge without smoothing, you will have to overlap two vertices and set two normals.
///
/// You can expand the vertices list in the inspector to access data and enter precise values.
///
/// This component doesn't offer much control as Unity is not a modeling tool. That said, you should be able to create your own version easily.
/// </summary>
[ExecuteInEditMode]
[RequireComponent(typeof(MeshFilter))]
[RequireComponent(typeof(MeshRenderer))]
[RequireComponent(typeof(Spline))]
public class SplineExtrusion : MonoBehaviour {
private MeshFilter mf;
public Spline spline;
public float TextureScale = 1;
public List<Vertex> ShapeVertices = new List<Vertex>();
private bool toUpdate = true;
/// <summary>
/// Clear shape vertices, then create three vertices with three normals for the extrusion to be visible
/// </summary>
private void Reset() {
ShapeVertices.Clear();
ShapeVertices.Add(new Vertex(new Vector2(0, 0.5f), new Vector2(0, 1), 0));
ShapeVertices.Add(new Vertex(new Vector2(1, -0.5f), new Vector2(1, -1), 0.33f));
ShapeVertices.Add(new Vertex(new Vector2(-1, -0.5f), new Vector2(-1, -1), 0.66f));
toUpdate = true;
OnEnable();
}
private void OnValidate() {
toUpdate = true;
}
private void OnEnable() {
mf = GetComponent<MeshFilter>();
spline = GetComponent<Spline>();
// if (mf.sharedMesh == null) {
mf.sharedMesh = new Mesh();
// }
spline.NodeCountChanged.AddListener(() => toUpdate = true);
spline.CurveChanged.AddListener(() => toUpdate = true);
}
private void Update() {
if (toUpdate) {
if (mf.sharedMesh == null) {
mf.sharedMesh = new Mesh();
}
GenerateMesh();
toUpdate = false;
}
}
private List<OrientedPoint> GetPath()
{
var path = new List<OrientedPoint>();
for (float t = 0; t < spline.nodes.Count-1; t += 1/10.0f)
{
var point = spline.GetLocationAlongSpline(t);
var rotation = CubicBezierCurve.GetRotationFromTangent(spline.GetTangentAlongSpline(t));
path.Add(new OrientedPoint(point, rotation));
}
return path;
}
public void GenerateMesh() {
List<OrientedPoint> path = GetPath();
int vertsInShape = ShapeVertices.Count;
int segments = path.Count - 1;
int edgeLoops = path.Count;
int vertCount = vertsInShape * edgeLoops;
var triangleIndices = new List<int>(vertsInShape * 2 * segments * 3);
var vertices = new Vector3[vertCount];
var normals = new Vector3[vertCount];
var uvs = new Vector2[vertCount];
int index = 0;
foreach(OrientedPoint op in path) {
foreach(Vertex v in ShapeVertices) {
vertices[index] = op.LocalToWorld(v.point);
normals[index] = op.LocalToWorldDirection(v.normal);
uvs[index] = new Vector2(v.uCoord, path.IndexOf(op) / ((float)edgeLoops)* TextureScale);
index++;
}
}
index = 0;
for (int i = 0; i < segments; i++) {
for (int j = 0; j < ShapeVertices.Count; j++) {
int offset = j == ShapeVertices.Count - 1 ? -(ShapeVertices.Count - 1) : 1;
int a = index + ShapeVertices.Count;
int b = index;
int c = index + offset;
int d = index + offset + ShapeVertices.Count;
triangleIndices.Add(c);
triangleIndices.Add(b);
triangleIndices.Add(a);
triangleIndices.Add(a);
triangleIndices.Add(d);
triangleIndices.Add(c);
index++;
}
}
mf.sharedMesh.Clear();
mf.sharedMesh.vertices = vertices;
mf.sharedMesh.normals = normals;
mf.sharedMesh.uv = uvs;
mf.sharedMesh.triangles = triangleIndices.ToArray();
MeshCollider findMC = GetComponent<MeshCollider> ();
if (findMC != null)
findMC.sharedMesh = mf.sharedMesh;
}
[Serializable]
public class Vertex
{
public Vector2 point;
public Vector2 normal;
public float uCoord;
public Vertex(Vector2 point, Vector2 normal, float uCoord)
{
this.point = point;
this.normal = normal;
this.uCoord = uCoord;
}
}
public struct OrientedPoint
{
public Vector3 position;
public Quaternion rotation;
public OrientedPoint(Vector3 position, Quaternion rotation)
{
this.position = position;
this.rotation = rotation;
}
public Vector3 LocalToWorld(Vector3 point)
{
return position + rotation * point;
}
public Vector3 LocalToWorldDirection(Vector3 dir)
{
return rotation * dir;
}
}
}