Code/CharacterKit/AppearanceCatalog.cs

Catalog of curated character appearance assets and controls. Defines appearance option/preset/color/slider types, provides lists of skins, hair, facial hair, colors, sliders and presets, randomization and loading of native resources, and helpers to apply/reset presets and ensure assets are loaded.

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

namespace CharacterEditor;
public sealed record AppearanceOption(string Id, string Label, string ResourcePath, string BodyType = "", string Icon = "");
public sealed record AppearancePreset(string Id, string Label, CharacterAppearanceData Appearance);
public sealed record AppearanceColorOption(string Id, string Label, Color Color, bool NormalizeSkinTone = false);
public sealed class AppearanceSlider
{
	public string Id { get; }
	public string Label { get; }
	public string Group { get; }
	public float Min => 0;
	public float Max => 1;
	public float Neutral => Id is "eartaper" or "belly" ? 0 : 0.5f;

	public string LabelFor(CharacterAppearanceData data) => Id == "bodyfeature" ? (AppearanceCatalog.IsFemale(data) ? "Breast size" : "Bulge size") : Label;
	public AppearanceSlider(string id, string label, string group)
	{
		Id = id;
		Label = label;
		Group = group;
	}

	public float Get(CharacterAppearanceData data) => Id switch
	{
		"neck" => data.NeckSize,
		"trapezius" => data.TrapeziusSize,
		"collarbone" => data.CollarboneHeight,
		"belly" => data.BellySize,
		"waist" => data.WaistSize,
		"chest" => data.ChestSize,
		"fitness" => data.Fitness,
		"head" => data.HeadShape,
		"nose" => data.NoseSize,
		"nosewidth" => data.NoseWidth,
		"chin" => data.ChinSize,
		"chinwidth" => data.ChinWidth,
		"jaw" => data.JawWidth,
		"nostrils" => data.NostrilWidth,
		"forehead" => data.ForeheadWidth,
		"cheekbones" => data.CheekboneWidth,
		"ears" => data.EarSize,
		"eyes" => data.EyeSize,
		"cheeks" => data.CheekFullness,
		"nostrilfullness" => data.NostrilFullness,
		"noseheight" => data.NoseHeight,
		"chinheight" => data.ChinHeight,
		"submental" => data.SubmentalFullness,
		"brow" => data.BrowProminence,
		"bodyfeature" => data.BodyFeatureSize,
		"butt" => data.ButtSize,
		"feet" => data.FootSize,
		"eartaper" => data.EarTaper,
		"philtrum" => data.PhiltrumWidth,
		"columella" => data.ColumellaWidth,
		"mouthwidth" => data.MouthWidth,
		"mouthprojection" => data.MouthProjection,
		"upperlip" => data.UpperLipFullness,
		"lowerlip" => data.LowerLipFullness,
		_ => Neutral
	};
	public void Set(CharacterAppearanceData data, float value)
	{
		if (data is null || !float.IsFinite(value))
			return;
		value = Math.Clamp(value, Min, Max);
		// Store data rather than delegates in the catalog so native hotload can preserve these selectors.
		switch (Id)
		{
			case "neck":
				data.NeckSize = value;
				break;
			case "trapezius":
				data.TrapeziusSize = value;
				break;
			case "collarbone":
				data.CollarboneHeight = value;
				break;
			case "belly":
				data.BellySize = value;
				break;
			case "waist":
				data.WaistSize = value;
				break;
			case "chest":
				data.ChestSize = value;
				break;
			case "fitness":
				data.Fitness = value;
				break;
			case "head":
				data.HeadShape = value;
				break;
			case "nose":
				data.NoseSize = value;
				break;
			case "nosewidth":
				data.NoseWidth = value;
				break;
			case "chin":
				data.ChinSize = value;
				break;
			case "chinwidth":
				data.ChinWidth = value;
				break;
			case "jaw":
				data.JawWidth = value;
				break;
			case "nostrils":
				data.NostrilWidth = value;
				break;
			case "forehead":
				data.ForeheadWidth = value;
				break;
			case "cheekbones":
				data.CheekboneWidth = value;
				break;
			case "ears":
				data.EarSize = value;
				break;
			case "eyes":
				data.EyeSize = value;
				break;
			case "cheeks":
				data.CheekFullness = value;
				break;
			case "nostrilfullness":
				data.NostrilFullness = value;
				break;
			case "noseheight":
				data.NoseHeight = value;
				break;
			case "chinheight":
				data.ChinHeight = value;
				break;
			case "submental":
				data.SubmentalFullness = value;
				break;
			case "brow":
				data.BrowProminence = value;
				break;
			case "bodyfeature":
				data.BodyFeatureSize = value;
				break;
			case "butt":
				data.ButtSize = value;
				break;
			case "feet":
				data.FootSize = value;
				break;
			case "eartaper":
				data.EarTaper = value;
				break;
			case "philtrum":
				data.PhiltrumWidth = value;
				break;
			case "columella":
				data.ColumellaWidth = value;
				break;
			case "mouthwidth":
				data.MouthWidth = value;
				break;
			case "mouthprojection":
				data.MouthProjection = value;
				break;
			case "upperlip":
				data.UpperLipFullness = value;
				break;
			case "lowerlip":
				data.LowerLipFullness = value;
				break;
		}
	}
}

/// <summary>Curated installed, non-inventory native assets. IDs and paths are stable; no cloud discovery or external download is needed.</summary>
public static class AppearanceCatalog
{
	static readonly Dictionary<string, Task> resourceLoads = new(StringComparer.OrdinalIgnoreCase);
	public static bool Loading => resourceLoads.Values.Any(x => x is not null && !x.IsCompleted);
	public static string Error { get; private set; }
	public static int Revision { get; private set; }

	const string Root = "models/citizen_clothes/";
	static AppearanceOption Skin(string number, string label, string body) => new("human" + number, label, Root + "body/human" + number + ".clothing", body, Root + "body/human" + number + ".clothing.png");
	static AppearanceOption Style(string id, string label, string relative, string body = "") => new(id, label, Root + "hair/" + relative + ".clothing", body, Root + "hair/" + relative + ".clothing.png");
	public static IReadOnlyList<AppearanceOption> Skins { get; } = Array.AsReadOnly(new[] { Skin("01", "Face 1 · dark · male", "male"), Skin("02", "Face 2 · dark · female", "female"), Skin("03", "Face 3 · light · male", "male"), Skin("04", "Face 4 · light · female", "female"), Skin("05", "Face 5 · light · male", "male"), Skin("06", "Face 6 · dark · male", "male"), Skin("07", "Face 7 · light · male", "male") });
	public static IReadOnlyList<AppearanceOption> Hair { get; } = Array.AsReadOnly(new[] { new AppearanceOption("none", "No hair", ""), Style("scruffy", "Short scruffy", "hair_shortscruffy/hair_shortscruffy_brown"), Style("short_braids", "Short braids", "hair_short_braids/hair_short_braids"), Style("short_dreads", "Short dreadlocks", "hair_short_dreadlocks/short_dreadlocks"), Style("bob", "Bob cut", "hair_bobcut/hair_bobcut"), Style("long_bob", "Long bob", "hair_longbob/hair_longbob"), Style("messy_bun", "Messy bun", "hair_messy_bun/hair_messy_bun"), Style("ponytail", "Tight ponytail", "hair_tight_ponytail/hair_tight_ponytail_brown"), Style("long_curly", "Long curly", "hair_longcurly/hair_longcurly_brown"), Style("long_bangs", "Long bangs", "hair_long_bangs/hair_long_bangs"), Style("long_dreads", "Long dreadlocks", "hair_long_dreadlocks/hair_long_dreadlocks"), Style("tied_braids", "Tied braids", "hair_tied_braids/hair_tied_braids"), Style("tied_dreads", "Tied dreadlocks", "hair_tied_dreads/hair_tied_dreads"), Style("slicked", "Slicked back", "hair_slickedback/hair_slickedback"), Style("balding", "Balding", "hair_balding/hair_baldingbrown"), Style("thinning", "Thinning short", "hair_thinning_short/thinning_short"), Style("pigtails", "Pigtails", "hair_pigtails/hair_pigtails"), Style("long_brown", "Long hair", "hair_longbrown/Models/hair_longbrown"), Style("loose", "Loose hair", "hair_looseblonde/hair.loose.grey"), Style("slicked_balding", "Slicked back balding", "hair_slicked_back_balding/hair_slicked_back_balding") });
	public static IReadOnlyList<AppearanceOption> FacialHair { get; } = Array.AsReadOnly(new[] { new AppearanceOption("none", "No facial hair", ""), Style("patchy", "Patchy beard", "patchy_beard/patchy_beard", "male"), Style("medium", "Medium beard", "medium_beard/medium_beard", "male"), Style("goatee", "Goatee", "goatee/goatee", "male"), Style("moustache", "Moustache", "moustache/moustache", "male"), Style("handlebar", "Handlebar moustache", "handlebar_moustache/handlebar_moustache", "male"), Style("chops", "Sideburns", "mutton_chops/mutton_chops", "male"), Style("braided", "Braided beard", "braided_long_beard/braided_long_beard", "male"), Style("decorated", "Decorated beard", "decorated_beard/decorated_beard", "male") });
	public static IReadOnlyList<AppearanceColorOption> Colors { get; } = Array.AsReadOnly(new[] { new AppearanceColorOption("black", "Black", new Color(0.045f, 0.035f, 0.028f)), new AppearanceColorOption("brown", "Brown", new Color(0.22f, 0.13f, 0.08f)), new AppearanceColorOption("auburn", "Auburn", new Color(0.48f, 0.17f, 0.07f)), new AppearanceColorOption("blonde", "Blonde", new Color(0.74f, 0.57f, 0.31f)), new AppearanceColorOption("silver", "Silver", new Color(0.66f, 0.67f, 0.7f)), new AppearanceColorOption("white", "White", new Color(0.92f, 0.89f, 0.82f)) });

	public const int ShapeControlCount = 34;
	public static bool IsFemale(CharacterAppearanceData data) => data?.BaseShape is not null ? data.BaseShape.MaleBody < .5f : Skins.FirstOrDefault(x => string.Equals(x.ResourcePath, data?.SkinPath, StringComparison.OrdinalIgnoreCase))?.BodyType == "female";
	static IReadOnlyList<AppearanceColorOption> humanSkinColors;
	public static IReadOnlyList<AppearanceColorOption> HumanSkinColors
	{
		get
		{
			if (humanSkinColors?.Count != 7 || !humanSkinColors[1].NormalizeSkinTone)
				humanSkinColors = CreateHumanSkinColors();
			return humanSkinColors;
		}
	}

	static IReadOnlyList<AppearanceColorOption> CreateHumanSkinColors() => Array.AsReadOnly(new[] { new AppearanceColorOption("native", "Native", Color.White), new AppearanceColorOption("fair", "Fair", Color.White, true), new AppearanceColorOption("rose", "Rose", new Color(1, 0.96f, 0.96f), true), new AppearanceColorOption("golden", "Golden", new Color(1, 0.96f, 0.88f), true), new AppearanceColorOption("olive", "Olive", new Color(0.91f, 0.88f, 0.77f), true), new AppearanceColorOption("tan", "Tan", new Color(0.72f, 0.6f, 0.5f), true), new AppearanceColorOption("umber", "Umber", new Color(0.42f, 0.3f, 0.23f), true) });
	static IReadOnlyList<AppearanceColorOption> fantasySkinColors;
	public static IReadOnlyList<AppearanceColorOption> FantasySkinColors
	{
		get
		{
			if (fantasySkinColors?.Count != 6 || fantasySkinColors.Any(x => !x.NormalizeSkinTone))
				fantasySkinColors = CreateFantasySkinColors();
			return fantasySkinColors;
		}
	}

	static IReadOnlyList<AppearanceColorOption> CreateFantasySkinColors() => Array.AsReadOnly(new[] { new AppearanceColorOption("blue", "Blue", new Color(0.18f, 0.4f, 1), true), new AppearanceColorOption("purple", "Purple", new Color(0.6f, 0.25f, 1), true), new AppearanceColorOption("green", "Green", new Color(0.3f, 0.85f, 0.4f), true), new AppearanceColorOption("red", "Crimson", new Color(1, 0.25f, 0.22f), true), new AppearanceColorOption("teal", "Teal", new Color(0.18f, 0.85f, 0.9f), true), new AppearanceColorOption("grey", "Slate", new Color(0.5f, 0.6f, 0.74f), true) });
	// Native hotload can retain the previous static list when the catalog grows.
	// Build the supported immutable catalog lazily so new controls are available
	// in an already running editor as well as a cold-start game.
	public static IReadOnlyList<AppearanceSlider> Sliders
	{
		get
		{
			if (contourControls?.Count != ShapeControlCount)
				contourControls = CreateContourControls();
			return contourControls;
		}
	}

	static IReadOnlyList<AppearanceSlider> contourControls;
	static IReadOnlyList<AppearanceSlider> CreateContourControls() => Array.AsReadOnly(new[] { new AppearanceSlider("neck", "Neck size", "Body"), new AppearanceSlider("trapezius", "Trapezius", "Body"), new AppearanceSlider("collarbone", "Collarbone height", "Body"), new AppearanceSlider("belly", "Belly size", "Body"), new AppearanceSlider("waist", "Waist size", "Body"), new AppearanceSlider("chest", "Chest size", "Body"), new AppearanceSlider("fitness", "Fitness", "Body"), new AppearanceSlider("bodyfeature", "Body contour", "Body"), new AppearanceSlider("butt", "Butt size", "Body"), new AppearanceSlider("feet", "Foot size", "Body"), new AppearanceSlider("head", "Head shape", "Face"), new AppearanceSlider("forehead", "Forehead width", "Face"), new AppearanceSlider("cheekbones", "Cheekbone width", "Face"), new AppearanceSlider("cheeks", "Cheek fullness", "Face"), new AppearanceSlider("jaw", "Jaw width", "Jaw & chin"), new AppearanceSlider("chin", "Chin projection", "Jaw & chin"), new AppearanceSlider("chinwidth", "Chin width", "Jaw & chin"), new AppearanceSlider("chinheight", "Chin height", "Jaw & chin"), new AppearanceSlider("submental", "Under-chin fullness", "Jaw & chin"), new AppearanceSlider("nose", "Nose projection", "Nose"), new AppearanceSlider("nosewidth", "Nose width", "Nose"), new AppearanceSlider("nostrils", "Nostril width", "Nose"), new AppearanceSlider("nostrilfullness", "Nostril-wing fullness", "Nose"), new AppearanceSlider("noseheight", "Nose tip height", "Nose"), new AppearanceSlider("columella", "Columella width", "Nose"), new AppearanceSlider("philtrum", "Philtrum width", "Mouth"), new AppearanceSlider("mouthwidth", "Mouth width", "Mouth"), new AppearanceSlider("mouthprojection", "Mouth projection", "Mouth"), new AppearanceSlider("upperlip", "Upper lip fullness", "Mouth"), new AppearanceSlider("lowerlip", "Lower lip fullness", "Mouth"), new AppearanceSlider("eyes", "Eye size", "Eyes & ears"), new AppearanceSlider("ears", "Ear size", "Eyes & ears"), new AppearanceSlider("brow", "Brow prominence", "Eyes & ears"), new AppearanceSlider("eartaper", "Ear taper", "Eyes & ears") });
	static CharacterAppearanceData Preset(string skin, string hair, string facial, string color) => new()
	{
		SkinPath = Skins.Single(x => x.Id == skin).ResourcePath,
		HairPath = Hair.Single(x => x.Id == hair).ResourcePath,
		FacialHairPath = FacialHair.Single(x => x.Id == facial).ResourcePath,
		HairColor = Colors.Single(x => x.Id == color).Color
	};
	static IReadOnlyList<AppearancePreset> presets;
	public static IReadOnlyList<AppearancePreset> Presets
	{
		get
		{
			if (presets?.Count != 7)
				presets = Array.AsReadOnly(new[]
				{
					new AppearancePreset("traveller", "Traveller", Preset("human03", "scruffy", "none", "brown")),
					new AppearancePreset("scout", "Scout", Preset("human02", "short_braids", "none", "black")),
					new AppearancePreset("scholar", "Scholar", Preset("human04", "messy_bun", "none", "auburn")),
					new AppearancePreset("veteran", "Veteran", Preset("human07", "balding", "medium", "silver")),
					new AppearancePreset("wanderer", "Wanderer", Preset("human06", "short_dreads", "none", "black")),
					new AppearancePreset("artisan", "Artisan", Preset("human05", "slicked", "goatee", "brown")),
					new AppearancePreset("explorer", "Explorer", Preset("human01", "tied_braids", "none", "black"))
				});
			return presets;
		}
	}

	public static CharacterAppearanceData CreateDefault() => ApplyPreset("traveller");
	public static CharacterAppearanceData ApplyPreset(string id) => Presets.FirstOrDefault(x => x.Id == id)?.Appearance.DeepCopy() ?? new CharacterAppearanceData();
	public static void ResetShapes(CharacterAppearanceData data)
	{
		if (data is null)
			return;
		foreach (var slider in Sliders)
			slider.Set(data, slider.Neutral);
		if (data.BaseShape is not null)
		{
			data.BaseShape.BreastSize = .5f;
			data.BaseShape.BulgeSize = .5f;
		}
	}

	public static CharacterAppearanceData Randomize(Random rng = null, bool blendHeads = false)
	{
		rng ??= new Random();
		var body = rng.Next(2) == 0 ? "male" : "female";
		// Cross-body skins need weighted heads; native (v1) characters keep their native pool.
		var skins = Skins.Where(x => blendHeads ? (body == "male" || x.Id is not ("human05" or "human07")) : x.BodyType == body).ToArray();
		var skin = skins[rng.Next(skins.Length)];
		var hair = RandomHairFor(body);
		var facial = FacialHair.Where(x => x.BodyType == "" || x.BodyType == body).ToArray();
		var tones = HumanSkinColors.Where(x => body == "female" && skin.Id == "human06" ? x.Id is "native" or "tan" or "umber" : x.NormalizeSkinTone).ToArray();
		var tone = tones[rng.Next(tones.Length)];
		var data = new CharacterAppearanceData
		{
			SkinPath = skin.ResourcePath,
			HairPath = hair[rng.Next(hair.Count)].ResourcePath,
			FacialHairPath = facial[rng.Next(facial.Length)].ResourcePath,
			HairColor = Colors[rng.Next(Colors.Count)].Color,
			SkinColor = tone.Color,
			NormalizeSkinTone = tone.NormalizeSkinTone
		};
		foreach (var slider in Sliders.Where(x => x.Id != "belly"))
			slider.Set(data, 0.3f + (float)rng.NextDouble() * 0.4f);
		// An added belly is an occasional, modest feature rather than a default.
		data.BellySize = rng.NextDouble() < 0.10 ? 0.1f + (float)rng.NextDouble() * 0.3f : 0;
		if (body == "female" && rng.NextDouble() < 0.60)
		{
			AddMakeup("Lipstick", 0.70, 0.35f, 0.85f, 4);
			AddMakeup("Eyeshadow", 0.45, 0.20f, 0.65f, 3);
			AddMakeup("Blush", 0.55, 0.15f, 0.55f, 4);
		}

		if (blendHeads)
		{
			UnifiedHuman.Enable(data);
			// Body identity must follow the roll, not the selected skin's native body.
			bool female = body == "female";
			data.BaseShape.MaleBody = female ? 0 : 1;
			data.BaseShape.BreastSize = female ? data.BodyFeatureSize : .5f;
			data.BaseShape.BulgeSize = female ? .5f : data.BodyFeatureSize;
			data.BaseShape.HeadWeights = RandomHeadWeights(rng, female, skin.Id, false);
			foreach (var region in FaceRegions.Ids)
			{
				// Each part gets its own dominant source rather than averaging every head
				// equally. Keep jaw/neck identity especially close to the body's family.
				data.BaseShape.Regions[region] = new()
				{
					OverallInfluence = .15f + (float)rng.NextDouble() * .20f,
					HeadWeights = RandomHeadWeights(rng, female, skin.Id, region is "jaw" or "throat")
				};
			}

			// A shared jaw/throat source keeps the lower-face silhouette coherent.
			data.BaseShape.Regions["throat"] = data.BaseShape.Regions["jaw"].DeepCopy();
		}

		data.ClothingPaths = WardrobeCatalog.Randomize(rng, body == "female");
		data.Version = 2;
		return data;
		void AddMakeup(string layer, double chance, float low, float high, int paletteCount)
		{
			if (rng.NextDouble() >= chance)
				return;
			var options = MakeupCatalog.Colors(layer);
			var target = MakeupCatalog.Layer(data.Makeup, layer);
			target.Color = options[rng.Next(Math.Min(paletteCount, options.Count))].Color;
			target.Amount = low + (float)rng.NextDouble() * (high - low);
		}
	}

	static float[] RandomHeadWeights(Random rng, bool female, string skin, bool structural)
	{
		// Allocate by family before choosing individual heads: five masculine faces
		// must not overwhelm the two feminine ones. Skin 04 on men excludes them.
		float ownShare = structural ? .95f + (float)rng.NextDouble() * .05f : .85f + (float)rng.NextDouble() * .15f;
		float femaleShare = female ? ownShare : skin == "human04" ? 0 : 1 - ownShare;
		var weights = new float[7];
		Fill(new[] { 1, 3 }, femaleShare);
		Fill(new[] { 0, 2, 4, 5, 6 }, 1 - femaleShare);
		float max = weights.Max();
		for (int i = 0; i < weights.Length; i++)
			weights[i] /= max;
		return weights;
		void Fill(int[] indices, float share)
		{
			int primary = indices[rng.Next(indices.Length)];
			float dominance = .50f + (float)rng.NextDouble() * .40f;
			weights[primary] = share * dominance;
			float total = 0;
			foreach (int i in indices)
				if (i != primary)
				{
					weights[i] = .05f + (float)rng.NextDouble();
					total += weights[i];
				}

			foreach (int i in indices)
				if (i != primary)
					weights[i] *= share * (1 - dominance) / total;
		}
	}

	// Preferences apply only to Randomize. Every hairstyle remains manually
	// selectable and valid on either human body, including male pigtails.
	public static IReadOnlyList<AppearanceOption> RandomHairFor(string bodyType) => Hair.Where(x => bodyType == "female" ? x.Id is not ("balding" or "thinning" or "slicked_balding") : x.Id is "none" or "scruffy" or "short_braids" or "short_dreads" or "long_dreads" or "tied_braids" or "tied_dreads" or "slicked" or "balding" or "thinning" or "ponytail" or "long_curly" or "long_brown" or "slicked_balding").ToArray();
	/// <summary>Loads only curated native resources, either the whole creator catalog or one selected/saved appearance.</summary>
	public static async Task EnsureLoadedAsync(CharacterAppearanceData data = null)
	{
		if (data is not null && CharacterAppearanceData.Validate(data)is string error)
			throw new InvalidOperationException(error);
		var paths = data is null ? Skins.Concat(Hair).Concat(FacialHair).Select(x => x.ResourcePath) : new[]
		{
			data.SkinPath,
			UnifiedHuman.PresentationSkin(data),
			data.HairPath,
			data.FacialHairPath
		};
		try
		{
			foreach (var path in paths.Where(x => !string.IsNullOrEmpty(x)).Distinct(StringComparer.OrdinalIgnoreCase))
				await Load(path);
			if (data is null || data.Makeup.Active)
				foreach (var skin in data is null ? Skins : Skins.Where(x => x.ResourcePath == data.SkinPath))
					await HumanMakeupAssets.EnsureLoadedAsync(skin.Id);
			if (data is null || data.CustomEyeColor)
				await EyeColorPresentation.EnsureLoadedAsync();
			if (data is not null)
				await HumanMarkingModels.EnsureLoadedAsync(data);
			if (data?.BaseShape is not null)
			{
				var model = await Model.LoadAsync(UnifiedHuman.ModelPath);
				if (model is null || model.IsError)
					throw new InvalidOperationException("The unified human model is unavailable.");
				await UnifiedNeckAssets.LoadHeadAsync(data.SkinPath);
			}

			Error = null;
		}
		catch (Exception e)
		{
			Error = e.Message;
			throw;
		}

		static Task Load(string path)
		{
			if (resourceLoads.TryGetValue(path, out var pending) && pending is not null && !pending.IsFaulted && !pending.IsCanceled)
				return pending;
			return resourceLoads[path] = LoadResource(path);
		}

		static async Task LoadResource(string path)
		{
			Clothing clothing = null;
			for (var attempt = 0; attempt < 3; attempt++)
			{
				clothing = await ClothingResources.Load(path);
				if (clothing is not null && !clothing.IsError)
					break;
				if (attempt < 2)
					await Task.Delay(150);
			}

			if (clothing is null || clothing.IsError)
				throw new InvalidOperationException("A native appearance resource is unavailable: " + path);
			foreach (var modelPath in new[]
			{
				clothing.HumanSkinModel,
				clothing.HumanAltModel,
				clothing.HumanAltFemaleModel
			}.Where(x => !string.IsNullOrEmpty(x)).Distinct(StringComparer.OrdinalIgnoreCase))
			{
				var model = await Model.LoadAsync(modelPath);
				if (model is null || model.IsError)
					throw new InvalidOperationException("A native appearance model is unavailable: " + modelPath);
			}

			Revision++;
		}
	}
}