Editor/CitizenRetarget/CitizenRetargetWorkstationModels.cs
#nullable enable
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Security.Cryptography;
using System.Text;
namespace Editor.CitizenRetarget;
internal sealed class RetargetSourceLibraryRef
{
public string LibraryId { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string SourceFbxPath { get; set; } = string.Empty;
public string SourceProfilePath { get; set; } = string.Empty;
}
internal sealed class RetargetResultRef
{
public string RunId { get; set; } = string.Empty;
public string SequenceName { get; set; } = string.Empty;
public string ClipName { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public string TargetVmdlPath { get; set; } = string.Empty;
public string ManifestPath { get; set; } = string.Empty;
public string ImportedAssetPath { get; set; } = string.Empty;
}
internal sealed class RetargetCompareSelection
{
public string SelectedSourceLibraryId { get; set; } = string.Empty;
public string SelectedSourceClipName { get; set; } = string.Empty;
public string SelectedResultRunId { get; set; } = string.Empty;
}
internal sealed class RetargetSessionState
{
public RetargetCompareSelection CompareSelection { get; set; } = new();
public string SelectedHistoryRunId { get; set; } = string.Empty;
public string HomeResultFilter { get; set; } = "All";
}
internal sealed class RetargetTargetPresetRef
{
public string Key { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string TargetVmdlPath { get; set; } = string.Empty;
public string OutputAnimationFolder { get; set; } = string.Empty;
public string SequencePrefix { get; set; } = string.Empty;
public bool ExistsOnDisk { get; set; }
public override string ToString() => DisplayName;
}
internal sealed class RetargetTargetAnimationEntry
{
public string SequenceName { get; set; } = string.Empty;
public bool Looping { get; set; }
public bool IsImported { get; set; }
public bool IsReadOnly { get; set; }
public string VmdlResourcePath { get; set; } = string.Empty;
public string SourceResourcePath { get; set; } = string.Empty;
}
internal static class RetargetSourceLibraryResolver
{
public static RetargetSourceLibraryRef FromJob( CitizenRetargetJob job )
{
var normalizedPath = CitizenRetargetPaths.DecodeExternalPath( job.SourceFbxPath ).Trim().Trim( '"' );
var displayName = DeriveDisplayName( normalizedPath );
return new RetargetSourceLibraryRef
{
LibraryId = DeriveLibraryId( normalizedPath ),
DisplayName = string.IsNullOrWhiteSpace( displayName ) ? "Source Library" : displayName,
SourceFbxPath = job.SourceFbxPath ?? string.Empty,
SourceProfilePath = job.SourceProfilePath ?? string.Empty
};
}
public static string DeriveLibraryId( string sourceFbxPath )
{
var normalizedPath = CitizenRetargetPaths.DecodeExternalPath( sourceFbxPath ).Trim().Trim( '"' );
if ( string.IsNullOrWhiteSpace( normalizedPath ) )
return "source_library";
var stem = DeriveDisplayName( normalizedPath );
var safeStem = new string( stem
.Select( character => char.IsLetterOrDigit( character ) ? char.ToLowerInvariant( character ) : '_' )
.ToArray() )
.Trim( '_' );
if ( string.IsNullOrWhiteSpace( safeStem ) )
safeStem = "source_library";
var hashBytes = SHA1.HashData( Encoding.UTF8.GetBytes( normalizedPath.ToLowerInvariant() ) );
var hash = Convert.ToHexString( hashBytes[..4] ).ToLowerInvariant();
return $"{safeStem}_{hash}";
}
public static string DeriveDisplayName( string sourceFbxPath )
{
var normalizedPath = CitizenRetargetPaths.DecodeExternalPath( sourceFbxPath ).Trim().Trim( '"' );
if ( string.IsNullOrWhiteSpace( normalizedPath ) )
return "Source Library";
var fileName = Path.GetFileNameWithoutExtension( normalizedPath );
return string.IsNullOrWhiteSpace( fileName ) ? "Source Library" : fileName;
}
}
internal sealed class RetargetSourceInspection
{
public string SourcePath { get; set; } = string.Empty;
public NativeSceneAuditResult Audit { get; set; } = new();
public Dictionary<string, NativeAuditBoneInfo> BonesByName { get; set; } = new( StringComparer.OrdinalIgnoreCase );
}
internal sealed class RetargetSlotAssignmentState
{
public string SlotId { get; set; } = string.Empty;
public string TargetBone { get; set; } = string.Empty;
public string Group { get; set; } = "body";
public bool Required { get; set; }
public bool Enabled { get; set; } = true;
public bool Locked { get; set; }
public string MirrorSlotId { get; set; } = string.Empty;
public string AssignedSourceBone { get; set; } = string.Empty;
public string AutoSourceBone { get; set; } = string.Empty;
public string EffectiveSourceBone => string.IsNullOrWhiteSpace( AssignedSourceBone ) ? AutoSourceBone : AssignedSourceBone;
public bool IsManualOverride { get; set; }
public float Confidence { get; set; }
public List<string> CandidateBones { get; set; } = new();
public List<string> SourceAliases { get; set; } = new();
public string Notes { get; set; } = string.Empty;
}
internal sealed class RetargetDiagnosticSummary
{
public List<string> MissingRequiredSlots { get; set; } = new();
public List<string> DuplicateSourceBones { get; set; } = new();
public List<string> DuplicateTargetBones { get; set; } = new();
public List<string> DisabledFingerSlots { get; set; } = new();
}
internal sealed class RetargetQueueItem
{
public RetargetClipDescriptor Clip { get; set; } = new();
public string Status { get; set; } = "pending";
public string Message { get; set; } = string.Empty;
public RetargetImportResult? Result { get; set; }
}
internal sealed class RetargetRunManifest
{
public string RunId { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public string RecipeId { get; set; } = string.Empty;
public string SourceProfileId { get; set; } = string.Empty;
public string TargetProfileId { get; set; } = string.Empty;
public string SourceFile { get; set; } = string.Empty;
public string SelectedAction { get; set; } = string.Empty;
public string RetargetedActionName { get; set; } = string.Empty;
public string RetargetBackend { get; set; } = string.Empty;
public string BackendVersion { get; set; } = string.Empty;
public Dictionary<string, object> BackendSettings { get; set; } = new();
public List<RetargetGeneratedBoneMapEntry> BackendGeneratedBoneMap { get; set; } = new();
public RetargetBackendMappingOverrideSummary BackendMappingOverrideSummary { get; set; } = new();
public List<string> BackendWarnings { get; set; } = new();
public RetargetPoseNormalizationSummary PoseNormalization { get; set; } = new();
public RetargetMotionTrajectoryAnalysis MotionTrajectoryAnalysis { get; set; } = new();
public Dictionary<string, RetargetBoneMapEntry> BoneMap { get; set; } = new( StringComparer.OrdinalIgnoreCase );
public RetargetMappingCoverageSummary MappingCoverage { get; set; } = new();
public RetargetBoneMapOverrideSummary BoneMapOverrideSummary { get; set; } = new();
public List<string> UnmappedRequiredSlots { get; set; } = new();
public List<string> Warnings { get; set; } = new();
public List<RetargetStageManifestEntry> Stages { get; set; } = new();
public RetargetOutputsSummary Outputs { get; set; } = new();
}
internal sealed class RetargetGeneratedBoneMapEntry
{
public string SourceBone { get; set; } = string.Empty;
public string TargetBone { get; set; } = string.Empty;
public string BoneKey { get; set; } = string.Empty;
public string CanonicalSlot { get; set; } = string.Empty;
public bool IsCustom { get; set; }
public bool Required { get; set; }
public bool UserOverridden { get; set; }
public string MappingOrigin { get; set; } = string.Empty;
}
internal sealed class RetargetBackendMappingOverrideSummary
{
public bool Enabled { get; set; }
public bool Applied { get; set; }
public string Reason { get; set; } = string.Empty;
public int ValidatedPairCount { get; set; }
public List<RetargetValidatedBonePair> ValidatedPairs { get; set; } = new();
public List<string> MissingSourceBones { get; set; } = new();
public List<string> MissingTargetBones { get; set; } = new();
}
internal sealed class RetargetValidatedBonePair
{
public string Slot { get; set; } = string.Empty;
public string SourceBone { get; set; } = string.Empty;
public string TargetBone { get; set; } = string.Empty;
}
internal sealed class RetargetBoneMapEntry
{
public string SourceBone { get; set; } = string.Empty;
public string TargetBone { get; set; } = string.Empty;
public bool Required { get; set; }
public bool Enabled { get; set; }
public bool Locked { get; set; }
public bool UserOverridden { get; set; }
public string Group { get; set; } = string.Empty;
public string MappingOrigin { get; set; } = string.Empty;
}
internal sealed class RetargetMappingCoverageSummary
{
public int RequiredSlotCount { get; set; }
public int MappedRequiredSlotCount { get; set; }
public int OptionalSlotCount { get; set; }
public int MappedOptionalSlotCount { get; set; }
public int ResolvedCandidateSlotCount { get; set; }
public int TotalMappedSlotCount { get; set; }
public int UserOverrideSlotCount { get; set; }
public int LockedSlotCount { get; set; }
}
internal sealed class RetargetBoneMapOverrideSummary
{
public bool Provided { get; set; }
public int EnabledSlotCount { get; set; }
public int RequiredSlotCount { get; set; }
public int OptionalSlotCount { get; set; }
public int DisabledSlotCount { get; set; }
public int LockedSlotCount { get; set; }
public int UserOverrideSlotCount { get; set; }
public List<string> DisabledSlots { get; set; } = new();
public List<string> RequiredSlots { get; set; } = new();
public List<string> OptionalSlots { get; set; } = new();
public List<string> LockedSlots { get; set; } = new();
public List<string> UserOverrideSlots { get; set; } = new();
public List<string> MissingTargetBoneSlots { get; set; } = new();
public List<string> InvalidSlots { get; set; } = new();
}
internal sealed class RetargetPoseNormalizationSummary
{
public string TargetPosePresetId { get; set; } = string.Empty;
public string TargetPosePresetSource { get; set; } = string.Empty;
}
internal sealed class RetargetMotionTrajectoryAnalysis
{
public List<string> Warnings { get; set; } = new();
public List<string> Failures { get; set; } = new();
}
internal sealed class RetargetStageManifestEntry
{
public string StageId { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public List<string> Warnings { get; set; } = new();
public string Error { get; set; } = string.Empty;
}
internal sealed class RetargetOutputsSummary
{
public string RunDir { get; set; } = string.Empty;
public string ExportPath { get; set; } = string.Empty;
public string ManifestPath { get; set; } = string.Empty;
public string BoneMapOverridesPath { get; set; } = string.Empty;
}
internal sealed class RetargetPreviewArtifacts
{
public string PreviewBlendPath { get; set; } = string.Empty;
public string PreviewVideoPath { get; set; } = string.Empty;
public string ComparisonBlendPath { get; set; } = string.Empty;
public string ComparisonVideoPath { get; set; } = string.Empty;
}
internal sealed class RetargetArtifactLinks
{
public string RunDirectoryPath { get; set; } = string.Empty;
public string ManifestPath { get; set; } = string.Empty;
public string ExportPath { get; set; } = string.Empty;
public string SourcePreviewPath { get; set; } = string.Empty;
public string ImportedAnimationPath { get; set; } = string.Empty;
public string SourcePreviewVmdlPath { get; set; } = string.Empty;
public string VmdlPath { get; set; } = string.Empty;
public string BoneMapOverridePath { get; set; } = string.Empty;
public string RunLogPath { get; set; } = string.Empty;
}
internal sealed class RetargetBackendInvocation
{
public string BlenderExecutablePath { get; set; } = string.Empty;
public string BackendRootPath { get; set; } = string.Empty;
public string RecipePath { get; set; } = string.Empty;
public string RunId { get; set; } = string.Empty;
public string BoneMapOverridePath { get; set; } = string.Empty;
}
internal static class RetargetManifestJson
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNameCaseInsensitive = true,
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip
};
public static T Deserialize<T>( string json ) where T : new()
{
var value = JsonSerializer.Deserialize<T>( json, Options ) ?? new T();
if ( value is RetargetRunManifest manifest )
NormalizeManifest( manifest );
return value;
}
private static void NormalizeManifest( RetargetRunManifest manifest )
{
manifest.RunId ??= string.Empty;
manifest.Status ??= string.Empty;
manifest.RecipeId ??= string.Empty;
manifest.SourceProfileId ??= string.Empty;
manifest.TargetProfileId ??= string.Empty;
manifest.SourceFile ??= string.Empty;
manifest.SelectedAction ??= string.Empty;
manifest.RetargetedActionName ??= string.Empty;
manifest.RetargetBackend ??= string.Empty;
manifest.BackendVersion ??= string.Empty;
manifest.BackendSettings ??= new Dictionary<string, object>();
manifest.BackendGeneratedBoneMap ??= new List<RetargetGeneratedBoneMapEntry>();
foreach ( var entry in manifest.BackendGeneratedBoneMap )
{
if ( entry is null )
continue;
entry.SourceBone ??= string.Empty;
entry.TargetBone ??= string.Empty;
entry.BoneKey ??= string.Empty;
entry.CanonicalSlot ??= string.Empty;
entry.MappingOrigin ??= string.Empty;
}
manifest.BackendMappingOverrideSummary ??= new RetargetBackendMappingOverrideSummary();
manifest.BackendMappingOverrideSummary.Reason ??= string.Empty;
manifest.BackendMappingOverrideSummary.ValidatedPairs ??= new List<RetargetValidatedBonePair>();
foreach ( var pair in manifest.BackendMappingOverrideSummary.ValidatedPairs )
{
if ( pair is null )
continue;
pair.Slot ??= string.Empty;
pair.SourceBone ??= string.Empty;
pair.TargetBone ??= string.Empty;
}
manifest.BackendMappingOverrideSummary.MissingSourceBones ??= new List<string>();
manifest.BackendMappingOverrideSummary.MissingTargetBones ??= new List<string>();
manifest.BackendWarnings ??= new List<string>();
manifest.PoseNormalization ??= new RetargetPoseNormalizationSummary();
manifest.PoseNormalization.TargetPosePresetId ??= string.Empty;
manifest.PoseNormalization.TargetPosePresetSource ??= string.Empty;
manifest.MotionTrajectoryAnalysis ??= new RetargetMotionTrajectoryAnalysis();
manifest.MotionTrajectoryAnalysis.Warnings ??= new List<string>();
manifest.MotionTrajectoryAnalysis.Failures ??= new List<string>();
manifest.BoneMap = manifest.BoneMap is null
? new Dictionary<string, RetargetBoneMapEntry>( StringComparer.OrdinalIgnoreCase )
: new Dictionary<string, RetargetBoneMapEntry>( manifest.BoneMap, StringComparer.OrdinalIgnoreCase );
foreach ( var entry in manifest.BoneMap.Values )
{
if ( entry is null )
continue;
entry.SourceBone ??= string.Empty;
entry.TargetBone ??= string.Empty;
entry.Group ??= string.Empty;
entry.MappingOrigin ??= string.Empty;
}
manifest.MappingCoverage ??= new RetargetMappingCoverageSummary();
manifest.BoneMapOverrideSummary ??= new RetargetBoneMapOverrideSummary();
manifest.BoneMapOverrideSummary.DisabledSlots ??= new List<string>();
manifest.BoneMapOverrideSummary.RequiredSlots ??= new List<string>();
manifest.BoneMapOverrideSummary.OptionalSlots ??= new List<string>();
manifest.BoneMapOverrideSummary.LockedSlots ??= new List<string>();
manifest.BoneMapOverrideSummary.UserOverrideSlots ??= new List<string>();
manifest.BoneMapOverrideSummary.MissingTargetBoneSlots ??= new List<string>();
manifest.BoneMapOverrideSummary.InvalidSlots ??= new List<string>();
manifest.UnmappedRequiredSlots ??= new List<string>();
manifest.Warnings ??= new List<string>();
manifest.Stages ??= new List<RetargetStageManifestEntry>();
foreach ( var stage in manifest.Stages )
{
if ( stage is null )
continue;
stage.StageId ??= string.Empty;
stage.Status ??= string.Empty;
stage.Warnings ??= new List<string>();
stage.Error ??= string.Empty;
}
manifest.Outputs ??= new RetargetOutputsSummary();
manifest.Outputs.RunDir ??= string.Empty;
manifest.Outputs.ExportPath ??= string.Empty;
manifest.Outputs.ManifestPath ??= string.Empty;
manifest.Outputs.BoneMapOverridesPath ??= string.Empty;
}
}