Editor/CitizenRetarget/BlenderDmxExportBridge.cs
using System.Diagnostics;
using System.Text;
using System.Text.Json;
#nullable enable
namespace Editor.CitizenRetarget;
internal sealed class BlenderDmxExportBridge
{
private static readonly string BlenderSourceToolsRoot = CitizenRetargetPaths.GetTempPath( "BlenderSourceTools" );
private static readonly string[] ForensicFocusBones =
{
"pelvis",
"spine_0",
"spine_2",
"clavicle_L",
"arm_upper_L",
"leg_upper_L"
};
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true
};
public BlenderTargetBindCache EnsureTargetBindCache()
{
var bindCachePath = CitizenRetargetPaths.GetTempPath( "blender", "reference", "citizen_bind_cache.json" );
Directory.CreateDirectory( Path.GetDirectoryName( bindCachePath )! );
if ( !File.Exists( bindCachePath ) )
{
RunBlender(
$"--mode dump-bind --citizen-fbx \"{GetCitizenFbxAbsolutePath()}\" --template-dmx \"{GetStockTemplateDmxAbsolutePath()}\" --output \"{bindCachePath}\" --bst-root \"{BlenderSourceToolsRoot}\"",
"Blender bind cache export" );
}
if ( !File.Exists( bindCachePath ) )
throw new FileNotFoundException( $"Blender bind cache export did not produce '{bindCachePath}'" );
return LoadBindCache( bindCachePath );
}
public string ExportPayloadJsonToDmx(
string payloadJsonPath,
string outputAbsolutePath,
string? templateDmxAbsolutePath = null,
string? preExportPoseJsonAbsolutePath = null )
{
Directory.CreateDirectory( Path.GetDirectoryName( outputAbsolutePath )! );
var templatePath = string.IsNullOrWhiteSpace( templateDmxAbsolutePath )
? GetStockTemplateDmxAbsolutePath()
: templateDmxAbsolutePath;
var templateArgument = $" --template-dmx \"{templatePath}\"";
var preExportArgument = string.IsNullOrWhiteSpace( preExportPoseJsonAbsolutePath )
? string.Empty
: $" --pre-export-json \"{preExportPoseJsonAbsolutePath}\"";
RunBlender(
$"--mode export-payload --citizen-fbx \"{GetCitizenFbxAbsolutePath()}\" --input-json \"{payloadJsonPath}\" --output \"{outputAbsolutePath}\" --bst-root \"{BlenderSourceToolsRoot}\"{templateArgument}{preExportArgument}",
"Blender DMX export" );
var actualOutputPath = ResolveActualOutputPath( outputAbsolutePath );
if ( string.IsNullOrWhiteSpace( actualOutputPath ) )
throw new FileNotFoundException( $"Blender export did not produce '{outputAbsolutePath}'" );
return actualOutputPath;
}
public string ExportPayloadJsonToFbx(
string payloadJsonPath,
string outputAbsolutePath,
string? preExportPoseJsonAbsolutePath = null )
{
Directory.CreateDirectory( Path.GetDirectoryName( outputAbsolutePath )! );
var preExportArgument = string.IsNullOrWhiteSpace( preExportPoseJsonAbsolutePath )
? string.Empty
: $" --pre-export-json \"{preExportPoseJsonAbsolutePath}\"";
RunBlender(
$"--mode export-payload-fbx --citizen-fbx \"{GetCitizenFbxAbsolutePath()}\" --input-json \"{payloadJsonPath}\" --output \"{outputAbsolutePath}\" --bst-root \"{BlenderSourceToolsRoot}\"{preExportArgument}",
"Blender FBX payload export" );
if ( !File.Exists( outputAbsolutePath ) )
throw new FileNotFoundException( $"Blender FBX payload export did not produce '{outputAbsolutePath}'" );
return outputAbsolutePath;
}
public BlenderForensicArtifactResult CaptureForensicRoundTrip(
string sequenceName,
string payloadJsonPath,
string outputAbsolutePath,
string? templateDmxAbsolutePath = null )
{
var forensicFolder = CitizenRetargetPaths.GetTempPath( "blender", "forensics", sequenceName );
Directory.CreateDirectory( forensicFolder );
var payloadCopyPath = Path.Combine( forensicFolder, "payload.json" );
var appliedPosePath = Path.Combine( forensicFolder, "applied_pose.json" );
var reimportedPosePath = Path.Combine( forensicFolder, "reimported_pose.json" );
var summaryJsonPath = Path.Combine( forensicFolder, "summary.json" );
var summaryMarkdownPath = Path.Combine( forensicFolder, "summary.md" );
File.Copy( payloadJsonPath, payloadCopyPath, overwrite: true );
var actualOutputPath = ExportPayloadJsonToDmx( payloadCopyPath, outputAbsolutePath, templateDmxAbsolutePath, appliedPosePath );
RunBlender(
$"--mode dump-pose --citizen-fbx \"{GetCitizenFbxAbsolutePath()}\" --input-dmx \"{actualOutputPath}\" --output \"{reimportedPosePath}\" --bst-root \"{BlenderSourceToolsRoot}\"",
"Blender DMX post-import pose dump" );
WriteForensicSummary( payloadCopyPath, appliedPosePath, reimportedPosePath, summaryJsonPath, summaryMarkdownPath );
return new BlenderForensicArtifactResult
{
PayloadJsonAbsolutePath = payloadCopyPath,
AppliedPoseJsonAbsolutePath = appliedPosePath,
ReimportedPoseJsonAbsolutePath = reimportedPosePath,
SummaryJsonAbsolutePath = summaryJsonPath,
SummaryMarkdownAbsolutePath = summaryMarkdownPath,
GeneratedDmxAbsolutePath = actualOutputPath
};
}
public string RoundTripDmx( string inputDmxAbsolutePath, string outputAbsolutePath )
{
Directory.CreateDirectory( Path.GetDirectoryName( outputAbsolutePath )! );
RunBlender(
$"--mode roundtrip-dmx --citizen-fbx \"{GetCitizenFbxAbsolutePath()}\" --input-dmx \"{inputDmxAbsolutePath}\" --output \"{outputAbsolutePath}\" --bst-root \"{BlenderSourceToolsRoot}\"",
"Blender DMX roundtrip" );
var actualOutputPath = ResolveActualOutputPath( outputAbsolutePath );
if ( string.IsNullOrWhiteSpace( actualOutputPath ) )
throw new FileNotFoundException( $"Blender roundtrip did not produce '{outputAbsolutePath}'" );
return actualOutputPath;
}
public string ExportDirectTransferClipToDmx(
string sourceFbxAbsolutePath,
string clipSourceName,
string sequenceName,
string mappingJsonAbsolutePath,
string compensationJsonAbsolutePath,
bool includeHands,
string outputAbsolutePath,
string? templateDmxAbsolutePath = null )
{
Directory.CreateDirectory( Path.GetDirectoryName( outputAbsolutePath )! );
var templatePath = string.IsNullOrWhiteSpace( templateDmxAbsolutePath )
? GetStockTemplateDmxAbsolutePath()
: templateDmxAbsolutePath;
var includeHandsFlag = includeHands ? "1" : "0";
RunBlender(
$"--mode retarget-clip --citizen-fbx \"{GetCitizenFbxAbsolutePath()}\" --source-fbx \"{sourceFbxAbsolutePath}\" --clip-name \"{clipSourceName}\" --sequence-name \"{sequenceName}\" --mapping-json \"{mappingJsonAbsolutePath}\" --compensation-json \"{compensationJsonAbsolutePath}\" --include-hands \"{includeHandsFlag}\" --template-dmx \"{templatePath}\" --output \"{outputAbsolutePath}\" --bst-root \"{BlenderSourceToolsRoot}\"",
"Blender direct-transfer DMX export" );
var actualOutputPath = ResolveActualOutputPath( outputAbsolutePath );
if ( string.IsNullOrWhiteSpace( actualOutputPath ) )
throw new FileNotFoundException( $"Blender direct-transfer export did not produce '{outputAbsolutePath}'" );
return actualOutputPath;
}
public string ExportSolvedCitizenFbxToDmx(
string sourceFbxAbsolutePath,
string sequenceName,
string outputAbsolutePath,
string? sourceActionName = null,
string? templateDmxAbsolutePath = null )
{
Directory.CreateDirectory( Path.GetDirectoryName( outputAbsolutePath )! );
var templatePath = string.IsNullOrWhiteSpace( templateDmxAbsolutePath )
? GetStockTemplateDmxAbsolutePath()
: templateDmxAbsolutePath;
var sourceActionArgument = string.IsNullOrWhiteSpace( sourceActionName )
? string.Empty
: $" --source-action \"{sourceActionName}\"";
RunBlender(
$"--mode copy-fbx-to-dmx --citizen-fbx \"{GetCitizenFbxAbsolutePath()}\" --source-fbx \"{sourceFbxAbsolutePath}\" --sequence-name \"{sequenceName}\" --template-dmx \"{templatePath}\" --output \"{outputAbsolutePath}\" --bst-root \"{BlenderSourceToolsRoot}\"{sourceActionArgument}",
"Blender solved-FBX to DMX export" );
var actualOutputPath = ResolveActualOutputPath( outputAbsolutePath );
if ( string.IsNullOrWhiteSpace( actualOutputPath ) )
throw new FileNotFoundException( $"Blender solved-FBX export did not produce '{outputAbsolutePath}'" );
return actualOutputPath;
}
public string ExportSolvedCitizenFbxToTemplateFbx(
string sourceFbxAbsolutePath,
string sequenceName,
string outputAbsolutePath,
string? sourceActionName = null,
string? templateFbxAbsolutePath = null )
{
Directory.CreateDirectory( Path.GetDirectoryName( outputAbsolutePath )! );
var templatePath = string.IsNullOrWhiteSpace( templateFbxAbsolutePath )
? GetStockTemplateAnimationFbxAbsolutePath()
: templateFbxAbsolutePath;
var sourceActionArgument = string.IsNullOrWhiteSpace( sourceActionName )
? string.Empty
: $" --source-action \"{sourceActionName}\"";
RunBlender(
$"--mode copy-fbx-to-fbx-template --citizen-fbx \"{GetCitizenFbxAbsolutePath()}\" --source-fbx \"{sourceFbxAbsolutePath}\" --template-fbx \"{templatePath}\" --sequence-name \"{sequenceName}\" --output \"{outputAbsolutePath}\" --bst-root \"{BlenderSourceToolsRoot}\"{sourceActionArgument}",
"Blender solved-FBX to template-FBX export" );
if ( !File.Exists( outputAbsolutePath ) )
throw new FileNotFoundException( $"Blender solved-FBX template export did not produce '{outputAbsolutePath}'" );
return outputAbsolutePath;
}
public string ExportSourceActionToPreviewFbx(
string sourceFbxAbsolutePath,
string sourceActionName,
string sequenceName,
string outputAbsolutePath )
{
Directory.CreateDirectory( Path.GetDirectoryName( outputAbsolutePath )! );
var sourceActionArgument = string.IsNullOrWhiteSpace( sourceActionName )
? string.Empty
: $" --source-action \"{sourceActionName}\"";
var sequenceArgument = string.IsNullOrWhiteSpace( sequenceName )
? string.Empty
: $" --sequence-name \"{sequenceName}\"";
RunBlender(
$"--mode copy-source-action-to-fbx --citizen-fbx \"{GetCitizenFbxAbsolutePath()}\" --source-fbx \"{sourceFbxAbsolutePath}\" --output \"{outputAbsolutePath}\" --bst-root \"{BlenderSourceToolsRoot}\"{sourceActionArgument}{sequenceArgument}",
"Blender source-action preview export" );
if ( !File.Exists( outputAbsolutePath ) )
throw new FileNotFoundException( $"Blender source-action preview export did not produce '{outputAbsolutePath}'" );
return outputAbsolutePath;
}
private static void WriteForensicSummary(
string payloadJsonPath,
string appliedPoseJsonPath,
string reimportedPoseJsonPath,
string summaryJsonPath,
string summaryMarkdownPath )
{
var payload = LoadPoseDump( payloadJsonPath );
var applied = LoadPoseDump( appliedPoseJsonPath );
var reimported = LoadPoseDump( reimportedPoseJsonPath );
var comparisons = ForensicFocusBones
.Select( boneName => BuildBoneComparison( boneName, payload, applied, reimported ) )
.Where( comparison => comparison is not null )
.Cast<object>()
.ToList();
var summary = new
{
payload_json = payloadJsonPath,
applied_pose_json = appliedPoseJsonPath,
reimported_pose_json = reimportedPoseJsonPath,
payload_armature_object = payload.ObjectTransform,
applied_armature_object = applied.ObjectTransform,
reimported_armature_object = reimported.ObjectTransform,
focus_bones = comparisons
};
File.WriteAllText( summaryJsonPath, JsonSerializer.Serialize( summary, JsonOptions ) );
var builder = new StringBuilder();
builder.AppendLine( "# Blender Forensic Round-Trip" );
builder.AppendLine();
builder.AppendLine( $"- Payload: `{payloadJsonPath}`" );
builder.AppendLine( $"- Applied pose dump: `{appliedPoseJsonPath}`" );
builder.AppendLine( $"- Re-imported pose dump: `{reimportedPoseJsonPath}`" );
builder.AppendLine();
builder.AppendLine( "## Object Transform" );
builder.AppendLine();
builder.AppendLine( $"- Payload armature object: `{FormatObjectTransform( payload.ObjectTransform )}`" );
builder.AppendLine( $"- Applied armature object: `{FormatObjectTransform( applied.ObjectTransform )}`" );
builder.AppendLine( $"- Re-imported armature object: `{FormatObjectTransform( reimported.ObjectTransform )}`" );
builder.AppendLine();
builder.AppendLine( "## Focus Bones" );
builder.AppendLine();
foreach ( var boneName in ForensicFocusBones )
{
var comparison = BuildBoneComparison( boneName, payload, applied, reimported );
if ( comparison is null )
continue;
builder.AppendLine( $"### {boneName}" );
builder.AppendLine();
builder.AppendLine( $"- Payload translation: `{FormatArray( comparison.PayloadTranslation )}`" );
builder.AppendLine( $"- Applied translation: `{FormatArray( comparison.AppliedTranslation )}`" );
builder.AppendLine( $"- Re-imported translation: `{FormatArray( comparison.ReimportedTranslation )}`" );
builder.AppendLine( $"- Payload rotation: `{FormatArray( comparison.PayloadRotation )}`" );
builder.AppendLine( $"- Applied rotation: `{FormatArray( comparison.AppliedRotation )}`" );
builder.AppendLine( $"- Re-imported rotation: `{FormatArray( comparison.ReimportedRotation )}`" );
builder.AppendLine( $"- Payload->Applied translation delta: `{comparison.PayloadToAppliedTranslationError:0.####}`" );
builder.AppendLine( $"- Applied->Re-imported translation delta: `{comparison.AppliedToReimportedTranslationError:0.####}`" );
builder.AppendLine( $"- Payload->Applied rotation delta: `{comparison.PayloadToAppliedRotationDegrees:0.####} deg`" );
builder.AppendLine( $"- Applied->Re-imported rotation delta: `{comparison.AppliedToReimportedRotationDegrees:0.####} deg`" );
builder.AppendLine();
}
File.WriteAllText( summaryMarkdownPath, builder.ToString() );
}
private static BlenderBoneComparison? BuildBoneComparison( string boneName, BlenderPoseDump payload, BlenderPoseDump applied, BlenderPoseDump reimported )
{
if ( !TryGetFrameZeroTransform( payload, boneName, out var payloadTransform ) ||
!TryGetFrameZeroTransform( applied, boneName, out var appliedTransform ) ||
!TryGetFrameZeroTransform( reimported, boneName, out var reimportedTransform ) )
{
return null;
}
return new BlenderBoneComparison
{
BoneName = boneName,
PayloadTranslation = payloadTransform!.Translation,
PayloadRotation = payloadTransform.Rotation,
AppliedTranslation = appliedTransform!.Translation,
AppliedRotation = appliedTransform.Rotation,
ReimportedTranslation = reimportedTransform!.Translation,
ReimportedRotation = reimportedTransform.Rotation,
PayloadToAppliedTranslationError = Distance( payloadTransform.Translation, appliedTransform.Translation ),
AppliedToReimportedTranslationError = Distance( appliedTransform.Translation, reimportedTransform.Translation ),
PayloadToAppliedRotationDegrees = QuaternionAngleDegrees( payloadTransform.Rotation, appliedTransform.Rotation ),
AppliedToReimportedRotationDegrees = QuaternionAngleDegrees( appliedTransform.Rotation, reimportedTransform.Rotation )
};
}
private static bool TryGetFrameZeroTransform( BlenderPoseDump dump, string boneName, out BlenderPoseTransform? transform )
{
transform = null;
if ( dump.Frames.Count == 0 )
return false;
var index = dump.Bones.FindIndex( bone => bone.Name.Equals( boneName, StringComparison.OrdinalIgnoreCase ) );
if ( index < 0 || index >= dump.Frames[0].BoneTransforms.Count )
return false;
transform = dump.Frames[0].BoneTransforms[index];
return true;
}
private static BlenderPoseDump LoadPoseDump( string jsonPath )
{
return JsonSerializer.Deserialize<BlenderPoseDump>( File.ReadAllText( jsonPath ) ) ?? new BlenderPoseDump();
}
private static float Distance( IReadOnlyList<float> first, IReadOnlyList<float> second )
{
if ( first.Count < 3 || second.Count < 3 )
return float.NaN;
var dx = first[0] - second[0];
var dy = first[1] - second[1];
var dz = first[2] - second[2];
return MathF.Sqrt( dx * dx + dy * dy + dz * dz );
}
private static float QuaternionAngleDegrees( IReadOnlyList<float> first, IReadOnlyList<float> second )
{
if ( first.Count < 4 || second.Count < 4 )
return float.NaN;
var firstQuaternion = RetargetMath.Normalize( new System.Numerics.Quaternion( first[0], first[1], first[2], first[3] ) );
var secondQuaternion = RetargetMath.Normalize( new System.Numerics.Quaternion( second[0], second[1], second[2], second[3] ) );
var dot = Math.Clamp( MathF.Abs( System.Numerics.Quaternion.Dot( firstQuaternion, secondQuaternion ) ), 0f, 1f );
return MathF.Acos( dot ) * 2f * (180f / MathF.PI);
}
private static string FormatArray( IReadOnlyList<float> values )
{
return string.Join( ", ", values.Select( value => value.ToString( "0.####" ) ) );
}
private static string FormatObjectTransform( BlenderObjectTransform transform )
{
return $"T=({FormatArray( transform.Translation )}) R=({FormatArray( transform.Rotation )}) S=({FormatArray( transform.Scale )})";
}
private static string ResolveActualOutputPath( string requestedOutputPath )
{
if ( File.Exists( requestedOutputPath ) )
return requestedOutputPath;
var outputDirectory = Path.GetDirectoryName( requestedOutputPath )!;
var fileName = Path.GetFileName( requestedOutputPath );
var candidatePaths = new[]
{
Path.Combine( outputDirectory, "anims", fileName ),
Path.Combine( outputDirectory, "anims", "anims", fileName )
};
foreach ( var candidatePath in candidatePaths )
{
if ( !File.Exists( candidatePath ) )
continue;
var canonicalPath = requestedOutputPath;
Directory.CreateDirectory( Path.GetDirectoryName( canonicalPath )! );
if ( !candidatePath.Equals( canonicalPath, StringComparison.OrdinalIgnoreCase ) )
{
if ( File.Exists( canonicalPath ) )
File.Delete( canonicalPath );
File.Move( candidatePath, canonicalPath );
var candidateDirectory = Path.GetDirectoryName( candidatePath );
if ( !string.IsNullOrWhiteSpace( candidateDirectory ) &&
Directory.Exists( candidateDirectory ) &&
!Directory.EnumerateFileSystemEntries( candidateDirectory ).Any() )
{
Directory.Delete( candidateDirectory, recursive: true );
}
}
return canonicalPath;
}
var recursiveMatch = Directory.Exists( outputDirectory )
? Directory.GetFiles( outputDirectory, fileName, SearchOption.AllDirectories ).FirstOrDefault()
: null;
if ( !string.IsNullOrWhiteSpace( recursiveMatch ) && File.Exists( recursiveMatch ) )
{
var canonicalPath = requestedOutputPath;
Directory.CreateDirectory( Path.GetDirectoryName( canonicalPath )! );
if ( !recursiveMatch.Equals( canonicalPath, StringComparison.OrdinalIgnoreCase ) )
{
if ( File.Exists( canonicalPath ) )
File.Delete( canonicalPath );
File.Move( recursiveMatch, canonicalPath );
}
return canonicalPath;
}
return string.Empty;
}
private static BlenderTargetBindCache LoadBindCache( string jsonPath )
{
using var document = JsonDocument.Parse( File.ReadAllText( jsonPath ) );
var root = document.RootElement;
var cache = new BlenderTargetBindCache
{
JsonAbsolutePath = jsonPath,
OutputUnitMeters = root.TryGetProperty( "output_unit_meters", out var unitMeters )
? unitMeters.GetDouble()
: 0.01
};
if ( !root.TryGetProperty( "bones", out var bones ) )
throw new InvalidOperationException( $"Blender bind cache '{jsonPath}' is missing a bones array." );
foreach ( var bone in bones.EnumerateArray() )
{
cache.Bones.Add( new NativeBoneInfo
{
Name = bone.GetProperty( "name" ).GetString() ?? string.Empty,
ParentName = ResolveParentName( bone, bones ),
Translation = ReadVector3( bone.GetProperty( "rest_translation" ) ),
Rotation = ReadQuaternion( bone.GetProperty( "rest_rotation" ) )
} );
}
return cache;
}
private static string ResolveParentName( JsonElement bone, JsonElement allBones )
{
var parentId = bone.GetProperty( "parent_id" ).GetInt32();
if ( parentId < 0 )
return string.Empty;
foreach ( var candidate in allBones.EnumerateArray() )
{
if ( candidate.GetProperty( "id" ).GetInt32() == parentId )
return candidate.GetProperty( "name" ).GetString() ?? string.Empty;
}
return string.Empty;
}
private static float[] ReadVector3( JsonElement element )
{
return new[]
{
element[0].GetSingle(),
element[1].GetSingle(),
element[2].GetSingle()
};
}
private static float[] ReadQuaternion( JsonElement element )
{
return new[]
{
element[0].GetSingle(),
element[1].GetSingle(),
element[2].GetSingle(),
element[3].GetSingle()
};
}
private static string GetCitizenFbxAbsolutePath()
{
return Ual2SourceAdapter.ResolveAssetAbsolutePath( CitizenTargetProfile.CitizenAnimationBindAssetPath );
}
private static string GetStockTemplateDmxAbsolutePath()
{
return Ual2SourceAdapter.ResolveAssetAbsolutePath( CitizenTargetProfile.ReferenceStockDmxPath );
}
private static string GetStockTemplateAnimationFbxAbsolutePath()
{
return Ual2SourceAdapter.ResolveAssetAbsolutePath( CitizenTargetProfile.CitizenTemplateAnimationFbxPath );
}
private static void RunBlender( string scriptArguments, string stepName )
{
var blenderPath = RetargetSetupResolver.Resolve( RetargetToolSettings.Load() ).BlenderExecutablePath;
if ( string.IsNullOrWhiteSpace( blenderPath ) )
throw new FileNotFoundException( "Could not locate blender.exe. Set Blender Path in Diagnostics, set CITIZEN_RETARGET_BLENDER, or install Blender 4.4." );
var scriptPath = CitizenRetargetPaths.GetPluginAbsolutePath( "tools/blender_export_citizen_dmx.py" );
var arguments = $"--background --factory-startup --python \"{scriptPath}\" -- {scriptArguments}";
RunProcess( blenderPath, arguments, stepName );
}
private static void RunProcess( string fileName, string arguments, string stepName )
{
using var process = new Process();
process.StartInfo = new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
WorkingDirectory = CitizenRetargetPaths.ProjectRoot,
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true
};
process.Start();
var standardOutput = process.StandardOutput.ReadToEnd();
var standardError = process.StandardError.ReadToEnd();
process.WaitForExit();
var combinedOutput = $"{standardOutput}{Environment.NewLine}{standardError}";
if ( process.ExitCode == 0 && !LooksLikePythonFailure( combinedOutput ) )
return;
var builder = new StringBuilder();
builder.AppendLine( $"{stepName} failed with exit code {process.ExitCode}." );
if ( !string.IsNullOrWhiteSpace( standardOutput ) )
{
builder.AppendLine( "stdout:" );
builder.AppendLine( standardOutput.Trim() );
}
if ( !string.IsNullOrWhiteSpace( standardError ) )
{
builder.AppendLine( "stderr:" );
builder.AppendLine( standardError.Trim() );
}
throw new InvalidOperationException( builder.ToString().TrimEnd() );
}
private static bool LooksLikePythonFailure( string output )
{
return output.Contains( "Traceback (most recent call last)", StringComparison.OrdinalIgnoreCase )
|| output.Contains( "Error: Python:", StringComparison.OrdinalIgnoreCase )
|| output.Contains( "ImportError:", StringComparison.OrdinalIgnoreCase )
|| output.Contains( "RuntimeError:", StringComparison.OrdinalIgnoreCase );
}
private sealed class BlenderPoseDump
{
public BlenderObjectTransform ObjectTransform { get; set; } = new();
public List<BlenderPoseBone> Bones { get; set; } = new();
public List<BlenderPoseFrame> Frames { get; set; } = new();
}
private sealed class BlenderObjectTransform
{
public float[] Translation { get; set; } = new[] { 0f, 0f, 0f };
public float[] Rotation { get; set; } = new[] { 0f, 0f, 0f, 1f };
public float[] Scale { get; set; } = new[] { 1f, 1f, 1f };
}
private sealed class BlenderPoseBone
{
public string Name { get; set; } = string.Empty;
}
private sealed class BlenderPoseFrame
{
public int Index { get; set; }
public List<BlenderPoseTransform> BoneTransforms { get; set; } = new();
}
private sealed class BlenderPoseTransform
{
public float[] Translation { get; set; } = new[] { 0f, 0f, 0f };
public float[] Rotation { get; set; } = new[] { 0f, 0f, 0f, 1f };
}
private sealed class BlenderBoneComparison
{
public string BoneName { get; set; } = string.Empty;
public IReadOnlyList<float> PayloadTranslation { get; set; } = Array.Empty<float>();
public IReadOnlyList<float> PayloadRotation { get; set; } = Array.Empty<float>();
public IReadOnlyList<float> AppliedTranslation { get; set; } = Array.Empty<float>();
public IReadOnlyList<float> AppliedRotation { get; set; } = Array.Empty<float>();
public IReadOnlyList<float> ReimportedTranslation { get; set; } = Array.Empty<float>();
public IReadOnlyList<float> ReimportedRotation { get; set; } = Array.Empty<float>();
public float PayloadToAppliedTranslationError { get; set; }
public float AppliedToReimportedTranslationError { get; set; }
public float PayloadToAppliedRotationDegrees { get; set; }
public float AppliedToReimportedRotationDegrees { get; set; }
}
}