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.
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;
}
}