CharacterKit/EarShapes.cs

Defines ear-shape blending for character ears. EarShapeBlend stores a 2D blend coordinate with validation and copy utilities. EarShapes maps that 2D triangle to family weight vectors (Goblin/Elf/Animal), projects points into the triangle, computes weights, sets/resets appearance fields, and applies legacy-photo offsets.

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

namespace CharacterEditor;
/// <summary>Portable position in the Elf/Goblin/Animal ear triangle.</summary>
public sealed class EarShapeBlend
{
	public float X { get; set; } = .5f;
	public float Y { get; set; } = 2f / 3;

	[JsonIgnore]
	public bool Valid => EarShapes.IsInside(new(X, Y));

	public EarShapeBlend DeepCopy() => new()
	{
		X = X,
		Y = Y
	};
	public static bool Same(EarShapeBlend a, EarShapeBlend b) => ReferenceEquals(a, b) || (a is not null && b is not null && a.X == b.X && a.Y == b.Y);
}

/// <summary>
/// Maps the triangle to nonnegative Goblin/Elf/Animal weights. Its center is
/// exactly human; the remaining human share is one minus the fantasy sum.
/// </summary>
public static class EarShapes
{
	public static readonly Vector2 Elf = new(.5f, 0);
	public static readonly Vector2 Goblin = new(0, 1);
	public static readonly Vector2 Animal = new(1, 1);
	public static readonly Vector2 Human = new(.5f, 2f / 3);
	public static Vector2 Position(CharacterAppearanceData appearance)
	{
		if (appearance?.EarBlend is { } blend)
			return Project(new(blend.X, blend.Y));
		float legacy = appearance?.EffectiveEarShape ?? .5f;
		if (!float.IsFinite(legacy))
			return Human;
		legacy = Math.Clamp(legacy, 0, 1);
		return legacy < .5f ? Human + (Goblin - Human) * (1 - 2 * legacy) : Human + (Elf - Human) * (2 * legacy - 1);
	}

	/// <summary>X = Goblin, Y = Elf, Z = Animal; weights sum to at most one.</summary>
	public static Vector3 Weights(CharacterAppearanceData appearance)
	{
		// Keep old saves on their exact former coefficients, without a round trip
		// through barycentric coordinates introducing tiny extra family weights.
		if (appearance?.EarBlend is null)
		{
			float legacy = appearance?.EffectiveEarShape ?? .5f;
			if (!float.IsFinite(legacy))
				return Vector3.Zero;
			legacy = Math.Clamp(legacy, 0, 1);
			return new(MathF.Max(0, 1 - 2 * legacy), MathF.Max(0, 2 * legacy - 1), 0);
		}

		return WeightsAt(Position(appearance));
	}

	static Vector3 WeightsAt(Vector2 position)
	{
		if (position == Human)
			return Vector3.Zero;
		var p = Project(position);
		float goblin = MathF.Max(0, .5f + .5f * p.y - p.x);
		float elf = MathF.Max(0, 1 - p.y);
		float animal = MathF.Max(0, p.x - .5f + .5f * p.y);
		float total = goblin + elf + animal;
		goblin /= total;
		elf /= total;
		animal /= total;
		float shared = MathF.Min(goblin, MathF.Min(elf, animal));
		var fantasy = new Vector3(goblin - shared, elf - shared, animal - shared);
		float sum = fantasy.x + fantasy.y + fantasy.z;
		return sum > 1 ? fantasy / sum : fantasy;
	}

	/// <summary>Accept boundary rounding from a projected pointer, but reject invalid saved data.</summary>
	public static bool IsInside(Vector2 p) => float.IsFinite(p.x) && float.IsFinite(p.y) && p.x >= 0 && p.x <= 1 && p.y >= 0 && p.y <= 1 && MathF.Abs(p.x - .5f) <= .5f * p.y + .000001f;
	/// <summary>Closest Euclidean point on the closed triangle; nonfinite input resolves to human.</summary>
	public static Vector2 Project(Vector2 p)
	{
		if (!float.IsFinite(p.x) || !float.IsFinite(p.y))
			return Human;
		if (p.y >= 0 && p.y <= 1 && p.x >= .5f - .5f * p.y && p.x <= .5f + .5f * p.y)
			return p;
		var closest = OnSegment(p, Elf, Goblin);
		var next = OnSegment(p, Goblin, Animal);
		if (DistanceSquared(p, next) < DistanceSquared(p, closest))
			closest = next;
		next = OnSegment(p, Animal, Elf);
		return DistanceSquared(p, next) < DistanceSquared(p, closest) ? next : closest;
	}

	static double DistanceSquared(Vector2 a, Vector2 b)
	{
		double x = (double)a.x - b.x, y = (double)a.y - b.y;
		return x * x + y * y;
	}

	static Vector2 OnSegment(Vector2 p, Vector2 a, Vector2 b)
	{
		var edge = b - a;
		float t = (float)Math.Clamp((((double)p.x - a.x) * edge.x + ((double)p.y - a.y) * edge.y) / (edge.x * edge.x + edge.y * edge.y), 0, 1);
		return a + edge * t;
	}

	public static void SetPosition(CharacterAppearanceData appearance, Vector2 position)
	{
		if (appearance is null || !float.IsFinite(position.x) || !float.IsFinite(position.y))
			return;
		var p = Project(position);
		appearance.EarBlend = new()
		{
			X = p.x,
			Y = p.y
		};
		var weights = WeightsAt(p);
		// Old readers can represent only Goblin/Human/Elf along a single axis.
		// Keep that bias; the new Animal contribution has no legacy equivalent.
		appearance.EarShape = Math.Clamp(.5f + .5f * (weights.y - weights.x), 0, 1);
		appearance.EarTaper = MathF.Max(0, 2 * appearance.EarShape.Value - 1);
	}

	public static void Reset(CharacterAppearanceData appearance)
	{
		if (appearance is null)
			return;
		appearance.EarBlend = null;
		appearance.EarShape = .5f;
		appearance.EarTaper = 0;
	}

	/// <summary>Preserve Animal while old photo controls bias the remaining Goblin/Elf share.</summary>
	internal static void ApplyPhotoOffsets(CharacterAppearanceData appearance, float legacyTaperOffset, float shapeOffset)
	{
		if (legacyTaperOffset == 0 && shapeOffset == 0)
			return;
		var w = Weights(appearance);
		float bias = w.y - w.x;
		// A legacy taper cannot create elf shape from a manually chosen goblin.
		if (bias >= 0)
			bias = Math.Clamp(bias + legacyTaperOffset, 0, 1);
		bias = Math.Clamp(bias + 2 * shapeOffset, -(1 - w.z), 1 - w.z);
		float common = MathF.Min(MathF.Min(w.x, w.y), MathF.Max(0, (1 - w.z - MathF.Abs(bias)) * .5f));
		float goblin = common + MathF.Max(0, -bias), elf = common + MathF.Max(0, bias);
		float human = MathF.Max(0, 1 - goblin - elf - w.z);
		SetPosition(appearance, Human * human + Goblin * goblin + Elf * elf + Animal * w.z);
	}
}