Editor/CitizenRetarget/CitizenRetargetWindow.cs
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using System.IO.Compression;
#nullable enable
namespace Editor.CitizenRetarget;
[EditorApp( CitizenRetargetPluginInfo.AppTitle, "sports_mma", "Retarget humanoid FBX clips onto Citizen with queueing, mapping, and preview" )]
public sealed class CitizenRetargetWindow : BaseWindow
{
private sealed class BackgroundJobProgressReporter
{
private readonly BackgroundJobState _job;
public BackgroundJobProgressReporter( BackgroundJobState job )
{
_job = job;
}
public void ReportDetail( string detail )
{
_job.Detail = detail;
}
public void ReportProgress( float progress, string? detail = null )
{
_job.IsIndeterminate = false;
_job.Progress = Math.Clamp( progress, 0f, 1f );
if ( !string.IsNullOrWhiteSpace( detail ) )
_job.Detail = detail;
}
}
private sealed class BackgroundJobState
{
public string Id { get; init; } = Guid.NewGuid().ToString( "N" );
public string Scope { get; init; } = string.Empty;
public string Title { get; init; } = string.Empty;
public string Detail { get; set; } = string.Empty;
public bool IsIndeterminate { get; set; } = true;
public float Progress { get; set; }
public bool BlocksInteraction { get; init; } = true;
public bool ShowInJobsStrip { get; init; } = true;
public Task? Task { get; set; }
public Action? ApplySuccess { get; set; }
public Action<Exception>? ApplyError { get; set; }
public Exception? Error { get; set; }
public bool CompletedNotified { get; set; }
}
private sealed class EnvironmentDependencyRow
{
public Label Status { get; init; } = null!;
public Label Title { get; init; } = null!;
public Label Summary { get; init; } = null!;
public Label Detail { get; init; } = null!;
public Widget FieldHost { get; init; } = null!;
public Widget Actions { get; init; } = null!;
}
private sealed class ScanJobResult
{
public List<RetargetClipDescriptor> Clips { get; init; } = new();
public RetargetSourceInspection Inspection { get; init; } = new();
public List<RetargetSlotAssignmentState> MappingState { get; init; } = new();
public string DetectedSourceProfilePath { get; init; } = string.Empty;
public string DetectedSourceProfileDisplayName { get; init; } = string.Empty;
public string DetectedMappingProfilePath { get; init; } = string.Empty;
}
private sealed class TargetAnimationLoadResult
{
public List<RetargetTargetAnimationEntry> ImportedTargets { get; init; } = new();
public List<RetargetTargetAnimationEntry> BuiltInTargets { get; init; } = new();
public int DeletedCount { get; init; }
}
private sealed class ClipBrowserListView : ListView
{
public Func<bool>? CanAddToQueue { get; set; }
public Action? AddToQueue { get; set; }
public ClipBrowserListView( Widget parent ) : base( parent )
{
}
protected override void OnMousePress( MouseEvent e )
{
var item = GetItemAt( e.LocalPosition );
if ( e.LeftMouseButton && item is not null && CitizenRetargetWindow.RectContainsPoint( CitizenRetargetWindow.GetOverflowIndicatorRect( item.Rect ), e.LocalPosition ) )
{
if ( item.Object is not null )
SelectItem( item.Object, true, true );
if ( CanAddToQueue?.Invoke() == true && AddToQueue is not null )
{
var menu = new ContextMenu();
menu.AddOption( "Add To Retarget Queue", action: AddToQueue );
menu.OpenAtCursor();
e.Accepted = true;
return;
}
}
base.OnMousePress( e );
}
protected override void OnMouseRightClick( MouseEvent e )
{
base.OnMouseRightClick( e );
if ( CanAddToQueue?.Invoke() != true || AddToQueue is null )
return;
var menu = new ContextMenu();
menu.AddOption( "Add To Retarget Queue", action: AddToQueue );
menu.OpenAtCursor();
e.Accepted = true;
}
}
private sealed class TargetAnimationListView : ListView
{
public Func<RetargetTargetAnimationEntry?>? ResolveContextTarget { get; set; }
public Func<RetargetTargetAnimationEntry, bool>? CanDeleteImportedAnimation { get; set; }
public Action<RetargetTargetAnimationEntry>? DeleteImportedAnimation { get; set; }
public TargetAnimationListView( Widget parent ) : base( parent )
{
}
protected override void OnMousePress( MouseEvent e )
{
var item = GetItemAt( e.LocalPosition );
if ( e.LeftMouseButton && item is not null && CitizenRetargetWindow.RectContainsPoint( CitizenRetargetWindow.GetOverflowIndicatorRect( item.Rect ), e.LocalPosition ) )
{
if ( item.Object is not null )
SelectItem( item.Object, true, true );
var target = ResolveContextTarget?.Invoke();
if ( target is not null
&& target.IsImported
&& !target.IsReadOnly
&& DeleteImportedAnimation is not null
&& CanDeleteImportedAnimation?.Invoke( target ) != false )
{
var menu = new ContextMenu();
menu.AddOption( "Delete Imported Animation", action: () => DeleteImportedAnimation( target ) );
menu.OpenAtCursor();
e.Accepted = true;
return;
}
}
base.OnMousePress( e );
}
protected override void OnMouseRightClick( MouseEvent e )
{
base.OnMouseRightClick( e );
var target = ResolveContextTarget?.Invoke();
if ( target is null )
return;
if ( !target.IsImported || target.IsReadOnly || DeleteImportedAnimation is null || CanDeleteImportedAnimation?.Invoke( target ) == false )
return;
var menu = new ContextMenu();
menu.AddOption( "Delete Imported Animation", action: () => DeleteImportedAnimation( target ) );
menu.OpenAtCursor();
e.Accepted = true;
}
}
private sealed class LinearProgressIndicator : Widget
{
public float Progress { get; set; }
public bool IsIndeterminate { get; set; }
public Color AccentColor { get; set; } = Theme.Primary;
public LinearProgressIndicator( Widget parent ) : base( parent )
{
FixedHeight = 8f;
OnPaintOverride = PaintIndicator;
}
private bool PaintIndicator()
{
var rect = LocalRect;
if ( rect.Width <= 0 || rect.Height <= 0 )
return false;
var radius = MathF.Min( 4f, rect.Height * 0.5f );
Paint.ClearPen();
Paint.SetBrush( Theme.ControlBackground.Lighten( 0.1f ) );
Paint.DrawRect( rect, radius );
Paint.SetBrush( AccentColor );
if ( IsIndeterminate )
{
var segmentWidth = MathF.Max( rect.Width * 0.24f, 36f );
var travel = rect.Width + segmentWidth;
var offset = (RealTime.Now * 160f) % travel - segmentWidth;
var left = MathF.Max( rect.Left, offset );
var right = MathF.Min( rect.Right, offset + segmentWidth );
if ( right > left )
{
var segmentRect = new Rect( left, rect.Top, right - left, rect.Height );
Paint.DrawRect( segmentRect, radius );
}
}
else
{
var clamped = Math.Clamp( Progress, 0f, 1f );
if ( clamped > 0f )
{
var fillRect = rect;
fillRect.Width *= clamped;
Paint.DrawRect( fillRect, radius );
}
}
return false;
}
}
private readonly CitizenRetargetPipeline _pipeline = new();
private readonly RetargetEnvironmentDiagnosticsService _environmentDiagnostics = new();
private RetargetToolSettings _toolSettings = new();
private CitizenRetargetJob _job = new();
private RetargetSourceProfile _sourceProfile = new();
private RetargetMappingProfile _mappingProfile = new();
private RetargetSourceLibraryRef _activeSourceLibrary = new();
private RetargetSessionState _session = new();
private RetargetSourceInspection? _inspection;
private List<RetargetClipDescriptor> _allClips = new();
private List<RetargetSlotAssignmentState> _mappingState = new();
private readonly List<RetargetQueueItem> _queueItems = new();
private readonly List<BackgroundJobState> _backgroundJobs = new();
private readonly Dictionary<string, Vector3> _sourceFacingEulerOverrides = new( StringComparer.OrdinalIgnoreCase );
private readonly HashSet<string> _sourceFacingManualOverrideEnabled = new( StringComparer.OrdinalIgnoreCase );
private RetargetSlotAssignmentState? _selectedSlot;
private NativeAuditBoneInfo? _selectedSourceBone;
private RetargetImportResult? _latestResult;
private RetargetImportResult? _selectedCompareResult;
private RetargetImportResult? _inspectedRunResult;
private RetargetEnvironmentReport? _environmentReport;
private RetargetTargetAnimationEntry? _selectedTargetAnimation;
private readonly List<RetargetTargetPresetRef> _targetPresets = new();
private bool _advancedSettingsVisible;
private bool _showBuiltInTargetAnimations;
private bool _builtInTargetAnimationsLoaded;
private bool _builtInTargetAnimationsLoadRequested;
private bool _showOpenExistingTargetFlow;
private bool _showCreateTargetFlow;
private bool _queueRunning;
private bool _cancelQueueRequested;
private bool _jobInFlight;
private bool _suppressHistorySelection;
private bool _suppressSourcePresetSync;
private bool _suppressSourceFacingFieldSync;
private bool _suppressSourceFacingManualOverrideSync;
private bool _startQueueAfterEnvironmentCheck;
private Vector3 _sourceFacingEulerDegrees;
private string _latestQueueFailureMessage = string.Empty;
private string _latestSetupFailureMessage = string.Empty;
private string _lastScanStatus = "No source scan has run yet.";
private string _lastScanDetails = string.Empty;
private string _lastScanSourcePath = string.Empty;
private DateTime? _lastScanCompletedAt;
private int _jobsSpinnerFrame;
private Label _statusLabel = null!;
private Label _summaryLabel = null!;
private Label _clipSummaryLabel = null!;
private Label _queueSummaryLabel = null!;
private Label _artifactSummaryLabel = null!;
private Label _environmentSummaryLabel = null!;
private Label _environmentDetailsLabel = null!;
private EnvironmentDependencyRow _environmentSettingsRow = null!;
private EnvironmentDependencyRow _environmentBackendRow = null!;
private EnvironmentDependencyRow _environmentBlenderRow = null!;
private EnvironmentDependencyRow _environmentRokokoRow = null!;
private EnvironmentDependencyRow _environmentNativeFbxRow = null!;
private EnvironmentDependencyRow _environmentTargetRow = null!;
private Label _runHealthLabel = null!;
private Label _runNextStepLabel = null!;
private Label _issuesLabel = null!;
private Label _targetSummaryLabel = null!;
private Label _builtInTargetSummaryLabel = null!;
private Label _homeQueueSummaryLabel = null!;
private Label _resultMetaLabel = null!;
private Label _resultDetailsLabel = null!;
private Label _currentTargetWorkspaceLabel = null!;
private Label _newTargetVmdlPreviewLabel = null!;
private Label _newTargetFolderPreviewLabel = null!;
private Label _newTargetPrefixPreviewLabel = null!;
private Label _sourceBusyLabel = null!;
private Label _targetBusyLabel = null!;
private Label _queueBusyLabel = null!;
private Label _sourceFacingSummaryLabel = null!;
private Label _sourceFacingHintLabel = null!;
private Label _jobsStripLabel = null!;
private Label _jobsStripProgressLabel = null!;
private LinearProgressIndicator _jobsStripProgressBar = null!;
private Label _poseCompensationSummaryLabel = null!;
private Label _mappingSelectionTitleLabel = null!;
private Label _mappingSelectionSummaryLabel = null!;
private Label _mappingSelectionHintLabel = null!;
private LineEdit _sourcePath = null!;
private LineEdit _mappingProfilePath = null!;
private LineEdit _targetVmdlPath = null!;
private LineEdit _targetPosePresetId = null!;
private LineEdit _outputFolder = null!;
private LineEdit _sequencePrefix = null!;
private LineEdit _clipFilter = null!;
private LineEdit _boneFilter = null!;
private LineEdit _mappingSearch = null!;
private LineEdit _newTargetName = null!;
private LineEdit _sourceFacingTiltField = null!;
private LineEdit _sourceFacingRollField = null!;
private LineEdit _sourceFacingFacingField = null!;
private LineEdit _backendPath = null!;
private LineEdit _blenderPath = null!;
private LineEdit _rokokoAddonPath = null!;
private ComboBox _rootMotionMode = null!;
private ComboBox _sourceProfilePreset = null!;
private ComboBox _mappingSourceProfilePreset = null!;
private ComboBox _mappingFilter = null!;
private Checkbox _importHands = null!;
private Button _scanButton = null!;
private Button _retryFailedQueueButton = null!;
private Button _clearFinishedQueueButton = null!;
private Button _clearQueueButton = null!;
private Button _scanEnvironmentButton = null!;
private Button _openEnvironmentBackendButton = null!;
private Button _browseEnvironmentBackendButton = null!;
private Button _browseBlenderButton = null!;
private Button _autoScanRokokoAddonButton = null!;
private Button _browseRokokoAddonButton = null!;
private Button _openRokokoAddonButton = null!;
private Button _clearRokokoAddonButton = null!;
private Button _downloadNativeHelperButton = null!;
private Button _openNativeHelperFolderButton = null!;
private Button _copyEnvironmentDiagnosticsButton = null!;
private Button _exportSupportBundleButton = null!;
private Button _useHistoryAsCompareButton = null!;
private Button _assignBoneButton = null!;
private Button _clearSlotButton = null!;
private Button _resetSlotButton = null!;
private Button _saveMappingButton = null!;
private Button _toggleAdvancedButton = null!;
private Button _openRunDirButton = null!;
private Button _openManifestButton = null!;
private Button _openRawExportButton = null!;
private Button _openImportedAnimationButton = null!;
private Button _openGeneratedModelButton = null!;
private Button _openRunLogButton = null!;
private Button _clearHomeResultButton = null!;
private Button _inspectHomeResultInRunsButton = null!;
private Button _openHomeGeneratedModelButton = null!;
private Button _addClipToQueueButton = null!;
private Button _openExistingTargetButton = null!;
private Button _createTargetButton = null!;
private Button _toggleBuiltInTargetAnimationsButton = null!;
private Button _beginOpenExistingTargetButton = null!;
private Button _beginCreateTargetButton = null!;
private Button _browseSourceButton = null!;
private Button _openPoseCompensationButton = null!;
private Checkbox _sourceFacingManualOverrideCheckbox = null!;
private Button _sourceFacingResetButton = null!;
private Button _sourceFacingRotate180Button = null!;
private Button _sourceFacingRotateLeftButton = null!;
private Button _sourceFacingRotateRightButton = null!;
private Button _sourceFacingTiltForwardButton = null!;
private Button _sourceFacingTiltBackButton = null!;
private Button _sourceFacingRollLeftButton = null!;
private Button _sourceFacingRollRightButton = null!;
private Button _editCompensationDataButton = null!;
private Button _runHomeQueueButton = null!;
private Button _removeHomeQueueItemButton = null!;
private Button _clearHomeQueueButton = null!;
private Widget _advancedSettingsHost = null!;
private Widget _targetWorkspaceIntroHost = null!;
private Widget _targetOpenExistingHost = null!;
private Widget _targetCreateHost = null!;
private Widget _builtInTargetSectionHost = null!;
private Widget _sourceFacingCard = null!;
private Widget _jobsStripHost = null!;
private ClipBrowserListView _clipList = null!;
private ListView _sourceBoneList = null!;
private ListView _mappingList = null!;
private ListView _queueList = null!;
private ListView _historyList = null!;
private ListView _targetPresetList = null!;
private ListView _targetAnimationList = null!;
private ListView _builtInTargetAnimationList = null!;
private ListView _homeQueueList = null!;
private TextEdit _runLogOutput = null!;
private RetargetLivePreviewWidget _livePreview = null!;
private RetargetSourceFacingPreviewWidget _sourceFacingPreview = null!;
public CitizenRetargetWindow()
{
DeleteOnClose = true;
WindowTitle = CitizenRetargetPluginInfo.DisplayVersion;
SetWindowIcon( "sports_mma" );
Size = new Vector2( 1680, 980 );
_toolSettings = RetargetToolSettings.Load();
_job = _pipeline.LoadOrCreateJob();
_activeSourceLibrary = RetargetSourceLibraryResolver.FromJob( _job );
_session = CreateSessionStateFromJob( _job );
LoadProfilesFromJob();
BuildUi();
TryHydrateFromJob();
Show();
}
[Menu( "Editor", "Tools/CARL" )]
public static void OpenFromMenu()
{
new CitizenRetargetWindow();
}
[Menu( "Editor", "CARL/Open Retargeter" )]
public static void OpenFromLibraryMenu()
{
OpenFromMenu();
}
[EditorEvent.Hotload]
public void OnHotload()
{
BuildUi();
TryHydrateFromJob();
}
protected override void OnClosed()
{
base.OnClosed();
_pipeline.Dispose();
}
[EditorEvent.Frame]
private void ProcessQueue()
{
if ( !_queueRunning || _jobInFlight )
return;
if ( _cancelQueueRequested )
{
_queueRunning = false;
_cancelQueueRequested = false;
var removedCount = _queueItems.Count;
_queueItems.Clear();
RefreshQueueList();
SetStatus( removedCount > 0 ? $"Queue cleared after the current clip finished. Removed {removedCount} item(s)." : "Queue cleared." );
UpdateButtons();
return;
}
var next = _queueItems.FirstOrDefault( item => item.Status == "pending" );
if ( next is null )
{
FinalizeQueueIfIdle();
return;
}
RunQueuedItem( next );
}
private void FinalizeQueueIfIdle()
{
if ( !_queueRunning || _jobInFlight )
return;
if ( _queueItems.Any( item => item.Status == "pending" || item.Status == "running" ) )
return;
_queueRunning = false;
var completedCount = _queueItems.Count( item => item.Status == "completed" );
var failedCount = _queueItems.Count( item => item.Status == "failed" );
if ( failedCount > 0 )
{
var summary = $"Queue finished with {failedCount} failure(s) and {completedCount} completed run(s).";
SetStatus( string.IsNullOrWhiteSpace( _latestQueueFailureMessage ) ? summary : $"{summary} Last error: {_latestQueueFailureMessage}" );
}
else if ( completedCount > 0 )
{
_queueItems.Clear();
RefreshQueueList();
SetStatus( $"Queue finished. Completed {completedCount} run(s) and cleared the queue." );
}
else
{
SetStatus( "Queue finished." );
}
UpdateButtons();
}
[EditorEvent.Frame]
private void ProcessBackgroundJobs()
{
if ( _backgroundJobs.Count == 0 )
return;
_jobsSpinnerFrame++;
var completedJobs = new List<BackgroundJobState>();
foreach ( var job in _backgroundJobs.ToList() )
{
if ( job.Task is null || !job.Task.IsCompleted || job.CompletedNotified )
continue;
job.CompletedNotified = true;
try
{
if ( job.Error is not null )
job.ApplyError?.Invoke( job.Error );
else
job.ApplySuccess?.Invoke();
}
catch ( Exception exception )
{
SetStatus( $"Background job apply failed: {exception.Message}" );
}
completedJobs.Add( job );
}
foreach ( var job in completedJobs )
_backgroundJobs.Remove( job );
RefreshBackgroundJobUi();
UpdateButtons();
}
private void BuildUi()
{
Layout = Layout.Column();
Layout.Margin = 8;
Layout.Spacing = 8;
Layout.Clear( true );
_statusLabel = new Label( "Ready." );
_summaryLabel = new Label( string.Empty ) { WordWrap = true };
var workspaceTabs = Layout.Add( new TabWidget( this ), 1 );
workspaceTabs.StateCookie = "CitizenRetarget.WorkspaceTabs";
workspaceTabs.AddPage( "Home", "home", BuildHomeTab( workspaceTabs ) );
workspaceTabs.AddPage( "Mapping", "account_tree", BuildMappingTab( workspaceTabs ) );
workspaceTabs.AddPage( "Diagnostics", "history", BuildRunsTab( workspaceTabs ) );
_jobsStripHost = Layout.Add( new Widget( this ) );
_jobsStripHost.Layout = Layout.Column();
_jobsStripHost.Layout.Margin = 8;
_jobsStripHost.Layout.Spacing = 6;
var jobsStripRow = _jobsStripHost.Layout.AddRow();
jobsStripRow.Spacing = 8;
_jobsStripLabel = jobsStripRow.Add( CreateSupportingLabel( "No background jobs.", false ) );
jobsStripRow.AddStretchCell();
_jobsStripProgressLabel = jobsStripRow.Add( CreateMetricLabel( string.Empty ) );
_jobsStripProgressBar = _jobsStripHost.Layout.Add( new LinearProgressIndicator( _jobsStripHost ) );
_jobsStripProgressBar.AccentColor = Theme.Primary;
_jobsStripHost.Hide();
ApplyJobToControls();
ApplySettingsToControls();
RefreshClipList();
RefreshSourceBoneList();
RefreshMappingList();
RefreshQueueList();
RefreshHistoryList();
RefreshResultList();
RefreshResultSurfaces();
RefreshEnvironmentDiagnostics();
UpdateDiagnostics();
RefreshClipSummary();
RefreshBackgroundJobUi();
UpdateButtons();
}
private Widget BuildHomeTab( Widget parent )
{
var pane = new Widget( parent );
pane.Layout = Layout.Column();
pane.Layout.Margin = 0;
pane.Layout.Spacing = 8;
var workspace = pane.Layout.Add( new Splitter( pane ), 1 );
workspace.IsHorizontal = true;
var sourcePane = new Widget( workspace );
sourcePane.Layout = Layout.Column();
sourcePane.Layout.Margin = 0;
sourcePane.Layout.Spacing = 8;
workspace.AddWidget( sourcePane );
workspace.SetStretch( 0, 4 );
var previewPane = new Widget( workspace );
previewPane.Layout = Layout.Column();
previewPane.Layout.Margin = 0;
previewPane.Layout.Spacing = 8;
workspace.AddWidget( previewPane );
workspace.SetStretch( 1, 7 );
var resultPane = new Widget( workspace );
resultPane.Layout = Layout.Column();
resultPane.Layout.Margin = 0;
resultPane.Layout.Spacing = 8;
workspace.AddWidget( resultPane );
workspace.SetStretch( 2, 4 );
var sourceCard = CreateCard( sourcePane, "Source" );
var sourcePathRow = sourceCard.Layout.AddRow();
sourcePathRow.Spacing = 6;
sourcePathRow.Add( CreateFieldLabel( "FBX" ) );
_sourcePath = sourcePathRow.Add( StyleInteractiveField( new LineEdit() ) );
_sourcePath.PlaceholderText = @"C:\...\humanoid.fbx";
_sourcePath.TextEdited += _ => SyncJobFromControls();
_browseSourceButton = sourcePathRow.Add( StyleSecondaryActionButton( new Button( "Browse" ) { Clicked = BrowseSourceFbx } ) );
var sourcePresetRow = sourceCard.Layout.AddRow();
sourcePresetRow.Spacing = 6;
sourcePresetRow.Add( CreateFieldLabel( "Preset" ) );
_sourceProfilePreset = sourcePresetRow.Add( CreateSourcePresetCombo() );
_sourceProfilePreset.ItemChanged += () => OnSourcePresetChanged( _sourceProfilePreset );
var sourceActions = sourceCard.Layout.AddRow();
sourceActions.Spacing = 6;
_scanButton = sourceActions.Add( StylePrimaryActionButton( new Button.Primary( "Scan" ) { Clicked = BeginScanAndInspectAsync } ) );
_toggleAdvancedButton = sourceActions.Add( StyleSecondaryActionButton( new Button( "Show Advanced" ) { Clicked = ToggleAdvancedSettings } ) );
sourceActions.AddStretchCell();
_sourceBusyLabel = sourceCard.Layout.Add( CreateCaptionLabel( string.Empty, true ) );
_advancedSettingsHost = sourceCard.Layout.Add( new Widget( sourceCard ) );
_advancedSettingsHost.Layout = Layout.Column();
_advancedSettingsHost.Layout.Margin = 0;
_advancedSettingsHost.Layout.Spacing = 6;
AddLabeledLineEdit( _advancedSettingsHost, "Mapping Profile", out _mappingProfilePath, CitizenTargetProfile.DefaultMappingProfileAssetPath, SyncJobFromControls );
AddLabeledLineEdit( _advancedSettingsHost, "Target VMDL", out _targetVmdlPath, CitizenTargetProfile.DefaultTargetVmdlPath, SyncJobFromControls );
AddLabeledLineEdit( _advancedSettingsHost, "Target Pose Preset", out _targetPosePresetId, CitizenTargetProfile.DefaultTargetPosePresetId, SyncJobFromControls );
AddLabeledLineEdit( _advancedSettingsHost, "Animation Folder", out _outputFolder, CitizenTargetProfile.DefaultOutputAnimationFolder, SyncJobFromControls );
AddLabeledLineEdit( _advancedSettingsHost, "Sequence Prefix", out _sequencePrefix, CitizenTargetProfile.DefaultSequencePrefix, SyncJobFromControls );
var rootRow = _advancedSettingsHost.Layout.AddRow();
rootRow.Spacing = 6;
rootRow.Add( CreateFieldLabel( "Root motion" ) );
_rootMotionMode = rootRow.Add( StyleInteractiveField( new ComboBox() ) );
_rootMotionMode.AddItem( "Keep" );
_rootMotionMode.AddItem( "In Place" );
_rootMotionMode.ItemChanged += SyncJobFromControls;
_importHands = _advancedSettingsHost.Layout.Add( new Checkbox( "Enable finger / hand slots" ) );
_importHands.StateChanged += _ => { SyncJobFromControls(); RebuildAutoMap(); };
_advancedSettingsHost.Layout.Add( new Label.Subtitle( "Target pose and compensation" ) );
_poseCompensationSummaryLabel = _advancedSettingsHost.Layout.Add( CreateSupportingLabel( "Pose preset details will appear here." ) );
var poseActions = _advancedSettingsHost.Layout.AddRow();
poseActions.Spacing = 6;
_openPoseCompensationButton = poseActions.Add( StyleSecondaryActionButton( new Button( "Open Compensation Data" ) { Clicked = OpenPoseCompensationData } ) );
poseActions.AddStretchCell();
SetAdvancedSettingsVisible( _advancedSettingsVisible );
var clipCard = CreateCard( sourcePane, "Clips", 1 );
AddLabeledLineEdit( clipCard, "Filter", out _clipFilter, "Filter clips", RefreshClipList );
_clipSummaryLabel = clipCard.Layout.Add( CreatePlaceholderLabel( "Scan a source FBX to load clips and start a queue." ) );
_clipList = clipCard.Layout.Add( StyleInteractiveList( new ClipBrowserListView( clipCard )
{
CanAddToQueue = () => _clipList.SelectedItems.OfType<RetargetClipDescriptor>().Any() && !_queueRunning && !_jobInFlight && !HasActiveBackgroundJob( "source" ) && !HasActiveBackgroundJob( "queue" ),
AddToQueue = AddSelectedClipsToQueue
} ), 1 );
_clipList.MultiSelect = true;
_clipList.ItemSize = new Vector2( 0, 34 );
_clipList.ItemPaint = PaintClipItem;
_clipList.ItemSelected = _ => OnClipSelectionChanged();
var clipActions = clipCard.Layout.AddRow();
clipActions.Spacing = 6;
_addClipToQueueButton = clipActions.Add( StylePrimaryActionButton( new Button.Primary( "Add To Retarget Queue" ) { Clicked = AddSelectedClipsToQueue } ) );
clipActions.AddStretchCell();
var previewCard = CreateCard( previewPane, "Preview", 4 );
_livePreview = previewCard.Layout.Add( new RetargetLivePreviewWidget( previewCard ), 1 );
var homeQueueCard = CreateCard( previewPane, "Queue", 2 );
_homeQueueSummaryLabel = homeQueueCard.Layout.Add( CreatePlaceholderLabel( "No queued clips yet." ) );
_queueBusyLabel = homeQueueCard.Layout.Add( CreateCaptionLabel( string.Empty, true ) );
var homeQueueActions = homeQueueCard.Layout.AddRow();
homeQueueActions.Spacing = 6;
_runHomeQueueButton = homeQueueActions.Add( StylePrimaryActionButton( new Button.Primary( "Run Queue" ) { Clicked = RunQueue } ) );
_removeHomeQueueItemButton = homeQueueActions.Add( StyleSecondaryActionButton( new Button( "Remove Selected" ) { Clicked = RemoveSelectedHomeQueueItems } ) );
_clearHomeQueueButton = homeQueueActions.Add( StyleSubtleActionButton( new Button( "Clear Queue" ) { Clicked = ClearQueue } ) );
homeQueueCard.Layout.AddSeparator();
_homeQueueList = homeQueueCard.Layout.Add( StyleInteractiveList( new ListView( homeQueueCard ) ), 1 );
_homeQueueList.ItemSize = new Vector2( 0, 72 );
_homeQueueList.ItemPaint = PaintQueueItem;
_homeQueueList.ItemSelected = _ => UpdateButtons();
var targetPresetCard = CreateCard( resultPane, "Target Workspace" );
_currentTargetWorkspaceLabel = targetPresetCard.Layout.Add( CreateSupportingLabel( "Open an existing target or create a new Citizen fork for imported animations." ) );
_targetWorkspaceIntroHost = targetPresetCard.Layout.Add( new Widget( targetPresetCard ) );
_targetWorkspaceIntroHost.Layout = Layout.Column();
_targetWorkspaceIntroHost.Layout.Margin = 0;
_targetWorkspaceIntroHost.Layout.Spacing = 6;
var targetEntryActions = _targetWorkspaceIntroHost.Layout.AddRow();
targetEntryActions.Spacing = 6;
_beginCreateTargetButton = targetEntryActions.Add( StylePrimaryActionButton( new Button.Primary( "Create New Target" ) { Clicked = BeginCreateTargetFlow } ) );
_beginOpenExistingTargetButton = targetEntryActions.Add( StyleSecondaryActionButton( new Button( "Open Existing Target" ) { Clicked = BeginOpenExistingTargetFlow } ) );
targetEntryActions.AddStretchCell();
_targetOpenExistingHost = targetPresetCard.Layout.Add( new Widget( targetPresetCard ) );
_targetOpenExistingHost.Layout = Layout.Column();
_targetOpenExistingHost.Layout.Margin = 0;
_targetOpenExistingHost.Layout.Spacing = 6;
_targetOpenExistingHost.Layout.Add( new Label.Subtitle( "Existing targets" ) );
_targetPresetList = _targetOpenExistingHost.Layout.Add( StyleInteractiveList( new ListView( targetPresetCard ) ) );
_targetPresetList.ItemSize = new Vector2( 0, 28 );
_targetPresetList.ItemPaint = PaintTargetPresetItem;
_targetPresetList.ItemSelected = _ => UpdateButtons();
var targetPresetActions = _targetOpenExistingHost.Layout.AddRow();
targetPresetActions.Spacing = 6;
_openExistingTargetButton = targetPresetActions.Add( StylePrimaryActionButton( new Button.Primary( "Open Existing Target" ) { Clicked = OpenSelectedTargetPreset } ) );
targetPresetActions.Add( StyleSubtleActionButton( new Button( "Back" ) { Clicked = ResetTargetWorkspaceFlow } ) );
targetPresetActions.AddStretchCell();
_targetCreateHost = targetPresetCard.Layout.Add( new Widget( targetPresetCard ) );
_targetCreateHost.Layout = Layout.Column();
_targetCreateHost.Layout.Margin = 0;
_targetCreateHost.Layout.Spacing = 6;
_targetCreateHost.Layout.Add( new Label.Subtitle( "New target" ) );
var newTargetRow = _targetCreateHost.Layout.AddRow();
newTargetRow.Spacing = 6;
_newTargetName = newTargetRow.Add( StyleInteractiveField( new LineEdit() ) );
_newTargetName.PlaceholderText = "citizen_retarget";
_newTargetName.TextEdited += _ => UpdateNewTargetPreview();
_createTargetButton = newTargetRow.Add( StylePrimaryActionButton( new Button( "Create New" ) { Clicked = CreateNewTargetPreset } ) );
newTargetRow.Add( StyleSubtleActionButton( new Button( "Back" ) { Clicked = ResetTargetWorkspaceFlow } ) );
_newTargetVmdlPreviewLabel = _targetCreateHost.Layout.Add( CreateSupportingLabel( "VMDL: -" ) );
_newTargetFolderPreviewLabel = _targetCreateHost.Layout.Add( CreateSupportingLabel( "Animation folder: -" ) );
_newTargetPrefixPreviewLabel = _targetCreateHost.Layout.Add( CreateSupportingLabel( "Sequence prefix: -" ) );
var resultHistoryCard = CreateCard( resultPane, "Target Results", 1 );
_resultMetaLabel = resultHistoryCard.Layout.Add( CreateSupportingLabel( "No active target result yet. Pick a target animation or run a retarget." ) );
_targetBusyLabel = resultHistoryCard.Layout.Add( CreateCaptionLabel( string.Empty, true ) );
_targetBusyLabel.Hide();
var resultActions = resultHistoryCard.Layout.AddRow();
resultActions.Spacing = 6;
_inspectHomeResultInRunsButton = resultActions.Add( StyleSecondaryActionButton( new Button( "Open Diagnostics" ) { Clicked = InspectHomeResultInRuns } ) );
_openHomeGeneratedModelButton = resultActions.Add( StyleSecondaryActionButton( new Button( "Open Model" ) { Clicked = OpenHomeGeneratedModel } ) );
_clearHomeResultButton = resultActions.Add( StyleSubtleActionButton( new Button( "Clear Active" ) { Clicked = ClearHomeResult } ) );
resultActions.AddStretchCell();
_targetSummaryLabel = resultHistoryCard.Layout.Add( CreatePlaceholderLabel( "No imported retarget animations yet." ) );
var targetAnimationList = new TargetAnimationListView( resultHistoryCard );
targetAnimationList.ResolveContextTarget = () => _targetAnimationList?.SelectedItems.OfType<RetargetTargetAnimationEntry>().FirstOrDefault();
targetAnimationList.CanDeleteImportedAnimation = CanDeleteImportedTargetAnimation;
targetAnimationList.DeleteImportedAnimation = DeleteImportedTargetAnimation;
_targetAnimationList = resultHistoryCard.Layout.Add( StyleInteractiveList( targetAnimationList ), 1 );
_targetAnimationList.ItemSize = new Vector2( 0, 52 );
_targetAnimationList.ItemPaint = PaintTargetAnimationItem;
_targetAnimationList.ItemSelected = item =>
{
if ( item is RetargetTargetAnimationEntry target )
OnTargetAnimationSelectionChanged( target );
};
resultHistoryCard.Layout.AddSeparator();
var builtInHeader = resultHistoryCard.Layout.AddRow();
builtInHeader.Spacing = 6;
builtInHeader.Add( new Label.Subtitle( "Built-in target animations" ) );
builtInHeader.AddStretchCell();
_toggleBuiltInTargetAnimationsButton = builtInHeader.Add( StyleSubtleActionButton( new Button( "Show Built-In" ) { Clicked = ToggleBuiltInTargetAnimations } ) );
_builtInTargetSectionHost = resultHistoryCard.Layout.Add( new Widget( resultHistoryCard ) );
_builtInTargetSectionHost.Layout = Layout.Column();
_builtInTargetSectionHost.Layout.Margin = 0;
_builtInTargetSectionHost.Layout.Spacing = 6;
_builtInTargetSummaryLabel = _builtInTargetSectionHost.Layout.Add( CreatePlaceholderLabel( "Target inherits base Citizen animations." ) );
_builtInTargetAnimationList = _builtInTargetSectionHost.Layout.Add( StyleInteractiveList( new ListView( resultHistoryCard ) ), 1 );
_builtInTargetAnimationList.ItemSize = new Vector2( 0, 44 );
_builtInTargetAnimationList.ItemPaint = PaintTargetAnimationItem;
_builtInTargetAnimationList.ItemSelected = item =>
{
if ( item is RetargetTargetAnimationEntry target )
OnTargetAnimationSelectionChanged( target );
};
_builtInTargetSectionHost.Hide();
return pane;
}
private Widget BuildMappingTab( Widget parent )
{
var pane = new Widget( parent );
pane.Layout = Layout.Column();
pane.Layout.Margin = 0;
pane.Layout.Spacing = 8;
var split = pane.Layout.Add( new Splitter( pane ), 1 );
split.IsHorizontal = true;
var left = new Widget( split );
left.Layout = Layout.Column();
left.Layout.Margin = 0;
left.Layout.Spacing = 8;
split.AddWidget( left );
split.SetStretch( 0, 3 );
var right = new Widget( split );
right.Layout = Layout.Column();
right.Layout.Margin = 0;
right.Layout.Spacing = 8;
split.AddWidget( right );
split.SetStretch( 1, 5 );
var mappingSetupCard = CreateCard( left, "Source Setup" );
var mappingPresetRow = mappingSetupCard.Layout.AddRow();
mappingPresetRow.Spacing = 6;
mappingPresetRow.Add( CreateFieldLabel( "Preset" ) );
_mappingSourceProfilePreset = mappingPresetRow.Add( CreateSourcePresetCombo() );
_mappingSourceProfilePreset.ItemChanged += () => OnSourcePresetChanged( _mappingSourceProfilePreset );
_sourceFacingCard = mappingSetupCard.Layout.Add( new Widget( mappingSetupCard ) );
_sourceFacingCard.Layout = Layout.Column();
_sourceFacingCard.Layout.Margin = 0;
_sourceFacingCard.Layout.Spacing = 6;
AddInlineSectionHeader( _sourceFacingCard, "Orientation Calibration" );
_sourceFacingSummaryLabel = _sourceFacingCard.Layout.Add( CreateSupportingLabel( "Scan a source FBX to align its skeleton before retargeting." ) );
_sourceFacingHintLabel = _sourceFacingCard.Layout.Add( CreatePlaceholderLabel( "Align the yellow source arrow with the green Citizen front arrow. Enable manual override only when the preset is wrong." ) );
_sourceFacingHintLabel.SetStyles( $"padding: 10px 12px; background-color: {Theme.ControlBackground.WithAlpha( 0.34f ).Hex}; border: 1px solid {Theme.Border.WithAlpha( 0.18f ).Hex}; border-radius: 6px;" );
_sourceFacingPreview = _sourceFacingCard.Layout.Add( new RetargetSourceFacingPreviewWidget( _sourceFacingCard ) );
AddInlineSectionHeader( _sourceFacingCard, "Rotation Override" );
_sourceFacingManualOverrideCheckbox = _sourceFacingCard.Layout.Add( new Checkbox( "Enable manual override" ) );
_sourceFacingManualOverrideCheckbox.StateChanged += state =>
{
if ( _suppressSourceFacingManualOverrideSync )
return;
SetSourceFacingManualOverrideEnabled( state == CheckState.On );
};
var sourceFacingMatrix = _sourceFacingCard.Layout.AddRow();
sourceFacingMatrix.Spacing = 6;
sourceFacingMatrix.Add( CreateSourceFacingDegreeCell( _sourceFacingCard, "X", out _sourceFacingTiltField, value => SetSourceFacingEulerDegrees( new Vector3( value, _sourceFacingEulerDegrees.y, _sourceFacingEulerDegrees.z ) ) ) );
sourceFacingMatrix.Add( CreateSourceFacingDegreeCell( _sourceFacingCard, "Y", out _sourceFacingRollField, value => SetSourceFacingEulerDegrees( new Vector3( _sourceFacingEulerDegrees.x, value, _sourceFacingEulerDegrees.z ) ) ) );
sourceFacingMatrix.Add( CreateSourceFacingDegreeCell( _sourceFacingCard, "Z", out _sourceFacingFacingField, value => SetSourceFacingEulerDegrees( _sourceFacingEulerDegrees.WithZ( value ) ) ) );
var sourceFacingModeActions = _sourceFacingCard.Layout.AddRow();
sourceFacingModeActions.Spacing = 6;
_sourceFacingResetButton = sourceFacingModeActions.Add( StyleSubtleActionButton( new Button( "Reset to Preset" ) { Clicked = ResetSourceFacingCorrection } ) );
sourceFacingModeActions.AddStretchCell();
AddInlineSectionHeader( _sourceFacingCard, "Quick Rotate" );
var xAdjustRow = _sourceFacingCard.Layout.AddRow();
xAdjustRow.Spacing = 6;
xAdjustRow.Add( CreateFieldLabel( "X" ) );
_sourceFacingTiltBackButton = xAdjustRow.Add( StyleSecondaryActionButton( new Button( "-90" ) { Clicked = () => RotateSourceFacingBy( new Vector3( -90f, 0f, 0f ), "X" ) } ) );
_sourceFacingTiltForwardButton = xAdjustRow.Add( StyleSecondaryActionButton( new Button( "+90" ) { Clicked = () => RotateSourceFacingBy( new Vector3( 90f, 0f, 0f ), "X" ) } ) );
xAdjustRow.AddStretchCell();
var yAdjustRow = _sourceFacingCard.Layout.AddRow();
yAdjustRow.Spacing = 6;
yAdjustRow.Add( CreateFieldLabel( "Y" ) );
_sourceFacingRollRightButton = yAdjustRow.Add( StyleSecondaryActionButton( new Button( "-90" ) { Clicked = () => RotateSourceFacingBy( new Vector3( 0f, -90f, 0f ), "Y" ) } ) );
_sourceFacingRollLeftButton = yAdjustRow.Add( StyleSecondaryActionButton( new Button( "+90" ) { Clicked = () => RotateSourceFacingBy( new Vector3( 0f, 90f, 0f ), "Y" ) } ) );
yAdjustRow.AddStretchCell();
var zAdjustRow = _sourceFacingCard.Layout.AddRow();
zAdjustRow.Spacing = 6;
zAdjustRow.Add( CreateFieldLabel( "Z" ) );
_sourceFacingRotateLeftButton = zAdjustRow.Add( StyleSecondaryActionButton( new Button( "-90" ) { Clicked = () => RotateSourceFacingBy( new Vector3( 0f, 0f, -90f ), "Z" ) } ) );
_sourceFacingRotateRightButton = zAdjustRow.Add( StyleSecondaryActionButton( new Button( "+90" ) { Clicked = () => RotateSourceFacingBy( new Vector3( 0f, 0f, 90f ), "Z" ) } ) );
_sourceFacingRotate180Button = zAdjustRow.Add( StylePrimaryActionButton( new Button.Primary( "180" ) { Clicked = () => RotateSourceFacingBy( new Vector3( 0f, 0f, 180f ), "Z" ) } ) );
zAdjustRow.AddStretchCell();
var sourceBonesCard = CreateCard( left, "Source Bones", 1 );
AddLabeledLineEdit( sourceBonesCard, "Filter", out _boneFilter, "Filter source bones", RefreshSourceBoneList );
_sourceBoneList = sourceBonesCard.Layout.Add( StyleInteractiveList( new ListView( sourceBonesCard ) ), 1 );
_sourceBoneList.ItemSize = new Vector2( 0, 34 );
_sourceBoneList.ItemPaint = PaintSourceBoneItem;
_sourceBoneList.ItemSelected = item => OnSourceBoneSelectionChanged( item as NativeAuditBoneInfo );
var mappingCard = CreateCard( right, "Bone Map Editor", 1 );
var mappingFilterRow = mappingCard.Layout.AddRow();
mappingFilterRow.Spacing = 6;
_mappingSearch = mappingFilterRow.Add( StyleInteractiveField( new LineEdit() ) );
_mappingSearch.PlaceholderText = "Filter slots, target bones, or mapped source bones";
_mappingSearch.TextEdited += _ => RefreshMappingList();
_mappingFilter = mappingFilterRow.Add( StyleInteractiveField( new ComboBox() ) );
_mappingFilter.AddItem( "All" );
_mappingFilter.AddItem( "Needs Attention" );
_mappingFilter.AddItem( "Manual Overrides" );
_mappingFilter.AddItem( "Auto Mapped" );
_mappingFilter.AddItem( "Required Only" );
_mappingFilter.ItemChanged += RefreshMappingList;
_mappingFilter.FixedWidth = 220;
AddInlineSectionHeader( mappingCard, "Selected Slot" );
_mappingSelectionTitleLabel = mappingCard.Layout.Add( CreateSupportingLabel( "No slot selected yet." ) );
_mappingSelectionSummaryLabel = mappingCard.Layout.Add( CreatePlaceholderLabel( "Pick a row in Bone Map Editor, then pick a source bone on the left." ) );
_mappingSelectionHintLabel = mappingCard.Layout.Add( CreatePlaceholderLabel( "Manual flow: select slot -> select source bone -> Assign Selected Bone -> Save Mapping Profile." ) );
_mappingSelectionTitleLabel.SetStyles( $"padding: 10px 12px; background-color: {Theme.SurfaceBackground.WithAlpha( 0.28f ).Hex}; border: 1px solid {Theme.Border.WithAlpha( 0.2f ).Hex}; border-radius: 6px;" );
_mappingSelectionSummaryLabel.SetStyles( $"padding: 10px 12px; background-color: {Theme.ControlBackground.WithAlpha( 0.32f ).Hex}; border: 1px solid {Theme.Border.WithAlpha( 0.18f ).Hex}; border-radius: 6px;" );
_mappingSelectionHintLabel.SetStyles( $"padding: 8px 12px; background-color: {Theme.ControlBackground.WithAlpha( 0.18f ).Hex}; border: 1px solid {Theme.Border.WithAlpha( 0.14f ).Hex}; border-radius: 6px;" );
var mappingButtons = mappingCard.Layout.AddRow();
mappingButtons.Spacing = 6;
mappingButtons.Add( StyleSecondaryActionButton( new Button( "Rebuild Auto Map" ) { Clicked = RebuildAutoMap } ) );
_assignBoneButton = mappingButtons.Add( StylePrimaryActionButton( new Button.Primary( "Assign Selected Bone" ) { Clicked = AssignSelectedBone } ) );
_clearSlotButton = mappingButtons.Add( StyleSubtleActionButton( new Button( "Clear" ) { Clicked = ClearSelectedSlot } ) );
_resetSlotButton = mappingButtons.Add( StyleSecondaryActionButton( new Button( "Reset to Auto" ) { Clicked = ResetSelectedSlot } ) );
_saveMappingButton = mappingButtons.Add( StyleSecondaryActionButton( new Button( "Save Mapping Profile" ) { Clicked = SaveMappingProfile } ) );
_mappingList = mappingCard.Layout.Add( StyleInteractiveList( new ListView( mappingCard ) ), 1 );
_mappingList.ItemSize = new Vector2( 0, 56 );
_mappingList.ItemPaint = PaintMappingItem;
_mappingList.ItemSelected = item => OnMappingSlotSelected( item as RetargetSlotAssignmentState );
return pane;
}
private Widget BuildRunsTab( Widget parent )
{
var scroll = new ScrollArea( parent );
scroll.HorizontalSizeMode = SizeMode.Flexible;
scroll.VerticalSizeMode = SizeMode.Flexible;
scroll.HorizontalScrollbarMode = ScrollbarMode.Off;
scroll.Canvas = new Widget( scroll );
scroll.Canvas.Layout = Layout.Column();
scroll.Canvas.Layout.Margin = 0;
scroll.Canvas.Layout.Spacing = 8;
scroll.Canvas.HorizontalSizeMode = SizeMode.Flexible;
scroll.Canvas.VerticalSizeMode = SizeMode.CanGrow;
var pane = scroll.Canvas;
var environmentCard = CreateCard( pane, "Environment", collapsible: true );
var setupToolbar = environmentCard.Layout.AddRow();
setupToolbar.Spacing = 10;
var setupHeader = setupToolbar.Add( new Widget( environmentCard ), 1 );
setupHeader.Layout = Layout.Column();
setupHeader.Layout.Margin = 0;
setupHeader.Layout.Spacing = 2;
var setupTitle = setupHeader.Layout.Add( new Label.Body( "Setup health" )
{
WordWrap = false,
Color = Theme.Text.WithAlpha( 0.96f )
} );
setupTitle.SetStyles( "font-weight: 700; padding: 0; background-color: transparent; border: 0px;" );
var setupVersion = setupHeader.Layout.Add( new Label.Small( CitizenRetargetPluginInfo.DisplayVersion )
{
WordWrap = false,
Color = Theme.Text.WithAlpha( 0.58f )
} );
setupVersion.SetStyles( "padding: 0; background-color: transparent; border: 0px;" );
setupToolbar.AddStretchCell();
_scanEnvironmentButton = setupToolbar.Add( StyleEnvironmentActionButton( new Button( "Re-scan" ) { Clicked = () => BeginEnvironmentCheck( false ) } ) );
_copyEnvironmentDiagnosticsButton = setupToolbar.Add( StyleEnvironmentActionButton( new Button( "Copy Diagnostics" ) { Clicked = CopyEnvironmentDiagnostics } ) );
_exportSupportBundleButton = setupToolbar.Add( StyleEnvironmentActionButton( new Button( "Export Support Bundle" ) { Clicked = ExportSupportBundle } ) );
_environmentSummaryLabel = environmentCard.Layout.Add( CreateSupportingLabel( "Setup has not been checked yet." ) );
_environmentSummaryLabel.SetStyles( $"padding: 10px 12px; background-color: {Theme.SurfaceBackground.WithAlpha( 0.26f ).Hex}; border: 1px solid {Theme.Border.WithAlpha( 0.24f ).Hex}; border-radius: 6px;" );
var dependencyList = environmentCard.Layout.Add( new Widget( environmentCard ) );
dependencyList.Layout = Layout.Column();
dependencyList.Layout.Margin = 0;
dependencyList.Layout.Spacing = 6;
dependencyList.SetStyles( $"padding: 6px 10px 6px 6px; background-color: {Theme.SurfaceBackground.WithAlpha( 0.14f ).Hex}; border: 1px solid {Theme.Border.WithAlpha( 0.18f ).Hex}; border-radius: 8px;" );
_environmentSettingsRow = CreateEnvironmentDependencyRow( dependencyList, "Settings", "Project-local tool settings." );
_environmentBackendRow = CreateEnvironmentDependencyRow( dependencyList, "Backend", "Bundled retarget scripts and recipes." );
_backendPath = CreateHiddenEnvironmentPathField( environmentCard, SyncToolSettingsFromControls );
_browseEnvironmentBackendButton = _environmentBackendRow.Actions.Layout.Add( StyleEnvironmentActionButton( new Button( "Browse Override" ) { Clicked = BrowseEnvironmentBackendFolder } ) );
_openEnvironmentBackendButton = _environmentBackendRow.Actions.Layout.Add( StyleEnvironmentActionButton( new Button( "Open" ) { Clicked = OpenEnvironmentBackendFolder } ) );
_environmentBlenderRow = CreateEnvironmentDependencyRow( dependencyList, "Blender", "External Blender executable used by the retarget backend." );
_blenderPath = CreateHiddenEnvironmentPathField( environmentCard, SyncToolSettingsFromControls );
_browseBlenderButton = _environmentBlenderRow.Actions.Layout.Add( StyleEnvironmentActionButton( new Button( "Browse EXE" ) { Clicked = BrowseBlenderExecutablePath } ) );
_environmentRokokoRow = CreateEnvironmentDependencyRow( dependencyList, "Rokoko Addon", "Blender addon required by the backend retarget solve." );
_rokokoAddonPath = CreateHiddenEnvironmentPathField( environmentCard, SyncToolSettingsFromControls );
_autoScanRokokoAddonButton = _environmentRokokoRow.Actions.Layout.Add( StyleEnvironmentActionButton( new Button( "Auto Scan" ) { Clicked = AutoScanRokokoAddon }, primary: true ) );
_browseRokokoAddonButton = _environmentRokokoRow.Actions.Layout.Add( StyleEnvironmentActionButton( new Button( "Browse" ) { Clicked = BrowseRokokoAddonPath } ) );
_openRokokoAddonButton = _environmentRokokoRow.Actions.Layout.Add( StyleEnvironmentActionButton( new Button( "Open" ) { Clicked = OpenRokokoAddonFolder } ) );
_clearRokokoAddonButton = _environmentRokokoRow.Actions.Layout.Add( StyleEnvironmentActionButton( new Button( "Clear" ) { Clicked = ClearRokokoAddonPath }, subtle: true ) );
_environmentNativeFbxRow = CreateEnvironmentDependencyRow( dependencyList, "Native FBX", "Native scanner DLL used to read FBX clips and skeletons." );
_downloadNativeHelperButton = _environmentNativeFbxRow.Actions.Layout.Add( StyleEnvironmentActionButton( new Button( "Download Helper" ) { Clicked = ConfirmDownloadNativeHelper }, primary: true ) );
_openNativeHelperFolderButton = _environmentNativeFbxRow.Actions.Layout.Add( StyleEnvironmentActionButton( new Button( "Open" ) { Clicked = OpenNativeHelperFolder } ) );
_environmentTargetRow = CreateEnvironmentDependencyRow( dependencyList, "Target", "Current Citizen target model used by Home retargeting." );
var environmentDetailsCard = CreateCard( pane, "Environment Details", collapsible: true, initiallyExpanded: false );
_environmentDetailsLabel = environmentDetailsCard.Layout.Add( CreatePlaceholderLabel( "Run setup diagnostics to see the raw dependency report." ) );
_environmentDetailsLabel.SetStyles( $"padding: 10px 12px; background-color: {Theme.SurfaceBackground.WithAlpha( 0.18f ).Hex}; border: 1px solid {Theme.Border.WithAlpha( 0.18f ).Hex}; border-radius: 6px;" );
var topSplit = pane.Layout.Add( new Splitter( pane ) );
topSplit.IsHorizontal = true;
topSplit.MinimumHeight = 240;
topSplit.FixedHeight = 280;
var queueHost = new Widget( topSplit );
queueHost.Layout = Layout.Column();
queueHost.Layout.Margin = 0;
queueHost.Layout.Spacing = 0;
var queueCard = CreateCard( queueHost, "Queue", 1 );
_queueSummaryLabel = queueCard.Layout.Add( CreatePlaceholderLabel( "No queued clips yet." ) );
var queueActions = queueCard.Layout.AddRow();
queueActions.Spacing = 6;
_retryFailedQueueButton = queueActions.Add( StyleSecondaryActionButton( new Button( "Retry Failed" ) { Clicked = RetryFailedQueueItems } ) );
_clearFinishedQueueButton = queueActions.Add( StyleSecondaryActionButton( new Button( "Clear Finished" ) { Clicked = ClearFinishedQueueItems } ) );
_clearQueueButton = queueActions.Add( StyleSubtleActionButton( new Button( "Clear Queue" ) { Clicked = ClearQueue } ) );
queueCard.Layout.AddSeparator();
_queueList = queueCard.Layout.Add( StyleInteractiveList( new ListView( queueCard ) ), 1 );
_queueList.ItemSize = new Vector2( 0, 72 );
_queueList.ItemPaint = PaintQueueItem;
topSplit.AddWidget( queueHost );
topSplit.SetStretch( 0, 1 );
var historyHost = new Widget( topSplit );
historyHost.Layout = Layout.Column();
historyHost.Layout.Margin = 0;
historyHost.Layout.Spacing = 0;
var historyCard = CreateCard( historyHost, "History", 1 );
var historyActions = historyCard.Layout.AddRow();
historyActions.Spacing = 6;
_useHistoryAsCompareButton = historyActions.Add( StylePrimaryActionButton( new Button( "Set As Active Result" ) { Clicked = UseSelectedHistoryRunAsCompareResult } ) );
historyActions.AddStretchCell();
_historyList = historyCard.Layout.Add( StyleInteractiveList( new ListView( historyCard ) ), 1 );
_historyList.ItemSize = new Vector2( 0, 56 );
_historyList.ItemPaint = PaintHistoryItem;
_historyList.ItemSelected = item =>
{
if ( !_suppressHistorySelection )
OpenHistoryEntry( item as RetargetRunHistoryEntry );
};
topSplit.AddWidget( historyHost );
topSplit.SetStretch( 1, 1 );
var runHealthCard = CreateCard( pane, "Run Health", collapsible: true );
_runHealthLabel = runHealthCard.Layout.Add( CreateSupportingLabel( "No run selected yet. Run a retarget from Home or select a run from History." ) );
_runNextStepLabel = runHealthCard.Layout.Add( CreatePlaceholderLabel( "Next step: select a run from History, or retarget a clip from Home." ) );
_runHealthLabel.SetStyles( $"padding: 10px 12px; background-color: {Theme.SurfaceBackground.WithAlpha( 0.26f ).Hex}; border: 1px solid {Theme.Border.WithAlpha( 0.24f ).Hex}; border-radius: 6px;" );
_runNextStepLabel.SetStyles( $"padding: 10px 12px; background-color: {Theme.ControlBackground.WithAlpha( 0.34f ).Hex}; border: 1px solid {Theme.Border.WithAlpha( 0.18f ).Hex}; border-radius: 6px;" );
var issuesCard = CreateCard( pane, "Issues", collapsible: true );
_issuesLabel = issuesCard.Layout.Add( CreatePlaceholderLabel( "No issues to show yet." ), 1 );
_issuesLabel.SetStyles( $"padding: 12px; background-color: {Theme.SurfaceBackground.WithAlpha( 0.18f ).Hex}; border: 1px solid {Theme.Border.WithAlpha( 0.18f ).Hex}; border-radius: 6px;" );
var artifactsCard = CreateCard( pane, "Artifacts", collapsible: true, initiallyExpanded: false );
_artifactSummaryLabel = artifactsCard.Layout.Add( CreatePlaceholderLabel( "Select a run to inspect its artifacts. Start with Run Log when something fails." ) );
var artifactButtonsPrimary = artifactsCard.Layout.AddRow();
artifactButtonsPrimary.Spacing = 6;
_openRunDirButton = artifactButtonsPrimary.Add( StyleSecondaryActionButton( new Button( "Run Dir" ) { Clicked = OpenCurrentRunDirectory } ) );
_openManifestButton = artifactButtonsPrimary.Add( StyleSecondaryActionButton( new Button( "Manifest" ) { Clicked = OpenCurrentManifest } ) );
_openRawExportButton = artifactButtonsPrimary.Add( StyleSecondaryActionButton( new Button( "Raw Export" ) { Clicked = OpenCurrentRawExport } ) );
_openRunLogButton = artifactButtonsPrimary.Add( StylePrimaryActionButton( new Button( "Run Log" ) { Clicked = OpenCurrentRunLog } ) );
var artifactButtonsSecondary = artifactsCard.Layout.AddRow();
artifactButtonsSecondary.Spacing = 6;
_openImportedAnimationButton = artifactButtonsSecondary.Add( StyleSecondaryActionButton( new Button( "Imported FBX" ) { Clicked = OpenCurrentImportedAnimation } ) );
_openGeneratedModelButton = artifactButtonsSecondary.Add( StyleSecondaryActionButton( new Button( "Generated VMDL" ) { Clicked = OpenCurrentGeneratedModel } ) );
var runLogCard = CreateCard( pane, "Run Log", collapsible: true, initiallyExpanded: false );
_runLogOutput = runLogCard.Layout.Add( new TextEdit( runLogCard ) );
_runLogOutput.MinimumHeight = 260;
_runLogOutput.ReadOnly = true;
return scroll;
}
private Widget BuildPreviewDock( Widget parent )
{
var pane = new Widget( parent );
pane.Layout = Layout.Column();
pane.Layout.Margin = 0;
pane.Layout.Spacing = 8;
pane.MinimumWidth = 560;
var previewCard = CreateCard( pane, "Preview", 1 );
_livePreview = previewCard.Layout.Add( new RetargetLivePreviewWidget( previewCard ), 1 );
var summaryCard = CreateCard( pane, "Current Run" );
_resultDetailsLabel = summaryCard.Layout.Add( CreatePlaceholderLabel( "Production path: template FBX to generated Citizen full-fork VMDL." ) );
var artifactButtonsPrimary = summaryCard.Layout.AddRow();
artifactButtonsPrimary.Spacing = 6;
_openRunDirButton = artifactButtonsPrimary.Add( StyleSecondaryActionButton( new Button( "Run Dir" ) { Clicked = OpenCurrentRunDirectory } ) );
_openManifestButton = artifactButtonsPrimary.Add( StyleSecondaryActionButton( new Button( "Manifest" ) { Clicked = OpenCurrentManifest } ) );
_openRawExportButton = artifactButtonsPrimary.Add( StyleSecondaryActionButton( new Button( "Raw Export" ) { Clicked = OpenCurrentRawExport } ) );
var artifactButtonsSecondary = summaryCard.Layout.AddRow();
artifactButtonsSecondary.Spacing = 6;
_openImportedAnimationButton = artifactButtonsSecondary.Add( StyleSecondaryActionButton( new Button( "Imported FBX" ) { Clicked = OpenCurrentImportedAnimation } ) );
_openGeneratedModelButton = artifactButtonsSecondary.Add( StyleSecondaryActionButton( new Button( "Generated VMDL" ) { Clicked = OpenCurrentGeneratedModel } ) );
return pane;
}
private Widget CreateCard( Widget parent, string title, int stretch = 0, bool collapsible = false, bool initiallyExpanded = true )
{
var card = new Widget( parent );
card.Layout = Layout.Column();
card.Layout.Margin = 10;
card.Layout.Spacing = 8;
card.HorizontalSizeMode = SizeMode.Flexible;
card.VerticalSizeMode = stretch > 0 ? SizeMode.Flexible : SizeMode.CanShrink;
card.SetStyles( $"background-color: {Theme.ControlBackground.Hex}; border: 1px solid {Theme.Border.WithAlpha( 0.35f ).Hex}; border-radius: 8px;" );
if ( stretch > 0 )
parent.Layout.Add( card, stretch );
else
parent.Layout.Add( card );
var header = card.Layout.AddRow();
header.Spacing = 6;
IconButton? toggle = null;
Widget? body = null;
bool expanded = initiallyExpanded;
if ( collapsible )
{
toggle = new IconButton( expanded ? "expand_more" : "chevron_right", parent: card )
{
IconSize = 18,
ToolTip = expanded ? "Collapse section" : "Expand section",
Background = Theme.ButtonBackground.WithAlpha( 0.35f ),
Foreground = Theme.Text
};
toggle.FixedSize = Theme.RowHeight;
header.Add( toggle );
}
header.Add( new Label.Header( title ) );
header.AddStretchCell();
var separator = card.Layout.AddSeparator();
if ( !collapsible )
return card;
body = new Widget( card );
body.Layout = Layout.Column();
body.Layout.Margin = 0;
body.Layout.Spacing = 8;
card.Layout.Add( body );
void UpdateExpanded()
{
if ( body is null || toggle is null )
return;
toggle.Icon = expanded ? "expand_more" : "chevron_right";
toggle.ToolTip = expanded ? "Collapse section" : "Expand section";
if ( expanded )
{
separator.Show();
body.Show();
body.MaximumHeight = 1000000;
card.MaximumHeight = 1000000;
card.MinimumHeight = 0;
card.VerticalSizeMode = stretch > 0 ? SizeMode.Flexible : SizeMode.CanGrow;
}
else
{
separator.Hide();
body.Hide();
body.MaximumHeight = 0;
card.MinimumHeight = Theme.RowHeight + 20;
card.MaximumHeight = Theme.RowHeight + 24;
card.VerticalSizeMode = SizeMode.CanShrink;
}
card.Update();
card.UpdateGeometry();
card.Parent?.UpdateGeometry();
}
if ( toggle is not null )
toggle.OnClick = () =>
{
expanded = !expanded;
UpdateExpanded();
};
UpdateExpanded();
return body;
}
private void AddInlineSectionHeader( Widget parent, string title )
{
var header = parent.Layout.AddRow();
header.Spacing = 6;
header.Add( new Label.Subtitle( title ) );
header.AddStretchCell();
parent.Layout.AddSeparator();
}
private static Label.Body CreateSupportingLabel( string text, bool wordWrap = true )
{
var label = new Label.Body( text )
{
WordWrap = wordWrap,
Color = Theme.Text.WithAlpha( 0.78f )
};
label.SetStyles( "padding: 4px 6px;" );
return label;
}
private static Label.Small CreatePlaceholderLabel( string text, bool wordWrap = true )
{
var label = new Label.Small( text )
{
WordWrap = wordWrap,
Color = Theme.Text.WithAlpha( 0.58f )
};
label.SetStyles( "padding: 5px 6px;" );
return label;
}
private static Label.Small CreateCaptionLabel( string text, bool wordWrap = false )
{
var label = new Label.Small( text )
{
WordWrap = wordWrap,
Color = Theme.Text.WithAlpha( 0.64f )
};
label.SetStyles( "padding: 3px 6px;" );
return label;
}
private static Label.Small CreateMetricLabel( string text, bool wordWrap = false )
{
var label = new Label.Small( text )
{
WordWrap = wordWrap,
Color = Theme.Text.WithAlpha( 0.74f )
};
label.SetStyles( $"font-family: '{Theme.MonospaceFont}'; padding: 3px 6px;" );
return label;
}
private static Label.Small CreateFieldLabel( string text )
{
var label = new Label.Small( text )
{
Color = Theme.Text.WithAlpha( 0.68f )
};
label.SetStyles( "padding: 2px 4px 4px 2px;" );
return label;
}
private static Label.Body CreateEnvironmentHealthBadge( string title, RetargetEnvironmentCheckSeverity severity, string status, bool unknown = true )
{
var label = new Label.Body( string.Empty )
{
WordWrap = false
};
UpdateEnvironmentHealthBadge( label, title, severity, status, unknown );
return label;
}
private static Label.Small CreateActionGroupLabel( string text )
{
var label = new Label.Small( text )
{
WordWrap = false,
Color = Theme.Text.WithAlpha( 0.7f )
};
label.SetStyles( $"padding: 7px 9px; min-height: 30px; background-color: {Theme.ControlBackground.WithAlpha( 0.55f ).Hex}; border: 1px solid {Theme.Border.WithAlpha( 0.22f ).Hex}; border-radius: 6px;" );
return label;
}
private static void UpdateEnvironmentHealthBadge( Label label, string title, RetargetEnvironmentCheckSeverity severity, string status, bool unknown = false )
{
var color = unknown
? Theme.Border.WithAlpha( 0.8f )
: severity switch
{
RetargetEnvironmentCheckSeverity.Ok => new Color( 0.33f, 0.78f, 0.42f ),
RetargetEnvironmentCheckSeverity.Warning => new Color( 0.95f, 0.7f, 0.24f ),
_ => new Color( 0.95f, 0.28f, 0.24f )
};
var token = unknown
? "?"
: severity switch
{
RetargetEnvironmentCheckSeverity.Ok => "OK",
RetargetEnvironmentCheckSeverity.Warning => "WARN",
_ => "ERR"
};
label.Text = $"{token} {title}: {status}";
label.Color = unknown ? Theme.Text.WithAlpha( 0.68f ) : Theme.Text.WithAlpha( 0.95f );
label.SetStyles( $"padding: 7px 10px; min-height: 30px; font-weight: 600; background-color: {color.WithAlpha( unknown ? 0.08f : 0.16f ).Hex}; border: 1px solid {color.WithAlpha( unknown ? 0.28f : 0.68f ).Hex}; border-radius: 999px;" );
}
private EnvironmentDependencyRow CreateEnvironmentDependencyRow( Widget parent, string title, string summary )
{
var row = parent.Layout.Add( new Widget( parent ) );
row.Layout = Layout.Row();
row.Layout.Margin = 0;
row.Layout.Spacing = 8;
row.MinimumHeight = 60;
row.SetStyles( $"padding: 10px 11px; background-color: {Theme.ControlBackground.WithAlpha( 0.34f ).Hex}; border: 1px solid {Theme.Border.WithAlpha( 0.18f ).Hex}; border-radius: 8px;" );
var status = row.Layout.Add( new Label.Body( "?" )
{
WordWrap = false
} );
status.MinimumWidth = 34;
status.MaximumWidth = 34;
status.MinimumHeight = 28;
status.MaximumHeight = 28;
status.FixedHeight = 28;
status.Alignment = TextFlag.Center;
var textHost = row.Layout.Add( new Widget( row ), 1 );
textHost.Layout = Layout.Column();
textHost.Layout.Margin = 0;
textHost.Layout.Spacing = 3;
var titleLabel = textHost.Layout.Add( new Label.Body( title )
{
WordWrap = false,
Color = Theme.Text.WithAlpha( 0.96f )
} );
titleLabel.SetStyles( "font-weight: 700; padding: 0 0 1px 0; background-color: transparent; border: 0px;" );
var summaryLabel = textHost.Layout.Add( new Label.Small( summary )
{
WordWrap = true,
Color = Theme.Text.WithAlpha( 0.68f )
} );
summaryLabel.SetStyles( "padding: 0; background-color: transparent; border: 0px;" );
var detailLabel = textHost.Layout.Add( new Label.Small( string.Empty )
{
WordWrap = true,
Color = Theme.Text.WithAlpha( 0.56f )
} );
detailLabel.SetStyles( $"font-family: '{Theme.MonospaceFont}'; padding: 0; background-color: transparent; border: 0px;" );
var fieldHost = textHost.Layout.Add( new Widget( textHost ) );
fieldHost.Layout = Layout.Column();
fieldHost.Layout.Margin = 0;
fieldHost.Layout.Spacing = 0;
var actions = row.Layout.Add( new Widget( row ) );
actions.Layout = Layout.Row();
actions.Layout.Margin = 0;
actions.Layout.Spacing = 4;
actions.FixedHeight = 32;
UpdateEnvironmentStatusPill( status, RetargetEnvironmentCheckSeverity.Warning, true );
return new EnvironmentDependencyRow
{
Status = status,
Title = titleLabel,
Summary = summaryLabel,
Detail = detailLabel,
FieldHost = fieldHost,
Actions = actions
};
}
private static LineEdit CreateHiddenEnvironmentPathField( Widget parent, Action onEdited )
{
var field = new LineEdit( parent );
field.TextEdited += _ => onEdited();
field.FixedHeight = 0;
field.MaximumHeight = 0;
field.MinimumHeight = 0;
field.SetStyles( "opacity: 0; max-height: 0px; min-height: 0px; height: 0px; padding: 0px; margin: 0px; border: 0px;" );
return field;
}
private static Button StyleEnvironmentActionButton( Button button, bool primary = false, bool subtle = false )
{
var background = primary
? Theme.Primary.WithAlpha( 0.88f )
: subtle
? Theme.ControlBackground.WithAlpha( 0.46f )
: Theme.ControlBackground.WithAlpha( 0.82f );
var border = primary
? Theme.Primary.Lighten( 0.12f ).WithAlpha( 0.65f )
: Theme.Border.WithAlpha( subtle ? 0.18f : 0.34f );
var text = subtle
? Theme.Text.WithAlpha( 0.62f )
: Theme.Text.WithAlpha( 0.94f );
button.MinimumHeight = 30;
button.SetStyles( $"padding: 5px 8px; min-height: 30px; min-width: 0px; border-radius: 6px; background-color: {background.Hex}; border: 1px solid {border.Hex}; color: {text.Hex}; font-weight: 600;" );
return button;
}
private static void SetEnvironmentActionButtonPresent( Button button, bool present, bool primary = false, bool subtle = false )
{
button.Visible = present;
if ( present )
{
button.MinimumWidth = 0;
button.MinimumHeight = 30;
button.MaximumWidth = 1000000;
button.MaximumHeight = 1000000;
StyleEnvironmentActionButton( button, primary, subtle );
return;
}
button.Enabled = false;
button.MinimumWidth = 0;
button.MinimumHeight = 0;
button.MaximumWidth = 0;
button.MaximumHeight = 0;
button.SetStyles( "padding: 0px; margin: 0px; min-width: 0px; max-width: 0px; width: 0px; min-height: 0px; max-height: 0px; height: 0px; border: 0px; opacity: 0;" );
}
private static void UpdateEnvironmentDependencyRow( EnvironmentDependencyRow row, RetargetEnvironmentCheckSeverity severity, string summary, string detail, bool unknown = false )
{
UpdateEnvironmentStatusPill( row.Status, severity, unknown );
row.Summary.Text = summary;
row.Detail.Text = CompactEnvironmentDetail( detail );
}
private static void UpdateEnvironmentStatusPill( Label label, RetargetEnvironmentCheckSeverity severity, bool unknown = false )
{
var color = unknown
? Theme.Border.WithAlpha( 0.8f )
: severity switch
{
RetargetEnvironmentCheckSeverity.Ok => new Color( 0.33f, 0.78f, 0.42f ),
RetargetEnvironmentCheckSeverity.Warning => new Color( 0.95f, 0.7f, 0.24f ),
_ => new Color( 0.95f, 0.28f, 0.24f )
};
label.Text = unknown
? "?"
: severity switch
{
RetargetEnvironmentCheckSeverity.Ok => "✓",
RetargetEnvironmentCheckSeverity.Warning => "!",
_ => "×"
};
label.Color = unknown ? Theme.Text.WithAlpha( 0.68f ) : Theme.Text.WithAlpha( 0.96f );
label.SetStyles( $"padding: 0px; min-width: 28px; max-width: 28px; min-height: 28px; max-height: 28px; font-weight: 800; background-color: {color.WithAlpha( unknown ? 0.08f : 0.15f ).Hex}; border: 1px solid {color.WithAlpha( unknown ? 0.28f : 0.62f ).Hex}; border-radius: 7px;" );
}
private static string CompactEnvironmentDetail( string detail )
{
if ( string.IsNullOrWhiteSpace( detail ) )
return string.Empty;
var normalized = detail.Trim();
const int maxLength = 128;
if ( normalized.Length <= maxLength )
return normalized;
var keepStart = Math.Min( 42, normalized.Length / 2 );
var keepEnd = maxLength - keepStart - 3;
if ( keepEnd <= 0 )
return normalized[..maxLength];
return $"{normalized[..keepStart]}...{normalized[^keepEnd..]}";
}
private static T StyleInteractiveField<T>( T widget ) where T : Widget
{
widget.SetStyles( $"background-color: {Theme.SurfaceBackground.WithAlpha( 0.75f ).Hex}; border: 1px solid {Theme.Border.WithAlpha( 0.42f ).Hex}; border-radius: 6px; padding: 4px 6px;" );
return widget;
}
private static T StyleInteractiveList<T>( T widget ) where T : Widget
{
widget.SetStyles( $"background-color: {Theme.SurfaceBackground.WithAlpha( 0.82f ).Hex}; border: 1px solid {Theme.Border.WithAlpha( 0.4f ).Hex}; border-radius: 8px;" );
return widget;
}
private static Button StylePrimaryActionButton( Button button )
{
button.SetStyles( $"padding: 6px 12px; min-height: 32px; font-weight: 600; border-radius: 6px; background-color: {Theme.Primary.Hex}; color: white; border: 1px solid {Theme.Primary.Lighten( 0.15f ).Hex};" );
return button;
}
private static Button StyleSecondaryActionButton( Button button )
{
button.SetStyles( $"padding: 6px 12px; min-height: 32px; font-weight: 500; border-radius: 6px; background-color: {Theme.SurfaceBackground.WithAlpha( 0.92f ).Hex}; border: 1px solid {Theme.Border.WithAlpha( 0.5f ).Hex}; color: {Theme.Text.Hex};" );
return button;
}
private static Button StyleSubtleActionButton( Button button )
{
button.SetStyles( $"padding: 5px 10px; min-height: 30px; border-radius: 6px; background-color: {Theme.ControlBackground.WithAlpha( 0.72f ).Hex}; border: 1px solid {Theme.Border.WithAlpha( 0.34f ).Hex}; color: {Theme.Text.WithAlpha( 0.88f ).Hex};" );
return button;
}
private void AddLabeledLineEdit( Widget parent, string label, out LineEdit lineEdit, string placeholder, Action onEdited )
{
var row = parent.Layout.AddRow();
row.Spacing = 6;
row.Add( CreateFieldLabel( label ) );
lineEdit = row.Add( StyleInteractiveField( new LineEdit() ) );
lineEdit.PlaceholderText = placeholder;
lineEdit.TextEdited += _ => onEdited();
}
private Widget CreateSourceFacingDegreeCell( Widget parent, string label, out LineEdit field, Action<float> onChanged )
{
var cell = new Widget( parent );
cell.Layout = Layout.Column();
cell.Layout.Margin = 0;
cell.Layout.Spacing = 4;
cell.SetStyles( $"padding: 8px; background-color: {Theme.SurfaceBackground.WithAlpha( 0.35f ).Hex}; border: 1px solid {Theme.Border.WithAlpha( 0.22f ).Hex}; border-radius: 7px;" );
cell.Layout.Add( CreateCaptionLabel( label, false ) );
var input = cell.Layout.Add( StyleInteractiveField( new LineEdit() ) );
input.PlaceholderText = "0";
input.TextEdited += _ =>
{
if ( _suppressSourceFacingFieldSync )
return;
var text = input.Text?.Trim() ?? string.Empty;
if ( !float.TryParse( text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var value )
&& !float.TryParse( text, out value ) )
{
return;
}
onChanged( value );
};
field = input;
return cell;
}
private ComboBox CreateSourcePresetCombo()
{
var combo = StyleInteractiveField( new ComboBox() );
combo.AddItem( "Generic Humanoid" );
combo.AddItem( "Mixamo Humanoid" );
combo.AddItem( "Quaternius UAL2" );
return combo;
}
private int GetSelectedSourcePresetIndex()
{
if ( _sourceProfilePreset is not null )
return _sourceProfilePreset.CurrentIndex;
if ( _mappingSourceProfilePreset is not null )
return _mappingSourceProfilePreset.CurrentIndex;
return 0;
}
private void SetSourcePresetControlsFromJob()
{
var index = GetSourcePresetIndexFromPath( _job.SourceProfilePath );
_suppressSourcePresetSync = true;
try
{
if ( _sourceProfilePreset is not null )
_sourceProfilePreset.CurrentIndex = index;
if ( _mappingSourceProfilePreset is not null )
_mappingSourceProfilePreset.CurrentIndex = index;
}
finally
{
_suppressSourcePresetSync = false;
}
}
private void OnSourcePresetChanged( ComboBox changedPreset )
{
if ( _suppressSourcePresetSync )
return;
_suppressSourcePresetSync = true;
try
{
if ( _sourceProfilePreset is not null && !ReferenceEquals( changedPreset, _sourceProfilePreset ) )
_sourceProfilePreset.CurrentIndex = changedPreset.CurrentIndex;
if ( _mappingSourceProfilePreset is not null && !ReferenceEquals( changedPreset, _mappingSourceProfilePreset ) )
_mappingSourceProfilePreset.CurrentIndex = changedPreset.CurrentIndex;
}
finally
{
_suppressSourcePresetSync = false;
}
SyncJobFromControls();
LoadProfilesFromJob();
if ( _inspection is not null )
_mappingState = _pipeline.BuildMappingState( _job, _sourceProfile, _mappingProfile, _inspection );
_sourceFacingEulerDegrees = GetStoredSourceFacingEulerDegrees();
ClearLegacyManualFacingIfItMatchesPreset();
RefreshSourceFacingUi();
RefreshSourceBoneList();
RefreshMappingList();
UpdateDiagnostics();
UpdateButtons();
}
private void LoadProfilesFromJob() { _sourceProfile = _pipeline.LoadSourceProfile( _job.SourceProfilePath ); _mappingProfile = _pipeline.LoadMappingProfile( _job.MappingProfilePath ); }
private static int GetSourcePresetIndexFromPath( string? sourceProfilePath )
{
var normalized = NormalizeProfilePathForUi( sourceProfilePath );
if ( normalized.Equals( NormalizeProfilePathForUi( CitizenTargetProfile.MixamoSourceProfileAssetPath ), StringComparison.OrdinalIgnoreCase ) )
return 1;
if ( normalized.Equals( NormalizeProfilePathForUi( CitizenTargetProfile.DefaultSourceProfileAssetPath ), StringComparison.OrdinalIgnoreCase ) )
return 2;
return 0;
}
private static string GetSourceProfilePathFromPresetIndex( int index )
{
return index switch
{
1 => CitizenTargetProfile.MixamoSourceProfileAssetPath,
2 => CitizenTargetProfile.DefaultSourceProfileAssetPath,
_ => string.Empty
};
}
private static string NormalizeProfilePathForUi( string? path )
{
return (path ?? string.Empty).Trim().Replace( '\\', '/' ).TrimStart( '/' );
}
private void CacheSourceProfileForScan( IDictionary<string, RetargetSourceProfile> profilesByPath, string profilePath )
{
var key = NormalizeProfilePathForUi( profilePath );
if ( string.IsNullOrWhiteSpace( key ) || profilesByPath.ContainsKey( key ) )
return;
profilesByPath[key] = _pipeline.LoadSourceProfile( profilePath );
}
private void TryHydrateFromJob()
{
SetStatus( File.Exists( _job.SourceFbxPath )
? "Source path restored. Press Scan when you want to inspect the FBX."
: "Point the workstation at a humanoid FBX and press Scan." );
}
private void ReportScanFailure( string phase, Exception exception )
{
var sourcePath = CitizenRetargetPaths.DecodeExternalPath( _job.SourceFbxPath ?? string.Empty );
var message = $"Scan failed during {phase}: {exception.Message}";
RememberScanDiagnostic(
"failed",
sourcePath,
$"{message}{Environment.NewLine}{exception}" );
SetStatus( message );
if ( _clipSummaryLabel is not null )
_clipSummaryLabel.Text = $"{message} Open Diagnostics -> Copy Diagnostics or Export Support Bundle if you need help.";
try
{
var logPath = CitizenRetargetPaths.GetTempPath( "scan_failures.log" );
Directory.CreateDirectory( Path.GetDirectoryName( logPath )! );
File.AppendAllText(
logPath,
$"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {message}{Environment.NewLine}" +
$"Source: {sourcePath}{Environment.NewLine}" +
$"{exception}{Environment.NewLine}{Environment.NewLine}" );
}
catch
{
// Status text is the important user-facing fallback; logging must never break Scan.
}
}
private void RememberScanDiagnostic( string status, string sourcePath, string details )
{
_lastScanStatus = status;
_lastScanSourcePath = sourcePath;
_lastScanDetails = details;
_lastScanCompletedAt = DateTime.Now;
}
private static string BuildScanSuccessDiagnostic( ScanJobResult result )
{
var lines = new List<string>
{
$"Clips: {result.Clips.Count}",
$"Bones: {result.Inspection.Audit.Bones.Count}",
$"Source profile: {result.DetectedSourceProfileDisplayName}",
$"Source profile path: {result.DetectedSourceProfilePath}",
$"Mapping profile path: {result.DetectedMappingProfilePath}"
};
if ( result.Clips.Count == 0 )
{
lines.Add( "No animation clips were discovered. The FBX may contain only mesh/skeleton data, unsupported animation stacks, or animation data that the native scanner could not expose." );
}
return string.Join( Environment.NewLine, lines );
}
private void BeginEnvironmentCheck( bool startQueueWhenReady )
{
if ( HasActiveBackgroundJob( "environment" ) )
{
SetStatus( "Setup diagnostics are already running." );
return;
}
SyncJobFromControls();
SyncToolSettingsFromControls();
var jobSnapshot = _pipeline.CreateRuntimeJobSnapshot( _job );
_startQueueAfterEnvironmentCheck = startQueueWhenReady;
StartBackgroundJob(
title: "Checking retarget setup",
scope: "environment",
blocksInteraction: false,
showInJobsStrip: true,
work: reporter =>
{
var report = _environmentDiagnostics.Check(
jobSnapshot,
probeBlenderAddon: true,
progress: (progress, detail) => reporter.ReportProgress( progress, detail ) );
return () =>
{
_environmentReport = report;
RefreshEnvironmentDiagnostics();
UpdateButtons();
if ( report.CanRunRetarget )
{
_latestSetupFailureMessage = string.Empty;
ClearPendingSetupMessages();
RefreshQueueList();
SetStatus( _startQueueAfterEnvironmentCheck
? "Setup check passed. Starting queue."
: "Setup check passed. Retarget environment is ready." );
if ( _startQueueAfterEnvironmentCheck )
StartQueueCore();
}
else
{
_latestSetupFailureMessage = BuildSetupFailureMessage( report );
MarkPendingQueueItemsWithSetupFailure( _latestSetupFailureMessage );
RefreshQueueList();
SetStatus( _latestSetupFailureMessage );
}
_startQueueAfterEnvironmentCheck = false;
};
},
onError: exception =>
{
_startQueueAfterEnvironmentCheck = false;
_latestSetupFailureMessage = $"Setup diagnostics failed: {exception.Message}";
_environmentReport = new RetargetEnvironmentReport
{
Checks =
{
new RetargetEnvironmentCheck
{
Id = "environment_exception",
Title = "Environment diagnostics",
Severity = RetargetEnvironmentCheckSeverity.Error,
BlocksRetarget = true,
Detail = exception.Message,
FixHint = "Open the editor console. The setup checker failed before producing a report."
}
}
};
MarkPendingQueueItemsWithSetupFailure( _latestSetupFailureMessage );
RefreshQueueList();
RefreshEnvironmentDiagnostics();
SetStatus( _latestSetupFailureMessage );
} );
}
private void OpenEnvironmentBackendFolder()
{
SyncToolSettingsFromControls();
var backendRoot = _environmentReport?.BackendRootPath ?? _toolSettings.BackendRootPath ?? string.Empty;
if ( string.IsNullOrWhiteSpace( backendRoot ) || !Directory.Exists( backendRoot ) )
{
SetStatus( "Backend folder is not available. Re-sync the plugin, or set Backend Override for development." );
return;
}
_pipeline.OpenPath( backendRoot );
}
private static string GetNativeHelperTargetDirectory()
{
return Path.Combine( CitizenRetargetPaths.NativeRoot, "win-x64" );
}
private static string GetNativeHelperDllPath()
{
return Path.Combine( GetNativeHelperTargetDirectory(), NativeHelperInstaller.DllFileName );
}
private void ConfirmDownloadNativeHelper()
{
if ( HasActiveBackgroundJob( "environment" ) )
{
SetStatus( BuildActiveJobStatus( "environment", "Native helper install cannot start while setup diagnostics are running." ) );
return;
}
var targetDirectory = GetNativeHelperTargetDirectory();
var dllPath = Path.Combine( targetDirectory, NativeHelperInstaller.DllFileName );
var action = File.Exists( dllPath ) ? "reinstall" : "download and install";
Dialog.AskConfirm(
() => BeginNativeHelperDownload( targetDirectory ),
$"CARL will {action} the Windows native FBX helper from the official GitHub Release, verify SHA256, and place it in {targetDirectory}. Continue?",
"Install Native Helper",
"Download",
"Cancel" );
}
private void BeginNativeHelperDownload( string targetDirectory )
{
if ( HasActiveBackgroundJob( "environment" ) )
{
SetStatus( "Setup diagnostics or native helper install is already running." );
return;
}
StartBackgroundJob(
title: "Installing native FBX helper",
scope: "environment",
blocksInteraction: false,
showInJobsStrip: true,
work: reporter =>
{
var result = NativeHelperInstaller.InstallDefaultWinX64Async(
targetDirectory,
(progress, detail) => reporter.ReportProgress( progress, detail ) ).GetAwaiter().GetResult();
return () =>
{
SetStatus( $"Native FBX helper installed and verified: {result.DllPath}" );
BeginEnvironmentCheck( false );
};
},
onError: exception =>
{
SetStatus( $"Native helper install failed: {exception.Message}" );
RefreshEnvironmentDiagnostics();
} );
}
private void OpenNativeHelperFolder()
{
var targetDirectory = GetNativeHelperTargetDirectory();
if ( !Directory.Exists( targetDirectory ) )
{
SetStatus( "Native helper folder is not available yet. Use Download Helper first." );
return;
}
_pipeline.OpenPath( targetDirectory );
}
private void BrowseEnvironmentBackendFolder()
{
try
{
SyncToolSettingsFromControls();
var fallbackDirectory = Directory.GetParent( CitizenRetargetPaths.ProjectRoot )?.FullName
?? Environment.GetFolderPath( Environment.SpecialFolder.MyDocuments );
var selectedPath = RetargetFileDialogs.PickDirectory( "Choose Backend Override Folder", _toolSettings.BackendRootPath, fallbackDirectory );
if ( string.IsNullOrWhiteSpace( selectedPath ) )
{
SetStatus( "Backend override selection cancelled." );
return;
}
if ( _backendPath is not null )
_backendPath.Text = selectedPath;
SyncToolSettingsFromControls();
SetStatus( $"Backend override set to '{selectedPath}'." );
BeginEnvironmentCheck( false );
}
catch ( Exception exception )
{
SetStatus( $"Browse backend failed: {exception.Message}" );
}
}
private void BrowseBlenderExecutablePath()
{
try
{
SyncToolSettingsFromControls();
var fallbackDirectory = Environment.GetFolderPath( Environment.SpecialFolder.ProgramFiles );
var selectedPath = RetargetFileDialogs.PickExistingFile( "Choose blender.exe", "exe", _toolSettings.BlenderExecutablePath, fallbackDirectory );
if ( string.IsNullOrWhiteSpace( selectedPath ) )
{
SetStatus( "Blender path selection cancelled." );
return;
}
if ( !Path.GetFileName( selectedPath ).Equals( "blender.exe", StringComparison.OrdinalIgnoreCase ) )
{
SetStatus( "Selected file is not blender.exe. Pick Blender's executable file." );
return;
}
if ( _blenderPath is not null )
_blenderPath.Text = selectedPath;
SyncToolSettingsFromControls();
SetStatus( $"Blender path set to '{selectedPath}'." );
BeginEnvironmentCheck( false );
}
catch ( Exception exception )
{
SetStatus( $"Browse Blender failed: {exception.Message}" );
}
}
private void AutoScanRokokoAddon()
{
if ( _rokokoAddonPath is not null )
_rokokoAddonPath.Text = string.Empty;
SyncToolSettingsFromControls();
SetStatus( "Rokoko addon override cleared. Diagnostics will auto-scan Blender addons." );
BeginEnvironmentCheck( false );
}
private void BrowseRokokoAddonPath()
{
try
{
SyncToolSettingsFromControls();
var selectedPath = RetargetFileDialogs.PickDirectory(
"Choose Rokoko Addon Folder",
ResolveRokokoAddonFolder( _toolSettings.RokokoAddonPath ),
Environment.GetFolderPath( Environment.SpecialFolder.ApplicationData ) );
if ( string.IsNullOrWhiteSpace( selectedPath ) )
{
SetStatus( "Rokoko addon selection cancelled." );
return;
}
if ( _rokokoAddonPath is not null )
_rokokoAddonPath.Text = selectedPath;
SyncToolSettingsFromControls();
SetStatus( $"Rokoko addon path set to '{selectedPath}'." );
BeginEnvironmentCheck( false );
}
catch ( Exception exception )
{
SetStatus( $"Browse Rokoko addon failed: {exception.Message}" );
}
}
private void OpenRokokoAddonFolder()
{
SyncToolSettingsFromControls();
var folder = ResolveRokokoAddonFolder( ResolveCurrentRokokoAddonPath() );
if ( string.IsNullOrWhiteSpace( folder ) || !Directory.Exists( folder ) )
{
SetStatus( "Rokoko addon folder is not available. Paste a valid addon folder/__init__.py path or use Auto Scan Rokoko." );
return;
}
_pipeline.OpenPath( folder );
}
private void ClearRokokoAddonPath()
{
if ( _rokokoAddonPath is not null )
_rokokoAddonPath.Text = string.Empty;
SyncToolSettingsFromControls();
SetStatus( "Rokoko addon path cleared. Auto Scan Rokoko will use Blender's installed addon list." );
}
private static string ResolveRokokoAddonFolder( string? configuredPath )
{
var path = (configuredPath ?? string.Empty).Trim().Trim( '"' );
if ( string.IsNullOrWhiteSpace( path ) )
return string.Empty;
if ( Directory.Exists( path ) )
return path;
if ( File.Exists( path ) )
return Path.GetDirectoryName( path ) ?? string.Empty;
return path;
}
private string ResolveCurrentRokokoAddonPath()
{
if ( !string.IsNullOrWhiteSpace( _toolSettings.RokokoAddonPath ) )
return _toolSettings.RokokoAddonPath;
return _environmentReport?.Checks.FirstOrDefault( check => check.Id == "rokoko_addon" && !string.IsNullOrWhiteSpace( check.Path ) )?.Path ?? string.Empty;
}
private void CopyEnvironmentDiagnostics()
{
try
{
SyncToolSettingsFromControls();
var text = BuildSupportDiagnosticsText();
EditorUtility.Clipboard.Copy( text );
SetStatus( "Copied CARL diagnostics to clipboard." );
}
catch ( Exception exception )
{
SetStatus( $"Failed to copy diagnostics: {exception.Message}" );
}
}
private void ExportSupportBundle()
{
try
{
SyncToolSettingsFromControls();
var stamp = DateTime.Now.ToString( "yyyyMMdd_HHmmss" );
var defaultBundleRoot = Path.Combine( CitizenRetargetPaths.LocalSettingsRoot, "support_bundles" );
Directory.CreateDirectory( defaultBundleRoot );
var zipPath = RetargetFileDialogs.SaveZipFile(
"Save CARL Support Bundle",
Path.Combine( defaultBundleRoot, $"citizen-retarget-support-{stamp}.zip" ) );
if ( string.IsNullOrWhiteSpace( zipPath ) )
{
SetStatus( "Support bundle export cancelled." );
return;
}
Directory.CreateDirectory( Path.GetDirectoryName( zipPath )! );
var stagingRoot = Path.Combine( defaultBundleRoot, $"_staging-{stamp}" );
if ( Directory.Exists( stagingRoot ) )
Directory.Delete( stagingRoot, true );
Directory.CreateDirectory( stagingRoot );
File.WriteAllText( Path.Combine( stagingRoot, "diagnostics.txt" ), BuildSupportDiagnosticsText() );
WriteSupportBundleSummary( Path.Combine( stagingRoot, "summary.json" ) );
CopySupportFileIfExists( RetargetToolSettings.SettingsAbsolutePath, Path.Combine( stagingRoot, "settings.json" ) );
CopySupportFileIfExists( CitizenRetargetPaths.GetTempPath( "scan_failures.log" ), Path.Combine( stagingRoot, "scan_failures.log" ) );
var artifactResult = GetCurrentArtifactResult();
if ( artifactResult is not null )
{
var runArtifactsRoot = Path.Combine( stagingRoot, "latest_run" );
Directory.CreateDirectory( runArtifactsRoot );
CopySupportFileIfExists( artifactResult.ArtifactLinks.ManifestPath, Path.Combine( runArtifactsRoot, "result_manifest.json" ) );
CopySupportFileIfExists( artifactResult.ArtifactLinks.RunLogPath, Path.Combine( runArtifactsRoot, CitizenRetargetPaths.EditorRunLogFileName ) );
}
if ( File.Exists( zipPath ) )
File.Delete( zipPath );
ZipFile.CreateFromDirectory( stagingRoot, zipPath );
Directory.Delete( stagingRoot, true );
_pipeline.OpenPath( zipPath );
SetStatus( $"Exported support bundle: {zipPath}" );
}
catch ( Exception exception )
{
SetStatus( $"Failed to export support bundle: {exception.Message}" );
}
}
private void WriteSupportBundleSummary( string path )
{
var latestEntry = (_job.RecentRuns ?? new List<RetargetRunHistoryEntry>())
.OrderByDescending( entry => entry.CreatedUtc ?? string.Empty )
.FirstOrDefault();
var summary = new
{
createdUtc = DateTime.UtcNow.ToString( "o" ),
pluginVersion = CitizenRetargetPluginInfo.Version,
projectRoot = CitizenRetargetPaths.ProjectRoot,
pluginRoot = CitizenRetargetPaths.PluginRoot,
setup = _environmentReport is null
? "not scanned"
: _environmentReport.BuildHeadline(),
latestScan = new
{
status = _lastScanStatus,
source = _lastScanSourcePath,
completedAt = _lastScanCompletedAt?.ToString( "o" ) ?? string.Empty
},
latestRun = latestEntry is null
? null
: new
{
runId = latestEntry.RunId,
clip = latestEntry.ClipName,
sequence = latestEntry.SequenceName,
status = latestEntry.Status,
createdUtc = latestEntry.CreatedUtc,
manifest = CitizenRetargetPaths.DecodeExternalPath( latestEntry.ManifestPath ),
importedAsset = CitizenRetargetPaths.DecodeExternalPath( latestEntry.ImportedAssetPath ),
targetVmdl = latestEntry.TargetVmdlPath
}
};
File.WriteAllText( path, JsonSerializer.Serialize( summary, new JsonSerializerOptions { WriteIndented = true } ) );
}
private static void CopySupportFileIfExists( string? sourcePath, string destinationPath )
{
if ( string.IsNullOrWhiteSpace( sourcePath ) || !File.Exists( sourcePath ) )
return;
Directory.CreateDirectory( Path.GetDirectoryName( destinationPath )! );
File.Copy( sourcePath, destinationPath, true );
}
private static string BuildSetupFailureMessage( RetargetEnvironmentReport report )
{
var firstIssue = report.BlockingIssues.FirstOrDefault();
if ( firstIssue is null )
return "Setup check failed. Open Diagnostics for details.";
var hint = string.IsNullOrWhiteSpace( firstIssue.FixHint ) ? string.Empty : $" Fix: {firstIssue.FixHint}";
return $"Setup blocked: {firstIssue.Title} - {firstIssue.Detail}.{hint}";
}
private void MarkPendingQueueItemsWithSetupFailure( string message )
{
foreach ( var item in _queueItems.Where( item => item.Status.Equals( "pending", StringComparison.OrdinalIgnoreCase ) ) )
item.Message = message;
}
private void ClearPendingSetupMessages()
{
foreach ( var item in _queueItems.Where( item => item.Status.Equals( "pending", StringComparison.OrdinalIgnoreCase ) ) )
{
if ( item.Message.StartsWith( "Setup blocked:", StringComparison.OrdinalIgnoreCase )
|| item.Message.StartsWith( "Setup diagnostics failed:", StringComparison.OrdinalIgnoreCase )
|| item.Message.StartsWith( "Checking retarget setup", StringComparison.OrdinalIgnoreCase ) )
{
item.Message = "Queued";
}
}
}
private void RefreshEnvironmentDiagnostics()
{
if ( _environmentSummaryLabel is null || _environmentDetailsLabel is null )
return;
if ( _environmentReport is null )
{
_environmentSummaryLabel.Text = "Setup has not been checked yet.";
_environmentDetailsLabel.Text = "Run setup diagnostics to verify Blender, the Rokoko addon, backend files, native FBX scanning, and the current target.";
UpdateEnvironmentDependencyRow( _environmentSettingsRow, RetargetEnvironmentCheckSeverity.Warning, "Not checked yet.", "Settings will be stored under .sbox/citizen_retarget/settings.json.", unknown: true );
UpdateEnvironmentDependencyRow( _environmentBackendRow, RetargetEnvironmentCheckSeverity.Warning, "Not checked yet.", "Bundled backend will be used unless an override is set.", unknown: true );
UpdateEnvironmentDependencyRow( _environmentBlenderRow, RetargetEnvironmentCheckSeverity.Warning, "Not checked yet.", "Leave empty to auto-detect Blender, or browse to blender.exe.", unknown: true );
UpdateEnvironmentDependencyRow( _environmentRokokoRow, RetargetEnvironmentCheckSeverity.Warning, "Not checked yet.", "Leave empty to auto-scan Blender addons, or browse to the addon folder.", unknown: true );
UpdateEnvironmentDependencyRow( _environmentNativeFbxRow, RetargetEnvironmentCheckSeverity.Warning, "Not checked yet.", "Use Download Helper if the native scanner DLL is missing.", unknown: true );
UpdateEnvironmentDependencyRow( _environmentTargetRow, RetargetEnvironmentCheckSeverity.Warning, "Not checked yet.", "Current target model must exist before retargeting.", unknown: true );
return;
}
_environmentSummaryLabel.Text = $"{_environmentReport.BuildHeadline()} Checked at {_environmentReport.CheckedAt:HH:mm:ss}.";
_environmentDetailsLabel.Text = BuildEnvironmentDetailsLabel( _environmentReport );
UpdateEnvironmentDependencyRow( _environmentSettingsRow, GetEnvironmentGroupSeverity( _environmentReport, "tool_settings" ), BuildEnvironmentGroupStatus( _environmentReport, "tool_settings" ), BuildEnvironmentGroupDetail( _environmentReport, "Project-local settings file.", "tool_settings" ) );
UpdateEnvironmentDependencyRow( _environmentBackendRow, GetEnvironmentGroupSeverity( _environmentReport, "backend_root", "backend_recipe", "backend_script", "target_reference", "recipe_target_reference" ), BuildEnvironmentGroupStatus( _environmentReport, "backend_root", "backend_recipe", "backend_script", "target_reference", "recipe_target_reference" ), BuildEnvironmentGroupDetail( _environmentReport, "Bundled backend is available.", "backend_root", "backend_recipe", "backend_script", "target_reference", "recipe_target_reference" ) );
UpdateEnvironmentDependencyRow( _environmentBlenderRow, GetEnvironmentGroupSeverity( _environmentReport, "blender", "blender_version" ), BuildEnvironmentGroupStatus( _environmentReport, "blender", "blender_version" ), BuildBlenderEnvironmentDetail( _environmentReport ) );
UpdateEnvironmentDependencyRow( _environmentRokokoRow, GetEnvironmentGroupSeverity( _environmentReport, "rokoko_addon", "rokoko_addon_path" ), BuildEnvironmentGroupStatus( _environmentReport, "rokoko_addon", "rokoko_addon_path" ), BuildEnvironmentGroupDetail( _environmentReport, "Rokoko addon was found in Blender.", "rokoko_addon", "rokoko_addon_path" ) );
UpdateEnvironmentDependencyRow( _environmentNativeFbxRow, GetEnvironmentGroupSeverity( _environmentReport, "native_fbx" ), BuildEnvironmentGroupStatus( _environmentReport, "native_fbx" ), BuildEnvironmentGroupDetail( _environmentReport, "Native FBX scanner is available.", "native_fbx" ) );
UpdateEnvironmentDependencyRow( _environmentTargetRow, GetEnvironmentGroupSeverity( _environmentReport, "target_model" ), BuildEnvironmentGroupStatus( _environmentReport, "target_model" ), BuildEnvironmentGroupDetail( _environmentReport, "Current target model exists.", "target_model" ) );
}
private static RetargetEnvironmentCheckSeverity GetEnvironmentGroupSeverity( RetargetEnvironmentReport report, params string[] ids )
{
var matches = report.Checks.Where( check => ids.Contains( check.Id ) ).ToList();
if ( matches.Any( check => check.Severity == RetargetEnvironmentCheckSeverity.Error ) )
return RetargetEnvironmentCheckSeverity.Error;
if ( matches.Any( check => check.Severity == RetargetEnvironmentCheckSeverity.Warning ) )
return RetargetEnvironmentCheckSeverity.Warning;
return RetargetEnvironmentCheckSeverity.Ok;
}
private static string BuildEnvironmentGroupStatus( RetargetEnvironmentReport report, params string[] ids )
{
var matches = report.Checks.Where( check => ids.Contains( check.Id ) ).ToList();
if ( matches.Count == 0 )
return "Not checked";
var blocking = matches.Count( check => check.BlocksRetarget && check.Severity == RetargetEnvironmentCheckSeverity.Error );
if ( blocking > 0 )
return $"{blocking} blocker{(blocking == 1 ? string.Empty : "s")}";
var errors = matches.Count( check => check.Severity == RetargetEnvironmentCheckSeverity.Error );
if ( errors > 0 )
return $"{errors} error{(errors == 1 ? string.Empty : "s")}";
var warnings = matches.Count( check => check.Severity == RetargetEnvironmentCheckSeverity.Warning );
if ( warnings > 0 )
return $"{warnings} warning{(warnings == 1 ? string.Empty : "s")}";
return "Healthy";
}
private static string BuildEnvironmentGroupDetail( RetargetEnvironmentReport report, string fallback, params string[] ids )
{
var matches = report.Checks.Where( check => ids.Contains( check.Id ) ).ToList();
var primary = matches.FirstOrDefault( check => check.BlocksRetarget && check.Severity == RetargetEnvironmentCheckSeverity.Error )
?? matches.FirstOrDefault( check => check.Severity == RetargetEnvironmentCheckSeverity.Error )
?? matches.FirstOrDefault( check => check.Severity == RetargetEnvironmentCheckSeverity.Warning )
?? matches.FirstOrDefault( check => !string.IsNullOrWhiteSpace( check.Path ) )
?? matches.FirstOrDefault();
if ( primary is null )
return fallback;
if ( !string.IsNullOrWhiteSpace( primary.FixHint ) && primary.Severity != RetargetEnvironmentCheckSeverity.Ok )
return primary.FixHint;
if ( !string.IsNullOrWhiteSpace( primary.Path ) )
return primary.Path;
if ( !string.IsNullOrWhiteSpace( primary.Detail ) )
return primary.Detail;
return fallback;
}
private static string BuildBlenderEnvironmentDetail( RetargetEnvironmentReport report )
{
var issue = report.Checks.FirstOrDefault( check => check.Id is "blender" or "blender_version" && check.Severity != RetargetEnvironmentCheckSeverity.Ok );
if ( issue is not null )
return !string.IsNullOrWhiteSpace( issue.FixHint ) ? issue.FixHint : issue.Detail;
var parts = new List<string>();
if ( !string.IsNullOrWhiteSpace( report.BlenderVersion ) )
parts.Add( $"Blender {report.BlenderVersion}" );
if ( !string.IsNullOrWhiteSpace( report.BlenderExecutablePath ) )
parts.Add( report.BlenderExecutablePath );
return parts.Count > 0 ? string.Join( " | ", parts ) : "Blender executable was found.";
}
private static string BuildEnvironmentDetailsLabel( RetargetEnvironmentReport report )
{
var lines = new List<string>();
foreach ( var check in report.Checks )
{
var status = check.Severity switch
{
RetargetEnvironmentCheckSeverity.Ok => "OK",
RetargetEnvironmentCheckSeverity.Warning => "WARN",
_ => "ERROR"
};
var blocking = check.BlocksRetarget && check.Severity == RetargetEnvironmentCheckSeverity.Error
? " | blocks retarget"
: string.Empty;
lines.Add( $"{status} - {check.Title}{blocking}" );
if ( !string.IsNullOrWhiteSpace( check.Detail ) )
lines.Add( $" {check.Detail}" );
if ( !string.IsNullOrWhiteSpace( check.FixHint ) )
lines.Add( $" Fix: {check.FixHint}" );
if ( !string.IsNullOrWhiteSpace( check.Path ) )
lines.Add( $" Path: {check.Path}" );
lines.Add( string.Empty );
}
return string.Join( Environment.NewLine, lines ).TrimEnd();
}
private string BuildSupportDiagnosticsText()
{
var lines = new List<string>
{
"CARL Diagnostics",
$"Plugin version: {CitizenRetargetPluginInfo.Version}",
$"Generated: {DateTime.Now:yyyy-MM-dd HH:mm:ss}",
$"Project: {CitizenRetargetPaths.ProjectRoot}",
$"Plugin root: {CitizenRetargetPaths.PluginRoot}",
$"Settings file: {RetargetToolSettings.SettingsAbsolutePath}",
string.Empty,
"Setup",
$"Backend override setting: {_toolSettings.BackendRootPath}",
$"Blender path setting: {_toolSettings.BlenderExecutablePath}",
$"Rokoko addon path setting: {_toolSettings.RokokoAddonPath}",
$"CITIZEN_RETARGET_BLENDER: {Environment.GetEnvironmentVariable( "CITIZEN_RETARGET_BLENDER" ) ?? string.Empty}",
$"CITIZEN_RETARGET_ROKOKO_ADDON_PATH: {Environment.GetEnvironmentVariable( "CITIZEN_RETARGET_ROKOKO_ADDON_PATH" ) ?? string.Empty}",
};
if ( _environmentReport is null )
{
lines.Add( "Environment report: not scanned yet" );
}
else
{
lines.Add( $"Environment report: {_environmentReport.BuildHeadline()}" );
lines.Add( $"Resolved backend: {_environmentReport.BackendRootPath}" );
lines.Add( $"Backend source: {_environmentReport.BackendSource}" );
lines.Add( $"Resolved Blender: {_environmentReport.BlenderExecutablePath}" );
lines.Add( $"Blender version: {_environmentReport.BlenderVersion}" );
foreach ( var check in _environmentReport.Checks )
{
lines.Add( $"- {check.Severity}: {check.Title}" );
if ( !string.IsNullOrWhiteSpace( check.Detail ) )
lines.Add( $" detail: {check.Detail}" );
if ( !string.IsNullOrWhiteSpace( check.FixHint ) )
lines.Add( $" fix: {check.FixHint}" );
if ( !string.IsNullOrWhiteSpace( check.Path ) )
lines.Add( $" path: {check.Path}" );
}
}
var latestEntry = (_job.RecentRuns ?? new List<RetargetRunHistoryEntry>())
.OrderByDescending( entry => entry.CreatedUtc ?? string.Empty )
.FirstOrDefault();
lines.Add( string.Empty );
lines.Add( "Latest Run" );
if ( latestEntry is null )
{
lines.Add( "No run history recorded." );
}
else
{
lines.Add( $"Run ID: {latestEntry.RunId}" );
lines.Add( $"Clip: {latestEntry.ClipName}" );
lines.Add( $"Sequence: {latestEntry.SequenceName}" );
lines.Add( $"Status: {latestEntry.Status}" );
lines.Add( $"Created: {latestEntry.CreatedUtc}" );
lines.Add( $"Manifest: {CitizenRetargetPaths.DecodeExternalPath( latestEntry.ManifestPath )}" );
lines.Add( $"Export: {CitizenRetargetPaths.DecodeExternalPath( latestEntry.ExportPath )}" );
lines.Add( $"Imported: {CitizenRetargetPaths.DecodeExternalPath( latestEntry.ImportedAssetPath )}" );
lines.Add( $"Target VMDL: {latestEntry.TargetVmdlPath}" );
}
lines.Add( string.Empty );
lines.Add( "Latest Scan" );
lines.Add( $"Status: {_lastScanStatus}" );
lines.Add( $"Source: {_lastScanSourcePath}" );
lines.Add( $"Completed at: {_lastScanCompletedAt?.ToString( "yyyy-MM-dd HH:mm:ss" ) ?? string.Empty}" );
if ( !string.IsNullOrWhiteSpace( _lastScanDetails ) )
lines.Add( _lastScanDetails );
lines.Add( string.Empty );
lines.Add( "Current Job" );
lines.Add( $"Source FBX: {CitizenRetargetPaths.DecodeExternalPath( _job.SourceFbxPath ?? string.Empty )}" );
lines.Add( $"Source profile: {_job.SourceProfilePath}" );
lines.Add( $"Mapping profile: {_job.MappingProfilePath}" );
lines.Add( $"Target VMDL: {_job.TargetVmdlPath}" );
lines.Add( $"Animation folder: {_job.OutputAnimationFolder}" );
lines.Add( $"Sequence prefix: {_job.SequencePrefix}" );
return string.Join( Environment.NewLine, lines );
}
private BackgroundJobState StartBackgroundJob(
string title,
string scope,
bool blocksInteraction,
bool showInJobsStrip,
Func<BackgroundJobProgressReporter, Action?> work,
Action<Exception>? onError = null )
{
var job = new BackgroundJobState
{
Scope = scope,
Title = title,
Detail = title,
BlocksInteraction = blocksInteraction,
ShowInJobsStrip = showInJobsStrip
};
_backgroundJobs.Add( job );
RefreshBackgroundJobUi();
UpdateButtons();
job.Task = Task.Run( () =>
{
try
{
job.ApplySuccess = work( new BackgroundJobProgressReporter( job ) );
}
catch ( Exception exception )
{
job.Error = exception;
}
} );
job.ApplyError = onError;
return job;
}
private bool HasActiveBackgroundJob( string scope )
{
return GetActiveBackgroundJob( scope ) is not null;
}
private bool HasBlockingBackgroundJob( string scope )
{
return GetBlockingBackgroundJob( scope ) is not null;
}
private BackgroundJobState? GetActiveBackgroundJob( string scope )
{
return _backgroundJobs.LastOrDefault( job =>
job.Scope.Equals( scope, StringComparison.OrdinalIgnoreCase )
&& !job.CompletedNotified );
}
private BackgroundJobState? GetBlockingBackgroundJob( string scope )
{
return _backgroundJobs.LastOrDefault( job =>
job.Scope.Equals( scope, StringComparison.OrdinalIgnoreCase )
&& job.BlocksInteraction
&& !job.CompletedNotified );
}
private string BuildActiveJobStatus( string scope, string fallback )
{
var job = GetActiveBackgroundJob( scope );
if ( job is null )
return fallback;
var detail = string.IsNullOrWhiteSpace( job.Detail ) ? job.Title : job.Detail;
return string.IsNullOrWhiteSpace( detail ) ? fallback : $"{fallback} Current step: {detail}";
}
private string BuildSpinnerGlyph()
{
var frames = new[] { "◐", "◓", "◑", "◒" };
return frames[_jobsSpinnerFrame % frames.Length];
}
private void RefreshBackgroundJobUi()
{
if ( _jobsStripHost is null || _jobsStripLabel is null || _jobsStripProgressLabel is null || _jobsStripProgressBar is null )
return;
var visibleJobs = _backgroundJobs.Where( job => job.ShowInJobsStrip ).ToList();
if ( visibleJobs.Count == 0 )
{
_jobsStripHost.Hide();
if ( _sourceBusyLabel is not null )
_sourceBusyLabel.Text = string.Empty;
if ( _targetBusyLabel is not null )
{
_targetBusyLabel.Text = string.Empty;
_targetBusyLabel.Hide();
}
if ( _queueBusyLabel is not null )
_queueBusyLabel.Text = string.Empty;
_jobsStripProgressBar.IsIndeterminate = false;
_jobsStripProgressBar.Progress = 0f;
return;
}
var activeJob = visibleJobs[^1];
var spinner = BuildSpinnerGlyph();
_jobsStripLabel.Text = $"{spinner} {activeJob.Title}" + (string.IsNullOrWhiteSpace( activeJob.Detail ) || activeJob.Detail == activeJob.Title ? string.Empty : $" | {activeJob.Detail}");
_jobsStripProgressBar.IsIndeterminate = activeJob.IsIndeterminate;
_jobsStripProgressBar.Progress = activeJob.Progress;
_jobsStripProgressLabel.Text = activeJob.IsIndeterminate
? $"{visibleJobs.Count} background job(s) active"
: $"{FormatProgressText( activeJob.Progress )} complete";
_jobsStripHost.Show();
if ( _sourceBusyLabel is not null )
_sourceBusyLabel.Text = GetBusyLabelForScope( "source" );
if ( _targetBusyLabel is not null )
{
_targetBusyLabel.Text = GetBusyLabelForScope( "target" );
if ( string.IsNullOrWhiteSpace( _targetBusyLabel.Text ) )
_targetBusyLabel.Hide();
else
_targetBusyLabel.Show();
}
if ( _queueBusyLabel is not null )
_queueBusyLabel.Text = GetBusyLabelForScope( "queue" );
}
private string GetBusyLabelForScope( string scope )
{
var job = GetActiveBackgroundJob( scope );
if ( job is null )
return string.Empty;
return $"{BuildSpinnerGlyph()} {job.Title}";
}
private static string FormatProgressText( float progress )
{
var percent = (int)MathF.Round( Math.Clamp( progress, 0f, 1f ) * 100f );
return $"{percent}%";
}
private void ApplyJobToControls()
{
RefreshSourceLibraryFromJob();
_sourcePath.Text = _job.SourceFbxPath ?? string.Empty;
SetSourcePresetControlsFromJob();
_mappingProfilePath.Text = _job.MappingProfilePath ?? CitizenTargetProfile.DefaultMappingProfileAssetPath;
_targetVmdlPath.Text = _job.TargetVmdlPath ?? CitizenTargetProfile.DefaultTargetVmdlPath;
_targetPosePresetId.Text = _job.TargetPosePresetId ?? CitizenTargetProfile.DefaultTargetPosePresetId;
_outputFolder.Text = _job.OutputAnimationFolder ?? CitizenTargetProfile.DefaultOutputAnimationFolder;
_sequencePrefix.Text = _job.SequencePrefix ?? CitizenTargetProfile.DefaultSequencePrefix;
if ( _newTargetName is not null )
_newTargetName.Text = DeriveTargetKeyFromJob();
_rootMotionMode.CurrentIndex = _job.RootMotionMode == CitizenRetargetRootMotionMode.InPlace ? 1 : 0;
_importHands.State = _job.ImportHands ? CheckState.On : CheckState.Off;
UpdateNewTargetPreview();
UpdatePoseCompensationSummary();
SetTargetWorkspaceFlowState();
_sourceFacingEulerDegrees = GetStoredSourceFacingEulerDegrees();
RefreshSourceFacingUi();
}
private void SyncJobFromControls()
{
var previousLibraryId = _activeSourceLibrary.LibraryId;
_job.SourceFbxPath = _sourcePath.Text?.Trim() ?? string.Empty;
_job.SourceProfilePath = GetSourceProfilePathFromPresetIndex( GetSelectedSourcePresetIndex() );
_job.MappingProfilePath = _mappingProfilePath.Text?.Trim() ?? CitizenTargetProfile.DefaultMappingProfileAssetPath;
_job.TargetVmdlPath = _targetVmdlPath.Text?.Trim() ?? CitizenTargetProfile.DefaultTargetVmdlPath;
_job.TargetPosePresetId = _targetPosePresetId.Text?.Trim() ?? CitizenTargetProfile.DefaultTargetPosePresetId;
_job.OutputAnimationFolder = _outputFolder.Text?.Trim() ?? CitizenTargetProfile.DefaultOutputAnimationFolder;
_job.SequencePrefix = _sequencePrefix.Text?.Trim() ?? CitizenTargetProfile.DefaultSequencePrefix;
_job.RootMotionMode = _rootMotionMode.CurrentIndex == 1 ? CitizenRetargetRootMotionMode.InPlace : CitizenRetargetRootMotionMode.Keep;
_job.ImportHands = _importHands.State == CheckState.On;
RefreshSourceLibraryFromJob();
if ( !string.Equals( previousLibraryId, _activeSourceLibrary.LibraryId, StringComparison.OrdinalIgnoreCase ) )
{
InvalidateCompareSelectionForSourceChange();
_inspection = null;
_allClips.Clear();
_mappingState.Clear();
_selectedSourceBone = null;
_selectedSlot = null;
_job.SelectedClipNames = new List<string>();
if ( _clipFilter is not null )
_clipFilter.Text = string.Empty;
_clipList?.UnselectAll();
_sourceFacingEulerDegrees = GetStoredSourceFacingEulerDegrees();
RefreshClipList();
RefreshSourceBoneList();
RefreshMappingList();
}
_pipeline.SaveJob( _job );
UpdatePoseCompensationSummary();
RefreshSourceFacingUi();
}
private void ApplySettingsToControls()
{
if ( _backendPath is not null )
_backendPath.Text = _toolSettings.BackendRootPath ?? string.Empty;
if ( _blenderPath is not null )
_blenderPath.Text = _toolSettings.BlenderExecutablePath ?? string.Empty;
if ( _rokokoAddonPath is not null )
_rokokoAddonPath.Text = _toolSettings.RokokoAddonPath ?? string.Empty;
}
private void SyncToolSettingsFromControls()
{
var backendRootPath = _backendPath?.Text?.Trim() ?? string.Empty;
var blenderExecutablePath = _blenderPath?.Text?.Trim() ?? string.Empty;
var rokokoAddonPath = _rokokoAddonPath?.Text?.Trim() ?? string.Empty;
var changed = !string.Equals( _toolSettings.BackendRootPath ?? string.Empty, backendRootPath, StringComparison.Ordinal )
|| !string.Equals( _toolSettings.BlenderExecutablePath ?? string.Empty, blenderExecutablePath, StringComparison.Ordinal )
|| !string.Equals( _toolSettings.RokokoAddonPath ?? string.Empty, rokokoAddonPath, StringComparison.Ordinal );
_toolSettings.BackendRootPath = backendRootPath;
_toolSettings.BlenderExecutablePath = blenderExecutablePath;
_toolSettings.RokokoAddonPath = rokokoAddonPath;
_toolSettings.Save();
if ( changed )
{
_environmentReport = null;
_latestSetupFailureMessage = string.Empty;
ClearPendingSetupMessages();
}
RefreshEnvironmentDiagnostics();
RefreshQueueList();
UpdateButtons();
}
private Vector3 GetStoredSourceFacingEulerDegrees()
{
return _sourceFacingEulerOverrides.TryGetValue( _activeSourceLibrary.LibraryId, out var storedEuler )
? storedEuler
: Vector3.Zero;
}
private void SetSourceFacingEulerDegrees( Vector3 eulerDegrees )
{
_sourceFacingEulerDegrees = NormalizeFacingEulerDegrees( eulerDegrees );
if ( string.IsNullOrWhiteSpace( _activeSourceLibrary.LibraryId ) )
return;
if ( MathF.Abs( _sourceFacingEulerDegrees.x ) <= 0.001f
&& MathF.Abs( _sourceFacingEulerDegrees.y ) <= 0.001f
&& MathF.Abs( _sourceFacingEulerDegrees.z ) <= 0.001f )
{
_sourceFacingEulerOverrides.Remove( _activeSourceLibrary.LibraryId );
}
else
{
_sourceFacingEulerOverrides[_activeSourceLibrary.LibraryId] = _sourceFacingEulerDegrees;
}
RefreshSourceFacingUi();
RefreshSourceFacingFields();
UpdateButtons();
}
private void ResetSourceFacingCorrection()
{
if ( _inspection is null || _inspection.Audit.Bones.Count == 0 )
return;
SetSourceFacingEulerDegrees( Vector3.Zero );
SetStatus( "Source orientation reset to the selected preset default." );
}
private void RotateSourceFacingBy( Vector3 deltaDegrees, string axisLabel )
{
SetSourceFacingEulerDegrees( _sourceFacingEulerDegrees + deltaDegrees );
SetStatus( IsZeroSourceFacingEuler( _sourceFacingEulerDegrees )
? "Source orientation uses the detected default for this source."
: $"Source {axisLabel} adjustment saved: {FormatSourceFacingEuler( _sourceFacingEulerDegrees )}." );
}
private static float NormalizeFacingDegrees( float degrees )
{
var normalized = degrees % 360f;
if ( normalized > 180f )
normalized -= 360f;
if ( normalized <= -180f )
normalized += 360f;
return normalized;
}
private static Vector3 NormalizeFacingEulerDegrees( Vector3 eulerDegrees )
{
return new Vector3(
NormalizeFacingDegrees( eulerDegrees.x ),
NormalizeFacingDegrees( eulerDegrees.y ),
NormalizeFacingDegrees( eulerDegrees.z ) );
}
private static bool IsZeroSourceFacingEuler( Vector3 eulerDegrees )
{
return MathF.Abs( eulerDegrees.x ) <= 0.001f
&& MathF.Abs( eulerDegrees.y ) <= 0.001f
&& MathF.Abs( eulerDegrees.z ) <= 0.001f;
}
private static string FormatSourceFacingEuler( Vector3 eulerDegrees )
{
return $"X {eulerDegrees.x:+0;-0;0} | Y {eulerDegrees.y:+0;-0;0} | Z {eulerDegrees.z:+0;-0;0}";
}
private static string FormatSourceFacingFieldValue( float value )
{
return NormalizeFacingDegrees( value ).ToString( "0.###", System.Globalization.CultureInfo.InvariantCulture );
}
private void RefreshSourceFacingFields()
{
if ( _sourceFacingTiltField is null || _sourceFacingRollField is null || _sourceFacingFacingField is null )
return;
_suppressSourceFacingFieldSync = true;
try
{
_sourceFacingTiltField.Text = FormatSourceFacingFieldValue( _sourceFacingEulerDegrees.x );
_sourceFacingRollField.Text = FormatSourceFacingFieldValue( _sourceFacingEulerDegrees.y );
_sourceFacingFacingField.Text = FormatSourceFacingFieldValue( _sourceFacingEulerDegrees.z );
}
finally
{
_suppressSourceFacingFieldSync = false;
}
}
private Vector3 GetSourceFacingBaseEulerDegrees()
{
if ( TryReadSourceProfileFacingPreset( _job.SourceProfilePath, out var presetEuler ) )
return NormalizeFacingEulerDegrees( presetEuler );
var backendSourceProfileId = _sourceProfile?.BackendSourceProfileId?.Trim() ?? string.Empty;
return backendSourceProfileId.Equals( "mixamo_humanoid", StringComparison.OrdinalIgnoreCase )
? new Vector3( 0f, 0f, 180f )
: Vector3.Zero;
}
private bool IsSourceFacingHandledByProfile()
{
var backendSourceProfileId = _sourceProfile?.BackendSourceProfileId?.Trim() ?? string.Empty;
return backendSourceProfileId.Equals( "quaternius_ual2", StringComparison.OrdinalIgnoreCase );
}
private bool IsSourceFacingManualOverrideEnabled()
{
return !string.IsNullOrWhiteSpace( _activeSourceLibrary.LibraryId )
&& _sourceFacingManualOverrideEnabled.Contains( _activeSourceLibrary.LibraryId );
}
private Vector3 GetAppliedSourceFacingEulerDegrees()
{
var baseEuler = GetSourceFacingBaseEulerDegrees();
if ( !IsSourceFacingManualOverrideEnabled() )
return NormalizeFacingEulerDegrees( baseEuler );
if ( IsSourceFacingHandledByProfile() )
return _sourceFacingEulerDegrees;
return NormalizeFacingEulerDegrees( baseEuler + _sourceFacingEulerDegrees );
}
private static bool TryReadSourceProfileFacingPreset( string? sourceProfilePath, out Vector3 presetEuler )
{
presetEuler = Vector3.Zero;
if ( string.IsNullOrWhiteSpace( sourceProfilePath ) )
return false;
try
{
var normalized = sourceProfilePath.Replace( '\\', '/' ).Trim().TrimStart( '/' );
if ( normalized.StartsWith( "Assets/", StringComparison.OrdinalIgnoreCase ) )
normalized = normalized["Assets/".Length..];
var absolutePath = CitizenRetargetPaths.GetAssetAbsolutePath( normalized );
if ( !File.Exists( absolutePath ) )
return false;
using var document = JsonDocument.Parse( File.ReadAllText( absolutePath ) );
if ( !document.RootElement.TryGetProperty( "DefaultSourceFacingEulerDegrees", out var element ) || element.ValueKind != JsonValueKind.Array )
return false;
var values = element.EnumerateArray()
.Take( 3 )
.Select( value => value.TryGetSingle( out var number ) ? number : 0f )
.ToArray();
if ( values.Length < 3 )
return false;
presetEuler = new Vector3( values[0], values[1], values[2] );
return true;
}
catch
{
return false;
}
}
private static bool IsSameSourceFacingEuler( Vector3 left, Vector3 right )
{
var delta = NormalizeFacingEulerDegrees( left - right );
return IsZeroSourceFacingEuler( delta );
}
private void ClearLegacyManualFacingIfItMatchesPreset()
{
var presetEuler = GetSourceFacingBaseEulerDegrees();
if ( IsZeroSourceFacingEuler( presetEuler ) || string.IsNullOrWhiteSpace( _activeSourceLibrary.LibraryId ) )
return;
if ( !_sourceFacingEulerOverrides.TryGetValue( _activeSourceLibrary.LibraryId, out var storedEuler ) )
return;
if ( !IsSameSourceFacingEuler( storedEuler, presetEuler ) )
return;
_sourceFacingEulerOverrides.Remove( _activeSourceLibrary.LibraryId );
_sourceFacingEulerDegrees = Vector3.Zero;
}
private void SetSourceFacingManualOverrideEnabled( bool enabled )
{
if ( string.IsNullOrWhiteSpace( _activeSourceLibrary.LibraryId ) )
return;
if ( enabled )
{
_sourceFacingManualOverrideEnabled.Add( _activeSourceLibrary.LibraryId );
SetStatus( "Manual orientation override enabled for this source." );
}
else
{
_sourceFacingManualOverrideEnabled.Remove( _activeSourceLibrary.LibraryId );
SetStatus( "Manual orientation override disabled. Preset/default orientation will be used." );
}
RefreshSourceFacingUi();
UpdateButtons();
}
private Vector3 GetEffectiveSourceFacingPreviewEulerDegrees()
{
// Preview must mirror what the backend receives. Profile defaults are backend-side hints,
// while manual override replaces them instead of stacking on top.
return GetAppliedSourceFacingEulerDegrees();
}
private void RefreshSourceFacingUi()
{
if ( _sourceFacingCard is null || _sourceFacingPreview is null || _sourceFacingSummaryLabel is null || _sourceFacingHintLabel is null )
return;
var hasInspection = _inspection is not null && _inspection.Audit.Bones.Count > 0;
if ( hasInspection )
{
var inspection = _inspection;
if ( inspection is null )
{
_sourceFacingCard.Hide();
_sourceFacingPreview.UpdatePreview( null, Vector3.Zero, "Scan a source FBX to inspect its facing." );
RefreshSourceFacingManualOverrideCheckbox();
return;
}
_sourceFacingCard.Show();
var effectiveEuler = GetEffectiveSourceFacingPreviewEulerDegrees();
var baseEuler = NormalizeFacingEulerDegrees( GetSourceFacingBaseEulerDegrees() );
var profileHandled = IsSourceFacingHandledByProfile();
var manualOverrideEnabled = IsSourceFacingManualOverrideEnabled();
var manualCorrectionText = IsZeroSourceFacingEuler( _sourceFacingEulerDegrees )
? "none"
: FormatSourceFacingEuler( _sourceFacingEulerDegrees );
var detectedText = IsZeroSourceFacingEuler( baseEuler )
? "Detected default facing: no built-in correction."
: $"Detected default orientation: {FormatSourceFacingEuler( baseEuler )}.";
_sourceFacingSummaryLabel.Text = profileHandled
? $"{detectedText} This profile already handles facing automatically. Manual override: {(manualOverrideEnabled ? manualCorrectionText : "off")}."
: $"{detectedText} Manual override: {(manualOverrideEnabled ? manualCorrectionText : "off")}.";
_sourceFacingHintLabel.Text = manualOverrideEnabled
? "Edit X/Y/Z until the yellow source arrow points the same way as the green Citizen front arrow."
: "Preset/default orientation is active. Enable manual override only if the yellow source arrow points the wrong way.";
_sourceFacingPreview.UpdatePreview(
inspection.Audit.Bones,
effectiveEuler,
profileHandled && !manualOverrideEnabled
? "Green arrow = Citizen front. This preset handles source facing automatically."
: manualOverrideEnabled
? $"Manual override: {manualCorrectionText}. Yellow arrow should match the green Citizen front arrow."
: "Preset/default orientation is active. Enable manual override if the yellow arrow needs correction." );
RefreshSourceFacingManualOverrideCheckbox();
RefreshSourceFacingFields();
return;
}
_sourceFacingCard.Hide();
_sourceFacingPreview.UpdatePreview( null, Vector3.Zero, "Scan a source FBX to inspect its facing." );
RefreshSourceFacingManualOverrideCheckbox();
RefreshSourceFacingFields();
}
private void RefreshSourceFacingManualOverrideCheckbox()
{
if ( _sourceFacingManualOverrideCheckbox is null )
return;
_suppressSourceFacingManualOverrideSync = true;
try
{
_sourceFacingManualOverrideCheckbox.State = IsSourceFacingManualOverrideEnabled()
? CheckState.On
: CheckState.Off;
}
finally
{
_suppressSourceFacingManualOverrideSync = false;
}
}
private void ToggleAdvancedSettings()
{
SetAdvancedSettingsVisible( !_advancedSettingsVisible );
}
private void BrowseSourceFbx()
{
try
{
var currentPath = CitizenRetargetPaths.DecodeExternalPath( _job.SourceFbxPath );
var selectedPath = RetargetFileDialogs.PickExistingFile(
"Choose Source FBX",
"fbx",
currentPath,
Environment.GetFolderPath( Environment.SpecialFolder.MyDocuments ) );
if ( string.IsNullOrWhiteSpace( selectedPath ) )
{
SetStatus( "Source FBX selection cancelled." );
return;
}
if ( _sourcePath is not null )
_sourcePath.Text = selectedPath;
SyncJobFromControls();
SetStatus( $"Source FBX selected: {selectedPath}. Press Scan to inspect it." );
}
catch ( Exception exception )
{
SetStatus( $"Browse failed: {exception.Message}" );
}
}
private void BeginOpenExistingTargetFlow()
{
LoadTargetPresetInventory();
_showOpenExistingTargetFlow = true;
_showCreateTargetFlow = false;
SetTargetWorkspaceFlowState();
}
private void BeginCreateTargetFlow()
{
_showOpenExistingTargetFlow = false;
_showCreateTargetFlow = true;
if ( _newTargetName is not null && string.IsNullOrWhiteSpace( _newTargetName.Text ) )
_newTargetName.Text = DeriveTargetKeyFromJob();
UpdateNewTargetPreview();
SetTargetWorkspaceFlowState();
}
private void ResetTargetWorkspaceFlow()
{
_showOpenExistingTargetFlow = false;
_showCreateTargetFlow = false;
ClearTargetPresetInventory();
SetTargetWorkspaceFlowState();
}
private void SetTargetWorkspaceFlowState()
{
if ( _targetWorkspaceIntroHost is not null )
{
if ( !_showOpenExistingTargetFlow && !_showCreateTargetFlow )
_targetWorkspaceIntroHost.Show();
else
_targetWorkspaceIntroHost.Hide();
}
if ( _targetOpenExistingHost is not null )
{
if ( _showOpenExistingTargetFlow )
_targetOpenExistingHost.Show();
else
_targetOpenExistingHost.Hide();
}
if ( _targetCreateHost is not null )
{
if ( _showCreateTargetFlow )
_targetCreateHost.Show();
else
_targetCreateHost.Hide();
}
if ( _currentTargetWorkspaceLabel is not null )
{
_currentTargetWorkspaceLabel.Text = _showCreateTargetFlow
? "Create a new Citizen target. The plugin will generate a new VMDL and animation folder for it."
: _showOpenExistingTargetFlow
? "Pick an existing target to make it the only active target in this session."
: $"Current target: {_job.TargetVmdlPath}. Home works with this target only.";
}
}
private void UpdateNewTargetPreview()
{
var key = SanitizeTargetKey( _newTargetName?.Text );
var preset = BuildTargetPresetFromKey( key, $"models/citizen_custom/citizen_{key}.vmdl", false );
if ( _newTargetVmdlPreviewLabel is not null )
_newTargetVmdlPreviewLabel.Text = $"VMDL: {preset.TargetVmdlPath}";
if ( _newTargetFolderPreviewLabel is not null )
_newTargetFolderPreviewLabel.Text = $"Animation Folder: {preset.OutputAnimationFolder}";
if ( _newTargetPrefixPreviewLabel is not null )
_newTargetPrefixPreviewLabel.Text = $"Sequence Prefix: {preset.SequencePrefix}";
UpdateButtons();
}
private void OpenSelectedTargetPreset()
{
var preset = _targetPresetList?.SelectedItems.OfType<RetargetTargetPresetRef>().FirstOrDefault();
if ( preset is null )
{
SetStatus( "Select a target preset before opening it." );
return;
}
ApplyTargetPreset( preset, ensureTargetExists: false );
ResetTargetWorkspaceFlow();
SetStatus( $"Opened target '{preset.DisplayName}'." );
}
private void CreateNewTargetPreset()
{
var requestedKey = _newTargetName?.Text;
var targetKey = SanitizeTargetKey( string.IsNullOrWhiteSpace( requestedKey ) ? _activeSourceLibrary.DisplayName : requestedKey );
var existingPresets = DiscoverTargetPresets();
var uniqueKey = targetKey;
var suffix = 2;
while ( existingPresets.Any( preset => preset.Key.Equals( uniqueKey, StringComparison.OrdinalIgnoreCase ) ) )
{
uniqueKey = $"{targetKey}_{suffix}";
suffix++;
}
var preset = BuildTargetPresetFromKey( uniqueKey, $"models/citizen_custom/citizen_{uniqueKey}.vmdl", false );
ApplyTargetPreset( preset, ensureTargetExists: true );
if ( _newTargetName is not null )
_newTargetName.Text = uniqueKey;
UpdateNewTargetPreview();
ResetTargetWorkspaceFlow();
SetStatus( $"Created new target '{uniqueKey}'." );
}
private void ApplyTargetPreset( RetargetTargetPresetRef preset, bool ensureTargetExists )
{
_job.TargetVmdlPath = preset.TargetVmdlPath;
_job.OutputAnimationFolder = preset.OutputAnimationFolder;
_job.SequencePrefix = preset.SequencePrefix;
_targetVmdlPath.Text = _job.TargetVmdlPath;
_outputFolder.Text = _job.OutputAnimationFolder;
_sequencePrefix.Text = _job.SequencePrefix;
if ( _newTargetName is not null )
_newTargetName.Text = preset.Key;
_selectedTargetAnimation = null;
_builtInTargetAnimationsLoaded = false;
_builtInTargetAnimationsLoadRequested = false;
SetBuiltInTargetAnimationVisibility( false );
_session.CompareSelection.SelectedResultRunId = string.Empty;
_selectedCompareResult = null;
_pipeline.SaveJob( _job );
if ( ensureTargetExists )
_pipeline.EnsureTargetAssetExists( _job );
ClearTargetPresetInventory();
RefreshResultList();
RefreshResultSurfaces();
UpdateButtons();
BeginRefreshTargetAnimationListAsync( ensureTargetExists
? $"Created target '{preset.DisplayName}' and loaded its animation list."
: $"Opened target '{preset.DisplayName}'." );
}
private void ToggleBuiltInTargetAnimations()
{
if ( _showBuiltInTargetAnimations )
{
SetBuiltInTargetAnimationVisibility( false );
return;
}
if ( !_builtInTargetAnimationsLoaded )
{
_builtInTargetAnimationsLoadRequested = true;
if ( _toggleBuiltInTargetAnimationsButton is not null )
{
_toggleBuiltInTargetAnimationsButton.Enabled = false;
_toggleBuiltInTargetAnimationsButton.Text = "Loading Built-In...";
}
BeginRefreshTargetAnimationListAsync( "Loaded built-in target animations." );
return;
}
SetBuiltInTargetAnimationVisibility( true );
}
private void SetBuiltInTargetAnimationVisibility( bool visible )
{
_showBuiltInTargetAnimations = visible;
if ( _builtInTargetSectionHost is not null )
{
if ( visible )
_builtInTargetSectionHost.Show();
else
_builtInTargetSectionHost.Hide();
}
if ( _toggleBuiltInTargetAnimationsButton is not null )
{
_toggleBuiltInTargetAnimationsButton.Enabled = true;
_toggleBuiltInTargetAnimationsButton.Text = visible ? "Hide Built-In" : "Show Built-In";
}
}
private void SetAdvancedSettingsVisible( bool visible )
{
_advancedSettingsVisible = visible;
if ( _advancedSettingsHost is not null )
{
if ( visible )
_advancedSettingsHost.Show();
else
_advancedSettingsHost.Hide();
}
if ( _toggleAdvancedButton is not null )
_toggleAdvancedButton.Text = visible ? "Hide Advanced" : "Show Advanced";
}
private static RetargetSessionState CreateSessionStateFromJob( CitizenRetargetJob job )
{
var sourceLibrary = RetargetSourceLibraryResolver.FromJob( job );
var selectedSourceClipName = job.SelectedClipNames?.FirstOrDefault() ?? string.Empty;
var selectedResultRunId = string.IsNullOrWhiteSpace( job.LastSuccessfulRunId )
? string.Empty
: job.LastSuccessfulRunId;
return new RetargetSessionState
{
CompareSelection = new RetargetCompareSelection
{
SelectedSourceLibraryId = selectedSourceClipName.Length > 0 ? sourceLibrary.LibraryId : string.Empty,
SelectedSourceClipName = selectedSourceClipName,
SelectedResultRunId = selectedResultRunId
},
SelectedHistoryRunId = string.Empty,
HomeResultFilter = "All"
};
}
private void RefreshSourceLibraryFromJob()
{
_activeSourceLibrary = RetargetSourceLibraryResolver.FromJob( _job );
}
private void InvalidateCompareSelectionForSourceChange()
{
_session.CompareSelection.SelectedSourceLibraryId = _activeSourceLibrary.LibraryId;
_session.CompareSelection.SelectedSourceClipName = string.Empty;
_session.CompareSelection.SelectedResultRunId = string.Empty;
_selectedCompareResult = null;
RefreshResultList();
RefreshResultSurfaces();
}
private void SyncSelectedClipsToJob()
{
_job.SelectedClipNames = _clipList.SelectedItems.OfType<RetargetClipDescriptor>().Select( clip => clip.DisplayName ).ToList();
_pipeline.SaveJob( _job );
}
private void OnClipSelectionChanged()
{
SyncSelectedClipsToJob();
SyncCompareSelectionFromCurrentClip();
ResolveCompareResultSelection();
RefreshClipSummary();
RefreshResultList();
RefreshResultSurfaces();
UpdateButtons();
}
private void BeginScanAndInspectAsync()
{
if ( HasActiveBackgroundJob( "source" ) )
{
SetStatus( BuildActiveJobStatus( "source", "Source scan is already running." ) );
return;
}
if ( _jobInFlight )
{
SetStatus( "Wait for the current retarget job to finish before scanning a new source." );
return;
}
CitizenRetargetJob jobSnapshot;
RetargetSourceProfile sourceProfileSnapshot;
RetargetMappingProfile mappingProfileSnapshot;
Dictionary<string, RetargetSourceProfile> sourceProfilesByPath;
string mappingProfilePath;
try
{
SetStatus( "Preparing source scan..." );
SyncJobFromControls();
var resolvedSourcePath = _pipeline.ResolveSourceFbxPath( _job );
_job.SourceFbxPath = resolvedSourcePath;
if ( _sourcePath is not null )
_sourcePath.Text = resolvedSourcePath;
_pipeline.SaveJob( _job );
RememberScanDiagnostic( "running", resolvedSourcePath, "Source scan has started." );
SetStatus( $"Scanning '{Path.GetFileName( resolvedSourcePath )}'..." );
LoadProfilesFromJob();
RefreshSourceLibraryFromJob();
jobSnapshot = _pipeline.CreateRuntimeJobSnapshot( _job );
sourceProfileSnapshot = _pipeline.LoadSourceProfile( jobSnapshot.SourceProfilePath );
sourceProfilesByPath = new Dictionary<string, RetargetSourceProfile>( StringComparer.OrdinalIgnoreCase )
{
[NormalizeProfilePathForUi( jobSnapshot.SourceProfilePath )] = sourceProfileSnapshot
};
CacheSourceProfileForScan( sourceProfilesByPath, CitizenTargetProfile.MixamoSourceProfileAssetPath );
CacheSourceProfileForScan( sourceProfilesByPath, CitizenTargetProfile.DefaultSourceProfileAssetPath );
mappingProfilePath = string.IsNullOrWhiteSpace( sourceProfileSnapshot.DefaultMappingProfilePath )
? (jobSnapshot.MappingProfilePath ?? CitizenTargetProfile.DefaultMappingProfileAssetPath)
: sourceProfileSnapshot.DefaultMappingProfilePath;
mappingProfileSnapshot = _pipeline.LoadMappingProfile( mappingProfilePath );
}
catch ( Exception exception )
{
ReportScanFailure( "prepare", exception );
UpdateButtons();
RefreshBackgroundJobUi();
return;
}
StartBackgroundJob(
title: "Scanning source FBX",
scope: "source",
blocksInteraction: true,
showInJobsStrip: true,
work: reporter =>
{
reporter.ReportDetail( "Scanning clips..." );
var clips = _pipeline.ScanClips( jobSnapshot ).ToList();
reporter.ReportProgress( 0.35f, "Inspecting source skeleton..." );
var inspection = _pipeline.InspectSourceSkeleton( jobSnapshot );
var effectiveSourceProfilePath = jobSnapshot.SourceProfilePath;
var effectiveSourceProfile = sourceProfileSnapshot;
if ( string.IsNullOrWhiteSpace( effectiveSourceProfilePath ) )
{
var detectedSourceProfilePath = _pipeline.DetectSourceProfilePath( jobSnapshot, inspection, clips );
if ( !string.IsNullOrWhiteSpace( detectedSourceProfilePath ) )
{
effectiveSourceProfilePath = detectedSourceProfilePath;
var detectedProfileKey = NormalizeProfilePathForUi( effectiveSourceProfilePath );
if ( !sourceProfilesByPath.TryGetValue( detectedProfileKey, out effectiveSourceProfile ) )
throw new InvalidOperationException( $"Detected source profile '{effectiveSourceProfilePath}' was not preloaded before background scanning." );
}
}
var effectiveMappingProfilePath = string.IsNullOrWhiteSpace( effectiveSourceProfile.DefaultMappingProfilePath )
? mappingProfilePath
: effectiveSourceProfile.DefaultMappingProfilePath;
var effectiveMappingProfile = effectiveMappingProfilePath.Equals( mappingProfilePath, StringComparison.OrdinalIgnoreCase )
? mappingProfileSnapshot
: _pipeline.LoadMappingProfile( effectiveMappingProfilePath );
reporter.ReportProgress( 0.75f, "Building mapping state..." );
var mappingState = _pipeline.BuildMappingState( jobSnapshot, effectiveSourceProfile, effectiveMappingProfile, inspection );
reporter.ReportProgress( 1f, "Applying scan results..." );
var result = new ScanJobResult
{
Clips = clips,
Inspection = inspection,
MappingState = mappingState,
DetectedSourceProfilePath = effectiveSourceProfilePath,
DetectedSourceProfileDisplayName = effectiveSourceProfile.DisplayName,
DetectedMappingProfilePath = effectiveMappingProfilePath
};
return () => ApplyScanJobResult( result );
},
onError: exception =>
{
ReportScanFailure( "background", exception );
UpdateButtons();
RefreshBackgroundJobUi();
} );
}
private void ApplyScanJobResult( ScanJobResult result )
{
if ( !string.IsNullOrWhiteSpace( result.DetectedSourceProfilePath ) )
_job.SourceProfilePath = result.DetectedSourceProfilePath;
if ( !string.IsNullOrWhiteSpace( result.DetectedMappingProfilePath ) )
_job.MappingProfilePath = result.DetectedMappingProfilePath;
LoadProfilesFromJob();
RefreshSourceLibraryFromJob();
SetSourcePresetControlsFromJob();
_mappingProfilePath.Text = _job.MappingProfilePath;
_allClips = result.Clips;
_inspection = result.Inspection;
_mappingState = result.MappingState;
_sourceFacingEulerDegrees = GetStoredSourceFacingEulerDegrees();
ClearLegacyManualFacingIfItMatchesPreset();
if ( _allClips.Count > 0 && !string.IsNullOrWhiteSpace( _clipFilter?.Text ) && CountVisibleClips( _clipFilter.Text ) == 0 )
_clipFilter.Text = string.Empty;
RefreshClipList();
RefreshSourceBoneList();
RefreshMappingList();
RefreshSourceFacingUi();
UpdateDiagnostics();
RefreshClipSummary();
SyncCompareSelectionFromCurrentClip();
ResolveCompareResultSelection();
RefreshResultList();
RefreshResultSurfaces();
UpdateButtons();
RememberScanDiagnostic( _allClips.Count == 0 ? "completed_no_clips" : "completed", CitizenRetargetPaths.DecodeExternalPath( _job.SourceFbxPath ?? string.Empty ), BuildScanSuccessDiagnostic( result ) );
SetStatus( _allClips.Count == 0
? $"Scanned source and inspected {_inspection.Audit.Bones.Count} bones using '{result.DetectedSourceProfileDisplayName}', but no animation clips were found."
: $"Scanned {_allClips.Count} clips and inspected {_inspection.Audit.Bones.Count} bones using '{result.DetectedSourceProfileDisplayName}'." );
if ( _allClips.Count == 0 && _clipSummaryLabel is not null )
_clipSummaryLabel.Text = "Scan finished, but no animation clips were found. Try a different FBX export, or export a support bundle from Diagnostics.";
}
private void BeginRefreshTargetAnimationListAsync( string statusMessage )
{
if ( _targetAnimationList is null || _builtInTargetAnimationList is null )
return;
if ( HasActiveBackgroundJob( "target" ) )
return;
var previewVmdlPath = _pipeline.ResolvePreviewableTargetVmdlPath( _job );
var importedSources = _pipeline.GetTargetAnimationSources( _job );
var shouldLoadBuiltIns = _showBuiltInTargetAnimations || _builtInTargetAnimationsLoadRequested || _builtInTargetAnimationsLoaded;
StartBackgroundJob(
title: "Loading target animations",
scope: "target",
blocksInteraction: false,
showInJobsStrip: false,
work: reporter =>
{
reporter.ReportDetail( "Reading imported target animations..." );
var importedTargets = importedSources
.Select( target => new RetargetTargetAnimationEntry
{
SequenceName = target.SequenceName,
Looping = target.Looping,
IsImported = true,
IsReadOnly = false,
VmdlResourcePath = previewVmdlPath,
SourceResourcePath = target.ResourcePath
} )
.ToList();
reporter.ReportProgress( 0.5f, shouldLoadBuiltIns ? "Reading model animation list..." : "Skipping built-in animation load..." );
var builtInTargets = shouldLoadBuiltIns
? _pipeline.GetModelAnimationNames( previewVmdlPath )
.Where( sequenceName => !importedTargets.Select( target => target.SequenceName ).Contains( sequenceName, StringComparer.OrdinalIgnoreCase ) )
.Select( sequenceName => new RetargetTargetAnimationEntry
{
SequenceName = sequenceName,
Looping = sequenceName.Contains( "loop", StringComparison.OrdinalIgnoreCase ),
IsImported = false,
IsReadOnly = true,
VmdlResourcePath = previewVmdlPath
} )
.OrderBy( target => target.SequenceName, StringComparer.OrdinalIgnoreCase )
.ToList()
: new List<RetargetTargetAnimationEntry>();
reporter.ReportProgress( 1f, "Applying target animation list..." );
return () => ApplyTargetAnimationEntries( importedTargets, builtInTargets, statusMessage, shouldLoadBuiltIns );
},
onError: exception =>
{
_builtInTargetAnimationsLoadRequested = false;
if ( _toggleBuiltInTargetAnimationsButton is not null && !_showBuiltInTargetAnimations )
_toggleBuiltInTargetAnimationsButton.Text = "Show Built-In";
SetStatus( $"Target load failed: {exception.Message}" );
} );
}
private void ApplyTargetAnimationEntries(
List<RetargetTargetAnimationEntry> importedTargets,
List<RetargetTargetAnimationEntry> builtInTargets,
string statusMessage,
bool builtInsLoaded )
{
_targetAnimationList.SetItems( importedTargets );
_builtInTargetAnimationList.SetItems( builtInTargets );
_targetAnimationList.UpdateIfDirty();
_builtInTargetAnimationList.UpdateIfDirty();
if ( builtInsLoaded )
_builtInTargetAnimationsLoaded = true;
if ( _builtInTargetAnimationsLoadRequested && builtInsLoaded )
{
_builtInTargetAnimationsLoadRequested = false;
SetBuiltInTargetAnimationVisibility( true );
}
if ( _selectedTargetAnimation is not null )
{
var matchingImported = importedTargets.FirstOrDefault( target =>
target.IsImported == _selectedTargetAnimation.IsImported
&& target.SequenceName.Equals( _selectedTargetAnimation.SequenceName, StringComparison.OrdinalIgnoreCase ) );
if ( matchingImported is not null )
_targetAnimationList.SelectItem( matchingImported, true, true );
var matchingBuiltIn = builtInTargets.FirstOrDefault( target =>
target.IsImported == _selectedTargetAnimation.IsImported
&& target.SequenceName.Equals( _selectedTargetAnimation.SequenceName, StringComparison.OrdinalIgnoreCase ) );
if ( matchingBuiltIn is not null )
_builtInTargetAnimationList.SelectItem( matchingBuiltIn, true, true );
}
if ( _targetSummaryLabel is not null )
_targetSummaryLabel.Text = BuildTargetAnimationSummary( importedTargets, builtInTargets );
if ( _builtInTargetSummaryLabel is not null )
_builtInTargetSummaryLabel.Text = BuildBuiltInTargetAnimationSummary( builtInTargets );
SetBuiltInTargetAnimationVisibility( _showBuiltInTargetAnimations );
UpdateButtons();
SetStatus( statusMessage );
}
private void RebuildAutoMap()
{
if ( _inspection is null )
return;
SyncJobFromControls();
LoadProfilesFromJob();
_mappingState = _pipeline.BuildMappingState( _job, _sourceProfile, _mappingProfile, _inspection );
RefreshMappingList();
RefreshMappingEditorState();
UpdateDiagnostics();
UpdateButtons();
SetStatus( "Rebuilt the mapping state from the current profiles." );
}
private void SaveMappingProfile()
{
try
{
SyncJobFromControls();
LoadProfilesFromJob();
var path = _pipeline.SaveMappingProfile( _mappingProfile, _mappingState, _job.MappingProfilePath );
SetStatus( $"Saved mapping profile to '{path}'." );
}
catch ( Exception exception )
{
SetStatus( $"Saving mapping profile failed: {exception.Message}" );
}
}
private void OnSourceBoneSelectionChanged( NativeAuditBoneInfo? bone )
{
_selectedSourceBone = bone;
RefreshMappingEditorState();
UpdateButtons();
}
private void OnMappingSlotSelected( RetargetSlotAssignmentState? slot )
{
_selectedSlot = slot;
TrySelectSourceBoneForSelectedSlot();
RefreshMappingEditorState();
UpdateButtons();
}
private void TrySelectSourceBoneForSelectedSlot()
{
if ( _selectedSlot is null || _sourceBoneList is null || _inspection is null )
return;
var preferredBoneName = !string.IsNullOrWhiteSpace( _selectedSlot.AssignedSourceBone )
? _selectedSlot.AssignedSourceBone
: _selectedSlot.EffectiveSourceBone;
if ( string.IsNullOrWhiteSpace( preferredBoneName ) )
return;
var preferredBone = _inspection.Audit.Bones.FirstOrDefault( bone =>
bone.Name.Equals( preferredBoneName, StringComparison.OrdinalIgnoreCase ) );
if ( preferredBone is null )
return;
_selectedSourceBone = preferredBone;
_sourceBoneList.SelectItem( preferredBone, true, true );
}
private void RefreshMappingEditorState()
{
if ( _mappingSelectionTitleLabel is null || _mappingSelectionSummaryLabel is null || _mappingSelectionHintLabel is null )
return;
if ( _selectedSlot is null )
{
_mappingSelectionTitleLabel.Text = "No slot selected yet.";
_mappingSelectionSummaryLabel.Text = "Pick a row in Bone Map Editor, then pick a source bone on the left.";
_mappingSelectionHintLabel.Text = "Manual flow: select slot -> select source bone -> Assign Selected Bone -> Save Mapping Profile.";
return;
}
var currentSource = string.IsNullOrWhiteSpace( _selectedSlot.EffectiveSourceBone ) ? "Unmapped" : _selectedSlot.EffectiveSourceBone;
var autoSource = string.IsNullOrWhiteSpace( _selectedSlot.AutoSourceBone ) ? "No auto match" : _selectedSlot.AutoSourceBone;
var sourceState = _selectedSlot.IsManualOverride
? "Manual override"
: string.IsNullOrWhiteSpace( _selectedSlot.EffectiveSourceBone )
? "Needs manual assignment"
: "Auto mapped";
var importance = _selectedSlot.Required ? "Required slot" : "Optional slot";
var selectedBoneText = _selectedSourceBone?.Name ?? "No source bone selected";
var candidates = _selectedSlot.CandidateBones
.Where( bone => !string.IsNullOrWhiteSpace( bone ) )
.Take( 4 )
.ToList();
var candidateText = candidates.Count == 0
? "No other auto candidates."
: $"Auto candidates: {string.Join( ", ", candidates )}{(_selectedSlot.CandidateBones.Count > candidates.Count ? "..." : string.Empty)}";
_mappingSelectionTitleLabel.Text = $"{_selectedSlot.SlotId} -> {_selectedSlot.TargetBone}";
_mappingSelectionSummaryLabel.Text =
$"Current source: {currentSource}{Environment.NewLine}" +
$"State: {sourceState} | {importance}{Environment.NewLine}" +
$"Auto suggestion: {autoSource}{Environment.NewLine}" +
$"{candidateText}";
_mappingSelectionHintLabel.Text = string.IsNullOrWhiteSpace( _selectedSourceBone?.Name )
? "Select a source bone on the left, then press Assign Selected Bone. Use Clear to leave the slot unmapped or Reset to Auto to restore the auto suggestion."
: $"Selected source bone: {selectedBoneText}. Press Assign Selected Bone to apply it, then Save Mapping Profile when you are happy with the overrides.";
}
private void AddSelectedClipsToQueue()
{
var selectedClips = _clipList.SelectedItems.OfType<RetargetClipDescriptor>().ToList();
if ( selectedClips.Count == 0 )
{
SetStatus( "Select one or more clips before adding them to the queue." );
return;
}
var addedCount = 0;
var refreshedCount = 0;
foreach ( var clip in selectedClips )
{
var existing = _queueItems.FirstOrDefault( item => item.Clip.DisplayName.Equals( clip.DisplayName, StringComparison.OrdinalIgnoreCase ) );
if ( existing is not null )
{
existing.Status = "pending";
existing.Message = "Queued";
existing.Result = null;
refreshedCount++;
continue;
}
_queueItems.Add( new RetargetQueueItem
{
Clip = clip,
Status = "pending",
Message = "Queued"
} );
addedCount++;
}
RefreshQueueList();
RefreshClipSummary();
UpdateButtons();
SetStatus( $"Queue updated. Added {addedCount} clip(s), refreshed {refreshedCount} existing clip(s)." );
}
private void RunQueue()
{
if ( _queueRunning || _jobInFlight )
return;
var pendingCount = _queueItems.Count( item => item.Status == "pending" );
if ( pendingCount == 0 )
{
SetStatus( _queueItems.Count == 0
? "Queue is empty. Add clips from Home before starting a run."
: "Queue has no pending clips. Requeue failed clips or clear finished items first." );
return;
}
if ( _environmentReport is null || !_environmentReport.CanRunRetarget )
{
_latestSetupFailureMessage = "Checking retarget setup before starting the queue...";
MarkPendingQueueItemsWithSetupFailure( _latestSetupFailureMessage );
RefreshQueueList();
SetStatus( _latestSetupFailureMessage );
BeginEnvironmentCheck( startQueueWhenReady: true );
return;
}
StartQueueCore();
}
private void StartQueueCore()
{
if ( _queueRunning || _jobInFlight )
return;
var pendingCount = _queueItems.Count( item => item.Status == "pending" );
if ( pendingCount == 0 )
return;
_latestQueueFailureMessage = string.Empty;
_latestSetupFailureMessage = string.Empty;
ClearPendingSetupMessages();
_cancelQueueRequested = false;
_queueRunning = true;
RefreshQueueList();
UpdateButtons();
SetStatus( $"Starting queue with {pendingCount} pending clip(s)." );
}
private void RetryFailedQueueItems()
{
var failedItems = _queueItems.Where( item => item.Status == "failed" ).ToList();
if ( failedItems.Count == 0 )
{
SetStatus( "Queue has no failed clips to retry." );
return;
}
foreach ( var item in failedItems )
{
item.Status = "pending";
item.Message = "Retry queued";
item.Result = null;
}
RefreshQueueList();
UpdateButtons();
SetStatus( $"Re-queued {failedItems.Count} failed clip(s)." );
}
private void ClearFinishedQueueItems()
{
if ( _queueRunning || _jobInFlight )
{
SetStatus( "Wait for the current queue run to finish before clearing finished items." );
return;
}
var removedCount = _queueItems.RemoveAll( item => item.Status == "completed" || item.Status == "failed" );
RefreshQueueList();
UpdateButtons();
SetStatus( removedCount > 0 ? $"Cleared {removedCount} finished queue item(s)." : "Queue has no finished items to clear." );
}
private void ClearQueue()
{
if ( _jobInFlight || _queueItems.Any( item => item.Status == "running" ) )
{
_cancelQueueRequested = true;
var removedWaitingCount = _queueItems.RemoveAll( item => item.Status != "running" );
RefreshQueueList();
UpdateButtons();
SetStatus(
removedWaitingCount > 0
? $"Queue clear requested. Removed {removedWaitingCount} waiting item(s); the current clip will finish before the queue is fully cleared."
: "Queue clear requested. The current clip will finish before the queue is fully cleared." );
return;
}
_queueRunning = false;
_cancelQueueRequested = false;
var removedCount = _queueItems.Count;
_queueItems.Clear();
RefreshQueueList();
UpdateButtons();
SetStatus( removedCount > 0 ? $"Cleared {removedCount} queued clip(s)." : "Queue is already empty." );
}
private void RemoveSelectedHomeQueueItems()
{
if ( _queueRunning || _jobInFlight )
{
SetStatus( "Wait for the current queue run to finish before editing the queue." );
return;
}
var selectedItems = _homeQueueList?.SelectedItems.OfType<RetargetQueueItem>().ToList() ?? new List<RetargetQueueItem>();
if ( selectedItems.Count == 0 )
{
SetStatus( "Select one or more queue items on Home before removing them." );
return;
}
var removedCount = 0;
foreach ( var item in selectedItems )
{
if ( _queueItems.Remove( item ) )
removedCount++;
}
RefreshQueueList();
UpdateButtons();
SetStatus( removedCount > 0 ? $"Removed {removedCount} item(s) from the queue." : "No queue items were removed." );
}
private void RunQueuedItem( RetargetQueueItem item )
{
_jobInFlight = true;
item.Status = "running";
item.Message = "Retargeting in Blender";
RefreshQueueList();
UpdateButtons();
SyncJobFromControls();
LoadProfilesFromJob();
SetStatus( $"Retargeting '{item.Clip.DisplayName}' through the production path (template FBX -> generated Citizen VMDL)." );
var jobSnapshot = _pipeline.CreateRuntimeJobSnapshot( _job );
var sourceProfileSnapshot = _sourceProfile;
var mappingProfileSnapshot = _mappingProfile;
var mappingStateSnapshot = _mappingState.ToList();
var sourceFacingEulerDegrees = GetAppliedSourceFacingEulerDegrees();
RetargetDiagnosticSummary diagnostics;
try
{
diagnostics = _pipeline.ValidateImportDiagnostics( mappingStateSnapshot );
}
catch ( Exception exception )
{
ApplyFailedQueueItem( item, exception );
return;
}
StartBackgroundJob(
title: $"Retargeting {item.Clip.DisplayName}",
scope: "queue",
blocksInteraction: true,
showInJobsStrip: true,
work: reporter =>
{
reporter.ReportProgress( 0.12f, "Running Blender retarget backend..." );
var result = _pipeline.RunBackendRetargetOnly( jobSnapshot, item.Clip, sourceProfileSnapshot, mappingProfileSnapshot, mappingStateSnapshot, sourceFacingEulerDegrees );
reporter.ReportProgress( 0.78f, "Importing animation and updating the target..." );
reporter.ReportProgress( 1f, "Finalizing imported assets..." );
return () =>
{
try
{
var finalizedResult = _pipeline.FinalizeImportedResult( _job, item.Clip, result, diagnostics );
ApplyCompletedQueueItem( item, finalizedResult );
}
catch ( Exception exception )
{
ApplyFailedQueueItem( item, exception );
}
};
},
onError: exception => ApplyFailedQueueItem( item, exception ) );
}
private void ApplyCompletedQueueItem( RetargetQueueItem item, RetargetImportResult result )
{
item.Result = result;
var completedWithoutImportError =
result.Manifest.Status.Equals( "completed", StringComparison.OrdinalIgnoreCase )
&& string.IsNullOrWhiteSpace( result.PostImportError );
item.Status = completedWithoutImportError ? "completed" : "failed";
item.Message = BuildQueueResultMessage( result, completedWithoutImportError );
_latestResult = result;
if ( completedWithoutImportError && IsSingleSelectedClip( item.Clip ) )
{
_session.CompareSelection.SelectedSourceLibraryId = _activeSourceLibrary.LibraryId;
_session.CompareSelection.SelectedSourceClipName = item.Clip.DisplayName;
_session.CompareSelection.SelectedResultRunId = result.Manifest.RunId;
_selectedCompareResult = result;
}
else if ( _inspectedRunResult is null )
{
_inspectedRunResult = result;
}
if ( string.IsNullOrWhiteSpace( result.PostImportError ) )
{
SetStatus( $"Completed '{item.Clip.DisplayName}'. Imported FBX and generated Citizen model are ready." );
}
else
{
_latestQueueFailureMessage = result.PostImportError;
SetStatus( $"Retarget completed in Blender for '{item.Clip.DisplayName}', but import into the target project failed: {result.PostImportError}" );
}
_jobInFlight = false;
RefreshResultSurfaces();
RefreshRunsInspectionState();
RefreshQueueList();
RefreshHistoryList();
RefreshResultList();
UpdateDiagnostics();
FinalizeQueueIfIdle();
UpdateButtons();
if ( !_queueItems.Any( queueItem => queueItem.Status == "pending" || queueItem.Status == "running" ) )
BeginRefreshTargetAnimationListAsync( $"Loaded target animations after '{item.Clip.DisplayName}'." );
}
private void ApplyFailedQueueItem( RetargetQueueItem item, Exception exception )
{
item.Status = "failed";
item.Message = BuildQueueExceptionMessage( exception );
_latestResult = null;
_latestQueueFailureMessage = item.Message;
_jobInFlight = false;
RefreshResultSurfaces();
RefreshRunsInspectionState();
RefreshQueueList();
RefreshHistoryList();
RefreshResultList();
UpdateDiagnostics();
FinalizeQueueIfIdle();
UpdateButtons();
SetStatus( $"Retarget failed for '{item.Clip.DisplayName}': {item.Message}" );
}
private static string BuildQueueResultMessage( RetargetImportResult result, bool completedWithoutImportError )
{
if ( completedWithoutImportError )
return "Imported FBX + Citizen model ready";
if ( !string.IsNullOrWhiteSpace( result.PostImportError ) )
return $"target import failed: {TruncateDiagnosticText( result.PostImportError, 120 )}";
var failedStage = FindFirstFailedStage( result.Manifest );
if ( failedStage is not null )
{
var stageName = FormatStageName( failedStage.StageId );
var stageError = TruncateDiagnosticText( failedStage.Error, 120 );
return string.IsNullOrWhiteSpace( stageError )
? $"failed during {stageName}"
: $"failed during {stageName}: {stageError}";
}
if ( result.Manifest.MotionTrajectoryAnalysis.Failures.Count > 0 )
return $"motion validation failed: {TruncateDiagnosticText( result.Manifest.MotionTrajectoryAnalysis.Failures[0], 120 )}";
if ( result.Manifest.UnmappedRequiredSlots.Count > 0 )
return $"missing required slots: {BuildIssueListPreview( result.Manifest.UnmappedRequiredSlots, 4 )}";
return string.IsNullOrWhiteSpace( result.Manifest.Status )
? "backend finished with unknown status"
: $"backend status: {result.Manifest.Status.Replace( '_', ' ' )}";
}
private static string BuildQueueExceptionMessage( Exception exception )
{
var message = (exception.Message ?? string.Empty)
.Split( new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries )
.FirstOrDefault()
?.Trim() ?? string.Empty;
if ( string.IsNullOrWhiteSpace( message )
|| message.Equals( "failed", StringComparison.OrdinalIgnoreCase )
|| message.Equals( "error", StringComparison.OrdinalIgnoreCase ) )
{
return "retarget failed before a detailed manifest was written";
}
return TruncateDiagnosticText( message, 140 );
}
private bool CanDeleteImportedTargetAnimation( RetargetTargetAnimationEntry target )
{
if ( target is null || !target.IsImported || target.IsReadOnly )
return false;
var sourceBusy = HasActiveBackgroundJob( "source" );
var targetBusy = HasActiveBackgroundJob( "target" );
var queueBusy = HasActiveBackgroundJob( "queue" );
return !_queueRunning && !sourceBusy && !targetBusy && !queueBusy && !_jobInFlight;
}
private void DeleteImportedTargetAnimation( RetargetTargetAnimationEntry target )
{
if ( target is null || !target.IsImported || target.IsReadOnly )
{
SetStatus( "Only imported target animations can be deleted." );
return;
}
if ( !CanDeleteImportedTargetAnimation( target ) )
{
SetStatus( "Wait for the current background work to finish before deleting target animations." );
return;
}
if ( string.IsNullOrWhiteSpace( target.SourceResourcePath ) )
{
SetStatus( $"Target animation '{target.SequenceName}' does not expose a deletable source asset path." );
return;
}
var targetSnapshot = new RetargetTargetAnimationEntry
{
SequenceName = target.SequenceName,
IsImported = target.IsImported,
IsReadOnly = target.IsReadOnly,
Looping = target.Looping,
SourceResourcePath = target.SourceResourcePath,
VmdlResourcePath = target.VmdlResourcePath
};
SetStatus( $"Removing imported target animation '{target.SequenceName}'..." );
StartBackgroundJob(
title: $"Removing {target.SequenceName}",
scope: "target",
blocksInteraction: false,
showInJobsStrip: true,
work: reporter =>
{
reporter.ReportDetail( "Deleting imported animation assets..." );
var absoluteSourcePath = CitizenRetargetPaths.GetAssetAbsolutePath( targetSnapshot.SourceResourcePath );
var absoluteFolder = Path.GetDirectoryName( absoluteSourcePath ) ?? string.Empty;
var fileStem = Path.GetFileNameWithoutExtension( absoluteSourcePath );
var deletedCount = 0;
foreach ( var extension in new[] { ".fbx", ".smd", ".dmx" } )
{
var candidate = Path.Combine( absoluteFolder, $"{fileStem}{extension}" );
if ( !File.Exists( candidate ) )
continue;
File.Delete( candidate );
deletedCount++;
}
reporter.ReportProgress( 0.7f, "Rebuilding target asset..." );
if ( deletedCount > 0 )
_pipeline.RefreshTargetAssetDefinition( _job );
reporter.ReportProgress( 0.88f, "Refreshing target animations..." );
var importedTargets = BuildImportedTargetAnimationEntries();
var builtInTargets = (_showBuiltInTargetAnimations || _builtInTargetAnimationsLoaded || _builtInTargetAnimationsLoadRequested)
? BuildBuiltInTargetAnimationEntries( importedTargets )
: new List<RetargetTargetAnimationEntry>();
reporter.ReportProgress( 1f, "Applying UI updates..." );
var loadResult = new TargetAnimationLoadResult
{
DeletedCount = deletedCount,
ImportedTargets = importedTargets,
BuiltInTargets = builtInTargets
};
return () =>
{
if ( loadResult.DeletedCount == 0 )
{
SetStatus( $"Imported target animation '{targetSnapshot.SequenceName}' is already missing on disk." );
RefreshResultList();
RefreshResultSurfaces();
UpdateButtons();
return;
}
if ( _selectedTargetAnimation is not null
&& _selectedTargetAnimation.IsImported
&& _selectedTargetAnimation.SequenceName.Equals( targetSnapshot.SequenceName, StringComparison.OrdinalIgnoreCase ) )
{
_selectedTargetAnimation = null;
}
var selectedResultEntry = FindHistoryEntryByRunId( _session.CompareSelection.SelectedResultRunId );
if ( selectedResultEntry is not null
&& selectedResultEntry.SequenceName.Equals( targetSnapshot.SequenceName, StringComparison.OrdinalIgnoreCase ) )
{
_session.CompareSelection.SelectedResultRunId = string.Empty;
_selectedCompareResult = null;
}
_pipeline.SaveJob( _job );
ApplyTargetAnimationEntries(
loadResult.ImportedTargets,
loadResult.BuiltInTargets,
$"Removed imported target animation '{targetSnapshot.SequenceName}'.",
_showBuiltInTargetAnimations || _builtInTargetAnimationsLoaded || _builtInTargetAnimationsLoadRequested );
RefreshResultList();
RefreshResultSurfaces();
UpdateButtons();
};
},
onError: exception =>
{
SetStatus( $"Failed to delete imported target animation '{targetSnapshot.SequenceName}': {exception.Message}" );
UpdateButtons();
} );
}
private void AssignSelectedBone()
{
if ( _selectedSlot is null || _selectedSourceBone is null )
return;
_selectedSlot.AssignedSourceBone = _selectedSourceBone.Name;
_selectedSlot.IsManualOverride = true;
RefreshMappingList();
RefreshMappingEditorState();
UpdateDiagnostics();
UpdateButtons();
SetStatus( $"Assigned '{_selectedSourceBone.Name}' to '{_selectedSlot.SlotId}'." );
}
private void ClearSelectedSlot()
{
if ( _selectedSlot is null )
return;
_selectedSlot.AssignedSourceBone = string.Empty;
_selectedSlot.IsManualOverride = true;
RefreshMappingList();
RefreshMappingEditorState();
UpdateDiagnostics();
UpdateButtons();
SetStatus( $"Cleared manual source assignment for '{_selectedSlot.SlotId}'." );
}
private void ResetSelectedSlot()
{
if ( _selectedSlot is null )
return;
_selectedSlot.AssignedSourceBone = string.Empty;
_selectedSlot.IsManualOverride = false;
RefreshMappingList();
RefreshMappingEditorState();
UpdateDiagnostics();
UpdateButtons();
SetStatus( $"Reset '{_selectedSlot.SlotId}' back to the auto mapping suggestion." );
}
private void OpenResultInModelDoc()
{
try
{
SyncJobFromControls();
_pipeline.OpenResultInModelDoc( _job );
SetStatus( $"Opened '{_job.TargetVmdlPath}' in the model viewer." );
}
catch ( Exception exception )
{
SetStatus( $"Open model failed: {exception.Message}" );
}
}
private void OpenModelAssetInViewer( string? assetPath )
{
if ( string.IsNullOrWhiteSpace( assetPath ) )
return;
_pipeline.OpenModelAsset( assetPath );
}
private void OpenArtifact( string? absolutePath )
{
if ( !string.IsNullOrWhiteSpace( absolutePath ) )
_pipeline.OpenPath( absolutePath );
}
private RetargetImportResult? GetCurrentArtifactResult()
=> GetRunsInspectionResult();
private void OpenCurrentRunDirectory() => OpenArtifact( GetCurrentArtifactResult()?.ArtifactLinks.RunDirectoryPath );
private void OpenCurrentManifest() => OpenArtifact( GetCurrentArtifactResult()?.ArtifactLinks.ManifestPath );
private void OpenCurrentRawExport() => OpenArtifact( GetCurrentArtifactResult()?.ArtifactLinks.ExportPath );
private void OpenCurrentImportedAnimation() => OpenArtifact( GetCurrentArtifactResult()?.ArtifactLinks.ImportedAnimationPath );
private void OpenCurrentGeneratedModel() => OpenModelAssetInViewer( GetCurrentArtifactResult()?.ArtifactLinks.VmdlPath );
private void OpenCurrentRunLog() => OpenArtifact( GetCurrentArtifactResult()?.ArtifactLinks.RunLogPath );
private void OpenHomeGeneratedModel()
{
var activePreviewModelPath = GetActivePreviewResult()?.ArtifactLinks.VmdlPath;
if ( !string.IsNullOrWhiteSpace( activePreviewModelPath ) )
{
OpenModelAssetInViewer( activePreviewModelPath );
return;
}
OpenModelAssetInViewer( _job.TargetVmdlPath );
}
private void OpenPoseCompensationData()
{
var compensationPath = Path.Combine( CitizenRetargetPaths.DataRoot, "citizen_arm_rest_compensation.json" );
OpenArtifact( compensationPath );
}
private void OpenHistoryEntry( RetargetRunHistoryEntry? entry )
{
if ( entry is null )
return;
var hydrated = BuildImportResultFromHistoryEntry( entry );
if ( hydrated is null )
return;
_session.SelectedHistoryRunId = entry.RunId;
_inspectedRunResult = hydrated;
RefreshHistoryList();
RefreshRunsInspectionState();
UpdateDiagnostics();
RefreshClipSummary();
SetStatus( $"Loaded run '{entry.RunId}' from history." );
}
private void UseSelectedHistoryRunAsCompareResult()
{
var entry = FindHistoryEntryByRunId( _session.SelectedHistoryRunId );
if ( entry is null )
{
SetStatus( "Select a run in Diagnostics before setting it as the active result." );
return;
}
if ( !MatchesActiveSourceLibrary( entry ) )
{
SetStatus( $"Run '{entry.RunId}' belongs to a different source library. Switch the source FBX first to compare it on Home." );
return;
}
var clip = FindClipForHistoryEntry( entry );
if ( clip is not null )
{
_job.SelectedClipNames = new List<string> { clip.DisplayName };
_pipeline.SaveJob( _job );
RefreshClipList();
}
_selectedTargetAnimation = null;
_session.CompareSelection.SelectedSourceLibraryId = _activeSourceLibrary.LibraryId;
_session.CompareSelection.SelectedSourceClipName = entry.ClipName ?? string.Empty;
_session.CompareSelection.SelectedResultRunId = entry.RunId ?? string.Empty;
ResolveCompareResultSelection();
RefreshResultList();
RefreshResultSurfaces();
UpdateButtons();
SetStatus( $"Run '{entry.RunId}' is now the active result on Home." );
}
private void ClearHomeResult()
{
var selectedClip = GetSingleSelectedClip();
_session.CompareSelection.SelectedSourceLibraryId = selectedClip is null ? string.Empty : _activeSourceLibrary.LibraryId;
_session.CompareSelection.SelectedSourceClipName = selectedClip?.DisplayName ?? string.Empty;
_session.CompareSelection.SelectedResultRunId = string.Empty;
_selectedCompareResult = null;
_selectedTargetAnimation = null;
RefreshResultList();
RefreshResultSurfaces();
UpdateButtons();
SetStatus( selectedClip is null
? "Cleared the active result."
: $"Cleared the active result for '{selectedClip.DisplayName}'." );
}
private void InspectHomeResultInRuns()
{
var entry = FindHistoryEntryByRunId( _session.CompareSelection.SelectedResultRunId );
if ( entry is null )
{
SetStatus( "Select a result on Home before inspecting it in Runs." );
return;
}
var hydrated = BuildImportResultFromHistoryEntry( entry );
if ( hydrated is null )
{
SetStatus( $"Run '{entry.RunId}' could not be hydrated for inspection." );
return;
}
_session.SelectedHistoryRunId = entry.RunId;
_inspectedRunResult = hydrated;
RefreshHistoryList();
RefreshRunsInspectionState();
UpdateDiagnostics();
UpdateButtons();
SetStatus( $"Run '{entry.RunId}' is ready to inspect in Runs." );
}
private RetargetImportResult? GetActivePreviewResult()
{
return _selectedCompareResult;
}
private RetargetTargetAnimationEntry? GetActiveTargetPreviewAnimation()
{
return _selectedTargetAnimation;
}
private static bool HasMaterializedImportedResult( RetargetImportResult? result )
{
if ( result is null )
return false;
var importedAnimationPath = result.ArtifactLinks.ImportedAnimationPath;
var generatedModelPath = result.ArtifactLinks.VmdlPath;
return !string.IsNullOrWhiteSpace( importedAnimationPath )
&& File.Exists( importedAnimationPath )
&& !string.IsNullOrWhiteSpace( generatedModelPath )
&& File.Exists( generatedModelPath );
}
private RetargetClipDescriptor? GetSingleSelectedClip()
{
var selectedClips = _clipList?.SelectedItems.OfType<RetargetClipDescriptor>().ToList() ?? new List<RetargetClipDescriptor>();
return selectedClips.Count == 1 ? selectedClips[0] : null;
}
private RetargetImportResult? GetRunsInspectionResult()
=> _inspectedRunResult ?? _latestResult;
private RetargetRunHistoryEntry? GetSelectedHistoryEntry()
=> FindHistoryEntryByRunId( _session.SelectedHistoryRunId );
private void SyncCompareSelectionFromCurrentClip()
{
var selectedClip = GetSingleSelectedClip();
if ( selectedClip is null )
{
_session.CompareSelection.SelectedSourceLibraryId = string.Empty;
_session.CompareSelection.SelectedSourceClipName = string.Empty;
_session.CompareSelection.SelectedResultRunId = string.Empty;
_selectedCompareResult = null;
return;
}
_session.CompareSelection.SelectedSourceLibraryId = _activeSourceLibrary.LibraryId;
_session.CompareSelection.SelectedSourceClipName = selectedClip.DisplayName;
if ( string.IsNullOrWhiteSpace( _session.CompareSelection.SelectedResultRunId ) )
return;
var currentResultEntry = FindHistoryEntryByRunId( _session.CompareSelection.SelectedResultRunId );
if ( currentResultEntry is null || !MatchesClip( currentResultEntry, selectedClip ) || !MatchesActiveSourceLibrary( currentResultEntry ) || !MatchesActiveTargetWorkspace( currentResultEntry ) )
_session.CompareSelection.SelectedResultRunId = string.Empty;
}
private void ResolveCompareResultSelection()
{
var selectedClip = GetSingleSelectedClip();
if ( selectedClip is null )
{
_selectedCompareResult = null;
return;
}
if ( !string.Equals( _session.CompareSelection.SelectedSourceLibraryId, _activeSourceLibrary.LibraryId, StringComparison.OrdinalIgnoreCase ) )
{
_selectedCompareResult = null;
_session.CompareSelection.SelectedResultRunId = string.Empty;
return;
}
var selectedResultEntry = FindHistoryEntryByRunId( _session.CompareSelection.SelectedResultRunId );
if ( selectedResultEntry is null || !MatchesClip( selectedResultEntry, selectedClip ) || !MatchesActiveSourceLibrary( selectedResultEntry ) || !MatchesActiveTargetWorkspace( selectedResultEntry ) )
{
selectedResultEntry = FindPreferredHistoryEntryForClip( selectedClip );
_session.CompareSelection.SelectedResultRunId = selectedResultEntry?.RunId ?? string.Empty;
}
_selectedCompareResult = selectedResultEntry is null
? null
: BuildImportResultFromHistoryEntry( selectedResultEntry );
}
private RetargetImportResult? BuildImportResultFromHistoryEntry( RetargetRunHistoryEntry entry )
{
var manifest = LoadManifestFromHistoryEntry( entry ) ?? BuildFallbackManifestFromHistoryEntry( entry );
var manifestAbsolutePath = !string.IsNullOrWhiteSpace( entry.ManifestPath ) && File.Exists( entry.ManifestPath )
? entry.ManifestPath
: string.Empty;
var runDirectoryPath = !string.IsNullOrWhiteSpace( manifestAbsolutePath )
? Path.GetDirectoryName( manifestAbsolutePath ) ?? string.Empty
: string.Empty;
var importedAnimationPath = CitizenRetargetPaths.DecodeExternalPath( entry.ImportedAssetPath );
var exportPath = CitizenRetargetPaths.DecodeExternalPath( entry.ExportPath );
var previewVideoPath = CitizenRetargetPaths.DecodeExternalPath( entry.PreviewVideoPath );
var comparisonVideoPath = CitizenRetargetPaths.DecodeExternalPath( entry.ComparisonVideoPath );
var targetVmdlPath = string.IsNullOrWhiteSpace( entry.TargetVmdlPath ) ? _job.TargetVmdlPath : entry.TargetVmdlPath;
var runLogPath = string.IsNullOrWhiteSpace( runDirectoryPath )
? string.Empty
: Path.Combine( runDirectoryPath, CitizenRetargetPaths.EditorRunLogFileName );
return new RetargetImportResult
{
ManifestAbsolutePath = manifestAbsolutePath,
Manifest = manifest,
PreviewArtifacts = new RetargetPreviewArtifacts
{
PreviewVideoPath = previewVideoPath,
ComparisonVideoPath = comparisonVideoPath
},
ArtifactLinks = new RetargetArtifactLinks
{
RunDirectoryPath = runDirectoryPath,
ManifestPath = manifestAbsolutePath,
ImportedAnimationPath = importedAnimationPath,
ExportPath = exportPath,
VmdlPath = CitizenRetargetPaths.GetAssetAbsolutePath( targetVmdlPath ),
RunLogPath = File.Exists( runLogPath ) ? runLogPath : string.Empty
},
SequenceName = entry.SequenceName,
VmdlResourcePath = targetVmdlPath
};
}
private static RetargetRunManifest? LoadManifestFromHistoryEntry( RetargetRunHistoryEntry entry )
{
if ( string.IsNullOrWhiteSpace( entry.ManifestPath ) || !File.Exists( entry.ManifestPath ) )
return null;
try
{
return RetargetManifestJson.Deserialize<RetargetRunManifest>( File.ReadAllText( entry.ManifestPath ) );
}
catch
{
return null;
}
}
private RetargetRunManifest BuildFallbackManifestFromHistoryEntry( RetargetRunHistoryEntry entry )
{
return new RetargetRunManifest
{
RunId = entry.RunId ?? string.Empty,
Status = entry.Status ?? string.Empty,
SourceFile = _job.SourceFbxPath ?? string.Empty,
RetargetedActionName = entry.SequenceName ?? string.Empty,
Warnings = new List<string>
{
"Run manifest is unavailable. Showing preview from persisted history entry."
},
BackendWarnings = new List<string>(),
UnmappedRequiredSlots = new List<string>(),
Stages = new List<RetargetStageManifestEntry>(),
MotionTrajectoryAnalysis = new RetargetMotionTrajectoryAnalysis(),
PoseNormalization = new RetargetPoseNormalizationSummary(),
Outputs = new RetargetOutputsSummary
{
RunDir = string.Empty,
ExportPath = CitizenRetargetPaths.DecodeExternalPath( entry.ExportPath ),
ManifestPath = string.Empty,
BoneMapOverridesPath = string.Empty
}
};
}
private RetargetRunHistoryEntry? FindPreferredHistoryEntryForClip( RetargetClipDescriptor clip )
{
var matchingEntries = (_job.RecentRuns ?? new List<RetargetRunHistoryEntry>())
.Where( entry => MatchesClip( entry, clip ) && MatchesActiveSourceLibrary( entry ) && MatchesActiveTargetWorkspace( entry ) )
.ToList();
return matchingEntries.FirstOrDefault( IsCompletedRun )
?? matchingEntries.FirstOrDefault();
}
private RetargetRunHistoryEntry? FindHistoryEntryByRunId( string? runId )
{
if ( string.IsNullOrWhiteSpace( runId ) )
return null;
return (_job.RecentRuns ?? new List<RetargetRunHistoryEntry>())
.FirstOrDefault( entry => entry.RunId.Equals( runId, StringComparison.OrdinalIgnoreCase ) );
}
private RetargetClipDescriptor? FindClipForHistoryEntry( RetargetRunHistoryEntry entry )
{
return _allClips.FirstOrDefault( clip =>
clip.DisplayName.Equals( entry.ClipName, StringComparison.OrdinalIgnoreCase )
|| BuildSequenceNameForClip( clip ).Equals( entry.SequenceName, StringComparison.OrdinalIgnoreCase ) );
}
private bool MatchesCurrentSelectedClip( RetargetImportResult result )
{
var selectedClip = GetSingleSelectedClip();
return selectedClip is not null && MatchesClip( result, selectedClip );
}
private bool IsSingleSelectedClip( RetargetClipDescriptor clip )
{
var selectedClip = GetSingleSelectedClip();
return selectedClip is not null && selectedClip.DisplayName.Equals( clip.DisplayName, StringComparison.OrdinalIgnoreCase );
}
private bool MatchesClip( RetargetImportResult result, RetargetClipDescriptor clip )
{
var sequenceName = BuildSequenceNameForClip( clip );
return !string.IsNullOrWhiteSpace( result.SequenceName )
&& result.SequenceName.Equals( sequenceName, StringComparison.OrdinalIgnoreCase );
}
private bool MatchesClip( RetargetRunHistoryEntry entry, RetargetClipDescriptor clip )
{
var sequenceName = BuildSequenceNameForClip( clip );
return (!string.IsNullOrWhiteSpace( entry.ClipName )
&& entry.ClipName.Equals( clip.DisplayName, StringComparison.OrdinalIgnoreCase ))
|| (!string.IsNullOrWhiteSpace( sequenceName )
&& entry.SequenceName.Equals( sequenceName, StringComparison.OrdinalIgnoreCase ));
}
private bool MatchesActiveSourceLibrary( RetargetRunHistoryEntry entry )
{
if ( string.IsNullOrWhiteSpace( _activeSourceLibrary.LibraryId ) )
return true;
var historySourcePath = TryGetHistoryEntrySourceFile( entry );
if ( string.IsNullOrWhiteSpace( historySourcePath ) )
return true;
return string.Equals(
NormalizeSourcePathForComparison( historySourcePath ),
NormalizeSourcePathForComparison( _activeSourceLibrary.SourceFbxPath ),
StringComparison.OrdinalIgnoreCase );
}
private bool MatchesActiveTargetWorkspace( RetargetRunHistoryEntry entry )
{
var currentTargetPath = NormalizeAssetPathForComparison( _job.TargetVmdlPath );
if ( string.IsNullOrWhiteSpace( currentTargetPath ) )
return true;
var historyTargetPath = NormalizeAssetPathForComparison( entry.TargetVmdlPath );
if ( string.IsNullOrWhiteSpace( historyTargetPath ) )
return false;
return string.Equals( historyTargetPath, currentTargetPath, StringComparison.OrdinalIgnoreCase );
}
private static string TryGetHistoryEntrySourceFile( RetargetRunHistoryEntry entry )
{
if ( string.IsNullOrWhiteSpace( entry.ManifestPath ) || !File.Exists( entry.ManifestPath ) )
return string.Empty;
try
{
var manifest = RetargetManifestJson.Deserialize<RetargetRunManifest>( File.ReadAllText( entry.ManifestPath ) );
return CitizenRetargetPaths.DecodeExternalPath( manifest.SourceFile ).Trim().Trim( '"' );
}
catch
{
return string.Empty;
}
}
private static string NormalizeSourcePathForComparison( string path )
{
var decoded = CitizenRetargetPaths.DecodeExternalPath( path ).Trim().Trim( '"' );
if ( string.IsNullOrWhiteSpace( decoded ) )
return string.Empty;
try
{
return Path.GetFullPath( decoded );
}
catch
{
return decoded;
}
}
private static string NormalizeAssetPathForComparison( string? path )
{
var decoded = CitizenRetargetPaths.DecodeExternalPath( path ?? string.Empty )
.Replace( '\\', '/' )
.Trim();
if ( string.IsNullOrWhiteSpace( decoded ) )
return string.Empty;
if ( decoded.StartsWith( "Assets/", StringComparison.OrdinalIgnoreCase ) )
decoded = decoded["Assets/".Length..];
return decoded.TrimStart( '/' );
}
private static bool IsCompletedRun( RetargetRunHistoryEntry entry )
=> entry.Status.Equals( "completed", StringComparison.OrdinalIgnoreCase );
private string BuildSequenceNameForClip( RetargetClipDescriptor clip )
=> RetargetSequenceNames.Build( _job.SequencePrefix, clip.DisplayName );
private void RefreshClipList()
{
var filter = _clipFilter?.Text?.Trim() ?? string.Empty;
var visible = string.IsNullOrWhiteSpace( filter )
? _allClips
: _allClips.Where( clip => ClipMatchesFilter( clip, filter ) ).ToList();
_clipList.SetItems( visible );
if ( _job.SelectedClipNames is null || _job.SelectedClipNames.Count == 0 )
return;
foreach ( var clip in visible.Where( clip => _job.SelectedClipNames.Contains( clip.DisplayName, StringComparer.OrdinalIgnoreCase ) ) )
{
_clipList.SelectItem( clip, true, true );
}
RefreshClipSummary();
}
private int CountVisibleClips( string? filter )
{
var normalizedFilter = filter?.Trim() ?? string.Empty;
if ( string.IsNullOrWhiteSpace( normalizedFilter ) )
return _allClips.Count;
return _allClips.Count( clip => ClipMatchesFilter( clip, normalizedFilter ) );
}
private static bool ClipMatchesFilter( RetargetClipDescriptor clip, string filter )
{
return clip.DisplayName.Contains( filter, StringComparison.OrdinalIgnoreCase )
|| clip.SourceName.Contains( filter, StringComparison.OrdinalIgnoreCase );
}
private void RefreshSourceBoneList()
{
var bones = _inspection?.Audit.Bones ?? new List<NativeAuditBoneInfo>();
var filter = _boneFilter?.Text?.Trim() ?? string.Empty;
var visible = string.IsNullOrWhiteSpace( filter )
? bones
: bones.Where( bone => bone.Name.Contains( filter, StringComparison.OrdinalIgnoreCase ) || bone.ParentName.Contains( filter, StringComparison.OrdinalIgnoreCase ) ).ToList();
_sourceBoneList.SetItems( visible.OrderBy( bone => bone.Name, StringComparer.OrdinalIgnoreCase ).ToList() );
}
private void RefreshMappingList()
{
var currentFilter = _mappingFilter?.CurrentText ?? "All";
var search = _mappingSearch?.Text?.Trim() ?? string.Empty;
var visible = _mappingState.Where( slot => currentFilter switch
{
"Needs Attention" => slot.Enabled && string.IsNullOrWhiteSpace( slot.EffectiveSourceBone ),
"Manual Overrides" => slot.IsManualOverride,
"Auto Mapped" => slot.Enabled && !slot.IsManualOverride && !string.IsNullOrWhiteSpace( slot.EffectiveSourceBone ),
"Required Only" => slot.Required,
_ => true
} ).Where( slot =>
string.IsNullOrWhiteSpace( search )
|| slot.SlotId.Contains( search, StringComparison.OrdinalIgnoreCase )
|| slot.TargetBone.Contains( search, StringComparison.OrdinalIgnoreCase )
|| slot.EffectiveSourceBone.Contains( search, StringComparison.OrdinalIgnoreCase )
|| slot.AutoSourceBone.Contains( search, StringComparison.OrdinalIgnoreCase ) ).ToList();
_mappingList.SetItems( visible );
if ( _selectedSlot is not null )
{
var matchingSlot = visible.FirstOrDefault( slot => slot.SlotId.Equals( _selectedSlot.SlotId, StringComparison.OrdinalIgnoreCase ) );
if ( matchingSlot is not null )
{
_selectedSlot = matchingSlot;
_mappingList.SelectItem( matchingSlot, true, true );
}
else
{
_selectedSlot = null;
}
}
RefreshMappingEditorState();
}
private void RefreshQueueList()
{
_queueList.SetItems( _queueItems.ToList() );
if ( _homeQueueList is not null )
_homeQueueList.SetItems( _queueItems.ToList() );
if ( _queueSummaryLabel is not null )
_queueSummaryLabel.Text = BuildQueueSummary();
if ( _homeQueueSummaryLabel is not null )
_homeQueueSummaryLabel.Text = BuildQueueSummary();
RefreshHomeStatusStrip();
}
private void LoadTargetPresetInventory()
{
if ( _targetPresetList is null )
return;
_targetPresets.Clear();
_targetPresets.AddRange( DiscoverTargetPresets() );
_targetPresetList.SetItems( _targetPresets.ToList() );
var currentPreset = _targetPresets.FirstOrDefault( preset =>
preset.TargetVmdlPath.Equals( _job.TargetVmdlPath, StringComparison.OrdinalIgnoreCase ) );
if ( currentPreset is not null )
_targetPresetList.SelectItem( currentPreset, true, true );
}
private void ClearTargetPresetInventory()
{
_targetPresets.Clear();
if ( _targetPresetList is not null )
_targetPresetList.SetItems( new List<RetargetTargetPresetRef>() );
}
private void RefreshTargetAnimationList()
{
if ( _targetAnimationList is null || _builtInTargetAnimationList is null )
return;
var importedTargets = BuildImportedTargetAnimationEntries();
var builtInTargets = (_showBuiltInTargetAnimations || _builtInTargetAnimationsLoaded)
? BuildBuiltInTargetAnimationEntries( importedTargets )
: new List<RetargetTargetAnimationEntry>();
_targetAnimationList.SetItems( importedTargets );
_builtInTargetAnimationList.SetItems( builtInTargets );
_targetAnimationList.UpdateIfDirty();
_builtInTargetAnimationList.UpdateIfDirty();
SyncTargetAnimationSelection(importedTargets, builtInTargets);
if ( _targetSummaryLabel is not null )
_targetSummaryLabel.Text = BuildTargetAnimationSummary( importedTargets, builtInTargets );
if ( _builtInTargetSummaryLabel is not null )
_builtInTargetSummaryLabel.Text = BuildBuiltInTargetAnimationSummary( builtInTargets );
SetBuiltInTargetAnimationVisibility( _showBuiltInTargetAnimations );
}
private void SyncTargetAnimationSelection(
IReadOnlyList<RetargetTargetAnimationEntry> importedTargets,
IReadOnlyList<RetargetTargetAnimationEntry> builtInTargets )
{
RetargetTargetAnimationEntry? preferredImported = null;
RetargetTargetAnimationEntry? preferredBuiltIn = null;
if ( _selectedTargetAnimation is not null )
{
preferredImported = importedTargets.FirstOrDefault( target =>
target.IsImported == _selectedTargetAnimation.IsImported
&& target.SequenceName.Equals( _selectedTargetAnimation.SequenceName, StringComparison.OrdinalIgnoreCase ) );
preferredBuiltIn = builtInTargets.FirstOrDefault( target =>
target.IsImported == _selectedTargetAnimation.IsImported
&& target.SequenceName.Equals( _selectedTargetAnimation.SequenceName, StringComparison.OrdinalIgnoreCase ) );
}
if ( preferredImported is null )
{
var activeResultSequence = GetActivePreviewResult()?.SequenceName ?? string.Empty;
if ( !string.IsNullOrWhiteSpace( activeResultSequence ) )
{
preferredImported = importedTargets.FirstOrDefault( target =>
target.SequenceName.Equals( activeResultSequence, StringComparison.OrdinalIgnoreCase ) );
}
}
if ( preferredImported is not null )
{
_selectedTargetAnimation = preferredImported;
_targetAnimationList.SelectItem( preferredImported, true, true );
return;
}
if ( preferredBuiltIn is not null )
{
_selectedTargetAnimation = preferredBuiltIn;
_builtInTargetAnimationList.SelectItem( preferredBuiltIn, true, true );
return;
}
_selectedTargetAnimation = null;
_targetAnimationList.UnselectAll();
_builtInTargetAnimationList.UnselectAll();
}
private List<RetargetTargetAnimationEntry> BuildImportedTargetAnimationEntries()
{
var previewVmdlPath = _pipeline.ResolvePreviewableTargetVmdlPath( _job );
return _pipeline.GetTargetAnimationSources( _job )
.Select( target => new RetargetTargetAnimationEntry
{
SequenceName = target.SequenceName,
Looping = target.Looping,
IsImported = true,
IsReadOnly = false,
VmdlResourcePath = previewVmdlPath,
SourceResourcePath = target.ResourcePath
} )
.ToList();
}
private List<RetargetTargetAnimationEntry> BuildBuiltInTargetAnimationEntries( IReadOnlyList<RetargetTargetAnimationEntry> importedTargets )
{
var previewVmdlPath = _pipeline.ResolvePreviewableTargetVmdlPath( _job );
var importedNames = importedTargets
.Select( target => target.SequenceName )
.ToHashSet( StringComparer.OrdinalIgnoreCase );
return _pipeline.GetModelAnimationNames( previewVmdlPath )
.Where( sequenceName => !importedNames.Contains( sequenceName ) )
.Select( sequenceName => new RetargetTargetAnimationEntry
{
SequenceName = sequenceName,
Looping = sequenceName.Contains( "loop", StringComparison.OrdinalIgnoreCase ),
IsImported = false,
IsReadOnly = true,
VmdlResourcePath = previewVmdlPath
} )
.OrderBy( target => target.SequenceName, StringComparer.OrdinalIgnoreCase )
.ToList();
}
private List<RetargetTargetPresetRef> DiscoverTargetPresets()
{
var discovered = new List<RetargetTargetPresetRef>();
var citizenCustomRoot = CitizenRetargetPaths.GetAssetAbsolutePath( "models/citizen_custom" );
if ( Directory.Exists( citizenCustomRoot ) )
{
foreach ( var vmdlPath in Directory.EnumerateFiles( citizenCustomRoot, "citizen_*.vmdl", SearchOption.TopDirectoryOnly ) )
{
var resourcePath = CitizenRetargetPaths.GetAssetResourcePath( vmdlPath );
var fileName = Path.GetFileNameWithoutExtension( vmdlPath );
var key = fileName.StartsWith( "citizen_", StringComparison.OrdinalIgnoreCase )
? fileName["citizen_".Length..]
: fileName;
discovered.Add( BuildTargetPresetFromKey( key, resourcePath, true ) );
}
}
var currentKey = DeriveTargetKeyFromJob();
if ( !discovered.Any( preset => preset.Key.Equals( currentKey, StringComparison.OrdinalIgnoreCase ) ) )
discovered.Add( BuildTargetPresetFromKey( currentKey, _job.TargetVmdlPath, File.Exists( CitizenRetargetPaths.GetAssetAbsolutePath( _job.TargetVmdlPath ) ) ) );
return discovered
.OrderByDescending( preset => preset.TargetVmdlPath.Equals( _job.TargetVmdlPath, StringComparison.OrdinalIgnoreCase ) )
.ThenBy( preset => preset.DisplayName, StringComparer.OrdinalIgnoreCase )
.ToList();
}
private RetargetTargetPresetRef BuildTargetPresetFromKey( string rawKey, string targetVmdlPath, bool existsOnDisk )
{
var key = SanitizeTargetKey( rawKey );
return new RetargetTargetPresetRef
{
Key = key,
DisplayName = key.Equals( DeriveTargetKeyFromJob(), StringComparison.OrdinalIgnoreCase ) ? $"{key} (current)" : key,
TargetVmdlPath = string.IsNullOrWhiteSpace( targetVmdlPath ) ? $"models/citizen_custom/citizen_{key}.vmdl" : targetVmdlPath,
OutputAnimationFolder = $"models/citizen_custom/animations/{key}",
SequencePrefix = $"{key}_",
ExistsOnDisk = existsOnDisk
};
}
private string DeriveTargetKeyFromJob()
{
var fileName = Path.GetFileNameWithoutExtension( _job.TargetVmdlPath ?? string.Empty );
if ( fileName.StartsWith( "citizen_", StringComparison.OrdinalIgnoreCase ) )
fileName = fileName["citizen_".Length..];
return SanitizeTargetKey( string.IsNullOrWhiteSpace( fileName ) ? _activeSourceLibrary.DisplayName : fileName );
}
private static string SanitizeTargetKey( string? value )
{
var safe = new string( (value ?? string.Empty)
.Select( character => char.IsLetterOrDigit( character ) ? char.ToLowerInvariant( character ) : '_' )
.ToArray() )
.Trim( '_' );
return string.IsNullOrWhiteSpace( safe ) ? "citizen_custom" : safe;
}
private void RefreshHistoryList()
{
var history = (_job.RecentRuns ?? new List<RetargetRunHistoryEntry>()).ToList();
_historyList.SetItems( history );
if ( string.IsNullOrWhiteSpace( _session.SelectedHistoryRunId ) )
return;
var selectedEntry = history.FirstOrDefault( entry => entry.RunId.Equals( _session.SelectedHistoryRunId, StringComparison.OrdinalIgnoreCase ) );
if ( selectedEntry is not null )
{
_suppressHistorySelection = true;
try
{
_historyList.SelectItem( selectedEntry, true, true );
}
finally
{
_suppressHistorySelection = false;
}
}
}
private void RefreshResultList()
{
RefreshTargetAnimationList();
}
private void RefreshRunsInspectionState()
{
var selectedEntry = GetSelectedHistoryEntry();
var inspectedResult = GetRunsInspectionResult();
if ( _artifactSummaryLabel is not null )
_artifactSummaryLabel.Text = inspectedResult is null && selectedEntry is null
? "Select a run to inspect its artifacts, or use the latest run as a fallback."
: BuildRunInspectionSummary( selectedEntry, inspectedResult );
if ( _runLogOutput is not null )
_runLogOutput.PlainText = BuildRunLogText( selectedEntry, inspectedResult );
}
private void RefreshResultSurfaces()
{
var activeResult = GetActivePreviewResult();
var targetPreviewAnimation = GetActiveTargetPreviewAnimation();
var selectedClip = GetSingleSelectedClip();
if ( activeResult is null )
{
if ( targetPreviewAnimation is not null && !string.IsNullOrWhiteSpace( targetPreviewAnimation.VmdlResourcePath ) )
{
_livePreview.LoadAnimation( targetPreviewAnimation.VmdlResourcePath, targetPreviewAnimation.SequenceName );
}
else
{
_livePreview.ClearAnimation( BuildEmptyPreviewMessage( selectedClip ) );
}
RefreshRunsInspectionState();
RefreshHomeStatusStrip();
UpdateButtons();
return;
}
var hasImportedAnimation =
activeResult.Manifest.Status.Equals( "completed", StringComparison.OrdinalIgnoreCase )
&& !string.IsNullOrWhiteSpace( activeResult.ArtifactLinks.ImportedAnimationPath )
&& File.Exists( activeResult.ArtifactLinks.ImportedAnimationPath )
&& !string.IsNullOrWhiteSpace( activeResult.VmdlResourcePath );
if ( hasImportedAnimation )
_livePreview.LoadAnimation( activeResult.VmdlResourcePath, activeResult.SequenceName );
else
_livePreview.ClearAnimation( BuildUnavailableResultPreviewMessage( activeResult ) );
RefreshRunsInspectionState();
RefreshHomeStatusStrip();
UpdateButtons();
}
private RetargetRunManifest GetDiagnosticsManifest( RetargetRunHistoryEntry? entry, RetargetImportResult? result )
{
return result?.Manifest
?? (entry is null ? null : LoadManifestFromHistoryEntry( entry ))
?? (entry is null ? new RetargetRunManifest() : BuildFallbackManifestFromHistoryEntry( entry ));
}
private static RetargetStageManifestEntry? FindFirstFailedStage( RetargetRunManifest manifest )
{
return manifest.Stages.FirstOrDefault( stage => stage.Status.Equals( "failed", StringComparison.OrdinalIgnoreCase ) );
}
private static string FormatStageName( string stageId )
{
if ( string.IsNullOrWhiteSpace( stageId ) )
return "unknown stage";
var readable = stageId.Replace( '_', ' ' ).Replace( '-', ' ' ).Trim();
return string.IsNullOrWhiteSpace( readable ) ? stageId : readable;
}
private static string TruncateDiagnosticText( string? text, int maxLength = 180 )
{
var value = (text ?? string.Empty).Trim();
if ( value.Length <= maxLength )
return value;
return value[..Math.Max( 0, maxLength - 1 )].TrimEnd() + "...";
}
private static string BuildIssueListPreview( IReadOnlyList<string> values, int take = 4 )
{
if ( values.Count == 0 )
return string.Empty;
var preview = values.Take( take ).ToList();
var suffix = values.Count > take ? $" (+{values.Count - take} more)" : string.Empty;
return string.Join( ", ", preview ) + suffix;
}
private static Color GetRunStatusColor( string? status )
{
var value = status ?? string.Empty;
if ( value.Equals( "completed", StringComparison.OrdinalIgnoreCase ) )
return Theme.Green;
if ( value.Contains( "failed", StringComparison.OrdinalIgnoreCase ) )
return Theme.Yellow;
return Theme.Blue;
}
private static string FormatHistoryStatus( string? status )
{
var value = status ?? string.Empty;
if ( value.Equals( "completed", StringComparison.OrdinalIgnoreCase ) )
return "success";
if ( value.Equals( "import_failed", StringComparison.OrdinalIgnoreCase ) )
return "import failed";
if ( value.Contains( "failed", StringComparison.OrdinalIgnoreCase ) )
return "failed";
return string.IsNullOrWhiteSpace( value ) ? "history" : value.Replace( '_', ' ' );
}
private static void AppendLogSection( List<string> lines, string title, bool addLeadingSpacing = true )
{
if ( addLeadingSpacing && lines.Count > 0 )
lines.Add( string.Empty );
lines.Add( title );
lines.Add( new string( '-', title.Length ) );
}
private bool HasMaterializedImportedResult( RetargetRunHistoryEntry? entry )
{
if ( entry is null )
return false;
var importedAnimationPath = CitizenRetargetPaths.DecodeExternalPath( entry.ImportedAssetPath ?? string.Empty );
var generatedModelPath = string.IsNullOrWhiteSpace( entry.TargetVmdlPath )
? string.Empty
: CitizenRetargetPaths.GetAssetAbsolutePath( entry.TargetVmdlPath );
return !string.IsNullOrWhiteSpace( importedAnimationPath )
&& File.Exists( importedAnimationPath )
&& !string.IsNullOrWhiteSpace( generatedModelPath )
&& File.Exists( generatedModelPath );
}
private string BuildRunHealthLabel( RetargetDiagnosticSummary diagnostics, RetargetRunHistoryEntry? entry, RetargetImportResult? result )
{
if ( result is null && entry is null )
{
return diagnostics.MissingRequiredSlots.Count > 0
? "Retarget is not ready yet because required mapping slots are still missing."
: "No run selected yet.";
}
var manifest = GetDiagnosticsManifest( entry, result );
var sequenceName = entry?.SequenceName ?? result?.SequenceName ?? manifest.RetargetedActionName ?? "selected run";
var failedStage = FindFirstFailedStage( manifest );
if ( result is not null && !string.IsNullOrWhiteSpace( result.PostImportError ) )
return $"Failed during target import for '{sequenceName}'.";
if ( failedStage is not null )
return $"Failed during stage '{FormatStageName( failedStage.StageId )}' for '{sequenceName}'.";
if ( manifest.MotionTrajectoryAnalysis.Failures.Count > 0 )
return $"Run '{sequenceName}' completed with motion validation failures.";
if ( manifest.Status.Equals( "completed", StringComparison.OrdinalIgnoreCase ) )
{
var materialized = result is not null ? HasMaterializedImportedResult( result ) : HasMaterializedImportedResult( entry );
return materialized
? $"Run '{sequenceName}' completed successfully."
: $"Run '{sequenceName}' completed, but the target animation is not materialized on disk.";
}
return string.IsNullOrWhiteSpace( manifest.Status )
? $"Run '{sequenceName}' is selected for inspection."
: $"Run '{sequenceName}' is currently '{manifest.Status}'.";
}
private string BuildRunNextStepLabel( RetargetDiagnosticSummary diagnostics, RetargetRunHistoryEntry? entry, RetargetImportResult? result )
{
if ( result is null && entry is null )
{
return diagnostics.MissingRequiredSlots.Count > 0
? "Next step: fix the missing required slots in Mapping, then run the queue again."
: "Next step: select a run from History, or retarget a clip from Home.";
}
var manifest = GetDiagnosticsManifest( entry, result );
var failedStage = FindFirstFailedStage( manifest );
if ( result is not null && !string.IsNullOrWhiteSpace( result.PostImportError ) )
return "Next step: open Run Log first. If Blender export succeeded, inspect Raw Export and Manifest after that.";
if ( failedStage is not null )
return $"Next step: open Run Log and inspect the failed '{FormatStageName( failedStage.StageId )}' stage.";
if ( manifest.MotionTrajectoryAnalysis.Failures.Count > 0 )
return "Next step: review the motion failures in Run Log, then verify pose preset, root motion, and mapping.";
if ( diagnostics.MissingRequiredSlots.Count > 0 )
return "Next step: complete the missing required mapping slots before trusting this retarget setup.";
if ( manifest.Status.Equals( "completed", StringComparison.OrdinalIgnoreCase ) )
return "Next step: preview the animation or open the generated model. Use Run Log only if the result still looks wrong.";
return "Next step: open Run Log for the detailed backend and stage trace.";
}
private string BuildIssuesLabel( RetargetDiagnosticSummary diagnostics, RetargetRunHistoryEntry? entry, RetargetImportResult? result )
{
var manifest = GetDiagnosticsManifest( entry, result );
var blockingIssues = new List<string>();
var warningSummaries = new List<string>();
if ( manifest.BackendWarnings.Count > 0 )
warningSummaries.Add( $"Backend warnings: {manifest.BackendWarnings.Count}" );
if ( manifest.Warnings.Count > 0 )
warningSummaries.Add( $"Run warnings: {manifest.Warnings.Count}" );
if ( manifest.MotionTrajectoryAnalysis.Warnings.Count > 0 )
warningSummaries.Add( $"Motion warnings: {manifest.MotionTrajectoryAnalysis.Warnings.Count}" );
if ( diagnostics.DisabledFingerSlots.Count > 0 )
warningSummaries.Add( $"Disabled finger slots: {diagnostics.DisabledFingerSlots.Count}" );
var warningCount = warningSummaries.Count;
if ( diagnostics.MissingRequiredSlots.Count > 0 )
blockingIssues.Add( $"Missing required slots: {BuildIssueListPreview( diagnostics.MissingRequiredSlots, 6 )}" );
if ( diagnostics.DuplicateSourceBones.Count > 0 )
blockingIssues.Add( $"Duplicate source assignments: {BuildIssueListPreview( diagnostics.DuplicateSourceBones, 3 )}" );
if ( diagnostics.DuplicateTargetBones.Count > 0 )
blockingIssues.Add( $"Duplicate target assignments: {BuildIssueListPreview( diagnostics.DuplicateTargetBones, 3 )}" );
if ( result is not null && !string.IsNullOrWhiteSpace( result.PostImportError ) )
blockingIssues.Add( $"Target import failed: {TruncateDiagnosticText( result.PostImportError, 220 )}" );
if ( manifest.UnmappedRequiredSlots.Count > 0 )
blockingIssues.Add( $"Manifest unmapped required slots: {BuildIssueListPreview( manifest.UnmappedRequiredSlots, 6 )}" );
if ( manifest.MotionTrajectoryAnalysis.Failures.Count > 0 )
blockingIssues.Add( $"Motion failures: {BuildIssueListPreview( manifest.MotionTrajectoryAnalysis.Failures, 3 )}" );
var failedStages = manifest.Stages
.Where( stage => stage.Status.Equals( "failed", StringComparison.OrdinalIgnoreCase ) )
.ToList();
foreach ( var stage in failedStages )
{
var stageError = string.IsNullOrWhiteSpace( stage.Error ) ? "No error text was recorded." : stage.Error;
blockingIssues.Add( $"Stage '{FormatStageName( stage.StageId )}' failed: {TruncateDiagnosticText( stageError, 220 )}" );
}
if ( blockingIssues.Count == 0 )
{
if ( warningCount == 0 )
return "No blocking issues were detected for this run.";
var warningLines = new List<string>
{
"No blocking issues were detected.",
string.Empty,
"Warnings recorded:"
};
warningLines.AddRange( warningSummaries.Select( summary => $"- {summary}" ) );
warningLines.Add( string.Empty );
warningLines.Add( "Open Run Log if you need the full warning list." );
return string.Join( Environment.NewLine, warningLines );
}
var lines = new List<string>
{
"Blocking issues:"
};
lines.AddRange( blockingIssues.Select( issue => $"- {issue}" ) );
if ( warningCount > 0 )
{
lines.Add( string.Empty );
lines.Add( "Warnings recorded:" );
lines.AddRange( warningSummaries.Select( summary => $"- {summary}" ) );
lines.Add( string.Empty );
lines.Add( "Open Run Log for the full warning list." );
}
return string.Join( Environment.NewLine, lines );
}
private void UpdateDiagnostics()
{
var diagnostics = _pipeline.BuildDiagnostics( _mappingState );
var selectedEntry = GetSelectedHistoryEntry();
var diagnosticsResult = GetRunsInspectionResult();
RefreshEnvironmentDiagnostics();
if ( _runHealthLabel is not null )
_runHealthLabel.Text = BuildRunHealthLabel( diagnostics, selectedEntry, diagnosticsResult );
if ( _runNextStepLabel is not null )
_runNextStepLabel.Text = BuildRunNextStepLabel( diagnostics, selectedEntry, diagnosticsResult );
if ( _issuesLabel is not null )
_issuesLabel.Text = BuildIssuesLabel( diagnostics, selectedEntry, diagnosticsResult );
_summaryLabel.Text = diagnostics.MissingRequiredSlots.Count == 0
? BuildRunSummary()
: $"Missing required slots: {string.Join( ", ", diagnostics.MissingRequiredSlots )}";
RefreshHomeStatusStrip();
}
private string BuildRunSummary()
{
if ( _latestResult is null )
return "Bone-map coverage is ready. Queue a clip when you want the first retarget run.";
if ( !string.IsNullOrWhiteSpace( _latestResult.PostImportError ) )
return $"Latest run exported from Blender, but import failed: {_latestResult.PostImportError}";
if ( _latestResult.Manifest.MotionTrajectoryAnalysis.Failures.Count > 0 )
return $"Latest run failed {_latestResult.Manifest.MotionTrajectoryAnalysis.Failures.Count} motion gate(s). See Runs for details.";
if ( _latestResult.Manifest.BackendWarnings.Count > 0 )
return $"Latest run completed with {_latestResult.Manifest.BackendWarnings.Count} backend warning(s). See Runs for details.";
if ( _latestResult.Manifest.Status.Equals( "completed", StringComparison.OrdinalIgnoreCase ) )
return $"Latest run '{_latestResult.Manifest.RunId}' completed. Sequence '{_latestResult.SequenceName}' is now in the generated Citizen model.";
return $"Latest run '{_latestResult.Manifest.RunId}' is {_latestResult.Manifest.Status}.";
}
private void RefreshHomeStatusStrip()
{
if ( _resultMetaLabel is not null )
_resultMetaLabel.Text = BuildResultMetadataLabel();
}
private string BuildTargetAnimationSummary( IReadOnlyList<RetargetTargetAnimationEntry> importedTargets, IReadOnlyList<RetargetTargetAnimationEntry> builtInTargets )
{
var selectedClip = GetSingleSelectedClip();
var activeResult = GetActivePreviewResult();
if ( importedTargets.Count == 0 )
return builtInTargets.Count == 0
? "No imported retarget animations yet."
: $"No imported retarget animations yet. Base target exposes {builtInTargets.Count} built-in animation(s).";
var loopingCount = importedTargets.Count( target => target.Looping );
if ( selectedClip is not null && activeResult is not null && !string.IsNullOrWhiteSpace( activeResult.SequenceName ) )
return $"Imported target animations: {importedTargets.Count} total | {loopingCount} looping | '{selectedClip.DisplayName}' -> '{activeResult.SequenceName}'";
return $"Imported target animations: {importedTargets.Count} total | {loopingCount} looping";
}
private string BuildBuiltInTargetAnimationSummary( IReadOnlyList<RetargetTargetAnimationEntry> builtInTargets )
{
if ( builtInTargets.Count == 0 )
return "No built-in target animations were discovered.";
var loopingCount = builtInTargets.Count( target => target.Looping );
return $"Built-in target animations: {builtInTargets.Count} read-only | {loopingCount} looping";
}
private string BuildResultMetadataLabel()
{
var selectedResultEntry = FindHistoryEntryByRunId( _session.CompareSelection.SelectedResultRunId );
var activeResult = GetActivePreviewResult();
var selectedTargetAnimation = GetActiveTargetPreviewAnimation();
if ( selectedResultEntry is null && activeResult is null && selectedTargetAnimation is null )
return "No active target result yet. Pick a target animation or run a retarget.";
var sequenceName = selectedResultEntry?.SequenceName ?? activeResult?.SequenceName ?? selectedTargetAnimation?.SequenceName ?? "unknown";
var hasImportedResult = HasMaterializedImportedResult( activeResult );
if ( activeResult is not null && !string.IsNullOrWhiteSpace( activeResult.PostImportError ) )
return $"Result '{sequenceName}' exists in run history, but import into the current target failed. Check Diagnostics -> Run Log.";
if ( selectedTargetAnimation?.IsReadOnly == true )
return $"Previewing built-in target animation '{sequenceName}'.";
if ( hasImportedResult || selectedTargetAnimation?.IsImported == true )
return $"Previewing: {sequenceName}";
if ( selectedResultEntry is not null )
return $"Result '{sequenceName}' exists in history, but it is not currently materialized on this target.";
return $"Previewing: {sequenceName}";
}
private void OnTargetAnimationSelectionChanged( RetargetTargetAnimationEntry target )
{
_selectedTargetAnimation = target;
if ( target.IsReadOnly )
{
_session.CompareSelection.SelectedResultRunId = string.Empty;
_selectedCompareResult = null;
RefreshResultList();
RefreshResultSurfaces();
UpdateButtons();
SetStatus( $"Built-in target animation '{target.SequenceName}' is selected in read-only mode." );
return;
}
var entry = (_job.RecentRuns ?? new List<RetargetRunHistoryEntry>())
.Where( historyEntry => MatchesActiveTargetWorkspace( historyEntry ) )
.Where( historyEntry => MatchesActiveSourceLibrary( historyEntry ) )
.Where( historyEntry => historyEntry.SequenceName.Equals( target.SequenceName, StringComparison.OrdinalIgnoreCase ) )
.OrderByDescending( historyEntry => historyEntry.CreatedUtc, StringComparer.OrdinalIgnoreCase )
.FirstOrDefault();
if ( entry is null )
{
_session.CompareSelection.SelectedResultRunId = string.Empty;
_selectedCompareResult = null;
RefreshResultList();
RefreshResultSurfaces();
UpdateButtons();
SetStatus( target.IsImported
? $"Target animation '{target.SequenceName}' is on the model, but no matching run history entry was found."
: $"Built-in target animation '{target.SequenceName}' is selected in read-only mode." );
return;
}
var clip = FindClipForHistoryEntry( entry );
if ( clip is not null )
{
_job.SelectedClipNames = new List<string> { clip.DisplayName };
_pipeline.SaveJob( _job );
RefreshClipList();
}
_session.CompareSelection.SelectedSourceLibraryId = MatchesActiveSourceLibrary( entry ) ? _activeSourceLibrary.LibraryId : _session.CompareSelection.SelectedSourceLibraryId;
_session.CompareSelection.SelectedSourceClipName = entry.ClipName ?? _session.CompareSelection.SelectedSourceClipName;
_session.CompareSelection.SelectedResultRunId = entry.RunId ?? string.Empty;
ResolveCompareResultSelection();
RefreshResultList();
RefreshResultSurfaces();
UpdateButtons();
SetStatus( $"Target animation '{target.SequenceName}' is now the active result selection." );
}
private void UpdateButtons()
{
var sourceBusy = HasActiveBackgroundJob( "source" );
var targetBusy = HasActiveBackgroundJob( "target" );
var queueBusy = HasActiveBackgroundJob( "queue" );
var environmentBusy = HasActiveBackgroundJob( "environment" );
var sourceBlocking = HasBlockingBackgroundJob( "source" );
var targetBlocking = HasBlockingBackgroundJob( "target" );
var queueBlocking = HasBlockingBackgroundJob( "queue" );
var environmentBlocking = HasBlockingBackgroundJob( "environment" );
var anyBusy = sourceBlocking || targetBlocking || queueBlocking || environmentBlocking || _jobInFlight;
var hasClipSelection = _clipList?.SelectedItems.OfType<RetargetClipDescriptor>().Any() ?? false;
var hasFacingPreview = _inspection is not null && _inspection.Audit.Bones.Count > 0;
var facingManualControlsEnabled = hasFacingPreview && !anyBusy && IsSourceFacingManualOverrideEnabled();
var hasSelectedSlot = _selectedSlot is not null;
var hasSelectedBone = _selectedSourceBone is not null;
var currentArtifactResult = GetCurrentArtifactResult();
var currentRunDirectory = currentArtifactResult?.ArtifactLinks.RunDirectoryPath ?? string.Empty;
var currentManifestPath = currentArtifactResult?.ArtifactLinks.ManifestPath ?? string.Empty;
var currentRawExportPath = currentArtifactResult?.ArtifactLinks.ExportPath ?? string.Empty;
var currentImportedAnimationPath = currentArtifactResult?.ArtifactLinks.ImportedAnimationPath ?? string.Empty;
var currentGeneratedModelPath = currentArtifactResult?.ArtifactLinks.VmdlPath ?? string.Empty;
var currentRunLogPath = currentArtifactResult?.ArtifactLinks.RunLogPath ?? string.Empty;
var activePreviewResult = GetActivePreviewResult();
var activePreviewGeneratedModelPath = activePreviewResult?.ArtifactLinks.VmdlPath ?? string.Empty;
var currentTargetModelPath = CitizenRetargetPaths.GetAssetAbsolutePath( _job.TargetVmdlPath );
_scanButton.Enabled = !anyBusy;
if ( _browseSourceButton is not null )
_browseSourceButton.Enabled = !_queueRunning && !anyBusy;
if ( _beginOpenExistingTargetButton is not null )
_beginOpenExistingTargetButton.Enabled = !_queueRunning && !anyBusy;
if ( _beginCreateTargetButton is not null )
_beginCreateTargetButton.Enabled = !_queueRunning && !anyBusy;
if ( _openExistingTargetButton is not null )
_openExistingTargetButton.Enabled = (_targetPresetList?.SelectedItems.OfType<RetargetTargetPresetRef>().Any() ?? false) && !_queueRunning && !anyBusy;
if ( _createTargetButton is not null )
_createTargetButton.Enabled = !string.IsNullOrWhiteSpace( SanitizeTargetKey( _newTargetName?.Text ) ) && !_queueRunning && !anyBusy;
if ( _toggleBuiltInTargetAnimationsButton is not null )
_toggleBuiltInTargetAnimationsButton.Enabled = !targetBusy && !queueBusy && !_jobInFlight;
if ( _addClipToQueueButton is not null )
_addClipToQueueButton.Enabled = hasClipSelection && !_queueRunning && !sourceBusy && !queueBusy && !_jobInFlight;
if ( _sourceFacingManualOverrideCheckbox is not null )
_sourceFacingManualOverrideCheckbox.Enabled = hasFacingPreview && !anyBusy;
if ( _sourceFacingResetButton is not null )
_sourceFacingResetButton.Enabled = facingManualControlsEnabled;
if ( _sourceFacingRotate180Button is not null )
_sourceFacingRotate180Button.Enabled = facingManualControlsEnabled;
if ( _sourceFacingRotateLeftButton is not null )
_sourceFacingRotateLeftButton.Enabled = facingManualControlsEnabled;
if ( _sourceFacingRotateRightButton is not null )
_sourceFacingRotateRightButton.Enabled = facingManualControlsEnabled;
if ( _sourceFacingTiltForwardButton is not null )
_sourceFacingTiltForwardButton.Enabled = facingManualControlsEnabled;
if ( _sourceFacingTiltBackButton is not null )
_sourceFacingTiltBackButton.Enabled = facingManualControlsEnabled;
if ( _sourceFacingRollLeftButton is not null )
_sourceFacingRollLeftButton.Enabled = facingManualControlsEnabled;
if ( _sourceFacingRollRightButton is not null )
_sourceFacingRollRightButton.Enabled = facingManualControlsEnabled;
if ( _sourceFacingTiltField is not null )
_sourceFacingTiltField.Enabled = facingManualControlsEnabled;
if ( _sourceFacingRollField is not null )
_sourceFacingRollField.Enabled = facingManualControlsEnabled;
if ( _sourceFacingFacingField is not null )
_sourceFacingFacingField.Enabled = facingManualControlsEnabled;
if ( _editCompensationDataButton is not null )
_editCompensationDataButton.Enabled = !_jobInFlight;
_retryFailedQueueButton.Enabled = _queueItems.Any( item => item.Status == "failed" ) && !_queueRunning && !anyBusy;
_clearFinishedQueueButton.Enabled = _queueItems.Any( item => item.Status == "completed" || item.Status == "failed" ) && !_queueRunning && !anyBusy;
_clearQueueButton.Enabled = _queueItems.Count > 0;
if ( _scanEnvironmentButton is not null )
_scanEnvironmentButton.Enabled = !environmentBusy;
if ( _browseEnvironmentBackendButton is not null )
_browseEnvironmentBackendButton.Enabled = !environmentBusy;
if ( _browseBlenderButton is not null )
_browseBlenderButton.Enabled = !environmentBusy;
if ( _openEnvironmentBackendButton is not null )
{
var configuredBackendPath = _environmentReport?.BackendRootPath
?? RetargetSetupResolver.Resolve( _toolSettings ).BackendRootPath;
_openEnvironmentBackendButton.Enabled = !string.IsNullOrWhiteSpace( configuredBackendPath )
&& Directory.Exists( configuredBackendPath );
}
if ( _copyEnvironmentDiagnosticsButton is not null )
_copyEnvironmentDiagnosticsButton.Enabled = !environmentBusy;
if ( _exportSupportBundleButton is not null )
_exportSupportBundleButton.Enabled = !environmentBusy;
if ( _autoScanRokokoAddonButton is not null )
_autoScanRokokoAddonButton.Enabled = !environmentBusy;
if ( _browseRokokoAddonButton is not null )
_browseRokokoAddonButton.Enabled = !environmentBusy;
if ( _openRokokoAddonButton is not null )
{
var hasResolvedRokokoPath = Directory.Exists( ResolveRokokoAddonFolder( ResolveCurrentRokokoAddonPath() ) );
SetEnvironmentActionButtonPresent( _openRokokoAddonButton, hasResolvedRokokoPath );
_openRokokoAddonButton.Enabled = !environmentBusy && hasResolvedRokokoPath;
}
if ( _clearRokokoAddonButton is not null )
{
var hasManualRokokoPath = !string.IsNullOrWhiteSpace( _toolSettings.RokokoAddonPath );
SetEnvironmentActionButtonPresent( _clearRokokoAddonButton, hasManualRokokoPath, subtle: true );
_clearRokokoAddonButton.Enabled = !environmentBusy && hasManualRokokoPath;
}
if ( _downloadNativeHelperButton is not null )
{
var nativeDllExists = File.Exists( GetNativeHelperDllPath() );
_downloadNativeHelperButton.Text = nativeDllExists ? "Reinstall Helper" : "Download Helper";
SetEnvironmentActionButtonPresent( _downloadNativeHelperButton, true, primary: !nativeDllExists );
_downloadNativeHelperButton.Enabled = !environmentBusy;
}
if ( _openNativeHelperFolderButton is not null )
{
var nativeHelperTargetDirectory = GetNativeHelperTargetDirectory();
var hasNativeHelperTargetDirectory = Directory.Exists( nativeHelperTargetDirectory );
SetEnvironmentActionButtonPresent( _openNativeHelperFolderButton, hasNativeHelperTargetDirectory );
_openNativeHelperFolderButton.Enabled = !environmentBusy && hasNativeHelperTargetDirectory;
}
_useHistoryAsCompareButton.Enabled = !string.IsNullOrWhiteSpace( _session.SelectedHistoryRunId ) && !anyBusy;
_assignBoneButton.Enabled = hasSelectedSlot && hasSelectedBone && !anyBusy;
_clearSlotButton.Enabled = hasSelectedSlot && !anyBusy;
_resetSlotButton.Enabled = hasSelectedSlot && !anyBusy;
_saveMappingButton.Enabled = _mappingState.Count > 0 && !anyBusy;
_openRunDirButton.Enabled = !string.IsNullOrWhiteSpace( currentRunDirectory )
&& Directory.Exists( currentRunDirectory );
_openManifestButton.Enabled = !string.IsNullOrWhiteSpace( currentManifestPath )
&& File.Exists( currentManifestPath );
_openRawExportButton.Enabled = !string.IsNullOrWhiteSpace( currentRawExportPath )
&& File.Exists( currentRawExportPath );
if ( _openRunLogButton is not null )
{
_openRunLogButton.Enabled = !string.IsNullOrWhiteSpace( currentRunLogPath )
&& File.Exists( currentRunLogPath );
}
_openImportedAnimationButton.Enabled = !string.IsNullOrWhiteSpace( currentImportedAnimationPath )
&& File.Exists( currentImportedAnimationPath );
_openGeneratedModelButton.Enabled = !string.IsNullOrWhiteSpace( currentGeneratedModelPath )
&& File.Exists( currentGeneratedModelPath );
if ( _clearHomeResultButton is not null )
_clearHomeResultButton.Enabled = activePreviewResult is not null && !anyBusy;
if ( _inspectHomeResultInRunsButton is not null )
_inspectHomeResultInRunsButton.Enabled = activePreviewResult is not null && !anyBusy;
if ( _openHomeGeneratedModelButton is not null )
{
_openHomeGeneratedModelButton.Enabled =
(!string.IsNullOrWhiteSpace( activePreviewGeneratedModelPath ) && File.Exists( activePreviewGeneratedModelPath ))
|| File.Exists( currentTargetModelPath );
}
_runHomeQueueButton.Enabled = _queueItems.Any( item => item.Status == "pending" ) && !_queueRunning && !sourceBusy && !queueBusy && !environmentBusy && !_jobInFlight;
_removeHomeQueueItemButton.Enabled = (_homeQueueList?.SelectedItems.OfType<RetargetQueueItem>().Any() ?? false) && !_queueRunning && !sourceBusy && !queueBusy && !environmentBusy && !_jobInFlight;
_clearHomeQueueButton.Enabled = _queueItems.Count > 0;
}
private void RefreshClipSummary()
{
if ( _clipSummaryLabel is null )
return;
if ( _inspection is null )
{
_clipSummaryLabel.Text = "Scan a source FBX to load clips, inspect the skeleton, and start a queue.";
return;
}
var clipFilter = _clipFilter?.Text?.Trim() ?? string.Empty;
var visibleCount = CountVisibleClips( clipFilter );
var selectedCount = _clipList?.SelectedItems.OfType<RetargetClipDescriptor>().Count() ?? 0;
_clipSummaryLabel.Text =
$"Scanned {_allClips.Count} clip(s) and {_inspection.Audit.Bones.Count} bone(s). " +
$"Visible: {visibleCount}. Selected: {selectedCount}.";
RefreshHomeStatusStrip();
}
private string BuildQueueSummary()
{
if ( !string.IsNullOrWhiteSpace( _latestSetupFailureMessage ) )
return _latestSetupFailureMessage;
if ( _queueItems.Count == 0 )
return "No queued clips yet.";
var pendingCount = _queueItems.Count( item => item.Status == "pending" );
var runningCount = _queueItems.Count( item => item.Status == "running" );
var completedCount = _queueItems.Count( item => item.Status == "completed" );
var failedCount = _queueItems.Count( item => item.Status == "failed" );
return $"Pending: {pendingCount} | Running: {runningCount} | Completed: {completedCount} | Failed: {failedCount}";
}
private string BuildRunInspectionSummary( RetargetRunHistoryEntry? entry, RetargetImportResult? result )
{
if ( result is null && entry is null )
return "Select a run to inspect its artifacts. Start with Run Log when something fails.";
var runId = entry?.RunId ?? result?.Manifest.RunId ?? "unknown";
var status = entry?.Status ?? result?.Manifest.Status ?? "unknown";
var sequenceName = entry?.SequenceName ?? result?.SequenceName ?? "unknown";
var targetVmdl = entry?.TargetVmdlPath ?? result?.VmdlResourcePath ?? string.Empty;
var manifestPath = result?.ArtifactLinks.ManifestPath ?? string.Empty;
var runDirectoryPath = result?.ArtifactLinks.RunDirectoryPath ?? string.Empty;
var importedAnimationPath = result?.ArtifactLinks.ImportedAnimationPath ?? CitizenRetargetPaths.DecodeExternalPath( entry?.ImportedAssetPath ?? string.Empty );
var exportPath = result?.ArtifactLinks.ExportPath ?? CitizenRetargetPaths.DecodeExternalPath( entry?.ExportPath ?? string.Empty );
var generatedModelPath = result?.ArtifactLinks.VmdlPath ?? (!string.IsNullOrWhiteSpace( targetVmdl ) ? CitizenRetargetPaths.GetAssetAbsolutePath( targetVmdl ) : string.Empty);
var sourceFile = result?.Manifest.SourceFile ?? string.Empty;
if ( string.IsNullOrWhiteSpace( sourceFile ) && entry is not null )
sourceFile = TryGetHistoryEntrySourceFile( entry );
var details = new List<string> { $"Run {runId} | {status} | {sequenceName}" };
if ( !string.IsNullOrWhiteSpace( entry?.CreatedUtc ) )
details.Add( $"Created: {entry.CreatedUtc}" );
if ( !string.IsNullOrWhiteSpace( sourceFile ) )
details.Add( $"Source: {sourceFile}" );
if ( !string.IsNullOrWhiteSpace( targetVmdl ) )
details.Add( $"Target: {targetVmdl}" );
if ( result is not null && !string.IsNullOrWhiteSpace( result.PostImportError ) )
details.Add( $"Primary failure: {TruncateDiagnosticText( result.PostImportError, 180 )}" );
details.Add( $"Manifest: {BuildArtifactStateLabel( manifestPath, File.Exists )}" );
details.Add( $"Run dir: {BuildArtifactStateLabel( runDirectoryPath, Directory.Exists )}" );
details.Add( $"Export: {BuildArtifactStateLabel( exportPath, File.Exists )}" );
details.Add( $"Imported FBX: {BuildArtifactStateLabel( importedAnimationPath, File.Exists )}" );
details.Add( $"Generated VMDL: {BuildArtifactStateLabel( generatedModelPath, File.Exists )}" );
if ( result is null )
details.Add( "This artifact view is based on persisted history only." );
return string.Join( Environment.NewLine, details );
}
private static bool IsStructuredRunLog( string text )
{
if ( string.IsNullOrWhiteSpace( text ) )
return false;
return text.Contains( "RUN STATUS", StringComparison.OrdinalIgnoreCase )
&& text.Contains( "OUTPUTS", StringComparison.OrdinalIgnoreCase );
}
private string BuildRunLogText( RetargetRunHistoryEntry? entry, RetargetImportResult? result )
{
if ( result is not null )
{
var persistedLog = TryReadRunLog( result.ArtifactLinks.RunLogPath );
if ( IsStructuredRunLog( persistedLog ) )
return persistedLog;
if ( IsStructuredRunLog( result.Log ) )
return result.Log;
}
if ( entry is null && result is null )
return "Select a run to inspect its detailed run log.";
var diagnostics = _pipeline.BuildDiagnostics( _mappingState );
var manifest = result?.Manifest ?? (entry is null ? null : LoadManifestFromHistoryEntry( entry )) ?? (entry is null ? new RetargetRunManifest() : BuildFallbackManifestFromHistoryEntry( entry ));
var runId = entry?.RunId ?? manifest.RunId ?? "unknown";
var status = entry?.Status ?? manifest.Status ?? "unknown";
var sourceFile = manifest.SourceFile;
var targetVmdl = entry?.TargetVmdlPath ?? result?.VmdlResourcePath ?? string.Empty;
var exportPath = result?.ArtifactLinks.ExportPath ?? CitizenRetargetPaths.DecodeExternalPath( entry?.ExportPath ?? string.Empty );
var importedAnimationPath = result?.ArtifactLinks.ImportedAnimationPath ?? CitizenRetargetPaths.DecodeExternalPath( entry?.ImportedAssetPath ?? string.Empty );
var generatedModelPath = result?.ArtifactLinks.VmdlPath ?? (!string.IsNullOrWhiteSpace( targetVmdl ) ? CitizenRetargetPaths.GetAssetAbsolutePath( targetVmdl ) : string.Empty);
var manifestPath = result?.ArtifactLinks.ManifestPath ?? entry?.ManifestPath ?? string.Empty;
var runDirectoryPath = result?.ArtifactLinks.RunDirectoryPath ?? manifest.Outputs.RunDir ?? string.Empty;
var lines = new List<string>();
AppendLogSection( lines, "Run Status", addLeadingSpacing: false );
lines.AddRange(
[
$"Summary: {BuildRunHealthLabel( diagnostics, entry, result )}",
$"Run ID: {runId}",
$"Status: {status}"
] );
if ( !string.IsNullOrWhiteSpace( entry?.CreatedUtc ) )
lines.Add( $"Created: {entry.CreatedUtc}" );
if ( !string.IsNullOrWhiteSpace( sourceFile ) )
lines.Add( $"Source file: {sourceFile}" );
if ( !string.IsNullOrWhiteSpace( targetVmdl ) )
lines.Add( $"Target VMDL: {targetVmdl}" );
AppendLogSection( lines, "Prerequisites" );
if ( !string.IsNullOrWhiteSpace( manifest.PoseNormalization.TargetPosePresetId ) )
lines.Add( $"Pose preset: {manifest.PoseNormalization.TargetPosePresetId}" );
lines.Add( $"Required mapping: {manifest.MappingCoverage.MappedRequiredSlotCount}/{manifest.MappingCoverage.RequiredSlotCount}" );
lines.Add( $"Optional mapping: {manifest.MappingCoverage.MappedOptionalSlotCount}/{manifest.MappingCoverage.OptionalSlotCount}" );
lines.Add( $"User overrides: {manifest.MappingCoverage.UserOverrideSlotCount}" );
AppendLogSection( lines, "Process" );
if ( manifest.Stages.Count == 0 )
{
lines.Add( "- No stage data was recorded for this run." );
}
else
{
foreach ( var stage in manifest.Stages )
{
lines.Add( $"- {FormatStageName( stage.StageId )}: {stage.Status}" );
if ( stage.Warnings.Count > 0 )
lines.AddRange( stage.Warnings.Select( warning => $" warning: {warning}" ) );
if ( !string.IsNullOrWhiteSpace( stage.Error ) )
lines.Add( $" error: {stage.Error}" );
}
}
AppendLogSection( lines, "Outputs" );
lines.Add( $"Run dir: {BuildArtifactStateLabel( runDirectoryPath, Directory.Exists )} | {runDirectoryPath}" );
lines.Add( $"Manifest: {BuildArtifactStateLabel( manifestPath, File.Exists )} | {manifestPath}" );
lines.Add( $"Raw export: {BuildArtifactStateLabel( exportPath, File.Exists )} | {exportPath}" );
lines.Add( $"Imported FBX: {BuildArtifactStateLabel( importedAnimationPath, File.Exists )} | {importedAnimationPath}" );
lines.Add( $"Generated VMDL: {BuildArtifactStateLabel( generatedModelPath, File.Exists )} | {generatedModelPath}" );
var warningLines = new List<string>();
if ( manifest.BackendWarnings.Count > 0 )
warningLines.AddRange( manifest.BackendWarnings.Select( warning => $"- Backend: {warning}" ) );
if ( manifest.Warnings.Count > 0 )
warningLines.AddRange( manifest.Warnings.Select( warning => $"- Run: {warning}" ) );
if ( manifest.MotionTrajectoryAnalysis.Warnings.Count > 0 )
warningLines.AddRange( manifest.MotionTrajectoryAnalysis.Warnings.Select( warning => $"- Motion warning: {warning}" ) );
if ( manifest.MotionTrajectoryAnalysis.Failures.Count > 0 )
warningLines.AddRange( manifest.MotionTrajectoryAnalysis.Failures.Select( failure => $"- Motion failure: {failure}" ) );
if ( warningLines.Count > 0 )
{
AppendLogSection( lines, "Warnings" );
lines.AddRange( warningLines );
}
var issuesText = BuildIssuesLabel( diagnostics, entry, result );
if ( !string.IsNullOrWhiteSpace( issuesText ) )
{
AppendLogSection( lines, "Issues Summary" );
lines.Add( issuesText );
}
AppendLogSection( lines, "What To Check Next" );
lines.Add( BuildRunNextStepLabel( diagnostics, entry, result ) );
return string.Join( Environment.NewLine, lines );
}
private static string BuildArtifactStateLabel( string path, Func<string, bool> exists )
{
if ( string.IsNullOrWhiteSpace( path ) )
return "missing";
return exists( path )
? "available"
: "missing";
}
private static string TryReadRunLog( string path )
{
if ( string.IsNullOrWhiteSpace( path ) || !File.Exists( path ) )
return string.Empty;
try
{
return File.ReadAllText( path );
}
catch
{
return string.Empty;
}
}
private string BuildEmptyPreviewMessage( RetargetClipDescriptor? selectedClip )
{
if ( selectedClip is null )
return "Select a clip and run retarget to see the target-side preview.";
return $"No target-side preview is available yet for '{selectedClip.DisplayName}'. Run the queue or pick a target animation.";
}
private static string BuildUnavailableResultPreviewMessage( RetargetImportResult result )
{
if ( !string.IsNullOrWhiteSpace( result.PostImportError ) )
return $"Run '{result.Manifest.RunId}' finished the Blender export, but import into the current target failed. Check Diagnostics -> Run Log.";
if ( result.Manifest.Status.Equals( "completed", StringComparison.OrdinalIgnoreCase ) )
return $"Run '{result.Manifest.RunId}' completed, but no compiled target animation is available to preview yet.";
return $"Run '{result.Manifest.RunId}' is '{result.Manifest.Status}', so target-side preview is unavailable.";
}
private void UpdatePoseCompensationSummary()
{
if ( _poseCompensationSummaryLabel is null )
return;
var presetId = string.IsNullOrWhiteSpace( _job.TargetPosePresetId )
? CitizenTargetProfile.DefaultTargetPosePresetId
: _job.TargetPosePresetId.Trim();
var compensationPath = Path.Combine( CitizenRetargetPaths.DataRoot, "citizen_arm_rest_compensation.json" );
if ( !File.Exists( compensationPath ) )
{
_poseCompensationSummaryLabel.Text = $"Pose preset: {presetId}. Local compensation data file is missing.";
return;
}
try
{
using var document = JsonDocument.Parse( File.ReadAllText( compensationPath ) );
var method = document.RootElement.TryGetProperty( "method", out var methodElement )
? methodElement.GetString() ?? "unknown"
: "unknown";
var notes = document.RootElement.TryGetProperty( "notes", out var notesElement ) && notesElement.ValueKind == JsonValueKind.Array
? notesElement.EnumerateArray().Select( note => note.GetString() ?? string.Empty ).Where( note => !string.IsNullOrWhiteSpace( note ) ).ToList()
: new List<string>();
var affectedBones = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
if ( document.RootElement.TryGetProperty( "offsets_deg", out var offsetsElement ) && offsetsElement.ValueKind == JsonValueKind.Object )
{
foreach ( var side in offsetsElement.EnumerateObject() )
{
foreach ( var property in side.Value.EnumerateObject() )
{
var split = property.Name.Split( '.', 2 );
if ( split.Length == 2 && !string.IsNullOrWhiteSpace( split[0] ) )
affectedBones.Add( split[0] );
}
}
}
var affectedBoneSummary = affectedBones.Count == 0
? "No affected bones listed."
: $"Affects {affectedBones.Count} bone(s): {string.Join( ", ", affectedBones.OrderBy( bone => bone, StringComparer.OrdinalIgnoreCase ).Take( 8 ) )}{(affectedBones.Count > 8 ? "..." : string.Empty)}";
var noteSummary = notes.Count == 0 ? "No additional notes." : string.Join( " ", notes );
_poseCompensationSummaryLabel.Text =
$"Pose preset: {presetId}{Environment.NewLine}" +
$"Compensation method: {method}{Environment.NewLine}" +
$"{affectedBoneSummary}{Environment.NewLine}" +
$"{noteSummary}";
}
catch ( Exception exception )
{
_poseCompensationSummaryLabel.Text = $"Pose preset: {presetId}. Failed to read local compensation data: {exception.Message}";
}
}
private void SetStatus( string message ) { if ( _statusLabel is not null ) _statusLabel.Text = $"[{DateTime.Now:HH:mm:ss}] {message}"; }
private void PaintClipItem( VirtualWidget item )
{
if ( item.Object is not RetargetClipDescriptor clip )
return;
var badges = BuildClipBadges( clip );
var accent = badges.Count > 0 ? badges[0].Color : Theme.Primary;
PaintListRow( item.Rect, clip.DisplayName, $"{clip.FrameCount} frames @ {clip.FrameRate:0.##} fps", accent, badges, showMenuIndicator: true );
}
private void PaintSourceBoneItem( VirtualWidget item )
{
if ( item.Object is not NativeAuditBoneInfo bone )
return;
PaintListRow( item.Rect, bone.Name, string.IsNullOrWhiteSpace( bone.ParentName ) ? "root" : $"parent: {bone.ParentName}", Theme.Blue );
}
private void PaintMappingItem( VirtualWidget item )
{
if ( item.Object is not RetargetSlotAssignmentState slot )
return;
var effectiveSource = string.IsNullOrWhiteSpace( slot.EffectiveSourceBone ) ? "Unmapped" : slot.EffectiveSourceBone;
var state = slot.IsManualOverride ? "Manual override" : string.IsNullOrWhiteSpace( slot.EffectiveSourceBone ) ? "Needs assignment" : "Auto mapped";
var color = string.IsNullOrWhiteSpace( slot.EffectiveSourceBone ) ? Theme.Yellow : slot.Required ? Theme.Green : Theme.Primary;
var badges = new List<RetargetBadgeVisual>();
if ( slot.Required )
badges.Add( new RetargetBadgeVisual( "REQ", Theme.Green ) );
if ( slot.IsManualOverride )
badges.Add( new RetargetBadgeVisual( "MANUAL", Theme.Primary ) );
PaintComfortableListRow( item.Rect, slot.SlotId, $"Source: {effectiveSource} | {state}", color, badges );
}
private void PaintQueueItem( VirtualWidget item )
{
if ( item.Object is not RetargetQueueItem queueItem )
return;
var color = queueItem.Status switch
{
"completed" => Theme.Green,
"failed" => Theme.Yellow,
"running" => Theme.Primary,
_ => Theme.Text
};
var subtitle = BuildQueueSubtitle( queueItem.Status, queueItem.Message );
if ( queueItem.Status != "running" )
{
PaintComfortableListRow( item.Rect, queueItem.Clip.DisplayName, subtitle, color );
return;
}
var queueJob = GetActiveBackgroundJob( "queue" );
var progress = queueJob?.Progress ?? 0f;
var isIndeterminate = queueJob?.IsIndeterminate ?? true;
var progressLabel = queueJob is null
? "Retargeting..."
: queueJob.IsIndeterminate
? (string.IsNullOrWhiteSpace( queueJob.Detail ) ? "Retargeting..." : queueJob.Detail)
: $"{FormatProgressText( progress )} | {(string.IsNullOrWhiteSpace( queueJob.Detail ) ? "Retargeting..." : queueJob.Detail)}";
PaintListRowWithProgress( item.Rect, queueItem.Clip.DisplayName, progressLabel, color, progress, isIndeterminate );
}
private static string BuildQueueSubtitle( string status, string message )
{
var statusLabel = FormatHistoryStatus( status );
var detail = (message ?? string.Empty).Trim();
if ( string.IsNullOrWhiteSpace( detail ) || detail.Equals( status, StringComparison.OrdinalIgnoreCase ) )
return statusLabel;
return $"{statusLabel} | {detail}";
}
private void PaintHistoryItem( VirtualWidget item )
{
if ( item.Object is not RetargetRunHistoryEntry entry )
return;
var caption = string.IsNullOrWhiteSpace( entry.SequenceName ) ? entry.ClipName : $"{entry.ClipName} -> {entry.SequenceName}";
var color = GetRunStatusColor( entry.Status );
var created = FormatHistoryTimestamp( entry.CreatedUtc );
PaintComfortableListRow( item.Rect, caption, $"{FormatHistoryStatus( entry.Status )} | {created}", color );
}
private void PaintHomeResultItem( VirtualWidget item )
{
if ( item.Object is not RetargetRunHistoryEntry entry )
return;
var color = entry.Status.Equals( "completed", StringComparison.OrdinalIgnoreCase )
? Theme.Green
: entry.Status.Contains( "failed", StringComparison.OrdinalIgnoreCase )
? Theme.Yellow
: Theme.Blue;
var caption = string.IsNullOrWhiteSpace( entry.SequenceName ) ? entry.RunId : entry.SequenceName;
var created = string.IsNullOrWhiteSpace( entry.CreatedUtc ) ? "history" : entry.CreatedUtc;
PaintListRow( item.Rect, caption, $"{entry.Status} | {created} | {entry.RunId}", color );
}
private void PaintTargetPresetItem( VirtualWidget item )
{
if ( item.Object is not RetargetTargetPresetRef preset )
return;
var subtitle = preset.ExistsOnDisk
? $"{preset.TargetVmdlPath} | existing"
: $"{preset.TargetVmdlPath} | virtual";
PaintListRow( item.Rect, preset.DisplayName, subtitle, Theme.Blue );
}
private void PaintTargetAnimationItem( VirtualWidget item )
{
if ( item.Object is not RetargetTargetAnimationEntry target )
return;
var latestEntry = (_job.RecentRuns ?? new List<RetargetRunHistoryEntry>())
.Where( entry => entry.SequenceName.Equals( target.SequenceName, StringComparison.OrdinalIgnoreCase ) )
.OrderByDescending( entry => entry.CreatedUtc, StringComparer.OrdinalIgnoreCase )
.FirstOrDefault();
var color = target.IsReadOnly
? Theme.Text
: latestEntry is null
? Theme.Blue
: latestEntry.Status.Equals( "completed", StringComparison.OrdinalIgnoreCase )
? Theme.Green
: latestEntry.Status.Contains( "failed", StringComparison.OrdinalIgnoreCase )
? Theme.Yellow
: Theme.Blue;
var sourceKind = target.IsReadOnly ? "built-in" : "imported";
var subtitle = latestEntry is null
? (target.Looping ? $"{sourceKind} | looping | no history" : $"{sourceKind} | no history")
: latestEntry.Status.Equals( "completed", StringComparison.OrdinalIgnoreCase )
? $"{sourceKind} | {(target.Looping ? "looping | " : string.Empty)}{latestEntry.RunId}"
: $"{sourceKind} | {latestEntry.Status} | {(target.Looping ? "looping | " : string.Empty)}{latestEntry.RunId}";
PaintComfortableListRow( item.Rect, target.SequenceName, subtitle, color, showMenuIndicator: target.IsImported && !target.IsReadOnly );
}
private List<RetargetBadgeVisual> BuildClipBadges( RetargetClipDescriptor clip )
{
var badges = new List<RetargetBadgeVisual>();
var queueItem = _queueItems.FirstOrDefault( item => item.Clip.DisplayName.Equals( clip.DisplayName, StringComparison.OrdinalIgnoreCase ) );
if ( queueItem is not null )
{
switch ( queueItem.Status )
{
case "running":
badges.Add( new RetargetBadgeVisual( "RUN", Theme.Primary ) );
break;
case "pending":
badges.Add( new RetargetBadgeVisual( "QUEUED", Theme.Primary ) );
break;
}
}
if ( clip.DisplayName.Contains( "loop", StringComparison.OrdinalIgnoreCase ) )
badges.Add( new RetargetBadgeVisual( "LOOP", Theme.Blue ) );
return badges.Take( 2 ).ToList();
}
private static void PaintListRow( Rect rowRect, string title, string subtitle, Color accent, IReadOnlyList<RetargetBadgeVisual>? badges = null, bool showMenuIndicator = false )
{
var rect = rowRect.Shrink( 6, 3 );
var isSelected = Paint.HasSelected;
var isHovered = Paint.HasMouseOver;
var fillColor = isSelected
? accent.WithAlpha( 0.2f )
: isHovered
? Theme.SurfaceBackground.Lighten( 0.08f ).WithAlpha( 0.92f )
: Theme.ControlBackground.WithAlpha( 0.78f );
var borderColor = isSelected
? accent.WithAlpha( 0.85f )
: isHovered
? Theme.Border.WithAlpha( 0.6f )
: Theme.Border.WithAlpha( 0.24f );
Paint.ClearPen();
Paint.SetBrush( fillColor );
Paint.DrawRect( rect, 6 );
var accentStrip = new Rect( rect.Left, rect.Top, 4f, rect.Height );
Paint.SetBrush( (isSelected ? accent : isHovered ? accent.WithAlpha( 0.55f ) : accent.WithAlpha( 0.18f )) );
Paint.DrawRect( accentStrip, 4f );
Paint.SetBrush( Color.Transparent );
Paint.SetPen( borderColor );
Paint.DrawRect( rect, 6 );
var textRect = rect.Shrink( 14, 6 );
textRect.Left += 2f;
var menuInset = showMenuIndicator ? PaintOverflowIndicator( rect ) : 0f;
if ( badges is { Count: > 0 } )
textRect.Right -= PaintBadgesWithin( rect, badges, menuInset );
textRect.Right -= menuInset;
Paint.SetPen( isSelected ? Theme.Text : Theme.Text.WithAlpha( 0.95f ) );
Paint.DrawText( textRect, title, TextFlag.LeftTop );
textRect.Top += 14;
Paint.SetPen( Theme.Text.WithAlpha( isSelected ? 0.78f : 0.6f ) );
Paint.DrawText( textRect, subtitle, TextFlag.LeftTop );
}
private static void PaintComfortableListRow( Rect rowRect, string title, string subtitle, Color accent, IReadOnlyList<RetargetBadgeVisual>? badges = null, bool showMenuIndicator = false )
{
var rect = rowRect.Shrink( 6, 4 );
var isSelected = Paint.HasSelected;
var isHovered = Paint.HasMouseOver;
var fillColor = isSelected
? accent.WithAlpha( 0.2f )
: isHovered
? Theme.SurfaceBackground.Lighten( 0.08f ).WithAlpha( 0.92f )
: Theme.ControlBackground.WithAlpha( 0.78f );
var borderColor = isSelected
? accent.WithAlpha( 0.85f )
: isHovered
? Theme.Border.WithAlpha( 0.6f )
: Theme.Border.WithAlpha( 0.24f );
Paint.ClearPen();
Paint.SetBrush( fillColor );
Paint.DrawRect( rect, 6 );
var accentStrip = new Rect( rect.Left, rect.Top, 4f, rect.Height );
Paint.SetBrush( isSelected ? accent : isHovered ? accent.WithAlpha( 0.55f ) : accent.WithAlpha( 0.18f ) );
Paint.DrawRect( accentStrip, 4f );
Paint.SetBrush( Color.Transparent );
Paint.SetPen( borderColor );
Paint.DrawRect( rect, 6 );
var textRect = rect.Shrink( 14, 8 );
textRect.Left += 2f;
var menuInset = showMenuIndicator ? PaintOverflowIndicator( rect ) : 0f;
if ( badges is { Count: > 0 } )
textRect.Right -= PaintBadgesWithin( rect, badges, menuInset );
textRect.Right -= menuInset;
var titleRect = new Rect( textRect.Left, rect.Top + 8f, textRect.Width, 18f );
var subtitleRect = new Rect( textRect.Left, rect.Top + 27f, textRect.Width, 16f );
Paint.SetPen( isSelected ? Theme.Text : Theme.Text.WithAlpha( 0.96f ) );
Paint.DrawText( titleRect, title, TextFlag.LeftTop );
Paint.SetPen( Theme.Text.WithAlpha( isSelected ? 0.8f : 0.64f ) );
Paint.DrawText( subtitleRect, subtitle, TextFlag.LeftTop );
}
private static string FormatHistoryTimestamp( string? createdUtc )
{
if ( string.IsNullOrWhiteSpace( createdUtc ) )
return "history";
if ( DateTime.TryParse( createdUtc, out var parsed ) )
return parsed.ToLocalTime().ToString( "yyyy-MM-dd HH:mm:ss" );
return createdUtc;
}
private static void PaintListRowWithProgress( Rect rowRect, string title, string subtitle, Color accent, float progress, bool isIndeterminate )
{
var rect = rowRect.Shrink( 6, 6 );
var isSelected = Paint.HasSelected;
var isHovered = Paint.HasMouseOver;
var fillColor = isSelected
? accent.WithAlpha( 0.2f )
: isHovered
? Theme.SurfaceBackground.Lighten( 0.08f ).WithAlpha( 0.92f )
: Theme.ControlBackground.WithAlpha( 0.78f );
var borderColor = isSelected
? accent.WithAlpha( 0.85f )
: isHovered
? Theme.Border.WithAlpha( 0.6f )
: Theme.Border.WithAlpha( 0.24f );
Paint.ClearPen();
Paint.SetBrush( fillColor );
Paint.DrawRect( rect, 6 );
var accentStrip = new Rect( rect.Left, rect.Top, 4f, rect.Height );
Paint.SetBrush( (isSelected ? accent : isHovered ? accent.WithAlpha( 0.55f ) : accent.WithAlpha( 0.18f )) );
Paint.DrawRect( accentStrip, 4f );
Paint.SetBrush( Color.Transparent );
Paint.SetPen( borderColor );
Paint.DrawRect( rect, 6 );
var contentLeft = rect.Left + 16f;
var contentWidth = rect.Width - 32f;
var titleRect = new Rect( contentLeft, rect.Top + 8f, contentWidth, 18f );
var subtitleRect = new Rect( contentLeft, rect.Top + 29f, contentWidth, 16f );
var progressRect = new Rect( contentLeft, rect.Bottom - 14f, contentWidth, 6f );
Paint.SetPen( isSelected ? Theme.Text : Theme.Text.WithAlpha( 0.95f ) );
Paint.DrawText( titleRect, title, TextFlag.LeftTop );
Paint.SetPen( Theme.Text.WithAlpha( isSelected ? 0.78f : 0.6f ) );
Paint.DrawText( subtitleRect, subtitle, TextFlag.LeftTop );
PaintInlineProgressBar( progressRect, accent, progress, isIndeterminate );
}
private static void PaintInlineProgressBar( Rect rect, Color accent, float progress, bool isIndeterminate )
{
var radius = MathF.Min( 3f, rect.Height * 0.5f );
Paint.ClearPen();
Paint.SetBrush( Theme.ControlBackground.Lighten( 0.08f ) );
Paint.DrawRect( rect, radius );
Paint.SetBrush( accent );
if ( isIndeterminate )
{
var segmentWidth = MathF.Max( rect.Width * 0.24f, 24f );
var travel = rect.Width + segmentWidth;
var offset = (RealTime.Now * 140f) % travel - segmentWidth;
var left = MathF.Max( rect.Left, offset );
var right = MathF.Min( rect.Right, offset + segmentWidth );
if ( right > left )
{
var segmentRect = new Rect( left, rect.Top, right - left, rect.Height );
Paint.DrawRect( segmentRect, radius );
}
return;
}
var clamped = Math.Clamp( progress, 0f, 1f );
if ( clamped <= 0f )
return;
var fillRect = rect;
fillRect.Width *= clamped;
Paint.DrawRect( fillRect, radius );
}
private static float PaintMenuIndicator( Rect rowRect )
{
var width = 18f;
var indicatorRect = new Rect( rowRect.Right - width - 8f, rowRect.Top + 6f, width, 14f );
Paint.SetPen( Theme.Text.WithAlpha( Paint.HasMouseOver ? 0.85f : 0.45f ) );
Paint.DrawText( indicatorRect, "⋮", TextFlag.Center );
return width + 8f;
}
private static float PaintBadges( Rect rowRect, IReadOnlyList<RetargetBadgeVisual> badges )
{
var right = rowRect.Right - 10f;
var top = rowRect.Top + 8f;
var consumedWidth = 0f;
for ( var index = badges.Count - 1; index >= 0; index-- )
{
var badge = badges[index];
var width = MathF.Max( 42f, badge.Text.Length * 7f + 14f );
var badgeRect = new Rect( right - width, top, width, 16f );
Paint.SetBrush( badge.Color.WithAlpha( 0.2f ) );
Paint.DrawRect( badgeRect, 8f );
Paint.SetPen( badge.Color );
Paint.DrawText( badgeRect, badge.Text, TextFlag.Center );
right -= width + 6f;
consumedWidth += width + 6f;
}
return consumedWidth;
}
private static Rect GetOverflowIndicatorRect( Rect rowRect )
{
var width = 26f;
return new Rect( rowRect.Right - width - 8f, rowRect.Top + 5f, width, 18f );
}
private static float PaintOverflowIndicator( Rect rowRect )
{
var width = 26f;
var indicatorRect = GetOverflowIndicatorRect( rowRect );
Paint.ClearPen();
Paint.SetBrush( Paint.HasMouseOver ? Theme.SurfaceBackground.Lighten( 0.12f ).WithAlpha( 0.96f ) : Theme.ControlBackground.WithAlpha( 0.92f ) );
Paint.DrawRect( indicatorRect, 9f );
Paint.SetPen( Theme.Text.WithAlpha( Paint.HasMouseOver ? 0.92f : 0.72f ) );
Paint.DrawIcon( indicatorRect, "more_vert", 14, TextFlag.Center );
return width + 10f;
}
private static bool RectContainsPoint( Rect rect, Vector2 point )
{
return point.x >= rect.Left
&& point.x <= rect.Right
&& point.y >= rect.Top
&& point.y <= rect.Bottom;
}
private static float PaintBadgesWithin( Rect rowRect, IReadOnlyList<RetargetBadgeVisual> badges, float rightInset )
{
var right = rowRect.Right - 10f - rightInset;
var top = rowRect.Top + 8f;
var consumedWidth = 0f;
for ( var index = badges.Count - 1; index >= 0; index-- )
{
var badge = badges[index];
var width = MathF.Max( 42f, badge.Text.Length * 7f + 14f );
var badgeRect = new Rect( right - width, top, width, 16f );
Paint.SetBrush( badge.Color.WithAlpha( 0.2f ) );
Paint.DrawRect( badgeRect, 8f );
Paint.SetPen( badge.Color );
Paint.DrawText( badgeRect, badge.Text, TextFlag.Center );
right -= width + 6f;
consumedWidth += width + 6f;
}
return consumedWidth;
}
private readonly struct RetargetBadgeVisual
{
public RetargetBadgeVisual( string text, Color color )
{
Text = text;
Color = color;
}
public string Text { get; }
public Color Color { get; }
}
}