Editor/HumanoidMocap/MocapContactEditor.cs
using System;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using Editor;
using Sandbox;
using HumanoidMocap.Mapping;
using HumanoidMocap.Motion;

namespace HumanoidMocap.Editor;

public sealed partial class RetargetWindow
{
    internal ContactEditorDialog OpenContactEditor(ContactInterval contact=null)
    {
        if(_editedMotion is null||_processing is not null)return null;
        var dialog=new ContactEditorDialog(this,contact);dialog.Show();return dialog;
    }

    internal async Task SaveContactAsync(MotionDocument expected,int index,ContactInterval replacement)
    {
        if(expected!=_editedMotion||_processing is not null)throw new InvalidOperationException("The capture changed. Reopen contact editing.");
        var candidate=ContactAuthoring.Replace(expected,index,replacement);
        if(new PropContactMotion(candidate).UnsupportedReason(replacement) is {} reason)throw new ArgumentException(reason);
        var source=_rawMotion.Copy();source.Contacts=candidate.Contacts;
        _editedMotion=_appliedCleanup is null?source:MotionCleanup.Apply(source,_appliedCleanup);
        RefreshContacts();await RefreshMocapPreviewAsync();
    }

    internal sealed class ContactEditorDialog : Dialog
    {
        internal readonly LineEdit Start,End,Target;
        internal readonly Label Status;
        internal readonly Button Save;
        internal readonly Button Place;
        internal readonly Checkbox HoldOrientation;
        internal readonly Checkbox Sliding;
        internal readonly MocapContactKeys Keys;
        internal FingerContactDialog FingerDialog;
        internal readonly Button Fingers;
        readonly MotionDocument _motion;
        readonly ContactInterval _draft;

        public ContactEditorDialog(RetargetWindow owner,ContactInterval contact):base(owner)
        {
            _motion=owner._editedMotion;
            var index=contact is null?-1:_motion.Contacts.IndexOf(contact);
            _draft=index<0?new(){Start=_motion.Frames[0].Time,End=_motion.Frames[^1].Time,Review=ContactReview.Suggested}:
                _motion.Copy().Contacts[index];
            Window.WindowTitle=index<0?"Add wrist contact":"Edit wrist contact";Window.SetWindowIcon("touch_app");
            Window.Size=new Vector2(560,430);Window.MinimumSize=new Vector2(520,420);
            SetStyles($"background-color: {Theme.WidgetBackground.Hex}; color: {Theme.Text.Hex};");
            Layout=Layout.Column();Layout.Margin=16;Layout.Spacing=10;
            Layout.Add(new Label("Place an object-local wrist anchor and review it against the video. Saved edits return to Suggested; confirm them in Contact review.",this){WordWrap=true});
            var hands=Layout.Add(new ComboBox(this));
            foreach(var bone in _motion.Bones.Where(b=>b.Role is BoneRole.HandL or BoneRole.HandR))
            {var name=bone.Name;if(string.IsNullOrEmpty(_draft.Bone))_draft.Bone=name;hands.AddItem(name,onSelected:()=>{if(_draft.Bone!=name)_draft.LocalRotation=null;_draft.Bone=name;},selected:name==_draft.Bone);}
            var props=Layout.Add(new ComboBox(this));
            foreach(var prop in _motion.Objects)foreach(var bone in prop.Bones)
            {
                var id=prop.Id;var name=bone.Name;if(string.IsNullOrEmpty(_draft.Object)){_draft.Object=id;_draft.ObjectBone=name;}
                props.AddItem(id+" / "+name,onSelected:()=>{if(_draft.Object!=id||_draft.ObjectBone!=name)_draft.LocalRotation=null;_draft.Object=id;_draft.ObjectBone=name;},
                    selected:id==_draft.Object&&(name==_draft.ObjectBone||string.IsNullOrEmpty(_draft.ObjectBone)&&bone.Parent<0));
            }
            LineEdit Input(string title,string value){var row=Layout.AddRow();row.Spacing=8;row.Add(new Label(title,this){FixedWidth=160});return row.Add(new LineEdit(this){Text=value},1);}
            Start=Input("Start at video (s)",_draft.Start.ToString("R",CultureInfo.InvariantCulture));
            End=Input("End at video (s)",_draft.End.ToString("R",CultureInfo.InvariantCulture));
            Target=Input("Local X, Y, Z (m)",string.Join(",",_draft.LocalTarget.Select(v=>v.ToString("R",CultureInfo.InvariantCulture))));
            HoldOrientation=Layout.Add(new Checkbox("Hold wrist orientation relative to prop"){Value=_draft.LocalRotation is not null});
            HoldOrientation.ToolTip="Optional for a rigid grip. Place an anchor below, then review and confirm. Captured finger articulation remains unchanged.";
            var place=Place=Layout.Add(new Button("Use wrist at interval midpoint","my_location"));
            place.ToolTip="Use the captured wrist at the nearest midpoint sample. This places a manual anchor; it does not detect a grip. Sliding position keys are preserved.";
            Status=new Label("",this){WordWrap=true};
            Status.SetStyles($"color: {Theme.Yellow.Hex};");
            Sliding=Layout.Add(new Checkbox("Sliding contact · animate the local wrist target"){Value=_draft.Sliding});
            Sliding.ToolTip="Uses at least two authored position keys. Turning this off keeps the keys for later and uses the fixed anchor above.";
            void RefreshMode()
            {
                _draft.Sliding=Sliding.Value;Keys.Visible=Sliding.Value;Target.ReadOnly=Sliding.Value;
                props.Enabled=hands.Enabled=_draft.TargetKeys.Count==0&&_draft.FingerTargets.Count==0;
                props.ToolTip=hands.ToolTip=!props.Enabled?"Remove the draft's position keys and finger points before changing their hand or object coordinate frame.":"";
                Window.Size=new Vector2(560,Sliding.Value?700:500);
            }
            Keys=Layout.Add(new MocapContactKeys(this,_motion,_draft,Read,()=>owner.PlaybackTime,message=>Status.Text=message,RefreshMode));
            Sliding.Clicked=RefreshMode;RefreshMode();
            var fingerActions=Layout.AddRow();fingerActions.Spacing=8;
            Fingers=fingerActions.Add(new Button("Finger contact points…","touch_app"),1);
            fingerActions.Add(new Button("Clear points","clear"){ToolTip="Remove all target-specific finger points from this draft. Cancel restores the saved contact.",Clicked=()=>{
                _draft.FingerTargets.Clear();RefreshMode();Status.Text="Finger points removed from this draft. Save to keep the change, or cancel to restore them.";
            }});
            Fingers.Clicked=()=>{try{Read();FingerDialog=new(owner,this,_draft,()=>{RefreshMode();Status.Text=$"{_draft.FingerTargets.Count} target-specific finger points. Save and review before confirming.";});FingerDialog.Show();}catch(Exception error){Status.Text=error.Message;}};
            Layout.Add(Status);
            place.Clicked=()=>{try{Read();var time=ContactAuthoring.PlaceAtWrist(_motion,_draft,HoldOrientation.Value);Target.Text=string.Join(",",_draft.LocalTarget.Select(v=>v.ToString("R",CultureInfo.InvariantCulture)));Status.Text=$"Manual anchor placed from the wrist at {time:F3} s.";}catch(Exception e){Status.Text=e.Message;}};
            var buttons=Layout.AddRow();buttons.AddStretchCell();buttons.Add(new Button("Cancel"){Clicked=Close});Save=buttons.Add(new Button.Primary("Save suggestion"));
            Save.Clicked=async ()=>{
                try{Read();if(Sliding.Value&&Keys.HasUnappliedChanges)throw new ArgumentException("Add or update the edited sliding key before saving.");
                    if(HoldOrientation.Value&&_draft.LocalRotation is null)throw new ArgumentException("Use wrist at interval midpoint to place the orientation anchor.");
                    if(!HoldOrientation.Value)_draft.LocalRotation=null;
                    _draft.Review=ContactReview.Suggested;_draft.Reason="Manually placed/edited wrist contact; requires review against the video.";
                    Save.Enabled=false;await owner.SaveContactAsync(_motion,index,_draft);await EditorPipeline.SwitchToMainThread();if(this.IsValid())Close();}
                catch(Exception e){await EditorPipeline.SwitchToMainThread();if(this.IsValid()){Status.Text=e.Message;Save.Enabled=true;}}
            };
        }
        void Read()
        {
            _draft.Start=double.Parse(Start.Text,CultureInfo.InvariantCulture);_draft.End=double.Parse(End.Text,CultureInfo.InvariantCulture);
            _draft.LocalTarget=MotionDocument.A(Vector(Target));
        }
    }
}