|
| 1 | +using System; |
| 2 | +using System.Linq; |
| 3 | +using UnityEditor; |
| 4 | +using UnityEngine; |
| 5 | + |
| 6 | +namespace Pawlygon.UnityTools.Editor |
| 7 | +{ |
| 8 | + /// <summary> |
| 9 | + /// Editor window for Blendshape Fixes: finds the fix shapes on an avatar (helper blendshapes that prevent clipping |
| 10 | + /// while two face tracking shapes are active together, like MouthSmileLeft-EyeClosedLeft) and generates an |
| 11 | + /// animator that drives them from Pawlygon VRCFT 1.x face tracking, as a VRCFury prefab. The plan comes from |
| 12 | + /// <see cref="FTFixesPlan"/>, the animator from <see cref="FTFixesGenerator"/>. |
| 13 | + /// </summary> |
| 14 | + public class BlendshapeFixes : EditorWindow |
| 15 | + { |
| 16 | + private const string MenuPath = "!Pawlygon/Tools/Blendshape Fixes"; |
| 17 | + private const string WindowTitle = "Blendshape Fixes"; |
| 18 | + private const float SectionSpacing = 10f; |
| 19 | + |
| 20 | + [SerializeField] private Vector2 scrollPosition; |
| 21 | + private GameObject selectedAvatar; |
| 22 | + private FTFixesProfile profile; |
| 23 | + private SerializedObject profileObject; |
| 24 | + private FTFixesPlan plan = new FTFixesPlan(); |
| 25 | + private bool planDirty = true; |
| 26 | + private bool showParameters; |
| 27 | + private bool? prefabOnAvatarCache; |
| 28 | + private FTFixesGenerator.Result lastResult; |
| 29 | + private readonly PawlygonStatus status = new PawlygonStatus(); |
| 30 | + |
| 31 | + [MenuItem(MenuPath, priority = 42)] // Tools: Tune |
| 32 | + public static void ShowWindow() |
| 33 | + { |
| 34 | + var window = GetWindow<BlendshapeFixes>(); |
| 35 | + window.titleContent = new GUIContent(WindowTitle); |
| 36 | + window.minSize = new Vector2(420f, 420f); |
| 37 | + } |
| 38 | + |
| 39 | + private void OnEnable() |
| 40 | + { |
| 41 | + titleContent = new GUIContent(WindowTitle); |
| 42 | + Undo.undoRedoPerformed += OnUndoRedo; |
| 43 | + if (selectedAvatar == null) selectedAvatar = EyeMuscleSettingsCore.FindFirstAvatarInScene(); |
| 44 | + LoadAvatar(selectedAvatar); |
| 45 | + } |
| 46 | + |
| 47 | + private void OnDisable() |
| 48 | + { |
| 49 | + Undo.undoRedoPerformed -= OnUndoRedo; |
| 50 | + } |
| 51 | + |
| 52 | + private void OnUndoRedo() |
| 53 | + { |
| 54 | + profileObject?.Update(); |
| 55 | + MarkDirty(); |
| 56 | + } |
| 57 | + |
| 58 | + // Meshes, blendshapes or the generated prefab may have changed meanwhile |
| 59 | + private void OnHierarchyChange() => MarkDirty(); |
| 60 | + private void OnProjectChange() => MarkDirty(); |
| 61 | + private void OnFocus() => MarkDirty(); |
| 62 | + |
| 63 | + private void MarkDirty() |
| 64 | + { |
| 65 | + planDirty = true; |
| 66 | + prefabOnAvatarCache = null; |
| 67 | + Repaint(); |
| 68 | + } |
| 69 | + |
| 70 | + private void LoadAvatar(GameObject avatar) |
| 71 | + { |
| 72 | + selectedAvatar = avatar; |
| 73 | + lastResult = null; |
| 74 | + status.Clear(); |
| 75 | + |
| 76 | + profile = avatar != null ? FTFixesGenerator.FindProfile(avatar) : null; |
| 77 | + if (profile == null && avatar != null) |
| 78 | + { |
| 79 | + // Saved with the generated files on the first Generate |
| 80 | + profile = CreateInstance<FTFixesProfile>(); |
| 81 | + profile.hideFlags = HideFlags.DontSave; |
| 82 | + } |
| 83 | + profileObject = profile != null ? new SerializedObject(profile) : null; |
| 84 | + MarkDirty(); |
| 85 | + } |
| 86 | + |
| 87 | + // ===================================================================== |
| 88 | + // OnGUI |
| 89 | + // ===================================================================== |
| 90 | + |
| 91 | + private void OnGUI() |
| 92 | + { |
| 93 | + PawlygonEditorUI.EnsureStyles(); |
| 94 | + |
| 95 | + // Everything that decides which controls are drawn is refreshed before the layout pass. |
| 96 | + if (Event.current.type == EventType.Layout && planDirty) |
| 97 | + { |
| 98 | + planDirty = false; |
| 99 | + plan = FTFixesPlan.Resolve(profile, selectedAvatar); |
| 100 | + } |
| 101 | + |
| 102 | + PawlygonEditorUI.DrawHeader( |
| 103 | + WindowTitle, |
| 104 | + "Drives fix blendshapes like MouthSmileLeft-EyeClosedLeft from VRCFT face tracking (Pawlygon VRCFT 1.x).", |
| 105 | + PawlygonEditorUI.DocumentationUrl); |
| 106 | + |
| 107 | + if (PawlygonEditorUI.DrawAvatarBar(this, ref selectedAvatar, "Avatar")) |
| 108 | + { |
| 109 | + LoadAvatar(selectedAvatar); |
| 110 | + GUIUtility.ExitGUI(); |
| 111 | + } |
| 112 | + EditorGUILayout.Space(6f); |
| 113 | + |
| 114 | + scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition, GUILayout.ExpandHeight(true)); |
| 115 | + if (selectedAvatar == null || profileObject == null) |
| 116 | + { |
| 117 | + DrawNoAvatar(); |
| 118 | + } |
| 119 | + else |
| 120 | + { |
| 121 | + profileObject.Update(); |
| 122 | + EditorGUI.BeginChangeCheck(); |
| 123 | + |
| 124 | + DrawFixes(); |
| 125 | + EditorGUILayout.Space(SectionSpacing); |
| 126 | + DrawManualFixes(); |
| 127 | + EditorGUILayout.Space(SectionSpacing); |
| 128 | + DrawParameters(); |
| 129 | + EditorGUILayout.Space(SectionSpacing); |
| 130 | + DrawGenerate(); |
| 131 | + |
| 132 | + if (EditorGUI.EndChangeCheck()) |
| 133 | + { |
| 134 | + profileObject.ApplyModifiedProperties(); |
| 135 | + planDirty = true; |
| 136 | + } |
| 137 | + } |
| 138 | + EditorGUILayout.EndScrollView(); |
| 139 | + |
| 140 | + PawlygonEditorUI.DrawStatusBar(status); |
| 141 | + if (selectedAvatar != null && profile != null) DrawActions(); |
| 142 | + PawlygonEditorUI.DrawFooter(); |
| 143 | + } |
| 144 | + |
| 145 | + private void DrawNoAvatar() |
| 146 | + { |
| 147 | + using (new EditorGUILayout.VerticalScope(PawlygonEditorUI.SectionStyle)) |
| 148 | + { |
| 149 | + PawlygonEditorUI.DrawSectionHeader( |
| 150 | + "Pick an Avatar", |
| 151 | + "Choose your avatar above: drag it from the Hierarchy, pick it from the Scene list, or select it and click Use Selection."); |
| 152 | + } |
| 153 | + } |
| 154 | + |
| 155 | + private void DrawFixes() |
| 156 | + { |
| 157 | + using (new EditorGUILayout.VerticalScope(PawlygonEditorUI.SectionStyle)) |
| 158 | + { |
| 159 | + PawlygonEditorUI.DrawSectionHeader( |
| 160 | + "Fixes", |
| 161 | + "Fix shapes are helper blendshapes that prevent clipping while two face tracking shapes are active together, " + |
| 162 | + "named after the shapes they fix and joined with '-' (e.g. MouthSmileLeft-EyeClosedLeft). Each one follows the " + |
| 163 | + "same VRCFT parameters as the shapes it fixes."); |
| 164 | + |
| 165 | + EditorGUILayout.PropertyField(profileObject.FindProperty(nameof(FTFixesProfile.autoDetect)), new GUIContent("Auto-detect")); |
| 166 | + EditorGUILayout.PropertyField(profileObject.FindProperty(nameof(FTFixesProfile.combine)), new GUIContent("Combine")); |
| 167 | + EditorGUILayout.Space(4f); |
| 168 | + |
| 169 | + var detected = plan.Entries.Where(e => !e.IsManual).ToList(); |
| 170 | + if (!profile.autoDetect) |
| 171 | + { |
| 172 | + EditorGUILayout.LabelField("Auto-detect is off: only the manual fixes below are driven.", PawlygonEditorUI.SubLabelStyle); |
| 173 | + return; |
| 174 | + } |
| 175 | + if (detected.Count == 0) |
| 176 | + { |
| 177 | + EditorGUILayout.LabelField( |
| 178 | + "No fix shapes found: no blendshape on the avatar is named after face tracking shapes joined with '-'.", |
| 179 | + PawlygonEditorUI.SubLabelStyle); |
| 180 | + return; |
| 181 | + } |
| 182 | + |
| 183 | + foreach (var entry in detected) DrawDetectedRow(entry); |
| 184 | + } |
| 185 | + } |
| 186 | + |
| 187 | + private void DrawDetectedRow(FTFixesPlan.Entry entry) |
| 188 | + { |
| 189 | + using (new EditorGUILayout.HorizontalScope()) |
| 190 | + { |
| 191 | + bool include = EditorGUILayout.ToggleLeft(new GUIContent(entry.Blendshape, "Drive this fix."), !entry.Excluded, GUILayout.MinWidth(200f)); |
| 192 | + if (include == entry.Excluded) |
| 193 | + { |
| 194 | + Undo.RecordObject(profile, include ? "Include Blendshape Fix" : "Exclude Blendshape Fix"); |
| 195 | + if (include) profile.excludedFixes.Remove(entry.Blendshape); |
| 196 | + else if (!profile.excludedFixes.Contains(entry.Blendshape)) profile.excludedFixes.Add(entry.Blendshape); |
| 197 | + EditorUtility.SetDirty(profile); |
| 198 | + profileObject.Update(); |
| 199 | + planDirty = true; |
| 200 | + } |
| 201 | + |
| 202 | + GUILayout.FlexibleSpace(); |
| 203 | + if (entry.Excluded) PawlygonEditorUI.DrawBadge("Off", PawlygonEditorUI.BadgeKind.Neutral, "Not driven."); |
| 204 | + else PawlygonEditorUI.DrawBadge(Plural(entry.MeshPaths.Count, "mesh", "meshes"), PawlygonEditorUI.BadgeKind.Ok, string.Join("\n", entry.MeshPaths)); |
| 205 | + } |
| 206 | + EditorGUILayout.LabelField(DescribeSources(entry), PawlygonEditorUI.RichMiniLabelStyle); |
| 207 | + } |
| 208 | + |
| 209 | + private void DrawManualFixes() |
| 210 | + { |
| 211 | + using (new EditorGUILayout.VerticalScope(PawlygonEditorUI.SectionStyle)) |
| 212 | + { |
| 213 | + PawlygonEditorUI.DrawSectionHeader( |
| 214 | + "Manual Fixes", |
| 215 | + "Fix shapes named differently: the fix blendshape and the shapes it fixes, separated by commas. " + |
| 216 | + "An entry with the same blendshape as a detected fix replaces it."); |
| 217 | + |
| 218 | + EditorGUILayout.PropertyField(profileObject.FindProperty(nameof(FTFixesProfile.fixes)), new GUIContent("Fixes"), true); |
| 219 | + |
| 220 | + foreach (var entry in plan.Entries.Where(e => e.IsManual)) |
| 221 | + { |
| 222 | + if (entry.Problem != null) EditorGUILayout.HelpBox($"{entry.Blendshape}: {entry.Problem}", MessageType.Warning); |
| 223 | + else EditorGUILayout.LabelField($"<b>{entry.Blendshape}</b> {DescribeSources(entry)}", PawlygonEditorUI.RichMiniLabelStyle); |
| 224 | + } |
| 225 | + } |
| 226 | + } |
| 227 | + |
| 228 | + private void DrawParameters() |
| 229 | + { |
| 230 | + using (new EditorGUILayout.VerticalScope(PawlygonEditorUI.SectionStyle)) |
| 231 | + { |
| 232 | + showParameters = EditorGUILayout.Foldout(showParameters, "Parameters", true); |
| 233 | + if (!showParameters) return; |
| 234 | + |
| 235 | + EditorGUILayout.LabelField( |
| 236 | + "The fixes read the same VRCFT parameters as the Pawlygon VRCFT 1.x animator: the synced OSCmooth proxies by default. " + |
| 237 | + "Fixes of eye shapes only run while eye tracking is on, fixes of mouth shapes while lip tracking is on.", |
| 238 | + PawlygonEditorUI.SubLabelStyle); |
| 239 | + EditorGUILayout.PropertyField(profileObject.FindProperty(nameof(FTFixesProfile.parameterPrefix)), new GUIContent("Parameter Prefix")); |
| 240 | + EditorGUILayout.PropertyField(profileObject.FindProperty(nameof(FTFixesProfile.eyeTrackingActive)), new GUIContent("Eye Tracking Active")); |
| 241 | + EditorGUILayout.PropertyField(profileObject.FindProperty(nameof(FTFixesProfile.lipTrackingActive)), new GUIContent("Lip Tracking Active")); |
| 242 | + } |
| 243 | + } |
| 244 | + |
| 245 | + private void DrawGenerate() |
| 246 | + { |
| 247 | + using (new EditorGUILayout.VerticalScope(PawlygonEditorUI.SectionStyle)) |
| 248 | + { |
| 249 | + PawlygonEditorUI.DrawSectionHeader( |
| 250 | + "Generate", |
| 251 | + "Builds an FX controller that drives the fixes and a VRCFury prefab to add to the avatar. Needs VRCFury and the Pawlygon VRCFT 1.x prefab on the avatar."); |
| 252 | + |
| 253 | + int count = plan.Generated.Count(); |
| 254 | + EditorGUILayout.LabelField($"<b>Fixes:</b> {count}", PawlygonEditorUI.RichMiniLabelStyle); |
| 255 | + EditorGUILayout.LabelField($"<b>Output:</b> {FTFixesGenerator.GetOutputFolder(selectedAvatar)}", PawlygonEditorUI.RichMiniLabelStyle); |
| 256 | + |
| 257 | + if (!AssetDatabase.Contains(profile)) |
| 258 | + { |
| 259 | + EditorGUILayout.LabelField("These settings are saved with the generated files.", PawlygonEditorUI.SubLabelStyle); |
| 260 | + } |
| 261 | + else if (!string.IsNullOrEmpty(profile.lastGeneratedSignature) && profile.lastGeneratedSignature != FTFixesPlan.Signature(profile, plan)) |
| 262 | + { |
| 263 | + EditorGUILayout.Space(4f); |
| 264 | + EditorGUILayout.HelpBox("The fixes or settings changed since the last generation. Generate again to update the animator.", MessageType.Warning); |
| 265 | + } |
| 266 | + |
| 267 | + if (lastResult != null) |
| 268 | + { |
| 269 | + foreach (string warning in lastResult.Warnings) EditorGUILayout.HelpBox(warning, MessageType.Warning); |
| 270 | + } |
| 271 | + } |
| 272 | + } |
| 273 | + |
| 274 | + private void DrawActions() |
| 275 | + { |
| 276 | + bool hasPrefab = AssetDatabase.LoadAssetAtPath<GameObject>(FTFixesGenerator.GetPrefabPath(selectedAvatar)) != null; |
| 277 | + if (prefabOnAvatarCache == null) prefabOnAvatarCache = hasPrefab && FTFixesGenerator.IsPrefabOnAvatar(selectedAvatar); |
| 278 | + bool onAvatar = prefabOnAvatarCache.Value; |
| 279 | + |
| 280 | + PawlygonEditorUI.BeginActionBar(); |
| 281 | + |
| 282 | + using (new EditorGUI.DisabledScope(!hasPrefab || onAvatar)) |
| 283 | + { |
| 284 | + var addContent = new GUIContent( |
| 285 | + onAvatar ? "✓ Prefab Is on the Avatar" : "Add Prefab to Avatar", |
| 286 | + !hasPrefab ? "Generate first: this adds the generated prefab to the avatar." : $"Adds {FTFixesGenerator.PrefabName} to {selectedAvatar.name}."); |
| 287 | + if (PawlygonEditorUI.DrawSecondaryButton(addContent, 28f)) |
| 288 | + { |
| 289 | + FTFixesGenerator.AddPrefabToAvatar(selectedAvatar); |
| 290 | + prefabOnAvatarCache = null; |
| 291 | + GUIUtility.ExitGUI(); |
| 292 | + } |
| 293 | + } |
| 294 | + |
| 295 | + GUILayout.FlexibleSpace(); |
| 296 | + |
| 297 | + using (new EditorGUI.DisabledScope(!plan.Generated.Any())) |
| 298 | + { |
| 299 | + if (PawlygonEditorUI.DrawPrimaryButton(new GUIContent("Generate", "Builds the FX controller and the VRCFury prefab."), 28f, GUILayout.Width(150f))) |
| 300 | + { |
| 301 | + Generate(); |
| 302 | + GUIUtility.ExitGUI(); |
| 303 | + } |
| 304 | + } |
| 305 | + |
| 306 | + PawlygonEditorUI.EndActionBar(); |
| 307 | + } |
| 308 | + |
| 309 | + private void Generate() |
| 310 | + { |
| 311 | + try |
| 312 | + { |
| 313 | + bool wasSaved = AssetDatabase.Contains(profile); |
| 314 | + if (!wasSaved) profile.hideFlags = HideFlags.None; |
| 315 | + |
| 316 | + lastResult = FTFixesGenerator.Generate(profile, selectedAvatar); |
| 317 | + if (!wasSaved && AssetDatabase.Contains(profile)) profileObject = new SerializedObject(profile); |
| 318 | + |
| 319 | + if (!lastResult.Success) status.Error(lastResult.Message); |
| 320 | + else if (lastResult.PrefabPath != null) |
| 321 | + { |
| 322 | + status.Info(lastResult.Message, "Ping", PawlygonStatus.Ping(AssetDatabase.LoadAssetAtPath<GameObject>(lastResult.PrefabPath))); |
| 323 | + } |
| 324 | + else status.Warning(lastResult.Message); |
| 325 | + } |
| 326 | + catch (Exception ex) |
| 327 | + { |
| 328 | + Debug.LogException(ex); |
| 329 | + status.Error($"Generation failed: {ex.Message}"); |
| 330 | + } |
| 331 | + finally |
| 332 | + { |
| 333 | + if (!AssetDatabase.Contains(profile)) profile.hideFlags = HideFlags.DontSave; |
| 334 | + MarkDirty(); |
| 335 | + } |
| 336 | + } |
| 337 | + |
| 338 | + private static string DescribeSources(FTFixesPlan.Entry entry) |
| 339 | + { |
| 340 | + return "Fixes " + string.Join(" + ", entry.Sources.Select(s => $"{s.Shape} <color=#888888>({s.Parameter})</color>")); |
| 341 | + } |
| 342 | + |
| 343 | + private static string Plural(int count, string singular, string plural) => $"{count} {(count == 1 ? singular : plural)}"; |
| 344 | + } |
| 345 | +} |
0 commit comments