Editor/HumanoidRetargeter/FittedCitizenAnimations.cs
#nullable enable annotations
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Editor;
using HumanoidRetargeter.Formats.Dmx;
using HumanoidRetargeter.Formats.Fbx;
using HumanoidRetargeter.Maths;
using HumanoidRetargeter.Skeleton;
using HumanoidRetargeter.Target;
using Sandbox;
using NVector3 = System.Numerics.Vector3;
using NQuaternion = System.Numerics.Quaternion;

namespace HumanoidRetargeter.Editor;

/// <summary>Uses the engine's source importers, then rebinds raw channels before the original ModelDoc processing.</summary>
internal static class FittedCitizenAnimations
{
	internal static async Task<(string Vmdl, List<string> Files)> ConvertAsync(
		TargetPickers.ResolvedTarget target, string referencePath, string vmdl, string folder, Action<string> progress )
	{
		var directory = CitizenAnimationModels.ProjectFile( folder );
		if ( Directory.Exists( directory ) )
			throw new InvalidOperationException( "Retargeted Citizen sources already exist. Choose a new output name to preserve them." );
		var plan = new CitizenAnimationSources( vmdl, path =>
			File.ReadAllText( AssetSystem.FindByPath( path )?.AbsolutePath ?? throw new InvalidOperationException( "Missing Citizen source: " + path ) ) );
		var shipped = File.ReadAllText( AssetSystem.FindByPath( referencePath ).AbsolutePath );
		progress?.Invoke( $"Importing {plan.Sources.Count} stock animation sources for fitted-armature retargeting…" );
		var captured = await EditorPipeline.WriteAndCompileAsync(
			new RetargetBatchResult { StandaloneVmdl = plan.SamplingModel( shipped ) }, folder,
			standaloneVmdlName: "reference", compileTimeoutSeconds: EditorPipeline.MeshCompileTimeoutSeconds,
			allowAnimationSetupOnly: true );
		await EditorPipeline.SwitchToMainThread();
		if ( !captured.Compiled ) throw new InvalidOperationException( "Stock source import failed: " + string.Join( "; ", captured.Errors ) );
		var model = Model.Load( captured.VmdlAsset.Path );
		var source = TargetPickers.SkeletonFromModel( model );
		var destination = TargetPickers.SkeletonFromModel( Model.Load( target.PreviewModelPath ) );
		var offset = ModelGrounding.Offset( vmdl );
		if ( offset != 0 ) destination = HumanoidRetargeter.Skeleton.Skeleton.Create( destination.Bones.Select( bone => new BoneDefinition(
			bone.Name, bone.ParentIndex < 0 ? null : destination[bone.ParentIndex].Name,
			ModelGrounding.SourceLocal( bone.RestLocal, bone.ParentIndex < 0, offset ) ) ).ToArray() );
		var transfer = new FittedCitizenPose( source, destination );
		var spec = StockAnimationReplacement.TargetSpec( referencePath, target.PreviewModelPath );
		var sourceIndices = source.Bones.Select( b => model.Bones.GetBone( b.Name ).Index ).ToArray();
		var scaleIndices = destination.Bones.Select( b => source.IndexOf( b.Name ) ).ToArray();
		var files = new List<string>();
		var world = new SceneWorld();
		var scene = new SceneModel( world, model, Transform.Zero ) { UseAnimGraph = false };
		try
		{
			foreach ( var entry in plan.Sources )
			{
				progress?.Invoke( $"Retargeting Citizen animations {files.Count + 1}/{plan.Sources.Count}: {Path.GetFileNameWithoutExtension( entry.Filename )}" );
				scene.CurrentSequence.Name = entry.Name + "_frames";
				scene.Update( 0 );
				var intervals = (int)MathF.Round( scene.CurrentSequence.Duration );
				scene.CurrentSequence.Name = entry.Name;
				scene.Update( 0 );
				var duration = scene.CurrentSequence.Duration;
				if ( intervals < 0 || intervals > 100000 || !float.IsFinite( duration ) || duration < 0 )
					throw new InvalidOperationException( "Invalid stock animation duration: " + entry.Filename );
				var fps = intervals > 0 && duration > 0 ? intervals / duration : 30f;
				var frames = new List<XForm[]>( intervals + 1 );
				var scales = new List<float[]>( intervals + 1 );
				var hasScale = false;
				for ( var frame = 0; frame <= intervals; frame++ )
				{
					scene.CurrentSequence.Time = intervals == 0 ? 0 : duration * frame / intervals;
					scene.Update( 0 );
					var pose = new XForm[source.Count];
					var sourceScales = new float[source.Count];
					for ( var b = 0; b < source.Count; b++ )
					{
						var transform = scene.GetParentSpaceBone( sourceIndices[b] );
						pose[b] = new XForm( new NVector3( transform.Position.x, transform.Position.y, transform.Position.z ),
							new NQuaternion( transform.Rotation.x, transform.Rotation.y, transform.Rotation.z, transform.Rotation.w ) );
						sourceScales[b] = transform.UniformScale;
					}
					var fitted = transfer.Transfer( pose );
					var frameScales = new float[destination.Count];
					for ( var b = 0; b < fitted.Length; b++ )
					{
						fitted[b] = CompiledRigSourceSpace.FromEngineLocal( fitted[b], destination[b].ParentIndex < 0, TargetUpAxis.YUpCm );
						frameScales[b] = scaleIndices[b] < 0 ? 1 : sourceScales[scaleIndices[b]];
						hasScale |= MathF.Abs( frameScales[b] - 1 ) > 0.00001f;
					}
					frames.Add( fitted );
					scales.Add( frameScales );
					if ( frame % 128 == 0 ) { await Task.Delay( 1 ); await EditorPipeline.SwitchToMainThread(); }
				}
				var filename = folder + "/" + entry.Name + (hasScale ? ".fbx" : ".dmx");
				var file = CitizenAnimationModels.ProjectFile( filename );
				// The file is an integer-rate native frame grid; each AnimFile retains its original playback rate.
				var clip = new Clip( entry.Name, 30, false, Retargeter.PrepareDmxFrames( frames, spec ) );
				if ( hasScale )
					File.WriteAllBytes( file, await Task.Run( () => FbxAnimationWriter.Write( spec.Rig.Skeleton, clip, scales ) ) );
				else
					File.WriteAllText( file, await Task.Run( () => DmxWriter.Write( spec.Rig.Skeleton, clip,
						new DmxWriteOptions { Name = entry.Name, SourceNote = entry.Filename } ) ) );
				files.Add( file );
				entry.SetConvertedFile( filename, fps );
				await EditorPipeline.SwitchToMainThread();
			}
		}
		finally { await EditorPipeline.SwitchToMainThread(); scene.Delete(); world.Delete(); }
		return (plan.ModelText, files);
	}
}