Code/CharacterKit/HornClearance.cs

Solver for horn/head clearance in the character editor. Computes separating planes for a head mesh and finds placement (position, rotation, clearance) for a horn path so it does not intersect the head by testing candidate rotations and measuring clearance against planes.

Native Interop
using System;

namespace CharacterEditor;
/// <summary>Head clearance is solved once on edits, never during animation.</summary>
internal static class HornClearance
{
	internal readonly record struct Plane(Vector3 Normal, float Distance);
	internal readonly record struct Fit(Vector3 Position, Rotation Rotation, float Clearance);
	internal static Plane[] Planes(Vector3[] points)
	{
		var center = points.Aggregate(Vector3.Zero, (a, b) => a + b) / points.Length;
		var result = new List<Plane>();
		for (int i = 0; i < HornHeadEnvelope.Triangles.Length; i += 3)
		{
			var a = points[HornHeadEnvelope.Triangles[i]];
			var b = points[HornHeadEnvelope.Triangles[i + 1]];
			var c = points[HornHeadEnvelope.Triangles[i + 2]];
			var n = Vector3.Cross(b - a, c - a).Normal;
			if (Vector3.Dot(n, a - center) < 0)
				n = -n;
			result.Add(new(n, Vector3.Dot(n, a)));
		}

		return result.ToArray();
	}

	internal static Fit Solve(CharacterHornData data, bool mirrored, Vector3 point, Vector3 normal, Plane[] planes)
	{
		var path = CharacterHornMesh.CreatePath(data, 64, mirrored);
		float sign = mirrored ? -1 : 1;
		// Thickness is a width control, not a placement control. Choose one angle
		// that clears the full thickness range, then only change the root's burial
		// depth as its rim grows. Solving each current width independently made
		// small slider steps switch collision branches differently on each side.
		var widest = data.DeepCopy();
		widest.Thickness = 1;
		var narrowest = data.DeepCopy();
		narrowest.Thickness = 0;
		var maximum = CharacterHornMesh.CreatePath(widest, 64, mirrored);
		var minimum = CharacterHornMesh.CreatePath(narrowest, 64, mirrored);
		// Align the departure to the actual scalp, not mainly to world-up. This
		// keeps a crown horn above the head even at the ends of the tilt sliders.
		var align = Rotation.FromToRotation(Vector3.Up, normal);
		var restingAlign = Rotation.FromToRotation(Vector3.Up, Vector3.Lerp(Vector3.Up, normal, .65f).Normal);
		var desired = restingAlign * Rotation.FromPitch((data.Tilt - .5f) * 50) * Rotation.FromRoll(-sign * ((data.Splay - .5f) * 40 + (data.Family == "ram" ? 10 : 0)));
		var best = Evaluate(desired);
		if (best.Clearance >= 0)
			return best;
		for (int step = 1; step <= 10; step++)
		{
			var candidate = Evaluate(Rotation.Slerp(desired, align, step / 10f));
			if (candidate.Clearance >= 0)
				return candidate;
			if (candidate.Clearance > best.Clearance)
				best = candidate;
		}

		// A returning curl may need to open around the side of the head. Keep the
		// authored curve intact and turn its bend plane instead of distorting it.
		foreach (float turn in new[]
		{
			30f,
			-30f,
			60f,
			-60f,
			90f,
			-90f,
			120f,
			-120f,
			180f
		}

		)
		{
			var candidate = Evaluate(align * Rotation.FromYaw(turn * sign));
			if (candidate.Clearance >= 0)
				return candidate;
			if (candidate.Clearance > best.Clearance)
				best = candidate;
		}

		foreach (float lean in new[]
		{
			-25f,
			25f,
			-40f,
			40f
		}

		)
			foreach (float turn in new[]
			{
				0f,
				45f,
				-45f,
				90f,
				-90f,
				135f,
				-135f,
				180f
			}

			)
			{
				var target = align * Rotation.FromYaw(turn * sign) * Rotation.FromPitch(lean);
				var candidate = Evaluate(target);
				if (candidate.Clearance >= 0)
				{
					// Find the first clear orientation along the shortest turn from the
					// requested pose, so a limiting slider does not jump to the full lean.
					float low = 0, high = 1;
					for (int j = 0; j < 7; j++)
					{
						float mid = (low + high) * .5f;
						var probe = Evaluate(Rotation.Slerp(desired, target, mid));
						if (probe.Clearance >= 0)
						{
							high = mid;
							candidate = probe;
						}
						else
							low = mid;
					}

					return candidate;
				}

				if (candidate.Clearance > best.Clearance)
					best = candidate;
			}

		return best;
		Fit Evaluate(Rotation rotation)
		{
			float cosine = Vector3.Dot(rotation * path.Tangents[0], normal);
			float slope = MathF.Sqrt(MathF.Max(0, 1 - cosine * cosine));
			float Depth(float radius) => .10f + radius * slope + .11f * radius * radius;
			var position = point - normal * Depth(path.Radii[0]);
			var deepest = point - normal * Depth(maximum.Radii[0]);
			var shallowest = point - normal * Depth(minimum.Radii[0]);
			return new(position, rotation, MeasureRange(maximum, deepest, shallowest, rotation, planes));
		}
	}

	static float MeasureRange(CharacterHornMesh.Path maximum, Vector3 deepest, Vector3 shallowest, Rotation rotation, Plane[] planes)
	{
		float minimum = float.MaxValue;
		for (int i = 1; i < maximum.Centers.Length; i++)
		{
			// Use the same root socket for the entire width range. Moving the first
			// tested ring with the current radius was another source of angle jumps.
			if (maximum.Length * i / (maximum.Centers.Length - 1) < maximum.Radii[0] * 1.8f + .25f)
				continue;
			var offset = rotation * maximum.Centers[i];
			float distance = float.MinValue;
			foreach (var plane in planes)
			{
				// Every intermediate root position lies on this segment and every
				// intermediate radius is no larger. A separating plane that clears
				// this whole capsule therefore clears every thickness, not just 0/1.
				float a = Vector3.Dot(plane.Normal, deepest + offset) - plane.Distance;
				float b = Vector3.Dot(plane.Normal, shallowest + offset) - plane.Distance;
				distance = MathF.Max(distance, MathF.Min(a, b));
			}

			minimum = MathF.Min(minimum, distance - maximum.Radii[i] - .12f);
		}

		return minimum;
	}
}