Editor/WeaponImporter/Core/Analysis/GripFinder.cs
#nullable enable annotations
using System.Numerics;
using WeaponImporter.Core.Geometry;
using WeaponImporter.Core.Weapon;
namespace WeaponImporter.Core.Analysis;
using Vector3 = System.Numerics.Vector3;
/// <summary>
/// Finds where hands belong from the weapon's own geometry: the pistol grip hangs below the
/// receiver behind the trigger, the handguard runs under the barrel ahead of the magazine,
/// melee handles are the round, narrow end. Contacts are then probed on the real surface.
/// Model space: +X forward, +Y left, +Z up.
/// </summary>
public static class GripFinder
{
/// <summary>
/// A right palm sits on the right side of a pistol grip (heel on the backstrap), so the
/// fingers point forward around it and the wrist stays straight behind the grip.
/// </summary>
private const float PalmWrapDegrees = 70f;
public static void FindGrips(WeaponAnalysis a)
{
if (a.WeaponBvh.IsEmpty)
return;
if (a.Type.Type == WeaponType.Melee)
{
FindMeleeGrips(a);
return;
}
a.Primary = FindPistolGrip(a) ?? BarGrip(a) ?? FallbackPrimary(a);
foreach (var c in SupportCandidates(a))
a.SupportAreas.Add(c);
a.Support = a.SupportAreas.FirstOrDefault();
}
// ------------------------------------------------------------------ primary
/// <summary>Silhouette of the weapon without parts that also hang down (magazine, foregrip, pump).</summary>
private static ShapeProfile FrameProfile(WeaponAnalysis a)
{
var excluded = new HashSet<int>();
foreach (var part in a.Parts.Where(p => p.Kind is PartKind.Magazine or PartKind.Foregrip or PartKind.Pump))
foreach (var t in PartTriangles(a, part))
excluded.Add(t);
var tris = a.WeaponTriangles.Where(t => !excluded.Contains(t)).ToArray();
return tris.Length > 0 ? ShapeProfile.Build(a.Asset.Mesh, tris) : a.Profile;
}
public static IEnumerable<int> PartTriangles(WeaponAnalysis a, PartDetection part)
{
var mesh = a.Asset.Mesh;
var bone = part.Bone != "" ? a.Asset.Skeleton.IndexOf(part.Bone) : -1;
var meshParts = new HashSet<int>(part.Meshes.Select(n => mesh.PartNames.ToList().IndexOf(n)).Where(i => i >= 0));
HashSet<int>? bones = null;
if (bone >= 0)
{
bones = new HashSet<int> { bone };
for (var b = 0; b < a.Asset.Skeleton.Count; b++)
if (a.Asset.Skeleton[b].ParentIndex >= 0 && bones.Contains(a.Asset.Skeleton[b].ParentIndex))
bones.Add(b);
}
foreach (var t in a.WeaponTriangles)
if ((bones is not null && bones.Contains(mesh.TriangleBone(t))) || meshParts.Contains(mesh.TrianglePart[t]))
yield return t;
}
/// <summary>Triangles of the frame: the weapon minus parts that sit inside or hang off the grip.</summary>
private static int[] FrameTriangles(WeaponAnalysis a)
{
var excluded = new HashSet<int>();
foreach (var part in a.Parts.Where(p => p.Kind is PartKind.Magazine or PartKind.Foregrip or PartKind.Pump or PartKind.Trigger))
foreach (var t in PartTriangles(a, part))
excluded.Add(t);
var tris = a.WeaponTriangles.Where(t => !excluded.Contains(t)).ToArray();
return tris.Length > 0 ? tris : a.WeaponTriangles;
}
private static GripCandidate? FindPistolGrip(WeaponAnalysis a)
{
var p = FrameProfile(a);
// The magazine often sits inside the grip; probe the shell around it, not the magazine.
var frame = new MeshBvh(a.Asset.Mesh, FrameTriangles(a));
var length = a.Length;
var boreZ = a.BoreStart.Z;
// Receiver bottom: typical lowest surface over the middle of the weapon.
var from = p.Bin(p.MinX + length * 0.2f);
var to = p.Bin(p.MinX + length * 0.8f);
// Upper percentile: on compact guns the handle spans much of the middle, the receiver
// underside is the higher level most slices share.
var receiverBottom = p.Percentile(i => p.Bottom[i], length < 14f ? 0.75f : 0.6f, from, to);
var drop = MathF.Max(0.9f, MathF.Min(2.5f, (boreZ - receiverBottom) * 0.8f));
var runs = p.Runs(i => p.Bottom[i] < receiverBottom - drop, 2);
WeaponAnalyzer.Trace?.Invoke($"grip: receiver bottom {receiverBottom:0.##}, drop {drop:0.##}, runs {string.Join(" ", runs.Select(r => $"[{p.X(r.Start):0.#}..{p.X(r.End):0.#}]"))}");
if (runs.Count == 0)
return null;
var trigger = a.Part(PartKind.Trigger);
var mag = a.Part(PartKind.Magazine);
var pistolLike = length < 14f;
(int Start, int End)? bestRun = null;
var bestScore = float.MinValue;
var bestReason = "";
foreach (var run in runs)
{
var x0 = p.X(run.Start);
var x1 = p.X(run.End);
var width = x1 - x0 + p.BinWidth;
var cx = (x0 + x1) * 0.5f;
var deepest = Enumerable.Range(run.Start, run.End - run.Start + 1).Where(p.Has).Min(i => p.Bottom[i]);
var depth = receiverBottom - deepest;
var score = MathF.Min(depth, 6f) * 0.8f;
var why = $"handle below the receiver ({depth:0.#} in)";
// Grips are 0.8-3 in front to back.
score -= MathF.Abs(Math.Clamp(width, 0.8f, 3.2f) - width) * 1.2f;
if (trigger is { } tr)
{
// The trigger sits at the front-top of the grip: 0-4 in ahead of its centre.
var behind = tr.Center.X - cx;
var outside = behind < -1f ? -1f - behind : behind > 4.5f ? behind - 4.5f : 0f;
score += 2f - outside;
if (outside == 0f)
why += ", behind the trigger";
}
if (!pistolLike)
{
// The rear-most drop on a long gun is usually the stock toe.
if (x0 < p.MinX + length * 0.12f)
score -= 3f;
// The grip sits behind the magazine.
if (mag is { } m && cx > m.Center.X)
score -= 2f;
// Buttstock drops are long.
if (width > 5f)
score -= 2f;
}
if (score > bestScore)
{
bestScore = score;
bestRun = run;
bestReason = why;
}
}
if (bestRun is not { } chosen)
return null;
// No real pistol grip: the only drop is the buttstock. Hold the stock's wrist instead.
if (!pistolLike && p.X(chosen.Start) < p.MinX + length * 0.12f && (p.X(chosen.End) - p.X(chosen.Start)) > 5f)
return StockWrist(a, frame, p, trigger);
// Axis of the grip: centre line of its horizontal cross-sections.
var gripBottom = Enumerable.Range(chosen.Start, chosen.End - chosen.Start + 1).Where(p.Has).Min(i => p.Bottom[i]);
var axis = GripAxis(frame, a, p, chosen, receiverBottom, gripBottom);
// Palm height: upper third of the handle, just under the receiver (web of the hand high).
var contactZ = receiverBottom - (receiverBottom - gripBottom) * 0.32f;
// Handle extent at that height from the silhouette (slices that reach below contactZ).
var reaching = Enumerable.Range(chosen.Start, chosen.End - chosen.Start + 1).Where(i => p.Has(i) && p.Bottom[i] <= contactZ).ToList();
var profX0 = reaching.Count > 0 ? p.X(reaching.First()) : p.X(chosen.Start);
var profX1 = reaching.Count > 0 ? p.X(reaching.Last()) : p.X(chosen.End);
var profMid = reaching.Count > 0 ? reaching[reaching.Count / 2] : (chosen.Start + chosen.End) / 2;
var profileCenter = new Vector3((profX0 + profX1) * 0.5f, p.Has(profMid) ? (p.Left[profMid] + p.Right[profMid]) * 0.5f : a.BoreStart.Y, contactZ);
var rayCenter = SectionCenter(frame, a, contactZ, profX0 - 0.5f, profX1 + 0.5f);
// The centre must really be inside the handle: sample across it at this height and
// take the middle of the longest inside span (open or odd meshes fool single rays).
var center = InsideCenter(frame, profX0 - 0.5f, profX1 + 0.5f, profileCenter.Y, contactZ)
?? (rayCenter is { } rc && rc.X >= profX0 - p.BinWidth && rc.X <= profX1 + p.BinWidth ? rc : profileCenter);
// Rear-right of the handle: rotate "backwards" around the grip axis toward the weapon's right.
var back = Vector3.Normalize(-Vector3.UnitX - axis * Vector3.Dot(-Vector3.UnitX, axis));
var right = Vector3.Normalize(Vector3.Cross(axis, back));
if (right.Y > 0f) right = -right; // weapon right is -Y
var rad = PalmWrapDegrees * MathF.PI / 180f;
var dir = Vector3.Normalize(back * MathF.Cos(rad) + right * MathF.Sin(rad));
var surface = ProbeFromInside(frame, center, dir, axis) ?? SurfaceProbe.FromPoint(frame, center + dir * 3f, axis);
if (surface is not null)
{
// The handle's axis from its cross-sections is more reliable than the local patch.
var along = axis - surface.Normal * Vector3.Dot(axis, surface.Normal);
if (along.LengthSquared() > 1e-4f)
surface = surface with { Axis = Vector3.Normalize(along) };
}
WeaponAnalyzer.Trace?.Invoke($"grip: contactZ {contactZ:0.##} centre {center} axis {axis} dir {dir} -> {surface?.Contact}");
if (surface is null)
return null;
var confidence = Math.Clamp(0.45f + bestScore * 0.08f, 0.3f, 0.95f);
return new GripCandidate { Surface = surface, Style = GripStyle.Wrap, Confidence = confidence, Reason = bestReason };
}
/// <summary>
/// Straight-stock weapons (pump shotguns, hunting rifles) are held at the narrow wrist of the
/// stock just behind the trigger guard.
/// </summary>
private static GripCandidate? StockWrist(WeaponAnalysis a, MeshBvh frame, ShapeProfile p, PartDetection? trigger)
{
var tx = trigger?.Center.X ?? (p.MinX + a.Length * 0.38f);
// Narrow slice about two inches behind the trigger (the hand's width behind the guard).
var best = -1;
var bestScore = float.MaxValue;
for (var i = p.Bin(tx - 4f); i <= p.Bin(tx - 1f); i++)
{
if (!p.Has(i))
continue;
var score = p.Height(i) + MathF.Abs(p.X(i) - (tx - 2f)) * 0.6f;
if (score < bestScore)
{
bestScore = score;
best = i;
}
}
WeaponAnalyzer.Trace?.Invoke($"stock wrist: trigger x {tx:0.##}, best bin {best}");
if (best < 0)
return null;
var x = p.X(best);
var y = (p.Left[best] + p.Right[best]) * 0.5f;
var center = new Vector3(x, y, (p.Top[best] + p.Bottom[best]) * 0.5f);
// Thumbhole stocks: the slice centre can be in the hole. Use the lowest solid segment
// along a vertical line that is wrist thick.
var hits = frame.RaycastAll(new Vector3(x, y, p.Bottom[best] - 2f), Vector3.UnitZ, p.Height(best) + 4f);
for (var h = 0; h + 1 < hits.Count; h += 2)
{
var thick = hits[h + 1].Point.Z - hits[h].Point.Z;
if (thick >= 0.5f && thick <= 3.2f)
{
center = new Vector3(x, y, (hits[h].Point.Z + hits[h + 1].Point.Z) * 0.5f);
break;
}
}
// The wrist slopes down toward the butt; follow the centre line of nearby slices.
var back = p.Bin(x - 1.5f);
var fore = p.Bin(x + 1.5f);
var axis = p.Has(back) && p.Has(fore)
? Vector3.Normalize(new Vector3(p.X(fore) - p.X(back), 0f, (p.Top[fore] + p.Bottom[fore] - p.Top[back] - p.Bottom[back]) * 0.5f))
: Vector3.UnitX;
// Hand wraps the wrist: knuckles along its long axis, palm on the right side.
var surface = ProbeFromInside(frame, center, Vector3.Normalize(new Vector3(0f, -1f, 0.35f)), axis)
?? SurfaceProbe.FromPoint(frame, center - Vector3.UnitY * 2f, axis);
if (surface is null)
return null;
return new GripCandidate { Surface = surface, Style = GripStyle.Wrap, Confidence = 0.6f, Reason = "wrist of the stock (no pistol grip)" };
}
/// <summary>Middle of the longest run of inside samples along X at (y, z), or null.</summary>
private static Vector3? InsideCenter(MeshBvh bvh, float x0, float x1, float y, float z)
{
const int n = 24;
int bestStart = -1, bestLen = 0, start = -1;
for (var i = 0; i <= n; i++)
{
var inside = i < n && bvh.IsInside(new Vector3(x0 + (x1 - x0) * (i + 0.5f) / n, y, z), 60f);
if (inside && start < 0)
start = i;
if (!inside && start >= 0)
{
if (i - start > bestLen) { bestLen = i - start; bestStart = start; }
start = -1;
}
}
if (bestLen == 0)
return null;
var mid = bestStart + bestLen * 0.5f;
return new Vector3(x0 + (x1 - x0) * mid / n, y, z);
}
/// <summary>Average X/Y centre of the horizontal slice at height z between x0 and x1.</summary>
private static Vector3? SectionCenter(MeshBvh bvh, WeaponAnalysis a, float z, float x0, float x1)
{
var y = a.BoreStart.Y;
// Two horizontal rays through the slice give its front/back; two lateral rays its sides.
var back = bvh.Raycast(new Vector3(x0 - 4f, y, z), Vector3.UnitX, x1 - x0 + 8f);
var front = bvh.Raycast(new Vector3(x1 + 4f, y, z), -Vector3.UnitX, x1 - x0 + 8f);
if (back is null || front is null || front.Value.Point.X <= back.Value.Point.X)
return null;
var cx = (back.Value.Point.X + front.Value.Point.X) * 0.5f;
var left = bvh.Raycast(new Vector3(cx, y + 6f, z), -Vector3.UnitY, 12f);
var rightHit = bvh.Raycast(new Vector3(cx, y - 6f, z), Vector3.UnitY, 12f);
var cy = left is { } l && rightHit is { } r ? (l.Point.Y + r.Point.Y) * 0.5f : y;
return new Vector3(cx, cy, z);
}
private static Vector3 GripAxis(MeshBvh bvh, WeaponAnalysis a, ShapeProfile p, (int Start, int End) run, float top, float bottom)
{
var x0 = p.X(run.Start) - 0.5f;
var x1 = p.X(run.End) + 0.5f;
var upper = SectionCenter(bvh, a, top - (top - bottom) * 0.15f, x0, x1);
var lower = SectionCenter(bvh, a, bottom + (top - bottom) * 0.15f, x0, x1);
if (upper is { } u && lower is { } l && Vector3.Distance(u, l) > 0.5f)
{
var d = Vector3.Normalize(u - l);
// Grips rake back (bottom behind top) by 0-30 degrees; reject anything wilder.
if (d.Z > 0.7f)
return d;
}
return Vector3.Normalize(new Vector3(0.28f, 0f, 1f));
}
/// <summary>
/// Casts from a point inside the part outward and measures the surface where the ray
/// leaves it. Falls back to the closest surface point when the part is open.
/// </summary>
public static GripSurface? ProbeFromInside(MeshBvh bvh, Vector3 inside, Vector3 direction, Vector3? axisHint)
{
var hit = bvh.Raycast(inside, direction, 12f);
if (hit is not { } h)
return null;
// The returned normal faces the ray origin (inward here); outward is the opposite.
var outward = -h.Normal;
if (Vector3.Dot(outward, direction) < 0f)
outward = -outward;
return SurfaceProbe.Measure(bvh, h.Point, outward, h.Triangle, axisHint);
}
/// <summary>
/// Thumbhole stocks and bullpups (P90, AWP-style stocks): the firing hand wraps the solid
/// bar just behind the trigger, found by entering the underside and measuring how thick
/// the solid is before the hole.
/// </summary>
private static GripCandidate? BarGrip(WeaponAnalysis a)
{
if (a.Part(PartKind.Trigger) is not { } trigger)
return null;
var frame = new MeshBvh(a.Asset.Mesh, FrameTriangles(a));
var x = trigger.Center.X - 1.6f;
var start = new Vector3(x, a.BoreStart.Y, a.WeaponBounds.Min.Z - 2f);
var hits = frame.RaycastAll(start, Vector3.UnitZ, a.WeaponBounds.Size.Z + 4f);
if (hits.Count < 2)
return null;
var z0 = hits[0].Point.Z;
var z1 = hits[1].Point.Z;
var thickness = z1 - z0;
// A bar under a hole: thin, and below the bore.
if (thickness < 0.4f || thickness > 3f || z1 > a.BoreStart.Z)
return null;
var center = new Vector3(x, a.BoreStart.Y, (z0 + z1) * 0.5f);
var surface = ProbeFromInside(frame, center, Vector3.Normalize(new Vector3(0f, -1f, -0.3f)), Vector3.UnitX);
if (surface is null)
return null;
// Fist upright through the thumbhole: knuckle line raked like a pistol grip.
var raked = Vector3.Normalize(new Vector3(0.35f, 0f, 1f));
var along = raked - surface.Normal * Vector3.Dot(raked, surface.Normal);
if (along.LengthSquared() > 1e-4f)
surface = surface with { Axis = Vector3.Normalize(along) };
return new GripCandidate { Surface = surface, Style = GripStyle.Wrap, Confidence = 0.5f, Reason = $"bar behind the trigger ({thickness:0.#} in thick)" };
}
private static GripCandidate? FallbackPrimary(WeaponAnalysis a)
{
// No clear handle: hold the rear third of the underside.
var x = a.WeaponBounds.Min.X + a.Length * (a.Length < 14f ? 0.3f : 0.35f);
var below = new Vector3(x, a.BoreStart.Y, a.WeaponBounds.Min.Z - 2f);
var hit = a.WeaponBvh.Raycast(below, Vector3.UnitZ, a.WeaponBounds.Size.Z + 4f);
if (hit is null)
return null;
var s = SurfaceProbe.Measure(a.WeaponBvh, hit.Value.Point, -Vector3.UnitZ, hit.Value.Triangle, Vector3.UnitX);
return new GripCandidate { Surface = s, Style = GripStyle.Wrap, Confidence = 0.25f, Reason = "no pistol grip found; using the underside" };
}
// ------------------------------------------------------------------ support
private static IEnumerable<GripCandidate> SupportCandidates(WeaponAnalysis a)
{
var list = new List<GripCandidate>();
var type = a.Type.Type;
if (a.Part(PartKind.Foregrip) is { } fg && PartSurface(a, fg, new Vector3(-0.6f, 0.6f, 0f), Vector3.UnitZ) is { } fgs)
list.Add(new GripCandidate { Surface = fgs, Style = GripStyle.Wrap, Confidence = Math.Clamp(fg.Confidence, 0.4f, 0.95f), Reason = "foregrip" });
if (a.Part(PartKind.Pump) is { } pump && PartSurface(a, pump, new Vector3(0f, 0.55f, -1f), Vector3.UnitX) is { } ps)
list.Add(new GripCandidate { Surface = ps, Style = GripStyle.Pump, Confidence = Math.Clamp(pump.Confidence, 0.4f, 0.95f), Reason = "pump" });
if (type is WeaponType.Pistol or WeaponType.Revolver || (a.Length < 12f && type == WeaponType.Custom))
{
if (a.Primary is { } primary)
{
// Support hand wraps the firing hand: its palm sits on the left of the grip, outside the right fingers.
var inside = primary.Surface.Center;
var s = ProbeFromInside(a.WeaponBvh, inside, Vector3.Normalize(new Vector3(0.15f, 1f, -0.2f)), primary.Surface.Axis);
if (s is not null)
list.Add(new GripCandidate { Surface = s, Style = GripStyle.Overlay, Confidence = 0.7f, Reason = "two-handed pistol grip" });
}
}
else if (Handguard(a) is { } hg)
{
list.Add(hg);
}
// Magazine well as a fallback support area (SMGs, bullpups).
if (a.Part(PartKind.Magazine) is { } mag && type is not (WeaponType.Pistol or WeaponType.Revolver))
{
var tris = PartTriangles(a, mag).ToArray();
if (tris.Length > 0)
{
var top = tris.Max(t => { var (x, y, z) = a.Asset.Mesh.Triangle(t); return MathF.Max(x.Z, MathF.Max(y.Z, z.Z)); });
var inside = new Vector3(mag.Center.X, mag.Center.Y, MathF.Min(top - 0.6f, (top + mag.Center.Z) * 0.5f));
var s = ProbeFromInside(a.WeaponBvh, inside, Vector3.Normalize(new Vector3(0.2f, 1f, -0.3f)), Vector3.UnitZ);
if (s is not null)
list.Add(new GripCandidate { Surface = s, Style = GripStyle.Wrap, Confidence = 0.35f, Reason = "magazine well" });
}
}
return list.OrderByDescending(c => c.Confidence);
}
private static GripSurface? PartSurface(WeaponAnalysis a, PartDetection part, Vector3 outward, Vector3 axisHint)
=> ProbeFromInside(a.WeaponBvh, part.Center, Vector3.Normalize(outward), axisHint);
/// <summary>Underside of the barrel/handguard ahead of the magazine and trigger.</summary>
private static GripCandidate? Handguard(WeaponAnalysis a)
{
var p = a.Profile;
var gripX = a.Primary?.Surface.Center.X ?? (a.WeaponBounds.Min.X + a.Length * 0.35f);
var muzzleX = a.Muzzle?.Model.Pos.X ?? a.WeaponBounds.Max.X;
var reach = muzzleX - gripX;
if (reach < 4f)
return null;
var ahead = new[] { a.Part(PartKind.Magazine)?.Center.X ?? gripX, a.Part(PartKind.Trigger)?.Center.X ?? gripX }.Max();
var ideal = gripX + Math.Clamp(reach * 0.45f, 5f, 16f);
var minX = MathF.Max(ahead + 2.2f, gripX + 4f);
var maxX = muzzleX - 1.5f;
if (minX > maxX)
minX = maxX = Math.Clamp(ideal, gripX + 2f, muzzleX - 0.5f);
var x = Math.Clamp(ideal, minX, maxX);
// Slide to the nearest slice with geometry close under the bore (skip bare barrel gaps).
var boreZ = a.BoreStart.Z;
var bestBin = -1;
var bestDist = float.MaxValue;
for (var i = p.Bin(minX); i <= p.Bin(maxX); i++)
{
if (!p.Has(i))
continue;
var under = boreZ - p.Bottom[i];
if (under < 0.15f || under > 4.5f)
continue;
var d = MathF.Abs(p.X(i) - x);
// Prefer thicker sections (a handguard over a bare barrel).
d -= MathF.Min(p.Height(i), 3f) * 0.8f;
if (d < bestDist)
{
bestDist = d;
bestBin = i;
}
}
if (bestBin >= 0)
x = p.X(bestBin);
var bin = p.Bin(x);
var zMid = p.Has(bin) ? (MathF.Min(boreZ, p.Top[bin]) + p.Bottom[bin]) * 0.5f : boreZ;
var inside = new Vector3(x, a.BoreStart.Y, zMid);
// Palm under the handguard, heel toward the weapon's left.
var dir = Vector3.Normalize(new Vector3(0f, 0.5f, -1f));
var s = ProbeFromInside(a.WeaponBvh, inside, dir, Vector3.UnitX);
if (s is null)
{
var below = a.WeaponBvh.Raycast(new Vector3(x, a.BoreStart.Y, a.WeaponBounds.Min.Z - 2f), Vector3.UnitZ, 20f);
if (below is null)
return null;
s = SurfaceProbe.Measure(a.WeaponBvh, below.Value.Point, -Vector3.UnitZ, below.Value.Triangle, Vector3.UnitX);
}
var conf = bestBin >= 0 ? 0.65f : 0.4f;
return new GripCandidate { Surface = s, Style = GripStyle.Cradle, Confidence = conf, Reason = "handguard under the barrel" };
}
// ------------------------------------------------------------------ melee
private static void FindMeleeGrips(WeaponAnalysis a)
{
var p = a.Profile;
// A handle is a run of small, roughly round slices; blades are flat, guards are wide.
float Big(int i) => MathF.Max(p.Height(i), p.Width(i));
float Small(int i) => MathF.Max(0.05f, MathF.Min(p.Height(i), p.Width(i)));
bool HandleSlice(int i) => p.Has(i) && Big(i) <= 2.2f && Big(i) / Small(i) <= 2.4f;
var runs = p.Runs(HandleSlice, 3);
(int Start, int End) handle;
if (runs.Count > 0)
{
// Prefer long runs that touch an end of the weapon (pommel side).
handle = runs.OrderByDescending(r =>
{
var len = (r.End - r.Start + 1) * p.BinWidth;
var atEnd = r.Start <= 2 || r.End >= p.Count - 3 ? 1.5f : 0f;
return len + atEnd;
}).First();
}
else
{
// Fall back to the thinner end.
var n = Math.Max(2, p.Count / 5);
var rear = Enumerable.Range(0, n).Where(p.Has).Select(Big).DefaultIfEmpty(99f).Average();
var front = Enumerable.Range(p.Count - n, n).Where(p.Has).Select(Big).DefaultIfEmpty(99f).Average();
handle = rear <= front ? (0, n - 1) : (p.Count - n, p.Count - 1);
}
var x0 = p.X(handle.Start);
var x1 = p.X(handle.End);
var handleLength = x1 - x0 + p.BinWidth;
// The blade (the bigger remainder) sits on the side with more geometry.
var before = Enumerable.Range(0, handle.Start).Count(p.Has);
var after = Enumerable.Range(handle.End + 1, Math.Max(0, p.Count - handle.End - 1)).Count(p.Has);
var towardBlade = after >= before ? Vector3.UnitX : -Vector3.UnitX;
var guardEnd = towardBlade.X > 0 ? x1 : x0;
Vector3 CenterAt(float x)
{
var bin = p.Bin(x);
return new Vector3(x, p.Has(bin) ? (p.Left[bin] + p.Right[bin]) * 0.5f : a.BoreStart.Y, p.Has(bin) ? (p.Top[bin] + p.Bottom[bin]) * 0.5f : a.BoreStart.Z);
}
// One fist is ~3.5 in wide: centre it a little below the guard.
var cx = guardEnd - towardBlade.X * MathF.Min(1.8f, handleLength * 0.45f);
var s = ProbeFromInside(a.WeaponBvh, CenterAt(cx), -Vector3.UnitY, towardBlade);
if (s is null)
return;
a.Primary = new GripCandidate { Surface = s, Style = GripStyle.Handle, Confidence = runs.Count > 0 && handleLength > 2.5f ? 0.75f : 0.4f, Reason = $"handle ({handleLength:0.#} in)" };
if (handleLength > 7f)
{
var cx2 = guardEnd - towardBlade.X * MathF.Min(handleLength - 1.5f, 5.4f);
var s2 = ProbeFromInside(a.WeaponBvh, CenterAt(cx2), Vector3.UnitY, towardBlade);
if (s2 is not null)
{
var support = new GripCandidate { Surface = s2, Style = GripStyle.Handle, Confidence = 0.55f, Reason = "long handle, two-handed" };
a.SupportAreas.Add(support);
a.Support = support;
}
}
}
}