Editor/RetargetWindow.Export.cs

Editor window export logic for retargeting mocap motions. It prompts for a file, validates state, builds FBX bytes (optionally retargeted using preview data), writes atomically via a temp file, and can also export a rebuilt AI version beside the main file.

File Access
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Editor;
using HumanoidMocap.Core.Formats.Fbx;
using HumanoidMocap.Core.Motion;
using HumanoidMocap.Core.Skeleton;
using HumanoidMocap.Core.Target;
using Sandbox;
using HumanoidMocap.Core;

namespace HumanoidMocap.EditorTools;

public sealed partial class RetargetWindow
{
    bool _exportingMotion;
    /// <summary>Where the last export wrote the MotionBricks rebuild, or null when there was none.</summary>
    string _exportedRebuilt;

    /// <summary><c>walk.fbx</c> → <c>walk_rebuilt.fbx</c>, beside it.</summary>
    internal static string RebuiltPath(string path)
        => Path.Combine(Path.GetDirectoryName(path) ?? "", Path.GetFileNameWithoutExtension(path) + RebuiltSuffix + Path.GetExtension(path));

    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 = _exportedRebuilt is { } rebuilt
                ? $"Exported {Path.GetFileName(path)} and {Path.GetFileName(rebuilt)} (AI version) · armature and bone animation."
                : $"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;
        // A slowed-down clip (see MocapClipSpeed) exports at that speed: the same frames, further apart.
        var speed=_animationSpeed;
        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.");
            // A MotionBricks rebuild shown beside the capture is exported beside it.
            var rebuilt=CurrentRebuilt;var rebuiltPreview=_rebuiltBaked;_exportedRebuilt=null;
            if(rebuilt is not null&&retarget&&rebuiltPreview is null)throw new InvalidOperationException("Wait for the rebuilt animation's preview before exporting.");
            var rebuiltDestination=rebuilt is null?null:RebuiltPath(destination);
            if(rebuiltDestination is not null&&_target?.ModelFilePath is { } model&&string.Equals(rebuiltDestination,Path.GetFullPath(model),StringComparison.OrdinalIgnoreCase))
                throw new InvalidOperationException("Choose a new animation filename to preserve the original target model.");
            await Write(destination,motion,preview);
            if(rebuiltDestination is not null){await Write(rebuiltDestination,rebuilt,rebuiltPreview);_exportedRebuilt=rebuiltDestination;}
        }
        finally { await EditorPipeline.SwitchToMainThread(); _exportingMotion = false;if(this.IsValid())UpdateExportAvailability(); }

        async Task Write(string destination,MotionDocument motion,BakedPreview preview)
        {
            var bytes = await Task.Run(() =>
            {
                if (!retarget) return MotionFbxExport.Write(ClipSpeed.Retimed(motion,speed));
                // 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);
                var fps=clip.Fps*speed;
                return FbxAnimationWriter.Write(combined.Skeleton, clip.ClipName, combined.Frames,
                    Enumerable.Range(0, clip.SolvedFrames.Count).Select(i => i / (double)fps).ToArray(), 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); }
        }
    }
}