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