From 0bc1c6c88e8445bdec65ce646a29880b95df3e02 Mon Sep 17 00:00:00 2001 From: bd_ Date: Wed, 22 Feb 2023 20:59:40 +0900 Subject: [PATCH] implement the core virtual menu infrastructure --- .../_ModularAvatar/EditModeTests/TestBase.cs | 4 +- .../EditModeTests/VirtualMenuTests.meta | 3 + .../VirtualMenuTests/VirtualMenuTests.cs | 553 ++++++++++++++++++ .../VirtualMenuTests/VirtualMenuTests.cs.meta | 3 + .../Editor/ErrorReporting/NominalException.cs | 28 + .../ErrorReporting/NominalException.cs.meta | 3 + .../Editor/Inspector/MAMenuItemInspector.cs | 22 +- .../Editor/MenuExtractor.cs | 6 +- .../Editor/MenuGeneration.meta | 3 + .../Editor/MenuGeneration/VirtualMenu.cs | 375 ++++++++++++ .../Editor/MenuGeneration/VirtualMenu.cs.meta | 3 + .../Runtime/ModularAvatarMenuInstallTarget.cs | 67 +++ .../ModularAvatarMenuInstallTarget.cs.meta | 3 + ...MAMenuItem.cs => ModularAvatarMenuItem.cs} | 8 +- ....cs.meta => ModularAvatarMenuItem.cs.meta} | 0 15 files changed, 1062 insertions(+), 19 deletions(-) create mode 100644 Assets/_ModularAvatar/EditModeTests/VirtualMenuTests.meta create mode 100644 Assets/_ModularAvatar/EditModeTests/VirtualMenuTests/VirtualMenuTests.cs create mode 100644 Assets/_ModularAvatar/EditModeTests/VirtualMenuTests/VirtualMenuTests.cs.meta create mode 100644 Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/NominalException.cs create mode 100644 Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/NominalException.cs.meta create mode 100644 Packages/nadena.dev.modular-avatar/Editor/MenuGeneration.meta create mode 100644 Packages/nadena.dev.modular-avatar/Editor/MenuGeneration/VirtualMenu.cs create mode 100644 Packages/nadena.dev.modular-avatar/Editor/MenuGeneration/VirtualMenu.cs.meta create mode 100644 Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarMenuInstallTarget.cs create mode 100644 Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarMenuInstallTarget.cs.meta rename Packages/nadena.dev.modular-avatar/Runtime/{MAMenuItem.cs => ModularAvatarMenuItem.cs} (96%) rename Packages/nadena.dev.modular-avatar/Runtime/{MAMenuItem.cs.meta => ModularAvatarMenuItem.cs.meta} (100%) diff --git a/Assets/_ModularAvatar/EditModeTests/TestBase.cs b/Assets/_ModularAvatar/EditModeTests/TestBase.cs index bb20d435..e189d53c 100644 --- a/Assets/_ModularAvatar/EditModeTests/TestBase.cs +++ b/Assets/_ModularAvatar/EditModeTests/TestBase.cs @@ -15,13 +15,13 @@ namespace modular_avatar_tests private const string MinimalAvatarGuid = "60d3416d1f6af4a47bf9056aefc38333"; [SetUp] - public void Setup() + public virtual void Setup() { objects = new List(); } [TearDown] - public void Teardown() + public virtual void Teardown() { foreach (var obj in objects) { diff --git a/Assets/_ModularAvatar/EditModeTests/VirtualMenuTests.meta b/Assets/_ModularAvatar/EditModeTests/VirtualMenuTests.meta new file mode 100644 index 00000000..d1d6f2c4 --- /dev/null +++ b/Assets/_ModularAvatar/EditModeTests/VirtualMenuTests.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 10cb5a7057c1452d8e1caf299c31b05b +timeCreated: 1676981463 \ No newline at end of file diff --git a/Assets/_ModularAvatar/EditModeTests/VirtualMenuTests/VirtualMenuTests.cs b/Assets/_ModularAvatar/EditModeTests/VirtualMenuTests/VirtualMenuTests.cs new file mode 100644 index 00000000..fdc5d5cf --- /dev/null +++ b/Assets/_ModularAvatar/EditModeTests/VirtualMenuTests/VirtualMenuTests.cs @@ -0,0 +1,553 @@ +using System.Collections.Generic; +using nadena.dev.modular_avatar.core; +using nadena.dev.modular_avatar.core.editor.menu; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using VRC.SDK3.Avatars.ScriptableObjects; + +namespace modular_avatar_tests.VirtualMenuTests +{ + public class VirtualMenuTests : TestBase + { + private Texture2D testTex; + private List toDestroy; + + public override void Setup() + { + base.Setup(); + testTex = new Texture2D(1, 1); + toDestroy = new List(); + } + + public override void Teardown() + { + base.Teardown(); + Object.DestroyImmediate(testTex); + foreach (var obj in toDestroy) + { + Object.DestroyImmediate(obj); + } + } + + private T Create(string name = null) where T : ScriptableObject + { + if (name == null) name = GUID.Generate().ToString(); + + T val = ScriptableObject.CreateInstance(); + val.name = name; + + toDestroy.Add(val); + + return val; + } + + [Test] + public void TestEmptyMenu() + { + var virtualMenu = new VirtualMenu(null); + virtualMenu.FreezeMenu(); + + Assert.AreEqual(1, virtualMenu.ResolvedMenu.Count); + var root = virtualMenu.ResolvedMenu[RootMenu.Instance]; + Assert.AreEqual(0, root.Controls.Count); + Assert.AreSame(RootMenu.Instance, root.NodeKey); + } + + [Test] + public void TestBasicMenu() + { + var rootMenu = Create(); + + rootMenu.controls = new List() + { + GenerateTestControl(), + GenerateTestControl() + }; + + var virtualMenu = new VirtualMenu(rootMenu); + virtualMenu.FreezeMenu(); + + Assert.AreEqual(1, virtualMenu.ResolvedMenu.Count); + var root = virtualMenu.ResolvedMenu[rootMenu]; + Assert.AreEqual(2, root.Controls.Count); + Assert.AreSame(rootMenu, root.NodeKey); + AssertControlEquals(rootMenu.controls[0], root.Controls[0]); + AssertControlEquals(rootMenu.controls[1], root.Controls[1]); + } + + [Test] + public void TestNativeMenuWithCycles() + { + var rootMenu = Create("root"); + var sub1 = Create("sub1"); + var sub2 = Create("sub2"); + + rootMenu.controls = new List() + { + GenerateTestSubmenu(sub1) + }; + + sub1.controls = new List() + { + GenerateTestSubmenu(sub2) + }; + + sub2.controls = new List() + { + GenerateTestSubmenu(rootMenu) + }; + + var virtualMenu = new VirtualMenu(rootMenu); + virtualMenu.FreezeMenu(); + + Assert.AreEqual(3, virtualMenu.ResolvedMenu.Count); + var rootNode = virtualMenu.ResolvedMenu[rootMenu]; + var sub1Node = virtualMenu.ResolvedMenu[sub1]; + var sub2Node = virtualMenu.ResolvedMenu[sub2]; + + Assert.AreEqual(1, rootNode.Controls.Count); + Assert.AreSame(rootMenu, rootNode.NodeKey); + Assert.AreSame(sub1Node, rootNode.Controls[0].SubmenuNode); + Assert.IsNull(rootNode.Controls[0].subMenu); + + Assert.AreEqual(1, sub1Node.Controls.Count); + Assert.AreSame(sub1, sub1Node.NodeKey); + Assert.AreSame(sub2Node, sub1Node.Controls[0].SubmenuNode); + Assert.IsNull(sub1Node.Controls[0].subMenu); + + Assert.AreEqual(1, sub2Node.Controls.Count); + Assert.AreSame(sub2, sub2Node.NodeKey); + Assert.AreSame(rootNode, sub2Node.Controls[0].SubmenuNode); + Assert.IsNull(sub2Node.Controls[0].subMenu); + } + + [Test] + public void TestBasicMenuInstaller() + { + VRCExpressionsMenu testMenu = Create(); + testMenu.controls = new List() + { + GenerateTestControl(), + GenerateTestControl() + }; + + var installer = CreateInstaller("test"); + installer.menuToAppend = testMenu; + + var virtualMenu = new VirtualMenu(null); + virtualMenu.RegisterMenuInstaller(installer); + virtualMenu.FreezeMenu(); + + Assert.AreEqual(1, virtualMenu.ResolvedMenu.Count); + var root = virtualMenu.ResolvedMenu[RootMenu.Instance]; + Assert.AreEqual(2, root.Controls.Count); + Assert.AreSame(RootMenu.Instance, root.NodeKey); + AssertControlEquals(testMenu.controls[0], root.Controls[0]); + AssertControlEquals(testMenu.controls[1], root.Controls[1]); + } + + [Test] + public void TestMenuItemInstaller() + { + var installer = CreateInstaller("test"); + installer.menuToAppend = Create(); + + var item = installer.gameObject.AddComponent(); + item.Control = GenerateTestControl(); + + var virtualMenu = new VirtualMenu(null); + virtualMenu.RegisterMenuInstaller(installer); + virtualMenu.FreezeMenu(); + + item.Control.name = "test"; + + Assert.AreEqual(1, virtualMenu.ResolvedMenu.Count); + var root = virtualMenu.ResolvedMenu[RootMenu.Instance]; + Assert.AreEqual(1, root.Controls.Count); + Assert.AreSame(RootMenu.Instance, root.NodeKey); + AssertControlEquals(item.Control, root.Controls[0]); + } + + [Test] + public void TestInstallOntoInstaller() + { + var installer_a = CreateInstaller("a"); + var installer_b = CreateInstaller("b"); + var installer_c = CreateInstaller("c"); + + var menu_a = Create(); + var menu_b = Create(); + var menu_c = Create(); + + menu_a.controls = new List() + { + GenerateTestControl() + }; + menu_b.controls = new List() + { + GenerateTestControl() + }; + menu_c.controls = new List() + { + GenerateTestControl() + }; + + installer_a.menuToAppend = menu_a; + installer_b.menuToAppend = menu_b; + installer_c.menuToAppend = menu_c; + + installer_c.installTargetMenu = installer_a.menuToAppend; + + VirtualMenu virtualMenu = new VirtualMenu(null); + virtualMenu.RegisterMenuInstaller(installer_a); + virtualMenu.RegisterMenuInstaller(installer_b); + virtualMenu.RegisterMenuInstaller(installer_c); + + virtualMenu.FreezeMenu(); + Assert.AreEqual(1, virtualMenu.ResolvedMenu.Count); + var rootMenu = virtualMenu.ResolvedMenu[RootMenu.Instance]; + Assert.AreEqual(3, rootMenu.Controls.Count); + Assert.AreSame(RootMenu.Instance, rootMenu.NodeKey); + AssertControlEquals(menu_a.controls[0], rootMenu.Controls[0]); + AssertControlEquals(menu_c.controls[0], rootMenu.Controls[1]); + AssertControlEquals(menu_b.controls[0], rootMenu.Controls[2]); + } + + [Test] + public void TestInstallSubmenu() + { + var installer_a = CreateInstaller("a"); + + var menu_a = Create("a"); + var menu_b = Create("b"); + menu_a.controls = new List() + { + GenerateTestSubmenu(menu_b) + }; + menu_b.controls = new List() + { + GenerateTestControl() + }; + + installer_a.menuToAppend = menu_a; + + VirtualMenu virtualMenu = new VirtualMenu(null); + virtualMenu.RegisterMenuInstaller(installer_a); + + virtualMenu.FreezeMenu(); + + Assert.AreEqual(2, virtualMenu.ResolvedMenu.Count); + var rootMenu = virtualMenu.ResolvedMenu[RootMenu.Instance]; + var subMenu = virtualMenu.ResolvedMenu[menu_b]; + Assert.AreSame(subMenu, rootMenu.Controls[0].SubmenuNode); + Assert.AreSame(RootMenu.Instance, rootMenu.NodeKey); + Assert.AreSame(menu_b, subMenu.NodeKey); + Assert.AreEqual(1, subMenu.Controls.Count); + AssertControlEquals(menu_b.controls[0], subMenu.Controls[0]); + } + + [Test] + public void TestYankInstaller() + { + var installer_a = CreateInstaller("a"); + var installer_b = CreateInstaller("b"); + + var menu_a = Create("a"); + menu_a.controls = new List() + { + GenerateTestControl() + }; + installer_a.menuToAppend = menu_a; + + var item = installer_b.gameObject.AddComponent(); + item.Control = GenerateTestControl(); + item.Control.type = VRCExpressionsMenu.Control.ControlType.SubMenu; + item.MenuSource = SubmenuSource.Children; + + var child = CreateChild(item.gameObject, "child"); + var childItem = child.AddComponent(); + childItem.installer = installer_a; + + VirtualMenu virtualMenu = new VirtualMenu(null); + virtualMenu.RegisterMenuInstaller(installer_a); + virtualMenu.RegisterMenuInstaller(installer_b); + virtualMenu.RegisterMenuInstallTarget(childItem); + + virtualMenu.FreezeMenu(); + item.Control.name = "b"; + + Assert.AreEqual(2, virtualMenu.ResolvedMenu.Count); + var rootMenu = virtualMenu.ResolvedMenu[RootMenu.Instance]; + var item_node = virtualMenu.ResolvedMenu[item]; + Assert.AreEqual(1, rootMenu.Controls.Count); + Assert.AreSame(RootMenu.Instance, rootMenu.NodeKey); + AssertControlEquals(item.Control, rootMenu.Controls[0]); + Assert.AreSame(item_node, rootMenu.Controls[0].SubmenuNode); + + Assert.AreEqual(1, item_node.Controls.Count); + Assert.AreSame(item, item_node.NodeKey); + AssertControlEquals(menu_a.controls[0], item_node.Controls[0]); + } + + [Test] + public void WhenMenuInstallersLoop_LoopIsTerminated() + { + var installer_a = CreateInstaller("a"); + var installer_b = CreateInstaller("b"); + + var menu_a = Create("a"); + menu_a.controls = new List() + { + GenerateTestControl() + }; + installer_a.menuToAppend = menu_a; + + var menu_b = Create("b"); + menu_b.controls = new List() + { + GenerateTestControl() + }; + installer_b.menuToAppend = menu_b; + + installer_a.installTargetMenu = menu_b; + installer_b.installTargetMenu = menu_a; + + VirtualMenu virtualMenu = new VirtualMenu(menu_a); + virtualMenu.RegisterMenuInstaller(installer_a); + virtualMenu.RegisterMenuInstaller(installer_b); + + virtualMenu.FreezeMenu(); + + Assert.AreEqual(1, virtualMenu.ResolvedMenu.Count); + var rootMenu = virtualMenu.ResolvedMenu[menu_a]; + var menu_a_node = virtualMenu.ResolvedMenu[menu_a]; + Assert.AreEqual(3, rootMenu.Controls.Count); + } + + [Test] + public void TestExternalSubmenuSource_WithMenuInstaller() + { + var installer_a = CreateInstaller("a"); + var installer_b = CreateInstaller("b"); + + var menu_a = Create("a"); + menu_a.controls = new List() + { + GenerateTestControl() + }; + + var menu_b = Create("b"); + menu_b.controls = new List() + { + GenerateTestControl() + }; + + var item_a = installer_a.gameObject.AddComponent(); + item_a.Control = GenerateTestControl(); + item_a.Control.type = VRCExpressionsMenu.Control.ControlType.SubMenu; + item_a.MenuSource = SubmenuSource.External; + item_a.Control.subMenu = menu_a; + + installer_b.menuToAppend = menu_b; + installer_b.installTargetMenu = menu_a; + + VirtualMenu virtualMenu = new VirtualMenu(null); + virtualMenu.RegisterMenuInstaller(installer_a); + virtualMenu.RegisterMenuInstaller(installer_b); + + virtualMenu.FreezeMenu(); + + var rootNode = virtualMenu.ResolvedMenu[RootMenu.Instance]; + Assert.AreEqual(VRCExpressionsMenu.Control.ControlType.SubMenu, rootNode.Controls[0].type); + var menu_a_node = rootNode.Controls[0].SubmenuNode; + AssertControlEquals(menu_a.controls[0], menu_a_node.Controls[0]); + AssertControlEquals(menu_b.controls[0], menu_a_node.Controls[1]); + } + + [Test] + public void MenuItem_NestedSubmenuNodes() + { + var installer_a = CreateInstaller("root"); + var root_item = installer_a.gameObject.AddComponent(); + root_item.Control = GenerateTestControl(); + root_item.Control.type = VRCExpressionsMenu.Control.ControlType.SubMenu; + root_item.MenuSource = SubmenuSource.Children; + + var mid_obj = CreateChild(root_item.gameObject, "mid"); + var mid_item = mid_obj.AddComponent(); + mid_item.Control = GenerateTestControl(); + mid_item.Control.type = VRCExpressionsMenu.Control.ControlType.SubMenu; + mid_item.MenuSource = SubmenuSource.Children; + + var leaf_obj = CreateChild(mid_obj, "leaf"); + var leaf_item = leaf_obj.AddComponent(); + leaf_item.Control = GenerateTestControl(); + + VirtualMenu virtualMenu = new VirtualMenu(null); + virtualMenu.RegisterMenuInstaller(installer_a); + + virtualMenu.FreezeMenu(); + + var rootNode = virtualMenu.ResolvedMenu[RootMenu.Instance]; + Assert.AreEqual(VRCExpressionsMenu.Control.ControlType.SubMenu, rootNode.Controls[0].type); + var mid_node = rootNode.Controls[0].SubmenuNode; + var leaf_node = mid_node.Controls[0].SubmenuNode; + + Assert.AreEqual(1, rootNode.Controls.Count); + Assert.AreEqual(1, mid_node.Controls.Count); + Assert.AreEqual(1, leaf_node.Controls.Count); + + root_item.Control.name = "root"; + mid_item.Control.name = "mid"; + leaf_item.Control.name = "leaf"; + + AssertControlEquals(root_item.Control, rootNode.Controls[0]); + AssertControlEquals(mid_item.Control, mid_node.Controls[0]); + AssertControlEquals(leaf_item.Control, leaf_node.Controls[0]); + } + + [Test] + public void MenuItem_RemoteReference() + { + var installer_a = CreateInstaller("root"); + var root_item = installer_a.gameObject.AddComponent(); + + var extern_root = CreateRoot("test"); + var extern_obj = CreateChild(extern_root, "control"); + var extern_item = extern_obj.AddComponent(); + extern_item.Control = GenerateTestControl(); + + root_item.Control = GenerateTestControl(); + root_item.Control.type = VRCExpressionsMenu.Control.ControlType.SubMenu; + root_item.MenuSource = SubmenuSource.Children; + root_item.menuSource_otherObjectChildren = extern_root; + + VirtualMenu virtualMenu = new VirtualMenu(null); + virtualMenu.RegisterMenuInstaller(installer_a); + + virtualMenu.FreezeMenu(); + + root_item.Control.name = "root"; + extern_item.Control.name = "control"; + + var rootNode = virtualMenu.ResolvedMenu[RootMenu.Instance]; + Assert.AreEqual(VRCExpressionsMenu.Control.ControlType.SubMenu, rootNode.Controls[0].type); + var extern_node = rootNode.Controls[0].SubmenuNode; + + Assert.AreEqual(1, rootNode.Controls.Count); + Assert.AreEqual(1, extern_node.Controls.Count); + + AssertControlEquals(root_item.Control, rootNode.Controls[0]); + AssertControlEquals(extern_item.Control, extern_node.Controls[0]); + } + + [Test] + public void InstallerInstallsInstallTarget() + { + var installer_a = CreateInstaller("a"); + var installer_b = CreateInstaller("b"); + + var menu_b = Create("b"); + menu_b.controls = new List() + { + GenerateTestControl() + }; + installer_b.menuToAppend = menu_b; + + var item_a = installer_a.gameObject.AddComponent(); + item_a.installer = installer_b; + + VirtualMenu virtualMenu = new VirtualMenu(null); + virtualMenu.RegisterMenuInstaller(installer_a); + virtualMenu.RegisterMenuInstaller(installer_b); + virtualMenu.RegisterMenuInstallTarget(item_a); + + virtualMenu.FreezeMenu(); + + var rootNode = virtualMenu.ResolvedMenu[RootMenu.Instance]; + Assert.AreEqual(1, rootNode.Controls.Count); + AssertControlEquals(menu_b.controls[0], rootNode.Controls[0]); + } + + ModularAvatarMenuInstaller CreateInstaller(string name) + { + GameObject obj = new GameObject(); + obj.name = name; + + var installer = obj.AddComponent(); + installer.name = name; + + toDestroy.Add(obj); + + return installer; + } + + VRCExpressionsMenu.Control GenerateTestSubmenu(VRCExpressionsMenu menu) + { + var control = GenerateTestControl(); + control.type = VRCExpressionsMenu.Control.ControlType.SubMenu; + control.subMenu = menu; + + return control; + } + + VRCExpressionsMenu.Control GenerateTestControl() + { + var control = new VRCExpressionsMenu.Control(); + + VRCExpressionsMenu.Control.ControlType[] types = new[] + { + VRCExpressionsMenu.Control.ControlType.Button, + // VRCExpressionsMenu.Control.ControlType.SubMenu, + VRCExpressionsMenu.Control.ControlType.Toggle, + VRCExpressionsMenu.Control.ControlType.RadialPuppet, + VRCExpressionsMenu.Control.ControlType.FourAxisPuppet, + VRCExpressionsMenu.Control.ControlType.TwoAxisPuppet, + VRCExpressionsMenu.Control.ControlType.OneAxisPuppet, + }; + + control.type = types[Random.Range(0, types.Length)]; + control.name = "Test Control " + GUID.Generate(); + control.parameter = new VRCExpressionsMenu.Control.Parameter(); + control.parameter.name = "Test Parameter " + GUID.Generate(); + control.icon = new Texture2D(1, 1); + control.labels = new[] + { + new VRCExpressionsMenu.Control.Label() + { + name = "label", + icon = testTex + } + }; + control.subParameters = new[] + { + new VRCExpressionsMenu.Control.Parameter() + { + name = "Test Sub Parameter " + GUID.Generate() + } + }; + control.value = 0.42f; + control.style = VRCExpressionsMenu.Control.Style.Style3; + + return control; + } + + void AssertControlEquals(VRCExpressionsMenu.Control expected, VRCExpressionsMenu.Control actual) + { + Assert.AreEqual(expected.type, actual.type); + Assert.AreEqual(expected.name, actual.name); + Assert.AreEqual(expected.parameter.name, actual.parameter.name); + Assert.AreNotSame(expected.parameter, actual.parameter); + Assert.AreEqual(expected.icon, actual.icon); + Assert.AreEqual(expected.labels.Length, actual.labels.Length); + Assert.AreEqual(expected.labels[0].name, actual.labels[0].name); + Assert.AreEqual(expected.labels[0].icon, actual.labels[0].icon); + Assert.AreEqual(expected.subParameters.Length, actual.subParameters.Length); + Assert.AreEqual(expected.subParameters[0].name, actual.subParameters[0].name); + Assert.AreNotSame(expected.subParameters[0], actual.subParameters[0]); + Assert.AreEqual(expected.value, actual.value); + Assert.AreEqual(expected.style, actual.style); + } + } +} \ No newline at end of file diff --git a/Assets/_ModularAvatar/EditModeTests/VirtualMenuTests/VirtualMenuTests.cs.meta b/Assets/_ModularAvatar/EditModeTests/VirtualMenuTests/VirtualMenuTests.cs.meta new file mode 100644 index 00000000..1a3365ec --- /dev/null +++ b/Assets/_ModularAvatar/EditModeTests/VirtualMenuTests/VirtualMenuTests.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: c96564b3caa04c63aa9c7c4ce05429eb +timeCreated: 1676981475 \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/NominalException.cs b/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/NominalException.cs new file mode 100644 index 00000000..dc9b8200 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/NominalException.cs @@ -0,0 +1,28 @@ +using System; +using System.Runtime.Serialization; +using JetBrains.Annotations; + +namespace nadena.dev.modular_avatar.editor.ErrorReporting +{ + /// + /// These exceptions will not be logged in the error report. + /// + public class NominalException : Exception + { + public NominalException() + { + } + + protected NominalException([NotNull] SerializationInfo info, StreamingContext context) : base(info, context) + { + } + + public NominalException(string message) : base(message) + { + } + + public NominalException(string message, Exception innerException) : base(message, innerException) + { + } + } +} \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/NominalException.cs.meta b/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/NominalException.cs.meta new file mode 100644 index 00000000..3c3838dc --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/NominalException.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 152d780db95c4e408240ec3cd4dad60e +timeCreated: 1676979798 \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/Inspector/MAMenuItemInspector.cs b/Packages/nadena.dev.modular-avatar/Editor/Inspector/MAMenuItemInspector.cs index 975c5722..91015f2e 100644 --- a/Packages/nadena.dev.modular-avatar/Editor/Inspector/MAMenuItemInspector.cs +++ b/Packages/nadena.dev.modular-avatar/Editor/Inspector/MAMenuItemInspector.cs @@ -5,7 +5,7 @@ using VRC.SDK3.Avatars.ScriptableObjects; namespace nadena.dev.modular_avatar.core.editor { - [CustomEditor(typeof(MAMenuItem))] + [CustomEditor(typeof(ModularAvatarMenuItem))] internal class MAMenuItemInspector : MAEditorBase { private SerializedProperty prop_submenu_source; @@ -14,9 +14,10 @@ namespace nadena.dev.modular_avatar.core.editor void OnEnable() { - prop_control = serializedObject.FindProperty(nameof(MAMenuItem.Control)); - prop_submenu_source = serializedObject.FindProperty(nameof(MAMenuItem.MenuSource)); - prop_otherObjChildren = serializedObject.FindProperty(nameof(MAMenuItem.menuSource_otherObjectChildren)); + prop_control = serializedObject.FindProperty(nameof(ModularAvatarMenuItem.Control)); + prop_submenu_source = serializedObject.FindProperty(nameof(ModularAvatarMenuItem.MenuSource)); + prop_otherObjChildren = + serializedObject.FindProperty(nameof(ModularAvatarMenuItem.menuSource_otherObjectChildren)); } private void DrawControlSettings(SerializedProperty control, string name = null, @@ -25,7 +26,7 @@ namespace nadena.dev.modular_avatar.core.editor if (name != null) { EditorGUI.BeginChangeCheck(); - var targetGameObject = ((MAMenuItem) target).gameObject; + var targetGameObject = ((ModularAvatarMenuItem) target).gameObject; var newName = EditorGUILayout.TextField("Name", targetGameObject.name); if (EditorGUI.EndChangeCheck() && commitName != null) { @@ -51,7 +52,7 @@ namespace nadena.dev.modular_avatar.core.editor if (!multiEdit) { EditorGUI.BeginChangeCheck(); - var targetGameObject = ((MAMenuItem) target).gameObject; + var targetGameObject = ((ModularAvatarMenuItem) target).gameObject; name = targetGameObject.name; commitName = newName => { @@ -68,7 +69,7 @@ namespace nadena.dev.modular_avatar.core.editor if (multiEdit) return; - var menuItem = (MAMenuItem) target; + var menuItem = (ModularAvatarMenuItem) target; if (menuItem.Control.type == VRCExpressionsMenu.Control.ControlType.SubMenu) { GUILayout.Space(EditorStyles.label.lineHeight); @@ -92,7 +93,7 @@ namespace nadena.dev.modular_avatar.core.editor : menuItem.gameObject; foreach (Transform t in source.transform) { - var child = t.GetComponent(); + var child = t.GetComponent(); if (child == null) continue; EditorGUILayout.BeginVertical(EditorStyles.helpBox); @@ -100,7 +101,8 @@ namespace nadena.dev.modular_avatar.core.editor GUILayout.BeginHorizontal(); using (new EditorGUI.DisabledScope(true)) { - EditorGUILayout.ObjectField(new GUIContent(), child, typeof(MAMenuItem), true, + EditorGUILayout.ObjectField(new GUIContent(), child, typeof(ModularAvatarMenuItem), + true, GUILayout.ExpandWidth(true)); } @@ -125,7 +127,7 @@ namespace nadena.dev.modular_avatar.core.editor }; var childSO = new SerializedObject(child); - var childControl = childSO.FindProperty(nameof(MAMenuItem.Control)); + var childControl = childSO.FindProperty(nameof(ModularAvatarMenuItem.Control)); DrawControlSettings(childControl, name, commitName); childSO.ApplyModifiedProperties(); diff --git a/Packages/nadena.dev.modular-avatar/Editor/MenuExtractor.cs b/Packages/nadena.dev.modular-avatar/Editor/MenuExtractor.cs index 39e04b08..22861d5e 100644 --- a/Packages/nadena.dev.modular-avatar/Editor/MenuExtractor.cs +++ b/Packages/nadena.dev.modular-avatar/Editor/MenuExtractor.cs @@ -44,15 +44,15 @@ namespace nadena.dev.modular_avatar.core.editor itemObj.transform.localRotation = Quaternion.identity; itemObj.transform.localScale = Vector3.one; - var menuItem = itemObj.AddComponent(); + var menuItem = itemObj.AddComponent(); menuItem.Control = sourceControl; if (menuItem.Control.type == VRCExpressionsMenu.Control.ControlType.SubMenu) { if (convertedMenus.TryGetValue(sourceControl.subMenu, out var otherSource)) { - menuItem.MenuSource = SubmenuSource.OtherMenuItem; - menuItem.menuSource_otherSource = otherSource; + menuItem.MenuSource = SubmenuSource.Children; + menuItem.menuSource_otherObjectChildren = otherSource.gameObject; } else { diff --git a/Packages/nadena.dev.modular-avatar/Editor/MenuGeneration.meta b/Packages/nadena.dev.modular-avatar/Editor/MenuGeneration.meta new file mode 100644 index 00000000..de45c2b6 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/MenuGeneration.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 435b9162d1fd4050b9ced045ab20af27 +timeCreated: 1676977199 \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/MenuGeneration/VirtualMenu.cs b/Packages/nadena.dev.modular-avatar/Editor/MenuGeneration/VirtualMenu.cs new file mode 100644 index 00000000..8d99409c --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/MenuGeneration/VirtualMenu.cs @@ -0,0 +1,375 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using nadena.dev.modular_avatar.editor.ErrorReporting; +using UnityEditor; +using UnityEngine; +using VRC.SDK3.Avatars.ScriptableObjects; + +namespace nadena.dev.modular_avatar.core.editor.menu +{ + /// + /// Sentinel object to represent the avatar root menu (for avatars which don't have a root menu) + /// + internal sealed class RootMenu + { + public static readonly RootMenu Instance = new RootMenu(); + + private RootMenu() + { + } + } + + /// + /// A MenuNode represents a single VRCExpressionsMenu, prior to overflow splitting. MenuNodes form a directed graph, + /// which may contain cycles, and may include contributions from multiple MenuInstallers, or from the base avatar + /// menu. + /// + internal class MenuNode + { + internal List Controls = new List(); + + /// + /// The primary (serialized) object that contributed to this menu; if we want to add more items to it, we look + /// here. This can currently be either a VRCExpressionsMenu, a MAMenuItem, or a RootMenu. + /// + internal readonly object NodeKey; + + internal MenuNode(object nodeKey) + { + NodeKey = nodeKey; + } + } + + internal class VirtualControl : VRCExpressionsMenu.Control + { + /// + /// VirtualControls do not reference real VRCExpressionsMenu objects, but rather virtual MenuNodes. + /// + internal MenuNode SubmenuNode; + + internal VirtualControl(VRCExpressionsMenu.Control control) + { + this.name = control.name; + this.type = control.type; + this.parameter = new Parameter() {name = control.parameter.name}; + this.value = control.value; + this.icon = control.icon; + this.style = control.style; + this.subMenu = null; + this.subParameters = control.subParameters.Select(p => new VRCExpressionsMenu.Control.Parameter() + { + name = p.name + }).ToArray(); + this.labels = control.labels.ToArray(); + } + } + + /** + * The VirtualMenu class tracks a fully realized shadow menu. Notably, this is _not_ converted to unity + * ScriptableObjects, making it easier to discard it when we need to update it. + */ + internal class VirtualMenu + { + private readonly object RootMenuKey; + + /// + /// Indexes which menu installers are contributing to which VRCExpressionMenu assets. + /// + private Dictionary> _targetMenuToInstaller + = new Dictionary>(); + + private Dictionary> _installerToTargetComponent + = new Dictionary>(); + + /// + /// Maps from either VRCEXpressionsMenu objects or MenuItems to menu nodes. The ROOT_MENU here is a special + /// object used to mark contributors to the avatar root menu. + /// + private Dictionary _menuNodeMap = new Dictionary(); + + private Dictionary _resolvedMenu = new Dictionary(); + + // TODO: immutable? + public Dictionary ResolvedMenu => _resolvedMenu; + + /// + /// Initializes the VirtualMenu. + /// + /// The root VRCExpressionsMenu to import + internal VirtualMenu(VRCExpressionsMenu rootMenu) + { + if (rootMenu != null) + { + RootMenuKey = rootMenu; + ImportMenu(rootMenu); + } + else + { + RootMenuKey = RootMenu.Instance; + _menuNodeMap[RootMenu.Instance] = new MenuNode(RootMenu.Instance); + } + } + + private MenuNode ImportMenu(VRCExpressionsMenu menu, object menuKey = null) + { + if (menuKey == null) menuKey = menu; + if (_menuNodeMap.TryGetValue(menuKey, out var subMenuNode)) return subMenuNode; + + var node = new MenuNode(menuKey); + _menuNodeMap[menuKey] = node; + foreach (var control in menu.controls) + { + var virtualControl = new VirtualControl(control); + if (control.subMenu != null) + { + virtualControl.SubmenuNode = ImportMenu(control.subMenu); + } + + node.Controls.Add(virtualControl); + } + + return node; + } + + /// + /// Registers a menu installer with this virtual menu. Because we need the full set of components indexed to + /// determine the effects of this menu installer, further processing is deferred until we freeze the menu. + /// + /// + internal void RegisterMenuInstaller(ModularAvatarMenuInstaller installer) + { + // initial validation + if (installer.menuToAppend == null && installer.GetComponent() == null) return; + + var target = installer.installTargetMenu ? (object) installer.installTargetMenu : RootMenuKey; + if (!_targetMenuToInstaller.TryGetValue(target, out var targets)) + { + targets = new List(); + _targetMenuToInstaller[target] = targets; + } + + targets.Add(installer); + } + + /// + /// Registers an install target with this virtual menu. As with menu installers, processing is delayed. + /// + /// + internal void RegisterMenuInstallTarget(ModularAvatarMenuInstallTarget target) + { + if (target.installer == null) return; + if (!_installerToTargetComponent.TryGetValue(target.installer, out var targets)) + { + targets = new List(); + _installerToTargetComponent[target.installer] = targets; + } + } + + /// + /// Freezes the menu, fully resolving all members of all menus. + /// + internal void FreezeMenu() + { + ResolveNode(RootMenuKey); + } + + private HashSet _sourceTrace = null; + + private MenuNode ResolveNode(object nodeKey) + { + if (_resolvedMenu.TryGetValue(nodeKey, out var node)) return node; + + if (nodeKey is ModularAvatarMenuItem item) + { + return ResolveSubmenuItem(item); + } + + if (nodeKey is VRCExpressionsMenu menu) + { + ImportMenu(menu); + } + + if (_menuNodeMap.TryGetValue(nodeKey, out node)) + { + _resolvedMenu[nodeKey] = node; + } + else + { + node = new MenuNode(nodeKey); + _menuNodeMap[nodeKey] = node; + _resolvedMenu[nodeKey] = node; + } + + + // Find any menu installers which target this node, and recursively include them. + // Note that we're also recursing through MenuNodes, and should not consider the objects visited on + // different submenus when cutting off cycles. + var priorTrace = _sourceTrace; + _sourceTrace = new HashSet(); + try + { + // We use a stack here to maintain the expected order of elements. Consider if we have three menu + // installers as follows: + // A -> root + // B -> root + // C -> A + // We'll first push [B, A], then visit A. At this point we'll push C back on the stack, so we visit + // [A, C, B] in the end. + Stack installers = new Stack(); + if (_targetMenuToInstaller.TryGetValue(nodeKey, out var rootInstallers)) + { + foreach (var i in rootInstallers.Select(x => x).Reverse()) + { + if (_installerToTargetComponent.ContainsKey(i)) continue; + installers.Push(i); + } + } + + while (installers.Count > 0) + { + var next = installers.Pop(); + if (_sourceTrace.Contains(next)) continue; + _sourceTrace.Add(next); + + BuildReport.ReportingObject(next, () => ResolveInstaller(node, next, installers)); + } + + // Resolve any submenus + foreach (var virtualControl in node.Controls) + { + if (virtualControl.SubmenuNode != null) + { + virtualControl.SubmenuNode = ResolveNode(virtualControl.SubmenuNode.NodeKey); + } + } + } + finally + { + _sourceTrace = priorTrace; + } + + return node; + } + + private MenuNode ResolveSubmenuItem(ModularAvatarMenuItem item) + { + return BuildReport.ReportingObject(item, () => + { + MenuNode node = new MenuNode(item); + _resolvedMenu[item] = node; + + switch (item.MenuSource) + { + case SubmenuSource.External: + { + if (item.Control.subMenu != null) + { + node.Controls = ResolveNode(item.Control.subMenu).Controls; + } + + break; + } + case SubmenuSource.Children: + { + var transformRoot = item.menuSource_otherObjectChildren != null + ? item.menuSource_otherObjectChildren.transform + : item.transform; + foreach (Transform child in transformRoot) + { + if (!child.gameObject.activeSelf) continue; + + var source = child.GetComponent(); + if (source == null) continue; + + if (source is ModularAvatarMenuItem subItem) + { + var control = new VirtualControl(subItem.Control); + if (control.type == VRCExpressionsMenu.Control.ControlType.SubMenu) + { + control.SubmenuNode = ResolveNode(subItem); + } + + control.name = subItem.gameObject.name; + node.Controls.Add(control); + } + else if (source is ModularAvatarMenuInstallTarget target && target.installer != null) + { + ResolveInstaller(node, target.installer, new Stack()); + } + else + { + // TODO validation + } + } + + break; + } + default: + // TODO validation + break; + } + + return node; + }); + } + + private void ResolveInstaller(MenuNode node, ModularAvatarMenuInstaller installer, + Stack installers) + { + if (installer == null || !installer.enabled) return; + + var menuSource = installer.GetComponent(); + + if (menuSource == null) + { + var expMenu = installer.menuToAppend; + if (expMenu == null) return; + var controls = expMenu.controls; + if (controls == null) return; + + foreach (var control in controls) + { + var virtualControl = new VirtualControl(control); + if (control.subMenu != null) + { + virtualControl.SubmenuNode = ResolveNode(control.subMenu); + } + + node.Controls.Add(virtualControl); + } + + if (_targetMenuToInstaller.TryGetValue(expMenu, out var subInstallers)) + { + foreach (var subInstaller in subInstallers.Select(x => x).Reverse()) + { + if (_installerToTargetComponent.ContainsKey(subInstaller)) continue; + installers.Push(subInstaller); + } + } + } + else if (menuSource is ModularAvatarMenuInstallTarget target) + { + if (target.installer != null) + { + installers.Push(target.installer); + } + } + else if (menuSource is ModularAvatarMenuItem item) + { + var virtualControl = new VirtualControl(item.Control); + virtualControl.name = item.gameObject.name; + node.Controls.Add(virtualControl); + if (virtualControl.type == VRCExpressionsMenu.Control.ControlType.SubMenu) + { + virtualControl.SubmenuNode = ResolveNode(item); + } + } + else + { + BuildReport.Log(ReportLevel.Error, "virtual_menu.unknown_source_type", + strings: new object[] {menuSource.GetType().ToString()}); + } + } + } +} \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/MenuGeneration/VirtualMenu.cs.meta b/Packages/nadena.dev.modular-avatar/Editor/MenuGeneration/VirtualMenu.cs.meta new file mode 100644 index 00000000..5d57f25d --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/MenuGeneration/VirtualMenu.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 70cfde8e889a4057b14153c7021e16c8 +timeCreated: 1676977210 \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarMenuInstallTarget.cs b/Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarMenuInstallTarget.cs new file mode 100644 index 00000000..1bf9201f --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarMenuInstallTarget.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; +using VRC.SDK3.Avatars.ScriptableObjects; + +namespace nadena.dev.modular_avatar.core +{ + /// + /// The menu install target includes the controls of the target menu installer at the point of reference. + /// Notably, this can include multiple controls. + /// + /// One tricky aspect of this feature is that we need to disambiguate when a menu installer also cites a target menu. + /// Generally, if an installer is targeted by any menu install target (even if - especially if - disabled), we + /// ignore its install target configuration entirely. + /// + /// We can also end up with a loop between install targets; in this case, we break the loop at an arbitrary point. + /// + internal class ModularAvatarMenuInstallTarget : MenuSource + { + public ModularAvatarMenuInstaller installer; + + private static HashSet _recursing = new HashSet(); + + internal delegate T Returning(); + + /** + * Temporarily clears the list of install targets we're recursing through. This is useful if we need to generate + * a submenu; these have their own recursion stack, and we shouldn't truncate the set of controls registered on + * a different submenu that happens to transclude the same point. + */ + internal static T PushRecursing(Returning callback) + { + HashSet oldRecursing = _recursing; + _recursing = new HashSet(); + try + { + return callback(); + } + finally + { + _recursing = oldRecursing; + } + } + + internal override VRCExpressionsMenu.Control[] GenerateMenu() + { + if (installer == null) return new VRCExpressionsMenu.Control[] { }; + + _recursing.Add(this); + try + { + var source = installer.GetComponent(); + if (source != null) + { + return source.GenerateMenu(); + } + else + { + // ReSharper disable once Unity.NoNullPropagation + return installer.menuToAppend?.controls?.ToArray() ?? new VRCExpressionsMenu.Control[] { }; + } + } + finally + { + _recursing.Remove(this); + } + } + } +} \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarMenuInstallTarget.cs.meta b/Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarMenuInstallTarget.cs.meta new file mode 100644 index 00000000..806f78a5 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarMenuInstallTarget.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 1fad1419b52a42ae89b0df52eb861e47 +timeCreated: 1676976513 \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Runtime/MAMenuItem.cs b/Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarMenuItem.cs similarity index 96% rename from Packages/nadena.dev.modular-avatar/Runtime/MAMenuItem.cs rename to Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarMenuItem.cs index a583320f..31708093 100644 --- a/Packages/nadena.dev.modular-avatar/Runtime/MAMenuItem.cs +++ b/Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarMenuItem.cs @@ -22,17 +22,15 @@ namespace nadena.dev.modular_avatar.core { External, Children, - MenuInstaller, - OtherMenuItem, } - public class MAMenuItem : MenuSource + [AddComponentMenu("Modular Avatar/MA Menu Item")] + public class ModularAvatarMenuItem : MenuSource { public VRCExpressionsMenu.Control Control; public SubmenuSource MenuSource; public ModularAvatarMenuInstaller menuSource_installer; - public MenuSource menuSource_otherSource; public GameObject menuSource_otherObjectChildren; internal override VRCExpressionsMenu.Control[] GenerateMenu() @@ -75,6 +73,7 @@ namespace nadena.dev.modular_avatar.core break; } + /* case SubmenuSource.MenuInstaller: controls = menuSource_installer.installTargetMenu?.controls?.ToList(); break; @@ -95,6 +94,7 @@ namespace nadena.dev.modular_avatar.core _recursing = false; } } + */ } if (controls == null) diff --git a/Packages/nadena.dev.modular-avatar/Runtime/MAMenuItem.cs.meta b/Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarMenuItem.cs.meta similarity index 100% rename from Packages/nadena.dev.modular-avatar/Runtime/MAMenuItem.cs.meta rename to Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarMenuItem.cs.meta