From 08344d5510dbe3c0f7dc62794893138f94fb76e1 Mon Sep 17 00:00:00 2001 From: David Back Date: Fri, 11 Jan 2019 18:39:29 -0800 Subject: [PATCH] add bone rule errors/warnings, add versioning --- .../Assets/Editor/AvatarExporter.cs | 758 +++++++++++++----- tools/unity-avatar-exporter/Assets/README.txt | 4 + .../avatarExporter.unitypackage | Bin 8794 -> 12627 bytes 3 files changed, 565 insertions(+), 197 deletions(-) diff --git a/tools/unity-avatar-exporter/Assets/Editor/AvatarExporter.cs b/tools/unity-avatar-exporter/Assets/Editor/AvatarExporter.cs index b6470a7551..81834c860e 100644 --- a/tools/unity-avatar-exporter/Assets/Editor/AvatarExporter.cs +++ b/tools/unity-avatar-exporter/Assets/Editor/AvatarExporter.cs @@ -12,7 +12,14 @@ using System; using System.IO; using System.Collections.Generic; -class AvatarExporter : MonoBehaviour { +class AvatarExporter : MonoBehaviour { + // update version number for every PR that changes this file, also set updated version in README file + static readonly string AVATAR_EXPORTER_VERSION = "0.1"; + + static readonly float HIPS_GROUND_MIN_Y = 0.01f; + static readonly float HIPS_SPINE_CHEST_MIN_SEPARATION = 0.001f; + static readonly string EMPTY_WARNING_TEXT = "None"; + static readonly Dictionary HUMANOID_TO_HIFI_JOINT_NAME = new Dictionary { {"Chest", "Spine1"}, {"Head", "Head"}, @@ -70,70 +77,161 @@ class AvatarExporter : MonoBehaviour { {"UpperChest", "Spine2"}, }; - static readonly Dictionary referenceAbsoluteRotations = new Dictionary { + // absolute reference rotations for each Humanoid bone using Artemis fbx in Unity 2018.2.12f1 + static readonly Dictionary REFERENCE_ROTATIONS = new Dictionary { + {"Chest", new Quaternion(-0.0824653f, 1.25274e-7f, -6.75759e-6f, 0.996594f)}, {"Head", new Quaternion(-2.509889e-9f, -3.379446e-12f, 2.306033e-13f, 1f)}, {"Hips", new Quaternion(-3.043941e-10f, -1.573706e-7f, 5.112975e-6f, 1f)}, - {"LeftHandIndex3", new Quaternion(-0.5086057f, 0.4908088f, -0.4912299f, -0.5090388f)}, - {"LeftHandIndex2", new Quaternion(-0.4934928f, 0.5062312f, -0.5064303f, -0.4936835f)}, - {"LeftHandIndex1", new Quaternion(-0.4986293f, 0.5017503f, -0.5013659f, -0.4982448f)}, - {"LeftHandPinky3", new Quaternion(-0.490056f, 0.5143053f, -0.5095307f, -0.4855038f)}, - {"LeftHandPinky2", new Quaternion(-0.5083722f, 0.4954255f, -0.4915887f, -0.5044324f)}, - {"LeftHandPinky1", new Quaternion(-0.5062528f, 0.497324f, -0.4937346f, -0.5025966f)}, - {"LeftHandMiddle3", new Quaternion(-0.4871885f, 0.5123404f, -0.5125002f, -0.4873383f)}, - {"LeftHandMiddle2", new Quaternion(-0.5171652f, 0.4827828f, -0.4822642f, -0.5166069f)}, - {"LeftHandMiddle1", new Quaternion(-0.4955998f, 0.5041052f, -0.5043675f, -0.4958555f)}, - {"LeftHandRing3", new Quaternion(-0.4936301f, 0.5097645f, -0.5061787f, -0.4901562f)}, - {"LeftHandRing2", new Quaternion(-0.5089865f, 0.4943658f, -0.4909532f, -0.5054707f)}, - {"LeftHandRing1", new Quaternion(-0.5020972f, 0.5005084f, -0.4979034f, -0.4994819f)}, - {"LeftHandThumb3", new Quaternion(-0.6617184f, 0.2884935f, -0.3604706f, -0.5907297f)}, - {"LeftHandThumb2", new Quaternion(-0.6935627f, 0.1995147f, -0.2805665f, -0.6328092f)}, - {"LeftHandThumb1", new Quaternion(-0.6663674f, 0.278572f, -0.3507071f, -0.5961183f)}, + {"Left Index Distal", new Quaternion(-0.5086057f, 0.4908088f, -0.4912299f, -0.5090388f)}, + {"Left Index Intermediate", new Quaternion(-0.4934928f, 0.5062312f, -0.5064303f, -0.4936835f)}, + {"Left Index Proximal", new Quaternion(-0.4986293f, 0.5017503f, -0.5013659f, -0.4982448f)}, + {"Left Little Distal", new Quaternion(-0.490056f, 0.5143053f, -0.5095307f, -0.4855038f)}, + {"Left Little Intermediate", new Quaternion(-0.5083722f, 0.4954255f, -0.4915887f, -0.5044324f)}, + {"Left Little Proximal", new Quaternion(-0.5062528f, 0.497324f, -0.4937346f, -0.5025966f)}, + {"Left Middle Distal", new Quaternion(-0.4871885f, 0.5123404f, -0.5125002f, -0.4873383f)}, + {"Left Middle Intermediate", new Quaternion(-0.5171652f, 0.4827828f, -0.4822642f, -0.5166069f)}, + {"Left Middle Proximal", new Quaternion(-0.4955998f, 0.5041052f, -0.5043675f, -0.4958555f)}, + {"Left Ring Distal", new Quaternion(-0.4936301f, 0.5097645f, -0.5061787f, -0.4901562f)}, + {"Left Ring Intermediate", new Quaternion(-0.5089865f, 0.4943658f, -0.4909532f, -0.5054707f)}, + {"Left Ring Proximal", new Quaternion(-0.5020972f, 0.5005084f, -0.4979034f, -0.4994819f)}, + {"Left Thumb Distal", new Quaternion(-0.6617184f, 0.2884935f, -0.3604706f, -0.5907297f)}, + {"Left Thumb Intermediate", new Quaternion(-0.6935627f, 0.1995147f, -0.2805665f, -0.6328092f)}, + {"Left Thumb Proximal", new Quaternion(-0.6663674f, 0.278572f, -0.3507071f, -0.5961183f)}, {"LeftEye", new Quaternion(-2.509889e-9f, -3.379446e-12f, 2.306033e-13f, 1f)}, {"LeftFoot", new Quaternion(0.009215056f, 0.3612514f, 0.9323555f, -0.01121602f)}, {"LeftHand", new Quaternion(-0.4797408f, 0.5195366f, -0.5279632f, -0.4703038f)}, - {"LeftForeArm", new Quaternion(-0.4594738f, 0.4594729f, -0.5374805f, -0.5374788f)}, - {"LeftLeg", new Quaternion(-0.0005380471f, -0.03154583f, 0.9994993f, 0.002378627f)}, + {"LeftLowerArm", new Quaternion(-0.4594738f, 0.4594729f, -0.5374805f, -0.5374788f)}, + {"LeftLowerLeg", new Quaternion(-0.0005380471f, -0.03154583f, 0.9994993f, 0.002378627f)}, {"LeftShoulder", new Quaternion(-0.3840606f, 0.525857f, -0.5957767f, -0.47013f)}, - {"LeftToeBase", new Quaternion(-0.0002536641f, 0.7113448f, 0.7027079f, -0.01379319f)}, - {"LeftArm", new Quaternion(-0.4591927f, 0.4591916f, -0.5377204f, -0.5377193f)}, - {"LeftUpLeg", new Quaternion(-0.0006682819f, 0.0006864658f, 0.9999968f, -0.002333928f)}, + {"LeftToes", new Quaternion(-0.0002536641f, 0.7113448f, 0.7027079f, -0.01379319f)}, + {"LeftUpperArm", new Quaternion(-0.4591927f, 0.4591916f, -0.5377204f, -0.5377193f)}, + {"LeftUpperLeg", new Quaternion(-0.0006682819f, 0.0006864658f, 0.9999968f, -0.002333928f)}, {"Neck", new Quaternion(-2.509889e-9f, -3.379446e-12f, 2.306033e-13f, 1f)}, - {"RightHandIndex3", new Quaternion(0.5083892f, 0.4911618f, -0.4914584f, 0.5086939f)}, - {"RightHandIndex2", new Quaternion(0.4931984f, 0.5065879f, -0.5067145f, 0.4933202f)}, - {"RightHandIndex1", new Quaternion(0.4991491f, 0.5012957f, -0.5008481f, 0.4987026f)}, - {"RightHandPinky3", new Quaternion(0.4890696f, 0.5154139f, -0.5104482f, 0.4843578f)}, - {"RightHandPinky2", new Quaternion(0.5084175f, 0.495413f, -0.4915423f, 0.5044444f)}, - {"RightHandPinky1", new Quaternion(0.5069782f, 0.4965974f, -0.4930001f, 0.5033045f)}, - {"RightHandMiddle3", new Quaternion(0.4867662f, 0.5129694f, -0.5128888f, 0.4866894f)}, - {"RightHandMiddle2", new Quaternion(0.5167004f, 0.4833596f, -0.4827653f, 0.5160643f)}, - {"RightHandMiddle1", new Quaternion(0.4965845f, 0.5031784f, -0.5033959f, 0.4967981f)}, - {"RightHandRing3", new Quaternion(0.4933217f, 0.5102056f, -0.5064691f, 0.4897075f)}, - {"RightHandRing2", new Quaternion(0.5085972f, 0.494844f, -0.4913519f, 0.505007f)}, - {"RightHandRing1", new Quaternion(0.502959f, 0.4996676f, -0.4970418f, 0.5003144f)}, - {"RightHandThumb3", new Quaternion(0.6611374f, 0.2896575f, -0.3616535f, 0.5900872f)}, - {"RightHandThumb2", new Quaternion(0.6937408f, 0.1986776f, -0.279922f, 0.6331626f)}, - {"RightHandThumb1", new Quaternion(0.6664271f, 0.2783172f, -0.3505667f, 0.596253f)}, + {"Right Index Distal", new Quaternion(0.5083892f, 0.4911618f, -0.4914584f, 0.5086939f)}, + {"Right Index Intermediate", new Quaternion(0.4931984f, 0.5065879f, -0.5067145f, 0.4933202f)}, + {"Right Index Proximal", new Quaternion(0.4991491f, 0.5012957f, -0.5008481f, 0.4987026f)}, + {"Right Little Distal", new Quaternion(0.4890696f, 0.5154139f, -0.5104482f, 0.4843578f)}, + {"Right Little Intermediate", new Quaternion(0.5084175f, 0.495413f, -0.4915423f, 0.5044444f)}, + {"Right Little Proximal", new Quaternion(0.5069782f, 0.4965974f, -0.4930001f, 0.5033045f)}, + {"Right Middle Distal", new Quaternion(0.4867662f, 0.5129694f, -0.5128888f, 0.4866894f)}, + {"Right Middle Intermediate", new Quaternion(0.5167004f, 0.4833596f, -0.4827653f, 0.5160643f)}, + {"Right Middle Proximal", new Quaternion(0.4965845f, 0.5031784f, -0.5033959f, 0.4967981f)}, + {"Right Ring Distal", new Quaternion(0.4933217f, 0.5102056f, -0.5064691f, 0.4897075f)}, + {"Right Ring Intermediate", new Quaternion(0.5085972f, 0.494844f, -0.4913519f, 0.505007f)}, + {"Right Ring Proximal", new Quaternion(0.502959f, 0.4996676f, -0.4970418f, 0.5003144f)}, + {"Right Thumb Distal", new Quaternion(0.6611374f, 0.2896575f, -0.3616535f, 0.5900872f)}, + {"Right Thumb Intermediate", new Quaternion(0.6937408f, 0.1986776f, -0.279922f, 0.6331626f)}, + {"Right Thumb Proximal", new Quaternion(0.6664271f, 0.2783172f, -0.3505667f, 0.596253f)}, {"RightEye", new Quaternion(-2.509889e-9f, -3.379446e-12f, 2.306033e-13f, 1f)}, {"RightFoot", new Quaternion(-0.009482829f, 0.3612484f, 0.9323512f, 0.01144584f)}, {"RightHand", new Quaternion(0.4797273f, 0.5195542f, -0.5279628f, 0.4702987f)}, - {"RightForeArm", new Quaternion(0.4594217f, 0.4594215f, -0.5375242f, 0.5375237f)}, - {"RightLeg", new Quaternion(0.0005446263f, -0.03177159f, 0.9994922f, -0.002395923f)}, + {"RightLowerArm", new Quaternion(0.4594217f, 0.4594215f, -0.5375242f, 0.5375237f)}, + {"RightLowerLeg", new Quaternion(0.0005446263f, -0.03177159f, 0.9994922f, -0.002395923f)}, {"RightShoulder", new Quaternion(0.3841222f, 0.5257177f, -0.5957286f, 0.4702966f)}, - {"RightToeBase", new Quaternion(0.0001034f, 0.7113398f, 0.7027067f, 0.01411251f)}, - {"RightArm", new Quaternion(0.4591419f, 0.4591423f, -0.537763f, 0.5377624f)}, - {"RightUpLeg", new Quaternion(0.0006750703f, 0.0008973633f, 0.9999966f, 0.002352045f)}, + {"RightToes", new Quaternion(0.0001034f, 0.7113398f, 0.7027067f, 0.01411251f)}, + {"RightUpperArm", new Quaternion(0.4591419f, 0.4591423f, -0.537763f, 0.5377624f)}, + {"RightUpperLeg", new Quaternion(0.0006750703f, 0.0008973633f, 0.9999966f, 0.002352045f)}, {"Spine", new Quaternion(-0.05427956f, 1.508558e-7f, -2.775203e-6f, 0.9985258f)}, - {"Spine1", new Quaternion(-0.0824653f, 1.25274e-7f, -6.75759e-6f, 0.996594f)}, - {"Spine2", new Quaternion(-0.0824653f, 1.25274e-7f, -6.75759e-6f, 0.996594f)}, + {"UpperChest", new Quaternion(-0.0824653f, 1.25274e-7f, -6.75759e-6f, 0.996594f)}, + }; + + // Humanoid mapping name suffixes for each set of appendages + static readonly string[] legMappingSuffixes = new string[] { + "UpperLeg", + "LowerLeg", + "Foot", + "Toes", + }; + static readonly string[] armMappingsSuffixes = new string[] { + "Shoulder", + "UpperArm", + "LowerArm", + "Hand", + }; + static readonly string[] handMappingsSuffixes = new string[] { + " Index Distal", + " Index Intermediate", + " Index Proximal", + " Little Distal", + " Little Intermediate", + " Little Proximal", + " Middle Distal", + " Middle Intermediate", + " Middle Proximal", + " Ring Distal", + " Ring Intermediate", + " Ring Proximal", + " Thumb Distal", + " Thumb Intermediate", + " Thumb Proximal", }; - static Dictionary userBoneToHumanoidMappings = new Dictionary(); - static Dictionary userParentNames = new Dictionary(); - static Dictionary userAbsoluteRotations = new Dictionary(); + enum BoneRule { + SingleRoot, + NoDuplicateMapping, + NoAsymmetricalLegMapping, + NoAsymmetricalArmMapping, + NoAsymmetricalHandMapping, + HipsMapped, + SpineMapped, + SpineDescendantOfHips, + ChestMapped, + ChestDescendantOfSpine, + NeckMapped, + HeadMapped, + HeadDescendantOfChest, + EyesMapped, + HipsNotOnGround, + HipsSpineChestNotCoincident, + BoneRuleEnd, + }; + // rules that are treated as errors and prevent exporting, otherwise rules will show as warnings + static readonly BoneRule[] exportBlockingBoneRules = new BoneRule[] { + BoneRule.HipsMapped, + BoneRule.SpineMapped, + BoneRule.ChestMapped, + BoneRule.HeadMapped, + }; + + class UserBoneInformation { + public string humanName; // bone name in Humanoid if it is mapped, otherwise "" + public string parentName; // parent user bone name + public int mappingCount; // number of times this bone is mapped in Humanoid + public Vector3 position; // absolute position + public Quaternion rotation; // absolute rotation + public BoneTreeNode boneTreeNode; + + public UserBoneInformation() { + humanName = ""; + parentName = ""; + mappingCount = 0; + position = new Vector3(); + rotation = new Quaternion(); + boneTreeNode = new BoneTreeNode(); + } + + public bool HasHumanMapping() { return !string.IsNullOrEmpty(humanName); } + } + + class BoneTreeNode { + public string boneName; + public List children = new List(); + + public BoneTreeNode() {} + public BoneTreeNode(string name) { + boneName = name; + } + } + + static Dictionary userBoneInfos = new Dictionary(); + static Dictionary humanoidToUserBoneMappings = new Dictionary(); + static BoneTreeNode userBoneTree = new BoneTreeNode(); + static Dictionary failedBoneRules = new Dictionary(); static string assetPath = ""; static string assetName = ""; static HumanDescription humanDescription; + [MenuItem("High Fidelity/Export New Avatar")] static void ExportNewAvatar() { @@ -144,6 +242,11 @@ class AvatarExporter : MonoBehaviour { static void UpdateAvatar() { ExportSelectedAvatar(true); } + + [MenuItem("High Fidelity/About")] + static void About() { + EditorUtility.DisplayDialog("About", "High Fidelity, Inc.\nAvatar Exporter\nVersion " + AVATAR_EXPORTER_VERSION, "Ok"); + } static void ExportSelectedAvatar(bool updateAvatar) { string[] guids = Selection.assetGUIDs; @@ -163,14 +266,58 @@ class AvatarExporter : MonoBehaviour { return; } if (modelImporter.animationType != ModelImporterAnimationType.Human) { - EditorUtility.DisplayDialog("Error", "Please set model's Animation Type to Humanoid in the Rig section of it's Inspector window.", "Ok"); + EditorUtility.DisplayDialog("Error", "Please set model's Animation Type to Humanoid in " + + " the Rig section of it's Inspector window.", "Ok"); return; } - - humanDescription = modelImporter.humanDescription; - if (!SetJointMappingsAndParentNames()) { + + humanDescription = modelImporter.humanDescription; + SetUserBoneInformation(); + + // format resulting bone rule failure strings + // consider export-blocking bone rules to be errors and show them in an error dialog, + // and also include any other bone rule failures as warnings in the dialog + string boneErrors = ""; + string boneWarnings = ""; + foreach (var failedBoneRule in failedBoneRules) { + if (Array.IndexOf(exportBlockingBoneRules, failedBoneRule.Key) >= 0) { + boneErrors += failedBoneRule.Value + "\n\n"; + } else { + boneWarnings += failedBoneRule.Value + "\n\n"; + } + } + if (!string.IsNullOrEmpty(boneErrors)) { + // if there are both errors and warnings then warnings will be displayed with errors in the error dialog + if (!string.IsNullOrEmpty(boneWarnings)) { + boneErrors = "Errors:\n\n" + boneErrors; + boneErrors += "Warnings:\n\n" + boneWarnings; + } + // remove ending newlines from the last rule failure string that was added above + boneErrors = boneErrors.Substring(0, boneErrors.LastIndexOf("\n\n")); + EditorUtility.DisplayDialog("Error", boneErrors, "Ok"); return; } + + if (!humanoidToUserBoneMappings.ContainsKey("UpperChest")) { + // if parent of Neck is not Chest then map the parent to UpperChest + string neckUserBone; + if (humanoidToUserBoneMappings.TryGetValue("Neck", out neckUserBone)) { + UserBoneInformation neckParentBoneInfo; + string neckParentUserBone = userBoneInfos[neckUserBone].parentName; + if (userBoneInfos.TryGetValue(neckParentUserBone, out neckParentBoneInfo) && !neckParentBoneInfo.HasHumanMapping()) { + neckParentBoneInfo.humanName = "UpperChest"; + humanoidToUserBoneMappings.Add("UpperChest", neckParentUserBone); + } + } + // if there is still no UpperChest bone but there is a Chest bone then we remap Chest to UpperChest + string chestUserBone; + if (!humanoidToUserBoneMappings.ContainsKey("UpperChest") && + humanoidToUserBoneMappings.TryGetValue("Chest", out chestUserBone)) { + userBoneInfos[chestUserBone].humanName = "UpperChest"; + humanoidToUserBoneMappings.Remove("Chest"); + humanoidToUserBoneMappings.Add("UpperChest", chestUserBone); + } + } string documentsFolder = System.Environment.GetFolderPath(System.Environment.SpecialFolder.MyDocuments); string hifiFolder = documentsFolder + "\\High Fidelity Projects"; @@ -236,11 +383,12 @@ class AvatarExporter : MonoBehaviour { modelImporter = ModelImporter.GetAtPath(assetPath) as ModelImporter; modelImporter.animationType = ModelImporterAnimationType.Human; EditorUtility.SetDirty(modelImporter); - modelImporter.SaveAndReimport(); - humanDescription = modelImporter.humanDescription; + modelImporter.SaveAndReimport(); - // redo joint mappings and parent names due to the fbx change - SetJointMappingsAndParentNames(); + // redo parent names, joint mappings, and user bone positions due to the fbx change + // as well as re-check the bone rules for failures + humanDescription = modelImporter.humanDescription; + SetUserBoneInformation(); } } } else { @@ -277,19 +425,30 @@ class AvatarExporter : MonoBehaviour { // write out a new fst file in place of the old file WriteFST(exportFstPath, projectName); + + // display success dialog with any bone rule warnings + string successDialog = "Avatar successfully updated!"; + if (!string.IsNullOrEmpty(boneWarnings)) { + successDialog += "\n\nWarnings:\n" + boneWarnings; + } + EditorUtility.DisplayDialog("Success!", successDialog, "Ok"); } else { // Export New Avatar menu option // create High Fidelity Projects folder in user documents folder if it doesn't exist if (!Directory.Exists(hifiFolder)) { Directory.CreateDirectory(hifiFolder); } + if (string.IsNullOrEmpty(boneWarnings)) { + boneWarnings = EMPTY_WARNING_TEXT; + } + // open a popup window to enter new export project name and project location ExportProjectWindow window = ScriptableObject.CreateInstance(); - window.Init(hifiFolder, OnExportProjectWindowClose); + window.Init(hifiFolder, boneWarnings, OnExportProjectWindowClose); } } - static void OnExportProjectWindowClose(string projectDirectory, string projectName) { + static void OnExportProjectWindowClose(string projectDirectory, string projectName, string warnings) { // copy the fbx from the Unity Assets folder to the project directory string exportModelPath = projectDirectory + assetName + ".fbx"; File.Copy(assetPath, exportModelPath); @@ -304,94 +463,19 @@ class AvatarExporter : MonoBehaviour { string exportFstPath = projectDirectory + "avatar.fst"; WriteFST(exportFstPath, projectName); - // remove any double slashes in texture directory path and warn user to copy external textures over + // remove any double slashes in texture directory path, display success dialog with any + // bone warnings previously mentioned, and suggest user to copy external textures over texturesDirectory = texturesDirectory.Replace("\\\\", "\\"); - EditorUtility.DisplayDialog("Warning", "If you are using any external textures with your model, " + - "please copy those textures to " + texturesDirectory, "Ok"); + string successDialog = "Avatar successfully exported!\n\n"; + if (warnings != EMPTY_WARNING_TEXT) { + successDialog += "Warnings:\n" + warnings; + } + successDialog += "Note: If you are using any external textures with your model, " + + "please copy those textures to " + texturesDirectory; + EditorUtility.DisplayDialog("Success!", successDialog, "Ok"); } - static bool SetJointMappingsAndParentNames() { - userParentNames.Clear(); - userBoneToHumanoidMappings.Clear(); - - // instantiate a game object of the user avatar to save out bone parents then destroy it - UnityEngine.Object avatarResource = AssetDatabase.LoadAssetAtPath(assetPath, typeof(UnityEngine.Object)); - GameObject assetGameObject = (GameObject)Instantiate(avatarResource); - SetParentNames(assetGameObject.transform, userParentNames); - DestroyImmediate(assetGameObject); - - // store joint mappings only for joints that exist in hifi and verify missing required joints - HumanBone[] boneMap = humanDescription.human; - string chestUserBone = ""; - string neckUserBone = ""; - foreach (HumanBone bone in boneMap) { - string humanName = bone.humanName; - string boneName = bone.boneName; - string hifiJointName; - if (HUMANOID_TO_HIFI_JOINT_NAME.TryGetValue(humanName, out hifiJointName)) { - userBoneToHumanoidMappings.Add(boneName, humanName); - if (humanName == "Chest") { - chestUserBone = boneName; - } else if (humanName == "Neck") { - neckUserBone = boneName; - } - } - - } - if (!userBoneToHumanoidMappings.ContainsValue("Hips")) { - EditorUtility.DisplayDialog("Error", "There is no Hips bone in selected avatar", "Ok"); - return false; - } - if (!userBoneToHumanoidMappings.ContainsValue("Spine")) { - EditorUtility.DisplayDialog("Error", "There is no Spine bone in selected avatar", "Ok"); - return false; - } - if (!userBoneToHumanoidMappings.ContainsValue("Chest")) { - // check to see if there is a child of Spine that could be mapped to Chest - string spineChild = ""; - foreach (var parentRelation in userParentNames) { - string humanName; - if (userBoneToHumanoidMappings.TryGetValue(parentRelation.Value, out humanName) && humanName == "Spine") { - if (spineChild == "") { - spineChild = parentRelation.Key; - } else { - // found more than one Spine child so we can't choose one to remap - spineChild = ""; - break; - } - } - } - if (spineChild != "" && !userBoneToHumanoidMappings.ContainsKey(spineChild)) { - // use child of Spine as Chest - userBoneToHumanoidMappings.Add(spineChild, "Chest"); - chestUserBone = spineChild; - } else { - EditorUtility.DisplayDialog("Error", "There is no Chest bone in selected avatar", "Ok"); - return false; - } - } - if (!userBoneToHumanoidMappings.ContainsValue("UpperChest")) { - //if parent of Neck is not Chest then map the parent to UpperChest - if (neckUserBone != "") { - string neckParentUserBone, neckParentHuman; - userParentNames.TryGetValue(neckUserBone, out neckParentUserBone); - userBoneToHumanoidMappings.TryGetValue(neckParentUserBone, out neckParentHuman); - if (neckParentHuman != "Chest" && !userBoneToHumanoidMappings.ContainsKey(neckParentUserBone)) { - userBoneToHumanoidMappings.Add(neckParentUserBone, "UpperChest"); - } - } - // if there is still no UpperChest bone but there is a Chest bone then we remap Chest to UpperChest - if (!userBoneToHumanoidMappings.ContainsValue("UpperChest") && chestUserBone != "") { - userBoneToHumanoidMappings[chestUserBone] = "UpperChest"; - } - } - - return true; - } - - static void WriteFST(string exportFstPath, string projectName) { - userAbsoluteRotations.Clear(); - + static void WriteFST(string exportFstPath, string projectName) { // write out core fields to top of fst file try { File.WriteAllText(exportFstPath, "name = " + projectName + "\ntype = body+head\nscale = 1\nfilename = " + @@ -403,49 +487,53 @@ class AvatarExporter : MonoBehaviour { } // write out joint mappings to fst file - foreach (var jointMapping in userBoneToHumanoidMappings) { - string hifiJointName = HUMANOID_TO_HIFI_JOINT_NAME[jointMapping.Value]; - File.AppendAllText(exportFstPath, "jointMap = " + hifiJointName + " = " + jointMapping.Key + "\n"); + foreach (var userBoneInfo in userBoneInfos) { + if (userBoneInfo.Value.HasHumanMapping()) { + string hifiJointName = HUMANOID_TO_HIFI_JOINT_NAME[userBoneInfo.Value.humanName]; + File.AppendAllText(exportFstPath, "jointMap = " + hifiJointName + " = " + userBoneInfo.Key + "\n"); + } } // calculate and write out joint rotation offsets to fst file SkeletonBone[] skeletonMap = humanDescription.skeleton; foreach (SkeletonBone userBone in skeletonMap) { string userBoneName = userBone.name; - Quaternion userBoneRotation = userBone.rotation; - - string parentName; - userParentNames.TryGetValue(userBoneName, out parentName); - if (parentName == "root") { - // if the parent is root then use bone's rotation - userAbsoluteRotations.Add(userBoneName, userBoneRotation); - } else { - // otherwise multiply bone's rotation by parent bone's absolute rotation - userAbsoluteRotations.Add(userBoneName, userAbsoluteRotations[parentName] * userBoneRotation); + UserBoneInformation userBoneInfo; + if (!userBoneInfos.TryGetValue(userBoneName, out userBoneInfo)) { + continue; } - // generate joint rotation offsets for both humanoid-mapped bones as well as extra unmapped bones in user avatar + Quaternion userBoneRotation = userBone.rotation; + string parentName = userBoneInfo.parentName; + if (parentName == "root") { + // if the parent is root then use bone's rotation + userBoneInfo.rotation = userBoneRotation; + } else { + // otherwise multiply bone's rotation by parent bone's absolute rotation + userBoneInfo.rotation = userBoneInfos[parentName].rotation * userBoneRotation; + } + + // generate joint rotation offsets for both humanoid-mapped bones as well as extra unmapped bones Quaternion jointOffset = new Quaternion(); - string humanName, outputJointName = ""; - if (userBoneToHumanoidMappings.TryGetValue(userBoneName, out humanName)) { - outputJointName = HUMANOID_TO_HIFI_JOINT_NAME[humanName]; - Quaternion rotation = referenceAbsoluteRotations[outputJointName]; - jointOffset = Quaternion.Inverse(userAbsoluteRotations[userBoneName]) * rotation; - } else if (userAbsoluteRotations.ContainsKey(userBoneName)) { + string outputJointName = ""; + if (userBoneInfo.HasHumanMapping()) { + outputJointName = HUMANOID_TO_HIFI_JOINT_NAME[userBoneInfo.humanName]; + Quaternion rotation = REFERENCE_ROTATIONS[userBoneInfo.humanName]; + jointOffset = Quaternion.Inverse(userBoneInfo.rotation) * rotation; + } else { outputJointName = userBoneName; - string lastRequiredParent = FindLastRequiredParentBone(userBoneName); - if (lastRequiredParent == "root") { - jointOffset = Quaternion.Inverse(userAbsoluteRotations[userBoneName]); - } else { + jointOffset = Quaternion.Inverse(userBoneInfo.rotation); + string lastRequiredParent = FindLastRequiredAncestorBone(userBoneName); + if (lastRequiredParent != "root") { // take the previous offset and multiply it by the current local when we have an extra joint - string lastRequiredParentHifiName = HUMANOID_TO_HIFI_JOINT_NAME[userBoneToHumanoidMappings[lastRequiredParent]]; - Quaternion lastRequiredParentRotation = referenceAbsoluteRotations[lastRequiredParentHifiName]; - jointOffset = Quaternion.Inverse(userAbsoluteRotations[userBoneName]) * lastRequiredParentRotation; + string lastRequiredParentHumanName = userBoneInfos[lastRequiredParent].humanName; + Quaternion lastRequiredParentRotation = REFERENCE_ROTATIONS[lastRequiredParentHumanName]; + jointOffset *= lastRequiredParentRotation; } } // swap from left-handed (Unity) to right-handed (HiFi) coordinates and write out joint rotation offset to fst - if (outputJointName != "") { + if (!string.IsNullOrEmpty(outputJointName)) { jointOffset = new Quaternion(-jointOffset.x, jointOffset.y, jointOffset.z, -jointOffset.w); File.AppendAllText(exportFstPath, "jointRotationOffset = " + outputJointName + " = (" + jointOffset.x + ", " + jointOffset.y + ", " + jointOffset.z + ", " + jointOffset.w + ")\n"); @@ -455,48 +543,311 @@ class AvatarExporter : MonoBehaviour { // open File Explorer to the project directory once finished System.Diagnostics.Process.Start("explorer.exe", "/select," + exportFstPath); } - - static void SetParentNames(Transform modelBone, Dictionary parentNames) { - for (int i = 0; i < modelBone.childCount; i++) { - SetParentNames(modelBone.GetChild(i), parentNames); + + static void SetUserBoneInformation() { + userBoneInfos.Clear(); + humanoidToUserBoneMappings.Clear(); + userBoneTree = new BoneTreeNode(); + + // instantiate a game object of the user avatar to traverse the bone tree to gather + // bone parents and positions as well as build a bone tree, then destroy it + UnityEngine.Object avatarResource = AssetDatabase.LoadAssetAtPath(assetPath, typeof(UnityEngine.Object)); + GameObject assetGameObject = (GameObject)Instantiate(avatarResource); + TraverseUserBoneTree(assetGameObject.transform); + DestroyImmediate(assetGameObject); + + // iterate over Humanoid bones and update user bone info to increase human mapping counts for each bone + // as well as set their Humanoid name and build a Humanoid to user bone mapping + HumanBone[] boneMap = humanDescription.human; + foreach (HumanBone bone in boneMap) { + string humanName = bone.humanName; + string userBoneName = bone.boneName; + string hifiJointName; + if (userBoneInfos.ContainsKey(userBoneName)) { + ++userBoneInfos[userBoneName].mappingCount; + if (HUMANOID_TO_HIFI_JOINT_NAME.TryGetValue(humanName, out hifiJointName)) { + userBoneInfos[userBoneName].humanName = humanName; + humanoidToUserBoneMappings.Add(humanName, userBoneName); + } + } } - if (modelBone.parent != null) { - parentNames.Add(modelBone.name, modelBone.parent.name); - } else { - parentNames.Add(modelBone.name, "root"); + + // generate the list of bone rule failure strings for any bone rules that are not satisfied by this avatar + SetFailedBoneRules(); + } + + static void TraverseUserBoneTree(Transform modelBone) { + GameObject gameObject = modelBone.gameObject; + + // check if this transform is a node containing mesh, light, or camera instead of a bone + bool mesh = gameObject.GetComponent() != null || gameObject.GetComponent() != null; + bool light = gameObject.GetComponent() != null; + bool camera = gameObject.GetComponent() != null; + + // if it is in fact a bone, add it to the bone tree as well as user bone infos list with position and parent name + if (!mesh && !light && !camera) { + UserBoneInformation userBoneInfo = new UserBoneInformation(); + userBoneInfo.position = modelBone.position; // bone's absolute position + + string boneName = modelBone.name; + if (modelBone.parent == null) { + // if no parent then this is actual root bone node of the user avatar, so consider it's parent as "root" + userBoneTree = new BoneTreeNode(boneName); // initialize root of tree + userBoneInfo.parentName = "root"; + userBoneInfo.boneTreeNode = userBoneTree; + } else { + // otherwise add this bone node as a child to it's parent's children list + string parentName = modelBone.parent.name; + BoneTreeNode boneTreeNode = new BoneTreeNode(boneName); + userBoneInfos[parentName].boneTreeNode.children.Add(boneTreeNode); + userBoneInfo.parentName = parentName; + } + + userBoneInfos.Add(boneName, userBoneInfo); + } + + // recurse over transform node's children + for (int i = 0; i < modelBone.childCount; ++i) { + TraverseUserBoneTree(modelBone.GetChild(i)); } } - static string FindLastRequiredParentBone(string currentBone) { + static string FindLastRequiredAncestorBone(string currentBone) { string result = currentBone; - while (result != "root" && !userBoneToHumanoidMappings.ContainsKey(result)) { - result = userParentNames[result]; + // iterating upward through user bone info parent names, find the first ancestor bone that is mapped in Humanoid + while (result != "root" && userBoneInfos.ContainsKey(result) && !userBoneInfos[result].HasHumanMapping()) { + result = userBoneInfos[result].parentName; } return result; } + + static void SetFailedBoneRules() { + failedBoneRules.Clear(); + + string hipsUserBone = ""; + string spineUserBone = ""; + string chestUserBone = ""; + string headUserBone = ""; + + Vector3 hipsPosition = new Vector3(); + + // iterate over all bone rules in order and add any rules that fail + // to the failed bone rules map with appropriate error or warning text + for (BoneRule boneRule = 0; boneRule < BoneRule.BoneRuleEnd; ++boneRule) { + switch (boneRule) { + case BoneRule.SingleRoot: + // bone rule fails if the root bone node has more than one child bone + if (userBoneTree.children.Count > 1) { + failedBoneRules.Add(boneRule, "There is more than one bone at the top level of the selected avatar's " + + "bone hierarchy. Please ensure all bones for Humanoid mappings are " + + "under the same bone hierarchy."); + } + break; + case BoneRule.NoDuplicateMapping: + // bone rule fails if any user bone is mapped to more than one Humanoid bone + foreach (var userBoneInfo in userBoneInfos) { + string boneName = userBoneInfo.Key; + int mappingCount = userBoneInfo.Value.mappingCount; + if (mappingCount > 1) { + string text = "The " + boneName + " bone is mapped to more than one bone in Humanoid."; + if (failedBoneRules.ContainsKey(boneRule)) { + failedBoneRules[boneRule] += "\n" + text; + } else { + failedBoneRules.Add(boneRule, text); + } + } + } + break; + case BoneRule.NoAsymmetricalLegMapping: + CheckAsymmetricalMappingRule(boneRule, legMappingSuffixes, "leg"); + break; + case BoneRule.NoAsymmetricalArmMapping: + CheckAsymmetricalMappingRule(boneRule, armMappingsSuffixes, "arm"); + break; + case BoneRule.NoAsymmetricalHandMapping: + CheckAsymmetricalMappingRule(boneRule, handMappingsSuffixes, "hand"); + break; + case BoneRule.HipsMapped: + hipsUserBone = CheckHumanBoneMappingRule(boneRule, "Hips"); + break; + case BoneRule.SpineMapped: + spineUserBone = CheckHumanBoneMappingRule(boneRule, "Spine"); + break; + case BoneRule.SpineDescendantOfHips: + CheckUserBoneDescendantOfHumanRule(boneRule, spineUserBone, "Hips"); + break; + case BoneRule.ChestMapped: + if (!humanoidToUserBoneMappings.TryGetValue("Chest", out chestUserBone)) { + // check to see if there is a child of Spine that we can suggest to be mapped to Chest + string spineChild = ""; + if (!string.IsNullOrEmpty(spineUserBone)) { + BoneTreeNode spineTreeNode = userBoneInfos[spineUserBone].boneTreeNode; + if (spineTreeNode.children.Count == 1) { + spineChild = spineTreeNode.children[0].boneName; + } + } + failedBoneRules.Add(boneRule, "There is no Chest bone mapped in Humanoid for the selected avatar."); + // if the only found child of Spine is not yet mapped then add it as a suggestion for Chest mapping + if (!string.IsNullOrEmpty(spineChild) && !userBoneInfos[spineChild].HasHumanMapping()) { + failedBoneRules[boneRule] += " It is suggested that you map bone " + spineChild + + " to Chest in Humanoid."; + } + } + break; + case BoneRule.ChestDescendantOfSpine: + CheckUserBoneDescendantOfHumanRule(boneRule, chestUserBone, "Spine"); + break; + case BoneRule.NeckMapped: + CheckHumanBoneMappingRule(boneRule, "Neck"); + break; + case BoneRule.HeadMapped: + headUserBone = CheckHumanBoneMappingRule(boneRule, "Head"); + break; + case BoneRule.HeadDescendantOfChest: + CheckUserBoneDescendantOfHumanRule(boneRule, headUserBone, "Chest"); + break; + case BoneRule.EyesMapped: + bool leftEyeMapped = humanoidToUserBoneMappings.ContainsKey("LeftEye"); + bool rightEyeMapped = humanoidToUserBoneMappings.ContainsKey("RightEye"); + if (!leftEyeMapped || !rightEyeMapped) { + if (leftEyeMapped && !rightEyeMapped) { + failedBoneRules.Add(boneRule, "There is no RightEye bone mapped in Humanoid " + + "for the selected avatar."); + } else if (!leftEyeMapped && rightEyeMapped) { + failedBoneRules.Add(boneRule, "There is no LeftEye bone mapped in Humanoid " + + "for the selected avatar."); + } else { + failedBoneRules.Add(boneRule, "There is no LeftEye or RightEye bone mapped in Humanoid " + + "for the selected avatar."); + } + } + break; + case BoneRule.HipsNotOnGround: + // ensure the absolute Y position for the bone mapped to Hips (if its mapped) is at least HIPS_GROUND_MIN_Y + if (!string.IsNullOrEmpty(hipsUserBone)) { + UserBoneInformation hipsBoneInfo = userBoneInfos[hipsUserBone]; + hipsPosition = hipsBoneInfo.position; + if (hipsPosition.y < HIPS_GROUND_MIN_Y) { + failedBoneRules.Add(boneRule, "The bone mapped to Hips in Humanoid (" + hipsUserBone + + ") should not be at ground level."); + } + } + break; + case BoneRule.HipsSpineChestNotCoincident: + // ensure the bones mapped to Hips, Spine, and Chest are all not in the same position, + // check Hips to Spine and Spine to Chest lengths are within HIPS_SPINE_CHEST_MIN_SEPARATION + if (!string.IsNullOrEmpty(spineUserBone) && !string.IsNullOrEmpty(chestUserBone) && + !string.IsNullOrEmpty(hipsUserBone)) { + UserBoneInformation spineBoneInfo = userBoneInfos[spineUserBone]; + UserBoneInformation chestBoneInfo = userBoneInfos[chestUserBone]; + Vector3 hipsToSpine = hipsPosition - spineBoneInfo.position; + Vector3 spineToChest = spineBoneInfo.position - chestBoneInfo.position; + if (hipsToSpine.magnitude < HIPS_SPINE_CHEST_MIN_SEPARATION && + spineToChest.magnitude < HIPS_SPINE_CHEST_MIN_SEPARATION) { + failedBoneRules.Add(boneRule, "The bone mapped to Hips in Humanoid (" + hipsUserBone + + "), the bone mapped to Spine in Humanoid (" + spineUserBone + + "), and the bone mapped to Chest in Humanoid (" + chestUserBone + + ") should not be coincidental."); + } + } + break; + } + } + } + + static string CheckHumanBoneMappingRule(BoneRule boneRule, string humanBoneName) { + string userBoneName = ""; + // bone rule fails if bone is not mapped in Humanoid + if (!humanoidToUserBoneMappings.TryGetValue(humanBoneName, out userBoneName)) { + failedBoneRules.Add(boneRule, "There is no " + humanBoneName + " bone mapped in Humanoid for the selected avatar."); + } + return userBoneName; + } + + static void CheckUserBoneDescendantOfHumanRule(BoneRule boneRule, string userBoneName, string descendantOfHumanName) { + if (string.IsNullOrEmpty(userBoneName)) { + return; + } + + string descendantOfUserBoneName = ""; + if (!humanoidToUserBoneMappings.TryGetValue(descendantOfHumanName, out descendantOfUserBoneName)) { + return; + } + + string userBone = userBoneName; + string ancestorUserBone = ""; + UserBoneInformation userBoneInfo = new UserBoneInformation(); + // iterate upward from user bone through user bone info parent names until root + // is reached or the ancestor bone name matches the target descendant of name + while (ancestorUserBone != "root") { + if (userBoneInfos.TryGetValue(userBone, out userBoneInfo)) { + ancestorUserBone = userBoneInfo.parentName; + if (ancestorUserBone == descendantOfUserBoneName) { + return; + } + userBone = ancestorUserBone; + } else { + break; + } + } + + // bone rule fails if no ancestor of given user bone matched the descendant of name (no early return) + failedBoneRules.Add(boneRule, "The bone mapped to " + userBoneInfo.humanName + " in Humanoid (" + userBoneName + + ") is not a child of the bone mapped to " + descendantOfHumanName + " in Humanoid (" + + descendantOfUserBoneName + ")."); + } + + static void CheckAsymmetricalMappingRule(BoneRule boneRule, string[] mappingSuffixes, string appendage) { + int leftCount = 0; + int rightCount = 0; + // add Left/Right to each mapping suffix to make Humanoid mapping names, + // and count the number of bones mapped in Humanoid on each side + foreach (string mappingSuffix in mappingSuffixes) { + string leftMapping = "Left" + mappingSuffix; + string rightMapping = "Right" + mappingSuffix; + if (humanoidToUserBoneMappings.ContainsKey(leftMapping)) { + ++leftCount; + } + if (humanoidToUserBoneMappings.ContainsKey(rightMapping)) { + ++rightCount; + } + } + // bone rule fails if number of left appendage mappings doesn't match number of right appendage mappings + if (leftCount != rightCount) { + failedBoneRules.Add(boneRule, "The number of bones mapped in Humanoid for the left " + appendage + " (" + + leftCount + ") does not match the number of bones mapped in Humanoid for the right " + + appendage + " (" + rightCount + ")."); + } + } } class ExportProjectWindow : EditorWindow { - const int MIN_WIDTH = 450; - const int MIN_HEIGHT = 250; + const int WINDOW_WIDTH = 500; + const int WINDOW_HEIGHT = 460; const int BUTTON_FONT_SIZE = 16; const int LABEL_FONT_SIZE = 16; const int TEXT_FIELD_FONT_SIZE = 14; const int TEXT_FIELD_HEIGHT = 20; const int ERROR_FONT_SIZE = 12; + const int WARNING_SCROLL_HEIGHT = 170; + const string EMPTY_ERROR_TEXT = "None\n"; string projectName = ""; string projectLocation = ""; string projectDirectory = ""; - string errorLabel = "\n"; + string errorText = EMPTY_ERROR_TEXT; + string warningText = ""; + Vector2 warningScrollPosition = new Vector2(0, 0); - public delegate void OnCloseDelegate(string projectDirectory, string projectName); + public delegate void OnCloseDelegate(string projectDirectory, string projectName, string warnings); OnCloseDelegate onCloseCallback; - public void Init(string initialPath, OnCloseDelegate closeCallback) { - minSize = new Vector2(MIN_WIDTH, MIN_HEIGHT); + public void Init(string initialPath, string warnings, OnCloseDelegate closeCallback) { + minSize = new Vector2(WINDOW_WIDTH, WINDOW_HEIGHT); + maxSize = new Vector2(WINDOW_WIDTH, WINDOW_HEIGHT); titleContent.text = "Export New Avatar"; projectLocation = initialPath; + warningText = warnings; onCloseCallback = closeCallback; ShowUtility(); } @@ -513,6 +864,9 @@ class ExportProjectWindow : EditorWindow { GUIStyle errorStyle = new GUIStyle(GUI.skin.label); errorStyle.fontSize = ERROR_FONT_SIZE; errorStyle.normal.textColor = Color.red; + errorStyle.wordWrap = true; + GUIStyle warningStyle = new GUIStyle(errorStyle); + warningStyle.normal.textColor = Color.yellow; GUILayout.Space(10); @@ -534,10 +888,20 @@ class ExportProjectWindow : EditorWindow { } } - // Red error label text to display any issues under text fields and Browse button - GUILayout.Label(errorLabel, errorStyle); + // Red error label text to display any file-related errors + GUILayout.Label("Error:", errorStyle); + GUILayout.Label(errorText, errorStyle); - GUILayout.Space(20); + GUILayout.Space(10); + + // Yellow warning label text to display scrollable list of any bone-related warnings + GUILayout.Label("Warnings:", warningStyle); + warningScrollPosition = GUILayout.BeginScrollView(warningScrollPosition, GUILayout.Width(WINDOW_WIDTH), + GUILayout.Height(WARNING_SCROLL_HEIGHT)); + GUILayout.Label(warningText, warningStyle); + GUILayout.EndScrollView(); + + GUILayout.Space(10); // Export button which will verify project folder can actually be created // before closing popup window and calling back to initiate the export @@ -546,7 +910,7 @@ class ExportProjectWindow : EditorWindow { export = true; if (!CheckForErrors(true)) { Close(); - onCloseCallback(projectDirectory, projectName); + onCloseCallback(projectDirectory, projectName, warningText); } } @@ -562,12 +926,12 @@ class ExportProjectWindow : EditorWindow { } bool CheckForErrors(bool exporting) { - errorLabel = "\n"; // default to no error + errorText = EMPTY_ERROR_TEXT; // default to None if no errors found projectDirectory = projectLocation + "\\" + projectName + "\\"; if (projectName.Length > 0) { // new project must have a unique folder name since the folder will be created for it if (Directory.Exists(projectDirectory)) { - errorLabel = "A folder with the name " + projectName + + errorText = "A folder with the name " + projectName + " already exists at that location.\nPlease choose a different project name or location."; return true; } @@ -575,7 +939,7 @@ class ExportProjectWindow : EditorWindow { if (projectLocation.Length > 0) { // before clicking Export we can verify that the project location at least starts with a drive if (!Char.IsLetter(projectLocation[0]) || projectLocation.Length == 1 || projectLocation[1] != ':') { - errorLabel = "Project location is invalid. Please choose a different project location.\n"; + errorText = "Project location is invalid. Please choose a different project location.\n"; return true; } } @@ -583,16 +947,16 @@ class ExportProjectWindow : EditorWindow { // when exporting, project name and location must both be defined, and project location must // be valid and accessible (we attempt to create the project folder at this time to verify this) if (projectName.Length == 0) { - errorLabel = "Please define a project name.\n"; + errorText = "Please define a project name.\n"; return true; } else if (projectLocation.Length == 0) { - errorLabel = "Please define a project location.\n"; + errorText = "Please define a project location.\n"; return true; } else { try { Directory.CreateDirectory(projectDirectory); } catch { - errorLabel = "Project location is invalid. Please choose a different project location.\n"; + errorText = "Project location is invalid. Please choose a different project location.\n"; return true; } } diff --git a/tools/unity-avatar-exporter/Assets/README.txt b/tools/unity-avatar-exporter/Assets/README.txt index 3ca4dbb1ee..f02bc688ae 100644 --- a/tools/unity-avatar-exporter/Assets/README.txt +++ b/tools/unity-avatar-exporter/Assets/README.txt @@ -1,3 +1,7 @@ +High Fidelity, Inc. +Avatar Exporter +Version 0.1 + Note: It is recommended to use Unity versions between 2017.4.17f1 and 2018.2.12f1 for this Avatar Exporter. To create a new avatar project: diff --git a/tools/unity-avatar-exporter/avatarExporter.unitypackage b/tools/unity-avatar-exporter/avatarExporter.unitypackage index 28052efea5971fbd5c100a1b48b25c69d5ba1ef0..2c584622d1af9ecc55112f579adec8a432363064 100644 GIT binary patch literal 12627 zcmV-ZF|5uXiwFpvv`1V70AX@tXmn+5a4vLVasccd+j85w(a)agKQMY|u!0St#9xwvqd!D0u25? zpSAp-MT_fcbQ5pg{Q;nB_}{hoA9Vd-%ljrg`9J6XewM|HZ0q~;fBK+q4#%TmC-D2d zz;6%xZNGmxY(>3s*zd)`zeoQABH?%a|1F-i{EtvUY~AZA)&Ich(DxPpTR!9u;m>#d z|1F+>{O#|%yPmiIIRY{qewn57MLcf~v+dp8ZA^GMkE2CA@-A22LG(Epc|VVapS*PH z1%2-%{pji!CUc*oCRWgbv$zs*;j;6z=0C|vPi+OUnM6k>0D4u(Z z>)6|$MML;=oDAb>7B{@##Pck{dYY{!6+4e($`}oY>CG&fu9E4MH%=x{=IG_&$@!s| z&b{l!V)krz_xAR-84jN3zK5J@ekA)AZ-@HBdHP z&b_~Ed+u7ovXYi6_M2m(OO|sO3 zF3VwzB#>n4ogMBUyg8(dR1=8)A{lzX;!!%Ctl)i)4cY(A{>A>;$HU*gJw3ZPJp1^Y z!?W|F(-ZHxS8Fx>+8&k71u&kZfYs}xx91;!ad!Id1V%n#qZz4+b7_xoojM<>7dcyajK3q<-PoyH97 z+_DD=aVwgye#*c!IJg(y>vwPVPfm{xK3<%De0}ul=;JR>k4`Q=p6tIl1i;hywxlp} zlRtl{y}XXIMXll0&SxNFe(h^ROMDHOKpOqEQj%E)DfHJ&IgZB*?+AL&7wS*Z1hUb` z*U@xD-@~E;@Gl78O*~3KKAd7fVX?RK^h@I4mP*6}vqGcDz zL+auh^iU2~^gS#pP>2_$21UgrW=@eD)DBk&S^RXJwI=@br6BzqgCiU*-yhvlz0pj=1qb#R^?`AUtIry2INg&4G z9n?CLcwmdWuU* z9NkhmIJ(-0KD+A2EmVdiOV7d5)j)2sJT#RKQbm*!Dk@NnAg1P^XXzoQNDgX_CSqTe zDk>~ej2$KCpl53%w@?m(Tp!8bZVsW5)QgcvRbkZU=8;-SmL0P26l9rr4yKefE757 z=kat1ztTlSRy(t^Ve;U;Ufx90G=bRkF{UUmczup1eH#cy@Y0=I%K&FUt$&nwV5f<*7dfv$-F%yPa^{@cd@b z33~1LX%AkWcALFUuQQ0BcHynn91OahL3_NT(UjY!F3_OaX$|^)C_6w%VKeLv+U;)q z)DPf2XojtBD-7Y4YBgXotJZ}HKw9l^(DtEJ3xWF0PA}}Wx(wS+)AxfxuLIb+AYF4f zj}U-!zuW5csI~1utKaJPu_Ao&gJ3YA7i?K8gcPTSys^x!hrosHL4dFkS~m!ZDo^R9 z9k!4rELXQ5cHDaGp|7Bhem59|ybizDVPNSc1V)NFfcI^)Ev|)LSVgPV>9QJpz`G-B z7<9r`kJr%eK-DERtS1++CF}(OXG^CYbUKnPoqoS3YHhc}pj}*18NC=EIzgXT(gR46 z3%#)26?FuiLAUGH!3NPaVYbeFN1d9clqH?tlS#9U0^vt#TtTsA4tb(6?R*Iw~*dJs|PBwu!i-->H^?y zz!aoE7(lNlHejWo35bfAD}>j9#S?exR@jy<$PaHzuixnfyeXX)aKIN$>H5CaOXO7V zp~oXJD|F$)*a;2wJ3S%Z)odRb3&RA9ES2g+dYy|4%4lq-{J#+zG1*lz>J_}CM4KuUyMbb7sRPm%`sxsy9%F2XxN zGPjx5>G^&LgCqOh3PAb>f}0>dA?vH|9J-X~{(!m<|Lu1roqIv6tzP^=m=C;^=-n=; zdLNNwul;Tt)ET2XK3!66jO<~E7RZh>yMO4;Q4*U{|f;bI7RzFdm3u3rtcenypqJ6g;n@4*~^t zLU<2ILl=vaOz8sWDc&v>Vj z^TVwI8qyOORC2=Ph*lT6LPrqA?=d@qQE1Q^2r~8tedvX2 zJvMp0BZNg(j%q7_QB6%tx+K#ieV_<|8x)dm2K4ts3QJ5EP`Dy<}*7Bh#Pc6X=j1y&>1K@D+ttx54|GD<7A1YMTQo*J_2EA z^`HV}X$AeRB#kpIRvy3t)P0!xNINSWXm%EtJ&67A(VotMTViK1)2dCHg#QjgWmyre z_|KqS0J?4ygL)HzjvTSZU!5o0xWHoZAB+E8+Q%s<4dM;GMh;vgcs7`tjRAubA-wlXP zYCb$=7vv=rLbL{A#cTQ-L+Fn8b67n%TS7Y&_s#*0NqhzptU#Tl2g@0RDnSKY4D_`9 zY;|)JFXoUwfsU{y2T0+X94MGh7WB;H$9SZ^kwVM;co1hp?Ecfm=@?6@WYSWsjP+43 zLqJqI&Q9$zxcrd!O$S48RWjHuybWf%PSVBc^cVAVIkgf94FUpLUZ%-(2w`he1yS$I zGMRYUb$W|MZ=?AXa^>n&iOe59aCkqTq{B~;ohJyLp=bPy)0C#hX*ZE5QkY7lDoj-z za|aj}1otkB=a~Iy3d6|_t+{C!&X$+Z2Sh;EHI7FoFb3@bwP_iSjY!E<4OPk5OBNn1 z$=y)3I&o{Y0=Tni4jd7H`Kz}C%v2?I(FAhvVeutM(E>~I&^#Oq7s(Be#3K+2Asty8 z=5OL51Q>?iEX@)m#Gbx5DZkpK)WA=xmQF$T)h$R|x|qlDNji!FOZBoRb6Q36xK-al zeyK-s38U?5dq$d~w3}#<6Ovm-s%$5g(zAapA_o9T zC*JEQBZhMQiS&RDx>(Mq-XkVp&7~FkV1#XfheiV?h`(_Bw(~)+<}>PimI?B(EV@SGY3DIUVaFq$VGME6(EGT|m1FRZBBuck^cZM! z2^uVCRU8)cr9^@LEk|SjGF>k6YNZ6DIu@t?ZjoTYCfKmENwhjhqDgvHuTgf2B6ef) zf0#1dJQ0)r!&C&`*Ssf1k>CL9^ixeX#YQl{^`tqM8o{6ehU!tXoU3IrLP=&20GTF< z-7nr99b{60;J9C>oXz8SdId83=(*<$suX5=4#SL|sS*hf(U?OLzMaHT1`}dJ42(P0 zAG}4%jj?7;(MEf|dNGLKL)GeKG<&F8xBkY5shp9{===Nl`v9193H3IS?icC4NU!Iy ztMD%o;^~Ihu{8hZ#@m}PQ6MB1|Jhw~!CYmf`5bN3x(;ud_ zierFAfz4s6r$86vPe@cNcI-y(?qWIQJBR@Me+e?636x|zW|@jN?!mYODPZa$9>Et; zP>2Y7(*>m(rh+;OSM^u|#(q5`T6s^j#Jx3~tno_Bvf^XWkSg3j@lE;}nl~LmW;DPj z$rRn?^Yn(=4Kut&UiW66quU55I~t+i=n}we7q(8lG|!ip3`D)v(7zu;O+wz8#O&o3ARa27f~|JptIHWrHy=f6dsy%Om0GSu1wPfb19M_qALM^u#7Z- zDxd~DF;@jJ0;xSm7E2Jnn6JQcpk7%Q5j9}X7yxqxHD_F)LT?FUk-~9t5ms3RAn&<3 z3I2nQ!iT2vyfBz{8}+hgv)rm!6J2a~ydVGAd*ps^+Ab1K5%+Hcr&74u#GMBqvOdociS6~(~x|E!9-smr(|C$1M{)O)hPC11Gbmoc!ru-qPMeASw~!~hS`IF`ehq6 zqjb2u0U^s?rIQiP7R2W8!|CT_o=!0h=N~MYO{&}s=U~o)US^q^Z&n8as=`G2$&+y+ z>auGm+u;Xe@{h|^e*{}OtC{m6YkH2-p6?9t0RGZT$@n#>LbLoKoCQFWjEj`H0stv; z!0hFTb^=>ypqYH?d63M>h+j3S%B(J%1{nQJZ|F0-p(b>@+orHoO3 z5jVgWDS`H8oG{*)Kmu|kw5lq|DJE_}hbx%vj9CX}w99EAH`iZfe0FV(z^8y0&W+zj z(|A&^oio@?18S|IQwfITu@Y{be@0EZ$AdAGn$NJO(PtRJ#-|Awn+%XUz!xh*yHVyl zbVINzhH?Gz?&F5{_=g`J@02rVlBS=QG=K{Zkhd5Rjwd5!M?tfx4W;zv5Q^3u7W0)m z6e-^mS#D4aSpv<7M*EY=F@7_NtF_0Ot}s{x3E0LU77Ty1sFcMpR7W5ZY-RpAm1s&U z?zzrso8xBL{8@`jp-=IOS7REICbS*U_{oiM7Z+hbuz<|SuNu`|{OGK45U|_w#Pio< zfX^k+{Znz4uiMqWTEjl{j>Yi$&OWbz)1?;#8$6Sdr8HPEA*=siSht!%LbGqO- zHT;A$b z0INQ-FGK;?E;Bb~vg6HYx)9&eNi)$@<)WWLjk9WU0E?jjX0D&%AepClph|=#(fj*} zILx#iL8H6~p60*!39DwG9N-6_!7h>;=E`MFd_PavH?D(_t~jZa-(7#H3y?LcGDMrg z`SXQ59AD&CRHp1w?oli2Mla!jpGo{h>rEFxMniTg^_80Pt8!)=V!;_}O9!t?BTD7Y zS!0zoapHZDdaHB^ll$o+Uq+g?knnFmt2HXr^gFim`Xt4FU(!HcHckk#KlcJ|3$QY> zKo#9ks!?;TK2>dtA{2hdBL!W@SfPmh^c#!VJ@{@*k}I?oMY0LSD%5%}F~d;;vOi63 zh*!x1gu+xzj11D?6a_h37(m4> znEk?Fa0@cYe0i}P;Soy1rf^~XOfBOHYPKqFFyUc8#rkpnV=daTwF>%HGGeHwgf?nT z$5_INDl&y}fUmPoh zYiXNvGKau{ZB(~Y6|M8=bG#2Hpu|jgzy$X0#JYJrN|{fT&8R?T|Cnl#Y#B+ZjC@lu zXPGxz;yg?A34YdGNnNxi1k*|Qi&C#_QnfsV0wULt98eFi{3f3D%W)#dW^)2}2}fC< z=mxyOIRo$iOc`W(W~Ndo%8Dcngyl%7uqqyEV4KIxV=nd6okqF>>qg)Xcw>t;w$!vC zkrZ#ypa|nrWwU4FfkzBF>qX)&X3kgWr0Rnja2S;&(>;J1BYSY|)fh{#LARh;ujVmL zD&@cvt6@d8+(A)TOY8k0=4hk_A6;0S(Wu(I`7U#3Y)Q_vS>lBj738~=ZDA@*cae8q2jJ#zMN=6%6Jd7s? zObLKBDqv&TClz0tj>ps+7pcjS3f?u6r~_(`?MC|-IbCG`_%Cs~*jyvradCxlRx%$) z6K3J3V`1dGT%c*vtMdz!*bUwNR_>4$%lNSMYltQrU$duq1S%&rrmNiwuB%9tW_&_l zEL%Mg-^Z}dzY+)4KC10KR7ikX?UUzhrX&~mH(TB>mArFS)1z9$tW7qaf8FHumx7a!zUp#$dZ<}7NgD9G`!Pk4y>1x6vCp{2jyc}ZwZ9D zx$+`~p^>vBBfd$+foxqn5zny)`Q$_re2LP7x5R+ z{V|QMjPtTFI8CV5ngs`D#iXM<9U=9BQoUZ$d3&YOP_?dS!rb%x!7l(mH(;ONS zn=m5}TyJ=#-rU-Z%t@j5K$}WPd}ia)e6QNhk4n}_zOq*mpVxKr^g3{$wBtep=X^mQ z8wUT)(1sO#c-x&p80wesfwg$lp>{8<(Lns1Xr(aXL9^(bq}a*P>d7@M#r`nOVEZ$E z@_(3O&8%>haCB#kV4_q5{y-1bItP3Q>N>L;uexFBPH7k=A7B=9?@4Zj=ue&T*3=T3 zBAK2W>TN`cftEDhbHlv#3@l!{TKu6FJoU>o5xZ2&ZhiQJTa}7i`>>Zk@!h9WGKyrE zr~_y+5tPVCIpg2;iZMc+3AeM^XAM(8@`uYwv>>xpv8Z^e@3QLr6VBt)DIkM;%QOB; zEpS}7O_7`v)vp7R%>#bxAdzb>$7F8CF!|c#J50-iZVgiA%sGqWVzp{{M68}SPsDJ~ z1~?yQx(9B9Akyzdae-dfPl<5 zlX8b7h9Z10#EP~c&^DH{_)kkPLq{x%o$e>#MDMv8DvjajfvlLPdTqkG)$ zzKA|il!Kh|@ScGKo6-OYbam!U7%t}sn=Bo0n)4w1>xkxNtdB5yQH7NW@LI@)qZ8b` zJj7k`szf&3(r4w;T7;Ss*)r+)$@AhG@41YT-9|I!$CkuE6_r9a_H=!g@L!m zsqe3oSIG_xo9P_(QUN)#DwYa2R4V$9t!|X3eaaQ#Q!TX#aUS~X%6$EE!!vT7O_DMiyJM%x^;^&=eb}BvFmOGY+;~i4Oeu>Ku z*0P2;H<~8dwF+k!yDnf-bu~>hnA>K}w=4#wc@EBpMZG5W0yN_`SrpRy{t%re{JxEY+Eh#yj`C1aeWxh2=>3KxM zgbLzVKn;)rWzpC&pkoGM4pN_$ktBvOa=A=k!75VV8q8)K#n4sLmA0C#U&`2I&T)?0 zS)74$Wf(gL6&$D02)?-CK@AE~O2>5-c;Jm(ZG;`D-SNAl*Gx7ZMC_>!9z^^@RKdqI1@ z=ja+>;wVpn9O3~@>i9=0vz;IL1Lz?~6IHKVCK1e+DNv)rK~yH-QZ7ErqznZ!O#F~H zy_Cg83+Zq1oKo9?y22EcN~qJB)_EDq4P;}_o=hnmtDZbDEf2lGho*kUR=x#R zVpki}Um}Q{`%er@N>!kCjR$xu}m9@an|M6Qlo zX9LoH}Y@E=2x^z?=*J%3j5wFZ+4eEu*?Bwf~3!zJx^BKPrYg`p~rD->nxv4%I zXI5}$VKQcTOO?g;w@#tBH)KMK!VZCkpmZl_*)?$E#1MeaBN~Cg1xrWA3MHcU4N@uZ zxk8NgNMb-6K*;Bc10BIj_@11y0Ql-x6_XXuvHe~jsc z#d#Y`%Dol(E1&b@GJnMyywh`c>jL(Z@#Z@gr(fA#tq1a2B z{5d9U2nWbn?)=hygB*>$@)G8Oiw2Gr9Ui({55=F+QV|ai&S2*pVy{r7f*_Jyi0ta3m^mA?iSzq8&8OX(<451BR&=C-STiCt0HySXG+7x>d03 z4Gx!#S)gjLAg*b=(XBSGjuZQYjsb-~Kh@Yl+4-5PPo5;A=at&nvNY*n1W-?QDnz<4 z!6^4?u{^@1WDl_{l^^cpJ=e4CxkftKk~3J1M&X9jiLb6rC2mLWj{z|rbU4pIB`~~L zelj-J96Wn_jT7oR!%lhHP}LWjpgcD_?86jWmhxe}OIMO3zq}@&dMS%tYW0!BE1#6R z#*~iL*i3V$+3q0=vrNcX{$W-b9-QoCaS!q+&w{Je1v$hIaTqS)^mgqzxrKAXXfn}^ zRTLQTyW#ig2=y)eU1@eKkxU21Z4pjN1&Jh(hpWuMSu>l%yrNzz$oO)YJ-A;- zKna+Tvi*SYnQ1=CQMh4FA19UeXJ;h85GP!^k1Nt#f%7-D6U4%a;Tw^)LQxqLIIesm z43mtX;>nGZ$Jrfyi1^f~rt1U*a6Y_V$v6Z!gAry?QW$9-}fPVk(SI)xcK302FzzOpUwv$mGbEL{z$O|F;A@H9K5F{mni1jw(IX$#OFMBxuS zyj_HexhPdKv^P^ESdrFUs&|`Bj^o&%n^|{bly`xR@4 z|4G*aT1rGyewt^D=W2;~YknZF8dC@jr@Fj(1Wj6V!fC24aubM3O>}%3P*lA+Gd3Nt z<*Qgf9_pYS5!g1tsHRfRls`aCzvEf=B{FpN-N5d@7$Tp49qS!mO ze{+l%8d-uUjz?X^3tlUEBDn*Zyk=ZJaoUJjFg)dY-(BU6n3CrvP^lGM1)JGE-VwSy zM0=>I(E`Kou{B6}X6h*nHi?yXo|^2#RZYPMw|DaI(Ru&{u4~1I8EVv(Ub*O18}Sgx zy|BP5sYfH-bb)TAxomx6$H}kW6K*OrX(H^sM z3PBe(#fRMq_!%!)b~@1`19MzTd}P)qlv5D^v;2>Lyh-^D#}d(=qT!^<7M8N7c9-Eu zJCW&;LyLbIDL`)iQz`M_ZA|C?p`3r{fz7(5vB+7*-Gzv+S_b}|THt1F;j~hQH$wO7 zw74#OJUe8?FFrIVQIb z`9mQ?thE4obr#mkrxH2@i1hraOOyNe;XJ6CxbqOj!XK)cL`=w0Z z37srW9(h{jfjUT1&fgxL9DaQH`tbaM#O3_(?fw}BsBhSp%-JgrCHeV;$*S@dvhrWh zX$i~1UQ4MJx@{h0YF8m-O057{UnIOp84sVEYT_wt#0GkT1LYI%lnKOh8;pd{nz8|W z6bgkfE(%;hxC&lK^wV`h#`s_S7&vbN_`gx>HS9r9_GqVGMwf@H7&Jy+hf=#n|58Q&wQ5{R;pEa?`ED4I$s(jxiJ}{dthV2kE-k_*D<4qmO+-OHtB2a_16mW7m5nTi+kQLjaP9N9nwlh>Y&WYc%kJ^DEa zymKV!PQu`{NeYEo+hz?Y5PN4Eq?J#GNLe8@4;#s<0&93Q7j1b{3;0!nyF74$Cpq+E%%S+qc#f2bpFYS_J35kgMMK1qj zR6Tr#FP~y35=(@lTDOO?U1IE%`Q^=Jj8bY$SPfpJQ&uNNxo;O-p<|K*ire(b-^xWq z;S@(@k1Ykxn}zfCXc5Wul1Lu6=tkEC>K^03jyz&`8x{AFrF3^UR1?iplM<)lT z??1kWOT%A7C+f6Xd-*wDA0GYU^#x>UcbzOhe|K?ldh+qrDQrYMKl=S4kMhAP>OXUVF8fnRqPD@=_TJj^}i8un6r7ijs5?QyJCaotTwFQNE!-q{0!G z>12`{o*v+JW33&ng|p=)#LCjC*H>Uhiz7$re9;5;@t)_5Qe`$sFJ;; zF+}KaRrkU|r;>&Pf2c#(b=FNXJ%=q=7FqP((=dCA)=zJuFLy4xNEVYA2MOHbBqCy+ zGl|s<$(1Xe0y3K^67KnmHlZPpK^m3u9Ih5GdL2DHhLYi zJ+-cOb&+JsrxHD+eZb1tINDlzFE9HPr|}bN_sbN{0D)rkw+XwwatqwT)|mHmJcNMm zK5!c#WIneE3Xq1rD6s~k73}U!Z+V4!wZ{>}IpIaIc>b#2D%kP!RvlN(g^tYiWC{nz z5X0E1oH>AxF*6)S=^lIitk%$kQB`b>PzuzAkgvL~qp5lg#64Rmj7{M3)k$z~Ya zstrGJp9S*r_dH3A1YQzP%FVy|EREN6Z zmTYbJmfKkAAZa3x$`FTP$cCzus*BtDo@asPitJVc3hhfxcw;!@ zho%1}iErzdP- z@^e?XmM{<7h|PP&*QhYoqH-0cK&sK*So|QT-m7$eNPV@AxpG}9Waa7{C@_HQ?kvU_ z8a4iJsD9WzSAv0mTxJWRJX;+wh)>ZQv?GEewT6%katR?70oEQ7@E*scIKk}{+W5@x znafxxia1e+D@c9~)+iZ)H;Ed?vhifL<}!py+b1BLkmKkQwmRGGYEm^kVl&RYB+KGA z2wL$~8A2}YLYk}sMo@!BgYwBXP)OWQj~xO%0&Ee0x0MC{OIYsI-X7iL1-)WAnBsIX)08T2vD z<9Gp!qZXdHNoNNmuJh5S?LaxnfABw`ANcXJ$7?xnPYc+gN}r<%?AY+u^GajvLvTr| z0hcN4JX)Ed;oLAJaiM<34t~Cj$?G9*_z-2xtSv|a6B4z0>9r2{PwWl_WPH^ zR@57Z{azgG(t)E*SjUdG?)tR+R;$iah)%qrN{Rckig?L2)O}~L7w9wt<1>0B4Wc18iCmP#t(rGI0WQO2%c@q*l`!IXv z{RR6kEKjqu=0;i#p0I7tV$vVO7_$H20|9}P!|0T2JctOefN#kVOZ_F`m*O2Az-ItW zaV2>8?=w^mB&a_Jn|MNT8uqu}M1Tea*?ZqL#YX)fU5GgBZr%M^*Z*5#5Bq=M_rf3u zJCHx@cEj)e|64rlM#Wv`uH9uze}`ZAJ?q14>-+P+{j>t#_lKQU-1A4HZnr&(`q8K} z>_**q81=fn-L3mSpkwm;zk00xAGF)h|2tdX;Q2rA|7-dG4eY<5)3)uuw%_@#|G&jk zV*jlZjcw);pb_Mmw}MNh4wy~1-X{ERxc#R*>zMpa>_5L_>3`_{z3=`1TRevSxApz` ze|y5#WpLRI0)ISe^@Fe-U$%O~_NY7R1f5YB#Q!GyFX;K-?Z0pFtmQwA#9Q}z*69DR zE$9CM*n%yb|A%4wyZ-+c&+7#CB))<@I2fnZfH3D_^GDnKYIflV_|Z1E4ZT*=|Izl3 zw)t)73$`SGZt1Gr`bObLc+Y1Cq6tG2*@QoJ~VHCI(bHy%&7b!rKrS}356M@w{0h$yv zJr)3mkaTp_*>wpOV;e!>RJ)ny!!NgDTKN+xX9;ZGVu5dBs~lWZHlsx$fhd$^P^Jmr z>w2-8;XwFXiFhkS?dF;4z zC!!TUln&ya#d)ueA|oRSmKlnmk`;A3pji2j*b3rKLtUApZJ1SSSF%EL@e^_DG`&-B zLGv4W;xCL#4I{Uzm~dmEn~8Cip_FVMlnF#Xlm$|VBRo)$%;+}A)KGDP`Nzu_*%8=+#TF2!0b8-WP zvA?r8*QwEV7l0H(i+tEMKPSWZDe9w$7$qbIv)s%`ZpZw4c1;9g;h$6^VL~;@_z^^k ze-qIZvv>}FZ@8*ZEpF@LLZ9y;Ek6-Ei@cxwX#0qv9L-VinZ8(Ud)4dAVFwd^yAA}#k0mso{Np`|Q zg-sR$VFR0RvqdSkoCqArSn_5K?3eMtC-Dn(_gs=jlAVKaMYXlD=IH6^?&=`j%sRj_>q0Ov|!Nd&6)y?%`RaSuo!K4E~?bO8(D+ z>>>^>!;PCi0CWZay%PUz&+;~md*R9dod0`i8fNLn!}C9VP&Yf1@yNBUfp1&=(V%Y) z&PHb7Pn>}t+JBG!2Soe_{eK_NO8y6^AU1CGH0yug>)U<7f75l`4HJIZ_JjVvkLQoy ze&60UjJ+2Dkm2CvESYEFyf;c)+uJS7csvh-EF2qWOJhHH5si)agVB#h5*zlwc$&O0 zY||RBg2`+-kIpYLN-{o#pT?7D98RNb*)QwB8M<|Vff=tXupZM7CDg84iKEDz%IC=PcEN#iI><|652nP%Z- z@vV1wTuOMHOsC-}1KiWz2Voq}qtR}wHJZXeDv{aPF+NG+k3cZ z>E0&?#v>yRubL|V0$CcQz45y|_19~jmuB)i<(<@aW_zZ)>MGRYMY(oeHHn#4r2@5sB|;WI_53GEf}>CV zE|iea^DXHmj*_czzBj*AaONSr))YJn&qYD_sTVrANETDj&#aJo*GrxzA?g6}`~4uT z=-}tGSphltSy@OR#@{v6o`$0zA)EddbDn|G(KHTARVt^pN*xo$HjQHKdY%)T@1TTshHpjOZI z9p40?KD@d;%d&^Q8*X_z$g(4;12+NT0n8{7l-cVK&4D=>U@Q1y+4gWqFW4>9ft&{L zwho@4IQ^lGAQ6aXJ4A^sdg(hR(t(xo29DbV-l_pV@a&<(!CSt|AkvHDxrjY0W%v74 z*eiol1Ku=Uk3qKpT~|UMx{m2{=mQs;ZG>*uk>2rbo0Hz{+pa4~?+ymOXsh3M?0ysY zD%vn|yY_&C_W_20ju9kJK)2nY=jqUMR;7kE1K%19TuvO@>6-+fUR=|}Zn091GjJN= z*HFjuEzjlD8QA`Sy0S$tHpD|jW1eSv!$$aZqT{;5p^(hJWkOqm;Ev}@R=B|43dvL^ zv`+3l$Fw-~Ap~$-DfgC7yk+ubxt`qsy;gES@LkSYK-ZOn79~@5#O?d0-vGT%aBRTX z=D3^Cn3NnJ)PQ^$_6JrK<&^=i1n+skSpbRP?ZE&j#Yy9MCg3Xsb7=aY*qXrG)!+f1 zXETLn4TmspiP+c{jAbGkmU7^AXg7hkYOwb}Iym&*^wDyK4s$#hnij)-=-7@c6pjf-#PZBKa#TyWKlJCLC&7SckfeH10E<;#-yj zCXM|zZ4kYoU?s?oQ#mdhsBH~t#PHviCkgD^W?#Kn!z#2tpEc0i^FVQ1h#Y$zczw`T zjNtfqq`4Ty9cO44*~wVfY6mo)VCdBt?_9DZdC#Y#C?I`5ZpswxkRQZ zt+nHr{gTBmPqb?&4EX9Y|_VAZh^dL>mrGfDew4M8*o?qHsWeAeMPj^))W0p$kisDlUYTfz%+ ztehL<0+~4KFafC&#lOM}k^z_+Lo|d@GO&lz59$wceh@W4ZcrbTdo3$<(98|8eMh=M zprvwdkeGh?AV-5L@vC=(nCHU=jrgq`AJ?XK_;05Ybfr~hP7sh4HWElD2rRohR8Ej> zOPh=%->%|egBwI%kOj-r!VB`D0p$kS15Xk~n|c;PP&L?FFb9!NkTc9VL7va~*tgKr ztpZ(7Yp8@Y;J~xp-gGQtcuQ=kF(Rc$$2;xD4VX8h;YZW{{PxT;}a;?p~vjPXmY~W=~#PqcA=P9>4y`umt9{g&sXJOjSrdY&*y2S%h@! z!P3lZ8Z7stV49qF+6VJ_LJJC?PD9YoMoNeoLCoaG$Py!@KKHQD@sDkyje1@gA@J<3 zS~YM>$uwNI?ZVqU%ocNyTrIaSR`&4sJ}~JF+U+6TPm?{7->G8Dx8`%zZedhm71->O zJ%Q~wnDk|ofd$PDUS_cG2KA$CI0xX91O)i-lJAANEWAA`<|6Dps-J@d7!*qt#fIZl ziA3)-p_Qi2CJI!$H#vLRHh%rJj09+=#dM02bZdUb1~$SPSd*z58w=elpxO)K=#tdw z>2ektub0TTm(T5y&bwu&GS-tfQ$xZw5IA;9m?tBOX~Pyc5l~}9Ws->zWl-xdPG^t= zJGQQ(c${2an*`z!37X;t5h{{M)9kFaqUh@r}Dn zXCdg3QP_EN`^~QL=6Bz{x!KH|X_EZ7n4#Jb95C?k2_PI!$HruyTv8r%oBGg5Zw{g8 z`Kc$HFLUEr^G~KxO#Uyj1iBH7_omY$Oe+#s_D4MvkSW-DYIGPZf&^@15Sb3qA5c5M zkXbMfKqT0%qDNGtM=!^dPWw@w(`AmA>b_g$0qGR_F~0hy6sZECyz$+Jd7pxl?Y4Qn+9_?!XVf2=R^sDJpw!*S-_m$tRtWp79LTr13#KbF9DTw7l-fgctkY?G8|o1S03eJiwvU@RyQvYS zIM<{d=RvGW(NCeqSu+)Yg((211(#T?P-GlKPE%oWG4jK9~n0H1y~(fVIAGjs8K7uKGm$b zi;V)-R9Bxs*NWIX_bg(!;Coq;bfH~SBzsV8jaF|ZW@sg#_*O&{C77Op+^y49C3wc) zAbCnwa-zlI0%pb*i6JL&mN4Q9x1+8}s7%#_WsnZ1sL0vE04i>EjRcIr)jUF@j^(9= zM=0Ggg=^bqY8g*bl~r+<2@m@z0&~?*DX_!lzm(QsG1SvQ8#V7QO!EU70mzm!ayrlj z9^tP9Xfemlsq-+sz*A!p3MW!?6sD;JQP<|(28GH`Qv_vo0HqdgN!y%|JCrSp0h`Tr z(K-oUgnO`v8#3Xka)IwA0>YoQ#W*XOkJf~_;>kpK<2n~;KJ(<{v`Fl3K29bvQ)Ug@RqN07A$g_3 z=rq4kS_4=a>J}Necmv5{8o!wln{-X~dQ;q3 z<#$VNjaUmi1XG&pdDl3OD{4QUCTT$^TI&DuYJCHQPBB<6N#E6?=xnmRa;j9}2yj18 zQgwLHKy`^aNklel@d^xAymnQo+g*h z+TX#0)(QRx%-6E7Ujj-%MXYr=A-^ByQh+1(RCNR?FoXs9MvShpR^+Mpv}a^zglp4mQAVpXaYgPD5a4x4}V&KT#k7y z*$PPt3eC6AiJc%Z#v?70w3X7@6Vakv1MqjNDOXisf4zcEO8(VpceuYA^k;| z3_G^GN+o+Dp>pp970H*z|LO`8{b)c^3C#s;xR3~zahDLa`y!Yw!j8nKivy(ywXoM_ zjj_8o9(M%JUDf%TK!ePu1#3EuU$uFTb8bu6xn8+1~8ui8-Zy1Fk$R$J}r#G)?Koy#HuoUT4Lg?dg zsrt88EV{76U;$?75-84A4uKqur<}!UVqC%R0OsG2l7NSWMc8^D@OeFi%M3Qw+d{K4 z*4}C$>Fd}mvSpp203^)SWn)376#yy=evmR5zpmv_jdi6t*ln${1gh4ZZUoQiBi5Vi z>7%kvD&cl}4MjKD)T$`}#s&m(1q~nTNyfUu9AJ=C{J{&N^eUK|5Ebm>>)OcFJt?M) zM3rtXt$N+Aq`?Z>Cf_>OGI|r6yVsGoJah%Ct_`YQbkeRDJUcP++n=-$P(@`+RBHlKVS(PMi0p5$|)RPtCtXi~^zpNlAKWL)<&BVb6ox~?L~zQj{Av+1&o6%5%{_B*2>N`E*_rarQINIHKPe7A9@8E_JZr4EjGobuBJE%xq`zq z_!l(8Jb*jS3;9u;j}_NQIcxivS~MP&Pn&O6hUJR!wAq4!=K0evH=J>6qApvibQO8y z>uOs8ODNfDdMGYDYPjq2+fvgy$P!&u1F-0d`}tXKb-;NVJl_NflbWVg9RjR`Q}nzA z%xe%bUuCy$5(JA@*)tx>X6lPJ{Kw-XEdyamJQ@O~I!IlO{Uwy%ZCxvj0;4wzehm2% zV~Drvr+nO^rBwPV0>#AEu1AYG_J{l(*qg(r3>N{d1hNs#xPu`&pfQg~^-fH?N)@)6*(#6(Zso2Xq{` zVYdpzT1PnDilg*Gos|_wx?!Gn9w#YGtJB`6Y=e961T1{kX^Vr#z3?U73%$)Ed0lPN zR(st>d4=^()n9RqiytUVZa|g2fy|RWByrqzng>^?h^K1j?-38XK(`t(z<@ zD}g3QB;|X^b0Ju;n8Luz7gLR2U7)CTc%j#s5x!o;kJT-E9J1Z=wC7uv@w|FY^{O0h zVx@%~zv5rMsoXoa!vh}dm7_BPGm+8e`A-g?e*Y!hO7bD#)OXF@itG;$4nO$t6!L97 z|NYNTPmiB||Ku2K?aARc2N>S*%7u>h-aj~6QRejEtJCkF93C9)7Yp_q3M#}jybhi{ zJAPJ#WV14@m1ozBnMWc-qYBh)=F>}r7)?3~&cZ3?f$q$qFoVrc(TKJ)pMyCPI|ymJ z;66*d-nK!4U4~#J?EP^tou0u*r8ERsyR;cnzagTlfg9z4<%Rn)icg}SLvCTeAUy4k zl&)@0l5(Rwi{PsnIBZalU=$DOq+j+VE9gwgqbrM*dkw`3h^vBd2i+A91$GK{LGT0a zdj422%B4@BDu!sz1$WX%Z*JT8=G6g5>GH7ty-g6 z<;E(NLS>ccWjOvYq_1>5(lvyRBpaD)(L-usR;P?(S<~!R6_3G(2&UBU#|bzoP>uff zz;f{lc@&QV*dvHH(%{ZS%dCm;f2y`sbN-S$9mU{85^Y$}zXC96B#3qE+p~At-5lRl z!zE3rK%EL!c5)@U)O-%aEqf@UqVAOvjXjJQeXU3HNTwV|%kp`~v9I-PnBGQcbE$$7P6LEu?9W}})( z4)GkBIA&`|wCNXiIWEGKe{xaDCrx#W?5`DBru1#N|0=MR$qsHj01r5z|$9V29CIC8YD@iRJ_qn1~k*OWGPEK~68 z2l#|4b7E+NbAjHpubh^8IUXx$#~?S*PW5@^BH5Ryf|!FK~mU`c%m~u zwK#PR-WlBv6y75Zkj1Xv0N!9 zo`4awkf(v};{#d5WoIb(al*>T8v4AUtg__wJ$doriN%4D%P;E9Qm77n968D_f#f{+ z3W_TFX%X^)k3N7nNSR%TRHiAE8bEedov+mnrIkCX$(T;RJ>I6gLxZ8DNU`SOY4FGK zCMb4Fmdq$xRb`Xlv^HCe7d!j{kjDu{Zc7K&lDVpsBfh@>W6e!qC05sSO)1&(VBp&m zx$>rtA*pmn0n)LP!kHkVVo&GMi?B)?!a1MA^eD_AI$FjPuO-FHJ8RH?1QYCv>~F2- z7(RJ(=grlu*Sx(5m+@W%(`ei?Rx>Nd*}Gtq9PT<-*!qPm(A5q&N?d59v1QD&kk1>( z-SQboRa9rcLH(y1!3d+B(HTaj@X$MO>2ih|hL6jND^!@+106HEr7GbPLus4TShFsn zqx)-cj+i6n#X*4{*JTG^+tUZi-Jo<+DIv73bzMO15kE~WUi-R$dmJ}Ks;qHfLiM0k zwNX}uEI0l~&wAhg?_GvjuyNC;_WS?*flmhT@Baf9o(tvs@cQum|ND4wPuLTT0cHGy z`#Xl+!pqcmj8&r1!jIRB%Y}AYhCEfu&g-Q+#xFSNQc&zL;(rduxfTrJhCUN((op={ z=L94iEocF94rT(dfV60YwSI~CS^C3$NCwcfQh_HTcW^|LpuP{YTXqF`Iqi!8(1^1g z<9{{0S3awlyp0hZw3Zt;e^!lu)A9NE_wehTP~L&h>^zMB`*_$r4cjbww#^nb4qoyL z!$#@G!}H&MOxv=ok!yy&H6FuX#Ychrp7&`s%%=Ab7 zu{VZqDvljH{G0qg+jk%Qzx#Mr@}Dg6#;u+e`rm<1gDU=;7S8{jhxp(9JWrDhJg!4_ z&@db&aQhaXh@cNErQ$Nj7tA$GVbv$Q3SouGHZ8x`@4@$bEnEP?_d(C@SvI`GvLLPE zi0epYG~u<@YpqkZBqZ*?<%i%z#DC|tmen(u`^YY%!z0zako;IemQY^tO8+-gapmOM zQY`w9=fU|oyYg-fFA431miNCBBz)r)#f}9$-M3_S zEvIMT=NZfRvI`dE9V68!GGL_(WCeN3n`4pVeMz2x*4$7!h9#C%V`pDLR};84?4EXt(CKbwuBF08$7`4hpqY z_zntB6FVBK0-| z(G*vBN7Kca=r^Z(R8x@3jNaj*IQ+OA-@*eYeB>q5a#zSjmd(Exsppn_-iREb+QPA%sHw