s&box Package Code Search
Search C# source code, UI razor templates, shaders, and configs across s&box packages.
link Raw Facepunch API Link: https://public.facepunch.com/sbox/code/search/1/?ident=notpointless.chomnr_humanoid_hand_retargeter&take=20
Showing code results for query:
*
(108 total matches found)
Game
library
#nullable enable
using HumanoidHandRetargeter.Mapping;
using SkeletonModel = HumanoidHandRetargeter.Skeleton.Skeleton;
namespace HumanoidHandRetargeter.Validation;
public sealed record RigIssue(string Code, string Message, HandSide? Side = null, int? Bone = null);
/// <summary>Shared validation for automatic/manual mapping, batch processing and the editor.</summary>
public static class HandRigValidator
{
public static IReadOnlyList<RigIssue> Validate(SkeletonModel skeleton, IEnumerable<HandRigDefinition> hands)
{
ArgumentNullException.ThrowIfNull(skeleton);
ArgumentNullException.ThrowIfNull(hands);
var issues = new List<RigIssue>();
var claimed = new HashSet<int>();
var sides = new HashSet<HandSide>();
var count = 0;
foreach (var bone in skeleton.Bones)
{
if (!PoseValidator.ValidTransform(bone.RestLocal))
issues.Add(new("invalid-rest", $"Bone '{bone.Name}' has an invalid rest transform.", Bone: bone.Index));
var world = skeleton.RestWorld[bone.Index].Pos;
if (!float.IsFinite(world.X) || !float.IsFinite(world.Y) || !float.IsFinite(world.Z))
issues.Add(new("invalid-world-rest", $"Bone '{bone.Name}' has an invalid accumulated rest position.", Bone: bone.Index));
}
foreach (var hand in hands)
{
count++;
if (hand is null)
{
issues.Add(new("missing-hand", "A hand mapping is missing."));
continue;
}
if (!Enum.IsDefined(typeof(HandSide), hand.Side))
issues.Add(new("invalid-side", "Choose left or right for the hand."));
if (!sides.Add(hand.Side))
issues.Add(new("duplicate-side", $"More than one {hand.Side} hand is mapped.", hand.Side));
var arm = new[] { hand.Clavicle, hand.UpperArm, hand.Forearm, hand.Wrist }
.Where(b => b.HasValue).Select(b => b!.Value).ToArray();
CheckChain(arm, hand.Side, "arm", allowZeroLength: true);
var roles = new HashSet<string>(StringComparer.Ordinal);
foreach (var digit in hand.Digits)
{
if (digit is null)
{
issues.Add(new("missing-digit", "A digit mapping is missing.", hand.Side));
continue;
}
var key = digit.Role == DigitRole.Extra ? "Extra:" + digit.ExtraSlot : digit.Role.ToString();
if (!Enum.IsDefined(typeof(DigitRole), digit.Role)
|| (digit.Role == DigitRole.Extra && string.IsNullOrWhiteSpace(digit.ExtraSlot)))
issues.Add(new("invalid-digit-role", "Choose a digit role or name its extra slot.", hand.Side));
if (!roles.Add(key))
issues.Add(new("duplicate-digit-role", $"{hand.Side} {key} is mapped more than once.", hand.Side));
if (digit.Segments.Count == 0)
issues.Add(new("empty-digit", $"{hand.Side} {key} needs at least one segment.", hand.Side));
var chain = digit.Bones.ToArray();
CheckChain(chain, hand.Side, key, allowZeroLength: false, tip: digit.Tip);
if (chain.Length > 0 && Valid(chain[0]) && Valid(hand.Wrist)
&& !skeleton.DescendsFrom(chain[0], hand.Wrist))
issues.Add(new("wrong-wrist", $"{hand.Side} {key} must descend from its wrist.", hand.Side, chain[0]));
}
foreach (var helper in hand.TwistOrHelperBones)
{
Claim(helper, hand.Side);
var armRoot = arm[0];
if (Valid(helper) && Valid(armRoot) && !skeleton.DescendsFrom(helper, armRoot))
issues.Add(new("wrong-helper-root", $"{hand.Side} helper must belong to its mapped arm or hand hierarchy.", hand.Side, helper));
}
}
if (count == 0)
issues.Add(new("no-hands", "Map at least one wrist before retargeting."));
return issues.AsReadOnly();
bool Valid(int bone) => bone >= 0 && bone < skeleton.Count;
void Claim(int bone, HandSide side)
{
if (!Valid(bone))
issues.Add(new("invalid-bone", $"{side} mapping references a missing bone.", side, bone));
else if (!claimed.Add(bone))
issues.Add(new("overlapping-bone", $"Bone '{skeleton[bone].Name}' is assigned to multiple roles.", side, bone));
}
void CheckChain(int[] bones, HandSide side, string role, bool allowZeroLength, int? tip = null)
{
for (var i = 0; i < bones.Length; i++)
{
Claim(bones[i], side);
if (i == 0 || !Valid(bones[i - 1]) || !Valid(bones[i])) continue;
if (!skeleton.DescendsFrom(bones[i], bones[i - 1]))
issues.Add(new("chain-order", $"{side} {role} joints must follow their hierarchy.", side, bones[i]));
if (!allowZeroLength && bones[i] != tip &&
System.Numerics.Vector3.DistanceSquared(skeleton.RestWorld[bones[i - 1]].Pos,
skeleton.RestWorld[bones[i]].Pos) < 1e-12f)
issues.Add(new("zero-length-digit", $"{side} {role} has coincident joints; correct the chain or classify a helper.", side, bones[i]));
}
}
}
}
Game
library
// Reused from humanoid-retargeter 26084c96c3fc870aaf9a5bd798de063ce2fd62df; specialized where needed for hand assets.
#nullable enable annotations
using System;
using System.Collections.Generic;
using System.Numerics;
namespace HumanoidHandRetargeter.Formats.Fbx;
using Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/HumanoidHandRetargeter/Assembly.cs)
/// <summary>One <c>P</c> entry of a <c>Properties70</c> block: name, FBX type string, values.</summary>
public sealed class FbxProperty70
{
/// <summary>Property name (e.g. <c>"Lcl Translation"</c>, <c>"PreRotation"</c>, <c>"d|X"</c>).</summary>
public string Name { get; }
/// <summary>FBX type string (e.g. <c>"Lcl Translation"</c>, <c>"enum"</c>, <c>"Number"</c>).</summary>
public string Type { get; }
/// <summary>Raw values (props 4.. of the P node).</summary>
public IReadOnlyList<object> Values { get; }
internal FbxProperty70(string name, string type, IReadOnlyList<object> values)
{
Name = name;
Type = type;
Values = values;
}
/// <summary>Value <paramref name="i"/> as a double (tolerant of int/long/float storage).</summary>
public double GetDouble(int i = 0) => Convert.ToDouble(Values[i], System.Globalization.CultureInfo.InvariantCulture);
/// <summary>Value <paramref name="i"/> as an int (tolerant of long/double storage).</summary>
public int GetInt(int i = 0) => Convert.ToInt32(Values[i], System.Globalization.CultureInfo.InvariantCulture);
/// <summary>First three values as a vector.</summary>
public Vector3 GetVector3()
=> new((float)GetDouble(0), (float)GetDouble(1), (float)GetDouble(2));
}
/// <summary>
/// One object from the FBX <c>Objects</c> section (Model, NodeAttribute, AnimationStack,
/// AnimationLayer, AnimationCurveNode, AnimationCurve, Pose, ...).
/// </summary>
public sealed class FbxObject
{
/// <summary>Unique object id (the first property of the object node).</summary>
public long Id { get; }
/// <summary>Node type — the FBX node name: "Model", "AnimationCurve", ...</summary>
public string NodeType { get; }
/// <summary>Object name (namespace prefixes like <c>mixamorig1:</c> preserved).</summary>
public string Name { get; }
/// <summary>Object sub-class (third property): "LimbNode", "Null", "Root", "Mesh", ...</summary>
public string SubClass { get; }
/// <summary>The underlying token-tree node.</summary>
public FbxNode Node { get; }
/// <summary>Own Properties70 entries by name (template defaults NOT merged — see <see cref="FbxScene.FindProperty"/>).</summary>
public IReadOnlyDictionary<string, FbxProperty70> Properties { get; }
/// <summary>For Models: the parent Model via an OO connection, or null at the scene root.</summary>
public FbxObject? ModelParent { get; internal set; }
/// <summary>For Models: child Models via OO connections, in connection order.</summary>
public List<FbxObject> ModelChildren { get; } = new();
internal FbxObject(
long id, string nodeType, string name, string subClass, FbxNode node,
IReadOnlyDictionary<string, FbxProperty70> properties)
{
Id = id;
NodeType = nodeType;
Name = name;
SubClass = subClass;
Node = node;
Properties = properties;
}
/// <inheritdoc />
public override string ToString() => $"{NodeType} '{Name}' ({SubClass}) #{Id}";
}
/// <summary>A single animation curve: keyframes for one scalar channel.</summary>
public sealed class FbxAnimCurve
{
/// <summary>KTIME ticks per second (FBX constant).</summary>
public const long TicksPerSecond = 46186158000L;
/// <summary>Key times in KTIME ticks, ascending.</summary>
public long[] KeyTimes { get; }
/// <summary>Key values, parallel to <see cref="KeyTimes"/>.</summary>
public float[] KeyValues { get; }
internal FbxAnimCurve(long[] keyTimes, float[] keyValues)
{
KeyTimes = keyTimes;
KeyValues = keyValues;
}
/// <summary>
/// Samples the curve at a KTIME tick: linear interpolation between keys, constant
/// extrapolation outside the key range.
/// </summary>
public float Evaluate(long ticks)
{
var times = KeyTimes;
int n = times.Length;
if (n == 0)
return 0f;
if (ticks <= times[0])
return KeyValues[0];
if (ticks >= times[n - 1])
return KeyValues[n - 1];
int hi = Array.BinarySearch(times, ticks);
if (hi >= 0)
return KeyValues[hi];
hi = ~hi; // first index with time > ticks; >=1 and <=n-1 here
int lo = hi - 1;
double span = times[hi] - times[lo];
double t = span <= 0 ? 0.0 : (ticks - times[lo]) / span;
return (float)(KeyValues[lo] + (KeyValues[hi] - KeyValues[lo]) * t);
}
}
/// <summary>
/// An AnimationCurveNode: up to three channel curves (X/Y/Z) targeting one transform
/// property (<c>"Lcl Translation"</c> / <c>"Lcl Rotation"</c> / <c>"Lcl Scaling"</c>) of one Model.
/// </summary>
public sealed class FbxAnimCurveNode
{
/// <summary>The curve node object.</summary>
public FbxObject Object { get; }
/// <summary>Channel curves by axis ('X'/'Y'/'Z'), from <c>"d|X"</c>-style OP connections.</summary>
public Dictionary<char, FbxAnimCurve> Channels { get; } = new();
internal FbxAnimCurveNode(FbxObject obj) => Object = obj;
/// <summary>
/// Samples one component: the channel curve when connected, else the curve node's static
/// <c>d|X</c> default, else <paramref name="fallback"/> (the model's Lcl value).
/// </summary>
public float Component(char axis, long ticks, float fallback)
{
if (Channels.TryGetValue(axis, out var curve))
return curve.Evaluate(ticks);
if (Object.Properties.TryGetValue("d|" + axis, out var def) && def.Values.Count > 0)
return (float)def.GetDouble();
return fallback;
}
}
/// <summary>One AnimationStack with its curve bindings, flattened across its layers.</summary>
public sealed class FbxAnimStack
{
/// <summary>The stack object (its Name is the clip name, e.g. "mixamo.com").</summary>
public FbxObject Object { get; }
/// <summary>
/// Curve nodes bound to model transform properties:
/// (model id, property name) → curve node. When several layers animate the same property
/// the first connected layer wins (layer blending is not supported).
/// </summary>
public Dictionary<(long ModelId, string Property), FbxAnimCurveNode> Bindings { get; } = new();
/// <summary>LocalStart from the stack's Properties70, in KTIME ticks (0 when absent).</summary>
public long LocalStart { get; internal set; }
/// <summary>LocalStop from the stack's Properties70, in KTIME ticks (0 when absent).</summary>
public long LocalStop { get; internal set; }
internal FbxAnimStack(FbxObject obj) => Object = obj;
}
/// <summary>
/// Semantic object graph built from an FBX token tree: typed objects, the Model hierarchy
/// (OO connections), animation bindings (OP connections), Properties70 lookup with
/// Definitions-template defaults, bind poses, and GlobalSettings.
/// </summary>
public sealed class FbxScene
{
/// <summary>All parsed objects by id.</summary>
public IReadOnlyDictionary<long, FbxObject> ObjectsById => _objectsById;
/// <summary>All Model objects in document order.</summary>
public IReadOnlyList<FbxObject> Models => _models;
/// <summary>All animation stacks in document order.</summary>
public IReadOnlyList<FbxAnimStack> Stacks => _stacks;
/// <summary>World-space bind matrices from Pose/BindPose nodes, by model id (row-vector layout).</summary>
public IReadOnlyDictionary<long, Matrix4x4> BindPose => _bindPose;
/// <summary>GlobalSettings UnitScaleFactor: source-unit → centimeter factor (FBX default 1 = cm).</summary>
public double UnitScaleFactor { get; private set; } = 1.0;
/// <summary>GlobalSettings UpAxis (default 1 = Y).</summary>
public int UpAxis { get; private set; } = 1;
/// <summary>GlobalSettings UpAxisSign (default +1).</summary>
public int UpAxisSign { get; private set; } = 1;
/// <summary>GlobalSettings FrontAxis (default 2 = Z).</summary>
public int FrontAxis { get; private set; } = 2;
/// <summary>GlobalSettings FrontAxisSign (default +1).</summary>
public int FrontAxisSign { get; private set; } = 1;
/// <summary>GlobalSettings CoordAxis (default 0 = X).</summary>
public int CoordAxis { get; private set; } = 0;
/// <summary>GlobalSettings CoordAxisSign (default +1).</summary>
public int CoordAxisSign { get; private set; } = 1;
/// <summary>GlobalSettings OriginalUpAxis (-1 when not recorded).</summary>
public int OriginalUpAxis { get; private set; } = -1;
/// <summary>GlobalSettings TimeMode (FbxTime::EMode enum value; 0 = default mode).</summary>
public int TimeMode { get; private set; }
/// <summary>GlobalSettings CustomFrameRate (-1 when not recorded).</summary>
public double CustomFrameRate { get; private set; } = -1.0;
/// <summary>
/// Native frame rate (fps) implied by <see cref="TimeMode"/> /
/// <see cref="CustomFrameRate"/>. This is the rate the source timeline was authored at —
/// external frame ranges (e.g. Unity <c>.meta</c> clipAnimations) are expressed in it.
/// The default mode (0) and unknown values map to 30 fps (this importer's resample default).
/// </summary>
public double FrameRate => TimeModeFrameRate(TimeMode, CustomFrameRate);
/// <summary>FbxTime::EMode → frames per second (eCustom uses the custom rate).</summary>
internal static double TimeModeFrameRate(int timeMode, double customFrameRate) => timeMode switch
{
1 => 120.0, // eFrames120
2 => 100.0, // eFrames100
3 => 60.0, // eFrames60
4 => 50.0, // eFrames50
5 => 48.0, // eFrames48
6 => 30.0, // eFrames30
7 => 30.0, // eFrames30Drop
8 => 30.0 / 1.001, // eNTSCDropFrame (29.97)
9 => 30.0 / 1.001, // eNTSCFullFrame (29.97)
10 => 25.0, // ePAL
11 => 24.0, // eFrames24
12 => 1000.0, // eFrames1000
13 => 24.0 / 1.001, // eFilmFullFrame (23.976)
14 => customFrameRate > 0 ? customFrameRate : 30.0, // eCustom
15 => 96.0, // eFrames96
16 => 72.0, // eFrames72
17 => 60.0 / 1.001, // eFrames59dot94
18 => 120.0 / 1.001, // eFrames119dot88
_ => 30.0, // eDefaultMode / unknown
};
private readonly Dictionary<long, FbxObject> _objectsById = new();
private readonly List<FbxObject> _models = new();
private readonly List<FbxAnimStack> _stacks = new();
private readonly Dictionary<long, Matrix4x4> _bindPose = new();
private readonly Dictionary<string, Dictionary<string, FbxProperty70>> _templates = new();
private FbxScene()
{
}
/// <summary>
/// Looks up a property on an object: its own Properties70 first, then the Definitions
/// property template for its node type. Returns null when neither defines it.
/// </summary>
public FbxProperty70? FindProperty(FbxObject obj, string name)
{
if (obj.Properties.TryGetValue(name, out var own))
return own;
if (_templates.TryGetValue(obj.NodeType, out var template) &&
template.TryGetValue(name, out var def))
return def;
return null;
}
/// <summary>Vector property with template fallback and a hard default.</summary>
public Vector3 GetVector3(FbxObject obj, string name, Vector3 fallback)
{
var p = FindProperty(obj, name);
return p is { Values.Count: >= 3 } ? p.GetVector3() : fallback;
}
/// <summary>Double property with template fallback and a hard default.</summary>
public double GetDouble(FbxObject obj, string name, double fallback)
{
var p = FindProperty(obj, name);
return p is { Values.Count: >= 1 } ? p.GetDouble() : fallback;
}
/// <summary>Int property with template fallback and a hard default.</summary>
public int GetInt(FbxObject obj, string name, int fallback)
{
var p = FindProperty(obj, name);
return p is { Values.Count: >= 1 } ? p.GetInt() : fallback;
}
/// <summary>Builds the semantic graph from a tokenized FBX document.</summary>
public static FbxScene Build(FbxNode root)
{
ArgumentNullException.ThrowIfNull(root);
var scene = new FbxScene();
scene.ReadGlobalSettings(root.Child("GlobalSettings"));
scene.ReadTemplates(root.Child("Definitions"));
scene.ReadObjects(root.Child("Objects"));
scene.ReadConnections(root.Child("Connections"));
return scene;
}
// ------------------------------------------------------------------ objects
private void ReadObjects(FbxNode? objects)
{
if (objects is null)
return;
long syntheticId = -1000; // for exotic files whose objects lack numeric ids
foreach (var node in objects.Children)
{
// Standard 7x object layout: (id:int64, "Name\0\x01Class":string, "SubClass":string).
long id;
int nameProp;
if (node.Properties.Count >= 1 && node.Properties[0] is long or int)
{
id = node.Prop<long>(0);
nameProp = 1;
}
else
{
id = syntheticId--;
nameProp = 0;
}
string name = "", subClass = "";
if (node.Properties.Count > nameProp && node.Properties[nameProp] is string rawName)
(name, _) = FbxNode.SplitName(rawName);
if (node.Properties.Count > nameProp + 1 && node.Properties[nameProp + 1] is string sub)
subClass = sub;
var obj = new FbxObject(id, node.Name, name, subClass, node, ReadProperties70(node));
_objectsById.TryAdd(obj.Id, obj);
switch (node.Name)
{
case "Model":
_models.Add(obj);
break;
case "Pose":
if (subClass == "BindPose")
ReadBindPose(node);
break;
}
}
}
private void ReadBindPose(FbxNode pose)
{
foreach (var poseNode in pose.ChildrenNamed("PoseNode"))
{
var nodeId = poseNode.Child("Node");
var matrix = poseNode.Child("Matrix");
if (nodeId is null || matrix is null)
continue;
double[] m = matrix.AsDoubleArray(0);
if (m.Length < 16)
continue;
// FBX matrices are stored as 16 doubles with translation in elements 12..14 —
// the same memory layout as System.Numerics row-vector matrices.
_bindPose[nodeId.Prop<long>(0)] = new Matrix4x4(
(float)m[0], (float)m[1], (float)m[2], (float)m[3],
(float)m[4], (float)m[5], (float)m[6], (float)m[7],
(float)m[8], (float)m[9], (float)m[10], (float)m[11],
(float)m[12], (float)m[13], (float)m[14], (float)m[15]);
}
}
private static IReadOnlyDictionary<string, FbxProperty70> ReadProperties70(FbxNode node)
{
var result = new Dictionary<string, FbxProperty70>(StringComparer.Ordinal);
var block = node.Child("Properties70") ?? node.Child("Properties60");
if (block is null)
return result;
foreach (var p in block.ChildrenNamed("P"))
{
if (p.Properties.Count < 2 || p.Properties[0] is not string name)
continue;
string type = p.Properties[1] as string ?? "";
int valueStart = Math.Min(4, p.Properties.Count);
var values = p.Properties.GetRange(valueStart, p.Properties.Count - valueStart);
result[name] = new FbxProperty70(name, type, values);
}
return result;
}
// ------------------------------------------------------------------ templates
private void ReadTemplates(FbxNode? definitions)
{
if (definitions is null)
return;
foreach (var objectType in definitions.ChildrenNamed("ObjectType"))
{
if (objectType.Properties.Count < 1 || objectType.Properties[0] is not string typeName)
continue;
var template = objectType.Child("PropertyTemplate");
if (template is null)
continue;
_templates[typeName] = new Dictionary<string, FbxProperty70>(
(Dictionary<string, FbxProperty70>)ReadProperties70(template), StringComparer.Ordinal);
}
}
// ------------------------------------------------------------------ global settings
private void ReadGlobalSettings(FbxNode? globalSettings)
{
if (globalSettings is null)
return;
var props = ReadProperties70(globalSettings);
double D(string name, double fallback)
=> props.TryGetValue(name, out var p) && p.Values.Count > 0 ? p.GetDouble() : fallback;
int I(string name, int fallback)
=> props.TryGetValue(name, out var p) && p.Values.Count > 0 ? p.GetInt() : fallback;
UnitScaleFactor = D("UnitScaleFactor", 1.0);
UpAxis = I("UpAxis", 1);
UpAxisSign = I("UpAxisSign", 1);
FrontAxis = I("FrontAxis", 2);
FrontAxisSign = I("FrontAxisSign", 1);
CoordAxis = I("CoordAxis", 0);
CoordAxisSign = I("CoordAxisSign", 1);
OriginalUpAxis = I("OriginalUpAxis", -1);
TimeMode = I("TimeMode", 0);
CustomFrameRate = D("CustomFrameRate", -1.0);
}
// ------------------------------------------------------------------ connections
private void ReadConnections(FbxNode? connections)
{
if (connections is null)
return;
// First pass: typed object wiring that doesn't depend on other connections.
// OO child → parent: Model→Model (hierarchy), AnimationLayer→AnimationStack,
// AnimationCurveNode→AnimationLayer
// OP child → parent.property:
// AnimationCurveNode→Model ("Lcl Translation"/"Lcl Rotation"/"Lcl Scaling")
// AnimationCurve→AnimationCurveNode ("d|X"/"d|Y"/"d|Z")
var layerToStack = new Dictionary<long, FbxObject>(); // layer id → stack object
var curveNodeToLayers = new Dictionary<long, List<long>>(); // curve node id → layer ids
var curveNodeTargets = new Dictionary<long, (long ModelId, string Property)>();
var curveNodes = new Dictionary<long, FbxAnimCurveNode>();
var stacksById = new Dictionary<long, FbxAnimStack>();
foreach (var c in connections.ChildrenNamed("C"))
{
if (c.Properties.Count < 3 ||
c.Properties[0] is not string kind ||
c.Properties[1] is not (long or int) ||
c.Properties[2] is not (long or int))
continue;
long srcId = c.Prop<long>(1); // child
long dstId = c.Prop<long>(2); // parent
if (!_objectsById.TryGetValue(srcId, out var src))
continue;
_objectsById.TryGetValue(dstId, out var dst); // dst id 0 = scene root (no object)
if (kind == "OO")
{
if (src.NodeType == "Model" && dst?.NodeType == "Model")
{
if (src.ModelParent is null) // first parent wins on instancing
{
src.ModelParent = dst;
dst.ModelChildren.Add(src);
}
}
else if (src.NodeType == "AnimationLayer" && dst?.NodeType == "AnimationStack")
{
layerToStack[srcId] = dst;
}
else if (src.NodeType == "AnimationCurveNode" && dst?.NodeType == "AnimationLayer")
{
if (!curveNodeToLayers.TryGetValue(srcId, out var layers))
curveNodeToLayers[srcId] = layers = new List<long>();
layers.Add(dstId);
}
}
else if (kind == "OP" && c.Properties.Count >= 4 && c.Properties[3] is string property)
{
if (src.NodeType == "AnimationCurveNode" && dst?.NodeType == "Model")
{
curveNodeTargets[srcId] = (dstId, property);
}
else if (src.NodeType == "AnimationCurve" && dst?.NodeType == "AnimationCurveNode")
{
// Channel name "d|X" → axis 'X'.
char axis = property.Length > 0 ? property[^1] : '?';
if (axis is 'X' or 'Y' or 'Z')
{
if (!curveNodes.TryGetValue(dstId, out var cn))
curveNodes[dstId] = cn = new FbxAnimCurveNode(dst);
var curve = ReadCurve(src.Node);
if (curve is not null && !cn.Channels.ContainsKey(axis))
cn.Channels[axis] = curve;
}
}
}
}
// Second pass: assemble stacks (document order) with flattened bindings.
foreach (var obj in _objectsById.Values)
{
if (obj.NodeType != "AnimationStack")
continue;
var stack = new FbxAnimStack(obj)
{
LocalStart = ReadTimeProperty(obj, "LocalStart"),
LocalStop = ReadTimeProperty(obj, "LocalStop"),
};
stacksById[obj.Id] = stack;
_stacks.Add(stack);
}
foreach (var (curveNodeId, target) in curveNodeTargets)
{
if (!curveNodeToLayers.TryGetValue(curveNodeId, out var layerIds))
continue;
if (!curveNodes.TryGetValue(curveNodeId, out var cn))
{
// Curve node with defaults but no connected curves still drives the property.
if (!_objectsById.TryGetValue(curveNodeId, out var cnObj))
continue;
cn = new FbxAnimCurveNode(cnObj);
}
foreach (var layerId in layerIds)
{
if (!layerToStack.TryGetValue(layerId, out var stackObj) ||
!stacksById.TryGetValue(stackObj.Id, out var stack))
continue;
stack.Bindings.TryAdd((target.ModelId, target.Property), cn);
}
}
}
private long ReadTimeProperty(FbxObject obj, string name)
{
var p = FindProperty(obj, name);
if (p is null || p.Values.Count < 1)
return 0;
try
{
return Convert.ToInt64(p.Values[0], System.Globalization.CultureInfo.InvariantCulture);
}
// ArithmeticException covers OverflowException, which is not s&box-whitelisted
catch (Exception ex) when (ex is InvalidCastException or ArithmeticException or FormatException)
{
return 0;
}
}
private static FbxAnimCurve? ReadCurve(FbxNode curveNode)
{
var timesNode = curveNode.Child("KeyTime");
var valuesNode = curveNode.Child("KeyValueFloat") ?? curveNode.Child("KeyValue");
if (timesNode is null || valuesNode is null)
return null;
long[] times = timesNode.AsLongArray(0);
float[] values = valuesNode.AsFloatArray(0);
if (times.Length == 0 || times.Length != values.Length)
return null;
return new FbxAnimCurve(times, values);
}
}
Game
library
namespace HumanoidHandRetargeter.Target;
/// <summary>Small hidden mesh required by SceneModel's animation owner.
/// Cube FBX layout from ufbx's public-domain maya_cube_7400_ascii fixture:
/// https://github.com/ufbx/ufbx/blob/master/data/maya_cube_7400_ascii.fbx
/// Used under ufbx LICENSE alternative B (public domain).</summary>
public static class AnimationDriverMesh
{
public static readonly string Fbx = Text.Replace("\r\n", "\n").Replace("\n", "\r\n");
private const string Text = """
; FBX 7.4.0 project file
; ----------------------------------------------------
FBXHeaderExtension: {
FBXHeaderVersion: 1003
FBXVersion: 7400
CreationTimeStamp: {
Version: 1000
Year: 2020
Month: 3
Day: 31
Hour: 21
Minute: 36
Second: 10
Millisecond: 814
}
Creator: "FBX SDK/FBX Plugins version 2019.2"
SceneInfo: "SceneInfo::GlobalInfo", "UserData" {
Type: "UserData"
Version: 100
MetaData: {
Version: 100
Title: ""
Subject: ""
Author: ""
Keywords: ""
Revision: ""
Comment: ""
}
Properties70: {
P: "DocumentUrl", "KString", "Url", "", "animation_driver.fbx"
P: "SrcDocumentUrl", "KString", "Url", "", "animation_driver.fbx"
P: "Original", "Compound", "", ""
P: "Original|ApplicationVendor", "KString", "", "", "Autodesk"
P: "Original|ApplicationName", "KString", "", "", "Maya"
P: "Original|ApplicationVersion", "KString", "", "", "201900"
P: "Original|DateTime_GMT", "DateTime", "", "", "31/03/2020 18:36:10.811"
P: "Original|FileName", "KString", "", "", "animation_driver.fbx"
P: "LastSaved", "Compound", "", ""
P: "LastSaved|ApplicationVendor", "KString", "", "", "Autodesk"
P: "LastSaved|ApplicationName", "KString", "", "", "Maya"
P: "LastSaved|ApplicationVersion", "KString", "", "", "201900"
P: "LastSaved|DateTime_GMT", "DateTime", "", "", "31/03/2020 18:36:10.811"
P: "Original|ApplicationActiveProject", "KString", "", "", "animation_driver.fbx"
}
}
}
GlobalSettings: {
Version: 1000
Properties70: {
P: "UpAxis", "int", "Integer", "",1
P: "UpAxisSign", "int", "Integer", "",1
P: "FrontAxis", "int", "Integer", "",2
P: "FrontAxisSign", "int", "Integer", "",1
P: "CoordAxis", "int", "Integer", "",0
P: "CoordAxisSign", "int", "Integer", "",1
P: "OriginalUpAxis", "int", "Integer", "",1
P: "OriginalUpAxisSign", "int", "Integer", "",1
P: "UnitScaleFactor", "double", "Number", "",1
P: "OriginalUnitScaleFactor", "double", "Number", "",1
P: "AmbientColor", "ColorRGB", "Color", "",0,0,0
P: "DefaultCamera", "KString", "", "", "Producer Perspective"
P: "TimeMode", "enum", "", "",11
P: "TimeProtocol", "enum", "", "",2
P: "SnapOnFrameMode", "enum", "", "",0
P: "TimeSpanStart", "KTime", "Time", "",1924423250
P: "TimeSpanStop", "KTime", "Time", "",384884650000
P: "CustomFrameRate", "double", "Number", "",-1
P: "TimeMarker", "Compound", "", ""
P: "CurrentTimeMarker", "int", "Integer", "",-1
}
}
; Documents Description
;------------------------------------------------------------------
Documents: {
Count: 1
Document: 1907658701024, "", "Scene" {
Properties70: {
P: "SourceObject", "object", "", ""
P: "ActiveAnimStackName", "KString", "", "", "Take 001"
}
RootNode: 0
}
}
; Document References
;------------------------------------------------------------------
References: {
}
; Object definitions
;------------------------------------------------------------------
Definitions: {
Version: 100
Count: 6
ObjectType: "GlobalSettings" {
Count: 1
}
ObjectType: "AnimationStack" {
Count: 1
PropertyTemplate: "FbxAnimStack" {
Properties70: {
P: "Description", "KString", "", "", ""
P: "LocalStart", "KTime", "Time", "",0
P: "LocalStop", "KTime", "Time", "",0
P: "ReferenceStart", "KTime", "Time", "",0
P: "ReferenceStop", "KTime", "Time", "",0
}
}
}
ObjectType: "AnimationLayer" {
Count: 1
PropertyTemplate: "FbxAnimLayer" {
Properties70: {
P: "Weight", "Number", "", "A",100
P: "Mute", "bool", "", "",0
P: "Solo", "bool", "", "",0
P: "Lock", "bool", "", "",0
P: "Color", "ColorRGB", "Color", "",0.8,0.8,0.8
P: "BlendMode", "enum", "", "",0
P: "RotationAccumulationMode", "enum", "", "",0
P: "ScaleAccumulationMode", "enum", "", "",0
P: "BlendModeBypass", "ULongLong", "", "",0
}
}
}
ObjectType: "Geometry" {
Count: 1
PropertyTemplate: "FbxMesh" {
Properties70: {
P: "Color", "ColorRGB", "Color", "",0.8,0.8,0.8
P: "BBoxMin", "Vector3D", "Vector", "",0,0,0
P: "BBoxMax", "Vector3D", "Vector", "",0,0,0
P: "Primary Visibility", "bool", "", "",1
P: "Casts Shadows", "bool", "", "",1
P: "Receive Shadows", "bool", "", "",1
}
}
}
ObjectType: "Material" {
Count: 1
PropertyTemplate: "FbxSurfaceLambert" {
Properties70: {
P: "ShadingModel", "KString", "", "", "Lambert"
P: "MultiLayer", "bool", "", "",0
P: "EmissiveColor", "Color", "", "A",0,0,0
P: "EmissiveFactor", "Number", "", "A",1
P: "AmbientColor", "Color", "", "A",0.2,0.2,0.2
P: "AmbientFactor", "Number", "", "A",1
P: "DiffuseColor", "Color", "", "A",0.8,0.8,0.8
P: "DiffuseFactor", "Number", "", "A",1
P: "Bump", "Vector3D", "Vector", "",0,0,0
P: "NormalMap", "Vector3D", "Vector", "",0,0,0
P: "BumpFactor", "double", "Number", "",1
P: "TransparentColor", "Color", "", "A",0,0,0
P: "TransparencyFactor", "Number", "", "A",0
P: "DisplacementColor", "ColorRGB", "Color", "",0,0,0
P: "DisplacementFactor", "double", "Number", "",1
P: "VectorDisplacementColor", "ColorRGB", "Color", "",0,0,0
P: "VectorDisplacementFactor", "double", "Number", "",1
}
}
}
ObjectType: "Model" {
Count: 1
PropertyTemplate: "FbxNode" {
Properties70: {
P: "QuaternionInterpolate", "enum", "", "",0
P: "RotationOffset", "Vector3D", "Vector", "",0,0,0
P: "RotationPivot", "Vector3D", "Vector", "",0,0,0
P: "ScalingOffset", "Vector3D", "Vector", "",0,0,0
P: "ScalingPivot", "Vector3D", "Vector", "",0,0,0
P: "TranslationActive", "bool", "", "",0
P: "TranslationMin", "Vector3D", "Vector", "",0,0,0
P: "TranslationMax", "Vector3D", "Vector", "",0,0,0
P: "TranslationMinX", "bool", "", "",0
P: "TranslationMinY", "bool", "", "",0
P: "TranslationMinZ", "bool", "", "",0
P: "TranslationMaxX", "bool", "", "",0
P: "TranslationMaxY", "bool", "", "",0
P: "TranslationMaxZ", "bool", "", "",0
P: "RotationOrder", "enum", "", "",0
P: "RotationSpaceForLimitOnly", "bool", "", "",0
P: "RotationStiffnessX", "double", "Number", "",0
P: "RotationStiffnessY", "double", "Number", "",0
P: "RotationStiffnessZ", "double", "Number", "",0
P: "AxisLen", "double", "Number", "",10
P: "PreRotation", "Vector3D", "Vector", "",0,0,0
P: "PostRotation", "Vector3D", "Vector", "",0,0,0
P: "RotationActive", "bool", "", "",0
P: "RotationMin", "Vector3D", "Vector", "",0,0,0
P: "RotationMax", "Vector3D", "Vector", "",0,0,0
P: "RotationMinX", "bool", "", "",0
P: "RotationMinY", "bool", "", "",0
P: "RotationMinZ", "bool", "", "",0
P: "RotationMaxX", "bool", "", "",0
P: "RotationMaxY", "bool", "", "",0
P: "RotationMaxZ", "bool", "", "",0
P: "InheritType", "enum", "", "",0
P: "ScalingActive", "bool", "", "",0
P: "ScalingMin", "Vector3D", "Vector", "",0,0,0
P: "ScalingMax", "Vector3D", "Vector", "",1,1,1
P: "ScalingMinX", "bool", "", "",0
P: "ScalingMinY", "bool", "", "",0
P: "ScalingMinZ", "bool", "", "",0
P: "ScalingMaxX", "bool", "", "",0
P: "ScalingMaxY", "bool", "", "",0
P: "ScalingMaxZ", "bool", "", "",0
P: "GeometricTranslation", "Vector3D", "Vector", "",0,0,0
P: "GeometricRotation", "Vector3D", "Vector", "",0,0,0
P: "GeometricScaling", "Vector3D", "Vector", "",1,1,1
P: "MinDampRangeX", "double", "Number", "",0
P: "MinDampRangeY", "double", "Number", "",0
P: "MinDampRangeZ", "double", "Number", "",0
P: "MaxDampRangeX", "double", "Number", "",0
P: "MaxDampRangeY", "double", "Number", "",0
P: "MaxDampRangeZ", "double", "Number", "",0
P: "MinDampStrengthX", "double", "Number", "",0
P: "MinDampStrengthY", "double", "Number", "",0
P: "MinDampStrengthZ", "double", "Number", "",0
P: "MaxDampStrengthX", "double", "Number", "",0
P: "MaxDampStrengthY", "double", "Number", "",0
P: "MaxDampStrengthZ", "double", "Number", "",0
P: "PreferedAngleX", "double", "Number", "",0
P: "PreferedAngleY", "double", "Number", "",0
P: "PreferedAngleZ", "double", "Number", "",0
P: "LookAtProperty", "object", "", ""
P: "UpVectorProperty", "object", "", ""
P: "Show", "bool", "", "",1
P: "NegativePercentShapeSupport", "bool", "", "",1
P: "DefaultAttributeIndex", "int", "Integer", "",-1
P: "Freeze", "bool", "", "",0
P: "LODBox", "bool", "", "",0
P: "Lcl Translation", "Lcl Translation", "", "A",0,0,0
P: "Lcl Rotation", "Lcl Rotation", "", "A",0,0,0
P: "Lcl Scaling", "Lcl Scaling", "", "A",1,1,1
P: "Visibility", "Visibility", "", "A",1
P: "Visibility Inheritance", "Visibility Inheritance", "", "",1
}
}
}
}
; Object properties
;------------------------------------------------------------------
Objects: {
Geometry: 1907663073408, "Geometry::", "Mesh" {
Vertices: *24 {
a: -0.5,-0.5,0.5,0.5,-0.5,0.5,-0.5,0.5,0.5,0.5,0.5,0.5,-0.5,0.5,-0.5,0.5,0.5,-0.5,-0.5,-0.5,-0.5,0.5,-0.5,-0.5
}
PolygonVertexIndex: *24 {
a: 0,1,3,-3,2,3,5,-5,4,5,7,-7,6,7,1,-1,1,7,5,-4,6,0,2,-5
}
Edges: *12 {
a: 0,2,6,10,3,1,7,5,11,9,15,13
}
GeometryVersion: 124
LayerElementNormal: 0 {
Version: 102
Name: ""
MappingInformationType: "ByPolygonVertex"
ReferenceInformationType: "Direct"
Normals: *72 {
a: 0,0,1,0,0,1,0,0,1,0,0,1,0,1,0,0,1,0,0,1,0,0,1,0,0,0,-1,0,0,-1,0,0,-1,0,0,-1,0,-1,0,0,-1,0,0,-1,0,0,-1,0,1,0,0,1,0,0,1,0,0,1,0,0,-1,0,0,-1,0,0,-1,0,0,-1,0,0
}
NormalsW: *24 {
a: 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
}
}
LayerElementBinormal: 0 {
Version: 102
Name: "map1"
MappingInformationType: "ByPolygonVertex"
ReferenceInformationType: "Direct"
Binormals: *72 {
a: 0,1,-0,0,1,-0,0,1,-0,0,1,-0,0,0,-1,0,0,-1,0,0,-1,0,0,-1,0,-1,0,0,-1,0,0,-1,0,0,-1,0,0,0,1,0,0,1,0,0,1,0,0,1,-0,1,0,-0,1,0,0,1,-0,-0,1,0,0,1,0,0,1,0,0,1,0,0,1,0
}
BinormalsW: *24 {
a: 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
}
}
LayerElementTangent: 0 {
Version: 102
Name: "map1"
MappingInformationType: "ByPolygonVertex"
ReferenceInformationType: "Direct"
Tangents: *72 {
a: 1,-0,-0,1,-0,0,1,-0,0,1,-0,0,1,-0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,-0,1,0,-0,1,0,-0,1,0,-0,0,0,-1,0,0,-1,0,-0,-1,0,0,-1,0,-0,1,0,-0,1,0,-0,1,0,-0,1
}
TangentsW: *24 {
a: 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
}
}
LayerElementUV: 0 {
Version: 101
Name: "map1"
MappingInformationType: "ByPolygonVertex"
ReferenceInformationType: "IndexToDirect"
UV: *28 {
a: 0.375,0,0.625,0,0.375,0.25,0.625,0.25,0.375,0.5,0.625,0.5,0.375,0.75,0.625,0.75,0.375,1,0.625,1,0.875,0,0.875,0.25,0.125,0,0.125,0.25
}
UVIndex: *24 {
a: 0,1,3,2,2,3,5,4,4,5,7,6,6,7,9,8,1,10,11,3,12,0,2,13
}
}
LayerElementSmoothing: 0 {
Version: 102
Name: ""
MappingInformationType: "ByEdge"
ReferenceInformationType: "Direct"
Smoothing: *12 {
a: 0,0,0,0,0,0,0,0,0,0,0,0
}
}
LayerElementMaterial: 0 {
Version: 101
Name: ""
MappingInformationType: "AllSame"
ReferenceInformationType: "IndexToDirect"
Materials: *1 {
a: 0
}
}
Layer: 0 {
Version: 100
LayerElement: {
Type: "LayerElementNormal"
TypedIndex: 0
}
LayerElement: {
Type: "LayerElementBinormal"
TypedIndex: 0
}
LayerElement: {
Type: "LayerElementTangent"
TypedIndex: 0
}
LayerElement: {
Type: "LayerElementMaterial"
TypedIndex: 0
}
LayerElement: {
Type: "LayerElementSmoothing"
TypedIndex: 0
}
LayerElement: {
Type: "LayerElementUV"
TypedIndex: 0
}
}
}
Model: 1907292236800, "Model::animation_driver", "Mesh" {
Version: 232
Properties70: {
P: "RotationActive", "bool", "", "",1
P: "InheritType", "enum", "", "",1
P: "ScalingMax", "Vector3D", "Vector", "",0,0,0
P: "DefaultAttributeIndex", "int", "Integer", "",0
P: "currentUVSet", "KString", "", "U", "map1"
}
Shading: T
Culling: "CullingOff"
}
Material: 1908464858960, "Material::lambert1", "" {
Version: 102
ShadingModel: "lambert"
MultiLayer: 0
Properties70: {
P: "AmbientColor", "Color", "", "A",0,0,0
P: "DiffuseColor", "Color", "", "A",0.5,0.5,0.5
P: "DiffuseFactor", "Number", "", "A",0.800000011920929
P: "TransparencyFactor", "Number", "", "A",1
P: "Emissive", "Vector3D", "Vector", "",0,0,0
P: "Ambient", "Vector3D", "Vector", "",0,0,0
P: "Diffuse", "Vector3D", "Vector", "",0.400000005960464,0.400000005960464,0.400000005960464
P: "Opacity", "double", "Number", "",1
}
}
AnimationStack: 1907674289488, "AnimStack::Take 001", "" {
Properties70: {
P: "LocalStart", "KTime", "Time", "",1924423250
P: "LocalStop", "KTime", "Time", "",230930790000
P: "ReferenceStart", "KTime", "Time", "",1924423250
P: "ReferenceStop", "KTime", "Time", "",230930790000
}
}
AnimationLayer: 1907631731008, "AnimLayer::BaseLayer", "" {
}
}
; Object connections
;------------------------------------------------------------------
Connections: {
;Model::animation_driver, Model::RootNode
C: "OO",1907292236800,0
;AnimLayer::BaseLayer, AnimStack::Take 001
C: "OO",1907631731008,1907674289488
;Geometry::, Model::animation_driver
C: "OO",1907663073408,1907292236800
;Material::lambert1, Model::animation_driver
C: "OO",1908464858960,1907292236800
}
;Takes section
;----------------------------------------------------
Takes: {
Current: "Take 001"
Take: "Take 001" {
FileName: "Take_001.tak"
LocalTime: 1924423250,230930790000
ReferenceTime: 1924423250,230930790000
}
}
""";
}
Game
library
#nullable enable
namespace HumanoidHandRetargeter.Target;
/// <summary>Minimal hand ModelDoc using the audited VmdlWriter mesh and scaling node shapes.</summary>
public static class HandModelFactory
{
public const string Header = "<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} format:modeldoc30:version{8c2d7a91-9c42-4bf0-883a-5a3b1762d4f1} -->";
public static string Create(string baseModel = "", string mesh = "", float meshUnitScaleCm = 1, IReadOnlyDictionary<string,string>? materialRemaps = null, IReadOnlyList<string>? meshNames = null)
{
if (!float.IsFinite(meshUnitScaleCm) || meshUnitScaleCm <= 0) throw new ArgumentOutOfRangeException(nameof(meshUnitScaleCm));
if (string.IsNullOrWhiteSpace(baseModel) == string.IsNullOrWhiteSpace(mesh)) throw new ArgumentException("Choose one base model or mesh source.");
var children = new KvArray();
if (mesh.Length > 0)
{
mesh = VmdlSetupService.NormalizeAssetPath(mesh, ".fbx");
var meshes = new KvArray();
meshes.Items.Add(new KvObject { ["_class"] = new KvString("RenderMeshFile"), ["name"] = new KvString("hands_mesh"),
["filename"] = new KvString(mesh), ["import_scale"] = new KvDouble(meshUnitScaleCm),
["import_translation"] = Vector(0,0,0), ["import_rotation"] = Vector(0,0,0),
["align_origin_x_type"] = new KvString("None"), ["align_origin_y_type"] = new KvString("None"), ["align_origin_z_type"] = new KvString("None") });
if(meshNames is {Count:>0})
{
var names=new KvArray();foreach(var name in meshNames)names.Items.Add(new KvString(name));
((KvObject)meshes.Items[0])["import_filter"]=new KvObject
{["exclude_by_default"]=new KvBool(true),["exception_list"]=names};
}
children.Items.Add(new KvObject { ["_class"] = new KvString("RenderMeshList"), ["children"] = meshes });
children.Items.Add(new KvObject { ["_class"] = new KvString("BoneMarkupList"), ["bone_cull_type"] = new KvString("None"), ["children"] = new KvArray() });
var remaps=new KvArray();
if(materialRemaps is not null)foreach(var pair in materialRemaps)remaps.Items.Add(new KvObject{["from"]=new KvString(pair.Key),["to"]=new KvString(pair.Value)});
var materials = new KvArray();
materials.Items.Add(new KvObject { ["_class"] = new KvString("DefaultMaterialGroup"), ["use_global_default"] = new KvBool(materialRemaps is null || materialRemaps.Count==0),
["global_default_material"] = new KvString("materials/dev/reflectivity_50.vmat"), ["remaps"] = remaps });
children.Items.Add(new KvObject { ["_class"] = new KvString("MaterialGroupList"), ["children"] = materials });
}
else baseModel = VmdlSetupService.NormalizeAssetPath(baseModel, ".vmdl");
var modifiers = new KvArray();
modifiers.Items.Add(new KvObject { ["_class"] = new KvString("ModelModifier_ScaleAndMirror"), ["scale"] = new KvDouble(1.0 / 2.54),
["mirror_x"] = new KvBool(false), ["mirror_y"] = new KvBool(false), ["mirror_z"] = new KvBool(false),
["flip_bone_forward"] = new KvBool(false), ["swap_left_and_right_bones"] = new KvBool(false) });
children.Items.Add(new KvObject { ["_class"] = new KvString("ModelModifierList"), ["children"] = modifiers });
var root = new KvObject { ["_class"] = new KvString("RootNode"), ["children"] = children, ["base_model_name"] = new KvString(baseModel),
["anim_graph_name"] = new KvString(""), ["model_archetype"] = new KvString(""), ["primary_associated_entity"] = new KvString("") };
return Kv3.Serialize(new Kv3Document(Header, new KvObject { ["rootNode"] = root }));
}
private static KvArray Vector(params double[] values) { var array = new KvArray(); foreach (var value in values) array.Items.Add(new KvDouble(value)); return array; }
}
Editor
library
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;
namespace HumanoidHandRetargeter.Editor;
/// <summary>Legacy PreviewDialog layout, with Third Person/FPS camera controls.</summary>
public sealed class HandPreviewDialog : Dialog
{
public HandPreviewWidget Preview {get;}
public Action? Confirmed {get;set;}
public HandPreviewDialog(Widget? parent,HandTarget target,IReadOnlyList<HandBakedClip> clips) : base(parent)
{
Window.WindowTitle="Preview - "+System.IO.Path.GetFileName(target.ModelPath);Window.SetWindowIcon("preview");Window.SetModal(true,true);
Window.MinimumWidth=560;Window.MinimumHeight=560;Window.Size=new Vector2(760,780);
Layout=Layout.Column();Layout.Margin=12;Layout.Spacing=8;
Preview=Layout.Add(new HandPreviewWidget(this,target),1);
var error=Layout.Add(new Label(this){WordWrap=true,Visible=false});error.SetStyles($"color:{Theme.Red.Hex};");
var modeRow=Layout.AddRow();modeRow.Spacing=8;
var modes=modeRow.Add(new ComboBox(this){MinimumWidth=150});
modes.AddItem("Third Person","3d_rotation",()=>Preview.Mode=HandPreviewMode.ThirdPerson,selected:true);
modes.AddItem("FPS","first_page",()=>Preview.Mode=HandPreviewMode.Fps);
modeRow.Add(new Button("Reset camera","center_focus_strong"){Clicked=Preview.ResetCamera});
modeRow.Add(new Button("−"){Clicked=()=>Preview.Zoom(1.2f)});modeRow.Add(new Button("+"){Clicked=()=>Preview.Zoom(.8f)});
modeRow.Add(new Button("Weapon model…","view_in_ar"){Clicked=()=>{var picker=AssetPicker.Create(this,AssetType.Model);picker.OnAssetPicked=assets=>{var a=assets.FirstOrDefault();if(a is not null){try{Preview.SetWeapon(a.Path);error.Visible=false;}catch(Exception ex){error.Text=ex.Message;error.Visible=true;}}};picker.Show();}});
var cameraRow=Layout.AddRow();cameraRow.Spacing=6;
var authored=cameraRow.Add(new Checkbox("Authored camera motion"){Value=true});authored.Clicked=()=>Preview.UseAuthoredCamera=authored.Value;
cameraRow.Add(new Label(this){Text="FOV:"});var fov=cameraRow.Add(new LineEdit(this){Text="75",FixedWidth=42});
cameraRow.Add(new Label(this){Text="FPS offset X / Y / Z:"});var offsets=Enumerable.Range(0,3).Select(_=>cameraRow.Add(new LineEdit(this){Text="0",FixedWidth=42})).ToArray();
cameraRow.Add(new Button("Apply"){Clicked=()=>{if(float.TryParse(fov.Text,out var f)&&float.IsFinite(f))Preview.FpsFov=f;var values=offsets.Select(e=>float.TryParse(e.Text,out var v)&&float.IsFinite(v)?v:0).ToArray();Preview.FpsOffset=new Vector3(values[0],values[1],values[2]);Preview.ApplyCurrentFrame();}});
var clipRow=Layout.AddRow();clipRow.Spacing=8;clipRow.Add(new Label(this){Text="Clip:"});var combo=clipRow.Add(new ComboBox(this){MinimumWidth=220},1);
for(var i=0;i<clips.Count;i++){var clip=clips[i];combo.AddItem(clip.Baked.Name,"movie",()=>Preview.SetClip(clip),selected:i==0);}
var transport=Layout.AddRow();transport.Spacing=8;
var play=transport.Add(new Button("","pause"){FixedWidth=28,FixedHeight=24});play.Clicked=()=>{Preview.Playing=!Preview.Playing;play.Icon=Preview.Playing?"pause":"play_arrow";};
var slider=transport.Add(new FloatSlider(this),1);slider.Minimum=0;slider.OnValueEdited=()=>Preview.Scrub((int)slider.Value);
var label=transport.Add(new Label(this){Text="0 / 0",FixedWidth=80});
var ghost=transport.Add(new Button("Show source","compare"){IsToggle=true,FixedHeight=24});ghost.Clicked=()=>Preview.ShowSource=ghost.IsChecked;
var skeleton=transport.Add(new Button("Skeleton","polyline"){IsToggle=true,FixedHeight=24});skeleton.Clicked=()=>Preview.SkeletonOnly=skeleton.IsChecked;
Preview.FrameChanged=frame=>{if(modes.CurrentIndex!=(int)Preview.Mode)modes.CurrentIndex=(int)Preview.Mode;play.Icon=Preview.Playing?"pause":"play_arrow";slider.Maximum=Math.Max(0,Preview.FrameCount-1);slider.Value=frame;label.Text=$"{frame+1} / {Preview.FrameCount}";authored.Enabled=Preview.HasAuthoredCamera;};
var footer=Layout.AddRow();footer.Spacing=8;footer.AddStretchCell();footer.Add(new Button("Cancel"){Clicked=Close});
footer.Add(new Button.Primary("Looks good - Convert"){Icon="check",Tint=Theme.Green,Clicked=()=>{Confirmed?.Invoke();Close();}});
if(clips.Count>0)Preview.SetClip(clips[0]);
}
}
Game
library
// Tokenization reused from humanoid-retargeter 26084c96c3fc870aaf9a5bd798de063ce2fd62df.
using System.Text;
namespace HumanoidHandRetargeter.Mapping;
internal static class BoneNameTokens
{
internal static List<string> Tokenize(string name)
{
var tokens = new List<string>();
var current = new StringBuilder();
char previous = '\0';
for (var i = 0; i < name.Length; i++)
{
var c = name[i];
if (!char.IsLetterOrDigit(c))
{
Flush();
previous = '\0';
continue;
}
if (current.Length > 0)
{
var boundary = char.IsDigit(c) != char.IsDigit(previous)
|| (char.IsUpper(c) && char.IsLower(previous))
// Acronym/single-side prefix before a Pascal word: LThumb → L, Thumb;
// FBXNode → FBX, Node. Without this, one-joint BVH thumbs lose both
// their side and their otherwise unambiguous finger name.
|| (char.IsUpper(c) && char.IsUpper(previous)
&& i + 1 < name.Length && char.IsLower(name[i + 1]));
if (boundary)
Flush();
}
current.Append(char.ToLowerInvariant(c));
previous = c;
}
Flush();
return tokens;
void Flush()
{
if (current.Length > 0)
{
tokens.Add(current.ToString());
current.Clear();
}
}
}
}
Game
library
#nullable enable
using Vec = System.Numerics.Vector3;
using Skel = HumanoidHandRetargeter.Skeleton.Skeleton;
namespace HumanoidHandRetargeter.Mapping;
internal static partial class HandDetectionProfiles
{
// Split biceps/forearms, a wrist-to-palm link, and five palm-rooted rays.
// Numeric/duplicate suffixes identify objects, not sides or anatomical fingers.
private static void ResolveUnlabelledSplitArms(Skel rig,Hint?[] hints)
{
string Name(int i)
{
var name=rig[i].Name[(rig[i].Name.LastIndexOf(':')+1)..];
return Key(name.Split('#')[0].Replace("_duplicate","",StringComparison.OrdinalIgnoreCase)).TrimEnd("0123456789".ToCharArray());
}
int Child(int i,string name)=>rig.ChildrenOf(i).Count==1&&Name(rig.ChildrenOf(i)[0])==name?rig.ChildrenOf(i)[0]:-1;
var proposals=new List<(HandSide Side,Dictionary<int,string> Roles)>();
foreach(var upper in rig.Bones.Where(b=>Name(b.Index) is "bisep" or "bicep" or "biceps"))
{
var upperSplit=Child(upper.Index,Name(upper.Index));if(upperSplit<0)continue;
var lower=Child(upperSplit,"forearm");if(lower<0)continue;
var lowerSplit=Child(lower,"forearm");if(lowerSplit<0)continue;
var wrist=Child(lowerSplit,"wrist");if(wrist<0)continue;
var palm=Child(wrist,"hand");if(palm<0||rig.ChildrenOf(palm).Count!=5)continue;
var rays=new List<int[]>();
foreach(var root in rig.ChildrenOf(palm))
{
var ray=new List<int>{root};var current=root;
while(rig.ChildrenOf(current).Count==1){current=rig.ChildrenOf(current)[0];ray.Add(current);}
if(rig.ChildrenOf(current).Count!=0){rays.Clear();break;}
if(Name(ray[^1]).Contains("end",StringComparison.Ordinal))ray.RemoveAt(ray.Count-1);
rays.Add(ray.ToArray());
}
if(rays.Count!=5)continue;
var thumbs=rays.Where(r=>r.Length==3&&Name(r[0]) is "thum" or "thumb").ToArray();
var fingers=rays.Where(r=>r.Length==4&&Name(r[0])=="finger").ToArray();
if(thumbs.Length!=1||fingers.Length!=4)continue;
var thumb=thumbs[0];
Vec Pos(int i)=>rig.RestWorld[i].Pos;
var ordered=fingers.OrderBy(r=>Vec.DistanceSquared(Pos(r[1]),Pos(thumb[1]))).ToArray();
var radial=Pos(ordered[0][1])-Pos(ordered[3][1]);
if(radial.LengthSquared()<1e-8f)continue;
radial=Vec.Normalize(radial);
// Require four distinctly ordered knuckles, not just a nearest-thumb guess.
var spacing=ordered.Select(r=>Vec.Dot(Pos(r[1])-Pos(ordered[3][1]),radial)).ToArray();
if(Enumerable.Range(0,3).Any(i=>spacing[i]-spacing[i+1]<spacing[0]*.1f))continue;
var forward=ordered.Select(r=>Pos(r[3])-Pos(r[1])).Aggregate(Vec.Zero,(a,b)=>a+b);
var normal=Vec.Cross(forward,radial);var thumbBend=Pos(thumb[2])-Pos(thumb[1]);
if(normal.LengthSquared()<1e-8f||thumbBend.LengthSquared()<1e-8f)continue;
var chirality=Vec.Dot(Vec.Normalize(normal),Vec.Normalize(thumbBend));
// An out-of-plane thumb bend supplies the palmar direction. Flat or
// ambiguous geometry remains for review rather than guessing a side.
if(MathF.Abs(chirality)<.25f)continue;
var side=chirality>0?HandSide.Left:HandSide.Right;
var roles=new Dictionary<int,string>{{upper.Index,"UpperArm"},{lower,"LowerArm"},{wrist,"Hand"},{palm,"PalmMeta"}};
var digitNames=new[]{"Index","Middle","Ring","Pinky"};
for(var digit=0;digit<4;digit++)
for(var joint=0;joint<4;joint++)roles[ordered[digit][joint]]=digitNames[digit]+(joint==0?"Meta":"Prox");
foreach(var bone in thumb)roles[bone]="ThumbProx";
if(roles.Keys.Any(i=>hints[i] is not null))continue;
proposals.Add((side,roles));
}
// Require a complete, disjoint bilateral pair before using unnamed sides.
if(proposals.Count!=2||proposals[0].Side==proposals[1].Side||proposals[0].Roles.Keys.Intersect(proposals[1].Roles.Keys).Any())return;
foreach(var proposal in proposals)
foreach(var role in proposal.Roles)hints[role.Key]=new(role.Value,proposal.Side,"split_arm_palm_rays");
}
}
Editor
library
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using Editor;
using Sandbox;
using HumanoidHandRetargeter.Calibration;
using HumanoidHandRetargeter.Formats.Dmx;
using HumanoidHandRetargeter.Formats.Fbx;
using HumanoidHandRetargeter.Mapping;
using HumanoidHandRetargeter.Maths;
using HumanoidHandRetargeter.Retargeting;
using HumanoidHandRetargeter.Skeleton;
using HumanoidHandRetargeter.Target;
using Skel = HumanoidHandRetargeter.Skeleton.Skeleton;
using Vec = System.Numerics.Vector3;
using Quat = System.Numerics.Quaternion;
namespace HumanoidHandRetargeter.Editor;
public sealed class HandSource
{
public required string Path { get; init; }
public required SourceScene Scene { get; init; }
public required HandMappingResult Mapping { get; set; }
public string? ModelPath { get; init; }
public IReadOnlyDictionary<HandSide, Quat>? PalmFrames { get; set; }
}
public sealed class HandTarget
{
public IReadOnlyList<string> ImportNotes { get; init; } = Array.Empty<string>();
public required string ModelPath { get; init; }
public required Skel Skeleton { get; init; }
public required HandMappingResult Mapping { get; set; }
public IReadOnlyDictionary<HandSide, Quat>? PalmFrames { get; set; }
}
public sealed record HandExportResult(string ModelPath,IReadOnlyList<string> Changes,string? BackupPath,IReadOnlyList<string>? WeaponPrefabs=null);
public sealed record HandBakedClip(HandSource Source, Clip Original, Clip Baked, IReadOnlyList<string> Notes,Vec? WeaponOffset=null,HandMotionOptions? MotionOptions=null);
/// <summary>Editor adapter over the pure importer, shared bake and transactional setup.
/// Engine calls use the same main-thread dispatch convention as the audited EditorPipeline.</summary>
public static class HandEditorPipeline
{
public const string HumanArms = "models/first_person/v_first_person_arms_human.vmdl";
public const string CitizenArms = "models/first_person/v_first_person_arms_citizen.vmdl";
public static string Assets => Project.Current?.GetAssetsPath() ?? throw new InvalidOperationException("Open an s&box project first.");
public static MainThreadAwaitable MainThread() => default;
public readonly struct MainThreadAwaitable : INotifyCompletion
{
public MainThreadAwaitable GetAwaiter() => this;
public bool IsCompleted => ThreadSafe.IsMainThread;
public void OnCompleted(Action continuation) => Sandbox.MainThread.Queue(continuation);
public void GetResult() { }
}
public static Transform ToEngine(XForm value)
=> new(new Vector3(value.Pos.X, value.Pos.Y, value.Pos.Z) / 2.54f, new Rotation(value.Rot.X,value.Rot.Y,value.Rot.Z,value.Rot.W));
public static XForm FromEngine(Transform value)
=> new(new Vec(value.Position.x,value.Position.y,value.Position.z)*2.54f, new Quat(value.Rotation.x,value.Rotation.y,value.Rotation.z,value.Rotation.w));
public static Skel ReadSkeleton(Model model)
{
return Skel.Create(model.Bones.AllBones.Select(b => {
var world = FromEngine(b.LocalTransform);
return new BoneDefinition(b.Name,b.Parent?.Name,b.Parent is null ? world : XForm.ToLocal(FromEngine(b.Parent.LocalTransform),world));
}).ToArray());
}
public static async Task<HandTarget> LoadTargetAsync(string path, CancellationToken cancel = default)
{
await MainThread();
var importNotes=new List<string>();
if (path.EndsWith(".fbx",StringComparison.OrdinalIgnoreCase)) path = await PrepareMeshAsync(path,cancel,importNotes);
path = ModelPath(path);
var model = Model.Load(path);
if (model is null || model.IsError || model.BoneCount == 0) throw new InvalidOperationException($"Cannot load a skinned model from '{path}'.");
var skeleton = ReadSkeleton(model);
return new HandTarget { ImportNotes=importNotes, ModelPath=path, Skeleton=skeleton, PalmFrames=HandPresetStore.LoadPalms(skeleton), Mapping=HandPresetStore.Load(skeleton) ?? HandRigDetector.Detect(skeleton) };
}
public static async Task<HandSource> LoadSourceAsync(string path, float fps = 30, CancellationToken cancel = default)
{
if (path.EndsWith(".fbx",StringComparison.OrdinalIgnoreCase))
{
var native = await Task.Run(()=>FbxImporter.Import(File.ReadAllBytes(path),new FbxImportOptions {SampleFps=fps}),cancel);
await MainThread();
// Share the target's X-forward/Z-up view space so the source grip, eye and weapon
// motion transfer directly, whatever axes or facing the file was authored in.
var nativeMapping=HandPresetStore.Load(native.Skeleton);
var axesMapping=nativeMapping ?? HandRigDetector.Detect(native.Skeleton);
var turn=HandViewSpace.ViewRotation(native,axesMapping);
var scene=HandViewSpace.ToViewAxes(native,axesMapping);
// Mappings are index based; palm frames saved against the native axes turn with the rig.
var palms=HandPresetStore.LoadPalms(scene.Skeleton) ?? HandPresetStore.LoadPalms(native.Skeleton)?.ToDictionary(p=>p.Key,p=>Quat.Normalize(turn*p.Value));
return new() { Path=path, Scene=scene, PalmFrames=palms, Mapping=HandPresetStore.Load(scene.Skeleton) ?? nativeMapping ?? HandRigDetector.Detect(scene.Skeleton) };
}
await MainThread();
path = ModelPath(path);
var model = Model.Load(path);
if (model is null || model.IsError) throw new InvalidOperationException($"Cannot load source model '{path}'.");
var skeleton = ReadSkeleton(model);
var clips = new List<Clip>();
using var sampling = new ModelSampler(model,skeleton);
foreach (var name in model.AnimationNames.ToArray())
{
cancel.ThrowIfCancellationRequested();
clips.Add(sampling.Sample(name,fps,cancel));
await Task.Delay(1,cancel); await MainThread();
}
// Facepunch weapons author recoil as additive layers. Capture their complete firing
// pose through the original weapon graph instead of retargeting a delta as a full pose.
if(skeleton.IndexOf("weapon_root")>=0&&!clips.Any(c=>WeaponAnimGraph.Classify(c.Name)==WeaponAction.Fire)
&&clips.FirstOrDefault(c=>c.Name.StartsWith("Fire_",StringComparison.OrdinalIgnoreCase)&&c.Name.EndsWith("_delta",StringComparison.OrdinalIgnoreCase)
&&!c.Name.Contains("Hold",StringComparison.OrdinalIgnoreCase)&&!c.Name.Contains("Dry",StringComparison.OrdinalIgnoreCase)) is {} recoil)
{
var firing=sampling.SampleFire(Math.Max(.25f,(recoil.FrameCount-1)/recoil.Fps),fps,cancel);
if(firing is not null)clips.Add(firing);
}
return new() { Path=path,ModelPath=path, Scene=new SourceScene(skeleton,clips,2.54f,upAxis:2,frontAxis:0,coordAxis:1),
PalmFrames=HandPresetStore.LoadPalms(skeleton), Mapping=HandPresetStore.Load(skeleton) ?? HandRigDetector.Detect(skeleton) };
}
public static HandRetargetProfile Calibrate(HandSource source,HandTarget target)
=> HandRetargetProfile.Calibrate(source.Scene.Skeleton,source.Mapping,target.Skeleton,target.Mapping,source.PalmFrames,target.PalmFrames);
public static HandBakedClip Bake(HandSource source, Clip clip, HandTarget target, HandMotionOptions options, CancellationToken cancel = default, HandRetargetProfile? profile = null)
{
profile ??= Calibrate(source,target);
// Compiled s&box models already share X-forward/Z-up coordinates. A wrist's
// bind rotation must not turn vertical reload travel into sideways/downward motion.
var weaponOffset=options.WeaponSpaceOffset;
// FBX sources are loaded in view space, so every weapon file (named weapon bones or not)
// keeps both palms locked to the source grip relative to the eye.
var viewSpace=source.ModelPath is not null||source.Scene.UpAxis==2&&source.Scene.FrontAxis==0;
if(options.PreserveWeaponGrip&&options.TransferWristPosition&&(source.ModelPath is null&&viewSpace
||source.Scene.Skeleton.Bones.Any(b=>b.Name.Equals("weapon_root",StringComparison.OrdinalIgnoreCase))))
weaponOffset ??= HandViewSpace.EyePosition(target.Skeleton,target.Mapping)-HandViewSpace.EyePosition(source.Scene.Skeleton,source.Mapping);
options=new HandMotionOptions{TransferWristPosition=options.TransferWristPosition,ScaleWristTravel=options.ScaleWristTravel,
SolveArmIk=options.SolveArmIk,PreserveWeaponGrip=options.PreserveWeaponGrip,WeaponSpaceOffset=weaponOffset,
WristTravelBasis=options.WristTravelBasis??(viewSpace?Quat.Identity:null)};
var notes=weaponOffset.HasValue?profile.Notes.Concat(new[]{"Preserved both palm grip anchors in one fixed weapon space; weapon motion is not scaled per arm."}).ToArray():profile.Notes;
return new(source,clip,HandRetargeter.Bake(profile,clip,options,cancel),notes,weaponOffset,options);
}
public static async Task<string> ExportAsync(HandTarget target,IReadOnlyList<HandBakedClip> clips,string outputModel,
bool autoGraph,bool backup,bool weaponCompatible,bool preserveSourceTracks,CancellationToken cancel=default)
=> (await ExportWithReportAsync(target,clips,outputModel,autoGraph,backup,weaponCompatible,preserveSourceTracks,cancel)).ModelPath;
public static async Task<HandExportResult> ExportWithReportAsync(HandTarget target, IReadOnlyList<HandBakedClip> clips, string outputModel,
bool autoGraph, bool backup, bool weaponCompatible, bool preserveSourceTracks, CancellationToken cancel = default)
{
await MainThread();
if(clips.Count==0)throw new InvalidOperationException("Select at least one animation to export.");
if(weaponCompatible)
{
foreach(var clip in clips)
{
if(clip.Source.ModelPath is null)throw new InvalidOperationException("Weapon-compatible mode requires a weapon VMDL source so its animation owner can be verified.");
var missing=target.Mapping.Hands.SelectMany(h=>new[]{h.Wrist}.Concat(h.Digits.SelectMany(d=>d.Bones))).Select(i=>target.Skeleton[i].Name).Where(n=>clip.Source.Scene.Skeleton.IndexOf(n)<0).ToArray();
if(missing.Length>0)throw new InvalidOperationException("These target bones cannot bonemerge onto the selected weapon: "+string.Join(", ",missing)+". Use standalone export for this custom rig, or choose a weapon whose skeleton contains those bones.");
}
}
outputModel = VmdlSetupService.NormalizeAssetPath(outputModel,".vmdl");
var absolute = System.IO.Path.Combine(Assets,outputModel);
var existing = File.Exists(absolute) ? File.ReadAllText(absolute) : null;
if(existing is not null)
{
var existingModel=Model.Load(outputModel);
if(existingModel is null||existingModel.IsError)throw new InvalidOperationException("The existing output model must compile before animations can be added.");
var existingRig=ReadSkeleton(existingModel);
if(existingRig.Count!=target.Skeleton.Count||target.Skeleton.Bones.Any(b=>existingRig.IndexOf(b.Name)<0))throw new InvalidOperationException("The existing output model has a different skeleton. Choose the target model or a new output path.");
}
var targetSource=System.IO.Path.Combine(Assets,target.ModelPath);
var original = existing ?? (File.Exists(targetSource)?File.ReadAllText(targetSource):HandModelFactory.Create(baseModel:target.ModelPath));
var folder = outputModel[..^5] + "_animations";
var files = new Dictionary<string,string>(StringComparer.OrdinalIgnoreCase);
var entries = new List<HandAnimationEntry>();
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "bindPose" };
// A compiled target is in inches. Existing model scaling controls the animation
// compiler's input units; new wrappers use the inherited cm-to-inch modifier.
var factor = ExportPositionFactor(original);
var turn=ExportRootRotation(original);
XForm ExportLocal(XForm value,int bone)=>target.Skeleton[bone].ParentIndex<0
?new XForm(Vec.Transform(value.Pos*factor,turn),Quat.Normalize(turn*value.Rot))
:new XForm(value.Pos*factor,value.Rot);
Clip ExportUnits(Clip clip) => new(clip.Name,clip.Fps,clip.Looping,clip.Frames.Select(frame=>frame.Select(ExportLocal).ToArray()).ToList(),clip.NativeFps);
var exportSkeleton = Skel.Create(target.Skeleton.Bones.Select(b=>new BoneDefinition(b.Name,b.ParentIndex<0?null:target.Skeleton[b.ParentIndex].Name,ExportLocal(b.RestLocal,b.Index))).ToArray());
var bind = new Clip("bindPose",30,false,new() { Pose.Rest(exportSkeleton).Locals });
files[folder+"/bind.dmx"] = DmxWriter.Write(exportSkeleton,bind,new() {Name="bindPose",UpAxisY=false,ForwardParity=1});
foreach(var clip in clips)
{
var stem=SourceKey(clip.Source)+"_"+SafeName(clip.Baked.Name); var name=stem; var suffix=1;
while(!names.Add(name)) name=stem+"_"+(++suffix);
var content=DmxWriter.Write(exportSkeleton,ExportUnits(clip.Baked),new() {Name=name,UpAxisY=false,ForwardParity=1,ChannelExcludedBones=target.Mapping.Hands.SelectMany(h=>h.TwistOrHelperBones).ToHashSet()});
var file=folder+"/"+name+"_"+ContentKey(content)+".dmx";
files[file]=content;
entries.Add(new(name,file,clip.Baked.Looping));
if(preserveSourceTracks)
{
// Full source companion intentionally retains every authored camera,
// weapon and IK track with its original hierarchy, units and timing.
var tracks=DmxWriter.Write(clip.Source.Scene.Skeleton,clip.Original,new() {Name=name+"_source_tracks",UpAxisY=clip.Source.Scene.UpAxis==1,ForwardParity=clip.Source.ModelPath is null&&clip.Source.Scene.FrontAxis!=0?2:1});
files[folder+"/source_tracks/"+name+"_"+ContentKey(tracks)+".dmx"]=tracks;
}
}
var prepared=VmdlSetupService.Prepare(original,entries,new() {ModelPath=outputModel,BindPoseSource=folder+"/bind.dmx",AutoConfigureAnimGraph=autoGraph,WeaponCompatibleArms=weaponCompatible});
var generatedAssets=new Dictionary<string,string>(StringComparer.OrdinalIgnoreCase);
var bundles=autoGraph?HandWeaponExport.Prepare(target,clips,outputModel,files,generatedAssets,cancel):Array.Empty<HandWeaponExport.Bundle>();
foreach(var bundle in bundles)generatedAssets[bundle.PrefabPath]=HandWeaponExport.Prefab(bundle);
var committed=await VmdlSetupTransaction.CommitAsync(Assets,outputModel,existing,prepared,files,async(paths,token)=>{
await MainThread(); foreach(var path in paths) AssetSystem.RegisterFile(path);
foreach(var bundle in bundles)
{
if(bundle.LiveCalibration is null)
{
if(!await CompileAsync(System.IO.Path.Combine(Assets,bundle.ModelPath),token))throw new InvalidOperationException("Weapon animation model did not compile: "+bundle.ModelPath);
await MainThread();
await Task.Delay(100,token);await MainThread();
var owner=Model.Load(bundle.ModelPath);var graph=AnimationGraph.Load(bundle.GraphPath);
if(owner is null||owner.IsError||!owner.HasRenderMeshes()||owner.BoneCount<bundle.BoneCount||graph is null||graph.IsError
||!bundle.Actions.All(a=>owner.AnimationNames.Contains(a.Sequence)))throw new InvalidOperationException($"Weapon animation owner validation failed: bones {owner?.BoneCount}/{bundle.BoneCount}, meshes {owner?.MeshCount}, vertices {owner?.MeshInfo.TotalVertices}, triangles {owner?.MeshInfo.TotalTriangles}, graph error {graph?.IsError}, sequences {string.Join(",",owner?.AnimationNames??Array.Empty<string>())}.");
if(target.Skeleton.Bones.Any(b=>owner.Bones.GetBone(WeaponClipBuilder.HandPrefix+b.Name) is null))return false;
}
if(!await CompileAsync(System.IO.Path.Combine(Assets,bundle.PrefabPath),token))throw new InvalidOperationException("Weapon prefab did not compile: "+bundle.PrefabPath);
await MainThread();
// The compiled file reaches disk before the editor's asset record updates.
// Managed resource loading uses that record, not File.Exists.
var prefabAsset=AssetSystem.FindByPath(bundle.PrefabPath);
for(var wait=0;wait<50&&string.IsNullOrEmpty(prefabAsset.GetCompiledFile(true));wait++)
{await Task.Delay(100,token);await MainThread();}
var prefab=prefabAsset.LoadResource<PrefabFile>();
if(prefab is null||prefab.IsError||SceneUtility.GetPrefabScene(prefab) is null)throw new InvalidOperationException("Weapon prefab could not be loaded: "+bundle.PrefabPath);
}
if(!await CompileAsync(absolute,token)) return false;
await MainThread(); var model=Model.Load(outputModel);
// The compiled file can arrive before an already-loaded model refreshes
// its sequence list. Re-exporting changed motion adds a preserved-name
// suffix, so wait for those new sequences before validating the bake.
var modelDeadline=DateTime.UtcNow.AddSeconds(15);
while(model is not null&&!model.IsError&&!prepared.Animations.All(a=>model.AnimationNames.Contains(a.SequenceName))
&&DateTime.UtcNow<modelDeadline)
{
await Task.Delay(50,token);await MainThread();model=Model.Load(outputModel);
}
if(model is null || model.IsError || !prepared.Animations.All(a=>model.AnimationNames.Contains(a.SequenceName)))return false;
if(prepared.GeneratedGraphPath is { } graphPath){var graph=AnimationGraph.Load(graphPath);if(graph is null||graph.IsError)return false;}
var compiledRig=ReadSkeleton(model);
using var sampler=new ModelSampler(model,compiledRig);
var checkedBones=target.Mapping.Hands.SelectMany(h=>new int?[]{h.Clavicle,h.UpperArm,h.Forearm,h.Wrist}.Where(i=>i.HasValue).Select(i=>i!.Value).Concat(h.Digits.SelectMany(d=>d.Bones))).Distinct().ToArray();
for(var i=0;i<clips.Count;i++)
{
var expectedClip=clips[i].Baked;var actualClip=sampler.Sample(prepared.Animations[i].SequenceName,expectedClip.Fps,token);
if(expectedClip.FrameCount>1&&actualClip.FrameCount!=expectedClip.FrameCount)return false;
foreach(var frame in new[]{0,expectedClip.FrameCount/2,expectedClip.FrameCount-1}.Distinct())
{
var expectedWorld=new Pose(expectedClip.Frames[frame]).ToWorld(target.Skeleton);
var actualWorld=new Pose(actualClip.Frames[frame]).ToWorld(compiledRig);
foreach(var bone in checkedBones)
{
var index=compiledRig.IndexOf(target.Skeleton[bone].Name);
if(index<0 || Vec.Distance(expectedWorld[bone].Pos,actualWorld[index].Pos)>.1f
|| MathQ.AngleBetween(expectedWorld[bone].Rot,actualWorld[index].Rot)>.02f)
throw new InvalidOperationException($"Compiled sequence '{expectedClip.Name}' differs from the preview at '{target.Skeleton[bone].Name}', frame {frame}. The export was rolled back.");
}
}
}
return true;
},backup,cancel,generatedAssets);
return new(outputModel,prepared.Changes.Concat(bundles.Select(b=>b.LiveCalibration is not null
? $"Preserved complete source graph {b.GraphPath} with live custom-hand retargeting: {b.PrefabPath}"
: $"Created weapon graph ({string.Join(", ",b.Actions.Select(a=>a.Action))}) and synchronized hands/weapon prefab: {b.PrefabPath}")).ToArray(),committed.BackupPath,bundles.Select(b=>b.PrefabPath).ToArray());
}
private static float ExportPositionFactor(string text)
{
float scale=1;
void Walk(KvValue v) { if(v is KvObject o) { if(o.GetString("_class")=="ModelModifier_ScaleAndMirror" && o.GetOrNull("scale") is KvDouble d) scale*=(float)d.Value; foreach(var key in o.Keys) Walk(o[key]); } else if(v is KvArray a) foreach(var item in a.Items) Walk(item); }
Walk(Kv3.Parse(text).Root);
if(!float.IsFinite(scale)||scale<=0) throw new InvalidOperationException("Target model has an unsupported scale modifier.");
return 1f/(2.54f*scale);
}
private static Quat ExportRootRotation(string text)
{
var turn=false;
void Walk(KvValue value)
{
if(value is KvObject node)
{
if(node.GetString("_class")=="ModelModifier_ScaleAndMirror"
&&node.GetOrNull("mirror_x") is KvBool {Value:true}
&&node.GetOrNull("mirror_y") is KvBool {Value:true}
&&node.GetOrNull("mirror_z") is not KvBool {Value:true})turn=!turn;
foreach(var key in node.Keys)Walk(node[key]);
}
else if(value is KvArray array)foreach(var child in array.Items)Walk(child);
}
Walk(Kv3.Parse(text).Root);
return turn?Quat.CreateFromAxisAngle(Vec.UnitZ,MathF.PI):Quat.Identity;
}
private static async Task<string> PrepareMeshAsync(string file, CancellationToken cancel,List<string> importNotes)
{
var bytes=File.ReadAllBytes(file);
var materials=await Task.Run(()=>FbxMaterialAssets.Inspect(file,bytes),cancel);
importNotes.AddRange(materials.Notes);
var hash=materials.Signature;
var folder="models/hand_retargeter/targets/"+SafeName(System.IO.Path.GetFileNameWithoutExtension(file))+"_"+hash+"_rig9";
var mesh=folder+"/hands.fbx"; var model=folder+"/hands.vmdl";
var scene=await Task.Run(()=>FbxImporter.Import(bytes,new(){SampleFps=(float)FbxScene.Build(FbxTokenizer.Parse(bytes)).FrameRate}),cancel);
var embeddedClips=scene.Clips;
var repaired=await Task.Run(()=>
{
var result=FbxBindPoseFixer.TryFix(bytes,out var report);
return (Bytes:result,Report:report);
},cancel);
if(repaired.Bytes is {} repairedBytes)
{
var repairedScene=await Task.Run(()=>FbxImporter.Import(repairedBytes),cancel);
if(!scene.Skeleton.Bones.Select(b=>(b.Name,b.ParentIndex)).SequenceEqual(repairedScene.Skeleton.Bones.Select(b=>(b.Name,b.ParentIndex))))
throw new InvalidOperationException("Bind-pose repair changed the target hierarchy; the original FBX was not modified.");
scene=repairedScene;bytes=repairedBytes;
importNotes.Add("Repaired the imported copy from its authored bind pose: "+repaired.Report);
}
var meshNames=FbxSkinnedMeshes.ReadNames(bytes);
await MainThread();
var meshAbs=System.IO.Path.Combine(Assets,mesh); Directory.CreateDirectory(System.IO.Path.GetDirectoryName(meshAbs)!);
if(!File.Exists(meshAbs)) File.WriteAllBytes(meshAbs,bytes);
var modelAbs=System.IO.Path.Combine(Assets,model);
var remaps=HandMaterialImport.Write(materials,folder);
var isNew=!File.Exists(modelAbs);
if(isNew)
{
// Keep the full authored hierarchy before ModelDoc can cull unweighted
// ancestors. The audited importer preview uses this same bind-source technique.
string EngineName(string name){var suffix=name.LastIndexOf('#');return suffix<0?name:name[..suffix]+"_duplicate";}
var rig=Skel.Create(scene.Skeleton.Bones.Select(b=>new BoneDefinition(EngineName(b.Name),b.ParentIndex<0?null:EngineName(scene.Skeleton[b.ParentIndex].Name),b.RestLocal)).ToArray());
var bindPath=folder+"/mesh_bind.dmx";
File.WriteAllText(System.IO.Path.Combine(Assets,bindPath),DmxWriter.Write(rig,new Clip("bindPose",30,false,new(){Pose.Rest(rig).Locals}),new(){Name="bindPose",UpAxisY=scene.UpAxis==1}));
var entries=new List<HandAnimationEntry>();
var names=new HashSet<string>(StringComparer.OrdinalIgnoreCase){"bindPose"};
for(var i=0;i<embeddedClips.Count;i++)
{
cancel.ThrowIfCancellationRequested();
var clip=embeddedClips[i];
var stem=SafeName(clip.Name).Replace('-','_');
var name=stem;var suffix=2;
while(!names.Add(name))name=stem+"_"+suffix++;
var path=folder+"/embedded_"+i+".dmx";
// Imported locals are already centimeters, matching mesh_bind and the
// model's final unit conversion. Preserve authored takes without IK.
File.WriteAllText(System.IO.Path.Combine(Assets,path),DmxWriter.Write(rig,clip,new(){Name=name,UpAxisY=scene.UpAxis==1}));
AssetSystem.RegisterFile(System.IO.Path.Combine(Assets,path));
entries.Add(new(name,path,clip.Looping));
}
var prepared=VmdlSetupService.Prepare(HandModelFactory.Create(mesh:mesh,meshUnitScaleCm:scene.UnitScaleCm,materialRemaps:remaps,meshNames:meshNames),entries,new(){ModelPath=model,BindPoseSource=bindPath,AutoConfigureAnimGraph=false});
File.WriteAllText(modelAbs,prepared.VmdlText);
AssetSystem.RegisterFile(System.IO.Path.Combine(Assets,bindPath));
}
AssetSystem.RegisterFile(meshAbs);
if(!await CompileAsync(modelAbs,cancel)) throw new InvalidOperationException("The target FBX could not be compiled into a skinned model.");
if(isNew)
{
await MainThread();
var compiledRig=ReadSkeleton(Model.Load(model));
var fit=HandTargetFit.Analyze(compiledRig,HandRigDetector.Detect(compiledRig));
if(fit.Scale!=1||fit.TurnAround)
{
File.WriteAllText(modelAbs,fit.Apply(File.ReadAllText(modelAbs)));
if(!await CompileAsync(modelAbs,cancel))throw new InvalidOperationException("The fitted FPS target could not be compiled.");
// Resource compilation finishes before the loaded Model's bone cache
// refreshes. Never hand an old-size skeleton to a newly scaled mesh.
var deadline=DateTime.UtcNow.AddSeconds(15);
while(true)
{
await Task.Delay(50,cancel);await MainThread();
var loaded=ReadSkeleton(Model.Load(model));
var ready=compiledRig.Bones.All(b=>
{
var index=loaded.IndexOf(b.Name);if(index<0)return false;
var expected=compiledRig.RestWorld[b.Index].Pos*fit.Scale;
if(fit.TurnAround)expected=new Vec(-expected.X,-expected.Y,expected.Z);
return Vec.Distance(loaded.RestWorld[index].Pos,expected)<.02f;
});
if(ready)break;
if(DateTime.UtcNow>=deadline)throw new InvalidOperationException("The fitted model is still reloading. Select the FBX again after asset compilation finishes.");
}
if(fit.Scale!=1)importNotes.Add($"Applied FPS import scale {fit.Scale:G} to the mesh and all embedded animations to correct the oversized or undersized export.");
if(fit.TurnAround)importNotes.Add("Turned the imported target 180 degrees to align its left and right shoulders with FPS weapon coordinates.");
}
}
await MainThread(); return model;
}
public static async Task<bool> CompileAsync(string absolute, CancellationToken cancel)
{
await MainThread(); var asset=AssetSystem.RegisterFile(absolute);
var compiled=absolute+"_c"; var before=File.Exists(compiled)?File.GetLastWriteTimeUtc(compiled):DateTime.MinValue;
asset.Compile(full:true);
var deadline=DateTime.UtcNow.AddSeconds(90);
while(DateTime.UtcNow<deadline)
{
cancel.ThrowIfCancellationRequested(); await MainThread();
if(asset.IsCompileFailed) return false;
if(File.Exists(compiled) && File.GetLastWriteTimeUtc(compiled)>before) return true;
await Task.Delay(100,cancel);
}
return false;
}
public static bool HasMissingMaterials(Model model)
{
var materials=model.Materials.Concat(Enumerable.Range(0,model.MaterialGroupCount).SelectMany(group=>model.GetMaterials(group))).Distinct();
foreach(var material in materials)
{
if(material is null||material.IsError||string.Equals(material.ResourcePath,"materials/error.vmat",StringComparison.OrdinalIgnoreCase))return true;
foreach(var parameter in new[]{"g_tColor","g_tNormal","g_tRoughness","g_tMetalness","g_tAmbientOcclusion","g_tSelfIllumMask","g_tTranslucency","TextureColor","TextureNormal","TextureRoughness","TextureMetalness","TextureAmbientOcclusion","TextureSelfIllumMask","TextureTranslucency"})
if(material.GetTexture(parameter) is {IsError:true})return true;
if(material.FirstTexture is {IsError:true})return true;
}
return false;
}
public static void ApplyPreviewMaterialFallback(SceneModel model)
{
// Incomplete visual dependencies must not prevent skeletal retargeting.
// Override this preview instance only; authored assets remain editable.
if(HasMissingMaterials(model.Model))model.SetMaterialOverride(Material.Load("materials/dev/reflectivity_50.vmat"));
}
public static string ModelPath(string path)
{
if(path.EndsWith(".vmdl_c",StringComparison.OrdinalIgnoreCase))path=path[..^2];
if(System.IO.Path.IsPathRooted(path)) path=System.IO.Path.GetRelativePath(Assets,path);
return VmdlSetupService.NormalizeAssetPath(path,".vmdl");
}
public static string SafeName(string name) => string.Concat(name.Select(c=>char.IsLetterOrDigit(c)||c=='_'||c=='-'?c:'_')).Trim('_') is {Length:>0} result ? result : "animation";
internal static string ContentKey(string text)=>Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(text))).ToLowerInvariant()[..16];
internal static string SourceKey(HandSource source)=>SafeName(System.IO.Path.GetFileNameWithoutExtension(source.Path))+"_"+ContentKey(source.Path.Replace('\\','/').ToLowerInvariant())[..8];
private sealed class ModelSampler : IDisposable
{
readonly Scene metadataScene = Scene.CreateEditorScene(); readonly SkinnedModelRenderer metadata;
readonly SceneWorld world=new(); readonly Model asset; readonly Skel skeleton; readonly int[] bones;
public ModelSampler(Model asset, Skel rig) { using(metadataScene.Push()) { metadata=new GameObject(true,"sequence metadata").GetOrAddComponent<SkinnedModelRenderer>(); metadata.Model=asset; metadata.UseAnimGraph=false; } this.asset=asset; skeleton=rig; bones=rig.Bones.Select(b=>asset.Bones.GetBone(b.Name).Index).ToArray(); }
public Clip Sample(string name,float fps,CancellationToken cancel=default)
{
var model=new SceneModel(world,asset,Transform.Zero){UseAnimGraph=false};
try {
model.SetAnimGraph("");model.UseAnimGraph=false;
model.CurrentSequence.Name=name; metadata.Sequence.Name=name;
var duration=model.CurrentSequence.Duration;
var count=Math.Max(1,(int)MathF.Round(duration*fps)+1);
if(count>100000) throw new InvalidOperationException($"Sequence '{name}' is too long to sample.");
var frames=new List<XForm[]>(count);
for(var f=0;f<count;f++) {
cancel.ThrowIfCancellationRequested();
model.CurrentSequence.Time=MathF.Min(f/fps,duration); model.Update(0);
var worlds=bones.Select(b=>FromEngine(model.GetBoneWorldTransform(b))).ToArray();
frames.Add(skeleton.Bones.Select(b=>b.ParentIndex<0?worlds[b.Index]:XForm.ToLocal(worlds[b.ParentIndex],worlds[b.Index])).ToArray());
}
return new(name,fps,metadata.Sequence.Looping,frames);
} finally {model.Delete();}
}
public Clip? SampleFire(float duration,float fps,CancellationToken cancel)
{
var model=new SceneModel(world,asset,Transform.Zero){UseAnimGraph=true};
try
{
if(model.AnimationGraph is null||model.AnimationGraph.IsError)return null;
model.SetAnimParameter("skeleton",0);model.SetAnimParameter("b_deploy_skip",true);
for(var i=0;i<120;i++){cancel.ThrowIfCancellationRequested();model.Update(1f/60);}
model.SetAnimParameter("b_deploy_skip",false);
var frames=new List<XForm[]>();
for(var f=0;f<=Math.Max(1,(int)MathF.Ceiling(duration*fps));f++)
{
cancel.ThrowIfCancellationRequested();model.SetAnimParameter("b_attack",f==0);model.Update(f==0?0:1f/fps);
var worlds=bones.Select(b=>FromEngine(model.GetBoneWorldTransform(b))).ToArray();
frames.Add(skeleton.Bones.Select(b=>b.ParentIndex<0?worlds[b.Index]:XForm.ToLocal(worlds[b.ParentIndex],worlds[b.Index])).ToArray());
}
return new Clip("Fire",fps,false,frames);
}
finally{model.Delete();}
}
public void Dispose() {world.Delete();metadataScene.Destroy();}
}
}
Game
library
#nullable enable
namespace HumanoidHandRetargeter.Mapping;
public enum HandSide { Left, Right }
public enum DigitRole { Thumb, Index, Middle, Ring, Pinky, Extra }
/// <summary>Ordered anatomical joints; optional palm and terminal bones are explicit.</summary>
public sealed class DigitChain
{
public DigitRole Role { get; }
public string ExtraSlot { get; }
public IReadOnlyList<int> Segments { get; }
public int? Metacarpal { get; }
public int? Tip { get; }
public DigitChain(DigitRole role, IEnumerable<int> segments,
int? metacarpal = null, int? tip = null, string extraSlot = "")
{
ArgumentNullException.ThrowIfNull(segments);
Role = role;
Segments = Array.AsReadOnly(segments.ToArray());
Metacarpal = metacarpal;
Tip = tip;
ExtraSlot = extraSlot ?? "";
}
public IEnumerable<int> Bones
{
get
{
if (Metacarpal is int palm) yield return palm;
foreach (var segment in Segments) yield return segment;
if (Tip is int tip) yield return tip;
}
}
}
/// <summary>A single hand; rigs with only one side do not need a placeholder other hand.</summary>
public sealed class HandRigDefinition
{
public HandSide Side { get; }
public int Wrist { get; }
public int? Clavicle { get; }
public int? UpperArm { get; }
public int? Forearm { get; }
public IReadOnlyList<DigitChain> Digits { get; }
public IReadOnlyList<int> TwistOrHelperBones { get; }
public HandRigDefinition(HandSide side, int wrist, IEnumerable<DigitChain> digits,
int? clavicle = null, int? upperArm = null, int? forearm = null,
IEnumerable<int>? twistOrHelperBones = null)
{
ArgumentNullException.ThrowIfNull(digits);
Side = side;
Wrist = wrist;
Clavicle = clavicle;
UpperArm = upperArm;
Forearm = forearm;
Digits = Array.AsReadOnly(digits.ToArray());
TwistOrHelperBones = Array.AsReadOnly((twistOrHelperBones ?? Array.Empty<int>()).ToArray());
}
}
Game
library
#nullable enable
namespace HumanoidHandRetargeter.Retargeting;
public sealed class HandMotionOptions
{
public bool TransferWristPosition { get; init; } = true;
public bool ScaleWristTravel { get; init; } = true;
/// <summary>Known source-to-target model axes, independent of the wrists' rest rotations.</summary>
public System.Numerics.Quaternion? WristTravelBasis {get;init;}
public bool SolveArmIk { get; init; } = true;
public bool PreserveWeaponGrip { get; init; } = true;
/// <summary>Shared source weapon-to-target translation in centimeters. Grip motion is never scaled per hand.</summary>
public System.Numerics.Vector3? WeaponSpaceOffset {get;init;}
}
Game
library
#nullable enable
using System.Numerics;
using HumanoidHandRetargeter.Calibration;
using HumanoidHandRetargeter.Formats;
using HumanoidHandRetargeter.Maths;
using HumanoidHandRetargeter.Skeleton;
using HumanoidHandRetargeter.Validation;
namespace HumanoidHandRetargeter.Retargeting;
using Vector3 = System.Numerics.Vector3;
/// <summary>One offline solve path for static poses and baked clips. Target translations
/// and unmapped local transforms stay authored; helper constraints remain target-owned.</summary>
public static class HandRetargeter
{
/// <summary>One continuous graph stream, retaining digit angle history between evaluated frames.</summary>
public sealed class PoseStream
{
private readonly HandRetargetProfile profile;
private readonly HandMotionOptions options;
private readonly AngleHistory history;
public PoseStream(HandRetargetProfile profile, HandMotionOptions options)
{ this.profile = profile; this.options = options; history = new(profile); }
public Pose Step(Pose source)
{
var pose = Solve(profile, source, history);
if (options.TransferWristPosition) ApplyWristMotion(profile, source, pose, options);
var sourceWorld = source.ToWorld(profile.Source);
foreach (var pair in profile.PreservedTracks)
{
var targetWorld = pose.ToWorld(profile.Target);
var parent = profile.Target[pair.Target].ParentIndex;
pose.Locals[pair.Target] = parent < 0 ? sourceWorld[pair.Source] : XForm.ToLocal(targetWorld[parent], sourceWorld[pair.Source]);
}
return pose;
}
}
public static Pose RetargetPose(HandRetargetProfile profile, Pose sourcePose)
=> Solve(profile, sourcePose, null);
/// <summary>Retains timing and fixes quaternion signs. Stateful angle unwrapping is
/// local to this clip, so a calibrated profile may safely be shared by concurrent jobs.</summary>
public static Clip RetargetClip(HandRetargetProfile profile, Clip sourceClip, CancellationToken cancellationToken = default)
=> Bake(profile, sourceClip, new HandMotionOptions { TransferWristPosition = false }, cancellationToken);
/// <summary>The shared editor preview/export bake including optional wrist travel and arm IK.</summary>
public static Clip Bake(HandRetargetProfile profile, Clip sourceClip, HandMotionOptions options, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(profile);
ArgumentNullException.ThrowIfNull(sourceClip);
ArgumentNullException.ThrowIfNull(options);
if (sourceClip.FrameCount == 0)
throw new RigValidationException(new[] { new RigIssue("empty-clip", "Source animation has no frames.") });
var frames = new List<XForm[]>(sourceClip.FrameCount);
var continuity = new AngleHistory(profile);
foreach (var sourceFrame in sourceClip.Frames)
{
cancellationToken.ThrowIfCancellationRequested();
var pose = Solve(profile, new Pose(sourceFrame), continuity);
if (options.TransferWristPosition) ApplyWristMotion(profile, new Pose(sourceFrame), pose, options);
if(profile.PreservedTracks.Length>0)
{
var sourceWorld=new Pose(sourceFrame).ToWorld(profile.Source);
foreach(var pair in profile.PreservedTracks)
{
var targetWorld=pose.ToWorld(profile.Target);var parent=profile.Target[pair.Target].ParentIndex;
pose.Locals[pair.Target]=parent<0?sourceWorld[pair.Source]:XForm.ToLocal(targetWorld[parent],sourceWorld[pair.Source]);
}
}
frames.Add(pose.Locals);
}
QuaternionContinuity.AlignFrames(frames);
return new Clip(sourceClip.Name, sourceClip.Fps, sourceClip.Looping, frames, sourceClip.NativeFps);
}
private static void ApplyWristMotion(HandRetargetProfile profile, Pose source, Pose target, HandMotionOptions options)
{
if(options.WeaponSpaceOffset.HasValue&&options.SolveArmIk)
foreach(var shoulder in profile.FpsShoulders)target.Locals[shoulder.Key].Pos=shoulder.Value;
var sourceWorld = source.ToWorld(profile.Source);
foreach (var pair in profile.WristMotion)
{
var world = target.ToWorld(profile.Target);
var travel = Vector3.Transform(sourceWorld[pair.Source].Pos - profile.Source.RestWorld[pair.Source].Pos, options.WristTravelBasis ?? pair.Basis);
var desired = profile.Target.RestWorld[pair.Target].Pos + travel * (options.ScaleWristTravel ? pair.Scale : 1f);
var wristRotation = world[pair.Target].Rot;
if(options.WeaponSpaceOffset is {} weaponOffset)
{
wristRotation=MathQ.Normalize(sourceWorld[pair.Source].Rot*pair.GripRotationOffset);
desired=sourceWorld[pair.Source].Pos+weaponOffset
+Vector3.Transform(pair.SourceGripLocal,sourceWorld[pair.Source].Rot)
-Vector3.Transform(pair.TargetGripLocal,wristRotation);
}
if (options.SolveArmIk && pair.UpperArm is int upper && pair.Forearm is int lower)
{
if(options.WeaponSpaceOffset.HasValue)
{
// Detached FPS arms can move their open shoulder ends. Avoid
// folding long/thick forearms back through their own upper arm
// when the weapon grip lies very close to the shoulder.
if(profile.Target[upper].ParentIndex<0)
{
var shoulder=HandViewSpace.ClearElbowFold(world[upper].Pos,world[lower].Pos,world[pair.Target].Pos,desired);
target.Locals[upper].Pos=shoulder;
world=target.ToWorld(profile.Target);
}
// FPS arm meshes have free shoulder ends. Move the shoulder enough
// to reach the fixed weapon grip instead of stretching either arm segment.
var reach=Vector3.Distance(world[upper].Pos,world[lower].Pos)+Vector3.Distance(world[lower].Pos,world[pair.Target].Pos);
var toGrip=desired-world[upper].Pos;var distance=toGrip.Length();
if(distance>reach*.98f&&distance>1e-5f)
{
var shoulder=world[upper].Pos+toGrip/distance*(distance-reach*.98f);
var parent=profile.Target[upper].ParentIndex;
target.Locals[upper].Pos=parent<0?shoulder:Vector3.Transform(shoulder-world[parent].Pos,Quaternion.Conjugate(world[parent].Rot));
world=target.ToWorld(profile.Target);
}
}
var correction = TwoBoneIk.Solve(world[upper].Pos, world[lower].Pos, world[pair.Target].Pos, desired, 0, pair.BendAxis);
void SetWorldRotation(int bone, Quaternion rotation)
{
var parent = profile.Target[bone].ParentIndex;
target.Locals[bone].Rot = MathQ.Normalize(parent < 0 ? rotation : Quaternion.Conjugate(world[parent].Rot) * rotation);
world = target.ToWorld(profile.Target);
}
var oldLower = world[lower].Rot;
SetWorldRotation(upper, correction.UpperWorldDelta * world[upper].Rot);
SetWorldRotation(lower, correction.LowerWorldDelta * oldLower);
if(options.WeaponSpaceOffset.HasValue && pair.SourceForearm is int sourceElbow && pair.SourceUpperArm is int sourceShoulder)
{
// Transfer the source bend plane between the two shoulder-to-wrist axes.
// An absolute source elbow position can lie on the target arm's axis
// when proportions differ, flipping its pole as the weapon recoils.
var axis=world[pair.Target].Pos-world[upper].Pos;
var sourceAxis=sourceWorld[pair.Source].Pos-sourceWorld[sourceShoulder].Pos;
if(axis.LengthSquared()>1e-8f&&sourceAxis.LengthSquared()>1e-8f)
{
axis=Vector3.Normalize(axis);
sourceAxis=Vector3.Normalize(sourceAxis);
var actual=world[lower].Pos-world[upper].Pos;
var wanted=sourceWorld[sourceElbow].Pos-sourceWorld[sourceShoulder].Pos;
wanted-=sourceAxis*Vector3.Dot(wanted,sourceAxis);
wanted=Vector3.Transform(wanted,MathQ.FromTo(sourceAxis,axis));
actual-=axis*Vector3.Dot(actual,axis);
// A straight arm has no defined pole: retain the preceding IK plane.
if(actual.LengthSquared()>1e-6f&&wanted.LengthSquared()>1e-6f)
{
actual=Vector3.Normalize(actual);wanted=Vector3.Normalize(wanted);
var angle=MathF.Atan2(Vector3.Dot(axis,Vector3.Cross(actual,wanted)),Vector3.Dot(actual,wanted));
var pole=Quaternion.CreateFromAxisAngle(axis,angle);
oldLower=world[lower].Rot;
SetWorldRotation(upper,pole*world[upper].Rot);
SetWorldRotation(lower,pole*oldLower);
}
}
}
if(options.WeaponSpaceOffset.HasValue&&!pair.HasTwistHelpers)
{
// Without authored twist helpers, put axial pronation in the forearm
// instead of winding the wrist through the fixed palm orientation.
// Rotating about elbow-to-wrist leaves the grip position unchanged.
var axis=world[pair.Target].Pos-world[lower].Pos;
if(axis.LengthSquared()>1e-8f)
{
var restRelative=MathQ.Normalize(Quaternion.Conjugate(profile.Target.RestWorld[lower].Rot)*profile.Target.RestWorld[pair.Target].Rot);
var relaxedLower=MathQ.Normalize(wristRotation*Quaternion.Conjugate(restRelative));
var delta=MathQ.Normalize(relaxedLower*Quaternion.Conjugate(world[lower].Rot));
MathQ.SwingTwist(delta,Vector3.Normalize(axis),out _,out var roll);
SetWorldRotation(lower,roll*world[lower].Rot);
}
}
SetWorldRotation(pair.Target, wristRotation);
if(options.WeaponSpaceOffset.HasValue&&Vector3.Distance(world[pair.Target].Pos,desired)>.1f)
throw new InvalidOperationException("The target arm cannot reach the weapon grip without stretching. Adjust the rig's shoulder placement or disable Preserve weapon grip.");
}
else
{
// Partial rigs cannot reach with a two-link chain. Preserve the authored
// wrist path by translating the wrist relative to its actual parent.
var parent = profile.Target[pair.Target].ParentIndex;
target.Locals[pair.Target].Pos = parent < 0 ? desired
: Vector3.Transform(desired - world[parent].Pos, Quaternion.Conjugate(world[parent].Rot));
target.Locals[pair.Target].Rot=MathQ.Normalize(parent<0?wristRotation:Quaternion.Conjugate(world[parent].Rot)*wristRotation);
}
}
var errors = PoseValidator.Validate(profile.Target, target);
if (errors.Count > 0) throw new RigValidationException(errors);
}
private static Pose Solve(HandRetargetProfile profile, Pose sourcePose, AngleHistory? history)
{
ArgumentNullException.ThrowIfNull(profile);
var errors = PoseValidator.Validate(profile.Source, sourcePose);
if (errors.Count > 0) throw new RigValidationException(errors);
var sourceWorld = sourcePose.ToWorld(profile.Source);
errors = PoseValidator.Validate(profile.Source, new Pose(sourceWorld));
if (errors.Count > 0) throw new RigValidationException(errors);
var sourceDeltas = new Quaternion[sourceWorld.Length];
for (var i = 0; i < sourceWorld.Length; i++)
sourceDeltas[i] = MathQ.Normalize(sourceWorld[i].Rot * Quaternion.Conjugate(profile.Source.RestWorld[i].Rot));
var targetDeltas = Enumerable.Repeat(Quaternion.Identity, profile.Target.Count).ToArray();
var solved = new bool[profile.Target.Count];
foreach (var pair in profile.Pairs)
{
var parentSource = pair.SourceParent >= 0 ? sourceDeltas[pair.SourceParent] : Quaternion.Identity;
var parentTarget = pair.TargetParent >= 0 ? targetDeltas[pair.TargetParent] : Quaternion.Identity;
var canonical = MathQ.Normalize(Quaternion.Conjugate(pair.SourceFrame) * Quaternion.Conjugate(parentSource)
* sourceDeltas[pair.Source] * pair.SourceFrame);
targetDeltas[pair.Target] = MathQ.Normalize(parentTarget * pair.TargetFrame * canonical * Quaternion.Conjugate(pair.TargetFrame));
solved[pair.Target] = true;
}
for (var d = 0; d < profile.Distributions.Length; d++)
{
var digit = profile.Distributions[d];
var curls = new float[digit.Source.Length];
var spread = 0f;
var previous = sourceDeltas[digit.SourceWrist];
for (var s = 0; s < digit.Source.Length; s++)
{
var frame = digit.SourceFrames[s];
var delta = sourceDeltas[digit.Source[s]];
var canonical = MathQ.Normalize(Quaternion.Conjugate(frame) * Quaternion.Conjugate(previous) * delta * frame);
// Legacy FingerSolver decomposition: canonical Y curl, Z splay, discard X twist when redistributing.
MathQ.SwingTwist(canonical, Vector3.UnitY, out var swing, out var curl);
curls[s] = SignedAngle(curl, Vector3.UnitY);
if (history is not null) curls[s] = history.Unwrap(d, s, curls[s], false);
if (s <= digit.ProximalSourceIndex)
{
MathQ.SwingTwist(swing, Vector3.UnitZ, out _, out var splay);
var angle = SignedAngle(splay, Vector3.UnitZ);
if (history is not null) angle = history.Unwrap(d, s, angle, true);
spread += angle;
}
previous = delta;
}
var accumulated = targetDeltas[digit.TargetWrist];
for (var t = 0; t < digit.Target.Length; t++)
{
var angle = 0f;
for (var s = 0; s < curls.Length; s++) angle += digit.Weights[t, s] * curls[s];
var motion = Quaternion.CreateFromAxisAngle(Vector3.UnitY, angle);
if (t == 0) motion = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, spread) * motion;
var frame = digit.TargetFrames[t];
accumulated = MathQ.Normalize(accumulated * frame * motion * Quaternion.Conjugate(frame));
targetDeltas[digit.Target[t]] = accumulated;
solved[digit.Target[t]] = true;
}
}
var targetPose = Pose.Rest(profile.Target);
var targetWorld = new XForm[profile.Target.Count];
for (var i = 0; i < targetWorld.Length; i++)
{
var parent = profile.Target[i].ParentIndex;
if (solved[i])
{
var desiredWorld = targetDeltas[i] * profile.Target.RestWorld[i].Rot;
targetPose.Locals[i].Rot = MathQ.Normalize(parent < 0 ? desiredWorld : Quaternion.Conjugate(targetWorld[parent].Rot) * desiredWorld);
}
targetWorld[i] = parent < 0 ? targetPose.Locals[i] : XForm.Compose(targetWorld[parent], targetPose.Locals[i]);
}
errors = PoseValidator.Validate(profile.Target, targetPose);
if (errors.Count > 0) throw new RigValidationException(errors);
errors = PoseValidator.Validate(profile.Target, new Pose(targetWorld));
if (errors.Count > 0) throw new RigValidationException(errors);
return targetPose;
}
// Same signed twist convention as the legacy FingerSolver.
private static float SignedAngle(Quaternion twist, Vector3 axis)
{
var angle = 2f * MathF.Atan2(twist.X * axis.X + twist.Y * axis.Y + twist.Z * axis.Z, twist.W);
if (angle > MathF.PI) angle -= 2f * MathF.PI;
if (angle < -MathF.PI) angle += 2f * MathF.PI;
return angle;
}
private sealed class AngleHistory
{
private readonly float[][] _curl;
private readonly float[][] _spread;
public AngleHistory(HandRetargetProfile profile)
{
_curl = profile.Distributions.Select(d => Enumerable.Repeat(float.NaN, d.Source.Length).ToArray()).ToArray();
_spread = profile.Distributions.Select(d => Enumerable.Repeat(float.NaN, d.Source.Length).ToArray()).ToArray();
}
public float Unwrap(int digit, int joint, float angle, bool spread)
{
var values = spread ? _spread : _curl;
var previous = values[digit][joint];
if (float.IsFinite(previous))
angle += 2f * MathF.PI * MathF.Round((previous - angle) / (2f * MathF.PI));
values[digit][joint] = angle;
return angle;
}
}
}
Game
library
// Adapted from humanoid-retargeter 26084c96c3fc870aaf9a5bd798de063ce2fd62df; cached hierarchy queries added.
#nullable enable annotations
using System;
using System.Collections.Generic;
using HumanoidHandRetargeter.Maths;
namespace HumanoidHandRetargeter.Skeleton;
/// <summary>
/// Input definition for a single bone, used to build a <see cref="Skeleton"/>.
/// Order does not matter; construction topologically sorts parents before children.
/// </summary>
/// <param name="Name">Unique bone name.</param>
/// <param name="ParentName">Parent bone name, or null for a root bone (multiple roots allowed).</param>
/// <param name="RestLocal">Rest (bind) transform relative to the parent bone, centimeters.</param>
public readonly record struct BoneDefinition(string Name, string? ParentName, XForm RestLocal);
/// <summary>One bone of an immutable <see cref="Skeleton"/>.</summary>
public readonly struct Bone
{
/// <summary>Index of this bone in the skeleton (parents always have a smaller index).</summary>
public int Index { get; }
/// <summary>Unique bone name.</summary>
public string Name { get; }
/// <summary>Index of the parent bone, or -1 for a root bone.</summary>
public int ParentIndex { get; }
/// <summary>Rest (bind) transform relative to the parent bone (or armature space for roots).</summary>
public XForm RestLocal { get; }
internal Bone(int index, string name, int parentIndex, XForm restLocal)
{
Index = index;
Name = name;
ParentIndex = parentIndex;
RestLocal = restLocal;
}
/// <inheritdoc />
public override string ToString() => $"[{Index}] {Name} (parent {ParentIndex})";
}
/// <summary>
/// Immutable bone hierarchy with rest pose. Bones are stored topologically sorted
/// (every bone's parent precedes it), which lets forward kinematics run as a single
/// in-order pass. Supports multiple roots (e.g. the s&box rig has both
/// <c>root_IK</c> and <c>pelvis</c> as parentless bones).
/// </summary>
public sealed class Skeleton
{
private readonly Bone[] _bones;
private readonly XForm[] _restWorld;
private readonly Dictionary<string, int> _indexByName;
private readonly IReadOnlyList<int>[] _children;
private Skeleton(Bone[] bones, XForm[] restWorld, Dictionary<string, int> indexByName)
{
_bones = bones;
_restWorld = restWorld;
_indexByName = indexByName;
var children = Enumerable.Range(0, bones.Length).Select(_ => new List<int>()).ToArray();
foreach (var bone in bones)
if (bone.ParentIndex >= 0) children[bone.ParentIndex].Add(bone.Index);
_children = children.Select(c => (IReadOnlyList<int>)c.AsReadOnly()).ToArray();
}
/// <summary>Number of bones.</summary>
public int Count => _bones.Length;
/// <summary>All bones, topologically sorted (parents before children).</summary>
public IReadOnlyList<Bone> Bones => _bones;
/// <summary>Rest (bind) world transforms, indexed like <see cref="Bones"/>.</summary>
public IReadOnlyList<XForm> RestWorld => _restWorld;
/// <summary>Bone name to index lookup.</summary>
public IReadOnlyDictionary<string, int> IndexByName => _indexByName;
/// <summary>The bone at <paramref name="index"/>.</summary>
public Bone this[int index] => _bones[index];
/// <summary>Returns the index of the named bone, or -1 when absent.</summary>
public int IndexOf(string name) => _indexByName.TryGetValue(name, out var index) ? index : -1;
/// <summary>Cached immediate children in stable skeleton order.</summary>
public IReadOnlyList<int> ChildrenOf(int bone) => _children[bone];
/// <summary>Whether child is strictly below ancestor; a bone is not its own descendant.</summary>
public bool DescendsFrom(int child, int ancestor)
{
if (child < 0 || child >= Count) throw new ArgumentOutOfRangeException(nameof(child));
if (ancestor < 0 || ancestor >= Count) throw new ArgumentOutOfRangeException(nameof(ancestor));
for (var parent = _bones[child].ParentIndex; parent >= 0; parent = _bones[parent].ParentIndex)
if (parent == ancestor) return true;
return false;
}
/// <summary>
/// Builds a skeleton from bone definitions in any order: validates names and parent links,
/// topologically sorts (stable — input order is preserved among bones whose parents are
/// already placed), and computes rest world transforms.
/// </summary>
/// <exception cref="ArgumentException">
/// Thrown on duplicate bone names, unknown parent names, or parent cycles.
/// </exception>
public static Skeleton Create(IReadOnlyList<BoneDefinition> definitions)
{
ArgumentNullException.ThrowIfNull(definitions);
var count = definitions.Count;
var originalIndexByName = new Dictionary<string, int>(count, StringComparer.Ordinal);
for (var i = 0; i < count; i++)
{
var name = definitions[i].Name;
if (string.IsNullOrEmpty(name))
throw new ArgumentException($"Bone at position {i} has an empty name.", nameof(definitions));
if (!originalIndexByName.TryAdd(name, i))
throw new ArgumentException($"Duplicate bone name '{name}'.", nameof(definitions));
}
foreach (var def in definitions)
{
if (def.ParentName is not null && !originalIndexByName.ContainsKey(def.ParentName))
throw new ArgumentException(
$"Bone '{def.Name}' references unknown parent '{def.ParentName}'.", nameof(definitions));
if (def.ParentName == def.Name)
throw new ArgumentException($"Bone '{def.Name}' is its own parent.", nameof(definitions));
}
// Stable topological sort: repeatedly place, in input order, every bone whose parent
// is already placed. O(n^2) worst case — fine for skeleton-sized inputs (~100 bones).
var newIndexByOriginal = new int[count];
Array.Fill(newIndexByOriginal, -1);
var order = new List<int>(count);
while (order.Count < count)
{
var progressed = false;
for (var i = 0; i < count; i++)
{
if (newIndexByOriginal[i] >= 0)
continue;
var parentName = definitions[i].ParentName;
if (parentName is not null && newIndexByOriginal[originalIndexByName[parentName]] < 0)
continue;
newIndexByOriginal[i] = order.Count;
order.Add(i);
progressed = true;
}
if (!progressed)
throw new ArgumentException("Bone hierarchy contains a parent cycle.", nameof(definitions));
}
var bones = new Bone[count];
var restWorld = new XForm[count];
var indexByName = new Dictionary<string, int>(count, StringComparer.Ordinal);
for (var newIndex = 0; newIndex < count; newIndex++)
{
var def = definitions[order[newIndex]];
var parentIndex = def.ParentName is null ? -1 : newIndexByOriginal[originalIndexByName[def.ParentName]];
bones[newIndex] = new Bone(newIndex, def.Name, parentIndex, def.RestLocal);
restWorld[newIndex] = parentIndex < 0
? def.RestLocal
: XForm.Compose(restWorld[parentIndex], def.RestLocal);
indexByName.Add(def.Name, newIndex);
}
return new Skeleton(bones, restWorld, indexByName);
}
}
Game
library
// Reused from humanoid-retargeter 26084c96c3fc870aaf9a5bd798de063ce2fd62df; specialized where needed for hand assets.
#nullable enable annotations
namespace HumanoidHandRetargeter.Formats.Fbx;
/// <summary>
/// A single node of an FBX document tree (binary or ASCII): a name, a flat list of
/// typed properties, and nested child nodes.
///
/// Property values are stored as the closest CLR type to what the file contained:
/// <list type="bullet">
/// <item><c>short</c> ('Y'), <c>bool</c> or character <c>byte</c> ('C'), <c>int</c> ('I'), <c>float</c> ('F'),
/// <c>double</c> ('D'), <c>long</c> ('L')</item>
/// <item><c>float[]</c> ('f'), <c>double[]</c> ('d'), <c>long[]</c> ('l'),
/// <c>int[]</c> ('i'), <c>bool[]</c>-as-<c>byte[]</c> ('b')</item>
/// <item><c>string</c> ('S' — kept raw, may contain the <c>\x00\x01</c> name/class
/// separator; see <see cref="SplitName"/>), <c>byte[]</c> ('R')</item>
/// </list>
/// ASCII files store numbers only as <c>long</c> / <c>double</c> (and arrays as
/// <c>long[]</c> / <c>double[]</c>), so the typed accessors below convert tolerantly.
/// </summary>
public sealed class FbxNode
{
public string Name { get; }
public List<object> Properties { get; } = new();
public List<FbxNode> Children { get; } = new();
public bool HasChildScope { get; set; }
// Document metadata retained when repairing binary FBX files.
public byte[]? FooterWatermark { get; set; }
public FbxNode(string name) => Name = name;
/// <summary>First child with the given name, or null.</summary>
public FbxNode? Child(string name)
{
foreach (var c in Children)
if (c.Name == name)
return c;
return null;
}
/// <summary>All children with the given name, in document order.</summary>
public IEnumerable<FbxNode> ChildrenNamed(string name)
{
foreach (var c in Children)
if (c.Name == name)
yield return c;
}
/// <summary>
/// Property <paramref name="i"/> converted to <typeparamref name="T"/>.
/// Numeric scalars convert tolerantly across widths (e.g. an 'I' i32 read as long);
/// anything else must match the stored type exactly.
/// </summary>
public T Prop<T>(int i)
{
object v = RawProp(i);
if (v is T t)
return t;
var target = typeof(T);
// s&box whitelist: Type.IsPrimitive is banned; enumerate the convertible targets.
if (v is IConvertible && (ConvertTargets.Contains(target) || target == typeof(string)))
{
try
{
return (T)Convert.ChangeType(v, target, System.Globalization.CultureInfo.InvariantCulture);
}
// ArithmeticException covers OverflowException, which is not s&box-whitelisted
catch (Exception ex) when (ex is InvalidCastException or ArithmeticException or FormatException)
{
throw new FormatException(
$"FBX node '{Name}': property {i} is {v.GetType().Name}, not convertible to {target.Name}.", ex);
}
}
throw new FormatException(
$"FBX node '{Name}': property {i} is {v.GetType().Name}, expected {target.Name}.");
}
/// <summary>Property <paramref name="i"/> as a double array (converts f/l/i/b arrays).</summary>
public double[] AsDoubleArray(int i) => RawProp(i) switch
{
double[] d => d,
float[] f => Array.ConvertAll(f, x => (double)x),
long[] l => Array.ConvertAll(l, x => (double)x),
int[] n => Array.ConvertAll(n, x => (double)x),
byte[] b => Array.ConvertAll(b, x => (double)x),
bool[] o => Array.ConvertAll(o, x => x ? 1.0 : 0.0),
var v => throw TypeError(i, v, "double[]"),
};
/// <summary>Property <paramref name="i"/> as a float array (converts d/l/i/b arrays).</summary>
public float[] AsFloatArray(int i) => RawProp(i) switch
{
float[] f => f,
double[] d => Array.ConvertAll(d, x => (float)x),
long[] l => Array.ConvertAll(l, x => (float)x),
int[] n => Array.ConvertAll(n, x => (float)x),
byte[] b => Array.ConvertAll(b, x => (float)x),
bool[] o => Array.ConvertAll(o, x => x ? 1f : 0f),
var v => throw TypeError(i, v, "float[]"),
};
/// <summary>Property <paramref name="i"/> as a long array (converts i/b; d/f if integral).</summary>
public long[] AsLongArray(int i) => RawProp(i) switch
{
long[] l => l,
int[] n => Array.ConvertAll(n, x => (long)x),
byte[] b => Array.ConvertAll(b, x => (long)x),
bool[] o => Array.ConvertAll(o, x => x ? 1L : 0L),
double[] d => Array.ConvertAll(d, x => checked((long)x)),
float[] f => Array.ConvertAll(f, x => checked((long)x)),
var v => throw TypeError(i, v, "long[]"),
};
/// <summary>Property <paramref name="i"/> as an int array (converts b; l/d/f narrowing-checked).</summary>
public int[] AsIntArray(int i) => RawProp(i) switch
{
int[] n => n,
long[] l => Array.ConvertAll(l, x => checked((int)x)),
byte[] b => Array.ConvertAll(b, x => (int)x),
bool[] o => Array.ConvertAll(o, x => x ? 1 : 0),
double[] d => Array.ConvertAll(d, x => checked((int)x)),
float[] f => Array.ConvertAll(f, x => checked((int)x)),
var v => throw TypeError(i, v, "int[]"),
};
/// <summary>Property <paramref name="i"/> as raw bytes ('R' blobs or 'b' bool arrays).</summary>
public byte[] AsByteArray(int i) => RawProp(i) switch
{
byte[] b => b,
var v => throw TypeError(i, v, "byte[]"),
};
/// <summary>Property <paramref name="i"/> as a string (raw 'S' content, separators intact).</summary>
public string AsString(int i) => RawProp(i) switch
{
string s => s,
var v => throw TypeError(i, v, "string"),
};
/// <summary>
/// Splits an FBX object name into (name, class).
/// Binary files store <c>"Name\x00\x01Class"</c> (e.g. <c>"mixamorig:Hips\x00\x01Model"</c>);
/// ASCII files store <c>"Class::Name"</c> (e.g. <c>"Model::pelvis"</c>).
/// A plain string with neither separator yields (name, "").
/// </summary>
public static (string Name, string Class) SplitName(string raw)
{
int bin = raw.IndexOf("\0\x01", StringComparison.Ordinal);
if (bin >= 0)
return (raw[..bin], raw[(bin + 2)..]);
int ascii = raw.IndexOf("::", StringComparison.Ordinal);
if (ascii >= 0)
return (raw[(ascii + 2)..], raw[..ascii]);
return (raw, "");
}
/// <summary>Primitive scalar types <see cref="Prop{T}"/> converts to (whitelist-safe IsPrimitive substitute).</summary>
private static readonly HashSet<Type> ConvertTargets = new()
{
typeof(bool), typeof(byte), typeof(sbyte), typeof(short), typeof(ushort),
typeof(int), typeof(uint), typeof(long), typeof(ulong),
typeof(float), typeof(double), typeof(char),
};
private object RawProp(int i)
{
if (i < 0 || i >= Properties.Count)
throw new FormatException(
$"FBX node '{Name}': property index {i} out of range (has {Properties.Count}).");
return Properties[i];
}
private FormatException TypeError(int i, object v, string wanted) =>
new($"FBX node '{Name}': property {i} is {v.GetType().Name}, expected {wanted}.");
}
Game
library
#nullable enable
using Sandbox;
using HumanoidHandRetargeter.Target;
namespace HumanoidHandRetargeter;
/// <summary>Optional local player controls for exported weapons. Games may instead drive RetargetedWeapon directly.</summary>
[Title("Retargeted Weapon Controls"), Category("Animation")]
public sealed class RetargetedWeaponController : Component, ICameraModifier
{
[Property] public PlayerController? Player { get; set; }
[Property] public bool ReadInput { get; set; } = true;
[Property] public int MagazineSize { get; set; } = 30;
[Property] public int StartingReserve { get; set; } = 120;
[Property] public float RoundsPerMinute { get; set; } = 800;
[Property] public float ReloadSeconds { get; set; } = 2.7f;
[Property] public float EmptyReloadSeconds { get; set; } = 3.2f;
[Property] public float Damage { get; set; } = 20;
[Property] public float Range { get; set; } = 10000;
[Property] public float HipFov { get; set; } = 75;
[Property] public float AimFov { get; set; } = 55;
[Property] public string WorldGripBone { get; set; } = "hand_R";
[Property] public int BodyHoldType { get; set; } = 2;
[Property] public int BodyHandedness { get; set; }
public int Ammo => magazine?.Rounds ?? MagazineSize;
public int Reserve => magazine?.Reserve ?? StartingReserve;
public bool Reloading => magazine?.Reloading ?? false;
public bool Aiming { get; private set; }
public int ShotsFired { get; private set; }
public int Hits { get; private set; }
public global::Vector3 LastHit { get; private set; }
public Action<SceneTraceResult>? Shot { get; set; }
private WeaponMagazine? magazine;
private RetargetedWeapon? weapon;
private float nextShot, reloadEnds, currentFov;
public int CameraOrder => 200;
protected override void OnStart()
{
weapon=GetComponent<RetargetedWeapon>();
Player ??= Scene.GetAllComponents<PlayerController>().FirstOrDefault();
if(Player is not null)Player.HideBodyInFirstPerson=true;
magazine=new(MagazineSize,StartingReserve);
currentFov=HipFov;
weapon?.Deploy(true);
}
public bool Fire()
{
if(weapon is null||magazine is null||Player is null||Time.Now<nextShot||Reloading)return false;
nextShot=Time.Now+60/Math.Max(1,RoundsPerMinute);
if(!magazine.TryFire()){weapon.DryFire();return false;}
weapon.Attack();weapon.SetEmpty(Ammo==0);ShotsFired++;
UpdateBodyPose();
Player.Renderer?.Set("b_attack",true);
var eye=Player.EyeTransform;
var hit=Scene.Trace.Ray(eye.Position,eye.Position+eye.Rotation.Forward*Range)
.IgnoreGameObjectHierarchy(Player.GameObject).IgnoreGameObjectHierarchy(GameObject).UseHitboxes().Run();
if(hit.Hit)
{
Hits++;LastHit=hit.HitPosition;
var info=new DamageInfo{Damage=Damage,Attacker=Player.GameObject,Weapon=GameObject,Position=hit.HitPosition,Origin=eye.Position};
for(var obj=hit.GameObject;obj is not null;obj=obj.Parent)
if(obj.GetComponent<IDamageable>() is {} damageable){damageable.OnDamage(info);break;}
}
Shot?.Invoke(hit);
return true;
}
public bool Reload()
{
if(magazine is null||weapon is null||!magazine.BeginReload())return false;
weapon.Reload(Ammo==0);reloadEnds=Time.Now+(Ammo==0?EmptyReloadSeconds:ReloadSeconds);
UpdateBodyPose();
Player?.Renderer?.Set("b_reload",true);
return true;
}
public void Aim(bool value){Aiming=value&&!Reloading;if(weapon is not null)weapon.Aiming=Aiming;}
protected override void OnUpdate()
{
if(weapon is null||Player is null||magazine is null)return;
if(Reloading&&Time.Now>=reloadEnds){magazine.FinishReload();weapon.SetEmpty(Ammo==0);}
if(ReadInput)Aim(Input.Down("Attack2"));
if(Reloading)Aim(false);
weapon.Aiming=Aiming;
weapon.Sprinting=ReadInput&&Input.Down("Run")&&Player.Velocity.Length>10&&!Aiming;
weapon.Movement=Math.Clamp(Player.Velocity.Length/150f,0,1);
weapon.SetParameter("attack_hold",ReadInput&&Input.Down("Attack1")&&!Reloading&&Ammo>0?1f:0f);
weapon.SetParameter("firing_mode",3);
weapon.SetParameter("ironsights_fire_scale",.3f);
if(ReadInput)
{
if(Input.Pressed("Reload"))Reload();
if(Input.Down("Attack1")&&!weapon.Sprinting)Fire();
}
weapon.ShowHands=!Player.ThirdPerson;
if(Player.ThirdPerson)weapon.WorldTransform=Player.EyeTransform;
// Keep the body graph warm even when hidden, so a camera switch during a shot
// or reload reveals the same action rather than restarting a holding pose.
UpdateBodyPose();
weapon.ThirdPersonBody=Player.ThirdPerson?Player.Renderer:null;
weapon.WorldGripBone=WorldGripBone;
}
private void UpdateBodyPose()
{
if(Player?.Renderer is not {} body)return;
body.Set("holdtype",BodyHoldType);body.Set("holdtype_handedness",BodyHandedness);
body.Set("aim_body_weight",1f);
}
public void ModifyCamera(CameraComponent camera,ref CameraView view)
{
if(Player is not null&&!Player.ThirdPerson&&camera==Scene.Camera)
{
currentFov=MathX.Lerp(currentFov,Aiming?AimFov:HipFov,Math.Clamp(Time.Delta*12,0,1));
view.FieldOfView=currentFov;
}
}
public void PostCameraSetup(CameraComponent camera,in CameraView view)
{
// EyeTransform during Update can precede the player's final camera pose.
// Place both viewmodel meshes against the composed view before PreRender.
if(weapon is not null&&Player is not null&&!Player.ThirdPerson&&camera==Scene.Camera)
weapon.WorldTransform=new global::Transform(view.Position,view.Rotation);
}
protected override void OnDisabled()
{
magazine?.CancelReload();Aiming=false;
if(weapon is not null){weapon.Aiming=false;weapon.ThirdPersonBody=null;}
Player?.Renderer?.Set("holdtype",0);
}
}
Game
library
#nullable enable
namespace HumanoidHandRetargeter.Retargeting;
public sealed class HandMotionOptions
{
public bool TransferWristPosition { get; init; } = true;
public bool ScaleWristTravel { get; init; } = true;
/// <summary>Known source-to-target model axes, independent of the wrists' rest rotations.</summary>
public System.Numerics.Quaternion? WristTravelBasis {get;init;}
public bool SolveArmIk { get; init; } = true;
public bool PreserveWeaponGrip { get; init; } = true;
/// <summary>Shared source weapon-to-target translation in centimeters. Grip motion is never scaled per hand.</summary>
public System.Numerics.Vector3? WeaponSpaceOffset {get;init;}
}
Game
library
#nullable enable
using HumanoidHandRetargeter.Mapping;
using SkeletonModel = HumanoidHandRetargeter.Skeleton.Skeleton;
namespace HumanoidHandRetargeter.Validation;
public sealed record RigIssue(string Code, string Message, HandSide? Side = null, int? Bone = null);
/// <summary>Shared validation for automatic/manual mapping, batch processing and the editor.</summary>
public static class HandRigValidator
{
public static IReadOnlyList<RigIssue> Validate(SkeletonModel skeleton, IEnumerable<HandRigDefinition> hands)
{
ArgumentNullException.ThrowIfNull(skeleton);
ArgumentNullException.ThrowIfNull(hands);
var issues = new List<RigIssue>();
var claimed = new HashSet<int>();
var sides = new HashSet<HandSide>();
var count = 0;
foreach (var bone in skeleton.Bones)
{
if (!PoseValidator.ValidTransform(bone.RestLocal))
issues.Add(new("invalid-rest", $"Bone '{bone.Name}' has an invalid rest transform.", Bone: bone.Index));
var world = skeleton.RestWorld[bone.Index].Pos;
if (!float.IsFinite(world.X) || !float.IsFinite(world.Y) || !float.IsFinite(world.Z))
issues.Add(new("invalid-world-rest", $"Bone '{bone.Name}' has an invalid accumulated rest position.", Bone: bone.Index));
}
foreach (var hand in hands)
{
count++;
if (hand is null)
{
issues.Add(new("missing-hand", "A hand mapping is missing."));
continue;
}
if (!Enum.IsDefined(typeof(HandSide), hand.Side))
issues.Add(new("invalid-side", "Choose left or right for the hand."));
if (!sides.Add(hand.Side))
issues.Add(new("duplicate-side", $"More than one {hand.Side} hand is mapped.", hand.Side));
var arm = new[] { hand.Clavicle, hand.UpperArm, hand.Forearm, hand.Wrist }
.Where(b => b.HasValue).Select(b => b!.Value).ToArray();
CheckChain(arm, hand.Side, "arm", allowZeroLength: true);
var roles = new HashSet<string>(StringComparer.Ordinal);
foreach (var digit in hand.Digits)
{
if (digit is null)
{
issues.Add(new("missing-digit", "A digit mapping is missing.", hand.Side));
continue;
}
var key = digit.Role == DigitRole.Extra ? "Extra:" + digit.ExtraSlot : digit.Role.ToString();
if (!Enum.IsDefined(typeof(DigitRole), digit.Role)
|| (digit.Role == DigitRole.Extra && string.IsNullOrWhiteSpace(digit.ExtraSlot)))
issues.Add(new("invalid-digit-role", "Choose a digit role or name its extra slot.", hand.Side));
if (!roles.Add(key))
issues.Add(new("duplicate-digit-role", $"{hand.Side} {key} is mapped more than once.", hand.Side));
if (digit.Segments.Count == 0)
issues.Add(new("empty-digit", $"{hand.Side} {key} needs at least one segment.", hand.Side));
var chain = digit.Bones.ToArray();
CheckChain(chain, hand.Side, key, allowZeroLength: false, tip: digit.Tip);
if (chain.Length > 0 && Valid(chain[0]) && Valid(hand.Wrist)
&& !skeleton.DescendsFrom(chain[0], hand.Wrist))
issues.Add(new("wrong-wrist", $"{hand.Side} {key} must descend from its wrist.", hand.Side, chain[0]));
}
foreach (var helper in hand.TwistOrHelperBones)
{
Claim(helper, hand.Side);
var armRoot = arm[0];
if (Valid(helper) && Valid(armRoot) && !skeleton.DescendsFrom(helper, armRoot))
issues.Add(new("wrong-helper-root", $"{hand.Side} helper must belong to its mapped arm or hand hierarchy.", hand.Side, helper));
}
}
if (count == 0)
issues.Add(new("no-hands", "Map at least one wrist before retargeting."));
return issues.AsReadOnly();
bool Valid(int bone) => bone >= 0 && bone < skeleton.Count;
void Claim(int bone, HandSide side)
{
if (!Valid(bone))
issues.Add(new("invalid-bone", $"{side} mapping references a missing bone.", side, bone));
else if (!claimed.Add(bone))
issues.Add(new("overlapping-bone", $"Bone '{skeleton[bone].Name}' is assigned to multiple roles.", side, bone));
}
void CheckChain(int[] bones, HandSide side, string role, bool allowZeroLength, int? tip = null)
{
for (var i = 0; i < bones.Length; i++)
{
Claim(bones[i], side);
if (i == 0 || !Valid(bones[i - 1]) || !Valid(bones[i])) continue;
if (!skeleton.DescendsFrom(bones[i], bones[i - 1]))
issues.Add(new("chain-order", $"{side} {role} joints must follow their hierarchy.", side, bones[i]));
if (!allowZeroLength && bones[i] != tip &&
System.Numerics.Vector3.DistanceSquared(skeleton.RestWorld[bones[i - 1]].Pos,
skeleton.RestWorld[bones[i]].Pos) < 1e-12f)
issues.Add(new("zero-length-digit", $"{side} {role} has coincident joints; correct the chain or classify a helper.", side, bones[i]));
}
}
}
}
Game
library
#nullable enable
using HumanoidHandRetargeter.Validation;
using SkeletonModel = HumanoidHandRetargeter.Skeleton.Skeleton;
namespace HumanoidHandRetargeter.Mapping;
/// <summary>
/// Name candidates constrained by wrist ancestry. Reuses legacy name tokenization;
/// deliberately replaces its full-body confidence and fixed phalanx slots.
/// Unnamed/ambiguous anatomy is proposed for review, never silently accepted.
/// </summary>
public static class HandRigDetector
{
public static HandMappingResult Detect(SkeletonModel skeleton,
IEnumerable<HandRigDefinition>? manualOverrides = null)
{
ArgumentNullException.ThrowIfNull(skeleton);
var hints = HandDetectionProfiles.Resolve(skeleton);
var names = skeleton.Bones.Select(b => new NameInfo(b.Name,hints[b.Index])).ToArray();
var hands = new List<HandRigDefinition>();
var candidates = new List<HandMappingCandidate>();
var issues = new List<RigIssue>();
var review = false;
var deformBelow = new bool?[skeleton.Count];
int? digitWrist = null;
var overrides = (manualOverrides ?? Array.Empty<HandRigDefinition>()).ToArray();
if (overrides.Any(h => h is null))
throw new ArgumentException("Manual overrides cannot contain a null hand.", nameof(manualOverrides));
foreach (var side in new[] { HandSide.Left, HandSide.Right })
{
var manual = overrides.Where(h => h.Side == side).ToArray();
if (manual.Length > 0)
{
hands.AddRange(manual);
foreach (var hand in manual)
{
Add(side, "Wrist", new[] { hand.Wrist }, 1f, "Manual mapping", true);
foreach (var (role, bone) in new[] { ("Clavicle", hand.Clavicle), ("UpperArm", hand.UpperArm), ("Forearm", hand.Forearm) })
if (bone is int b) Add(side, role, new[] { b }, 1f, "Manual mapping", true);
foreach (var digit in hand.Digits.Where(d => d is not null))
Add(side, digit.Role.ToString(), digit.Bones, 1f, "Manual mapping", true);
foreach (var helper in hand.TwistOrHelperBones)
Add(side, "Helper", new[] { helper }, 1f, "Manual mapping", true);
}
continue;
}
var wrists = skeleton.Bones.Where(b => names[b.Index].IsWrist && !Blocked(b.Index)
&& SideOf(b.Index) == side).Select(b => b.Index).ToArray();
var deformWrists=wrists.Where(w=>names[w].IsDeform).ToArray();
if(deformWrists.Length==1 && wrists.All(w=>names[w].FamilyKey==names[deformWrists[0]].FamilyKey))
wrists=deformWrists; // Rigify exports can contain DEF, ORG and animator-control copies.
if (wrists.Length == 0) continue; // A one-sided rig is valid.
if (wrists.Length > 1)
{
foreach (var wrist in wrists)
Add(side, "Wrist", new[] { wrist }, .5f, "Multiple wrist candidates on this side; choose one manually", false);
issues.Add(new("ambiguous-wrist", $"Choose the {side} wrist in Hand Mapping.", side));
continue;
}
var selected = wrists[0];
digitWrist = selected;
Add(side, "Wrist", new[] { selected }, .95f, names[selected].Profile is {} wristProfile
? $"{wristProfile} profile and wrist hierarchy agree" : "Wrist name and side agree", true);
var digits = new List<DigitChain>();
var seenRoles = new HashSet<DigitRole>();
foreach (var root in DigitRoots(selected))
{
var chain = new List<int>();
int? meta = null, tip = null;
var current = root;
var chainReview = false;
while (true)
{
var info = names[current];
if (info.IsTip)
{
tip = current;
if (skeleton.ChildrenOf(current).Count != 0) chainReview = true;
break;
}
if (info.IsMeta && current == root) meta = current;
else chain.Add(current);
var next = JointChildren(current).ToArray();
if (next.Length == 0) break;
if (next.Length > 1) { chainReview = true; break; }
current = next[0];
}
var role = names[root].Digit;
// A palm/metacarpal may be generically named, but its child's digit is explicit.
if (role is null && meta.HasValue && chain.Count > 0) role = names[chain[0]].Digit;
var chainSideMismatch = chain.Any(b => names[b].Side is HandSide explicitSide && explicitSide != side);
var chainRoleMismatch = role.HasValue && chain.Any(b => names[b].Digit is DigitRole namedRole && namedRole != role);
if (chainSideMismatch || chainRoleMismatch || chainReview || chain.Count == 0)
{
Add(side, role?.ToString() ?? "Digit", chain, .3f,
"Branched, empty or side-inconsistent digit chain; correct the mapping", false);
review = true;
continue;
}
if (role.HasValue && !seenRoles.Add(role.Value))
{
Add(side, role.Value.ToString(), chain, .4f, "Duplicate anatomical digit candidate; choose the intended chain", false);
review = true;
continue;
}
var digit = new DigitChain(role ?? DigitRole.Extra, chain, meta, tip,
role is null ? skeleton[root].Name : "");
digits.Add(digit);
var confidence = role.HasValue ? .9f : .4f;
Add(side, role?.ToString() ?? "Extra:" + skeleton[root].Name, digit.Bones, confidence,
role.HasValue ? names[root].Profile is {} digitProfile ? $"{digitProfile} digit convention follows this wrist's hierarchy"
: "Named digit follows this wrist's hierarchy; segment numbering is not used for side"
: "Unlabelled digit chain; assign an anatomical role or confirm an extra digit", true);
if (!role.HasValue) review = true;
}
var ancestors = new List<int>();
for (var p = skeleton[selected].ParentIndex; p >= 0; p = skeleton[p].ParentIndex)
ancestors.Add(p);
int? Find(params string[] aliases) => ancestors
.Where(b => !names[b].IsHelper && !Blocked(b) && aliases.Any(a => names[b].Core.EndsWith(a, StringComparison.Ordinal)))
.Select(b => (int?)b).FirstOrDefault();
int? ProfileJoint(string role)=>ancestors.Where(b=>names[b].ProfileRole==role && !names[b].IsHelper && !Blocked(b))
.Select(b=>(int?)b).FirstOrDefault();
var forearm = ProfileJoint("LowerArm") ?? Find("forearm", "lowerarm", "armlower", "elbow");
var upperArm = ProfileJoint("UpperArm") ?? Find("upperarm", "armupper", "uparm");
var clavicle = Find("clavicle", "collar") ?? ProfileJoint("Clavicle");
// Prototype calls upper arm Shoulder, Mixamo calls clavicle Shoulder.
if (upperArm is null && clavicle.HasValue) upperArm = Find("shoulder");
upperArm ??= ancestors.Where(b => names[b].Core.EndsWith("arm", StringComparison.Ordinal)
&& b != forearm && !names[b].IsHelper && !Blocked(b)).Select(b => (int?)b).FirstOrDefault();
clavicle ??= Find("shoulder");
if (clavicle == upperArm) clavicle = null;
foreach (var (role, bone) in new[] { ("Clavicle", clavicle), ("UpperArm", upperArm), ("Forearm", forearm) })
if (bone is int b)
{
var mismatch = names[b].ConflictingSides || (SideOf(b) is HandSide namedSide && namedSide != side);
Add(side, role, new[] { b }, mismatch ? .3f : .9f,
mismatch ? "Arm name conflicts with the wrist side; correct the mapping" : "Named joint on the wrist ancestor chain", true);
review |= mismatch;
}
var armRoot = clavicle ?? upperArm ?? forearm ?? selected;
var helpers = skeleton.Bones.Where(b => names[b.Index].IsHelper && !Blocked(b.Index)
&& skeleton.DescendsFrom(b.Index, armRoot)).Select(b => b.Index).ToArray();
foreach (var helper in helpers)
Add(side, "Helper", new[] { helper }, .9f, "Explicit twist/helper name in this arm hierarchy", true);
hands.Add(new(side, selected, digits, clavicle, upperArm, forearm, helpers));
}
// Unsupported enum values must reach validation instead of being silently dropped.
hands.AddRange(overrides.Where(h => !Enum.IsDefined(typeof(HandSide), h.Side)));
foreach (var bone in skeleton.Bones.Where(b => names[b.Index].IsWrist && !Blocked(b.Index) && SideOf(b.Index) is null
&& !hands.Any(h => h.Wrist == b.Index)))
{
Add(null, "Wrist", new[] { bone.Index }, .4f, "Wrist name found but side is unknown or conflicting", false);
review = true;
}
if (hands.Count == 0 && candidates.Count == 0)
{
// Topology hints only: without a known side/frame, geometry must not fabricate left/right.
foreach (var bone in skeleton.Bones.Where(b => !Blocked(b.Index) &&
skeleton.ChildrenOf(b.Index).Count(c => !Blocked(c) && skeleton.ChildrenOf(c).Count > 0) >= 2))
Add(null, "Wrist", new[] { bone.Index }, .2f, "Branching hierarchy may be a palm; confirm side and wrist manually", false);
review = true;
}
issues.AddRange(HandRigValidator.Validate(skeleton, hands));
return new(hands, candidates, issues, review);
void Add(HandSide? side, string role, IEnumerable<int> bones, float confidence, string reason, bool selected)
=> candidates.Add(new(side, role, Array.AsReadOnly(bones.ToArray()), confidence, reason, selected));
// The viewmodel may own the whole hierarchy: a weapon-named ancestor is not
// evidence that an explicitly named child wrist is a mechanical weapon bone.
bool Blocked(int bone) => names[bone].IsNonHandTrack;
HandSide? SideOf(int bone)
{
for (var b = bone; b >= 0; b = skeleton[b].ParentIndex)
{
if (names[b].ConflictingSides) return null;
if (names[b].Side.HasValue) return names[b].Side;
}
return null;
}
IEnumerable<int> DigitRoots(int parent)
{
foreach (var child in JointChildren(parent))
{
// Some palm/metacarpal containers parent several different digits
// (notably thumb and index). They are shared anatomy, not a fork
// within one finger. Keep the container wrist-driven and map each
// named child chain independently so no bone is assigned twice.
var branches=JointChildren(child).Where(i=>!names[i].IsTip).ToArray();
if(names[child].IsMeta&&branches.Length>1
&&branches.All(i=>names[i].Digit.HasValue)
&&branches.Select(i=>names[i].Digit).Distinct().Count()==branches.Length)
{
foreach(var branch in branches)yield return branch;
continue;
}
if (!names[child].IsTip) yield return child;
}
}
// Layered rigs (Rigify DEF/ORG/MCH, animator controls) can parent deform digits
// under a non-deform palm next to their control copies. Below a deform wrist,
// follow only the deform layer and the containers that lead to it.
bool LeadsToDeform(int bone)
{
if (deformBelow[bone] is bool known) return known;
var result = names[bone].IsDeform || skeleton.ChildrenOf(bone).Any(LeadsToDeform);
deformBelow[bone] = result;
return result;
}
IEnumerable<int> JointChildren(int parent)
{
var layered = digitWrist is int wrist && names[wrist].IsDeform
&& (parent == wrist || skeleton.DescendsFrom(parent, wrist))
&& skeleton.ChildrenOf(parent).Any(c => !Blocked(c) && LeadsToDeform(c));
foreach (var child in skeleton.ChildrenOf(parent))
{
if (Blocked(child)) continue;
// The parent's own deform copy is a skinned twin, not a digit.
if (layered && (!LeadsToDeform(child)
|| names[child].IsDeform && !names[child].IsTip && names[child].FamilyKey == names[parent].FamilyKey)) continue;
if (names[child].IsHelper)
{
foreach (var nested in JointChildren(child)) yield return nested;
}
else yield return child;
}
}
}
private sealed class NameInfo
{
public string Core { get; }
public HandSide? Side { get; }
public bool ConflictingSides { get; }
public DigitRole? Digit { get; }
public bool IsWrist { get; }
public bool IsHelper { get; }
public bool IsNonHandTrack { get; }
public bool IsMeta { get; }
public bool IsTip { get; }
public string? Profile { get; }
public string? ProfileRole { get; }
public bool IsDeform { get; }
public string FamilyKey { get; }
public NameInfo(string name,HandDetectionProfiles.Hint? hint)
{
var stripped = name[(name.LastIndexOf(':') + 1)..];
var hash = stripped.LastIndexOf('#');
if (hash >= 0 && stripped[(hash + 1)..].All(char.IsDigit)) stripped = stripped[..hash];
IsDeform=stripped.StartsWith("DEF-",StringComparison.OrdinalIgnoreCase);
FamilyKey=HandDetectionProfiles.Key(IsDeform||stripped.StartsWith("ORG-",StringComparison.OrdinalIgnoreCase)
||stripped.StartsWith("MCH-",StringComparison.OrdinalIgnoreCase)?stripped[4..]:stripped);
Profile=hint?.Profile;ProfileRole=hint?.Role;
var tokens = BoneNameTokens.Tokenize(stripped);
bool Has(params string[] words) => tokens.Any(t => words.Contains(t, StringComparer.Ordinal));
var left = Has("l", "left", "lft");
var right = Has("r", "right", "rgt");
// Case-only changes must not erase a wrist's side when camel-case boundaries disappear.
var compact = string.Concat(tokens.Where(t => !t.All(char.IsDigit)));
foreach (var joint in new[] { "hand", "wrist" })
{
left |= compact.EndsWith("left" + joint, StringComparison.Ordinal) || compact.EndsWith(joint + "left", StringComparison.Ordinal)
|| compact.EndsWith(joint + "l", StringComparison.Ordinal) || compact == "l" + joint;
right |= compact.EndsWith("right" + joint, StringComparison.Ordinal) || compact.EndsWith(joint + "right", StringComparison.Ordinal)
|| compact.EndsWith(joint + "r", StringComparison.Ordinal) || compact == "r" + joint;
}
ConflictingSides = left && right;
Side = left == right ? left ? null : hint?.Side : left ? HandSide.Left : HandSide.Right;
Core = string.Concat(tokens.Where(t => !new[] { "l", "left", "lft", "r", "right", "rgt", "def", "org", "mch", "stretch" }.Contains(t)
&& !t.All(char.IsDigit)));
IsHelper = Has("twist", "twistctrl", "roll", "share", "sharebone", "helper", "hlp", "corrective", "control", "ctrl");
IsNonHandTrack = Has("weapon", "camera", "ik", "ikrule", "hold", "socket", "attachment", "magazine", "bolt", "slide", "trigger")
|| compact.StartsWith("weapon", StringComparison.Ordinal) || compact.StartsWith("camera", StringComparison.Ordinal);
IsMeta = Has("meta", "metacarpal", "carpal", "palm") || hint?.Role.EndsWith("Meta",StringComparison.Ordinal)==true;
IsTip = Has("tip", "end", "nub", "endmarker") || hint?.Role.EndsWith("Tip",StringComparison.Ordinal)==true;
bool DigitName(params string[] aliases) => Has(aliases)
|| aliases.Any(a => Core.EndsWith(a, StringComparison.Ordinal) || Core.EndsWith(a + "finger", StringComparison.Ordinal));
Digit = DigitName("thumb") ? DigitRole.Thumb : DigitName("index", "forefinger", "point", "pointer", "pointing") ? DigitRole.Index
: DigitName("middle", "mid") ? DigitRole.Middle : DigitName("ring") ? DigitRole.Ring
: DigitName("pinky", "pinkie", "pink", "little") ? DigitRole.Pinky : null;
if(hint is not null && HandDetectionProfiles.IsDigit(hint.Role))
Digit=Enum.GetValues<DigitRole>().First(d=>hint.Role.StartsWith(d.ToString(),StringComparison.Ordinal));
IsWrist = !IsHelper && !IsNonHandTrack && !IsTip && Digit is null
&& new[] { "hand", "wrist", "handl", "handr", "wristl", "wristr", "handleft", "handright", "wristleft", "wristright" }
.Any(suffix => Core.EndsWith(suffix, StringComparison.Ordinal));
if(hint?.Role=="Hand"&&!IsHelper&&!IsNonHandTrack&&!IsTip)IsWrist=true;
else if(hint is not null)IsWrist=false;
}
}
}
Game
library
#nullable enable
using System.Text.RegularExpressions;
using Skel = HumanoidHandRetargeter.Skeleton.Skeleton;
namespace HumanoidHandRetargeter.Mapping;
/// <summary>Reference conventions supply semantic hints; they never bypass hierarchy validation.</summary>
internal static partial class HandDetectionProfiles
{
internal sealed record Alias(string Role,string Name);
internal sealed record Hint(string Role,HandSide Side,string Profile);
internal sealed class Profile
{
public string Name { get; }
private readonly Regex[] prefixes;
public Dictionary<string,string[]> Roles { get; }
public int RoleCount { get; }
public Profile(string name,string[] patterns,Alias[] aliases)
{
Name=name;prefixes=patterns.Select(p=>new Regex(p,RegexOptions.IgnoreCase|RegexOptions.CultureInvariant)).ToArray();
Roles=aliases.GroupBy(a=>Key(a.Name)).ToDictionary(g=>g.Key,g=>g.Select(a=>a.Role).Distinct().ToArray());
RoleCount=aliases.Select(a=>a.Role).Distinct().Count();
}
public string Strip(string name)
{
// Strip FBX namespaces independently of exporter prefixes (e.g. Character:CC_Base_L_Hand).
name=name[(name.LastIndexOf(':')+1)..];
var hash=name.LastIndexOf('#');
if(hash>=0&&name[(hash+1)..].All(char.IsDigit))name=name[..hash];
foreach(var prefix in prefixes)name=prefix.Replace(name,"");
return name;
}
}
internal static string Key(string name)=>string.Concat(name.Where(char.IsLetterOrDigit)).ToLowerInvariant();
internal static Hint?[] Resolve(Skel rig)
{
var proposals=new Dictionary<int,List<(int Score,Hint Hint)>>();
foreach(var profile in Catalog.Concat(new[]{CinemaHumanProfile}))
{
var matches=new Dictionary<int,string>();
foreach(var bone in rig.Bones)
if(profile.Roles.TryGetValue(Key(profile.Strip(bone.Name)),out var roles)&&roles.Length==1)
matches[bone.Index]=roles[0];
foreach(var wrist in matches.Where(p=>p.Value is "HandL" or "HandR"))
{
var side=wrist.Value[^1];
var relevant=matches.Where(p=>p.Value[^1]==side && (p.Key==wrist.Key ||
(IsDigit(p.Value)&&rig.DescendsFrom(p.Key,wrist.Key)) ||
(IsArm(p.Value)&&rig.DescendsFrom(wrist.Key,p.Key)))).ToArray();
// Match coverage distinguishes e.g. SOMA metacarpals from Mixamo phalanges.
// Namespace evidence breaks equivalent-name ties without overpowering anatomy.
var score=relevant.Length*1000+relevant.Select(p=>p.Value).Distinct().Count()*100/Math.Max(1,profile.RoleCount/2)
+relevant.Count(p=>profile.Strip(rig[p.Key].Name)!=rig[p.Key].Name)*2;
foreach(var match in relevant)
{
if(!proposals.TryGetValue(match.Key,out var list))proposals[match.Key]=list=new();
list.Add((score,new(match.Value[..^1],side=='L'?HandSide.Left:HandSide.Right,profile.Name)));
}
}
}
var hints=new Hint?[rig.Count];
foreach(var pair in proposals)
{
var best=pair.Value.Where(p=>p.Score==pair.Value.Max(v=>v.Score)).Select(p=>p.Hint).ToArray();
if(best.Select(p=>(p.Role,p.Side)).Distinct().Count()==1)hints[pair.Key]=best[0];
}
ResolveNumberedFpsHands(rig,hints);
ResolveUnlabelledSplitArms(rig,hints);
return hints;
}
// Numbered FPS rays can run from thumb to pinky or in reverse, with an
// additional palm joint on the four fingers. Require the whole convention
// and its radial geometry; bare finger numbers alone are ambiguous.
private static void ResolveNumberedFpsHands(Skel rig,Hint?[] hints)
{
foreach(var side in new[]{HandSide.Left,HandSide.Right})
{
var suffix=side==HandSide.Left?"l":"r";
string Name(int bone)=>Key(rig[bone].Name[(rig[bone].Name.LastIndexOf(':')+1)..]).Replace("left","l").Replace("right","r");
var wrists=rig.Bones.Where(b=>Name(b.Index)=="hand"+suffix).ToArray();
if(wrists.Length!=1)continue;
var wrist=wrists[0].Index;
foreach(var thumbNumber in new[]{1,5})
{
var chains=new List<int[]>();
for(var finger=1;finger<=5;finger++)
{
var chain=new List<int>();var parent=wrist;
for(var joint=1;joint<=(finger==thumbNumber?3:4);joint++)
{
var pattern=$"^finger0*{finger}0*{joint}{suffix}$";
var children=rig.ChildrenOf(parent).Where(i=>Regex.IsMatch(Name(i),pattern)).ToArray();
if(children.Length!=1)break;
parent=children[0];chain.Add(parent);
}
if(chain.Count!=(finger==thumbNumber?3:4))break;
// A three-joint thumb must not just be a truncated four-joint finger.
if(finger==thumbNumber&&rig.ChildrenOf(parent).Any(i=>Regex.IsMatch(Name(i),$"^finger0*{finger}0*4{suffix}$")))break;
chains.Add(chain.ToArray());
}
if(chains.Count!=5)continue;
var thumb=rig.RestWorld[chains[thumbNumber-1][0]].Pos;
var radial=System.Numerics.Vector3.DistanceSquared(thumb,rig.RestWorld[chains[thumbNumber==5?3:1][0]].Pos);
var ulnar=System.Numerics.Vector3.DistanceSquared(thumb,rig.RestWorld[chains[thumbNumber==5?0:4][0]].Pos);
if(!float.IsFinite(radial)||!float.IsFinite(ulnar)||radial>=ulnar*.8f)continue;
var roles=thumbNumber==5?new[]{"Pinky","Ring","Middle","Index","Thumb"}:new[]{"Thumb","Index","Middle","Ring","Pinky"};
if(chains.SelectMany(c=>c).Any(i=>hints[i] is not null))continue;
for(var finger=0;finger<5;finger++)
for(var joint=0;joint<chains[finger].Length;joint++)
hints[chains[finger][joint]]=new(roles[finger]+(finger!=thumbNumber-1&&joint==0?"Meta":"Prox"),side,"numbered_fps_hands");
}
}
}
internal static bool IsDigit(string role)=>new[]{"Thumb","Index","Middle","Ring","Pinky"}.Any(role.StartsWith);
private static readonly Profile CinemaHumanProfile=CinemaBaseHuman();
private static Profile CinemaBaseHuman()
{
var aliases=new List<Alias>();
foreach(var side in new[]{"L","R"})
{
aliases.Add(new("UpperArm"+side,side+"Upperarm"));
aliases.Add(new("LowerArm"+side,side+"Forearm1"));
aliases.Add(new("Hand"+side,side+"Palm"));
var digits=new[]{"Thumb","Index","Middle","Ring","Pinky"};
var joints=new[]{"Prox","Mid","Dist"};
for(var d=0;d<5;d++)for(var j=0;j<3;j++)
aliases.Add(new(digits[d]+joints[j]+side,side+"Digit"+(d+1)+(j+1)));
}
return new("cinema4d_base_human",new[]{"^Base[-_]Human"},aliases.ToArray());
}
private static bool IsArm(string role)=>role.StartsWith("Clavicle")||role.StartsWith("UpperArm")||role.StartsWith("LowerArm");
}
Game
library
// Adapted from humanoid-retargeter 26084c96c3fc870aaf9a5bd798de063ce2fd62df; namespace only.
#nullable enable annotations
using System;
using System.Numerics;
namespace HumanoidHandRetargeter.Maths;
using Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/HumanoidHandRetargeter/Assembly.cs)
/// <summary>
/// Quaternion algebra helpers used throughout the retargeting pipeline.
/// All angles are radians; quaternions are XYZW (<see cref="System.Numerics.Quaternion"/> native);
/// <c>a * b</c> applies <c>b</c> first (column-vector convention).
/// </summary>
public static class MathQ
{
private const float NearZeroLengthSq = 1e-12f;
/// <summary>
/// Defensively normalizes a quaternion. Returns <see cref="Quaternion.Identity"/> when the
/// input has near-zero length or non-finite components instead of producing NaNs.
/// </summary>
public static Quaternion Normalize(Quaternion q)
{
var lengthSq = q.LengthSquared();
if (!float.IsFinite(lengthSq) || lengthSq < NearZeroLengthSq)
return Quaternion.Identity;
return Quaternion.Multiply(q, 1f / MathF.Sqrt(lengthSq));
}
/// <summary>
/// Shortest-arc rotation taking direction <paramref name="a"/> onto direction <paramref name="b"/>
/// (magnitudes are ignored). For antiparallel inputs a 180° rotation about a stable axis
/// perpendicular to <paramref name="a"/> is returned. Near-zero inputs yield identity.
/// </summary>
public static Quaternion FromTo(Vector3 a, Vector3 b)
{
if (a.LengthSquared() < NearZeroLengthSq || b.LengthSquared() < NearZeroLengthSq)
return Quaternion.Identity;
var an = Vector3.Normalize(a);
var bn = Vector3.Normalize(b);
var dot = Vector3.Dot(an, bn);
if (dot >= 1f - 1e-8f)
return Quaternion.Identity;
if (dot <= -1f + 1e-7f)
{
// Antiparallel: any axis perpendicular to a works; pick a stable one.
return Quaternion.CreateFromAxisAngle(Perpendicular(an), MathF.PI);
}
// q = (a x b, 1 + a·b), normalized — the half-angle construction of the shortest arc.
var cross = Vector3.Cross(an, bn);
return Normalize(new Quaternion(cross.X, cross.Y, cross.Z, 1f + dot));
}
/// <summary>
/// Decomposes a rotation into <paramref name="swing"/> and <paramref name="twist"/> so that
/// <c>q == swing * twist</c> (twist applied first). The twist is the projection of the
/// rotation onto <paramref name="twistAxis"/>: its rotation axis is parallel to
/// <paramref name="twistAxis"/>, while the swing's rotation axis is perpendicular to it.
/// When the rotation is a pure 180° swing (no component about the axis), twist is identity.
/// </summary>
/// <exception cref="ArgumentException">Thrown when <paramref name="twistAxis"/> has near-zero length.</exception>
public static void SwingTwist(Quaternion q, Vector3 twistAxis, out Quaternion swing, out Quaternion twist)
{
if (twistAxis.LengthSquared() < NearZeroLengthSq)
throw new ArgumentException("Twist axis must be non-zero.", nameof(twistAxis));
var axis = Vector3.Normalize(twistAxis);
q = Normalize(q);
// Project the rotation's vector part onto the twist axis.
var projection = q.X * axis.X + q.Y * axis.Y + q.Z * axis.Z;
var raw = new Quaternion(axis.X * projection, axis.Y * projection, axis.Z * projection, q.W);
// Degenerate case (q.W ~ 0 and projection ~ 0): pure 180° swing, no twist.
twist = Normalize(raw);
swing = Normalize(q * Quaternion.Conjugate(twist));
}
/// <summary>
/// Geodesic angle between two rotations in radians, in [0, π], invariant under the
/// q / -q double cover (so <c>AngleBetween(q, -q) == 0</c>).
/// </summary>
public static float AngleBetween(Quaternion a, Quaternion b)
{
// Use atan2 on the delta rotation rather than acos on the dot product: acos is
// ill-conditioned near 0°, atan2 is accurate over the whole range.
var delta = Quaternion.Conjugate(Normalize(a)) * Normalize(b);
var sinHalf = MathF.Sqrt(delta.X * delta.X + delta.Y * delta.Y + delta.Z * delta.Z);
var cosHalf = MathF.Abs(delta.W); // |W| folds the q / -q double cover.
return 2f * MathF.Atan2(sinHalf, cosHalf);
}
/// <summary>
/// Angle between two directions in radians, in [0, π] (magnitudes are ignored).
/// Near-zero inputs yield 0. Uses atan2, which stays accurate near 0 and π where
/// acos of the dot product loses precision.
/// </summary>
public static float AngleBetween(Vector3 a, Vector3 b)
{
if (a.LengthSquared() < NearZeroLengthSq || b.LengthSquared() < NearZeroLengthSq)
return 0f;
return MathF.Atan2(Vector3.Cross(a, b).Length(), Vector3.Dot(a, b));
}
/// <summary>
/// Builds an orthonormal right-handed basis rotation from a forward and an up hint.
/// </summary>
/// <remarks>
/// Convention: returns the rotation <c>R</c> such that
/// <list type="bullet">
/// <item><c>rotate(R, +Y) == normalize(forward)</c> (primary axis),</item>
/// <item><c>rotate(R, +Z) == </c> the Gram-Schmidt orthonormalization of <paramref name="up"/>
/// against forward (secondary axis),</item>
/// <item><c>rotate(R, +X) == forwardN x upOrtho</c> (right-handed completion).</item>
/// </list>
/// Degenerate inputs are handled defensively: a near-zero forward yields identity; an up hint
/// (near-)parallel to forward falls back to world +Z, or world +X when forward itself is
/// (near-)parallel to +Z.
/// </remarks>
public static Quaternion BasisFromForwardUp(Vector3 forward, Vector3 up)
{
if (forward.LengthSquared() < NearZeroLengthSq)
return Quaternion.Identity;
var y = Vector3.Normalize(forward);
var z = up - y * Vector3.Dot(up, y);
if (z.LengthSquared() < 1e-8f)
{
// Up hint unusable: pick a stable fallback and orthonormalize it.
var fallback = MathF.Abs(Vector3.Dot(y, Vector3.UnitZ)) > 0.99f ? Vector3.UnitX : Vector3.UnitZ;
z = fallback - y * Vector3.Dot(fallback, y);
}
z = Vector3.Normalize(z);
var x = Vector3.Cross(y, z);
// System.Numerics matrices act on row vectors, so the rows are the images of the
// unit axes under the rotation: row1 = R*X, row2 = R*Y, row3 = R*Z.
var m = new Matrix4x4(
x.X, x.Y, x.Z, 0f,
y.X, y.Y, y.Z, 0f,
z.X, z.Y, z.Z, 0f,
0f, 0f, 0f, 1f);
return Normalize(Quaternion.CreateFromRotationMatrix(m));
}
/// <summary>Stable unit vector perpendicular to <paramref name="unit"/> (assumed normalized).
/// Internal so geometry passes (e.g. <c>TwoBoneIk</c>) reuse one implementation.</summary>
internal static Vector3 Perpendicular(Vector3 unit)
{
var p = Vector3.Cross(unit, Vector3.UnitX);
if (p.LengthSquared() < 1e-6f)
p = Vector3.Cross(unit, Vector3.UnitY);
return Vector3.Normalize(p);
}
}
Game
library
namespace HumanoidHandRetargeter.Target;
/// <summary>Small hidden mesh required by SceneModel's animation owner.
/// Cube FBX layout from ufbx's public-domain maya_cube_7400_ascii fixture:
/// https://github.com/ufbx/ufbx/blob/master/data/maya_cube_7400_ascii.fbx
/// Used under ufbx LICENSE alternative B (public domain).</summary>
public static class AnimationDriverMesh
{
public static readonly string Fbx = Text.Replace("\r\n", "\n").Replace("\n", "\r\n");
private const string Text = """
; FBX 7.4.0 project file
; ----------------------------------------------------
FBXHeaderExtension: {
FBXHeaderVersion: 1003
FBXVersion: 7400
CreationTimeStamp: {
Version: 1000
Year: 2020
Month: 3
Day: 31
Hour: 21
Minute: 36
Second: 10
Millisecond: 814
}
Creator: "FBX SDK/FBX Plugins version 2019.2"
SceneInfo: "SceneInfo::GlobalInfo", "UserData" {
Type: "UserData"
Version: 100
MetaData: {
Version: 100
Title: ""
Subject: ""
Author: ""
Keywords: ""
Revision: ""
Comment: ""
}
Properties70: {
P: "DocumentUrl", "KString", "Url", "", "animation_driver.fbx"
P: "SrcDocumentUrl", "KString", "Url", "", "animation_driver.fbx"
P: "Original", "Compound", "", ""
P: "Original|ApplicationVendor", "KString", "", "", "Autodesk"
P: "Original|ApplicationName", "KString", "", "", "Maya"
P: "Original|ApplicationVersion", "KString", "", "", "201900"
P: "Original|DateTime_GMT", "DateTime", "", "", "31/03/2020 18:36:10.811"
P: "Original|FileName", "KString", "", "", "animation_driver.fbx"
P: "LastSaved", "Compound", "", ""
P: "LastSaved|ApplicationVendor", "KString", "", "", "Autodesk"
P: "LastSaved|ApplicationName", "KString", "", "", "Maya"
P: "LastSaved|ApplicationVersion", "KString", "", "", "201900"
P: "LastSaved|DateTime_GMT", "DateTime", "", "", "31/03/2020 18:36:10.811"
P: "Original|ApplicationActiveProject", "KString", "", "", "animation_driver.fbx"
}
}
}
GlobalSettings: {
Version: 1000
Properties70: {
P: "UpAxis", "int", "Integer", "",1
P: "UpAxisSign", "int", "Integer", "",1
P: "FrontAxis", "int", "Integer", "",2
P: "FrontAxisSign", "int", "Integer", "",1
P: "CoordAxis", "int", "Integer", "",0
P: "CoordAxisSign", "int", "Integer", "",1
P: "OriginalUpAxis", "int", "Integer", "",1
P: "OriginalUpAxisSign", "int", "Integer", "",1
P: "UnitScaleFactor", "double", "Number", "",1
P: "OriginalUnitScaleFactor", "double", "Number", "",1
P: "AmbientColor", "ColorRGB", "Color", "",0,0,0
P: "DefaultCamera", "KString", "", "", "Producer Perspective"
P: "TimeMode", "enum", "", "",11
P: "TimeProtocol", "enum", "", "",2
P: "SnapOnFrameMode", "enum", "", "",0
P: "TimeSpanStart", "KTime", "Time", "",1924423250
P: "TimeSpanStop", "KTime", "Time", "",384884650000
P: "CustomFrameRate", "double", "Number", "",-1
P: "TimeMarker", "Compound", "", ""
P: "CurrentTimeMarker", "int", "Integer", "",-1
}
}
; Documents Description
;------------------------------------------------------------------
Documents: {
Count: 1
Document: 1907658701024, "", "Scene" {
Properties70: {
P: "SourceObject", "object", "", ""
P: "ActiveAnimStackName", "KString", "", "", "Take 001"
}
RootNode: 0
}
}
; Document References
;------------------------------------------------------------------
References: {
}
; Object definitions
;------------------------------------------------------------------
Definitions: {
Version: 100
Count: 6
ObjectType: "GlobalSettings" {
Count: 1
}
ObjectType: "AnimationStack" {
Count: 1
PropertyTemplate: "FbxAnimStack" {
Properties70: {
P: "Description", "KString", "", "", ""
P: "LocalStart", "KTime", "Time", "",0
P: "LocalStop", "KTime", "Time", "",0
P: "ReferenceStart", "KTime", "Time", "",0
P: "ReferenceStop", "KTime", "Time", "",0
}
}
}
ObjectType: "AnimationLayer" {
Count: 1
PropertyTemplate: "FbxAnimLayer" {
Properties70: {
P: "Weight", "Number", "", "A",100
P: "Mute", "bool", "", "",0
P: "Solo", "bool", "", "",0
P: "Lock", "bool", "", "",0
P: "Color", "ColorRGB", "Color", "",0.8,0.8,0.8
P: "BlendMode", "enum", "", "",0
P: "RotationAccumulationMode", "enum", "", "",0
P: "ScaleAccumulationMode", "enum", "", "",0
P: "BlendModeBypass", "ULongLong", "", "",0
}
}
}
ObjectType: "Geometry" {
Count: 1
PropertyTemplate: "FbxMesh" {
Properties70: {
P: "Color", "ColorRGB", "Color", "",0.8,0.8,0.8
P: "BBoxMin", "Vector3D", "Vector", "",0,0,0
P: "BBoxMax", "Vector3D", "Vector", "",0,0,0
P: "Primary Visibility", "bool", "", "",1
P: "Casts Shadows", "bool", "", "",1
P: "Receive Shadows", "bool", "", "",1
}
}
}
ObjectType: "Material" {
Count: 1
PropertyTemplate: "FbxSurfaceLambert" {
Properties70: {
P: "ShadingModel", "KString", "", "", "Lambert"
P: "MultiLayer", "bool", "", "",0
P: "EmissiveColor", "Color", "", "A",0,0,0
P: "EmissiveFactor", "Number", "", "A",1
P: "AmbientColor", "Color", "", "A",0.2,0.2,0.2
P: "AmbientFactor", "Number", "", "A",1
P: "DiffuseColor", "Color", "", "A",0.8,0.8,0.8
P: "DiffuseFactor", "Number", "", "A",1
P: "Bump", "Vector3D", "Vector", "",0,0,0
P: "NormalMap", "Vector3D", "Vector", "",0,0,0
P: "BumpFactor", "double", "Number", "",1
P: "TransparentColor", "Color", "", "A",0,0,0
P: "TransparencyFactor", "Number", "", "A",0
P: "DisplacementColor", "ColorRGB", "Color", "",0,0,0
P: "DisplacementFactor", "double", "Number", "",1
P: "VectorDisplacementColor", "ColorRGB", "Color", "",0,0,0
P: "VectorDisplacementFactor", "double", "Number", "",1
}
}
}
ObjectType: "Model" {
Count: 1
PropertyTemplate: "FbxNode" {
Properties70: {
P: "QuaternionInterpolate", "enum", "", "",0
P: "RotationOffset", "Vector3D", "Vector", "",0,0,0
P: "RotationPivot", "Vector3D", "Vector", "",0,0,0
P: "ScalingOffset", "Vector3D", "Vector", "",0,0,0
P: "ScalingPivot", "Vector3D", "Vector", "",0,0,0
P: "TranslationActive", "bool", "", "",0
P: "TranslationMin", "Vector3D", "Vector", "",0,0,0
P: "TranslationMax", "Vector3D", "Vector", "",0,0,0
P: "TranslationMinX", "bool", "", "",0
P: "TranslationMinY", "bool", "", "",0
P: "TranslationMinZ", "bool", "", "",0
P: "TranslationMaxX", "bool", "", "",0
P: "TranslationMaxY", "bool", "", "",0
P: "TranslationMaxZ", "bool", "", "",0
P: "RotationOrder", "enum", "", "",0
P: "RotationSpaceForLimitOnly", "bool", "", "",0
P: "RotationStiffnessX", "double", "Number", "",0
P: "RotationStiffnessY", "double", "Number", "",0
P: "RotationStiffnessZ", "double", "Number", "",0
P: "AxisLen", "double", "Number", "",10
P: "PreRotation", "Vector3D", "Vector", "",0,0,0
P: "PostRotation", "Vector3D", "Vector", "",0,0,0
P: "RotationActive", "bool", "", "",0
P: "RotationMin", "Vector3D", "Vector", "",0,0,0
P: "RotationMax", "Vector3D", "Vector", "",0,0,0
P: "RotationMinX", "bool", "", "",0
P: "RotationMinY", "bool", "", "",0
P: "RotationMinZ", "bool", "", "",0
P: "RotationMaxX", "bool", "", "",0
P: "RotationMaxY", "bool", "", "",0
P: "RotationMaxZ", "bool", "", "",0
P: "InheritType", "enum", "", "",0
P: "ScalingActive", "bool", "", "",0
P: "ScalingMin", "Vector3D", "Vector", "",0,0,0
P: "ScalingMax", "Vector3D", "Vector", "",1,1,1
P: "ScalingMinX", "bool", "", "",0
P: "ScalingMinY", "bool", "", "",0
P: "ScalingMinZ", "bool", "", "",0
P: "ScalingMaxX", "bool", "", "",0
P: "ScalingMaxY", "bool", "", "",0
P: "ScalingMaxZ", "bool", "", "",0
P: "GeometricTranslation", "Vector3D", "Vector", "",0,0,0
P: "GeometricRotation", "Vector3D", "Vector", "",0,0,0
P: "GeometricScaling", "Vector3D", "Vector", "",1,1,1
P: "MinDampRangeX", "double", "Number", "",0
P: "MinDampRangeY", "double", "Number", "",0
P: "MinDampRangeZ", "double", "Number", "",0
P: "MaxDampRangeX", "double", "Number", "",0
P: "MaxDampRangeY", "double", "Number", "",0
P: "MaxDampRangeZ", "double", "Number", "",0
P: "MinDampStrengthX", "double", "Number", "",0
P: "MinDampStrengthY", "double", "Number", "",0
P: "MinDampStrengthZ", "double", "Number", "",0
P: "MaxDampStrengthX", "double", "Number", "",0
P: "MaxDampStrengthY", "double", "Number", "",0
P: "MaxDampStrengthZ", "double", "Number", "",0
P: "PreferedAngleX", "double", "Number", "",0
P: "PreferedAngleY", "double", "Number", "",0
P: "PreferedAngleZ", "double", "Number", "",0
P: "LookAtProperty", "object", "", ""
P: "UpVectorProperty", "object", "", ""
P: "Show", "bool", "", "",1
P: "NegativePercentShapeSupport", "bool", "", "",1
P: "DefaultAttributeIndex", "int", "Integer", "",-1
P: "Freeze", "bool", "", "",0
P: "LODBox", "bool", "", "",0
P: "Lcl Translation", "Lcl Translation", "", "A",0,0,0
P: "Lcl Rotation", "Lcl Rotation", "", "A",0,0,0
P: "Lcl Scaling", "Lcl Scaling", "", "A",1,1,1
P: "Visibility", "Visibility", "", "A",1
P: "Visibility Inheritance", "Visibility Inheritance", "", "",1
}
}
}
}
; Object properties
;------------------------------------------------------------------
Objects: {
Geometry: 1907663073408, "Geometry::", "Mesh" {
Vertices: *24 {
a: -0.5,-0.5,0.5,0.5,-0.5,0.5,-0.5,0.5,0.5,0.5,0.5,0.5,-0.5,0.5,-0.5,0.5,0.5,-0.5,-0.5,-0.5,-0.5,0.5,-0.5,-0.5
}
PolygonVertexIndex: *24 {
a: 0,1,3,-3,2,3,5,-5,4,5,7,-7,6,7,1,-1,1,7,5,-4,6,0,2,-5
}
Edges: *12 {
a: 0,2,6,10,3,1,7,5,11,9,15,13
}
GeometryVersion: 124
LayerElementNormal: 0 {
Version: 102
Name: ""
MappingInformationType: "ByPolygonVertex"
ReferenceInformationType: "Direct"
Normals: *72 {
a: 0,0,1,0,0,1,0,0,1,0,0,1,0,1,0,0,1,0,0,1,0,0,1,0,0,0,-1,0,0,-1,0,0,-1,0,0,-1,0,-1,0,0,-1,0,0,-1,0,0,-1,0,1,0,0,1,0,0,1,0,0,1,0,0,-1,0,0,-1,0,0,-1,0,0,-1,0,0
}
NormalsW: *24 {
a: 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
}
}
LayerElementBinormal: 0 {
Version: 102
Name: "map1"
MappingInformationType: "ByPolygonVertex"
ReferenceInformationType: "Direct"
Binormals: *72 {
a: 0,1,-0,0,1,-0,0,1,-0,0,1,-0,0,0,-1,0,0,-1,0,0,-1,0,0,-1,0,-1,0,0,-1,0,0,-1,0,0,-1,0,0,0,1,0,0,1,0,0,1,0,0,1,-0,1,0,-0,1,0,0,1,-0,-0,1,0,0,1,0,0,1,0,0,1,0,0,1,0
}
BinormalsW: *24 {
a: 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
}
}
LayerElementTangent: 0 {
Version: 102
Name: "map1"
MappingInformationType: "ByPolygonVertex"
ReferenceInformationType: "Direct"
Tangents: *72 {
a: 1,-0,-0,1,-0,0,1,-0,0,1,-0,0,1,-0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,-0,1,0,-0,1,0,-0,1,0,-0,0,0,-1,0,0,-1,0,-0,-1,0,0,-1,0,-0,1,0,-0,1,0,-0,1,0,-0,1
}
TangentsW: *24 {
a: 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
}
}
LayerElementUV: 0 {
Version: 101
Name: "map1"
MappingInformationType: "ByPolygonVertex"
ReferenceInformationType: "IndexToDirect"
UV: *28 {
a: 0.375,0,0.625,0,0.375,0.25,0.625,0.25,0.375,0.5,0.625,0.5,0.375,0.75,0.625,0.75,0.375,1,0.625,1,0.875,0,0.875,0.25,0.125,0,0.125,0.25
}
UVIndex: *24 {
a: 0,1,3,2,2,3,5,4,4,5,7,6,6,7,9,8,1,10,11,3,12,0,2,13
}
}
LayerElementSmoothing: 0 {
Version: 102
Name: ""
MappingInformationType: "ByEdge"
ReferenceInformationType: "Direct"
Smoothing: *12 {
a: 0,0,0,0,0,0,0,0,0,0,0,0
}
}
LayerElementMaterial: 0 {
Version: 101
Name: ""
MappingInformationType: "AllSame"
ReferenceInformationType: "IndexToDirect"
Materials: *1 {
a: 0
}
}
Layer: 0 {
Version: 100
LayerElement: {
Type: "LayerElementNormal"
TypedIndex: 0
}
LayerElement: {
Type: "LayerElementBinormal"
TypedIndex: 0
}
LayerElement: {
Type: "LayerElementTangent"
TypedIndex: 0
}
LayerElement: {
Type: "LayerElementMaterial"
TypedIndex: 0
}
LayerElement: {
Type: "LayerElementSmoothing"
TypedIndex: 0
}
LayerElement: {
Type: "LayerElementUV"
TypedIndex: 0
}
}
}
Model: 1907292236800, "Model::animation_driver", "Mesh" {
Version: 232
Properties70: {
P: "RotationActive", "bool", "", "",1
P: "InheritType", "enum", "", "",1
P: "ScalingMax", "Vector3D", "Vector", "",0,0,0
P: "DefaultAttributeIndex", "int", "Integer", "",0
P: "currentUVSet", "KString", "", "U", "map1"
}
Shading: T
Culling: "CullingOff"
}
Material: 1908464858960, "Material::lambert1", "" {
Version: 102
ShadingModel: "lambert"
MultiLayer: 0
Properties70: {
P: "AmbientColor", "Color", "", "A",0,0,0
P: "DiffuseColor", "Color", "", "A",0.5,0.5,0.5
P: "DiffuseFactor", "Number", "", "A",0.800000011920929
P: "TransparencyFactor", "Number", "", "A",1
P: "Emissive", "Vector3D", "Vector", "",0,0,0
P: "Ambient", "Vector3D", "Vector", "",0,0,0
P: "Diffuse", "Vector3D", "Vector", "",0.400000005960464,0.400000005960464,0.400000005960464
P: "Opacity", "double", "Number", "",1
}
}
AnimationStack: 1907674289488, "AnimStack::Take 001", "" {
Properties70: {
P: "LocalStart", "KTime", "Time", "",1924423250
P: "LocalStop", "KTime", "Time", "",230930790000
P: "ReferenceStart", "KTime", "Time", "",1924423250
P: "ReferenceStop", "KTime", "Time", "",230930790000
}
}
AnimationLayer: 1907631731008, "AnimLayer::BaseLayer", "" {
}
}
; Object connections
;------------------------------------------------------------------
Connections: {
;Model::animation_driver, Model::RootNode
C: "OO",1907292236800,0
;AnimLayer::BaseLayer, AnimStack::Take 001
C: "OO",1907631731008,1907674289488
;Geometry::, Model::animation_driver
C: "OO",1907663073408,1907292236800
;Material::lambert1, Model::animation_driver
C: "OO",1908464858960,1907292236800
}
;Takes section
;----------------------------------------------------
Takes: {
Current: "Take 001"
Take: "Take 001" {
FileName: "Take_001.tak"
LocalTime: 1924423250,230930790000
ReferenceTime: 1924423250,230930790000
}
}
""";
}
Debug: View Raw JSON Response
{
"TotalCount": 108,
"Files": [
{
"Ident": "notpointless.chomnr_humanoid_hand_retargeter",
"Path": "Core/Validation/HandRigValidator.cs",
"FileName": "HandRigValidator.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 394704,
"IsPrivate": false,
"Code": "#nullable enable\r\nusing HumanoidHandRetargeter.Mapping;\r\nusing SkeletonModel = HumanoidHandRetargeter.Skeleton.Skeleton;\r\n\r\nnamespace HumanoidHandRetargeter.Validation;\r\n\r\npublic sealed record RigIssue(string Code, string Message, HandSide? Side = null, int? Bone = null);\r\n\r\n/// <summary>Shared validation for automatic/manual mapping, batch processing and the editor.</summary>\r\npublic static class HandRigValidator\r\n{\r\n public static IReadOnlyList<RigIssue> Validate(SkeletonModel skeleton, IEnumerable<HandRigDefinition> hands)\r\n {\r\n ArgumentNullException.ThrowIfNull(skeleton);\r\n ArgumentNullException.ThrowIfNull(hands);\r\n var issues = new List<RigIssue>();\r\n var claimed = new HashSet<int>();\r\n var sides = new HashSet<HandSide>();\r\n var count = 0;\r\n\r\n foreach (var bone in skeleton.Bones)\r\n {\r\n if (!PoseValidator.ValidTransform(bone.RestLocal))\r\n issues.Add(new(\"invalid-rest\", $\"Bone '{bone.Name}' has an invalid rest transform.\", Bone: bone.Index));\r\n var world = skeleton.RestWorld[bone.Index].Pos;\r\n if (!float.IsFinite(world.X) || !float.IsFinite(world.Y) || !float.IsFinite(world.Z))\r\n issues.Add(new(\"invalid-world-rest\", $\"Bone '{bone.Name}' has an invalid accumulated rest position.\", Bone: bone.Index));\r\n }\r\n\r\n foreach (var hand in hands)\r\n {\r\n count++;\r\n if (hand is null)\r\n {\r\n issues.Add(new(\"missing-hand\", \"A hand mapping is missing.\"));\r\n continue;\r\n }\r\n if (!Enum.IsDefined(typeof(HandSide), hand.Side))\r\n issues.Add(new(\"invalid-side\", \"Choose left or right for the hand.\"));\r\n if (!sides.Add(hand.Side))\r\n issues.Add(new(\"duplicate-side\", $\"More than one {hand.Side} hand is mapped.\", hand.Side));\r\n\r\n var arm = new[] { hand.Clavicle, hand.UpperArm, hand.Forearm, hand.Wrist }\r\n .Where(b => b.HasValue).Select(b => b!.Value).ToArray();\r\n CheckChain(arm, hand.Side, \"arm\", allowZeroLength: true);\r\n var roles = new HashSet<string>(StringComparer.Ordinal);\r\n foreach (var digit in hand.Digits)\r\n {\r\n if (digit is null)\r\n {\r\n issues.Add(new(\"missing-digit\", \"A digit mapping is missing.\", hand.Side));\r\n continue;\r\n }\r\n var key = digit.Role == DigitRole.Extra ? \"Extra:\" + digit.ExtraSlot : digit.Role.ToString();\r\n if (!Enum.IsDefined(typeof(DigitRole), digit.Role)\r\n || (digit.Role == DigitRole.Extra && string.IsNullOrWhiteSpace(digit.ExtraSlot)))\r\n issues.Add(new(\"invalid-digit-role\", \"Choose a digit role or name its extra slot.\", hand.Side));\r\n if (!roles.Add(key))\r\n issues.Add(new(\"duplicate-digit-role\", $\"{hand.Side} {key} is mapped more than once.\", hand.Side));\r\n if (digit.Segments.Count == 0)\r\n issues.Add(new(\"empty-digit\", $\"{hand.Side} {key} needs at least one segment.\", hand.Side));\r\n var chain = digit.Bones.ToArray();\r\n CheckChain(chain, hand.Side, key, allowZeroLength: false, tip: digit.Tip);\r\n if (chain.Length > 0 && Valid(chain[0]) && Valid(hand.Wrist)\r\n && !skeleton.DescendsFrom(chain[0], hand.Wrist))\r\n issues.Add(new(\"wrong-wrist\", $\"{hand.Side} {key} must descend from its wrist.\", hand.Side, chain[0]));\r\n }\r\n foreach (var helper in hand.TwistOrHelperBones)\r\n {\r\n Claim(helper, hand.Side);\r\n var armRoot = arm[0];\r\n if (Valid(helper) && Valid(armRoot) && !skeleton.DescendsFrom(helper, armRoot))\r\n issues.Add(new(\"wrong-helper-root\", $\"{hand.Side} helper must belong to its mapped arm or hand hierarchy.\", hand.Side, helper));\r\n }\r\n }\r\n if (count == 0)\r\n issues.Add(new(\"no-hands\", \"Map at least one wrist before retargeting.\"));\r\n return issues.AsReadOnly();\r\n\r\n bool Valid(int bone) => bone >= 0 && bone < skeleton.Count;\r\n void Claim(int bone, HandSide side)\r\n {\r\n if (!Valid(bone))\r\n issues.Add(new(\"invalid-bone\", $\"{side} mapping references a missing bone.\", side, bone));\r\n else if (!claimed.Add(bone))\r\n issues.Add(new(\"overlapping-bone\", $\"Bone '{skeleton[bone].Name}' is assigned to multiple roles.\", side, bone));\r\n }\r\n void CheckChain(int[] bones, HandSide side, string role, bool allowZeroLength, int? tip = null)\r\n {\r\n for (var i = 0; i < bones.Length; i++)\r\n {\r\n Claim(bones[i], side);\r\n if (i == 0 || !Valid(bones[i - 1]) || !Valid(bones[i])) continue;\r\n if (!skeleton.DescendsFrom(bones[i], bones[i - 1]))\r\n issues.Add(new(\"chain-order\", $\"{side} {role} joints must follow their hierarchy.\", side, bones[i]));\r\n if (!allowZeroLength && bones[i] != tip &&\r\n System.Numerics.Vector3.DistanceSquared(skeleton.RestWorld[bones[i - 1]].Pos,\r\n skeleton.RestWorld[bones[i]].Pos) < 1e-12f)\r\n issues.Add(new(\"zero-length-digit\", $\"{side} {role} has coincident joints; correct the chain or classify a helper.\", side, bones[i]));\r\n }\r\n }\r\n }\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_humanoid_hand_retargeter",
"Path": "Formats/Fbx/FbxScene.cs",
"FileName": "FbxScene.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 394704,
"IsPrivate": false,
"Code": "// Reused from humanoid-retargeter 26084c96c3fc870aaf9a5bd798de063ce2fd62df; specialized where needed for hand assets.\r\n#nullable enable annotations\r\n\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.Numerics;\r\n\r\nnamespace HumanoidHandRetargeter.Formats.Fbx;\r\n\r\nusing Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/HumanoidHandRetargeter/Assembly.cs)\r\n\r\n/// <summary>One <c>P</c> entry of a <c>Properties70</c> block: name, FBX type string, values.</summary>\r\npublic sealed class FbxProperty70\r\n{\r\n /// <summary>Property name (e.g. <c>\"Lcl Translation\"</c>, <c>\"PreRotation\"</c>, <c>\"d|X\"</c>).</summary>\r\n public string Name { get; }\r\n\r\n /// <summary>FBX type string (e.g. <c>\"Lcl Translation\"</c>, <c>\"enum\"</c>, <c>\"Number\"</c>).</summary>\r\n public string Type { get; }\r\n\r\n /// <summary>Raw values (props 4.. of the P node).</summary>\r\n public IReadOnlyList<object> Values { get; }\r\n\r\n internal FbxProperty70(string name, string type, IReadOnlyList<object> values)\r\n {\r\n Name = name;\r\n Type = type;\r\n Values = values;\r\n }\r\n\r\n /// <summary>Value <paramref name=\"i\"/> as a double (tolerant of int/long/float storage).</summary>\r\n public double GetDouble(int i = 0) => Convert.ToDouble(Values[i], System.Globalization.CultureInfo.InvariantCulture);\r\n\r\n /// <summary>Value <paramref name=\"i\"/> as an int (tolerant of long/double storage).</summary>\r\n public int GetInt(int i = 0) => Convert.ToInt32(Values[i], System.Globalization.CultureInfo.InvariantCulture);\r\n\r\n /// <summary>First three values as a vector.</summary>\r\n public Vector3 GetVector3()\r\n => new((float)GetDouble(0), (float)GetDouble(1), (float)GetDouble(2));\r\n}\r\n\r\n/// <summary>\r\n/// One object from the FBX <c>Objects</c> section (Model, NodeAttribute, AnimationStack,\r\n/// AnimationLayer, AnimationCurveNode, AnimationCurve, Pose, ...).\r\n/// </summary>\r\npublic sealed class FbxObject\r\n{\r\n /// <summary>Unique object id (the first property of the object node).</summary>\r\n public long Id { get; }\r\n\r\n /// <summary>Node type \u2014 the FBX node name: \"Model\", \"AnimationCurve\", ...</summary>\r\n public string NodeType { get; }\r\n\r\n /// <summary>Object name (namespace prefixes like <c>mixamorig1:</c> preserved).</summary>\r\n public string Name { get; }\r\n\r\n /// <summary>Object sub-class (third property): \"LimbNode\", \"Null\", \"Root\", \"Mesh\", ...</summary>\r\n public string SubClass { get; }\r\n\r\n /// <summary>The underlying token-tree node.</summary>\r\n public FbxNode Node { get; }\r\n\r\n /// <summary>Own Properties70 entries by name (template defaults NOT merged \u2014 see <see cref=\"FbxScene.FindProperty\"/>).</summary>\r\n public IReadOnlyDictionary<string, FbxProperty70> Properties { get; }\r\n\r\n /// <summary>For Models: the parent Model via an OO connection, or null at the scene root.</summary>\r\n public FbxObject? ModelParent { get; internal set; }\r\n\r\n /// <summary>For Models: child Models via OO connections, in connection order.</summary>\r\n public List<FbxObject> ModelChildren { get; } = new();\r\n\r\n internal FbxObject(\r\n long id, string nodeType, string name, string subClass, FbxNode node,\r\n IReadOnlyDictionary<string, FbxProperty70> properties)\r\n {\r\n Id = id;\r\n NodeType = nodeType;\r\n Name = name;\r\n SubClass = subClass;\r\n Node = node;\r\n Properties = properties;\r\n }\r\n\r\n /// <inheritdoc />\r\n public override string ToString() => $\"{NodeType} '{Name}' ({SubClass}) #{Id}\";\r\n}\r\n\r\n/// <summary>A single animation curve: keyframes for one scalar channel.</summary>\r\npublic sealed class FbxAnimCurve\r\n{\r\n /// <summary>KTIME ticks per second (FBX constant).</summary>\r\n public const long TicksPerSecond = 46186158000L;\r\n\r\n /// <summary>Key times in KTIME ticks, ascending.</summary>\r\n public long[] KeyTimes { get; }\r\n\r\n /// <summary>Key values, parallel to <see cref=\"KeyTimes\"/>.</summary>\r\n public float[] KeyValues { get; }\r\n\r\n internal FbxAnimCurve(long[] keyTimes, float[] keyValues)\r\n {\r\n KeyTimes = keyTimes;\r\n KeyValues = keyValues;\r\n }\r\n\r\n /// <summary>\r\n /// Samples the curve at a KTIME tick: linear interpolation between keys, constant\r\n /// extrapolation outside the key range.\r\n /// </summary>\r\n public float Evaluate(long ticks)\r\n {\r\n var times = KeyTimes;\r\n int n = times.Length;\r\n if (n == 0)\r\n return 0f;\r\n if (ticks <= times[0])\r\n return KeyValues[0];\r\n if (ticks >= times[n - 1])\r\n return KeyValues[n - 1];\r\n\r\n int hi = Array.BinarySearch(times, ticks);\r\n if (hi >= 0)\r\n return KeyValues[hi];\r\n hi = ~hi; // first index with time > ticks; >=1 and <=n-1 here\r\n int lo = hi - 1;\r\n double span = times[hi] - times[lo];\r\n double t = span <= 0 ? 0.0 : (ticks - times[lo]) / span;\r\n return (float)(KeyValues[lo] + (KeyValues[hi] - KeyValues[lo]) * t);\r\n }\r\n}\r\n\r\n/// <summary>\r\n/// An AnimationCurveNode: up to three channel curves (X/Y/Z) targeting one transform\r\n/// property (<c>\"Lcl Translation\"</c> / <c>\"Lcl Rotation\"</c> / <c>\"Lcl Scaling\"</c>) of one Model.\r\n/// </summary>\r\npublic sealed class FbxAnimCurveNode\r\n{\r\n /// <summary>The curve node object.</summary>\r\n public FbxObject Object { get; }\r\n\r\n /// <summary>Channel curves by axis ('X'/'Y'/'Z'), from <c>\"d|X\"</c>-style OP connections.</summary>\r\n public Dictionary<char, FbxAnimCurve> Channels { get; } = new();\r\n\r\n internal FbxAnimCurveNode(FbxObject obj) => Object = obj;\r\n\r\n /// <summary>\r\n /// Samples one component: the channel curve when connected, else the curve node's static\r\n /// <c>d|X</c> default, else <paramref name=\"fallback\"/> (the model's Lcl value).\r\n /// </summary>\r\n public float Component(char axis, long ticks, float fallback)\r\n {\r\n if (Channels.TryGetValue(axis, out var curve))\r\n return curve.Evaluate(ticks);\r\n if (Object.Properties.TryGetValue(\"d|\" + axis, out var def) && def.Values.Count > 0)\r\n return (float)def.GetDouble();\r\n return fallback;\r\n }\r\n}\r\n\r\n/// <summary>One AnimationStack with its curve bindings, flattened across its layers.</summary>\r\npublic sealed class FbxAnimStack\r\n{\r\n /// <summary>The stack object (its Name is the clip name, e.g. \"mixamo.com\").</summary>\r\n public FbxObject Object { get; }\r\n\r\n /// <summary>\r\n /// Curve nodes bound to model transform properties:\r\n /// (model id, property name) \u2192 curve node. When several layers animate the same property\r\n /// the first connected layer wins (layer blending is not supported).\r\n /// </summary>\r\n public Dictionary<(long ModelId, string Property), FbxAnimCurveNode> Bindings { get; } = new();\r\n\r\n /// <summary>LocalStart from the stack's Properties70, in KTIME ticks (0 when absent).</summary>\r\n public long LocalStart { get; internal set; }\r\n\r\n /// <summary>LocalStop from the stack's Properties70, in KTIME ticks (0 when absent).</summary>\r\n public long LocalStop { get; internal set; }\r\n\r\n internal FbxAnimStack(FbxObject obj) => Object = obj;\r\n}\r\n\r\n/// <summary>\r\n/// Semantic object graph built from an FBX token tree: typed objects, the Model hierarchy\r\n/// (OO connections), animation bindings (OP connections), Properties70 lookup with\r\n/// Definitions-template defaults, bind poses, and GlobalSettings.\r\n/// </summary>\r\npublic sealed class FbxScene\r\n{\r\n /// <summary>All parsed objects by id.</summary>\r\n public IReadOnlyDictionary<long, FbxObject> ObjectsById => _objectsById;\r\n\r\n /// <summary>All Model objects in document order.</summary>\r\n public IReadOnlyList<FbxObject> Models => _models;\r\n\r\n /// <summary>All animation stacks in document order.</summary>\r\n public IReadOnlyList<FbxAnimStack> Stacks => _stacks;\r\n\r\n /// <summary>World-space bind matrices from Pose/BindPose nodes, by model id (row-vector layout).</summary>\r\n public IReadOnlyDictionary<long, Matrix4x4> BindPose => _bindPose;\r\n\r\n /// <summary>GlobalSettings UnitScaleFactor: source-unit \u2192 centimeter factor (FBX default 1 = cm).</summary>\r\n public double UnitScaleFactor { get; private set; } = 1.0;\r\n\r\n /// <summary>GlobalSettings UpAxis (default 1 = Y).</summary>\r\n public int UpAxis { get; private set; } = 1;\r\n\r\n /// <summary>GlobalSettings UpAxisSign (default +1).</summary>\r\n public int UpAxisSign { get; private set; } = 1;\r\n\r\n /// <summary>GlobalSettings FrontAxis (default 2 = Z).</summary>\r\n public int FrontAxis { get; private set; } = 2;\r\n\r\n /// <summary>GlobalSettings FrontAxisSign (default +1).</summary>\r\n public int FrontAxisSign { get; private set; } = 1;\r\n\r\n /// <summary>GlobalSettings CoordAxis (default 0 = X).</summary>\r\n public int CoordAxis { get; private set; } = 0;\r\n\r\n /// <summary>GlobalSettings CoordAxisSign (default +1).</summary>\r\n public int CoordAxisSign { get; private set; } = 1;\r\n\r\n /// <summary>GlobalSettings OriginalUpAxis (-1 when not recorded).</summary>\r\n public int OriginalUpAxis { get; private set; } = -1;\r\n\r\n /// <summary>GlobalSettings TimeMode (FbxTime::EMode enum value; 0 = default mode).</summary>\r\n public int TimeMode { get; private set; }\r\n\r\n /// <summary>GlobalSettings CustomFrameRate (-1 when not recorded).</summary>\r\n public double CustomFrameRate { get; private set; } = -1.0;\r\n\r\n /// <summary>\r\n /// Native frame rate (fps) implied by <see cref=\"TimeMode\"/> /\r\n /// <see cref=\"CustomFrameRate\"/>. This is the rate the source timeline was authored at \u2014\r\n /// external frame ranges (e.g. Unity <c>.meta</c> clipAnimations) are expressed in it.\r\n /// The default mode (0) and unknown values map to 30 fps (this importer's resample default).\r\n /// </summary>\r\n public double FrameRate => TimeModeFrameRate(TimeMode, CustomFrameRate);\r\n\r\n /// <summary>FbxTime::EMode \u2192 frames per second (eCustom uses the custom rate).</summary>\r\n internal static double TimeModeFrameRate(int timeMode, double customFrameRate) => timeMode switch\r\n {\r\n 1 => 120.0, // eFrames120\r\n 2 => 100.0, // eFrames100\r\n 3 => 60.0, // eFrames60\r\n 4 => 50.0, // eFrames50\r\n 5 => 48.0, // eFrames48\r\n 6 => 30.0, // eFrames30\r\n 7 => 30.0, // eFrames30Drop\r\n 8 => 30.0 / 1.001, // eNTSCDropFrame (29.97)\r\n 9 => 30.0 / 1.001, // eNTSCFullFrame (29.97)\r\n 10 => 25.0, // ePAL\r\n 11 => 24.0, // eFrames24\r\n 12 => 1000.0, // eFrames1000\r\n 13 => 24.0 / 1.001, // eFilmFullFrame (23.976)\r\n 14 => customFrameRate > 0 ? customFrameRate : 30.0, // eCustom\r\n 15 => 96.0, // eFrames96\r\n 16 => 72.0, // eFrames72\r\n 17 => 60.0 / 1.001, // eFrames59dot94\r\n 18 => 120.0 / 1.001, // eFrames119dot88\r\n _ => 30.0, // eDefaultMode / unknown\r\n };\r\n\r\n private readonly Dictionary<long, FbxObject> _objectsById = new();\r\n private readonly List<FbxObject> _models = new();\r\n private readonly List<FbxAnimStack> _stacks = new();\r\n private readonly Dictionary<long, Matrix4x4> _bindPose = new();\r\n private readonly Dictionary<string, Dictionary<string, FbxProperty70>> _templates = new();\r\n\r\n private FbxScene()\r\n {\r\n }\r\n\r\n /// <summary>\r\n /// Looks up a property on an object: its own Properties70 first, then the Definitions\r\n /// property template for its node type. Returns null when neither defines it.\r\n /// </summary>\r\n public FbxProperty70? FindProperty(FbxObject obj, string name)\r\n {\r\n if (obj.Properties.TryGetValue(name, out var own))\r\n return own;\r\n if (_templates.TryGetValue(obj.NodeType, out var template) &&\r\n template.TryGetValue(name, out var def))\r\n return def;\r\n return null;\r\n }\r\n\r\n /// <summary>Vector property with template fallback and a hard default.</summary>\r\n public Vector3 GetVector3(FbxObject obj, string name, Vector3 fallback)\r\n {\r\n var p = FindProperty(obj, name);\r\n return p is { Values.Count: >= 3 } ? p.GetVector3() : fallback;\r\n }\r\n\r\n /// <summary>Double property with template fallback and a hard default.</summary>\r\n public double GetDouble(FbxObject obj, string name, double fallback)\r\n {\r\n var p = FindProperty(obj, name);\r\n return p is { Values.Count: >= 1 } ? p.GetDouble() : fallback;\r\n }\r\n\r\n /// <summary>Int property with template fallback and a hard default.</summary>\r\n public int GetInt(FbxObject obj, string name, int fallback)\r\n {\r\n var p = FindProperty(obj, name);\r\n return p is { Values.Count: >= 1 } ? p.GetInt() : fallback;\r\n }\r\n\r\n /// <summary>Builds the semantic graph from a tokenized FBX document.</summary>\r\n public static FbxScene Build(FbxNode root)\r\n {\r\n ArgumentNullException.ThrowIfNull(root);\r\n var scene = new FbxScene();\r\n scene.ReadGlobalSettings(root.Child(\"GlobalSettings\"));\r\n scene.ReadTemplates(root.Child(\"Definitions\"));\r\n scene.ReadObjects(root.Child(\"Objects\"));\r\n scene.ReadConnections(root.Child(\"Connections\"));\r\n return scene;\r\n }\r\n\r\n // ------------------------------------------------------------------ objects\r\n\r\n private void ReadObjects(FbxNode? objects)\r\n {\r\n if (objects is null)\r\n return;\r\n\r\n long syntheticId = -1000; // for exotic files whose objects lack numeric ids\r\n\r\n foreach (var node in objects.Children)\r\n {\r\n // Standard 7x object layout: (id:int64, \"Name\\0\\x01Class\":string, \"SubClass\":string).\r\n long id;\r\n int nameProp;\r\n if (node.Properties.Count >= 1 && node.Properties[0] is long or int)\r\n {\r\n id = node.Prop<long>(0);\r\n nameProp = 1;\r\n }\r\n else\r\n {\r\n id = syntheticId--;\r\n nameProp = 0;\r\n }\r\n\r\n string name = \"\", subClass = \"\";\r\n if (node.Properties.Count > nameProp && node.Properties[nameProp] is string rawName)\r\n (name, _) = FbxNode.SplitName(rawName);\r\n if (node.Properties.Count > nameProp + 1 && node.Properties[nameProp + 1] is string sub)\r\n subClass = sub;\r\n\r\n var obj = new FbxObject(id, node.Name, name, subClass, node, ReadProperties70(node));\r\n _objectsById.TryAdd(obj.Id, obj);\r\n\r\n switch (node.Name)\r\n {\r\n case \"Model\":\r\n _models.Add(obj);\r\n break;\r\n case \"Pose\":\r\n if (subClass == \"BindPose\")\r\n ReadBindPose(node);\r\n break;\r\n }\r\n }\r\n }\r\n\r\n private void ReadBindPose(FbxNode pose)\r\n {\r\n foreach (var poseNode in pose.ChildrenNamed(\"PoseNode\"))\r\n {\r\n var nodeId = poseNode.Child(\"Node\");\r\n var matrix = poseNode.Child(\"Matrix\");\r\n if (nodeId is null || matrix is null)\r\n continue;\r\n\r\n double[] m = matrix.AsDoubleArray(0);\r\n if (m.Length < 16)\r\n continue;\r\n\r\n // FBX matrices are stored as 16 doubles with translation in elements 12..14 \u2014\r\n // the same memory layout as System.Numerics row-vector matrices.\r\n _bindPose[nodeId.Prop<long>(0)] = new Matrix4x4(\r\n (float)m[0], (float)m[1], (float)m[2], (float)m[3],\r\n (float)m[4], (float)m[5], (float)m[6], (float)m[7],\r\n (float)m[8], (float)m[9], (float)m[10], (float)m[11],\r\n (float)m[12], (float)m[13], (float)m[14], (float)m[15]);\r\n }\r\n }\r\n\r\n private static IReadOnlyDictionary<string, FbxProperty70> ReadProperties70(FbxNode node)\r\n {\r\n var result = new Dictionary<string, FbxProperty70>(StringComparer.Ordinal);\r\n var block = node.Child(\"Properties70\") ?? node.Child(\"Properties60\");\r\n if (block is null)\r\n return result;\r\n\r\n foreach (var p in block.ChildrenNamed(\"P\"))\r\n {\r\n if (p.Properties.Count < 2 || p.Properties[0] is not string name)\r\n continue;\r\n string type = p.Properties[1] as string ?? \"\";\r\n int valueStart = Math.Min(4, p.Properties.Count);\r\n var values = p.Properties.GetRange(valueStart, p.Properties.Count - valueStart);\r\n result[name] = new FbxProperty70(name, type, values);\r\n }\r\n return result;\r\n }\r\n\r\n // ------------------------------------------------------------------ templates\r\n\r\n private void ReadTemplates(FbxNode? definitions)\r\n {\r\n if (definitions is null)\r\n return;\r\n\r\n foreach (var objectType in definitions.ChildrenNamed(\"ObjectType\"))\r\n {\r\n if (objectType.Properties.Count < 1 || objectType.Properties[0] is not string typeName)\r\n continue;\r\n var template = objectType.Child(\"PropertyTemplate\");\r\n if (template is null)\r\n continue;\r\n _templates[typeName] = new Dictionary<string, FbxProperty70>(\r\n (Dictionary<string, FbxProperty70>)ReadProperties70(template), StringComparer.Ordinal);\r\n }\r\n }\r\n\r\n // ------------------------------------------------------------------ global settings\r\n\r\n private void ReadGlobalSettings(FbxNode? globalSettings)\r\n {\r\n if (globalSettings is null)\r\n return;\r\n\r\n var props = ReadProperties70(globalSettings);\r\n\r\n double D(string name, double fallback)\r\n => props.TryGetValue(name, out var p) && p.Values.Count > 0 ? p.GetDouble() : fallback;\r\n int I(string name, int fallback)\r\n => props.TryGetValue(name, out var p) && p.Values.Count > 0 ? p.GetInt() : fallback;\r\n\r\n UnitScaleFactor = D(\"UnitScaleFactor\", 1.0);\r\n UpAxis = I(\"UpAxis\", 1);\r\n UpAxisSign = I(\"UpAxisSign\", 1);\r\n FrontAxis = I(\"FrontAxis\", 2);\r\n FrontAxisSign = I(\"FrontAxisSign\", 1);\r\n CoordAxis = I(\"CoordAxis\", 0);\r\n CoordAxisSign = I(\"CoordAxisSign\", 1);\r\n OriginalUpAxis = I(\"OriginalUpAxis\", -1);\r\n TimeMode = I(\"TimeMode\", 0);\r\n CustomFrameRate = D(\"CustomFrameRate\", -1.0);\r\n }\r\n\r\n // ------------------------------------------------------------------ connections\r\n\r\n private void ReadConnections(FbxNode? connections)\r\n {\r\n if (connections is null)\r\n return;\r\n\r\n // First pass: typed object wiring that doesn't depend on other connections.\r\n // OO child \u2192 parent: Model\u2192Model (hierarchy), AnimationLayer\u2192AnimationStack,\r\n // AnimationCurveNode\u2192AnimationLayer\r\n // OP child \u2192 parent.property:\r\n // AnimationCurveNode\u2192Model (\"Lcl Translation\"/\"Lcl Rotation\"/\"Lcl Scaling\")\r\n // AnimationCurve\u2192AnimationCurveNode (\"d|X\"/\"d|Y\"/\"d|Z\")\r\n var layerToStack = new Dictionary<long, FbxObject>(); // layer id \u2192 stack object\r\n var curveNodeToLayers = new Dictionary<long, List<long>>(); // curve node id \u2192 layer ids\r\n var curveNodeTargets = new Dictionary<long, (long ModelId, string Property)>();\r\n var curveNodes = new Dictionary<long, FbxAnimCurveNode>();\r\n var stacksById = new Dictionary<long, FbxAnimStack>();\r\n\r\n foreach (var c in connections.ChildrenNamed(\"C\"))\r\n {\r\n if (c.Properties.Count < 3 ||\r\n c.Properties[0] is not string kind ||\r\n c.Properties[1] is not (long or int) ||\r\n c.Properties[2] is not (long or int))\r\n continue;\r\n\r\n long srcId = c.Prop<long>(1); // child\r\n long dstId = c.Prop<long>(2); // parent\r\n if (!_objectsById.TryGetValue(srcId, out var src))\r\n continue;\r\n _objectsById.TryGetValue(dstId, out var dst); // dst id 0 = scene root (no object)\r\n\r\n if (kind == \"OO\")\r\n {\r\n if (src.NodeType == \"Model\" && dst?.NodeType == \"Model\")\r\n {\r\n if (src.ModelParent is null) // first parent wins on instancing\r\n {\r\n src.ModelParent = dst;\r\n dst.ModelChildren.Add(src);\r\n }\r\n }\r\n else if (src.NodeType == \"AnimationLayer\" && dst?.NodeType == \"AnimationStack\")\r\n {\r\n layerToStack[srcId] = dst;\r\n }\r\n else if (src.NodeType == \"AnimationCurveNode\" && dst?.NodeType == \"AnimationLayer\")\r\n {\r\n if (!curveNodeToLayers.TryGetValue(srcId, out var layers))\r\n curveNodeToLayers[srcId] = layers = new List<long>();\r\n layers.Add(dstId);\r\n }\r\n }\r\n else if (kind == \"OP\" && c.Properties.Count >= 4 && c.Properties[3] is string property)\r\n {\r\n if (src.NodeType == \"AnimationCurveNode\" && dst?.NodeType == \"Model\")\r\n {\r\n curveNodeTargets[srcId] = (dstId, property);\r\n }\r\n else if (src.NodeType == \"AnimationCurve\" && dst?.NodeType == \"AnimationCurveNode\")\r\n {\r\n // Channel name \"d|X\" \u2192 axis 'X'.\r\n char axis = property.Length > 0 ? property[^1] : '?';\r\n if (axis is 'X' or 'Y' or 'Z')\r\n {\r\n if (!curveNodes.TryGetValue(dstId, out var cn))\r\n curveNodes[dstId] = cn = new FbxAnimCurveNode(dst);\r\n var curve = ReadCurve(src.Node);\r\n if (curve is not null && !cn.Channels.ContainsKey(axis))\r\n cn.Channels[axis] = curve;\r\n }\r\n }\r\n }\r\n }\r\n\r\n // Second pass: assemble stacks (document order) with flattened bindings.\r\n foreach (var obj in _objectsById.Values)\r\n {\r\n if (obj.NodeType != \"AnimationStack\")\r\n continue;\r\n var stack = new FbxAnimStack(obj)\r\n {\r\n LocalStart = ReadTimeProperty(obj, \"LocalStart\"),\r\n LocalStop = ReadTimeProperty(obj, \"LocalStop\"),\r\n };\r\n stacksById[obj.Id] = stack;\r\n _stacks.Add(stack);\r\n }\r\n\r\n foreach (var (curveNodeId, target) in curveNodeTargets)\r\n {\r\n if (!curveNodeToLayers.TryGetValue(curveNodeId, out var layerIds))\r\n continue;\r\n if (!curveNodes.TryGetValue(curveNodeId, out var cn))\r\n {\r\n // Curve node with defaults but no connected curves still drives the property.\r\n if (!_objectsById.TryGetValue(curveNodeId, out var cnObj))\r\n continue;\r\n cn = new FbxAnimCurveNode(cnObj);\r\n }\r\n\r\n foreach (var layerId in layerIds)\r\n {\r\n if (!layerToStack.TryGetValue(layerId, out var stackObj) ||\r\n !stacksById.TryGetValue(stackObj.Id, out var stack))\r\n continue;\r\n stack.Bindings.TryAdd((target.ModelId, target.Property), cn);\r\n }\r\n }\r\n }\r\n\r\n private long ReadTimeProperty(FbxObject obj, string name)\r\n {\r\n var p = FindProperty(obj, name);\r\n if (p is null || p.Values.Count < 1)\r\n return 0;\r\n try\r\n {\r\n return Convert.ToInt64(p.Values[0], System.Globalization.CultureInfo.InvariantCulture);\r\n }\r\n // ArithmeticException covers OverflowException, which is not s&box-whitelisted\r\n catch (Exception ex) when (ex is InvalidCastException or ArithmeticException or FormatException)\r\n {\r\n return 0;\r\n }\r\n }\r\n\r\n private static FbxAnimCurve? ReadCurve(FbxNode curveNode)\r\n {\r\n var timesNode = curveNode.Child(\"KeyTime\");\r\n var valuesNode = curveNode.Child(\"KeyValueFloat\") ?? curveNode.Child(\"KeyValue\");\r\n if (timesNode is null || valuesNode is null)\r\n return null;\r\n\r\n long[] times = timesNode.AsLongArray(0);\r\n float[] values = valuesNode.AsFloatArray(0);\r\n if (times.Length == 0 || times.Length != values.Length)\r\n return null;\r\n return new FbxAnimCurve(times, values);\r\n }\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_humanoid_hand_retargeter",
"Path": "Target/AnimationDriverMesh.cs",
"FileName": "AnimationDriverMesh.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 394704,
"IsPrivate": false,
"Code": "namespace HumanoidHandRetargeter.Target;\r\n\r\n/// <summary>Small hidden mesh required by SceneModel's animation owner.\r\n/// Cube FBX layout from ufbx's public-domain maya_cube_7400_ascii fixture:\r\n/// https://github.com/ufbx/ufbx/blob/master/data/maya_cube_7400_ascii.fbx\r\n/// Used under ufbx LICENSE alternative B (public domain).</summary>\r\npublic static class AnimationDriverMesh\r\n{\r\n public static readonly string Fbx = Text.Replace(\"\\r\\n\", \"\\n\").Replace(\"\\n\", \"\\r\\n\");\r\n private const string Text = \"\"\"\r\n; FBX 7.4.0 project file\r\n; ----------------------------------------------------\r\n\r\nFBXHeaderExtension: {\r\n\tFBXHeaderVersion: 1003\r\n\tFBXVersion: 7400\r\n\tCreationTimeStamp: {\r\n\t\tVersion: 1000\r\n\t\tYear: 2020\r\n\t\tMonth: 3\r\n\t\tDay: 31\r\n\t\tHour: 21\r\n\t\tMinute: 36\r\n\t\tSecond: 10\r\n\t\tMillisecond: 814\r\n\t}\r\n\tCreator: \"FBX SDK/FBX Plugins version 2019.2\"\r\n\tSceneInfo: \"SceneInfo::GlobalInfo\", \"UserData\" {\r\n\t\tType: \"UserData\"\r\n\t\tVersion: 100\r\n\t\tMetaData: {\r\n\t\t\tVersion: 100\r\n\t\t\tTitle: \"\"\r\n\t\t\tSubject: \"\"\r\n\t\t\tAuthor: \"\"\r\n\t\t\tKeywords: \"\"\r\n\t\t\tRevision: \"\"\r\n\t\t\tComment: \"\"\r\n\t\t}\r\n\t\tProperties70: {\r\n\t\t\tP: \"DocumentUrl\", \"KString\", \"Url\", \"\", \"animation_driver.fbx\"\r\n\t\t\tP: \"SrcDocumentUrl\", \"KString\", \"Url\", \"\", \"animation_driver.fbx\"\r\n\t\t\tP: \"Original\", \"Compound\", \"\", \"\"\r\n\t\t\tP: \"Original|ApplicationVendor\", \"KString\", \"\", \"\", \"Autodesk\"\r\n\t\t\tP: \"Original|ApplicationName\", \"KString\", \"\", \"\", \"Maya\"\r\n\t\t\tP: \"Original|ApplicationVersion\", \"KString\", \"\", \"\", \"201900\"\r\n\t\t\tP: \"Original|DateTime_GMT\", \"DateTime\", \"\", \"\", \"31/03/2020 18:36:10.811\"\r\n\t\t\tP: \"Original|FileName\", \"KString\", \"\", \"\", \"animation_driver.fbx\"\r\n\t\t\tP: \"LastSaved\", \"Compound\", \"\", \"\"\r\n\t\t\tP: \"LastSaved|ApplicationVendor\", \"KString\", \"\", \"\", \"Autodesk\"\r\n\t\t\tP: \"LastSaved|ApplicationName\", \"KString\", \"\", \"\", \"Maya\"\r\n\t\t\tP: \"LastSaved|ApplicationVersion\", \"KString\", \"\", \"\", \"201900\"\r\n\t\t\tP: \"LastSaved|DateTime_GMT\", \"DateTime\", \"\", \"\", \"31/03/2020 18:36:10.811\"\r\n\t\t\tP: \"Original|ApplicationActiveProject\", \"KString\", \"\", \"\", \"animation_driver.fbx\"\r\n\t\t}\r\n\t}\r\n}\r\nGlobalSettings: {\r\n\tVersion: 1000\r\n\tProperties70: {\r\n\t\tP: \"UpAxis\", \"int\", \"Integer\", \"\",1\r\n\t\tP: \"UpAxisSign\", \"int\", \"Integer\", \"\",1\r\n\t\tP: \"FrontAxis\", \"int\", \"Integer\", \"\",2\r\n\t\tP: \"FrontAxisSign\", \"int\", \"Integer\", \"\",1\r\n\t\tP: \"CoordAxis\", \"int\", \"Integer\", \"\",0\r\n\t\tP: \"CoordAxisSign\", \"int\", \"Integer\", \"\",1\r\n\t\tP: \"OriginalUpAxis\", \"int\", \"Integer\", \"\",1\r\n\t\tP: \"OriginalUpAxisSign\", \"int\", \"Integer\", \"\",1\r\n\t\tP: \"UnitScaleFactor\", \"double\", \"Number\", \"\",1\r\n\t\tP: \"OriginalUnitScaleFactor\", \"double\", \"Number\", \"\",1\r\n\t\tP: \"AmbientColor\", \"ColorRGB\", \"Color\", \"\",0,0,0\r\n\t\tP: \"DefaultCamera\", \"KString\", \"\", \"\", \"Producer Perspective\"\r\n\t\tP: \"TimeMode\", \"enum\", \"\", \"\",11\r\n\t\tP: \"TimeProtocol\", \"enum\", \"\", \"\",2\r\n\t\tP: \"SnapOnFrameMode\", \"enum\", \"\", \"\",0\r\n\t\tP: \"TimeSpanStart\", \"KTime\", \"Time\", \"\",1924423250\r\n\t\tP: \"TimeSpanStop\", \"KTime\", \"Time\", \"\",384884650000\r\n\t\tP: \"CustomFrameRate\", \"double\", \"Number\", \"\",-1\r\n\t\tP: \"TimeMarker\", \"Compound\", \"\", \"\"\r\n\t\tP: \"CurrentTimeMarker\", \"int\", \"Integer\", \"\",-1\r\n\t}\r\n}\r\n\r\n; Documents Description\r\n;------------------------------------------------------------------\r\n\r\nDocuments: {\r\n\tCount: 1\r\n\tDocument: 1907658701024, \"\", \"Scene\" {\r\n\t\tProperties70: {\r\n\t\t\tP: \"SourceObject\", \"object\", \"\", \"\"\r\n\t\t\tP: \"ActiveAnimStackName\", \"KString\", \"\", \"\", \"Take 001\"\r\n\t\t}\r\n\t\tRootNode: 0\r\n\t}\r\n}\r\n\r\n; Document References\r\n;------------------------------------------------------------------\r\n\r\nReferences: {\r\n}\r\n\r\n; Object definitions\r\n;------------------------------------------------------------------\r\n\r\nDefinitions: {\r\n\tVersion: 100\r\n\tCount: 6\r\n\tObjectType: \"GlobalSettings\" {\r\n\t\tCount: 1\r\n\t}\r\n\tObjectType: \"AnimationStack\" {\r\n\t\tCount: 1\r\n\t\tPropertyTemplate: \"FbxAnimStack\" {\r\n\t\t\tProperties70: {\r\n\t\t\t\tP: \"Description\", \"KString\", \"\", \"\", \"\"\r\n\t\t\t\tP: \"LocalStart\", \"KTime\", \"Time\", \"\",0\r\n\t\t\t\tP: \"LocalStop\", \"KTime\", \"Time\", \"\",0\r\n\t\t\t\tP: \"ReferenceStart\", \"KTime\", \"Time\", \"\",0\r\n\t\t\t\tP: \"ReferenceStop\", \"KTime\", \"Time\", \"\",0\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\tObjectType: \"AnimationLayer\" {\r\n\t\tCount: 1\r\n\t\tPropertyTemplate: \"FbxAnimLayer\" {\r\n\t\t\tProperties70: {\r\n\t\t\t\tP: \"Weight\", \"Number\", \"\", \"A\",100\r\n\t\t\t\tP: \"Mute\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"Solo\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"Lock\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"Color\", \"ColorRGB\", \"Color\", \"\",0.8,0.8,0.8\r\n\t\t\t\tP: \"BlendMode\", \"enum\", \"\", \"\",0\r\n\t\t\t\tP: \"RotationAccumulationMode\", \"enum\", \"\", \"\",0\r\n\t\t\t\tP: \"ScaleAccumulationMode\", \"enum\", \"\", \"\",0\r\n\t\t\t\tP: \"BlendModeBypass\", \"ULongLong\", \"\", \"\",0\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\tObjectType: \"Geometry\" {\r\n\t\tCount: 1\r\n\t\tPropertyTemplate: \"FbxMesh\" {\r\n\t\t\tProperties70: {\r\n\t\t\t\tP: \"Color\", \"ColorRGB\", \"Color\", \"\",0.8,0.8,0.8\r\n\t\t\t\tP: \"BBoxMin\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"BBoxMax\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"Primary Visibility\", \"bool\", \"\", \"\",1\r\n\t\t\t\tP: \"Casts Shadows\", \"bool\", \"\", \"\",1\r\n\t\t\t\tP: \"Receive Shadows\", \"bool\", \"\", \"\",1\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\tObjectType: \"Material\" {\r\n\t\tCount: 1\r\n\t\tPropertyTemplate: \"FbxSurfaceLambert\" {\r\n\t\t\tProperties70: {\r\n\t\t\t\tP: \"ShadingModel\", \"KString\", \"\", \"\", \"Lambert\"\r\n\t\t\t\tP: \"MultiLayer\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"EmissiveColor\", \"Color\", \"\", \"A\",0,0,0\r\n\t\t\t\tP: \"EmissiveFactor\", \"Number\", \"\", \"A\",1\r\n\t\t\t\tP: \"AmbientColor\", \"Color\", \"\", \"A\",0.2,0.2,0.2\r\n\t\t\t\tP: \"AmbientFactor\", \"Number\", \"\", \"A\",1\r\n\t\t\t\tP: \"DiffuseColor\", \"Color\", \"\", \"A\",0.8,0.8,0.8\r\n\t\t\t\tP: \"DiffuseFactor\", \"Number\", \"\", \"A\",1\r\n\t\t\t\tP: \"Bump\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"NormalMap\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"BumpFactor\", \"double\", \"Number\", \"\",1\r\n\t\t\t\tP: \"TransparentColor\", \"Color\", \"\", \"A\",0,0,0\r\n\t\t\t\tP: \"TransparencyFactor\", \"Number\", \"\", \"A\",0\r\n\t\t\t\tP: \"DisplacementColor\", \"ColorRGB\", \"Color\", \"\",0,0,0\r\n\t\t\t\tP: \"DisplacementFactor\", \"double\", \"Number\", \"\",1\r\n\t\t\t\tP: \"VectorDisplacementColor\", \"ColorRGB\", \"Color\", \"\",0,0,0\r\n\t\t\t\tP: \"VectorDisplacementFactor\", \"double\", \"Number\", \"\",1\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\tObjectType: \"Model\" {\r\n\t\tCount: 1\r\n\t\tPropertyTemplate: \"FbxNode\" {\r\n\t\t\tProperties70: {\r\n\t\t\t\tP: \"QuaternionInterpolate\", \"enum\", \"\", \"\",0\r\n\t\t\t\tP: \"RotationOffset\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"RotationPivot\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"ScalingOffset\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"ScalingPivot\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"TranslationActive\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"TranslationMin\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"TranslationMax\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"TranslationMinX\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"TranslationMinY\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"TranslationMinZ\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"TranslationMaxX\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"TranslationMaxY\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"TranslationMaxZ\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"RotationOrder\", \"enum\", \"\", \"\",0\r\n\t\t\t\tP: \"RotationSpaceForLimitOnly\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"RotationStiffnessX\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"RotationStiffnessY\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"RotationStiffnessZ\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"AxisLen\", \"double\", \"Number\", \"\",10\r\n\t\t\t\tP: \"PreRotation\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"PostRotation\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"RotationActive\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"RotationMin\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"RotationMax\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"RotationMinX\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"RotationMinY\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"RotationMinZ\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"RotationMaxX\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"RotationMaxY\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"RotationMaxZ\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"InheritType\", \"enum\", \"\", \"\",0\r\n\t\t\t\tP: \"ScalingActive\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"ScalingMin\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"ScalingMax\", \"Vector3D\", \"Vector\", \"\",1,1,1\r\n\t\t\t\tP: \"ScalingMinX\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"ScalingMinY\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"ScalingMinZ\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"ScalingMaxX\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"ScalingMaxY\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"ScalingMaxZ\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"GeometricTranslation\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"GeometricRotation\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"GeometricScaling\", \"Vector3D\", \"Vector\", \"\",1,1,1\r\n\t\t\t\tP: \"MinDampRangeX\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"MinDampRangeY\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"MinDampRangeZ\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"MaxDampRangeX\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"MaxDampRangeY\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"MaxDampRangeZ\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"MinDampStrengthX\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"MinDampStrengthY\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"MinDampStrengthZ\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"MaxDampStrengthX\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"MaxDampStrengthY\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"MaxDampStrengthZ\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"PreferedAngleX\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"PreferedAngleY\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"PreferedAngleZ\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"LookAtProperty\", \"object\", \"\", \"\"\r\n\t\t\t\tP: \"UpVectorProperty\", \"object\", \"\", \"\"\r\n\t\t\t\tP: \"Show\", \"bool\", \"\", \"\",1\r\n\t\t\t\tP: \"NegativePercentShapeSupport\", \"bool\", \"\", \"\",1\r\n\t\t\t\tP: \"DefaultAttributeIndex\", \"int\", \"Integer\", \"\",-1\r\n\t\t\t\tP: \"Freeze\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"LODBox\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"Lcl Translation\", \"Lcl Translation\", \"\", \"A\",0,0,0\r\n\t\t\t\tP: \"Lcl Rotation\", \"Lcl Rotation\", \"\", \"A\",0,0,0\r\n\t\t\t\tP: \"Lcl Scaling\", \"Lcl Scaling\", \"\", \"A\",1,1,1\r\n\t\t\t\tP: \"Visibility\", \"Visibility\", \"\", \"A\",1\r\n\t\t\t\tP: \"Visibility Inheritance\", \"Visibility Inheritance\", \"\", \"\",1\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\n; Object properties\r\n;------------------------------------------------------------------\r\n\r\nObjects: {\r\n\tGeometry: 1907663073408, \"Geometry::\", \"Mesh\" {\r\n\t\tVertices: *24 {\r\n\t\t\ta: -0.5,-0.5,0.5,0.5,-0.5,0.5,-0.5,0.5,0.5,0.5,0.5,0.5,-0.5,0.5,-0.5,0.5,0.5,-0.5,-0.5,-0.5,-0.5,0.5,-0.5,-0.5\r\n\t\t}\r\n\t\tPolygonVertexIndex: *24 {\r\n\t\t\ta: 0,1,3,-3,2,3,5,-5,4,5,7,-7,6,7,1,-1,1,7,5,-4,6,0,2,-5\r\n\t\t}\r\n\t\tEdges: *12 {\r\n\t\t\ta: 0,2,6,10,3,1,7,5,11,9,15,13\r\n\t\t}\r\n\t\tGeometryVersion: 124\r\n\t\tLayerElementNormal: 0 {\r\n\t\t\tVersion: 102\r\n\t\t\tName: \"\"\r\n\t\t\tMappingInformationType: \"ByPolygonVertex\"\r\n\t\t\tReferenceInformationType: \"Direct\"\r\n\t\t\tNormals: *72 {\r\n\t\t\t\ta: 0,0,1,0,0,1,0,0,1,0,0,1,0,1,0,0,1,0,0,1,0,0,1,0,0,0,-1,0,0,-1,0,0,-1,0,0,-1,0,-1,0,0,-1,0,0,-1,0,0,-1,0,1,0,0,1,0,0,1,0,0,1,0,0,-1,0,0,-1,0,0,-1,0,0,-1,0,0\r\n\t\t\t}\r\n\t\t\tNormalsW: *24 {\r\n\t\t\t\ta: 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1\r\n\t\t\t}\r\n\t\t}\r\n\t\tLayerElementBinormal: 0 {\r\n\t\t\tVersion: 102\r\n\t\t\tName: \"map1\"\r\n\t\t\tMappingInformationType: \"ByPolygonVertex\"\r\n\t\t\tReferenceInformationType: \"Direct\"\r\n\t\t\tBinormals: *72 {\r\n\t\t\t\ta: 0,1,-0,0,1,-0,0,1,-0,0,1,-0,0,0,-1,0,0,-1,0,0,-1,0,0,-1,0,-1,0,0,-1,0,0,-1,0,0,-1,0,0,0,1,0,0,1,0,0,1,0,0,1,-0,1,0,-0,1,0,0,1,-0,-0,1,0,0,1,0,0,1,0,0,1,0,0,1,0\r\n\t\t\t}\r\n\t\t\tBinormalsW: *24 {\r\n\t\t\t\ta: 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1\r\n\t\t\t}\r\n\r\n\t\t}\r\n\t\tLayerElementTangent: 0 {\r\n\t\t\tVersion: 102\r\n\t\t\tName: \"map1\"\r\n\t\t\tMappingInformationType: \"ByPolygonVertex\"\r\n\t\t\tReferenceInformationType: \"Direct\"\r\n\t\t\tTangents: *72 {\r\n\t\t\t\ta: 1,-0,-0,1,-0,0,1,-0,0,1,-0,0,1,-0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,-0,1,0,-0,1,0,-0,1,0,-0,0,0,-1,0,0,-1,0,-0,-1,0,0,-1,0,-0,1,0,-0,1,0,-0,1,0,-0,1\r\n\t\t\t}\r\n\t\t\tTangentsW: *24 {\r\n\t\t\t\ta: 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1\r\n\t\t\t}\r\n\t\t}\r\n\t\tLayerElementUV: 0 {\r\n\t\t\tVersion: 101\r\n\t\t\tName: \"map1\"\r\n\t\t\tMappingInformationType: \"ByPolygonVertex\"\r\n\t\t\tReferenceInformationType: \"IndexToDirect\"\r\n\t\t\tUV: *28 {\r\n\t\t\t\ta: 0.375,0,0.625,0,0.375,0.25,0.625,0.25,0.375,0.5,0.625,0.5,0.375,0.75,0.625,0.75,0.375,1,0.625,1,0.875,0,0.875,0.25,0.125,0,0.125,0.25\r\n\t\t\t}\r\n\t\t\tUVIndex: *24 {\r\n\t\t\t\ta: 0,1,3,2,2,3,5,4,4,5,7,6,6,7,9,8,1,10,11,3,12,0,2,13\r\n\t\t\t}\r\n\t\t}\r\n\t\tLayerElementSmoothing: 0 {\r\n\t\t\tVersion: 102\r\n\t\t\tName: \"\"\r\n\t\t\tMappingInformationType: \"ByEdge\"\r\n\t\t\tReferenceInformationType: \"Direct\"\r\n\t\t\tSmoothing: *12 {\r\n\t\t\t\ta: 0,0,0,0,0,0,0,0,0,0,0,0\r\n\t\t\t}\r\n\t\t}\r\n\t\tLayerElementMaterial: 0 {\r\n\t\t\tVersion: 101\r\n\t\t\tName: \"\"\r\n\t\t\tMappingInformationType: \"AllSame\"\r\n\t\t\tReferenceInformationType: \"IndexToDirect\"\r\n\t\t\tMaterials: *1 {\r\n\t\t\t\ta: 0\r\n\t\t\t}\r\n\t\t}\r\n\t\tLayer: 0 {\r\n\t\t\tVersion: 100\r\n\t\t\tLayerElement: {\r\n\t\t\t\tType: \"LayerElementNormal\"\r\n\t\t\t\tTypedIndex: 0\r\n\t\t\t}\r\n\t\t\tLayerElement: {\r\n\t\t\t\tType: \"LayerElementBinormal\"\r\n\t\t\t\tTypedIndex: 0\r\n\t\t\t}\r\n\t\t\tLayerElement: {\r\n\t\t\t\tType: \"LayerElementTangent\"\r\n\t\t\t\tTypedIndex: 0\r\n\t\t\t}\r\n\t\t\tLayerElement: {\r\n\t\t\t\tType: \"LayerElementMaterial\"\r\n\t\t\t\tTypedIndex: 0\r\n\t\t\t}\r\n\t\t\tLayerElement: {\r\n\t\t\t\tType: \"LayerElementSmoothing\"\r\n\t\t\t\tTypedIndex: 0\r\n\t\t\t}\r\n\t\t\tLayerElement: {\r\n\t\t\t\tType: \"LayerElementUV\"\r\n\t\t\t\tTypedIndex: 0\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\tModel: 1907292236800, \"Model::animation_driver\", \"Mesh\" {\r\n\t\tVersion: 232\r\n\t\tProperties70: {\r\n\t\t\tP: \"RotationActive\", \"bool\", \"\", \"\",1\r\n\t\t\tP: \"InheritType\", \"enum\", \"\", \"\",1\r\n\t\t\tP: \"ScalingMax\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\tP: \"DefaultAttributeIndex\", \"int\", \"Integer\", \"\",0\r\n\t\t\tP: \"currentUVSet\", \"KString\", \"\", \"U\", \"map1\"\r\n\t\t}\r\n\t\tShading: T\r\n\t\tCulling: \"CullingOff\"\r\n\t}\r\n\tMaterial: 1908464858960, \"Material::lambert1\", \"\" {\r\n\t\tVersion: 102\r\n\t\tShadingModel: \"lambert\"\r\n\t\tMultiLayer: 0\r\n\t\tProperties70: {\r\n\t\t\tP: \"AmbientColor\", \"Color\", \"\", \"A\",0,0,0\r\n\t\t\tP: \"DiffuseColor\", \"Color\", \"\", \"A\",0.5,0.5,0.5\r\n\t\t\tP: \"DiffuseFactor\", \"Number\", \"\", \"A\",0.800000011920929\r\n\t\t\tP: \"TransparencyFactor\", \"Number\", \"\", \"A\",1\r\n\t\t\tP: \"Emissive\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\tP: \"Ambient\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\tP: \"Diffuse\", \"Vector3D\", \"Vector\", \"\",0.400000005960464,0.400000005960464,0.400000005960464\r\n\t\t\tP: \"Opacity\", \"double\", \"Number\", \"\",1\r\n\t\t}\r\n\t}\r\n\tAnimationStack: 1907674289488, \"AnimStack::Take 001\", \"\" {\r\n\t\tProperties70: {\r\n\t\t\tP: \"LocalStart\", \"KTime\", \"Time\", \"\",1924423250\r\n\t\t\tP: \"LocalStop\", \"KTime\", \"Time\", \"\",230930790000\r\n\t\t\tP: \"ReferenceStart\", \"KTime\", \"Time\", \"\",1924423250\r\n\t\t\tP: \"ReferenceStop\", \"KTime\", \"Time\", \"\",230930790000\r\n\t\t}\r\n\t}\r\n\tAnimationLayer: 1907631731008, \"AnimLayer::BaseLayer\", \"\" {\r\n\t}\r\n}\r\n\r\n; Object connections\r\n;------------------------------------------------------------------\r\n\r\nConnections: {\r\n\r\n\t;Model::animation_driver, Model::RootNode\r\n\tC: \"OO\",1907292236800,0\r\n\r\n\t;AnimLayer::BaseLayer, AnimStack::Take 001\r\n\tC: \"OO\",1907631731008,1907674289488\r\n\r\n\t;Geometry::, Model::animation_driver\r\n\tC: \"OO\",1907663073408,1907292236800\r\n\r\n\t;Material::lambert1, Model::animation_driver\r\n\tC: \"OO\",1908464858960,1907292236800\r\n}\r\n;Takes section\r\n;----------------------------------------------------\r\n\r\nTakes: {\r\n\tCurrent: \"Take 001\"\r\n\tTake: \"Take 001\" {\r\n\t\tFileName: \"Take_001.tak\"\r\n\t\tLocalTime: 1924423250,230930790000\r\n\t\tReferenceTime: 1924423250,230930790000\r\n\t}\r\n}\r\n\r\n\"\"\";\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_humanoid_hand_retargeter",
"Path": "Target/HandModelFactory.cs",
"FileName": "HandModelFactory.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 394704,
"IsPrivate": false,
"Code": "#nullable enable\r\nnamespace HumanoidHandRetargeter.Target;\r\n\r\n/// <summary>Minimal hand ModelDoc using the audited VmdlWriter mesh and scaling node shapes.</summary>\r\npublic static class HandModelFactory\r\n{\r\n public const string Header = \"<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} format:modeldoc30:version{8c2d7a91-9c42-4bf0-883a-5a3b1762d4f1} -->\";\r\n public static string Create(string baseModel = \"\", string mesh = \"\", float meshUnitScaleCm = 1, IReadOnlyDictionary<string,string>? materialRemaps = null, IReadOnlyList<string>? meshNames = null)\r\n {\r\n if (!float.IsFinite(meshUnitScaleCm) || meshUnitScaleCm <= 0) throw new ArgumentOutOfRangeException(nameof(meshUnitScaleCm));\r\n if (string.IsNullOrWhiteSpace(baseModel) == string.IsNullOrWhiteSpace(mesh)) throw new ArgumentException(\"Choose one base model or mesh source.\");\r\n var children = new KvArray();\r\n if (mesh.Length > 0)\r\n {\r\n mesh = VmdlSetupService.NormalizeAssetPath(mesh, \".fbx\");\r\n var meshes = new KvArray();\r\n meshes.Items.Add(new KvObject { [\"_class\"] = new KvString(\"RenderMeshFile\"), [\"name\"] = new KvString(\"hands_mesh\"),\r\n [\"filename\"] = new KvString(mesh), [\"import_scale\"] = new KvDouble(meshUnitScaleCm),\r\n [\"import_translation\"] = Vector(0,0,0), [\"import_rotation\"] = Vector(0,0,0),\r\n [\"align_origin_x_type\"] = new KvString(\"None\"), [\"align_origin_y_type\"] = new KvString(\"None\"), [\"align_origin_z_type\"] = new KvString(\"None\") });\r\n if(meshNames is {Count:>0})\r\n {\r\n var names=new KvArray();foreach(var name in meshNames)names.Items.Add(new KvString(name));\r\n ((KvObject)meshes.Items[0])[\"import_filter\"]=new KvObject\r\n {[\"exclude_by_default\"]=new KvBool(true),[\"exception_list\"]=names};\r\n }\r\n children.Items.Add(new KvObject { [\"_class\"] = new KvString(\"RenderMeshList\"), [\"children\"] = meshes });\r\n children.Items.Add(new KvObject { [\"_class\"] = new KvString(\"BoneMarkupList\"), [\"bone_cull_type\"] = new KvString(\"None\"), [\"children\"] = new KvArray() });\r\n var remaps=new KvArray();\r\n if(materialRemaps is not null)foreach(var pair in materialRemaps)remaps.Items.Add(new KvObject{[\"from\"]=new KvString(pair.Key),[\"to\"]=new KvString(pair.Value)});\r\n var materials = new KvArray();\r\n materials.Items.Add(new KvObject { [\"_class\"] = new KvString(\"DefaultMaterialGroup\"), [\"use_global_default\"] = new KvBool(materialRemaps is null || materialRemaps.Count==0),\r\n [\"global_default_material\"] = new KvString(\"materials/dev/reflectivity_50.vmat\"), [\"remaps\"] = remaps });\r\n children.Items.Add(new KvObject { [\"_class\"] = new KvString(\"MaterialGroupList\"), [\"children\"] = materials });\r\n }\r\n else baseModel = VmdlSetupService.NormalizeAssetPath(baseModel, \".vmdl\");\r\n var modifiers = new KvArray();\r\n modifiers.Items.Add(new KvObject { [\"_class\"] = new KvString(\"ModelModifier_ScaleAndMirror\"), [\"scale\"] = new KvDouble(1.0 / 2.54),\r\n [\"mirror_x\"] = new KvBool(false), [\"mirror_y\"] = new KvBool(false), [\"mirror_z\"] = new KvBool(false),\r\n [\"flip_bone_forward\"] = new KvBool(false), [\"swap_left_and_right_bones\"] = new KvBool(false) });\r\n children.Items.Add(new KvObject { [\"_class\"] = new KvString(\"ModelModifierList\"), [\"children\"] = modifiers });\r\n var root = new KvObject { [\"_class\"] = new KvString(\"RootNode\"), [\"children\"] = children, [\"base_model_name\"] = new KvString(baseModel),\r\n [\"anim_graph_name\"] = new KvString(\"\"), [\"model_archetype\"] = new KvString(\"\"), [\"primary_associated_entity\"] = new KvString(\"\") };\r\n return Kv3.Serialize(new Kv3Document(Header, new KvObject { [\"rootNode\"] = root }));\r\n }\r\n\r\n private static KvArray Vector(params double[] values) { var array = new KvArray(); foreach (var value in values) array.Items.Add(new KvDouble(value)); return array; }\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_humanoid_hand_retargeter",
"Path": "Editor/HumanoidHandRetargeter/HandPreviewDialog.cs",
"FileName": "HandPreviewDialog.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 394704,
"IsPrivate": false,
"Code": "#nullable enable\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing Editor;\r\nusing Sandbox;\r\n\r\nnamespace HumanoidHandRetargeter.Editor;\r\n\r\n/// <summary>Legacy PreviewDialog layout, with Third Person/FPS camera controls.</summary>\r\npublic sealed class HandPreviewDialog : Dialog\r\n{\r\n public HandPreviewWidget Preview {get;}\r\n public Action? Confirmed {get;set;}\r\n public HandPreviewDialog(Widget? parent,HandTarget target,IReadOnlyList<HandBakedClip> clips) : base(parent)\r\n {\r\n Window.WindowTitle=\"Preview - \"+System.IO.Path.GetFileName(target.ModelPath);Window.SetWindowIcon(\"preview\");Window.SetModal(true,true);\r\n Window.MinimumWidth=560;Window.MinimumHeight=560;Window.Size=new Vector2(760,780);\r\n Layout=Layout.Column();Layout.Margin=12;Layout.Spacing=8;\r\n Preview=Layout.Add(new HandPreviewWidget(this,target),1);\r\n var error=Layout.Add(new Label(this){WordWrap=true,Visible=false});error.SetStyles($\"color:{Theme.Red.Hex};\");\r\n var modeRow=Layout.AddRow();modeRow.Spacing=8;\r\n var modes=modeRow.Add(new ComboBox(this){MinimumWidth=150});\r\n modes.AddItem(\"Third Person\",\"3d_rotation\",()=>Preview.Mode=HandPreviewMode.ThirdPerson,selected:true);\r\n modes.AddItem(\"FPS\",\"first_page\",()=>Preview.Mode=HandPreviewMode.Fps);\r\n modeRow.Add(new Button(\"Reset camera\",\"center_focus_strong\"){Clicked=Preview.ResetCamera});\r\n modeRow.Add(new Button(\"\u2212\"){Clicked=()=>Preview.Zoom(1.2f)});modeRow.Add(new Button(\"+\"){Clicked=()=>Preview.Zoom(.8f)});\r\n modeRow.Add(new Button(\"Weapon model\u2026\",\"view_in_ar\"){Clicked=()=>{var picker=AssetPicker.Create(this,AssetType.Model);picker.OnAssetPicked=assets=>{var a=assets.FirstOrDefault();if(a is not null){try{Preview.SetWeapon(a.Path);error.Visible=false;}catch(Exception ex){error.Text=ex.Message;error.Visible=true;}}};picker.Show();}});\r\n var cameraRow=Layout.AddRow();cameraRow.Spacing=6;\r\n var authored=cameraRow.Add(new Checkbox(\"Authored camera motion\"){Value=true});authored.Clicked=()=>Preview.UseAuthoredCamera=authored.Value;\r\n cameraRow.Add(new Label(this){Text=\"FOV:\"});var fov=cameraRow.Add(new LineEdit(this){Text=\"75\",FixedWidth=42});\r\n cameraRow.Add(new Label(this){Text=\"FPS offset X / Y / Z:\"});var offsets=Enumerable.Range(0,3).Select(_=>cameraRow.Add(new LineEdit(this){Text=\"0\",FixedWidth=42})).ToArray();\r\n cameraRow.Add(new Button(\"Apply\"){Clicked=()=>{if(float.TryParse(fov.Text,out var f)&&float.IsFinite(f))Preview.FpsFov=f;var values=offsets.Select(e=>float.TryParse(e.Text,out var v)&&float.IsFinite(v)?v:0).ToArray();Preview.FpsOffset=new Vector3(values[0],values[1],values[2]);Preview.ApplyCurrentFrame();}});\r\n var clipRow=Layout.AddRow();clipRow.Spacing=8;clipRow.Add(new Label(this){Text=\"Clip:\"});var combo=clipRow.Add(new ComboBox(this){MinimumWidth=220},1);\r\n for(var i=0;i<clips.Count;i++){var clip=clips[i];combo.AddItem(clip.Baked.Name,\"movie\",()=>Preview.SetClip(clip),selected:i==0);}\r\n var transport=Layout.AddRow();transport.Spacing=8;\r\n var play=transport.Add(new Button(\"\",\"pause\"){FixedWidth=28,FixedHeight=24});play.Clicked=()=>{Preview.Playing=!Preview.Playing;play.Icon=Preview.Playing?\"pause\":\"play_arrow\";};\r\n var slider=transport.Add(new FloatSlider(this),1);slider.Minimum=0;slider.OnValueEdited=()=>Preview.Scrub((int)slider.Value);\r\n var label=transport.Add(new Label(this){Text=\"0 / 0\",FixedWidth=80});\r\n var ghost=transport.Add(new Button(\"Show source\",\"compare\"){IsToggle=true,FixedHeight=24});ghost.Clicked=()=>Preview.ShowSource=ghost.IsChecked;\r\n var skeleton=transport.Add(new Button(\"Skeleton\",\"polyline\"){IsToggle=true,FixedHeight=24});skeleton.Clicked=()=>Preview.SkeletonOnly=skeleton.IsChecked;\r\n Preview.FrameChanged=frame=>{if(modes.CurrentIndex!=(int)Preview.Mode)modes.CurrentIndex=(int)Preview.Mode;play.Icon=Preview.Playing?\"pause\":\"play_arrow\";slider.Maximum=Math.Max(0,Preview.FrameCount-1);slider.Value=frame;label.Text=$\"{frame+1} / {Preview.FrameCount}\";authored.Enabled=Preview.HasAuthoredCamera;};\r\n var footer=Layout.AddRow();footer.Spacing=8;footer.AddStretchCell();footer.Add(new Button(\"Cancel\"){Clicked=Close});\r\n footer.Add(new Button.Primary(\"Looks good - Convert\"){Icon=\"check\",Tint=Theme.Green,Clicked=()=>{Confirmed?.Invoke();Close();}});\r\n if(clips.Count>0)Preview.SetClip(clips[0]);\r\n }\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_humanoid_hand_retargeter",
"Path": "Code/Core/Mapping/BoneNameTokens.cs",
"FileName": "BoneNameTokens.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 394704,
"IsPrivate": false,
"Code": "// Tokenization reused from humanoid-retargeter 26084c96c3fc870aaf9a5bd798de063ce2fd62df.\r\nusing System.Text;\r\nnamespace HumanoidHandRetargeter.Mapping;\r\ninternal static class BoneNameTokens\r\n{\r\n internal static List<string> Tokenize(string name)\r\n {\r\n var tokens = new List<string>();\r\n var current = new StringBuilder();\r\n char previous = '\\0';\r\n for (var i = 0; i < name.Length; i++)\r\n {\r\n var c = name[i];\r\n if (!char.IsLetterOrDigit(c))\r\n {\r\n Flush();\r\n previous = '\\0';\r\n continue;\r\n }\r\n if (current.Length > 0)\r\n {\r\n var boundary = char.IsDigit(c) != char.IsDigit(previous)\r\n || (char.IsUpper(c) && char.IsLower(previous))\r\n // Acronym/single-side prefix before a Pascal word: LThumb \u2192 L, Thumb;\r\n // FBXNode \u2192 FBX, Node. Without this, one-joint BVH thumbs lose both\r\n // their side and their otherwise unambiguous finger name.\r\n || (char.IsUpper(c) && char.IsUpper(previous)\r\n && i + 1 < name.Length && char.IsLower(name[i + 1]));\r\n if (boundary)\r\n Flush();\r\n }\r\n current.Append(char.ToLowerInvariant(c));\r\n previous = c;\r\n }\r\n Flush();\r\n return tokens;\r\n\r\n void Flush()\r\n {\r\n if (current.Length > 0)\r\n {\r\n tokens.Add(current.ToString());\r\n current.Clear();\r\n }\r\n }\r\n }\r\n\r\n}"
},
{
"Ident": "notpointless.chomnr_humanoid_hand_retargeter",
"Path": "Code/Core/Mapping/HandDetectionProfiles.SplitArms.cs",
"FileName": "HandDetectionProfiles.SplitArms.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 394704,
"IsPrivate": false,
"Code": "#nullable enable\r\nusing Vec = System.Numerics.Vector3;\r\nusing Skel = HumanoidHandRetargeter.Skeleton.Skeleton;\r\n\r\nnamespace HumanoidHandRetargeter.Mapping;\r\n\r\ninternal static partial class HandDetectionProfiles\r\n{\r\n // Split biceps/forearms, a wrist-to-palm link, and five palm-rooted rays.\r\n // Numeric/duplicate suffixes identify objects, not sides or anatomical fingers.\r\n private static void ResolveUnlabelledSplitArms(Skel rig,Hint?[] hints)\r\n {\r\n string Name(int i)\r\n {\r\n var name=rig[i].Name[(rig[i].Name.LastIndexOf(':')+1)..];\r\n return Key(name.Split('#')[0].Replace(\"_duplicate\",\"\",StringComparison.OrdinalIgnoreCase)).TrimEnd(\"0123456789\".ToCharArray());\r\n }\r\n int Child(int i,string name)=>rig.ChildrenOf(i).Count==1&&Name(rig.ChildrenOf(i)[0])==name?rig.ChildrenOf(i)[0]:-1;\r\n var proposals=new List<(HandSide Side,Dictionary<int,string> Roles)>();\r\n foreach(var upper in rig.Bones.Where(b=>Name(b.Index) is \"bisep\" or \"bicep\" or \"biceps\"))\r\n {\r\n var upperSplit=Child(upper.Index,Name(upper.Index));if(upperSplit<0)continue;\r\n var lower=Child(upperSplit,\"forearm\");if(lower<0)continue;\r\n var lowerSplit=Child(lower,\"forearm\");if(lowerSplit<0)continue;\r\n var wrist=Child(lowerSplit,\"wrist\");if(wrist<0)continue;\r\n var palm=Child(wrist,\"hand\");if(palm<0||rig.ChildrenOf(palm).Count!=5)continue;\r\n var rays=new List<int[]>();\r\n foreach(var root in rig.ChildrenOf(palm))\r\n {\r\n var ray=new List<int>{root};var current=root;\r\n while(rig.ChildrenOf(current).Count==1){current=rig.ChildrenOf(current)[0];ray.Add(current);}\r\n if(rig.ChildrenOf(current).Count!=0){rays.Clear();break;}\r\n if(Name(ray[^1]).Contains(\"end\",StringComparison.Ordinal))ray.RemoveAt(ray.Count-1);\r\n rays.Add(ray.ToArray());\r\n }\r\n if(rays.Count!=5)continue;\r\n var thumbs=rays.Where(r=>r.Length==3&&Name(r[0]) is \"thum\" or \"thumb\").ToArray();\r\n var fingers=rays.Where(r=>r.Length==4&&Name(r[0])==\"finger\").ToArray();\r\n if(thumbs.Length!=1||fingers.Length!=4)continue;\r\n var thumb=thumbs[0];\r\n Vec Pos(int i)=>rig.RestWorld[i].Pos;\r\n var ordered=fingers.OrderBy(r=>Vec.DistanceSquared(Pos(r[1]),Pos(thumb[1]))).ToArray();\r\n var radial=Pos(ordered[0][1])-Pos(ordered[3][1]);\r\n if(radial.LengthSquared()<1e-8f)continue;\r\n radial=Vec.Normalize(radial);\r\n // Require four distinctly ordered knuckles, not just a nearest-thumb guess.\r\n var spacing=ordered.Select(r=>Vec.Dot(Pos(r[1])-Pos(ordered[3][1]),radial)).ToArray();\r\n if(Enumerable.Range(0,3).Any(i=>spacing[i]-spacing[i+1]<spacing[0]*.1f))continue;\r\n var forward=ordered.Select(r=>Pos(r[3])-Pos(r[1])).Aggregate(Vec.Zero,(a,b)=>a+b);\r\n var normal=Vec.Cross(forward,radial);var thumbBend=Pos(thumb[2])-Pos(thumb[1]);\r\n if(normal.LengthSquared()<1e-8f||thumbBend.LengthSquared()<1e-8f)continue;\r\n var chirality=Vec.Dot(Vec.Normalize(normal),Vec.Normalize(thumbBend));\r\n // An out-of-plane thumb bend supplies the palmar direction. Flat or\r\n // ambiguous geometry remains for review rather than guessing a side.\r\n if(MathF.Abs(chirality)<.25f)continue;\r\n var side=chirality>0?HandSide.Left:HandSide.Right;\r\n var roles=new Dictionary<int,string>{{upper.Index,\"UpperArm\"},{lower,\"LowerArm\"},{wrist,\"Hand\"},{palm,\"PalmMeta\"}};\r\n var digitNames=new[]{\"Index\",\"Middle\",\"Ring\",\"Pinky\"};\r\n for(var digit=0;digit<4;digit++)\r\n for(var joint=0;joint<4;joint++)roles[ordered[digit][joint]]=digitNames[digit]+(joint==0?\"Meta\":\"Prox\");\r\n foreach(var bone in thumb)roles[bone]=\"ThumbProx\";\r\n if(roles.Keys.Any(i=>hints[i] is not null))continue;\r\n proposals.Add((side,roles));\r\n }\r\n // Require a complete, disjoint bilateral pair before using unnamed sides.\r\n if(proposals.Count!=2||proposals[0].Side==proposals[1].Side||proposals[0].Roles.Keys.Intersect(proposals[1].Roles.Keys).Any())return;\r\n foreach(var proposal in proposals)\r\n foreach(var role in proposal.Roles)hints[role.Key]=new(role.Value,proposal.Side,\"split_arm_palm_rays\");\r\n }\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_humanoid_hand_retargeter",
"Path": "Editor/HumanoidHandRetargeter/HandEditorPipeline.cs",
"FileName": "HandEditorPipeline.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 394704,
"IsPrivate": false,
"Code": "#nullable enable\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.IO;\r\nusing System.Linq;\r\nusing System.Runtime.CompilerServices;\r\nusing System.Security.Cryptography;\r\nusing System.Threading;\r\nusing System.Threading.Tasks;\r\nusing Editor;\r\nusing Sandbox;\r\nusing HumanoidHandRetargeter.Calibration;\r\nusing HumanoidHandRetargeter.Formats.Dmx;\r\nusing HumanoidHandRetargeter.Formats.Fbx;\r\nusing HumanoidHandRetargeter.Mapping;\r\nusing HumanoidHandRetargeter.Maths;\r\nusing HumanoidHandRetargeter.Retargeting;\r\nusing HumanoidHandRetargeter.Skeleton;\r\nusing HumanoidHandRetargeter.Target;\r\nusing Skel = HumanoidHandRetargeter.Skeleton.Skeleton;\r\nusing Vec = System.Numerics.Vector3;\r\nusing Quat = System.Numerics.Quaternion;\r\n\r\nnamespace HumanoidHandRetargeter.Editor;\r\n\r\npublic sealed class HandSource\r\n{\r\n public required string Path { get; init; }\r\n public required SourceScene Scene { get; init; }\r\n public required HandMappingResult Mapping { get; set; }\r\n public string? ModelPath { get; init; }\r\n public IReadOnlyDictionary<HandSide, Quat>? PalmFrames { get; set; }\r\n}\r\n\r\npublic sealed class HandTarget\r\n{\r\n public IReadOnlyList<string> ImportNotes { get; init; } = Array.Empty<string>();\r\n public required string ModelPath { get; init; }\r\n public required Skel Skeleton { get; init; }\r\n public required HandMappingResult Mapping { get; set; }\r\n public IReadOnlyDictionary<HandSide, Quat>? PalmFrames { get; set; }\r\n}\r\n\r\npublic sealed record HandExportResult(string ModelPath,IReadOnlyList<string> Changes,string? BackupPath,IReadOnlyList<string>? WeaponPrefabs=null);\r\n\r\npublic sealed record HandBakedClip(HandSource Source, Clip Original, Clip Baked, IReadOnlyList<string> Notes,Vec? WeaponOffset=null,HandMotionOptions? MotionOptions=null);\r\n\r\n/// <summary>Editor adapter over the pure importer, shared bake and transactional setup.\r\n/// Engine calls use the same main-thread dispatch convention as the audited EditorPipeline.</summary>\r\npublic static class HandEditorPipeline\r\n{\r\n public const string HumanArms = \"models/first_person/v_first_person_arms_human.vmdl\";\r\n public const string CitizenArms = \"models/first_person/v_first_person_arms_citizen.vmdl\";\r\n public static string Assets => Project.Current?.GetAssetsPath() ?? throw new InvalidOperationException(\"Open an s&box project first.\");\r\n public static MainThreadAwaitable MainThread() => default;\r\n public readonly struct MainThreadAwaitable : INotifyCompletion\r\n {\r\n public MainThreadAwaitable GetAwaiter() => this;\r\n public bool IsCompleted => ThreadSafe.IsMainThread;\r\n public void OnCompleted(Action continuation) => Sandbox.MainThread.Queue(continuation);\r\n public void GetResult() { }\r\n }\r\n\r\n public static Transform ToEngine(XForm value)\r\n => new(new Vector3(value.Pos.X, value.Pos.Y, value.Pos.Z) / 2.54f, new Rotation(value.Rot.X,value.Rot.Y,value.Rot.Z,value.Rot.W));\r\n public static XForm FromEngine(Transform value)\r\n => new(new Vec(value.Position.x,value.Position.y,value.Position.z)*2.54f, new Quat(value.Rotation.x,value.Rotation.y,value.Rotation.z,value.Rotation.w));\r\n\r\n public static Skel ReadSkeleton(Model model)\r\n {\r\n return Skel.Create(model.Bones.AllBones.Select(b => {\r\n var world = FromEngine(b.LocalTransform);\r\n return new BoneDefinition(b.Name,b.Parent?.Name,b.Parent is null ? world : XForm.ToLocal(FromEngine(b.Parent.LocalTransform),world));\r\n }).ToArray());\r\n }\r\n\r\n public static async Task<HandTarget> LoadTargetAsync(string path, CancellationToken cancel = default)\r\n {\r\n await MainThread();\r\n var importNotes=new List<string>();\r\n if (path.EndsWith(\".fbx\",StringComparison.OrdinalIgnoreCase)) path = await PrepareMeshAsync(path,cancel,importNotes);\r\n path = ModelPath(path);\r\n var model = Model.Load(path);\r\n if (model is null || model.IsError || model.BoneCount == 0) throw new InvalidOperationException($\"Cannot load a skinned model from '{path}'.\");\r\n\r\n var skeleton = ReadSkeleton(model);\r\n return new HandTarget { ImportNotes=importNotes, ModelPath=path, Skeleton=skeleton, PalmFrames=HandPresetStore.LoadPalms(skeleton), Mapping=HandPresetStore.Load(skeleton) ?? HandRigDetector.Detect(skeleton) };\r\n }\r\n\r\n public static async Task<HandSource> LoadSourceAsync(string path, float fps = 30, CancellationToken cancel = default)\r\n {\r\n if (path.EndsWith(\".fbx\",StringComparison.OrdinalIgnoreCase))\r\n {\r\n var native = await Task.Run(()=>FbxImporter.Import(File.ReadAllBytes(path),new FbxImportOptions {SampleFps=fps}),cancel);\r\n await MainThread();\r\n // Share the target's X-forward/Z-up view space so the source grip, eye and weapon\r\n // motion transfer directly, whatever axes or facing the file was authored in.\r\n var nativeMapping=HandPresetStore.Load(native.Skeleton);\r\n var axesMapping=nativeMapping ?? HandRigDetector.Detect(native.Skeleton);\r\n var turn=HandViewSpace.ViewRotation(native,axesMapping);\r\n var scene=HandViewSpace.ToViewAxes(native,axesMapping);\r\n // Mappings are index based; palm frames saved against the native axes turn with the rig.\r\n var palms=HandPresetStore.LoadPalms(scene.Skeleton) ?? HandPresetStore.LoadPalms(native.Skeleton)?.ToDictionary(p=>p.Key,p=>Quat.Normalize(turn*p.Value));\r\n return new() { Path=path, Scene=scene, PalmFrames=palms, Mapping=HandPresetStore.Load(scene.Skeleton) ?? nativeMapping ?? HandRigDetector.Detect(scene.Skeleton) };\r\n }\r\n await MainThread();\r\n path = ModelPath(path);\r\n var model = Model.Load(path);\r\n if (model is null || model.IsError) throw new InvalidOperationException($\"Cannot load source model '{path}'.\");\r\n var skeleton = ReadSkeleton(model);\r\n var clips = new List<Clip>();\r\n using var sampling = new ModelSampler(model,skeleton);\r\n foreach (var name in model.AnimationNames.ToArray())\r\n {\r\n cancel.ThrowIfCancellationRequested();\r\n clips.Add(sampling.Sample(name,fps,cancel));\r\n await Task.Delay(1,cancel); await MainThread();\r\n }\r\n // Facepunch weapons author recoil as additive layers. Capture their complete firing\r\n // pose through the original weapon graph instead of retargeting a delta as a full pose.\r\n if(skeleton.IndexOf(\"weapon_root\")>=0&&!clips.Any(c=>WeaponAnimGraph.Classify(c.Name)==WeaponAction.Fire)\r\n &&clips.FirstOrDefault(c=>c.Name.StartsWith(\"Fire_\",StringComparison.OrdinalIgnoreCase)&&c.Name.EndsWith(\"_delta\",StringComparison.OrdinalIgnoreCase)\r\n &&!c.Name.Contains(\"Hold\",StringComparison.OrdinalIgnoreCase)&&!c.Name.Contains(\"Dry\",StringComparison.OrdinalIgnoreCase)) is {} recoil)\r\n {\r\n var firing=sampling.SampleFire(Math.Max(.25f,(recoil.FrameCount-1)/recoil.Fps),fps,cancel);\r\n if(firing is not null)clips.Add(firing);\r\n }\r\n return new() { Path=path,ModelPath=path, Scene=new SourceScene(skeleton,clips,2.54f,upAxis:2,frontAxis:0,coordAxis:1),\r\n PalmFrames=HandPresetStore.LoadPalms(skeleton), Mapping=HandPresetStore.Load(skeleton) ?? HandRigDetector.Detect(skeleton) };\r\n }\r\n\r\n public static HandRetargetProfile Calibrate(HandSource source,HandTarget target)\r\n => HandRetargetProfile.Calibrate(source.Scene.Skeleton,source.Mapping,target.Skeleton,target.Mapping,source.PalmFrames,target.PalmFrames);\r\n\r\n public static HandBakedClip Bake(HandSource source, Clip clip, HandTarget target, HandMotionOptions options, CancellationToken cancel = default, HandRetargetProfile? profile = null)\r\n {\r\n profile ??= Calibrate(source,target);\r\n // Compiled s&box models already share X-forward/Z-up coordinates. A wrist's\r\n // bind rotation must not turn vertical reload travel into sideways/downward motion.\r\n var weaponOffset=options.WeaponSpaceOffset;\r\n // FBX sources are loaded in view space, so every weapon file (named weapon bones or not)\r\n // keeps both palms locked to the source grip relative to the eye.\r\n var viewSpace=source.ModelPath is not null||source.Scene.UpAxis==2&&source.Scene.FrontAxis==0;\r\n if(options.PreserveWeaponGrip&&options.TransferWristPosition&&(source.ModelPath is null&&viewSpace\r\n ||source.Scene.Skeleton.Bones.Any(b=>b.Name.Equals(\"weapon_root\",StringComparison.OrdinalIgnoreCase))))\r\n weaponOffset ??= HandViewSpace.EyePosition(target.Skeleton,target.Mapping)-HandViewSpace.EyePosition(source.Scene.Skeleton,source.Mapping);\r\n options=new HandMotionOptions{TransferWristPosition=options.TransferWristPosition,ScaleWristTravel=options.ScaleWristTravel,\r\n SolveArmIk=options.SolveArmIk,PreserveWeaponGrip=options.PreserveWeaponGrip,WeaponSpaceOffset=weaponOffset,\r\n WristTravelBasis=options.WristTravelBasis??(viewSpace?Quat.Identity:null)};\r\n var notes=weaponOffset.HasValue?profile.Notes.Concat(new[]{\"Preserved both palm grip anchors in one fixed weapon space; weapon motion is not scaled per arm.\"}).ToArray():profile.Notes;\r\n return new(source,clip,HandRetargeter.Bake(profile,clip,options,cancel),notes,weaponOffset,options);\r\n }\r\n\r\n public static async Task<string> ExportAsync(HandTarget target,IReadOnlyList<HandBakedClip> clips,string outputModel,\r\n bool autoGraph,bool backup,bool weaponCompatible,bool preserveSourceTracks,CancellationToken cancel=default)\r\n => (await ExportWithReportAsync(target,clips,outputModel,autoGraph,backup,weaponCompatible,preserveSourceTracks,cancel)).ModelPath;\r\n\r\n public static async Task<HandExportResult> ExportWithReportAsync(HandTarget target, IReadOnlyList<HandBakedClip> clips, string outputModel,\r\n bool autoGraph, bool backup, bool weaponCompatible, bool preserveSourceTracks, CancellationToken cancel = default)\r\n {\r\n await MainThread();\r\n if(clips.Count==0)throw new InvalidOperationException(\"Select at least one animation to export.\");\r\n if(weaponCompatible)\r\n {\r\n foreach(var clip in clips)\r\n {\r\n if(clip.Source.ModelPath is null)throw new InvalidOperationException(\"Weapon-compatible mode requires a weapon VMDL source so its animation owner can be verified.\");\r\n var missing=target.Mapping.Hands.SelectMany(h=>new[]{h.Wrist}.Concat(h.Digits.SelectMany(d=>d.Bones))).Select(i=>target.Skeleton[i].Name).Where(n=>clip.Source.Scene.Skeleton.IndexOf(n)<0).ToArray();\r\n if(missing.Length>0)throw new InvalidOperationException(\"These target bones cannot bonemerge onto the selected weapon: \"+string.Join(\", \",missing)+\". Use standalone export for this custom rig, or choose a weapon whose skeleton contains those bones.\");\r\n }\r\n }\r\n outputModel = VmdlSetupService.NormalizeAssetPath(outputModel,\".vmdl\");\r\n var absolute = System.IO.Path.Combine(Assets,outputModel);\r\n var existing = File.Exists(absolute) ? File.ReadAllText(absolute) : null;\r\n if(existing is not null)\r\n {\r\n var existingModel=Model.Load(outputModel);\r\n if(existingModel is null||existingModel.IsError)throw new InvalidOperationException(\"The existing output model must compile before animations can be added.\");\r\n var existingRig=ReadSkeleton(existingModel);\r\n if(existingRig.Count!=target.Skeleton.Count||target.Skeleton.Bones.Any(b=>existingRig.IndexOf(b.Name)<0))throw new InvalidOperationException(\"The existing output model has a different skeleton. Choose the target model or a new output path.\");\r\n }\r\n var targetSource=System.IO.Path.Combine(Assets,target.ModelPath);\r\n var original = existing ?? (File.Exists(targetSource)?File.ReadAllText(targetSource):HandModelFactory.Create(baseModel:target.ModelPath));\r\n var folder = outputModel[..^5] + \"_animations\";\r\n var files = new Dictionary<string,string>(StringComparer.OrdinalIgnoreCase);\r\n var entries = new List<HandAnimationEntry>();\r\n var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { \"bindPose\" };\r\n // A compiled target is in inches. Existing model scaling controls the animation\r\n // compiler's input units; new wrappers use the inherited cm-to-inch modifier.\r\n var factor = ExportPositionFactor(original);\r\n var turn=ExportRootRotation(original);\r\n XForm ExportLocal(XForm value,int bone)=>target.Skeleton[bone].ParentIndex<0\r\n ?new XForm(Vec.Transform(value.Pos*factor,turn),Quat.Normalize(turn*value.Rot))\r\n :new XForm(value.Pos*factor,value.Rot);\r\n Clip ExportUnits(Clip clip) => new(clip.Name,clip.Fps,clip.Looping,clip.Frames.Select(frame=>frame.Select(ExportLocal).ToArray()).ToList(),clip.NativeFps);\r\n var exportSkeleton = Skel.Create(target.Skeleton.Bones.Select(b=>new BoneDefinition(b.Name,b.ParentIndex<0?null:target.Skeleton[b.ParentIndex].Name,ExportLocal(b.RestLocal,b.Index))).ToArray());\r\n var bind = new Clip(\"bindPose\",30,false,new() { Pose.Rest(exportSkeleton).Locals });\r\n files[folder+\"/bind.dmx\"] = DmxWriter.Write(exportSkeleton,bind,new() {Name=\"bindPose\",UpAxisY=false,ForwardParity=1});\r\n foreach(var clip in clips)\r\n {\r\n var stem=SourceKey(clip.Source)+\"_\"+SafeName(clip.Baked.Name); var name=stem; var suffix=1;\r\n while(!names.Add(name)) name=stem+\"_\"+(++suffix);\r\n var content=DmxWriter.Write(exportSkeleton,ExportUnits(clip.Baked),new() {Name=name,UpAxisY=false,ForwardParity=1,ChannelExcludedBones=target.Mapping.Hands.SelectMany(h=>h.TwistOrHelperBones).ToHashSet()});\r\n var file=folder+\"/\"+name+\"_\"+ContentKey(content)+\".dmx\";\r\n files[file]=content;\r\n entries.Add(new(name,file,clip.Baked.Looping));\r\n if(preserveSourceTracks)\r\n {\r\n // Full source companion intentionally retains every authored camera,\r\n // weapon and IK track with its original hierarchy, units and timing.\r\n var tracks=DmxWriter.Write(clip.Source.Scene.Skeleton,clip.Original,new() {Name=name+\"_source_tracks\",UpAxisY=clip.Source.Scene.UpAxis==1,ForwardParity=clip.Source.ModelPath is null&&clip.Source.Scene.FrontAxis!=0?2:1});\r\n files[folder+\"/source_tracks/\"+name+\"_\"+ContentKey(tracks)+\".dmx\"]=tracks;\r\n }\r\n }\r\n var prepared=VmdlSetupService.Prepare(original,entries,new() {ModelPath=outputModel,BindPoseSource=folder+\"/bind.dmx\",AutoConfigureAnimGraph=autoGraph,WeaponCompatibleArms=weaponCompatible});\r\n var generatedAssets=new Dictionary<string,string>(StringComparer.OrdinalIgnoreCase);\r\n var bundles=autoGraph?HandWeaponExport.Prepare(target,clips,outputModel,files,generatedAssets,cancel):Array.Empty<HandWeaponExport.Bundle>();\r\n foreach(var bundle in bundles)generatedAssets[bundle.PrefabPath]=HandWeaponExport.Prefab(bundle);\r\n var committed=await VmdlSetupTransaction.CommitAsync(Assets,outputModel,existing,prepared,files,async(paths,token)=>{\r\n await MainThread(); foreach(var path in paths) AssetSystem.RegisterFile(path);\r\n foreach(var bundle in bundles)\r\n {\r\n if(bundle.LiveCalibration is null)\r\n {\r\n if(!await CompileAsync(System.IO.Path.Combine(Assets,bundle.ModelPath),token))throw new InvalidOperationException(\"Weapon animation model did not compile: \"+bundle.ModelPath);\r\n await MainThread();\r\n await Task.Delay(100,token);await MainThread();\r\n var owner=Model.Load(bundle.ModelPath);var graph=AnimationGraph.Load(bundle.GraphPath);\r\n if(owner is null||owner.IsError||!owner.HasRenderMeshes()||owner.BoneCount<bundle.BoneCount||graph is null||graph.IsError\r\n ||!bundle.Actions.All(a=>owner.AnimationNames.Contains(a.Sequence)))throw new InvalidOperationException($\"Weapon animation owner validation failed: bones {owner?.BoneCount}/{bundle.BoneCount}, meshes {owner?.MeshCount}, vertices {owner?.MeshInfo.TotalVertices}, triangles {owner?.MeshInfo.TotalTriangles}, graph error {graph?.IsError}, sequences {string.Join(\",\",owner?.AnimationNames??Array.Empty<string>())}.\");\r\n if(target.Skeleton.Bones.Any(b=>owner.Bones.GetBone(WeaponClipBuilder.HandPrefix+b.Name) is null))return false;\r\n }\r\n\r\n if(!await CompileAsync(System.IO.Path.Combine(Assets,bundle.PrefabPath),token))throw new InvalidOperationException(\"Weapon prefab did not compile: \"+bundle.PrefabPath);\r\n await MainThread();\r\n // The compiled file reaches disk before the editor's asset record updates.\r\n // Managed resource loading uses that record, not File.Exists.\r\n var prefabAsset=AssetSystem.FindByPath(bundle.PrefabPath);\r\n for(var wait=0;wait<50&&string.IsNullOrEmpty(prefabAsset.GetCompiledFile(true));wait++)\r\n {await Task.Delay(100,token);await MainThread();}\r\n var prefab=prefabAsset.LoadResource<PrefabFile>();\r\n if(prefab is null||prefab.IsError||SceneUtility.GetPrefabScene(prefab) is null)throw new InvalidOperationException(\"Weapon prefab could not be loaded: \"+bundle.PrefabPath);\r\n }\r\n if(!await CompileAsync(absolute,token)) return false;\r\n await MainThread(); var model=Model.Load(outputModel);\r\n // The compiled file can arrive before an already-loaded model refreshes\r\n // its sequence list. Re-exporting changed motion adds a preserved-name\r\n // suffix, so wait for those new sequences before validating the bake.\r\n var modelDeadline=DateTime.UtcNow.AddSeconds(15);\r\n while(model is not null&&!model.IsError&&!prepared.Animations.All(a=>model.AnimationNames.Contains(a.SequenceName))\r\n &&DateTime.UtcNow<modelDeadline)\r\n {\r\n await Task.Delay(50,token);await MainThread();model=Model.Load(outputModel);\r\n }\r\n if(model is null || model.IsError || !prepared.Animations.All(a=>model.AnimationNames.Contains(a.SequenceName)))return false;\r\n if(prepared.GeneratedGraphPath is { } graphPath){var graph=AnimationGraph.Load(graphPath);if(graph is null||graph.IsError)return false;}\r\n\r\n var compiledRig=ReadSkeleton(model);\r\n using var sampler=new ModelSampler(model,compiledRig);\r\n var checkedBones=target.Mapping.Hands.SelectMany(h=>new int?[]{h.Clavicle,h.UpperArm,h.Forearm,h.Wrist}.Where(i=>i.HasValue).Select(i=>i!.Value).Concat(h.Digits.SelectMany(d=>d.Bones))).Distinct().ToArray();\r\n for(var i=0;i<clips.Count;i++)\r\n {\r\n var expectedClip=clips[i].Baked;var actualClip=sampler.Sample(prepared.Animations[i].SequenceName,expectedClip.Fps,token);\r\n if(expectedClip.FrameCount>1&&actualClip.FrameCount!=expectedClip.FrameCount)return false;\r\n foreach(var frame in new[]{0,expectedClip.FrameCount/2,expectedClip.FrameCount-1}.Distinct())\r\n {\r\n var expectedWorld=new Pose(expectedClip.Frames[frame]).ToWorld(target.Skeleton);\r\n var actualWorld=new Pose(actualClip.Frames[frame]).ToWorld(compiledRig);\r\n foreach(var bone in checkedBones)\r\n {\r\n var index=compiledRig.IndexOf(target.Skeleton[bone].Name);\r\n if(index<0 || Vec.Distance(expectedWorld[bone].Pos,actualWorld[index].Pos)>.1f\r\n || MathQ.AngleBetween(expectedWorld[bone].Rot,actualWorld[index].Rot)>.02f)\r\n throw new InvalidOperationException($\"Compiled sequence '{expectedClip.Name}' differs from the preview at '{target.Skeleton[bone].Name}', frame {frame}. The export was rolled back.\");\r\n }\r\n }\r\n }\r\n return true;\r\n },backup,cancel,generatedAssets);\r\n return new(outputModel,prepared.Changes.Concat(bundles.Select(b=>b.LiveCalibration is not null\r\n ? $\"Preserved complete source graph {b.GraphPath} with live custom-hand retargeting: {b.PrefabPath}\"\r\n : $\"Created weapon graph ({string.Join(\", \",b.Actions.Select(a=>a.Action))}) and synchronized hands/weapon prefab: {b.PrefabPath}\")).ToArray(),committed.BackupPath,bundles.Select(b=>b.PrefabPath).ToArray());\r\n }\r\n\r\n private static float ExportPositionFactor(string text)\r\n {\r\n float scale=1;\r\n void Walk(KvValue v) { if(v is KvObject o) { if(o.GetString(\"_class\")==\"ModelModifier_ScaleAndMirror\" && o.GetOrNull(\"scale\") is KvDouble d) scale*=(float)d.Value; foreach(var key in o.Keys) Walk(o[key]); } else if(v is KvArray a) foreach(var item in a.Items) Walk(item); }\r\n Walk(Kv3.Parse(text).Root);\r\n if(!float.IsFinite(scale)||scale<=0) throw new InvalidOperationException(\"Target model has an unsupported scale modifier.\");\r\n return 1f/(2.54f*scale);\r\n }\r\n\r\n private static Quat ExportRootRotation(string text)\r\n {\r\n var turn=false;\r\n void Walk(KvValue value)\r\n {\r\n if(value is KvObject node)\r\n {\r\n if(node.GetString(\"_class\")==\"ModelModifier_ScaleAndMirror\"\r\n &&node.GetOrNull(\"mirror_x\") is KvBool {Value:true}\r\n &&node.GetOrNull(\"mirror_y\") is KvBool {Value:true}\r\n &&node.GetOrNull(\"mirror_z\") is not KvBool {Value:true})turn=!turn;\r\n foreach(var key in node.Keys)Walk(node[key]);\r\n }\r\n else if(value is KvArray array)foreach(var child in array.Items)Walk(child);\r\n }\r\n Walk(Kv3.Parse(text).Root);\r\n return turn?Quat.CreateFromAxisAngle(Vec.UnitZ,MathF.PI):Quat.Identity;\r\n }\r\n\r\n private static async Task<string> PrepareMeshAsync(string file, CancellationToken cancel,List<string> importNotes)\r\n {\r\n var bytes=File.ReadAllBytes(file);\r\n var materials=await Task.Run(()=>FbxMaterialAssets.Inspect(file,bytes),cancel);\r\n importNotes.AddRange(materials.Notes);\r\n var hash=materials.Signature;\r\n var folder=\"models/hand_retargeter/targets/\"+SafeName(System.IO.Path.GetFileNameWithoutExtension(file))+\"_\"+hash+\"_rig9\";\r\n var mesh=folder+\"/hands.fbx\"; var model=folder+\"/hands.vmdl\";\r\n var scene=await Task.Run(()=>FbxImporter.Import(bytes,new(){SampleFps=(float)FbxScene.Build(FbxTokenizer.Parse(bytes)).FrameRate}),cancel);\r\n var embeddedClips=scene.Clips;\r\n var repaired=await Task.Run(()=>\r\n {\r\n var result=FbxBindPoseFixer.TryFix(bytes,out var report);\r\n return (Bytes:result,Report:report);\r\n },cancel);\r\n if(repaired.Bytes is {} repairedBytes)\r\n {\r\n var repairedScene=await Task.Run(()=>FbxImporter.Import(repairedBytes),cancel);\r\n if(!scene.Skeleton.Bones.Select(b=>(b.Name,b.ParentIndex)).SequenceEqual(repairedScene.Skeleton.Bones.Select(b=>(b.Name,b.ParentIndex))))\r\n throw new InvalidOperationException(\"Bind-pose repair changed the target hierarchy; the original FBX was not modified.\");\r\n scene=repairedScene;bytes=repairedBytes;\r\n importNotes.Add(\"Repaired the imported copy from its authored bind pose: \"+repaired.Report);\r\n }\r\n var meshNames=FbxSkinnedMeshes.ReadNames(bytes);\r\n await MainThread();\r\n var meshAbs=System.IO.Path.Combine(Assets,mesh); Directory.CreateDirectory(System.IO.Path.GetDirectoryName(meshAbs)!);\r\n if(!File.Exists(meshAbs)) File.WriteAllBytes(meshAbs,bytes);\r\n var modelAbs=System.IO.Path.Combine(Assets,model);\r\n var remaps=HandMaterialImport.Write(materials,folder);\r\n var isNew=!File.Exists(modelAbs);\r\n if(isNew)\r\n {\r\n // Keep the full authored hierarchy before ModelDoc can cull unweighted\r\n // ancestors. The audited importer preview uses this same bind-source technique.\r\n string EngineName(string name){var suffix=name.LastIndexOf('#');return suffix<0?name:name[..suffix]+\"_duplicate\";}\r\n var rig=Skel.Create(scene.Skeleton.Bones.Select(b=>new BoneDefinition(EngineName(b.Name),b.ParentIndex<0?null:EngineName(scene.Skeleton[b.ParentIndex].Name),b.RestLocal)).ToArray());\r\n var bindPath=folder+\"/mesh_bind.dmx\";\r\n File.WriteAllText(System.IO.Path.Combine(Assets,bindPath),DmxWriter.Write(rig,new Clip(\"bindPose\",30,false,new(){Pose.Rest(rig).Locals}),new(){Name=\"bindPose\",UpAxisY=scene.UpAxis==1}));\r\n var entries=new List<HandAnimationEntry>();\r\n var names=new HashSet<string>(StringComparer.OrdinalIgnoreCase){\"bindPose\"};\r\n for(var i=0;i<embeddedClips.Count;i++)\r\n {\r\n cancel.ThrowIfCancellationRequested();\r\n var clip=embeddedClips[i];\r\n var stem=SafeName(clip.Name).Replace('-','_');\r\n var name=stem;var suffix=2;\r\n while(!names.Add(name))name=stem+\"_\"+suffix++;\r\n var path=folder+\"/embedded_\"+i+\".dmx\";\r\n // Imported locals are already centimeters, matching mesh_bind and the\r\n // model's final unit conversion. Preserve authored takes without IK.\r\n File.WriteAllText(System.IO.Path.Combine(Assets,path),DmxWriter.Write(rig,clip,new(){Name=name,UpAxisY=scene.UpAxis==1}));\r\n AssetSystem.RegisterFile(System.IO.Path.Combine(Assets,path));\r\n entries.Add(new(name,path,clip.Looping));\r\n }\r\n var prepared=VmdlSetupService.Prepare(HandModelFactory.Create(mesh:mesh,meshUnitScaleCm:scene.UnitScaleCm,materialRemaps:remaps,meshNames:meshNames),entries,new(){ModelPath=model,BindPoseSource=bindPath,AutoConfigureAnimGraph=false});\r\n File.WriteAllText(modelAbs,prepared.VmdlText);\r\n AssetSystem.RegisterFile(System.IO.Path.Combine(Assets,bindPath));\r\n }\r\n AssetSystem.RegisterFile(meshAbs);\r\n if(!await CompileAsync(modelAbs,cancel)) throw new InvalidOperationException(\"The target FBX could not be compiled into a skinned model.\");\r\n if(isNew)\r\n {\r\n await MainThread();\r\n var compiledRig=ReadSkeleton(Model.Load(model));\r\n var fit=HandTargetFit.Analyze(compiledRig,HandRigDetector.Detect(compiledRig));\r\n if(fit.Scale!=1||fit.TurnAround)\r\n {\r\n File.WriteAllText(modelAbs,fit.Apply(File.ReadAllText(modelAbs)));\r\n if(!await CompileAsync(modelAbs,cancel))throw new InvalidOperationException(\"The fitted FPS target could not be compiled.\");\r\n // Resource compilation finishes before the loaded Model's bone cache\r\n // refreshes. Never hand an old-size skeleton to a newly scaled mesh.\r\n var deadline=DateTime.UtcNow.AddSeconds(15);\r\n while(true)\r\n {\r\n await Task.Delay(50,cancel);await MainThread();\r\n var loaded=ReadSkeleton(Model.Load(model));\r\n var ready=compiledRig.Bones.All(b=>\r\n {\r\n var index=loaded.IndexOf(b.Name);if(index<0)return false;\r\n var expected=compiledRig.RestWorld[b.Index].Pos*fit.Scale;\r\n if(fit.TurnAround)expected=new Vec(-expected.X,-expected.Y,expected.Z);\r\n return Vec.Distance(loaded.RestWorld[index].Pos,expected)<.02f;\r\n });\r\n if(ready)break;\r\n if(DateTime.UtcNow>=deadline)throw new InvalidOperationException(\"The fitted model is still reloading. Select the FBX again after asset compilation finishes.\");\r\n }\r\n if(fit.Scale!=1)importNotes.Add($\"Applied FPS import scale {fit.Scale:G} to the mesh and all embedded animations to correct the oversized or undersized export.\");\r\n if(fit.TurnAround)importNotes.Add(\"Turned the imported target 180 degrees to align its left and right shoulders with FPS weapon coordinates.\");\r\n }\r\n }\r\n await MainThread(); return model;\r\n }\r\n\r\n public static async Task<bool> CompileAsync(string absolute, CancellationToken cancel)\r\n {\r\n await MainThread(); var asset=AssetSystem.RegisterFile(absolute);\r\n var compiled=absolute+\"_c\"; var before=File.Exists(compiled)?File.GetLastWriteTimeUtc(compiled):DateTime.MinValue;\r\n asset.Compile(full:true);\r\n var deadline=DateTime.UtcNow.AddSeconds(90);\r\n while(DateTime.UtcNow<deadline)\r\n {\r\n cancel.ThrowIfCancellationRequested(); await MainThread();\r\n if(asset.IsCompileFailed) return false;\r\n if(File.Exists(compiled) && File.GetLastWriteTimeUtc(compiled)>before) return true;\r\n await Task.Delay(100,cancel);\r\n }\r\n return false;\r\n }\r\n\r\n public static bool HasMissingMaterials(Model model)\r\n {\r\n var materials=model.Materials.Concat(Enumerable.Range(0,model.MaterialGroupCount).SelectMany(group=>model.GetMaterials(group))).Distinct();\r\n foreach(var material in materials)\r\n {\r\n if(material is null||material.IsError||string.Equals(material.ResourcePath,\"materials/error.vmat\",StringComparison.OrdinalIgnoreCase))return true;\r\n foreach(var parameter in new[]{\"g_tColor\",\"g_tNormal\",\"g_tRoughness\",\"g_tMetalness\",\"g_tAmbientOcclusion\",\"g_tSelfIllumMask\",\"g_tTranslucency\",\"TextureColor\",\"TextureNormal\",\"TextureRoughness\",\"TextureMetalness\",\"TextureAmbientOcclusion\",\"TextureSelfIllumMask\",\"TextureTranslucency\"})\r\n if(material.GetTexture(parameter) is {IsError:true})return true;\r\n if(material.FirstTexture is {IsError:true})return true;\r\n }\r\n return false;\r\n }\r\n\r\n public static void ApplyPreviewMaterialFallback(SceneModel model)\r\n {\r\n // Incomplete visual dependencies must not prevent skeletal retargeting.\r\n // Override this preview instance only; authored assets remain editable.\r\n if(HasMissingMaterials(model.Model))model.SetMaterialOverride(Material.Load(\"materials/dev/reflectivity_50.vmat\"));\r\n }\r\n public static string ModelPath(string path)\r\n {\r\n if(path.EndsWith(\".vmdl_c\",StringComparison.OrdinalIgnoreCase))path=path[..^2];\r\n if(System.IO.Path.IsPathRooted(path)) path=System.IO.Path.GetRelativePath(Assets,path);\r\n return VmdlSetupService.NormalizeAssetPath(path,\".vmdl\");\r\n }\r\n public static string SafeName(string name) => string.Concat(name.Select(c=>char.IsLetterOrDigit(c)||c=='_'||c=='-'?c:'_')).Trim('_') is {Length:>0} result ? result : \"animation\";\r\n internal static string ContentKey(string text)=>Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(text))).ToLowerInvariant()[..16];\r\n internal static string SourceKey(HandSource source)=>SafeName(System.IO.Path.GetFileNameWithoutExtension(source.Path))+\"_\"+ContentKey(source.Path.Replace('\\\\','/').ToLowerInvariant())[..8];\r\n\r\n private sealed class ModelSampler : IDisposable\r\n {\r\n readonly Scene metadataScene = Scene.CreateEditorScene(); readonly SkinnedModelRenderer metadata;\r\n readonly SceneWorld world=new(); readonly Model asset; readonly Skel skeleton; readonly int[] bones;\r\n public ModelSampler(Model asset, Skel rig) { using(metadataScene.Push()) { metadata=new GameObject(true,\"sequence metadata\").GetOrAddComponent<SkinnedModelRenderer>(); metadata.Model=asset; metadata.UseAnimGraph=false; } this.asset=asset; skeleton=rig; bones=rig.Bones.Select(b=>asset.Bones.GetBone(b.Name).Index).ToArray(); }\r\n public Clip Sample(string name,float fps,CancellationToken cancel=default)\r\n {\r\n var model=new SceneModel(world,asset,Transform.Zero){UseAnimGraph=false};\r\n try {\r\n model.SetAnimGraph(\"\");model.UseAnimGraph=false;\r\n model.CurrentSequence.Name=name; metadata.Sequence.Name=name;\r\n var duration=model.CurrentSequence.Duration;\r\n var count=Math.Max(1,(int)MathF.Round(duration*fps)+1);\r\n if(count>100000) throw new InvalidOperationException($\"Sequence '{name}' is too long to sample.\");\r\n var frames=new List<XForm[]>(count);\r\n for(var f=0;f<count;f++) {\r\n cancel.ThrowIfCancellationRequested();\r\n model.CurrentSequence.Time=MathF.Min(f/fps,duration); model.Update(0);\r\n var worlds=bones.Select(b=>FromEngine(model.GetBoneWorldTransform(b))).ToArray();\r\n frames.Add(skeleton.Bones.Select(b=>b.ParentIndex<0?worlds[b.Index]:XForm.ToLocal(worlds[b.ParentIndex],worlds[b.Index])).ToArray());\r\n }\r\n return new(name,fps,metadata.Sequence.Looping,frames);\r\n } finally {model.Delete();}\r\n }\r\n public Clip? SampleFire(float duration,float fps,CancellationToken cancel)\r\n {\r\n var model=new SceneModel(world,asset,Transform.Zero){UseAnimGraph=true};\r\n try\r\n {\r\n if(model.AnimationGraph is null||model.AnimationGraph.IsError)return null;\r\n model.SetAnimParameter(\"skeleton\",0);model.SetAnimParameter(\"b_deploy_skip\",true);\r\n for(var i=0;i<120;i++){cancel.ThrowIfCancellationRequested();model.Update(1f/60);}\r\n model.SetAnimParameter(\"b_deploy_skip\",false);\r\n var frames=new List<XForm[]>();\r\n for(var f=0;f<=Math.Max(1,(int)MathF.Ceiling(duration*fps));f++)\r\n {\r\n cancel.ThrowIfCancellationRequested();model.SetAnimParameter(\"b_attack\",f==0);model.Update(f==0?0:1f/fps);\r\n var worlds=bones.Select(b=>FromEngine(model.GetBoneWorldTransform(b))).ToArray();\r\n frames.Add(skeleton.Bones.Select(b=>b.ParentIndex<0?worlds[b.Index]:XForm.ToLocal(worlds[b.ParentIndex],worlds[b.Index])).ToArray());\r\n }\r\n return new Clip(\"Fire\",fps,false,frames);\r\n }\r\n finally{model.Delete();}\r\n }\r\n public void Dispose() {world.Delete();metadataScene.Destroy();}\r\n }\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_humanoid_hand_retargeter",
"Path": "Core/Mapping/HandRigDefinition.cs",
"FileName": "HandRigDefinition.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 394704,
"IsPrivate": false,
"Code": "#nullable enable\r\nnamespace HumanoidHandRetargeter.Mapping;\r\n\r\npublic enum HandSide { Left, Right }\r\npublic enum DigitRole { Thumb, Index, Middle, Ring, Pinky, Extra }\r\n\r\n/// <summary>Ordered anatomical joints; optional palm and terminal bones are explicit.</summary>\r\npublic sealed class DigitChain\r\n{\r\n public DigitRole Role { get; }\r\n public string ExtraSlot { get; }\r\n public IReadOnlyList<int> Segments { get; }\r\n public int? Metacarpal { get; }\r\n public int? Tip { get; }\r\n\r\n public DigitChain(DigitRole role, IEnumerable<int> segments,\r\n int? metacarpal = null, int? tip = null, string extraSlot = \"\")\r\n {\r\n ArgumentNullException.ThrowIfNull(segments);\r\n Role = role;\r\n Segments = Array.AsReadOnly(segments.ToArray());\r\n Metacarpal = metacarpal;\r\n Tip = tip;\r\n ExtraSlot = extraSlot ?? \"\";\r\n }\r\n\r\n public IEnumerable<int> Bones\r\n {\r\n get\r\n {\r\n if (Metacarpal is int palm) yield return palm;\r\n foreach (var segment in Segments) yield return segment;\r\n if (Tip is int tip) yield return tip;\r\n }\r\n }\r\n}\r\n\r\n/// <summary>A single hand; rigs with only one side do not need a placeholder other hand.</summary>\r\npublic sealed class HandRigDefinition\r\n{\r\n public HandSide Side { get; }\r\n public int Wrist { get; }\r\n public int? Clavicle { get; }\r\n public int? UpperArm { get; }\r\n public int? Forearm { get; }\r\n public IReadOnlyList<DigitChain> Digits { get; }\r\n public IReadOnlyList<int> TwistOrHelperBones { get; }\r\n\r\n public HandRigDefinition(HandSide side, int wrist, IEnumerable<DigitChain> digits,\r\n int? clavicle = null, int? upperArm = null, int? forearm = null,\r\n IEnumerable<int>? twistOrHelperBones = null)\r\n {\r\n ArgumentNullException.ThrowIfNull(digits);\r\n Side = side;\r\n Wrist = wrist;\r\n Clavicle = clavicle;\r\n UpperArm = upperArm;\r\n Forearm = forearm;\r\n Digits = Array.AsReadOnly(digits.ToArray());\r\n TwistOrHelperBones = Array.AsReadOnly((twistOrHelperBones ?? Array.Empty<int>()).ToArray());\r\n }\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_humanoid_hand_retargeter",
"Path": "Core/Retargeting/HandMotionOptions.cs",
"FileName": "HandMotionOptions.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 394704,
"IsPrivate": false,
"Code": "#nullable enable\r\nnamespace HumanoidHandRetargeter.Retargeting;\r\n\r\npublic sealed class HandMotionOptions\r\n{\r\n public bool TransferWristPosition { get; init; } = true;\r\n public bool ScaleWristTravel { get; init; } = true;\r\n /// <summary>Known source-to-target model axes, independent of the wrists' rest rotations.</summary>\r\n public System.Numerics.Quaternion? WristTravelBasis {get;init;}\r\n public bool SolveArmIk { get; init; } = true;\r\n public bool PreserveWeaponGrip { get; init; } = true;\r\n /// <summary>Shared source weapon-to-target translation in centimeters. Grip motion is never scaled per hand.</summary>\r\n public System.Numerics.Vector3? WeaponSpaceOffset {get;init;}\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_humanoid_hand_retargeter",
"Path": "Core/Retargeting/HandRetargeter.cs",
"FileName": "HandRetargeter.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 394704,
"IsPrivate": false,
"Code": "#nullable enable\r\nusing System.Numerics;\r\nusing HumanoidHandRetargeter.Calibration;\r\nusing HumanoidHandRetargeter.Formats;\r\nusing HumanoidHandRetargeter.Maths;\r\nusing HumanoidHandRetargeter.Skeleton;\r\nusing HumanoidHandRetargeter.Validation;\r\n\r\nnamespace HumanoidHandRetargeter.Retargeting;\r\nusing Vector3 = System.Numerics.Vector3;\r\n\r\n/// <summary>One offline solve path for static poses and baked clips. Target translations\r\n/// and unmapped local transforms stay authored; helper constraints remain target-owned.</summary>\r\npublic static class HandRetargeter\r\n{\r\n /// <summary>One continuous graph stream, retaining digit angle history between evaluated frames.</summary>\r\n public sealed class PoseStream\r\n {\r\n private readonly HandRetargetProfile profile;\r\n private readonly HandMotionOptions options;\r\n private readonly AngleHistory history;\r\n public PoseStream(HandRetargetProfile profile, HandMotionOptions options)\r\n { this.profile = profile; this.options = options; history = new(profile); }\r\n public Pose Step(Pose source)\r\n {\r\n var pose = Solve(profile, source, history);\r\n if (options.TransferWristPosition) ApplyWristMotion(profile, source, pose, options);\r\n var sourceWorld = source.ToWorld(profile.Source);\r\n foreach (var pair in profile.PreservedTracks)\r\n {\r\n var targetWorld = pose.ToWorld(profile.Target);\r\n var parent = profile.Target[pair.Target].ParentIndex;\r\n pose.Locals[pair.Target] = parent < 0 ? sourceWorld[pair.Source] : XForm.ToLocal(targetWorld[parent], sourceWorld[pair.Source]);\r\n }\r\n return pose;\r\n }\r\n }\r\n\r\n public static Pose RetargetPose(HandRetargetProfile profile, Pose sourcePose)\r\n => Solve(profile, sourcePose, null);\r\n\r\n /// <summary>Retains timing and fixes quaternion signs. Stateful angle unwrapping is\r\n /// local to this clip, so a calibrated profile may safely be shared by concurrent jobs.</summary>\r\n public static Clip RetargetClip(HandRetargetProfile profile, Clip sourceClip, CancellationToken cancellationToken = default)\r\n => Bake(profile, sourceClip, new HandMotionOptions { TransferWristPosition = false }, cancellationToken);\r\n\r\n /// <summary>The shared editor preview/export bake including optional wrist travel and arm IK.</summary>\r\n public static Clip Bake(HandRetargetProfile profile, Clip sourceClip, HandMotionOptions options, CancellationToken cancellationToken = default)\r\n {\r\n ArgumentNullException.ThrowIfNull(profile);\r\n ArgumentNullException.ThrowIfNull(sourceClip);\r\n ArgumentNullException.ThrowIfNull(options);\r\n if (sourceClip.FrameCount == 0)\r\n throw new RigValidationException(new[] { new RigIssue(\"empty-clip\", \"Source animation has no frames.\") });\r\n var frames = new List<XForm[]>(sourceClip.FrameCount);\r\n var continuity = new AngleHistory(profile);\r\n foreach (var sourceFrame in sourceClip.Frames)\r\n {\r\n cancellationToken.ThrowIfCancellationRequested();\r\n var pose = Solve(profile, new Pose(sourceFrame), continuity);\r\n if (options.TransferWristPosition) ApplyWristMotion(profile, new Pose(sourceFrame), pose, options);\r\n if(profile.PreservedTracks.Length>0)\r\n {\r\n var sourceWorld=new Pose(sourceFrame).ToWorld(profile.Source);\r\n foreach(var pair in profile.PreservedTracks)\r\n {\r\n var targetWorld=pose.ToWorld(profile.Target);var parent=profile.Target[pair.Target].ParentIndex;\r\n pose.Locals[pair.Target]=parent<0?sourceWorld[pair.Source]:XForm.ToLocal(targetWorld[parent],sourceWorld[pair.Source]);\r\n }\r\n }\r\n frames.Add(pose.Locals);\r\n }\r\n QuaternionContinuity.AlignFrames(frames);\r\n return new Clip(sourceClip.Name, sourceClip.Fps, sourceClip.Looping, frames, sourceClip.NativeFps);\r\n }\r\n\r\n private static void ApplyWristMotion(HandRetargetProfile profile, Pose source, Pose target, HandMotionOptions options)\r\n {\r\n if(options.WeaponSpaceOffset.HasValue&&options.SolveArmIk)\r\n foreach(var shoulder in profile.FpsShoulders)target.Locals[shoulder.Key].Pos=shoulder.Value;\r\n var sourceWorld = source.ToWorld(profile.Source);\r\n foreach (var pair in profile.WristMotion)\r\n {\r\n var world = target.ToWorld(profile.Target);\r\n var travel = Vector3.Transform(sourceWorld[pair.Source].Pos - profile.Source.RestWorld[pair.Source].Pos, options.WristTravelBasis ?? pair.Basis);\r\n var desired = profile.Target.RestWorld[pair.Target].Pos + travel * (options.ScaleWristTravel ? pair.Scale : 1f);\r\n var wristRotation = world[pair.Target].Rot;\r\n if(options.WeaponSpaceOffset is {} weaponOffset)\r\n {\r\n wristRotation=MathQ.Normalize(sourceWorld[pair.Source].Rot*pair.GripRotationOffset);\r\n desired=sourceWorld[pair.Source].Pos+weaponOffset\r\n +Vector3.Transform(pair.SourceGripLocal,sourceWorld[pair.Source].Rot)\r\n -Vector3.Transform(pair.TargetGripLocal,wristRotation);\r\n }\r\n if (options.SolveArmIk && pair.UpperArm is int upper && pair.Forearm is int lower)\r\n {\r\n if(options.WeaponSpaceOffset.HasValue)\r\n {\r\n // Detached FPS arms can move their open shoulder ends. Avoid\r\n // folding long/thick forearms back through their own upper arm\r\n // when the weapon grip lies very close to the shoulder.\r\n if(profile.Target[upper].ParentIndex<0)\r\n {\r\n var shoulder=HandViewSpace.ClearElbowFold(world[upper].Pos,world[lower].Pos,world[pair.Target].Pos,desired);\r\n target.Locals[upper].Pos=shoulder;\r\n world=target.ToWorld(profile.Target);\r\n }\r\n // FPS arm meshes have free shoulder ends. Move the shoulder enough\r\n // to reach the fixed weapon grip instead of stretching either arm segment.\r\n var reach=Vector3.Distance(world[upper].Pos,world[lower].Pos)+Vector3.Distance(world[lower].Pos,world[pair.Target].Pos);\r\n var toGrip=desired-world[upper].Pos;var distance=toGrip.Length();\r\n if(distance>reach*.98f&&distance>1e-5f)\r\n {\r\n var shoulder=world[upper].Pos+toGrip/distance*(distance-reach*.98f);\r\n var parent=profile.Target[upper].ParentIndex;\r\n target.Locals[upper].Pos=parent<0?shoulder:Vector3.Transform(shoulder-world[parent].Pos,Quaternion.Conjugate(world[parent].Rot));\r\n world=target.ToWorld(profile.Target);\r\n }\r\n }\r\n var correction = TwoBoneIk.Solve(world[upper].Pos, world[lower].Pos, world[pair.Target].Pos, desired, 0, pair.BendAxis);\r\n void SetWorldRotation(int bone, Quaternion rotation)\r\n {\r\n var parent = profile.Target[bone].ParentIndex;\r\n target.Locals[bone].Rot = MathQ.Normalize(parent < 0 ? rotation : Quaternion.Conjugate(world[parent].Rot) * rotation);\r\n world = target.ToWorld(profile.Target);\r\n }\r\n var oldLower = world[lower].Rot;\r\n SetWorldRotation(upper, correction.UpperWorldDelta * world[upper].Rot);\r\n SetWorldRotation(lower, correction.LowerWorldDelta * oldLower);\r\n if(options.WeaponSpaceOffset.HasValue && pair.SourceForearm is int sourceElbow && pair.SourceUpperArm is int sourceShoulder)\r\n {\r\n // Transfer the source bend plane between the two shoulder-to-wrist axes.\r\n // An absolute source elbow position can lie on the target arm's axis\r\n // when proportions differ, flipping its pole as the weapon recoils.\r\n var axis=world[pair.Target].Pos-world[upper].Pos;\r\n var sourceAxis=sourceWorld[pair.Source].Pos-sourceWorld[sourceShoulder].Pos;\r\n if(axis.LengthSquared()>1e-8f&&sourceAxis.LengthSquared()>1e-8f)\r\n {\r\n axis=Vector3.Normalize(axis);\r\n sourceAxis=Vector3.Normalize(sourceAxis);\r\n var actual=world[lower].Pos-world[upper].Pos;\r\n var wanted=sourceWorld[sourceElbow].Pos-sourceWorld[sourceShoulder].Pos;\r\n wanted-=sourceAxis*Vector3.Dot(wanted,sourceAxis);\r\n wanted=Vector3.Transform(wanted,MathQ.FromTo(sourceAxis,axis));\r\n actual-=axis*Vector3.Dot(actual,axis);\r\n // A straight arm has no defined pole: retain the preceding IK plane.\r\n if(actual.LengthSquared()>1e-6f&&wanted.LengthSquared()>1e-6f)\r\n {\r\n actual=Vector3.Normalize(actual);wanted=Vector3.Normalize(wanted);\r\n var angle=MathF.Atan2(Vector3.Dot(axis,Vector3.Cross(actual,wanted)),Vector3.Dot(actual,wanted));\r\n var pole=Quaternion.CreateFromAxisAngle(axis,angle);\r\n oldLower=world[lower].Rot;\r\n SetWorldRotation(upper,pole*world[upper].Rot);\r\n SetWorldRotation(lower,pole*oldLower);\r\n }\r\n }\r\n }\r\n if(options.WeaponSpaceOffset.HasValue&&!pair.HasTwistHelpers)\r\n {\r\n // Without authored twist helpers, put axial pronation in the forearm\r\n // instead of winding the wrist through the fixed palm orientation.\r\n // Rotating about elbow-to-wrist leaves the grip position unchanged.\r\n var axis=world[pair.Target].Pos-world[lower].Pos;\r\n if(axis.LengthSquared()>1e-8f)\r\n {\r\n var restRelative=MathQ.Normalize(Quaternion.Conjugate(profile.Target.RestWorld[lower].Rot)*profile.Target.RestWorld[pair.Target].Rot);\r\n var relaxedLower=MathQ.Normalize(wristRotation*Quaternion.Conjugate(restRelative));\r\n var delta=MathQ.Normalize(relaxedLower*Quaternion.Conjugate(world[lower].Rot));\r\n MathQ.SwingTwist(delta,Vector3.Normalize(axis),out _,out var roll);\r\n SetWorldRotation(lower,roll*world[lower].Rot);\r\n }\r\n }\r\n SetWorldRotation(pair.Target, wristRotation);\r\n if(options.WeaponSpaceOffset.HasValue&&Vector3.Distance(world[pair.Target].Pos,desired)>.1f)\r\n throw new InvalidOperationException(\"The target arm cannot reach the weapon grip without stretching. Adjust the rig's shoulder placement or disable Preserve weapon grip.\");\r\n }\r\n else\r\n {\r\n // Partial rigs cannot reach with a two-link chain. Preserve the authored\r\n // wrist path by translating the wrist relative to its actual parent.\r\n var parent = profile.Target[pair.Target].ParentIndex;\r\n target.Locals[pair.Target].Pos = parent < 0 ? desired\r\n : Vector3.Transform(desired - world[parent].Pos, Quaternion.Conjugate(world[parent].Rot));\r\n target.Locals[pair.Target].Rot=MathQ.Normalize(parent<0?wristRotation:Quaternion.Conjugate(world[parent].Rot)*wristRotation);\r\n }\r\n }\r\n var errors = PoseValidator.Validate(profile.Target, target);\r\n if (errors.Count > 0) throw new RigValidationException(errors);\r\n }\r\n\r\n private static Pose Solve(HandRetargetProfile profile, Pose sourcePose, AngleHistory? history)\r\n {\r\n ArgumentNullException.ThrowIfNull(profile);\r\n var errors = PoseValidator.Validate(profile.Source, sourcePose);\r\n if (errors.Count > 0) throw new RigValidationException(errors);\r\n var sourceWorld = sourcePose.ToWorld(profile.Source);\r\n errors = PoseValidator.Validate(profile.Source, new Pose(sourceWorld));\r\n if (errors.Count > 0) throw new RigValidationException(errors);\r\n var sourceDeltas = new Quaternion[sourceWorld.Length];\r\n for (var i = 0; i < sourceWorld.Length; i++)\r\n sourceDeltas[i] = MathQ.Normalize(sourceWorld[i].Rot * Quaternion.Conjugate(profile.Source.RestWorld[i].Rot));\r\n var targetDeltas = Enumerable.Repeat(Quaternion.Identity, profile.Target.Count).ToArray();\r\n var solved = new bool[profile.Target.Count];\r\n foreach (var pair in profile.Pairs)\r\n {\r\n var parentSource = pair.SourceParent >= 0 ? sourceDeltas[pair.SourceParent] : Quaternion.Identity;\r\n var parentTarget = pair.TargetParent >= 0 ? targetDeltas[pair.TargetParent] : Quaternion.Identity;\r\n var canonical = MathQ.Normalize(Quaternion.Conjugate(pair.SourceFrame) * Quaternion.Conjugate(parentSource)\r\n * sourceDeltas[pair.Source] * pair.SourceFrame);\r\n targetDeltas[pair.Target] = MathQ.Normalize(parentTarget * pair.TargetFrame * canonical * Quaternion.Conjugate(pair.TargetFrame));\r\n solved[pair.Target] = true;\r\n }\r\n for (var d = 0; d < profile.Distributions.Length; d++)\r\n {\r\n var digit = profile.Distributions[d];\r\n var curls = new float[digit.Source.Length];\r\n var spread = 0f;\r\n var previous = sourceDeltas[digit.SourceWrist];\r\n for (var s = 0; s < digit.Source.Length; s++)\r\n {\r\n var frame = digit.SourceFrames[s];\r\n var delta = sourceDeltas[digit.Source[s]];\r\n var canonical = MathQ.Normalize(Quaternion.Conjugate(frame) * Quaternion.Conjugate(previous) * delta * frame);\r\n // Legacy FingerSolver decomposition: canonical Y curl, Z splay, discard X twist when redistributing.\r\n MathQ.SwingTwist(canonical, Vector3.UnitY, out var swing, out var curl);\r\n curls[s] = SignedAngle(curl, Vector3.UnitY);\r\n if (history is not null) curls[s] = history.Unwrap(d, s, curls[s], false);\r\n if (s <= digit.ProximalSourceIndex)\r\n {\r\n MathQ.SwingTwist(swing, Vector3.UnitZ, out _, out var splay);\r\n var angle = SignedAngle(splay, Vector3.UnitZ);\r\n if (history is not null) angle = history.Unwrap(d, s, angle, true);\r\n spread += angle;\r\n }\r\n previous = delta;\r\n }\r\n var accumulated = targetDeltas[digit.TargetWrist];\r\n for (var t = 0; t < digit.Target.Length; t++)\r\n {\r\n var angle = 0f;\r\n for (var s = 0; s < curls.Length; s++) angle += digit.Weights[t, s] * curls[s];\r\n var motion = Quaternion.CreateFromAxisAngle(Vector3.UnitY, angle);\r\n if (t == 0) motion = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, spread) * motion;\r\n var frame = digit.TargetFrames[t];\r\n accumulated = MathQ.Normalize(accumulated * frame * motion * Quaternion.Conjugate(frame));\r\n targetDeltas[digit.Target[t]] = accumulated;\r\n solved[digit.Target[t]] = true;\r\n }\r\n }\r\n\r\n var targetPose = Pose.Rest(profile.Target);\r\n var targetWorld = new XForm[profile.Target.Count];\r\n for (var i = 0; i < targetWorld.Length; i++)\r\n {\r\n var parent = profile.Target[i].ParentIndex;\r\n if (solved[i])\r\n {\r\n var desiredWorld = targetDeltas[i] * profile.Target.RestWorld[i].Rot;\r\n targetPose.Locals[i].Rot = MathQ.Normalize(parent < 0 ? desiredWorld : Quaternion.Conjugate(targetWorld[parent].Rot) * desiredWorld);\r\n }\r\n targetWorld[i] = parent < 0 ? targetPose.Locals[i] : XForm.Compose(targetWorld[parent], targetPose.Locals[i]);\r\n }\r\n errors = PoseValidator.Validate(profile.Target, targetPose);\r\n if (errors.Count > 0) throw new RigValidationException(errors);\r\n errors = PoseValidator.Validate(profile.Target, new Pose(targetWorld));\r\n if (errors.Count > 0) throw new RigValidationException(errors);\r\n return targetPose;\r\n }\r\n\r\n // Same signed twist convention as the legacy FingerSolver.\r\n private static float SignedAngle(Quaternion twist, Vector3 axis)\r\n {\r\n var angle = 2f * MathF.Atan2(twist.X * axis.X + twist.Y * axis.Y + twist.Z * axis.Z, twist.W);\r\n if (angle > MathF.PI) angle -= 2f * MathF.PI;\r\n if (angle < -MathF.PI) angle += 2f * MathF.PI;\r\n return angle;\r\n }\r\n\r\n private sealed class AngleHistory\r\n {\r\n private readonly float[][] _curl;\r\n private readonly float[][] _spread;\r\n public AngleHistory(HandRetargetProfile profile)\r\n {\r\n _curl = profile.Distributions.Select(d => Enumerable.Repeat(float.NaN, d.Source.Length).ToArray()).ToArray();\r\n _spread = profile.Distributions.Select(d => Enumerable.Repeat(float.NaN, d.Source.Length).ToArray()).ToArray();\r\n }\r\n public float Unwrap(int digit, int joint, float angle, bool spread)\r\n {\r\n var values = spread ? _spread : _curl;\r\n var previous = values[digit][joint];\r\n if (float.IsFinite(previous))\r\n angle += 2f * MathF.PI * MathF.Round((previous - angle) / (2f * MathF.PI));\r\n values[digit][joint] = angle;\r\n return angle;\r\n }\r\n }\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_humanoid_hand_retargeter",
"Path": "Core/Skeleton/Skeleton.cs",
"FileName": "Skeleton.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 394704,
"IsPrivate": false,
"Code": "// Adapted from humanoid-retargeter 26084c96c3fc870aaf9a5bd798de063ce2fd62df; cached hierarchy queries added.\r\n#nullable enable annotations\r\n\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing HumanoidHandRetargeter.Maths;\r\n\r\nnamespace HumanoidHandRetargeter.Skeleton;\r\n\r\n/// <summary>\r\n/// Input definition for a single bone, used to build a <see cref=\"Skeleton\"/>.\r\n/// Order does not matter; construction topologically sorts parents before children.\r\n/// </summary>\r\n/// <param name=\"Name\">Unique bone name.</param>\r\n/// <param name=\"ParentName\">Parent bone name, or null for a root bone (multiple roots allowed).</param>\r\n/// <param name=\"RestLocal\">Rest (bind) transform relative to the parent bone, centimeters.</param>\r\npublic readonly record struct BoneDefinition(string Name, string? ParentName, XForm RestLocal);\r\n\r\n/// <summary>One bone of an immutable <see cref=\"Skeleton\"/>.</summary>\r\npublic readonly struct Bone\r\n{\r\n /// <summary>Index of this bone in the skeleton (parents always have a smaller index).</summary>\r\n public int Index { get; }\r\n\r\n /// <summary>Unique bone name.</summary>\r\n public string Name { get; }\r\n\r\n /// <summary>Index of the parent bone, or -1 for a root bone.</summary>\r\n public int ParentIndex { get; }\r\n\r\n /// <summary>Rest (bind) transform relative to the parent bone (or armature space for roots).</summary>\r\n public XForm RestLocal { get; }\r\n\r\n internal Bone(int index, string name, int parentIndex, XForm restLocal)\r\n {\r\n Index = index;\r\n Name = name;\r\n ParentIndex = parentIndex;\r\n RestLocal = restLocal;\r\n }\r\n\r\n /// <inheritdoc />\r\n public override string ToString() => $\"[{Index}] {Name} (parent {ParentIndex})\";\r\n}\r\n\r\n/// <summary>\r\n/// Immutable bone hierarchy with rest pose. Bones are stored topologically sorted\r\n/// (every bone's parent precedes it), which lets forward kinematics run as a single\r\n/// in-order pass. Supports multiple roots (e.g. the s&box rig has both\r\n/// <c>root_IK</c> and <c>pelvis</c> as parentless bones).\r\n/// </summary>\r\npublic sealed class Skeleton\r\n{\r\n private readonly Bone[] _bones;\r\n private readonly XForm[] _restWorld;\r\n private readonly Dictionary<string, int> _indexByName;\r\n private readonly IReadOnlyList<int>[] _children;\r\n\r\n private Skeleton(Bone[] bones, XForm[] restWorld, Dictionary<string, int> indexByName)\r\n {\r\n _bones = bones;\r\n _restWorld = restWorld;\r\n _indexByName = indexByName;\r\n var children = Enumerable.Range(0, bones.Length).Select(_ => new List<int>()).ToArray();\r\n foreach (var bone in bones)\r\n if (bone.ParentIndex >= 0) children[bone.ParentIndex].Add(bone.Index);\r\n _children = children.Select(c => (IReadOnlyList<int>)c.AsReadOnly()).ToArray();\r\n }\r\n\r\n /// <summary>Number of bones.</summary>\r\n public int Count => _bones.Length;\r\n\r\n /// <summary>All bones, topologically sorted (parents before children).</summary>\r\n public IReadOnlyList<Bone> Bones => _bones;\r\n\r\n /// <summary>Rest (bind) world transforms, indexed like <see cref=\"Bones\"/>.</summary>\r\n public IReadOnlyList<XForm> RestWorld => _restWorld;\r\n\r\n /// <summary>Bone name to index lookup.</summary>\r\n public IReadOnlyDictionary<string, int> IndexByName => _indexByName;\r\n\r\n /// <summary>The bone at <paramref name=\"index\"/>.</summary>\r\n public Bone this[int index] => _bones[index];\r\n\r\n /// <summary>Returns the index of the named bone, or -1 when absent.</summary>\r\n public int IndexOf(string name) => _indexByName.TryGetValue(name, out var index) ? index : -1;\r\n\r\n /// <summary>Cached immediate children in stable skeleton order.</summary>\r\n public IReadOnlyList<int> ChildrenOf(int bone) => _children[bone];\r\n\r\n /// <summary>Whether child is strictly below ancestor; a bone is not its own descendant.</summary>\r\n public bool DescendsFrom(int child, int ancestor)\r\n {\r\n if (child < 0 || child >= Count) throw new ArgumentOutOfRangeException(nameof(child));\r\n if (ancestor < 0 || ancestor >= Count) throw new ArgumentOutOfRangeException(nameof(ancestor));\r\n for (var parent = _bones[child].ParentIndex; parent >= 0; parent = _bones[parent].ParentIndex)\r\n if (parent == ancestor) return true;\r\n return false;\r\n }\r\n\r\n /// <summary>\r\n /// Builds a skeleton from bone definitions in any order: validates names and parent links,\r\n /// topologically sorts (stable \u2014 input order is preserved among bones whose parents are\r\n /// already placed), and computes rest world transforms.\r\n /// </summary>\r\n /// <exception cref=\"ArgumentException\">\r\n /// Thrown on duplicate bone names, unknown parent names, or parent cycles.\r\n /// </exception>\r\n public static Skeleton Create(IReadOnlyList<BoneDefinition> definitions)\r\n {\r\n ArgumentNullException.ThrowIfNull(definitions);\r\n\r\n var count = definitions.Count;\r\n var originalIndexByName = new Dictionary<string, int>(count, StringComparer.Ordinal);\r\n for (var i = 0; i < count; i++)\r\n {\r\n var name = definitions[i].Name;\r\n if (string.IsNullOrEmpty(name))\r\n throw new ArgumentException($\"Bone at position {i} has an empty name.\", nameof(definitions));\r\n if (!originalIndexByName.TryAdd(name, i))\r\n throw new ArgumentException($\"Duplicate bone name '{name}'.\", nameof(definitions));\r\n }\r\n\r\n foreach (var def in definitions)\r\n {\r\n if (def.ParentName is not null && !originalIndexByName.ContainsKey(def.ParentName))\r\n throw new ArgumentException(\r\n $\"Bone '{def.Name}' references unknown parent '{def.ParentName}'.\", nameof(definitions));\r\n if (def.ParentName == def.Name)\r\n throw new ArgumentException($\"Bone '{def.Name}' is its own parent.\", nameof(definitions));\r\n }\r\n\r\n // Stable topological sort: repeatedly place, in input order, every bone whose parent\r\n // is already placed. O(n^2) worst case \u2014 fine for skeleton-sized inputs (~100 bones).\r\n var newIndexByOriginal = new int[count];\r\n Array.Fill(newIndexByOriginal, -1);\r\n var order = new List<int>(count);\r\n while (order.Count < count)\r\n {\r\n var progressed = false;\r\n for (var i = 0; i < count; i++)\r\n {\r\n if (newIndexByOriginal[i] >= 0)\r\n continue;\r\n var parentName = definitions[i].ParentName;\r\n if (parentName is not null && newIndexByOriginal[originalIndexByName[parentName]] < 0)\r\n continue;\r\n newIndexByOriginal[i] = order.Count;\r\n order.Add(i);\r\n progressed = true;\r\n }\r\n if (!progressed)\r\n throw new ArgumentException(\"Bone hierarchy contains a parent cycle.\", nameof(definitions));\r\n }\r\n\r\n var bones = new Bone[count];\r\n var restWorld = new XForm[count];\r\n var indexByName = new Dictionary<string, int>(count, StringComparer.Ordinal);\r\n for (var newIndex = 0; newIndex < count; newIndex++)\r\n {\r\n var def = definitions[order[newIndex]];\r\n var parentIndex = def.ParentName is null ? -1 : newIndexByOriginal[originalIndexByName[def.ParentName]];\r\n bones[newIndex] = new Bone(newIndex, def.Name, parentIndex, def.RestLocal);\r\n restWorld[newIndex] = parentIndex < 0\r\n ? def.RestLocal\r\n : XForm.Compose(restWorld[parentIndex], def.RestLocal);\r\n indexByName.Add(def.Name, newIndex);\r\n }\r\n\r\n return new Skeleton(bones, restWorld, indexByName);\r\n }\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_humanoid_hand_retargeter",
"Path": "Formats/Fbx/FbxNode.cs",
"FileName": "FbxNode.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 394704,
"IsPrivate": false,
"Code": "// Reused from humanoid-retargeter 26084c96c3fc870aaf9a5bd798de063ce2fd62df; specialized where needed for hand assets.\r\n#nullable enable annotations\r\n\r\nnamespace HumanoidHandRetargeter.Formats.Fbx;\r\n\r\n/// <summary>\r\n/// A single node of an FBX document tree (binary or ASCII): a name, a flat list of\r\n/// typed properties, and nested child nodes.\r\n///\r\n/// Property values are stored as the closest CLR type to what the file contained:\r\n/// <list type=\"bullet\">\r\n/// <item><c>short</c> ('Y'), <c>bool</c> or character <c>byte</c> ('C'), <c>int</c> ('I'), <c>float</c> ('F'),\r\n/// <c>double</c> ('D'), <c>long</c> ('L')</item>\r\n/// <item><c>float[]</c> ('f'), <c>double[]</c> ('d'), <c>long[]</c> ('l'),\r\n/// <c>int[]</c> ('i'), <c>bool[]</c>-as-<c>byte[]</c> ('b')</item>\r\n/// <item><c>string</c> ('S' \u2014 kept raw, may contain the <c>\\x00\\x01</c> name/class\r\n/// separator; see <see cref=\"SplitName\"/>), <c>byte[]</c> ('R')</item>\r\n/// </list>\r\n/// ASCII files store numbers only as <c>long</c> / <c>double</c> (and arrays as\r\n/// <c>long[]</c> / <c>double[]</c>), so the typed accessors below convert tolerantly.\r\n/// </summary>\r\npublic sealed class FbxNode\r\n{\r\n public string Name { get; }\r\n public List<object> Properties { get; } = new();\r\n public List<FbxNode> Children { get; } = new();\r\n public bool HasChildScope { get; set; }\r\n // Document metadata retained when repairing binary FBX files.\r\n public byte[]? FooterWatermark { get; set; }\r\n\r\n public FbxNode(string name) => Name = name;\r\n\r\n /// <summary>First child with the given name, or null.</summary>\r\n public FbxNode? Child(string name)\r\n {\r\n foreach (var c in Children)\r\n if (c.Name == name)\r\n return c;\r\n return null;\r\n }\r\n\r\n /// <summary>All children with the given name, in document order.</summary>\r\n public IEnumerable<FbxNode> ChildrenNamed(string name)\r\n {\r\n foreach (var c in Children)\r\n if (c.Name == name)\r\n yield return c;\r\n }\r\n\r\n /// <summary>\r\n /// Property <paramref name=\"i\"/> converted to <typeparamref name=\"T\"/>.\r\n /// Numeric scalars convert tolerantly across widths (e.g. an 'I' i32 read as long);\r\n /// anything else must match the stored type exactly.\r\n /// </summary>\r\n public T Prop<T>(int i)\r\n {\r\n object v = RawProp(i);\r\n if (v is T t)\r\n return t;\r\n\r\n var target = typeof(T);\r\n // s&box whitelist: Type.IsPrimitive is banned; enumerate the convertible targets.\r\n if (v is IConvertible && (ConvertTargets.Contains(target) || target == typeof(string)))\r\n {\r\n try\r\n {\r\n return (T)Convert.ChangeType(v, target, System.Globalization.CultureInfo.InvariantCulture);\r\n }\r\n // ArithmeticException covers OverflowException, which is not s&box-whitelisted\r\n catch (Exception ex) when (ex is InvalidCastException or ArithmeticException or FormatException)\r\n {\r\n throw new FormatException(\r\n $\"FBX node '{Name}': property {i} is {v.GetType().Name}, not convertible to {target.Name}.\", ex);\r\n }\r\n }\r\n\r\n throw new FormatException(\r\n $\"FBX node '{Name}': property {i} is {v.GetType().Name}, expected {target.Name}.\");\r\n }\r\n\r\n /// <summary>Property <paramref name=\"i\"/> as a double array (converts f/l/i/b arrays).</summary>\r\n public double[] AsDoubleArray(int i) => RawProp(i) switch\r\n {\r\n double[] d => d,\r\n float[] f => Array.ConvertAll(f, x => (double)x),\r\n long[] l => Array.ConvertAll(l, x => (double)x),\r\n int[] n => Array.ConvertAll(n, x => (double)x),\r\n byte[] b => Array.ConvertAll(b, x => (double)x),\r\n bool[] o => Array.ConvertAll(o, x => x ? 1.0 : 0.0),\r\n var v => throw TypeError(i, v, \"double[]\"),\r\n };\r\n\r\n /// <summary>Property <paramref name=\"i\"/> as a float array (converts d/l/i/b arrays).</summary>\r\n public float[] AsFloatArray(int i) => RawProp(i) switch\r\n {\r\n float[] f => f,\r\n double[] d => Array.ConvertAll(d, x => (float)x),\r\n long[] l => Array.ConvertAll(l, x => (float)x),\r\n int[] n => Array.ConvertAll(n, x => (float)x),\r\n byte[] b => Array.ConvertAll(b, x => (float)x),\r\n bool[] o => Array.ConvertAll(o, x => x ? 1f : 0f),\r\n var v => throw TypeError(i, v, \"float[]\"),\r\n };\r\n\r\n /// <summary>Property <paramref name=\"i\"/> as a long array (converts i/b; d/f if integral).</summary>\r\n public long[] AsLongArray(int i) => RawProp(i) switch\r\n {\r\n long[] l => l,\r\n int[] n => Array.ConvertAll(n, x => (long)x),\r\n byte[] b => Array.ConvertAll(b, x => (long)x),\r\n bool[] o => Array.ConvertAll(o, x => x ? 1L : 0L),\r\n double[] d => Array.ConvertAll(d, x => checked((long)x)),\r\n float[] f => Array.ConvertAll(f, x => checked((long)x)),\r\n var v => throw TypeError(i, v, \"long[]\"),\r\n };\r\n\r\n /// <summary>Property <paramref name=\"i\"/> as an int array (converts b; l/d/f narrowing-checked).</summary>\r\n public int[] AsIntArray(int i) => RawProp(i) switch\r\n {\r\n int[] n => n,\r\n long[] l => Array.ConvertAll(l, x => checked((int)x)),\r\n byte[] b => Array.ConvertAll(b, x => (int)x),\r\n bool[] o => Array.ConvertAll(o, x => x ? 1 : 0),\r\n double[] d => Array.ConvertAll(d, x => checked((int)x)),\r\n float[] f => Array.ConvertAll(f, x => checked((int)x)),\r\n var v => throw TypeError(i, v, \"int[]\"),\r\n };\r\n\r\n /// <summary>Property <paramref name=\"i\"/> as raw bytes ('R' blobs or 'b' bool arrays).</summary>\r\n public byte[] AsByteArray(int i) => RawProp(i) switch\r\n {\r\n byte[] b => b,\r\n var v => throw TypeError(i, v, \"byte[]\"),\r\n };\r\n\r\n /// <summary>Property <paramref name=\"i\"/> as a string (raw 'S' content, separators intact).</summary>\r\n public string AsString(int i) => RawProp(i) switch\r\n {\r\n string s => s,\r\n var v => throw TypeError(i, v, \"string\"),\r\n };\r\n\r\n /// <summary>\r\n /// Splits an FBX object name into (name, class).\r\n /// Binary files store <c>\"Name\\x00\\x01Class\"</c> (e.g. <c>\"mixamorig:Hips\\x00\\x01Model\"</c>);\r\n /// ASCII files store <c>\"Class::Name\"</c> (e.g. <c>\"Model::pelvis\"</c>).\r\n /// A plain string with neither separator yields (name, \"\").\r\n /// </summary>\r\n public static (string Name, string Class) SplitName(string raw)\r\n {\r\n int bin = raw.IndexOf(\"\\0\\x01\", StringComparison.Ordinal);\r\n if (bin >= 0)\r\n return (raw[..bin], raw[(bin + 2)..]);\r\n\r\n int ascii = raw.IndexOf(\"::\", StringComparison.Ordinal);\r\n if (ascii >= 0)\r\n return (raw[(ascii + 2)..], raw[..ascii]);\r\n\r\n return (raw, \"\");\r\n }\r\n\r\n /// <summary>Primitive scalar types <see cref=\"Prop{T}\"/> converts to (whitelist-safe IsPrimitive substitute).</summary>\r\n private static readonly HashSet<Type> ConvertTargets = new()\r\n {\r\n typeof(bool), typeof(byte), typeof(sbyte), typeof(short), typeof(ushort),\r\n typeof(int), typeof(uint), typeof(long), typeof(ulong),\r\n typeof(float), typeof(double), typeof(char),\r\n };\r\n\r\n private object RawProp(int i)\r\n {\r\n if (i < 0 || i >= Properties.Count)\r\n throw new FormatException(\r\n $\"FBX node '{Name}': property index {i} out of range (has {Properties.Count}).\");\r\n return Properties[i];\r\n }\r\n\r\n private FormatException TypeError(int i, object v, string wanted) =>\r\n new($\"FBX node '{Name}': property {i} is {v.GetType().Name}, expected {wanted}.\");\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_humanoid_hand_retargeter",
"Path": "Runtime/RetargetedWeaponController.cs",
"FileName": "RetargetedWeaponController.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 394704,
"IsPrivate": false,
"Code": "#nullable enable\r\nusing Sandbox;\r\nusing HumanoidHandRetargeter.Target;\r\n\r\nnamespace HumanoidHandRetargeter;\r\n\r\n/// <summary>Optional local player controls for exported weapons. Games may instead drive RetargetedWeapon directly.</summary>\r\n[Title(\"Retargeted Weapon Controls\"), Category(\"Animation\")]\r\npublic sealed class RetargetedWeaponController : Component, ICameraModifier\r\n{\r\n [Property] public PlayerController? Player { get; set; }\r\n [Property] public bool ReadInput { get; set; } = true;\r\n [Property] public int MagazineSize { get; set; } = 30;\r\n [Property] public int StartingReserve { get; set; } = 120;\r\n [Property] public float RoundsPerMinute { get; set; } = 800;\r\n [Property] public float ReloadSeconds { get; set; } = 2.7f;\r\n [Property] public float EmptyReloadSeconds { get; set; } = 3.2f;\r\n [Property] public float Damage { get; set; } = 20;\r\n [Property] public float Range { get; set; } = 10000;\r\n [Property] public float HipFov { get; set; } = 75;\r\n [Property] public float AimFov { get; set; } = 55;\r\n [Property] public string WorldGripBone { get; set; } = \"hand_R\";\r\n [Property] public int BodyHoldType { get; set; } = 2;\r\n [Property] public int BodyHandedness { get; set; }\r\n public int Ammo => magazine?.Rounds ?? MagazineSize;\r\n public int Reserve => magazine?.Reserve ?? StartingReserve;\r\n public bool Reloading => magazine?.Reloading ?? false;\r\n public bool Aiming { get; private set; }\r\n public int ShotsFired { get; private set; }\r\n public int Hits { get; private set; }\r\n public global::Vector3 LastHit { get; private set; }\r\n public Action<SceneTraceResult>? Shot { get; set; }\r\n private WeaponMagazine? magazine;\r\n private RetargetedWeapon? weapon;\r\n private float nextShot, reloadEnds, currentFov;\r\n public int CameraOrder => 200;\r\n protected override void OnStart()\r\n {\r\n weapon=GetComponent<RetargetedWeapon>();\r\n Player ??= Scene.GetAllComponents<PlayerController>().FirstOrDefault();\r\n if(Player is not null)Player.HideBodyInFirstPerson=true;\r\n magazine=new(MagazineSize,StartingReserve);\r\n currentFov=HipFov;\r\n weapon?.Deploy(true);\r\n }\r\n public bool Fire()\r\n {\r\n if(weapon is null||magazine is null||Player is null||Time.Now<nextShot||Reloading)return false;\r\n nextShot=Time.Now+60/Math.Max(1,RoundsPerMinute);\r\n if(!magazine.TryFire()){weapon.DryFire();return false;}\r\n weapon.Attack();weapon.SetEmpty(Ammo==0);ShotsFired++;\r\n UpdateBodyPose();\r\n Player.Renderer?.Set(\"b_attack\",true);\r\n var eye=Player.EyeTransform;\r\n var hit=Scene.Trace.Ray(eye.Position,eye.Position+eye.Rotation.Forward*Range)\r\n .IgnoreGameObjectHierarchy(Player.GameObject).IgnoreGameObjectHierarchy(GameObject).UseHitboxes().Run();\r\n if(hit.Hit)\r\n {\r\n Hits++;LastHit=hit.HitPosition;\r\n var info=new DamageInfo{Damage=Damage,Attacker=Player.GameObject,Weapon=GameObject,Position=hit.HitPosition,Origin=eye.Position};\r\n for(var obj=hit.GameObject;obj is not null;obj=obj.Parent)\r\n if(obj.GetComponent<IDamageable>() is {} damageable){damageable.OnDamage(info);break;}\r\n }\r\n Shot?.Invoke(hit);\r\n return true;\r\n }\r\n public bool Reload()\r\n {\r\n if(magazine is null||weapon is null||!magazine.BeginReload())return false;\r\n weapon.Reload(Ammo==0);reloadEnds=Time.Now+(Ammo==0?EmptyReloadSeconds:ReloadSeconds);\r\n UpdateBodyPose();\r\n Player?.Renderer?.Set(\"b_reload\",true);\r\n return true;\r\n }\r\n public void Aim(bool value){Aiming=value&&!Reloading;if(weapon is not null)weapon.Aiming=Aiming;}\r\n protected override void OnUpdate()\r\n {\r\n if(weapon is null||Player is null||magazine is null)return;\r\n if(Reloading&&Time.Now>=reloadEnds){magazine.FinishReload();weapon.SetEmpty(Ammo==0);}\r\n if(ReadInput)Aim(Input.Down(\"Attack2\"));\r\n if(Reloading)Aim(false);\r\n weapon.Aiming=Aiming;\r\n weapon.Sprinting=ReadInput&&Input.Down(\"Run\")&&Player.Velocity.Length>10&&!Aiming;\r\n weapon.Movement=Math.Clamp(Player.Velocity.Length/150f,0,1);\r\n weapon.SetParameter(\"attack_hold\",ReadInput&&Input.Down(\"Attack1\")&&!Reloading&&Ammo>0?1f:0f);\r\n weapon.SetParameter(\"firing_mode\",3);\r\n weapon.SetParameter(\"ironsights_fire_scale\",.3f);\r\n if(ReadInput)\r\n {\r\n if(Input.Pressed(\"Reload\"))Reload();\r\n if(Input.Down(\"Attack1\")&&!weapon.Sprinting)Fire();\r\n }\r\n weapon.ShowHands=!Player.ThirdPerson;\r\n if(Player.ThirdPerson)weapon.WorldTransform=Player.EyeTransform;\r\n // Keep the body graph warm even when hidden, so a camera switch during a shot\r\n // or reload reveals the same action rather than restarting a holding pose.\r\n UpdateBodyPose();\r\n weapon.ThirdPersonBody=Player.ThirdPerson?Player.Renderer:null;\r\n weapon.WorldGripBone=WorldGripBone;\r\n }\r\n private void UpdateBodyPose()\r\n {\r\n if(Player?.Renderer is not {} body)return;\r\n body.Set(\"holdtype\",BodyHoldType);body.Set(\"holdtype_handedness\",BodyHandedness);\r\n body.Set(\"aim_body_weight\",1f);\r\n }\r\n public void ModifyCamera(CameraComponent camera,ref CameraView view)\r\n {\r\n if(Player is not null&&!Player.ThirdPerson&&camera==Scene.Camera)\r\n {\r\n currentFov=MathX.Lerp(currentFov,Aiming?AimFov:HipFov,Math.Clamp(Time.Delta*12,0,1));\r\n view.FieldOfView=currentFov;\r\n }\r\n }\r\n public void PostCameraSetup(CameraComponent camera,in CameraView view)\r\n {\r\n // EyeTransform during Update can precede the player's final camera pose.\r\n // Place both viewmodel meshes against the composed view before PreRender.\r\n if(weapon is not null&&Player is not null&&!Player.ThirdPerson&&camera==Scene.Camera)\r\n weapon.WorldTransform=new global::Transform(view.Position,view.Rotation);\r\n }\r\n protected override void OnDisabled()\r\n {\r\n magazine?.CancelReload();Aiming=false;\r\n if(weapon is not null){weapon.Aiming=false;weapon.ThirdPersonBody=null;}\r\n Player?.Renderer?.Set(\"holdtype\",0);\r\n }\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_humanoid_hand_retargeter",
"Path": "Code/Core/Retargeting/HandMotionOptions.cs",
"FileName": "HandMotionOptions.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 394704,
"IsPrivate": false,
"Code": "#nullable enable\r\nnamespace HumanoidHandRetargeter.Retargeting;\r\n\r\npublic sealed class HandMotionOptions\r\n{\r\n public bool TransferWristPosition { get; init; } = true;\r\n public bool ScaleWristTravel { get; init; } = true;\r\n /// <summary>Known source-to-target model axes, independent of the wrists' rest rotations.</summary>\r\n public System.Numerics.Quaternion? WristTravelBasis {get;init;}\r\n public bool SolveArmIk { get; init; } = true;\r\n public bool PreserveWeaponGrip { get; init; } = true;\r\n /// <summary>Shared source weapon-to-target translation in centimeters. Grip motion is never scaled per hand.</summary>\r\n public System.Numerics.Vector3? WeaponSpaceOffset {get;init;}\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_humanoid_hand_retargeter",
"Path": "Code/Core/Validation/HandRigValidator.cs",
"FileName": "HandRigValidator.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 394704,
"IsPrivate": false,
"Code": "#nullable enable\r\nusing HumanoidHandRetargeter.Mapping;\r\nusing SkeletonModel = HumanoidHandRetargeter.Skeleton.Skeleton;\r\n\r\nnamespace HumanoidHandRetargeter.Validation;\r\n\r\npublic sealed record RigIssue(string Code, string Message, HandSide? Side = null, int? Bone = null);\r\n\r\n/// <summary>Shared validation for automatic/manual mapping, batch processing and the editor.</summary>\r\npublic static class HandRigValidator\r\n{\r\n public static IReadOnlyList<RigIssue> Validate(SkeletonModel skeleton, IEnumerable<HandRigDefinition> hands)\r\n {\r\n ArgumentNullException.ThrowIfNull(skeleton);\r\n ArgumentNullException.ThrowIfNull(hands);\r\n var issues = new List<RigIssue>();\r\n var claimed = new HashSet<int>();\r\n var sides = new HashSet<HandSide>();\r\n var count = 0;\r\n\r\n foreach (var bone in skeleton.Bones)\r\n {\r\n if (!PoseValidator.ValidTransform(bone.RestLocal))\r\n issues.Add(new(\"invalid-rest\", $\"Bone '{bone.Name}' has an invalid rest transform.\", Bone: bone.Index));\r\n var world = skeleton.RestWorld[bone.Index].Pos;\r\n if (!float.IsFinite(world.X) || !float.IsFinite(world.Y) || !float.IsFinite(world.Z))\r\n issues.Add(new(\"invalid-world-rest\", $\"Bone '{bone.Name}' has an invalid accumulated rest position.\", Bone: bone.Index));\r\n }\r\n\r\n foreach (var hand in hands)\r\n {\r\n count++;\r\n if (hand is null)\r\n {\r\n issues.Add(new(\"missing-hand\", \"A hand mapping is missing.\"));\r\n continue;\r\n }\r\n if (!Enum.IsDefined(typeof(HandSide), hand.Side))\r\n issues.Add(new(\"invalid-side\", \"Choose left or right for the hand.\"));\r\n if (!sides.Add(hand.Side))\r\n issues.Add(new(\"duplicate-side\", $\"More than one {hand.Side} hand is mapped.\", hand.Side));\r\n\r\n var arm = new[] { hand.Clavicle, hand.UpperArm, hand.Forearm, hand.Wrist }\r\n .Where(b => b.HasValue).Select(b => b!.Value).ToArray();\r\n CheckChain(arm, hand.Side, \"arm\", allowZeroLength: true);\r\n var roles = new HashSet<string>(StringComparer.Ordinal);\r\n foreach (var digit in hand.Digits)\r\n {\r\n if (digit is null)\r\n {\r\n issues.Add(new(\"missing-digit\", \"A digit mapping is missing.\", hand.Side));\r\n continue;\r\n }\r\n var key = digit.Role == DigitRole.Extra ? \"Extra:\" + digit.ExtraSlot : digit.Role.ToString();\r\n if (!Enum.IsDefined(typeof(DigitRole), digit.Role)\r\n || (digit.Role == DigitRole.Extra && string.IsNullOrWhiteSpace(digit.ExtraSlot)))\r\n issues.Add(new(\"invalid-digit-role\", \"Choose a digit role or name its extra slot.\", hand.Side));\r\n if (!roles.Add(key))\r\n issues.Add(new(\"duplicate-digit-role\", $\"{hand.Side} {key} is mapped more than once.\", hand.Side));\r\n if (digit.Segments.Count == 0)\r\n issues.Add(new(\"empty-digit\", $\"{hand.Side} {key} needs at least one segment.\", hand.Side));\r\n var chain = digit.Bones.ToArray();\r\n CheckChain(chain, hand.Side, key, allowZeroLength: false, tip: digit.Tip);\r\n if (chain.Length > 0 && Valid(chain[0]) && Valid(hand.Wrist)\r\n && !skeleton.DescendsFrom(chain[0], hand.Wrist))\r\n issues.Add(new(\"wrong-wrist\", $\"{hand.Side} {key} must descend from its wrist.\", hand.Side, chain[0]));\r\n }\r\n foreach (var helper in hand.TwistOrHelperBones)\r\n {\r\n Claim(helper, hand.Side);\r\n var armRoot = arm[0];\r\n if (Valid(helper) && Valid(armRoot) && !skeleton.DescendsFrom(helper, armRoot))\r\n issues.Add(new(\"wrong-helper-root\", $\"{hand.Side} helper must belong to its mapped arm or hand hierarchy.\", hand.Side, helper));\r\n }\r\n }\r\n if (count == 0)\r\n issues.Add(new(\"no-hands\", \"Map at least one wrist before retargeting.\"));\r\n return issues.AsReadOnly();\r\n\r\n bool Valid(int bone) => bone >= 0 && bone < skeleton.Count;\r\n void Claim(int bone, HandSide side)\r\n {\r\n if (!Valid(bone))\r\n issues.Add(new(\"invalid-bone\", $\"{side} mapping references a missing bone.\", side, bone));\r\n else if (!claimed.Add(bone))\r\n issues.Add(new(\"overlapping-bone\", $\"Bone '{skeleton[bone].Name}' is assigned to multiple roles.\", side, bone));\r\n }\r\n void CheckChain(int[] bones, HandSide side, string role, bool allowZeroLength, int? tip = null)\r\n {\r\n for (var i = 0; i < bones.Length; i++)\r\n {\r\n Claim(bones[i], side);\r\n if (i == 0 || !Valid(bones[i - 1]) || !Valid(bones[i])) continue;\r\n if (!skeleton.DescendsFrom(bones[i], bones[i - 1]))\r\n issues.Add(new(\"chain-order\", $\"{side} {role} joints must follow their hierarchy.\", side, bones[i]));\r\n if (!allowZeroLength && bones[i] != tip &&\r\n System.Numerics.Vector3.DistanceSquared(skeleton.RestWorld[bones[i - 1]].Pos,\r\n skeleton.RestWorld[bones[i]].Pos) < 1e-12f)\r\n issues.Add(new(\"zero-length-digit\", $\"{side} {role} has coincident joints; correct the chain or classify a helper.\", side, bones[i]));\r\n }\r\n }\r\n }\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_humanoid_hand_retargeter",
"Path": "Code/Core/Mapping/HandRigDetector.cs",
"FileName": "HandRigDetector.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 394704,
"IsPrivate": false,
"Code": "#nullable enable\r\nusing HumanoidHandRetargeter.Validation;\r\nusing SkeletonModel = HumanoidHandRetargeter.Skeleton.Skeleton;\r\n\r\nnamespace HumanoidHandRetargeter.Mapping;\r\n\r\n/// <summary>\r\n/// Name candidates constrained by wrist ancestry. Reuses legacy name tokenization;\r\n/// deliberately replaces its full-body confidence and fixed phalanx slots.\r\n/// Unnamed/ambiguous anatomy is proposed for review, never silently accepted.\r\n/// </summary>\r\npublic static class HandRigDetector\r\n{\r\n public static HandMappingResult Detect(SkeletonModel skeleton,\r\n IEnumerable<HandRigDefinition>? manualOverrides = null)\r\n {\r\n ArgumentNullException.ThrowIfNull(skeleton);\r\n var hints = HandDetectionProfiles.Resolve(skeleton);\r\n var names = skeleton.Bones.Select(b => new NameInfo(b.Name,hints[b.Index])).ToArray();\r\n var hands = new List<HandRigDefinition>();\r\n var candidates = new List<HandMappingCandidate>();\r\n var issues = new List<RigIssue>();\r\n var review = false;\r\n var deformBelow = new bool?[skeleton.Count];\r\n int? digitWrist = null;\r\n var overrides = (manualOverrides ?? Array.Empty<HandRigDefinition>()).ToArray();\r\n if (overrides.Any(h => h is null))\r\n throw new ArgumentException(\"Manual overrides cannot contain a null hand.\", nameof(manualOverrides));\r\n\r\n foreach (var side in new[] { HandSide.Left, HandSide.Right })\r\n {\r\n var manual = overrides.Where(h => h.Side == side).ToArray();\r\n if (manual.Length > 0)\r\n {\r\n hands.AddRange(manual);\r\n foreach (var hand in manual)\r\n {\r\n Add(side, \"Wrist\", new[] { hand.Wrist }, 1f, \"Manual mapping\", true);\r\n foreach (var (role, bone) in new[] { (\"Clavicle\", hand.Clavicle), (\"UpperArm\", hand.UpperArm), (\"Forearm\", hand.Forearm) })\r\n if (bone is int b) Add(side, role, new[] { b }, 1f, \"Manual mapping\", true);\r\n foreach (var digit in hand.Digits.Where(d => d is not null))\r\n Add(side, digit.Role.ToString(), digit.Bones, 1f, \"Manual mapping\", true);\r\n foreach (var helper in hand.TwistOrHelperBones)\r\n Add(side, \"Helper\", new[] { helper }, 1f, \"Manual mapping\", true);\r\n }\r\n continue;\r\n }\r\n var wrists = skeleton.Bones.Where(b => names[b.Index].IsWrist && !Blocked(b.Index)\r\n && SideOf(b.Index) == side).Select(b => b.Index).ToArray();\r\n var deformWrists=wrists.Where(w=>names[w].IsDeform).ToArray();\r\n if(deformWrists.Length==1 && wrists.All(w=>names[w].FamilyKey==names[deformWrists[0]].FamilyKey))\r\n wrists=deformWrists; // Rigify exports can contain DEF, ORG and animator-control copies.\r\n if (wrists.Length == 0) continue; // A one-sided rig is valid.\r\n if (wrists.Length > 1)\r\n {\r\n foreach (var wrist in wrists)\r\n Add(side, \"Wrist\", new[] { wrist }, .5f, \"Multiple wrist candidates on this side; choose one manually\", false);\r\n issues.Add(new(\"ambiguous-wrist\", $\"Choose the {side} wrist in Hand Mapping.\", side));\r\n continue;\r\n }\r\n var selected = wrists[0];\r\n digitWrist = selected;\r\n Add(side, \"Wrist\", new[] { selected }, .95f, names[selected].Profile is {} wristProfile\r\n ? $\"{wristProfile} profile and wrist hierarchy agree\" : \"Wrist name and side agree\", true);\r\n var digits = new List<DigitChain>();\r\n var seenRoles = new HashSet<DigitRole>();\r\n foreach (var root in DigitRoots(selected))\r\n {\r\n var chain = new List<int>();\r\n int? meta = null, tip = null;\r\n var current = root;\r\n var chainReview = false;\r\n while (true)\r\n {\r\n var info = names[current];\r\n if (info.IsTip)\r\n {\r\n tip = current;\r\n if (skeleton.ChildrenOf(current).Count != 0) chainReview = true;\r\n break;\r\n }\r\n if (info.IsMeta && current == root) meta = current;\r\n else chain.Add(current);\r\n var next = JointChildren(current).ToArray();\r\n if (next.Length == 0) break;\r\n if (next.Length > 1) { chainReview = true; break; }\r\n current = next[0];\r\n }\r\n var role = names[root].Digit;\r\n // A palm/metacarpal may be generically named, but its child's digit is explicit.\r\n if (role is null && meta.HasValue && chain.Count > 0) role = names[chain[0]].Digit;\r\n var chainSideMismatch = chain.Any(b => names[b].Side is HandSide explicitSide && explicitSide != side);\r\n var chainRoleMismatch = role.HasValue && chain.Any(b => names[b].Digit is DigitRole namedRole && namedRole != role);\r\n if (chainSideMismatch || chainRoleMismatch || chainReview || chain.Count == 0)\r\n {\r\n Add(side, role?.ToString() ?? \"Digit\", chain, .3f,\r\n \"Branched, empty or side-inconsistent digit chain; correct the mapping\", false);\r\n review = true;\r\n continue;\r\n }\r\n if (role.HasValue && !seenRoles.Add(role.Value))\r\n {\r\n Add(side, role.Value.ToString(), chain, .4f, \"Duplicate anatomical digit candidate; choose the intended chain\", false);\r\n review = true;\r\n continue;\r\n }\r\n var digit = new DigitChain(role ?? DigitRole.Extra, chain, meta, tip,\r\n role is null ? skeleton[root].Name : \"\");\r\n digits.Add(digit);\r\n var confidence = role.HasValue ? .9f : .4f;\r\n Add(side, role?.ToString() ?? \"Extra:\" + skeleton[root].Name, digit.Bones, confidence,\r\n role.HasValue ? names[root].Profile is {} digitProfile ? $\"{digitProfile} digit convention follows this wrist's hierarchy\"\r\n : \"Named digit follows this wrist's hierarchy; segment numbering is not used for side\"\r\n : \"Unlabelled digit chain; assign an anatomical role or confirm an extra digit\", true);\r\n if (!role.HasValue) review = true;\r\n }\r\n\r\n var ancestors = new List<int>();\r\n for (var p = skeleton[selected].ParentIndex; p >= 0; p = skeleton[p].ParentIndex)\r\n ancestors.Add(p);\r\n int? Find(params string[] aliases) => ancestors\r\n .Where(b => !names[b].IsHelper && !Blocked(b) && aliases.Any(a => names[b].Core.EndsWith(a, StringComparison.Ordinal)))\r\n .Select(b => (int?)b).FirstOrDefault();\r\n int? ProfileJoint(string role)=>ancestors.Where(b=>names[b].ProfileRole==role && !names[b].IsHelper && !Blocked(b))\r\n .Select(b=>(int?)b).FirstOrDefault();\r\n var forearm = ProfileJoint(\"LowerArm\") ?? Find(\"forearm\", \"lowerarm\", \"armlower\", \"elbow\");\r\n var upperArm = ProfileJoint(\"UpperArm\") ?? Find(\"upperarm\", \"armupper\", \"uparm\");\r\n var clavicle = Find(\"clavicle\", \"collar\") ?? ProfileJoint(\"Clavicle\");\r\n // Prototype calls upper arm Shoulder, Mixamo calls clavicle Shoulder.\r\n if (upperArm is null && clavicle.HasValue) upperArm = Find(\"shoulder\");\r\n upperArm ??= ancestors.Where(b => names[b].Core.EndsWith(\"arm\", StringComparison.Ordinal)\r\n && b != forearm && !names[b].IsHelper && !Blocked(b)).Select(b => (int?)b).FirstOrDefault();\r\n clavicle ??= Find(\"shoulder\");\r\n if (clavicle == upperArm) clavicle = null;\r\n foreach (var (role, bone) in new[] { (\"Clavicle\", clavicle), (\"UpperArm\", upperArm), (\"Forearm\", forearm) })\r\n if (bone is int b)\r\n {\r\n var mismatch = names[b].ConflictingSides || (SideOf(b) is HandSide namedSide && namedSide != side);\r\n Add(side, role, new[] { b }, mismatch ? .3f : .9f,\r\n mismatch ? \"Arm name conflicts with the wrist side; correct the mapping\" : \"Named joint on the wrist ancestor chain\", true);\r\n review |= mismatch;\r\n }\r\n var armRoot = clavicle ?? upperArm ?? forearm ?? selected;\r\n var helpers = skeleton.Bones.Where(b => names[b.Index].IsHelper && !Blocked(b.Index)\r\n && skeleton.DescendsFrom(b.Index, armRoot)).Select(b => b.Index).ToArray();\r\n foreach (var helper in helpers)\r\n Add(side, \"Helper\", new[] { helper }, .9f, \"Explicit twist/helper name in this arm hierarchy\", true);\r\n hands.Add(new(side, selected, digits, clavicle, upperArm, forearm, helpers));\r\n }\r\n\r\n // Unsupported enum values must reach validation instead of being silently dropped.\r\n hands.AddRange(overrides.Where(h => !Enum.IsDefined(typeof(HandSide), h.Side)));\r\n foreach (var bone in skeleton.Bones.Where(b => names[b.Index].IsWrist && !Blocked(b.Index) && SideOf(b.Index) is null\r\n && !hands.Any(h => h.Wrist == b.Index)))\r\n {\r\n Add(null, \"Wrist\", new[] { bone.Index }, .4f, \"Wrist name found but side is unknown or conflicting\", false);\r\n review = true;\r\n }\r\n if (hands.Count == 0 && candidates.Count == 0)\r\n {\r\n // Topology hints only: without a known side/frame, geometry must not fabricate left/right.\r\n foreach (var bone in skeleton.Bones.Where(b => !Blocked(b.Index) &&\r\n skeleton.ChildrenOf(b.Index).Count(c => !Blocked(c) && skeleton.ChildrenOf(c).Count > 0) >= 2))\r\n Add(null, \"Wrist\", new[] { bone.Index }, .2f, \"Branching hierarchy may be a palm; confirm side and wrist manually\", false);\r\n review = true;\r\n }\r\n issues.AddRange(HandRigValidator.Validate(skeleton, hands));\r\n return new(hands, candidates, issues, review);\r\n\r\n void Add(HandSide? side, string role, IEnumerable<int> bones, float confidence, string reason, bool selected)\r\n => candidates.Add(new(side, role, Array.AsReadOnly(bones.ToArray()), confidence, reason, selected));\r\n\r\n // The viewmodel may own the whole hierarchy: a weapon-named ancestor is not\r\n // evidence that an explicitly named child wrist is a mechanical weapon bone.\r\n bool Blocked(int bone) => names[bone].IsNonHandTrack;\r\n HandSide? SideOf(int bone)\r\n {\r\n for (var b = bone; b >= 0; b = skeleton[b].ParentIndex)\r\n {\r\n if (names[b].ConflictingSides) return null;\r\n if (names[b].Side.HasValue) return names[b].Side;\r\n }\r\n return null;\r\n }\r\n IEnumerable<int> DigitRoots(int parent)\r\n {\r\n foreach (var child in JointChildren(parent))\r\n {\r\n // Some palm/metacarpal containers parent several different digits\r\n // (notably thumb and index). They are shared anatomy, not a fork\r\n // within one finger. Keep the container wrist-driven and map each\r\n // named child chain independently so no bone is assigned twice.\r\n var branches=JointChildren(child).Where(i=>!names[i].IsTip).ToArray();\r\n if(names[child].IsMeta&&branches.Length>1\r\n &&branches.All(i=>names[i].Digit.HasValue)\r\n &&branches.Select(i=>names[i].Digit).Distinct().Count()==branches.Length)\r\n {\r\n foreach(var branch in branches)yield return branch;\r\n continue;\r\n }\r\n if (!names[child].IsTip) yield return child;\r\n }\r\n }\r\n // Layered rigs (Rigify DEF/ORG/MCH, animator controls) can parent deform digits\r\n // under a non-deform palm next to their control copies. Below a deform wrist,\r\n // follow only the deform layer and the containers that lead to it.\r\n bool LeadsToDeform(int bone)\r\n {\r\n if (deformBelow[bone] is bool known) return known;\r\n var result = names[bone].IsDeform || skeleton.ChildrenOf(bone).Any(LeadsToDeform);\r\n deformBelow[bone] = result;\r\n return result;\r\n }\r\n IEnumerable<int> JointChildren(int parent)\r\n {\r\n var layered = digitWrist is int wrist && names[wrist].IsDeform\r\n && (parent == wrist || skeleton.DescendsFrom(parent, wrist))\r\n && skeleton.ChildrenOf(parent).Any(c => !Blocked(c) && LeadsToDeform(c));\r\n foreach (var child in skeleton.ChildrenOf(parent))\r\n {\r\n if (Blocked(child)) continue;\r\n // The parent's own deform copy is a skinned twin, not a digit.\r\n if (layered && (!LeadsToDeform(child)\r\n || names[child].IsDeform && !names[child].IsTip && names[child].FamilyKey == names[parent].FamilyKey)) continue;\r\n if (names[child].IsHelper)\r\n {\r\n foreach (var nested in JointChildren(child)) yield return nested;\r\n }\r\n else yield return child;\r\n }\r\n }\r\n }\r\n\r\n private sealed class NameInfo\r\n {\r\n public string Core { get; }\r\n public HandSide? Side { get; }\r\n public bool ConflictingSides { get; }\r\n public DigitRole? Digit { get; }\r\n public bool IsWrist { get; }\r\n public bool IsHelper { get; }\r\n public bool IsNonHandTrack { get; }\r\n public bool IsMeta { get; }\r\n public bool IsTip { get; }\r\n public string? Profile { get; }\r\n public string? ProfileRole { get; }\r\n public bool IsDeform { get; }\r\n public string FamilyKey { get; }\r\n\r\n public NameInfo(string name,HandDetectionProfiles.Hint? hint)\r\n {\r\n var stripped = name[(name.LastIndexOf(':') + 1)..];\r\n var hash = stripped.LastIndexOf('#');\r\n if (hash >= 0 && stripped[(hash + 1)..].All(char.IsDigit)) stripped = stripped[..hash];\r\n IsDeform=stripped.StartsWith(\"DEF-\",StringComparison.OrdinalIgnoreCase);\r\n FamilyKey=HandDetectionProfiles.Key(IsDeform||stripped.StartsWith(\"ORG-\",StringComparison.OrdinalIgnoreCase)\r\n ||stripped.StartsWith(\"MCH-\",StringComparison.OrdinalIgnoreCase)?stripped[4..]:stripped);\r\n Profile=hint?.Profile;ProfileRole=hint?.Role;\r\n var tokens = BoneNameTokens.Tokenize(stripped);\r\n bool Has(params string[] words) => tokens.Any(t => words.Contains(t, StringComparer.Ordinal));\r\n var left = Has(\"l\", \"left\", \"lft\");\r\n var right = Has(\"r\", \"right\", \"rgt\");\r\n // Case-only changes must not erase a wrist's side when camel-case boundaries disappear.\r\n var compact = string.Concat(tokens.Where(t => !t.All(char.IsDigit)));\r\n foreach (var joint in new[] { \"hand\", \"wrist\" })\r\n {\r\n left |= compact.EndsWith(\"left\" + joint, StringComparison.Ordinal) || compact.EndsWith(joint + \"left\", StringComparison.Ordinal)\r\n || compact.EndsWith(joint + \"l\", StringComparison.Ordinal) || compact == \"l\" + joint;\r\n right |= compact.EndsWith(\"right\" + joint, StringComparison.Ordinal) || compact.EndsWith(joint + \"right\", StringComparison.Ordinal)\r\n || compact.EndsWith(joint + \"r\", StringComparison.Ordinal) || compact == \"r\" + joint;\r\n }\r\n ConflictingSides = left && right;\r\n Side = left == right ? left ? null : hint?.Side : left ? HandSide.Left : HandSide.Right;\r\n Core = string.Concat(tokens.Where(t => !new[] { \"l\", \"left\", \"lft\", \"r\", \"right\", \"rgt\", \"def\", \"org\", \"mch\", \"stretch\" }.Contains(t)\r\n && !t.All(char.IsDigit)));\r\n IsHelper = Has(\"twist\", \"twistctrl\", \"roll\", \"share\", \"sharebone\", \"helper\", \"hlp\", \"corrective\", \"control\", \"ctrl\");\r\n IsNonHandTrack = Has(\"weapon\", \"camera\", \"ik\", \"ikrule\", \"hold\", \"socket\", \"attachment\", \"magazine\", \"bolt\", \"slide\", \"trigger\")\r\n || compact.StartsWith(\"weapon\", StringComparison.Ordinal) || compact.StartsWith(\"camera\", StringComparison.Ordinal);\r\n IsMeta = Has(\"meta\", \"metacarpal\", \"carpal\", \"palm\") || hint?.Role.EndsWith(\"Meta\",StringComparison.Ordinal)==true;\r\n IsTip = Has(\"tip\", \"end\", \"nub\", \"endmarker\") || hint?.Role.EndsWith(\"Tip\",StringComparison.Ordinal)==true;\r\n bool DigitName(params string[] aliases) => Has(aliases)\r\n || aliases.Any(a => Core.EndsWith(a, StringComparison.Ordinal) || Core.EndsWith(a + \"finger\", StringComparison.Ordinal));\r\n Digit = DigitName(\"thumb\") ? DigitRole.Thumb : DigitName(\"index\", \"forefinger\", \"point\", \"pointer\", \"pointing\") ? DigitRole.Index\r\n : DigitName(\"middle\", \"mid\") ? DigitRole.Middle : DigitName(\"ring\") ? DigitRole.Ring\r\n : DigitName(\"pinky\", \"pinkie\", \"pink\", \"little\") ? DigitRole.Pinky : null;\r\n if(hint is not null && HandDetectionProfiles.IsDigit(hint.Role))\r\n Digit=Enum.GetValues<DigitRole>().First(d=>hint.Role.StartsWith(d.ToString(),StringComparison.Ordinal));\r\n IsWrist = !IsHelper && !IsNonHandTrack && !IsTip && Digit is null\r\n && new[] { \"hand\", \"wrist\", \"handl\", \"handr\", \"wristl\", \"wristr\", \"handleft\", \"handright\", \"wristleft\", \"wristright\" }\r\n .Any(suffix => Core.EndsWith(suffix, StringComparison.Ordinal));\r\n if(hint?.Role==\"Hand\"&&!IsHelper&&!IsNonHandTrack&&!IsTip)IsWrist=true;\r\n else if(hint is not null)IsWrist=false;\r\n }\r\n }\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_humanoid_hand_retargeter",
"Path": "Core/Mapping/HandDetectionProfiles.cs",
"FileName": "HandDetectionProfiles.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 394704,
"IsPrivate": false,
"Code": "#nullable enable\r\nusing System.Text.RegularExpressions;\r\nusing Skel = HumanoidHandRetargeter.Skeleton.Skeleton;\r\n\r\nnamespace HumanoidHandRetargeter.Mapping;\r\n\r\n/// <summary>Reference conventions supply semantic hints; they never bypass hierarchy validation.</summary>\r\ninternal static partial class HandDetectionProfiles\r\n{\r\n internal sealed record Alias(string Role,string Name);\r\n internal sealed record Hint(string Role,HandSide Side,string Profile);\r\n internal sealed class Profile\r\n {\r\n public string Name { get; }\r\n private readonly Regex[] prefixes;\r\n public Dictionary<string,string[]> Roles { get; }\r\n public int RoleCount { get; }\r\n public Profile(string name,string[] patterns,Alias[] aliases)\r\n {\r\n Name=name;prefixes=patterns.Select(p=>new Regex(p,RegexOptions.IgnoreCase|RegexOptions.CultureInvariant)).ToArray();\r\n Roles=aliases.GroupBy(a=>Key(a.Name)).ToDictionary(g=>g.Key,g=>g.Select(a=>a.Role).Distinct().ToArray());\r\n RoleCount=aliases.Select(a=>a.Role).Distinct().Count();\r\n }\r\n public string Strip(string name)\r\n {\r\n // Strip FBX namespaces independently of exporter prefixes (e.g. Character:CC_Base_L_Hand).\r\n name=name[(name.LastIndexOf(':')+1)..];\r\n var hash=name.LastIndexOf('#');\r\n if(hash>=0&&name[(hash+1)..].All(char.IsDigit))name=name[..hash];\r\n foreach(var prefix in prefixes)name=prefix.Replace(name,\"\");\r\n return name;\r\n }\r\n }\r\n internal static string Key(string name)=>string.Concat(name.Where(char.IsLetterOrDigit)).ToLowerInvariant();\r\n\r\n internal static Hint?[] Resolve(Skel rig)\r\n {\r\n var proposals=new Dictionary<int,List<(int Score,Hint Hint)>>();\r\n foreach(var profile in Catalog.Concat(new[]{CinemaHumanProfile}))\r\n {\r\n var matches=new Dictionary<int,string>();\r\n foreach(var bone in rig.Bones)\r\n if(profile.Roles.TryGetValue(Key(profile.Strip(bone.Name)),out var roles)&&roles.Length==1)\r\n matches[bone.Index]=roles[0];\r\n foreach(var wrist in matches.Where(p=>p.Value is \"HandL\" or \"HandR\"))\r\n {\r\n var side=wrist.Value[^1];\r\n var relevant=matches.Where(p=>p.Value[^1]==side && (p.Key==wrist.Key ||\r\n (IsDigit(p.Value)&&rig.DescendsFrom(p.Key,wrist.Key)) ||\r\n (IsArm(p.Value)&&rig.DescendsFrom(wrist.Key,p.Key)))).ToArray();\r\n // Match coverage distinguishes e.g. SOMA metacarpals from Mixamo phalanges.\r\n // Namespace evidence breaks equivalent-name ties without overpowering anatomy.\r\n var score=relevant.Length*1000+relevant.Select(p=>p.Value).Distinct().Count()*100/Math.Max(1,profile.RoleCount/2)\r\n +relevant.Count(p=>profile.Strip(rig[p.Key].Name)!=rig[p.Key].Name)*2;\r\n foreach(var match in relevant)\r\n {\r\n if(!proposals.TryGetValue(match.Key,out var list))proposals[match.Key]=list=new();\r\n list.Add((score,new(match.Value[..^1],side=='L'?HandSide.Left:HandSide.Right,profile.Name)));\r\n }\r\n }\r\n }\r\n var hints=new Hint?[rig.Count];\r\n foreach(var pair in proposals)\r\n {\r\n var best=pair.Value.Where(p=>p.Score==pair.Value.Max(v=>v.Score)).Select(p=>p.Hint).ToArray();\r\n if(best.Select(p=>(p.Role,p.Side)).Distinct().Count()==1)hints[pair.Key]=best[0];\r\n }\r\n ResolveNumberedFpsHands(rig,hints);\r\n ResolveUnlabelledSplitArms(rig,hints);\r\n return hints;\r\n }\r\n // Numbered FPS rays can run from thumb to pinky or in reverse, with an\r\n // additional palm joint on the four fingers. Require the whole convention\r\n // and its radial geometry; bare finger numbers alone are ambiguous.\r\n private static void ResolveNumberedFpsHands(Skel rig,Hint?[] hints)\r\n {\r\n foreach(var side in new[]{HandSide.Left,HandSide.Right})\r\n {\r\n var suffix=side==HandSide.Left?\"l\":\"r\";\r\n string Name(int bone)=>Key(rig[bone].Name[(rig[bone].Name.LastIndexOf(':')+1)..]).Replace(\"left\",\"l\").Replace(\"right\",\"r\");\r\n var wrists=rig.Bones.Where(b=>Name(b.Index)==\"hand\"+suffix).ToArray();\r\n if(wrists.Length!=1)continue;\r\n var wrist=wrists[0].Index;\r\n foreach(var thumbNumber in new[]{1,5})\r\n {\r\n var chains=new List<int[]>();\r\n for(var finger=1;finger<=5;finger++)\r\n {\r\n var chain=new List<int>();var parent=wrist;\r\n for(var joint=1;joint<=(finger==thumbNumber?3:4);joint++)\r\n {\r\n var pattern=$\"^finger0*{finger}0*{joint}{suffix}$\";\r\n var children=rig.ChildrenOf(parent).Where(i=>Regex.IsMatch(Name(i),pattern)).ToArray();\r\n if(children.Length!=1)break;\r\n parent=children[0];chain.Add(parent);\r\n }\r\n if(chain.Count!=(finger==thumbNumber?3:4))break;\r\n // A three-joint thumb must not just be a truncated four-joint finger.\r\n if(finger==thumbNumber&&rig.ChildrenOf(parent).Any(i=>Regex.IsMatch(Name(i),$\"^finger0*{finger}0*4{suffix}$\")))break;\r\n chains.Add(chain.ToArray());\r\n }\r\n if(chains.Count!=5)continue;\r\n var thumb=rig.RestWorld[chains[thumbNumber-1][0]].Pos;\r\n var radial=System.Numerics.Vector3.DistanceSquared(thumb,rig.RestWorld[chains[thumbNumber==5?3:1][0]].Pos);\r\n var ulnar=System.Numerics.Vector3.DistanceSquared(thumb,rig.RestWorld[chains[thumbNumber==5?0:4][0]].Pos);\r\n if(!float.IsFinite(radial)||!float.IsFinite(ulnar)||radial>=ulnar*.8f)continue;\r\n var roles=thumbNumber==5?new[]{\"Pinky\",\"Ring\",\"Middle\",\"Index\",\"Thumb\"}:new[]{\"Thumb\",\"Index\",\"Middle\",\"Ring\",\"Pinky\"};\r\n if(chains.SelectMany(c=>c).Any(i=>hints[i] is not null))continue;\r\n for(var finger=0;finger<5;finger++)\r\n for(var joint=0;joint<chains[finger].Length;joint++)\r\n hints[chains[finger][joint]]=new(roles[finger]+(finger!=thumbNumber-1&&joint==0?\"Meta\":\"Prox\"),side,\"numbered_fps_hands\");\r\n }\r\n }\r\n }\r\n internal static bool IsDigit(string role)=>new[]{\"Thumb\",\"Index\",\"Middle\",\"Ring\",\"Pinky\"}.Any(role.StartsWith);\r\n private static readonly Profile CinemaHumanProfile=CinemaBaseHuman();\r\n private static Profile CinemaBaseHuman()\r\n {\r\n var aliases=new List<Alias>();\r\n foreach(var side in new[]{\"L\",\"R\"})\r\n {\r\n aliases.Add(new(\"UpperArm\"+side,side+\"Upperarm\"));\r\n aliases.Add(new(\"LowerArm\"+side,side+\"Forearm1\"));\r\n aliases.Add(new(\"Hand\"+side,side+\"Palm\"));\r\n var digits=new[]{\"Thumb\",\"Index\",\"Middle\",\"Ring\",\"Pinky\"};\r\n var joints=new[]{\"Prox\",\"Mid\",\"Dist\"};\r\n for(var d=0;d<5;d++)for(var j=0;j<3;j++)\r\n aliases.Add(new(digits[d]+joints[j]+side,side+\"Digit\"+(d+1)+(j+1)));\r\n }\r\n return new(\"cinema4d_base_human\",new[]{\"^Base[-_]Human\"},aliases.ToArray());\r\n }\r\n private static bool IsArm(string role)=>role.StartsWith(\"Clavicle\")||role.StartsWith(\"UpperArm\")||role.StartsWith(\"LowerArm\");\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_humanoid_hand_retargeter",
"Path": "Core/Maths/MathQ.cs",
"FileName": "MathQ.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 394704,
"IsPrivate": false,
"Code": "// Adapted from humanoid-retargeter 26084c96c3fc870aaf9a5bd798de063ce2fd62df; namespace only.\r\n#nullable enable annotations\r\n\r\nusing System;\r\nusing System.Numerics;\r\n\r\nnamespace HumanoidHandRetargeter.Maths;\r\n\r\nusing Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/HumanoidHandRetargeter/Assembly.cs)\r\n\r\n/// <summary>\r\n/// Quaternion algebra helpers used throughout the retargeting pipeline.\r\n/// All angles are radians; quaternions are XYZW (<see cref=\"System.Numerics.Quaternion\"/> native);\r\n/// <c>a * b</c> applies <c>b</c> first (column-vector convention).\r\n/// </summary>\r\npublic static class MathQ\r\n{\r\n private const float NearZeroLengthSq = 1e-12f;\r\n\r\n /// <summary>\r\n /// Defensively normalizes a quaternion. Returns <see cref=\"Quaternion.Identity\"/> when the\r\n /// input has near-zero length or non-finite components instead of producing NaNs.\r\n /// </summary>\r\n public static Quaternion Normalize(Quaternion q)\r\n {\r\n var lengthSq = q.LengthSquared();\r\n if (!float.IsFinite(lengthSq) || lengthSq < NearZeroLengthSq)\r\n return Quaternion.Identity;\r\n return Quaternion.Multiply(q, 1f / MathF.Sqrt(lengthSq));\r\n }\r\n\r\n /// <summary>\r\n /// Shortest-arc rotation taking direction <paramref name=\"a\"/> onto direction <paramref name=\"b\"/>\r\n /// (magnitudes are ignored). For antiparallel inputs a 180\u00b0 rotation about a stable axis\r\n /// perpendicular to <paramref name=\"a\"/> is returned. Near-zero inputs yield identity.\r\n /// </summary>\r\n public static Quaternion FromTo(Vector3 a, Vector3 b)\r\n {\r\n if (a.LengthSquared() < NearZeroLengthSq || b.LengthSquared() < NearZeroLengthSq)\r\n return Quaternion.Identity;\r\n\r\n var an = Vector3.Normalize(a);\r\n var bn = Vector3.Normalize(b);\r\n var dot = Vector3.Dot(an, bn);\r\n\r\n if (dot >= 1f - 1e-8f)\r\n return Quaternion.Identity;\r\n\r\n if (dot <= -1f + 1e-7f)\r\n {\r\n // Antiparallel: any axis perpendicular to a works; pick a stable one.\r\n return Quaternion.CreateFromAxisAngle(Perpendicular(an), MathF.PI);\r\n }\r\n\r\n // q = (a x b, 1 + a\u00b7b), normalized \u2014 the half-angle construction of the shortest arc.\r\n var cross = Vector3.Cross(an, bn);\r\n return Normalize(new Quaternion(cross.X, cross.Y, cross.Z, 1f + dot));\r\n }\r\n\r\n /// <summary>\r\n /// Decomposes a rotation into <paramref name=\"swing\"/> and <paramref name=\"twist\"/> so that\r\n /// <c>q == swing * twist</c> (twist applied first). The twist is the projection of the\r\n /// rotation onto <paramref name=\"twistAxis\"/>: its rotation axis is parallel to\r\n /// <paramref name=\"twistAxis\"/>, while the swing's rotation axis is perpendicular to it.\r\n /// When the rotation is a pure 180\u00b0 swing (no component about the axis), twist is identity.\r\n /// </summary>\r\n /// <exception cref=\"ArgumentException\">Thrown when <paramref name=\"twistAxis\"/> has near-zero length.</exception>\r\n public static void SwingTwist(Quaternion q, Vector3 twistAxis, out Quaternion swing, out Quaternion twist)\r\n {\r\n if (twistAxis.LengthSquared() < NearZeroLengthSq)\r\n throw new ArgumentException(\"Twist axis must be non-zero.\", nameof(twistAxis));\r\n\r\n var axis = Vector3.Normalize(twistAxis);\r\n q = Normalize(q);\r\n\r\n // Project the rotation's vector part onto the twist axis.\r\n var projection = q.X * axis.X + q.Y * axis.Y + q.Z * axis.Z;\r\n var raw = new Quaternion(axis.X * projection, axis.Y * projection, axis.Z * projection, q.W);\r\n\r\n // Degenerate case (q.W ~ 0 and projection ~ 0): pure 180\u00b0 swing, no twist.\r\n twist = Normalize(raw);\r\n swing = Normalize(q * Quaternion.Conjugate(twist));\r\n }\r\n\r\n /// <summary>\r\n /// Geodesic angle between two rotations in radians, in [0, \u03c0], invariant under the\r\n /// q / -q double cover (so <c>AngleBetween(q, -q) == 0</c>).\r\n /// </summary>\r\n public static float AngleBetween(Quaternion a, Quaternion b)\r\n {\r\n // Use atan2 on the delta rotation rather than acos on the dot product: acos is\r\n // ill-conditioned near 0\u00b0, atan2 is accurate over the whole range.\r\n var delta = Quaternion.Conjugate(Normalize(a)) * Normalize(b);\r\n var sinHalf = MathF.Sqrt(delta.X * delta.X + delta.Y * delta.Y + delta.Z * delta.Z);\r\n var cosHalf = MathF.Abs(delta.W); // |W| folds the q / -q double cover.\r\n return 2f * MathF.Atan2(sinHalf, cosHalf);\r\n }\r\n\r\n /// <summary>\r\n /// Angle between two directions in radians, in [0, \u03c0] (magnitudes are ignored).\r\n /// Near-zero inputs yield 0. Uses atan2, which stays accurate near 0 and \u03c0 where\r\n /// acos of the dot product loses precision.\r\n /// </summary>\r\n public static float AngleBetween(Vector3 a, Vector3 b)\r\n {\r\n if (a.LengthSquared() < NearZeroLengthSq || b.LengthSquared() < NearZeroLengthSq)\r\n return 0f;\r\n return MathF.Atan2(Vector3.Cross(a, b).Length(), Vector3.Dot(a, b));\r\n }\r\n\r\n /// <summary>\r\n /// Builds an orthonormal right-handed basis rotation from a forward and an up hint.\r\n /// </summary>\r\n /// <remarks>\r\n /// Convention: returns the rotation <c>R</c> such that\r\n /// <list type=\"bullet\">\r\n /// <item><c>rotate(R, +Y) == normalize(forward)</c> (primary axis),</item>\r\n /// <item><c>rotate(R, +Z) == </c> the Gram-Schmidt orthonormalization of <paramref name=\"up\"/>\r\n /// against forward (secondary axis),</item>\r\n /// <item><c>rotate(R, +X) == forwardN x upOrtho</c> (right-handed completion).</item>\r\n /// </list>\r\n /// Degenerate inputs are handled defensively: a near-zero forward yields identity; an up hint\r\n /// (near-)parallel to forward falls back to world +Z, or world +X when forward itself is\r\n /// (near-)parallel to +Z.\r\n /// </remarks>\r\n public static Quaternion BasisFromForwardUp(Vector3 forward, Vector3 up)\r\n {\r\n if (forward.LengthSquared() < NearZeroLengthSq)\r\n return Quaternion.Identity;\r\n\r\n var y = Vector3.Normalize(forward);\r\n\r\n var z = up - y * Vector3.Dot(up, y);\r\n if (z.LengthSquared() < 1e-8f)\r\n {\r\n // Up hint unusable: pick a stable fallback and orthonormalize it.\r\n var fallback = MathF.Abs(Vector3.Dot(y, Vector3.UnitZ)) > 0.99f ? Vector3.UnitX : Vector3.UnitZ;\r\n z = fallback - y * Vector3.Dot(fallback, y);\r\n }\r\n z = Vector3.Normalize(z);\r\n\r\n var x = Vector3.Cross(y, z);\r\n\r\n // System.Numerics matrices act on row vectors, so the rows are the images of the\r\n // unit axes under the rotation: row1 = R*X, row2 = R*Y, row3 = R*Z.\r\n var m = new Matrix4x4(\r\n x.X, x.Y, x.Z, 0f,\r\n y.X, y.Y, y.Z, 0f,\r\n z.X, z.Y, z.Z, 0f,\r\n 0f, 0f, 0f, 1f);\r\n\r\n return Normalize(Quaternion.CreateFromRotationMatrix(m));\r\n }\r\n\r\n /// <summary>Stable unit vector perpendicular to <paramref name=\"unit\"/> (assumed normalized).\r\n /// Internal so geometry passes (e.g. <c>TwoBoneIk</c>) reuse one implementation.</summary>\r\n internal static Vector3 Perpendicular(Vector3 unit)\r\n {\r\n var p = Vector3.Cross(unit, Vector3.UnitX);\r\n if (p.LengthSquared() < 1e-6f)\r\n p = Vector3.Cross(unit, Vector3.UnitY);\r\n return Vector3.Normalize(p);\r\n }\r\n}\r\n"
},
{
"Ident": "notpointless.chomnr_humanoid_hand_retargeter",
"Path": "Code/Target/AnimationDriverMesh.cs",
"FileName": "AnimationDriverMesh.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 394704,
"IsPrivate": false,
"Code": "namespace HumanoidHandRetargeter.Target;\r\n\r\n/// <summary>Small hidden mesh required by SceneModel's animation owner.\r\n/// Cube FBX layout from ufbx's public-domain maya_cube_7400_ascii fixture:\r\n/// https://github.com/ufbx/ufbx/blob/master/data/maya_cube_7400_ascii.fbx\r\n/// Used under ufbx LICENSE alternative B (public domain).</summary>\r\npublic static class AnimationDriverMesh\r\n{\r\n public static readonly string Fbx = Text.Replace(\"\\r\\n\", \"\\n\").Replace(\"\\n\", \"\\r\\n\");\r\n private const string Text = \"\"\"\r\n; FBX 7.4.0 project file\r\n; ----------------------------------------------------\r\n\r\nFBXHeaderExtension: {\r\n\tFBXHeaderVersion: 1003\r\n\tFBXVersion: 7400\r\n\tCreationTimeStamp: {\r\n\t\tVersion: 1000\r\n\t\tYear: 2020\r\n\t\tMonth: 3\r\n\t\tDay: 31\r\n\t\tHour: 21\r\n\t\tMinute: 36\r\n\t\tSecond: 10\r\n\t\tMillisecond: 814\r\n\t}\r\n\tCreator: \"FBX SDK/FBX Plugins version 2019.2\"\r\n\tSceneInfo: \"SceneInfo::GlobalInfo\", \"UserData\" {\r\n\t\tType: \"UserData\"\r\n\t\tVersion: 100\r\n\t\tMetaData: {\r\n\t\t\tVersion: 100\r\n\t\t\tTitle: \"\"\r\n\t\t\tSubject: \"\"\r\n\t\t\tAuthor: \"\"\r\n\t\t\tKeywords: \"\"\r\n\t\t\tRevision: \"\"\r\n\t\t\tComment: \"\"\r\n\t\t}\r\n\t\tProperties70: {\r\n\t\t\tP: \"DocumentUrl\", \"KString\", \"Url\", \"\", \"animation_driver.fbx\"\r\n\t\t\tP: \"SrcDocumentUrl\", \"KString\", \"Url\", \"\", \"animation_driver.fbx\"\r\n\t\t\tP: \"Original\", \"Compound\", \"\", \"\"\r\n\t\t\tP: \"Original|ApplicationVendor\", \"KString\", \"\", \"\", \"Autodesk\"\r\n\t\t\tP: \"Original|ApplicationName\", \"KString\", \"\", \"\", \"Maya\"\r\n\t\t\tP: \"Original|ApplicationVersion\", \"KString\", \"\", \"\", \"201900\"\r\n\t\t\tP: \"Original|DateTime_GMT\", \"DateTime\", \"\", \"\", \"31/03/2020 18:36:10.811\"\r\n\t\t\tP: \"Original|FileName\", \"KString\", \"\", \"\", \"animation_driver.fbx\"\r\n\t\t\tP: \"LastSaved\", \"Compound\", \"\", \"\"\r\n\t\t\tP: \"LastSaved|ApplicationVendor\", \"KString\", \"\", \"\", \"Autodesk\"\r\n\t\t\tP: \"LastSaved|ApplicationName\", \"KString\", \"\", \"\", \"Maya\"\r\n\t\t\tP: \"LastSaved|ApplicationVersion\", \"KString\", \"\", \"\", \"201900\"\r\n\t\t\tP: \"LastSaved|DateTime_GMT\", \"DateTime\", \"\", \"\", \"31/03/2020 18:36:10.811\"\r\n\t\t\tP: \"Original|ApplicationActiveProject\", \"KString\", \"\", \"\", \"animation_driver.fbx\"\r\n\t\t}\r\n\t}\r\n}\r\nGlobalSettings: {\r\n\tVersion: 1000\r\n\tProperties70: {\r\n\t\tP: \"UpAxis\", \"int\", \"Integer\", \"\",1\r\n\t\tP: \"UpAxisSign\", \"int\", \"Integer\", \"\",1\r\n\t\tP: \"FrontAxis\", \"int\", \"Integer\", \"\",2\r\n\t\tP: \"FrontAxisSign\", \"int\", \"Integer\", \"\",1\r\n\t\tP: \"CoordAxis\", \"int\", \"Integer\", \"\",0\r\n\t\tP: \"CoordAxisSign\", \"int\", \"Integer\", \"\",1\r\n\t\tP: \"OriginalUpAxis\", \"int\", \"Integer\", \"\",1\r\n\t\tP: \"OriginalUpAxisSign\", \"int\", \"Integer\", \"\",1\r\n\t\tP: \"UnitScaleFactor\", \"double\", \"Number\", \"\",1\r\n\t\tP: \"OriginalUnitScaleFactor\", \"double\", \"Number\", \"\",1\r\n\t\tP: \"AmbientColor\", \"ColorRGB\", \"Color\", \"\",0,0,0\r\n\t\tP: \"DefaultCamera\", \"KString\", \"\", \"\", \"Producer Perspective\"\r\n\t\tP: \"TimeMode\", \"enum\", \"\", \"\",11\r\n\t\tP: \"TimeProtocol\", \"enum\", \"\", \"\",2\r\n\t\tP: \"SnapOnFrameMode\", \"enum\", \"\", \"\",0\r\n\t\tP: \"TimeSpanStart\", \"KTime\", \"Time\", \"\",1924423250\r\n\t\tP: \"TimeSpanStop\", \"KTime\", \"Time\", \"\",384884650000\r\n\t\tP: \"CustomFrameRate\", \"double\", \"Number\", \"\",-1\r\n\t\tP: \"TimeMarker\", \"Compound\", \"\", \"\"\r\n\t\tP: \"CurrentTimeMarker\", \"int\", \"Integer\", \"\",-1\r\n\t}\r\n}\r\n\r\n; Documents Description\r\n;------------------------------------------------------------------\r\n\r\nDocuments: {\r\n\tCount: 1\r\n\tDocument: 1907658701024, \"\", \"Scene\" {\r\n\t\tProperties70: {\r\n\t\t\tP: \"SourceObject\", \"object\", \"\", \"\"\r\n\t\t\tP: \"ActiveAnimStackName\", \"KString\", \"\", \"\", \"Take 001\"\r\n\t\t}\r\n\t\tRootNode: 0\r\n\t}\r\n}\r\n\r\n; Document References\r\n;------------------------------------------------------------------\r\n\r\nReferences: {\r\n}\r\n\r\n; Object definitions\r\n;------------------------------------------------------------------\r\n\r\nDefinitions: {\r\n\tVersion: 100\r\n\tCount: 6\r\n\tObjectType: \"GlobalSettings\" {\r\n\t\tCount: 1\r\n\t}\r\n\tObjectType: \"AnimationStack\" {\r\n\t\tCount: 1\r\n\t\tPropertyTemplate: \"FbxAnimStack\" {\r\n\t\t\tProperties70: {\r\n\t\t\t\tP: \"Description\", \"KString\", \"\", \"\", \"\"\r\n\t\t\t\tP: \"LocalStart\", \"KTime\", \"Time\", \"\",0\r\n\t\t\t\tP: \"LocalStop\", \"KTime\", \"Time\", \"\",0\r\n\t\t\t\tP: \"ReferenceStart\", \"KTime\", \"Time\", \"\",0\r\n\t\t\t\tP: \"ReferenceStop\", \"KTime\", \"Time\", \"\",0\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\tObjectType: \"AnimationLayer\" {\r\n\t\tCount: 1\r\n\t\tPropertyTemplate: \"FbxAnimLayer\" {\r\n\t\t\tProperties70: {\r\n\t\t\t\tP: \"Weight\", \"Number\", \"\", \"A\",100\r\n\t\t\t\tP: \"Mute\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"Solo\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"Lock\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"Color\", \"ColorRGB\", \"Color\", \"\",0.8,0.8,0.8\r\n\t\t\t\tP: \"BlendMode\", \"enum\", \"\", \"\",0\r\n\t\t\t\tP: \"RotationAccumulationMode\", \"enum\", \"\", \"\",0\r\n\t\t\t\tP: \"ScaleAccumulationMode\", \"enum\", \"\", \"\",0\r\n\t\t\t\tP: \"BlendModeBypass\", \"ULongLong\", \"\", \"\",0\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\tObjectType: \"Geometry\" {\r\n\t\tCount: 1\r\n\t\tPropertyTemplate: \"FbxMesh\" {\r\n\t\t\tProperties70: {\r\n\t\t\t\tP: \"Color\", \"ColorRGB\", \"Color\", \"\",0.8,0.8,0.8\r\n\t\t\t\tP: \"BBoxMin\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"BBoxMax\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"Primary Visibility\", \"bool\", \"\", \"\",1\r\n\t\t\t\tP: \"Casts Shadows\", \"bool\", \"\", \"\",1\r\n\t\t\t\tP: \"Receive Shadows\", \"bool\", \"\", \"\",1\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\tObjectType: \"Material\" {\r\n\t\tCount: 1\r\n\t\tPropertyTemplate: \"FbxSurfaceLambert\" {\r\n\t\t\tProperties70: {\r\n\t\t\t\tP: \"ShadingModel\", \"KString\", \"\", \"\", \"Lambert\"\r\n\t\t\t\tP: \"MultiLayer\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"EmissiveColor\", \"Color\", \"\", \"A\",0,0,0\r\n\t\t\t\tP: \"EmissiveFactor\", \"Number\", \"\", \"A\",1\r\n\t\t\t\tP: \"AmbientColor\", \"Color\", \"\", \"A\",0.2,0.2,0.2\r\n\t\t\t\tP: \"AmbientFactor\", \"Number\", \"\", \"A\",1\r\n\t\t\t\tP: \"DiffuseColor\", \"Color\", \"\", \"A\",0.8,0.8,0.8\r\n\t\t\t\tP: \"DiffuseFactor\", \"Number\", \"\", \"A\",1\r\n\t\t\t\tP: \"Bump\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"NormalMap\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"BumpFactor\", \"double\", \"Number\", \"\",1\r\n\t\t\t\tP: \"TransparentColor\", \"Color\", \"\", \"A\",0,0,0\r\n\t\t\t\tP: \"TransparencyFactor\", \"Number\", \"\", \"A\",0\r\n\t\t\t\tP: \"DisplacementColor\", \"ColorRGB\", \"Color\", \"\",0,0,0\r\n\t\t\t\tP: \"DisplacementFactor\", \"double\", \"Number\", \"\",1\r\n\t\t\t\tP: \"VectorDisplacementColor\", \"ColorRGB\", \"Color\", \"\",0,0,0\r\n\t\t\t\tP: \"VectorDisplacementFactor\", \"double\", \"Number\", \"\",1\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\tObjectType: \"Model\" {\r\n\t\tCount: 1\r\n\t\tPropertyTemplate: \"FbxNode\" {\r\n\t\t\tProperties70: {\r\n\t\t\t\tP: \"QuaternionInterpolate\", \"enum\", \"\", \"\",0\r\n\t\t\t\tP: \"RotationOffset\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"RotationPivot\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"ScalingOffset\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"ScalingPivot\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"TranslationActive\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"TranslationMin\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"TranslationMax\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"TranslationMinX\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"TranslationMinY\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"TranslationMinZ\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"TranslationMaxX\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"TranslationMaxY\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"TranslationMaxZ\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"RotationOrder\", \"enum\", \"\", \"\",0\r\n\t\t\t\tP: \"RotationSpaceForLimitOnly\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"RotationStiffnessX\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"RotationStiffnessY\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"RotationStiffnessZ\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"AxisLen\", \"double\", \"Number\", \"\",10\r\n\t\t\t\tP: \"PreRotation\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"PostRotation\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"RotationActive\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"RotationMin\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"RotationMax\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"RotationMinX\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"RotationMinY\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"RotationMinZ\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"RotationMaxX\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"RotationMaxY\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"RotationMaxZ\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"InheritType\", \"enum\", \"\", \"\",0\r\n\t\t\t\tP: \"ScalingActive\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"ScalingMin\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"ScalingMax\", \"Vector3D\", \"Vector\", \"\",1,1,1\r\n\t\t\t\tP: \"ScalingMinX\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"ScalingMinY\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"ScalingMinZ\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"ScalingMaxX\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"ScalingMaxY\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"ScalingMaxZ\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"GeometricTranslation\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"GeometricRotation\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\t\tP: \"GeometricScaling\", \"Vector3D\", \"Vector\", \"\",1,1,1\r\n\t\t\t\tP: \"MinDampRangeX\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"MinDampRangeY\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"MinDampRangeZ\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"MaxDampRangeX\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"MaxDampRangeY\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"MaxDampRangeZ\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"MinDampStrengthX\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"MinDampStrengthY\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"MinDampStrengthZ\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"MaxDampStrengthX\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"MaxDampStrengthY\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"MaxDampStrengthZ\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"PreferedAngleX\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"PreferedAngleY\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"PreferedAngleZ\", \"double\", \"Number\", \"\",0\r\n\t\t\t\tP: \"LookAtProperty\", \"object\", \"\", \"\"\r\n\t\t\t\tP: \"UpVectorProperty\", \"object\", \"\", \"\"\r\n\t\t\t\tP: \"Show\", \"bool\", \"\", \"\",1\r\n\t\t\t\tP: \"NegativePercentShapeSupport\", \"bool\", \"\", \"\",1\r\n\t\t\t\tP: \"DefaultAttributeIndex\", \"int\", \"Integer\", \"\",-1\r\n\t\t\t\tP: \"Freeze\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"LODBox\", \"bool\", \"\", \"\",0\r\n\t\t\t\tP: \"Lcl Translation\", \"Lcl Translation\", \"\", \"A\",0,0,0\r\n\t\t\t\tP: \"Lcl Rotation\", \"Lcl Rotation\", \"\", \"A\",0,0,0\r\n\t\t\t\tP: \"Lcl Scaling\", \"Lcl Scaling\", \"\", \"A\",1,1,1\r\n\t\t\t\tP: \"Visibility\", \"Visibility\", \"\", \"A\",1\r\n\t\t\t\tP: \"Visibility Inheritance\", \"Visibility Inheritance\", \"\", \"\",1\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\n; Object properties\r\n;------------------------------------------------------------------\r\n\r\nObjects: {\r\n\tGeometry: 1907663073408, \"Geometry::\", \"Mesh\" {\r\n\t\tVertices: *24 {\r\n\t\t\ta: -0.5,-0.5,0.5,0.5,-0.5,0.5,-0.5,0.5,0.5,0.5,0.5,0.5,-0.5,0.5,-0.5,0.5,0.5,-0.5,-0.5,-0.5,-0.5,0.5,-0.5,-0.5\r\n\t\t}\r\n\t\tPolygonVertexIndex: *24 {\r\n\t\t\ta: 0,1,3,-3,2,3,5,-5,4,5,7,-7,6,7,1,-1,1,7,5,-4,6,0,2,-5\r\n\t\t}\r\n\t\tEdges: *12 {\r\n\t\t\ta: 0,2,6,10,3,1,7,5,11,9,15,13\r\n\t\t}\r\n\t\tGeometryVersion: 124\r\n\t\tLayerElementNormal: 0 {\r\n\t\t\tVersion: 102\r\n\t\t\tName: \"\"\r\n\t\t\tMappingInformationType: \"ByPolygonVertex\"\r\n\t\t\tReferenceInformationType: \"Direct\"\r\n\t\t\tNormals: *72 {\r\n\t\t\t\ta: 0,0,1,0,0,1,0,0,1,0,0,1,0,1,0,0,1,0,0,1,0,0,1,0,0,0,-1,0,0,-1,0,0,-1,0,0,-1,0,-1,0,0,-1,0,0,-1,0,0,-1,0,1,0,0,1,0,0,1,0,0,1,0,0,-1,0,0,-1,0,0,-1,0,0,-1,0,0\r\n\t\t\t}\r\n\t\t\tNormalsW: *24 {\r\n\t\t\t\ta: 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1\r\n\t\t\t}\r\n\t\t}\r\n\t\tLayerElementBinormal: 0 {\r\n\t\t\tVersion: 102\r\n\t\t\tName: \"map1\"\r\n\t\t\tMappingInformationType: \"ByPolygonVertex\"\r\n\t\t\tReferenceInformationType: \"Direct\"\r\n\t\t\tBinormals: *72 {\r\n\t\t\t\ta: 0,1,-0,0,1,-0,0,1,-0,0,1,-0,0,0,-1,0,0,-1,0,0,-1,0,0,-1,0,-1,0,0,-1,0,0,-1,0,0,-1,0,0,0,1,0,0,1,0,0,1,0,0,1,-0,1,0,-0,1,0,0,1,-0,-0,1,0,0,1,0,0,1,0,0,1,0,0,1,0\r\n\t\t\t}\r\n\t\t\tBinormalsW: *24 {\r\n\t\t\t\ta: 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1\r\n\t\t\t}\r\n\r\n\t\t}\r\n\t\tLayerElementTangent: 0 {\r\n\t\t\tVersion: 102\r\n\t\t\tName: \"map1\"\r\n\t\t\tMappingInformationType: \"ByPolygonVertex\"\r\n\t\t\tReferenceInformationType: \"Direct\"\r\n\t\t\tTangents: *72 {\r\n\t\t\t\ta: 1,-0,-0,1,-0,0,1,-0,0,1,-0,0,1,-0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,-0,1,0,-0,1,0,-0,1,0,-0,0,0,-1,0,0,-1,0,-0,-1,0,0,-1,0,-0,1,0,-0,1,0,-0,1,0,-0,1\r\n\t\t\t}\r\n\t\t\tTangentsW: *24 {\r\n\t\t\t\ta: 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1\r\n\t\t\t}\r\n\t\t}\r\n\t\tLayerElementUV: 0 {\r\n\t\t\tVersion: 101\r\n\t\t\tName: \"map1\"\r\n\t\t\tMappingInformationType: \"ByPolygonVertex\"\r\n\t\t\tReferenceInformationType: \"IndexToDirect\"\r\n\t\t\tUV: *28 {\r\n\t\t\t\ta: 0.375,0,0.625,0,0.375,0.25,0.625,0.25,0.375,0.5,0.625,0.5,0.375,0.75,0.625,0.75,0.375,1,0.625,1,0.875,0,0.875,0.25,0.125,0,0.125,0.25\r\n\t\t\t}\r\n\t\t\tUVIndex: *24 {\r\n\t\t\t\ta: 0,1,3,2,2,3,5,4,4,5,7,6,6,7,9,8,1,10,11,3,12,0,2,13\r\n\t\t\t}\r\n\t\t}\r\n\t\tLayerElementSmoothing: 0 {\r\n\t\t\tVersion: 102\r\n\t\t\tName: \"\"\r\n\t\t\tMappingInformationType: \"ByEdge\"\r\n\t\t\tReferenceInformationType: \"Direct\"\r\n\t\t\tSmoothing: *12 {\r\n\t\t\t\ta: 0,0,0,0,0,0,0,0,0,0,0,0\r\n\t\t\t}\r\n\t\t}\r\n\t\tLayerElementMaterial: 0 {\r\n\t\t\tVersion: 101\r\n\t\t\tName: \"\"\r\n\t\t\tMappingInformationType: \"AllSame\"\r\n\t\t\tReferenceInformationType: \"IndexToDirect\"\r\n\t\t\tMaterials: *1 {\r\n\t\t\t\ta: 0\r\n\t\t\t}\r\n\t\t}\r\n\t\tLayer: 0 {\r\n\t\t\tVersion: 100\r\n\t\t\tLayerElement: {\r\n\t\t\t\tType: \"LayerElementNormal\"\r\n\t\t\t\tTypedIndex: 0\r\n\t\t\t}\r\n\t\t\tLayerElement: {\r\n\t\t\t\tType: \"LayerElementBinormal\"\r\n\t\t\t\tTypedIndex: 0\r\n\t\t\t}\r\n\t\t\tLayerElement: {\r\n\t\t\t\tType: \"LayerElementTangent\"\r\n\t\t\t\tTypedIndex: 0\r\n\t\t\t}\r\n\t\t\tLayerElement: {\r\n\t\t\t\tType: \"LayerElementMaterial\"\r\n\t\t\t\tTypedIndex: 0\r\n\t\t\t}\r\n\t\t\tLayerElement: {\r\n\t\t\t\tType: \"LayerElementSmoothing\"\r\n\t\t\t\tTypedIndex: 0\r\n\t\t\t}\r\n\t\t\tLayerElement: {\r\n\t\t\t\tType: \"LayerElementUV\"\r\n\t\t\t\tTypedIndex: 0\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\tModel: 1907292236800, \"Model::animation_driver\", \"Mesh\" {\r\n\t\tVersion: 232\r\n\t\tProperties70: {\r\n\t\t\tP: \"RotationActive\", \"bool\", \"\", \"\",1\r\n\t\t\tP: \"InheritType\", \"enum\", \"\", \"\",1\r\n\t\t\tP: \"ScalingMax\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\tP: \"DefaultAttributeIndex\", \"int\", \"Integer\", \"\",0\r\n\t\t\tP: \"currentUVSet\", \"KString\", \"\", \"U\", \"map1\"\r\n\t\t}\r\n\t\tShading: T\r\n\t\tCulling: \"CullingOff\"\r\n\t}\r\n\tMaterial: 1908464858960, \"Material::lambert1\", \"\" {\r\n\t\tVersion: 102\r\n\t\tShadingModel: \"lambert\"\r\n\t\tMultiLayer: 0\r\n\t\tProperties70: {\r\n\t\t\tP: \"AmbientColor\", \"Color\", \"\", \"A\",0,0,0\r\n\t\t\tP: \"DiffuseColor\", \"Color\", \"\", \"A\",0.5,0.5,0.5\r\n\t\t\tP: \"DiffuseFactor\", \"Number\", \"\", \"A\",0.800000011920929\r\n\t\t\tP: \"TransparencyFactor\", \"Number\", \"\", \"A\",1\r\n\t\t\tP: \"Emissive\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\tP: \"Ambient\", \"Vector3D\", \"Vector\", \"\",0,0,0\r\n\t\t\tP: \"Diffuse\", \"Vector3D\", \"Vector\", \"\",0.400000005960464,0.400000005960464,0.400000005960464\r\n\t\t\tP: \"Opacity\", \"double\", \"Number\", \"\",1\r\n\t\t}\r\n\t}\r\n\tAnimationStack: 1907674289488, \"AnimStack::Take 001\", \"\" {\r\n\t\tProperties70: {\r\n\t\t\tP: \"LocalStart\", \"KTime\", \"Time\", \"\",1924423250\r\n\t\t\tP: \"LocalStop\", \"KTime\", \"Time\", \"\",230930790000\r\n\t\t\tP: \"ReferenceStart\", \"KTime\", \"Time\", \"\",1924423250\r\n\t\t\tP: \"ReferenceStop\", \"KTime\", \"Time\", \"\",230930790000\r\n\t\t}\r\n\t}\r\n\tAnimationLayer: 1907631731008, \"AnimLayer::BaseLayer\", \"\" {\r\n\t}\r\n}\r\n\r\n; Object connections\r\n;------------------------------------------------------------------\r\n\r\nConnections: {\r\n\r\n\t;Model::animation_driver, Model::RootNode\r\n\tC: \"OO\",1907292236800,0\r\n\r\n\t;AnimLayer::BaseLayer, AnimStack::Take 001\r\n\tC: \"OO\",1907631731008,1907674289488\r\n\r\n\t;Geometry::, Model::animation_driver\r\n\tC: \"OO\",1907663073408,1907292236800\r\n\r\n\t;Material::lambert1, Model::animation_driver\r\n\tC: \"OO\",1908464858960,1907292236800\r\n}\r\n;Takes section\r\n;----------------------------------------------------\r\n\r\nTakes: {\r\n\tCurrent: \"Take 001\"\r\n\tTake: \"Take 001\" {\r\n\t\tFileName: \"Take_001.tak\"\r\n\t\tLocalTime: 1924423250,230930790000\r\n\t\tReferenceTime: 1924423250,230930790000\r\n\t}\r\n}\r\n\r\n\"\"\";\r\n}\r\n"
}
]
}