Data container for portable character appearance settings saved to disk. Holds paths for skin, hair, clothing, color floats, many body sliders, makeup and base shape, provides deep copy, composition key generation, and a validation routine for save/menu preflight.
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;
}
}