Editor/WeaponImporter/Core/Grip/GripSolver.cs
#nullable enable annotations
using System.Numerics;
using WeaponImporter.Core.Analysis;
using WeaponImporter.Core.Geometry;
using WeaponImporter.Core.Grasp;
using WeaponImporter.Core.Hands;
using WeaponImporter.Core.Ik;
using WeaponImporter.Core.Maths;
using WeaponImporter.Core.Weapon;
namespace WeaponImporter.Core.Grip;
using Vector3 = System.Numerics.Vector3;
/// <summary>The character side of a grip solve.</summary>
public sealed record CharacterRig
{
public required HandRig Right { get; init; }
public HandRig? Left { get; init; }
/// <summary>Bone the weapon follows in game (hold_R, else hand_R).</summary>
public required int HoldBone { get; init; }
/// <summary>Direction the character aims in model space (s&box characters face +X).</summary>
public Vector3 Forward { get; init; } = Vector3.UnitX;
public Vector3 Up { get; init; } = Vector3.UnitZ;
public static CharacterRig? From(Rig.Skeleton skeleton, bool pinkyFollowsRing = false)
{
var right = HandRig.Build(skeleton, Side.Right);
if (right is null)
return null;
var left = HandRig.Build(skeleton, Side.Left);
right.PinkyFollowsRing = pinkyFollowsRing;
if (left is not null)
left.PinkyFollowsRing = pinkyFollowsRing;
var hold = skeleton.IndexOf("hold_R");
return new CharacterRig { Right = right, Left = left, HoldBone = hold >= 0 ? hold : right.Hand };
}
}
/// <summary>User choices that shape the solve.</summary>
public sealed record GripOptions
{
public bool UseSupportHand { get; init; } = true;
public bool IndexOnTrigger { get; init; } = true;
/// <summary>0 keeps the weapon pointing exactly where the character aims; 1 keeps the animated wrist.</summary>
public float WristPreference { get; init; } = 0.35f;
/// <summary>User nudge of the weapon in the hand (applied after the solve), weapon space.</summary>
public XForm WeaponOffset { get; init; } = XForm.Identity;
/// <summary>Hand poses edited by the user (weapon space); they replace the generated ones.</summary>
public HandPose? RightOverride { get; init; }
public HandPose? LeftOverride { get; init; }
public IGripGenerator? Backend { get; init; }
/// <summary>
/// Preview pass: no placement search and no fine grasp search, just the direct grasp on the
/// given surfaces (milliseconds). The editor shows it at once and refines in the background.
/// </summary>
public bool Fast { get; init; }
}
/// <summary>Everything a baked grip needs, plus diagnostics.</summary>
public sealed class GripSolution
{
/// <summary>Weapon (canonical space) relative to the hold bone.</summary>
public required XForm WeaponInHold { get; init; }
/// <summary>Firing hand in weapon space, with finger angles.</summary>
public required HandPose Right { get; init; }
public HandPose? Left { get; init; }
public required GraspQuality RightQuality { get; init; }
public GraspQuality? LeftQuality { get; init; }
/// <summary>The grasp problems the hands were solved for (to re-score edited poses cheaply).</summary>
public GraspRequest? RightRequest { get; init; }
public GraspRequest? LeftRequest { get; init; }
/// <summary>Rotation the firing wrist needs away from the animation, degrees.</summary>
public float RightWristCorrection { get; init; }
public float LeftWristCorrection { get; init; }
/// <summary>Anatomical wrist bend after the solve (hand vs forearm, vs rest), degrees.</summary>
public float RightWristBend { get; init; }
public float LeftWristBend { get; init; }
public ReachResult RightReach { get; init; }
public ReachResult LeftReach { get; init; }
/// <summary>Reference pose with the grip applied (preview / validation).</summary>
public required CharacterPose Posed { get; init; }
/// <summary>Which grip each hand came from ("Procedural", a library grip's name, "Edited").</summary>
public string RightSource { get; init; } = "";
public string LeftSource { get; init; } = "";
public List<string> Notes { get; } = new();
}
/// <summary>
/// The grip pipeline: existing animation -> weapon placement -> wrist contact -> arm IK ->
/// wrist orientation -> finger fit -> penetration clean-up. Pure maths; the editor feeds it a
/// sampled character pose and the runtime component replays the baked result.
/// </summary>
public static class GripSolver
{
/// <summary>Stage timings for diagnostics (tests hook it; null in the editor).</summary>
public static Action<string>? Trace;
public static GripSolution Solve(WeaponAnalysis weapon, GripCandidate primary, GripCandidate? support, CharacterRig character, CharacterPose reference, GripOptions? options = null, CancellationToken cancel = default)
{
options ??= new GripOptions();
var clock = System.Diagnostics.Stopwatch.StartNew();
var backend = options.Backend ?? GripGenerators.All.First(b => b.IsAvailable);
var bvh = weapon.WeaponBvh;
var world = reference.World;
// 1+2. Firing hand and weapon placement, searched together: palm positions around the
// grip are tried and each is judged by grasp quality AND the wrist bend the arm needs.
HandPose right;
GraspRequest rightRequest;
var rightSource = options.RightOverride is null ? "Procedural" : "Edited";
var leftSource = options.LeftOverride is null ? "Procedural" : "Edited";
XForm weaponWorld, weaponInHold;
if (options.RightOverride is { } fixedRight)
{
rightRequest = BuildRequest(weapon, primary, character.Right, bvh, options, firing: true, other: null, preferred: null);
right = fixedRight.Clone();
(weaponWorld, weaponInHold) = PlaceWeapon(weapon, character, world, right, options);
}
else
{
(GraspRequest Request, HandPose Pose, XForm World, XForm InHold)? best = null;
var bestScore = float.MinValue;
var steps = (options.Fast ? new[] { new SearchStep(0, primary, primary) } : Search(bvh, primary)).ToList();
// Palm angles are independent: each stage is evaluated in parallel (background thread),
// stage 1 only around stage 0's winner.
(float Score, GraspRequest Request, HandPose Pose, XForm World, XForm InHold) Evaluate(SearchStep variant)
{
var request = BuildRequest(weapon, variant.Grip, character.Right, bvh, options, firing: true, other: null, preferred: null);
var pose = Quick(backend, request, cancel);
var (w, inHold) = PlaceWeapon(weapon, character, world, pose, options);
var trial = reference.Clone();
ArmIk.Solve(trial, character.Right, XForm.Compose(w, pose.Wrist));
var bend = WristBend(trial, character.Right);
return (GraspEvaluator.Evaluate(request, pose).Score - BendPenalty(bend), request, pose, w, inHold);
}
var stage0 = steps.Where(s => s.Stage == 0).ToList();
var results0 = new (float Score, GraspRequest Request, HandPose Pose, XForm World, XForm InHold)[stage0.Count];
Parallel.For(0, stage0.Count, new ParallelOptions { CancellationToken = cancel }, i => results0[i] = Evaluate(stage0[i]));
var win = Enumerable.Range(0, stage0.Count).MaxBy(i => results0[i].Score);
bestScore = results0[win].Score;
best = (results0[win].Request, results0[win].Pose, results0[win].World, results0[win].InHold);
var bestGrip = stage0[win].Grip;
var stage1 = steps.Where(s => s.Stage == 1 && ReferenceEquals(s.From, bestGrip)).ToList();
var results1 = new (float Score, GraspRequest Request, HandPose Pose, XForm World, XForm InHold)[stage1.Count];
Parallel.For(0, stage1.Count, new ParallelOptions { CancellationToken = cancel }, i => results1[i] = Evaluate(stage1[i]));
foreach (var r in results1)
if (r.Score > bestScore)
{
bestScore = r.Score;
best = (r.Request, r.Pose, r.World, r.InHold);
}
Trace?.Invoke($"right search {clock.ElapsedMilliseconds} ms");
clock.Restart();
// Fine search around the winning palm angle (library grips compete here), judged
// again with the wrist bend each candidate's weapon placement needs.
rightRequest = best!.Value.Request;
if (options.Fast)
{
right = best.Value.Pose;
rightSource = "Procedural";
}
else
{
// Library grips are captured relative to the grip's own surface, so they are
// also judged there, not only on the palm angle the procedural search picked.
var baseRequest = BuildRequest(weapon, primary, character.Right, bvh, options, firing: true, other: null, preferred: null);
var (chosen, chosenRequest) = Choose(backend, new[] { rightRequest, baseRequest }, pose =>
{
var (w, _) = PlaceWeapon(weapon, character, world, pose, options);
var trial = reference.Clone();
ArmIk.Solve(trial, character.Right, XForm.Compose(w, pose.Wrist));
return BendPenalty(WristBend(trial, character.Right));
}, cancel);
right = chosen.Pose;
rightSource = chosen.Source;
rightRequest = chosenRequest;
}
(weaponWorld, weaponInHold) = PlaceWeapon(weapon, character, world, right, options);
}
Trace?.Invoke($"right choose {clock.ElapsedMilliseconds} ms");
clock.Restart();
// 3. Support hand, avoiding the firing hand, also judged by its wrist.
HandPose? left = null;
GraspQuality? leftQuality = null;
GraspRequest? leftRequest = null;
if (support is not null && options.UseSupportHand && character.Left is { } leftRig)
{
var rightShape = HandShape.Of(character.Right, right);
var preferred = XForm.ToLocal(weaponWorld, world[leftRig.Hand]);
if (options.LeftOverride is { } fixedLeft)
{
left = fixedLeft.Clone();
leftRequest = BuildRequest(weapon, support, leftRig, bvh, options, firing: false, other: rightShape, preferred: preferred);
leftQuality = GraspEvaluator.Evaluate(leftRequest, left);
}
else
{
var bestScore = float.MinValue;
GraspRequest? bestLeftRequest = null;
GripCandidate bestSupport = support;
foreach (var variant in options.Fast ? new[] { new SearchStep(0, support, support) } : Search(bvh, support))
{
cancel.ThrowIfCancellationRequested();
if (variant.Stage == 1 && !ReferenceEquals(variant.From, bestSupport))
continue;
var request = BuildRequest(weapon, variant.Grip, leftRig, bvh, options, firing: false, other: rightShape, preferred: preferred);
var pose = Quick(backend, request, cancel);
var quality = GraspEvaluator.Evaluate(request, pose);
var trial = reference.Clone();
var reach = ArmIk.Solve(trial, leftRig, XForm.Compose(weaponWorld, pose.Wrist));
var bend = WristBend(trial, leftRig);
var score = quality.Score - BendPenalty(bend) - reach.Shortfall * 2f;
if (score > bestScore)
{
bestScore = score;
left = pose;
leftQuality = quality;
bestLeftRequest = request;
if (variant.Stage == 0)
bestSupport = variant.Grip;
}
}
if (bestLeftRequest is not null && !options.Fast)
{
var baseLeft = BuildRequest(weapon, support, leftRig, bvh, options, firing: false, other: rightShape, preferred: preferred);
var (chosen, chosenRequest) = Choose(backend, new[] { bestLeftRequest, baseLeft }, pose =>
{
var trial = reference.Clone();
var reach = ArmIk.Solve(trial, leftRig, XForm.Compose(weaponWorld, pose.Wrist));
return BendPenalty(WristBend(trial, leftRig)) + reach.Shortfall * 2f;
}, cancel);
left = chosen.Pose;
leftSource = chosen.Source;
leftQuality = GraspEvaluator.Evaluate(chosenRequest, left);
leftRequest = chosenRequest;
}
else if (bestLeftRequest is not null)
{
leftRequest = bestLeftRequest;
}
}
}
Trace?.Invoke($"left {clock.ElapsedMilliseconds} ms");
// 4. Apply to the reference pose: arm IK + fingers.
var posed = reference.Clone();
var (rightReach, leftReach, rightCorr, leftCorr) = Apply(posed, character, weaponInHold, right, left, 1f, 1f);
var rightBend = WristBend(posed, character.Right);
var leftBend = left is not null && character.Left is not null ? WristBend(posed, character.Left) : 0f;
var solution = new GripSolution
{
WeaponInHold = weaponInHold,
Right = right,
Left = left,
RightQuality = GraspEvaluator.Evaluate(rightRequest, right),
LeftQuality = leftQuality,
RightRequest = rightRequest,
LeftRequest = leftRequest,
RightReach = rightReach,
LeftReach = leftReach,
RightWristCorrection = rightCorr,
LeftWristCorrection = leftCorr,
RightWristBend = rightBend,
LeftWristBend = leftBend,
Posed = posed,
RightSource = rightSource,
LeftSource = left is null ? "" : leftSource,
};
if (!leftReach.Reached && left is not null)
solution.Notes.Add($"Left hand falls {leftReach.Shortfall:0.0} in short of the support grip.");
if (rightBend > MaxWristBend)
solution.Notes.Add($"Right wrist bends {rightBend:0}° against the forearm.");
return solution;
}
/// <summary>
/// The generator's best few candidates, re-ranked with what their placement costs the arm
/// (wrist bend, reach). Returns the winner with the name of the grip it came from.
/// </summary>
private static (GraspCandidate Candidate, GraspRequest Request) Choose(IGripGenerator backend, IEnumerable<GraspRequest> requests, Func<HandPose, float> penalty, CancellationToken cancel)
{
(GraspCandidate, GraspRequest)? best = null;
var bestScore = float.MinValue;
var first = true;
foreach (var request in requests)
{
// Later requests (the grip's own surface) only add library grips; the procedural
// neighbourhood was already searched on the first.
var list = (first || backend is not ChoiceGripGenerator choice ? backend.Generate(request, cancel) : choice.GenerateLibrary(request, cancel)).Take(16).ToList();
if (list.Count == 0 && first)
list.Add(Best(backend, request, cancel));
first = false;
foreach (var c in list)
{
cancel.ThrowIfCancellationRequested();
var score = c.Rank - penalty(c.Pose);
if (score > bestScore)
{
bestScore = score;
best = (c, request);
}
}
}
return best!.Value;
}
/// <summary>
/// Fast re-fit after a fine-tune or finger edit: keeps the solved hand placements (optionally
/// swapping in edited poses), re-places the weapon with the new options and re-runs the arm
/// IK. No search, so it is cheap enough to run on every slider move.
/// </summary>
public static GripSolution Refit(WeaponAnalysis weapon, GripSolution previous, CharacterRig character, CharacterPose reference, GripOptions options, HandPose? right = null, HandPose? left = null)
{
var r = right ?? previous.Right;
var l = left ?? previous.Left;
var (_, weaponInHold) = PlaceWeapon(weapon, character, reference.World, r, options);
var posed = reference.Clone();
var (rightReach, leftReach, rightCorr, leftCorr) = Apply(posed, character, weaponInHold, r, l, 1f, 1f);
var rightQuality = right is not null && previous.RightRequest is { } rr ? GraspEvaluator.Evaluate(rr, r) : previous.RightQuality;
var leftQuality = left is not null && previous.LeftRequest is { } lr ? GraspEvaluator.Evaluate(lr, left) : previous.LeftQuality;
var rightBend = WristBend(posed, character.Right);
var leftBend = l is not null && character.Left is not null ? WristBend(posed, character.Left) : 0f;
var solution = new GripSolution
{
WeaponInHold = weaponInHold,
Right = r,
Left = l,
RightQuality = rightQuality,
LeftQuality = leftQuality,
RightRequest = previous.RightRequest,
LeftRequest = previous.LeftRequest,
RightReach = rightReach,
LeftReach = leftReach,
RightWristCorrection = rightCorr,
LeftWristCorrection = leftCorr,
RightWristBend = rightBend,
LeftWristBend = leftBend,
Posed = posed,
RightSource = right is null ? previous.RightSource : "Edited",
LeftSource = left is null ? previous.LeftSource : "Edited",
};
if (!leftReach.Reached && l is not null)
solution.Notes.Add($"Left hand falls {leftReach.Shortfall:0.0} in short of the support grip.");
if (rightBend > MaxWristBend)
solution.Notes.Add($"Right wrist bends {rightBend:0}° against the forearm.");
return solution;
}
/// <summary>
/// Replays a solved grip on any animation frame: the weapon follows the hold bone, both
/// hands reach their weapon-space targets (weighted), fingers take the grip pose.
/// </summary>
public static (ReachResult Right, ReachResult Left, float RightCorrection, float LeftCorrection) Apply(CharacterPose pose, CharacterRig character, XForm weaponInHold, HandPose right, HandPose? left, float rightWeight, float leftWeight)
{
var weaponWorld = XForm.Compose(pose.World[character.HoldBone], weaponInHold);
var rightTarget = XForm.Compose(weaponWorld, right.Wrist);
var rightCorr = MathQ.AngleBetween(pose.World[character.Right.Hand].Rot, rightTarget.Rot) * 180f / MathF.PI;
// The hold bone usually hangs off the right hand; solving the right arm moves it, so
// the weapon placement is taken before the solve (weapon stays where the animation put it).
var rightReach = ArmIk.Solve(pose, character.Right, rightTarget, rightWeight);
ArmIk.ApplyFingers(pose, character.Right, right, rightWeight);
var leftReach = new ReachResult(true, 0f, 0f);
var leftCorr = 0f;
if (left is not null && character.Left is { } leftRig && leftWeight > 1e-4f)
{
var leftTarget = XForm.Compose(weaponWorld, left.Wrist);
leftCorr = MathQ.AngleBetween(pose.World[leftRig.Hand].Rot, leftTarget.Rot) * 180f / MathF.PI;
leftReach = ArmIk.Solve(pose, leftRig, leftTarget, leftWeight);
ArmIk.ApplyFingers(pose, leftRig, left, leftWeight);
}
return (rightReach, leftReach, rightCorr, leftCorr);
}
/// <summary>
/// Barrel along the aim (firearms; only limited roll from the animation), grip in the
/// animated palm.
/// </summary>
private static (XForm World, XForm InHold) PlaceWeapon(WeaponAnalysis weapon, CharacterRig character, XForm[] world, HandPose right, GripOptions options)
{
var handAnim = world[character.Right.Hand];
var aim = MathQ.FromAxes(character.Forward, Vector3.Cross(character.Up, character.Forward));
var implied = MathQ.Normalize(handAnim.Rot * Quaternion.Conjugate(right.Wrist.Rot));
Quaternion weaponRot;
if (WeaponTypes.IsFirearm(weapon.Type.Type))
{
var relative = MathQ.Normalize(Quaternion.Conjugate(aim) * implied);
MathQ.SwingTwist(relative, Vector3.UnitX, out _, out var twist);
var roll = MathQ.Slerp(Quaternion.Identity, twist, Math.Clamp(options.WristPreference, 0f, 1f));
var angle = 2f * MathF.Acos(Math.Clamp(MathF.Abs(roll.W), 0f, 1f));
if (angle > 20f * MathF.PI / 180f)
roll = MathQ.Slerp(Quaternion.Identity, roll, 20f * MathF.PI / 180f / angle);
weaponRot = MathQ.Normalize(aim * roll);
}
else
{
// Melee: no aim line to keep. The weapon follows the hand exactly, so the animated
// wrist (and every swing) is left untouched.
weaponRot = implied;
}
var weaponPos = handAnim.Pos - Vector3.Transform(right.Wrist.Pos, weaponRot);
var weaponWorld = XForm.Compose(new XForm(weaponPos, weaponRot), options.WeaponOffset);
return (weaponWorld, XForm.ToLocal(world[character.HoldBone], weaponWorld));
}
/// <summary>Mild below 60°, steep beyond (approaching the anatomical limit).</summary>
private static float BendPenalty(float bend)
=> MathF.Max(0f, bend - 35f) * 0.06f + MathF.Max(0f, bend - 60f) * 0.2f;
private readonly record struct SearchStep(int Stage, GripCandidate Grip, GripCandidate From);
/// <summary>
/// Coordinate search over hand placements: stage 0 moves the palm around the grip axis,
/// stage 1 (applied to stage 0's winner, which the caller tracks) rolls the knuckle line
/// within the surface, so diagonal fists on stocks and bars are reachable. Manual grips
/// and overlay grips are kept as they are.
/// </summary>
private static IEnumerable<SearchStep> Search(MeshBvh bvh, GripCandidate grip)
{
if (grip.Manual)
{
// A picked spot keeps its place along the grip, but the palm may still turn around
// it and roll, so the wrist stays natural (the weapon is placed from the grasp).
var around = Variants(bvh, grip, new[] { 0f, -20f, 20f, -40f, 40f }).ToList();
foreach (var v in around)
yield return new SearchStep(0, v, grip);
foreach (var origin in around)
foreach (var roll in new[] { -15f, 15f, -30f, 30f })
{
var s = origin.Surface;
var axis = Vector3.Transform(s.Axis, Quaternion.CreateFromAxisAngle(s.Normal, roll * MathF.PI / 180f));
yield return new SearchStep(1, origin with { Surface = s with { Axis = Vector3.Normalize(axis) } }, origin);
}
yield break;
}
// Overlay palms sit over the firing hand, so their spot only moves a little.
var stage0 = Variants(bvh, grip, grip.Style == GripStyle.Overlay ? new[] { 0f, -15f, 15f, -30f, 30f, -45f, 45f } : new[] { 0f, -25f, 25f, -50f, 50f, -75f, 75f }).ToList();
// Handguards and pumps: the hand may also slide along the barrel (reach decides).
if (grip.Style is GripStyle.Cradle or GripStyle.Pump)
foreach (var back in new[] { 1.5f, 3f, 4.5f, 6f, 8f })
if (Slide(bvh, grip, -back) is { } slid)
stage0.Add(slid);
foreach (var v in stage0)
yield return new SearchStep(0, v, grip);
// The caller skips stage-1 steps whose origin didn't win stage 0.
foreach (var origin in stage0)
foreach (var roll in new[] { -15f, 15f, -30f, 30f, -45f, 45f, -60f, 60f })
{
var s = origin.Surface;
var axis = Vector3.Transform(s.Axis, Quaternion.CreateFromAxisAngle(s.Normal, roll * MathF.PI / 180f));
yield return new SearchStep(1, origin with { Surface = s with { Axis = Vector3.Normalize(axis) } }, origin);
}
}
/// <summary>The grip moved along the weapon's length by <paramref name="dx"/> inches, re-measured on the surface.</summary>
private static GripCandidate? Slide(MeshBvh bvh, GripCandidate grip, float dx)
{
var s = grip.Surface;
var centre = s.Center + Vector3.UnitX * dx;
var probed = GripFinder.ProbeFromInside(bvh, centre, s.Normal, s.Axis);
if (probed is null || Vector3.Dot(probed.Normal, s.Normal) < 0.5f || Vector3.Distance(probed.Contact, s.Contact + Vector3.UnitX * dx) > 2.5f)
return null;
var axis = s.Axis - probed.Normal * Vector3.Dot(s.Axis, probed.Normal);
return grip with { Surface = axis.LengthSquared() > 1e-4f ? probed with { Axis = Vector3.Normalize(axis) } : probed };
}
/// <summary>The grip with its palm contact moved around the grip axis by each angle (re-measured on the mesh).</summary>
private static IEnumerable<GripCandidate> Variants(MeshBvh bvh, GripCandidate grip, float[] degrees)
{
var s = grip.Surface;
foreach (var d in degrees)
{
if (MathF.Abs(d) < 1e-3f)
{
yield return grip;
continue;
}
var n = Vector3.Transform(s.Normal, Quaternion.CreateFromAxisAngle(s.Axis, d * MathF.PI / 180f));
var probed = GripFinder.ProbeFromInside(bvh, s.Center, n, s.Axis);
if (probed is null || Vector3.Dot(probed.Normal, n) <= 0.3f)
continue;
// Keep the grip's own axis (measured from its whole cross-section); a patch on the
// flat side of a grip gives an unreliable local axis.
var axis = s.Axis - probed.Normal * Vector3.Dot(s.Axis, probed.Normal);
yield return grip with { Surface = axis.LengthSquared() > 1e-4f ? probed with { Axis = Vector3.Normalize(axis) } : probed };
}
}
/// <summary>
/// Beyond this the wrist looks broken (combined flexion/deviation, degrees). Human wrists
/// flex to about 70-80° and extend to about 70°; the search already penalises bends
/// steeply from 60° so solutions stay well inside the range.
/// </summary>
public const float MaxWristBend = 70f;
/// <summary>
/// How far the hand is bent relative to the forearm, compared with the rest pose: the
/// anatomical wrist angle the solve produced (what a viewer judges), not the change from
/// the animation. Twist about the forearm is excluded (the forearm twist bones carry it).
/// </summary>
public static float WristBend(CharacterPose pose, HandRig arm)
{
var w = pose.World;
var rest = arm.Skeleton.RestWorld;
var rel = MathQ.Normalize(Quaternion.Conjugate(w[arm.LowerArm].Rot) * w[arm.Hand].Rot);
var restRel = MathQ.Normalize(Quaternion.Conjugate(rest[arm.LowerArm].Rot) * rest[arm.Hand].Rot);
var delta = MathQ.Normalize(rel * Quaternion.Conjugate(restRel));
// Forearm axis in the lower-arm frame: toward the hand at rest.
var forearm = Vector3.Transform(rest[arm.Hand].Pos - rest[arm.LowerArm].Pos, Quaternion.Conjugate(rest[arm.LowerArm].Rot));
if (forearm.LengthSquared() < 1e-8f)
return MathQ.AngleBetween(Quaternion.Identity, delta) * 180f / MathF.PI;
MathQ.SwingTwist(delta, Vector3.Normalize(forearm), out var swing, out _);
return 2f * MathF.Acos(Math.Clamp(MathF.Abs(swing.W), 0f, 1f)) * 180f / MathF.PI;
}
/// <summary>
/// The support hand fitted on a region other than its grip (the magazine during a reload),
/// closest to the wrist the animation already has there. Weapon space; null without a
/// measured surface or a left hand.
/// </summary>
public static HandPose? SolveOnRegion(WeaponAnalysis weapon, GripRegion region, CharacterRig character, GripSolution grip, XForm? preferred, GripOptions? options = null, CancellationToken cancel = default)
{
options ??= new GripOptions();
if (region.Surface is not { } surface || character.Left is not { } leftRig)
return null;
var backend = options.Backend ?? GripGenerators.All.First(b => b.IsAvailable);
// Reached from below (a magazine well under a grip, a loading port): palm up under it.
var style = surface.Normal.Z < -0.6f ? GripStyle.Cradle : GripStyle.Wrap;
var candidate = new GripCandidate { Surface = surface, Style = style, Confidence = region.Confidence, Reason = region.Reason };
var rightShape = HandShape.Of(character.Right, grip.Right);
var request = BuildRequest(weapon, candidate, leftRig, weapon.WeaponBvh, options, firing: false, other: rightShape, preferred: preferred);
return options.Fast ? ProceduralGripGenerator.Solve(request) : Best(backend, request, cancel).Pose;
}
/// <summary>
/// How well a solved grip fits its pose (higher is better): grasp quality of both hands
/// minus the wrist bends and the support hand's missing reach. Used to compare the same
/// weapon on different hold animations.
/// </summary>
public static float FitScore(GripSolution s)
=> s.RightQuality.Score - BendPenalty(s.RightWristBend)
+ (s.LeftQuality?.Score ?? 0f) - (s.Left is null ? 0f : BendPenalty(s.LeftWristBend) + s.LeftReach.Shortfall * 2f);
/// <summary>Hold animations (citizen holdtype) worth trying for a weapon type, its default first.</summary>
public static int[] CandidateHolds(WeaponType type) => type switch
{
WeaponType.Pistol or WeaponType.Revolver => new[] { 1 },
WeaponType.Smg => new[] { 2, 1 },
WeaponType.Rifle or WeaponType.Sniper => new[] { 2, 3 },
WeaponType.Shotgun => new[] { 3, 2 },
WeaponType.Launcher => new[] { 7, 2 },
WeaponType.Melee => new[] { 6, 4 },
_ => new[] { 2, 1, 3, 4 },
};
public static string HoldLabel(int hold) => hold switch
{
1 => "Pistol",
2 => "Rifle",
3 => "Shotgun",
4 => "Item",
5 => "Fists",
6 => "Melee",
7 => "Launcher",
_ => $"Hold {hold}",
};
/// <summary>
/// Slides a hand's grasp to another point of the weapon, continuously: the wrist keeps its
/// place relative to the local surface frame and the fingers close again. Null when there is
/// no surface there (off the weapon).
/// </summary>
/// <summary>Farthest a sliding grasp moves in one update (inches).</summary>
public const float MaxSlideStep = 0.2f;
public static (HandPose Pose, GraspRequest Request)? Slide(MeshBvh bvh, GraspRequest request, HandPose pose, Vector3 point, Vector3 normal)
{
// Stay on the same side of the part: look for the surface along the current grip normal
// (the nearest point can hop across a thin handle); fall back to the nearest point.
var n = Vector3.Normalize(request.Surface.Normal);
var contact = bvh.Raycast(point + n * 1.5f, -n, 3f) is { } hit ? hit.Point : (Vector3?)null;
if (contact is null)
{
var nearest = SurfaceProbe.FromPoint(bvh, point + normal * 0.05f, request.Surface.Axis);
if (nearest is null)
return null;
contact = nearest.Contact;
}
// The hand moves at most MaxSlideStep per update toward the spot (steps in the surface,
// such as a trigger guard, never make it jump); callers repeat until it arrives.
var delta = contact.Value - request.Surface.Contact;
if (delta.Length() > MaxSlideStep)
contact = request.Surface.Contact + Vector3.Normalize(delta) * MaxSlideStep;
var probed = new { Contact = contact.Value };
// Faceted meshes give neighbouring spots very different normals, so the hand is not
// re-oriented to each probe: it slides with the contact point, keeping its orientation
// and the grip frame, and the refiner re-seats palm and fingers on the new spot.
var surface = request.Surface with { Contact = probed.Contact, Center = request.Surface.Center + (probed.Contact - request.Surface.Contact) };
var moved = pose.Clone();
moved.Wrist = new XForm(pose.Wrist.Pos + (surface.Contact - request.Surface.Contact), pose.Wrist.Rot);
var next = request with { Surface = surface };
var slid = moved.Wrist;
ContactRefiner.Refine(next, moved, 2);
// Re-seating the palm is also gradual: it converges over the following updates.
var seat = moved.Wrist.Pos - slid.Pos;
if (seat.Length() > MaxSlideStep)
moved.Wrist.Pos = slid.Pos + Vector3.Normalize(seat) * MaxSlideStep;
var turn = MathQ.AngleBetween(slid.Rot, moved.Wrist.Rot);
const float maxTurn = 3f * MathF.PI / 180f;
if (turn > maxTurn)
moved.Wrist.Rot = MathQ.Slerp(slid.Rot, moved.Wrist.Rot, maxTurn / turn);
return (moved, next);
}
/// <summary>World transform of the weapon for a pose (canonical weapon space -> character).</summary>
public static XForm WeaponWorld(CharacterPose pose, CharacterRig character, XForm weaponInHold)
=> XForm.Compose(pose.World[character.HoldBone], weaponInHold);
public static GraspRequest BuildRequest(WeaponAnalysis weapon, GripCandidate grip, HandRig hand, MeshBvh bvh, GripOptions options, bool firing, HandShape? other, XForm? preferred)
{
var s = grip.Surface;
var indexToward = grip.Style switch
{
GripStyle.Cradle or GripStyle.Pump => Vector3.UnitX,
GripStyle.Handle => s.Axis,
_ => Vector3.Normalize(Vector3.UnitZ + Vector3.UnitX * 0.3f),
};
Vector3? trigger = null;
if (firing && WeaponTypes.IsFirearm(weapon.Type.Type) && grip.Style == GripStyle.Wrap && weapon.Part(PartKind.Trigger) is { } t)
{
// Aim the finger pad at the rear face of the trigger, a finger radius behind it.
var index = hand.Finger(FingerKind.Index);
var radius = index?.Radius ?? 0.3f;
var rear = bvh.Raycast(t.Center - Vector3.UnitX * 3f, Vector3.UnitX, 3.2f);
var face = rear is { } h && Vector3.Distance(h.Point, t.Center) < 1.5f ? h.Point : t.Center;
trigger = face - Vector3.UnitX * radius;
}
return new GraspRequest
{
Hand = hand,
Weapon = bvh,
Surface = s,
Style = grip.Style,
IndexToward = indexToward,
Trigger = trigger,
IndexOffTrigger = !options.IndexOnTrigger,
OtherHand = other,
PreferredWrist = preferred,
PalmOffset = grip.Style == GripStyle.Overlay ? (other is null ? 0.9f : OverlayGap(other, s)) : 0f,
};
}
/// <summary>How far the support palm must sit off the grip to clear the firing hand's fingers.</summary>
private static float OverlayGap(HandShape other, GripSurface s)
{
var gap = 0.6f;
foreach (var seg in other.Segments)
{
var d = Vector3.Dot(seg.B - s.Contact, s.Normal) + seg.Radius;
gap = MathF.Max(gap, d);
}
return MathF.Min(gap, 2.5f);
}
/// <summary>One candidate without the roll/slide neighbourhood search (for comparing palm angles quickly).</summary>
/// <summary>
/// One candidate without the roll/slide neighbourhood search (for comparing palm angles
/// quickly). The procedural fit stands in for composites; presets compete in <see cref="Best"/>.
/// </summary>
private static HandPose Quick(IGripGenerator backend, GraspRequest request, CancellationToken cancel)
=> backend is ProceduralGripGenerator or CompositeGripGenerator or ChoiceGripGenerator ? ProceduralGripGenerator.Solve(request) : Best(backend, request, cancel).Pose;
private static GraspCandidate Best(IGripGenerator backend, GraspRequest request, CancellationToken cancel)
{
var best = backend.Generate(request, cancel).FirstOrDefault();
if (best is null && backend is not ProceduralGripGenerator)
best = new ProceduralGripGenerator().Generate(request, cancel).FirstOrDefault();
if (best is not null)
return best;
var pose = ProceduralGripGenerator.Solve(request);
return new GraspCandidate(pose, GraspEvaluator.Evaluate(request, pose), "Procedural");
}
}