Code/CharacterKit/CharacterAppearanceData.cs

Data container for a character appearance that is serializable to JSON. Stores model/asset paths, colors, many numeric sliders, makeup and base shape, provides DeepCopy, a composition key generator, and a Validate method that performs cold-safe checks against catalogs and supported IDs.

File Access
using System;
using System.Text.Json.Serialization;

namespace CharacterEditor;
/// <summary>Portable appearance values. No live models, components or engine color converters are required to read a save.</summary>
public sealed class CharacterAppearanceData
{
	public int Version { get; set; } = 1;
	public CharacterBaseShape BaseShape { get; set; }
	public string[] ClothingPaths { get; set; }
	public string VoiceId { get; set; } = "man-adult";
	public string SkinPath { get; set; } = "models/citizen_clothes/body/human03.clothing";
	public string HairPath { get; set; } = "models/citizen_clothes/hair/hair_shortscruffy/hair_shortscruffy_brown.clothing";
	public string FacialHairPath { get; set; } = "";
	public float HairColorR { get; set; } = 0.22f;
	public float HairColorG { get; set; } = 0.13f;
	public float HairColorB { get; set; } = 0.08f;
	public float SkinColorR { get; set; } = 1;
	public float SkinColorG { get; set; } = 1;
	public float SkinColorB { get; set; } = 1;
	public bool CustomEyeColor { get; set; }
	public float EyeColorR { get; set; } = .24f;
	public float EyeColorG { get; set; } = .12f;
	public float EyeColorB { get; set; } = .055f;

	[JsonIgnore]
	public Color EyeColor
	{
		get => new(EyeColorR, EyeColorG, EyeColorB);
		set
		{
			EyeColorR = value.r;
			EyeColorG = value.g;
			EyeColorB = value.b;
		}
	}

	public bool NormalizeSkinTone { get; set; }
	public CharacterMakeupData Makeup { get; set; } = new();
	public float NeckSize { get; set; } = 0.5f;
	public float TrapeziusSize { get; set; } = 0.5f;
	public float CollarboneHeight { get; set; } = 0.5f;
	// Zero leaves the existing abdomen unchanged, including in older saves.
	public float BellySize { get; set; } = 0;
	public float WaistSize { get; set; } = 0.5f;
	public float ChestSize { get; set; } = 0.5f;
	public float HeadShape { get; set; } = 0.5f;
	public float NoseSize { get; set; } = 0.5f;
	public float ChinSize { get; set; } = 0.5f;
	public float NoseWidth { get; set; } = 0.5f;
	public float ChinWidth { get; set; } = 0.5f;
	public float Fitness { get; set; } = 0.5f;
	public float JawWidth { get; set; } = 0.5f;
	public float NostrilWidth { get; set; } = 0.5f;
	public float ForeheadWidth { get; set; } = 0.5f;
	public float CheekboneWidth { get; set; } = 0.5f;
	public float EarSize { get; set; } = 0.5f;
	public float EyeSize { get; set; } = 0.5f;
	public float CheekFullness { get; set; } = 0.5f;
	public float NostrilFullness { get; set; } = 0.5f;
	public float NoseHeight { get; set; } = 0.5f;
	public float ChinHeight { get; set; } = 0.5f;
	public float SubmentalFullness { get; set; } = 0.5f;
	public float BrowProminence { get; set; } = 0.5f;
	public float BodyFeatureSize { get; set; } = 0.5f;
	public float ButtSize { get; set; } = 0.5f;
	public float FootSize { get; set; } = 0.5f;
	public float PhiltrumWidth { get; set; } = 0.5f;
	public float ColumellaWidth { get; set; } = 0.5f;
	public float MouthWidth { get; set; } = 0.5f;
	public float MouthProjection { get; set; } = 0.5f;
	public float UpperLipFullness { get; set; } = 0.5f;
	public float LowerLipFullness { get; set; } = 0.5f;
	// Normal ears are the minimum, including when reading an older save.
	public float EarTaper { get; set; } = 0;

	[JsonIgnore]
	public Color HairColor
	{
		get => new(HairColorR, HairColorG, HairColorB);
		set
		{
			HairColorR = value.r;
			HairColorG = value.g;
			HairColorB = value.b;
		}
	}

	[JsonIgnore]
	public Color SkinColor
	{
		get => new(SkinColorR, SkinColorG, SkinColorB);
		set
		{
			SkinColorR = value.r;
			SkinColorG = value.g;
			SkinColorB = value.b;
		}
	}

	public CharacterAppearanceData DeepCopy() => new()
	{
		Version = Version,
		BaseShape = BaseShape?.DeepCopy(),
		ClothingPaths = ClothingPaths?.ToArray(),
		VoiceId = VoiceId,
		SkinPath = SkinPath,
		HairPath = HairPath,
		FacialHairPath = FacialHairPath,
		HairColorR = HairColorR,
		HairColorG = HairColorG,
		HairColorB = HairColorB,
		SkinColorR = SkinColorR,
		SkinColorG = SkinColorG,
		SkinColorB = SkinColorB,
		NormalizeSkinTone = NormalizeSkinTone,
		CustomEyeColor = CustomEyeColor,
		EyeColorR = EyeColorR,
		EyeColorG = EyeColorG,
		EyeColorB = EyeColorB,
		Makeup = Makeup?.DeepCopy(),
		NeckSize = NeckSize,
		TrapeziusSize = TrapeziusSize,
		CollarboneHeight = CollarboneHeight,
		BellySize = BellySize,
		WaistSize = WaistSize,
		ChestSize = ChestSize,
		HeadShape = HeadShape,
		NoseSize = NoseSize,
		ChinSize = ChinSize,
		NoseWidth = NoseWidth,
		ChinWidth = ChinWidth,
		Fitness = Fitness,
		JawWidth = JawWidth,
		NostrilWidth = NostrilWidth,
		ForeheadWidth = ForeheadWidth,
		CheekboneWidth = CheekboneWidth,
		EarSize = EarSize,
		EyeSize = EyeSize,
		CheekFullness = CheekFullness,
		NostrilFullness = NostrilFullness,
		NoseHeight = NoseHeight,
		ChinHeight = ChinHeight,
		SubmentalFullness = SubmentalFullness,
		BrowProminence = BrowProminence,
		BodyFeatureSize = BodyFeatureSize,
		ButtSize = ButtSize,
		FootSize = FootSize,
		EarTaper = EarTaper,
		PhiltrumWidth = PhiltrumWidth,
		ColumellaWidth = ColumellaWidth,
		MouthWidth = MouthWidth,
		MouthProjection = MouthProjection,
		UpperLipFullness = UpperLipFullness,
		LowerLipFullness = LowerLipFullness
	};
	public static string CompositionKey(CharacterAppearanceData data) => data is null ? "" : $"{(data.BaseShape is null ? data.SkinPath : UnifiedHuman.ModelPath + UnifiedHuman.PresentationSkin(data))}|{data.BaseShape?.ClothingVariant}|{data.HairPath}|{data.FacialHairPath}|{HumanMarkingModels.NeedsArmRegions(data.Makeup)}|{string.Join(';', WardrobeCatalog.Paths(data))}";
	/// <summary>Cold-safe validation used by save lists and menu preflight. Resources are resolved separately when applied.</summary>
	public static string Validate(CharacterAppearanceData data)
	{
		if (data is null)
			return "Choose a character appearance.";
		if (data.Version is not (1 or 2))
			return "This appearance version is unsupported.";
		if (data.BaseShape is not null && (data.Version != 2 || !data.BaseShape.Valid))
			return "Base shape requires version 2, valid face influences and custom face data, and body controls between zero and one.";
		if (data.Version == 1 && data.ClothingPaths is not null)
			return "Saved clothing requires appearance version 2.";
		if (WardrobeCatalog.Validate(data.ClothingPaths)is string wardrobeError)
			return wardrobeError;
		if (!VoiceIds.IsSupported(data.VoiceId))
			return "Choose a supported character voice.";
		if (data.Makeup?.Valid != true)
			return "Makeup colors and strength must be finite values between zero and one, with a supported tattoo style.";
		if (data.HairPath is null || data.FacialHairPath is null)
			return "Use an empty path for no hair or facial hair.";
		var skin = AppearanceCatalog.Skins.FirstOrDefault(x => string.Equals(x.ResourcePath, data.SkinPath, StringComparison.OrdinalIgnoreCase));
		if (skin is null)
			return "Choose a supported native human face and skin.";
		if (data.HairPath.Length > 0 && !ClothingResources.ValidReference(data.HairPath))
			return "Choose a supported native hairstyle.";
		var facial = AppearanceCatalog.FacialHair.FirstOrDefault(x => string.Equals(x.ResourcePath, data.FacialHairPath ?? "", StringComparison.OrdinalIgnoreCase));
		if ((data.FacialHairPath.Length > 0 && !ClothingResources.ValidReference(data.FacialHairPath)) || (facial is not null && data.BaseShape is null && !string.IsNullOrEmpty(facial.BodyType) && facial.BodyType != skin.BodyType))
			return "That facial hair does not fit the selected body.";
		if (new[]
		{
			data.HairColorR,
			data.HairColorG,
			data.HairColorB
		}.Any(x => !float.IsFinite(x) || x < 0 || x > 1))
			return "Hair color must contain finite values between zero and one.";
		if (new[]
		{
			data.SkinColorR,
			data.SkinColorG,
			data.SkinColorB
		}.Any(x => !float.IsFinite(x) || x < 0 || x > 1))
			return "Skin color must contain finite values between zero and one.";
		if (new[]
		{
			data.EyeColorR,
			data.EyeColorG,
			data.EyeColorB
		}.Any(x => !float.IsFinite(x) || x < 0 || x > 1))
			return "Eye color must contain finite values between zero and one.";
		if (AppearanceCatalog.Sliders.Any(x => !float.IsFinite(x.Get(data)) || x.Get(data) < x.Min || x.Get(data) > x.Max))
			return "Appearance sliders must contain finite values between zero and one.";
		return null;
	}
}