Code/HumanoidMocap/Motion/PropTrackImport.cs
using System;
using System.Linq;
using System.Numerics;
using System.Threading;
using HumanoidMocap.Maths;
using HumanoidMocap.Skeleton;

namespace HumanoidMocap.Motion;
using Vector3 = System.Numerics.Vector3;

/// <summary>Imported animation, explicitly aligned by the user to capture coordinates.
/// This does not estimate camera motion or track an object from video.</summary>
public static class PropTrackImport
{
    public static PropTrack Convert(SourceScene scene,int clipIndex,string id,MotionSpace space,
        double startTime,Vector3 offset,Quaternion rotation,float scale=1,CancellationToken cancellation=default)
    {
        cancellation.ThrowIfCancellationRequested();
        if(string.IsNullOrWhiteSpace(id)||!double.IsFinite(startTime)||startTime<0||!float.IsFinite(scale)||scale<=0
            ||!float.IsFinite(offset.X)||!float.IsFinite(offset.Y)||!float.IsFinite(offset.Z)
            ||!float.IsFinite(rotation.LengthSquared())||rotation.LengthSquared()<1e-8f)
            throw new ArgumentException("Enter a prop name, nonnegative video time, finite placement and positive scale.");
        if(clipIndex<0||clipIndex>=scene.Clips.Count)throw new ArgumentException("Choose an animation take. A prop needs an explicit animated track.");
        var clip=scene.Clips[clipIndex];var rig=scene.Skeleton;
        if(clip.FrameCount is <1 or >108000||rig.Count is <1 or >1024)
            throw new ArgumentException("Prop animation exceeds the supported bone/frame budget. Import a shorter take.");
        if(rig.Count==0||rig.Bones.Count(b=>b.ParentIndex<0)!=1)
            throw new ArgumentException("Import one prop root per file. Detachable objects can be imported separately.");
        var axes=new[]{scene.CoordAxis,scene.UpAxis,scene.FrontAxis};var signs=new[]{scene.CoordAxisSign,scene.UpAxisSign,scene.FrontAxisSign};
        if(axes.Any(a=>a<0||a>2)||axes.Distinct().Count()!=3||signs.Any(s=>s is not (-1 or 1)))
            throw new ArgumentException("Invalid prop coordinate axes.");
        Vector3 Axis(Vector3 p)
        {
            float Component(int i)=>i==0?p.X:i==1?p.Y:p.Z;
            return new(Component(axes[0])*signs[0],Component(axes[1])*signs[1],Component(axes[2])*signs[2]);
        }
        var x=Axis(Vector3.UnitX);var y=Axis(Vector3.UnitY);var z=Axis(Vector3.UnitZ);
        var basis=new Matrix4x4(x.X,x.Y,x.Z,0,y.X,y.Y,y.Z,0,z.X,z.Y,z.Z,0,0,0,0,1);
        var inverse=Matrix4x4.Transpose(basis);rotation=Quaternion.Normalize(rotation);
        XForm Transform(XForm value,bool root)
        {
            var p=Axis(value.Pos)*(.01f*scale);
            // Basis conjugation also handles reflected source conventions without
            // representing a reflection as a quaternion.
            var r=Quaternion.Normalize(Quaternion.CreateFromRotationMatrix(inverse*Matrix4x4.CreateFromQuaternion(value.Rot)*basis));
            return root?new(offset+Vector3.Transform(p,rotation),Quaternion.Normalize(rotation*r)):new(p,r);
        }
        var prop=new PropTrack{Id=id,Source=ObjectMotionSource.ImportedAnimation,Space=space};
        foreach(var bone in rig.Bones)
        {
            var rest=Transform(bone.RestLocal,bone.ParentIndex<0);
            prop.Bones.Add(new(){Name=bone.Name,Parent=bone.ParentIndex,Group="prop",RestPosition=MotionDocument.A(rest.Pos),RestRotation=MotionDocument.A(rest.Rot)});
        }
        if((long)clip.FrameCount*rig.Count>Formats.Fbx.FbxAnimationWriter.MaximumTransformSamples)
            throw new ArgumentException("Prop animation exceeds the transform budget. Import a shorter take.");
        for(var f=0;f<clip.FrameCount;f++)
        {
            cancellation.ThrowIfCancellationRequested();
            var locals=clip.Frames[f].Select((pose,b)=>Transform(pose,rig[b].ParentIndex<0)).ToArray();
            prop.Frames.Add(new(){Time=startTime+f/(double)clip.Fps,Positions=locals.Select(p=>MotionDocument.A(p.Pos)).ToArray(),
                Rotations=locals.Select(p=>MotionDocument.A(p.Rot)).ToArray(),Evidence=locals.Select(_=>JointEvidence.Authored).ToArray()});
        }
        new MotionDocument{SourceFps=clip.Fps,Space=space,Bones=prop.Bones,Frames=prop.Frames}.Validate();
        return prop;
    }

    public static MotionDocument Add(MotionDocument capture,PropTrack prop)
    {
        if(prop.Space!=capture.Space)throw new ArgumentException("Align the prop to the capture coordinate space before importing.");
        if(capture.Objects.Any(p=>p.Id==prop.Id))throw new ArgumentException("A prop with that name already exists. Choose a unique name.");
        if(prop.Frames.Count==0||prop.Frames[0].Time>capture.Frames[0].Time+1e-6||prop.Frames[^1].Time<capture.Frames[^1].Time-1e-6)
            throw new ArgumentException("The prop animation must cover the entire capture range. Adjust its start time or provide a longer take; missing motion is not extrapolated.");
        var result=capture.Copy();result.Objects.Add(prop);result.Validate();return result.Copy();
    }
}