Editor/WeaponImporter/Tool/Engine/ImportSession.cs
using WeaponImporter.Core.Analysis;
using WeaponImporter.Core.Grip;
using WeaponImporter.Core.Hands;
using WeaponImporter.Core.Ik;
using WeaponImporter.Core.Import;
using WeaponImporter.Core.Maths;
using WeaponImporter.Core.Materials;
using WeaponImporter.Core.Setup;
using WeaponImporter.Core.Weapon;
using N = System.Numerics;
namespace WeaponImporter.Tool;
/// <summary>
/// One weapon being imported: source, analysis, setup, solved grip and validation. The UI
/// observes it through <see cref="Changed"/>; every heavy step runs off the main thread.
/// </summary>
public sealed class ImportSession : IDisposable
{
public string SourcePath { get; private set; } = "";
public WeaponAsset Asset { get; private set; }
public WeaponAnalysis Analysis { get; private set; }
public WeaponSetup Setup { get; private set; }
public GripSolution Grip { get; private set; }
public CharacterRig Character { get; private set; }
public CharacterPose ReferencePose { get; private set; }
public List<CheckResult> Checks { get; private set; } = new();
public string CharacterModel => CharacterLibrary.ModelPath( Setup?.Character );
public string OutputFolder => string.IsNullOrEmpty( Setup?.OutputFolder ) ? $"weapons/{Setup?.Name ?? "weapon"}" : Setup.OutputFolder;
public string SetupPath => $"{OutputFolder}/{Setup?.Name}.weapon.json";
/// <summary>Raised on the main thread whenever anything above changed.</summary>
public event Action Changed;
/// <summary>
/// Raised on every live re-fit while a slider is dragged (only the grip moved). Views that
/// show the pose follow it; <see cref="Changed"/> comes once the edit settles.
/// </summary>
public event Action GripMoved;
/// <summary>Short human-readable progress/status lines.</summary>
public event Action<string> Status;
/// <summary>Bumped on every change; lets views skip stale work.</summary>
public int Revision { get; private set; }
private CharacterPoser _poser;
// Character poses sampled over the actions depend only on the character and weapon type,
// never on the grip, so the action check reuses them instead of re-simulating the graph.
private readonly Dictionary<(int, AnimationRole, float, float), CharacterPose> _actionPoses = new();
private CancellationTokenSource _gripCancel;
private void Notify()
{
Revision++;
Changed?.Invoke();
}
private void Report( IProgress<string> progress, string message )
{
progress?.Report( message );
Status?.Invoke( message );
}
// ------------------------------------------------------------------ loading
/// <summary>Loads and analyzes a weapon file (FBX, GLB, glTF or VMDL), then runs auto setup.</summary>
public static async Task<ImportSession> LoadAsync( string path, IProgress<string> progress = null, CancellationToken cancel = default )
{
var session = new ImportSession { SourcePath = path };
await session.ReloadAsync( progress, cancel );
return session;
}
public async Task ReloadAsync( IProgress<string> progress = null, CancellationToken cancel = default )
{
var path = SourcePath;
var ext = System.IO.Path.GetExtension( path ).ToLowerInvariant();
Report( progress, $"Reading {System.IO.Path.GetFileName( path )}" );
WeaponAsset asset;
if ( ext == ".vmdl" || ext == ".vmdl_c" )
{
await EditorThread.SwitchToMainThread();
var rel = ToProjectRelative( path ) ?? path;
asset = VmdlReader.Read( rel.Replace( ".vmdl_c", ".vmdl" ), WeaponLoader.SafeName( path ) );
}
else
{
asset = await Task.Run( () => WeaponLoader.Load( path ), cancel );
}
cancel.ThrowIfCancellationRequested();
SourceAsset = asset;
// Existing setup next to a previous import keeps every manual choice.
await EditorThread.SwitchToMainThread();
var previous = TryLoadSetup( $"weapons/{asset.Name}/{asset.Name}.weapon.json" );
var options = previous is { OrientationManual: true } ? new AnalyzeOptions { Rotation = previous.ModelRotationQ, Scale = previous.Scale, ReferenceClip = previous.ReferenceClip } : new AnalyzeOptions();
_analyzeOptions = options;
// Attach Textures: images beside the model that belong to its materials.
Report( progress, "Looking for textures" );
var isVmdl = ext is ".vmdl" or ".vmdl_c";
TextureMatches = isVmdl ? new List<TextureMatch>() : await Task.Run( () => TextureMatcher.Match( asset, path ), cancel );
Asset = WithTextures( asset, previous ?? new WeaponSetup() );
Report( progress, "Analyzing weapon" );
var analysis = await AnalyzeAsync( Asset, options, cancel );
Report( progress, "Configuring setup" );
Setup = AutoSetup.Build( analysis, previous );
Setup.Source = path;
Setup.SourceHash = Hash( path );
Notify();
await PrepareCharacterAsync( progress, cancel );
await ResolveGripAsync( progress, cancel );
}
/// <summary>Re-runs the analysis with changed orientation/scale/reference options, keeping manual choices.</summary>
public async Task ReanalyzeAsync( AnalyzeOptions options, IProgress<string> progress = null, CancellationToken cancel = default )
{
Report( progress, "Analyzing weapon" );
_analyzeOptions = options;
Asset = WithTextures( SourceAsset, Setup );
var analysis = await AnalyzeAsync( Asset, options, cancel );
Setup = AutoSetup.Build( analysis, Setup );
Notify();
await ResolveGripAsync( progress, cancel );
}
private AnalyzeOptions _analyzeOptions = new();
/// <summary>Analyses off the main thread, with the grips of the file's own arms.</summary>
private async Task<WeaponAnalysis> AnalyzeAsync( WeaponAsset asset, AnalyzeOptions options, CancellationToken cancel )
{
var (analysis, own) = await Task.Run( () =>
{
var a = WeaponAnalyzer.Analyze( asset, options, cancel );
GripRegions.Of( a ); // cached for validation and the preview
// The file's own first-person arms, when they hold the weapon, give the best grips.
return (a, GripExtractor.ExtractOwn( a, OwnGripSource ));
}, cancel );
await EditorThread.SwitchToMainThread();
OwnGrips = own;
Analysis = analysis;
return analysis;
}
// ------------------------------------------------------------------ textures
/// <summary>The weapon as read from its file (before textures were attached).</summary>
public WeaponAsset SourceAsset { get; private set; }
/// <summary>Every material slot: textures the file links, and images found beside it.</summary>
public List<TextureMatch> TextureMatches { get; private set; } = new();
/// <summary>Whether a found (not linked) texture is used: the user's choice, else Attach Textures and confidence.</summary>
public static bool Accepted( WeaponSetup setup, TextureMatch match )
{
if ( match.Linked )
return true;
if ( setup.TextureChoices.TryGetValue( match.Key, out var chosen ) )
return string.Equals( chosen, match.Path, StringComparison.OrdinalIgnoreCase );
return setup.AttachTextures && match.Confidence >= TextureMatcher.AutoAttach;
}
private WeaponAsset WithTextures( WeaponAsset source, WeaponSetup setup )
{
if ( source is null )
return null;
var chosen = TextureMatches.Where( m => !m.Linked && Accepted( setup, m ) )
// One file per slot: the best accepted candidate.
.GroupBy( m => m.Key ).Select( g => g.OrderByDescending( m => m.Confidence ).First() ).ToList();
return chosen.Count == 0 ? source : TextureMatcher.Apply( source, chosen );
}
/// <summary>Re-applies texture choices (Attach Textures toggled, a proposal accepted or rejected).</summary>
public Task ApplyTextureChoicesAsync( IProgress<string> progress = null, CancellationToken cancel = default )
=> ReanalyzeAsync( _analyzeOptions, progress, cancel );
/// <summary>Runs the one-click setup again from scratch (manual choices are cleared).</summary>
public async Task AutoSetupAsync( IProgress<string> progress = null, CancellationToken cancel = default )
{
var fresh = new WeaponSetup { Name = Setup?.Name ?? Asset.Name, Source = SourcePath, Character = Setup?.Character ?? "human", OutputFolder = Setup?.OutputFolder ?? "" };
Setup = AutoSetup.Build( Analysis, fresh );
Notify();
await PrepareCharacterAsync( progress, cancel );
await ResolveGripAsync( progress, cancel );
}
// ------------------------------------------------------------------ character + grip
/// <summary>Loads the character, derives its hands and samples the holdtype reference pose.</summary>
public async Task PrepareCharacterAsync( IProgress<string> progress = null, CancellationToken cancel = default )
{
await EditorThread.SwitchToMainThread();
Report( progress, "Posing the character" );
// The new character is checked before the current one is replaced, so a model that
// can't be used leaves the session as it was.
var poser = new CharacterPoser( CharacterModel );
CharacterRig rig;
try
{
rig = CharacterRig.From( poser.Skeleton, CharacterLibrary.PinkyFollowsRing( CharacterModel ) ) ?? throw new InvalidOperationException( $"'{CharacterModel}' has no recognisable arms and hands." );
await EditorThread.NextFrame( cancel );
if ( !poser.Animates( rig, Setup.EffectiveHoldType ) )
throw new InvalidOperationException( $"{CharacterLibrary.Label( Setup.Character )}'s animation graph leaves its arms in the bind pose, so there is no hold animation to fit the grip to." );
}
catch
{
poser.Dispose();
throw;
}
_poser?.Dispose();
_actionPoses.Clear();
_poser = poser;
Character = rig;
// Each step drives the character's animgraph on the main thread: one frame per step
// keeps the editor responsive (the first import pays for loading the graph).
await EditorThread.NextFrame( cancel );
await ChooseHoldAsync( progress, cancel );
ReferencePose = _poser.Sample( Setup.EffectiveHoldType );
await MeasureActionsAsync( cancel );
await WarmActionPosesAsync( cancel );
await SampleActionTracksAsync( cancel );
Notify();
}
/// <summary>Every hold animation tried for this weapon with its fit (best first).</summary>
public List<(int Hold, float Score)> HoldRanking { get; private set; } = new();
/// <summary>
/// Tries the character's hold animations that suit the weapon type: a quick grip on each,
/// judged by how well the hands fit (grasp, wrist bend, reach). The best one is used unless
/// the user picked a hold.
/// </summary>
private async Task ChooseHoldAsync( IProgress<string> progress, CancellationToken cancel )
{
HoldRanking = new List<(int, float)>();
var candidates = GripSolver.CandidateHolds( Setup.Type );
if ( Setup.HoldTypeManual || candidates.Length < 2 || Analysis?.Primary is null )
return;
Report( progress, "Trying hold animations" );
var analysis = Analysis;
var primary = AutoSetup.Candidate( analysis, Setup.Primary ) ?? analysis.Primary;
var support = Setup.UseSupportHand && Setup.Support is not null ? AutoSetup.Candidate( analysis, Setup.Support ) : null;
var character = Character;
var options = Options() with { Fast = true };
foreach ( var hold in candidates )
{
cancel.ThrowIfCancellationRequested();
var pose = _poser.Sample( hold );
var solution = await Task.Run( () => GripSolver.Solve( analysis, primary, support, character, pose, options, cancel ), cancel );
await EditorThread.SwitchToMainThread();
// The weapon type's usual hold wins ties.
var score = GripSolver.FitScore( solution ) + (hold == candidates[0] ? 0.5f : 0f);
HoldRanking.Add( (hold, score) );
}
HoldRanking.Sort( ( a, b ) => b.Score.CompareTo( a.Score ) );
var best = HoldRanking[0].Hold;
Setup.HoldType = best == WeaponTypes.HoldType( Setup.Type ) ? -1 : best;
}
/// <summary>The character's actions sampled once per character/type (support-hand planning input).</summary>
public List<ActionTrack> ActionTracks { get; private set; } = new();
/// <summary>Last support-hand plan (magazine grab, reload touches).</summary>
public ActionContactPlan ContactPlan { get; private set; }
/// <summary>Measures how long the character's own actions run (per trigger) for this holdtype.</summary>
private async Task MeasureActionsAsync( CancellationToken cancel )
{
var key = $"{CharacterModel}|{Setup.EffectiveHoldType}";
if ( Setup.ActionSecondsKey == key && Setup.ActionSeconds.Count > 0 )
return;
var measured = new Dictionary<AnimationRole, float>();
var byTrigger = new Dictionary<string, float>();
foreach ( var role in AnimationRoles.All )
{
if ( AnimationRoles.GraphTrigger( role ) is not { } trigger )
continue;
if ( !byTrigger.TryGetValue( trigger, out var seconds ) )
{
// Median of three: one measurement can be a frame off (random idle layers).
var samples = new List<float>();
for ( var i = 0; i < 3; i++ )
{
await EditorThread.NextFrame( cancel );
if ( _poser is null )
return;
samples.Add( _poser.MeasureAction( Setup.EffectiveHoldType, trigger ) );
}
samples.Sort();
byTrigger[trigger] = seconds = samples[1];
}
if ( seconds > 0.05f )
measured[role] = MathF.Round( seconds, 3 );
}
// Replaced whole: playback never times an action from a half-measured set.
Setup.ActionSeconds = measured;
Setup.ActionSecondsKey = key;
}
private async Task SampleActionTracksAsync( CancellationToken cancel )
{
var tracks = new List<ActionTrack>();
if ( _poser is not null && Character?.Left is not null )
{
foreach ( var role in ActionTiming.ReloadRoles )
{
var seconds = ActionTiming.Seconds( Setup, Analysis?.Asset, role );
// Same trigger and length: reuse the samples.
var same = tracks.FirstOrDefault( t => MathF.Abs( t.Seconds - seconds ) < 1e-3f );
if ( same is null )
await EditorThread.NextFrame( cancel );
if ( _poser is null )
break;
var samples = same?.Samples ?? _poser.SampleTrack( Setup.EffectiveHoldType, AnimationRoles.GraphTrigger( role ), seconds, 49 );
tracks.Add( new ActionTrack( role, seconds, samples ) );
}
}
// Published whole: nothing sees a half-sampled set between frames.
ActionTracks = tracks;
}
/// <summary>A character pose sampled at an action's normalized time (for per-animation checks).</summary>
public CharacterPose SampleAction( AnimationRole role, float normalizedTime, float seconds )
{
if ( _poser is null )
return ReferencePose;
var key = (Setup.EffectiveHoldType, role, normalizedTime, seconds);
if ( _actionPoses.TryGetValue( key, out var cached ) )
return cached;
var trigger = AnimationRoles.GraphTrigger( role );
var pose = _poser.Sample( Setup.EffectiveHoldType, 0.6f + normalizedTime * seconds, trigger, 0.6f );
_actionPoses[key] = pose;
return pose;
}
/// <summary>
/// Solves the hands against the geometry (cancels a previous solve still running). A quick
/// pass is shown immediately (<see cref="GripMoved"/>), then the full-quality solve replaces
/// it. <paramref name="edited"/>: only that hand changed, so the other keeps its pose during
/// the quick pass (and the right hand keeps it entirely when only the left changed).
/// </summary>
public async Task ResolveGripAsync( IProgress<string> progress = null, CancellationToken cancel = default, Side? edited = null, bool quickOnly = false )
{
if ( Analysis is null || Character is null || ReferencePose is null )
return;
_gripCancel?.Cancel();
_gripCancel = CancellationTokenSource.CreateLinkedTokenSource( cancel );
var token = _gripCancel.Token;
Report( progress, "Fitting hands to the weapon" );
var analysis = Analysis;
var setup = Setup;
var primary = AutoSetup.Candidate( analysis, setup.Primary ) ?? analysis.Primary;
var support = setup.UseSupportHand && setup.Support is not null ? AutoSetup.Candidate( analysis, setup.Support ) : null;
if ( primary is null )
{
Grip = null;
Revalidate();
Notify();
return;
}
var options = Options();
var character = Character;
var reference = ReferencePose;
var previous = Grip;
GripSolution solution;
ActionContactPlan plan;
// Settle against the last full-quality grip (at first the one saved with the setup), not
// the quick preview below.
_settledGrip ??= Setup.Baked;
try
{
// 1. Quick pass, on screen in a frame or two.
var quick = options with
{
Fast = true,
RightOverride = options.RightOverride ?? (edited == Side.Left ? previous?.Right : null),
LeftOverride = options.LeftOverride ?? (edited == Side.Right ? previous?.Left : null),
};
var fast = await Task.Run( () => GripSolver.Solve( analysis, primary, support, character, reference, quick, token ), token );
await EditorThread.SwitchToMainThread();
if ( token.IsCancellationRequested )
return;
Grip = fast;
Setup.Baked = Bake( fast );
GripMoved?.Invoke();
// While a grip is dragged only quick fits run; the full fit follows on release.
if ( quickOnly )
return;
// 2. Full quality. A left-hand edit never changes the right hand.
var full = options with { RightOverride = options.RightOverride ?? (edited == Side.Left ? previous?.Right : null) };
Report( progress, "Refining the grip" );
var tracks = ActionTracks;
(solution, plan) = await Task.Run( () =>
{
var s = GripSolver.Solve( analysis, primary, support, character, reference, full, token );
// Support hand over the actions (magazine during reloads), from the same grip.
var p = ActionContacts.Plan( analysis, setup, character, s, tracks, full, token );
return (s, p);
}, token );
}
catch ( OperationCanceledException )
{
return;
}
await EditorThread.SwitchToMainThread();
if ( token.IsCancellationRequested )
return;
Grip = solution;
ContactPlan = plan;
ActionContacts.Apply( Setup, plan );
Setup.Baked = _settledGrip = Bake( solution );
Revalidate();
Notify();
try
{
await SweepActionsAsync( progress, token );
}
catch ( OperationCanceledException )
{
return;
}
Report( progress, "Ready" );
}
public const string OwnGripSource = "This weapon's first-person animation";
/// <summary>Grips captured from the weapon file's own arms (empty without first-person arms).</summary>
public List<GripPreset> OwnGrips { get; private set; } = new();
/// <summary>Every grip the hands can take, best sources first (own, project, built-in).</summary>
public List<GripPreset> Grips => GripLibraryStore.All( OwnGrips.Concat( LearnedGrips ) );
/// <summary>GrabNet grips of this weapon (from its setup), ranked below its own animation's grips.</summary>
private IEnumerable<GripPreset> LearnedGrips => (Setup?.LearnedGrips ?? new List<GripPreset>()).Select( p =>
{
p.Priority = GrabNetGrips.Priority;
return p;
} );
/// <summary>Writes the weapon the way GrabNet expects it (OBJ, metres, canonical frame).</summary>
public void ExportForGrabNet( string path )
{
if ( Analysis is null )
throw new InvalidOperationException( "Import a weapon first." );
System.IO.File.WriteAllText( path, GrabNetGrips.ExportObj( Analysis ) );
}
/// <summary>
/// Adds the grasps of a GrabNet result file (21 keypoints per hand, in the exported frame) as
/// grips of this weapon. Grasps with the same name are replaced, so re-reading a file is safe.
/// </summary>
public async Task<(int Added, List<string> Skipped)> AddLearnedGraspsAsync( string path, CancellationToken cancel = default )
{
var analysis = Analysis ?? throw new InvalidOperationException( "Import a weapon first." );
var source = $"{GrabNetGrips.Source} · {System.IO.Path.GetFileNameWithoutExtension( path )}";
var (presets, skipped) = await Task.Run( () =>
{
var grasps = GrabNetGrips.Parse( System.IO.File.ReadAllText( path ) );
var list = GrabNetGrips.ToPresets( analysis, grasps, source, out var skip );
return (list, skip);
}, cancel );
await EditorThread.SwitchToMainThread();
Setup.LearnedGrips ??= new List<GripPreset>();
Setup.LearnedGrips.RemoveAll( p => presets.Any( n => n.Name == p.Name ) );
Setup.LearnedGrips.AddRange( presets );
Notify();
return (presets.Count, skipped);
}
private GripOptions Options() => new()
{
Backend = new ChoiceGripGenerator( Grips, Setup.Primary?.Preset, Setup.Support?.Preset ),
UseSupportHand = Setup.UseSupportHand,
IndexOnTrigger = Setup.IndexOnTrigger,
WristPreference = Setup.Ik.WristPreference,
WeaponOffset = Setup.Ik.WeaponOffset,
RightOverride = Setup.Primary?.Pose?.ToPose(),
LeftOverride = Setup.Support?.Pose?.ToPose(),
};
/// <summary>
/// Live re-fit for fine-tune and finger edits: keeps the solved hand placements, re-places
/// the weapon and re-runs the arm IK on the main thread (well under a millisecond). Returns
/// false when there is no grip yet, in which case a full <see cref="ResolveGripAsync"/> is needed.
/// </summary>
public bool Refit( Side? editedHand = null, HandPose editedPose = null )
{
if ( Grip is null || Analysis is null || Character is null || ReferencePose is null )
return false;
if ( editedHand is { } hand && editedPose is not null )
{
var grip = hand == Side.Right ? Setup.Primary : Setup.Support;
if ( grip is null || (hand == Side.Left && Grip.Left is null) )
return false;
grip.Pose = HandPoseSetup.From( editedPose );
grip.Manual = true;
}
_gripCancel?.Cancel();
Grip = GripSolver.Refit( Analysis, Grip, Character, ReferencePose, Options(),
editedHand == Side.Right ? editedPose : null, editedHand == Side.Left ? editedPose : null );
Setup.Baked = _settledGrip = Bake( Grip );
GripMoved?.Invoke();
return true;
}
/// <summary>After live edits settle: re-check the actions and validation, then notify everyone.</summary>
public async Task SettleAsync( IProgress<string> progress = null, CancellationToken cancel = default )
{
Revalidate();
Notify();
await ReplanContactsAsync( cancel );
await SweepActionsAsync( progress, cancel );
}
/// <summary>
/// Slides a hand's grasp to another spot on the weapon, continuously: the wrist keeps its
/// place relative to the local surface frame, the fingers close again on the new surface and
/// the weapon is re-placed (no search, well under a millisecond or two). Used while a grip
/// contact is dragged in the viewport; returns false when there is no grip to slide.
/// </summary>
public float? SlideGrip( Side hand, N.Vector3 point, N.Vector3 normal )
{
if ( Grip is null || Analysis is null || Character is null )
return null;
var current = hand == Side.Right ? Grip.Right : Grip.Left;
var request = hand == Side.Right ? Grip.RightRequest : Grip.LeftRequest;
var grip = hand == Side.Right ? Setup.Primary : Setup.Support;
if ( current is null || request is null || grip is null )
return null;
if ( GripSolver.Slide( Analysis.WeaponBvh, request, current, point, normal ) is not { } slid )
return null;
var (pose, moved) = slid;
grip.Contact = V.A( moved.Surface.Contact );
grip.Normal = V.A( moved.Surface.Normal );
grip.Axis = V.A( moved.Surface.Axis );
grip.Manual = true;
// Keep the slid request so the next step continues from this spot.
Grip = new GripSolution
{
WeaponInHold = Grip.WeaponInHold,
Right = Grip.Right,
Left = Grip.Left,
RightQuality = Grip.RightQuality,
LeftQuality = Grip.LeftQuality,
RightRequest = hand == Side.Right ? moved : Grip.RightRequest,
LeftRequest = hand == Side.Left ? moved : Grip.LeftRequest,
Posed = Grip.Posed,
RightSource = Grip.RightSource,
LeftSource = Grip.LeftSource,
};
if ( !Refit( hand, pose ) )
return null;
// How far the grasp still is from the dragged spot (it moves a little each step).
return N.Vector3.Distance( moved.Surface.Contact, point );
}
/// <summary>Re-plans the support hand over the actions for the current grip (after edits).</summary>
public async Task ReplanContactsAsync( CancellationToken cancel = default )
{
if ( Grip is null || Analysis is null || Character is null )
return;
var (analysis, setup, character, grip, tracks, options) = (Analysis, Setup, Character, Grip, ActionTracks, Options());
var plan = await Task.Run( () => ActionContacts.Plan( analysis, setup, character, grip, tracks, options, cancel ), cancel );
await EditorThread.SwitchToMainThread();
if ( cancel.IsCancellationRequested || !ReferenceEquals( grip, Grip ) )
return;
ContactPlan = plan;
ActionContacts.Apply( Setup, plan );
GripMoved?.Invoke();
}
// Last full-quality baked grip: sampling noise below BakedGrip's tolerances keeps its values.
private BakedGrip _settledGrip;
/// <summary>The solved grip in the form <c>WeaponHold</c> consumes (model space == canonical space).</summary>
public BakedGrip Bake( GripSolution s )
{
var baked = new BakedGrip
{
Character = CharacterModel,
HoldBone = Character.Right.Skeleton[Character.HoldBone].Name,
WeaponInHold = V.A( s.WeaponInHold ),
RightHand = V.A( s.Right.Wrist ),
LeftHand = s.Left is null ? null : V.A( s.Left.Wrist ),
Quality = s.RightQuality.ToString(),
};
// Elbows where the fitted reference pose has them (weapon space): runtime IK leans toward them.
var weaponWorld = GripSolver.WeaponWorld( s.Posed, Character, s.WeaponInHold );
baked.RightElbow = V.A( XForm.ToLocal( weaponWorld, s.Posed.World[Character.Right.LowerArm] ).Pos );
if ( s.Left is not null && Character.Left is { } leftRig )
baked.LeftElbow = V.A( XForm.ToLocal( weaponWorld, s.Posed.World[leftRig.LowerArm] ).Pos );
void Fingers( HandRig rig, HandPose pose, Dictionary<string, float[]> into )
{
foreach ( var finger in rig.Fingers )
for ( var j = 0; j < finger.Joints.Length; j++ )
into[rig.Skeleton[finger.Joints[j]].Name] = V.A( pose.LocalRotation( rig, finger, j ) );
}
Fingers( Character.Right, s.Right, baked.RightFingers );
if ( s.Left is not null && Character.Left is not null )
Fingers( Character.Left, s.Left, baked.LeftFingers );
return baked.SettledOn( _settledGrip );
}
// ------------------------------------------------------------------ edits
/// <summary>Sets a grip from a point the user clicked on the weapon (canonical space).</summary>
public async Task PickGripAsync( Side hand, N.Vector3 point, N.Vector3? normal = null, bool quickOnly = false )
{
var surface = Core.Geometry.SurfaceProbe.FromPoint( Analysis.WeaponBvh, point + (normal ?? N.Vector3.Zero) * 0.05f, hand == Side.Right ? N.Vector3.UnitZ : N.Vector3.UnitX );
if ( surface is null )
return;
var existing = hand == Side.Right ? Setup.Primary : Setup.Support;
var style = existing?.Style ?? (hand == Side.Right ? GripStyle.Wrap : GripStyle.Cradle);
var grip = new GripCandidate { Surface = surface, Style = style, Confidence = 1f, Reason = "picked on the weapon", Manual = true };
var setup = AutoSetup.Grip( grip );
setup.Manual = true;
if ( hand == Side.Right )
Setup.Primary = setup;
else
{
Setup.Support = setup;
Setup.UseSupportHand = true;
}
Notify();
await ResolveGripAsync( edited: hand, quickOnly: quickOnly );
}
/// <summary>Clears manual grip edits for one hand and lets the importer place it again.</summary>
public async Task AutoFitAsync( Side? hand )
{
if ( hand is null or Side.Right )
{
if ( Analysis.Primary is { } p )
Setup.Primary = AutoSetup.Grip( p );
}
if ( hand is null or Side.Left )
{
Setup.Support = Analysis.Support is { } s ? AutoSetup.Grip( s ) : null;
Setup.UseSupportHand = Setup.Support is not null;
}
Notify();
await ResolveGripAsync( edited: hand );
}
/// <summary>Stores a hand pose edited in the viewport.</summary>
public async Task SetHandPoseAsync( Side hand, HandPose pose )
{
var grip = hand == Side.Right ? Setup.Primary : Setup.Support;
if ( grip is null )
return;
grip.Pose = HandPoseSetup.From( pose );
grip.Manual = true;
Notify();
await ResolveGripAsync();
}
public void MarkChanged() => Notify();
private static readonly AnimationRole[] CheckedRoles = { AnimationRole.Fire, AnimationRole.Reload, AnimationRole.Draw };
/// <summary>The checked actions with the length they run for (same timing as the game).</summary>
private IEnumerable<(AnimationRole Role, float Seconds)> SweepRoles
=> CheckedRoles.Select( r => (r, ActionTiming.Seconds( Setup, Analysis?.Asset, r )) );
private static readonly float[] SweepTimes = { 0.1f, 0.3f, 0.5f, 0.7f, 0.9f };
/// <summary>
/// Samples the character over the checked actions up front (during loading), so the action
/// check that follows every edit is pure maths and never stalls the editor.
/// </summary>
/// <summary>Samples the poses the action sweep checks, one frame per action.</summary>
private async Task WarmActionPosesAsync( CancellationToken cancel )
{
foreach ( var (role, seconds) in SweepRoles )
{
await EditorThread.NextFrame( cancel );
if ( _poser is null )
return;
foreach ( var t in SweepTimes )
SampleAction( role, t, seconds );
}
}
/// <summary>Last results of <see cref="SweepActionsAsync"/>.</summary>
public List<(AnimationRole Role, float Time, string Message)> ActionIssues { get; private set; } = new();
/// <summary>
/// Replays the grip over the character's own actions (fire, reload, draw) at several moments:
/// where the support hand should be locked it must reach, and the firing wrist must not twist
/// far from the animation. Results feed validation.
/// </summary>
public async Task SweepActionsAsync( IProgress<string> progress = null, CancellationToken cancel = default )
{
await EditorThread.SwitchToMainThread();
if ( Grip is null || Character is null || _poser is null )
return;
Report( progress, "Checking hands over the animations" );
var issues = new List<(AnimationRole, float, string)>();
foreach ( var (role, seconds) in SweepRoles )
{
Setup.Contacts.TryGetValue( role, out var track );
foreach ( var t in SweepTimes )
{
cancel.ThrowIfCancellationRequested();
var pose = SampleAction( role, t, seconds ).Clone();
var leftWeight = Setup.UseSupportHand && Grip.Left is not null ? track?.Weight( Side.Left, t, seconds ) ?? 1f : 0f;
var rightWeight = track?.Weight( Side.Right, t, seconds ) ?? 1f;
var (right, left, rightCorr, _) = GripSolver.Apply( pose, Character, Grip.WeaponInHold, Grip.Right, leftWeight > 0.5f ? Grip.Left : null, rightWeight, leftWeight );
if ( leftWeight > 0.5f && !left.Reached && left.Shortfall > Setup.Ik.ReachSlack )
issues.Add( (role, t, $"Left hand is {left.Shortfall:0.0} in short of the grip") );
var bend = rightWeight > 0.5f ? GripSolver.WristBend( pose, Character.Right ) : 0f;
// Transient action frames get headroom; only lasting problems are reported below.
if ( bend > GripSolver.MaxWristBend + 20f )
issues.Add( (role, t, $"Right wrist bends {bend:0}° against the forearm") );
await EditorThread.Delay( 1, cancel );
}
await EditorThread.Delay( 1, cancel );
}
// A problem is real when it lasts: at least two sampled moments of the same action.
ActionIssues = issues.GroupBy( i => (i.Item1, i.Item3.Split( ' ' )[0] + i.Item3.Split( ' ' )[1]) )
.Where( g => g.Count() >= 2 )
.SelectMany( g => g )
.ToList();
Revalidate();
Notify();
}
public void Revalidate( IReadOnlyDictionary<string, IReadOnlyList<string>> compileErrors = null, IReadOnlyCollection<string> compiledSequences = null )
{
if ( Setup is null )
return;
Checks = Validation.Run( new ValidationInput
{
Setup = Setup,
Analysis = Analysis,
Grip = Grip,
CompileErrors = compileErrors,
CompiledSequences = compiledSequences,
ActionIssues = ActionIssues,
Changed = () => { _ = ResolveGripAsync(); Notify(); },
} );
if ( SetupLoadProblem is { } problem )
Checks.Insert( 0, new CheckResult { Name = "Previous setup", Severity = CheckSeverity.Warning, Message = problem } );
}
public List<string> ApplyTemplate( string setupJsonPath, TemplateParts parts = TemplateParts.All )
{
var template = WeaponSetup.FromJson( System.IO.File.ReadAllText( setupJsonPath ) );
var notes = TemplateAdapter.Apply( Setup, Analysis, template, parts );
Notify();
_ = ResolveGripAsync();
return notes;
}
// ------------------------------------------------------------------ persistence
public void Save()
{
if ( Setup is null )
return;
Setup.OutputFolder = OutputFolder;
AssetCompiler.WriteText( SetupPath, Setup.ToJson() );
}
public Task<BakeResult> BakeAsync( IProgress<string> progress = null, CancellationToken cancel = default )
=> WeaponBaker.BakeAsync( this, progress, cancel );
/// <summary>Why the previous setup next to the weapon could not be used (shown in Check), or null.</summary>
public string SetupLoadProblem { get; private set; }
/// <summary>
/// The setup saved by a previous import. An unreadable one is kept as <c>.bak</c> (the next
/// bake overwrites the original) and reported, so manual choices never disappear silently.
/// </summary>
private WeaponSetup TryLoadSetup( string relative )
{
SetupLoadProblem = null;
var abs = AssetCompiler.Absolute( relative );
if ( !System.IO.File.Exists( abs ) )
return null;
try
{
return WeaponSetup.FromJson( System.IO.File.ReadAllText( abs ) );
}
catch ( Exception e ) when ( e is not OperationCanceledException )
{
var backup = abs + ".bak";
try
{
System.IO.File.Copy( abs, backup, true );
}
catch ( Exception copy ) when ( copy is System.IO.IOException or UnauthorizedAccessException )
{
backup = null;
}
SetupLoadProblem = $"{relative} could not be read ({e.Message}); the weapon was set up from scratch." + (backup is null ? "" : $" The old file is kept as {System.IO.Path.GetFileName( backup )}.");
Log.Warning( $"[weapon importer] {SetupLoadProblem}" );
return null;
}
}
private static string Hash( string path )
{
try
{
using var stream = System.IO.File.OpenRead( path );
var hash = System.Security.Cryptography.SHA256.HashData( stream );
return Convert.ToHexString( hash, 0, 12 );
}
catch ( Exception )
{
return "";
}
}
/// <summary>Project-relative path of a file inside the assets folder, or null.</summary>
public static string ToProjectRelative( string absolute )
{
var root = AssetCompiler.AssetsRoot;
if ( string.IsNullOrEmpty( root ) )
return null;
var full = System.IO.Path.GetFullPath( absolute );
var r = System.IO.Path.GetFullPath( root ).TrimEnd( '\\', '/' ) + System.IO.Path.DirectorySeparatorChar;
return full.StartsWith( r, StringComparison.OrdinalIgnoreCase ) ? full[r.Length..].Replace( '\\', '/' ) : null;
}
public void Dispose()
{
_gripCancel?.Cancel();
_poser?.Dispose();
_poser = null;
}
}