Code/RigControl/RigAnimDocument.cs

Asset type and data model for a rig animation document used by the Marionette editor and runtime. Defines clip metadata (source model, frame count, speed), lists of reference props, objects/parts, lights, cameras, bone tracks, events and morphs, plus helpers to find tracks/parts and evaluate keyframed transforms with easing.

File Access
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>
	/// Models shown in the viewport to pose against - the switch a hand reaches for, the weapon it
	/// grips, the surface it rests on.
	///
	/// A prop stays static until you key it. Left alone it is pure reference: somewhere real to aim
	/// a grip, instead of guessing at empty space and discovering you were wrong once it's in game.
	/// Key it and it becomes a PART - a whole object animated on the same timeline as the bones,
	/// which is what a door, a lever or a magazine actually is. See TrackTarget, and
	/// RigAnimPlayerComponent.Parts for how a part is wired up at runtime.
	///
	/// A prop that carries its own skeleton gets its bones posed as well, alongside the main
	/// model's and on the same playhead - a weapon's bolt, a magazine's follower, a door on a
	/// hinge. Its tracks are named for the prop as well as the bone, so two objects are each
	/// allowed a bone called "root"; see RigTrackName.
	///
	/// 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();

	/// <summary>
	/// The objects in this clip that are not the main model - a door and its handle, a weapon and
	/// its magazine, anything imported from a mesh file.
	///
	/// NOT REFERENCE PROPS. A reference prop is scenery: something to aim a grip at. An object is
	/// part of the animation - it has parts, each part moves on its own, and every one of them
	/// records to the timeline exactly as a bone does. That difference is why they are two lists
	/// rather than a flag on one: a prop that is only there to be looked at should not be asking
	/// you which of its parts you meant.
	///
	/// ReferenceProps is still here and still works, because clips already exist that use it.
	/// </summary>
	[Property, Group( "Objects" )] public List<RigObject> Objects { get; set; } = new();

	/// <summary>
	/// Lights for the Marionette viewport to render this clip under.
	///
	/// A LIGHT IS WORKSPACE UNTIL YOU SAY OTHERWISE. Every one of these starts as viewport-only:
	/// lighting is mostly how you SEE what you are posing, and a light added to make out an
	/// elbow has no business turning up in somebody's game. Tick a light's Export With Clip and
	/// it becomes part of the shot instead, spawned beside the model by
	/// RigAnimPlayerComponent when the clip plays - a desk lamp authored with the pose it lights.
	///
	/// Either way it stays out of an exported .vmdl, because a .vmdl animation is bone channels
	/// and there is nowhere in the format for a light (see RigAnimExport, which says so rather
	/// than dropping them quietly).
	///
	/// It is stored on the clip rather than in an editor cookie because the useful lighting is
	/// per-shot, not per-person: a clip of him at the desk wants the desk's key light, and the
	/// next clip wants something else. Kept as a preference it would follow you between clips and
	/// be wrong for most of them.
	///
	/// EMPTY MEANS THE DEFAULT LIGHTING, not darkness. Every clip authored before this existed
	/// has an empty list and has to keep looking exactly as it did - so the viewport's built-in
	/// sun and ambient stay on until this list says otherwise, at which point these replace them
	/// wholesale. See RigViewport.BuildLights.
	/// </summary>
	[Property, Group( "Lighting" )] public List<RigLight> Lights { get; set; } = new();

	/// <summary>
	/// Camera shots for this clip - where you looked from when a moment read right.
	///
	/// LIKE LIGHTS, A CAMERA IS WORKSPACE UNTIL TICKED OTHERWISE. Every one of these starts as
	/// viewport-only: framing is how you SEE what you are posing, and a camera dropped to look at
	/// an elbow has no business turning up in somebody's game. Tick a camera's Export With Clip and
	/// it becomes part of the shot instead - spawned by RigAnimPlayerComponent when the clip plays,
	/// and baked out by the export.
	///
	/// It is stored on the clip rather than in an editor cookie because the useful framing is
	/// per-shot, not per-person: a clip of him at the desk wants the shot over the desk's shoulder,
	/// and the next clip wants something else.
	/// </summary>
	[Property, Group( "Cameras" )] public List<RigCamera> Cameras { 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 RigObject FindObject( string name ) =>
		Objects?.FirstOrDefault( o => o is not null && o.Name == name );

	/// <summary>The object or part a track name means, or null for a name nothing owns. Splitting
	/// here rather than at every call site is what keeps "door" and "door/handle" one idea.</summary>
	public (RigObject Object, RigObjectPart Part) FindPartTarget( string key )
	{
		if ( string.IsNullOrEmpty( key ) || Objects is null )
			return (null, null);

		if ( FindObject( key ) is { } whole )
			return (whole, null);

		var (objectName, partName) = RigTrackName.Split( key );

		if ( FindObject( objectName ) is not { } owner )
			return (null, null);

		return (owner, owner.FindPart( partName ));
	}

	public BoneTrack FindTrack( string bone ) =>
		BoneTracks.FirstOrDefault( t => t.Target == TrackTarget.Bone && t.BoneName == bone );

	public BoneTrack GetOrAddTrack( string bone ) => GetOrAddTrack( bone, TrackTarget.Bone );

	/// <summary>The track for a whole part - a reference prop moved as one object rather than a
	/// bone inside the skeleton. See TrackTarget.</summary>
	public BoneTrack FindPartTrack( string part ) =>
		BoneTracks.FirstOrDefault( t => t.Target == TrackTarget.Part && t.BoneName == part );

	public BoneTrack GetOrAddPartTrack( string part ) => GetOrAddTrack( part, TrackTarget.Part );

	private BoneTrack GetOrAddTrack( string name, TrackTarget target )
	{
		var track = BoneTracks.FirstOrDefault( t => t.Target == target && t.BoneName == name );

		if ( track is null )
		{
			track = new BoneTrack { BoneName = name, Target = target };
			BoneTracks.Add( track );
		}

		return track;
	}

	/// <summary>Only the tracks that drive real skeleton bones. Anything that walks the skeleton -
	/// playback, .vmdl export - wants this rather than BoneTracks, or it will go hunting for a bone
	/// named after a prop and quietly find nothing.</summary>
	public IEnumerable<BoneTrack> SkeletonTracks => BoneTracks.Where( t => t.Target == TrackTarget.Bone );

	/// <summary>Only the whole-part tracks.</summary>
	public IEnumerable<BoneTrack> PartTracks => BoneTracks.Where( t => t.Target == TrackTarget.Part );

	/// <summary>A part's own transform, relative to whatever it follows: its keyframes at
	/// <paramref name="frame"/> when it has any, otherwise where it was placed. A null frame means
	/// where it was placed, which in the editor is also where the last scrub left it.</summary>
	public Transform PartLocalAt( RigObject owner, RigObjectPart part, float? frame )
	{
		if ( frame is { } f && FindPartTrack( RigTrackName.Qualify( owner.Name, part.Name ) ) is { Keyframes.Count: > 0 } track )
			return track.Evaluate( f );

		return part.LocalTransform;
	}

	/// <summary>
	/// Where a part sits inside its object, following every part it follows up the chain.
	///
	/// The one place that chain is walked, so the editor placing a part, the editor converting a
	/// part's keys when what it follows changes, and the game playing it all agree.
	/// </summary>
	public Transform PartInObject( RigObject owner, RigObjectPart part, float? frame )
	{
		var result = PartLocalAt( owner, part, frame );

		// ParentOf breaks loops, so this chain always ends.
		for ( var leader = owner.ParentOf( part ); leader is not null; leader = owner.ParentOf( leader ) )
			result = PartLocalAt( owner, leader, frame ).ToWorld( result );

		return result;
	}
}

/// <summary>
/// What a track drives: a bone inside the skeleton, or a whole part.
///
/// A PART IS A WHOLE OBJECT, NOT A JOINT. A lever, a fridge door, a magazine, a light switch's
/// toggle - things Marionette could previously only place statically as reference props, when
/// half of what an interaction clip is made of is those props moving. A part track keys exactly
/// what a bone track keys (a local transform per frame, with the same easing), so everything the
/// timeline already does - dragging keys, marquee select, copy/paste, interpolation modes - works
/// on a part without knowing it is one.
///
/// Both kinds live in the same BoneTracks list so they share one timeline and one undo history.
///
/// NEW VALUES ARE APPENDED, NEVER INSERTED - these serialize by ordinal, and Bone MUST stay 0 so
/// every .riganim written before parts existed still reads back as bone tracks.
/// </summary>
public enum TrackTarget
{
	/// <summary>A named bone in the source model's skeleton.</summary>
	Bone,

	/// <summary>A named entry in ReferenceProps, moved as one whole object.</summary>
	Part
}

/// <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 );
}

/// <summary>
/// One object in the clip: a thing with parts, each of which animates on its own.
///
/// THE OBJECT IS THE PARENT AND THE PARTS ARE ITS CHILDREN. Moving the object moves everything in
/// it; moving a part moves it within the object. That is the whole reason an imported mesh is
/// split at all - a door you can only move as one lump is a door that cannot open, and welding the
/// handle to the leaf is exactly what an exporter's o/g markers were trying to avoid.
///
/// Its own transform is keyed under its name; a part's under "object/part". See RigTrackName.
/// </summary>
public sealed class RigObject
{
	[Property] public string Name { get; set; } = "object";

	/// <summary>Hidden rather than deleted, so something can be got out of the way for a moment
	/// without losing its placement or its keyframes.</summary>
	[Property] public bool Visible { get; set; } = true;

	/// <summary>
	/// A Wavefront OBJ every part is read out of, and which lump each part is - see
	/// RigObjectPart.ObjPart.
	///
	/// The mesh is NOT in the document: a 60k-triangle import would be megabytes of vertices in a
	/// file whose virtue is being small and readable. Relative paths are relative to the .riganim,
	/// which is where an import copies the file once the clip has been saved, so the clip travels
	/// with its meshes.
	/// </summary>
	[Property, Title( "OBJ File" )] public string ObjSource { get; set; } = "";

	[Property] public List<RigObjectPart> Parts { get; set; } = new();

	[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. A bone of the main model this object follows, for something already in
	/// the hand - a weapon being reloaded, rather than one lying on a table.</summary>
	[Property, Group( "Transform" ), Title( "Follow Bone" )] public string FollowBone { get; set; } = "";

	[Hide]
	public Transform LocalTransform => new( Position, Rotation.ToRotation(), Scale );

	public RigObjectPart FindPart( string name ) =>
		Parts?.FirstOrDefault( p => p is not null && p.Name == name );

	/// <summary>
	/// The part this one follows, or null when it follows only the object.
	///
	/// Null as well for a name that is not in this object, and for a loop - a part that ends up
	/// following itself, which the editor refuses but a hand-edited file can still contain. A loop
	/// is broken rather than followed, because following one would place its parts forever.
	/// </summary>
	public RigObjectPart ParentOf( RigObjectPart part )
	{
		if ( part is null || string.IsNullOrEmpty( part.ParentPart ) )
			return null;

		var leader = FindPart( part.ParentPart );

		if ( leader is null || leader == part )
			return null;

		// Up from the leader: arriving back at this part is a loop, running out of parents is not.
		var up = leader;

		for ( var guard = 0; guard < 256; guard++ )
		{
			if ( string.IsNullOrEmpty( up.ParentPart ) )
				return leader;

			var next = FindPart( up.ParentPart );

			if ( next is null || next == up )
				return leader;

			if ( next == part )
				return null;

			up = next;
		}

		return null;
	}
}

/// <summary>One moving piece of an object - a handle, a hinge, a bolt, a magazine follower. Its
/// transform is relative to the object it belongs to, so the object can be placed once and its
/// parts animated within it.</summary>
public sealed class RigObjectPart
{
	[Property] public string Name { get; set; } = "part";

	[Property] public bool Visible { get; set; } = true;

	/// <summary>Which lump of the object's OBJ this part draws - the o/g name the exporter wrote.
	/// Empty means the whole file, which is what a file with no markers comes back as.</summary>
	[Property, Title( "OBJ Part" )] public string ObjPart { get; set; } = "";

	/// <summary>A compiled model instead of an OBJ lump, for a part assembled by hand rather than
	/// imported.</summary>
	[Property] public Model Model { get; set; }

	/// <summary>
	/// Another part of the same object this one follows - the eyes following the head. Empty means
	/// it follows only the object.
	///
	/// A FOLLOWING PART IS STORED RELATIVE TO WHAT IT FOLLOWS, keyframes included, the same way a
	/// part is stored relative to its object. That is what makes it follow: move the head and the
	/// eyes' numbers don't change, so they stay where they sit on the head. They can still be moved
	/// on their own - a look to the side is the eyes' own keyframes, on top of wherever the head is.
	///
	/// By name, and empty by default, so every clip saved before this existed reads back exactly as
	/// it was: every part following only its object.
	/// </summary>
	[Property, Title( "Follows" )] public string ParentPart { get; set; } = "";

	[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;

	[Hide]
	public Transform LocalTransform => new( Position, Rotation.ToRotation(), Scale );
}

public sealed class BoneTrack
{
	/// <summary>The bone's name, or - when Target is Part - the reference prop's name. One field
	/// because a track is a name and a list of keys either way, and splitting it would have meant
	/// a second copy of Evaluate, a second timeline and a second undo path for no gain.</summary>
	[Property] public string BoneName { get; set; } = "";

	/// <summary>Bone or whole part. Defaults to Bone, which is what every clip written before
	/// parts existed is.</summary>
	[Property] public TrackTarget Target { get; set; } = TrackTarget.Bone;

	/// <summary>What the timeline shows in the gutter: the thing's own name, with whatever owns it
	/// in brackets. Whether a row drives a bone or a whole object is carried by colour instead -
	/// the gutter paints parts green, matching their handles in the viewport.</summary>
	public string DisplayName => RigTrackName.Display( BoneName );
	[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;
}