Code/RigControl/RigAnimDocument.cs
using Sandbox;
using Sandbox.Utility;
using System.Collections.Generic;
using System.Linq;

namespace Marionette;

// A keyframed clip authored against a RigDocument: a diamond-per-bone timeline of FK poses,
// plus AnimEvents (prop attach/detach) and morph triggers layered over the same frame range.
// Replaces the older RigEventsAsset, which only carried events and had nothing to actually pose
// or play back - this is the real shape of the tool (see RigDocument for the rig/constraint half).
[AssetType( Name = "Rig Animation", Extension = "riganim", Category = "Marionette" )]
public sealed class RigAnimDocument : GameResource
{
	[Property, Group( "Source" )] public Model SourceModel { get; set; }
	[Property, Group( "Source" ), Title( "Rig Asset Path" )] public RigDocument RigAsset { get; set; }
	[Property, Group( "Source" ), Title( "Animation Speed" )] public int AnimationSpeed { get; set; } = 30;
	/// <summary>How many frames long the clip is.
	///
	/// 900 - thirty seconds at the default 30fps. The old default of 30 gave a clip exactly ONE
	/// SECOND long, which read as the timeline being broken rather than as the clip being short.
	///
	/// The editor's timeline never goes below thirty seconds regardless of this value, so an
	/// existing clip saved at the old default still gets room to work in; see
	/// RigTimeline.MinimumTimelineFrames. This number is what the clip itself claims to be.</summary>
	[Property, Group( "Source" ), Title( "Frame Count" )] public int FrameCount { get; set; } = 900;

	/// <summary>
	/// Static models shown in the viewport purely to pose against - the switch a hand reaches for,
	/// the weapon it grips, the surface it rests on.
	///
	/// They are NOT part of the animation: nothing is keyframed, nothing plays back. They exist so
	/// a grip can be aimed at something real rather than at empty space, which is otherwise
	/// guesswork you only discover was wrong once it's in game. That's also why they need no
	/// bones - a reference is something you animate AGAINST, not something you animate.
	///
	/// Distinct from AnimEvents, which attach a prop TO a bone for a frame range and are part of
	/// the clip. A reference prop stays where the world puts it; an event prop follows the hand.
	/// </summary>
	[Property, Group( "Reference" )] public List<ReferenceProp> ReferenceProps { get; set; } = new();

	[Property] public List<BoneTrack> BoneTracks { get; set; } = new();
	[Property] public List<RigEvent> Events { get; set; } = new();
	[Property] public List<MorphEvent> MorphEvents { get; set; } = new();

	public BoneTrack FindTrack( string bone ) => BoneTracks.FirstOrDefault( t => t.BoneName == bone );

	public BoneTrack GetOrAddTrack( string bone )
	{
		var track = FindTrack( bone );

		if ( track is null )
		{
			track = new BoneTrack { BoneName = bone };
			BoneTracks.Add( track );
		}

		return track;
	}
}

/// <summary>One static model in the viewport to pose against. See RigAnimDocument.ReferenceProps.</summary>
public sealed class ReferenceProp
{
	[Property] public string Name { get; set; } = "reference";

	[Property] public Model Model { get; set; }

	/// <summary>
	/// Further models carried by this same prop, sharing its transform.
	///
	/// A "prop" is usually more than one model - a light switch is a plate and a toggle, a door is
	/// a frame and a leaf. Held as separate props they had separate transforms, so lining them up
	/// meant placing each one and then moving both every time you changed your mind, keeping two
	/// sets of numbers in agreement by hand.
	///
	/// Extra rather than replacing Model, because Model is what every existing .riganim on disk
	/// already stores. Renaming it would silently drop the prop out of clips that already work.
	/// </summary>
	[Property, Title( "Extra Models" )] public List<Model> ExtraModels { get; set; } = new();

	/// <summary>Every model this prop draws - the primary and the extras, skipping empty slots.
	/// One place to ask, so nothing has to remember that Model is special.</summary>
	public IEnumerable<Model> AllModels
	{
		get
		{
			if ( Model is not null )
				yield return Model;

			if ( ExtraModels is null )
				yield break;

			foreach ( var extra in ExtraModels )
			{
				if ( extra is not null )
					yield return extra;
			}
		}
	}

	/// <summary>Hidden rather than deleted, so a prop can be got out of the way for a moment
	/// without losing the placement you spent time getting right.</summary>
	[Property] public bool Visible { get; set; } = true;

	[Property, Group( "Transform" )] public Vector3 Position { get; set; }
	[Property, Group( "Transform" )] public Angles Rotation { get; set; }
	[Property, Group( "Transform" )] public float Scale { get; set; } = 1f;

	/// <summary>Optional. Named bone this prop follows, for a reference that should move with the
	/// rig rather than sit still in the world - a weapon already in the hand, say. Empty means it
	/// stays where Position puts it.</summary>
	[Property, Group( "Transform" ), Title( "Follow Bone" )] public string FollowBone { get; set; } = "";

	/// <summary>Position/Rotation/Scale as one transform, for the viewport. Hidden from the
	/// property sheet - it's derived from the three fields directly above it, so showing it gave
	/// every prop a second, read-only copy of its own transform sitting under the real one.</summary>
	[Hide]
	public Transform LocalTransform => new( Position, Rotation.ToRotation(), Scale );
}

public sealed class BoneTrack
{
	[Property] public string BoneName { get; set; } = "";
	[Property] public List<BoneKeyframe> Keyframes { get; set; } = new();

	/// <summary>The bone's local (parent-space) transform at a given frame, interpolated between
	/// the keyframes either side of it. Constant before the first key and after the last.
	///
	/// The easing between two keys is set by the FIRST of them - a key governs the segment leaving
	/// it. See KeyInterpolation for why the default isn't linear.</summary>
	public Transform Evaluate( float frame )
	{
		if ( Keyframes.Count == 0 )
			return Transform.Zero;

		EnsureSorted();

		if ( frame <= Keyframes[0].Frame )
			return Keyframes[0].Local;

		if ( frame >= Keyframes[^1].Frame )
			return Keyframes[^1].Local;

		for ( var i = 0; i < Keyframes.Count - 1; i++ )
		{
			var a = Keyframes[i];
			var b = Keyframes[i + 1];

			if ( frame < a.Frame || frame > b.Frame )
				continue;

			var span = b.Frame - a.Frame;
			var t = span > 0 ? (frame - a.Frame) / span : 0f;

			return Transform.Lerp( a.Local, b.Local, a.Ease( t ), true );
		}

		return Keyframes[0].Local;
	}

	/// <summary>Evaluate walks the list in order, so it has to be in order. This used to be an
	/// OrderBy().ToList() inside Evaluate itself - a sort and an allocation per bone per frame,
	/// which at ~95 bones and 60fps is thousands of throwaway lists a second. Keyframes are
	/// almost always already sorted (they're inserted in place), so this scans first and only
	/// sorts - in place, no allocation - when something actually moved.</summary>
	private void EnsureSorted()
	{
		for ( var i = 1; i < Keyframes.Count; i++ )
		{
			if ( Keyframes[i - 1].Frame <= Keyframes[i].Frame )
				continue;

			Keyframes.Sort( ( x, y ) => x.Frame.CompareTo( y.Frame ) );
			return;
		}
	}

	public void SetKeyframe( int frame, Transform local )
	{
		var existing = Keyframes.FirstOrDefault( k => k.Frame == frame );

		if ( existing is not null )
		{
			existing.Local = local;
			return;
		}

		// Inserted in place rather than appended, so the list stays sorted and EnsureSorted's
		// scan stays the fast path.
		var index = Keyframes.FindIndex( k => k.Frame > frame );

		if ( index < 0 )
			Keyframes.Add( new BoneKeyframe { Frame = frame, Local = local } );
		else
			Keyframes.Insert( index, new BoneKeyframe { Frame = frame, Local = local } );
	}
}

/// <summary>
/// How a keyframe eases into the next one.
///
/// Smooth is the default, deliberately. Straight linear interpolation moves a bone at a constant
/// speed and then stops dead - no acceleration, no settle - which reads as robotic no matter how
/// good the poses either side of it are. It's the most common reason hand-keyed animation looks
/// amateur, and it isn't fixable by posing harder.
/// </summary>
/// <summary>
/// How a keyframe's outgoing segment is timed.
///
/// NEW VALUES ARE APPENDED, NEVER INSERTED. These serialize into .riganim by ordinal, so putting
/// EaseIn between Smooth and Linear would silently turn every existing Linear key into an EaseIn
/// one - a corruption with no error and no obvious symptom beyond "my clip feels different".
/// </summary>
public enum KeyInterpolation
{
	/// <summary>Ease out of this key and into the next. The sane default for body motion.</summary>
	Smooth,

	/// <summary>Constant speed. Right for mechanical motion, and for a straight pass between two
	/// breakdowns you intend to smooth later.</summary>
	Linear,

	/// <summary>Hold this pose until the next key, then snap. For pops, blinks, and anything that
	/// should read as instant.</summary>
	Stepped,

	/// <summary>Start slow, arrive at full speed. The wind-up half of an action - a limb loading
	/// before it fires.</summary>
	[Title( "Ease In" )]
	EaseIn,

	/// <summary>Leave at full speed, settle slowly. The half you want arriving at a pose, and the
	/// one that makes a movement look like it has weight rather than being switched off.</summary>
	[Title( "Ease Out" )]
	EaseOut
}

public sealed class BoneKeyframe
{
	[Property] public int Frame { get; set; }
	[Property] public Transform Local { get; set; } = Transform.Zero;

	/// <summary>Governs the segment LEAVING this key, not arriving at it.</summary>
	[Property] public KeyInterpolation Interpolation { get; set; } = KeyInterpolation.Smooth;

	/// <summary>
	/// Remap a 0..1 position within this key's outgoing segment.
	///
	/// THE CURVES COME FROM Sandbox.Utility.Easing, not from here. This used to hand-roll its own
	/// smoothstep, which is a curve the engine already ships - and shipping only one eased mode
	/// meant there was no way to ask for ease-in without ease-out, which is the distinction an
	/// animator reaches for most: a limb loads slowly and arrives fast on the way out, and the
	/// reverse on the way in.
	///
	/// MovieMaker maps its own InterpolationMode onto the same functions
	/// (editor/MovieMaker/Code/Interpolation.cs), so a clip authored here eases the way the rest
	/// of the editor does.
	/// </summary>
	public float Ease( float t ) => Interpolation switch
	{
		KeyInterpolation.Smooth => Easing.QuadraticInOut( t ),
		KeyInterpolation.EaseIn => Easing.QuadraticIn( t ),
		KeyInterpolation.EaseOut => Easing.QuadraticOut( t ),
		KeyInterpolation.Stepped => 0f,
		_ => t
	};
}

public sealed class MorphEvent
{
	[Property] public string Name { get; set; } = "morph";
	[Property, Title( "Start Frame" )] public int StartFrame { get; set; }
	[Property, Title( "End Frame" )] public int EndFrame { get; set; } = 1;
	[Property, Title( "Morph Name" )] public string MorphName { get; set; } = "";
	[Property] public float Value { get; set; } = 1f;

	public bool IsActive( float frame ) => frame >= StartFrame && frame <= EndFrame;
}