Editor/CitizenRetarget/RetargetAuditService.cs
using System.Text;
using System.Text.Json;
#nullable enable
namespace Editor.CitizenRetarget;
using NVector3 = System.Numerics.Vector3;
[Obsolete( "Deprecated pre-release audit service retained for historical troubleshooting only.", false )]
internal sealed class RetargetAuditService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true
};
private static readonly Dictionary<string, (float MinCm, float MaxCm)> ExpectedTargetBoneLengths = new( StringComparer.OrdinalIgnoreCase )
{
["spine_0"] = (4f, 24f),
["spine_1"] = (4f, 24f),
["spine_2"] = (4f, 24f),
["neck_0"] = (2f, 18f),
["clavicle_L"] = (3f, 20f),
["clavicle_R"] = (3f, 20f),
["arm_upper_L"] = (12f, 48f),
["arm_upper_R"] = (12f, 48f),
["arm_lower_L"] = (12f, 48f),
["arm_lower_R"] = (12f, 48f),
["hand_L"] = (5f, 28f),
["hand_R"] = (5f, 28f),
["leg_upper_L"] = (18f, 65f),
["leg_upper_R"] = (18f, 65f),
["leg_lower_L"] = (18f, 65f),
["leg_lower_R"] = (18f, 65f),
["ankle_L"] = (3f, 20f),
["ankle_R"] = (3f, 20f),
["ball_L"] = (2f, 18f),
["ball_R"] = (2f, 18f),
};
private readonly Ual2SourceAdapter _sourceAdapter;
private readonly Ual2SourceProfile _sourceProfile;
public RetargetAuditService( Ual2SourceAdapter sourceAdapter, Ual2SourceProfile sourceProfile )
{
_sourceAdapter = sourceAdapter;
_sourceProfile = sourceProfile;
}
public RetargetAuditReport RunAudit( RetargetAuditConfig config )
{
ArgumentNullException.ThrowIfNull( config );
var report = new RetargetAuditReport
{
SourcePath = config.SourceFbxAbsolutePath,
AuditJsonPath = CitizenRetargetPaths.GetProjectAbsolutePath( config.AuditJsonPath ),
AuditMarkdownPath = CitizenRetargetPaths.GetProjectAbsolutePath( config.AuditMarkdownPath ),
DiagnosticsVmdlAbsolutePath = CitizenTargetProfile.EnableGeneratedDebugAssets
? CitizenRetargetPaths.GetAssetAbsolutePath( config.DiagnosticsVmdlPath )
: string.Empty
};
Directory.CreateDirectory( Path.GetDirectoryName( report.AuditJsonPath )! );
Directory.CreateDirectory( Path.GetDirectoryName( report.AuditMarkdownPath )! );
var sourceCandidates = new List<(TargetBindProfile Profile, RetargetAuditSceneReport Report, NativeSceneAuditResult Native)>();
foreach ( var profile in config.Profiles )
{
var sourceAudit = _sourceAdapter.InspectScene( config.SourceFbxAbsolutePath, profile );
var sceneReport = CreateSourceSceneReport( config.SourceFbxAbsolutePath, profile, sourceAudit );
report.SceneReports.Add( sceneReport );
sourceCandidates.Add( (profile, sceneReport, sourceAudit) );
}
var targetCandidates = new[]
{
config.TargetBindAssetPath,
config.FallbackTargetBindAssetPath
}
.Where( path => !string.IsNullOrWhiteSpace( path ) )
.Distinct( StringComparer.OrdinalIgnoreCase )
.ToList();
var allTargetCandidates = new List<(string AssetPath, string AbsolutePath, RetargetAuditSceneReport Report, NativeSceneAuditResult Native)>();
foreach ( var assetPath in targetCandidates )
{
var absolutePath = Ual2SourceAdapter.ResolveAssetAbsolutePath( assetPath );
foreach ( var profile in config.Profiles )
{
var nativeAudit = _sourceAdapter.InspectScene( absolutePath, profile );
var sceneReport = CreateTargetSceneReport( assetPath, absolutePath, profile, nativeAudit );
report.SceneReports.Add( sceneReport );
allTargetCandidates.Add( (assetPath, absolutePath, sceneReport, nativeAudit) );
}
}
var bestTargetCandidate = allTargetCandidates
.Where( item => item.Report.IsBelievable )
.OrderByDescending( item => item.Report.PlausibilityScore )
.ThenByDescending( item => GetTargetAssetPreference( item.AssetPath ) )
.FirstOrDefault();
NativeSceneAuditResult? lockedNativeScene = null;
string? lockedAssetPath = null;
if ( bestTargetCandidate.Report is not null )
{
report.LockedTargetBindAssetPath = bestTargetCandidate.AssetPath;
report.LockedTargetBindAbsolutePath = bestTargetCandidate.AbsolutePath;
report.LockedTargetProfile = bestTargetCandidate.Report.Profile;
report.LockedTargetOutputUnitMeters = bestTargetCandidate.Native.OutputUnitMeters;
var centimetersPerUnit = RetargetMath.CentimetersPerUnit( bestTargetCandidate.Native.OutputUnitMeters );
report.LockedTargetBones = bestTargetCandidate.Native.Bones.Select( bone => ConvertBone( bone, centimetersPerUnit ) ).ToList();
report.HasLockedTargetProfile = true;
report.FallbackTargetWasUsed = !bestTargetCandidate.AssetPath.Equals( config.TargetBindAssetPath, StringComparison.OrdinalIgnoreCase );
lockedNativeScene = bestTargetCandidate.Native;
lockedAssetPath = bestTargetCandidate.AssetPath;
}
else
{
foreach ( var assetPath in targetCandidates )
report.Warnings.Add( $"No believable target bind profile found for '{assetPath}'." );
}
if ( !report.HasLockedTargetProfile )
{
var provisional = allTargetCandidates
.OrderByDescending( item => item.Report.PlausibilityScore )
.ThenByDescending( item => GetTargetAssetPreference( item.AssetPath ) )
.FirstOrDefault();
if ( provisional.Report is null )
throw new InvalidOperationException( "Target bind audit did not produce any candidate reports." );
report.LockedTargetBindAssetPath = provisional.AssetPath;
report.LockedTargetBindAbsolutePath = provisional.AbsolutePath;
report.LockedTargetProfile = provisional.Report.Profile;
report.LockedTargetOutputUnitMeters = provisional.Native.OutputUnitMeters;
var centimetersPerUnit = RetargetMath.CentimetersPerUnit( provisional.Native.OutputUnitMeters );
report.LockedTargetBones = provisional.Native.Bones.Select( bone => ConvertBone( bone, centimetersPerUnit ) ).ToList();
report.HasLockedTargetProfile = true;
report.FallbackTargetWasUsed = !provisional.AssetPath.Equals( config.TargetBindAssetPath, StringComparison.OrdinalIgnoreCase );
lockedNativeScene = provisional.Native;
lockedAssetPath = provisional.AssetPath;
report.Warnings.Add( "No target bind profile passed the plausibility threshold. Continuing with the best provisional profile so the SMD diagnostics can decide whether the bind contract is still usable." );
}
var sourceMappings = _sourceProfile.LoadMapping( importHands: true, solveStage: RetargetSolveStage.BodyOnly );
var targetLengths = report.LockedTargetBones
.Where( bone => !string.IsNullOrWhiteSpace( bone.Name ) )
.ToDictionary(
bone => bone.Name,
bone => RetargetMath.LengthOrZero( bone.LocalTransform.Translation ),
StringComparer.OrdinalIgnoreCase );
foreach ( var candidate in sourceCandidates )
{
var score = ScoreSourceSkeleton(
candidate.Report.CoreBones,
targetLengths,
sourceMappings,
candidate.Report.ApproximateHeightCm,
candidate.Profile );
candidate.Report.PlausibilityScore = score;
candidate.Report.IsBelievable = score >= 0.7f;
}
var bestSourceCandidate = sourceCandidates
.Where( item => item.Report.IsBelievable )
.OrderByDescending( item => item.Report.PlausibilityScore )
.ThenByDescending( item => GetSourceProfilePreference( item.Profile ) )
.FirstOrDefault();
if ( bestSourceCandidate.Report is not null )
{
report.LockedSourceProfile = bestSourceCandidate.Profile;
report.LockedSourceOutputUnitMeters = bestSourceCandidate.Native.OutputUnitMeters;
var centimetersPerUnit = RetargetMath.CentimetersPerUnit( bestSourceCandidate.Native.OutputUnitMeters );
report.LockedSourceBones = bestSourceCandidate.Native.Bones.Select( bone => ConvertBone( bone, centimetersPerUnit ) ).ToList();
report.HasLockedSourceProfile = true;
}
else
{
var provisionalSource = sourceCandidates
.OrderByDescending( item => item.Report.PlausibilityScore )
.ThenByDescending( item => GetSourceProfilePreference( item.Profile ) )
.FirstOrDefault();
if ( provisionalSource.Report is not null )
{
report.LockedSourceProfile = provisionalSource.Profile;
report.LockedSourceOutputUnitMeters = provisionalSource.Native.OutputUnitMeters;
var centimetersPerUnit = RetargetMath.CentimetersPerUnit( provisionalSource.Native.OutputUnitMeters );
report.LockedSourceBones = provisionalSource.Native.Bones.Select( bone => ConvertBone( bone, centimetersPerUnit ) ).ToList();
report.HasLockedSourceProfile = true;
report.Warnings.Add( "No source ingest profile passed the plausibility threshold. Continuing with the best provisional source profile." );
}
}
if ( lockedNativeScene is not null && CitizenTargetProfile.EnableGeneratedDebugAssets )
{
var diagnosticGenerator = new RetargetDiagnosticGenerator();
var diagnostics = diagnosticGenerator.GenerateDiagnostics(
report.LockedTargetBones,
config.DiagnosticsAssetFolder,
config.DiagnosticsVmdlPath );
report.DiagnosticsVmdlAbsolutePath = diagnostics.DiagnosticsVmdlAbsolutePath;
}
if ( report.FallbackTargetWasUsed )
report.Warnings.Add( $"Fell back from '{config.TargetBindAssetPath}' to '{lockedAssetPath}' for the target bind contract." );
WriteAuditOutputs( report );
return report;
}
private RetargetAuditSceneReport CreateSourceSceneReport( string absolutePath, TargetBindProfile profile, NativeSceneAuditResult nativeAudit )
{
var mappings = _sourceProfile.LoadMapping( importHands: true, solveStage: RetargetSolveStage.Full );
var allowedSourceBones = new HashSet<string>(
mappings
.Where( entry => CitizenTargetProfile.CoreAuditTargets.Contains( entry.TargetBone, StringComparer.OrdinalIgnoreCase ) )
.Select( entry => entry.SourceBone ),
StringComparer.OrdinalIgnoreCase );
var centimetersPerUnit = RetargetMath.CentimetersPerUnit( nativeAudit.OutputUnitMeters );
var coreBones = nativeAudit.Bones
.Where( bone => allowedSourceBones.Contains( bone.Name ) )
.Select( bone => CreateBoneReport( bone, centimetersPerUnit ) )
.OrderBy( bone => bone.Name, StringComparer.OrdinalIgnoreCase )
.ToList();
return new RetargetAuditSceneReport
{
Kind = "Source",
AssetPath = absolutePath,
AbsolutePath = absolutePath,
Profile = profile,
SceneUnitMeters = nativeAudit.UnitMeters,
OutputUnitMeters = nativeAudit.OutputUnitMeters,
Creator = nativeAudit.Metadata.Creator,
OriginalApplication = FormatApplication( nativeAudit.Metadata.OriginalApplication ),
LatestApplication = FormatApplication( nativeAudit.Metadata.LatestApplication ),
ApproximateHeightCm = EstimateHeightCm( nativeAudit.Bones, centimetersPerUnit ),
PlausibilityScore = 0f,
IsBelievable = false,
CoreBones = coreBones
};
}
private static RetargetAuditSceneReport CreateTargetSceneReport( string assetPath, string absolutePath, TargetBindProfile profile, NativeSceneAuditResult nativeAudit )
{
var centimetersPerUnit = RetargetMath.CentimetersPerUnit( nativeAudit.OutputUnitMeters );
var coreBones = nativeAudit.Bones
.Where( bone => CitizenTargetProfile.CoreAuditTargets.Contains( bone.Name, StringComparer.OrdinalIgnoreCase ) )
.Select( bone => CreateBoneReport( bone, centimetersPerUnit ) )
.OrderBy( bone => bone.Name, StringComparer.OrdinalIgnoreCase )
.ToList();
var heightCm = EstimateHeightCm( nativeAudit.Bones, centimetersPerUnit );
var score = ScoreTargetSkeleton( coreBones, heightCm );
return new RetargetAuditSceneReport
{
Kind = "Target",
AssetPath = assetPath,
AbsolutePath = absolutePath,
Profile = profile,
SceneUnitMeters = nativeAudit.UnitMeters,
OutputUnitMeters = nativeAudit.OutputUnitMeters,
Creator = nativeAudit.Metadata.Creator,
OriginalApplication = FormatApplication( nativeAudit.Metadata.OriginalApplication ),
LatestApplication = FormatApplication( nativeAudit.Metadata.LatestApplication ),
ApproximateHeightCm = heightCm,
PlausibilityScore = score,
IsBelievable = score >= 0.58f,
CoreBones = coreBones
};
}
private static RetargetAuditBoneReport CreateBoneReport( NativeAuditBoneInfo bone, float centimetersPerUnit )
{
var local = RetargetMath.ScaleTranslation( bone.LocalTransform, centimetersPerUnit );
var world = RetargetMath.ScaleTranslation( bone.WorldTransform, centimetersPerUnit );
return new RetargetAuditBoneReport
{
Name = bone.Name,
ParentName = bone.ParentName,
LocalLengthCm = RetargetMath.LengthOrZero( local.Translation ),
LocalTranslation = [local.Translation.X, local.Translation.Y, local.Translation.Z],
LocalRotation = [local.Rotation.X, local.Rotation.Y, local.Rotation.Z, local.Rotation.W],
LocalScale = bone.Scale.ToArray(),
WorldTranslation = [world.Translation.X, world.Translation.Y, world.Translation.Z],
WorldRotation = [world.Rotation.X, world.Rotation.Y, world.Rotation.Z, world.Rotation.W]
};
}
private static float ScoreTargetSkeleton( IReadOnlyList<RetargetAuditBoneReport> coreBones, float approximateHeightCm )
{
var score = 0f;
var count = 0;
var suspiciousNearOne = 0;
var suspiciousNearHundred = 0;
foreach ( var bone in coreBones )
{
if ( !ExpectedTargetBoneLengths.TryGetValue( bone.Name, out var expected ) )
continue;
var length = bone.LocalLengthCm;
var center = (expected.MinCm + expected.MaxCm) * 0.5f;
var radius = MathF.Max( (expected.MaxCm - expected.MinCm) * 0.5f, 1f );
var closeness = MathF.Max( 0f, 1f - MathF.Abs( length - center ) / radius );
score += closeness;
count++;
if ( length >= 0.8f && length <= 1.25f )
suspiciousNearOne++;
if ( length >= 80f && length <= 120f )
suspiciousNearHundred++;
}
if ( count == 0 )
return 0f;
var normalizedScore = score / count;
var heightCenter = 175f;
var heightRadius = 85f;
var heightScore = MathF.Max( 0f, 1f - MathF.Abs( approximateHeightCm - heightCenter ) / heightRadius );
normalizedScore = normalizedScore * 0.75f + heightScore * 0.25f;
if ( suspiciousNearOne >= 8 )
normalizedScore *= 0.2f;
if ( suspiciousNearHundred >= 8 )
normalizedScore *= 0.45f;
return normalizedScore;
}
private static float ScoreSourceSkeleton(
IReadOnlyList<RetargetAuditBoneReport> sourceBones,
IReadOnlyDictionary<string, float> targetLengths,
IReadOnlyList<BoneMapEntry> mappings,
float approximateHeightCm,
TargetBindProfile profile )
{
var sourceByName = sourceBones.ToDictionary( bone => bone.Name, bone => bone, StringComparer.OrdinalIgnoreCase );
var mappedCount = 0;
var plausibleCount = 0;
var suspiciousNearOne = 0;
var ratioLogs = new List<float>();
foreach ( var mapping in mappings )
{
if ( !sourceByName.TryGetValue( mapping.SourceBone, out var sourceBone ) )
continue;
if ( !targetLengths.TryGetValue( mapping.TargetBone, out var targetLength ) )
continue;
var sourceLength = sourceBone.LocalLengthCm;
if ( sourceLength <= 0.001f || targetLength <= 0.001f )
continue;
mappedCount++;
if ( sourceLength >= 4f && sourceLength <= 120f )
plausibleCount++;
if ( sourceLength >= 0.8f && sourceLength <= 1.25f )
suspiciousNearOne++;
ratioLogs.Add( MathF.Log( targetLength / sourceLength ) );
}
if ( mappedCount == 0 )
return 0f;
var plausibleScore = (float)plausibleCount / mappedCount;
var heightScore = MathF.Max( 0f, 1f - MathF.Abs( approximateHeightCm - 175f ) / 85f );
var ratioConsistency = 0f;
if ( ratioLogs.Count > 0 )
{
var mean = (float)ratioLogs.Average();
var variance = (float)(ratioLogs.Sum( value => (value - mean) * (value - mean) ) / ratioLogs.Count);
ratioConsistency = 1f / (1f + MathF.Sqrt( variance ));
}
var score = plausibleScore * 0.7f + ratioConsistency * 0.2f + heightScore * 0.1f;
if ( suspiciousNearOne >= 5 )
score *= 0.15f;
score += GetSourceProfilePreference( profile ) * 0.001f;
return score;
}
private static int GetSourceProfilePreference( TargetBindProfile profile )
{
return profile switch
{
TargetBindProfile.RawUnits => 4,
TargetBindProfile.TargetOnlyControl => 3,
TargetBindProfile.ModifyGeometry => 2,
_ => 1
};
}
private static int GetTargetAssetPreference( string assetPath )
{
if ( assetPath.Equals( CitizenTargetProfile.CitizenAnimationBindAssetPath, StringComparison.OrdinalIgnoreCase ) )
return 2;
if ( assetPath.Equals( CitizenTargetProfile.CitizenFallbackBindAssetPath, StringComparison.OrdinalIgnoreCase ) )
return 1;
return 0;
}
private static float EstimateHeightCm( IReadOnlyList<NativeAuditBoneInfo> bones, float centimetersPerUnit )
{
if ( bones.Count == 0 )
return 0f;
var positions = bones
.Where( bone => CitizenTargetProfile.CoreAuditTargets.Contains( bone.Name, StringComparer.OrdinalIgnoreCase ) )
.Select( bone => RetargetMath.ScaleTranslation( bone.WorldTransform, centimetersPerUnit ).Translation )
.ToList();
if ( positions.Count == 0 )
{
positions = bones
.Select( bone => RetargetMath.ScaleTranslation( bone.WorldTransform, centimetersPerUnit ).Translation )
.ToList();
}
var minZ = positions.Min( point => point.Z );
var maxZ = positions.Max( point => point.Z );
return maxZ - minZ;
}
private static NativeBoneInfo ConvertBone( NativeAuditBoneInfo bone, float centimetersPerUnit )
{
var local = RetargetMath.ScaleTranslation( bone.LocalTransform, centimetersPerUnit );
return new NativeBoneInfo
{
Name = bone.Name,
ParentName = bone.ParentName,
Translation = [local.Translation.X, local.Translation.Y, local.Translation.Z],
Rotation = [local.Rotation.X, local.Rotation.Y, local.Rotation.Z, local.Rotation.W]
};
}
private static string FormatApplication( NativeApplicationInfo application )
{
var parts = new List<string>();
if ( !string.IsNullOrWhiteSpace( application.Name ) )
parts.Add( application.Name );
if ( !string.IsNullOrWhiteSpace( application.Version ) )
parts.Add( application.Version );
if ( !string.IsNullOrWhiteSpace( application.Vendor ) )
parts.Add( application.Vendor );
return parts.Count > 0 ? string.Join( " / ", parts ) : string.Empty;
}
private static void WriteAuditOutputs( RetargetAuditReport report )
{
File.WriteAllText( report.AuditJsonPath, JsonSerializer.Serialize( report, JsonOptions ) );
File.WriteAllText( report.AuditMarkdownPath, BuildMarkdownReport( report ) );
}
private static string BuildMarkdownReport( RetargetAuditReport report )
{
var builder = new StringBuilder();
builder.AppendLine( "# CARL Math Audit" );
builder.AppendLine();
builder.AppendLine( "This report audits `FBX ingest -> bind/rest contract -> SMD diagnostics` before further solver tuning." );
builder.AppendLine();
builder.AppendLine( "Primary references:" );
builder.AppendLine( "- [ufbx node transforms](https://ufbx.github.io/fbx/node-transforms/)" );
builder.AppendLine( "- [ufbx nodes and transforms](https://ufbx.github.io/elements/nodes/)" );
builder.AppendLine( "- [Valve SMD format](https://developer.valvesoftware.com/wiki/SMD)" );
builder.AppendLine( "- [Blender Source Tools exporter](https://github.com/Artfunkel/BlenderSourceTools)" );
builder.AppendLine( "- [Godot retargeting 3D skeletons](https://docs.godotengine.org/en/4.1/tutorials/assets_pipeline/retargeting_3d_skeletons.html)" );
builder.AppendLine( "- [Wicked Engine animation retargeting](https://wickedengine.net/2022/09/animation-retargeting/)" );
builder.AppendLine( "- [ozz-animation how-tos](https://guillaumeblanc.github.io/ozz-animation/documentation/howtos/)" );
builder.AppendLine();
builder.AppendLine( "Diagnostic clips compare raw local export, inverse-local export, root-only `Y-up` correction, and Valve legacy-style `Ry90 @ Rz90` corrections both as simple rotation tweaks and as full local-transform basis remaps before further solver changes." );
builder.AppendLine();
if ( report.HasLockedTargetProfile )
{
builder.AppendLine( $"Locked target bind: `{report.LockedTargetBindAssetPath}` with profile `{report.LockedTargetProfile}`." );
builder.AppendLine( "Target bind preference favors `models/citizen/citizen.fbx` because stock Citizen `bindPose` sequences are authored against that source file, while `citizen_REF.fbx` is documented as a clothing/simple-rig reference." );
builder.AppendLine();
}
else
{
builder.AppendLine( "No believable target bind profile was found." );
builder.AppendLine();
}
if ( report.HasLockedSourceProfile )
{
builder.AppendLine( $"Locked source ingest profile: `{report.LockedSourceProfile}`." );
builder.AppendLine();
}
else
{
builder.AppendLine( "No believable source ingest profile was found." );
builder.AppendLine();
}
if ( report.Warnings.Count > 0 )
{
builder.AppendLine( "Warnings:" );
foreach ( var warning in report.Warnings )
builder.AppendLine( $"- {warning}" );
builder.AppendLine();
}
foreach ( var group in report.SceneReports.GroupBy( item => item.Kind ) )
{
builder.AppendLine( $"## {group.Key}" );
builder.AppendLine();
foreach ( var scene in group.OrderBy( item => item.AssetPath, StringComparer.OrdinalIgnoreCase ).ThenBy( item => item.Profile ) )
{
builder.AppendLine( $"### `{scene.AssetPath}` / `{scene.Profile}`" );
builder.AppendLine();
builder.AppendLine( $"- Scene unit meters: `{scene.SceneUnitMeters:0.######}`" );
builder.AppendLine( $"- Output unit meters: `{scene.OutputUnitMeters:0.######}`" );
builder.AppendLine( $"- Approximate height (cm): `{scene.ApproximateHeightCm:0.##}`" );
builder.AppendLine( $"- Plausibility score: `{scene.PlausibilityScore:0.###}`" );
builder.AppendLine( $"- Believable: `{scene.IsBelievable}`" );
if ( !string.IsNullOrWhiteSpace( scene.Creator ) )
builder.AppendLine( $"- Creator: `{scene.Creator}`" );
if ( !string.IsNullOrWhiteSpace( scene.OriginalApplication ) )
builder.AppendLine( $"- Original application: `{scene.OriginalApplication}`" );
if ( !string.IsNullOrWhiteSpace( scene.LatestApplication ) )
builder.AppendLine( $"- Latest application: `{scene.LatestApplication}`" );
builder.AppendLine();
builder.AppendLine( "| Bone | Parent | Local Length (cm) | Local Translation | Local Scale |" );
builder.AppendLine( "| --- | --- | ---: | --- | --- |" );
foreach ( var bone in scene.CoreBones )
{
builder.AppendLine( $"| `{bone.Name}` | `{bone.ParentName}` | `{bone.LocalLengthCm:0.###}` | `{FormatVector( bone.LocalTranslation )}` | `{FormatVector( bone.LocalScale )}` |" );
}
builder.AppendLine();
}
}
return builder.ToString();
}
private static string FormatVector( float[] values )
{
if ( values is null || values.Length < 3 )
return "0, 0, 0";
return $"{values[0]:0.###}, {values[1]:0.###}, {values[2]:0.###}";
}
}