Editor/WeaponImporter/Tool/UI/Steps/HandsStep.cs
using Editor;
using Sandbox;
using WeaponImporter.Core.Analysis;
using WeaponImporter.Core.Grasp;
using WeaponImporter.Core.Grip;
using WeaponImporter.Core.Hands;
using WeaponImporter.Core.Setup;
namespace WeaponImporter.Tool.UI;
/// <summary>Step 2: where the hands hold the weapon and how the fit is tuned.</summary>
public sealed class HandsStep : StepPanel
{
private Side _curlSide = Side.Right;
private HandPose _curlBase;
private readonly Dictionary<FingerKind, float> _curl = new();
public HandsStep( Widget parent, ImporterController controller ) : base( parent, controller )
{
controller.PickChanged += MarkDirty;
}
public override string Title => "Hands";
protected override string StructureKey()
{
var s = C.Setup;
return $"{s.UseSupportHand}|{s.Primary.Preset}|{s.Support?.Preset}|{C.Session.Grips.Count}|{C.Pick}|{s.Support is null}|{s.Character}|{_curlSide}|{C.Session.Character?.Left is null}|{System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode( C.Analysis )}";
}
protected override void Build()
{
_curlBase = null;
_curl.Clear();
BuildFit();
BuildHand( Side.Right );
BuildHand( Side.Left );
BuildTrigger();
BuildFineTune();
BuildFingers();
BuildRegions();
BuildLibrary();
}
// ------------------------------------------------------------------ fit
private void BuildFit()
{
var card = AddCard( "auto_fix_high", "Auto Grip", out var header, "Fit both hands to the weapon: library grips and the procedural fit compete, the best fit wins" );
header.AddStretchCell();
header.Add( new UiButton( card, "Auto Grip", "auto_fix_high", () => AutoFit( null ), "Forget hand edits and fit both hands again" ) );
// The character's hold animation (its holdtype): the one the weapon fits best, or forced.
var s = C.Setup;
var holdRow = UiStyle.FieldRow( card, card.Layout, "Hold", "The character's hold animation (holdtype) the weapon is fitted to" );
var hold = Combo( card, holdRow, "The character's hold animation. Auto tries the ones that suit this weapon type and keeps the best fit." );
var ranking = C.Session.HoldRanking;
var best = ranking.Count > 0 ? ranking[0].Hold : s.EffectiveHoldType;
hold.AddItem( $"Auto ({GripSolver.HoldLabel( best )})", "auto_fix_high", () => SetHold( -1, false ),
description: ranking.Count > 1 ? "Tried: " + string.Join( ", ", ranking.Select( r => $"{GripSolver.HoldLabel( r.Hold )} {r.Score:0.0}" ) ) : "The weapon type's hold", selected: !s.HoldTypeManual );
foreach ( var h in new[] { 1, 2, 3, 4, 6, 7 } )
{
var value = h;
hold.AddItem( GripSolver.HoldLabel( h ), "accessibility_new", () => SetHold( value, true ), selected: s.HoldTypeManual && s.EffectiveHoldType == h );
}
var mag = card.Layout.Add( new Checkbox( "Reload: reach for this weapon's magazine", card ) { Value = s.ReloadTouchesMagazine } );
mag.ToolTip = "Off: during third-person reloads the support hand plays the character's own reload and returns to the grip. On: it is moved onto this weapon's magazine where the reload works the magazine.";
mag.StateChanged = state =>
{
if ( s.ReloadTouchesMagazine == mag.Value )
return;
s.ReloadTouchesMagazine = mag.Value;
foreach ( var track in s.Contacts.Values.Where( t => t.Generated ) )
track.Keys.Clear();
var session = C.Session;
_ = C.RunAsync( "Planning the reload", ( p, c ) => session.ReplanContactsAsync( c ), heavy: false );
};
}
private void SetHold( int hold, bool manual )
{
var s = C.Setup;
if ( s.HoldTypeManual == manual && (!manual || s.HoldType == hold) )
return;
s.HoldTypeManual = manual;
s.HoldType = manual ? hold : -1;
var session = C.Session;
ForceRebuild();
_ = C.RunAsync( "Changing the hold", async ( p, c ) =>
{
await session.PrepareCharacterAsync( p, c );
await session.ResolveGripAsync( p, c );
} );
}
private void BuildRegions()
{
var regions = GripRegions.Of( C.Analysis );
if ( regions.Count == 0 )
return;
var card = AddCard( "select_all", "Regions", out _, "Parts of the weapon the hands use or must keep clear of. Click a row to show it on the weapon." );
foreach ( var region in regions )
{
var kind = region.Kind;
var row = card.Layout.Add( new ClickRow( card, () =>
{
C.HighlightRegion = C.HighlightRegion == kind ? null : kind;
MarkDirty();
}, $"{GripRegion.Label( kind )}: {region.Reason}" ) );
row.Layout.Add( new ColorDot( row, WeaponViewport.RegionColor( kind ) ) );
row.Layout.Add( new Label( GripRegion.Label( kind ), row ) { FixedWidth = 110, FixedHeight = UiStyle.ControlHeight, Alignment = TextFlag.LeftCenter } );
// Wraps: a non-wrapping label is as wide as its text and would widen the whole page.
row.Layout.Add( UiStyle.Muted( new Label( region.Reason, row ) { WordWrap = true, MinimumHeight = UiStyle.ControlHeight, Alignment = TextFlag.LeftCenter, MinimumWidth = 20 }, small: true ), 1 );
Bind( () => row.Selected = C.HighlightRegion == kind );
}
}
/// <summary>A small colour swatch (region colours match the preview).</summary>
private sealed class ColorDot : Widget
{
private readonly Color _color;
public ColorDot( Widget parent, Color color ) : base( parent )
{
_color = color;
FixedSize = new Vector2( 10, UiStyle.ControlHeight );
}
protected override void OnPaint()
{
Paint.Antialiasing = true;
Paint.ClearPen();
Paint.SetBrush( _color );
Paint.DrawRect( new Rect( 0, Height * 0.5f - 4, 8, 8 ), 4 );
}
}
private void BuildLibrary()
{
var card = AddCard( "collections_bookmark", "Grip library", out var header, "Grips captured from first-person animations; they compete in Auto Grip and can be picked per hand" );
header.AddStretchCell();
header.Add( new Pill( card, $"{C.Session.Grips.Count} GRIPS", Theme.TextLight ) );
var row = card.Layout.AddRow();
row.Add( UiStyle.Secondary( card, "Add grips from animation files…", "library_add", AddGrips, "Pick FBX / GLB / glTF files whose arms hold a weapon: their grips are added to this project's library" ) );
row.AddStretchCell();
row.Add( UiStyle.Icon( card, "psychology", ShowGrabNet, "GrabNet: export this weapon for the grasp model, or load the grasps it generated" ) );
}
private void ShowGrabNet()
{
var menu = new Menu( this );
menu.AddHeading( "GrabNet grasps" );
menu.AddOption( "Export weapon for GrabNet…", "file_download", ExportGrabNet );
menu.AddOption( "Load GrabNet grasps…", "file_upload", LoadGrabNet );
menu.OpenAtCursor();
}
private void ExportGrabNet()
{
var session = C.Session;
var path = EditorUtility.SaveFileDialog( "Weapon for GrabNet (metres)", "obj", $"{session.Asset?.Name ?? "weapon"}.obj" );
if ( string.IsNullOrEmpty( path ) )
return;
try
{
session.ExportForGrabNet( path );
C.SetStatus( $"Exported {System.IO.Path.GetFileName( path )}: run GrabNet on it, then load its grasps (21 keypoints per hand, same frame).", Theme.Green );
}
catch ( Exception e )
{
C.SetStatus( $"Export failed: {e.Message}", Theme.Red );
}
}
private void LoadGrabNet()
{
var session = C.Session;
var path = EditorUtility.OpenFileDialog( "GrabNet grasps", "json", null );
if ( string.IsNullOrEmpty( path ) )
return;
ForceRebuild();
_ = C.RunAsync( "Reading GrabNet grasps", async ( p, c ) =>
{
var (added, skipped) = await session.AddLearnedGraspsAsync( path, c );
C.SetStatus( skipped.Count == 0 ? $"Added {added} GrabNet grips; they compete in Auto Grip." : $"Added {added} GrabNet grips; skipped {string.Join( "; ", skipped )}.", added > 0 ? (skipped.Count == 0 ? Theme.Green : Theme.Yellow) : Theme.Red );
C.ScheduleResolve( 0.01f );
}, heavy: false );
}
private void AddGrips()
{
var dialog = new FileDialog( null ) { Title = "Animation files with arms holding a weapon" };
dialog.SetFindExistingFiles();
dialog.SetModeOpen();
dialog.SetNameFilter( "Models (*.fbx *.glb *.gltf)" );
if ( !dialog.Execute() )
return;
var files = dialog.SelectedFiles.ToList();
if ( files.Count == 0 )
return;
ForceRebuild();
_ = C.RunAsync( "Reading grips", async ( p, c ) =>
{
var (added, skipped) = await GripLibraryStore.AddAsync( files, p, c );
C.SetStatus( skipped.Count == 0 ? $"Added {added} grips to the library." : $"Added {added} grips; no hands on a weapon in {string.Join( ", ", skipped.Select( System.IO.Path.GetFileName ) )}.", skipped.Count == 0 ? Theme.Green : Theme.Yellow );
C.ScheduleResolve( 0.01f );
}, heavy: false );
}
private void AutoFit( Side? side )
{
var session = C.Session;
ForceRebuild();
var what = side switch { Side.Right => "right hand", Side.Left => "left hand", _ => "both hands" };
_ = C.RunAsync( $"Fitting the {what}", ( p, c ) => session.AutoFitAsync( side ), heavy: false );
}
// ------------------------------------------------------------------ hand cards
/// <summary>How far one Move click moves a hand.</summary>
private const float MoveInches = 0.1f;
private void BuildHand( Side side )
{
var s = C.Setup;
var right = side == Side.Right;
var card = AddCard( right ? "back_hand" : "front_hand", right ? "Right hand" : "Left hand", out var header,
right ? "The firing hand" : "The support hand" );
header.AddStretchCell();
var quality = header.Add( new Pill( card, "", Theme.TextLight ) );
if ( !right )
{
var enabled = card.Layout.Add( new Checkbox( "Use the support hand", card ) { Value = s.UseSupportHand, ToolTip = "Off makes the weapon one-handed: the left arm keeps its animation" } );
enabled.Toggled = () => Safe( () =>
{
C.Setup.UseSupportHand = enabled.Value;
if ( enabled.Value && C.Setup.Support is null )
{
AutoFit( Side.Left );
return;
}
C.MarkChanged();
C.ScheduleResolve( 0.01f );
} );
if ( !s.UseSupportHand )
{
quality.Visible = false;
return;
}
}
var grip = right ? s.Primary : s.Support;
var styleRow = UiStyle.FieldRow( card, card.Layout, "Grip style", "How the hand closes around the surface" );
var style = Combo( card, styleRow, "How the hand closes around the surface" );
foreach ( var st in Enum.GetValues<GripStyle>() )
{
var value = st;
style.AddItem( st.ToString(), StyleIcon( st ), () => SetStyle( side, value ), description: StyleHelp( st ), selected: grip is not null && grip.Style == st );
}
if ( grip is not null && !grip.Manual )
styleRow.Add( new ConfidencePill( card, grip.Confidence, false, grip.Reason ) );
else if ( grip is { Manual: true } )
styleRow.Add( new ConfidencePill( card, 1f, true ) );
// One dropdown for where the grip comes from: automatic (every grip competes), a grip from the
// library (captured from first-person animations), the procedural fit, or a spot picked on the weapon.
var target = right ? PickTarget.RightGrip : PickTarget.LeftGrip;
var gripRow = UiStyle.FieldRow( card, card.Layout, "Grip", "Where this hand's grip comes from" );
var source = Combo( card, gripRow, "Where this hand's grip comes from. Pick a grip, then fine-tune it below or in the viewport." );
var chosen = grip?.Preset ?? "";
source.AddItem( "Auto (best fit)", "auto_fix_high", () => SetPreset( side, "" ), description: "Every grip below competes; the one that fits this weapon and animation best wins", selected: chosen == "" );
foreach ( var preset in C.Session.Grips.Where( p => p.Side == side ) )
{
var name = preset.Name;
source.AddItem( name, name.StartsWith( ImportSession.OwnGripSource ) ? "star" : "front_hand", () => SetPreset( side, name ), description: preset.Source, selected: chosen == name );
}
source.AddItem( "Procedural fit", "gesture", () => SetPreset( side, ChoiceGripGenerator.Procedural ), description: "Close the fingers around the surface from scratch", selected: chosen == ChoiceGripGenerator.Procedural );
source.AddItem( "Pick on the weapon…", "ads_click", () => C.BeginPick( target ), description: "Then click on the weapon where this hand should hold it" );
// Move just the hand (grip shape, fingers and wrist angle unchanged). Dragging the hand's
// dot in the preview slides it along the surface instead.
var moveRow = UiStyle.FieldRow( card, card.Layout, "Move", "Move only this hand on the weapon; everything else stays as it is. Auto Grip undoes it." );
foreach ( var (icon, delta, tip) in new[]
{
("arrow_forward", new System.Numerics.Vector3( MoveInches, 0, 0 ), "Forward, toward the muzzle"),
("arrow_back", new System.Numerics.Vector3( -MoveInches, 0, 0 ), "Back, toward the stock"),
("arrow_upward", new System.Numerics.Vector3( 0, 0, MoveInches ), "Up, toward the top of the gun"),
("arrow_downward", new System.Numerics.Vector3( 0, 0, -MoveInches ), "Down"),
("west", new System.Numerics.Vector3( 0, MoveInches, 0 ), "To the weapon's left"),
("east", new System.Numerics.Vector3( 0, -MoveInches, 0 ), "To the weapon's right"),
} )
{
var d = delta;
moveRow.Add( UiStyle.Icon( card, icon, () => C.MoveHand( side, d ), $"{tip} ({MoveInches} in)" ) );
}
moveRow.AddStretchCell();
var surface = UiStyle.FieldRow( card, card.Layout, "Surface", "The part of the weapon this hand holds, as measured" ).Add( UiStyle.Muted( new Label( "", card ) { WordWrap = true, MinimumWidth = 20 }, small: true ), 1 );
var detail = UiStyle.FieldRow( card, card.Layout, "Grasp", "Fingers touching the weapon, fingers in the air, and how deep the hand passes into it" ).Add( UiStyle.Muted( new Label( "", card ) { WordWrap = true, MinimumWidth = 20 }, small: true ), 1 );
Bind( () =>
{
var candidate = right ? C.RightSurface : C.LeftSurface;
surface.Text = candidate?.Surface is { } g
? $"radius {g.Radius:0.00} in · {g.Depth:0.0} × {g.Width:0.0} in · clearance {g.Clearance:0.0} in"
: "not measured";
var solution = C.Session.Grip;
var q = right ? solution?.RightQuality : solution?.LeftQuality;
var (text, color, info) = Describe( q );
quality.Set( text, color );
quality.ToolTip = info;
var from = right ? solution?.RightSource : solution?.LeftSource;
detail.Text = string.IsNullOrEmpty( from ) ? info : $"{info} · {ShortSource( from )}";
if ( !right && solution is { Left: not null } && !solution.LeftReach.Reached )
detail.Text += $" · {solution.LeftReach.Shortfall:0.0} in out of reach";
} );
}
/// <summary>"Procedural roll 8° slide 0.3" reads as "procedural fit"; library grips keep their name.</summary>
private static string ShortSource( string source ) => source.StartsWith( "Procedural" ) ? "procedural fit" : source == "Edited" ? "edited" : source;
private void SetPreset( Side side, string preset )
{
var grip = side == Side.Right ? C.Setup.Primary : C.Setup.Support;
if ( grip is null || grip.Preset == preset )
return;
grip.Preset = preset;
// A different grip replaces the edited pose.
grip.Pose = null;
C.MarkChanged();
C.ScheduleResolve( 0.01f, side );
}
private void SetStyle( Side side, GripStyle style )
{
var grip = side == Side.Right ? C.Setup.Primary : C.Setup.Support;
if ( grip is null || grip.Style == style )
return;
grip.Style = style;
grip.Manual = true;
grip.Pose = null;
C.MarkChanged();
C.ScheduleResolve( 0.01f, side );
}
/// <summary>Pill text, colour and a readable detail line for a grasp.</summary>
public static (string Text, Color Color, string Detail) Describe( GraspQuality q )
{
if ( q is null )
return ("NOT FITTED", Theme.TextLight, "No fit yet.");
var penetration = MathF.Max( q.MaxPenetration, q.PalmPenetration );
var detail = $"{q.TouchingFingers} touching · {q.FloatingFingers} floating · {penetration:0.00} in deep";
// A little finger clipping into a grip reads fine in game; only deep passes are errors.
if ( penetration > Validation.SeverePenetration * 2f )
return ("PENETRATING", Theme.Red, detail);
if ( penetration > Validation.SeverePenetration )
return ("CLIPPING", Theme.Yellow, detail);
if ( q.TouchingFingers < 2 || q.FloatingFingers >= 3 )
return ("LOOSE", Theme.Yellow, detail);
if ( penetration > 0.2f || q.FloatingFingers >= 2 || q.HandOverlap > 0.3f )
return ("FAIR", Theme.Yellow, detail);
return ("GOOD GRIP", Theme.Green, detail);
}
private static string StyleIcon( GripStyle style ) => style switch
{
GripStyle.Wrap => "back_hand",
GripStyle.Cradle => "front_hand",
GripStyle.Pump => "sync_alt",
GripStyle.Overlay => "join_inner",
GripStyle.Handle => "sports_mma",
_ => "pan_tool",
};
private static string StyleHelp( GripStyle style ) => style switch
{
GripStyle.Wrap => "Fingers wrap a vertical handle: pistol grip, vertical foregrip",
GripStyle.Cradle => "Palm under a horizontal handguard, fingers up the side",
GripStyle.Pump => "Hand around a shotgun pump or forend",
GripStyle.Overlay => "Support hand cupping the firing hand (pistols)",
GripStyle.Handle => "Melee handle held in a fist",
_ => "",
};
// ------------------------------------------------------------------ trigger
private void BuildTrigger()
{
var card = AddCard( "touch_app", "Trigger finger", out var header, "Where the right index finger rests" );
header.AddStretchCell();
var seg = header.Add( new SegmentedControl( card ) { FixedHeight = 26, FixedWidth = 200, ToolTip = "On the trigger, or straight along the frame (trigger discipline)" } );
seg.AddOption( "On trigger", "touch_app" );
seg.AddOption( "Along frame", "straighten" );
seg.SelectedIndex = C.Setup.IndexOnTrigger ? 0 : 1;
seg.OnSelectedChanged = _ => Safe( () =>
{
var on = seg.SelectedIndex == 0;
if ( C.Setup.IndexOnTrigger == on )
return;
C.Setup.IndexOnTrigger = on;
C.MarkChanged();
C.ScheduleResolve( 0.01f );
} );
}
// ------------------------------------------------------------------ fine tune
private void BuildFineTune()
{
var s = C.Setup;
var card = AddCard( "tune", "Fine tune", out var header, "Small corrections on top of the automatic fit" );
header.AddStretchCell();
header.Add( UiStyle.Icon( card, "restart_alt", () => Safe( () =>
{
var ik = C.Setup.Ik;
ik.WristPreference = new IkSettings().WristPreference;
ik.WeaponOffsetPosition = new float[3];
ik.WeaponOffsetRotation = new float[] { 0, 0, 0, 1 };
ForceRebuild();
C.LiveRefit();
} ), "Reset the fine tuning" ) );
Slider( card, card.Layout, "Wrist vs aim", "Left: the weapon aims exactly along the character's view. Right: keep the animated wrist angle.",
0f, 1f, s.Ik.WristPreference, v => $"{v * 100f:0}%", v =>
{
C.Setup.Ik.WristPreference = v;
C.LiveRefit();
} );
card.Layout.Add( new SectionHeader( card, "Weapon nudge" ) );
var axes = new[] { ("Forward", "Move the weapon forward/back in the hand (inches)"), ("Left", "Move the weapon left/right in the hand (inches)"), ("Up", "Move the weapon up/down in the hand (inches)") };
for ( var i = 0; i < 3; i++ )
{
var axis = i;
Slider( card, card.Layout, axes[i].Item1, axes[i].Item2, -2f, 2f, s.Ik.WeaponOffsetPosition[i], v => $"{v:+0.00;-0.00;0.00} in", v =>
{
C.Setup.Ik.WeaponOffsetPosition[axis] = v;
C.LiveRefit();
} );
}
var angles = V.Q( s.Ik.WeaponOffsetRotation ).ToEngine().Angles();
var euler = new[] { angles.pitch, angles.yaw, angles.roll };
var names = new[] { ("Pitch", "Tilt the muzzle up/down"), ("Yaw", "Turn the muzzle left/right"), ("Roll", "Cant the weapon") };
for ( var i = 0; i < 3; i++ )
{
var index = i;
Slider( card, card.Layout, names[i].Item1, names[i].Item2, -15f, 15f, Math.Clamp( euler[i], -15f, 15f ), v => $"{v:+0.0;-0.0;0.0}°", v =>
{
euler[index] = v;
C.Setup.Ik.WeaponOffsetRotation = V.A( Rotation.From( euler[0], euler[1], euler[2] ).ToCore() );
C.LiveRefit();
} );
}
}
// ------------------------------------------------------------------ fingers
private void BuildFingers()
{
var rig = C.Session.Character;
if ( rig is null )
return;
var card = AddCard( "front_hand", "Fingers", out var header, "Open or close single fingers of the fitted hand" );
header.AddStretchCell();
var seg = header.Add( new SegmentedControl( card ) { FixedHeight = 26, FixedWidth = 150, ToolTip = "Which hand the curl sliders edit" } );
seg.AddOption( "Right", "back_hand" );
if ( rig.Left is not null && C.Setup.UseSupportHand )
seg.AddOption( "Left", "front_hand" );
else
_curlSide = Side.Right;
seg.SelectedIndex = _curlSide == Side.Right ? 0 : 1;
seg.OnSelectedChanged = _ =>
{
var side = seg.SelectedIndex == 0 ? Side.Right : Side.Left;
if ( side == _curlSide )
return;
_curlSide = side;
ForceRebuild();
};
var hand = _curlSide == Side.Right ? rig.Right : rig.Left;
foreach ( var finger in hand.Fingers )
{
var kind = finger.Kind;
// The character's model copies the ring onto the pinky: one slider moves both.
if ( hand.PinkyFollowsRing && kind == FingerKind.Pinky )
continue;
var together = hand.PinkyFollowsRing && kind == FingerKind.Ring;
var label = together ? "Ring & pinky" : kind.ToString();
var tip = together
? "Curl of the ring and little fingers: left opens them, right closes them. This character's model copies the ring finger onto the pinky, so they move together."
: $"Curl of the {kind.ToString().ToLowerInvariant()}: left opens it, right closes it";
Slider( card, card.Layout, label, tip, 0f, 1.6f, 1f, v => $"{v * 100f:0}%", v => Curl( kind, v ) );
}
card.Layout.Add( UiStyle.Muted( new Label( "100% is the automatic fit. Changes are kept as a hand edit.", card ) { WordWrap = true }, small: true ) );
if ( hand.PinkyFollowsRing )
card.Layout.Add( UiStyle.Muted( new Label( "This character's model copies the ring finger onto the pinky, so they share one slider.", card ) { WordWrap = true }, small: true ) );
}
private void Curl( FingerKind kind, float factor )
{
var solution = C.Session.Grip;
var side = _curlSide;
_curlBase ??= (side == Side.Right ? solution?.Right : solution?.Left)?.Clone();
if ( _curlBase is null )
return;
_curl[kind] = factor;
var pose = _curlBase.Clone();
foreach ( var (k, f) in _curl )
{
if ( !_curlBase.Flex.TryGetValue( k, out var flex ) )
continue;
pose.Flex[k] = flex.Select( x => x * f ).ToArray();
}
C.LiveRefit( side, pose );
}
public override void OnDestroyed()
{
C.PickChanged -= MarkDirty;
base.OnDestroyed();
}
}