CharacterKit/FaceRegions.cs

Defines face-region weighting for character appearance. FaceRegionBlend stores overall influence, head weights and per-photo weights with validation and deep copy. FaceRegions provides region ids, label/mapping helpers, methods to compute active blends, share fractions, combined weights, apply morphs to a SkinnedModelRenderer, remove photo entries and compute reference weights.

File Access
using System;

namespace CharacterEditor;
/// <summary>Optional regional identity weights. An empty region inherits the overall blend.</summary>
public sealed class FaceRegionBlend
{
	// Zero preserves older saved overrides. New UI regions start at the midpoint.
	public float OverallInfluence { get; set; }
	public float[] HeadWeights { get; set; } = new float[7];
	public Dictionary<string, float> PhotoWeights { get; set; } = new();

	[System.Text.Json.Serialization.JsonIgnore]
	public float Total => OverallInfluence + HeadWeights.Sum() + PhotoWeights.Values.Sum();

	public bool Valid(IReadOnlyList<PhotoFace> faces) => Unit(OverallInfluence) && HeadWeights?.Length == 7 && HeadWeights.All(Unit) && PhotoWeights is not null && PhotoWeights.Count <= 4 && PhotoWeights.All(x => Unit(x.Value) && faces.Any(f => f.Id == x.Key));
	static bool Unit(float x) => float.IsFinite(x) && x >= 0 && x <= 1;
	public FaceRegionBlend DeepCopy() => new()
	{
		OverallInfluence = OverallInfluence,
		HeadWeights = HeadWeights.ToArray(),
		PhotoWeights = new(PhotoWeights)
	};
}

public static class FaceRegions
{
	public static readonly string[] Ids =
	{
		"nose",
		"jaw",
		"forehead",
		"ears",
		"eyes",
		"mouth",
		"cheeks",
		"throat"
	};
	// Throat reuses the existing lower-neck complement, including its pinned
	// collar boundary. Retain asset names so older portable kits remain compatible.
	public static string MorphRegion(string region) => region == "throat" ? "neck" : region;
	public static string Label(string id) => id switch
	{
		"overall" => "Overall",
		"jaw" => "Jaw & chin",
		_ => char.ToUpperInvariant(id[0]) + id[1..]
	};
	public static string ForSlider(string id) => id switch
	{
		"nose" or "nosewidth" or "nostrils" or "nostrilfullness" or "noseheight" or "columella" => "nose",
		"jaw" or "chin" or "chinwidth" or "chinheight" or "submental" => "jaw",
		"forehead" or "brow" => "forehead",
		"ears" or "eartaper" => "ears",
		"eyes" => "eyes",
		"mouthwidth" or "mouthprojection" or "upperlip" or "lowerlip" or "philtrum" => "mouth",
		"cheeks" or "cheekbones" => "cheeks",
		_ => null
	};
	public static FaceRegionBlend Active(CharacterBaseShape shape, string region) => region is not null && shape.Regions.TryGetValue(region, out var r) && r.Total > 0 ? r : null;
	public static float PhotoShare(CharacterBaseShape shape, PhotoFace face, string region)
	{
		var r = Active(shape, region);
		return r is null ? shape.PhotoShare(face) : (r.OverallInfluence * shape.PhotoShare(face) + r.PhotoWeights.GetValueOrDefault(face.Id)) / r.Total;
	}

	public static float OverallShare(CharacterBaseShape shape, string region) => Active(shape, region)is { } r ? r.OverallInfluence / r.Total : 1;
	public static float NativeShare(CharacterBaseShape shape, int index, string region)
	{
		if (!Ids.Contains(region))
			return shape.NativeShare(index);
		var r = Active(shape, region);
		return r is null ? 0 : r.HeadWeights[index] / r.Total;
	}

	public static float PhotoEntryShare(CharacterBaseShape shape, PhotoFace face, string region)
	{
		if (!Ids.Contains(region))
			return shape.PhotoShare(face);
		var r = Active(shape, region);
		return r is null ? 0 : r.PhotoWeights.GetValueOrDefault(face.Id) / r.Total;
	}

	public static float[] Weights(CharacterBaseShape shape, string region)
	{
		var r = Active(shape, region);
		if (r is null)
			return shape.Normalized();
		var overall = shape.Normalized();
		return r.HeadWeights.Select((w, i) => (r.OverallInfluence * overall[i] + w + shape.PhotoFaces.Sum(f => r.PhotoWeights.GetValueOrDefault(f.Id) * f.HeadWeights[i])) / r.Total).ToArray();
	}

	public static void RemovePhoto(CharacterBaseShape shape, PhotoFace face)
	{
		foreach (var r in shape.Regions.Values)
			r.PhotoWeights.Remove(face.Id);
		shape.PhotoFaces.Remove(face);
	}

	public static void Apply(SkinnedModelRenderer body, CharacterBaseShape shape, float[] overall)
	{
		foreach (var region in Ids)
		{
			var weights = Weights(shape, region);
			var morphRegion = MorphRegion(region);
			for (int i = 0; i < 7; i++)
				if (i != 2)
				{
					float delta = weights[i] - overall[i];
					// Separate signed shapes avoid relying on negative engine morph weights.
					body.Morphs.Set($"ce_{morphRegion}_human{i + 1:00}_add", Math.Max(0, delta));
					body.Morphs.Set($"ce_{morphRegion}_human{i + 1:00}_subtract", Math.Max(0, -delta));
				}
		}
	}

	// Rebase on the regions to avoid quantized global/add/subtract cancellation
	// across the whole head. The complementary neck shapes retain Overall there.
	public static float[] ReferenceWeights(CharacterBaseShape shape)
	{
		// Keep the reference independent of Throat, so changing it cannot introduce
		// rounding/shading changes on the rest of the face.
		var regions = Ids.Where(r => r != "throat").Select(r => Weights(shape, r)).ToArray();
		return Enumerable.Range(0, 7).Select(i => regions.Average(w => w[i])).ToArray();
	}
}