Editor/EffigyEditor/EffigySkeletonProbe.cs
using Effigy;
using Sandbox;
using System;
using System.Linq;

using Skeleton = Effigy.Skeleton;

namespace Marionette.EditorTools;

/// <summary>
/// Import a compiled model's skeleton and report whether it survived - from the console, because
/// the answer needs a running engine and the kernel test suite deliberately has none.
///
///     effigy_skeleton_probe                                     citizen
///     effigy_skeleton_probe models/citizen/citizen.vmdl         any model
///     effigy_skeleton_probe models/citizen/citizen.vmdl names   plus every bone name
///
/// WHAT IT IS ACTUALLY ASKING. Reading names and parents out of a model is easy and proves
/// nothing; the question that decides whether a playermodel can be built on top of an imported
/// skeleton is whether the BIND POSE is still the same pose afterwards. So this rebuilds each
/// bone's world transform by walking Effigy's parent chain and compares it against the engine's
/// own <c>GetBoneTransform</c>. A non-zero worst error means the import rotated or moved a bone,
/// and every animation authored against that skeleton would be wrong by exactly that much.
/// </summary>
public static class EffigySkeletonProbe
{
	const string Citizen = "models/citizen/citizen.vmdl";

	[ConCmd( "effigy_skeleton_probe" )]
	public static void Probe( string path = Citizen, string option = "" )
	{
		if ( string.IsNullOrWhiteSpace( path ) )
			path = Citizen;

		var model = Model.Load( path );

		if ( model is null || model.IsError )
		{
			Log.Error( $"[skel] could not load {path}" );
			return;
		}

		var result = EffigySkeletonImport.FromModel( model );
		var skeleton = result.Skeleton;

		Log.Info( $"[skel] {path}" );
		// Attachments used to be counted here. Model.AttachmentCount is obsolete and this line is
		// diagnostic only, so it is dropped rather than guessed at - nothing downstream reads it.
		Log.Info( $"[skel] engine bones {model.BoneCount}, imported {skeleton.Count}, "
			+ $"roots {result.Roots}, "
			+ $"anim graph {(model.AnimGraph is null ? "none" : "yes")}" );

		Log.Info( $"[skel] topological order {(TopologicallyOrdered( skeleton ) ? "held" : "BROKEN")}" );

		ReportBindPose( model, skeleton );

		foreach ( var note in result.Notes )
			Log.Info( $"[skel] note: {note}" );

		// A DUMP RATHER THAN 95 LOG LINES. Printing a whole skeleton to the console is both hard
		// to read back and enough output to make the editor stall; a file can be diffed, parsed
		// and compared against another skeleton offline, which is what mapping one rig onto
		// another actually needs.
		if ( option.Contains( "dump", StringComparison.OrdinalIgnoreCase ) )
		{
			var to = System.IO.Path.Combine( Environment.GetFolderPath(
				Environment.SpecialFolder.LocalApplicationData ), "skeleton-dump.txt" );
			var text = new System.Text.StringBuilder();

			text.AppendLine( $"# {path}  bones={skeleton.Count} roots={result.Roots}" );
			text.AppendLine( "# index	name	parent	model-space x,y,z	length" );

			for ( var i = 0; i < skeleton.Count; i++ )
			{
				var bone = skeleton.Bones[i];
				var parent = bone.Parent < 0 ? "-" : skeleton.Bones[bone.Parent].Name;
				var w = skeleton.WorldBind( i ).Origin;

				text.AppendLine( $"{i}	{bone.Name}	{parent}	{w.x:0.####},{w.y:0.####},{w.z:0.####}	{bone.Length:0.####}" );
			}

			System.IO.File.WriteAllText( to, text.ToString() );
			Log.Info( $"[skel] wrote {to}" );
		}

		if ( option.Contains( "names", StringComparison.OrdinalIgnoreCase ) )
		{
			for ( var i = 0; i < skeleton.Count; i++ )
			{
				var bone = skeleton.Bones[i];
				var parent = bone.Parent < 0 ? "-" : skeleton.Bones[bone.Parent].Name;

				Log.Info( $"[skel] {i,3}  {bone.Name,-24} parent {parent,-24} len {bone.Length:0.##}" );
			}
		}

		// THE ORIENTATION DUMP. The plain dump above writes model-space positions only, which is
		// exactly the blind spot that let the bind-pose roll be wrong for years - positions are
		// roll-independent, so a skeleton whose orientations are all wrong reads back perfect.
		// This writes each bone's LOCAL basis (the three axes plus origin, in the parent's frame)
		// as a ready-to-paste `Add(...)` row, so the CitizenSkeleton table can be regenerated with
		// the roll the engine actually reports rather than whatever the old converter produced.
		if ( option.Contains( "local", StringComparison.OrdinalIgnoreCase ) )
		{
			var to = System.IO.Path.Combine( Environment.GetFolderPath(
				Environment.SpecialFolder.LocalApplicationData ), "skeleton-local.txt" );
			var text = new System.Text.StringBuilder();

			text.AppendLine( $"# {path}  bones={skeleton.Count} roots={result.Roots}" );

			for ( var i = 0; i < skeleton.Count; i++ )
			{
				var bone = skeleton.Bones[i];
				var l = bone.Local;
				text.AppendLine( $"Add( s, \"{bone.Name}\", {bone.Parent}, "
					+ $"{l.X.x:0.000000}f, {l.X.y:0.000000}f, {l.X.z:0.000000}f, "
					+ $"{l.Y.x:0.000000}f, {l.Y.y:0.000000}f, {l.Y.z:0.000000}f, "
					+ $"{l.Z.x:0.000000}f, {l.Z.y:0.000000}f, {l.Z.z:0.000000}f, "
					+ $"{l.Origin.x:0.000000}f, {l.Origin.y:0.000000}f, {l.Origin.z:0.000000}f );" );
			}

			System.IO.File.WriteAllText( to, text.ToString() );
			Log.Info( $"[skel] wrote {to}" );
		}
	}

	/// <summary>The invariant Skeleton.AddBone exists to enforce - a parent always sits at a lower
	/// index. Checked rather than assumed, since the import chose the order itself.</summary>
	static bool TopologicallyOrdered( Skeleton skeleton )
	{
		for ( var i = 0; i < skeleton.Count; i++ )
		{
			if ( skeleton.Bones[i].Parent >= i )
				return false;
		}

		return true;
	}

	/// <summary>
	/// Worst disagreement between Effigy's rebuilt world bind and the engine's, over every bone,
	/// in inches and in degrees. Position and orientation are reported separately because they
	/// fail for different reasons: a position drift is a units or parenting mistake, an
	/// orientation drift is the axis convention.
	/// </summary>
	static void ReportBindPose( Model model, Skeleton skeleton )
	{
		var worstPos = 0f;
		var worstAng = 0f;
		var worstPosBone = "-";
		var worstAngBone = "-";

		for ( var i = 0; i < skeleton.Count; i++ )
		{
			var name = skeleton.Bones[i].Name;
			var engine = model.GetBoneTransform( name );
			var mine = skeleton.WorldBind( i );

			var dp = Vector3.DistanceBetween( engine.Position, EffigySkeletonImport.ToVector3( mine.Origin ) );

			if ( dp > worstPos )
			{
				worstPos = dp;
				worstPosBone = name;
			}

			// Compare the three axes rather than the quaternions, which have a sign ambiguity and
			// would report 180 degrees for two identical rotations.
			var da = MathF.Max(
				Angle( engine.Rotation.Forward, mine.X ),
				MathF.Max( Angle( engine.Rotation.Left, mine.Y ), Angle( engine.Rotation.Up, mine.Z ) ) );

			if ( da > worstAng )
			{
				worstAng = da;
				worstAngBone = name;
			}
		}

		Log.Info( $"[skel] worst bind error: {worstPos:0.####} in at '{worstPosBone}', "
			+ $"{worstAng:0.####} deg at '{worstAngBone}'" );

		// THE RIVAL HYPOTHESIS. If BoneCollection's LocalTransform is not parent-relative after
		// all but already model-space, then walking the parent chain compounds it and the error
		// above is meaningless. Measuring both tells the two apart in one run instead of two.
		var worstFlat = 0f;
		var flatBone = "-";

		for ( var i = 0; i < skeleton.Count; i++ )
		{
			var name = skeleton.Bones[i].Name;
			var d = Vector3.DistanceBetween( model.GetBoneTransform( name ).Position,
				EffigySkeletonImport.ToVector3( skeleton.Bones[i].Local.Origin ) );

			if ( d > worstFlat )
			{
				worstFlat = d;
				flatBone = name;
			}
		}

		Log.Info( $"[skel] if local were already model-space: worst {worstFlat:0.####} in at '{flatBone}'" );

		for ( var i = 0; i < Math.Min( 6, skeleton.Count ); i++ )
		{
			var name = skeleton.Bones[i].Name;

			Log.Info( $"[skel]   {name,-20} engine {model.GetBoneTransform( name ).Position} "
				+ $"| local {EffigySkeletonImport.ToVector3( skeleton.Bones[i].Local.Origin )} "
				+ $"| walked {EffigySkeletonImport.ToVector3( skeleton.WorldBind( i ).Origin )}" );
		}
	}

	static float Angle( Vector3 engine, Vec3 mine )
	{
		var a = engine.Normal;
		var b = EffigySkeletonImport.ToVector3( mine ).Normal;

		return MathF.Acos( Math.Clamp( Vector3.Dot( a, b ), -1f, 1f ) ).RadianToDegree();
	}
}