diff --git a/Packages/net.fushizen.modular-avatar/Editor/Inspector/AvatarParametersEditor.cs b/Packages/net.fushizen.modular-avatar/Editor/Inspector/AvatarParametersEditor.cs index 16b5973c..0a4c6614 100644 --- a/Packages/net.fushizen.modular-avatar/Editor/Inspector/AvatarParametersEditor.cs +++ b/Packages/net.fushizen.modular-avatar/Editor/Inspector/AvatarParametersEditor.cs @@ -1,7 +1,11 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; using UnityEditor; using UnityEditorInternal; using UnityEngine; +using UnityEngine.Serialization; namespace net.fushizen.modular_avatar.core.editor { @@ -17,27 +21,81 @@ namespace net.fushizen.modular_avatar.core.editor private ReorderableList _reorderableList; private SerializedProperty _parameters; + private bool _needsRebuild = false; + private float elemHeight; + private readonly List _selectedIndices = new List(); + private ImplicitParams _implicitParams = null; + private SerializedObject _implicitParamsObj; + private SerializedProperty _implicitParamsProp; + + private class ImplicitParams : ScriptableObject + { + public List ImplicitParameters = new List(); + } private void OnEnable() { SetupList(); } + private SerializedProperty GetParamByIndex(int idx) + { + if (idx >= 0) + { + return _parameters.GetArrayElementAtIndex(idx); + } + else + { + idx = -(idx + 1); + return _implicitParamsProp.GetArrayElementAtIndex(idx); + } + } + + private void DetectParameters() + { + if (_implicitParams == null || _implicitParamsProp == null) + { + _implicitParams = CreateInstance(); + _implicitParamsObj = new SerializedObject(_implicitParams); + _implicitParamsProp = _implicitParamsObj.FindProperty(nameof(ImplicitParams.ImplicitParameters)); + } + + var target = (ModularAvatarParameters) this.target; + var registered = target.parameters.Select(p => p.nameOrPrefix + (p.isPrefix ? "*" : "")) + .ToImmutableHashSet(); + + _implicitParams.ImplicitParameters.Clear(); + + var detected = ParameterPolicy.ProbeParameters(((ModularAvatarParameters) target).gameObject); + foreach (var param in detected.Values) + { + if (registered.Contains(param.MapKey)) continue; + + var config = new ParameterConfig() + { + internalParameter = false, + isPrefix = param.IsPrefix, + nameOrPrefix = param.OriginalName, + remapTo = "", + }; + + _implicitParams.ImplicitParameters.Add(config); + _selectedIndices.Add(-_implicitParams.ImplicitParameters.Count); + } + + _implicitParamsObj.Update(); + } + private void SetupList() { _parameters = serializedObject.FindProperty(nameof(ModularAvatarParameters.parameters)); if (_devMode) { - _reorderableList = new ReorderableList( - serializedObject, - _parameters, - true, true, true, true - ); - _reorderableList.drawHeaderCallback = DrawHeader; - _reorderableList.drawElementCallback = DrawElement; - _reorderableList.onAddCallback = AddElement; - _reorderableList.onRemoveCallback = RemoveElement; + var target = (ModularAvatarParameters) this.target; + + _selectedIndices.Clear(); + _selectedIndices.AddRange(Enumerable.Range(0, target.parameters.Count)); } else { @@ -50,42 +108,120 @@ namespace net.fushizen.modular_avatar.core.editor _selectedIndices.Add(i); } + } + DetectParameters(); + + if (_reorderableList == null) + { _reorderableList = new ReorderableList( _selectedIndices, typeof(int), - false, true, false, false + false, true, _devMode, _devMode ); _reorderableList.drawHeaderCallback = DrawHeader; _reorderableList.drawElementCallback = DrawElement; _reorderableList.onAddCallback = AddElement; _reorderableList.onRemoveCallback = RemoveElement; + _reorderableList.elementHeightCallback = ElementHeight; + _reorderableList.onReorderCallbackWithDetails = ReorderElements; + elemHeight = _reorderableList.elementHeight; } + + _reorderableList.displayAdd = _devMode; + _reorderableList.displayRemove = _devMode; + _reorderableList.draggable = _devMode; + + _needsRebuild = false; + } + + private void ReorderElements(ReorderableList list, int oldindex, int newindex) + { + if (_selectedIndices[oldindex] >= 0 && _selectedIndices[newindex] >= 0) + { + // We're in dev mode, so the "real" entries are in the same order in _selectedIndices + // as the underlying. So just reorder them in the underlying object. + serializedObject.ApplyModifiedProperties(); + + var target = (ModularAvatarParameters) this.target; + var tmp = target.parameters[oldindex]; + target.parameters.RemoveAt(oldindex); + target.parameters.Insert(newindex, tmp); + + serializedObject.Update(); + } + + // The reorderable list trashed our internal indices, so force a rebuild on next repaint. + _needsRebuild = true; } private void AddElement(ReorderableList list) { _parameters.arraySize += 1; + _selectedIndices.Insert(_parameters.arraySize - 1, _parameters.arraySize - 1); list.index = _parameters.arraySize - 1; } private void RemoveElement(ReorderableList list) { - if (list.index < 0) return; + if (list.index < 0 || _selectedIndices[list.index] < 0) return; _parameters.DeleteArrayElementAtIndex(list.index); + + _needsRebuild = true; + } + + private float ElementHeight(int index) + { + if (_selectedIndices[index] == -1) + { + return elemHeight * 2; + } + else + { + return elemHeight; + } + } + + private void DrawAutodetectHeader(ref Rect rect) + { + Rect top = rect; + top.height /= 2; + Rect bottom = rect; + bottom.y += top.height; + bottom.height -= top.height; + rect = bottom; + + var style = new GUIStyle(EditorStyles.label); + style.fontStyle = FontStyle.Italic; + var content = new GUIContent(" Autodetected Parameters "); + var size = style.CalcSize(content); + + var centeredRect = new Rect( + top.x + (top.width - size.x) / 2, + top.y + (top.height - size.y) / 2, + size.x, + size.y + ); + + EditorGUI.LabelField(centeredRect, content, style); } private void DrawElement(Rect rect, int index, bool isactive, bool isfocused) { + var originalIndex = index; + index = _selectedIndices[index]; + var elem = GetParamByIndex(index); + + if (index == -1) + { + DrawAutodetectHeader(ref rect); + } + var margin = 20; var halfMargin = margin / 2; var leftHalf = new Rect(rect.x, rect.y, rect.width / 2 - halfMargin, rect.height); var rightHalf = new Rect(rect.x + leftHalf.width + halfMargin, rect.y, leftHalf.width, rect.height); - if (!_devMode) index = _selectedIndices[index]; - - var elem = _parameters.GetArrayElementAtIndex(index); - var nameOrPrefix = elem.FindPropertyRelative(nameof(ParameterConfig.nameOrPrefix)); var remapTo = elem.FindPropertyRelative(nameof(ParameterConfig.remapTo)); var internalParameter = elem.FindPropertyRelative(nameof(ParameterConfig.internalParameter)); @@ -94,6 +230,8 @@ namespace net.fushizen.modular_avatar.core.editor var indentLevel = EditorGUI.indentLevel; try { + EditorGUI.BeginChangeCheck(); + indentLevel = 0; if (_devMode) { @@ -114,6 +252,42 @@ namespace net.fushizen.modular_avatar.core.editor EditorGUI.LabelField(leftHalf, isPrefix.boolValue ? nameOrPrefix.stringValue + "*" : nameOrPrefix.stringValue); EditorGUI.PropertyField(rightHalf, remapTo, GUIContent.none); + + if (string.IsNullOrWhiteSpace(remapTo.stringValue)) + { + var style = new GUIStyle(EditorStyles.label); + style.fontStyle = FontStyle.Italic; + var oldColor = GUI.color; + var color = GUI.color; + color.a = 0.5f; + GUI.color = color; + + EditorGUI.LabelField(rightHalf, nameOrPrefix.stringValue, style); + + GUI.color = oldColor; + } + } + + if (EditorGUI.EndChangeCheck() && index < 0) + { + var target = (ModularAvatarParameters) this.target; + + // Create this implicit parameter in the backing object + var implicitIndex = -(index + 1); + + serializedObject.ApplyModifiedProperties(); + Undo.RecordObject(target, "Updating parameters"); + _implicitParamsObj.ApplyModifiedPropertiesWithoutUndo(); + + var config = _implicitParams.ImplicitParameters[implicitIndex]; + target.parameters.Add(config); + _selectedIndices.RemoveAt(originalIndex); + _selectedIndices.Insert(target.parameters.Count - 1, target.parameters.Count - 1); + + serializedObject.Update(); + _reorderableList.index = target.parameters.Count - 1; + + _needsRebuild = true; } } finally @@ -135,7 +309,7 @@ namespace net.fushizen.modular_avatar.core.editor { EditorGUI.BeginChangeCheck(); _devMode = EditorGUILayout.Toggle("Developer mode", _devMode); - if (EditorGUI.EndChangeCheck()) SetupList(); + if (EditorGUI.EndChangeCheck() || _reorderableList == null || _needsRebuild) SetupList(); _reorderableList.DoLayoutList(); serializedObject.ApplyModifiedProperties(); } diff --git a/Packages/net.fushizen.modular-avatar/Editor/ParameterPolicy.cs b/Packages/net.fushizen.modular-avatar/Editor/ParameterPolicy.cs new file mode 100644 index 00000000..c9423794 --- /dev/null +++ b/Packages/net.fushizen.modular-avatar/Editor/ParameterPolicy.cs @@ -0,0 +1,251 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using UnityEditor.Animations; +using UnityEngine; +using VRC.SDK3.Avatars.ScriptableObjects; +using VRC.SDK3.Dynamics.Contact.Components; +using VRC.SDK3.Dynamics.PhysBone.Components; + +namespace net.fushizen.modular_avatar.core.editor +{ + public struct DetectedParameter + { + public string OriginalName; + public bool IsPrefix; + + public string MapKey => IsPrefix ? OriginalName + "*" : OriginalName; + } + + public static class ParameterPolicy + { + /// + /// Parameters predefined by the VRChat SDK which should not be offered as remappable. + /// + public static ImmutableHashSet VRCSDKParameters = new string[] + { + "IsLocal", + "Viseme", + "Voice", + "GestureLeft", + "GestureRight", + "GestureLeftWeight", + "GestureRightWeight", + "AngularY", + "VelocityX", + "VelocityY", + "VelocityZ", + "Upright", + "Grounded", + "Seated", + "AFK", + "TrackingType", + "VRMode", + "MuteSelf", + "InStation", + "Earmuffs", + }.ToImmutableHashSet(); + + public static ImmutableList PhysBoneSuffixes = new string[] + { + "_IsGrabbed", + "_Angle", + "_Stretch", + }.ToImmutableList(); + + public static ImmutableDictionary ProbeParameters(GameObject root) + { + Dictionary parameters = new Dictionary(); + + WalkTree(ref parameters, root, false); + + CleanPhysBoneParams(parameters); + + return parameters.ToImmutableDictionary(); + } + + private static void WalkTree(ref Dictionary parameters, GameObject root, + bool applyRemappings) + { + ModularAvatarParameters parametersComponent = null; + + foreach (var component in root.GetComponents()) + { + switch (component) + { + case ModularAvatarParameters p: + { + parametersComponent = p; + break; + } + + case VRCPhysBone bone: + { + if (!string.IsNullOrWhiteSpace(bone.parameter)) + { + var param = new DetectedParameter() + { + OriginalName = bone.parameter, + IsPrefix = true, + }; + + parameters[param.MapKey] = param; + } + + break; + } + + case VRCContactReceiver contact: + { + if (!string.IsNullOrWhiteSpace(contact.parameter)) + { + var param = new DetectedParameter() + { + OriginalName = contact.parameter, + IsPrefix = false, + }; + + parameters[param.MapKey] = param; + } + + break; + } + + case Animator anim: + { + WalkAnimator(parameters, anim.runtimeAnimatorController as AnimatorController); + break; + } + + case ModularAvatarMenuInstaller installer: + { + WalkMenu(parameters, installer.menuToAppend, new HashSet()); + break; + } + } + } + + foreach (Transform child in root.transform) + { + WalkTree(ref parameters, child.gameObject, true); + } + + if (parametersComponent != null && applyRemappings) + { + CleanPhysBoneParams(parameters); + ApplyRemappings(ref parameters, parametersComponent); + } + } + + private static void WalkMenu(Dictionary parameters, VRCExpressionsMenu menu, + HashSet visited) + { + if (menu == null || visited.Contains(menu)) return; + visited.Add(menu); + + void AddParam(string name) + { + var param = new DetectedParameter() + { + OriginalName = name, + IsPrefix = false + }; + parameters[param.MapKey] = param; + } + + foreach (var control in menu.controls) + { + if (!string.IsNullOrWhiteSpace(control.parameter.name)) + { + AddParam(control.parameter.name); + } + + foreach (var subParam in control.subParameters) + { + if (!string.IsNullOrWhiteSpace(subParam.name)) + { + AddParam(subParam.name); + } + } + + if (control.type == VRCExpressionsMenu.Control.ControlType.SubMenu) + { + WalkMenu(parameters, control.subMenu, visited); + } + } + } + + private static void CleanPhysBoneParams(Dictionary parameters) + { + var physBonePrefixes = parameters.Values.Where(p => p.IsPrefix) + .Select(p => p.OriginalName); + + foreach (var prefix in physBonePrefixes) + { + foreach (var suffix in PhysBoneSuffixes) + { + var key = prefix + suffix; + if (parameters.ContainsKey(key)) + { + parameters.Remove(key); + } + } + } + } + + private static void WalkAnimator( + Dictionary parameters, + AnimatorController controller + ) + { + if (controller == null) return; + + foreach (var parameter in controller.parameters) + { + if (VRCSDKParameters.Contains(parameter.name)) continue; + var param = new DetectedParameter() + { + OriginalName = parameter.name, + IsPrefix = false + }; + parameters[param.MapKey] = param; + } + } + + private static void ApplyRemappings(ref Dictionary parameters, + ModularAvatarParameters parametersComponent) + { + Dictionary newParams = new Dictionary(); + + foreach (var map in parametersComponent.parameters) + { + var dictKey = map.nameOrPrefix + (map.isPrefix ? "*" : ""); + if (!parameters.ContainsKey(dictKey)) + { + continue; + } + + if (map.internalParameter) + { + parameters.Remove(dictKey); + } + else if (!string.IsNullOrWhiteSpace(map.remapTo)) + { + var param = parameters[dictKey]; + param.OriginalName = map.remapTo; + newParams[param.MapKey] = param; + parameters.Remove(dictKey); + } + } + + // TODO - warn of overlap? could be intentional... + foreach (var param in parameters) + { + newParams[param.Key] = param.Value; + } + + parameters = newParams; + } + } +} \ No newline at end of file diff --git a/Packages/net.fushizen.modular-avatar/Editor/ParameterPolicy.cs.meta b/Packages/net.fushizen.modular-avatar/Editor/ParameterPolicy.cs.meta new file mode 100644 index 00000000..919ba48a --- /dev/null +++ b/Packages/net.fushizen.modular-avatar/Editor/ParameterPolicy.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: fb45d824aec1444fb553cecca1e042e3 +timeCreated: 1665870025 \ No newline at end of file