Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion Editor/Graph/TransitionEdge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -193,9 +193,15 @@ public override bool UpdateEdgeControl()
}

/// <summary>The node's visible rectangle expressed in this edge's local coordinate space.</summary>
/// <remarks>
/// Clips against <c>#node-border</c> (the rounded body) rather than the outer Node element.
/// Unity's default node styling leaves a few pixels of inset between the two, so using the
/// outer element made transitions terminate in empty space outside the visible border.
/// </remarks>
Rect NodeRectInEdgeSpace(VisualElement node)
{
Rect world = node.worldBound;
VisualElement bordered = node.Q("node-border") ?? node;
Rect world = bordered.worldBound;
Vector2 min = this.WorldToLocal(new Vector2(world.xMin, world.yMin));
Vector2 max = this.WorldToLocal(new Vector2(world.xMax, world.yMax));
return new Rect(min, max - min);
Expand Down
347 changes: 346 additions & 1 deletion Editor/Panels/InspectorPanel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ class InspectorPanel : PanelBase
object _lastSelection;
bool _showBlendTree = true;
List<ControllerAnalyzer.Issue> _issues;
// VRC Parameter Driver: remembered "selected row" per behaviour so the Add/Up/Down/Delete
// buttons know which entry they act on. Keyed by StateMachineBehaviour instance ID; stale
// entries are harmless (Unity domain reload clears them) so we don't bother pruning.
readonly Dictionary<int, int> _vrcDriverSelectedIndex = new Dictionary<int, int>();

public InspectorPanel(DaerDContext context, AnimatorGraphView graphView)
: base(context, "Inspector")
Expand Down Expand Up @@ -547,14 +551,355 @@ void DrawBehaviours(AnimatorState state)
GUIUtility.ExitGUI();
}
EditorGUILayout.EndHorizontal();
DrawSerializedFields(behaviour);
if (!TryDrawKnownVrcBehaviour(behaviour))
DrawSerializedFields(behaviour);
EditorGUILayout.EndVertical();
}

if (GUILayout.Button("+ Add Behaviour"))
ShowAddBehaviourMenu(state);
}

/// <summary>
/// Render VRC SDK behaviours (Tracking Control, Parameter Driver) with a UI matching
/// their native inspector. Detected by type name, so we don't need to reference VRCSDK.
/// Returns true if the behaviour was drawn — caller should skip the generic renderer.
/// </summary>
bool TryDrawKnownVrcBehaviour(StateMachineBehaviour behaviour)
{
switch (behaviour.GetType().Name)
{
case "VRCAnimatorTrackingControl": DrawVrcTrackingControl(behaviour); return true;
case "VRCAvatarParameterDriver": DrawVrcParameterDriver(behaviour); return true;
default: return false;
}
}

// Body part rows in the order the VRCSDK inspector shows them: display label + the
// matching serialized property name on VRCAnimatorTrackingControl.
static readonly (string label, string property)[] VrcTrackingTargets =
{
("Head", "trackingHead"),
("Left Hand", "trackingLeftHand"),
("Right Hand", "trackingRightHand"),
("Hip", "trackingHip"),
("Left Foot", "trackingLeftFoot"),
("Right Foot", "trackingRightFoot"),
("Left Fingers", "trackingLeftFingers"),
("Right Fingers", "trackingRightFingers"),
("Eyes & Eyelids", "trackingEyes"),
("Mouth & Jaw", "trackingMouth"),
};
static readonly string[] VrcTrackingColumns = { "No Change", "Tracking", "Animation" };

void DrawVrcTrackingControl(StateMachineBehaviour behaviour)
{
var so = new SerializedObject(behaviour);
so.Update();

EditorGUILayout.LabelField("Tracking Control", EditorStyles.miniBoldLabel);

// Column headers use the same subdivision as the rows so they stay aligned when
// the inspector is resized.
var headerRect = GUILayoutUtility.GetRect(0, EditorGUIUtility.singleLineHeight,
GUILayout.ExpandWidth(true));
var headerCols = SubdivideTrackingRow(headerRect);
for (int i = 0; i < VrcTrackingColumns.Length; i++)
GUI.Label(headerCols[i + 1], VrcTrackingColumns[i], TrackingColumnHeaderStyle);

// "All" row acts as a bulk selector: shows the common value across every body part
// (or nothing when they diverge) and, when clicked, forces that column onto them all.
int commonValue = -1;
bool commonKnown = true;
foreach (var (_, prop) in VrcTrackingTargets)
{
var p = so.FindProperty(prop);
if (p == null) continue;
if (commonValue == -1) commonValue = p.intValue;
else if (commonValue != p.intValue) { commonKnown = false; break; }
}
int allPicked = DrawVrcTrackingRow("All", commonKnown ? commonValue : -1);
if (allPicked >= 0 && (!commonKnown || allPicked != commonValue))
{
foreach (var (_, prop) in VrcTrackingTargets)
{
var p = so.FindProperty(prop);
if (p != null) p.intValue = allPicked;
}
}

foreach (var (label, propPath) in VrcTrackingTargets)
{
var prop = so.FindProperty(propPath);
if (prop == null) continue;
int picked = DrawVrcTrackingRow(label, prop.intValue);
if (picked >= 0 && picked != prop.intValue)
prop.intValue = picked;
}

var debug = so.FindProperty("debugString");
if (debug != null)
{
EditorGUILayout.PropertyField(debug, new GUIContent("Debug String"));
}

so.ApplyModifiedProperties();
}

static GUIStyle _trackingColumnHeaderStyle;
static GUIStyle TrackingColumnHeaderStyle
{
get
{
if (_trackingColumnHeaderStyle == null)
_trackingColumnHeaderStyle = new GUIStyle(EditorStyles.miniLabel)
{
alignment = TextAnchor.MiddleCenter,
};
return _trackingColumnHeaderStyle;
}
}

/// <summary>
/// Splits a full-width row rect into [label, col0, col1, col2] with widths that scale
/// with the available space. Cells shrink before the label does, and both keep a floor
/// so the checkboxes stay clickable in a narrow inspector.
/// </summary>
static Rect[] SubdivideTrackingRow(Rect row)
{
const float preferredLabelFraction = 0.34f;
const float minLabel = 44f;
const float minCell = 28f;

float labelWidth = Mathf.Max(minLabel, row.width * preferredLabelFraction);
float cellWidth = (row.width - labelWidth) / 3f;
if (cellWidth < minCell)
{
cellWidth = minCell;
labelWidth = Mathf.Max(0f, row.width - cellWidth * 3f);
}
return new[]
{
new Rect(row.x, row.y, labelWidth, row.height),
new Rect(row.x + labelWidth, row.y, cellWidth, row.height),
new Rect(row.x + labelWidth + cellWidth, row.y, cellWidth, row.height),
new Rect(row.x + labelWidth + cellWidth * 2f, row.y, cellWidth, row.height),
};
}

/// <summary>
/// Row of three exclusive tri-state checkboxes. Returns the column the user just clicked
/// on (0/1/2), or -1 if nothing changed this frame.
/// </summary>
static int DrawVrcTrackingRow(string label, int currentValue)
{
var rowRect = GUILayoutUtility.GetRect(0, EditorGUIUtility.singleLineHeight,
GUILayout.ExpandWidth(true));
var cols = SubdivideTrackingRow(rowRect);
GUI.Label(cols[0], label);
int picked = -1;
for (int i = 0; i < 3; i++)
{
bool wasOn = currentValue == i;
// Centre the checkbox inside its column so the grid reads as neatly aligned as
// the reference — a bare GUI.Toggle would hug the left edge of its cell.
var cell = cols[i + 1];
var box = new Rect(cell.x + (cell.width - 16f) * 0.5f, cell.y, 16f, cell.height);
bool nowOn = GUI.Toggle(box, wasOn, GUIContent.none);
if (nowOn && !wasOn) picked = i;
}
return picked;
}

// Text lifted from the VRCSDK inspector so users get the same guidance.
const string VrcParameterDriverInfo =
"This behaviour modifies parameters on this and all other animation controllers referenced on the avatar descriptor.\n" +
"\n" +
"Keep in mind only parameters defined in your VRCExpressionParameter object will be synced across the network.\n" +
"\n" +
"Additionally, synced parameters are clamped between Int [0,255] and Float [-1,1]. Operations that modify these parameters will be clipped inside those bounds.";

void DrawVrcParameterDriver(StateMachineBehaviour behaviour)
{
var so = new SerializedObject(behaviour);
so.Update();

EditorGUILayout.HelpBox(VrcParameterDriverInfo, MessageType.Info);

var localOnly = so.FindProperty("localOnly");
if (localOnly != null)
EditorGUILayout.PropertyField(localOnly, new GUIContent("Local Only"));

var debugString = so.FindProperty("debugString");
if (debugString != null)
EditorGUILayout.PropertyField(debugString, new GUIContent("Debug String"));

var parameters = so.FindProperty("parameters");
if (parameters == null || !parameters.isArray)
{
so.ApplyModifiedProperties();
return;
}

int instanceId = behaviour.GetInstanceID();
int selected = _vrcDriverSelectedIndex.TryGetValue(instanceId, out var stored) ? stored : 0;
if (parameters.arraySize == 0) selected = -1;
else selected = Mathf.Clamp(selected, 0, parameters.arraySize - 1);

EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("Add"))
{
parameters.arraySize++;
selected = parameters.arraySize - 1;
}
using (new EditorGUI.DisabledScope(selected <= 0))
{
if (GUILayout.Button("Up"))
{
parameters.MoveArrayElement(selected, selected - 1);
selected--;
}
}
using (new EditorGUI.DisabledScope(selected < 0 || selected >= parameters.arraySize - 1))
{
if (GUILayout.Button("Down"))
{
parameters.MoveArrayElement(selected, selected + 1);
selected++;
}
}
using (new EditorGUI.DisabledScope(selected < 0))
{
if (GUILayout.Button("Delete"))
{
parameters.DeleteArrayElementAtIndex(selected);
if (parameters.arraySize == 0) selected = -1;
else selected = Mathf.Clamp(selected, 0, parameters.arraySize - 1);
}
}
EditorGUILayout.EndHorizontal();

var controllerParameters = Context.Controller?.parameters;
for (int i = 0; i < parameters.arraySize; i++)
{
DrawVrcDriverEntry(parameters.GetArrayElementAtIndex(i), i, ref selected, controllerParameters);
}

_vrcDriverSelectedIndex[instanceId] = selected;
so.ApplyModifiedProperties();
}

/// <summary>Renders one parameter entry, laying out the fields that apply to its Type.</summary>
void DrawVrcDriverEntry(SerializedProperty entry, int index, ref int selected,
AnimatorControllerParameter[] controllerParameters)
{
bool isSelected = index == selected;
// Highlight the active entry with a coloured background so it stands out like the
// native inspector's blue rows. GUI.backgroundColor tints EditorStyles.helpBox.
var savedBg = GUI.backgroundColor;
if (isSelected) GUI.backgroundColor = new Color(0.35f, 0.55f, 0.85f);
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
GUI.backgroundColor = savedBg;

// Clicking anywhere on this entry's box makes it the selected one.
var headerRect = GUILayoutUtility.GetRect(0, EditorGUIUtility.singleLineHeight);
EditorGUI.LabelField(headerRect, "Parameter " + index, EditorStyles.miniBoldLabel);
if (Event.current.type == EventType.MouseDown && headerRect.Contains(Event.current.mousePosition))
{
selected = index;
Event.current.Use();
}

var typeProp = entry.FindPropertyRelative("type");
var nameProp = entry.FindPropertyRelative("name");
var valueProp = entry.FindPropertyRelative("value");
var valueMinProp = entry.FindPropertyRelative("valueMin");
var valueMaxProp = entry.FindPropertyRelative("valueMax");
var chanceProp = entry.FindPropertyRelative("chance");
var sourceProp = entry.FindPropertyRelative("source");
var convertRangeProp = entry.FindPropertyRelative("convertRange");
var sourceMinProp = entry.FindPropertyRelative("sourceMin");
var sourceMaxProp = entry.FindPropertyRelative("sourceMax");
var destMinProp = entry.FindPropertyRelative("destMin");
var destMaxProp = entry.FindPropertyRelative("destMax");

if (typeProp != null)
EditorGUILayout.PropertyField(typeProp, new GUIContent("Type"));

int type = typeProp != null ? typeProp.intValue : 0;

// For Set/Add/Random the destination is `name`; for Copy VRCSDK reuses `name` as the
// Copy destination while `source` holds the parameter being read.
if (type == 3 && sourceProp != null)
EditorGUILayout.PropertyField(sourceProp, new GUIContent("Source"));
if (nameProp != null)
{
EditorGUILayout.PropertyField(nameProp, new GUIContent("Destination"));
if (controllerParameters != null
&& !string.IsNullOrEmpty(nameProp.stringValue)
&& !ControllerHasParameter(controllerParameters, nameProp.stringValue))
{
EditorGUILayout.HelpBox(
"Parameter '" + nameProp.stringValue + "' not found. Make sure you defined it in the Animator window's Parameter list.",
MessageType.Warning);
}
}

switch (type)
{
case 0: // Set
case 1: // Add
if (valueProp != null)
EditorGUILayout.PropertyField(valueProp, new GUIContent("Value"));
break;
case 2: // Random
if (valueMinProp != null)
EditorGUILayout.PropertyField(valueMinProp, new GUIContent("Value Min"));
if (valueMaxProp != null)
EditorGUILayout.PropertyField(valueMaxProp, new GUIContent("Value Max"));
if (chanceProp != null)
EditorGUILayout.PropertyField(chanceProp, new GUIContent("Chance"));
break;
case 3: // Copy
if (convertRangeProp != null)
{
EditorGUILayout.PropertyField(convertRangeProp, new GUIContent("Convert Range"));
if (convertRangeProp.boolValue)
{
DrawMinMaxRow("Source", sourceMinProp, sourceMaxProp);
DrawMinMaxRow("Destination", destMinProp, destMaxProp);
}
}
break;
}

EditorGUILayout.EndVertical();
}

static bool ControllerHasParameter(AnimatorControllerParameter[] parameters, string name)
{
foreach (var p in parameters)
if (p != null && p.name == name) return true;
return false;
}

/// <summary>
/// Draws Min and Max side by side on one row (single vertical slot instead of two) —
/// used for the Copy driver's Source / Destination ranges so the block stays compact.
/// </summary>
static void DrawMinMaxRow(string label, SerializedProperty minProp, SerializedProperty maxProp)
{
if (minProp == null && maxProp == null) return;
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField(label, GUILayout.Width(EditorGUIUtility.labelWidth));
float saved = EditorGUIUtility.labelWidth;
EditorGUIUtility.labelWidth = 30f;
if (minProp != null) EditorGUILayout.PropertyField(minProp, new GUIContent("Min"));
if (maxProp != null) EditorGUILayout.PropertyField(maxProp, new GUIContent("Max"));
EditorGUIUtility.labelWidth = saved;
EditorGUILayout.EndHorizontal();
}

void ShowAddBehaviourMenu(AnimatorState state)
{
var menu = new GenericMenu();
Expand Down
Loading
Loading