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..bcb41689 --- /dev/null +++ b/Assets/_ModularAvatar/EditModeTests/VirtualMenuTests/VirtualMenuTests.cs @@ -0,0 +1,609 @@ +using System.Collections.Generic; +using nadena.dev.modular_avatar.core; +using nadena.dev.modular_avatar.core.editor.menu; +using nadena.dev.modular_avatar.core.menu; +using NUnit.Framework; +using UnityEditor; +using UnityEditor.VersionControl; +using UnityEngine; +using VRC.SDK3.Avatars.ScriptableObjects; + +namespace modular_avatar_tests.VirtualMenuTests +{ + public class VirtualMenuTests : TestBase + { + private Texture2D testTex; + private List toDestroy; + private int controlIndex; + + public override void Setup() + { + base.Setup(); + testTex = new Texture2D(1, 1); + toDestroy = new List(); + controlIndex = 0; + } + + 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[new MenuNodesUnder(item.gameObject)]; + 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.AreEqual(new MenuNodesUnder(item.gameObject), 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.MenuAsset; + 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]); + } + + [Test] + public void TestSerializeMenu() + { + var menu_a = Create("test"); + var menu_b = Create("test2"); + var menu_c = Create("test3"); + + menu_a.controls = new List() + { + GenerateTestControl(), + GenerateTestSubmenu(menu_b), + }; + + menu_b.controls = new List() + { + GenerateTestControl(), + GenerateTestSubmenu(menu_c), + }; + + menu_c.controls = new List() + { + GenerateTestSubmenu(menu_a), + }; + + var virtualMenu = new VirtualMenu(menu_a); + virtualMenu.FreezeMenu(); + + var assetSet = new HashSet(); + var serialized = virtualMenu.SerializeMenu(obj => assetSet.Add(obj)); + + Assert.AreEqual(3, assetSet.Count); + Assert.AreEqual(2, serialized.controls.Count); + + AssertControlEquals(menu_a.controls[0], serialized.controls[0]); + AssertControlEquals(menu_a.controls[1], serialized.controls[1]); + + var serialized_b = serialized.controls[1].subMenu; + Assert.AreEqual(2, serialized_b.controls.Count); + + AssertControlEquals(menu_b.controls[0], serialized_b.controls[0]); + AssertControlEquals(menu_b.controls[1], serialized_b.controls[1]); + + var serialized_c = serialized_b.controls[1].subMenu; + Assert.AreEqual(1, serialized_c.controls.Count); + + AssertControlEquals(menu_c.controls[0], serialized_c.controls[0]); + + Assert.True(assetSet.Contains(serialized)); + Assert.True(assetSet.Contains(serialized_b)); + Assert.True(assetSet.Contains(serialized_c)); + } + + + 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, + }; + + control.type = types[Random.Range(0, types.Length)]; + control.name = "Test Control " + controlIndex++; + 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/manifest.json b/Packages/manifest.json index 8a81d421..97863409 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -11,6 +11,7 @@ "com.unity.ugui": "1.0.0", "com.unity.xr.oculus.standalone": "2.38.4", "com.unity.xr.openvr.standalone": "2.0.5", + "de.thryrallo.vrc.avatar-performance-tools": "https://github.com/Thryrallo/VRC-Avatar-Performance-Tools.git", "nadena.dev.modular-avatar": "0.0.1", "com.unity.modules.ai": "1.0.0", "com.unity.modules.androidjni": "1.0.0", diff --git a/Packages/nadena.dev.modular-avatar/Editor/AvatarProcessor.cs b/Packages/nadena.dev.modular-avatar/Editor/AvatarProcessor.cs index 04850d62..6417e2fd 100644 --- a/Packages/nadena.dev.modular-avatar/Editor/AvatarProcessor.cs +++ b/Packages/nadena.dev.modular-avatar/Editor/AvatarProcessor.cs @@ -214,6 +214,8 @@ namespace nadena.dev.modular_avatar.core.editor ErrorReportUI.MaybeOpenErrorReportUI(); AssetDatabase.SaveAssets(); + + Resources.UnloadUnusedAssets(); } } } diff --git a/Packages/nadena.dev.modular-avatar/Editor/BuildContext.cs b/Packages/nadena.dev.modular-avatar/Editor/BuildContext.cs index b3c46283..19bd479c 100644 --- a/Packages/nadena.dev.modular-avatar/Editor/BuildContext.cs +++ b/Packages/nadena.dev.modular-avatar/Editor/BuildContext.cs @@ -1,8 +1,10 @@ using System; +using System.Collections.Generic; using UnityEditor; using UnityEditor.Animations; using UnityEngine; using VRC.SDK3.Avatars.Components; +using VRC.SDK3.Avatars.ScriptableObjects; using Object = UnityEngine.Object; namespace nadena.dev.modular_avatar.core.editor @@ -13,6 +15,10 @@ namespace nadena.dev.modular_avatar.core.editor internal readonly AnimationDatabase AnimationDatabase = new AnimationDatabase(); internal readonly AnimatorController AssetContainer; + internal readonly Dictionary ClonedMenus + = new Dictionary(); + + public BuildContext(VRCAvatarDescriptor avatarDescriptor) { AvatarDescriptor = avatarDescriptor; @@ -72,5 +78,38 @@ namespace nadena.dev.modular_avatar.core.editor merger.AddOverrideController("", overrideController, null); return merger.Finish(); } + + public VRCExpressionsMenu CloneMenu(VRCExpressionsMenu menu) + { + if (menu == null) return null; + if (ClonedMenus.TryGetValue(menu, out var newMenu)) return newMenu; + newMenu = Object.Instantiate(menu); + this.SaveAsset(newMenu); + ClonedMenus[menu] = newMenu; + + foreach (var control in newMenu.controls) + { + if (Util.ValidateExpressionMenuIcon(control.icon) != Util.ValidateExpressionMenuIconResult.Success) + control.icon = null; + + for (int i = 0; i < control.labels.Length; i++) + { + var label = control.labels[i]; + var labelResult = Util.ValidateExpressionMenuIcon(label.icon); + if (labelResult != Util.ValidateExpressionMenuIconResult.Success) + { + label.icon = null; + control.labels[i] = label; + } + } + + if (control.type == VRCExpressionsMenu.Control.ControlType.SubMenu) + { + control.subMenu = CloneMenu(control.subMenu); + } + } + + return newMenu; + } } } \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/ClonedMenuMappings.cs b/Packages/nadena.dev.modular-avatar/Editor/ClonedMenuMappings.cs deleted file mode 100644 index a886193e..00000000 --- a/Packages/nadena.dev.modular-avatar/Editor/ClonedMenuMappings.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Collections.Generic; -using System.Collections.Immutable; -using VRC.SDK3.Avatars.ScriptableObjects; - -namespace nadena.dev.modular_avatar.core.editor -{ - internal static class ClonedMenuMappings - { - /// - /// Map to link the cloned menu from the clone source. - /// If one menu is specified for multiple installers, they are replicated separately, so there is a one-to-many relationship. - /// - private static readonly Dictionary> ClonedMappings = - new Dictionary>(); - - /// - /// Map to link the clone source from the cloned menu. - /// Map is the opposite of ClonedMappings. - /// - private static readonly Dictionary OriginalMapping = - new Dictionary(); - - public static void Clear() - { - ClonedMappings.Clear(); - OriginalMapping.Clear(); - } - - public static void Add(VRCExpressionsMenu original, VRCExpressionsMenu clonedMenu) - { - if (!ClonedMappings.TryGetValue(original, out ImmutableList clonedMenus)) - { - clonedMenus = ImmutableList.Empty; - } - ClonedMappings[original] = clonedMenus.Add(clonedMenu); - OriginalMapping[clonedMenu] = original; - } - - public static bool TryGetClonedMenus(VRCExpressionsMenu original, out ImmutableList clonedMenus) - { - return ClonedMappings.TryGetValue(original, out clonedMenus); - } - - public static VRCExpressionsMenu GetOriginal(VRCExpressionsMenu cloned) - { - return OriginalMapping.TryGetValue(cloned, out VRCExpressionsMenu original) ? original : null; - } - } -} \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ComponentValidation.cs b/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ComponentValidation.cs index 17376f2a..ae9652fc 100644 --- a/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ComponentValidation.cs +++ b/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ComponentValidation.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using nadena.dev.modular_avatar.core; +using nadena.dev.modular_avatar.core.menu; using UnityEngine; using VRC.SDK3.Avatars.Components; @@ -137,7 +138,7 @@ namespace nadena.dev.modular_avatar.editor.ErrorReporting private static List CheckInternal(ModularAvatarMenuInstaller mi) { // TODO - check that target menu is in the avatar - if (mi.menuToAppend == null) + if (mi.menuToAppend == null && mi.GetComponent() == null) { return new List() { diff --git a/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ErrorLog.cs b/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ErrorLog.cs index 6fea2c85..26b98951 100644 --- a/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ErrorLog.cs +++ b/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ErrorLog.cs @@ -334,7 +334,7 @@ namespace nadena.dev.modular_avatar.editor.ErrorReporting internal static T ReportingObject(UnityEngine.Object obj, Func action) { - CurrentReport._references.Push(obj); + if (obj != null) CurrentReport._references.Push(obj); try { return action(); @@ -347,7 +347,7 @@ namespace nadena.dev.modular_avatar.editor.ErrorReporting } finally { - CurrentReport._references.Pop(); + if (obj != null) CurrentReport._references.Pop(); } } 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/AvMenuTreeView.cs b/Packages/nadena.dev.modular-avatar/Editor/Inspector/AvMenuTreeView.cs deleted file mode 100644 index 5f73a280..00000000 --- a/Packages/nadena.dev.modular-avatar/Editor/Inspector/AvMenuTreeView.cs +++ /dev/null @@ -1,180 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using NUnit.Framework; -using UnityEditor; -using UnityEditor.IMGUI.Controls; -using UnityEngine; -using VRC.SDK3.Avatars.Components; -using VRC.SDK3.Avatars.ScriptableObjects; -using VRC.SDKBase; - -namespace nadena.dev.modular_avatar.core.editor -{ - class AvMenuTreeViewWindow : EditorWindow - { - private VRCAvatarDescriptor _avatarDescriptor; - private AvMenuTreeView _treeView; - - public VRCAvatarDescriptor Avatar - { - get => _treeView.Avatar; - set => _treeView.Avatar = value; - } - - public ModularAvatarMenuInstaller TargetInstaller - { - get => _treeView.TargetInstaller; - set => _treeView.TargetInstaller = value; - } - - public Action OnMenuSelected = (menu) => { }; - - private void Awake() - { - _treeView = new AvMenuTreeView(new TreeViewState()); - _treeView.OnSelect = (menu) => OnMenuSelected.Invoke(menu); - _treeView.OnDoubleclickSelect = Close; - } - - private void OnLostFocus() - { - Close(); - } - - private void OnDisable() - { - OnMenuSelected = (menu) => { }; - } - - private void OnGUI() - { - if (_treeView == null || _treeView.Avatar == null) - { - Close(); - return; - } - - _treeView.OnGUI(new Rect(0, 0, position.width, position.height)); - } - - internal static void Show(VRCAvatarDescriptor Avatar, ModularAvatarMenuInstaller Installer, Action OnSelect) - { - var window = GetWindow(); - window.titleContent = new GUIContent("Select menu"); - - window.Avatar = Avatar; - window.TargetInstaller = Installer; - window.OnMenuSelected = OnSelect; - - window.Show(); - } - } - - class AvMenuTreeView : TreeView - { - private VRCAvatarDescriptor _avatar; - - public VRCAvatarDescriptor Avatar - { - get => _avatar; - set - { - _avatar = value; - Reload(); - } - } - - private ModularAvatarMenuInstaller _targetInstaller; - - public ModularAvatarMenuInstaller TargetInstaller - { - get => _targetInstaller; - set - { - _targetInstaller = value; - Reload(); - } - } - - internal Action OnSelect = (menu) => { }; - internal Action OnDoubleclickSelect = () => { }; - - private List _menuItems = new List(); - private HashSet _visitedMenus = new HashSet(); - - private MenuTree _menuTree; - private Stack _visitedMenuStack = new Stack(); - - public AvMenuTreeView(TreeViewState state) : base(state) - { - } - - protected override void SelectionChanged(IList selectedIds) - { - OnSelect.Invoke(_menuItems[selectedIds[0]]); - } - - protected override void DoubleClickedItem(int id) - { - OnSelect.Invoke(_menuItems[id]); - OnDoubleclickSelect.Invoke(); - } - - protected override TreeViewItem BuildRoot() - { - _menuItems.Clear(); - _visitedMenuStack.Clear(); - - _menuTree = new MenuTree(Avatar); - _menuTree.TraverseAvatarMenu(); - foreach (ModularAvatarMenuInstaller installer in Avatar.gameObject.GetComponentsInChildren(true)) - { - if (installer == TargetInstaller) continue; - _menuTree.TraverseMenuInstaller(installer); - } - - var root = new TreeViewItem(-1, -1, ""); - List treeItems = new List - { - new TreeViewItem - { - id = 0, - depth = 0, - displayName = $"{Avatar.gameObject.name} ({(Avatar.expressionsMenu == null ? "None" : Avatar.expressionsMenu.name)})" - } - }; - _menuItems.Add(Avatar.expressionsMenu); - _visitedMenuStack.Push(Avatar.expressionsMenu); - - TraverseMenu(1, treeItems, Avatar.expressionsMenu); - SetupParentsAndChildrenFromDepths(root, treeItems); - return root; - } - - private void TraverseMenu(int depth, List items, VRCExpressionsMenu menu) - { - IEnumerable children = _menuTree.GetChildren(menu) - .Where(child => !_visitedMenuStack.Contains(child.menu)); - foreach (MenuTree.ChildElement child in children) - { - if (child.menu == null) continue; - string displayName = child.installer == null ? - $"{child.menuName} ({child.menu.name})" : - $"{child.menuName} ({child.menu.name}) InstallerObject : {child.installer.name}"; - items.Add( - new TreeViewItem - { - id = items.Count, - depth = depth, - displayName = displayName - } - ); - _menuItems.Add(child.menu); - _visitedMenuStack.Push(child.menu); - TraverseMenu(depth + 1, items, child.menu); - _visitedMenuStack.Pop(); - } - } - } -} \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu.meta b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu.meta new file mode 100644 index 00000000..4196cad2 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu.meta @@ -0,0 +1,4 @@ +fileFormatVersion: 2 +guid: c587475b37a44ad9ac97c6e7b9e1b4a2 +timeCreated: 1677148000 +folderAsset: yes \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/AvMenuTreeView.cs b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/AvMenuTreeView.cs new file mode 100644 index 00000000..b37edadc --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/AvMenuTreeView.cs @@ -0,0 +1,233 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using nadena.dev.modular_avatar.core.editor.menu; +using nadena.dev.modular_avatar.core.menu; +using NUnit.Framework; +using UnityEditor; +using UnityEditor.IMGUI.Controls; +using UnityEngine; +using VRC.SDK3.Avatars.Components; +using VRC.SDK3.Avatars.ScriptableObjects; +using VRC.SDKBase; + +namespace nadena.dev.modular_avatar.core.editor +{ + class AvMenuTreeViewWindow : EditorWindow + { + private VRCAvatarDescriptor _avatarDescriptor; + private AvMenuTreeView _treeView; + private long _cacheIndex = -1; + + public VRCAvatarDescriptor Avatar + { + get => _treeView.Avatar; + set => _treeView.Avatar = value; + } + + public ModularAvatarMenuInstaller TargetInstaller + { + get => _treeView.TargetInstaller; + set => _treeView.TargetInstaller = value; + } + + public Action OnMenuSelected = (menu) => { }; + + private void Awake() + { + _treeView = new AvMenuTreeView(new TreeViewState()); + _treeView.OnSelect = (menu) => OnMenuSelected.Invoke(menu); + _treeView.OnDoubleclickSelect = Close; + _cacheIndex = -1; + } + + private void OnLostFocus() + { + Close(); + } + + private void OnDisable() + { + OnMenuSelected = (menu) => { }; + } + + private void OnGUI() + { + if (_treeView == null || _treeView.Avatar == null) + { + Close(); + return; + } + + if (_cacheIndex != VirtualMenu.CacheSequence) + { + _treeView.ReloadPreservingExpanded(); + _cacheIndex = VirtualMenu.CacheSequence; + } + + _treeView.OnGUI(new Rect(0, 0, position.width, position.height)); + } + + internal static void Show(VRCAvatarDescriptor Avatar, ModularAvatarMenuInstaller Installer, + Action OnSelect) + { + var window = GetWindow(); + window.titleContent = new GUIContent("Select menu"); + + window.Avatar = Avatar; + window.TargetInstaller = Installer; + window.OnMenuSelected = OnSelect; + + window.Show(); + } + } + + class AvMenuTreeView : TreeView + { + private VRCAvatarDescriptor _avatar; + + public VRCAvatarDescriptor Avatar + { + get => _avatar; + set + { + _avatar = value; + Reload(); + } + } + + private ModularAvatarMenuInstaller _targetInstaller; + + public ModularAvatarMenuInstaller TargetInstaller + { + get => _targetInstaller; + set + { + _targetInstaller = value; + Reload(); + } + } + + internal Action OnSelect = (menu) => { }; + internal Action OnDoubleclickSelect = () => { }; + + private List _nodeKeys = new List(); + private HashSet _visitedMenus = new HashSet(); + + private VirtualMenu _menuTree; + private Stack _visitedMenuStack = new Stack(); + + public AvMenuTreeView(TreeViewState state) : base(state) + { + } + + public void ReloadPreservingExpanded() + { + var expanded = GetExpanded().Select(id => _nodeKeys[id]).ToImmutableHashSet(); + var selected = GetSelection().Select(id => _nodeKeys[id]).ToImmutableHashSet(); + CollapseAll(); + Reload(); + SetExpanded(Enumerable.Range(0, _nodeKeys.Count) + .Where(i => _nodeKeys[i] != null && expanded.Contains(_nodeKeys[i])) + .ToList()); + SetSelection(Enumerable.Range(0, _nodeKeys.Count) + .Where(i => _nodeKeys[i] != null && selected.Contains(_nodeKeys[i])) + .ToList()); + } + + protected override void SelectionChanged(IList selectedIds) + { + OnSelect.Invoke(_nodeKeys[selectedIds[0]]); + } + + protected override void DoubleClickedItem(int id) + { + OnSelect.Invoke(_nodeKeys[id]); + OnDoubleclickSelect.Invoke(); + } + + protected override TreeViewItem BuildRoot() + { + _nodeKeys.Clear(); + _visitedMenuStack.Clear(); + + _menuTree = VirtualMenu.ForAvatar(_avatar); + + var preferredRoot = FindPreferredRoot(_menuTree); + var rootName = ""; + if (preferredRoot is ModularAvatarMenuGroup group) + { + if (group.targetObject != null) rootName = $"({group.targetObject.name})"; + else rootName = $"({group.gameObject.name})"; + } + else if (preferredRoot is VRCExpressionsMenu menu) + { + rootName = $"({menu.name})"; + } + + var root = new TreeViewItem(-1, -1, ""); + List treeItems = new List + { + new TreeViewItem + { + id = 0, + depth = 0, + displayName = + $"{Avatar.gameObject.name} {rootName}" + } + }; + + _nodeKeys.Add(preferredRoot); + _visitedMenuStack.Push(preferredRoot); + TraverseMenu(1, treeItems, _menuTree.RootMenuNode); + SetupParentsAndChildrenFromDepths(root, treeItems); + return root; + } + + private object FindPreferredRoot(VirtualMenu menuTree) + { + // There's always a VRCExpressionsMenu at the root, but we'd prefer to add stuff under a MenuItem tree if + // available. See if we can find one. + foreach (var installer in _avatar.GetComponentsInChildren(true)) + { + if (installer.installTargetMenu != null && installer.installTargetMenu != menuTree.RootMenuKey) + { + continue; + } + + var menuSource = installer.GetComponent(); + if (menuSource == null || !(menuSource is ModularAvatarMenuGroup group)) continue; + + return menuSource; + } + + return menuTree.RootMenuKey; + } + + private void TraverseMenu(int depth, List items, VirtualMenuNode node) + { + IEnumerable children = node.Controls + .Where(control => control.type == VRCExpressionsMenu.Control.ControlType.SubMenu && + control.SubmenuNode != null && + !_visitedMenuStack.Contains(control.SubmenuNode)); + foreach (var child in children) + { + string displayName = child.name; + + items.Add( + new TreeViewItem + { + id = items.Count, + depth = depth, + displayName = displayName + } + ); + _nodeKeys.Add(child.SubmenuNode.NodeKey); + _visitedMenuStack.Push(child.SubmenuNode); + TraverseMenu(depth + 1, items, child.SubmenuNode); + _visitedMenuStack.Pop(); + } + } + } +} \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/Inspector/AvMenuTreeView.cs.meta b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/AvMenuTreeView.cs.meta similarity index 100% rename from Packages/nadena.dev.modular-avatar/Editor/Inspector/AvMenuTreeView.cs.meta rename to Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/AvMenuTreeView.cs.meta diff --git a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MAMenuItemInspector.cs b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MAMenuItemInspector.cs new file mode 100644 index 00000000..4bb4aaae --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MAMenuItemInspector.cs @@ -0,0 +1,55 @@ +using UnityEditor; +using static nadena.dev.modular_avatar.core.editor.Localization; + +namespace nadena.dev.modular_avatar.core.editor +{ + [CustomEditor(typeof(ModularAvatarMenuItem))] + [CanEditMultipleObjects] + internal class MAMenuItemInspector : MAEditorBase + { + private MenuItemCoreGUI _coreGUI; + + void OnEnable() + { + _coreGUI = new MenuItemCoreGUI(serializedObject, Repaint); + _coreGUI.AlwaysExpandContents = true; + } + + protected override void OnInnerInspectorGUI() + { + serializedObject.Update(); + + _coreGUI.DoGUI(); + + serializedObject.ApplyModifiedProperties(); + + ShowLanguageUI(); + } + } + + [CustomEditor(typeof(ModularAvatarMenuGroup))] + internal class MAMenuGroupInspector : MAEditorBase + { + private MenuPreviewGUI _previewGUI; + private SerializedProperty _prop_target; + + void OnEnable() + { + _previewGUI = new MenuPreviewGUI(Repaint); + _prop_target = serializedObject.FindProperty(nameof(ModularAvatarMenuGroup.targetObject)); + } + + protected override void OnInnerInspectorGUI() + { + serializedObject.Update(); + + EditorGUILayout.PropertyField(_prop_target, G("menuitem.prop.source_override")); + + _previewGUI.DoGUI((ModularAvatarMenuGroup) target); + + serializedObject.ApplyModifiedProperties(); + + ShowLanguageUI(); + } + } +} \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MAMenuItemInspector.cs.meta b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MAMenuItemInspector.cs.meta new file mode 100644 index 00000000..e4426ed4 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MAMenuItemInspector.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 5b674c72186c4e6884b0cd05098f11b6 +timeCreated: 1676791017 \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MenuInstallerEditor.cs b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MenuInstallerEditor.cs new file mode 100644 index 00000000..f3f1a6bc --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MenuInstallerEditor.cs @@ -0,0 +1,565 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using nadena.dev.modular_avatar.core.editor.menu; +using nadena.dev.modular_avatar.core.menu; +using UnityEditor; +using UnityEngine; +using VRC.SDK3.Avatars.Components; +using VRC.SDK3.Avatars.ScriptableObjects; +using static nadena.dev.modular_avatar.core.editor.Localization; +using static nadena.dev.modular_avatar.core.editor.Util; +using Object = UnityEngine.Object; + +namespace nadena.dev.modular_avatar.core.editor +{ + [CustomEditor(typeof(ModularAvatarMenuInstaller))] + [CanEditMultipleObjects] + internal class MenuInstallerEditor : MAEditorBase + { + private ModularAvatarMenuInstaller _installer; + private Editor _innerMenuEditor; + private VRCExpressionsMenu _menuToAppend; + + private bool _menuFoldout; + private bool _devFoldout; + + private MenuPreviewGUI _previewGUI; + + private HashSet _avatarMenus; + + private Dictionary> _menuInstallersMap; + + private void OnEnable() + { + _installer = (ModularAvatarMenuInstaller) target; + _previewGUI = new MenuPreviewGUI(Repaint); + + FindMenus(); + FindMenuInstallers(); + + VRCAvatarDescriptor commonAvatar = FindCommonAvatar(); + } + + private long _cacheSeq = -1; + private ImmutableList _cachedTargets = null; + + // Interpretation: + // : Inconsistent install targets + // List of [null]: Install to root + // List of [VRCExpMenu]: Install to expressions menu + // List of [InstallTarget]: Install to single install target + // List of [InstallTarget, InstallTarget ...]: Install to multiple install targets + private ImmutableList InstallTargets + { + get + { + if (VirtualMenu.CacheSequence == _cacheSeq && _cachedTargets != null) return _cachedTargets; + + List> perTarget = new List>(); + + var commonAvatar = FindCommonAvatar(); + if (commonAvatar == null) + { + _cacheSeq = VirtualMenu.CacheSequence; + _cachedTargets = ImmutableList.Empty; + return _cachedTargets; + } + + var virtualMenu = VirtualMenu.ForAvatar(commonAvatar); + + foreach (var target in targets) + { + var installer = (ModularAvatarMenuInstaller) target; + + var installTargets = virtualMenu.GetInstallTargetsForInstaller(installer) + .Select(o => (object) o).ToImmutableList(); + if (installTargets.Any()) + { + perTarget.Add(installTargets); + } + else + { + perTarget.Add(ImmutableList.Empty.Add(installer.installTargetMenu)); + } + } + + for (int i = 1; i < perTarget.Count; i++) + { + if (perTarget[0].Count != perTarget[i].Count || + perTarget[0].Zip(perTarget[i], (a, b) => (Resolve(a) != Resolve(b))).Any(differs => differs)) + { + perTarget.Clear(); + perTarget.Add(ImmutableList.Empty); + break; + } + } + + _cacheSeq = VirtualMenu.CacheSequence; + _cachedTargets = perTarget[0]; + return _cachedTargets; + + object Resolve(object p0) + { + if (p0 is ModularAvatarMenuInstallTarget target && target != null) return target.transform.parent; + return p0; + } + } + } + + private void SetupMenuEditor() + { + if (targets.Length != 1) + { + _innerMenuEditor = null; + _menuToAppend = null; + } + else if (_installer.menuToAppend != _menuToAppend) + { + if (_installer.menuToAppend == null) _innerMenuEditor = null; + else + { + _innerMenuEditor = CreateEditor(_installer.menuToAppend); + } + + _menuToAppend = _installer.menuToAppend; + } + } + + protected override void OnInnerInspectorGUI() + { + SetupMenuEditor(); + + var installTo = serializedObject.FindProperty(nameof(ModularAvatarMenuInstaller.installTargetMenu)); + + var isEnabled = targets.Length != 1 || ((ModularAvatarMenuInstaller) target).enabled; + + VRCAvatarDescriptor commonAvatar = FindCommonAvatar(); + + if (InstallTargets.Count == 0) + { + // TODO - show warning for inconsistent targets? + } + else if (InstallTargets.Count > 0) + { + if (InstallTargets.Count == 1) + { + if (InstallTargets[0] == null) + { + if (isEnabled) + { + EditorGUILayout.HelpBox(S("menuinstall.help.hint_set_menu"), MessageType.Info); + } + } + else if (InstallTargets[0] is VRCExpressionsMenu menu + && !IsMenuReachable(RuntimeUtil.FindAvatarInParents(((Component) target).transform), menu)) + { + EditorGUILayout.HelpBox(S("menuinstall.help.hint_bad_menu"), MessageType.Error); + } + } + + if (InstallTargets.Count == 1 && (InstallTargets[0] is VRCExpressionsMenu || InstallTargets[0] == null)) + { + var displayValue = installTo.objectReferenceValue; + if (displayValue == null) displayValue = commonAvatar.expressionsMenu; + + EditorGUI.BeginChangeCheck(); + var newValue = EditorGUILayout.ObjectField(G("menuinstall.installto"), displayValue, + typeof(VRCExpressionsMenu), false); + if (EditorGUI.EndChangeCheck()) + { + installTo.objectReferenceValue = newValue; + _cacheSeq = -1; + } + } + else + { + using (new EditorGUI.DisabledScope(true)) + { + foreach (var target in InstallTargets) + { + if (target is VRCExpressionsMenu menu) + { + EditorGUILayout.ObjectField(G("menuinstall.installto"), menu, + typeof(VRCExpressionsMenu), true); + } + else if (target is ModularAvatarMenuInstallTarget t) + { + EditorGUILayout.ObjectField(G("menuinstall.installto"), t.transform.parent.gameObject, + typeof(GameObject), true); + } + } + } + } + + var avatar = commonAvatar; + if (avatar != null && InstallTargets.Count == 1 && GUILayout.Button(G("menuinstall.selectmenu"))) + { + AvMenuTreeViewWindow.Show(avatar, _installer, menu => + { + if (InstallTargets.Count != 1 || menu == InstallTargets[0]) return; + + if (InstallTargets[0] is ModularAvatarMenuInstallTarget oldTarget && oldTarget != null) + { + DestroyInstallTargets(); + } + + if (menu is ModularAvatarMenuItem item) + { + if (item.MenuSource == SubmenuSource.MenuAsset) + { + menu = item.Control.subMenu; + } + else + { + var menuParent = item.menuSource_otherObjectChildren != null + ? item.menuSource_otherObjectChildren + : item.gameObject; + + menu = new MenuNodesUnder(menuParent); + } + } + else if (menu is ModularAvatarMenuGroup group) + { + if (group.targetObject != null) menu = new MenuNodesUnder(group.targetObject); + else menu = new MenuNodesUnder(group.gameObject); + } + + if (menu is VRCExpressionsMenu expMenu) + { + if (expMenu == avatar.expressionsMenu) installTo.objectReferenceValue = null; + else installTo.objectReferenceValue = expMenu; + } + else if (menu is RootMenu) + { + installTo.objectReferenceValue = null; + } + else if (menu is MenuNodesUnder nodesUnder) + { + installTo.objectReferenceValue = null; + + foreach (var target in targets) + { + var installer = (ModularAvatarMenuInstaller) target; + var child = new GameObject(); + Undo.RegisterCreatedObjectUndo(child, "Set install target"); + child.transform.SetParent(nodesUnder.root.transform, false); + child.name = installer.gameObject.name; + + var targetComponent = child.AddComponent(); + targetComponent.installer = installer; + + EditorGUIUtility.PingObject(child); + } + } + + serializedObject.ApplyModifiedProperties(); + VirtualMenu.InvalidateCaches(); + Repaint(); + }); + } + } + + if (targets.Length == 1) + { + _menuFoldout = EditorGUILayout.Foldout(_menuFoldout, G("menuinstall.showcontents")); + if (_menuFoldout) + { + _previewGUI.DoGUI((ModularAvatarMenuInstaller) target); + } + } + + if (targets.Any(t => + { + var installer = (ModularAvatarMenuInstaller) t; + return installer.GetComponent() == null && installer.menuToAppend != null; + })) + { + if (GUILayout.Button("Extract menu to objects")) + { + ExtractMenu(); + } + } + + bool inconsistentSources = false; + MenuSource menuSource = null; + bool first = true; + foreach (var target in targets) + { + var component = (ModularAvatarMenuInstaller) target; + var componentSource = component.GetComponent(); + if (componentSource != null) + { + if (menuSource == null && first) + { + menuSource = componentSource; + } + else + { + inconsistentSources = true; + } + } + } + + if (menuSource != null) + { + // TODO localize + EditorGUILayout.HelpBox("Menu contents provided by " + menuSource.GetType() + " component", + MessageType.Info); + } + + if (!inconsistentSources) + { + _devFoldout = EditorGUILayout.Foldout(_devFoldout, G("menuinstall.devoptions")); + if (_devFoldout) + { + SerializedProperty menuToAppendProperty = + serializedObject.FindProperty(nameof(ModularAvatarMenuInstaller.menuToAppend)); + if (!menuToAppendProperty.hasMultipleDifferentValues) + { + switch (ValidateExpressionMenuIcon( + (VRCExpressionsMenu) menuToAppendProperty.objectReferenceValue)) + { + case ValidateExpressionMenuIconResult.Success: + break; + case ValidateExpressionMenuIconResult.TooLarge: + EditorGUILayout.HelpBox(S("menuinstall.menu_icon_too_large"), MessageType.Error); + break; + case ValidateExpressionMenuIconResult.Uncompressed: + EditorGUILayout.HelpBox(S("menuinstall.menu_icon_uncompressed"), MessageType.Error); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + EditorGUI.indentLevel++; + EditorGUILayout.PropertyField( + menuToAppendProperty, new GUIContent(G("menuinstall.srcmenu"))); + EditorGUI.indentLevel--; + } + } + + serializedObject.ApplyModifiedProperties(); + + Localization.ShowLanguageUI(); + } + + private void ExtractMenu() + { + serializedObject.ApplyModifiedProperties(); + + foreach (var t in targets) + { + var installer = (ModularAvatarMenuInstaller) t; + if (installer.GetComponent() || installer.menuToAppend == null) continue; + + var menu = installer.menuToAppend; + if (menu.controls.Count == 0) + { + continue; + } + + Undo.RecordObject(installer, "Extract menu"); + + if (menu.controls.Count == 1) + { + // Attach control directly to the installer + var item = installer.gameObject.AddComponent(); + Undo.RegisterCreatedObjectUndo(item, "Extract menu"); + MenuExtractor.ControlToMenuItem(item, menu.controls[0]); + } + else + { + // Use a menu group and attach items on a child + var group = installer.gameObject.AddComponent(); + var menuRoot = new GameObject(); + menuRoot.name = "Menu"; + Undo.RegisterCreatedObjectUndo(menuRoot, "Extract menu"); + menuRoot.transform.SetParent(group.transform, false); + foreach (var control in menu.controls) + { + var itemObject = new GameObject(); + itemObject.gameObject.name = control.name; + Undo.RegisterCreatedObjectUndo(itemObject, "Extract menu"); + itemObject.transform.SetParent(menuRoot.transform, false); + var item = itemObject.AddComponent(); + MenuExtractor.ControlToMenuItem(item, control); + } + } + + PrefabUtility.RecordPrefabInstancePropertyModifications(installer); + EditorUtility.SetDirty(installer); + } + } + + private void DestroyInstallTargets() + { + VirtualMenu menu = VirtualMenu.ForAvatar(FindCommonAvatar()); + + foreach (var t in targets) + { + foreach (var oldTarget in menu.GetInstallTargetsForInstaller((ModularAvatarMenuInstaller) t)) + { + if (PrefabUtility.IsPartOfPrefabInstance(oldTarget)) + { + Undo.RecordObject(oldTarget, "Change menu install target"); + oldTarget.installer = null; + PrefabUtility.RecordPrefabInstancePropertyModifications(oldTarget); + } + else + { + if (oldTarget.transform.childCount == 0 && + oldTarget.GetComponents(typeof(Component)).Length == 2) + { + Undo.DestroyObjectImmediate(oldTarget.gameObject); + } + else + { + Undo.DestroyObjectImmediate(oldTarget); + } + } + } + } + } + + private VRCAvatarDescriptor FindCommonAvatar() + { + VRCAvatarDescriptor commonAvatar = null; + + foreach (var target in targets) + { + var component = (ModularAvatarMenuInstaller) target; + var avatar = RuntimeUtil.FindAvatarInParents(component.transform); + if (avatar == null) return null; + + if (commonAvatar == null) + { + commonAvatar = avatar; + } + else if (commonAvatar != avatar) + { + return null; + } + } + + return commonAvatar; + } + + private void FindMenus() + { + if (targets.Length > 1) + { + _avatarMenus = null; + return; + } + + _avatarMenus = new HashSet(); + var queue = new Queue(); + var avatar = RuntimeUtil.FindAvatarInParents(((Component) target).transform); + if (avatar == null || avatar.expressionsMenu == null) return; + queue.Enqueue(avatar.expressionsMenu); + + while (queue.Count > 0) + { + var menu = queue.Dequeue(); + if (_avatarMenus.Contains(menu)) continue; + + _avatarMenus.Add(menu); + foreach (var subMenu in menu.controls) + { + if (subMenu.type == VRCExpressionsMenu.Control.ControlType.SubMenu) + { + queue.Enqueue(subMenu.subMenu); + } + } + } + } + + private void FindMenuInstallers() + { + if (targets.Length > 1) + { + _menuInstallersMap = null; + return; + } + + _menuInstallersMap = new Dictionary>(); + var avatar = RuntimeUtil.FindAvatarInParents(((Component) target).transform); + if (avatar == null) return; + var menuInstallers = avatar.GetComponentsInChildren(true) + .Where(menuInstaller => menuInstaller.enabled && menuInstaller.menuToAppend != null); + foreach (ModularAvatarMenuInstaller menuInstaller in menuInstallers) + { + if (menuInstaller == target) continue; + var visitedMenus = new HashSet(); + var queue = new Queue(); + queue.Enqueue(menuInstaller.menuToAppend); + + while (queue.Count > 0) + { + VRCExpressionsMenu parent = queue.Dequeue(); + var controls = parent.controls.Where(control => + control.type == VRCExpressionsMenu.Control.ControlType.SubMenu && control.subMenu != null); + foreach (VRCExpressionsMenu.Control control in controls) + { + // Do not filter in LINQ to avoid closure allocation + if (visitedMenus.Contains(control.subMenu)) continue; + if (!_menuInstallersMap.TryGetValue(control.subMenu, + out List fromInstallers)) + { + fromInstallers = new List(); + _menuInstallersMap[control.subMenu] = fromInstallers; + } + + fromInstallers.Add(menuInstaller); + visitedMenus.Add(control.subMenu); + queue.Enqueue(control.subMenu); + } + } + } + } + + private bool IsMenuReachable(VRCAvatarDescriptor avatar, VRCExpressionsMenu menu) + { + var virtualMenu = VirtualMenu.ForAvatar(avatar); + + return virtualMenu.ContainsMenu(menu); + } + + private static ValidateExpressionMenuIconResult ValidateExpressionMenuIcon(VRCExpressionsMenu menu, + HashSet visitedMenus = null) + { + if (menu == null) return ValidateExpressionMenuIconResult.Success; + if (visitedMenus == null) visitedMenus = new HashSet(); + if (visitedMenus.Contains(menu)) return ValidateExpressionMenuIconResult.Success; + visitedMenus.Add(menu); + + foreach (VRCExpressionsMenu.Control control in menu.controls) + { + // Control + ValidateExpressionMenuIconResult result = Util.ValidateExpressionMenuIcon(control.icon); + if (result != ValidateExpressionMenuIconResult.Success) return result; + + // Labels + if (control.labels != null) + { + foreach (VRCExpressionsMenu.Control.Label label in control.labels) + { + ValidateExpressionMenuIconResult labelResult = Util.ValidateExpressionMenuIcon(label.icon); + if (labelResult != ValidateExpressionMenuIconResult.Success) return labelResult; + } + } + + // SubMenu + if (control.type != VRCExpressionsMenu.Control.ControlType.SubMenu) continue; + ValidateExpressionMenuIconResult subMenuResult = + ValidateExpressionMenuIcon(control.subMenu, visitedMenus); + if (subMenuResult != ValidateExpressionMenuIconResult.Success) return subMenuResult; + } + + return ValidateExpressionMenuIconResult.Success; + } + } +} \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/Inspector/MenuInstallerEditor.cs.meta b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MenuInstallerEditor.cs.meta similarity index 100% rename from Packages/nadena.dev.modular-avatar/Editor/Inspector/MenuInstallerEditor.cs.meta rename to Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MenuInstallerEditor.cs.meta diff --git a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MenuItemGUI.cs b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MenuItemGUI.cs new file mode 100644 index 00000000..897b7824 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MenuItemGUI.cs @@ -0,0 +1,444 @@ +using System; +using System.Linq; +using System.Runtime.Serialization; +using Codice.CM.Common.Tree.Partial; +using nadena.dev.modular_avatar.core.menu; +using UnityEditor; +using UnityEngine; +using VRC.SDK3.Avatars.ScriptableObjects; +using static nadena.dev.modular_avatar.core.editor.Localization; + +namespace nadena.dev.modular_avatar.core.editor +{ + [CustomPropertyDrawer(typeof(SubmenuSource))] + class SubmenuSourceDrawer : EnumDrawer + { + protected override string localizationPrefix => "submenu_source"; + } + + internal class MenuItemCoreGUI + { + private static readonly ObjectIDGenerator IdGenerator = new ObjectIDGenerator(); + private readonly GameObject _parameterReference; + private readonly Action _redraw; + + private readonly SerializedObject _obj; + + private readonly SerializedProperty _name; + private readonly SerializedProperty _texture; + private readonly SerializedProperty _type; + private readonly SerializedProperty _value; + private readonly SerializedProperty _submenu; + + private readonly ParameterGUI _parameterGUI; + + private readonly SerializedProperty _subParamsRoot; + private readonly SerializedProperty _labelsRoot; + + private readonly MenuPreviewGUI _previewGUI; + + private ParameterGUI[] _subParams; + private SerializedProperty[] _labels; + + private int texPicker = -1; + + private readonly SerializedProperty _prop_submenuSource; + private readonly SerializedProperty _prop_otherObjSource; + + public bool AlwaysExpandContents = false; + public bool ExpandContents = false; + + public MenuItemCoreGUI(SerializedObject obj, Action redraw) + { + _obj = obj; + + GameObject parameterReference = null; + if (obj.targetObjects.Length == 1) + { + parameterReference = (obj.targetObject as Component)?.gameObject; + } + + _parameterReference = parameterReference; + _redraw = redraw; + + var gameObjects = new SerializedObject( + obj.targetObjects.Select(o => + (UnityEngine.Object) ((ModularAvatarMenuItem) o).gameObject + ).ToArray() + ); + + _name = gameObjects.FindProperty("m_Name"); + var control = obj.FindProperty(nameof(ModularAvatarMenuItem.Control)); + + _texture = control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.icon)); + _type = control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.type)); + var parameter = control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.parameter)) + .FindPropertyRelative(nameof(VRCExpressionsMenu.Control.parameter.name)); + _value = control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.value)); + _submenu = control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.subMenu)); + + _parameterGUI = new ParameterGUI(parameterReference, parameter, redraw); + + _subParamsRoot = control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.subParameters)); + _labelsRoot = control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.labels)); + + _prop_submenuSource = obj.FindProperty(nameof(ModularAvatarMenuItem.MenuSource)); + _prop_otherObjSource = obj.FindProperty(nameof(ModularAvatarMenuItem.menuSource_otherObjectChildren)); + _previewGUI = new MenuPreviewGUI(redraw); + } + + public MenuItemCoreGUI(GameObject parameterReference, SerializedProperty _control, Action redraw) + { + _obj = _control.serializedObject; + _parameterReference = parameterReference; + _redraw = redraw; + _name = _control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.name)); + _texture = _control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.icon)); + _type = _control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.type)); + var parameter = _control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.parameter)) + .FindPropertyRelative(nameof(VRCExpressionsMenu.Control.parameter.name)); + _value = _control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.value)); + _submenu = _control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.subMenu)); + + _parameterGUI = new ParameterGUI(parameterReference, parameter, redraw); + + _subParamsRoot = _control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.subParameters)); + _labelsRoot = _control.FindPropertyRelative(nameof(VRCExpressionsMenu.Control.labels)); + + _prop_submenuSource = null; + _prop_otherObjSource = null; + _previewGUI = new MenuPreviewGUI(redraw); + } + + public void DoGUI() + { + EditorGUILayout.BeginHorizontal(); + + EditorGUILayout.BeginVertical(); + EditorGUILayout.PropertyField(_name, G("menuitem.prop.name")); + EditorGUILayout.PropertyField(_texture, G("menuitem.prop.icon")); + EditorGUILayout.PropertyField(_type, G("menuitem.prop.type")); + EditorGUILayout.PropertyField(_value, G("menuitem.prop.value")); + + _parameterGUI.DoGUI(); + + EditorGUILayout.EndVertical(); + + if (_texture != null) + { + var tex = _texture.objectReferenceValue as Texture2D; + if (tex != null && !_texture.hasMultipleDifferentValues) + { + var size = EditorGUIUtility.singleLineHeight * 5; + var margin = 4; + var withMargin = new Vector2(margin + size, margin + size); + + var rect = GUILayoutUtility.GetRect(withMargin.x, withMargin.y, GUILayout.ExpandWidth(false), + GUILayout.ExpandHeight(true)); + rect.x += margin; + rect.y = rect.y + rect.height / 2 - size / 2; + rect.width = size; + rect.height = size; + + GUI.Box(rect, new GUIContent(), "flow node 1"); + GUI.DrawTexture(rect, tex); + } + } + + EditorGUILayout.EndHorizontal(); + + try + { + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.BeginVertical(); + + if (_type.hasMultipleDifferentValues) return; + VRCExpressionsMenu.Control.ControlType type = + (VRCExpressionsMenu.Control.ControlType) Enum + .GetValues(typeof(VRCExpressionsMenu.Control.ControlType)) + .GetValue(_type.enumValueIndex); + + switch (type) + { + case VRCExpressionsMenu.Control.ControlType.Button: + case VRCExpressionsMenu.Control.ControlType.Toggle: + return; + } + + EditorGUILayout.LabelField("", GUI.skin.horizontalSlider); + + switch (type) + { + case VRCExpressionsMenu.Control.ControlType.SubMenu: + { + object menuSource = null; + + if (_prop_submenuSource != null) + { + EditorGUILayout.PropertyField(_prop_submenuSource, G("menuitem.prop.submenu_source")); + if (_prop_submenuSource.hasMultipleDifferentValues) break; + + var sourceType = (SubmenuSource) Enum.GetValues(typeof(SubmenuSource)) + .GetValue(_prop_submenuSource.enumValueIndex); + + switch (sourceType) + { + case SubmenuSource.Children: + { + EditorGUILayout.PropertyField(_prop_otherObjSource, + G("menuitem.prop.source_override")); + if (_prop_otherObjSource.hasMultipleDifferentValues) break; + if (_prop_otherObjSource.objectReferenceValue == null) + { + if (_obj.targetObjects.Length != 1) break; + menuSource = new MenuNodesUnder((_obj.targetObject as Component)?.gameObject); + } + else + { + menuSource = + new MenuNodesUnder((GameObject) _prop_otherObjSource.objectReferenceValue); + } + + break; + } + case SubmenuSource.MenuAsset: + { + EditorGUILayout.PropertyField(_submenu, G("menuitem.prop.submenu_asset")); + if (_submenu.hasMultipleDifferentValues) break; + menuSource = _submenu.objectReferenceValue; + break; + } + } + } + else + { + // Native VRCSDK control + EditorGUILayout.PropertyField(_submenu, G("menuitem.prop.submenu_asset")); + if (_submenu.hasMultipleDifferentValues) break; + menuSource = _submenu.objectReferenceValue; + } + + if (menuSource != null) + { + if (AlwaysExpandContents) + { + ExpandContents = true; + } + else + { + EditorGUI.indentLevel += 1; + ExpandContents = EditorGUILayout.Foldout(ExpandContents, G("menuitem.showcontents")); + EditorGUI.indentLevel -= 1; + } + + if (ExpandContents) + { + if (menuSource is VRCExpressionsMenu menu) _previewGUI.DoGUI(menu, _parameterReference); + else if (menuSource is MenuSource nodes) _previewGUI.DoGUI(nodes); + } + } + + break; + } + case VRCExpressionsMenu.Control.ControlType.RadialPuppet: + { + EnsureParameterCount(1); + + _subParams[0].DoGUI(G("menuitem.param.rotation")); + + break; + } + case VRCExpressionsMenu.Control.ControlType.TwoAxisPuppet: + { + EnsureParameterCount(2); + EnsureLabelCount(4); + + EditorGUILayout.LabelField("Parameters", EditorStyles.boldLabel); + EditorGUILayout.Space(2); + + _subParams[0].DoGUI(G("menuitem.param.horizontal")); + _subParams[1].DoGUI(G("menuitem.param.vertical")); + + DoFourAxisLabels(false); + + break; + } + + case VRCExpressionsMenu.Control.ControlType.FourAxisPuppet: + { + DoFourAxisLabels(true); + break; + } + } + } + finally + { + EditorGUILayout.EndVertical(); + EditorGUILayout.EndHorizontal(); + + _obj.ApplyModifiedProperties(); + } + } + + private void EnsureLabelCount(int i) + { + if (_labels == null || _labelsRoot.arraySize < i || _labels.Length < i) + { + _labelsRoot.arraySize = i; + _labels = new SerializedProperty[i]; + for (int j = 0; j < i; j++) + { + _labels[j] = _labelsRoot.GetArrayElementAtIndex(j); + } + } + } + + private void CenterLabel(Rect rect, GUIContent content, GUIStyle style) + { + var size = style.CalcSize(content); + var x = rect.x + rect.width / 2 - size.x / 2; + var y = rect.y + rect.height / 2 - size.y / 2; + GUI.Label(new Rect(x, y, size.x, size.y), content, style); + } + + private void DoFourAxisLabels(bool showParams) + { + float maxWidth = 128 * 3; + + EnsureLabelCount(4); + if (showParams) EnsureParameterCount(4); + + float extraHeight = EditorGUIUtility.singleLineHeight * 3; + if (showParams) extraHeight += EditorGUIUtility.singleLineHeight; + + EditorGUILayout.LabelField( + G(showParams ? "menuitem.label.control_labels_and_params" : "menuitem.label.control_labels"), + EditorStyles.boldLabel); + + var square = GUILayoutUtility.GetAspectRect(1, GUILayout.MaxWidth(maxWidth)); + var extraSpace = GUILayoutUtility.GetRect(0, 0, extraHeight, + extraHeight, GUILayout.ExpandWidth(true)); + + var rect = square; + rect.height += extraSpace.height; + + float extraWidth = Math.Max(0, extraSpace.width - rect.width); + rect.x += extraWidth / 2; + + var blockHeight = rect.height / 3; + var blockWidth = rect.width / 3; + + var up = rect; + up.yMax -= blockHeight * 2; + up.xMin += blockWidth; + up.xMax -= blockWidth; + + var down = rect; + down.yMin += blockHeight * 2; + down.xMin += blockWidth; + down.xMax -= blockWidth; + + var left = rect; + left.yMin += blockHeight; + left.yMax -= blockHeight; + left.xMax -= blockWidth * 2; + + var right = rect; + right.yMin += blockHeight; + right.yMax -= blockHeight; + right.xMin += blockWidth * 2; + + var center = rect; + center.yMin += blockHeight; + center.yMax -= blockHeight; + center.xMin += blockWidth; + center.xMax -= blockWidth; + + SingleLabel(0, up); + SingleLabel(1, right); + SingleLabel(2, down); + SingleLabel(3, left); + + var rect_param_l = center; + rect_param_l.yMin = rect_param_l.yMax - EditorGUIUtility.singleLineHeight; + var rect_name_l = rect_param_l; + if (showParams) rect_name_l.y -= rect_param_l.height; + + if (showParams) CenterLabel(rect_param_l, G("menuitem.prop.parameter"), EditorStyles.label); + CenterLabel(rect_name_l, G("menuitem.prop.label"), EditorStyles.label); + + void SingleLabel(int index, Rect block) + { + var prop_name = _labels[index].FindPropertyRelative(nameof(VRCExpressionsMenu.Control.Label.name)); + var prop_icon = _labels[index].FindPropertyRelative(nameof(VRCExpressionsMenu.Control.Label.icon)); + + var rect_param = block; + rect_param.yMin = rect_param.yMax - EditorGUIUtility.singleLineHeight; + + var rect_name = rect_param; + if (showParams) rect_name.y -= rect_param.height; + + var rect_icon = block; + rect_icon.yMax = rect_name.yMin; + + EditorGUI.PropertyField(rect_name, prop_name, GUIContent.none); + if (showParams) + { + _subParams[index].DoGUI(rect_param, GUIContent.none); + } + + var tex = prop_icon.objectReferenceValue as Texture; + GUIContent icon_content; + + if (prop_icon.hasMultipleDifferentValues) + { + icon_content = G("menuitem.misc.multiple"); + } + else + { + icon_content = tex != null ? new GUIContent(tex) : G("menuitem.misc.no_icon"); + } + + int objectId = GUIUtility.GetControlID( + ((int) IdGenerator.GetId(this, out bool _) << 2) | index, + FocusType.Passive, + block + ); + + if (GUI.Button(rect_icon, icon_content)) + { + texPicker = index; + + EditorGUIUtility.ShowObjectPicker( + prop_icon.hasMultipleDifferentValues ? null : prop_icon.objectReferenceValue, false, + "t:texture2d", objectId); + } + + if (texPicker == index) + { + if (Event.current.commandName == "ObjectSelectorUpdated" && + EditorGUIUtility.GetObjectPickerControlID() == objectId) + { + prop_icon.objectReferenceValue = EditorGUIUtility.GetObjectPickerObject() as Texture; + _redraw(); + } + } + } + } + + private void EnsureParameterCount(int i) + { + if (_subParams == null || _subParamsRoot.arraySize < i || _subParams.Length < i) + { + _subParamsRoot.arraySize = i; + _subParams = new ParameterGUI[i]; + for (int j = 0; j < i; j++) + { + var prop = _subParamsRoot.GetArrayElementAtIndex(j) + .FindPropertyRelative(nameof(VRCExpressionsMenu.Control.Parameter.name)); + _subParams[j] = new ParameterGUI(_parameterReference, prop, _redraw); + } + } + } + } +} \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MenuItemGUI.cs.meta b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MenuItemGUI.cs.meta new file mode 100644 index 00000000..aa13c17f --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MenuItemGUI.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: f70714a2a244442e9eabd3fce5a190e6 +timeCreated: 1677149863 \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MenuPreviewGUI.cs b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MenuPreviewGUI.cs new file mode 100644 index 00000000..5ca1f0c0 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MenuPreviewGUI.cs @@ -0,0 +1,247 @@ +using System; +using System.Collections.Generic; +using nadena.dev.modular_avatar.core.menu; +using UnityEditor; +using UnityEngine; +using VRC.SDK3.Avatars.ScriptableObjects; + +namespace nadena.dev.modular_avatar.core.editor +{ + internal class MenuPreviewGUI + { + private const float INDENT_PER_LEVEL = 2; + private Action _redraw; + private float _indentLevel = 0; + private readonly Dictionary _guiNodes = new Dictionary(); + + public MenuPreviewGUI(Action redraw) + { + _redraw = redraw; + } + + public void DoGUI(MenuSource root) + { + _indentLevel = 0; + new VisitorContext(this).PushNode(root); + } + + public void DoGUI(ModularAvatarMenuInstaller root) + { + _indentLevel = 0; + new VisitorContext(this).PushMenuInstaller(root); + } + + public void DoGUI(VRCExpressionsMenu menu, GameObject parameterReference = null) + { + _indentLevel = 0; + new VisitorContext(this).PushNode(menu); + } + + private void PushGuiNode(object key, Func guiBuilder) + { + if (!_guiNodes.TryGetValue(key, out var gui)) + { + gui = guiBuilder(); + _guiNodes.Add(key, gui); + } + + gui(); + } + + private class Header + { + private MenuPreviewGUI _gui; + private UnityEngine.Object _headerObj; + private SerializedProperty _disableProp; + + public Header(MenuPreviewGUI gui, UnityEngine.Object headerObj, SerializedProperty disableProp = null) + { + _gui = gui; + _headerObj = headerObj; + _disableProp = disableProp; + } + + public IDisposable Scope() + { + GUILayout.BeginHorizontal(); + GUILayout.Space(_gui._indentLevel); + _gui._indentLevel += INDENT_PER_LEVEL; + + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + if (_headerObj != null) + { + GUILayout.BeginHorizontal(); + using (new EditorGUI.DisabledScope(true)) + { + EditorGUILayout.ObjectField(new GUIContent(), _headerObj, _headerObj.GetType(), + true, + GUILayout.ExpandWidth(true)); + } + + if (_disableProp != null) + { + _disableProp.serializedObject.Update(); + GUILayout.Space(20); + GUILayout.Label("Enabled", GUILayout.Width(50)); + EditorGUILayout.PropertyField(_disableProp, GUIContent.none, + GUILayout.Width(EditorGUIUtility.singleLineHeight)); + _disableProp.serializedObject.ApplyModifiedProperties(); + } + + GUILayout.EndHorizontal(); + } + + return new ScopeSentinel(_gui); + } + + private class ScopeSentinel : IDisposable + { + private readonly MenuPreviewGUI _gui; + + public ScopeSentinel(MenuPreviewGUI gui) + { + _gui = gui; + } + + public void Dispose() + { + GUILayout.EndVertical(); + _gui._indentLevel -= INDENT_PER_LEVEL; + GUILayout.EndHorizontal(); + } + } + } + + private class VisitorContext : NodeContext + { + private readonly HashSet _visited = new HashSet(); + private readonly MenuPreviewGUI _gui; + + public VisitorContext(MenuPreviewGUI gui) + { + _gui = gui; + } + + public void PushMenu(VRCExpressionsMenu expMenu, GameObject parameterReference = null) + { + _gui.PushGuiNode((expMenu, parameterReference), () => + { + var header = new Header(_gui, expMenu); + var obj = new SerializedObject(expMenu); + var controls = obj.FindProperty(nameof(expMenu.controls)); + var subGui = new List(); + for (int i = 0; i < controls.arraySize; i++) + { + subGui.Add(new MenuItemCoreGUI(parameterReference, controls.GetArrayElementAtIndex(i), + _gui._redraw)); + } + + return () => + { + using (header.Scope()) + { + foreach (var gui in subGui) + { + using (new Header(_gui, null).Scope()) + { + gui.DoGUI(); + } + } + } + }; + }); + } + + public void PushNode(VRCExpressionsMenu expMenu) + { + PushMenu(expMenu, null); + } + + public void PushNode(MenuSource source) + { + if (source is ModularAvatarMenuItem item) + { + _gui.PushGuiNode(item, () => + { + var header = new Header(_gui, item, + new SerializedObject(item.gameObject).FindProperty("m_IsActive")); + var gui = new MenuItemCoreGUI(new SerializedObject(item), _gui._redraw); + return () => + { + using (header.Scope()) + { + gui.DoGUI(); + } + }; + }); + } + else + { + using (new Header(_gui, source as UnityEngine.Object).Scope()) + { + if (_visited.Contains(source)) return; + _visited.Add(source); + + source.Visit(this); + } + } + } + + public void PushNode(ModularAvatarMenuInstaller installer) + { + using (new Header(_gui, installer).Scope()) + { + PushMenuInstaller(installer); + } + } + + internal void PushMenuInstaller(ModularAvatarMenuInstaller installer) + { + var source = installer.GetComponent(); + if (source != null) + { + PushNode(source); + } + else if (installer.menuToAppend != null) + { + PushMenu(installer.menuToAppend, installer.gameObject); + } + } + + public void PushControl(VRCExpressionsMenu.Control control) + { + // Construct a read-only GUI, as we can't build a serialized property reference for this control object + _gui.PushGuiNode(control, () => + { + var container = ScriptableObject.CreateInstance(); + container.controls = new List {control}; + var prop = new SerializedObject(container).FindProperty("controls").GetArrayElementAtIndex(0); + var gui = new MenuItemCoreGUI(null, prop, _gui._redraw); + return () => + { + using (new EditorGUI.DisabledScope(true)) + { + gui.DoGUI(); + } + }; + }); + } + + public void PushControl(VirtualControl control) + { + PushControl((VRCExpressionsMenu.Control) control); + } + + public VirtualMenuNode NodeFor(VRCExpressionsMenu menu) + { + return new VirtualMenuNode(menu); + } + + public VirtualMenuNode NodeFor(MenuSource menu) + { + return new VirtualMenuNode(menu); + } + } + } +} \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MenuPreviewGUI.cs.meta b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MenuPreviewGUI.cs.meta new file mode 100644 index 00000000..496c0f76 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/MenuPreviewGUI.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: f61c45884f584638a85253fa309f247f +timeCreated: 1677302074 \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ParameterField.cs b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ParameterField.cs new file mode 100644 index 00000000..1f2589bd --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ParameterField.cs @@ -0,0 +1,278 @@ +using System; +using System.Collections.Generic; +using UnityEditor; +using UnityEditor.IMGUI.Controls; +using UnityEngine; +using VRC.SDK3.Avatars.Components; +using Object = UnityEngine.Object; +using static nadena.dev.modular_avatar.core.editor.Localization; + +namespace nadena.dev.modular_avatar.core.editor +{ + internal class ParameterGUI + { + private GameObject _parameterReference; + private readonly SerializedProperty _property; + private readonly Action _redraw; + private Rect fieldRect; + + internal ParameterGUI(GameObject parameterReference, SerializedProperty property, Action redraw) + { + _parameterReference = parameterReference; + _property = property; + _redraw = redraw; + } + + public void DoGUI(GUIContent label = null) + { + DoGUI(EditorGUILayout.GetControlRect( + true, + EditorGUIUtility.singleLineHeight + ), label); + } + + public void DoGUI(Rect rect, GUIContent label = null) + { + if (label == null) label = G("menuitem.prop.parameter"); + + if (_parameterReference != null) GUILayout.Space(-2); + GUILayout.BeginHorizontal(); + + rect.width -= EditorGUIUtility.singleLineHeight; + + EditorGUI.PropertyField(rect, _property, label); + Rect propField = new Rect(); + + if (Event.current.type == EventType.Repaint) + { + propField = rect; + } + + Rect buttonRect = rect; + buttonRect.xMin = rect.xMax; + buttonRect.width = EditorGUIUtility.singleLineHeight; + + GUIStyle style = "IN DropDown"; + + buttonRect.xMin += (buttonRect.width - style.fixedWidth) / 2; + + if (_parameterReference != null && + EditorGUI.DropdownButton(buttonRect, new GUIContent(), FocusType.Keyboard, style)) + { + PopupWindow.Show(fieldRect, + new ParameterWindow(_parameterReference, _property, fieldRect.width, _redraw)); + } + + if (Event.current.type == EventType.Repaint) + { + float labelWidth = label == GUIContent.none ? 0 : EditorGUIUtility.labelWidth; + + fieldRect = propField; + fieldRect.x += labelWidth + 2; + fieldRect.width = buttonRect.xMax - propField.x - labelWidth; + fieldRect.height = 0; + } + + GUILayout.EndHorizontal(); + if (_parameterReference != null) GUILayout.Space(2); + } + + private class ParameterWindow : PopupWindowContent + { + private readonly GameObject _target; + private readonly SerializedProperty _prop; + private readonly float _width; + private readonly Action _redraw; + + private SearchField _searchField; + private ParameterTree _tree; + private string _searchString; + + public ParameterWindow(GameObject target, SerializedProperty prop, float width, Action redraw) + { + _target = target; + _prop = prop; + _width = width; + _redraw = redraw; + } + + public override void OnGUI(Rect rect) + { + var sfRect = rect; + sfRect.height = EditorGUIUtility.singleLineHeight; + rect.y += EditorGUIUtility.singleLineHeight; + rect.height -= EditorGUIUtility.singleLineHeight; + + if (_searchField == null) + { + _searchField = new SearchField(); + } + + _searchString = _searchField.OnGUI(sfRect, _searchString); + + if (_tree == null) + { + _tree = new ParameterTree(new TreeViewState(), _target); + _tree.OnSelect = (s) => + { + _prop.stringValue = s; + _prop.serializedObject.ApplyModifiedProperties(); + _redraw(); + }; + + _tree.OnCommit = (s) => + { + _prop.stringValue = s; + _prop.serializedObject.ApplyModifiedProperties(); + editorWindow.Close(); + _redraw(); + }; + _tree.Reload(); + } + + _tree.searchString = _searchString; + _tree.OnGUI(rect); + } + + public override Vector2 GetWindowSize() + { + return new Vector2(Math.Max(256, _width), 150); + } + } + + private class ParameterTree : TreeView + { + private List _items; + public Action OnSelect, OnCommit; + + private GameObject _obj; + + private class SourceItem : TreeViewItem + { + public GameObject source; + } + + private class ParamItem : TreeViewItem + { + public GameObject source; + } + + public ParameterTree(TreeViewState state, GameObject obj) : base(state) + { + _obj = obj; + } + + protected override void SelectionChanged(IList selectedIds) + { + var item = _items[selectedIds[0]]; + if (item != null) OnSelect(item); + } + + protected override void DoubleClickedItem(int id) + { + var item = _items[id]; + if (item != null) OnCommit(item); + } + + protected override void RowGUI(RowGUIArgs args) + { + if (!string.IsNullOrEmpty(searchString) && args.item is ParamItem offer) + { + var rect = args.rowRect; + + var objName = offer.source.name + " / "; + var content = new GUIContent(objName); + + var width = EditorStyles.label.CalcSize(content).x; + var color = GUI.color; + + var grey = color; + grey.a *= 0.7f; + GUI.color = grey; + + EditorGUI.LabelField(rect, content); + + GUI.color = color; + + rect.x += width; + rect.width -= width; + + if (rect.width >= 0) + { + EditorGUI.LabelField(rect, offer.displayName); + } + } + else if (args.item is SourceItem source) + { + var rect = args.rowRect; + rect.xMin += this.GetContentIndent(args.item) + this.extraSpaceBeforeIconAndLabel; + EditorGUI.LabelField(rect, source.source.name); + } + else + { + base.RowGUI(args); + } + } + + protected override TreeViewItem BuildRoot() + { + List treeItems = new List(); + _items = new List(); + + _items.Add(""); + var root = new TreeViewItem {id = 0, depth = -1, displayName = "Root"}; + + GameObject priorNode = null; + + foreach ((GameObject node, string param) in FindParameters()) + { + if (node != priorNode) + { + _items.Add(null); + treeItems.Add(new SourceItem() + {id = _items.Count - 1, depth = 0, displayName = "", source = node}); + priorNode = node; + } + + _items.Add(param); + treeItems.Add(new ParamItem {id = _items.Count - 1, depth = 1, displayName = param, source = node}); + } + + SetupParentsAndChildrenFromDepths(root, treeItems); + + return root; + } + + private IEnumerable<(GameObject, string)> FindParameters() + { + HashSet emitted = new HashSet(); + GameObject node = _obj; + while (node != null && node.GetComponent() == null) + { + var paramComp = node.GetComponent(); + if (paramComp != null) + { + foreach (var param in paramComp.parameters) + { + if (!param.isPrefix) + { + if (emitted.Add(param.nameOrPrefix)) yield return (node, param.nameOrPrefix); + } + } + } + + node = node.transform.parent?.gameObject; + } + + var desc = node?.GetComponent(); + if (desc != null) + { + foreach (var param in desc.expressionParameters.parameters) + { + if (emitted.Add(param.name)) yield return (node, param.name); + } + } + } + } + } +} \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ParameterField.cs.meta b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ParameterField.cs.meta new file mode 100644 index 00000000..cacd5362 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/Inspector/Menu/ParameterField.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 69bb3e09018e430581cabad176e3068e +timeCreated: 1677148788 \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/Inspector/MenuInstallerEditor.cs b/Packages/nadena.dev.modular-avatar/Editor/Inspector/MenuInstallerEditor.cs deleted file mode 100644 index 13310e65..00000000 --- a/Packages/nadena.dev.modular-avatar/Editor/Inspector/MenuInstallerEditor.cs +++ /dev/null @@ -1,304 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using UnityEditor; -using UnityEngine; -using VRC.SDK3.Avatars.Components; -using VRC.SDK3.Avatars.ScriptableObjects; -using static nadena.dev.modular_avatar.core.editor.Localization; -using static nadena.dev.modular_avatar.core.editor.Util; - -namespace nadena.dev.modular_avatar.core.editor -{ - [CustomEditor(typeof(ModularAvatarMenuInstaller))] - [CanEditMultipleObjects] - internal class MenuInstallerEditor : MAEditorBase - { - private ModularAvatarMenuInstaller _installer; - private Editor _innerMenuEditor; - private VRCExpressionsMenu _menuToAppend; - - private bool _menuFoldout; - private bool _devFoldout; - - private HashSet _avatarMenus; - - private Dictionary> _menuInstallersMap; - - private void OnEnable() - { - _installer = (ModularAvatarMenuInstaller) target; - - FindMenus(); - FindMenuInstallers(); - } - - private void SetupMenuEditor() - { - if (targets.Length != 1) - { - _innerMenuEditor = null; - _menuToAppend = null; - } - else if (_installer.menuToAppend != _menuToAppend) - { - if (_installer.menuToAppend == null) _innerMenuEditor = null; - else - { - _innerMenuEditor = CreateEditor(_installer.menuToAppend); - } - - _menuToAppend = _installer.menuToAppend; - } - } - protected override void OnInnerInspectorGUI() - { - SetupMenuEditor(); - - var installTo = serializedObject.FindProperty(nameof(ModularAvatarMenuInstaller.installTargetMenu)); - - var isEnabled = targets.Length != 1 || ((ModularAvatarMenuInstaller) target).enabled; - - VRCAvatarDescriptor commonAvatar = FindCommonAvatar(); - - if (!installTo.hasMultipleDifferentValues) - { - if (installTo.objectReferenceValue == null) - { - if (isEnabled) - { - EditorGUILayout.HelpBox(S("menuinstall.help.hint_set_menu"), MessageType.Info); - } - } - else if (!IsMenuReachable(RuntimeUtil.FindAvatarInParents(((Component) target).transform), - (VRCExpressionsMenu) installTo.objectReferenceValue)) - { - EditorGUILayout.HelpBox(S("menuinstall.help.hint_bad_menu"), MessageType.Error); - } - } - - if (installTo.hasMultipleDifferentValues || commonAvatar == null) - { - EditorGUILayout.PropertyField(installTo, G("menuinstall.installto")); - } - else - { - var displayValue = installTo.objectReferenceValue; - if (displayValue == null) displayValue = commonAvatar.expressionsMenu; - - EditorGUI.BeginChangeCheck(); - var newValue = EditorGUILayout.ObjectField(G("menuinstall.installto"), displayValue, - typeof(VRCExpressionsMenu), false); - if (EditorGUI.EndChangeCheck()) - { - installTo.objectReferenceValue = newValue; - } - } - - var avatar = RuntimeUtil.FindAvatarInParents(_installer.transform); - if (avatar != null && GUILayout.Button(G("menuinstall.selectmenu"))) - { - AvMenuTreeViewWindow.Show(avatar, _installer, menu => - { - installTo.objectReferenceValue = menu; - serializedObject.ApplyModifiedProperties(); - }); - } - - if (targets.Length == 1) - { - _menuFoldout = EditorGUILayout.Foldout(_menuFoldout, G("menuinstall.showcontents")); - if (_menuFoldout) - { - EditorGUI.indentLevel++; - using (var disabled = new EditorGUI.DisabledScope(true)) - { - if (_innerMenuEditor != null) _innerMenuEditor.OnInspectorGUI(); - else EditorGUILayout.HelpBox(S("menuinstall.showcontents.notselected"), MessageType.Info); - } - - EditorGUI.indentLevel--; - } - } - - _devFoldout = EditorGUILayout.Foldout(_devFoldout, G("menuinstall.devoptions")); - if (_devFoldout) - { - SerializedProperty menuToAppendProperty = serializedObject.FindProperty(nameof(ModularAvatarMenuInstaller.menuToAppend)); - switch (ValidateExpressionMenuIcon((VRCExpressionsMenu)menuToAppendProperty.objectReferenceValue)) - { - case ValidateExpressionMenuIconResult.Success: - break; - case ValidateExpressionMenuIconResult.TooLarge: - EditorGUILayout.HelpBox(S("menuinstall.menu_icon_too_large"), MessageType.Error); - break; - case ValidateExpressionMenuIconResult.Uncompressed: - EditorGUILayout.HelpBox(S("menuinstall.menu_icon_uncompressed"), MessageType.Error); - break; - default: - throw new ArgumentOutOfRangeException(); - } - - EditorGUI.indentLevel++; - EditorGUILayout.PropertyField( - menuToAppendProperty, new GUIContent(G("menuinstall.srcmenu"))); - EditorGUI.indentLevel--; - } - - serializedObject.ApplyModifiedProperties(); - - Localization.ShowLanguageUI(); - } - - private VRCAvatarDescriptor FindCommonAvatar() - { - VRCAvatarDescriptor commonAvatar = null; - - foreach (var target in targets) - { - var component = (ModularAvatarMenuInstaller) target; - var avatar = RuntimeUtil.FindAvatarInParents(component.transform); - if (avatar == null) return null; - - if (commonAvatar == null) - { - commonAvatar = avatar; - } - else if (commonAvatar != avatar) - { - return null; - } - } - - return commonAvatar; - } - - private void FindMenus() - { - if (targets.Length > 1) - { - _avatarMenus = null; - return; - } - - _avatarMenus = new HashSet(); - var queue = new Queue(); - var avatar = RuntimeUtil.FindAvatarInParents(((Component) target).transform); - if (avatar == null || avatar.expressionsMenu == null) return; - queue.Enqueue(avatar.expressionsMenu); - - while (queue.Count > 0) - { - var menu = queue.Dequeue(); - if (_avatarMenus.Contains(menu)) continue; - - _avatarMenus.Add(menu); - foreach (var subMenu in menu.controls) - { - if (subMenu.type == VRCExpressionsMenu.Control.ControlType.SubMenu) - { - queue.Enqueue(subMenu.subMenu); - } - } - } - } - - private void FindMenuInstallers() - { - if (targets.Length > 1) - { - _menuInstallersMap = null; - return; - } - - _menuInstallersMap = new Dictionary>(); - var avatar = RuntimeUtil.FindAvatarInParents(((Component)target).transform); - if (avatar == null) return; - var menuInstallers = avatar.GetComponentsInChildren(true) - .Where(menuInstaller => menuInstaller.enabled && menuInstaller.menuToAppend != null); - foreach (ModularAvatarMenuInstaller menuInstaller in menuInstallers) - { - if (menuInstaller == target) continue; - var visitedMenus = new HashSet(); - var queue = new Queue(); - queue.Enqueue(menuInstaller.menuToAppend); - - while (queue.Count > 0) - { - VRCExpressionsMenu parent = queue.Dequeue(); - var controls = parent.controls.Where(control => control.type == VRCExpressionsMenu.Control.ControlType.SubMenu && control.subMenu != null); - foreach (VRCExpressionsMenu.Control control in controls) - { - // Do not filter in LINQ to avoid closure allocation - if (visitedMenus.Contains(control.subMenu)) continue; - if (!_menuInstallersMap.TryGetValue(control.subMenu, out List fromInstallers)) - { - fromInstallers = new List(); - _menuInstallersMap[control.subMenu] = fromInstallers; - } - - fromInstallers.Add(menuInstaller); - visitedMenus.Add(control.subMenu); - queue.Enqueue(control.subMenu); - } - } - } - } - - private bool IsMenuReachable(VRCAvatarDescriptor avatar, VRCExpressionsMenu menu, HashSet visitedInstaller = null) - { - if (_avatarMenus == null || _avatarMenus.Contains(menu)) return true; - - if (_menuInstallersMap == null) return true; - if (visitedInstaller == null) visitedInstaller = new HashSet { (ModularAvatarMenuInstaller)target }; - - if (!_menuInstallersMap.TryGetValue(menu, out List installers)) return false; - foreach (ModularAvatarMenuInstaller installer in installers) - { - // Root is always reachable if installTargetMenu is null - if (installer.installTargetMenu == null) return true; - // Even in a circular structure, it may be possible to reach root by another path. - if (visitedInstaller.Contains(installer)) continue; - visitedInstaller.Add(installer); - if (IsMenuReachable(avatar, installer.installTargetMenu, visitedInstaller)) - { - return true; - } - } - - return false; - } - - private static ValidateExpressionMenuIconResult ValidateExpressionMenuIcon(VRCExpressionsMenu menu, HashSet visitedMenus = null) - { - if (menu == null) return ValidateExpressionMenuIconResult.Success; - if (visitedMenus == null) visitedMenus = new HashSet(); - if (visitedMenus.Contains(menu)) return ValidateExpressionMenuIconResult.Success; - visitedMenus.Add(menu); - - foreach (VRCExpressionsMenu.Control control in menu.controls) { - // Control - ValidateExpressionMenuIconResult result = Util.ValidateExpressionMenuIcon(control.icon); - if (result != ValidateExpressionMenuIconResult.Success) return result; - - // Labels - if (control.labels != null) - { - foreach (VRCExpressionsMenu.Control.Label label in control.labels) - { - ValidateExpressionMenuIconResult labelResult = Util.ValidateExpressionMenuIcon(label.icon); - if (labelResult != ValidateExpressionMenuIconResult.Success) return labelResult; - } - } - - // SubMenu - if (control.type != VRCExpressionsMenu.Control.ControlType.SubMenu) continue; - ValidateExpressionMenuIconResult subMenuResult = ValidateExpressionMenuIcon(control.subMenu, visitedMenus); - if (subMenuResult != ValidateExpressionMenuIconResult.Success) return subMenuResult; - } - - return ValidateExpressionMenuIconResult.Success; - } - - } -} \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/Localization/en.json b/Packages/nadena.dev.modular-avatar/Editor/Localization/en.json index b3642dbe..a0dfca7d 100644 --- a/Packages/nadena.dev.modular-avatar/Editor/Localization/en.json +++ b/Packages/nadena.dev.modular-avatar/Editor/Localization/en.json @@ -78,5 +78,33 @@ "validation.menu_installer.no_menu": "No menu to install specified", "validation.merge_animator.no_animator": "No animator to merge specified", "validation.merge_armature.no_target": "No merge target specified", - "validation.merge_armature.target_is_child": "Merge target cannot be a child of this object" + "validation.merge_armature.target_is_child": "Merge target cannot be a child of this object", + "submenu_source.Children": "Children", + "submenu_source.MenuAsset": "Expressions Menu Asset", + "menuitem.showcontents": "Show menu contents", + "menuitem.prop.name": "Name", + "menuitem.prop.icon": "Icon", + "menuitem.prop.icon.tooltip": "(Optional) The icon to be shown in the expressions menu", + "menuitem.prop.type": "Type", + "menuitem.prop.type.tooltip": "The type of this item", + "menuitem.prop.value": "Value", + "menuitem.prop.value.tooltip": "The value to set the parameter to when this control is used", + "menuitem.prop.parameter": "Parameter", + "menuitem.prop.label": "Label", + "menuitem.prop.submenu_asset": "Submenu Asset", + "menuitem.prop.submenu_asset.tooltip": "The asset to use as the submenu", + "menuitem.prop.submenu_source": "Submenu Source", + "menuitem.prop.submenu_source.tooltip": "Where to find the items to put inside this submenu", + "menuitem.prop.source_override": "Source object override", + "menuitem.prop.source_override.tooltip": "If specified, this object will be used as the source for the contents of the submenu. Otherwise, children of this menu item will be used.", + "menuitem.param.rotation": "Parameter: Rotation", + "menuitem.param.rotation.tooltip": "The parameter to set based on the rotation of this menu item", + "menuitem.param.horizontal": "Parameter: Horizontal", + "menuitem.param.horizontal.tooltip": "The parameter to set based on the horizontal position of the thumbstick", + "menuitem.param.vertical": "Parameter: Vertical", + "menuitem.param.vertical.tooltip": "The parameter to set based on the vertical position of the thumbstick", + "menuitem.label.control_labels_and_params": "Control Labels and Parameters", + "menuitem.label.control_labels": "Control Labels", + "menuitem.misc.multiple": "(multiple)", + "menuitem.misc.no_icon": "(no icon)" } \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/Localization/ja.json b/Packages/nadena.dev.modular-avatar/Editor/Localization/ja.json index d67a2f6d..c8a461ae 100644 --- a/Packages/nadena.dev.modular-avatar/Editor/Localization/ja.json +++ b/Packages/nadena.dev.modular-avatar/Editor/Localization/ja.json @@ -75,5 +75,33 @@ "validation.bone_proxy.no_target": "ターゲットオブジェクトが未設定、もしくは存在しません。", "validation.menu_installer.no_menu": "インストールするメニューがありません。", "validation.merge_animator.no_animator": "Animator Controllerがありません。", - "validation.merge_armature.no_target": "ターゲットオブジェクトが未設定、もしくは存在しません。" + "validation.merge_armature.no_target": "ターゲットオブジェクトが未設定、もしくは存在しません。", + "submenu_source.Children": "子オブジェクトから生成", + "submenu_source.MenuAsset": "Expressions Menu アセットを指定", + "menuitem.showcontents": "メニュー内容を表示", + "menuitem.prop.name": "表示名", + "menuitem.prop.icon": "アイコン", + "menuitem.prop.icon.tooltip": "(任意) メニューに表示するべきアイコン", + "menuitem.prop.type": "タイプ", + "menuitem.prop.type.tooltip": "この項目の種別", + "menuitem.prop.value": "パラメーター値", + "menuitem.prop.value.tooltip": "この項目が操作されたとき、パラメーターが設定される値", + "menuitem.prop.parameter": "パラメーター名", + "menuitem.prop.label": "表示名", + "menuitem.prop.submenu_asset": "サブメニューアセット", + "menuitem.prop.submenu_asset.tooltip": "サブメニューとして引用するアセット", + "menuitem.prop.submenu_source": "サブメニュー引用元", + "menuitem.prop.submenu_source.tooltip": "このサブメニューの内容をどこから引用するべきかを指定", + "menuitem.prop.source_override": "引用元オブジェクト", + "menuitem.prop.source_override.tooltip": "指定した場合は、指定したオブジェクトの子をメニューの内容として指定します。指定されてない場合はこのオブジェクト直下の子を使用します。", + "menuitem.param.rotation": "回転パラメーター名", + "menuitem.param.rotation.tooltip": "このメニューアイテムの回転に連動するべきパラメーター", + "menuitem.param.horizontal": "横パラメーター名", + "menuitem.param.horizontal.tooltip": "横操作に連動するパラメーター名", + "menuitem.param.vertical": "立てパラメーター名", + "menuitem.param.vertical.tooltip": "上下操作に連動するパラメーター名", + "menuitem.label.control_labels_and_params": "表示名・パラメーター", + "menuitem.label.control_labels": "表示名", + "menuitem.misc.multiple": "(複数設定)", + "menuitem.misc.no_icon": "(アイコン無し)" } diff --git a/Packages/nadena.dev.modular-avatar/Editor/Menu.meta b/Packages/nadena.dev.modular-avatar/Editor/Menu.meta new file mode 100644 index 00000000..20e82910 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/Menu.meta @@ -0,0 +1,4 @@ +fileFormatVersion: 2 +guid: 435b9162d1fd4050b9ced045ab20af27 +timeCreated: 1676977199 +folderAsset: yes diff --git a/Packages/nadena.dev.modular-avatar/Editor/Menu/ClonedMenuMappings.cs b/Packages/nadena.dev.modular-avatar/Editor/Menu/ClonedMenuMappings.cs new file mode 100644 index 00000000..f24c2cb4 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/Menu/ClonedMenuMappings.cs @@ -0,0 +1,51 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using VRC.SDK3.Avatars.ScriptableObjects; + +namespace nadena.dev.modular_avatar.core.editor +{ + internal static class ClonedMenuMappings + { + /// + /// Map to link the cloned menu from the clone source. + /// If one menu is specified for multiple installers, they are replicated separately, so there is a one-to-many relationship. + /// + private static readonly Dictionary> ClonedMappings = + new Dictionary>(); + + /// + /// Map to link the clone source from the cloned menu. + /// Map is the opposite of ClonedMappings. + /// + private static readonly Dictionary OriginalMapping = + new Dictionary(); + + public static void Clear() + { + ClonedMappings.Clear(); + OriginalMapping.Clear(); + } + + public static void Add(VRCExpressionsMenu original, VRCExpressionsMenu clonedMenu) + { + if (!ClonedMappings.TryGetValue(original, out ImmutableList clonedMenus)) + { + clonedMenus = ImmutableList.Empty; + } + + ClonedMappings[original] = clonedMenus.Add(clonedMenu); + OriginalMapping[clonedMenu] = original; + } + + public static bool TryGetClonedMenus(VRCExpressionsMenu original, + out ImmutableList clonedMenus) + { + return ClonedMappings.TryGetValue(original, out clonedMenus); + } + + public static VRCExpressionsMenu GetOriginal(VRCExpressionsMenu cloned) + { + return OriginalMapping.TryGetValue(cloned, out VRCExpressionsMenu original) ? original : null; + } + } +} \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/ClonedMenuMappings.cs.meta b/Packages/nadena.dev.modular-avatar/Editor/Menu/ClonedMenuMappings.cs.meta similarity index 100% rename from Packages/nadena.dev.modular-avatar/Editor/ClonedMenuMappings.cs.meta rename to Packages/nadena.dev.modular-avatar/Editor/Menu/ClonedMenuMappings.cs.meta diff --git a/Packages/nadena.dev.modular-avatar/Editor/Menu/MenuExtractor.cs b/Packages/nadena.dev.modular-avatar/Editor/Menu/MenuExtractor.cs new file mode 100644 index 00000000..8ce9865d --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/Menu/MenuExtractor.cs @@ -0,0 +1,133 @@ +using System.Collections.Generic; +using System.Linq; +using nadena.dev.modular_avatar.core.menu; +using UnityEditor; +using UnityEngine; +using VRC.SDK3.Avatars.Components; +using VRC.SDK3.Avatars.ScriptableObjects; + +namespace nadena.dev.modular_avatar.core.editor +{ + internal class MenuExtractor + { + private const int PRIORITY = 49; + + [MenuItem("GameObject/[Modular Avatar] Extract menu", false, PRIORITY)] + static void ExtractMenu(MenuCommand menuCommand) + { + if (!(menuCommand.context is GameObject gameObj)) return; + var avatar = gameObj.GetComponent(); + if (avatar == null || avatar.expressionsMenu == null) return; + + var parent = ExtractSingleLayerMenu(avatar.expressionsMenu, gameObj, "Avatar Menu"); + parent.AddComponent(); + parent.AddComponent(); + + // The VRCSDK requires that an expressions menu asset be provided if any parameters are defined. + // We can't just remove the asset, so we'll replace it with a dummy asset. However, to avoid users + // accidentally overwriting files in Packages, we'll place this dummy asset next to where the original + // asset was (or in the Assets root, if the original asset was in Packages). + Undo.RecordObject(avatar, "Extract menu"); + + var assetPath = AssetDatabase.GetAssetPath(avatar.expressionsMenu); + var dummyAssetPathBase = assetPath.Replace(".asset", " placeholder"); + if (dummyAssetPathBase.StartsWith("Packages" + System.IO.Path.DirectorySeparatorChar)) + { + var filename = System.IO.Path.GetFileName(dummyAssetPathBase); + dummyAssetPathBase = System.IO.Path.Combine("Assets", filename); + } + + // Check that a similarly-named file doesn't already exist + int i = 0; + do + { + var fullPath = dummyAssetPathBase + (i > 0 ? " " + i : "") + ".asset"; + if (System.IO.File.Exists(fullPath)) + { + var asset = AssetDatabase.LoadAssetAtPath(fullPath); + if (asset != null && asset.controls.Count == 0) + { + avatar.expressionsMenu = asset; + break; + } + } + else if (!System.IO.File.Exists(fullPath)) + { + var dummyAsset = ScriptableObject.CreateInstance(); + AssetDatabase.CreateAsset(dummyAsset, fullPath); + avatar.expressionsMenu = dummyAsset; + + break; + } + + i++; + } while (true); + + EditorUtility.SetDirty(avatar); + PrefabUtility.RecordPrefabInstancePropertyModifications(avatar); + } + + /// + /// Extracts a single expressions menu asset to Menu Item components. + /// + /// The menu to extract + /// The parent object to use + /// The name of a gameobject to place between the parent and menu item objects, + /// or null to skip + /// the direct parent of the generated menu items + internal static GameObject ExtractSingleLayerMenu( + VRCExpressionsMenu menu, + GameObject parent, + string containerName = null) + { + if (containerName != null) + { + var container = new GameObject(); + container.name = containerName; + container.transform.SetParent(parent.transform, false); + parent = container; + Undo.RegisterCreatedObjectUndo(container, "Convert menu"); + } + + foreach (var control in menu.controls) + { + var itemObj = new GameObject(); + itemObj.name = string.IsNullOrEmpty(control.name) ? " " : control.name; + Undo.RegisterCreatedObjectUndo(itemObj, "Convert menu"); + itemObj.transform.SetParent(parent.transform, false); + + var menuItem = itemObj.AddComponent(); + ControlToMenuItem(menuItem, control); + } + + return parent; + } + + internal static void ControlToMenuItem(ModularAvatarMenuItem menuItem, VRCExpressionsMenu.Control control) + { + menuItem.Control = CloneControl(control); + if (menuItem.Control.type == VRCExpressionsMenu.Control.ControlType.SubMenu) + { + menuItem.MenuSource = SubmenuSource.MenuAsset; + } + } + + internal static VRCExpressionsMenu.Control CloneControl(VRCExpressionsMenu.Control c) + { + return new VRCExpressionsMenu.Control() + { + type = c.type, + name = c.name, + icon = c.icon, + parameter = new VRCExpressionsMenu.Control.Parameter() {name = c.parameter?.name}, + subMenu = c.subMenu, + subParameters = c.subParameters?.Select(p => + new VRCExpressionsMenu.Control.Parameter() {name = p?.name}) + .ToArray(), + labels = c.labels.ToArray(), + style = c.style, + value = c.value, + }; + } + } +} \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/Menu/MenuExtractor.cs.meta b/Packages/nadena.dev.modular-avatar/Editor/Menu/MenuExtractor.cs.meta new file mode 100644 index 00000000..c8fa22d3 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/Menu/MenuExtractor.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: de7ea831512b4d9c9e92985ab6fd5f17 +timeCreated: 1676896326 \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/Menu/VirtualMenu.cs b/Packages/nadena.dev.modular-avatar/Editor/Menu/VirtualMenu.cs new file mode 100644 index 00000000..54c238ef --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/Menu/VirtualMenu.cs @@ -0,0 +1,378 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using JetBrains.Annotations; +using nadena.dev.modular_avatar.core.menu; +using nadena.dev.modular_avatar.editor.ErrorReporting; +using UnityEditor; +using UnityEngine; +using VRC.SDK3.Avatars.Components; +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 : MenuSource + { + public static readonly RootMenu Instance = new RootMenu(); + + private RootMenu() + { + } + + public void Visit(NodeContext context) + { + // we initialize the root node manually + throw new NotImplementedException(); + } + } + + class NodeContextImpl : NodeContext + { + [CanBeNull] + internal delegate VirtualMenuNode NodeForDelegate(object menu); + + private readonly ImmutableDictionary> + _menuToInstallerMap; + + private readonly VirtualMenuNode _node; + private readonly NodeForDelegate _nodeFor; + private readonly Action _visitedMenu; + private readonly HashSet _visited = new HashSet(); + + public NodeContextImpl( + VirtualMenuNode node, + NodeForDelegate nodeFor, + ImmutableDictionary> menuToInstallerMap, + Action visitedMenu + ) + { + _node = node; + _nodeFor = nodeFor; + _menuToInstallerMap = menuToInstallerMap; + _visitedMenu = visitedMenu; + } + + public void PushNode(VRCExpressionsMenu expMenu) + { + if (expMenu == null) return; + if (_visited.Contains(expMenu)) return; + _visited.Add(expMenu); + _visitedMenu(expMenu); + + foreach (var control in expMenu.controls) + { + PushControl(control); + } + + if (_menuToInstallerMap.TryGetValue(expMenu, out var installers)) + { + foreach (var installer in installers) + { + PushNode(installer); + } + } + } + + public void PushNode(MenuSource source) + { + if (source == null) return; + if (_visited.Contains(source)) return; + _visited.Add(source); + + BuildReport.ReportingObject(source as UnityEngine.Object, () => source.Visit(this)); + } + + public void PushNode(ModularAvatarMenuInstaller installer) + { + if (installer == null) return; + if (_visited.Contains(installer)) return; + _visited.Add(installer); + + BuildReport.ReportingObject(installer, () => + { + var menuSourceComp = installer.GetComponent(); + if (menuSourceComp != null) + { + PushNode(menuSourceComp); + } + else if (installer.menuToAppend != null) + { + PushNode(installer.menuToAppend); + } + }); + } + + public void PushControl(VRCExpressionsMenu.Control control) + { + var virtualControl = new VirtualControl(control); + + virtualControl.SubmenuNode = NodeFor(control.subMenu); + + PushControl(virtualControl); + } + + public void PushControl(VirtualControl control) + { + _node.Controls.Add(control); + } + + public VirtualMenuNode NodeFor(VRCExpressionsMenu menu) + { + if (menu == null) return null; + return _nodeFor(menu); + } + + public VirtualMenuNode NodeFor(MenuSource source) + { + if (source == null) return null; + return _nodeFor(source); + } + } + + /** + * 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 + { + internal readonly object RootMenuKey; + + private static long _cacheSeq = 0; + + internal static void InvalidateCaches() + { + _cacheSeq++; + } + + static VirtualMenu() + { + RuntimeUtil.OnMenuInvalidate += InvalidateCaches; + } + + internal static long CacheSequence => _cacheSeq; + + private readonly long _initialCacheSeq = _cacheSeq; + internal bool IsOutdated => _initialCacheSeq != _cacheSeq; + + /// + /// Indexes which menu installers are contributing to which VRCExpressionMenu assets. + /// + private Dictionary> _targetMenuToInstaller + = new Dictionary>(); + + private Dictionary> _installerToTargetComponent + = new Dictionary>(); + + private Dictionary _resolvedMenu = new Dictionary(); + + // TODO: immutable? + public Dictionary ResolvedMenu => _resolvedMenu; + public VirtualMenuNode RootMenuNode => ResolvedMenu[RootMenuKey]; + + private Queue _pendingGeneration = new Queue(); + private HashSet _visitedMenus = new HashSet(); + + /// + /// Initializes the VirtualMenu. + /// + /// The root VRCExpressionsMenu to import + internal VirtualMenu(VRCExpressionsMenu rootMenu) + { + if (rootMenu != null) + { + RootMenuKey = rootMenu; + } + else + { + RootMenuKey = RootMenu.Instance; + } + } + + internal static VirtualMenu ForAvatar(VRCAvatarDescriptor avatar) + { + var menu = new VirtualMenu(avatar.expressionsMenu); + foreach (var installer in avatar.GetComponentsInChildren(true)) + { + menu.RegisterMenuInstaller(installer); + } + + foreach (var target in avatar.GetComponentsInChildren(true)) + { + menu.RegisterMenuInstallTarget(target); + } + + menu.FreezeMenu(); + + return menu; + } + + internal IEnumerable GetInstallTargetsForInstaller( + ModularAvatarMenuInstaller installer + ) + { + if (_installerToTargetComponent.TryGetValue(installer, out var targets)) + { + return targets; + } + else + { + return Array.Empty(); + } + } + + /// + /// 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; + } + + targets.Add(target); + } + + /// + /// Freezes the menu, fully resolving all members of all menus. + /// + internal void FreezeMenu() + { + ImmutableDictionary> menuToInstallerFiltered = + _targetMenuToInstaller + .Select(kvp => new KeyValuePair>( + kvp.Key, + kvp.Value.Where(i => !_installerToTargetComponent.ContainsKey(i)).ToImmutableList() + )) + .Where(kvp => !kvp.Value.IsEmpty) + .ToImmutableDictionary(); + + var RootNode = new VirtualMenuNode(RootMenuKey); + _resolvedMenu[RootMenuKey] = RootNode; + + var rootContext = + new NodeContextImpl(RootNode, NodeFor, menuToInstallerFiltered, m => _visitedMenus.Add(m)); + if (RootMenuKey is VRCExpressionsMenu menu) + { + foreach (var control in menu.controls) + { + rootContext.PushControl(control); + } + } + + if (menuToInstallerFiltered.TryGetValue(RootMenuKey, out var installers)) + { + foreach (var installer in installers) + { + rootContext.PushNode(installer); + } + } + + while (_pendingGeneration.Count > 0) + { + _pendingGeneration.Dequeue()(); + } + + VirtualMenuNode NodeFor(object key) + { + if (_resolvedMenu.TryGetValue(key, out var node)) return node; + node = new VirtualMenuNode(key); + _resolvedMenu[key] = node; + + _pendingGeneration.Enqueue(() => + { + BuildReport.ReportingObject(key as UnityEngine.Object, () => + { + var context = new NodeContextImpl(node, NodeFor, menuToInstallerFiltered, + m => _visitedMenus.Add(m)); + if (key is VRCExpressionsMenu expMenu) + { + context.PushNode(expMenu); + } + else if (key is MenuSource source) + { + context.PushNode(source); + } + else + { + // TODO warning + } + }); + }); + + return node; + } + } + + internal VRCExpressionsMenu SerializeMenu(Action SaveAsset) + { + Dictionary serializedMenus = new Dictionary(); + + return Serialize(RootMenuKey); + + VRCExpressionsMenu Serialize(object menuKey) + { + if (menuKey == null) return null; + if (serializedMenus.TryGetValue(menuKey, out var menu)) return menu; + if (!_resolvedMenu.TryGetValue(menuKey, out var node)) return null; + + menu = ScriptableObject.CreateInstance(); + serializedMenus[menuKey] = menu; + menu.controls = node.Controls.Select(c => + { + var control = new VRCExpressionsMenu.Control(); + control.name = c.name; + control.type = c.type; + control.parameter = new VRCExpressionsMenu.Control.Parameter() {name = c.parameter.name}; + control.value = c.value; + control.icon = c.icon; + control.style = c.style; + control.labels = c.labels.ToArray(); + control.subParameters = c.subParameters.Select(p => new VRCExpressionsMenu.Control.Parameter() + { + name = p.name + }).ToArray(); + control.subMenu = Serialize(c.SubmenuNode?.NodeKey); + + return control; + }).ToList(); + + SaveAsset(menu); + + return menu; + } + } + + public bool ContainsMenu(VRCExpressionsMenu menu) + { + return _visitedMenus.Contains(menu); + } + } +} \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/Menu/VirtualMenu.cs.meta b/Packages/nadena.dev.modular-avatar/Editor/Menu/VirtualMenu.cs.meta new file mode 100644 index 00000000..5d57f25d --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/Menu/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/Editor/MenuInstallHook.cs b/Packages/nadena.dev.modular-avatar/Editor/MenuInstallHook.cs index 6a60ebf1..ba6c9f9b 100644 --- a/Packages/nadena.dev.modular-avatar/Editor/MenuInstallHook.cs +++ b/Packages/nadena.dev.modular-avatar/Editor/MenuInstallHook.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Runtime.InteropServices; +using nadena.dev.modular_avatar.core.editor.menu; using nadena.dev.modular_avatar.editor.ErrorReporting; using UnityEditor; using UnityEngine; @@ -20,12 +21,8 @@ namespace nadena.dev.modular_avatar.core.editor private BuildContext _context; - private Dictionary _clonedMenus; - - private VRCExpressionsMenu _rootMenu; - private MenuTree _menuTree; private Stack _visitedInstallerStack; public void OnPreprocessAvatar(GameObject avatarRoot, BuildContext context) @@ -38,8 +35,6 @@ namespace nadena.dev.modular_avatar.core.editor .ToArray(); if (menuInstallers.Length == 0) return; - - _clonedMenus = new Dictionary(); _visitedInstallerStack = new Stack(); VRCAvatarDescriptor avatar = avatarRoot.GetComponent(); @@ -49,59 +44,19 @@ namespace nadena.dev.modular_avatar.core.editor var menu = ScriptableObject.CreateInstance(); _context.SaveAsset(menu); avatar.expressionsMenu = menu; - _clonedMenus[menu] = menu; + context.ClonedMenus[menu] = menu; } _rootMenu = avatar.expressionsMenu; - _menuTree = new MenuTree(avatar); - _menuTree.TraverseAvatarMenu(); - - avatar.expressionsMenu = CloneMenu(avatar.expressionsMenu); - - foreach (ModularAvatarMenuInstaller installer in menuInstallers) + var virtualMenu = VirtualMenu.ForAvatar(avatar); + avatar.expressionsMenu = virtualMenu.SerializeMenu(asset => { - BuildReport.ReportingObject(installer, () => _menuTree.TraverseMenuInstaller(installer)); - } - - foreach (MenuTree.ChildElement childElement in _menuTree.GetChildInstallers(null)) - { - BuildReport.ReportingObject(childElement.installer, () => InstallMenu(childElement.installer)); - } + context.SaveAsset(asset); + if (asset is VRCExpressionsMenu menu) SplitMenu(menu); + }); } - private void InstallMenu(ModularAvatarMenuInstaller installer, VRCExpressionsMenu installTarget = null) - { - if (!installer.enabled) return; - - if (installer.installTargetMenu == null) - { - installer.installTargetMenu = _rootMenu; - } - - if (installTarget == null) - { - installTarget = installer.installTargetMenu; - } - - if (installer.installTargetMenu == null || installer.menuToAppend == null) return; - if (!_clonedMenus.TryGetValue(installTarget, out var targetMenu)) return; - - // Clone before appending to sanitize menu icons - targetMenu.controls.AddRange(CloneMenu(installer.menuToAppend).controls); - - SplitMenu(installer, targetMenu); - - if (_visitedInstallerStack.Contains(installer)) return; - _visitedInstallerStack.Push(installer); - foreach (MenuTree.ChildElement childElement in _menuTree.GetChildInstallers(installer)) - { - InstallMenu(childElement.installer, childElement.parent); - } - - _visitedInstallerStack.Pop(); - } - - private void SplitMenu(ModularAvatarMenuInstaller installer, VRCExpressionsMenu targetMenu) + private void SplitMenu(VRCExpressionsMenu targetMenu) { while (targetMenu.controls.Count > VRCExpressionsMenu.MAX_CONTROLS) { @@ -128,42 +83,8 @@ namespace nadena.dev.modular_avatar.core.editor labels = Array.Empty() }); - _clonedMenus[installer.installTargetMenu] = newMenu; targetMenu = newMenu; } } - - private VRCExpressionsMenu CloneMenu(VRCExpressionsMenu menu) - { - if (menu == null) return null; - if (_clonedMenus.TryGetValue(menu, out var newMenu)) return newMenu; - newMenu = Object.Instantiate(menu); - _context.SaveAsset(newMenu); - _clonedMenus[menu] = newMenu; - - foreach (var control in newMenu.controls) - { - if (Util.ValidateExpressionMenuIcon(control.icon) != Util.ValidateExpressionMenuIconResult.Success) - control.icon = null; - - for (int i = 0; i < control.labels.Length; i++) - { - var label = control.labels[i]; - var labelResult = Util.ValidateExpressionMenuIcon(label.icon); - if (labelResult != Util.ValidateExpressionMenuIconResult.Success) - { - label.icon = null; - control.labels[i] = label; - } - } - - if (control.type == VRCExpressionsMenu.Control.ControlType.SubMenu) - { - control.subMenu = CloneMenu(control.subMenu); - } - } - - return newMenu; - } } } \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/MenuTree.cs b/Packages/nadena.dev.modular-avatar/Editor/MenuTree.cs deleted file mode 100644 index cde6ab69..00000000 --- a/Packages/nadena.dev.modular-avatar/Editor/MenuTree.cs +++ /dev/null @@ -1,209 +0,0 @@ -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using UnityEngine; -using VRC.SDK3.Avatars.Components; -using VRC.SDK3.Avatars.ScriptableObjects; -using static VRC.SDK3.Avatars.ScriptableObjects.VRCExpressionsMenu.Control; - -namespace nadena.dev.modular_avatar.core.editor -{ - internal class MenuTree - { - - public struct ChildElement - { - /// - /// Parent menu control name - /// - public string menuName; - public VRCExpressionsMenu menu; - public VRCExpressionsMenu parent; - /// - /// Installer to install this menu. Is null if the this menu is not installed by the installer. - /// - public ModularAvatarMenuInstaller installer; - /// - /// Whether the this submenu is added directly by the installer - /// - public bool isInstallerRoot; - } - - private readonly HashSet _included; - - private readonly VRCExpressionsMenu _rootMenu; - - /// - /// Map to link child menus from parent menu - /// - private readonly Dictionary> _menuChildrenMap; - - public MenuTree(VRCAvatarDescriptor descriptor) - { - _rootMenu = descriptor.expressionsMenu; - _included = new HashSet(); - _menuChildrenMap = new Dictionary>(); - - if (_rootMenu == null) - { - // If the route menu is null, create a temporary menu indicating the route - _rootMenu = ScriptableObject.CreateInstance(); - } - - _included.Add(_rootMenu); - } - - public void TraverseAvatarMenu() - { - if (_rootMenu == null) return; - TraverseMenu(_rootMenu); - } - - public void TraverseMenuInstaller(ModularAvatarMenuInstaller installer) - { - if (!installer.enabled) return; - if (installer.menuToAppend == null) return; - TraverseMenu(installer); - } - - public ImmutableList GetChildren(VRCExpressionsMenu parent) - { - if (parent == null) parent = _rootMenu; - return !_menuChildrenMap.TryGetValue(parent, out ImmutableList immutableList) ? ImmutableList.Empty : immutableList; - } - - public IEnumerable GetChildInstallers(ModularAvatarMenuInstaller parentInstaller) - { - HashSet visitedMenus = new HashSet(); - Queue queue = new Queue(); - if (parentInstaller != null && parentInstaller.menuToAppend == null) yield break; - if (parentInstaller == null) - { - queue.Enqueue(_rootMenu); - } - else - { - if (parentInstaller.menuToAppend == null) yield break; - foreach (KeyValuePair childMenu in GetChildMenus(parentInstaller.menuToAppend)) - { - queue.Enqueue(childMenu.Value); - } - } - - while (queue.Count > 0) - { - VRCExpressionsMenu parentMenu = queue.Dequeue(); - if (visitedMenus.Contains(parentMenu)) continue; - visitedMenus.Add(parentMenu); - HashSet returnedInstallers = new HashSet(); - foreach (ChildElement childElement in GetChildren(parentMenu)) - { - if (!childElement.isInstallerRoot) - { - queue.Enqueue(childElement.menu); - continue; - } - - // One installer may add multiple children, so filter to return only one. - if (returnedInstallers.Contains(childElement.installer)) continue; - returnedInstallers.Add(childElement.installer); - yield return childElement; - } - } - } - - - private void TraverseMenu(VRCExpressionsMenu root) - { - foreach (KeyValuePair childMenu in GetChildMenus(root)) - { - TraverseMenu(root, new ChildElement - { - menuName = childMenu.Key, - menu = childMenu.Value - }); - } - } - - private void TraverseMenu(ModularAvatarMenuInstaller installer) - { - IEnumerable> childMenus = GetChildMenus(installer.menuToAppend); - IEnumerable parents = Enumerable.Empty(); - if (installer.installTargetMenu != null && - ClonedMenuMappings.TryGetClonedMenus(installer.installTargetMenu, out ImmutableList parentMenus)) - { - parents = parentMenus; - } - - VRCExpressionsMenu[] parentsMenus = parents.DefaultIfEmpty(installer.installTargetMenu).ToArray(); - bool hasChildMenu = false; - /* - * Installer adds the controls in specified menu to the installation destination. - * So, since the specified menu itself does not exist as a child menu, - * and the child menus of the specified menu are the actual child menus, a single installer may add multiple child menus. - */ - foreach (KeyValuePair childMenu in childMenus) - { - hasChildMenu = true; - ChildElement childElement = new ChildElement - { - menuName = childMenu.Key, - menu = childMenu.Value, - installer = installer, - isInstallerRoot = true - }; - foreach (VRCExpressionsMenu parentMenu in parentsMenus) - { - TraverseMenu(parentMenu, childElement); - } - } - - if (hasChildMenu) return; - /* - * If the specified menu does not have any submenus, it is not mapped as a child menu and the Installer information itself is not registered. - * Therefore, register elements that do not have child menus themselves, but only have information about the installer. - */ - foreach (VRCExpressionsMenu parentMenu in parentsMenus) - { - TraverseMenu(parentMenu, new ChildElement - { - installer = installer, - isInstallerRoot = true - }); - } - - } - - private void TraverseMenu(VRCExpressionsMenu parent, ChildElement childElement) - { - if (parent == null) parent = _rootMenu; - childElement.parent = parent; - if (!_menuChildrenMap.TryGetValue(parent, out ImmutableList children)) - { - children = ImmutableList.Empty; - _menuChildrenMap[parent] = children; - } - - _menuChildrenMap[parent] = children.Add(childElement); - if (childElement.menu == null) return; - if (_included.Contains(childElement.menu)) return; - _included.Add(childElement.menu); - foreach (KeyValuePair childMenu in GetChildMenus(childElement.menu)) - { - TraverseMenu(childElement.menu, new ChildElement - { - menuName = childMenu.Key, - menu = childMenu.Value, - installer = childElement.installer - }); - } - } - - private static IEnumerable> GetChildMenus(VRCExpressionsMenu expressionsMenu) - { - return expressionsMenu.controls - .Where(control => control.type == ControlType.SubMenu && control.subMenu != null) - .Select(control => new KeyValuePair(control.name, control.subMenu)); - } - } -} \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/MenuTree.cs.meta b/Packages/nadena.dev.modular-avatar/Editor/MenuTree.cs.meta deleted file mode 100644 index dc5993a1..00000000 --- a/Packages/nadena.dev.modular-avatar/Editor/MenuTree.cs.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: effd4557902f4578af42d3bdfb7f876d -timeCreated: 1670746991 \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/RenameParametersHook.cs b/Packages/nadena.dev.modular-avatar/Editor/RenameParametersHook.cs index 0e28d828..b02ad24e 100644 --- a/Packages/nadena.dev.modular-avatar/Editor/RenameParametersHook.cs +++ b/Packages/nadena.dev.modular-avatar/Editor/RenameParametersHook.cs @@ -178,6 +178,26 @@ namespace nadena.dev.modular_avatar.core.editor break; } + + case ModularAvatarMenuItem menuItem: + { + if (menuItem.Control.parameter?.name != null && + remaps.TryGetValue(menuItem.Control.parameter.name, out var newVal)) + { + menuItem.Control.parameter.name = newVal; + } + + foreach (var subParam in menuItem.Control.subParameters ?? + Array.Empty()) + { + if (subParam?.name != null && remaps.TryGetValue(subParam.name, out var subNewVal)) + { + subParam.name = subNewVal; + } + } + + break; + } } }); } diff --git a/Packages/nadena.dev.modular-avatar/Runtime/Menu/ModularAvatarMenuGroup.cs b/Packages/nadena.dev.modular-avatar/Runtime/Menu/ModularAvatarMenuGroup.cs new file mode 100644 index 00000000..b23826a6 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Runtime/Menu/ModularAvatarMenuGroup.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using nadena.dev.modular_avatar.core.menu; +using UnityEngine; +using VRC.SDK3.Avatars.ScriptableObjects; + +namespace nadena.dev.modular_avatar.core +{ + public class ModularAvatarMenuGroup : MenuSourceComponent + { + private bool recursing = false; + + public GameObject targetObject; + + public override void Visit(NodeContext context) + { + context.PushNode(new MenuNodesUnder(targetObject != null ? targetObject : gameObject)); + } + } +} \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Runtime/Menu/ModularAvatarMenuGroup.cs.meta b/Packages/nadena.dev.modular-avatar/Runtime/Menu/ModularAvatarMenuGroup.cs.meta new file mode 100644 index 00000000..96715265 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Runtime/Menu/ModularAvatarMenuGroup.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 97e46a47dd8a425eb4ce9411defe313d +timeCreated: 1677080023 \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarMenuInstaller.cs b/Packages/nadena.dev.modular-avatar/Runtime/Menu/ModularAvatarMenuInstaller.cs similarity index 59% rename from Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarMenuInstaller.cs rename to Packages/nadena.dev.modular-avatar/Runtime/Menu/ModularAvatarMenuInstaller.cs index 826162f9..14af6464 100644 --- a/Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarMenuInstaller.cs +++ b/Packages/nadena.dev.modular-avatar/Runtime/Menu/ModularAvatarMenuInstaller.cs @@ -6,15 +6,22 @@ namespace nadena.dev.modular_avatar.core { [AddComponentMenu("Modular Avatar/MA Menu Installer")] public class ModularAvatarMenuInstaller : AvatarTagComponent - { - public VRCExpressionsMenu menuToAppend; + { + public VRCExpressionsMenu menuToAppend; public VRCExpressionsMenu installTargetMenu; // ReSharper disable once Unity.RedundantEventFunction void Start() - { - // Ensure that unity generates an enable checkbox - } + { + // Ensure that unity generates an enable checkbox + } + + protected override void OnValidate() + { + base.OnValidate(); + + RuntimeUtil.InvalidateMenu(); + } } } \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarMenuInstaller.cs.meta b/Packages/nadena.dev.modular-avatar/Runtime/Menu/ModularAvatarMenuInstaller.cs.meta similarity index 100% rename from Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarMenuInstaller.cs.meta rename to Packages/nadena.dev.modular-avatar/Runtime/Menu/ModularAvatarMenuInstaller.cs.meta diff --git a/Packages/nadena.dev.modular-avatar/Runtime/Menu/VirtualMenuAPI.cs b/Packages/nadena.dev.modular-avatar/Runtime/Menu/VirtualMenuAPI.cs new file mode 100644 index 00000000..d0159a11 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Runtime/Menu/VirtualMenuAPI.cs @@ -0,0 +1,174 @@ +using System.Collections.Generic; +using System.Linq; +using UnityEngine; +using VRC.SDK3.Avatars.ScriptableObjects; + +// Internal runtime API for the Virtual Menu system. +// +// IMPORTANT: This API is currently considered unstable. Due to C# protection rules, we are required to make classes +// here public, but be aware that they may change without warning in the future. +namespace nadena.dev.modular_avatar.core.menu +{ + /// + /// 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. + /// + public class VirtualMenuNode + { + public 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. + /// + public readonly object NodeKey; + + internal VirtualMenuNode(object nodeKey) + { + NodeKey = nodeKey; + } + } + + /** + * A single control on a MenuNode. The main difference between this and a true VRCExpressionsMenu.Control is that + * we use a MenuNode instead of a VRCExpressionsMenu for submenus. + */ + public class VirtualControl : VRCExpressionsMenu.Control + { + /// + /// VirtualControls do not reference real VRCExpressionsMenu objects, but rather virtual MenuNodes. + /// + public VirtualMenuNode 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(); + } + } + + /// + /// Helper MenuSource which includes all children of a given GameObject containing MenuSourceComponents as menu + /// items. Implements equality based on the GameObject in question. + /// + internal class MenuNodesUnder : MenuSource + { + internal readonly GameObject root; + + public MenuNodesUnder(GameObject root) + { + this.root = root; + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != this.GetType()) return false; + return root == ((MenuNodesUnder) obj).root; + } + + public override int GetHashCode() + { + return (root != null ? root.GetHashCode() : 0); + } + + public void Visit(NodeContext context) + { + foreach (Transform t in root.transform) + { + var source = t.GetComponent(); + if (source != null) context.PushNode(source); + } + } + } + + /// + /// The NodeContext provides callbacks for MenuSource visitors to append controls and/or other node types to a menu + /// node. + /// + public interface NodeContext + { + /// + /// Pushes the contents of this expressions menu asset onto the current menu node, handling loops and menu + /// installer invocations. + /// + /// + void PushNode(VRCExpressionsMenu expMenu); + + /// + /// Pushes the contents of this menu source onto the current menu node. + /// + /// + void PushNode(MenuSource source); + + /// + /// Pushes this menu installer onto this node + /// + /// + void PushNode(ModularAvatarMenuInstaller installer); + + /// + /// Pushes a single expressions menu control onto the current menu node. Converts submenus into menu nodes + /// automatically. + /// + /// + void PushControl(VRCExpressionsMenu.Control control); + + /// + /// Pushes a single expressions menu control onto the current menu node. + /// + /// + void PushControl(VirtualControl control); + + /// + /// Returns the menu node for a given VRCExpressionsMenu asset. This node may not be populated at the time this + /// node returns. + /// + /// + /// + VirtualMenuNode NodeFor(VRCExpressionsMenu menu); + + /// + /// Returns the menu node for a given menu source asset. The contents of the node may not yet be populated. + /// + /// + /// + VirtualMenuNode NodeFor(MenuSource menu); + } + + /// + /// An object which can contribute controls to a menu. + /// + public interface MenuSource + { + void Visit(NodeContext context); + } + + /// + /// A component which can be used to generate menu items. + /// + [DisallowMultipleComponent] + public abstract class MenuSourceComponent : AvatarTagComponent, MenuSource + { + protected override void OnValidate() + { + base.OnValidate(); + + RuntimeUtil.InvalidateMenu(); + } + + public abstract void Visit(NodeContext context); + } +} \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Runtime/Menu/VirtualMenuAPI.cs.meta b/Packages/nadena.dev.modular-avatar/Runtime/Menu/VirtualMenuAPI.cs.meta new file mode 100644 index 00000000..12485cd8 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Runtime/Menu/VirtualMenuAPI.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 1defe88300684bc38ee24944075d540e +timeCreated: 1677147255 \ 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..da61cf34 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarMenuInstallTarget.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using nadena.dev.modular_avatar.core.menu; +using VRC.SDK3.Avatars.ScriptableObjects; +using VRC.SDKBase; + +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 : MenuSourceComponent + { + public ModularAvatarMenuInstaller installer; + + public override void Visit(NodeContext context) + { + context.PushNode(installer); + } + } +} \ 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/ModularAvatarMenuItem.cs b/Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarMenuItem.cs new file mode 100644 index 00000000..79c35be5 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarMenuItem.cs @@ -0,0 +1,51 @@ +using System.Collections.Generic; +using System.Linq; +using nadena.dev.modular_avatar.core.menu; +using UnityEngine; +using VRC.SDK3.Avatars.ScriptableObjects; + +namespace nadena.dev.modular_avatar.core +{ + public enum SubmenuSource + { + MenuAsset, + Children, + } + + [AddComponentMenu("Modular Avatar/MA Menu Item")] + public class ModularAvatarMenuItem : MenuSourceComponent + { + public VRCExpressionsMenu.Control Control; + public SubmenuSource MenuSource; + + public GameObject menuSource_otherObjectChildren; + + public override void Visit(NodeContext context) + { + var cloned = new VirtualControl(Control); + cloned.subMenu = null; + cloned.name = gameObject.name; + + if (cloned.type == VRCExpressionsMenu.Control.ControlType.SubMenu) + { + switch (this.MenuSource) + { + case SubmenuSource.MenuAsset: + cloned.SubmenuNode = context.NodeFor(this.Control.subMenu); + break; + case SubmenuSource.Children: + { + var root = this.menuSource_otherObjectChildren != null + ? this.menuSource_otherObjectChildren + : this.gameObject; + + cloned.SubmenuNode = context.NodeFor(new MenuNodesUnder(root)); + break; + } + } + } + + context.PushControl(cloned); + } + } +} \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarMenuItem.cs.meta b/Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarMenuItem.cs.meta new file mode 100644 index 00000000..ae5a5a16 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Runtime/ModularAvatarMenuItem.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 3b29d45007c5493d926d2cd45a489529 +timeCreated: 1676787152 \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Runtime/RuntimeUtil.cs b/Packages/nadena.dev.modular-avatar/Runtime/RuntimeUtil.cs index ebd5da9b..6f79afe5 100644 --- a/Packages/nadena.dev.modular-avatar/Runtime/RuntimeUtil.cs +++ b/Packages/nadena.dev.modular-avatar/Runtime/RuntimeUtil.cs @@ -39,6 +39,13 @@ namespace nadena.dev.modular_avatar.core public static Action delayCall = (_) => { }; public static event Action OnHierarchyChanged; + internal static event Action OnMenuInvalidate; + + internal static void InvalidateMenu() + { + OnMenuInvalidate?.Invoke(); + } + public enum OnDemandSource { Awake, diff --git a/Packages/nadena.dev.modular-avatar/Samples/Fingerpen.prefab b/Packages/nadena.dev.modular-avatar/Samples/Fingerpen.prefab index bf4061da..683fa69e 100644 --- a/Packages/nadena.dev.modular-avatar/Samples/Fingerpen.prefab +++ b/Packages/nadena.dev.modular-avatar/Samples/Fingerpen.prefab @@ -11,6 +11,7 @@ GameObject: - component: {fileID: 2929086332187968543} - component: {fileID: 7996665789334891805} - component: {fileID: 7184458914139497616} + - component: {fileID: 4714073180460761108} m_Layer: 0 m_Name: Fingerpen m_TagString: Untagged @@ -30,6 +31,7 @@ Transform: m_LocalScale: {x: 1, y: 1, z: 1} m_Children: - {fileID: 6917936183219720762} + - {fileID: 728513151284643908} m_Father: {fileID: 0} m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} @@ -74,6 +76,32 @@ MonoBehaviour: syncType: 3 defaultValue: 0 saved: 0 +--- !u!114 &4714073180460761108 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2929086332187968542} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 3b29d45007c5493d926d2cd45a489529, type: 3} + m_Name: + m_EditorClassIdentifier: + Control: + name: + icon: {fileID: 0} + type: 103 + parameter: + name: + value: 1 + style: 0 + subMenu: {fileID: 0} + subParameters: [] + labels: [] + MenuSource: 1 + menuSource_installer: {fileID: 0} + menuSource_otherObjectChildren: {fileID: 5354519745013217104} --- !u!1 &2929086333654575795 GameObject: m_ObjectHideFlags: 0 @@ -250,6 +278,95 @@ TrailRenderer: m_MinVertexDistance: 0.005 m_Autodestruct: 0 m_Emitting: 0 +--- !u!1 &5354519745013217104 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 728513151284643908} + m_Layer: 0 + m_Name: Menu + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &728513151284643908 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5354519745013217104} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_Children: + - {fileID: 1892928965174260857} + - {fileID: 3041863097553433968} + m_Father: {fileID: 2929086332187968543} + m_RootOrder: 1 + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &6837276917073475290 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1892928965174260857} + - component: {fileID: 1436771406052350457} + m_Layer: 0 + m_Name: Enable + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &1892928965174260857 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6837276917073475290} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_Children: [] + m_Father: {fileID: 728513151284643908} + m_RootOrder: 0 + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &1436771406052350457 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6837276917073475290} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 3b29d45007c5493d926d2cd45a489529, type: 3} + m_Name: + m_EditorClassIdentifier: + Control: + name: + icon: {fileID: 0} + type: 102 + parameter: + name: Enable + value: 1 + style: 0 + subMenu: {fileID: 0} + subParameters: [] + labels: [] + MenuSource: 0 + menuSource_installer: {fileID: 0} + menuSource_otherObjectChildren: {fileID: 0} --- !u!1 &7502135110828725241 GameObject: m_ObjectHideFlags: 0 @@ -319,6 +436,63 @@ Animator: m_HasTransformHierarchy: 1 m_AllowConstantClipSamplingOptimization: 1 m_KeepAnimatorControllerStateOnDisable: 0 +--- !u!1 &8323130273213051223 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 3041863097553433968} + - component: {fileID: 1720968870752482547} + m_Layer: 0 + m_Name: Draw + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &3041863097553433968 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 8323130273213051223} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_Children: [] + m_Father: {fileID: 728513151284643908} + m_RootOrder: 1 + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &1720968870752482547 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 8323130273213051223} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 3b29d45007c5493d926d2cd45a489529, type: 3} + m_Name: + m_EditorClassIdentifier: + Control: + name: + icon: {fileID: 0} + type: 101 + parameter: + name: Draw + value: 1 + style: 0 + subMenu: {fileID: 0} + subParameters: [] + labels: [] + MenuSource: 0 + menuSource_installer: {fileID: 0} + menuSource_otherObjectChildren: {fileID: 0} --- !u!1 &9091324582054793565 GameObject: m_ObjectHideFlags: 0 diff --git a/Packages/nadena.dev.modular-avatar/Samples/Fingerpen/Fingerpen.asset b/Packages/nadena.dev.modular-avatar/Samples/Fingerpen/Fingerpen.asset deleted file mode 100644 index d9bd4c8f..00000000 --- a/Packages/nadena.dev.modular-avatar/Samples/Fingerpen/Fingerpen.asset +++ /dev/null @@ -1,25 +0,0 @@ -%YAML 1.1 -%TAG !u! tag:unity3d.com,2011: ---- !u!114 &11400000 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 0} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: -340790334, guid: 67cc4cb7839cd3741b63733d5adf0442, type: 3} - m_Name: Fingerpen - m_EditorClassIdentifier: - controls: - - name: Pen - icon: {fileID: 0} - type: 103 - parameter: - name: - value: 1 - style: 0 - subMenu: {fileID: 11400000, guid: 1d1f3d0e712c1394d9623677363da960, type: 2} - subParameters: [] - labels: [] diff --git a/Packages/nadena.dev.modular-avatar/Samples/Fingerpen/Fingerpen.asset.meta b/Packages/nadena.dev.modular-avatar/Samples/Fingerpen/Fingerpen.asset.meta deleted file mode 100644 index 6bcf80eb..00000000 --- a/Packages/nadena.dev.modular-avatar/Samples/Fingerpen/Fingerpen.asset.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 8c9e45343a49f484ba069e09ccced597 -NativeFormatImporter: - externalObjects: {} - mainObjectFileID: 11400000 - userData: - assetBundleName: - assetBundleVariant: diff --git a/Packages/nadena.dev.modular-avatar/Samples/Fingerpen/FingerpenSub.asset b/Packages/nadena.dev.modular-avatar/Samples/Fingerpen/FingerpenSub.asset deleted file mode 100644 index 7affbcb0..00000000 --- a/Packages/nadena.dev.modular-avatar/Samples/Fingerpen/FingerpenSub.asset +++ /dev/null @@ -1,35 +0,0 @@ -%YAML 1.1 -%TAG !u! tag:unity3d.com,2011: ---- !u!114 &11400000 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 0} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: -340790334, guid: 67cc4cb7839cd3741b63733d5adf0442, type: 3} - m_Name: FingerpenSub - m_EditorClassIdentifier: - controls: - - name: Enable - icon: {fileID: 0} - type: 102 - parameter: - name: Enable - value: 1 - style: 0 - subMenu: {fileID: 0} - subParameters: [] - labels: [] - - name: Draw - icon: {fileID: 0} - type: 101 - parameter: - name: Draw - value: 1 - style: 0 - subMenu: {fileID: 0} - subParameters: [] - labels: [] diff --git a/Packages/nadena.dev.modular-avatar/Samples/Fingerpen/FingerpenSub.asset.meta b/Packages/nadena.dev.modular-avatar/Samples/Fingerpen/FingerpenSub.asset.meta deleted file mode 100644 index ccb7eeb8..00000000 --- a/Packages/nadena.dev.modular-avatar/Samples/Fingerpen/FingerpenSub.asset.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 1d1f3d0e712c1394d9623677363da960 -NativeFormatImporter: - externalObjects: {} - mainObjectFileID: 11400000 - userData: - assetBundleName: - assetBundleVariant: diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index 0ddfff93..6c082976 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -158,6 +158,13 @@ "com.unity.nuget.newtonsoft-json": "2.0.2" } }, + "de.thryrallo.vrc.avatar-performance-tools": { + "version": "https://github.com/Thryrallo/VRC-Avatar-Performance-Tools.git", + "depth": 0, + "source": "git", + "dependencies": {}, + "hash": "ba9c16e482e7a376db18e9a85e24fabc04649d37" + }, "nadena.dev.modular-avatar": { "version": "file:nadena.dev.modular-avatar", "depth": 0, diff --git a/Packages/vpm-manifest.json b/Packages/vpm-manifest.json index f2d34349..2fa362a1 100644 --- a/Packages/vpm-manifest.json +++ b/Packages/vpm-manifest.json @@ -9,13 +9,14 @@ }, "locked": { "com.vrchat.avatars": { - "version": "3.1.10", + "version": "3.1.11", "dependencies": { - "com.vrchat.base": "3.1.x" + "com.vrchat.base": "3.1.11" } }, "com.vrchat.base": { - "version": "3.1.10" + "version": "3.1.11", + "dependencies": {} }, "com.vrchat.core.vpm-resolver": { "version": "0.1.17"