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

namespace HumanoidMocap.Editor;

public sealed partial class RetargetWindow
{
    bool _exportingMotion;

    async void PickMotionExport(bool retarget)
    {
        var dialog = new FileDialog(null) { Title = "Export armature and animation", DefaultSuffix = ".fbx" };
        dialog.SelectFile("capture.fbx"); dialog.SetFindFile(); dialog.SetModeSave(); dialog.SetNameFilter("FBX animation (*.fbx)");
        string path;
        try { if (!dialog.Execute()) return; path = dialog.SelectedFile; }
        finally { dialog.Destroy(); }
        if (!Path.GetExtension(path).Equals(".fbx", StringComparison.OrdinalIgnoreCase)) path += ".fbx";
        try
        {
            await ExportMotionFbxAsync(path, retarget);
            await EditorPipeline.SwitchToMainThread();
            if (this.IsValid()) _captureStatus.Text = $"Exported {Path.GetFileName(path)} · armature and bone animation.";
        }
        catch (Exception e) { await EditorPipeline.SwitchToMainThread(); if (this.IsValid()) _captureStatus.Text = e.Message; }
    }

    internal async Task ExportMotionFbxAsync(string path, bool retarget)
    {
        if (_exportingMotion || _processing is not null) throw new InvalidOperationException("Wait for the current operation to finish.");
        if (_editedMotion is null) throw new InvalidOperationException("Reconstruct or open motion before exporting.");
        if (retarget && _target is null) throw new InvalidOperationException("Select a target rig to retarget the animation.");
        var destination = Path.GetFullPath(path);
        if (_target?.ModelFilePath is { } modelPath && string.Equals(destination, Path.GetFullPath(modelPath), StringComparison.OrdinalIgnoreCase))
            throw new InvalidOperationException("Choose a new animation filename to preserve the original target model.");
        _exportingMotion = true;
        UpdateExportAvailability();
        try
        {
            while(retarget)
            {
                await EditorPipeline.SwitchToMainThread();
                if(!_previewPending)break;
                await _mocapPreviewTask;
            }
            await EditorPipeline.SwitchToMainThread();
            if (!this.IsValid()) return;
            var motion = _editedMotion;var preview=_bakedPreview;
            if(retarget&&preview is null)throw new InvalidOperationException("Wait for a valid animation preview before exporting.");
            var bytes = await Task.Run(() =>
            {
                if (!retarget) return MotionFbxExport.Write(motion);
                // Export the exact baked transforms shown in the viewport. Unapplied
                // edits in Advanced must not silently change the exported animation.
                var clip=preview.Clip;var target=preview.Target;
                if(preview.Props.Objects.Count>0&&!preview.SupportsProps)
                    throw new InvalidOperationException("Target prop export currently needs First Person workspace, camera-relative hand capture and root-motion removal off. Export the captured skeleton to retain objects in source coordinates.");
                var sourceTimes=Enumerable.Range(0,clip.SolvedFrames.Count).Select(i=>Math.Min(preview.Props.StartTime+i/(double)clip.Fps,preview.Props.EndTime)).ToArray();
                var combined=PropAnimation.Append(target.Rig.Skeleton,clip.SolvedFrames,sourceTimes,preview.Props,preview.Placement);
                return FbxAnimationWriter.Write(combined.Skeleton, clip.ClipName, combined.Frames,
                    Enumerable.Range(0, clip.SolvedFrames.Count).Select(i => i / (double)clip.Fps).ToArray(), clip.Fps,
                    target.UpAxis == TargetUpAxis.YUpCm ? 1 : 2, target.UpAxis == TargetUpAxis.ZUpEngine ? 2.54 : 1,
                    preview.Space + "; retargeted");
            });
            // Stage next to the destination. A failed write leaves any existing export intact.
            var temporary = destination + "." + Guid.NewGuid().ToString("N") + ".tmp";
            try
            {
                await File.WriteAllBytesAsync(temporary, bytes);
                File.Move(temporary, destination, true);
            }
            finally { if (File.Exists(temporary)) File.Delete(temporary); }
        }
        finally { await EditorPipeline.SwitchToMainThread(); _exportingMotion = false;if(this.IsValid())UpdateExportAvailability(); }
    }
}