Editor/HumanoidMocap/TargetPickers.cs
#nullable enable annotations
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using System.Numerics;
using Editor;
using HumanoidMocap.Formats.Fbx;
using HumanoidMocap.Formats.Gltf;
using HumanoidMocap.Mapping;
using HumanoidMocap.Maths;
using HumanoidMocap.Target;
using Sandbox;
using SkeletonModel = HumanoidMocap.Skeleton.Skeleton;
namespace HumanoidMocap.Editor;
/// <summary>
/// Builds <see cref="RetargetTargetSpec"/>s for the window's target picker
/// (Task 9.5.2): the shipped s&box default, a custom compiled model/vmdl asset, or a
/// custom FBX, GLB, or glTF file. Custom targets run the same humanoid detection as sources and are
/// rejected with a clear message below the detection threshold.
/// </summary>
/// <remarks>
/// <b>Units.</b> The pipeline is unit-agnostic as long as one target is self-consistent:
/// the solver scales pelvis translation by the target/source hip-height RATIO (so cm
/// sources drive inch targets correctly) and all other channels are rotations; DMX
/// positions are emitted in whatever units the target skeleton uses.
/// <list type="bullet">
/// <item>Compiled model targets: <c>Model.Bones</c> bind pose is in engine units (inches)
/// → <c>VmdlScale = 1.0</c>, no conversion anywhere.</item>
/// <item>Source-model targets: imported in source units normalized to cm →
/// <c>VmdlScale = 0.3937</c> (cm → inch at compile time),
/// matching the s&box citizen pipeline.</item>
/// </list>
/// </remarks>
public static class TargetPickers
{
/// <summary>A resolved conversion target plus what the preview needs to render it.</summary>
public sealed class ResolvedTarget
{
/// <summary>The spec handed to <see cref="Retargeter.ConvertBatch"/>.</summary>
public RetargetTargetSpec Spec { get; set; }
/// <summary>Short description for the window status line / target chip.</summary>
public string Description { get; set; }
/// <summary>Asset path of a compiled model whose bone names match the target rig -
/// used by the skinned preview. Null before a source-model target's preview compiles:
/// the preview then shows a "no preview model" notice.</summary>
public string PreviewModelPath { get; set; }
/// <summary>Multiplier taking target-skeleton positions to engine units for the
/// preview (0.3937 for cm rigs, 1.0 for engine-unit rigs).</summary>
public float PreviewPositionScale { get; set; } = 1.0f;
/// <summary>Absolute path of the picked target model file (FBX/glTF/GLB; null
/// otherwise). At convert time the editor copies it into the output folder and sets
/// <see cref="RetargetTargetSpec.MeshFilePath"/> so the standalone vmdl embeds the
/// mesh — without it the vmdl compiles into an empty model that plays nothing.</summary>
public string ModelFilePath { get; set; }
/// <summary>The importer's source-unit→cm factor for
/// <see cref="ModelFilePath"/> (resourcecompiler reads raw values, so this
/// becomes the RenderMeshFile <c>import_scale</c>).</summary>
public float ModelUnitScaleCm { get; set; } = 1.0f;
/// <summary>The skeleton imported directly from <see cref="ModelFilePath"/>.
/// Kept when <see cref="Spec"/> is rebuilt from the compiled preview: glTF mesh
/// conversion must continue using the source bind, not feed the compiler-converted
/// bind back through the model-DMX writer a second time.</summary>
internal SkeletonModel SourceModelSkeleton { get; set; }
// Compatibility aliases for editor gate consumers written before GLB/glTF targets.
public string FbxAbsolutePath { get => ModelFilePath; set => ModelFilePath = value; }
public float FbxUnitScaleCm { get => ModelUnitScaleCm; set => ModelUnitScaleCm = value; }
/// <summary>Non-fatal caveat about the pick, surfaced on the window status strip
/// (e.g. a skeleton-only target FBX whose standalone vmdl will have no visible
/// model). Null when there is nothing to warn about.</summary>
public string Warning { get; set; }
/// <summary>Sequence names of the target model's own embedded animation takes
/// (index-aligned with the imported clips). Converted alongside every batch as
/// exact identity retargets so an animated FBX keeps its animations - AnimFile
/// nodes referencing the FBX directly cannot rescale translations (no import
/// scale on the node), which broke inch-authored files. Null/empty = none.</summary>
public IReadOnlyList<string> EmbeddedTakeNames { get; set; }
}
/// <summary>
/// A picked custom target whose skeleton could not be auto-recognized: everything the
/// window's manual-mapping fallback needs to let the user assign the roles by hand and
/// build the target anyway (any humanoid-LIKE model — a paw, one finger, a missing
/// hand — is a valid target once its core bones are pointed out).
/// </summary>
public sealed class RejectedTarget
{
/// <summary>The candidate target skeleton (model bones / imported FBX).</summary>
public SkeletonModel Skeleton { get; set; }
/// <summary>The cascade's best-effort map — prefills the manual mapping editor.</summary>
public MappingResult BestEffortMap { get; set; }
/// <summary>Set for compiled-model picks (mutually exclusive with <see cref="FbxPath"/>).</summary>
public Asset ModelAsset { get; set; }
/// <summary>Set for source-model-file picks (absolute path).</summary>
public string ModelFilePath { get; set; }
/// <summary>Importer's source-unit→cm factor (source-model-file picks only).</summary>
public float ModelUnitScaleCm { get; set; } = 1.0f;
/// <summary>The source up-axis index (1 = Y, 2 = Z).</summary>
public int ModelUpAxis { get; set; } = 1;
public string FbxPath { get => ModelFilePath; set => ModelFilePath = value; }
public float FbxUnitScaleCm { get => ModelUnitScaleCm; set => ModelUnitScaleCm = value; }
public int FbxUpAxis { get => ModelUpAxis; set => ModelUpAxis = value; }
/// <summary>Short name for dialog titles.</summary>
public string DisplayName { get; set; }
}
/// <summary>
/// Builds the target from a user-authored mapping over a previously rejected pick
/// (the manual-mapping fallback's confirm path). Returns null with
/// <paramref name="error"/> set when the mapping is structurally unusable (e.g.
/// duplicate bone assignments).
/// </summary>
public static ResolvedTarget FromManualMapping(
RejectedTarget rejected, MappingResult mapping, out string error )
{
error = null;
try
{
return rejected.ModelAsset is not null
? BuildModelTarget( rejected.ModelAsset, rejected.Skeleton, mapping )
: BuildFileTarget( rejected.ModelFilePath, rejected.ModelUnitScaleCm, rejected.ModelUpAxis,
rejected.Skeleton, mapping );
}
catch ( Exception e )
{
error = $"Could not build the target from the mapping: {e.Message}";
return null;
}
}
/// <summary>The shipped s&box human target. Throws when the rig JSON is missing.</summary>
public static ResolvedTarget SboxDefault()
{
var spec = EditorPipeline.LoadSboxDefaultTarget();
return new ResolvedTarget
{
Spec = spec,
Description = "s&box Human (default)",
PreviewModelPath = RetargetTargetSpec.SboxHumanMalePath,
PreviewPositionScale = RetargetTargetSpec.SboxSourceScale,
};
}
/// <summary>The classic (4-finger) s&box citizen target. Throws when the rig JSON is
/// missing. Pinky roles stay unassigned on this rig (it has no pinky bones).</summary>
public static ResolvedTarget SboxCitizen()
{
var spec = EditorPipeline.LoadSboxCitizenTarget();
return new ResolvedTarget
{
Spec = spec,
Description = "s&box Citizen (classic)",
PreviewModelPath = RetargetTargetSpec.SboxCitizenPath,
PreviewPositionScale = RetargetTargetSpec.SboxSourceScale,
};
}
/// <summary>
/// Builds a target from a compiled model asset (vmdl): skeleton from
/// <c>Model.Bones</c> (engine units → VmdlScale 1.0), roles from preset detection /
/// auto-mapping, bone classes from <see cref="BoneClassRules"/> name patterns.
/// Returns null with <paramref name="error"/> set when the model fails to load or the
/// armature is not recognized as humanoid.
/// </summary>
public static ResolvedTarget FromModelAsset( Asset asset, out string error )
=> FromModelAsset( asset, out error, out _ );
/// <summary>As <see cref="FromModelAsset(Asset, out string)"/>; additionally reports a
/// <see cref="RejectedTarget"/> when the skeleton loaded fine but was not recognized —
/// the window then offers manual mapping instead of a dead end.</summary>
public static ResolvedTarget FromModelAsset( Asset asset, out string error, out RejectedTarget rejected )
{
error = null;
rejected = null;
var model = Model.Load( asset.Path );
if ( model is null || model.IsError )
{
error = $"Could not load model '{asset.Path}'.";
return null;
}
SkeletonModel skeleton;
try
{
skeleton = SkeletonFromModel( model );
}
catch ( Exception e )
{
error = $"Could not read the model's skeleton: {e.Message}";
return null;
}
var map = DetectHumanoid( skeleton, out error, out var bestEffort );
if ( map is null )
{
rejected = new RejectedTarget
{
Skeleton = skeleton,
BestEffortMap = bestEffort,
ModelAsset = asset,
DisplayName = asset.Name,
};
return null;
}
return BuildModelTarget( asset, skeleton, map );
}
/// <summary>The first-person viewmodel arms s&box games use, as cloud packages.</summary>
public const string HumanArmsPackage = "facepunch.v_first_person_arms_human";
public const string CitizenArmsPackage = "facepunch.v_first_person_arms_citizen";
public const string HumanArmsPath = "models/first_person/v_first_person_arms_human.vmdl";
public const string CitizenArmsPath = "models/first_person/v_first_person_arms_citizen.vmdl";
/// <summary>The human (5-finger) or citizen (4-finger) first-person arms, mounted from their package
/// when needed. Arms-only rigs map as partial targets: clavicles, arms, hands and fingers.</summary>
public static async Task<ResolvedTarget> FirstPersonArmsAsync( bool citizen )
{
var path = citizen ? CitizenArmsPath : HumanArmsPath;
var model = Model.Load( path );
if ( model is null || model.IsError || model.BoneCount == 0 )
{
await Package.MountAsync( citizen ? CitizenArmsPackage : HumanArmsPackage, false );
await EditorPipeline.SwitchToMainThread();
}
var target = FromModelPath( path, citizen ? "s&box Citizen arms (first person)" : "s&box Human arms (first person)", out var error );
return target ?? throw new InvalidOperationException( error ?? "Could not load the first-person arms." );
}
/// <summary>Builds a target from a compiled model loaded by path, such as a mounted cloud model that
/// is not a project asset.</summary>
public static ResolvedTarget FromModelPath( string path, string description, out string error )
{
error = null;
var model = Model.Load( path );
if ( model is null || model.IsError || model.BoneCount == 0 )
{
error = $"Could not load model '{path}'.";
return null;
}
var skeleton = SkeletonFromModel( model );
var map = DetectHumanoid( skeleton, out error, out _ );
if ( map is null ) return null;
var target = BuildModelTarget( path, description, skeleton, map );
return target;
}
static ResolvedTarget BuildModelTarget( Asset asset, SkeletonModel skeleton, MappingResult map )
=> BuildModelTarget( asset.Path, $"Custom model: {asset.Name}", skeleton, map );
static ResolvedTarget BuildModelTarget( string path, string description, SkeletonModel skeleton, MappingResult map )
{
var rig = TargetRig.FromSkeleton( skeleton, map );
return new ResolvedTarget
{
Spec = new RetargetTargetSpec
{
Rig = rig,
VmdlScale = 1.0f, // engine units already
BaseModelPath = path,
DefaultRootBone = RootBoneName( skeleton, map ),
UpAxis = TargetUpAxis.ZUpEngine, // Model.Bones bind pose is engine space
DlWeights = DlAssets.TryLoadWeights(),
},
Description = description,
PreviewModelPath = path,
PreviewPositionScale = 1.0f,
};
}
/// <summary>
/// Compatibility entry point for building a target from an FBX file.
/// </summary>
public static ResolvedTarget FromFbxFile( string filePath, out string error )
=> FromModelFile( filePath, out error, out _ );
/// <summary>Builds a custom target from an FBX, GLB, or glTF source model.</summary>
public static ResolvedTarget FromModelFile( string filePath, out string error )
=> FromModelFile( filePath, out error, out _ );
/// <summary>As <see cref="FromFbxFile(string, out string)"/>; additionally reports a
/// <see cref="RejectedTarget"/> when the file imported fine but the skeleton was not
/// recognized — the window then offers manual mapping instead of a dead end.</summary>
public static ResolvedTarget FromFbxFile( string filePath, out string error, out RejectedTarget rejected )
=> FromModelFile( filePath, out error, out rejected );
/// <summary>As <see cref="FromModelFile(string, out string)"/>; also returns the
/// best-effort mapping state when the model needs manual role assignment.</summary>
public static ResolvedTarget FromModelFile(
string filePath, out string error, out RejectedTarget rejected )
{
error = null;
rejected = null;
SkeletonModel skeleton;
float unitScaleCm;
int upAxis;
bool hasMesh;
try
{
var bytes = File.ReadAllBytes( filePath );
var extension = Path.GetExtension( filePath ).ToLowerInvariant();
var imported = extension switch
{
".fbx" => FbxImporter.Import( bytes ),
".glb" => GltfImporter.Import( bytes, new GltfImportOptions { UseSkinBindPose = true } ),
".gltf" => GltfImporter.Import( bytes, new GltfImportOptions
{
UseSkinBindPose = true,
ExternalBufferResolver = uri => ReadGltfDependency( filePath, uri ),
} ),
_ => throw new FormatException( "Expected an .fbx, .glb, or .gltf model." ),
};
skeleton = imported.Skeleton;
unitScaleCm = imported.UnitScaleCm;
upAxis = imported.UpAxis;
// Both tokens live uncompressed in their respective container metadata.
hasMesh = extension == ".fbx"
? ContainsToken( bytes, "Geometry" )
: ContainsToken( bytes, "\"meshes\"" );
}
catch ( Exception e )
{
error = $"Could not import '{Path.GetFileName( filePath )}': {e.Message}";
return null;
}
// The engine sanitizes bone names when importing the mesh (observed: 3ds Max
// Biped's "Bip01 R Finger22" compiles to "Bip01_R_Finger22"), and DMX animation
// channels bind BY NAME - a rig built on the raw names produces sequences that
// play ZERO motion on the compiled model (the user's "animation doesn't play").
// Build the whole target on the sanitized names; preset detection is separator-
// insensitive, so recognition is unaffected.
skeleton = SanitizeBoneNamesLikeEngine( skeleton );
var map = DetectHumanoid( skeleton, out error, out var bestEffort );
if ( map is null )
{
rejected = new RejectedTarget
{
Skeleton = skeleton,
BestEffortMap = bestEffort,
ModelFilePath = Path.GetFullPath( filePath ),
ModelUnitScaleCm = unitScaleCm,
ModelUpAxis = upAxis,
DisplayName = Path.GetFileName( filePath ),
};
return null;
}
var resolved = BuildFileTarget( Path.GetFullPath( filePath ), unitScaleCm, upAxis, skeleton, map );
if ( !hasMesh )
{
resolved.Warning = $"'{Path.GetFileName( filePath )}' contains no mesh (skeleton-only "
+ "export): a NEW vmdl generated for it will have nothing visible to play. Add the "
+ "animations to an existing vmdl of this model instead, or re-export it with skin.";
}
return resolved;
}
internal static byte[] ReadGltfDependency( string gltfPath, string uri )
{
var relative = Uri.UnescapeDataString( uri.Split( '?', '#' )[0] )
.Replace( '/', Path.DirectorySeparatorChar );
if ( Path.IsPathRooted( relative ) )
throw new FormatException( $"glTF dependency must be relative: '{uri}'." );
var directory = Path.GetFullPath( Path.GetDirectoryName( gltfPath ) );
var path = Path.GetFullPath( Path.Combine( directory, relative ) );
if ( !path.StartsWith( directory.TrimEnd( Path.DirectorySeparatorChar )
+ Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase ) )
throw new FormatException( $"glTF dependency escapes the model folder: '{uri}'." );
return File.ReadAllBytes( path );
}
/// <summary>
/// Rebuilds an FBX target's rig from its COMPILED preview model — the engine's own
/// import is the single source of truth the sequences will play on, so deriving the
/// rig from it makes rig ≡ compiled bind BY CONSTRUCTION for any FBX, killing the
/// per-exporter drift our importer can never fully chase (measured: 6 finger bones up
/// to 6in/8° off on an Auto-Rig Pro export — stretched fingers — while a Biped export
/// matched exactly). The engine skeleton (Z-up inches) is re-expressed in the vmdl's
/// source space (Y-up cm) so the DMX and the ScaleAndMirror-carrying vmdl stay unit-
/// and axis-consistent with the embedded mesh. False = keep the importer-derived rig.
/// </summary>
internal static bool TryRebuildFromCompiledPreview( ResolvedTarget target, string modelPath )
{
try
{
var model = Model.Load( modelPath );
if ( model is null || model.IsError || model.BoneCount == 0 )
return false;
var engine = SkeletonFromModel( model );
// Engine (Z-up, inches) → vmdl source space (Y-up, cm): uniform inverse scale
// on every local position; the inverse basis rotation folds into the roots.
var definitions = new List<HumanoidMocap.Skeleton.BoneDefinition>( engine.Count );
foreach ( var bone in engine.Bones )
{
var local = CompiledRigSourceSpace.FromEngineLocal(
bone.RestLocal, bone.ParentIndex < 0, target.Spec.UpAxis );
definitions.Add( new HumanoidMocap.Skeleton.BoneDefinition(
bone.Name, bone.ParentIndex < 0 ? null : engine[bone.ParentIndex].Name,
local ) );
}
var skeleton = SkeletonModel.Create( definitions );
var map = DetectHumanoid( skeleton, out var error, out _ );
if ( map is null )
{
Log.Warning( $"[sbox-humanoid-mocap] compiled-preview rig rebuild skipped: {error}" );
return false;
}
target.Spec.Rig = TargetRig.FromSkeleton( skeleton, map );
target.Spec.DefaultRootBone = RootBoneName( skeleton, map );
Log.Info( "[sbox-humanoid-mocap] target rig rebuilt from the compiled preview model "
+ $"({skeleton.Count} bones) - rig now matches the engine bind exactly" );
return true;
}
catch ( Exception e )
{
Log.Warning( $"[sbox-humanoid-mocap] compiled-preview rig rebuild failed: {e.Message}" );
return false;
}
}
/// <summary>Resourcecompiler's bone-name sanitization (non-alphanumeric → underscore),
/// applied to a candidate TARGET skeleton so the generated animation channels match the
/// compiled model's bones exactly. Kept as-is when nothing changes or when sanitizing
/// would collide two names (e.g. "a b" vs "a_b" - better recognizable than broken).</summary>
static SkeletonModel SanitizeBoneNamesLikeEngine( SkeletonModel skeleton )
{
var changed = false;
var definitions = new List<HumanoidMocap.Skeleton.BoneDefinition>( skeleton.Count );
foreach ( var bone in skeleton.Bones )
{
var safe = SanitizeBoneName( bone.Name );
changed |= safe != bone.Name;
definitions.Add( new HumanoidMocap.Skeleton.BoneDefinition(
safe,
bone.ParentIndex >= 0 ? SanitizeBoneName( skeleton[bone.ParentIndex].Name ) : null,
bone.RestLocal ) );
}
if ( !changed )
return skeleton;
try
{
return SkeletonModel.Create( definitions );
}
catch ( Exception e )
{
Log.Warning( $"[sbox-humanoid-mocap] could not sanitize target bone names ({e.Message}) - keeping the originals" );
return skeleton;
}
}
static string SanitizeBoneName( string name )
{
var builder = new System.Text.StringBuilder( name.Length );
foreach ( var c in name )
builder.Append( char.IsLetterOrDigit( c ) || c == '_' ? c : '_' );
return builder.ToString();
}
/// <summary>Byte-level token scan (works for binary and ASCII FBX alike).</summary>
static bool ContainsToken( byte[] data, string token )
{
var needle = System.Text.Encoding.ASCII.GetBytes( token );
for ( var i = 0; i <= data.Length - needle.Length; i++ )
{
var match = true;
for ( var j = 0; j < needle.Length; j++ )
{
if ( data[i + j] != needle[j] ) { match = false; break; }
}
if ( match )
return true;
}
return false;
}
static ResolvedTarget BuildFileTarget(
string filePath, float unitScaleCm, int upAxis, SkeletonModel skeleton, MappingResult map )
{
var rig = TargetRig.FromSkeleton( skeleton, map );
return new ResolvedTarget
{
Spec = new RetargetTargetSpec
{
Rig = rig,
VmdlScale = RetargetTargetSpec.SboxSourceScale, // cm-authored skeleton
BaseModelPath = "",
DefaultRootBone = RootBoneName( skeleton, map ),
// UE / 3ds Max FBX exports are Z-up: the DMX must declare Z-up so the
// compiler applies no rotation (the embedded mesh is in the same space) -
// declaring Y-up would compile the animation lying on its back.
UpAxis = upAxis == 2 ? TargetUpAxis.ZUpCm : TargetUpAxis.YUpCm,
DlWeights = DlAssets.TryLoadWeights(),
},
Description = $"Custom {Path.GetExtension( filePath ).TrimStart( '.' ).ToUpperInvariant()}: {Path.GetFileName( filePath )}",
PreviewModelPath = null,
PreviewPositionScale = RetargetTargetSpec.SboxSourceScale,
ModelFilePath = filePath,
ModelUnitScaleCm = unitScaleCm,
SourceModelSkeleton = skeleton,
};
}
/// <summary>
/// The facade's single mapping cascade (presets → saved user presets → auto/topology),
/// with this picker's own acceptance rule on top. A below-threshold map used to be
/// rejected outright, which made every odd-but-humanoid model — a paw instead of
/// fingers, one finger, a missing hand — unusable as a target (and, before the sbox
/// preset existed, even the citizen skeleton itself scored 5%). The solver skips
/// unmapped roles cleanly (the classic citizen ships without pinky roles), so a
/// best-effort map is accepted when it covers the body structural minimum or usable
/// hand geometry. Hand capture can drive an arms/hands-only rig without a body.
/// </summary>
static MappingResult DetectHumanoid(
SkeletonModel skeleton, out string error, out MappingResult bestEffort )
{
error = null;
var assetsPath = Project.Current?.GetAssetsPath();
var (map, report) = Retargeter.ResolveMapping( skeleton,
userPresetLookup: assetsPath is null
? null
: signature => UserPresets.TryLoad( assetsPath, signature, skeleton ) );
bestEffort = map;
if ( !report.NeedsUserDecision )
return map;
// Full-body profile scoring penalizes missing hips and legs. For viewmodel
// rigs, retain exact finger aliases instead of falling back to generic
// numbering (e.g. s&box's proximal 0 must not become a metacarpal).
if ( !HasStructuralMinimum( map ) )
{
var handMap = HumanoidMocap.Motion.HandCaptureRetargeter.DetectTargetHandMapping( skeleton, out var ambiguous );
if ( handMap is not null ) return handMap;
if ( ambiguous )
{
error = "Hand presets disagree about this rig's bone roles. Review the wrist and finger mapping.";
return null;
}
}
if ( HasStructuralMinimum( map ) || HumanoidMocap.Motion.HandCaptureRetargeter.HasTargetHandGeometry( skeleton, map ) )
{
map.Notes.Add(
$"Target accepted best-effort (mapping confidence {map.Confidence * 100f:0}%): "
+ $"{map.RoleToBone.Count} roles mapped; unmapped bones keep the model's rest pose." );
return map;
}
error = "Armature needs bone mapping (mapping confidence "
+ $"{map.Confidence * 100f:0}%): map a body, or at least one hand with finger "
+ "joints defining its palm and finger chains for hand capture.";
return null;
}
/// <summary>The least anatomy a usable conversion target needs: hips + both upper legs
/// (pelvis line and ground reference) and one source of upper-body direction — the same
/// bones the character-frame construction consumes. Everything else (hands, fingers,
/// toes, even whole arms one side) may be missing and is simply skipped.</summary>
static bool HasStructuralMinimum( MappingResult map )
{
bool Has( BoneRole role ) => map.RoleToBone.ContainsKey( role );
var upperBody = (Has( BoneRole.UpperArmL ) && Has( BoneRole.UpperArmR ))
|| (Has( BoneRole.ClavicleL ) && Has( BoneRole.ClavicleR ))
|| Has( BoneRole.Neck );
return Has( BoneRole.Hips ) && Has( BoneRole.UpperLegL ) && Has( BoneRole.UpperLegR )
&& upperBody;
}
/// <summary>Converts <c>Model.Bones</c> to the library's skeleton model.
/// <c>BoneCollection.Bone.LocalTransform</c> is the bind transform in MODEL space
/// despite its name (measured: treating it as parent-relative compounds the citizen's
/// upper body until the head FKs to 220in from the pelvis — the exploded "stretched
/// mesh" class; near-root bones like the pelvis stay coincidentally correct, which is
/// why position asserts on the pelvis alone never caught it). Convert to the
/// parent-relative locals <see cref="SkeletonModel.Create"/> expects.</summary>
static SkeletonModel SkeletonFromModel( Model model )
{
static XForm ToXForm( Transform transform ) => new(
new System.Numerics.Vector3( transform.Position.x, transform.Position.y, transform.Position.z ),
new System.Numerics.Quaternion(
transform.Rotation.x, transform.Rotation.y, transform.Rotation.z, transform.Rotation.w ) );
var definitions = new List<HumanoidMocap.Skeleton.BoneDefinition>();
foreach ( var bone in model.Bones.AllBones )
{
var world = ToXForm( bone.LocalTransform );
var local = bone.Parent is null
? world
: XForm.ToLocal( ToXForm( bone.Parent.LocalTransform ), world );
definitions.Add( new HumanoidMocap.Skeleton.BoneDefinition(
bone.Name, bone.Parent?.Name, local ) );
}
return SkeletonModel.Create( definitions );
}
/// <summary>The topmost ancestor of the hips bone - the bone vmdl ExtractMotion nodes
/// should operate on (<c>default_root_bone_name</c>). Falls back to the first root.</summary>
static string RootBoneName( SkeletonModel skeleton, MappingResult map )
{
var index = map.RoleToBone.TryGetValue( BoneRole.Hips, out var hips ) ? hips : 0;
while ( skeleton[index].ParentIndex >= 0 )
index = skeleton[index].ParentIndex;
return skeleton[index].Name;
}
}