Editor/RigControlEditor/RigWaveBuilder.cs
using Editor;
using Marionette;
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace Marionette.Tools;

/// <summary>
/// Generates a wave for a mounted Halo character.
///
/// SOLVED, NOT HAND-AUTHORED, for the same reason RigSampleBuilder is: nobody knows which local
/// axis of r_upperarm points along the bone without testing, so any Euler angles picked by hand
/// are a coin flip. Saying "put the end of the forearm HERE" needs no such knowledge.
///
/// The grunt's arm is exactly three bones - r_clavicle, r_upperarm, r_forearm - and there is no
/// hand or finger below it. That's a two-bone chain with the forearm as the effector, which is
/// precisely what TrySolveTwoBone wants, so the wave is authored by moving the end of the forearm
/// and letting the shoulder and elbow follow.
///
/// TARGETS ARE DERIVED FROM THE SKELETON, NOT TYPED IN. The raised position is measured as a
/// fraction of the arm's own reach from the shoulder, so this produces a sane wave on any of the
/// mounted characters whose arms are named this way - elite, brute, marine - not only the grunt,
/// despite them being wildly different sizes.
/// </summary>
internal static class RigWaveBuilder
{
	private const string DefaultModel = "models/halo/grunt.vmdl";

	private const string EndBone = "r_forearm";

	/// <summary>30fps. Tight enough to read as a greeting rather than a stretch - a real wave is
	/// about two beats per second, and the most common mistake is doing it half that speed.</summary>
	private const int Fps = 30;

	private const int FrameRest = 0;
	private const int FrameWindUp = 6;
	private const int FrameRaised = 16;
	private const int FrameOut1 = 24;
	private const int FrameIn1 = 32;
	private const int FrameOut2 = 40;
	private const int FrameIn2 = 48;
	private const int FrameDown = 62;
	private const int FrameCount = 70;

	[ConCmd( "rig_build_wave" )]
	public static void Build( string modelPath = DefaultModel )
	{
		var path = string.IsNullOrWhiteSpace( modelPath ) ? DefaultModel : modelPath;
		var model = Model.Load( path );

		if ( model?.Bones is null )
		{
			Log.Error( $"[wave] couldn't load {path}" );
			return;
		}

		var scene = Scene.CreateEditorScene();

		try
		{
			using var scope = scene.Push();

			var renderer = new GameObject( true, "wave" ).GetOrAddComponent<SkinnedModelRenderer>( false );
			renderer.Model = model;
			renderer.UseAnimGraph = false;
			renderer.Enabled = true;

			// Bone writes only land on a tick - see RigViewport.ApplyWorldTransform. Reading the
			// rest pose before this returns nothing useful.
			//
			// ONE TICK IS NOT ALWAYS ENOUGH. RigSampleBuilder gets away with a single tick on the
			// first-person arms, and the same single tick returns nothing for the mounted Halo
			// models - the bone array isn't populated until the renderer has been through setup,
			// and how many ticks that takes is not ours to know. So it ticks until the bones read
			// back, rather than ticking a fixed number of times and hoping.
			scene.EditorTick( 0f, 1f / 60f );

			if ( model.Bones.GetBone( EndBone ) is not { } end )
			{
				Log.Error( $"[wave] no {EndBone} on this model - bones are: " +
					string.Join( ", ", model.Bones.AllBones.Select( b => b.Name ) ) );
				return;
			}

			if ( end.Parent is not { } mid || mid.Parent is not { } root )
			{
				Log.Error( $"[wave] {EndBone} isn't three bones deep - can't solve a chain" );
				return;
			}

			if ( !WaitForBones( scene, renderer, end, mid, root, out var endRest, out var midRest, out var rootRest ) )
			{
				Log.Error( $"[wave] couldn't read the rest pose of {root.Name} / {mid.Name} / {end.Name} " +
					$"after {MaxSetupTicks} ticks. The model loaded and the bones exist, so this is the " +
					$"renderer not having built its bone array - not a naming problem." );
				return;
			}

			var shoulder = rootRest.Position;
			var reach = (midRest.Position - rootRest.Position).Length
				+ (endRest.Position - midRest.Position).Length;

			if ( reach <= 0.01f )
			{
				Log.Error( "[wave] arm has no length" );
				return;
			}

			// Source convention: +x forward, +y left, +z up. This is the RIGHT arm, so "out" is -y.
			//
			// 0.92 of full reach, never 1.0 - at full extension the elbow's bend plane is undefined
			// and it snaps to an arbitrary side. The solver clamps this itself, but asking for a
			// pose it has to rescue is a good way to get one you didn't design.
			Vector3 Raised( float outward, float lift ) =>
				shoulder + new Vector3( 0.30f, outward, lift ).Normal * (reach * 0.92f);

			// The two ends of the wave. Same height, different splay - a wave is a rotation at the
			// elbow, so the hand swings sideways rather than up and down.
			var waveOut = Raised( -0.62f, 0.72f );
			var waveIn = Raised( -0.14f, 0.86f );

			// Anticipation: the arm dips before it lifts. Small - this is a greeting, not a throw.
			var windUp = endRest.Position + new Vector3( -0.04f, -0.04f, -0.10f ) * reach;

			var poses = new (int Frame, Vector3 Target, string Label)[]
			{
				(FrameRest, endRest.Position, "rest - arm at the side"),
				(FrameWindUp, windUp, "anticipation - dip before the lift"),
				(FrameRaised, waveOut, "raised - arm up and out"),
				(FrameOut1, waveIn, "wave in"),
				(FrameIn1, waveOut, "wave out"),
				(FrameOut2, waveIn, "wave in"),
				(FrameIn2, waveOut, "wave out"),
				(FrameDown, endRest.Position, "back to rest")
			};

			var doc = new RigAnimDocument
			{
				SourceModel = model,
				AnimationSpeed = Fps,
				FrameCount = FrameCount
			};

			foreach ( var (frame, target, label) in poses )
			{
				// The pole spans the bend plane with the chain direction. Forward, not up or down:
				// the arm is raised, so the chain direction is close to vertical, and a pole that's
				// also vertical makes the cross product collapse and the elbow pick a side at
				// random. Forward stays perpendicular through the whole motion.
				if ( !RigConstraintSolver.TrySolveTwoBone( renderer, end, target, Vector3.Forward, out var chain ) )
				{
					Log.Warning( $"[wave] frame {frame} ({label}): solve failed" );
					continue;
				}

				// The chain's own solved transforms are the parents for the bones below them.
				// Reading a parent back off the renderer gives its REST pose, not its solved one -
				// which shows up as a bone's parent-space position drifting between keyframes.
				// Bones don't change length; if position moves, the parent was wrong.
				var solved = chain.ToDictionary( c => c.Bone.Name, c => c.World );

				foreach ( var (bone, world) in chain )
				{
					var parentWorld = bone.Parent is { } parent
						? (solved.TryGetValue( parent.Name, out var solvedParent )
							? solvedParent
							: renderer.TryGetBoneTransform( parent, out var p ) ? p : renderer.WorldTransform)
						: renderer.WorldTransform;

					doc.GetOrAddTrack( bone.Name ).SetKeyframe( frame, parentWorld.ToLocal( world ) );
				}

				Log.Info( $"[wave] frame {frame,2}: {label}" );
			}

			// CreateResource takes an ABSOLUTE filename - handed a relative one it resolves against
			// the sbox install directory and throws.
			if ( Project.Current?.GetAssetsPath() is not { } assetsPath )
			{
				Log.Error( "[wave] no current project" );
				return;
			}

			var name = System.IO.Path.GetFileNameWithoutExtension( path );
			var output = $"animations/{name}_wave.riganim";
			var absolute = System.IO.Path.Combine( assetsPath, output.Replace( '/', System.IO.Path.DirectorySeparatorChar ) );

			System.IO.Directory.CreateDirectory( System.IO.Path.GetDirectoryName( absolute ) );

			var asset = AssetSystem.CreateResource( "riganim", absolute );

			if ( asset is null )
			{
				Log.Error( $"[wave] couldn't create {absolute}" );
				return;
			}

			asset.SaveToDisk( doc );

			Log.Info( $"[wave] wrote {output} - {doc.BoneTracks.Count} tracks, " +
				$"{doc.BoneTracks.Sum( t => t.Keyframes.Count )} keyframes" );
		}
		catch ( Exception e )
		{
			Log.Error( $"[wave] threw: {e}" );
		}
		finally
		{
			scene.Destroy();
		}
	}

	/// <summary>Generous - these are cheap, and the cost of being one tick short is a command that
	/// looks broken.</summary>
	private const int MaxSetupTicks = 32;

	/// <summary>
	/// Ticks the scene until all three chain bones report a transform.
	///
	/// Polling rather than a fixed count because the number of ticks a renderer needs before its
	/// bone array exists is an engine detail, and one that clearly differs per model - the same
	/// single tick that works for the first-person arms returns nothing for the mounted Halo
	/// characters.
	/// </summary>
	private static bool WaitForBones( Scene scene, SkinnedModelRenderer renderer,
		BoneCollection.Bone end, BoneCollection.Bone mid, BoneCollection.Bone root,
		out Transform endRest, out Transform midRest, out Transform rootRest )
	{
		endRest = midRest = rootRest = default;

		for ( var tick = 0; tick < MaxSetupTicks; tick++ )
		{
			if ( renderer.TryGetBoneTransform( end, out endRest )
				&& renderer.TryGetBoneTransform( mid, out midRest )
				&& renderer.TryGetBoneTransform( root, out rootRest ) )
			{
				// A bone that reports an identity transform hasn't been posed yet, it's just been
				// allocated - and every bone of a real skeleton sits somewhere other than the
				// origin. Waiting for a non-degenerate chain avoids solving against zeros.
				if ( (midRest.Position - rootRest.Position).Length > 0.001f
					&& (endRest.Position - midRest.Position).Length > 0.001f )
				{
					if ( tick > 0 )
						Log.Info( $"[wave] bones became readable after {tick + 1} ticks" );

					return true;
				}
			}

			scene.EditorTick( (tick + 1) / 60f, 1f / 60f );
		}

		return false;
	}

	[Menu( "Editor", "Marionette/Build Grunt Wave", "waving_hand" )]
	public static void BuildDefault() => Build( DefaultModel );
}