Editor/HumanoidMocap/MocapProps.cs
using System;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Editor;
using Sandbox;
using HumanoidMocap.Formats.Fbx;
using HumanoidMocap.Motion;
using HumanoidMocap.Skeleton;

namespace HumanoidMocap.Editor;

public sealed partial class RetargetWindow
{
    async void PickPropAnimation()
    {
        if(_rawMotion is null||_processing is not null)return;
        var path=EditorUtility.OpenFileDialog("Import prop animation","FBX animation (*.fbx)",null);
        if(string.IsNullOrEmpty(path))return;
        var session=_editSession;
        _processing=new CancellationTokenSource();var token=_processing.Token;SetCaptureBusy(true);
        try
        {
            var fps=(float)_rawMotion.SourceFps;
            var scene=await Task.Run(()=>{
                if(new FileInfo(path).Length>64*1024*1024)throw new InvalidDataException("Prop FBX exceeds 64 MiB. Export only the selected prop, a simple contact mesh and its animation.");
                return FbxImporter.Import(File.ReadAllBytes(path),new(){SampleFps=fps,MaximumTransformSamples=FbxAnimationWriter.MaximumTransformSamples,CancellationToken=token});
            },token);
            await EditorPipeline.SwitchToMainThread();token.ThrowIfCancellationRequested();
            if(!this.IsValid()||session!=_editSession)return;
            if(scene.Clips.Count==0)throw new InvalidDataException("The FBX has no animation take. Export the prop's root and part animation.");
            new PropImportDialog(this,scene,path,session).Show();
        }
        catch(OperationCanceledException){await EditorPipeline.SwitchToMainThread();if(this.IsValid())_captureStatus.Text="Prop import cancelled.";}
        catch(Exception error){await EditorPipeline.SwitchToMainThread();if(this.IsValid())_captureStatus.Text=error.Message;}
        finally{await EditorPipeline.SwitchToMainThread();_processing.Dispose();_processing=null;if(this.IsValid()){SetCaptureBusy(false);StartNextQueuedVideo();}}
    }

    internal async Task ImportPropAsync(SourceScene scene,int take,string id,string sourcePath,double start,
        System.Numerics.Vector3 offset,System.Numerics.Quaternion rotation,float scale,MocapAdjustmentStore.Session expected)
    {
        if(_processing is not null||expected!=_editSession)throw new InvalidOperationException("The capture changed or is busy. Reopen prop import.");
        if(!_firstPerson||!HandCaptureRetargeter.Supports(_rawMotion)||_rawMotion.Space!=MotionSpace.CameraRelative)
            throw new InvalidOperationException("Prop import currently requires First Person camera-relative hand capture.");
        _processing=new CancellationTokenSource();var token=_processing.Token;SetCaptureBusy(true);
        try
        {
            var raw=_rawMotion;var contacts=_editedMotion.Copy().Contacts;var cleanup=_appliedCleanup;var path=_motionPath;
            var saved=await Task.Run(()=>{
                token.ThrowIfCancellationRequested();
                var prop=PropTrackImport.Convert(scene,take,id,raw.Space,start,offset,rotation,scale,token);prop.ModelPath=Path.GetFullPath(sourcePath);
                if(new FileInfo(sourcePath).Length>64*1024*1024)throw new InvalidDataException("Prop FBX exceeds 64 MiB.");
                var surfaces=FbxPropSurfaces.Read(File.ReadAllBytes(sourcePath),scene,scale,token);prop.Surfaces=surfaces.Surfaces;
                var composed=PropTrackImport.Add(raw,prop);composed.Contacts=contacts;
                composed.Diagnostics.Add($"Prop '{id}' imported from {Path.GetFileName(sourcePath)} / {scene.Clips[take].Name}; placement and timing supplied by the user. Not video object tracking.");
                composed.Diagnostics.Add($"Prop '{id}': {prop.Surfaces.Sum(s=>s.Triangles.Length/3)} rigid contact triangles imported; {surfaces.SkippedFaces} unsupported/nontriangular faces omitted. Contact geometry is used for suggestions, not exported as skin.");
                composed.Validate();var edited=cleanup is null?composed:MotionCleanup.Apply(composed,cleanup);
                token.ThrowIfCancellationRequested();
                var folder=Path.GetDirectoryName(path);var stem=Path.GetFileNameWithoutExtension(path)+"-props-"+Guid.NewGuid().ToString("N")[..8];
                var rawPath=Path.Combine(folder,stem+"-source.hmotion");var editedPath=Path.Combine(folder,stem+".hmotion");
                // New files only: never overwrite the reconstruction or an earlier edit.
                using(var stream=new StreamWriter(new FileStream(rawPath,FileMode.CreateNew,FileAccess.Write)))stream.Write(composed.ToJson());
                using(var stream=new StreamWriter(new FileStream(editedPath,FileMode.CreateNew,FileAccess.Write)))stream.Write(edited.ToJson());
                return (rawPath,editedPath);
            },token);
            await EditorPipeline.SwitchToMainThread();if(!this.IsValid()||expected!=_editSession)return;
            await LoadMotionAsync(saved.editedPath,saved.rawPath,cleanup);
            if(!this.IsValid())return;
            if(_editSession==expected||_motionPath!=saved.editedPath)
                throw new InvalidOperationException("The imported capture was saved, but could not be opened: "+_captureStatus.Text);
            foreach(var pair in expected.State.Targets)_editSession.State.Targets[pair.Key]=pair.Value;
            RestoreTargetAdjustments();await RefreshMocapPreviewAsync();
            _captureStatus.Text="Prop animation imported into a separate capture. Review alignment, then add wrist contacts in Advanced.";
        }
        finally{await EditorPipeline.SwitchToMainThread();_processing.Dispose();_processing=null;if(this.IsValid()){SetCaptureBusy(false);StartNextQueuedVideo();}}
    }

    internal sealed class PropImportDialog : Dialog
    {
        internal readonly Checkbox Aligned;
        internal readonly Button Import;
        public PropImportDialog(RetargetWindow owner,SourceScene scene,string path,MocapAdjustmentStore.Session session):base(owner)
        {
            Window.WindowTitle="Import prop animation";Window.SetWindowIcon("view_in_ar");
            Window.Size=new Vector2(540,440);Window.MinimumSize=new Vector2(500,420);
            SetStyles($"background-color: {Theme.WidgetBackground.Hex}; color: {Theme.Text.Hex};");
            Layout=Layout.Column();Layout.Margin=16;Layout.Spacing=10;
            Layout.Add(new Label("Align this animation to the capture camera. The importer does not recover prop motion or camera calibration.",this){WordWrap=true});
            LineEdit Input(string title,string value){var row=Layout.AddRow();row.Spacing=8;row.Add(new Label(title,this){FixedWidth=150});return row.Add(new LineEdit(this){Text=value},1);}
            var name=Input("Prop name",Path.GetFileNameWithoutExtension(path));
            var row=Layout.AddRow();row.Spacing=8;row.Add(new Label("Animation take",this){FixedWidth=150});var picker=row.Add(new ComboBox(this),1);int take=0;
            for(var i=0;i<scene.Clips.Count;i++){var n=i;picker.AddItem(scene.Clips[i].Name,onSelected:()=>take=n,selected:i==0);}
            var start=Input("First frame at video (s)",owner._rawMotion.Frames[0].Time.ToString("R",CultureInfo.InvariantCulture));
            var offset=Input("Position X, Y, Z (m)","0,0,0");var angles=Input("Yaw, pitch, roll (°)","0,0,0");var scale=Input("Scale multiplier","1");
            Layout.Add(new Label("X right, Y up, −Z in front of camera. FBX axes and units are converted first; placement applies to the root. The take must cover the capture range.",this){WordWrap=true});
            var aligned=Aligned=Layout.Add(new Checkbox("I have aligned this track to the capture camera"));
            var status=Layout.Add(new Label("",this){WordWrap=true});status.SetStyles($"color: {Theme.Yellow.Hex};");
            var buttons=Layout.AddRow();buttons.AddStretchCell();buttons.Add(new Button("Cancel"){Clicked=Close});var add=Import=buttons.Add(new Button.Primary("Import"));
            add.Clicked=async ()=>{
                try
                {
                    if(!aligned.Value)throw new InvalidOperationException("Check camera-space placement before importing. Imported animation is not automatically aligned.");
                    var a=Vector(angles)*(MathF.PI/180);
                    var q=System.Numerics.Quaternion.CreateFromYawPitchRoll(a.X,a.Y,a.Z);
                    var t=double.Parse(start.Text,CultureInfo.InvariantCulture);var s=float.Parse(scale.Text,CultureInfo.InvariantCulture);
                    add.Enabled=false;await owner.ImportPropAsync(scene,take,name.Text,path,t,Vector(offset),q,s,session);
                    await EditorPipeline.SwitchToMainThread();if(this.IsValid())Close();
                }
                catch(Exception error){await EditorPipeline.SwitchToMainThread();if(this.IsValid()){status.Text=error.Message;add.Enabled=true;}}
            };
        }
    }
}