Editor/ModelGroundingGate.cs

Editor utility that applies a grounding modification to a fitted model VMDL, recompiles it, validates that mesh floor is at zero, checks that bone bind poses and animations preserve relative transforms (with only the grounding offset), and writes an augmented retargeted clip to verify animation offsets.

File Access
#nullable enable annotations
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using HumanoidRetargeter.Core;
using HumanoidRetargeter.Core.Cleanup;
using HumanoidRetargeter.Core.Target;
using Sandbox;

namespace HumanoidRetargeter.EditorTools;

/// <summary>Additional regression: compile the same fitted library with a final grounding modifier.</summary>
internal static class ModelGroundingGate
{
	internal static async Task RunAsync( EditorPipeline.WriteResult original, string folder, string reference )
	{
		var model = Model.Load( original.VmdlAsset.Path );
		var before = File.ReadAllText( original.VmdlAsset.AbsolutePath );
		var groundedText = ModelGrounding.Apply( before, model.Bounds.Mins.z );
		var offset = ModelGrounding.Offset( groundedText ) - ModelGrounding.Offset( before );
		if ( offset <= 0 ) throw new Exception( "Grounding fixture must start below the floor." );
		CitizenAnimationSetup.ValidateSourceScale( groundedText );
		var result = await EditorPipeline.WriteAndCompileAsync( new RetargetBatchResult { StandaloneVmdl = groundedText },
			folder, standaloneVmdlName: "fitted_grounded", allowAnimationSetupOnly: true );
		await EditorPipeline.SwitchToMainThread();
		if ( !result.Compiled ) throw new Exception( string.Join( "; ", result.Errors ) );
		var grounded = Model.Load( result.VmdlAsset.Path );
		var floor = grounded.GetVertices().Min( v => v.Position.z );
		if ( MathF.Abs( floor ) > .05f ) throw new Exception( $"Grounded mesh floor is {floor}, expected zero." );
		var sourceBind = TargetPickers.SkeletonFromModel( model );
		var groundedBind = TargetPickers.SkeletonFromModel( grounded );
		foreach ( var bone in groundedBind.Bones )
		{
			var local = ModelGrounding.SourceLocal( bone.RestLocal, bone.ParentIndex < 0, offset );
			var expected = sourceBind[sourceBind.IndexOf( bone.Name )].RestLocal;
			if ( System.Numerics.Vector3.Distance( local.Pos, expected.Pos ) > .01f
				|| HumanoidRetargeter.Core.Maths.MathQ.AngleBetween( local.Rot, expected.Rot ) > .001f )
				throw new Exception( "Grounding changed relative bind: " + bone.Name );
		}
		var ungroundedSpec = StockAnimationReplacement.TargetSpec( reference, original.VmdlAsset.Path );
		var groundedSpec = StockAnimationReplacement.TargetSpec( reference, result.VmdlAsset.Path );
		if ( CitizenAnimationSetup.CompatibilityError( ungroundedSpec.Rig.Skeleton, groundedSpec.Rig.Skeleton ) is not null )
			throw new Exception( "Replacement clips would apply the grounding offset twice." );
		if ( model.AnimationNames.Except( grounded.AnimationNames ).Any() || grounded.AnimGraph is null || grounded.AnimGraph.IsError )
			throw new Exception( "Grounding lost sequences or the animgraph." );
		var world = new SceneWorld();
		var a = new SceneModel( world, model, Transform.Zero ) { UseAnimGraph = false };
		var b = new SceneModel( world, grounded, Transform.Zero ) { UseAnimGraph = false };
		try
		{
			foreach ( var name in model.AnimationNames.Where( n => n is "IdlePose_Default" or "Walk_N" or "Run_N"
				|| n.Contains( "debug_animation_scaling" ) ) )
			foreach ( var time in new[] { 0f, .5f, .9f } )
			{
				a.CurrentSequence.Name = b.CurrentSequence.Name = name;
				a.CurrentSequence.TimeNormalized = b.CurrentSequence.TimeNormalized = time;
				a.Update( 0 ); b.Update( 0 );
				foreach ( var bone in sourceBind.Bones )
				{
					var expected = a.GetBoneWorldTransform( bone.Name );
					var actual = b.GetBoneWorldTransform( bone.Name );
					if ( (actual.Position - expected.Position - Vector3.Up * offset).Length > .05f
						|| actual.Rotation.Distance( expected.Rotation ) > .1f )
						throw new Exception( $"Grounding changed animation {name}/{bone.Name}: {actual.Position - expected.Position}, expected +{offset} Z." );
				}
			}
		}
		finally { a.Delete(); b.Delete(); world.Delete(); }
		var entry = SourceFileEntry.Load( Environment.GetEnvironmentVariable( "HR_UI_FIXTURE_STEPS" ), Project.Current.GetAssetsPath() );
		var request = new RetargetRequest { SourceData = entry.Bytes, SourceFileName = entry.FileName,
			MappingOverride = entry.Mapping, RootMotion = RootMotionMode.InPlace, ClipNameOverride = "grounded_added_clip" };
		var picked = TargetPickers.FromModelAsset( result.VmdlAsset, out var pickError );
		if ( picked is null ) throw new Exception( pickError );
		var augmented = await RetargetWindow.ConvertAndWriteAsync( new[] { request }, picked,
			new BatchOptions { AugmentVmdlText = groundedText, DmxFolderRelative = folder }, result.VmdlAsset.AbsolutePath );
		await EditorPipeline.SwitchToMainThread();
		if ( augmented.Write?.Compiled != true ) throw new Exception( "Grounded augmentation did not compile: " + string.Join( "; ", augmented.Batch.Errors ) );
		var clip = augmented.Batch.Clips.Single();
		var frame = clip.SolvedFrames.Count / 2;
		var expectedPose = new HumanoidRetargeter.Core.Skeleton.Pose( clip.SolvedFrames[frame] ).ToWorld( groundedSpec.Rig.Skeleton );
		var probeWorld = new SceneWorld();
		var probe = new SceneModel( probeWorld, Model.Load( result.VmdlAsset.Path ), Transform.Zero ) { UseAnimGraph = false };
		try
		{
			probe.CurrentSequence.Name = request.ClipNameOverride;
			probe.CurrentSequence.Time = frame / clip.Fps;
			probe.Update( 0 );
			foreach ( var name in new[] { "pelvis", "ankle_L", "ankle_R" } )
			{
				var expected = expectedPose[groundedSpec.Rig.Skeleton.IndexOf( name )].Pos;
				var expectedEngine = new Vector3( expected.X, -expected.Z, expected.Y ) * RetargetTargetSpec.SboxSourceScale + Vector3.Up * offset;
				if ( probe.GetBoneWorldTransform( name ).Position.Distance( expectedEngine ) > .1f )
					throw new Exception( "Grounded augmentation applied an incorrect offset: " + name );
			}
		}
		finally { probe.Delete(); probeWorld.Delete(); }
		Log.Info( $"[hr-ui-smoke] Grounding: mesh floor {floor}, bind and animated bones translated by {offset}, replacement source bind unchanged." );
	}
}