Editor/HumanoidRetargeter/PreviewDialog.cs

Editor UI dialog for previewing retargeted animation clips on a target rig. It shows a skinned preview widget with play/pause, frame scrubber, optional source-ghost overlay, clip selection for multi-take files, and Confirm/Cancel controls (Confirm invokes a callback and optionally saves a preset).

File AccessNetworking
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using HumanoidRetargeter.Mapping;

namespace HumanoidRetargeter.Editor;

/// <summary>
/// Modal preview + confirmation step (design §6 "Preview + preset learning"): shows the
/// retargeted clip on the skinned <see cref="PreviewWidget"/> with play/pause + frame
/// scrubbing, then either confirms ("Looks good - Convert", which also offers saving the
/// mapping as a user preset when it came from manual edits or the blind auto-mapper) or
/// cancels. The conversion itself is the caller's job - this dialog only decides.
/// </summary>
public sealed class PreviewDialog : Dialog
{
	readonly PreviewWidget _preview;
	readonly FloatSlider _scrubber;
	readonly Label _frameLabel;
	readonly Button _playButton;
	readonly Button _ghostButton;
	readonly Checkbox _savePresetCheckbox;
	readonly List<HumanoidRetargeter.ClipResult> _clips;

	/// <summary>Invoked on confirm with whether "Save as profile for this rig" was checked.</summary>
	public Action<bool> Confirmed { get; set; }

	/// <summary>Invoked when the user cancels (closes without converting).</summary>
	public Action Cancelled { get; set; }

	bool _confirmed;

	/// <summary>
	/// Creates the dialog over already-solved clips of one source file.
	/// <paramref name="target"/> supplies the rig + preview model;
	/// <paramref name="mappingSource"/> controls the preset-saving checkbox (shown for
	/// Manual / AutoName / AutoTopology mappings, checked by default).
	/// <paramref name="sourceSkeleton"/>/<paramref name="sourceClip"/>/<paramref name="sourceMapping"/>
	/// (all optional) feed the "Show source" stick-skeleton ghost overlay - the toggle is
	/// disabled when they are absent or the mapping cannot anchor the ghost.
	/// </summary>
	public PreviewDialog(
		Widget parent, string fileName, IReadOnlyList<HumanoidRetargeter.ClipResult> clips,
		TargetPickers.ResolvedTarget target, MappingSource mappingSource,
		HumanoidRetargeter.Skeleton.Skeleton sourceSkeleton = null,
		HumanoidRetargeter.Skeleton.Clip sourceClip = null,
		MappingResult sourceMapping = null ) : base( parent )
	{
		_clips = clips.Where( c => c.Success && c.SolvedFrames is { Count: > 0 } ).ToList();

		Window.WindowTitle = $"Preview - {fileName}";
		Window.SetWindowIcon( "preview" );
		Window.SetModal( true, true );
		Window.MinimumWidth = 560;
		Window.MinimumHeight = 560;

		Layout = Layout.Column();
		Layout.Margin = 12;
		Layout.Spacing = 8;

		// ---- preview viewport ----------------------------------------------------------
		_preview = new PreviewWidget(
			this, target.Spec.Rig, target.PreviewModelPath, target.PreviewPositionScale,
			target.Spec.UpAxis );
		Layout.Add( _preview, 1 );

		if ( sourceSkeleton is not null && sourceClip is not null && sourceMapping is not null )
			_preview.SetSourceGhost( sourceSkeleton, sourceClip, sourceMapping );

		if ( !_preview.HasModel )
		{
			Layout.Add( new Label( this )
			{
				Text = "No compiled preview model exists for this target - the clip was solved, but cannot be shown skinned.",
				WordWrap = true,
			} );
		}

		// ---- clip picker (multi-take files) ---------------------------------------------
		if ( _clips.Count > 1 )
		{
			var clipRow = Layout.AddRow();
			clipRow.Spacing = 8;
			clipRow.Add( new Label( this ) { Text = "Clip:" } );
			var combo = clipRow.Add( new ComboBox( this ) { MinimumWidth = 220 } );
			for ( var i = 0; i < _clips.Count; i++ )
			{
				var clip = _clips[i];
				combo.AddItem( clip.ClipName, "movie", () => SelectClip( clip ), selected: i == 0 );
			}
			clipRow.AddStretchCell();
		}

		// ---- transport ------------------------------------------------------------------
		var transport = Layout.AddRow();
		transport.Spacing = 8;

		_playButton = transport.Add( new Button( "", "pause" ) { FixedWidth = 28, FixedHeight = 24 } );
		_playButton.Clicked = TogglePlay;

		_scrubber = transport.Add( new FloatSlider( this ), 1 );
		_scrubber.Minimum = 0;
		_scrubber.OnValueEdited = () => _preview.Scrub( (int)_scrubber.Value );

		_frameLabel = transport.Add( new Label( this ) { Text = "0 / 0", FixedWidth = 80 } );

		// Source-ghost toggle lives ON the preview (transport bar), not under Options:
		// it is a per-preview inspection aid, not a conversion setting.
		_ghostButton = transport.Add( new Button( "Show source", "compare" ) { IsToggle = true, FixedHeight = 24 } );
		_ghostButton.ToolTip = "Overlay the SOURCE clip as a semi-transparent stick skeleton, "
			+ "root-aligned and hip-height-scaled onto the target, synced to the scrub position.";
		_ghostButton.Enabled = _preview.HasSourceGhost;
		_ghostButton.Clicked = () => _preview.ShowSourceGhost = _ghostButton.IsChecked;

		_preview.FrameChanged = frame =>
		{
			_scrubber.Value = frame;
			UpdateFrameLabel();
		};

		// ---- confirm row ------------------------------------------------------------------
		var confirm = Layout.AddRow();
		confirm.Spacing = 8;

		if ( mappingSource is MappingSource.Manual or MappingSource.AutoName or MappingSource.AutoTopology )
		{
			_savePresetCheckbox = confirm.Add( new Checkbox( "Save as profile for this rig" ) { Value = true } );
			_savePresetCheckbox.ToolTip =
				"Stores the confirmed mapping as a user preset (keyed by the skeleton's signature), "
				+ "so this rig is recognized automatically next time.";
		}

		confirm.AddStretchCell();

		var cancel = confirm.Add( new Button( "Cancel" ) );
		cancel.Clicked = Close;

		var ok = confirm.Add( new Button.Primary( "Looks good - Convert" ) { Icon = "check" } );
		ok.Tint = Theme.Green;
		ok.Enabled = _clips.Count > 0;
		ok.Clicked = () =>
		{
			_confirmed = true;
			Confirmed?.Invoke( _savePresetCheckbox?.Value ?? false );
			Close();
		};

		if ( _clips.Count > 0 )
			SelectClip( _clips[0] );

		Window.Size = new Vector2( 640, 720 );
	}

	void SelectClip( HumanoidRetargeter.ClipResult clip )
	{
		_preview.SetClip( clip );
		_preview.Playing = true;
		_playButton.Icon = "pause";
		_scrubber.Maximum = Math.Max( _preview.FrameCount - 1, 0 );
		_scrubber.Value = 0;
		UpdateFrameLabel();
	}

	void TogglePlay()
	{
		_preview.Playing = !_preview.Playing;
		_playButton.Icon = _preview.Playing ? "pause" : "play_arrow";
	}

	void UpdateFrameLabel()
	{
		_frameLabel.Text = $"{_preview.CurrentFrame + 1} / {_preview.FrameCount}";
	}

	public override void OnDestroyed()
	{
		base.OnDestroyed();
		if ( !_confirmed )
			Cancelled?.Invoke();
	}
}