Editor/HumanoidMocap/MocapWristOffsets.cs
using System;
using System.Collections.Generic;
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
{
readonly List<WristPositionOffset> _wristOffsets=new();
Layout _wristOffsetRows;
int _wristEditRevision;
internal WristOffsetDialog OpenWristOffsetEditor(int index=-1)
{
if(_processing is not null||_editedMotion is null||!HandCaptureRetargeter.Supports(_editedMotion)||_editedMotion.Frames.Count<2)return null;
_playing=false;UpdateTransport();
var dialog=new WristOffsetDialog(this,index);dialog.Show();return dialog;
}
async Task SaveWristOffsetAsync(MotionDocument expected,int revision,int index,WristPositionOffset replacement)
{
if(expected!=_editedMotion||revision!=_wristEditRevision||_processing is not null)
throw new InvalidOperationException("The capture or target changed. Reopen wrist correction.");
var candidate=_wristOffsets.Select(e=>e.Copy()).ToList();
if(index < -1||index>=candidate.Count)throw new ArgumentOutOfRangeException(nameof(index));
if(replacement is null){if(index<0)return;candidate.RemoveAt(index);}
else if(index<0)candidate.Add(replacement.Copy());else candidate[index]=replacement.Copy();
WristPositionOffsets.Validate(candidate);
var start=expected.Frames[0].Time;var end=expected.Frames[^1].Time;
foreach(var edit in candidate)
{
var bone=expected.Bones.FindIndex(b=>b.Role==edit.Hand);
if(bone<0||edit.Start<start||edit.End>end)throw new ArgumentException("Choose a wrist and interval inside this capture.");
if(edit.Enabled&&!expected.Frames.Any(f=>f.Time<=edit.Start&&f.Evidence[bone]==JointEvidence.Reconstructed))
throw new ArgumentException("Start after this hand has been observed. A correction cannot create a never-tracked hand.");
}
var previous=_wristOffsets.Select(e=>e.Copy()).ToArray();var appliedRevision=++_wristEditRevision;
_wristOffsets.Clear();_wristOffsets.AddRange(candidate);RefreshContacts();
var previewTask=RefreshMocapPreviewAsync();var previewRevision=_previewRevision;
await previewTask;await EditorPipeline.SwitchToMainThread();
if(!this.IsValid()||_wristEditRevision!=appliedRevision||_editedMotion!=expected||_previewRevision!=previewRevision)return;
if(_bakedPreview is null)
{
var error=_captureStatus.Text;_wristOffsets.Clear();_wristOffsets.AddRange(previous);++_wristEditRevision;
RefreshContacts();await RefreshMocapPreviewAsync();throw new InvalidOperationException(error);
}
_captureStatus.Text=_adjustmentSaveError??"Manual wrist correction saved for this target. Review the interval before exporting; original capture is unchanged.";
}
async Task ChangeWristOffsetAsync(int index,bool remove)
{
try
{
var replacement=remove?null:_wristOffsets[index].Copy();if(replacement is not null)replacement.Enabled=!replacement.Enabled;
await SaveWristOffsetAsync(_editedMotion,_wristEditRevision,index,replacement);
}
catch(Exception error){await EditorPipeline.SwitchToMainThread();if(this.IsValid())_captureStatus.Text=error.Message;}
}
void RefreshWristOffsetRows()
{
if(_wristOffsetRows is null)return;
_wristOffsetRows.Clear(true);
var available=_editedMotion is {Frames.Count:>1}&&HandCaptureRetargeter.Supports(_editedMotion)&&_processing is null;
_wristOffsetRows.Add(new Button("Correct wrist…","edit_location"){Enabled=available,Clicked=()=>OpenWristOffsetEditor(),
ToolTip="Apply a reversible position offset over a video interval for this target. No prop or model rerun needed. Does not recover missing motion."});
for(var i=0;i<_wristOffsets.Count;i++)
{
var index=i;var edit=_wristOffsets[i];var row=_wristOffsetRows.AddRow();row.Spacing=6;
row.Add(new Label($"{(edit.Hand==BoneRole.HandL?"Left":"Right")} · {edit.Start:F2}–{edit.End:F2}s · {(edit.Enabled?"Manual offset":"Disabled")}",this),1);
row.Add(new IconButton("play_arrow",()=>{
var range=PlaybackRange;SeekPlaybackFraction((float)(((edit.Start+edit.End)*.5-range.Start)/(range.Last-range.Start)));
},this){FixedSize=24,IconSize=16,ToolTip="Review this interval"});
row.Add(new IconButton("edit",()=>OpenWristOffsetEditor(index),this){FixedSize=24,IconSize=16,Enabled=available,ToolTip="Edit wrist correction"});
row.Add(new IconButton(edit.Enabled?"visibility_off":"visibility",()=>_=ChangeWristOffsetAsync(index,false),this){FixedSize=24,IconSize=16,Enabled=available,ToolTip=edit.Enabled?"Disable correction":"Enable correction"});
row.Add(new IconButton("delete",()=>_=ChangeWristOffsetAsync(index,true),this){FixedSize=24,IconSize=16,Enabled=available,ToolTip="Remove correction; captured motion is preserved"});
}
}
internal sealed class WristOffsetDialog : Dialog
{
internal readonly LineEdit Start,End,Offset,Fade;
internal readonly Label Status;
internal readonly Button Save;
internal readonly ComboBox Hands;
internal BoneRole SelectedHand;
public WristOffsetDialog(RetargetWindow owner,int index):base(owner)
{
var motion=owner._editedMotion;var revision=owner._wristEditRevision;
var draft=index<0?new WristPositionOffset{Start=Math.Max(motion.Frames[0].Time,owner.PlaybackTime-.15),
End=Math.Min(motion.Frames[^1].Time,owner.PlaybackTime+.15)}:owner._wristOffsets[index].Copy();
SelectedHand=draft.Hand;
if(!motion.Bones.Any(b=>b.Role==SelectedHand))SelectedHand=motion.Bones.First(b=>b.Role is BoneRole.HandL or BoneRole.HandR).Role.Value;
Window.WindowTitle="Correct wrist position";Window.SetWindowIcon("edit_location");
Window.Size=new Vector2(500,360);Window.MinimumSize=new Vector2(460,340);
SetStyles($"background-color: {Theme.WidgetBackground.Hex}; color: {Theme.Text.Hex};");
Layout=Layout.Column();Layout.Margin=16;Layout.Spacing=10;
Layout.Add(new Label("Manual position edit for this target. Fingers and wrist rotation stay captured. Confirmed prop contacts and arm reach limits take priority.",this){WordWrap=true});
Hands=Layout.Add(new ComboBox(this));
foreach(var role in new[]{BoneRole.HandL,BoneRole.HandR})if(motion.Bones.Any(b=>b.Role==role))
Hands.AddItem(role==BoneRole.HandL?"Left wrist":"Right wrist",onSelected:()=>SelectedHand=role,selected:role==SelectedHand);
string Number(double v)=>v.ToString("R",CultureInfo.InvariantCulture);
LineEdit Input(string label,string value){var row=Layout.AddRow();row.Spacing=8;row.Add(new Label(label,this){FixedWidth=160});return row.Add(new LineEdit(this){Text=value},1);}
Start=Input("Start at video (s)",Number(draft.Start));End=Input("End at video (s)",Number(draft.End));
Offset=Input("Offset X, Y, Z (cm)",string.Join(",",draft.Offset.Select(v=>Number(v*100))));
Fade=Input("Blend at edges (s)",Number(draft.FadeSeconds));
Layout.Add(new Label("X right · Y up · +Z toward the capture camera. This is authored correction, including during tracking loss.",this){WordWrap=true});
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});Save=buttons.Add(new Button.Primary("Apply correction"));
Save.Clicked=async ()=>{
try
{
var offset=Offset.Text.Split(',').Select(v=>float.Parse(v.Trim(),CultureInfo.InvariantCulture)/100).ToArray();
if(offset.Length!=3)throw new ArgumentException("Enter three comma-separated centimetre offsets.");
draft.Offset=offset;draft.Hand=SelectedHand;
draft.Start=double.Parse(Start.Text,CultureInfo.InvariantCulture);draft.End=double.Parse(End.Text,CultureInfo.InvariantCulture);
draft.FadeSeconds=double.Parse(Fade.Text,CultureInfo.InvariantCulture);
Save.Enabled=false;await owner.SaveWristOffsetAsync(motion,revision,index,draft);await EditorPipeline.SwitchToMainThread();if(this.IsValid())Close();
}
catch(Exception error){await EditorPipeline.SwitchToMainThread();if(this.IsValid()){Status.Text=error.Message;Save.Enabled=true;}}
};
}
}
}