Code/CharacterKit/CharacterMakeupData.cs

Data model for character makeup and makeup layers. Defines MakeupLayerData with color components, amount, validity and deep copy, and CharacterMakeupData with preset layers, eyeliner/wing parameters, markings, validity checks, presentation key serialization, and a migration method for tattoo->markings.

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

namespace CharacterEditor;
public sealed class MakeupLayerData
{
	public float Red { get; set; } = 0.5f;
	public float Green { get; set; } = 0.12f;
	public float Blue { get; set; } = 0.18f;
	public float Amount { get; set; }

	[JsonIgnore]
	public Color Color
	{
		get => new(Red, Green, Blue);
		set
		{
			Red = value.r;
			Green = value.g;
			Blue = value.b;
		}
	}

	public MakeupLayerData DeepCopy() => new()
	{
		Red = Red,
		Green = Green,
		Blue = Blue,
		Amount = Amount
	};
	[JsonIgnore]
	public bool Valid => new[]
	{
		Red,
		Green,
		Blue,
		Amount
	}.All(x => float.IsFinite(x) && x >= 0 && x <= 1);
}

public sealed class CharacterMakeupData
{
	public const int CosmeticRevision = 18;
	public MakeupLayerData Lipstick { get; set; } = new();
	public MakeupLayerData Eyeshadow { get; set; } = new()
	{
		Red = 0.2f,
		Green = 0.12f,
		Blue = 0.28f
	};
	public MakeupLayerData Eyeliner { get; set; } = new()
	{
		Red = .025f,
		Green = .02f,
		Blue = .025f
	};
	public MakeupLayerData LowerEyeshadow { get; set; } = new()
	{
		Red = .2f,
		Green = .12f,
		Blue = .28f
	};
	// Eyeliner.Amount remains the upper strength for compatibility with existing JSON.
	public float EyelinerLowerStrength { get; set; }
	public float EyelinerInnerStrength { get; set; }
	public string EyelinerWing { get; set; } = "none";
	public float EyelinerWingStrength { get; set; } = 1;
	public float EyelinerWingHorizontal { get; set; } = .25f;
	public float EyelinerWingVertical { get; set; }
	public float EyelinerWingThickness { get; set; } = .25f;
	// Retain legacy wing names when importing old presets; the UI converts them
	// to an adjustable wing as soon as its tip is moved.
	[JsonIgnore]
	public float WingTipHorizontal => EyelinerWing switch { "flick" => .1f, "cat-eye" => .25f, "downward" => .22f, _ => EyelinerWingHorizontal };
	[JsonIgnore]
	public float WingTipVertical => EyelinerWing switch { "flick" => .18f, "cat-eye" => .38f, "downward" => -.38f, _ => EyelinerWingVertical };
	[JsonIgnore]
	public bool EyelinerActive => Eyeliner.Amount > 0 || EyelinerLowerStrength > 0 || EyelinerInnerStrength > 0;
	/// <summary>Explicit new-field key also invalidates presentations after live code/asset updates.</summary>
	public string PresentationKey() => System.Text.Json.JsonSerializer.Serialize(this) +
		$"|eyeliner-v{CosmeticRevision}|{EyelinerLowerStrength:R}|{EyelinerInnerStrength:R}|{EyelinerWing}|{EyelinerWingStrength:R}|{EyelinerWingHorizontal:R}|{EyelinerWingVertical:R}|{EyelinerWingThickness:R}";
	public MakeupLayerData Blush { get; set; } = new()
	{
		Red = 0.7f,
		Green = 0.3f,
		Blue = 0.32f
	};
	public MakeupLayerData Tattoo { get; set; } = new()
	{
		Red = 0.1f,
		Green = 0.12f,
		Blue = 0.16f
	};
	public string TattooStyle { get; set; } = "none";
	public List<CharacterMarkingData> Markings { get; set; } = new();

	[JsonIgnore]
	public bool Active => Lipstick.Amount > 0 || Eyeshadow.Amount > 0 || EyelinerActive || LowerEyeshadow.Amount > 0 || Blush.Amount > 0 || (TattooStyle != "none" && Tattoo.Amount > 0) || Markings.Any(x => x.Style != "none" && x.Ink.Amount > 0);

	public CharacterMakeupData DeepCopy() => new()
	{
		Lipstick = Lipstick?.DeepCopy(),
		Eyeshadow = Eyeshadow?.DeepCopy(),
		Eyeliner = Eyeliner?.DeepCopy(),
		EyelinerLowerStrength = EyelinerLowerStrength,
		EyelinerInnerStrength = EyelinerInnerStrength,
		EyelinerWing = EyelinerWing,
		EyelinerWingStrength = EyelinerWingStrength,
		EyelinerWingHorizontal = EyelinerWingHorizontal,
		EyelinerWingVertical = EyelinerWingVertical,
		EyelinerWingThickness = EyelinerWingThickness,
		LowerEyeshadow = LowerEyeshadow?.DeepCopy(),
		Blush = Blush?.DeepCopy(),
		Tattoo = Tattoo?.DeepCopy(),
		TattooStyle = TattooStyle,
		Markings = Markings?.Select(x => x?.DeepCopy()).ToList()
	};
	[JsonIgnore]
	public bool Valid =>
		Lipstick?.Valid == true && Eyeshadow?.Valid == true && Eyeliner?.Valid == true &&
		LowerEyeshadow?.Valid == true && Blush?.Valid == true && Tattoo?.Valid == true &&
		(TattooStyle is "none" or "crescent") &&
		(EyelinerWing is "none" or "wing" or "flick" or "cat-eye" or "downward") &&
		float.IsFinite(EyelinerWingVertical) && EyelinerWingVertical >= -1 && EyelinerWingVertical <= 1 &&
		new[] { EyelinerLowerStrength, EyelinerInnerStrength, EyelinerWingStrength, EyelinerWingHorizontal, EyelinerWingThickness }
			.All(x => float.IsFinite(x) && x >= 0 && x <= 1) &&
		Markings is not null && Markings.Count <= MarkingCatalog.Maximum &&
		Markings.All(x => x?.Valid == true) &&
		!(TattooStyle != "none" && Markings.Count == MarkingCatalog.Maximum);

	// Upgrade only when opening the markings editor. Disk reads/copies remain exact and cold-safe.
	public void EditMarkings()
	{
		if (TattooStyle == "crescent")
		{
			Markings.Insert(0, new() { Region = "left-cheek", Ink = Tattoo.DeepCopy() });
			TattooStyle = "none";
			Tattoo.Amount = 0;
		}
	}
}