CharacterKit/CharacterVisual.cs

CharacterVisual is a presentation helper that creates and updates a character's visible model, clothing, skin, hair and deformation for portraits, editor and world prefabs. It loads required resources, composes skinned renderers, applies colors/deformation and toggles clothing, and remembers applied state for incremental updates. VoiceIds is a small helper that validates a set of supported voice id strings.

File AccessNetworking
using System;
using System.Text.Json;
using System.Threading.Tasks;

namespace CharacterEditor;
/// <summary>The common presentation used by portraits, the editor and world prefabs.</summary>
public sealed class CharacterVisual : IDisposable
{
	public static readonly string[] OutfitPaths = WardrobeCatalog.DefaultPaths;
	readonly GameObject owner;
	readonly bool ownsBody;
	readonly Dresser dresser;
	readonly SkinColorPresentation colors = new();
	readonly SkinColorPresentation neckColors = new();
	SkinnedModelRenderer replacementNeck;
	readonly List<GameObject> details = new(), clothes = new();
	readonly Dictionary<GameObject, Clothing> detailItems = new();
	readonly Dictionary<SkinnedModelRenderer, SkinColorPresentation> clothingSkin = new();
	GameObject deformation;
	string requested, composition, shaped;
	int appliedCosmeticRevision;
	Task resources;
	bool clothing, bodyGroupsDirty;
	CharacterAppearanceData appliedAppearance;
	Model appliedModel;
	string appliedGroup;
	ulong appliedBodyGroups;
	bool appliedDresser;
	readonly List<ClothingContainer.ClothingEntry> appliedWardrobe = new();
	readonly List<(SkinnedModelRenderer Renderer, Model Model, string Group)> appliedRenderers = new();
	public SkinnedModelRenderer Body { get; private set; }
	public string Error { get; private set; }
	public bool Loading => resources is not null && !resources.IsCompleted;

	public CharacterVisual(GameObject owner, SkinnedModelRenderer body = null, Dresser dresser = null)
	{
		this.owner = owner;
		Body = body;
		ownsBody = body is null;
		this.dresser = dresser;
	}

	public static async Task Load(CharacterAppearanceData data)
	{
		await AppearanceCatalog.EnsureLoadedAsync(data);
		await WardrobeCatalog.Load(data);
	}

	public void Update(CharacterAppearanceData data, bool showClothing = true)
	{
		// Keep streaming cosmetics alive, but leave unchanged geometry and wardrobe alone.
		// Compare values, not object identity: callers can edit the public data in place.
		if (Error is null && appliedCosmeticRevision == CharacterMakeupData.CosmeticRevision && appliedAppearance is not null && AppearanceComparison.Same(appliedAppearance, data))
		{
			if (showClothing == clothing && PresentationUnchanged())
			{
				colors.Refresh();
				return;
			}

			if (Body.IsValid() && (Body.Model != appliedModel || Body.MaterialGroup != appliedGroup))
				composition = null;
			Invalidate();
		}

		if (CharacterAppearanceData.Validate(data)is string invalid)
		{
			Error = invalid;
			return;
		}

		// Cosmetic inputs can change without changing mesh composition.
		var loadKey = CharacterAppearanceData.CompositionKey(data) + "|" + data.SkinPath + "|" + data.Makeup.Active + "|" + data.CustomEyeColor + "|matched-neck-v2";
		if (requested != loadKey)
		{
			requested = loadKey;
			resources = Load(data.DeepCopy());
			shaped = null;
			Error = null;
		}

		if (Loading)
			return;
		if (resources?.IsFaulted == true)
		{
			Error = resources.Exception.GetBaseException().Message;
			return;
		}

		try
		{
			UpdateLoaded(data, showClothing);
			if (Error is null && !Loading)
				Remember(data);
		}
		catch (Exception failure)
		{
			Error = failure.Message;
		}
	}

	public void Invalidate()
	{
		appliedAppearance = null;
		shaped = null;
		bodyGroupsDirty = true;
		colors.Invalidate();
	}

	bool PresentationUnchanged()
	{
		if (!Body.IsValid() || !deformation.IsValid() || Body.Model != appliedModel || Body.MaterialGroup != appliedGroup || Body.BodyGroups != appliedBodyGroups)
			return false;
		if (dresser.IsValid() != appliedDresser)
			return false;
		if (dresser.IsValid())
		{
			if (dresser.Clothing.Count != appliedWardrobe.Count)
				return false;
			for (int i = 0; i < appliedWardrobe.Count; i++)
			{
				var a = dresser.Clothing[i];
				var b = appliedWardrobe[i];
				if (a.Clothing != b.Clothing || a.Tint != b.Tint || a.ItemDefinitionId != b.ItemDefinitionId || a.Bone != b.Bone || a.Transform != b.Transform)
					return false;
			}
		}

		int index = 0;
		foreach (var renderer in Body.GameObject.GetComponentsInChildren<SkinnedModelRenderer>(true, true))
		{
			if (index >= appliedRenderers.Count)
				return false;
			var previous = appliedRenderers[index++];
			if (renderer != previous.Renderer || renderer.Model != previous.Model || renderer.MaterialGroup != previous.Group)
				return false;
		}

		return index == appliedRenderers.Count;
	}

	void Remember(CharacterAppearanceData data)
	{
		appliedCosmeticRevision = CharacterMakeupData.CosmeticRevision;
		appliedAppearance = data.DeepCopy();
		appliedModel = Body.Model;
		appliedGroup = Body.MaterialGroup;
		appliedBodyGroups = Body.BodyGroups;
		appliedDresser = dresser.IsValid();
		appliedWardrobe.Clear();
		if (dresser.IsValid())
			foreach (var e in dresser.Clothing)
				appliedWardrobe.Add(new() { Clothing = e.Clothing, Tint = e.Tint, ItemDefinitionId = e.ItemDefinitionId, Bone = e.Bone, Transform = e.Transform });
		appliedRenderers.Clear();
		foreach (var r in Body.GameObject.GetComponentsInChildren<SkinnedModelRenderer>(true, true))
			appliedRenderers.Add((r, r.Model, r.MaterialGroup));
	}

	void UpdateLoaded(CharacterAppearanceData data, bool showClothing)
	{
		if (CharacterAppearance.ValidateForApply(data)is string error)
		{
			Error = error;
			return;
		}

		if (!Body.IsValid())
		{
			var go = new GameObject
			{
				Parent = owner,
				Name = "Human",
				NetworkMode = NetworkMode.Never
			};
			go.LocalTransform = Transform.Zero;
			Body = go.Components.Create<SkinnedModelRenderer>();
		}

		var key = CharacterAppearanceData.CompositionKey(data);
		if (dresser.IsValid())
			key += "|" + new ClothingContainer
			{
				Clothing = dresser.Clothing
			}.Serialize();
		var skin = ClothingResources.Get(UnifiedHuman.PresentationSkin(data));
		if (composition != key)
		{
			Body.Model = Model.Load(data.BaseShape is null ? HumanMarkingModels.ModelPath(skin.HumanSkinModel, data.Makeup) : UnifiedHuman.ModelPath);
			Body.MaterialGroup = data.BaseShape is null ? skin.HumanSkinMaterialGroup : "skin_light";
			Body.BodyGroups = data.BaseShape is null ? skin.HumanSkinBodyGroups : ulong.MaxValue;
			foreach (var presentation in clothingSkin.Values)
				presentation.Dispose();
			clothingSkin.Clear();
			if (!dresser.IsValid())
				foreach (var go in details.Concat(clothes))
					if (go.IsValid())
						go.Destroy();
			details.Clear();
			detailItems.Clear();
			clothes.Clear();
			var selected = new List<Clothing>
			{
				skin
			};
			selected.AddRange(new[] { data.HairPath, data.FacialHairPath }.Where(x => !string.IsNullOrEmpty(x)).Select(ClothingResources.Get));
			var outfit = WardrobeCatalog.Paths(data).Select(ClothingResources.Get).ToArray();
			var all = selected.Concat(outfit).ToArray();
			var tags = new TagSet(skin.HumanSkinTags.TryGetAll());
			tags.Add("human");
			if (!dresser.IsValid())
			{
				foreach (var item in selected.Skip(1))
					Add(item, details, data.HairColor);
				foreach (var item in outfit)
					Add(item, clothes, item.AllowTintSelect ? item.TintSelection.Evaluate(item.TintDefault) : Color.White);
			}

			void Add(Clothing item, List<GameObject> list, Color tint)
			{
				var model = UnifiedWardrobe.Resolve(item, all, tags, data);
				var go = new GameObject
				{
					Parent = Body.GameObject,
					Name = item.Title,
					NetworkMode = NetworkMode.Never
				};
				go.LocalTransform = Transform.Zero;
				var r = go.Components.Create<SkinnedModelRenderer>();
				r.Model = model;
				r.MaterialGroup = item.MaterialGroup;
				r.Tint = tint;
				r.BoneMergeTarget = Body;
				list.Add(go);
				if (list == details)
					detailItems[go] = item;
			}

			composition = key;
			shaped = null;
			clothing = !showClothing;
		}

		if (dresser.IsValid())
		{
			var expected = Model.Load(UnifiedHuman.ModelPath);
			if (Body.Model != expected)
			{
				Body.Model = expected;
				Body.MaterialGroup = "skin_light";
				colors.Invalidate();
				shaped = null;
			}

			details.RemoveAll(go => !go.IsValid());
			clothes.RemoveAll(go => !go.IsValid());
			foreach (var renderer in clothingSkin.Keys.Where(r => !r.IsValid()).ToArray())
			{
				clothingSkin[renderer].Dispose();
				clothingSkin.Remove(renderer);
			}

			var entries = dresser.Clothing.Where(e => e.Clothing is not null).ToArray();
			var tags = new TagSet(skin.HumanSkinTags.TryGetAll());
			tags.Add("human");
			var selectedItems = entries.Select(e => e.Clothing).ToArray();
			foreach (var renderer in Body.GameObject.GetComponentsInChildren<SkinnedModelRenderer>(true, true).Where(r => r != Body && r != replacementNeck))
			{
				var item = entries.FirstOrDefault(e => renderer.GameObject.Name == "Clothing - " + e.Clothing.ResourceName);
				var model = item is not null ? UnifiedWardrobe.Resolve(item.Clothing, selectedItems, tags, data) : renderer.Model;
				if (model is not null && renderer.Model != model)
				{
					renderer.Model = model;
					shaped = null;
				}

				bool hair = item is not null && (ClothingResources.IsHair(item.Clothing) || ClothingResources.IsFacialHair(item.Clothing));
				if (hair && item.Tint is null)
					renderer.Tint = data.HairColor;
				var list = hair ? details : clothes;
				if (hair)
					detailItems[renderer.GameObject] = item.Clothing;
				if (!list.Contains(renderer.GameObject))
				{
					list.Add(renderer.GameObject);
					shaped = null;
				}
			}
		}

		var signature = JsonSerializer.Serialize(data) + key + data.Makeup.PresentationKey();
		if (shaped != signature)
		{
			deformation = CharacterDeformation.Update(deformation, Body, data);
			colors.Apply(Body, data.SkinColor, data.NormalizeSkinTone, data.Makeup, data.SkinPath, data);
			// Some garments contain exposed arms/legs and hide the corresponding body mesh.
			// Their native skin slots must use the same selected surface as the main human.
			foreach (var go in clothes)
			{
				if (!go.IsValid())
					continue;
				// Hidden clothing still needs current skin settings before it is shown again.
				var renderer = go.Components.Get<SkinnedModelRenderer>(FindMode.InSelf | FindMode.Enabled | FindMode.Disabled);
				if (!renderer.IsValid())
					continue;
				if (!clothingSkin.TryGetValue(renderer, out var presentation))
					clothingSkin[renderer] = presentation = new();
				presentation.ApplyEmbeddedSkin(renderer, data);
				UnifiedWardrobe.Apply(renderer, data);
			}

			if (!dresser.IsValid())
				foreach (var go in details)
					go.Components.Get<SkinnedModelRenderer>(FindMode.InSelf | FindMode.Enabled | FindMode.Disabled).Tint = data.HairColor;
			shaped = signature;
		}
		else
			colors.Refresh();
		UnifiedHuman.Apply(Body, data);
		if (clothing != showClothing || bodyGroupsDirty)
		{
			foreach (var go in clothes)
				if (go.IsValid())
					go.Enabled = showClothing;
			Body.BodyGroups = data.BaseShape is null ? skin.HumanSkinBodyGroups : ulong.MaxValue;
			var selected = dresser.IsValid() ? dresser.Clothing.Select(e => e.Clothing).Where(c => c is not null) : new[]
			{
				skin
			}.Concat(new[] { data.HairPath, data.FacialHairPath }.Where(x => !string.IsNullOrEmpty(x)).Select(ClothingResources.Get));
			if (showClothing && !dresser.IsValid())
				selected = selected.Concat(WardrobeCatalog.Paths(data).Select(ClothingResources.Get));
			if (dresser.IsValid() && !showClothing)
				selected = selected.Where(i => ClothingResources.IsBaseSkin(i) || ClothingResources.IsHair(i) || ClothingResources.IsFacialHair(i));
			var activeItems = selected.ToArray();
			// Hairstyles are stored separately from outfits. Respect the native slot
			// compatibility without deleting the chosen style when headwear covers it.
			foreach (var go in details)
				if (go.IsValid() && detailItems.TryGetValue(go, out var hair))
					go.Enabled = !activeItems.Any(other => other != hair && !ClothingResources.IsBaseSkin(other) && !hair.CanBeWornWith(other));
			foreach (var group in new ClothingContainer
			{
				PrefersHuman = true
			}.GetBodyGroups(activeItems, Body.Model))
			{
				if (group.Item1 == "Head" && !activeItems.Any(i => !ClothingResources.IsBaseSkin(i) && (i.HideBody & Clothing.BodyGroups.Head) != 0) && (data.BaseShape is not null || group.Item2 == 0))
					continue;
				Body.SetBodyGroup(group.Item1, group.Item2);
			}

			clothing = showClothing;
			bodyGroupsDirty = false;
		}

		if (data.BaseShape is not null)
		{
			var worn = dresser.IsValid() ? dresser.Clothing.Where(e => e.Clothing is not null).Select(e => e.Clothing.ResourcePath) : WardrobeCatalog.Paths(data);
			bool covered = showClothing && worn.Any(path => (ClothingResources.Get(path)?.HideBody.ToString() ?? "").Split(',').Any(x => x.Trim() == "Chest"));
			Body.SetBodyGroup("UnderwearTop", data.BaseShape.MaleBody > .60f || covered ? 1 : 0);
		}

		bool needsNeck = data.BaseShape is not null && showClothing && Body.GetBodyGroup("Head") == 1;
		if (needsNeck)
		{
			var wornHeads = dresser.IsValid() ? dresser.Clothing.Where(e => e.Clothing is not null).Select(e => e.Clothing) : WardrobeCatalog.Paths(data).Select(ClothingResources.Get);
			needsNeck = !wornHeads.Any(item => (item.HideBody & Clothing.BodyGroups.Head) != 0 && !UnifiedWardrobe.RetainsHumanNeck(item));
		}
		if (needsNeck && !replacementNeck.IsValid())
		{
			var go = new GameObject { Parent = Body.GameObject, Name = "Replacement head neck", NetworkMode = NetworkMode.Never };
			go.LocalTransform = Transform.Zero;
			replacementNeck = go.Components.Create<SkinnedModelRenderer>();
			replacementNeck.Model = AutomaticWardrobeFit.ReplacementNeck();
			replacementNeck.BoneMergeTarget = Body;
		}
		if (replacementNeck.IsValid())
		{
			replacementNeck.GameObject.Enabled = needsNeck;
			if (needsNeck)
			{
				UnifiedWardrobe.Apply(replacementNeck, data);
				neckColors.ApplyEmbeddedSkin(replacementNeck, data);
			}
		}

		Error = null;
	}

	public void Dispose()
	{
		colors.Dispose();
		neckColors.Dispose();
		if (replacementNeck.IsValid()) replacementNeck.GameObject.Destroy();
		foreach (var presentation in clothingSkin.Values)
			presentation.Dispose();
		clothingSkin.Clear();
		if (deformation.IsValid())
			deformation.Destroy();
		if (ownsBody && Body.IsValid())
			Body.GameObject.Destroy();
	}
}

public static class VoiceIds
{
	public static bool IsSupported(string id) => id is "boy-teen" or "elven-woman" or "girl-young" or "man-adult" or "man-adult-deep" or "scientist" or "woman-adult" or "woman-young-adult";
}