Editor/Services/HostSkeletonBuilder.cs

Editor service that builds a HostSkeleton for weapon animation editing. It loads an arm profile model, composes and normalizes bind transforms, merges weapon rig bones into a host skeleton, caches results, validates bind parity, and exposes utility functions for transforms and topology ordering.

File AccessNetworking
#nullable enable annotations

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Sandbox;

namespace SboxWeaponAnimator.Editor;

public sealed class HostBone
{
	public int Index { get; set; }
	public string Name { get; set; } = "";
	public string ParentName { get; set; } = "";
	public Transform BindModelTransform { get; set; } = Transform.Zero;
	public Transform BindLocalTransform { get; set; } = Transform.Zero;
	public bool HasExplicitBindLocal { get; set; }
	public bool IsWeaponBone { get; set; }
	public string SourceBoneId { get; set; } = "";
	public int ArmSide { get; set; }
}

public sealed class HostSkeleton
{
	public List<HostBone> Bones { get; } = [];
	public Dictionary<string, HostBone> ByName { get; } = new( StringComparer.OrdinalIgnoreCase );
	public Dictionary<string, List<HostBone>> ChildrenByParent { get; } =
		new( StringComparer.OrdinalIgnoreCase );

	public void Add( HostBone bone )
	{
		if ( ByName.ContainsKey( bone.Name ) )
			return;

		bone.ArmSide = DirectArmSide( bone.Name );
		if ( bone.ArmSide == 0
			&& ByName.TryGetValue( bone.ParentName, out var parent ) )
			bone.ArmSide = parent.ArmSide;
		bone.Index = Bones.Count;
		Bones.Add( bone );
		ByName.Add( bone.Name, bone );
		if ( !ChildrenByParent.TryGetValue( bone.ParentName, out var children ) )
		{
			children = [];
			ChildrenByParent[bone.ParentName] = children;
		}
		children.Add( bone );
	}

	public IReadOnlyList<HostBone> ChildrenOf( string parentName ) =>
		ChildrenByParent.GetValueOrDefault( parentName ) ?? [];

	public Transform GetBindLocal( HostBone bone )
	{
		if ( bone.HasExplicitBindLocal )
			return bone.BindLocalTransform;

		if ( string.IsNullOrWhiteSpace( bone.ParentName )
			|| !ByName.TryGetValue( bone.ParentName, out var parent ) )
			return bone.BindModelTransform;

		return parent.BindModelTransform.ToLocal( bone.BindModelTransform );
	}

	public void RebuildModelTransformsFromLocals()
	{
		var pending = Bones.ToList();
		var rebuilt = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		while ( pending.Count > 0 )
		{
			var progressed = false;
			for ( var i = pending.Count - 1; i >= 0; i-- )
			{
				var bone = pending[i];
				if ( !string.IsNullOrWhiteSpace( bone.ParentName )
					&& ByName.ContainsKey( bone.ParentName )
					&& !rebuilt.Contains( bone.ParentName ) )
				{
					continue;
				}

				var local = GetBindLocal( bone );
				bone.BindModelTransform = string.IsNullOrWhiteSpace( bone.ParentName )
					|| !ByName.TryGetValue( bone.ParentName, out var parent )
						? local
						: ComposeLocal( parent.BindModelTransform, local );
				rebuilt.Add( bone.Name );
				pending.RemoveAt( i );
				progressed = true;
			}

			if ( progressed )
				continue;

			throw new InvalidOperationException(
				$"The animation host contains a cyclic bone hierarchy near '{pending[0].Name}'." );
		}

		ChildrenByParent.Clear();
		foreach ( var bone in Bones )
		{
			if ( !ChildrenByParent.TryGetValue( bone.ParentName, out var children ) )
			{
				children = [];
				ChildrenByParent[bone.ParentName] = children;
			}
			children.Add( bone );
		}

		foreach ( var bone in Bones )
			bone.ArmSide = ResolveArmSide( bone );
	}

	public IReadOnlyDictionary<string, Transform> BuildCompilerBindModelTransforms()
	{
		// Render-mesh import scale is already baked into physical bone positions. Preserve those
		// pivots while exposing the scale-one skeleton expected by ModelDoc and runtime skinning.
		return Bones.ToDictionary(
			bone => bone.Name,
			bone => bone.BindModelTransform.WithScale( Vector3.One ),
			StringComparer.OrdinalIgnoreCase );
	}

	public IReadOnlyDictionary<string, Transform> BuildCompilerBindLocalTransforms()
	{
		var model = BuildCompilerBindModelTransforms();
		return Bones.ToDictionary(
			bone => bone.Name,
			bone => string.IsNullOrWhiteSpace( bone.ParentName )
				|| !model.TryGetValue( bone.ParentName, out var parent )
					? model[bone.Name]
					: parent.ToLocal( model[bone.Name] ),
			StringComparer.OrdinalIgnoreCase );
	}

	private static Transform ComposeLocal( Transform parent, Transform local ) => new(
		parent.PointToWorld( local.Position ),
		parent.Rotation * local.Rotation,
		parent.Scale * local.Scale );

	private int ResolveArmSide( HostBone bone )
	{
		var current = bone;
		for ( var depth = 0; depth <= Bones.Count; depth++ )
		{
			var direct = DirectArmSide( current.Name );
			if ( direct != 0 )
				return direct;
			if ( string.IsNullOrWhiteSpace( current.ParentName )
				|| !ByName.TryGetValue( current.ParentName, out current ) )
				return 0;
			if ( current.ArmSide != 0 )
				return current.ArmSide;
		}
		return 0;
	}

	private static int DirectArmSide( string name ) =>
		name.EndsWith( "_R", StringComparison.OrdinalIgnoreCase )
			? 1
			: name.EndsWith( "_L", StringComparison.OrdinalIgnoreCase )
				? -1
				: 0;
}

public static class HostSkeletonBuilder
{
	public const string ProductionArmsModel = "models/first_person/v_first_person_arms_human.vmdl";
	public const string PreviewArmsModel = "models/first_person/first_person_arms_preview.vmdl";
	private static readonly Dictionary<(Guid DocumentId, bool Arms), CachedHostSkeleton>
		SkeletonCache = [];

	/// <summary>
	/// Shared, cached host skeleton. The returned instance is handed to every caller, so treat it
	/// as read-only — mutating it corrupts the viewport's live pose evaluation and every other
	/// holder. Call <see cref="Build"/> when you need an instance you own.
	/// </summary>
	public static HostSkeleton BuildCached(
		WeaponAnimationDocument document,
		bool includeArmProfile = true )
	{
		var key = (document.DocumentId, includeArmProfile);
		var signature = CacheSignature( document, includeArmProfile );
		if ( SkeletonCache.TryGetValue( key, out var cached )
			&& cached.Signature.Equals( signature, StringComparison.Ordinal ) )
			return cached.Skeleton;

		var skeleton = Build( document, includeArmProfile );
		if ( SkeletonCache.Count >= 8 && !SkeletonCache.ContainsKey( key ) )
			SkeletonCache.Clear();
		SkeletonCache[key] = new CachedHostSkeleton( signature, skeleton );
		return skeleton;
	}

	public static void ClearCache() => SkeletonCache.Clear();

	public static HostSkeleton Build(
		WeaponAnimationDocument document,
		bool includeArmProfile = true )
	{
		var skeleton = new HostSkeleton();
		var arms = includeArmProfile ? LoadArmProfile() : null;

		if ( arms is not null && !arms.IsError )
		{
			foreach ( var bone in arms.Bones.AllBones )
			{
				var local = bone.Parent is null
					? bone.LocalTransform
					: bone.Parent.LocalTransform.ToLocal( bone.LocalTransform );
				skeleton.Add( new HostBone
				{
					Name = bone.Name,
					ParentName = bone.Parent?.Name ?? "",
					BindModelTransform = bone.LocalTransform,
					BindLocalTransform = local,
					HasExplicitBindLocal = true
				} );
			}
		}

		EnsureCoreBone( skeleton, "root", "", Transform.Zero );
		var weaponPlacement = WeaponAnimationMath.Compose(
			document.Calibration.PhysicalTransform,
			document.Calibration.FramingTransform );
		EnsureCoreBone( skeleton, "camera", "root", new Transform( Vector3.Zero ) );
		var sourceRoot = document.Rig.FindBone( document.Rig.SourceSkeletonRootId )
			?? document.Rig.Bones.FirstOrDefault( bone =>
				string.IsNullOrWhiteSpace( bone.ParentId ) );
		var rootModel = sourceRoot is null
			? weaponPlacement
			: ApplyPlacement( weaponPlacement, sourceRoot.BindModelTransform );
		SetCoreBone(
			skeleton,
			"weapon_root",
			"root",
			rootModel,
			true,
			sourceRoot?.Id ?? "" );
		EnsureCoreBone(
			skeleton,
			"ik_hand_R",
			"weapon_root",
			document.Binding.PrimaryHand.Transform );
		EnsureCoreBone(
			skeleton,
			"ik_hand_L",
			"weapon_root",
			document.Binding.SupportHand.Transform );

		foreach ( var definition in TopologicalWeaponBones( document.Rig.Bones ) )
		{
			if ( !WeaponRigHierarchy.IsRetained( definition ) )
				continue;

			// The source root is normalized to the host's canonical weapon root.
			if ( sourceRoot is not null
				&& definition.Id.Equals( sourceRoot.Id, StringComparison.OrdinalIgnoreCase ) )
				continue;

			if ( skeleton.ByName.ContainsKey( definition.Name ) )
				continue;

			var parentName = definition.ParentName;
			if ( sourceRoot is not null
				&& definition.ParentId.Equals( sourceRoot.Id, StringComparison.OrdinalIgnoreCase ) )
				parentName = "weapon_root";
			if ( string.IsNullOrWhiteSpace( parentName )
				|| !skeleton.ByName.ContainsKey( parentName ) )
				parentName = "weapon_root";

			var parentModel = skeleton.ByName[parentName].BindModelTransform;
			var localTransform = definition.BindLocalTransform;
			if ( parentName == "weapon_root"
				&& sourceRoot is not null
				&& !definition.ParentId.Equals( sourceRoot.Id, StringComparison.OrdinalIgnoreCase ) )
			{
				localTransform = sourceRoot.BindModelTransform.ToLocal(
					definition.BindModelTransform );
			}
			var modelTransform = ComposeLocal( parentModel, localTransform );
			skeleton.Add( new HostBone
			{
				Name = definition.Name,
				ParentName = parentName,
				BindModelTransform = modelTransform,
				BindLocalTransform = localTransform,
				HasExplicitBindLocal = true,
				IsWeaponBone = true,
				SourceBoneId = definition.Id
			} );
		}

		// Replacing Facepunch's weapon_root changes every canonical helper below it. Their local
		// bind transforms remain authoritative, so rebuild model-space values before validation,
		// pose evaluation, and export inspect the combined hierarchy.
		skeleton.RebuildModelTransformsFromLocals();
		return skeleton;
	}

	public static IReadOnlyList<BindParityIssue> ValidateBindParity(
		WeaponAnimationDocument document,
		float tolerance = 0.001f,
		bool includeArmProfile = true )
	{
		var issues = new List<BindParityIssue>();
		var skeleton = Build( document, includeArmProfile );
		var placement = WeaponAnimationMath.Compose(
			document.Calibration.PhysicalTransform,
			document.Calibration.FramingTransform );

		foreach ( var definition in document.Rig.RetainedBones() )
		{
			var hostName = definition.Id.Equals(
				document.Rig.SourceSkeletonRootId,
				StringComparison.OrdinalIgnoreCase )
				? "weapon_root"
				: definition.Name;
			if ( !skeleton.ByName.TryGetValue( hostName, out var host ) )
			{
				issues.Add( new BindParityIssue(
					definition.Name,
					float.PositiveInfinity,
					float.PositiveInfinity,
					float.PositiveInfinity ) );
				continue;
			}

			var expected = ApplyPlacement( placement, definition.BindModelTransform );
			var positionDelta = expected.Position.Distance( host.BindModelTransform.Position );
			var rotationDelta = MathF.Max(
				(expected.Rotation.Forward - host.BindModelTransform.Rotation.Forward).Length,
				(expected.Rotation.Up - host.BindModelTransform.Rotation.Up).Length );
			var scaleDelta = (expected.Scale - host.BindModelTransform.Scale).Length;
			if ( positionDelta > tolerance || rotationDelta > tolerance || scaleDelta > tolerance )
			{
				issues.Add( new BindParityIssue(
					definition.Name,
					positionDelta,
					rotationDelta,
					scaleDelta ) );
			}
		}

		return issues;
	}

	public static IReadOnlyList<string> FindArmBoneCollisions(
		WeaponAnimationDocument document )
	{
		var arms = LoadArmProfile();
		if ( arms is null || arms.IsError )
			return [];

		var armNames = arms.Bones.AllBones
			.Select( x => x.Name )
			.ToHashSet( StringComparer.OrdinalIgnoreCase );
		return document.Rig.RetainedBones()
			.Where( x => !x.Id.Equals(
					document.Rig.SourceSkeletonRootId,
					StringComparison.OrdinalIgnoreCase )
				&& armNames.Contains( x.Name ) )
			.Select( x => x.Name )
			.Distinct( StringComparer.OrdinalIgnoreCase )
			.OrderBy( x => x, StringComparer.OrdinalIgnoreCase )
			.ToList();
	}

	/// <summary>
	/// Which arm profile <see cref="LoadArmProfile"/> would resolve to right now, or "" when none
	/// is loadable. Used to keep the skeleton cache honest across load-order changes.
	/// </summary>
	private static string ResolvedArmProfileName()
	{
		try
		{
			var profile = LoadArmProfile();
			return profile is null ? "" : profile.Name ?? ProductionArmsModel;
		}
		catch
		{
			return "";
		}
	}

	public static Model? LoadArmProfile()
	{
		var production = Model.Load( ProductionArmsModel );
		if ( production is not null && !production.IsError )
			return production;

		var preview = Model.Load( PreviewArmsModel );
		return preview is not null && !preview.IsError ? preview : null;
	}

	private static string CacheSignature(
		WeaponAnimationDocument document,
		bool includeArmProfile )
	{
		var builder = new StringBuilder( 256 + document.Rig.Bones.Count * 128 );
		builder.Append( includeArmProfile ).Append( '|' )
			// The arm profile degrades production -> preview -> no arms at all. Without it in the
			// signature, a skeleton built before the arms model was loadable stays cached, and the
			// viewport silently keeps an armless rig until some document field happens to change.
			.Append( includeArmProfile ? ResolvedArmProfileName() : "" ).Append( '|' )
			.Append( document.Rig.SourceSkeletonRootId ).Append( '|' );
		AppendTransform( builder, document.Calibration.PhysicalTransform );
		AppendTransform( builder, document.Calibration.FramingTransform );
		AppendTransform( builder, document.Binding.PrimaryHand.Transform );
		AppendTransform( builder, document.Binding.SupportHand.Transform );
		foreach ( var bone in document.Rig.Bones )
		{
			builder.Append( '\n' )
				.Append( bone.Id ).Append( '|' )
				.Append( bone.Name ).Append( '|' )
				.Append( bone.ParentId ).Append( '|' )
				.Append( bone.ParentName ).Append( '|' )
				.Append( bone.Classification ).Append( '|' )
				.Append( bone.Inclusion ).Append( '|' );
			AppendTransform( builder, bone.BindModelTransform );
			AppendTransform( builder, bone.BindLocalTransform );
		}
		return builder.ToString();
	}

	private static void AppendTransform( StringBuilder builder, Transform transform )
	{
		AppendFloat( builder, transform.Position.x );
		AppendFloat( builder, transform.Position.y );
		AppendFloat( builder, transform.Position.z );
		AppendFloat( builder, transform.Rotation.x );
		AppendFloat( builder, transform.Rotation.y );
		AppendFloat( builder, transform.Rotation.z );
		AppendFloat( builder, transform.Rotation.w );
		AppendFloat( builder, transform.Scale.x );
		AppendFloat( builder, transform.Scale.y );
		AppendFloat( builder, transform.Scale.z );
	}

	private static void AppendFloat( StringBuilder builder, float value ) =>
		builder.Append( BitConverter.SingleToInt32Bits( value ) ).Append( ',' );

	private static Transform ApplyPlacement( Transform placement, Transform sourceModel )
	{
		return new Transform(
			placement.PointToWorld( sourceModel.Position ),
			placement.Rotation * sourceModel.Rotation,
			placement.Scale * sourceModel.Scale );
	}

	private static void EnsureCoreBone(
		HostSkeleton skeleton,
		string name,
		string parent,
		Transform transform )
	{
		if ( skeleton.ByName.ContainsKey( name ) )
			return;

		skeleton.Add( new HostBone
		{
			Name = name,
			ParentName = parent,
			BindModelTransform = transform,
			BindLocalTransform = string.IsNullOrWhiteSpace( parent )
				? transform
				: skeleton.ByName.TryGetValue( parent, out var parentBone )
					? parentBone.BindModelTransform.ToLocal( transform )
					: transform,
			HasExplicitBindLocal = true
		} );
	}

	private static void SetCoreBone(
		HostSkeleton skeleton,
		string name,
		string parent,
		Transform transform,
		bool weaponBone = false,
		string sourceBoneId = "" )
	{
		var local = string.IsNullOrWhiteSpace( parent )
			? transform
			: skeleton.ByName.TryGetValue( parent, out var parentBone )
				? parentBone.BindModelTransform.ToLocal( transform )
				: transform;
		if ( skeleton.ByName.TryGetValue( name, out var existing ) )
		{
			existing.ParentName = parent;
			existing.BindModelTransform = transform;
			existing.BindLocalTransform = local;
			existing.HasExplicitBindLocal = true;
			existing.IsWeaponBone = weaponBone;
			existing.SourceBoneId = sourceBoneId;
			return;
		}

		skeleton.Add( new HostBone
		{
			Name = name,
			ParentName = parent,
			BindModelTransform = transform,
			BindLocalTransform = local,
			HasExplicitBindLocal = true,
			IsWeaponBone = weaponBone,
			SourceBoneId = sourceBoneId
		} );
	}

	private static Transform ComposeLocal( Transform parent, Transform local ) => new(
		parent.PointToWorld( local.Position ),
		parent.Rotation * local.Rotation,
		parent.Scale * local.Scale );

	private static IEnumerable<WeaponBoneDefinition> TopologicalWeaponBones(
		IEnumerable<WeaponBoneDefinition> definitions )
	{
		var pending = definitions.ToList();
		var emitted = new HashSet<string>( StringComparer.OrdinalIgnoreCase );

		while ( pending.Count > 0 )
		{
			var progressed = false;
			for ( var i = pending.Count - 1; i >= 0; i-- )
			{
				var bone = pending[i];
				if ( !string.IsNullOrWhiteSpace( bone.ParentName )
					&& pending.Any( x => x.Name.Equals( bone.ParentName, StringComparison.OrdinalIgnoreCase ) )
					&& !emitted.Contains( bone.ParentName ) )
					continue;

				yield return bone;
				emitted.Add( bone.Name );
				pending.RemoveAt( i );
				progressed = true;
			}

			if ( progressed )
				continue;

			foreach ( var bone in pending )
				yield return bone;
			yield break;
		}
	}

	private sealed record CachedHostSkeleton( string Signature, HostSkeleton Skeleton );
}

public sealed record BindParityIssue(
	string BoneName,
	float PositionDelta,
	float RotationDelta,
	float ScaleDelta );