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