Editor service that evaluates a weapon animation pose. It samples clip tracks and working-pose overrides, builds model and local transforms for skeleton bones, resolves and constrains hand/elbow controls, applies two-bone IK, maintains optional constraint offsets, and applies bone constraints to produce an EvaluatedPose.
#nullable enable annotations
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace SboxWeaponAnimator.Editor;
public sealed class EvaluatedPose
{
public Dictionary<string, Transform> Model { get; } = new( StringComparer.OrdinalIgnoreCase );
public Dictionary<string, Transform> Local { get; } = new( StringComparer.OrdinalIgnoreCase );
public bool PrimaryReachable { get; set; } = true;
public bool SupportReachable { get; set; } = true;
/// <summary>
/// The goals the IK actually solved toward, after clip sampling and constraints. The viewport
/// tether draws these; the raw binding transform diverges as soon as a clip animates the hand.
/// </summary>
public Transform? PrimaryHandGoal { get; set; }
public Transform? SupportHandGoal { get; set; }
}
public static class AnimationPoseEvaluator
{
public static EvaluatedPose Evaluate(
WeaponAnimationDocument document,
HostSkeleton skeleton,
WeaponAnimationClip? clip,
float time,
bool includeWorkingPose = false )
{
var local = skeleton.Bones.ToDictionary(
x => x.Name,
skeleton.GetBindLocal,
StringComparer.OrdinalIgnoreCase );
if ( clip is not null )
{
foreach ( var track in clip.Tracks.Where( x => !x.Muted ) )
{
if ( !local.TryGetValue( track.Target, out var fallback ) )
continue;
if ( !ShouldEvaluateTrack( document, skeleton, track ) )
continue;
local[track.Target] = WeaponAnimationMath.SampleTrack( track, time, fallback );
}
if ( includeWorkingPose )
{
foreach ( var working in document.Workspace.WorkingPoseOverrides.Where( x =>
x.ClipId == clip.Id
&& !x.Target.StartsWith( "@", StringComparison.Ordinal )
&& local.ContainsKey( x.Target )
&& ShouldEvaluateArmTarget(
document,
skeleton,
x.Target,
x.Kind ) ) )
{
local[working.Target] = working.Transform;
}
}
}
var pose = new EvaluatedPose();
BuildModelTransforms( skeleton, local, pose.Model );
Dictionary<string, Transform>? constraintStartModel = null;
if ( clip?.Constraints.Any( x => x.MaintainOffset ) == true )
constraintStartModel = BuildModelAtTime( skeleton, clip, clip.Constraints.Min( x => x.StartTime ) );
if ( document.Binding.PrimaryHand.IsBound )
{
var primaryHand = ResolveAndConstrainControl(
document.Binding.PrimaryHand,
"@primary_hand",
SamplePreviewControl(
document,
clip,
"@primary_hand",
time,
document.Binding.PrimaryHand.Transform,
includeWorkingPose ),
clip,
time,
skeleton,
pose.Model,
constraintStartModel );
var primaryPole = ResolveAndConstrainControl(
document.Binding.PrimaryElbowPole,
"@primary_elbow",
SamplePreviewControl(
document,
clip,
"@primary_elbow",
time,
document.Binding.PrimaryElbowPole.Transform,
includeWorkingPose ),
clip,
time,
skeleton,
pose.Model,
constraintStartModel );
pose.PrimaryHandGoal = primaryHand;
ApplyArmIk(
pose,
skeleton,
local,
true,
primaryHand,
primaryPole,
"arm_upper_R",
"arm_lower_R",
"hand_R" );
}
if ( document.Binding.Configuration == GripConfiguration.TwoHanded
&& document.Binding.SupportHand.IsBound )
{
var supportHand = ResolveAndConstrainControl(
document.Binding.SupportHand,
"@support_hand",
SamplePreviewControl(
document,
clip,
"@support_hand",
time,
document.Binding.SupportHand.Transform,
includeWorkingPose ),
clip,
time,
skeleton,
pose.Model,
constraintStartModel );
var supportPole = ResolveAndConstrainControl(
document.Binding.SupportElbowPole,
"@support_elbow",
SamplePreviewControl(
document,
clip,
"@support_elbow",
time,
document.Binding.SupportElbowPole.Transform,
includeWorkingPose ),
clip,
time,
skeleton,
pose.Model,
constraintStartModel );
pose.SupportHandGoal = supportHand;
ApplyArmIk(
pose,
skeleton,
local,
false,
supportHand,
supportPole,
"arm_upper_L",
"arm_lower_L",
"hand_L" );
}
ApplyBoneConstraints( clip, time, pose.Model );
foreach ( var bone in skeleton.Bones )
{
if ( !pose.Model.TryGetValue( bone.Name, out var modelTransform ) )
continue;
pose.Local[bone.Name] = string.IsNullOrWhiteSpace( bone.ParentName )
|| !pose.Model.TryGetValue( bone.ParentName, out var parent )
? modelTransform
: parent.ToLocal( modelTransform );
}
return pose;
}
internal static bool ShouldEvaluateTrack(
WeaponAnimationDocument document,
HostSkeleton skeleton,
TransformTrack track ) =>
ShouldEvaluateArmTarget( document, skeleton, track.Target, track.Kind );
private static bool ShouldEvaluateArmTarget(
WeaponAnimationDocument document,
HostSkeleton skeleton,
string target,
RigControlKind kind )
{
if ( kind != RigControlKind.Arm )
return true;
if ( IsArmSide( skeleton, target, "_R" ) )
return document.Binding.PrimaryHand.IsBound;
if ( IsArmSide( skeleton, target, "_L" ) )
return document.Binding.Configuration == GripConfiguration.TwoHanded
&& document.Binding.SupportHand.IsBound;
// Shared arm-root controls only affect a preview after at least one hand is bound.
return document.Binding.PrimaryHand.IsBound
|| (document.Binding.Configuration == GripConfiguration.TwoHanded
&& document.Binding.SupportHand.IsBound);
}
private static bool IsArmSide(
HostSkeleton skeleton,
string target,
string suffix )
{
return skeleton.ByName.TryGetValue( target, out var bone )
&& bone.ArmSide == (suffix.Equals(
"_R",
StringComparison.OrdinalIgnoreCase ) ? 1 : -1);
}
private static void BuildModelTransforms(
HostSkeleton skeleton,
IReadOnlyDictionary<string, Transform> local,
Dictionary<string, Transform> model )
{
foreach ( var bone in skeleton.Bones )
{
var boneLocal = local[bone.Name];
if ( string.IsNullOrWhiteSpace( bone.ParentName )
|| !model.TryGetValue( bone.ParentName, out var parent ) )
{
model[bone.Name] = boneLocal;
continue;
}
model[bone.Name] = ComposeLocal( parent, boneLocal );
}
}
private static void ApplyArmIk(
EvaluatedPose pose,
HostSkeleton skeleton,
IReadOnlyDictionary<string, Transform> local,
bool primary,
Transform target,
Transform pole,
string upperName,
string lowerName,
string handName )
{
if ( !pose.Model.TryGetValue( upperName, out var upper )
|| !pose.Model.TryGetValue( lowerName, out var lower )
|| !pose.Model.TryGetValue( handName, out var hand ) )
return;
var solution = WeaponAnimationMath.SolveTwoBone(
upper.Position,
lower.Position,
hand.Position,
target.Position,
pole.Position );
var upperRotation = WeaponAnimationMath.RotationFromTo(
lower.Position - upper.Position,
solution.Elbow - solution.Root ) * upper.Rotation;
var lowerRotation = WeaponAnimationMath.RotationFromTo(
hand.Position - lower.Position,
solution.End - solution.Elbow ) * lower.Rotation;
pose.Model[upperName] = new Transform( solution.Root, upperRotation, upper.Scale );
pose.Model[lowerName] = new Transform( solution.Elbow, lowerRotation, lower.Scale );
pose.Model[handName] = new Transform( solution.End, target.Rotation, hand.Scale );
RebuildSolvedDescendants(
skeleton,
local,
pose.Model,
upperName,
[upperName, lowerName, handName],
[] );
if ( primary )
pose.PrimaryReachable = solution.Reachable;
else
pose.SupportReachable = solution.Reachable;
}
private static void RebuildSolvedDescendants(
HostSkeleton skeleton,
IReadOnlyDictionary<string, Transform> local,
Dictionary<string, Transform> model,
string parentName,
HashSet<string> solvedBones,
HashSet<string> visited )
{
if ( !visited.Add( parentName ) || !model.TryGetValue( parentName, out var parent ) )
return;
foreach ( var child in skeleton.ChildrenOf( parentName ) )
{
if ( !solvedBones.Contains( child.Name )
&& local.TryGetValue( child.Name, out var childLocal ) )
{
model[child.Name] = ComposeLocal( parent, childLocal );
}
RebuildSolvedDescendants(
skeleton,
local,
model,
child.Name,
solvedBones,
visited );
}
}
private static Transform ComposeLocal( Transform parent, Transform local ) => new(
parent.PointToWorld( local.Position ),
parent.Rotation * local.Rotation,
parent.Scale * local.Scale );
private static Transform ResolveTarget(
RigTarget target,
Transform sampledTransform,
IReadOnlyDictionary<string, Transform> model )
{
if ( !string.IsNullOrWhiteSpace( target.AttachedBone )
&& model.TryGetValue( target.AttachedBone, out var attached ) )
{
return new Transform(
attached.PointToWorld( sampledTransform.Position ),
attached.Rotation * sampledTransform.Rotation,
attached.Scale * sampledTransform.Scale );
}
return sampledTransform;
}
private static Transform SampleControl(
WeaponAnimationClip? clip,
string target,
float time,
Transform fallback )
{
var track = clip?.Tracks.FirstOrDefault( x => x.Target == target );
return track is null
? fallback
: WeaponAnimationMath.SampleTrack( track, time, fallback );
}
private static Transform SamplePreviewControl(
WeaponAnimationDocument document,
WeaponAnimationClip? clip,
string target,
float time,
Transform fallback,
bool includeWorkingPose )
{
if ( includeWorkingPose
&& clip is not null
&& document.Workspace.GetWorkingPose( clip.Id, target ) is { } working )
return working.Transform;
return SampleControl( clip, target, time, fallback );
}
private static Transform ResolveAndConstrainControl(
RigTarget targetDefinition,
string controlName,
Transform sampled,
WeaponAnimationClip? clip,
float time,
HostSkeleton skeleton,
IReadOnlyDictionary<string, Transform> currentModel,
IReadOnlyDictionary<string, Transform>? sharedStartModel )
{
var source = ResolveTarget( targetDefinition, sampled, currentModel );
if ( clip is null )
return source;
foreach ( var constraint in clip.Constraints.Where( x =>
x.SourceControl.Equals( controlName, StringComparison.OrdinalIgnoreCase )
&& time >= x.StartTime && time <= x.EndTime ) )
{
if ( !currentModel.TryGetValue( constraint.TargetBone, out var constraintTarget ) )
continue;
var maintainedOffset = Transform.Zero;
if ( constraint.MaintainOffset )
{
var startModel = sharedStartModel;
if ( startModel is null
|| clip.Constraints.Any( x => x.MaintainOffset && MathF.Abs( x.StartTime - constraint.StartTime ) > 0.00001f ) )
{
startModel = BuildModelAtTime( skeleton, clip, constraint.StartTime );
}
if ( startModel.TryGetValue( constraint.TargetBone, out var startTarget ) )
{
var startSample = SampleControl(
clip,
controlName,
constraint.StartTime,
targetDefinition.Transform );
var startSource = ResolveTarget( targetDefinition, startSample, startModel );
maintainedOffset = startTarget.ToLocal( startSource );
}
}
source = ClipConstraintEvaluator.Apply(
source,
constraintTarget,
constraint,
time,
maintainedOffset );
}
return source;
}
private static Dictionary<string, Transform> BuildModelAtTime(
HostSkeleton skeleton,
WeaponAnimationClip clip,
float time )
{
var local = skeleton.Bones.ToDictionary(
x => x.Name,
skeleton.GetBindLocal,
StringComparer.OrdinalIgnoreCase );
foreach ( var track in clip.Tracks.Where( x => !x.Muted && local.ContainsKey( x.Target ) ) )
local[track.Target] = WeaponAnimationMath.SampleTrack( track, time, local[track.Target] );
var model = new Dictionary<string, Transform>( StringComparer.OrdinalIgnoreCase );
BuildModelTransforms( skeleton, local, model );
return model;
}
private static void ApplyBoneConstraints(
WeaponAnimationClip? clip,
float time,
Dictionary<string, Transform> model )
{
if ( clip is null )
return;
foreach ( var constraint in clip.Constraints )
{
if ( constraint.SourceControl.StartsWith( "@", StringComparison.Ordinal ) )
continue;
if ( !model.TryGetValue( constraint.SourceControl, out var source )
|| !model.TryGetValue( constraint.TargetBone, out var target ) )
continue;
model[constraint.SourceControl] = ClipConstraintEvaluator.Apply(
source,
target,
constraint,
time,
Transform.Zero );
}
}
}