Editor/Services/SequenceImportService.cs

Editor service that imports animation sequences from a compiled model into a WeaponAnimationClip. It samples a model sequence frame by frame in an editor Scene, builds transform tracks per retained bone, applies calibration for the source root, and writes tracks and metadata into the clip.

File AccessNative Interop
#nullable enable annotations

using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace SboxWeaponAnimator.Editor;

public sealed class SequenceImportResult
{
	public bool Success { get; init; }
	public string Message { get; init; } = "";
	public int ImportedTracks { get; init; }
}

public static class SequenceImportService
{
	public static IReadOnlyList<string> GetSequences( WeaponAnimationDocument document )
	{
		if ( string.IsNullOrWhiteSpace( document.Source.CompiledModelPath ) )
			return [];

		using var preview = new SequenceSamplingScene( document.Source.CompiledModelPath );
		return preview.IsValid
			? preview.Renderer.Sequence.SequenceNames.ToArray()
			: [];
	}

	public static SequenceImportResult Import(
		WeaponAnimationDocument document,
		WeaponAnimationClip clip,
		string sequenceName )
	{
		using var preview = new SequenceSamplingScene( document.Source.CompiledModelPath );
		if ( !preview.IsValid )
			return Failed( "The source model could not be loaded for sequence sampling." );

		if ( !preview.Renderer.Sequence.SequenceNames.Contains( sequenceName ) )
			return Failed( $"Sequence '{sequenceName}' does not exist on the source model." );

		preview.Renderer.Sequence.Name = sequenceName;
		preview.Renderer.Sequence.Looping = false;
		preview.Renderer.Sequence.PlaybackRate = 0;
		preview.Tick();
		var duration = MathF.Max( preview.Renderer.Sequence.Duration, 1.0f / clip.SampleRate );
		var frames = Math.Max( 1, (int)MathF.Ceiling( duration * clip.SampleRate ) );
		var tracks = new Dictionary<string, TransformTrack>( StringComparer.OrdinalIgnoreCase );

		foreach ( var definition in document.Rig.RetainedBones() )
		{
			var target = definition.Id.Equals(
				document.Rig.SourceSkeletonRootId,
				StringComparison.OrdinalIgnoreCase )
				? "weapon_root"
				: definition.Name;
			tracks[definition.Name] = new TransformTrack
			{
				Target = target,
				Kind = RigControlKind.Weapon,
				Interpolation = TrackInterpolation.Cubic
			};
		}

		for ( var frame = 0; frame <= frames; frame++ )
		{
			var time = MathF.Min( frame / clip.SampleRate, duration );
			preview.Renderer.Sequence.Time = time;
			preview.Tick();

			foreach ( var definition in document.Rig.Bones )
			{
				if ( !tracks.TryGetValue( definition.Name, out var track ) )
					continue;
				var bone = preview.Renderer.Model.Bones.GetBone( definition.Name );
				if ( bone is null || !preview.Renderer.TryGetBoneTransformLocal( bone, out var model ) )
					continue;

				var local = model;
				if ( bone.Parent is not null
					&& preview.Renderer.TryGetBoneTransformLocal( bone.Parent, out var parent ) )
					local = parent.ToLocal( model );

				if ( definition.Id.Equals(
					document.Rig.SourceSkeletonRootId,
					StringComparison.OrdinalIgnoreCase ) )
				{
					var placement = WeaponAnimationMath.Compose(
						document.Calibration.PhysicalTransform,
						document.Calibration.FramingTransform );
					local = new Transform(
						placement.PointToWorld( local.Position ),
						placement.Rotation * local.Rotation,
						placement.Scale * local.Scale );
				}

				WeaponAnimationMath.UpsertKey( track, time, local );
			}
		}

		clip.Tracks.RemoveAll( x => x.Kind == RigControlKind.Weapon );
		clip.Tracks.AddRange( tracks.Values.Where( x => x.Keys.Count > 0 ) );
		clip.VisibilityTracks.Clear();
		clip.Duration = duration;
		clip.ImportedSequence = sequenceName;
		clip.Readiness = clip.Role == WeaponClipRole.Idle
			? ClipReadiness.Ready
			: ClipReadiness.Draft;

		return new SequenceImportResult
		{
			Success = true,
			Message = $"Imported '{sequenceName}' into {tracks.Count} editable tracks.",
			ImportedTracks = tracks.Count
		};
	}

	private static SequenceImportResult Failed( string message ) => new()
	{
		Success = false,
		Message = message
	};

	private sealed class SequenceSamplingScene : IDisposable
	{
		public Scene Scene { get; }
		public SkinnedModelRenderer Renderer { get; }
		public bool IsValid => Scene.IsValid() && Renderer.IsValid()
			&& Renderer.Model is not null && !Renderer.Model.IsError;

		public SequenceSamplingScene( string modelPath )
		{
			Scene = Scene.CreateEditorScene();
			using ( Scene.Push() )
			{
				Renderer = new GameObject( true, "sequence_sampler" )
					.GetOrAddComponent<SkinnedModelRenderer>( false );
				Renderer.Model = Model.Load( modelPath );
				Renderer.UseAnimGraph = false;
				Renderer.Enabled = true;
			}
			Tick();
		}

		public void Tick()
		{
			if ( Scene.IsValid() )
				Scene.EditorTick( RealTime.Now, 0 );
		}

		public void Dispose()
		{
			Scene?.Destroy();
		}
	}
}