From c2e6bb53cd375e4c768c79591b3e803770929bb0 Mon Sep 17 00:00:00 2001 From: bd_ Date: Thu, 19 Jan 2023 21:32:44 +0900 Subject: [PATCH] feat: Add error reporting UI --- .../Editor/AnimationDatabase.cs | 10 +- .../Editor/AnimatorMerger.cs | 5 +- .../Editor/AvatarProcessor.cs | 133 +++--- .../BlendshapeSyncAnimationProcessor.cs | 59 +-- .../Editor/BoneProxyProcessor.cs | 64 +-- .../Editor/ErrorReporting.meta | 4 + .../ErrorReporting/ComponentValidation.cs | 186 +++++++++ .../ComponentValidation.cs.meta | 3 + .../Editor/ErrorReporting/ErrorElement.cs | 105 +++++ .../ErrorReporting/ErrorElement.cs.meta | 3 + .../Editor/ErrorReporting/ErrorLog.cs | 388 ++++++++++++++++++ .../Editor/ErrorReporting/ErrorLog.cs.meta | 3 + .../Editor/ErrorReporting/ErrorReportUI.cs | 306 ++++++++++++++ .../ErrorReporting/ErrorReportUI.cs.meta | 3 + .../Editor/ErrorReporting/Resources.meta | 8 + .../Resources/ModularAvatarErrorReport.uss | 91 ++++ .../ModularAvatarErrorReport.uss.meta | 11 + .../Editor/ErrorReporting/SelectionButton.cs | 32 ++ .../ErrorReporting/SelectionButton.cs.meta | 3 + .../Editor/Inspector/LogoDisplay.cs | 9 +- .../Editor/Localization/Localization.cs | 5 +- .../Editor/Localization/en.json | 20 +- .../Editor/Localization/ja.json | 19 +- .../Editor/MenuInstallHook.cs | 5 +- .../Editor/MergeAnimatorProcessor.cs | 71 ++-- .../Editor/MergeArmatureHook.cs | 23 +- .../Editor/MeshRetargeter.cs | 30 +- .../Editor/PhysboneBlockerPass.cs | 35 +- .../Editor/RemapAnimationPass.cs | 12 +- .../Editor/RenameParametersHook.cs | 140 +++---- .../Editor/VisibleHeadAccessoryProcessor.cs | 6 +- .../Runtime/AvatarTagComponent.cs | 12 +- .../Runtime/ModularAvatarBlendshapeSync.cs | 4 +- .../Runtime/ModularAvatarMergeArmature.cs | 4 +- .../Runtime/RuntimeUtil.cs | 4 +- 35 files changed, 1540 insertions(+), 276 deletions(-) create mode 100644 Packages/nadena.dev.modular-avatar/Editor/ErrorReporting.meta create mode 100644 Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ComponentValidation.cs create mode 100644 Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ComponentValidation.cs.meta create mode 100644 Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ErrorElement.cs create mode 100644 Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ErrorElement.cs.meta create mode 100644 Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ErrorLog.cs create mode 100644 Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ErrorLog.cs.meta create mode 100644 Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ErrorReportUI.cs create mode 100644 Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ErrorReportUI.cs.meta create mode 100644 Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/Resources.meta create mode 100644 Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/Resources/ModularAvatarErrorReport.uss create mode 100644 Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/Resources/ModularAvatarErrorReport.uss.meta create mode 100644 Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/SelectionButton.cs create mode 100644 Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/SelectionButton.cs.meta diff --git a/Packages/nadena.dev.modular-avatar/Editor/AnimationDatabase.cs b/Packages/nadena.dev.modular-avatar/Editor/AnimationDatabase.cs index 4f689b8b..66f1683c 100644 --- a/Packages/nadena.dev.modular-avatar/Editor/AnimationDatabase.cs +++ b/Packages/nadena.dev.modular-avatar/Editor/AnimationDatabase.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using nadena.dev.modular_avatar.editor.ErrorReporting; using UnityEditor; using UnityEditor.Animations; using UnityEngine; @@ -61,10 +62,13 @@ namespace nadena.dev.modular_avatar.core.editor { if (!layer.isDefault && layer.animatorController is AnimatorController ac && Util.IsTemporaryAsset(ac)) { - foreach (var state in Util.States(ac)) + BuildReport.ReportingObject(ac, () => { - RegisterState(state); - } + foreach (var state in Util.States(ac)) + { + RegisterState(state); + } + }); } } } diff --git a/Packages/nadena.dev.modular-avatar/Editor/AnimatorMerger.cs b/Packages/nadena.dev.modular-avatar/Editor/AnimatorMerger.cs index 2710d184..faeaa2d8 100644 --- a/Packages/nadena.dev.modular-avatar/Editor/AnimatorMerger.cs +++ b/Packages/nadena.dev.modular-avatar/Editor/AnimatorMerger.cs @@ -25,6 +25,7 @@ using System; using System.Collections.Generic; using System.Linq; +using nadena.dev.modular_avatar.editor.ErrorReporting; using UnityEditor; using UnityEditor.Animations; using UnityEngine; @@ -77,8 +78,8 @@ namespace nadena.dev.modular_avatar.core.editor { if (acp.type != param.type) { - throw new Exception( - $"Parameter {param.name} has different types in {basePath} and {controller.name}"); + BuildReport.LogFatal("error.merge_animator.param_type_mismatch", param.name, acp.type, + param.type); } continue; diff --git a/Packages/nadena.dev.modular-avatar/Editor/AvatarProcessor.cs b/Packages/nadena.dev.modular-avatar/Editor/AvatarProcessor.cs index e885a050..5d38c348 100644 --- a/Packages/nadena.dev.modular-avatar/Editor/AvatarProcessor.cs +++ b/Packages/nadena.dev.modular-avatar/Editor/AvatarProcessor.cs @@ -27,10 +27,13 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using System.Reflection; using System.Runtime.CompilerServices; +using nadena.dev.modular_avatar.editor.ErrorReporting; using UnityEditor; +using UnityEditor.Build.Reporting; using UnityEngine; using VRC.SDK3.Avatars.Components; using VRC.SDKBase.Editor.BuildPipeline; +using BuildReport = nadena.dev.modular_avatar.editor.ErrorReporting.BuildReport; using Object = UnityEngine.Object; [assembly: InternalsVisibleTo("Tests")] @@ -83,17 +86,25 @@ namespace nadena.dev.modular_avatar.core.editor savePath = basePath + " " + (++extension); } + string originalBasePath = RuntimeUtil.RelativePath(null, avatar); + avatar = Object.Instantiate(avatar); + + string clonedBasePath = RuntimeUtil.RelativePath(null, avatar); try { Util.OverridePath = savePath; - avatar = Object.Instantiate(avatar); + var original = avatar; avatar.transform.position += Vector3.forward * 2; + + BuildReport.Clear(); + ProcessAvatar(avatar); } finally { Util.OverridePath = null; + BuildReport.RemapPaths(originalBasePath, clonedBasePath); } } @@ -114,6 +125,7 @@ namespace nadena.dev.modular_avatar.core.editor { try { + BuildReport.Clear(); ProcessAvatar(avatarGameObject); FixupAnimatorDebugData(avatarGameObject); return true; @@ -129,71 +141,76 @@ namespace nadena.dev.modular_avatar.core.editor { if (nowProcessing) return; - try + var vrcAvatarDescriptor = avatarGameObject.GetComponent(); + + using (BuildReport.CurrentReport.ReportingOnAvatar(vrcAvatarDescriptor)) { - AssetDatabase.StartAssetEditing(); - nowProcessing = true; - - var vrcAvatarDescriptor = avatarGameObject.GetComponent(); - - BoneDatabase.ResetBones(); - PathMappings.Init(vrcAvatarDescriptor.gameObject); - ClonedMenuMappings.Clear(); - - // Sometimes people like to nest one avatar in another, when transplanting clothing. To avoid issues - // with inconsistently determining the avatar root, we'll go ahead and remove the extra sub-avatars - // here. - foreach (Transform directChild in avatarGameObject.transform) + try { - foreach (var component in directChild.GetComponentsInChildren(true)) + AssetDatabase.StartAssetEditing(); + nowProcessing = true; + + BoneDatabase.ResetBones(); + PathMappings.Init(vrcAvatarDescriptor.gameObject); + ClonedMenuMappings.Clear(); + + // Sometimes people like to nest one avatar in another, when transplanting clothing. To avoid issues + // with inconsistently determining the avatar root, we'll go ahead and remove the extra sub-avatars + // here. + foreach (Transform directChild in avatarGameObject.transform) { - Object.DestroyImmediate(component); + foreach (var component in directChild.GetComponentsInChildren(true)) + { + Object.DestroyImmediate(component); + } + + foreach (var component in directChild.GetComponentsInChildren(true)) + { + Object.DestroyImmediate(component); + } } - foreach (var component in directChild.GetComponentsInChildren(true)) + var context = new BuildContext(vrcAvatarDescriptor); + + new RenameParametersHook().OnPreprocessAvatar(avatarGameObject, context); + new MergeAnimatorProcessor().OnPreprocessAvatar(avatarGameObject, context); + context.AnimationDatabase.Bootstrap(vrcAvatarDescriptor); + + new MenuInstallHook().OnPreprocessAvatar(avatarGameObject, context); + new MergeArmatureHook().OnPreprocessAvatar(context, avatarGameObject); + new BoneProxyProcessor().OnPreprocessAvatar(avatarGameObject); + new VisibleHeadAccessoryProcessor(vrcAvatarDescriptor).Process(context); + new RemapAnimationPass(vrcAvatarDescriptor).Process(context.AnimationDatabase); + new BlendshapeSyncAnimationProcessor().OnPreprocessAvatar(avatarGameObject, context); + PhysboneBlockerPass.Process(avatarGameObject); + + context.AnimationDatabase.Commit(); + + AfterProcessing?.Invoke(avatarGameObject); + } + finally + { + AssetDatabase.StopAssetEditing(); + + nowProcessing = false; + + // Ensure that we clean up AvatarTagComponents after failed processing. This ensures we don't re-enter + // processing from the Awake method on the unprocessed AvatarTagComponents + foreach (var component in avatarGameObject.GetComponentsInChildren(true)) { - Object.DestroyImmediate(component); + UnityEngine.Object.DestroyImmediate(component); } + + var activator = avatarGameObject.GetComponent(); + if (activator != null) + { + UnityEngine.Object.DestroyImmediate(activator); + } + + ClonedMenuMappings.Clear(); + + ErrorReportUI.MaybeOpenErrorReportUI(); } - - var context = new BuildContext(vrcAvatarDescriptor); - - new RenameParametersHook().OnPreprocessAvatar(avatarGameObject, context); - new MergeAnimatorProcessor().OnPreprocessAvatar(avatarGameObject, context); - context.AnimationDatabase.Bootstrap(vrcAvatarDescriptor); - - new MenuInstallHook().OnPreprocessAvatar(avatarGameObject, context); - new MergeArmatureHook().OnPreprocessAvatar(context, avatarGameObject); - new BoneProxyProcessor().OnPreprocessAvatar(avatarGameObject); - new VisibleHeadAccessoryProcessor(vrcAvatarDescriptor).Process(context); - new RemapAnimationPass(vrcAvatarDescriptor).Process(context.AnimationDatabase); - new BlendshapeSyncAnimationProcessor().OnPreprocessAvatar(avatarGameObject, context); - PhysboneBlockerPass.Process(avatarGameObject); - - context.AnimationDatabase.Commit(); - - AfterProcessing?.Invoke(avatarGameObject); - } - finally - { - AssetDatabase.StopAssetEditing(); - - nowProcessing = false; - - // Ensure that we clean up AvatarTagComponents after failed processing. This ensures we don't re-enter - // processing from the Awake method on the unprocessed AvatarTagComponents - foreach (var component in avatarGameObject.GetComponentsInChildren(true)) - { - UnityEngine.Object.DestroyImmediate(component); - } - - var activator = avatarGameObject.GetComponent(); - if (activator != null) - { - UnityEngine.Object.DestroyImmediate(activator); - } - - ClonedMenuMappings.Clear(); } } diff --git a/Packages/nadena.dev.modular-avatar/Editor/BlendshapeSyncAnimationProcessor.cs b/Packages/nadena.dev.modular-avatar/Editor/BlendshapeSyncAnimationProcessor.cs index 60ccbbc5..ba6963de 100644 --- a/Packages/nadena.dev.modular-avatar/Editor/BlendshapeSyncAnimationProcessor.cs +++ b/Packages/nadena.dev.modular-avatar/Editor/BlendshapeSyncAnimationProcessor.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using nadena.dev.modular_avatar.editor.ErrorReporting; using UnityEditor; using UnityEditor.Animations; using UnityEngine; @@ -78,31 +79,7 @@ namespace nadena.dev.modular_avatar.core.editor foreach (var component in components) { - var targetObj = RuntimeUtil.RelativePath(avatarDescriptor.gameObject, component.gameObject); - - foreach (var binding in component.Bindings) - { - var refObj = binding.ReferenceMesh.Get(component); - if (refObj == null) continue; - var refSmr = refObj.GetComponent(); - if (refSmr == null) continue; - - var refPath = RuntimeUtil.RelativePath(avatarDescriptor.gameObject, refObj); - - var srcBinding = new SummaryBinding(refPath, binding.Blendshape); - - if (!_bindingMappings.TryGetValue(srcBinding, out var dstBindings)) - { - dstBindings = new List(); - _bindingMappings[srcBinding] = dstBindings; - } - - var targetBlendshapeName = string.IsNullOrWhiteSpace(binding.LocalBlendshape) - ? binding.Blendshape - : binding.LocalBlendshape; - - dstBindings.Add(new SummaryBinding(targetObj, targetBlendshapeName)); - } + BuildReport.ReportingObject(component, () => ProcessComponent(avatarDescriptor, component)); } // Walk and transform all clips @@ -110,11 +87,41 @@ namespace nadena.dev.modular_avatar.core.editor { if (clip.CurrentClip is AnimationClip anim) { - clip.CurrentClip = TransformMotion(anim); + BuildReport.ReportingObject(clip.CurrentClip, + () => { clip.CurrentClip = TransformMotion(anim); }); } }); } + private void ProcessComponent(VRCAvatarDescriptor avatarDescriptor, ModularAvatarBlendshapeSync component) + { + var targetObj = RuntimeUtil.RelativePath(avatarDescriptor.gameObject, component.gameObject); + + foreach (var binding in component.Bindings) + { + var refObj = binding.ReferenceMesh.Get(component); + if (refObj == null) continue; + var refSmr = refObj.GetComponent(); + if (refSmr == null) continue; + + var refPath = RuntimeUtil.RelativePath(avatarDescriptor.gameObject, refObj); + + var srcBinding = new SummaryBinding(refPath, binding.Blendshape); + + if (!_bindingMappings.TryGetValue(srcBinding, out var dstBindings)) + { + dstBindings = new List(); + _bindingMappings[srcBinding] = dstBindings; + } + + var targetBlendshapeName = string.IsNullOrWhiteSpace(binding.LocalBlendshape) + ? binding.Blendshape + : binding.LocalBlendshape; + + dstBindings.Add(new SummaryBinding(targetObj, targetBlendshapeName)); + } + } + Motion TransformMotion(Motion motion) { if (motion == null) return null; diff --git a/Packages/nadena.dev.modular-avatar/Editor/BoneProxyProcessor.cs b/Packages/nadena.dev.modular-avatar/Editor/BoneProxyProcessor.cs index d654df42..b9104170 100644 --- a/Packages/nadena.dev.modular-avatar/Editor/BoneProxyProcessor.cs +++ b/Packages/nadena.dev.modular-avatar/Editor/BoneProxyProcessor.cs @@ -22,6 +22,7 @@ * SOFTWARE. */ +using nadena.dev.modular_avatar.editor.ErrorReporting; using UnityEngine; namespace nadena.dev.modular_avatar.core.editor @@ -41,39 +42,44 @@ namespace nadena.dev.modular_avatar.core.editor foreach (var proxy in boneProxies) { - if (proxy.target != null && ValidateTarget(avatarGameObject, proxy.target) == ValidationResult.OK) + BuildReport.ReportingObject(proxy, () => ProcessProxy(avatarGameObject, proxy)); + } + } + + private void ProcessProxy(GameObject avatarGameObject, ModularAvatarBoneProxy proxy) + { + if (proxy.target != null && ValidateTarget(avatarGameObject, proxy.target) == ValidationResult.OK) + { + var oldPath = RuntimeUtil.AvatarRootPath(proxy.gameObject); + Transform transform = proxy.transform; + transform.SetParent(proxy.target, true); + + bool keepPos, keepRot; + switch (proxy.attachmentMode) { - var oldPath = RuntimeUtil.AvatarRootPath(proxy.gameObject); - Transform transform = proxy.transform; - transform.SetParent(proxy.target, true); - - bool keepPos, keepRot; - switch (proxy.attachmentMode) - { - default: - case BoneProxyAttachmentMode.Unset: - case BoneProxyAttachmentMode.AsChildAtRoot: - keepPos = keepRot = false; - break; - case BoneProxyAttachmentMode.AsChildKeepWorldPose: - keepPos = keepRot = true; - break; - case BoneProxyAttachmentMode.AsChildKeepPosition: - keepPos = true; - keepRot = false; - break; - case BoneProxyAttachmentMode.AsChildKeepRotation: - keepRot = true; - keepPos = false; - break; - } - - if (!keepPos) transform.localPosition = Vector3.zero; - if (!keepRot) transform.localRotation = Quaternion.identity; + default: + case BoneProxyAttachmentMode.Unset: + case BoneProxyAttachmentMode.AsChildAtRoot: + keepPos = keepRot = false; + break; + case BoneProxyAttachmentMode.AsChildKeepWorldPose: + keepPos = keepRot = true; + break; + case BoneProxyAttachmentMode.AsChildKeepPosition: + keepPos = true; + keepRot = false; + break; + case BoneProxyAttachmentMode.AsChildKeepRotation: + keepRot = true; + keepPos = false; + break; } - Object.DestroyImmediate(proxy); + if (!keepPos) transform.localPosition = Vector3.zero; + if (!keepRot) transform.localRotation = Quaternion.identity; } + + Object.DestroyImmediate(proxy); } internal static ValidationResult ValidateTarget(GameObject avatarGameObject, Transform proxyTarget) diff --git a/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting.meta b/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting.meta new file mode 100644 index 00000000..74f16cf1 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting.meta @@ -0,0 +1,4 @@ +fileFormatVersion: 2 +guid: 0fb1980d53a548a996bdfca622d468ca +timeCreated: 1674039787 +folderAsset: yes \ 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 new file mode 100644 index 00000000..17376f2a --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ComponentValidation.cs @@ -0,0 +1,186 @@ +using System.Collections.Generic; +using nadena.dev.modular_avatar.core; +using UnityEngine; +using VRC.SDK3.Avatars.Components; + +namespace nadena.dev.modular_avatar.editor.ErrorReporting +{ + internal static class ComponentValidation + { + /// + /// Validates the provided tag component. + /// + /// + /// Null if valid, otherwise a list of configuration errors + internal static List CheckComponent(this AvatarTagComponent tagComponent) + { + switch (tagComponent) + { + case ModularAvatarBlendshapeSync bs: + return CheckInternal(bs); + case ModularAvatarBoneProxy bp: + return CheckInternal(bp); + case ModularAvatarMenuInstaller mi: + return CheckInternal(mi); + case ModularAvatarMergeAnimator obj: + return CheckInternal(obj); + case ModularAvatarMergeArmature obj: + return CheckInternal(obj); + default: + return null; + } + } + + internal static List ValidateAll(GameObject root) + { + List logs = new List(); + foreach (var component in root.GetComponentsInChildren(true)) + { + var componentLogs = component.CheckComponent(); + if (componentLogs != null) + { + logs.AddRange(componentLogs); + } + } + + return logs; + } + + private static List CheckInternal(ModularAvatarBlendshapeSync bs) + { + var localMesh = bs.GetComponent(); + if (localMesh == null) + { + return new List + {new ErrorLog(ReportLevel.Validation, "validation.blendshape_sync.no_local_renderer", bs)}; + } + + if (localMesh.sharedMesh == null) + { + return new List + {new ErrorLog(ReportLevel.Validation, "validation.blendshape_sync.no_local_mesh", bs)}; + } + + if (bs.Bindings == null || bs.Bindings.Count == 0) + { + return new List + {new ErrorLog(ReportLevel.Validation, "validation.blendshape_sync.no_bindings", bs)}; + } + + List errorLogs = new List(); + foreach (var binding in bs.Bindings) + { + var localShape = string.IsNullOrWhiteSpace(binding.LocalBlendshape) + ? binding.Blendshape + : binding.LocalBlendshape; + + if (localMesh.sharedMesh.GetBlendShapeIndex(localShape) == -1) + { + errorLogs.Add(new ErrorLog(ReportLevel.Validation, "validation.blendshape_sync.missing_local_shape", + new string[] {localShape}, bs)); + } + + var targetObj = binding.ReferenceMesh.Get(bs.transform); + if (targetObj == null) + { + errorLogs.Add(new ErrorLog(ReportLevel.Validation, "validation.blendshape_sync.no_target", bs)); + continue; + } + + var targetRenderer = targetObj.GetComponent(); + if (targetRenderer == null) + { + errorLogs.Add(new ErrorLog(ReportLevel.Validation, + "validation.blendshape_sync.missing_target_renderer", bs, targetRenderer)); + continue; + } + + var targetMesh = targetRenderer.sharedMesh; + if (targetMesh == null) + { + errorLogs.Add(new ErrorLog(ReportLevel.Validation, "validation.blendshape_sync.missing_target_mesh", + bs, targetRenderer)); + continue; + } + + if (targetMesh.GetBlendShapeIndex(binding.Blendshape) == -1) + { + errorLogs.Add(new ErrorLog(ReportLevel.Validation, + "validation.blendshape_sync.missing_target_shape", new string[] {binding.Blendshape}, bs, + targetRenderer)); + } + } + + if (errorLogs.Count == 0) + { + return null; + } + else + { + return errorLogs; + } + } + + private static List CheckInternal(ModularAvatarBoneProxy bp) + { + if (bp.target == null) + { + return new List() + { + new ErrorLog(ReportLevel.Validation, "validation.bone_proxy.no_target", bp) + }; + } + + return null; + } + + private static List CheckInternal(ModularAvatarMenuInstaller mi) + { + // TODO - check that target menu is in the avatar + if (mi.menuToAppend == null) + { + return new List() + { + new ErrorLog(ReportLevel.Validation, "validation.menu_installer.no_menu", mi) + }; + } + + return null; + } + + private static List CheckInternal(ModularAvatarMergeAnimator ma) + { + if (ma.animator == null) + { + return new List() + { + new ErrorLog(ReportLevel.Validation, "validation.merge_animator.no_animator", ma) + }; + } + + return null; + } + + private static List CheckInternal(ModularAvatarMergeArmature ma) + { + if (ma.mergeTargetObject == null) + { + return new List() + { + new ErrorLog(ReportLevel.Validation, "validation.merge_armature.no_target", ma) + }; + } + + if (ma.mergeTargetObject == ma.gameObject || ma.mergeTargetObject.transform.IsChildOf(ma.transform)) + { + return new List() + { + new ErrorLog(ReportLevel.Validation, "error.merge_armature.merge_into_self", ma, + ma.mergeTargetObject) + }; + } + + return null; + } + } +} \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ComponentValidation.cs.meta b/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ComponentValidation.cs.meta new file mode 100644 index 00000000..2a8355da --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ComponentValidation.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 3b500f7714e4401aa090eef81c0bab01 +timeCreated: 1675855192 \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ErrorElement.cs b/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ErrorElement.cs new file mode 100644 index 00000000..3913c802 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ErrorElement.cs @@ -0,0 +1,105 @@ +using System; +using System.Linq; +using nadena.dev.modular_avatar.core.editor; +using UnityEditor; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.UIElements; + +namespace nadena.dev.modular_avatar.editor.ErrorReporting +{ + internal class ErrorElement : Box + { + private readonly ErrorLog log; + + Texture2D GetIcon() + { + switch (log.reportLevel) + { + case ReportLevel.Info: + return EditorGUIUtility.FindTexture("d_console.infoicon"); + case ReportLevel.Warning: + return EditorGUIUtility.FindTexture("d_console.warnicon"); + default: + return EditorGUIUtility.FindTexture("d_console.erroricon"); + } + } + + public ErrorElement(ErrorLog log, ObjectRefLookupCache cache) + { + this.log = log; + + AddToClassList("ErrorElement"); + var tex = GetIcon(); + if (tex != null) + { + var image = new Image(); + image.image = tex; + Add(image); + } + + var inner = new Box(); + Add(inner); + + var label = new Label(GetLabelText()); + inner.Add(label); + + foreach (var obj in log.referencedObjects) + { + var referenced = obj.Lookup(cache); + if (referenced != null) + { + inner.Add(new SelectionButton(obj.typeName, referenced)); + } + } + + if (!string.IsNullOrWhiteSpace(log.stacktrace)) + { + var foldout = new Foldout(); + foldout.text = Localization.S("error.stack_trace"); + var field = new TextField(); + field.value = log.stacktrace; + field.isReadOnly = true; + field.multiline = true; + foldout.Add(field); + foldout.value = false; + inner.Add(foldout); + } + } + + private static GameObject FindObject(string path) + { + var scene = SceneManager.GetActiveScene(); + foreach (var root in scene.GetRootGameObjects()) + { + if (root.name == path) return root; + if (path.StartsWith(root.name + "/")) + { + return root.transform.Find(path.Substring(root.name.Length + 1))?.gameObject; + } + } + + return null; + } + + private string GetLabelText() + { + var objArray = new object[log.substitutions.Length]; + for (int i = 0; i < log.substitutions.Length; i++) + { + objArray[i] = log.substitutions[i]; + } + + try + { + return string.Format(Localization.S(log.messageCode), objArray); + } + catch (FormatException e) + { + Debug.LogError("Error formatting message code: " + log.messageCode); + Debug.LogException(e); + return log.messageCode + "\n" + string.Join("\n", objArray); + } + } + } +} \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ErrorElement.cs.meta b/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ErrorElement.cs.meta new file mode 100644 index 00000000..763f54eb --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ErrorElement.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: a534edd7151c4cd49fe07919ae526004 +timeCreated: 1674132977 \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ErrorLog.cs b/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ErrorLog.cs new file mode 100644 index 00000000..8dd6dff4 --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ErrorLog.cs @@ -0,0 +1,388 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using nadena.dev.modular_avatar.core; +using Newtonsoft.Json; +using UnityEngine; +using VRC.SDK3.Avatars.Components; +using UnityEditor; +using UnityEngine.SceneManagement; +using UnityEngine.Serialization; +using Object = UnityEngine.Object; + +namespace nadena.dev.modular_avatar.editor.ErrorReporting +{ + internal class AvatarReport + { + [JsonProperty] internal ObjectRef objectRef; + + [JsonProperty] internal bool successful; + + [JsonProperty] internal List logs = new List(); + } + + internal class ObjectRefLookupCache + { + private Dictionary> _cache = + new Dictionary>(); + + internal UnityEngine.Object FindByGuidAndLocalId(string guid, long localId) + { + if (!_cache.TryGetValue(guid, out var fileContents)) + { + var path = AssetDatabase.GUIDToAssetPath(guid); + if (string.IsNullOrEmpty(path)) + { + return null; + } + + var assets = AssetDatabase.LoadAllAssetsAtPath(path); + fileContents = new Dictionary(assets.Length); + foreach (var asset in assets) + { + if (AssetDatabase.TryGetGUIDAndLocalFileIdentifier(asset, out var _, out long detectedId)) + { + fileContents[detectedId] = asset; + } + } + + _cache[guid] = fileContents; + } + + if (fileContents.TryGetValue(localId, out var obj)) + { + return obj; + } + else + { + return null; + } + } + } + + internal struct ObjectRef + { + [JsonProperty] internal string guid; + [JsonProperty] internal long? localId; + [JsonProperty] internal string path, name; + [JsonProperty] internal string typeName; + + internal ObjectRef(Object obj) + { + this.guid = null; + localId = null; + + if (obj == null) + { + this.guid = path = name = null; + localId = null; + typeName = null; + return; + } + + typeName = obj.GetType().Name; + + long id; + if (AssetDatabase.TryGetGUIDAndLocalFileIdentifier(obj, out var guid, out id)) + { + this.guid = guid; + localId = id; + } + + if (obj is Component c) + { + path = RuntimeUtil.RelativePath(null, c.gameObject); + } + else if (obj is GameObject go) + { + path = RuntimeUtil.RelativePath(null, go); + } + else + { + path = null; + } + + name = string.IsNullOrWhiteSpace(obj.name) ? "" : obj.name; + } + + internal UnityEngine.Object Lookup(ObjectRefLookupCache cache) + { + if (path != null) + { + return FindObject(path); + } + else if (guid != null && localId.HasValue) + { + return cache.FindByGuidAndLocalId(guid, localId.Value); + } + else + { + return null; + } + } + + private static GameObject FindObject(string path) + { + var scene = SceneManager.GetActiveScene(); + foreach (var root in scene.GetRootGameObjects()) + { + if (root.name == path) return root; + if (path.StartsWith(root.name + "/")) + { + return root.transform.Find(path.Substring(root.name.Length + 1))?.gameObject; + } + } + + return null; + } + + public ObjectRef Remap(string original, string cloned) + { + if (path == cloned) + { + path = original; + name = path.Substring(path.LastIndexOf('/') + 1); + } + else if (path != null && path.StartsWith(cloned + "/")) + { + path = original + path.Substring(cloned.Length); + name = path.Substring(path.LastIndexOf('/') + 1); + } + + return this; + } + } + + internal enum ReportLevel + { + Validation, + Info, + Warning, + Error, + InternalError, + } + + internal class ErrorLog + { + [JsonProperty] internal List referencedObjects; + [JsonProperty] internal ReportLevel reportLevel; + [JsonProperty] internal string messageCode; + [JsonProperty] internal string[] substitutions; + [JsonProperty] internal string stacktrace; + + internal ErrorLog(ReportLevel level, string code, string[] strings, params object[] args) + { + reportLevel = level; + + substitutions = strings.Select(s => s.ToString()).ToArray(); + + referencedObjects = args.Where(o => o is Component || o is GameObject) + .Select(o => new ObjectRef(o is Component c ? c.gameObject : (GameObject) o)) + .ToList(); + referencedObjects.AddRange(BuildReport.CurrentReport.GetActiveReferences()); + + messageCode = code; + stacktrace = null; + } + + internal ErrorLog(ReportLevel level, string code, params object[] args) : this(level, code, + Array.Empty(), args) + { + } + + internal ErrorLog(Exception e, string additionalStackTrace = "") + { + reportLevel = ReportLevel.InternalError; + messageCode = "error.internal_error"; + substitutions = new string[] {e.Message, e.TargetSite?.Name}; + referencedObjects = BuildReport.CurrentReport.GetActiveReferences().ToList(); + stacktrace = e.ToString() + additionalStackTrace; + } + + public string ToString() + { + return "[" + reportLevel + "] " + messageCode + " " + "subst: " + string.Join(", ", substitutions); + } + } + + internal class BuildReport + { + private const string Path = "Library/ModularAvatarBuildReport.json"; + + private static BuildReport _report; + private AvatarReport _currentAvatar; + private Stack _references = new Stack(); + + [JsonProperty] internal List Avatars = new List(); + internal AvatarReport CurrentAvatar => _currentAvatar; + + public static BuildReport CurrentReport + { + get + { + if (_report == null) _report = LoadReport() ?? new BuildReport(); + return _report; + } + } + + static BuildReport() + { + EditorApplication.playModeStateChanged += change => + { + switch (change) + { + case PlayModeStateChange.ExitingEditMode: + // TODO - skip if we're doing a VRCSDK build + _report = new BuildReport(); + break; + } + }; + } + + private static BuildReport LoadReport() + { + try + { + var data = File.ReadAllText(Path); + return JsonConvert.DeserializeObject(data); + } + catch (Exception e) + { + return null; + } + } + + internal static void SaveReport() + { + var report = CurrentReport; + var json = JsonConvert.SerializeObject(report); + + File.WriteAllText(Path, json); + + ErrorReportUI.reloadErrorReport(); + } + + private class AvatarReportScope : IDisposable + { + public void Dispose() + { + var successful = CurrentReport._currentAvatar.successful; + CurrentReport._currentAvatar = null; + BuildReport.SaveReport(); + if (!successful) throw new Exception("Avatar processing failed"); + } + } + + internal IDisposable ReportingOnAvatar(VRCAvatarDescriptor descriptor) + { + if (descriptor != null) + { + AvatarReport report = new AvatarReport(); + report.objectRef = new ObjectRef(descriptor.gameObject); + Avatars.Add(report); + _currentAvatar = report; + _currentAvatar.successful = true; + + _currentAvatar.logs.AddRange(ComponentValidation.ValidateAll(descriptor.gameObject)); + } + + return new AvatarReportScope(); + } + + internal static void Log(ReportLevel level, string code, params object[] objects) + { + ErrorLog errorLog = new ErrorLog(level, code, objects); + + var avatarReport = CurrentReport._currentAvatar; + if (avatarReport == null) + { + Debug.LogWarning("Error logged when not processing an avatar: " + errorLog); + return; + } + + avatarReport.logs.Add(errorLog); + } + + internal static void LogFatal(string code, params object[] objects) + { + Log(ReportLevel.Error, code, objects); + if (CurrentReport._currentAvatar != null) + { + CurrentReport._currentAvatar.successful = false; + } + else + { + throw new Exception("Fatal error without error reporting scope"); + } + } + + internal static void LogException(Exception e, string additionalStackTrace = "") + { + var avatarReport = CurrentReport._currentAvatar; + if (avatarReport == null) + { + Debug.LogException(e); + return; + } + else + { + avatarReport.logs.Add(new ErrorLog(e, additionalStackTrace)); + } + } + + internal static T ReportingObject(UnityEngine.Object obj, Func action) + { + CurrentReport._references.Push(obj); + try + { + return action(); + } + catch (Exception e) + { + var additionalStackTrace = string.Join("\n", Environment.StackTrace.Split('\n').Skip(1)) + "\n"; + LogException(e, additionalStackTrace); + return default; + } + finally + { + CurrentReport._references.Pop(); + } + } + + internal static void ReportingObject(UnityEngine.Object obj, Action action) + { + ReportingObject(obj, () => + { + action(); + return true; + }); + } + + internal IEnumerable GetActiveReferences() + { + return _references.Select(o => new ObjectRef(o)); + } + + public static void Clear() + { + _report = new BuildReport(); + } + + public static void RemapPaths(string original, string cloned) + { + foreach (var av in CurrentReport.Avatars) + { + av.objectRef = av.objectRef.Remap(original, cloned); + + foreach (var log in av.logs) + { + log.referencedObjects = log.referencedObjects.Select(o => o.Remap(original, cloned)).ToList(); + } + } + + ErrorReportUI.reloadErrorReport(); + } + } +} \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ErrorLog.cs.meta b/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ErrorLog.cs.meta new file mode 100644 index 00000000..9ef6218c --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ErrorLog.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 08d5f5d12365416d94e2d97970a24f5d +timeCreated: 1674039799 \ No newline at end of file diff --git a/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ErrorReportUI.cs b/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ErrorReportUI.cs new file mode 100644 index 00000000..6049669a --- /dev/null +++ b/Packages/nadena.dev.modular-avatar/Editor/ErrorReporting/ErrorReportUI.cs @@ -0,0 +1,306 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using nadena.dev.modular_avatar.core; +using nadena.dev.modular_avatar.core.editor; +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; + +namespace nadena.dev.modular_avatar.editor.ErrorReporting +{ + internal class ErrorReportUI : EditorWindow + { + internal static Action reloadErrorReport = () => { }; + + [MenuItem("Tools/Modular Avatar/Show error report", false, 100)] + public static void OpenErrorReportUI() + { + GetWindow().Show(); + } + + public static void MaybeOpenErrorReportUI() + { + if (BuildReport.CurrentReport.Avatars.Any(av => av.logs.Count > 0)) + { + OpenErrorReportUI(); + } + } + + private Vector2 _avatarScrollPos, _errorScrollPos; + private int _selectedAvatar = -1; + private List