From 1e3457cfd46ceaf8cc4bbe1af5731e6dad8e5d83 Mon Sep 17 00:00:00 2001 From: Edgar Date: Fri, 18 Aug 2023 16:54:59 +0200 Subject: [PATCH] :sparkles: "Update Existing Avatar" now properly updates the fst file It will now keep any previous edits instead of creating a brand-new file --- .../Editor/AvatarExporter/AvatarExporter.cs | 196 +++------- .../Assets/Editor/AvatarExporter/FST.cs | 364 ++++++++++++++++++ .../Assets/Editor/AvatarExporter/FST.cs.meta | 11 + 3 files changed, 423 insertions(+), 148 deletions(-) create mode 100644 tools/unity-avatar-exporter/Assets/Editor/AvatarExporter/FST.cs create mode 100644 tools/unity-avatar-exporter/Assets/Editor/AvatarExporter/FST.cs.meta diff --git a/tools/unity-avatar-exporter/Assets/Editor/AvatarExporter/AvatarExporter.cs b/tools/unity-avatar-exporter/Assets/Editor/AvatarExporter/AvatarExporter.cs index ba8eaea8b5..2c83e38e04 100644 --- a/tools/unity-avatar-exporter/Assets/Editor/AvatarExporter/AvatarExporter.cs +++ b/tools/unity-avatar-exporter/Assets/Editor/AvatarExporter/AvatarExporter.cs @@ -1,4 +1,4 @@ -// AvatarExporter.cs +// AvatarExporter.cs // // Created by David Back on 28 Nov 2018 // Copyright 2018 High Fidelity, Inc. @@ -15,12 +15,12 @@ using System; using System.Collections.Generic; using System.IO; using System.Globalization; - +using Overte; 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.6.0"; + public static readonly string AVATAR_EXPORTER_VERSION = "0.6.0"; static readonly float HIPS_MIN_Y_PERCENT_OF_HEIGHT = 0.03f; static readonly float BELOW_GROUND_THRESHOLD_PERCENT_OF_HEIGHT = -0.15f; @@ -223,106 +223,13 @@ class AvatarExporter : MonoBehaviour AvatarRule.HeadMapped, }; - class UserBoneInformation - { - public string humanName; // bone name in Humanoid if it is mapped, otherwise "" - public string parentName; // parent user bone name - public BoneTreeNode boneTreeNode; // node within the user bone tree - public int mappingCount; // number of times this bone is mapped in Humanoid - public Vector3 position; // absolute position - public Quaternion rotation; // absolute rotation - - public UserBoneInformation() - { - humanName = ""; - parentName = ""; - boneTreeNode = new BoneTreeNode(); - mappingCount = 0; - position = new Vector3(); - rotation = new Quaternion(); - } - public UserBoneInformation(string parent, BoneTreeNode treeNode, Vector3 pos) - { - humanName = ""; - parentName = parent; - boneTreeNode = treeNode; - mappingCount = 0; - position = pos; - rotation = new Quaternion(); - } - - public bool HasHumanMapping() { return !string.IsNullOrEmpty(humanName); } - } - - class BoneTreeNode - { - public string boneName; - public string parentName; - public List children = new List(); - - public BoneTreeNode() { } - public BoneTreeNode(string name, string parent) - { - boneName = name; - parentName = parent; - } - } - - class MaterialData - { - public Color albedo; - public string albedoMap; - public double metallic; - public string metallicMap; - public double roughness; - public string roughnessMap; - public string normalMap; - public string occlusionMap; - public Color emissive; - public string emissiveMap; - - public string getJSON() - { - string json = "{ \"materialVersion\": 1, \"materials\": { "; - - //Albedo - json += $"\"albedo\": [{albedo.r.F()}, {albedo.g.F()}, {albedo.b.F()}], "; - if (!string.IsNullOrEmpty(albedoMap)) - json += $"\"albedoMap\": \"{albedoMap}\", "; - - //Metallic - json += $"\"metallic\": {metallic.F()}, "; - if (!string.IsNullOrEmpty(metallicMap)) - json += $"\"metallicMap\": \"{metallicMap}\", "; - - //Roughness - json += $"\"roughness\": {roughness.F()}, "; - if (!string.IsNullOrEmpty(roughnessMap)) - json += $"\"roughnessMap\": \"{roughnessMap}\", "; - - //Normal - if (!string.IsNullOrEmpty(normalMap)) - json += $"\"normalMap\": \"{normalMap}\", "; - - //Occlusion - if (!string.IsNullOrEmpty(occlusionMap)) - json += $"\"occlusionMap\": \"{occlusionMap}\", "; - - //Emissive - json += $"\"emissive\": [{emissive.r.F()}, {emissive.g.F()}, {emissive.b.F()}]"; - if (!string.IsNullOrEmpty(emissiveMap)) - json += $", \"emissiveMap\": \"{emissiveMap}\""; - - json += " } }"; - return json; - } - } - static string assetPath = ""; static string assetName = ""; static ModelImporter modelImporter; static HumanDescription humanDescription; + static FST currentFst; + static Dictionary userBoneInfos = new Dictionary(); static Dictionary humanoidToUserBoneMappings = new Dictionary(); static BoneTreeNode userBoneTree = new BoneTreeNode(); @@ -362,6 +269,15 @@ class AvatarExporter : MonoBehaviour EditorUtility.DisplayDialog("About", "Avatar Exporter\nVersion " + AVATAR_EXPORTER_VERSION + "\nCopyright 2022 to 2023 Overte e.V.\nCopyright 2018 High Fidelity, Inc.", "Ok"); } + /*[MenuItem("Overte/Test")] + static void Test() + { + var f = new FST(); + f.LoadFile(@"E:\TMP2\test\avatar.fst"); + Debug.Log(f.remapBlendShapeList[1]); + f.ExportFile(@"E:\TMP2\test\avatar2.fst"); + }*/ + static void ExportSelectedAvatar(bool updateExistingAvatar) { // ensure everything is saved to file before doing anything @@ -613,21 +529,13 @@ class AvatarExporter : MonoBehaviour } } - // delete existing fst file since we will write a new file - // TODO: updating fst should only rewrite joint mappings and joint rotation offsets to existing file - try - { - File.Delete(exportFstPath); - } - catch - { - EditorUtility.DisplayDialog("Error", "Failed to overwrite existing file " + exportFstPath + - ". Please check the file and try again.", "Ok"); - return; - } + currentFst = new FST(); + // load the old file first + currentFst.LoadFile(exportFstPath); + currentFst.scale = scale; // write out a new fst file in place of the old file - if (!WriteFST(exportFstPath, projectName, scale)) + if (!WriteFST(exportFstPath)) { return; } @@ -662,7 +570,14 @@ class AvatarExporter : MonoBehaviour // write out the avatar.fst file to the project directory string exportFstPath = projectDirectory + "avatar.fst"; - if (!WriteFST(exportFstPath, projectName, scale)) + + currentFst = new FST + { + name = projectName, + scale = scale, + filename = $"{assetName}.fbx" + }; + if (!WriteFST(exportFstPath)) { return; } @@ -697,34 +612,18 @@ class AvatarExporter : MonoBehaviour return jointName.Substring(jointName.IndexOf(':') + 1); } - static bool WriteFST(string exportFstPath, string projectName, float scale) + static bool WriteFST(string exportFstPath) { - // write out core fields to top of fst file - try - { - File.WriteAllText(exportFstPath, - $"exporterVersion = {AVATAR_EXPORTER_VERSION}\n" + - $"name = {projectName}\n" + - "type = body+head\n" + - $"scale = {scale.F()}\n" + - $"filename = {assetName}.fbx\n" + - "texdir = textures\n" - ); - } - catch - { - EditorUtility.DisplayDialog("Error", "Failed to write file " + exportFstPath + - ". Please check the location and try again.", "Ok"); - return false; - } - // write out joint mappings to fst file foreach (var userBoneInfo in userBoneInfos) { if (userBoneInfo.Value.HasHumanMapping()) { - string overteJointName = HUMANOID_TO_OVERTE_JOINT_NAME[userBoneInfo.Value.humanName]; - File.AppendAllText(exportFstPath, $"jointMap = {overteJointName} = {removeTypeFromJointname(userBoneInfo.Key)}\n"); + string jointName = HUMANOID_TO_OVERTE_JOINT_NAME[userBoneInfo.Value.humanName]; + if (!currentFst.jointMapList.Exists(x => x.From == jointName)) + currentFst.jointMapList.Add(new JointMap(jointName, removeTypeFromJointname(userBoneInfo.Key))); + else + currentFst.jointMapList.Find(x => x.From == jointName).To = removeTypeFromJointname(userBoneInfo.Key); } } @@ -772,11 +671,15 @@ class AvatarExporter : MonoBehaviour } } - // swap from left-handed (Unity) to right-handed (Overte) coordinates and write out joint rotation offset to fst - jointOffset = new Quaternion(-jointOffset.x, jointOffset.y, jointOffset.z, -jointOffset.w); - File.AppendAllText(exportFstPath, - $"jointRotationOffset2 = {removeTypeFromJointname(userBoneName)} = ({jointOffset.x.F()}, {jointOffset.y.F()}, {jointOffset.z.F()}, {jointOffset.w.F()})\n" - ); + var norBName = removeTypeFromJointname(userBoneName); + if (!currentFst.jointRotationList.Exists(x => x.BoneName == norBName)) + // swap from left-handed (Unity) to right-handed (Overte) coordinates and write out joint rotation offset to fst + currentFst.jointRotationList.Add( + new JointRotationOffset(norBName, -jointOffset.x, jointOffset.y, jointOffset.z, -jointOffset.w) + ); + else + currentFst.jointRotationList.Find(x => x.BoneName == norBName).offset = + new Quaternion(-jointOffset.x, jointOffset.y, jointOffset.z, -jointOffset.w); } // if there is any material data to save then write out all materials in JSON material format to the materialMap field @@ -788,11 +691,13 @@ class AvatarExporter : MonoBehaviour // if this is the only material in the mapping and it is mapped to default material name No Name, // then the avatar has no embedded materials and this material should be applied to all meshes string matName = (materialMappings.Count == 1 && materialData.Key == DEFAULT_MATERIAL_NAME) ? "all" : $"mat::{materialData.Key}"; - matData.Add($"\"{matName}\": {materialData.Value.getJSON()}"); + matData.Add($"\"{matName}\": {materialData.Value}"); } - File.AppendAllText(exportFstPath, $"materialMap = {{{string.Join(",", matData)}}}"); + currentFst.materialMap = $"{{{string.Join(",", matData)}}}"; } + var res = currentFst.ExportFile(exportFstPath); + EditorPrefs.SetString("OV_LAST_PROJECT_PATH", exportFstPath); /*if (SystemInfo.operatingSystemFamily == OperatingSystemFamily.Windows) @@ -801,7 +706,7 @@ class AvatarExporter : MonoBehaviour System.Diagnostics.Process.Start("explorer.exe", "/select," + exportFstPath); }*/ - return true; + return res; } static void SetBoneAndMaterialInformation() @@ -1917,11 +1822,6 @@ class AvatarUtilities } } -public static class ConverterExtensions -{ - //Helper function to convert floats to string without commas - public static string F(this float x) => x.ToString("G", CultureInfo.InvariantCulture); - public static string F(this double x) => x.ToString("G", CultureInfo.InvariantCulture); -} + #endif diff --git a/tools/unity-avatar-exporter/Assets/Editor/AvatarExporter/FST.cs b/tools/unity-avatar-exporter/Assets/Editor/AvatarExporter/FST.cs new file mode 100644 index 0000000000..d2ebd5a312 --- /dev/null +++ b/tools/unity-avatar-exporter/Assets/Editor/AvatarExporter/FST.cs @@ -0,0 +1,364 @@ + + + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using UnityEditor; +using UnityEngine; +using System.Globalization; + +namespace Overte +{ + class JointMap + { + public string From; + public string To; + + private Regex parseRx = new Regex(@"^(?[\w]*)\s*=\s*(?.*)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public JointMap(string RawInput) + { + var parsed = parseRx.Matches(RawInput).First(); + From = parsed.Groups["From"].Value.Trim(); + To = parsed.Groups["To"].Value.Trim(); + } + + public JointMap(string f, string t) + { + From = f; To = t; + } + + public override string ToString() + { + return $"jointMap = {From} = {To}"; + } + } + + class JointRotationOffset + { + public string BoneName; + public Quaternion offset; + + private Regex parseRx = new Regex(@"(?.*)\s*=\s*\(\s*(?.*)\s*,\s*(?.*)\s*,\s*(?.*)\s*,\s*(?.*)\s*\)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public JointRotationOffset(string value) + { + var parsed = parseRx.Matches(value).First(); + BoneName = parsed.Groups["BoneName"].Value.Trim(); + offset = new Quaternion + { + x = float.Parse(parsed.Groups["X"].Value), + y = float.Parse(parsed.Groups["Y"].Value), + z = float.Parse(parsed.Groups["Z"].Value), + w = float.Parse(parsed.Groups["W"].Value) + }; + } + + public JointRotationOffset(string boneName, float x, float y, float z, float w) + { + BoneName = boneName; + offset = new Quaternion(x, y, z, w); + } + + public override string ToString() + { + return $"jointRotationOffset2 = {BoneName} = ({offset.x.F()}, {offset.y.F()}, {offset.z.F()}, {offset.w.F()})"; + } + + } + + class RemapBlendShape + { + public string From; + public string To; + public float Multiplier; + + private Regex parseRx = new Regex(@"(?.*)\s*=\s*(?.*)\s*=\s*(?.*)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public RemapBlendShape(string rawData) + { + var parsed = parseRx.Matches(rawData).First(); + From = parsed.Groups["From"].Value.Trim(); + To = parsed.Groups["To"].Value.Trim(); + Multiplier = float.Parse(parsed.Groups["Multiplier"].Value); + } + + public override string ToString() + { + return $"bs = {From} = {To} = {Multiplier}"; + } + } + + class JointIndex + { + public string BoneName; + public int Index; + + private Regex parseRx = new Regex(@"^(?[\w]*)\s*=\s*(?.*)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public JointIndex(string rawData) + { + var parsed = parseRx.Matches(rawData).First(); + BoneName = parsed.Groups["BoneName"].Value.Trim(); + Index = int.Parse(parsed.Groups["Index"].Value); + } + } + + class FST + { + public readonly string exporterVersion = AvatarExporter.AVATAR_EXPORTER_VERSION; + public string name; + public string type = "body+head"; + public float scale = 1.0f; + public string filename; + public string texdir = "textures"; + public string materialMap; + public string script; + + public List remapBlendShapeList = new List(); + + public List jointMapList = new List(); + public List jointRotationList = new List(); + public List jointIndexList = new List(); + + private Regex parseRx = new Regex(@"^(?[\w]*)\s*=\s*(?.*)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public string flowPhysicsData; + public string flowCollisionsData; + + public string lod; + public string joint; + + List fstContent = new List(); + + public bool ExportFile(string fstPath) + { + fstContent = new List + { + $"exporterVersion = {exporterVersion}", + $"name = {name}", + $"type = {type}", + $"scale = {scale.F()}", + $"filename = {filename}", + $"texdir = {texdir}" + }; + AddIfNotNull(remapBlendShapeList); + AddIfNotNull(jointMapList); + AddIfNotNull(jointIndexList); + AddIfNotNull(jointRotationList); + + AddIfNotNull(nameof(materialMap), materialMap); + AddIfNotNull(nameof(flowPhysicsData), flowPhysicsData); + AddIfNotNull(nameof(flowCollisionsData), flowCollisionsData); + AddIfNotNull(nameof(lod), lod); + AddIfNotNull(nameof(joint), joint); + AddIfNotNull(nameof(script), script); + + try + { + System.IO.File.WriteAllLines(fstPath, fstContent); + return true; + } + catch (Exception e) + { + EditorUtility.DisplayDialog("Error", "Failed to write file " + fstPath + + ". Please check the location and try again.", "Ok"); + Debug.LogException(e); + return false; + } + } + + private void AddIfNotNull(List list) + { + if (list.Count != 0) + fstContent.Add(string.Join("\n", list)); + } + + private void AddIfNotNull(string keyname, string valname) + { + if (!string.IsNullOrEmpty(valname)) + fstContent.Add($"{keyname} = {valname}"); + } + + + public bool LoadFile(string fstPath) + { + try + { + var rawFst = System.IO.File.ReadAllLines(fstPath); + + foreach (var l in rawFst) + { + var match = parseRx.Matches(l)[0]; + ParseLine(match.Groups["Key"].Value.Trim(), match.Groups["Value"].Value.Trim()); + } + + return true; + } + catch (Exception e) + { + EditorUtility.DisplayDialog("Error", "Failed to read file " + fstPath + + ". Please check the location and try again.", "Ok"); + Debug.LogException(e); + return false; + } + } + + private void ParseLine(string key, string value) + { + switch (key) + { + case "exporterVersion": + //Just ingnore the old exporterVersion + break; + case "name": + name = value; + break; + case "type": + type = value; + break; + case "scale": + scale = float.Parse(value); + break; + case "filename": + filename = value; + break; + case "texdir": + texdir = value; + break; + case "materialMap": + // The materialMap will be generated by unity, no need to parse it + // TODO:Parse it when changed to importing instead of updating + break; + + case "jointMap": + jointMapList.Add(new JointMap(value)); + break; + case "jointRotationOffset2": + jointRotationList.Add(new JointRotationOffset(value)); + break; + case "jointIndex": + jointIndexList.Add(new JointIndex(value)); + break; + + case "bs": + remapBlendShapeList.Add(new RemapBlendShape(value)); + break; + + default: + Debug.LogError($"Unknown key \"{key}\"\nPlease report this issue on the issue tracker"); + break; + } + } + + private KeyValuePair ParseKVPair(Regex rx, string sinput) + { + var match = rx.Matches(sinput).First(); + return new KeyValuePair(match.Groups["Key"].Value.Trim(), match.Groups["Value"].Value.Trim()); + } + } + + class UserBoneInformation + { + public string humanName; // bone name in Humanoid if it is mapped, otherwise "" + public string parentName; // parent user bone name + public BoneTreeNode boneTreeNode; // node within the user bone tree + public int mappingCount; // number of times this bone is mapped in Humanoid + public Vector3 position; // absolute position + public Quaternion rotation; // absolute rotation + + public UserBoneInformation() + { + humanName = ""; + parentName = ""; + boneTreeNode = new BoneTreeNode(); + mappingCount = 0; + position = new Vector3(); + rotation = new Quaternion(); + } + public UserBoneInformation(string parent, BoneTreeNode treeNode, Vector3 pos) + { + humanName = ""; + parentName = parent; + boneTreeNode = treeNode; + mappingCount = 0; + position = pos; + rotation = new Quaternion(); + } + + public bool HasHumanMapping() { return !string.IsNullOrEmpty(humanName); } + } + + class BoneTreeNode + { + public string boneName; + public string parentName; + public List children = new List(); + + public BoneTreeNode() { } + public BoneTreeNode(string name, string parent) + { + boneName = name; + parentName = parent; + } + } + + class MaterialData + { + public Color albedo; + public string albedoMap; + public double metallic; + public string metallicMap; + public double roughness; + public string roughnessMap; + public string normalMap; + public string occlusionMap; + public Color emissive; + public string emissiveMap; + + public override string ToString() + { + string json = "{ \"materialVersion\": 1, \"materials\": { "; + + //Albedo + json += $"\"albedo\": [{albedo.r.F()}, {albedo.g.F()}, {albedo.b.F()}], "; + if (!string.IsNullOrEmpty(albedoMap)) + json += $"\"albedoMap\": \"{albedoMap}\", "; + + //Metallic + json += $"\"metallic\": {metallic.F()}, "; + if (!string.IsNullOrEmpty(metallicMap)) + json += $"\"metallicMap\": \"{metallicMap}\", "; + + //Roughness + json += $"\"roughness\": {roughness.F()}, "; + if (!string.IsNullOrEmpty(roughnessMap)) + json += $"\"roughnessMap\": \"{roughnessMap}\", "; + + //Normal + if (!string.IsNullOrEmpty(normalMap)) + json += $"\"normalMap\": \"{normalMap}\", "; + + //Occlusion + if (!string.IsNullOrEmpty(occlusionMap)) + json += $"\"occlusionMap\": \"{occlusionMap}\", "; + + //Emissive + json += $"\"emissive\": [{emissive.r.F()}, {emissive.g.F()}, {emissive.b.F()}]"; + if (!string.IsNullOrEmpty(emissiveMap)) + json += $", \"emissiveMap\": \"{emissiveMap}\""; + + json += " } }"; + return json; + } + } + + public static class ConverterExtensions + { + //Helper function to convert floats to string without commas + public static string F(this float x) => x.ToString("G", CultureInfo.InvariantCulture); + public static string F(this double x) => x.ToString("G", CultureInfo.InvariantCulture); + } +} \ No newline at end of file diff --git a/tools/unity-avatar-exporter/Assets/Editor/AvatarExporter/FST.cs.meta b/tools/unity-avatar-exporter/Assets/Editor/AvatarExporter/FST.cs.meta new file mode 100644 index 0000000000..8af30ca3ca --- /dev/null +++ b/tools/unity-avatar-exporter/Assets/Editor/AvatarExporter/FST.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2472151ab437fca478d4c48d8d010c49 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: