Browse Source

Add face preset saving/loading

Works similarly to pose presets.

Keeping track of maid's selected face preset and category has been
removed for now.
habeebweeb 3 years ago
parent
commit
ac2457014e

+ 117 - 112
COM3D2.MeidoPhotoStudio.Plugin/Config/MeidoPhotoStudio/Translations/en/translation.face.json

@@ -1,116 +1,121 @@
 {
     "faceBlendPresetsDropdown": {
-        "通常": "General: Normal",
-        "微笑み": "General: Half Smile",
-        "笑顔": "General: Big Smile",
-        "にっこり": "General: Sweet Smile",
-        "優しさ": "General: Gentle",
-        "発情": "General: Lust",
-        "ジト目": "General: Glare",
-        "閉じ目": "General: Eyes Closed",
-        "思案伏せ目": "General: Consideration",
-        "ドヤ顔": "General: Proud",
-        "引きつり笑顔": "General: Awkward Smile",
-        "苦笑い": "General: Bitter Smile",
-        "困った": "General: Distressed",
-        "疑問": "General: Curious",
-        "ぷんすか": "General: Upset",
-        "むー": "General: Mmmm..",
-        "泣き": "General: Cry",
-        "拗ね": "General: Sulk",
-        "照れ": "General: Bashful",
-        "悲しみ2": "General: Sad",
-        "きょとん": "General: Confused",
-        "びっくり": "General: Surprised",
-        "少し怒り": "General: Kinda Angry",
-        "怒り": "General: Angry",
-        "照れ叫び": "General: Shout",
-        "誘惑": "General: Temptation",
-        "接吻": "General: Kiss",
-        "居眠り安眠": "General: Sleep",
-        "まぶたギュ": "General: Eyes Shut",
-        "目を見開いて": "General: Eyes Wide Open",
-        "痛みで目を見開いて": "General: Eyes Open Pain",
-        "恥ずかしい": "General: Embarrassed",
-        "ためいき": "General: Sigh",
-        "目口閉じ": "General: Eyes and Mouth Closed",
-        "ウインク照れ": "General: Shy Wink",
-        "ダンス目つむり": "Dance: Eyes and Mouth Closed",
-        "ダンスあくび": "Dance: Yawn",
-        "ダンスびっくり": "Dance: Surprised",
-        "ダンス微笑み": "Dance: Half Smile",
-        "ダンス目あけ": "Dance: Eyes Open",
-        "ダンス目とじ": "Dance: Eyes Closed",
-        "ダンス誘惑": "Dance: Temptation",
-        "ダンス困り顔": "Dance: Troubled",
-        "ダンスウインク": "Dance: Wink",
-        "ダンス真剣": "Dance: Serious",
-        "ダンス憂い": "Dance: Sorrow",
-        "ダンスジト目": "Dance: Glare",
-        "ダンスキス": "Dance: Kiss",
-        "エロ通常1": "Ero: General 1",
-        "エロ通常2": "Ero: General 2",
-        "エロ通常3": "Ero: General 3",
-        "エロ興奮0": "Ero: Excitement 1",
-        "エロ興奮1": "Ero: Excitement 2",
-        "エロ興奮2": "Ero: Excitement 3",
-        "エロ興奮3": "Ero: Excitement 4",
-        "エロ好感1": "Ero: Favourable 1",
-        "エロ好感2": "Ero: Favourable 2",
-        "エロ好感3": "Ero: Favourable 3",
-        "エロ期待": "Ero: Anticipation",
-        "エロ羞恥1": "Ero: Bashful 1",
-        "エロ羞恥2": "Ero: Bashful 2",
-        "エロ羞恥3": "Ero: Bashful 3",
-        "エロ緊張": "Ero: Nervous",
-        "エロ我慢1": "Ero: Endurance 1",
-        "エロ我慢2": "Ero: Endurance 2",
-        "エロ我慢3": "Ero: Endurance 3",
-        "エロ嫌悪1": "Ero: Hate",
-        "エロ痛み1": "Ero: Pain 1",
-        "エロ痛み2": "Ero: Pain 2",
-        "エロ痛み3": "Ero: Pain 3",
-        "エロ痛み我慢": "Ero: Enduring 1",
-        "エロ痛み我慢2": "Ero: Enduring 2",
-        "エロ痛み我慢3": "Ero: Enduring 3",
-        "エロ怯え": "Ero: Afraid",
-        "エロメソ泣き": "Ero: Lewd Cry",
-        "あーん": "Ero: Ahhnn",
-        "エロ舌責": "Ero: Tongue Out",
-        "エロ舌責快楽": "Ero: Tongue Out Pleasure",
-        "エロ舌責嫌悪": "Ero: Tongue Out Hate",
-        "エロ舐め通常": "Ero: Lick Normal 1",
-        "エロ舐め通常2": "Ero: Lick Normal 2",
-        "エロ舐め愛情": "Ero: Lick Love 1",
-        "エロ舐め愛情2": "Ero: Lick Love 2",
-        "エロ舐め快楽": "Ero: Lick Pleasure 1",
-        "エロ舐め快楽2": "Ero: Lick Pleasure 2",
-        "エロ舐め嫌悪": "Ero: Lick Hate 1",
-        "エロ舐め嫌悪2": "Ero: Lick Hate 2",
-        "エロフェラ通常": "Ero: Fellatio Normal",
-        "エロフェラ愛情": "Ero: Fellatio Love",
-        "エロフェラ快楽": "Ero: Fellatio Pleasure",
-        "エロフェラ嫌悪": "Ero: Fellatio Hate",
-        "閉じ舐め通常": "Ero: Lick Normal 1 (close)",
-        "閉じ舐め通常2": "Ero: Lick Normal 2 (close)",
-        "閉じ舐め愛情": "Ero: Lick Love 1 (close)",
-        "閉じ舐め愛情2": "Ero: Lick Love 2 (close)",
-        "閉じ舐め快楽": "Ero:  Lick Pleasure 1 (close)",
-        "閉じ舐め快楽2": "Ero: Lick Pleasure 2 (close)",
-        "閉じ舐め嫌悪": "Ero: Lick Hate 1 (close)",
-        "閉じ舐め嫌悪2": "Ero: Lick Hate 2 (close)",
-        "閉じフェラ通常": "Ero: Fellatio Normal (close)",
-        "閉じフェラ愛情": "Ero: Fellatio Love (close)",
-        "閉じフェラ快楽": "Ero: Fellatio Pleasure (close)",
-        "閉じフェラ嫌悪": "Ero: Fellatio Hate (close)",
-        "通常射精後1": "Ero: After Ejaculation 1",
-        "通常射精後2": "Ero: After Ejaculation 2",
-        "絶頂射精後1": "Ero: After Ejaculation Orgasm 1",
-        "絶頂射精後2": "Ero: After Ejaculation Orgasm 2",
-        "興奮射精後1": "Ero: After Ejaculation Aroused 1",
-        "興奮射精後2": "Ero: After Ejaculation Aroused 2",
-        "余韻弱": "Ero: Afterglow",
-        "エロ絶頂": "Ero: Orgasm",
-        "エロ放心": "Ero: Absent Minded"
+        "通常": "Normal",
+        "微笑み": "Half Smile",
+        "笑顔": "Big Smile",
+        "にっこり": "Sweet Smile",
+        "優しさ": "Gentle",
+        "発情": "Lust",
+        "ジト目": "Glare",
+        "閉じ目": "Eyes Closed",
+        "思案伏せ目": "Consideration",
+        "ドヤ顔": "Proud",
+        "引きつり笑顔": "Awkward Smile",
+        "苦笑い": "Bitter Smile",
+        "困った": "Distressed",
+        "疑問": "Curious",
+        "ぷんすか": "Upset",
+        "むー": "Mmmm..",
+        "泣き": "Cry",
+        "拗ね": "Sulk",
+        "照れ": "Bashful",
+        "悲しみ2": "Sad",
+        "きょとん": "Confused",
+        "びっくり": "Surprised",
+        "少し怒り": "Kinda Angry",
+        "怒り": "Angry",
+        "照れ叫び": "Shout",
+        "誘惑": "Temptation",
+        "接吻": "Kiss",
+        "居眠り安眠": "Sleep",
+        "まぶたギュ": "Eyes Shut",
+        "目を見開いて": "Eyes Wide Open",
+        "痛みで目を見開いて": "Eyes Open Pain",
+        "恥ずかしい": "Embarrassed",
+        "ためいき": "Sigh",
+        "目口閉じ": "Eyes and Mouth Closed",
+        "ウインク照れ": "Shy Wink",
+        "ダンス目つむり": "Eyes and Mouth Closed",
+        "ダンスあくび": "Yawn",
+        "ダンスびっくり": "Surprised",
+        "ダンス微笑み": "Half Smile",
+        "ダンス目あけ": "Eyes Open",
+        "ダンス目とじ": "Eyes Closed",
+        "ダンス誘惑": "Temptation",
+        "ダンス困り顔": "Troubled",
+        "ダンスウインク": "Wink",
+        "ダンス真剣": "Serious",
+        "ダンス憂い": "Sorrow",
+        "ダンスジト目": "Glare",
+        "ダンスキス": "Kiss",
+        "エロ通常1": "General 1",
+        "エロ通常2": "General 2",
+        "エロ通常3": "General 3",
+        "エロ興奮0": "Excitement 1",
+        "エロ興奮1": "Excitement 2",
+        "エロ興奮2": "Excitement 3",
+        "エロ興奮3": "Excitement 4",
+        "エロ好感1": "Favourable 1",
+        "エロ好感2": "Favourable 2",
+        "エロ好感3": "Favourable 3",
+        "エロ期待": "Anticipation",
+        "エロ羞恥1": "Bashful 1",
+        "エロ羞恥2": "Bashful 2",
+        "エロ羞恥3": "Bashful 3",
+        "エロ緊張": "Nervous",
+        "エロ我慢1": "Endurance 1",
+        "エロ我慢2": "Endurance 2",
+        "エロ我慢3": "Endurance 3",
+        "エロ嫌悪1": "Hate",
+        "エロ痛み1": "Pain 1",
+        "エロ痛み2": "Pain 2",
+        "エロ痛み3": "Pain 3",
+        "エロ痛み我慢": "Enduring 1",
+        "エロ痛み我慢2": "Enduring 2",
+        "エロ痛み我慢3": "Enduring 3",
+        "エロ怯え": "Afraid",
+        "エロメソ泣き": "Lewd Cry",
+        "あーん": "Ahhnn",
+        "エロ舌責": "Tongue Out",
+        "エロ舌責快楽": "Tongue Out Pleasure",
+        "エロ舌責嫌悪": "Tongue Out Hate",
+        "エロ舐め通常": "Lick Normal 1",
+        "エロ舐め通常2": "Lick Normal 2",
+        "エロ舐め愛情": "Lick Love 1",
+        "エロ舐め愛情2": "Lick Love 2",
+        "エロ舐め快楽": "Lick Pleasure 1",
+        "エロ舐め快楽2": "Lick Pleasure 2",
+        "エロ舐め嫌悪": "Lick Hate 1",
+        "エロ舐め嫌悪2": "Lick Hate 2",
+        "エロフェラ通常": "Fellatio Normal",
+        "エロフェラ愛情": "Fellatio Love",
+        "エロフェラ快楽": "Fellatio Pleasure",
+        "エロフェラ嫌悪": "Fellatio Hate",
+        "閉じ舐め通常": "Lick Normal 1 (close)",
+        "閉じ舐め通常2": "Lick Normal 2 (close)",
+        "閉じ舐め愛情": "Lick Love 1 (close)",
+        "閉じ舐め愛情2": "Lick Love 2 (close)",
+        "閉じ舐め快楽": " Lick Pleasure 1 (close)",
+        "閉じ舐め快楽2": "Lick Pleasure 2 (close)",
+        "閉じ舐め嫌悪": "Lick Hate 1 (close)",
+        "閉じ舐め嫌悪2": "Lick Hate 2 (close)",
+        "閉じフェラ通常": "Fellatio Normal (close)",
+        "閉じフェラ愛情": "Fellatio Love (close)",
+        "閉じフェラ快楽": "Fellatio Pleasure (close)",
+        "閉じフェラ嫌悪": "Fellatio Hate (close)",
+        "通常射精後1": "After Ejaculation 1",
+        "通常射精後2": "After Ejaculation 2",
+        "絶頂射精後1": "After Ejaculation Orgasm 1",
+        "絶頂射精後2": "After Ejaculation Orgasm 2",
+        "興奮射精後1": "After Ejaculation Aroused 1",
+        "興奮射精後2": "After Ejaculation Aroused 2",
+        "余韻弱": "Afterglow",
+        "エロ絶頂": "Orgasm",
+        "エロ放心": "Absent Minded"
     },
+    "faceBlendCategory": {
+        "一般": "General",
+        "ダンス": "Dance",
+        "エロ": "Ero"
+    }
 }

+ 5 - 1
COM3D2.MeidoPhotoStudio.Plugin/Config/MeidoPhotoStudio/Translations/en/translation.ui.json

@@ -150,7 +150,9 @@
         "speakEro": "H Lines"
     },
     "maidFaceWindow": {
-        "faceLock": "L"
+        "savePaneToggle": "Save",
+        "baseTab": "Base",
+        "customTab": "Custom"
     },
     "faceBlendValues": {
         "eyeclose": "Eye Shut",
@@ -191,6 +193,8 @@
         "hohol": "Blush 3"
     },
     "faceSave": {
+        "categoryHeader": "Cateogory",
+        "nameHeader": "Name",
         "saveButton": "Add",
         "deleteButton": "D"
     },

+ 107 - 17
COM3D2.MeidoPhotoStudio.Plugin/MeidoPhotoStudio/Constants.cs

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.Collections.Generic;
 using System.IO;
 using System.Linq;
@@ -17,12 +17,14 @@ namespace COM3D2.MeidoPhotoStudio.Plugin
         private static bool beginMpnAttachInit;
         public const string customPoseDirectory = "Custom Poses";
         public const string customHandDirectory = "Hand Presets";
+        public const string customFaceDirectory = "Face Presets";
         public const string sceneDirectory = "Scenes";
         public const string kankyoDirectory = "Environments";
         public const string configDirectory = "MeidoPhotoStudio";
         public const string translationDirectory = "Translations";
         public static readonly string customPosePath;
         public static readonly string customHandPath;
+        public static readonly string customFacePath;
         public static readonly string scenesPath;
         public static readonly string kankyoPath;
         public static readonly string configPath;
@@ -45,7 +47,10 @@ namespace COM3D2.MeidoPhotoStudio.Plugin
         public static readonly Dictionary<string, List<string>> CustomPoseDict = new Dictionary<string, List<string>>();
         public static readonly List<string> CustomHandGroupList = new List<string>();
         public static readonly Dictionary<string, List<string>> CustomHandDict = new Dictionary<string, List<string>>();
-        public static readonly List<string> FaceBlendList = new List<string>();
+        public static readonly List<string> FaceGroupList = new List<string>();
+        public static readonly Dictionary<string, List<string>> FaceDict = new Dictionary<string, List<string>>();
+        public static readonly List<string> CustomFaceGroupList = new List<string>();
+        public static readonly Dictionary<string, List<string>> CustomFaceDict = new Dictionary<string, List<string>>();
         public static readonly List<string> BGList = new List<string>();
         public static readonly List<KeyValuePair<string, string>> MyRoomCustomBGList
             = new List<KeyValuePair<string, string>>();
@@ -67,6 +72,7 @@ namespace COM3D2.MeidoPhotoStudio.Plugin
         public static event EventHandler<MenuFilesEventArgs> MenuFilesChange;
         public static event EventHandler<CustomPoseEventArgs> customPoseChange;
         public static event EventHandler<CustomPoseEventArgs> customHandChange;
+        public static event EventHandler<CustomPoseEventArgs> CustomFaceChange;
         public enum DoguCategory
         {
             Other, Mob, Desk, HandItem, BGSmall
@@ -89,10 +95,13 @@ namespace COM3D2.MeidoPhotoStudio.Plugin
 
             customPosePath = Path.Combine(presetPath, customPoseDirectory);
             customHandPath = Path.Combine(presetPath, customHandDirectory);
+            customFacePath = Path.Combine(presetPath, customFaceDirectory);
             scenesPath = Path.Combine(configPath, sceneDirectory);
             kankyoPath = Path.Combine(configPath, kankyoDirectory);
 
-            string[] directories = new[] { customPosePath, customHandPath, scenesPath, kankyoPath, configPath };
+            string[] directories = new[] {
+                customPosePath, customHandPath, scenesPath, kankyoPath, configPath, customFacePath
+            };
 
             foreach (string directory in directories)
             {
@@ -112,6 +121,67 @@ namespace COM3D2.MeidoPhotoStudio.Plugin
             InitializeMpnAttachProps();
         }
 
+        public static void AddFacePreset(Dictionary<string, float> faceData, string filename, string directory)
+        {
+            filename = Utility.SanitizePathPortion(filename);
+            directory = Utility.SanitizePathPortion(directory);
+
+            if (string.IsNullOrEmpty(filename)) filename = "face_preset";
+            if (directory.Equals(customFaceDirectory, StringComparison.InvariantCultureIgnoreCase))
+            {
+                directory = string.Empty;
+            }
+            directory = Path.Combine(customFacePath, directory);
+
+            if (!Directory.Exists(directory)) Directory.CreateDirectory(directory);
+
+            string fullPath = Path.Combine(directory, filename);
+
+            if (File.Exists($"{fullPath}.xml")) fullPath += $"_{DateTime.Now:yyyyMMddHHmmss}";
+
+            fullPath = Path.GetFullPath($"{fullPath}.xml");
+
+            if (!fullPath.StartsWith(customFacePath))
+            {
+                Utility.LogError($"Could not save face preset! Path is invalid: '{fullPath}'");
+                return;
+            }
+
+            XElement rootElement = new XElement("FaceData");
+
+            foreach (KeyValuePair<string, float> kvp in faceData)
+            {
+                rootElement.Add(new XElement("elm", kvp.Value.ToString("G9"), new XAttribute("name", kvp.Key)));
+            }
+
+            XDocument fullDocument = new XDocument(
+                new XDeclaration("1.0", "utf-8", "true"),
+                new XComment("MeidoPhotoStudio Face Preset"),
+                rootElement
+            );
+
+            fullDocument.Save(fullPath);
+
+            FileInfo fileInfo = new FileInfo(fullPath);
+
+            string category = fileInfo.Directory.Name;
+            string faceGroup = CustomFaceGroupList.Find(
+                group => string.Equals(category, group, StringComparison.InvariantCultureIgnoreCase)
+            );
+
+            if (string.IsNullOrEmpty(faceGroup))
+            {
+                CustomFaceGroupList.Add(category);
+                CustomHandDict[category] = new List<string>();
+            }
+            else category = faceGroup;
+
+            CustomFaceDict[category].Add(fullPath);
+            CustomFaceDict[category].Sort();
+
+            CustomFaceChange?.Invoke(null, new CustomPoseEventArgs(fullPath, category));
+        }
+
         public static void AddPose(byte[] anmBinary, string filename, string directory)
         {
             // TODO: Consider writing a file system monitor
@@ -295,7 +365,7 @@ namespace COM3D2.MeidoPhotoStudio.Plugin
                 PoseGroupList.AddRange(new[] { "normal2", "ero2" });
             }
 
-            Action<string> GetPoses = directory =>
+            void GetPoses(string directory)
             {
                 List<string> poseList = Directory.GetFiles(directory)
                     .Where(file => Path.GetExtension(file) == ".anm").ToList();
@@ -306,7 +376,7 @@ namespace COM3D2.MeidoPhotoStudio.Plugin
                     if (poseGroupName != customPoseDirectory) CustomPoseGroupList.Add(poseGroupName);
                     CustomPoseDict[poseGroupName] = poseList;
                 }
-            };
+            }
 
             CustomPoseGroupList.Add(customPoseDirectory);
             CustomPoseDict[customPoseDirectory] = new List<string>();
@@ -321,7 +391,7 @@ namespace COM3D2.MeidoPhotoStudio.Plugin
 
         public static void InitializeHandPresets()
         {
-            Action<string> GetPresets = directory =>
+            void GetPresets(string directory)
             {
                 IEnumerable<string> presetList = Directory.GetFiles(directory)
                     .Where(file => Path.GetExtension(file) == ".xml");
@@ -332,7 +402,7 @@ namespace COM3D2.MeidoPhotoStudio.Plugin
                     if (presetCategory != customHandDirectory) CustomHandGroupList.Add(presetCategory);
                     CustomHandDict[presetCategory] = new List<string>(presetList);
                 }
-            };
+            }
 
             CustomHandGroupList.Add(customHandDirectory);
             CustomHandDict[customHandDirectory] = new List<string>();
@@ -347,17 +417,37 @@ namespace COM3D2.MeidoPhotoStudio.Plugin
 
         public static void InitializeFaceBlends()
         {
-            using (CsvParser csvParser = OpenCsvParser("phot_face_list.nei"))
+            PhotoFaceData.Create();
+
+            FaceGroupList.AddRange(PhotoFaceData.popup_category_list.Select(kvp => kvp.Key));
+
+            foreach (KeyValuePair<string, List<PhotoFaceData>> kvp in PhotoFaceData.category_list)
             {
-                for (int cell_y = 1; cell_y < csvParser.max_cell_y; cell_y++)
+                FaceDict[kvp.Key] = kvp.Value.Select(data => data.setting_name).ToList();
+            }
+
+            void GetFacePresets(string directory)
+            {
+                List<string> presetList = Directory.GetFiles(directory)
+                    .Where(file => Path.GetExtension(file) == ".xml").ToList();
+
+                if (presetList.Count > 0)
                 {
-                    if (csvParser.IsCellToExistData(3, cell_y))
-                    {
-                        string blendValue = csvParser.GetCellAsString(3, cell_y);
-                        FaceBlendList.Add(blendValue);
-                    }
+                    string faceGroupName = new DirectoryInfo(directory).Name;
+                    if (faceGroupName != customFaceDirectory) CustomFaceGroupList.Add(faceGroupName);
+                    CustomFaceDict[faceGroupName] = presetList;
                 }
             }
+
+            CustomFaceGroupList.Add(customFaceDirectory);
+            CustomFaceDict[customFaceDirectory] = new List<string>();
+
+            GetFacePresets(customFacePath);
+
+            foreach (string directory in Directory.GetDirectories(customFacePath))
+            {
+                GetFacePresets(directory);
+            }
         }
 
         public static void InitializeBGs()
@@ -439,7 +529,7 @@ namespace COM3D2.MeidoPhotoStudio.Plugin
 
             string ignoreListPath = Path.Combine(configPath, "Database\\bg_ignore_list.json");
             string ignoreListJson = File.ReadAllText(ignoreListPath);
-            string[] ignoreList = JsonConvert.DeserializeObject<IEnumerable<string>>(ignoreListJson).ToArray();
+            string[] ignoreList = JsonConvert.DeserializeObject<string[]>(ignoreListJson);
 
             // bg object extend
             HashSet<string> doguHashSet = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase);
@@ -497,7 +587,7 @@ namespace COM3D2.MeidoPhotoStudio.Plugin
 
             List<string> com3d2DeskDogu = DoguDict[customDoguCategories[DoguCategory.Desk]];
 
-            Action<AFileSystemBase> GetDeskItems = fs =>
+            void GetDeskItems(AFileSystemBase fs)
             {
                 using (CsvParser csvParser = OpenCsvParser("desk_item_detail.nei", fs))
                 {
@@ -526,7 +616,7 @@ namespace COM3D2.MeidoPhotoStudio.Plugin
                         }
                     }
                 }
-            };
+            }
 
             GetDeskItems(GameUty.FileSystem);
         }

+ 99 - 29
COM3D2.MeidoPhotoStudio.Plugin/MeidoPhotoStudio/GUI/Panes/FaceWindowPanes/MaidFaceBlendPane.cs

@@ -1,42 +1,94 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
 using UnityEngine;
 
 namespace COM3D2.MeidoPhotoStudio.Plugin
 {
     internal class MaidFaceBlendPane : BasePane
     {
-        private MeidoManager meidoManager;
-        private Dropdown faceBlendDropdown;
-        private Button facePrevButton;
-        private Button faceNextButton;
+        private readonly MeidoManager meidoManager;
+        private readonly SelectionGrid faceBlendSourceGrid;
+        private readonly Dropdown faceBlendCategoryDropdown;
+        private readonly Button prevCategoryButton;
+        private readonly Button nextCategoryButton;
+        private readonly Dropdown faceBlendDropdown;
+        private readonly Button facePrevButton;
+        private readonly Button faceNextButton;
+        private static readonly string[] tabTranslations = { "baseTab", "customTab" };
+        private bool facePresetMode = false;
+        private bool faceListEnabled = false;
+        private Dictionary<string, List<string>> CurrentFaceDict
+        {
+            get => facePresetMode ? Constants.CustomFaceDict : Constants.FaceDict;
+        }
+        private List<string> CurrentFaceGroupList
+        {
+            get => facePresetMode ? Constants.CustomFaceGroupList : Constants.FaceGroupList;
+        }
+        private string SelectedFaceGroup => CurrentFaceGroupList[faceBlendCategoryDropdown.SelectedItemIndex];
+        private List<string> CurrentFaceList => CurrentFaceDict[SelectedFaceGroup];
+        private int SelectedFaceIndex => faceBlendDropdown.SelectedItemIndex;
+        private string SelectedFace => CurrentFaceList[SelectedFaceIndex];
 
         public MaidFaceBlendPane(MeidoManager meidoManager)
         {
+            Constants.CustomFaceChange += SaveFaceEnd;
             this.meidoManager = meidoManager;
 
-            this.faceBlendDropdown = new Dropdown(
-                Translation.GetArray("faceBlendPresetsDropdown", Constants.FaceBlendList)
+            faceBlendSourceGrid = new SelectionGrid(Translation.GetArray("maidFaceWindow", tabTranslations));
+            faceBlendSourceGrid.ControlEvent += (s, a) =>
+            {
+                facePresetMode = faceBlendSourceGrid.SelectedItemIndex == 1;
+                if (updating) return;
+                string[] list = facePresetMode
+                    ? CurrentFaceGroupList.ToArray()
+                    : Translation.GetArray("faceBlendCategory", Constants.FaceGroupList);
+                faceBlendCategoryDropdown.SetDropdownItems(list, 0);
+            };
+
+            faceBlendCategoryDropdown = new Dropdown(
+                Translation.GetArray("faceBlendCategory", Constants.FaceGroupList)
             );
-            this.faceBlendDropdown.SelectionChange += (s, a) =>
+            faceBlendCategoryDropdown.SelectionChange += (s, a) =>
+            {
+                faceBlendDropdown.SetDropdownItems(UIFaceList(), 0);
+                faceListEnabled = CurrentFaceList.Count > 0;
+            };
+
+            prevCategoryButton = new Button("<");
+            prevCategoryButton.ControlEvent += (s, a) => faceBlendCategoryDropdown.Step(-1);
+
+            nextCategoryButton = new Button(">");
+            nextCategoryButton.ControlEvent += (s, a) => faceBlendCategoryDropdown.Step(1);
+
+            faceBlendDropdown = new Dropdown(UIFaceList());
+            faceBlendDropdown.SelectionChange += (s, a) =>
             {
                 if (updating) return;
-                string faceBlend = Constants.FaceBlendList[this.faceBlendDropdown.SelectedItemIndex];
-                this.meidoManager.ActiveMeido.SetFaceBlendSet(faceBlend);
+                this.meidoManager.ActiveMeido.SetFaceBlendSet(SelectedFace, facePresetMode);
             };
 
-            this.facePrevButton = new Button("<");
-            this.facePrevButton.ControlEvent += (s, a) => this.faceBlendDropdown.Step(-1);
+            facePrevButton = new Button("<");
+            facePrevButton.ControlEvent += (s, a) => faceBlendDropdown.Step(-1);
 
-            this.faceNextButton = new Button(">");
-            this.faceNextButton.ControlEvent += (s, a) => this.faceBlendDropdown.Step(1);
+            faceNextButton = new Button(">");
+            faceNextButton.ControlEvent += (s, a) => faceBlendDropdown.Step(1);
+
+            faceListEnabled = CurrentFaceList.Count > 0;
         }
 
         protected override void ReloadTranslation()
         {
-            this.updating = true;
-            faceBlendDropdown.SetDropdownItems(
-                Translation.GetArray("faceBlendPresetsDropdown", Constants.FaceBlendList)
-            );
-            this.updating = false;
+            updating = true;
+            faceBlendSourceGrid.SetItems(Translation.GetArray("maidFaceWindow", tabTranslations));
+            if (!facePresetMode)
+            {
+                faceBlendCategoryDropdown.SetDropdownItems(
+                    Translation.GetArray("faceBlendCategory", Constants.FaceGroupList)
+                );
+            }
+            updating = false;
         }
 
         public override void Draw()
@@ -54,24 +106,42 @@ namespace COM3D2.MeidoPhotoStudio.Plugin
                 GUILayout.Width(dropdownButtonWidth)
             };
 
-            GUI.enabled = this.meidoManager.HasActiveMeido;
+            GUI.enabled = meidoManager.HasActiveMeido;
+
+            faceBlendSourceGrid.Draw();
+
+            MiscGUI.WhiteLine();
 
             GUILayout.BeginHorizontal();
-            this.facePrevButton.Draw(arrowLayoutOptions);
-            this.faceBlendDropdown.Draw(dropdownLayoutOptions);
-            this.faceNextButton.Draw(arrowLayoutOptions);
+            prevCategoryButton.Draw(arrowLayoutOptions);
+            faceBlendCategoryDropdown.Draw(dropdownLayoutOptions);
+            nextCategoryButton.Draw(arrowLayoutOptions);
+            GUILayout.EndHorizontal();
+
+            GUILayout.BeginHorizontal();
+            GUI.enabled = faceListEnabled;
+            facePrevButton.Draw(arrowLayoutOptions);
+            faceBlendDropdown.Draw(dropdownLayoutOptions);
+            faceNextButton.Draw(arrowLayoutOptions);
             GUILayout.EndHorizontal();
             GUI.enabled = true;
         }
 
-        public override void UpdatePane()
+        private string[] UIFaceList()
         {
-            this.updating = true;
-            int faceBlendSetIndex = Constants.FaceBlendList.FindIndex(
-                blend => blend == this.meidoManager.ActiveMeido.CurrentFaceBlendSet
-            );
-            this.faceBlendDropdown.SelectedItemIndex = Mathf.Clamp(faceBlendSetIndex, 0, Constants.FaceBlendList.Count);
-            this.updating = false;
+            if (CurrentFaceList.Count == 0) return new[] { "No Face Presets" };
+            else
+            {
+                return CurrentFaceList.Select(face => facePresetMode
+                    ? Path.GetFileNameWithoutExtension(face) : Translation.Get("faceBlendPresetsDropdown", face)
+                ).ToArray();
+            }
+        }
+
+        private void SaveFaceEnd(object sender, CustomPoseEventArgs args)
+        {
+            faceBlendSourceGrid.SelectedItemIndex = 1;
+            faceBlendCategoryDropdown.SelectedItemIndex = Constants.CustomHandGroupList.IndexOf(args.Category);
         }
     }
 }

+ 65 - 0
COM3D2.MeidoPhotoStudio.Plugin/MeidoPhotoStudio/GUI/Panes/FaceWindowPanes/SaveFacePane.cs

@@ -0,0 +1,65 @@
+using UnityEngine;
+
+namespace COM3D2.MeidoPhotoStudio.Plugin
+{
+    internal class SaveFacePane : BasePane
+    {
+        private readonly MeidoManager meidoManager;
+        private readonly ComboBox categoryComboBox;
+        private readonly TextField faceNameTextField;
+        private readonly Button saveFaceButton;
+        private string categoryHeader;
+        private string nameHeader;
+
+        public SaveFacePane(MeidoManager meidoManager)
+        {
+            Constants.CustomFaceChange += (s, a) =>
+            {
+                categoryComboBox.SetDropdownItems(Constants.CustomFaceGroupList.ToArray());
+            };
+
+            this.meidoManager = meidoManager;
+
+            categoryHeader = Translation.Get("faceSave", "categoryHeader");
+            nameHeader = Translation.Get("faceSave", "nameHeader");
+
+            saveFaceButton = new Button(Translation.Get("faceSave", "saveButton"));
+            saveFaceButton.ControlEvent += (s, a) => SaveFace();
+
+            categoryComboBox = new ComboBox(Constants.CustomFaceGroupList.ToArray());
+            faceNameTextField = new TextField();
+        }
+
+        protected override void ReloadTranslation()
+        {
+            categoryHeader = Translation.Get("faceSave", "saveButton");
+            nameHeader = Translation.Get("faceSave", "nameHeader");
+            saveFaceButton.Label = Translation.Get("faceSave", "saveButton");
+        }
+
+        public override void Draw()
+        {
+            GUI.enabled = meidoManager.HasActiveMeido;
+
+            MiscGUI.Header(categoryHeader);
+            categoryComboBox.Draw(GUILayout.Width(165f));
+
+            MiscGUI.Header(nameHeader);
+            GUILayout.BeginHorizontal();
+            faceNameTextField.Draw(GUILayout.Width(160f));
+            saveFaceButton.Draw(GUILayout.ExpandWidth(false));
+            GUILayout.EndHorizontal();
+
+            GUI.enabled = true;
+        }
+
+        private void SaveFace()
+        {
+            if (!meidoManager.HasActiveMeido) return;
+
+            Meido meido = meidoManager.ActiveMeido;
+            Constants.AddFacePreset(meido.SerializeFace(), faceNameTextField.Value, categoryComboBox.Value);
+            faceNameTextField.Value = string.Empty;
+        }
+    }
+}

+ 23 - 5
COM3D2.MeidoPhotoStudio.Plugin/MeidoPhotoStudio/GUI/Panes/MainWindowPanes/FaceWindowPane.cs

@@ -8,6 +8,9 @@ namespace COM3D2.MeidoPhotoStudio.Plugin
         private MaidFaceSliderPane maidFaceSliderPane;
         private MaidFaceBlendPane maidFaceBlendPane;
         private MaidSwitcherPane maidSwitcherPane;
+        private readonly SaveFacePane saveFacePane;
+        private readonly Toggle saveFaceToggle;
+        private bool saveFaceMode = false;
 
         public FaceWindowPane(MeidoManager meidoManager, MaidSwitcherPane maidSwitcherPane)
         {
@@ -17,18 +20,33 @@ namespace COM3D2.MeidoPhotoStudio.Plugin
 
             this.maidFaceSliderPane = AddPane(new MaidFaceSliderPane(this.meidoManager));
             this.maidFaceBlendPane = AddPane(new MaidFaceBlendPane(this.meidoManager));
+            saveFacePane = AddPane(new SaveFacePane(this.meidoManager));
+
+            saveFaceToggle = new Toggle(Translation.Get("maidFaceWindow", "savePaneToggle"));
+            saveFaceToggle.ControlEvent += (s, a) => saveFaceMode = !saveFaceMode;
+        }
+
+        protected override void ReloadTranslation()
+        {
+            saveFaceToggle.Label = Translation.Get("maidFaceWindow", "savePaneToggle");
         }
 
         public override void Draw()
         {
-            this.tabsPane.Draw();
-            this.maidSwitcherPane.Draw();
+            tabsPane.Draw();
+            maidSwitcherPane.Draw();
+
+            maidFaceBlendPane.Draw();
+
+            scrollPos = GUILayout.BeginScrollView(scrollPos);
 
-            this.scrollPos = GUILayout.BeginScrollView(this.scrollPos);
+            maidFaceSliderPane.Draw();
 
-            this.maidFaceBlendPane.Draw();
+            GUI.enabled = meidoManager.HasActiveMeido;
+            saveFaceToggle.Draw();
+            GUI.enabled = true;
 
-            this.maidFaceSliderPane.Draw();
+            if (saveFaceMode) saveFacePane.Draw();
 
             GUILayout.EndScrollView();
         }