using UnityEditor;
using UnityEditor.EditorTools;
using UnityEditor.Splines;
using UnityEngine.UIElements;
using UnityEngine;
using UnityEngine.Splines;
using UnityEditor.UIElements;
using System;
using System.Collections.Generic;
namespace Unity.Cinemachine.Editor
{
[CustomEditor(typeof(CinemachineSplineDollyLookAtTargets))]
class CinemachineLookAtDataOnSplineEditor : CinemachineComponentBaseEditor
{
class UndRedoMonitor
{
int m_LastUndoRedoFrame;
public UndRedoMonitor() { Undo.undoRedoPerformed += () => m_LastUndoRedoFrame = Time.frameCount; }
public bool IsUndoRedo => Time.frameCount <= m_LastUndoRedoFrame + 1;
}
readonly UndRedoMonitor m_UndoRedoMonitor = new ();
///
/// This is needed to keep track of array values so that when they are changed by the user
/// we can peek at the previous values and decide if we need to update the offset
/// based on how the LookAt target changed.
/// We also use it to select the appropriate item in the ListView when the user manipulates
/// a data point in the scene view.
///
class InspectorStateCache
{
readonly List> m_DataPointsCache = new ();
public int CurrentSelection { get; set; } = -1;
public bool GetCachedValue(int index, out DataPoint value)
{
if (index >= 0 && index < m_DataPointsCache.Count)
{
value = m_DataPointsCache[index];
return true;
}
value = default;
return false;
}
public void Reset(CinemachineSplineDollyLookAtTargets splineData)
{
m_DataPointsCache.Clear();
for (int i = 0; i < splineData.Targets.Count; ++i)
m_DataPointsCache.Add(splineData.Targets[i]);
}
}
// We keep the state cache in a static dictionary so that it can be accessed by the gizmo drawer
static readonly Dictionary s_CacheLookup = new ();
static InspectorStateCache GetInspectorStateCache(CinemachineSplineDollyLookAtTargets splineData)
{
if (s_CacheLookup.TryGetValue(splineData, out var value))
return value;
value = new ();
s_CacheLookup.Add(splineData, value);
return value;
}
private void OnDisable()
{
var t = target as CinemachineSplineDollyLookAtTargets;
if (t != null && s_CacheLookup.ContainsKey(t))
s_CacheLookup.Remove(t);
}
public override VisualElement CreateInspectorGUI()
{
var ux = new VisualElement();
var splineData = target as CinemachineSplineDollyLookAtTargets;
splineData.GetGetSplineAndDolly(out var spline, out var dolly);
this.AddMissingCmCameraHelpBox(ux);
var invalidHelp = new HelpBox(
"This component requires a CinemachineSplineDolly component referencing a nonempty Spline",
HelpBoxMessageType.Warning);
ux.Add(invalidHelp);
var toolHelp = ux.AddChild(new HelpBox(
"Use the Scene View tool to Edit the LookAt targets on the spline", HelpBoxMessageType.Info));
toolHelp.OnInitialGeometry(() =>
{
var icon = toolHelp.Q(className: "unity-help-box__icon");
if (icon != null)
{
icon.style.backgroundImage = AssetDatabase.LoadAssetAtPath(LookAtDataOnSplineTool.IconPath);
icon.style.marginRight = 12;
}
});
ux.AddSpace();
ux.TrackAnyUserActivity(() =>
{
var haveSpline = splineData != null && splineData.GetGetSplineAndDolly(out _, out _);
invalidHelp.SetVisible(!haveSpline);
});
var targetsProp = serializedObject.FindProperty(() => splineData.Targets);
ux.Add(SplineDataInspectorUtility.CreatePathUnitField(targetsProp, ()
=> splineData.GetGetSplineAndDolly(out var spline, out _) ? spline : null));
ux.AddHeader("Data Points");
var list = ux.AddChild(SplineDataInspectorUtility.CreateDataListField(
splineData.Targets, targetsProp,
() => splineData.GetGetSplineAndDolly(out var spline, out _) ? spline : null,
() =>
{
// Create a default item for index 0
var item = new CinemachineSplineDollyLookAtTargets.Item { LookAt = splineData.VirtualCamera.LookAt, Easing = 1 };
if (item.LookAt == null)
{
// No LookAt? Find a point to look at near the spline
dolly.SplineSettings.GetCachedSpline().EvaluateSplineWithRoll(
spline.transform, 0, null, out var pos, out var rot);
item.WorldLookAt = pos + rot * Vector3.right * 3;
}
return item;
}));
var arrayProp = targetsProp.FindPropertyRelative("m_DataPoints");
list.makeItem = () =>
{
var itemRootName = "ItemRoot";
var row = new BindableElement() { name = itemRootName, style = { marginRight = 4 }};
var overlay = new VisualElement () { style = { flexDirection = FlexDirection.Row, flexGrow = 1 }};
var overlayLabel = overlay.AddChild(new Label("Index"));
var indexField1 = overlay.AddChild(InspectorUtility.CreateDraggableField(
typeof(float), "m_Index", SplineDataInspectorUtility.ItemIndexTooltip, overlayLabel, out var dragger));
indexField1.style.flexGrow = 1;
indexField1.style.flexBasis = 50;
indexField1.SafeSetIsDelayed();
dragger.OnDragValueChangedFloat = (v) => BringCameraToCustomSplinePoint(splineData, v);
dragger.OnStartDrag = (d) => list.selectedIndex = GetIndexInList(list, d.DragElement, itemRootName);
CinemachineSplineDollyLookAtTargets.Item def = new ();
var lookAtField1 = overlay.AddChild(new ObjectField
{
bindingPath = "m_Value." + SerializedPropertyHelper.PropertyName(() => def.LookAt),
tooltip = SerializedPropertyHelper.PropertyTooltip(() => def.LookAt),
objectType = typeof(Transform),
style = { flexGrow = 4, flexBasis = 50, marginLeft = 6 }
});
var foldout = new Foldout() { value = false, text = "Target" }; // do not bind to "m_Value" because it will mess up the binding for index
var indexRow = foldout.AddChild(new InspectorUtility.LabeledRow("Index", SplineDataInspectorUtility.ItemIndexTooltip));
var indexField2 = indexRow.Contents.AddChild(InspectorUtility.CreateDraggableField(
typeof(float), "m_Index", SplineDataInspectorUtility.ItemIndexTooltip, indexRow.Label, out dragger));
indexField2.style.flexGrow = 1;
indexField2.style.flexBasis = 50;
indexField2.SafeSetIsDelayed();
dragger.OnDragValueChangedFloat = (v) => BringCameraToCustomSplinePoint(splineData, v);
dragger.OnStartDrag = (d) => list.selectedIndex = GetIndexInList(list, d.DragElement, itemRootName);
var lookAtField2 = foldout.AddChild(new PropertyField { bindingPath = "m_Value." + SerializedPropertyHelper.PropertyName(() => def.LookAt) });
foldout.Add(new PropertyField { bindingPath = "m_Value." + SerializedPropertyHelper.PropertyName(() => def.Offset) });
foldout.Add(new PropertyField { bindingPath = "m_Value." + SerializedPropertyHelper.PropertyName(() => def.Easing) });
var foldoutWithOverlay = row.AddChild(new InspectorUtility.FoldoutWithOverlay(
foldout, overlay, overlayLabel) { style = { marginLeft = 12 }});
foldoutWithOverlay.OpenFoldout.name = foldoutWithOverlay.ClosedFoldout.name = "ItemFoldout";
// When the LookAt is changed, we want to do a little processing to fix up the offset
lookAtField1.RegisterValueChangedCallback((evt) => OnLookAtChanged(GetIndexInList(list, row, itemRootName)));
return row;
// Sneaky way to find out which list element we are
static int GetIndexInList(ListView list, VisualElement element, string itemRootName)
{
var container = list.Q("unity-content-container");
if (container != null)
{
while (element != null && element.name != itemRootName)
element = element.parent;
if (element != null)
return container.IndexOf(element);
}
return - 1;
}
void OnLookAtChanged(int index)
{
// Don't mess with the offset if change was a result of undo/redo
if (m_UndoRedoMonitor.IsUndoRedo || index < 0 || index >= arrayProp.arraySize)
return;
var offsetProp = arrayProp.GetArrayElementAtIndex(index).FindPropertyRelative("m_Value.Offset");
var lookAtProp = arrayProp.GetArrayElementAtIndex(index).FindPropertyRelative("m_Value.LookAt");
// if lookAt target was set to null, preserve the worldspace location
if (GetInspectorStateCache(splineData).GetCachedValue(index, out var previous))
{
var newData = lookAtProp.objectReferenceValue;
if (newData == null && previous.Value.LookAt != null)
SetOffset(previous.Value.WorldLookAt);
// if lookAt target was changed, zero the offset
else if (newData != null && newData != previous.Value.LookAt)
SetOffset(Vector3.zero);
// local function
void SetOffset(Vector3 offset)
{
offsetProp.vector3Value = offset;
lookAtProp.serializedObject.ApplyModifiedProperties();
}
}
}
};
list.TrackPropertyWithInitialCallback(arrayProp, (p) =>
{
// Fix up the foldout names to reflect the index of the item
int index = 0;
list.Query("ItemFoldout").ForEach((e) =>
{
if (e is Foldout f)
f.text = $"Target {index++ / 2}"; // because there are 2 foldouts for each item
});
// Reset the state cache after all processing is done
EditorApplication.delayCall += () => GetInspectorStateCache(splineData).Reset(splineData);
});
// When the list selection changes, cache the index and put the camera at that point on the dolly track
list.selectedIndicesChanged += (indices) =>
{
var it = indices.GetEnumerator();
var cache = GetInspectorStateCache(splineData);
cache.CurrentSelection = it.MoveNext() ? it.Current : -1;
BringCameraToSplinePoint(splineData, cache.CurrentSelection);
};
LookAtDataOnSplineTool.s_OnDataLookAtDragged += OnToolDragged;
LookAtDataOnSplineTool.s_OnDataIndexDragged += OnToolDragged;
void OnToolDragged(CinemachineSplineDollyLookAtTargets data, int index)
{
EditorApplication.delayCall += () =>
{
// GML This is a hack to avoid spurious exceptions thrown by uitoolkit!
// GML TODO: Remove when they fix it
try
{
if (data == splineData)
{
list.selectedIndex = index;
BringCameraToSplinePoint(data, index);
}
}
catch {} // Ignore exceptions
};
}
return ux;
}
static void BringCameraToSplinePoint(CinemachineSplineDollyLookAtTargets splineData, int index)
{
if (splineData != null && index >= 0)
BringCameraToCustomSplinePoint(splineData, splineData.Targets[index].Index);
}
static void BringCameraToCustomSplinePoint(CinemachineSplineDollyLookAtTargets splineData, float splineIndex)
{
if (splineData != null && splineData.GetGetSplineAndDolly(out var spline, out var dolly))
{
Undo.RecordObject(dolly, "Modifying CinemachineSplineDollyLookAtTargets values");
dolly.CameraPosition = dolly.SplineSettings.GetCachedSpline().ConvertIndexUnit(
splineIndex, splineData.Targets.PathIndexUnit, dolly.PositionUnits);
EditorApplication.delayCall += () => InspectorUtility.RepaintGameView();
}
}
[DrawGizmo(GizmoType.Active | GizmoType.NotInSelectionHierarchy
| GizmoType.InSelectionHierarchy | GizmoType.Pickable, typeof(CinemachineSplineDollyLookAtTargets))]
static void DrawGizmos(CinemachineSplineDollyLookAtTargets splineData, GizmoType selectionType)
{
// For performance reasons, we only draw a gizmo for the current active game object
if (Selection.activeGameObject == splineData.gameObject && splineData.Targets.Count > 0
&& splineData.GetGetSplineAndDolly(out var splineContainer, out var dolly))
{
var spline = dolly.SplineSettings.GetCachedSpline();
var c = CinemachineCorePrefs.BoundaryObjectGizmoColour.Value;
if (ToolManager.activeToolType != typeof(LookAtDataOnSplineTool))
c.a = 0.5f;
var inspectorCache = GetInspectorStateCache(splineData);
var indexUnit = splineData.Targets.PathIndexUnit;
for (int i = 0; i < splineData.Targets.Count; i++)
{
var t = SplineUtility.GetNormalizedInterpolation(spline, splineData.Targets[i].Index, indexUnit);
var position = spline.EvaluateSplinePosition(splineContainer.transform, t);
var p = splineData.Targets[i].Value.WorldLookAt;
if (inspectorCache != null && inspectorCache.CurrentSelection == i)
Gizmos.color = CinemachineCorePrefs.BoundaryObjectGizmoColour.Value;
else
Gizmos.color = c;
Gizmos.DrawLine(position, p);
Gizmos.DrawSphere(p, HandleUtility.GetHandleSize(p) * 0.1f);
}
}
}
}
[EditorTool("Spline Dolly LookAt Targets Tool", typeof(CinemachineSplineDollyLookAtTargets))]
class LookAtDataOnSplineTool : EditorTool
{
GUIContent m_IconContent;
public override GUIContent toolbarIcon => m_IconContent;
public static Action s_OnDataIndexDragged;
public static Action s_OnDataLookAtDragged;
public static string IconPath => $"{CinemachineSceneToolHelpers.IconPath}/CmSplineLookAtTargetsTool@256.png";
void OnEnable()
{
m_IconContent = new ()
{
image = AssetDatabase.LoadAssetAtPath(IconPath),
tooltip = "Assign LookAt targets to positions on the spline."
};
}
public override void OnToolGUI(EditorWindow window)
{
var splineData = target as CinemachineSplineDollyLookAtTargets;
if (splineData == null || !splineData.GetGetSplineAndDolly(out var splineContainer, out var dolly))
return;
Undo.RecordObject(splineData, "Modifying CinemachineSplineDollyLookAtTargets values");
using (new Handles.DrawingScope(Handles.selectedColor))
{
var spline = new NativeSpline(splineContainer.Spline, splineContainer.transform.localToWorldMatrix);
int changedIndex = DrawDataPointHandles(spline, splineData);
if (changedIndex >= 0)
s_OnDataLookAtDragged?.Invoke(splineData, changedIndex);
changedIndex = DrawIndexPointHandles(spline, splineData);
if (changedIndex >= 0)
s_OnDataIndexDragged?.Invoke(splineData, changedIndex);
}
}
int DrawIndexPointHandles(ISpline spline, CinemachineSplineDollyLookAtTargets splineData)
{
int anchorId = GUIUtility.GetControlID(FocusType.Passive);
spline.DataPointHandles(splineData.Targets);
var nearestIndex = ControlIdToIndex(anchorId, HandleUtility.nearestControl, splineData.Targets.Count);
var hotIndex = ControlIdToIndex(anchorId, GUIUtility.hotControl, splineData.Targets.Count);
var tooltipIndex = hotIndex >= 0 ? hotIndex : nearestIndex;
if (tooltipIndex >= 0)
DrawTooltip(spline, splineData, tooltipIndex, false);
// Return the index that's being changed, or -1
return hotIndex;
// Local function
static int ControlIdToIndex(int anchorId, int controlId, int targetCount)
{
int index = controlId - anchorId - 2;
return index >= 0 && index < targetCount ? index : -1;
}
}
int DrawDataPointHandles(ISpline spline, CinemachineSplineDollyLookAtTargets splineData)
{
int changed = -1;
for (var i = 0; i < splineData.Targets.Count; ++i)
{
var dataPoint = splineData.Targets[i];
int anchorId0 = GUIUtility.GetControlID(FocusType.Passive);
var newPos = Handles.PositionHandle(dataPoint.Value.WorldLookAt, Quaternion.identity);
int anchorId1 = GUIUtility.GetControlID(FocusType.Passive);
var nearestIndex = HandleUtility.nearestControl > anchorId0 && HandleUtility.nearestControl < anchorId1 ? i : -1;
var hotIndex = GUIUtility.hotControl > anchorId0 && GUIUtility.hotControl < anchorId1 ? i : -1;
var tooltipIndex = hotIndex >= 0 ? hotIndex : nearestIndex;
if (tooltipIndex >= 0)
DrawTooltip(spline, splineData, tooltipIndex, true);
if (newPos != dataPoint.Value.WorldLookAt)
{
var item = dataPoint.Value;
item.WorldLookAt = newPos;
dataPoint.Value = item;
splineData.Targets[i] = dataPoint;
changed = i;
}
}
return changed;
}
void DrawTooltip(ISpline spline, CinemachineSplineDollyLookAtTargets splineData, int index, bool useLookAt)
{
var dataPoint = splineData.Targets[index];
var haveLookAt = dataPoint.Value.LookAt != null;
var targetText = haveLookAt ? dataPoint.Value.LookAt.name : dataPoint.Value.WorldLookAt.ToString();
if (haveLookAt && dataPoint.Value.Offset != Vector3.zero)
targetText += $" + {dataPoint.Value.Offset}";
var text = $"Target {index}\nIndex: {dataPoint.Index}\nLookAt: {targetText}";
var t = SplineUtility.GetNormalizedInterpolation(spline, dataPoint.Index, splineData.Targets.PathIndexUnit);
var p0 = spline.EvaluatePosition(t);
var p1 = dataPoint.Value.WorldLookAt;
CinemachineSceneToolHelpers.DrawLabel(useLookAt ? p1 : p0, text);
// Highlight the view line
Handles.DrawLine(p0, p1, Handles.lineThickness + 2);
}
}
}